Browse Source

Merge branch 'master' into disallow-covers

pull/4318/head
Edho Arief 3 months ago
parent
commit
c04a42b78e
No account linked to committer's email address
100 changed files with 1202 additions and 535 deletions
  1. 0
    8
      .github/pull_request_template.md
  2. 1
    1
      app/Console/Commands/DisqusImport.php
  3. 1
    0
      app/Http/Controllers/AccountController.php
  4. 5
    0
      app/Http/Controllers/BeatmapsController.php
  5. 20
    0
      app/Http/Controllers/BeatmapsetsController.php
  6. 15
    10
      app/Http/Controllers/ChangelogController.php
  7. 4
    0
      app/Http/Controllers/Forum/TopicsController.php
  8. 8
    4
      app/Http/Controllers/FriendsController.php
  9. 5
    1
      app/Http/Controllers/GroupsController.php
  10. 1
    1
      app/Http/Controllers/NotificationsController.php
  11. 10
    3
      app/Http/Controllers/RankingController.php
  12. 21
    0
      app/Jobs/BroadcastNotification.php
  13. 2
    2
      app/Libraries/NewForumTopic.php
  14. 50
    24
      app/Libraries/OsuAuthorize.php
  15. 9
    3
      app/Libraries/ReplayFile.php
  16. 1
    1
      app/Models/BeatmapMirror.php
  17. 58
    1
      app/Models/Beatmapset.php
  18. 3
    0
      app/Models/BeatmapsetEvent.php
  19. 1
    1
      app/Models/ChangelogEntry.php
  20. 1
    1
      app/Models/DeletedUser.php
  21. 5
    0
      app/Models/Forum/Forum.php
  22. 2
    0
      app/Models/Notification.php
  23. 1
    0
      app/Models/ReplayViewCount/Fruits.php
  24. 1
    0
      app/Models/ReplayViewCount/Mania.php
  25. 0
    6
      app/Models/ReplayViewCount/Model.php
  26. 1
    0
      app/Models/ReplayViewCount/Osu.php
  27. 1
    0
      app/Models/ReplayViewCount/Taiko.php
  28. 1
    1
      app/Models/Score/Best/Model.php
  29. 2
    2
      app/Models/Store/Order.php
  30. 20
    4
      app/Models/User.php
  31. 2
    1
      app/Models/UserGroup.php
  32. 1
    1
      app/Models/UserNotFound.php
  33. 2
    1
      app/Transformers/BeatmapsetTransformer.php
  34. 3
    1
      app/Transformers/UserTransformer.php
  35. 4
    0
      database/migrations/2015_01_01_133337_base_tables.php
  36. 32
    0
      database/migrations/2019_04_17_103403_add_discussion_locked_to_beatmapsets.php
  37. 66
    0
      database/migrations/2019_04_23_051152_add_discussion_locked_to_beatmapset_events.php
  38. 1
    1
      resources/assets/coffee/_classes/account-edit-blocklist.coffee
  39. 2
    2
      resources/assets/coffee/_classes/beatmap-discussion-helper.coffee
  40. 8
    0
      resources/assets/coffee/_classes/current-user-observer.coffee
  41. 1
    1
      resources/assets/coffee/_classes/form-error.coffee
  42. 1
    0
      resources/assets/coffee/_classes/line-chart.coffee
  43. 5
    1
      resources/assets/coffee/_classes/osu-audio.coffee
  44. 19
    0
      resources/assets/coffee/_classes/polyfills.coffee
  45. 51
    34
      resources/assets/coffee/_classes/user-card.coffee
  46. 5
    2
      resources/assets/coffee/react/_components/beatmapset-panel.coffee
  47. 24
    14
      resources/assets/coffee/react/_components/comment.coffee
  48. 6
    0
      resources/assets/coffee/react/_components/comments-manager.coffee
  49. 41
    15
      resources/assets/coffee/react/_components/comments.coffee
  50. 1
    2
      resources/assets/coffee/react/_components/friend-button.coffee
  51. 0
    37
      resources/assets/coffee/react/_components/supporter-icon.coffee
  52. 3
    2
      resources/assets/coffee/react/beatmap-discussions/discussion.coffee
  53. 3
    0
      resources/assets/coffee/react/beatmap-discussions/event.coffee
  54. 0
    1
      resources/assets/coffee/react/beatmap-discussions/header.coffee
  55. 16
    12
      resources/assets/coffee/react/beatmap-discussions/new-discussion.coffee
  56. 90
    10
      resources/assets/coffee/react/beatmap-discussions/nominations.coffee
  57. 2
    0
      resources/assets/coffee/react/beatmap-discussions/post.coffee
  58. 0
    15
      resources/assets/coffee/react/beatmap-discussions/user-filter.coffee
  59. 2
    1
      resources/assets/coffee/react/contest-voting/art-entry-list.coffee
  60. 1
    1
      resources/assets/coffee/react/mp-history/event.coffee
  61. 1
    1
      resources/assets/coffee/react/mp-history/game-header.coffee
  62. 1
    1
      resources/assets/coffee/react/mp-history/score.coffee
  63. 6
    4
      resources/assets/less/bem-index.less
  64. 3
    3
      resources/assets/less/bem/beatmap-discussion-new-float.less
  65. 3
    3
      resources/assets/less/bem/beatmap-discussion-post.less
  66. 1
    1
      resources/assets/less/bem/beatmap-discussions-user-filter.less
  67. 2
    0
      resources/assets/less/bem/beatmapset-event.less
  68. 1
    1
      resources/assets/less/bem/beatmapset-panel.less
  69. 2
    2
      resources/assets/less/bem/block-list-item.less
  70. 5
    6
      resources/assets/less/bem/block-list.less
  71. 2
    2
      resources/assets/less/bem/chat-conversation.less
  72. 6
    0
      resources/assets/less/bem/comments.less
  73. 31
    0
      resources/assets/less/bem/deleted-comments-count.less
  74. 15
    0
      resources/assets/less/bem/notification-popup-item.less
  75. 2
    2
      resources/assets/less/bem/osu-layout.less
  76. 1
    1
      resources/assets/less/bem/page-extra-tabs-before.less
  77. 1
    1
      resources/assets/less/bem/page-extra-tabs.less
  78. 24
    0
      resources/assets/less/bem/qtip.less
  79. 5
    0
      resources/assets/less/bem/search-result.less
  80. 1
    1
      resources/assets/less/bem/search.less
  81. 15
    15
      resources/assets/less/bem/sort.less
  82. 4
    4
      resources/assets/less/bem/status-incident.less
  83. 2
    2
      resources/assets/less/bem/store-slider.less
  84. 6
    7
      resources/assets/less/bem/store-supporter-tag.less
  85. 5
    15
      resources/assets/less/bem/supporter-icon.less
  86. 5
    4
      resources/assets/less/bem/tooltip-achievement.less
  87. 2
    0
      resources/assets/less/bem/tournament-list-item.less
  88. 11
    0
      resources/assets/less/bem/user-action-button.less
  89. 279
    0
      resources/assets/less/bem/user-card.less
  90. 9
    14
      resources/assets/less/bem/user-cards.less
  91. 15
    4
      resources/assets/less/bem/user-list.less
  92. 0
    204
      resources/assets/less/bem/usercard.less
  93. 1
    1
      resources/assets/less/colors.less
  94. 4
    0
      resources/assets/less/spinner.less
  95. 3
    2
      resources/assets/less/variables.less
  96. 4
    5
      resources/assets/lib/coffee-modules.d.ts
  97. 48
    0
      resources/assets/lib/deleted-comments-count.tsx
  98. 32
    0
      resources/assets/lib/models/legacy-pm-notification.ts
  99. 2
    0
      resources/assets/lib/models/notification.ts
  100. 0
    0
      resources/assets/lib/notification-widget/item.tsx

+ 0
- 8
.github/pull_request_template.md View File

@@ -1,8 +0,0 @@
1
-Add any details pertaining to developers above the break.
2
-
3
-- [ ] Depends on #PR
4
-- Closes #ISSUE
5
-
6
----
7
-
8
-Add a sentence or two describing this change in plain english. This will be displayed on the [changelog](https://osu.ppy.sh/home/changelog). A single screenshot or short gif is also welcomed.

+ 1
- 1
app/Console/Commands/DisqusImport.php View File

@@ -108,7 +108,7 @@ class DisqusImport extends Command
108 108
         $id = (int) $thread->attributes('dsq', true)->id;
109 109
         $link = (string) $thread->link;
110 110
 
111
-        list($commentableType, $commentableId) =
111
+        [$commentableType, $commentableId] =
112 112
             $this->findBeatmapset($legacyId, $link) ??
113 113
             $this->findBuild($legacyId) ??
114 114
             $this->findNewsPost($legacyId) ??

+ 1
- 0
app/Http/Controllers/AccountController.php View File

@@ -135,6 +135,7 @@ class AccountController extends Controller
135 135
                 'user_from:string',
136 136
                 'user_interests:string',
137 137
                 'user_msnm:string',
138
+                'user_notify:bool',
138 139
                 'user_occ:string',
139 140
                 'user_sig:string',
140 141
                 'user_twitter:string',

+ 5
- 0
app/Http/Controllers/BeatmapsController.php View File

@@ -22,7 +22,9 @@ namespace App\Http\Controllers;
22 22
 
23 23
 use App\Exceptions\ScoreRetrievalException;
24 24
 use App\Models\Beatmap;
25
+use App\Models\Score\Best\Model as BestModel;
25 26
 use Auth;
27
+use DB;
26 28
 use Request;
27 29
 
28 30
 class BeatmapsController extends Controller
@@ -56,9 +58,12 @@ class BeatmapsController extends Controller
56 58
                 }
57 59
             }
58 60
 
61
+            $class = BestModel::getClassByString($mode);
62
+            $table = (new $class)->getTable();
59 63
             $query = $beatmap
60 64
                 ->scoresBest($mode)
61 65
                 ->with(['beatmap', 'user.country'])
66
+                ->from(DB::raw("{$table} FORCE INDEX (beatmap_score_lookup)"))
62 67
                 ->defaultListing();
63 68
         } catch (ScoreRetrievalException $ex) {
64 69
             return error_popup($ex->getMessage());

+ 20
- 0
app/Http/Controllers/BeatmapsetsController.php View File

@@ -174,6 +174,26 @@ class BeatmapsetsController extends Controller
174 174
         }
175 175
     }
176 176
 
177
+    public function discussionUnlock($id)
178
+    {
179
+        priv_check('BeatmapsetDiscussionLock')->ensureCan();
180
+
181
+        $beatmapset = Beatmapset::where('discussion_enabled', true)->findOrFail($id);
182
+        $beatmapset->discussionUnlock(Auth::user(), request('reason'));
183
+
184
+        return $beatmapset->defaultDiscussionJson();
185
+    }
186
+
187
+    public function discussionLock($id)
188
+    {
189
+        priv_check('BeatmapsetDiscussionLock')->ensureCan();
190
+
191
+        $beatmapset = Beatmapset::where('discussion_enabled', true)->findOrFail($id);
192
+        $beatmapset->discussionLock(Auth::user(), request('reason'));
193
+
194
+        return $beatmapset->defaultDiscussionJson();
195
+    }
196
+
177 197
     public function download($id)
178 198
     {
179 199
         $beatmapset = Beatmapset::findOrFail($id);

+ 15
- 10
app/Http/Controllers/ChangelogController.php View File

@@ -39,14 +39,6 @@ class ChangelogController extends Controller
39 39
     {
40 40
         $this->getUpdateStreams();
41 41
 
42
-        $chartConfig = Cache::remember(
43
-            'chart_config_global',
44
-            config('osu.changelog.build_history_interval'),
45
-            function () {
46
-                return $this->chartConfig(null);
47
-            }
48
-        );
49
-
50 42
         $search = [
51 43
             'stream' => presence(request('stream')),
52 44
             'from' => presence(request('from')),
@@ -75,6 +67,7 @@ class ChangelogController extends Controller
75 67
         ]);
76 68
 
77 69
         $indexJson = [
70
+            'streams' => $this->updateStreams,
78 71
             'builds' => $buildsJson,
79 72
             'search' => $search,
80 73
         ];
@@ -82,6 +75,14 @@ class ChangelogController extends Controller
82 75
         if (request()->expectsJson()) {
83 76
             return $indexJson;
84 77
         } else {
78
+            $chartConfig = Cache::remember(
79
+                'chart_config_global',
80
+                config('osu.changelog.build_history_interval'),
81
+                function () {
82
+                    return $this->chartConfig(null);
83
+                }
84
+            );
85
+
85 86
             return view('changelog.index', compact('chartConfig', 'indexJson'));
86 87
         }
87 88
     }
@@ -90,7 +91,7 @@ class ChangelogController extends Controller
90 91
     {
91 92
         $token = config('osu.changelog.github_token');
92 93
 
93
-        list($algo, $signature) = explode('=', request()->header('X-Hub-Signature'));
94
+        [$algo, $signature] = explode('=', request()->header('X-Hub-Signature'));
94 95
         $hash = hash_hmac($algo, request()->getContent(), $token);
95 96
 
96 97
         if (!hash_equals((string) $hash, (string) $signature)) {
@@ -136,7 +137,11 @@ class ChangelogController extends Controller
136 137
                 return $this->chartConfig($build->updateStream);
137 138
             });
138 139
 
139
-        return view('changelog.build', compact('build', 'buildJson', 'chartConfig', 'commentBundle'));
140
+        if (request()->expectsJson()) {
141
+            return $buildJson;
142
+        } else {
143
+            return view('changelog.build', compact('build', 'buildJson', 'chartConfig', 'commentBundle'));
144
+        }
140 145
     }
141 146
 
142 147
     private function getUpdateStreams()

+ 4
- 0
app/Http/Controllers/Forum/TopicsController.php View File

@@ -377,6 +377,10 @@ class TopicsController extends Controller
377 377
             return error_popup($e->getMessage());
378 378
         }
379 379
 
380
+        if (Auth::user()->user_notify || $forum->isHelpForum()) {
381
+            TopicWatch::setState($topic, Auth::user(), 'watching_mail');
382
+        }
383
+
380 384
         ForumUpdateNotifier::onNew([
381 385
             'topic' => $topic,
382 386
             'post' => $topic->posts->last(),

+ 8
- 4
app/Http/Controllers/FriendsController.php View File

@@ -62,11 +62,15 @@ class FriendsController extends Controller
62 62
 
63 63
         if (is_api_request()) {
64 64
             return json_collection($friends, 'UserCompact', ['cover', 'country']);
65
-        } else {
66
-            $userlist = group_users_by_online_state($friends);
67
-
68
-            return view('friends.index', compact('userlist'));
69 65
         }
66
+
67
+        $userlist = group_users_by_online_state($friends);
68
+        $usersJson = [
69
+            'online' => json_collection($userlist['online'], 'UserCompact', ['cover', 'country']),
70
+            'offline' => json_collection($userlist['offline'], 'UserCompact', ['cover', 'country']),
71
+        ];
72
+
73
+        return view('friends.index', compact('usersJson'));
70 74
     }
71 75
 
72 76
     public function store()

+ 5
- 1
app/Http/Controllers/GroupsController.php View File

@@ -41,7 +41,11 @@ class GroupsController extends Controller
41 41
             ->get();
42 42
 
43 43
         $userlist = group_users_by_online_state($users);
44
+        $usersJson = [
45
+            'online' => json_collection($userlist['online'], 'UserCompact', ['cover', 'country']),
46
+            'offline' => json_collection($userlist['offline'], 'UserCompact', ['cover', 'country']),
47
+        ];
44 48
 
45
-        return view('groups.show', compact('group', 'userlist'));
49
+        return view('groups.show', compact('group', 'usersJson'));
46 50
     }
47 51
 }

+ 1
- 1
app/Http/Controllers/NotificationsController.php View File

@@ -50,7 +50,7 @@ class NotificationsController extends Controller
50 50
 
51 51
         $maxId = get_int(request('max_id'));
52 52
         if (isset($maxId)) {
53
-            $userNotificationsQuery->where('id', '<=', $maxId);
53
+            $userNotificationsQuery->where('notification_id', '<=', $maxId);
54 54
         }
55 55
 
56 56
         $userNotifications = $userNotificationsQuery->get();

+ 10
- 3
app/Http/Controllers/RankingController.php View File

@@ -26,6 +26,7 @@ use App\Models\CountryStatistics;
26 26
 use App\Models\Spotlight;
27 27
 use App\Models\User;
28 28
 use App\Models\UserStatistics;
29
+use DB;
29 30
 use Illuminate\Pagination\LengthAwarePaginator;
30 31
 
31 32
 class RankingController extends Controller
@@ -111,7 +112,9 @@ class RankingController extends Controller
111 112
                 static::MAX_RESULTS
112 113
             );
113 114
 
114
-            $stats = UserStatistics\Model::getClass($mode)
115
+            $class = UserStatistics\Model::getClass($mode);
116
+            $table = (new $class)->getTable();
117
+            $stats = $class
115 118
                 ::on('mysql-readonly')
116 119
                 ->with(['user', 'user.country'])
117 120
                 ->whereHas('user', function ($userQuery) {
@@ -123,9 +126,13 @@ class RankingController extends Controller
123 126
             }
124 127
 
125 128
             if ($type === 'performance') {
126
-                $stats->orderBy('rank_score', 'desc');
129
+                $stats
130
+                    ->orderBy('rank_score', 'desc')
131
+                    ->from(DB::raw("{$table} FORCE INDEX (rank_score)"));
127 132
             } else { // 'score'
128
-                $stats->orderBy('ranked_score', 'desc');
133
+                $stats
134
+                    ->orderBy('ranked_score', 'desc')
135
+                    ->from(DB::raw("{$table} FORCE INDEX (ranked_score)"));
129 136
             }
130 137
         }
131 138
 

+ 21
- 0
app/Jobs/BroadcastNotification.php View File

@@ -102,6 +102,26 @@ class BroadcastNotification implements ShouldQueue
102 102
         }
103 103
     }
104 104
 
105
+    private function onBeatmapsetDiscussionLock()
106
+    {
107
+        $this->receiverIds = static::beatmapsetReceiverIds($this->object);
108
+
109
+        $this->params['details'] = [
110
+            'title' => $this->object->title,
111
+            'cover_url' => $this->object->coverURL('card'),
112
+        ];
113
+    }
114
+
115
+    private function onBeatmapsetDiscussionUnlock()
116
+    {
117
+        $this->receiverIds = static::beatmapsetReceiverIds($this->object);
118
+
119
+        $this->params['details'] = [
120
+            'title' => $this->object->title,
121
+            'cover_url' => $this->object->coverURL('card'),
122
+        ];
123
+    }
124
+
105 125
     private function onBeatmapsetDiscussionPostNew()
106 126
     {
107 127
         $this->notifiable = $this->object->beatmapset;
@@ -173,6 +193,7 @@ class BroadcastNotification implements ShouldQueue
173 193
         $this->receiverIds = $this->object
174 194
             ->topic
175 195
             ->watches()
196
+            ->where('mail', true)
176 197
             ->where('user_id', '<>', $this->source->getKey())
177 198
             ->pluck('user_id')
178 199
             ->all();

+ 2
- 2
app/Libraries/NewForumTopic.php View File

@@ -42,7 +42,7 @@ class NewForumTopic
42 42
     {
43 43
         $body = null;
44 44
 
45
-        if ($this->forum->forum_id === config('osu.forum.help_forum_id')) {
45
+        if ($this->forum->isHelpForum()) {
46 46
             $client = $this->user->clients()->last('timestamp');
47 47
 
48 48
             $buildName = '';
@@ -73,7 +73,7 @@ class NewForumTopic
73 73
 
74 74
     public function titlePlaceholder()
75 75
     {
76
-        if ($this->forum->forum_id === config('osu.forum.help_forum_id')) {
76
+        if ($this->forum->isHelpForum()) {
77 77
             // In English language forum, no localization.
78 78
             return 'What is your problem (50 characters)';
79 79
         }

+ 50
- 24
app/Libraries/OsuAuthorize.php View File

@@ -30,7 +30,6 @@ use App\Models\Forum\TopicCover;
30 30
 use App\Models\Multiplayer\Match as MultiplayerMatch;
31 31
 use App\Models\User;
32 32
 use App\Models\UserContestEntry;
33
-use App\Models\UserGroup;
34 33
 use Carbon\Carbon;
35 34
 
36 35
 class OsuAuthorize
@@ -82,7 +81,7 @@ class OsuAuthorize
82 81
 
83 82
     public function checkBeatmapDiscussionAllowOrDenyKudosu($user, $discussion)
84 83
     {
85
-        if ($user !== null && ($user->isBNG() || $user->isGMT() || $user->isQAT())) {
84
+        if ($user !== null && ($user->isBNG() || $user->isGMT() || $user->isNAT())) {
86 85
             return 'ok';
87 86
         }
88 87
     }
@@ -94,7 +93,7 @@ class OsuAuthorize
94 93
         $this->ensureLoggedIn($user);
95 94
         $this->ensureCleanRecord($user);
96 95
 
97
-        if ($user->isGMT() || $user->isQAT()) {
96
+        if ($user->isGMT() || $user->isNAT()) {
98 97
             return 'ok';
99 98
         }
100 99
 
@@ -129,7 +128,7 @@ class OsuAuthorize
129 128
 
130 129
     public function checkBeatmapDiscussionModerate($user)
131 130
     {
132
-        if ($user !== null && ($user->isGMT() || $user->isQAT())) {
131
+        if ($user !== null && ($user->isGMT() || $user->isNAT())) {
133 132
             return 'ok';
134 133
         }
135 134
     }
@@ -157,7 +156,7 @@ class OsuAuthorize
157 156
             return 'ok';
158 157
         }
159 158
 
160
-        if ($user->isGMT() || $user->isQAT()) {
159
+        if ($user->isGMT() || $user->isNAT()) {
161 160
             return 'ok';
162 161
         }
163 162
 
@@ -166,7 +165,7 @@ class OsuAuthorize
166 165
 
167 166
     public function checkBeatmapDiscussionRestore($user, $discussion)
168 167
     {
169
-        if ($user !== null && ($user->isGMT() || $user->isQAT())) {
168
+        if ($user !== null && ($user->isGMT() || $user->isNAT())) {
170 169
             return 'ok';
171 170
         }
172 171
     }
@@ -183,7 +182,7 @@ class OsuAuthorize
183 182
             }
184 183
         }
185 184
 
186
-        if ($user !== null && ($user->isGMT() || $user->isQAT())) {
185
+        if ($user !== null && ($user->isGMT() || $user->isNAT())) {
187 186
             return 'ok';
188 187
         }
189 188
     }
@@ -194,7 +193,7 @@ class OsuAuthorize
194 193
         $this->ensureCleanRecord($user);
195 194
 
196 195
         if ($discussion->message_type === 'mapper_note') {
197
-            if ($user->getKey() !== $discussion->beatmapset->user_id && !$user->isQAT() && !$user->isBNG()) {
196
+            if ($user->getKey() !== $discussion->beatmapset->user_id && !$user->isNAT() && !$user->isBNG()) {
198 197
                 return 'beatmap_discussion.store.mapper_note_wrong_user';
199 198
             }
200 199
         }
@@ -216,7 +215,7 @@ class OsuAuthorize
216 215
         ];
217 216
 
218 217
         if (!in_array($discussion->beatmapset->approved, $votableStates, true)) {
219
-            if (!$user->isBNG() && !$user->isGMT() && !$user->isQAT()) {
218
+            if (!$user->isBNG() && !$user->isGMT() && !$user->isNAT()) {
220 219
                 return $prefix.'wrong_beatmapset_state';
221 220
             }
222 221
         }
@@ -225,7 +224,7 @@ class OsuAuthorize
225 224
             return $prefix.'owner';
226 225
         }
227 226
 
228
-        if ($user->isBNG() || $user->isGMT() || $user->isQAT()) {
227
+        if ($user->isBNG() || $user->isGMT() || $user->isNAT()) {
229 228
             return 'ok';
230 229
         }
231 230
 
@@ -257,7 +256,7 @@ class OsuAuthorize
257 256
             return $prefix.'owner';
258 257
         }
259 258
 
260
-        if ($user->isBNG() || $user->isGMT() || $user->isQAT()) {
259
+        if ($user->isBNG() || $user->isGMT() || $user->isNAT()) {
261 260
             return 'ok';
262 261
         }
263 262
 
@@ -275,7 +274,7 @@ class OsuAuthorize
275 274
             return $prefix.'system_generated';
276 275
         }
277 276
 
278
-        if ($user->isGMT() || $user->isQAT()) {
277
+        if ($user->isGMT() || $user->isNAT()) {
279 278
             return 'ok';
280 279
         }
281 280
 
@@ -306,7 +305,7 @@ class OsuAuthorize
306 305
 
307 306
     public function checkBeatmapDiscussionPostRestore($user, $post)
308 307
     {
309
-        if ($user !== null && ($user->isGMT() || $user->isQAT())) {
308
+        if ($user !== null && ($user->isGMT() || $user->isNAT())) {
310 309
             return 'ok';
311 310
         }
312 311
     }
@@ -317,7 +316,7 @@ class OsuAuthorize
317 316
             return 'ok';
318 317
         }
319 318
 
320
-        if ($user !== null && ($user->isGMT() || $user->isQAT())) {
319
+        if ($user !== null && ($user->isGMT() || $user->isNAT())) {
321 320
             return 'ok';
322 321
         }
323 322
     }
@@ -327,6 +326,14 @@ class OsuAuthorize
327 326
         $this->ensureLoggedIn($user);
328 327
         $this->ensureCleanRecord($user);
329 328
 
329
+        if ($user->isGMT() || $user->isNAT()) {
330
+            return 'ok';
331
+        }
332
+
333
+        if ($post->beatmapDiscussion->beatmapset->discussion_locked) {
334
+            return 'beatmap_discussion_post.store.beatmapset_locked';
335
+        }
336
+
330 337
         return 'ok';
331 338
     }
332 339
 
@@ -338,7 +345,7 @@ class OsuAuthorize
338 345
             return 'ok';
339 346
         }
340 347
 
341
-        if (!$beatmapset->isScoreable() && ($user->isGMT() || $user->isQAT())) {
348
+        if (!$beatmapset->isScoreable() && ($user->isGMT() || $user->isNAT())) {
342 349
             return 'ok';
343 350
         }
344 351
     }
@@ -347,7 +354,7 @@ class OsuAuthorize
347 354
     {
348 355
         $this->ensureLoggedIn($user);
349 356
 
350
-        if (!($user->isGMT() || $user->isQAT() || $user->isGroup(UserGroup::GROUPS['loved']))) {
357
+        if (!$user->isProjectLoved()) {
351 358
             return 'unauthorized';
352 359
         }
353 360
 
@@ -360,7 +367,7 @@ class OsuAuthorize
360 367
 
361 368
         static $prefix = 'beatmap_discussion.nominate.';
362 369
 
363
-        if (!$user->isBNG() && !$user->isQAT()) {
370
+        if (!$user->isBNG() && !$user->isNAT()) {
364 371
             return 'unauthorized';
365 372
         }
366 373
 
@@ -376,6 +383,16 @@ class OsuAuthorize
376 383
             return $prefix.'owner';
377 384
         }
378 385
 
386
+        if ($user->isLimitedBN()) {
387
+            if ($beatmapset->playmodeCount() > 1) {
388
+                return $prefix.'full_bn_required_hybrid';
389
+            }
390
+
391
+            if ($beatmapset->requiresFullBNNomination()) {
392
+                return $prefix.'full_bn_required';
393
+            }
394
+        }
395
+
379 396
         return 'ok';
380 397
     }
381 398
 
@@ -383,7 +400,7 @@ class OsuAuthorize
383 400
     {
384 401
         $this->ensureLoggedIn($user);
385 402
 
386
-        if (!$user->isBNG() && !$user->isQAT()) {
403
+        if (!$user->isBNG() && !$user->isNAT()) {
387 404
             return 'unauthorized';
388 405
         }
389 406
 
@@ -401,7 +418,7 @@ class OsuAuthorize
401 418
         }
402 419
 
403 420
         if ($user !== null) {
404
-            if ($user->isBNG() || $user->isGMT() || $user->isQAT()) {
421
+            if ($user->isBNG() || $user->isGMT() || $user->isNAT()) {
405 422
                 return 'ok';
406 423
             }
407 424
 
@@ -415,7 +432,7 @@ class OsuAuthorize
415 432
     {
416 433
         $this->ensureLoggedIn($user);
417 434
 
418
-        if ($user->user_id === $beatmapset->user_id || $user->isGMT() || $user->isQAT()) {
435
+        if ($user->user_id === $beatmapset->user_id || $user->isGMT() || $user->isNAT()) {
419 436
             return 'ok';
420 437
         }
421 438
 
@@ -426,7 +443,7 @@ class OsuAuthorize
426 443
     {
427 444
         $this->ensureLoggedIn($user);
428 445
 
429
-        if (!$user->isQAT()) {
446
+        if (!$user->isNAT() && !$user->isFullBN() && !$user->isGMT()) {
430 447
             return 'unauthorized';
431 448
         }
432 449
 
@@ -437,9 +454,18 @@ class OsuAuthorize
437 454
         return 'ok';
438 455
     }
439 456
 
457
+    public function checkBeatmapsetDiscussionLock($user)
458
+    {
459
+        $this->ensureLoggedIn($user);
460
+
461
+        if ($user->isGMT() || $user->isNAT()) {
462
+            return 'ok';
463
+        }
464
+    }
465
+
440 466
     public function checkBeatmapsetEventViewUserId($user, $event)
441 467
     {
442
-        if ($user !== null && $user->isQAT()) {
468
+        if ($user !== null && $user->isNAT()) {
443 469
             return 'ok';
444 470
         }
445 471
 
@@ -601,7 +627,7 @@ class OsuAuthorize
601 627
         $this->ensureLoggedIn($user);
602 628
         $this->ensureCleanRecord($user);
603 629
 
604
-        if ($user->isGMT() || $user->isQAT()) {
630
+        if ($user->isGMT() || $user->isNAT()) {
605 631
             return 'ok';
606 632
         }
607 633
     }
@@ -712,7 +738,7 @@ class OsuAuthorize
712 738
         $this->ensureLoggedIn($user);
713 739
         $this->ensureCleanRecord($user);
714 740
 
715
-        if ($user->isGMT() || $user->isQAT()) {
741
+        if ($user->isGMT() || $user->isNAT()) {
716 742
             return 'ok';
717 743
         }
718 744
 

+ 9
- 3
app/Libraries/ReplayFile.php View File

@@ -25,6 +25,8 @@ use Storage;
25 25
 
26 26
 class ReplayFile
27 27
 {
28
+    const DEFAULT_VERSION = 20151228;
29
+
28 30
     private $diskName;
29 31
     private $filename;
30 32
     private $score;
@@ -57,13 +59,17 @@ class ReplayFile
57 59
         return pack('q', $this->score->score_id);
58 60
     }
59 61
 
62
+    public function getVersion()
63
+    {
64
+        return optional($this->score->replayViewCount)->version ?? static::DEFAULT_VERSION;
65
+    }
66
+
60 67
     /**
61 68
      * Generates the header chunk for replay files.
62 69
      *
63
-     * @param string $version client version.
64 70
      * @return string Binary string of the chunk.
65 71
      */
66
-    public function headerChunk(string $version = '20151228') : string
72
+    public function headerChunk() : string
67 73
     {
68 74
         $score = $this->score;
69 75
         $beatmap = $score->beatmap;
@@ -76,7 +82,7 @@ class ReplayFile
76 82
         // easier debugging with array and implode instead of plain string concatenation.
77 83
         $components = [
78 84
             pack('c', $mode),
79
-            pack('i', $version),
85
+            pack('i', $this->getVersion()),
80 86
             pack_str($beatmap->checksum),
81 87
             pack_str($user->username),
82 88
             pack_str($md5),

+ 1
- 1
app/Models/BeatmapMirror.php View File

@@ -99,7 +99,7 @@ class BeatmapMirror extends Model
99 99
         $userId = Auth::check() ? Auth::user()->user_id : 0;
100 100
         $checksum = md5("{$beatmapset->beatmapset_id}{$diskFilename}{$serveFilename}{$time}{$noVideo}{$this->secret_key}");
101 101
 
102
-        $url = "{$this->base_url}d/{$beatmapset->beatmapset_id}?fs=".rawurlencode($serveFilename).'&fd='.rawurlencode($diskFilename)."&ts=$time&cs=$checksum&u=$userId&nv=$noVideo";
102
+        $url = "{$this->base_url}d/{$beatmapset->beatmapset_id}?fs=".rawurlencode($serveFilename).'&fd='.rawurlencode($diskFilename)."&ts=$time&cs=$checksum&nv=$noVideo";
103 103
 
104 104
         return $url;
105 105
     }

+ 58
- 1
app/Models/Beatmapset.php View File

@@ -55,6 +55,7 @@ use Illuminate\Database\QueryException;
55 55
  * @property \Carbon\Carbon|null $deleted_at
56 56
  * @property string|null $difficulty_names
57 57
  * @property bool $discussion_enabled
58
+ * @property bool $discussion_locked
58 59
  * @property string $displaytitle
59 60
  * @property bool $download_disabled
60 61
  * @property string|null $download_disabled_url
@@ -110,6 +111,7 @@ class Beatmapset extends Model implements AfterCommit
110 111
         'storyboard' => 'boolean',
111 112
         'video' => 'boolean',
112 113
         'discussion_enabled' => 'boolean',
114
+        'discussion_locked' => 'boolean',
113 115
     ];
114 116
 
115 117
     protected $dates = [
@@ -568,6 +570,34 @@ class Beatmapset extends Model implements AfterCommit
568 570
         }
569 571
     }
570 572
 
573
+    public function discussionLock($user, $reason)
574
+    {
575
+        if ($this->discussion_locked) {
576
+            return;
577
+        }
578
+
579
+        DB::transaction(function () use ($user, $reason) {
580
+            BeatmapsetEvent::log(BeatmapsetEvent::DISCUSSION_LOCK, $user, $this, [
581
+                'reason' => $reason,
582
+            ])->saveOrExplode();
583
+            $this->update(['discussion_locked' => true]);
584
+            broadcast_notification(Notification::BEATMAPSET_DISCUSSION_LOCK, $this, $user);
585
+        });
586
+    }
587
+
588
+    public function discussionUnlock($user)
589
+    {
590
+        if (!$this->discussion_locked) {
591
+            return;
592
+        }
593
+
594
+        DB::transaction(function () use ($user) {
595
+            BeatmapsetEvent::log(BeatmapsetEvent::DISCUSSION_UNLOCK, $user, $this)->saveOrExplode();
596
+            $this->update(['discussion_locked' => false]);
597
+            broadcast_notification(Notification::BEATMAPSET_DISCUSSION_UNLOCK, $this, $user);
598
+        });
599
+    }
600
+
571 601
     public function disqualify($user, $post)
572 602
     {
573 603
         if (!$this->isQualified()) {
@@ -804,13 +834,23 @@ class Beatmapset extends Model implements AfterCommit
804 834
         return $this->currentNominationCount() > 0;
805 835
     }
806 836
 
837
+    public function playmodes()
838
+    {
839
+        return $this->beatmaps->pluck('playmode')->unique();
840
+    }
841
+
842
+    public function playmodeCount()
843
+    {
844
+        return $this->playmodes()->count();
845
+    }
846
+
807 847
     public function rankingETA()
808 848
     {
809 849
         if (!$this->isQualified()) {
810 850
             return;
811 851
         }
812 852
 
813
-        $modes = $this->beatmaps->pluck('playmode')->unique()->toArray();
853
+        $modes = $this->playmodes()->toArray();
814 854
 
815 855
         $queueSize = static::qualified()
816 856
             ->whereHas('beatmaps', function ($query) use ($modes) {
@@ -856,6 +896,23 @@ class Beatmapset extends Model implements AfterCommit
856 896
         return $this->eventsSinceReset()->nominations();
857 897
     }
858 898
 
899
+    public function hasFullBNNomination()
900
+    {
901
+        return $this->nominationsSinceReset()
902
+            ->with('user')
903
+            ->get()
904
+            ->pluck('user')
905
+            ->contains(function ($user) {
906
+                return $user->isNAT() || $user->isFullBN();
907
+            });
908
+    }
909
+
910
+    public function requiresFullBNNomination()
911
+    {
912
+        return $this->currentNominationCount() === $this->requiredNominationCount() - 1
913
+            && !$this->hasFullBNNomination();
914
+    }
915
+
859 916
     public function status()
860 917
     {
861 918
         return array_search_null($this->approved, static::STATES);

+ 3
- 0
app/Models/BeatmapsetEvent.php View File

@@ -51,6 +51,9 @@ class BeatmapsetEvent extends Model
51 51
     const ISSUE_RESOLVE = 'issue_resolve';
52 52
     const ISSUE_REOPEN = 'issue_reopen';
53 53
 
54
+    const DISCUSSION_LOCK = 'discussion_lock';
55
+    const DISCUSSION_UNLOCK = 'discussion_unlock';
56
+
54 57
     const DISCUSSION_DELETE = 'discussion_delete';
55 58
     const DISCUSSION_RESTORE = 'discussion_restore';
56 59
 

+ 1
- 1
app/Models/ChangelogEntry.php View File

@@ -196,7 +196,7 @@ class ChangelogEntry extends Model
196 196
 
197 197
     public function messageHTML()
198 198
     {
199
-        list($private, $public) = static::splitMessage($this->message);
199
+        [$private, $public] = static::splitMessage($this->message);
200 200
 
201 201
         if ($public !== null) {
202 202
             return markdown($public, 'changelog_entry');

+ 1
- 1
app/Models/DeletedUser.php View File

@@ -73,7 +73,7 @@ namespace App\Models;
73 73
  * @property string $user_msnm
74 74
  * @property int $user_new_privmsg
75 75
  * @property string $user_newpasswd
76
- * @property int $user_notify
76
+ * @property bool $user_notify
77 77
  * @property int $user_notify_pm
78 78
  * @property int $user_notify_type
79 79
  * @property string|null $user_occ

+ 5
- 0
app/Models/Forum/Forum.php View File

@@ -270,6 +270,11 @@ class Forum extends Model
270 270
         return $this->forum_id === $id || isset($this->forum_parents[$id]);
271 271
     }
272 272
 
273
+    public function isHelpForum()
274
+    {
275
+        return $this->forum_id === config('osu.forum.help_forum_id');
276
+    }
277
+
273 278
     public function topicsAdded($count)
274 279
     {
275 280
         $this->getConnection()->transaction(function () use ($count) {

+ 2
- 0
app/Models/Notification.php View File

@@ -24,7 +24,9 @@ use App\Libraries\MorphMap;
24 24
 
25 25
 class Notification extends Model
26 26
 {
27
+    const BEATMAPSET_DISCUSSION_LOCK = 'beatmapset_discussion_lock';
27 28
     const BEATMAPSET_DISCUSSION_POST_NEW = 'beatmapset_discussion_post_new';
29
+    const BEATMAPSET_DISCUSSION_UNLOCK = 'beatmapset_discussion_unlock';
28 30
     const BEATMAPSET_DISQUALIFY = 'beatmapset_disqualify';
29 31
     const BEATMAPSET_LOVE = 'beatmapset_love';
30 32
     const BEATMAPSET_NOMINATE = 'beatmapset_nominate';

+ 1
- 0
app/Models/ReplayViewCount/Fruits.php View File

@@ -23,6 +23,7 @@ namespace App\Models\ReplayViewCount;
23 23
 /**
24 24
  * @property int $play_count
25 25
  * @property int $score_id
26
+ * @property int $version
26 27
  */
27 28
 class Fruits extends Model
28 29
 {

+ 1
- 0
app/Models/ReplayViewCount/Mania.php View File

@@ -23,6 +23,7 @@ namespace App\Models\ReplayViewCount;
23 23
 /**
24 24
  * @property int $play_count
25 25
  * @property int $score_id
26
+ * @property int $version
26 27
  */
27 28
 class Mania extends Model
28 29
 {

+ 0
- 6
app/Models/ReplayViewCount/Model.php View File

@@ -20,7 +20,6 @@
20 20
 
21 21
 namespace App\Models\ReplayViewCount;
22 22
 
23
-use App\Libraries\ReplayFile;
24 23
 use App\Models\Model as BaseModel;
25 24
 use App\Models\Score\Best as ScoreBest;
26 25
 
@@ -41,9 +40,4 @@ abstract class Model extends BaseModel
41 40
 
42 41
         return $this->belongsTo($class, 'score_id');
43 42
     }
44
-
45
-    public function file()
46
-    {
47
-        return new ReplayFile($this);
48
-    }
49 43
 }

+ 1
- 0
app/Models/ReplayViewCount/Osu.php View File

@@ -23,6 +23,7 @@ namespace App\Models\ReplayViewCount;
23 23
 /**
24 24
  * @property int $play_count
25 25
  * @property int $score_id
26
+ * @property int $version
26 27
  */
27 28
 class Osu extends Model
28 29
 {

+ 1
- 0
app/Models/ReplayViewCount/Taiko.php View File

@@ -23,6 +23,7 @@ namespace App\Models\ReplayViewCount;
23 23
 /**
24 24
  * @property int $play_count
25 25
  * @property int $score_id
26
+ * @property int $version
26 27
  */
27 28
 class Taiko extends Model
28 29
 {

+ 1
- 1
app/Models/Score/Best/Model.php View File

@@ -52,7 +52,7 @@ abstract class Model extends BaseModel
52 52
         'XH' => 'xh_rank_count',
53 53
     ];
54 54
 
55
-    public function replayFile()
55
+    public function replayFile() : ?ReplayFile
56 56
     {
57 57
         if ($this->replay) {
58 58
             return new ReplayFile($this);

+ 2
- 2
app/Models/Store/Order.php View File

@@ -455,7 +455,7 @@ class Order extends Model
455 455
     {
456 456
         // locking bottleneck
457 457
         $this->getConnection()->transaction(function () {
458
-            list($items, $products) = $this->lockForReserve();
458
+            [$items, $products] = $this->lockForReserve();
459 459
 
460 460
             $items->each->releaseProduct();
461 461
         });
@@ -465,7 +465,7 @@ class Order extends Model
465 465
     {
466 466
         // locking bottleneck
467 467
         $this->getConnection()->transaction(function () {
468
-            list($items, $products) = $this->lockForReserve();
468
+            [$items, $products] = $this->lockForReserve();
469 469
             $items->each->reserveProduct();
470 470
         });
471 471
     }

+ 20
- 4
app/Models/User.php View File

@@ -140,7 +140,7 @@ use Request;
140 140
  * @property string $user_msnm
141 141
  * @property int $user_new_privmsg
142 142
  * @property string $user_newpasswd
143
- * @property int $user_notify
143
+ * @property bool $user_notify
144 144
  * @property int $user_notify_pm
145 145
  * @property int $user_notify_type
146 146
  * @property string|null $user_occ
@@ -192,6 +192,7 @@ class User extends Model implements AuthenticatableContract
192 192
         'osu_subscriber' => 'boolean',
193 193
         'user_allow_pm' => 'boolean',
194 194
         'user_allow_viewonline' => 'boolean',
195
+        'user_notify' => 'boolean',
195 196
         'user_timezone' => 'float',
196 197
     ];
197 198
 
@@ -658,9 +659,9 @@ class User extends Model implements AuthenticatableContract
658 659
     |
659 660
     */
660 661
 
661
-    public function isQAT()
662
+    public function isNAT()
662 663
     {
663
-        return $this->isGroup(UserGroup::GROUPS['qat']);
664
+        return $this->isGroup(UserGroup::GROUPS['nat']);
664 665
     }
665 666
 
666 667
     public function isAdmin()
@@ -675,9 +676,19 @@ class User extends Model implements AuthenticatableContract
675 676
 
676 677
     public function isBNG()
677 678
     {
679
+        return $this->isFullBN() || $this->isLimitedBN();
680
+    }
681
+
682
+    public function isFullBN()
683
+    {
678 684
         return $this->isGroup(UserGroup::GROUPS['bng']);
679 685
     }
680 686
 
687
+    public function isLimitedBN()
688
+    {
689
+        return $this->isGroup(UserGroup::GROUPS['bng_limited']);
690
+    }
691
+
681 692
     public function isHax()
682 693
     {
683 694
         return $this->isGroup(UserGroup::GROUPS['hax']);
@@ -703,6 +714,11 @@ class User extends Model implements AuthenticatableContract
703 714
         return $this->isGroup(UserGroup::GROUPS['default']);
704 715
     }
705 716
 
717
+    public function isProjectLoved()
718
+    {
719
+        return $this->isGroup(UserGroup::GROUPS['loved']);
720
+    }
721
+
706 722
     public function isBot()
707 723
     {
708 724
         return $this->group_id === UserGroup::GROUPS['bot'];
@@ -736,7 +752,7 @@ class User extends Model implements AuthenticatableContract
736 752
             || $this->isMod()
737 753
             || $this->isGMT()
738 754
             || $this->isBNG()
739
-            || $this->isQAT();
755
+            || $this->isNAT();
740 756
     }
741 757
 
742 758
     public function isBanned()

+ 2
- 1
app/Models/UserGroup.php View File

@@ -39,7 +39,7 @@ class UserGroup extends Model
39 39
         'default' => 2,
40 40
         'gmt' => 4,
41 41
         'admin' => 5,
42
-        'qat' => 7,
42
+        'nat' => 7,
43 43
         'dev' => 11,
44 44
         'alumni' => 16,
45 45
         'hax' => 17,
@@ -47,6 +47,7 @@ class UserGroup extends Model
47 47
         'bng' => 28,
48 48
         'bot' => 29,
49 49
         'loved' => 31,
50
+        'bng_limited' => 32,
50 51
     ];
51 52
 
52 53
     public function group()

+ 1
- 1
app/Models/UserNotFound.php View File

@@ -73,7 +73,7 @@ namespace App\Models;
73 73
  * @property string $user_msnm
74 74
  * @property int $user_new_privmsg
75 75
  * @property string $user_newpasswd
76
- * @property int $user_notify
76
+ * @property bool $user_notify
77 77
  * @property int $user_notify_pm
78 78
  * @property int $user_notify_type
79 79
  * @property string|null $user_occ

+ 2
- 1
app/Transformers/BeatmapsetTransformer.php View File

@@ -81,6 +81,7 @@ class BeatmapsetTransformer extends Fractal\TransformerAbstract
81 81
             'status' => $beatmapset->status(),
82 82
             'has_scores' => $beatmapset->hasScores(),
83 83
             'discussion_enabled' => $beatmapset->discussion_enabled,
84
+            'discussion_locked' => $beatmapset->discussion_locked,
84 85
             'can_be_hyped' => $beatmapset->canBeHyped(),
85 86
             'hype' => [
86 87
                 'current' => $beatmapset->hype,
@@ -218,7 +219,7 @@ class BeatmapsetTransformer extends Fractal\TransformerAbstract
218 219
     {
219 220
         $rel = $params->get('with_trashed') ? 'allBeatmaps' : 'beatmaps';
220 221
 
221
-        return $this->collection($beatmapset->$rel, new BeatmapTransformer);
222
+        return $this->collection($beatmapset->$rel()->with('beatmapset')->get(), new BeatmapTransformer);
222 223
     }
223 224
 
224 225
     public function includeConverts(Beatmapset $beatmapset)

+ 3
- 1
app/Transformers/UserTransformer.php View File

@@ -70,8 +70,10 @@ class UserTransformer extends Fractal\TransformerAbstract
70 70
             'is_supporter' => $user->osu_subscriber,
71 71
             'has_supported' => $user->hasSupported(),
72 72
             'is_gmt' => $user->isGMT(),
73
-            'is_qat' => $user->isQAT(),
73
+            'is_nat' => $user->isNAT(),
74 74
             'is_bng' => $user->isBNG(),
75
+            'is_full_bn' => $user->isFullBN(),
76
+            'is_limited_bn' => $user->isLimitedBN(),
75 77
             'is_bot' => $user->isBot(),
76 78
             'is_active' => $user->isActive(),
77 79
             'interests' => $user->user_interests,

+ 4
- 0
database/migrations/2015_01_01_133337_base_tables.php View File

@@ -494,6 +494,7 @@ class BaseTables extends Migration
494 494
 
495 495
             $table->unsignedInteger('score_id')->default(0)->primary();
496 496
             $table->unsignedInteger('play_count')->default(0);
497
+            $table->integer('version')->nullable();
497 498
         });
498 499
         $this->setRowFormat('osu_replays', 'DYNAMIC');
499 500
 
@@ -503,6 +504,7 @@ class BaseTables extends Migration
503 504
 
504 505
             $table->unsignedInteger('score_id')->default(0)->primary();
505 506
             $table->unsignedInteger('play_count')->default(0);
507
+            $table->integer('version')->nullable();
506 508
         });
507 509
         $this->setRowFormat('osu_replays_fruits', 'DYNAMIC');
508 510
 
@@ -512,6 +514,7 @@ class BaseTables extends Migration
512 514
 
513 515
             $table->unsignedInteger('score_id')->default(0)->primary();
514 516
             $table->unsignedInteger('play_count')->default(0);
517
+            $table->integer('version')->nullable();
515 518
         });
516 519
         $this->setRowFormat('osu_replays_mania', 'DYNAMIC');
517 520
 
@@ -521,6 +524,7 @@ class BaseTables extends Migration
521 524
 
522 525
             $table->unsignedInteger('score_id')->default(0)->primary();
523 526
             $table->unsignedInteger('play_count')->default(0);
527
+            $table->integer('version')->nullable();
524 528
         });
525 529
         $this->setRowFormat('osu_replays_taiko', 'DYNAMIC');
526 530
 

+ 32
- 0
database/migrations/2019_04_17_103403_add_discussion_locked_to_beatmapsets.php View File

@@ -0,0 +1,32 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+class AddDiscussionLockedToBeatmapsets extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     *
12
+     * @return void
13
+     */
14
+    public function up()
15
+    {
16
+        Schema::table('osu_beatmapsets', function (Blueprint $table) {
17
+            $table->boolean('discussion_locked')->after('discussion_enabled')->default(0);
18
+        });
19
+    }
20
+
21
+    /**
22
+     * Reverse the migrations.
23
+     *
24
+     * @return void
25
+     */
26
+    public function down()
27
+    {
28
+        Schema::table('osu_beatmapsets', function (Blueprint $table) {
29
+            $table->dropColumn('discussion_locked');
30
+        });
31
+    }
32
+}

+ 66
- 0
database/migrations/2019_04_23_051152_add_discussion_locked_to_beatmapset_events.php View File

@@ -0,0 +1,66 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+
5
+class AddDiscussionLockedToBeatmapsetEvents extends Migration
6
+{
7
+    /**
8
+     * Run the migrations.
9
+     *
10
+     * @return void
11
+     */
12
+    public function up()
13
+    {
14
+        DB::statement("ALTER TABLE beatmapset_events CHANGE type type ENUM(
15
+            'nominate',
16
+            'qualify',
17
+            'disqualify',
18
+            'approve',
19
+            'rank',
20
+            'kudosu_allow',
21
+            'kudosu_deny',
22
+            'kudosu_gain',
23
+            'kudosu_lost',
24
+            'issue_resolve',
25
+            'issue_reopen',
26
+            'discussion_delete',
27
+            'discussion_restore',
28
+            'discussion_post_delete',
29
+            'discussion_post_restore',
30
+            'kudosu_recalculate',
31
+            'nomination_reset',
32
+            'love',
33
+            'discussion_lock',
34
+            'discussion_unlock'
35
+        )");
36
+    }
37
+
38
+    /**
39
+     * Reverse the migrations.
40
+     *
41
+     * @return void
42
+     */
43
+    public function down()
44
+    {
45
+        DB::statement("ALTER TABLE beatmapset_events CHANGE type type ENUM(
46
+            'nominate',
47
+            'qualify',
48
+            'disqualify',
49
+            'approve',
50
+            'rank',
51
+            'kudosu_allow',
52
+            'kudosu_deny',
53
+            'kudosu_gain',
54
+            'kudosu_lost',
55
+            'issue_resolve',
56
+            'issue_reopen',
57
+            'discussion_delete',
58
+            'discussion_restore',
59
+            'discussion_post_delete',
60
+            'discussion_post_restore',
61
+            'kudosu_recalculate',
62
+            'nomination_reset',
63
+            'love'
64
+        )");
65
+    }
66
+}

+ 1
- 1
resources/assets/coffee/_classes/account-edit-blocklist.coffee View File

@@ -17,7 +17,7 @@
17 17
 ###
18 18
 
19 19
 class @AccountEditBlocklist
20
-  element: 'user-list__content'
20
+  element: 'block-list__content'
21 21
   jsClass: '.js-account-edit-blocklist'
22 22
 
23 23
   constructor: ->

+ 2
- 2
resources/assets/coffee/_classes/beatmap-discussion-helper.coffee View File

@@ -31,7 +31,7 @@ class @BeatmapDiscussionHelper
31 31
   @canModeratePosts: (user) =>
32 32
     user ?= currentUser
33 33
 
34
-    user.is_admin || user.is_gmt || user.is_qat
34
+    user.is_admin || user.is_gmt || user.is_nat
35 35
 
36 36
 
37 37
   # text should be pre-escaped.
@@ -127,7 +127,7 @@ class @BeatmapDiscussionHelper
127 127
 
128 128
 
129 129
   @moderationGroup: (user) =>
130
-    _.intersection(_.concat(user.default_group, user.groups), ['qat', 'bng'])[0]
130
+    _.intersection(_.concat(user.default_group, user.groups), ['nat', 'bng', 'bng_limited'])[0]
131 131
 
132 132
 
133 133
   @previewMessage = (message) =>

+ 8
- 0
resources/assets/coffee/_classes/current-user-observer.coffee View File

@@ -29,6 +29,7 @@ class @CurrentUserObserver
29 29
   reinit: =>
30 30
     @setAvatars()
31 31
     @setCovers()
32
+    @setSentryUser()
32 33
 
33 34
 
34 35
   setAvatars: (elements) =>
@@ -51,3 +52,10 @@ class @CurrentUserObserver
51 52
     window.currentUser = data
52 53
 
53 54
     @reinit()
55
+
56
+
57
+  setSentryUser: ->
58
+    return unless Sentry?
59
+
60
+    Sentry.configureScope (scope) ->
61
+      scope.setUser id: currentUser.id, username: currentUser.username

+ 1
- 1
resources/assets/coffee/_classes/form-error.coffee View File

@@ -72,7 +72,7 @@ class @FormError
72 72
     state = if errors.length > 0 then 'error' else ''
73 73
 
74 74
     $(el)
75
-      .closest 'label'
75
+      .closest 'label, .js-form-error--field'
76 76
       .attr 'data-form-error-state', state
77 77
       .find '.js-form-error--error'
78 78
       .text errors.join(' ')

+ 1
- 0
resources/assets/coffee/_classes/line-chart.coffee View File

@@ -224,6 +224,7 @@ class @LineChart
224 224
     i = @lookupIndexFromX x
225 225
 
226 226
     return unless i
227
+    return unless @data[i - 1] && @data[i]
227 228
 
228 229
     @hoverStart()
229 230
     Timeout.clear @_autoEndHover

+ 5
- 1
resources/assets/coffee/_classes/osu-audio.coffee View File

@@ -40,7 +40,11 @@ class @OsuAudio
40 40
 
41 41
     @urlSet url
42 42
     @publish 'initializing'
43
-    @player().play()
43
+    promise = @player().play()
44
+    # old api returns undefined
45
+    promise?.catch (error) =>
46
+      return if error.name == 'AbortError' || error.name == 'NotSupportedError' && !osu.present(@urlGet())
47
+      throw error
44 48
 
45 49
 
46 50
   player: =>

+ 19
- 0
resources/assets/coffee/_classes/polyfills.coffee View File

@@ -21,6 +21,25 @@ class @Polyfills
21 21
     @customEvent()
22 22
     @localStorage()
23 23
     @mathTrunc()
24
+    @composedPath()
25
+
26
+
27
+  # Event.composedPath polyfill for Edge.
28
+  # Actual composedPath logic is a bit more complicated but this works for our usage
29
+  # until it gets implemented in Edge.
30
+  composedPath: ->
31
+    return if typeof Event.prototype.composedPath == 'function'
32
+
33
+    Event.prototype.composedPath = ->
34
+      target = @target
35
+      path = []
36
+      while target.parentNode?
37
+        path.push target
38
+        target = target.parentNode
39
+
40
+      path.push document, window
41
+
42
+      return path
24 43
 
25 44
 
26 45
   # For IE9+.

+ 51
- 34
resources/assets/coffee/_classes/user-card.coffee View File

@@ -22,41 +22,17 @@ class @UserCard
22 22
 
23 23
   constructor: ->
24 24
     $(document).on 'mouseover', '.js-usercard', @onMouseOver
25
+    $(document).on 'mousedown keydown', @handleForceHide
26
+    $(document).on 'mouseenter', '.js-react--user-card-tooltip', @onMouseEnter
27
+    $(document).on 'mouseleave', '.js-react--user-card-tooltip', @onMouseLeave
28
+    $(document).on 'turbolinks:before-cache', @onBeforeCache
25 29
 
26
-  onMouseOver: (event) =>
27
-    el = event.currentTarget
28
-    userId = el.getAttribute('data-user-id')
29
-    return unless userId
30
-    return if _.find(currentUser.blocks, target_id: parseInt(userId)) # don't show cards for blocked users
31
-
32
-    # when qtip has already been init for current element
33
-    if el._tooltip?
34
-      api = $(el).qtip('api')
35
-
36
-      if el._tooltip == userId
37
-        # disable existing cards when entering 'mobile' mode
38
-        if osu.isMobile()
39
-          event.preventDefault()
40
-          api.disable()
41
-          el._disable_card = true
42
-        else
43
-          if el._disable_card
44
-            el._disable_card = false
45
-            api.enable()
46
-            $(el).trigger('mouseover')
47
-
48
-        return
49
-      else
50
-        # wrong userId, destroy current tooltip
51
-        api.destroy()
52
-
53
-    # disable usercards on mobile
54
-    if osu.isMobile()
55
-      return
56 30
 
31
+  createTooltip: (el) =>
32
+    userId = el.dataset.userId
57 33
     el._tooltip = userId
58 34
 
59
-    at = el.getAttribute('data-tooltip-position') ? 'right center'
35
+    at = el.dataset.tooltipPosition ? 'right center'
60 36
     my = switch at
61 37
       when 'top center' then 'bottom center'
62 38
       when 'left center' then 'right center'
@@ -64,21 +40,24 @@ class @UserCard
64 40
 
65 41
     # react should override the existing content after mounting
66 42
     card = $('#js-usercard__loading-template').children().clone()[0]
67
-    card.classList.replace 'js-react--user-card', 'js-react--user-card-tooltip'
43
+    card.classList.remove 'js-react--user-card'
44
+    card.classList.add 'js-react--user-card-tooltip'
68 45
     delete card.dataset.reactTurbolinksLoaded
69 46
     card.dataset.lookup = userId
70 47
 
71 48
     options =
72 49
       events:
73 50
         render: reactTurbolinks.boot
51
+        show: @shouldShow
74 52
       style:
53
+        classes: 'qtip--user-card'
75 54
         def: false
76 55
         tip: false
77
-        width: 280
78
-        height: 130
79 56
       content:
80 57
         text: card
81 58
       position:
59
+        adjust:
60
+          scroll: false
82 61
         at: at
83 62
         my: my
84 63
         viewport: $(window)
@@ -92,3 +71,41 @@ class @UserCard
92 71
         effect: -> $(this).fadeTo(110, 0)
93 72
 
94 73
     $(el).qtip options
74
+
75
+
76
+  handleForceHide: (e) =>
77
+    $('.qtip--user-card').qtip('hide') if (e.keyCode == 27 || e.button == 0) && !@inCard
78
+
79
+
80
+  onBeforeCache: =>
81
+    @inCard = false
82
+    window.tooltipWithActiveMenu = null
83
+
84
+
85
+  onMouseEnter: =>
86
+    @inCard = true
87
+
88
+
89
+  onMouseLeave: =>
90
+    @inCard = false
91
+
92
+
93
+  onMouseOver: (event) =>
94
+    return if window.tooltipWithActiveMenu?
95
+    # No user cards on mobile layout
96
+    return if osu.isMobile()
97
+
98
+    el = event.currentTarget
99
+    userId = el.dataset.userId
100
+    return unless userId
101
+    return if _.find(currentUser.blocks, target_id: parseInt(userId)) # don't show cards for blocked users
102
+
103
+    return @createTooltip(el) if !el._tooltip?
104
+
105
+    if el._tooltip != el.dataset.userId
106
+      # wrong userId, destroy current tooltip
107
+      $(el).qtip('api').destroy()
108
+
109
+
110
+  shouldShow: (event) ->
111
+    event.preventDefault() if window.tooltipWithActiveMenu? || osu.isMobile()

+ 5
- 2
resources/assets/coffee/react/_components/beatmapset-panel.coffee View File

@@ -105,9 +105,12 @@ export class BeatmapsetPanel extends React.PureComponent
105 105
             src: beatmapset.covers.card
106 106
           div className: 'beatmapset-panel__image-overlay'
107 107
           div className: 'beatmapset-panel__status-container',
108
-            if beatmapset.video or beatmapset.storyboard
109
-              div className: 'beatmapset-panel__video-icon',
108
+            if beatmapset.video
109
+              div className: 'beatmapset-panel__extra-icon',
110 110
                 i className: 'fas fa-film fa-fw'
111
+            if beatmapset.storyboard
112
+              div className: 'beatmapset-panel__extra-icon',
113
+                i className: 'fas fa-image fa-fw'
111 114
             div className: 'beatmapset-status', beatmapset.status
112 115
 
113 116
           div className: 'beatmapset-panel__title-artist-box',

+ 24
- 14
resources/assets/coffee/react/_components/comment.coffee View File

@@ -18,6 +18,7 @@
18 18
 
19 19
 import { CommentEditor } from 'comment-editor'
20 20
 import { CommentShowMore } from 'comment-show-more'
21
+import DeletedCommentsCount from 'deleted-comments-count'
21 22
 import * as React from 'react'
22 23
 import { a, button, div, span, textarea } from 'react-dom-factories'
23 24
 import { ReportComment } from 'report-comment'
@@ -42,6 +43,7 @@ export class Comment extends React.PureComponent
42 43
 
43 44
 
44 45
   @defaultProps =
46
+    showDeleted: true
45 47
     showReplies: true
46 48
 
47 49
 
@@ -237,19 +239,9 @@ export class Comment extends React.PureComponent
237 239
       if @props.showReplies && @props.comment.replies_count > 0
238 240
         div
239 241
           className: repliesClass
240
-          for comment in children
241
-            el Comment,
242
-              key: comment.id
243
-              comment: comment
244
-              commentsByParentId: @props.commentsByParentId
245
-              usersById: @props.usersById
246
-              userVotesByCommentId: @props.userVotesByCommentId
247
-              commentableMetaById: @props.commentableMetaById
248
-              depth: @props.depth + 1
249
-              parent: @props.comment
250
-              modifiers: @props.modifiers
251
-              currentSort: @props.currentSort
252
-              moreComments: @props.moreComments
242
+          children.map @renderComment
243
+
244
+          el DeletedCommentsCount, { comments: children, showDeleted: @props.showDeleted }
253 245
 
254 246
           el CommentShowMore,
255 247
             parent: @props.comment
@@ -261,6 +253,24 @@ export class Comment extends React.PureComponent
261 253
             label: osu.trans('comments.show_replies') if children.length == 0
262 254
 
263 255
 
256
+  renderComment: (comment) =>
257
+    return null if comment.deleted_at? && !@props.showDeleted
258
+
259
+    el Comment,
260
+      key: comment.id
261
+      comment: comment
262
+      commentsByParentId: @props.commentsByParentId
263
+      usersById: @props.usersById
264
+      userVotesByCommentId: @props.userVotesByCommentId
265
+      commentableMetaById: @props.commentableMetaById
266
+      depth: @props.depth + 1
267
+      parent: @props.comment
268
+      modifiers: @props.modifiers
269
+      currentSort: @props.currentSort
270
+      moreComments: @props.moreComments
271
+      showDeleted: @props.showDeleted
272
+
273
+
264 274
   renderVoteButton: =>
265 275
     className = osu.classWithModifiers('comment-vote', @props.modifiers)
266 276
     className += ' comment-vote--posting' if @state.postingVote
@@ -309,7 +319,7 @@ export class Comment extends React.PureComponent
309 319
 
310 320
 
311 321
   canModerate: =>
312
-    currentUser.is_admin || currentUser.is_gmt || currentUser.is_qat
322
+    currentUser.is_admin || currentUser.is_gmt || currentUser.is_nat
313 323
 
314 324
 
315 325
   canReport: =>

+ 6
- 0
resources/assets/coffee/react/_components/comments-manager.coffee View File

@@ -45,11 +45,13 @@ export class CommentsManager extends React.PureComponent
45 45
         loadingSort: null
46 46
         currentSort: 'new'
47 47
         moreComments: {}
48
+        showDeleted: false
48 49
 
49 50
 
50 51
   componentDidMount: =>
51 52
     $.subscribe "comments:added.#{@id}", @appendBundle
52 53
     $.subscribe "comments:sort.#{@id}", @updateSort
54
+    $.subscribe "comments:toggle-show-deleted.#{@id}", @toggleShowDeleted
53 55
     $.subscribe "comment:updated.#{@id}", @update
54 56
     $.subscribe "commentVote:added.#{@id}", @addVote
55 57
     $.subscribe "commentVote:removed.#{@id}", @removeVote
@@ -128,6 +130,10 @@ export class CommentsManager extends React.PureComponent
128 130
     osu.storeJson @jsonStorageId(), @state
129 131
 
130 132
 
133
+  toggleShowDeleted: =>
134
+    @setState showDeleted: !@state.showDeleted
135
+
136
+
131 137
   updateSort: (_event, {sort}) =>
132 138
     return unless @props.commentableType && @props.commentableId
133 139
 

+ 41
- 15
resources/assets/coffee/react/_components/comments.coffee View File

@@ -20,6 +20,7 @@ import { Comment } from 'comment'
20 20
 import { CommentEditor } from 'comment-editor'
21 21
 import { CommentShowMore } from 'comment-show-more'
22 22
 import { CommentsSort } from 'comments-sort'
23
+import DeletedCommentsCount from 'deleted-comments-count'
23 24
 import * as React from 'react'
24 25
 import { button, div, h2, span } from 'react-dom-factories'
25 26
 
@@ -27,9 +28,8 @@ el = React.createElement
27 28
 
28 29
 export class Comments extends React.PureComponent
29 30
   render: =>
30
-    commentsByParentId = _.groupBy(@props.comments, 'parent_id')
31
-    comments = commentsByParentId[null]
32
-
31
+    @commentsByParentId = _.groupBy(@props.comments, 'parent_id')
32
+    comments = @commentsByParentId[null]
33 33
 
34 34
     div className: osu.classWithModifiers('comments', @props.modifiers),
35 35
       h2 className: 'comments__title',
@@ -42,24 +42,18 @@ export class Comments extends React.PureComponent
42 42
           focus: false
43 43
           modifiers: @props.modifiers
44 44
       div className: 'comments__content',
45
-        div className: 'comments__items',
45
+        div className: 'comments__items comments__items--toolbar',
46 46
           el CommentsSort,
47 47
             loadingSort: @props.loadingSort
48 48
             currentSort: @props.currentSort
49 49
             modifiers: @props.modifiers
50
+          @renderShowDeletedToggle()
50 51
         if comments?
51 52
           div className: "comments__items #{if @props.loadingSort? then 'comments__items--loading' else ''}",
52
-            for comment in comments
53
-              el Comment,
54
-                key: comment.id
55
-                comment: comment
56
-                commentsByParentId: commentsByParentId
57
-                userVotesByCommentId: @props.userVotesByCommentId
58
-                usersById: @props.usersById
59
-                depth: 0
60
-                currentSort: @props.currentSort
61
-                modifiers: @props.modifiers
62
-                moreComments: @props.moreComments
53
+            comments.map @renderComment
54
+
55
+            el DeletedCommentsCount, { comments, showDeleted: @props.showDeleted, modifiers: ['top'] }
56
+
63 57
             el CommentShowMore,
64 58
               commentableType: @props.commentableType
65 59
               commentableId: @props.commentableId
@@ -72,3 +66,35 @@ export class Comments extends React.PureComponent
72 66
           div
73 67
             className: 'comments__items comments__items--empty'
74 68
             osu.trans('comments.empty')
69
+
70
+
71
+  renderComment: (comment) =>
72
+    return null if comment.deleted_at? && !@props.showDeleted
73
+
74
+    el Comment,
75
+      key: comment.id
76
+      comment: comment
77
+      commentsByParentId: @commentsByParentId
78
+      userVotesByCommentId: @props.userVotesByCommentId
79
+      usersById: @props.usersById
80
+      depth: 0
81
+      currentSort: @props.currentSort
82
+      modifiers: @props.modifiers
83
+      moreComments: @props.moreComments
84
+      showDeleted: @props.showDeleted
85
+
86
+
87
+  renderShowDeletedToggle: =>
88
+    div className: osu.classWithModifiers('sort', @props.modifiers),
89
+      div className: 'sort__items',
90
+        button
91
+          type: 'button'
92
+          className: 'sort__item sort__item--button'
93
+          onClick: @toggleShowDeleted
94
+          span className: 'sort__item-icon',
95
+            span className: if @props.showDeleted then 'fas fa-check-square' else 'far fa-square'
96
+          osu.trans('common.buttons.show_deleted')
97
+
98
+
99
+  toggleShowDeleted: ->
100
+    $.publish 'comments:toggle-show-deleted'

+ 1
- 2
resources/assets/coffee/react/_components/friend-button.coffee View File

@@ -155,8 +155,7 @@ export class FriendButton extends React.PureComponent
155 155
               span
156 156
                 key: 'normal-mutual'
157 157
                 className: "#{bn}__icon #{bn}__icon--hover-hidden"
158
-                i className: 'fas fa-user'
159
-                i className: 'fas fa-user'
158
+                i className: 'fas fa-user-friends'
160 159
             else
161 160
               span
162 161
                 key: 'normal'

+ 0
- 37
resources/assets/coffee/react/_components/supporter-icon.coffee View File

@@ -1,37 +0,0 @@
1
-###
2
-#    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
3
-#
4
-#    This file is part of osu!web. osu!web is distributed with the hope of
5
-#    attracting more community contributions to the core ecosystem of osu!.
6
-#
7
-#    osu!web is free software: you can redistribute it and/or modify
8
-#    it under the terms of the Affero GNU General Public License version 3
9
-#    as published by the Free Software Foundation.
10
-#
11
-#    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
12
-#    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13
-#    See the GNU Affero General Public License for more details.
14
-#
15
-#    You should have received a copy of the GNU Affero General Public License
16
-#    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
17
-###
18
-
19
-import * as React from 'react'
20
-import { span, i } from 'react-dom-factories'
21
-el = React.createElement
22
-
23
-bn = 'supporter-icon'
24
-
25
-# see also _supporter_icon.blade.php for blade version
26
-export SupporterIcon = ({background = false, smaller = false}) ->
27
-  blockClass = bn
28
-
29
-  span
30
-    className: "#{bn}#{if smaller then " #{bn}--smaller" else ''} fa-stack"
31
-    title: osu.trans('users.show.is_supporter')
32
-
33
-    if background
34
-      i className: "#{bn}__bg fas fa-circle fa-stack-2x"
35
-
36
-    i className: "far fa-circle fa-stack-2x"
37
-    i className: "#{bn}__heart fas fa-heart fa-stack-1x"

+ 3
- 2
resources/assets/coffee/react/beatmap-discussions/discussion.coffee View File

@@ -170,11 +170,12 @@ export class Discussion extends React.PureComponent
170 170
 
171 171
 
172 172
   canDownvote: =>
173
-    @props.currentUser.is_admin || @props.currentUser.is_gmt || @props.currentUser.is_qat || @props.currentUser.is_bng
173
+    @props.currentUser.is_admin || @props.currentUser.is_gmt || @props.currentUser.is_nat || @props.currentUser.is_bng
174 174
 
175 175
 
176 176
   canBeRepliedTo: =>
177
-    !@props.discussion.beatmap_id? || !@props.currentBeatmap.deleted_at?
177
+    (!@props.beatmapset.discussion_locked || BeatmapDiscussionHelper.canModeratePosts(@props.currentUser)) &&
178
+    (!@props.discussion.beatmap_id? || !@props.currentBeatmap.deleted_at?)
178 179
 
179 180
 
180 181
   post: (post, type) =>

+ 3
- 0
resources/assets/coffee/react/beatmap-discussions/event.coffee View File

@@ -58,6 +58,9 @@ export class Event extends React.PureComponent
58 58
     else
59 59
       text = BeatmapDiscussionHelper.format @props.event.comment, newlines: false
60 60
 
61
+    if @props.event.type == 'discussion_lock'
62
+      text = BeatmapDiscussionHelper.format @props.event.comment.reason, newlines: false
63
+
61 64
     if @props.event.user_id?
62 65
       user = osu.link(laroute.route('users.show', user: @props.event.user_id), @props.users[@props.event.user_id].username)
63 66
 

+ 0
- 1
resources/assets/coffee/react/beatmap-discussions/header.coffee View File

@@ -113,7 +113,6 @@ export class Header extends React.PureComponent
113 113
           div
114 114
             className: "#{bn}__filter-group #{bn}__filter-group--stats"
115 115
             el UserFilter,
116
-              ownerId: @props.beatmapset.user_id
117 116
               selectedUser: if @props.selectedUserId? then @props.users[@props.selectedUserId] else null
118 117
               users: @props.discussionStarters
119 118
 

+ 16
- 12
resources/assets/coffee/react/beatmap-discussions/new-discussion.coffee View File

@@ -82,7 +82,7 @@ export class NewDiscussion extends React.PureComponent
82 82
     canPostNote =
83 83
       @props.currentUser.id == @props.beatmapset.user_id ||
84 84
       @props.currentUser.is_bng ||
85
-      @props.currentUser.is_qat
85
+      @props.currentUser.is_nat
86 86
 
87 87
     buttonCssClasses = 'btn-circle'
88 88
     buttonCssClasses += ' btn-circle--activated' if @props.pinned
@@ -120,12 +120,7 @@ export class NewDiscussion extends React.PureComponent
120 120
                   onKeyDown: @handleKeyDown
121 121
                   onFocus: @onFocus
122 122
                   innerRef: @setInputBox
123
-                  placeholder:
124
-                    if @canPost()
125
-                      osu.trans "beatmaps.discussions.message_placeholder.#{@props.mode}", version: @props.currentBeatmap.version
126
-                    else
127
-                      # FIXME: reason should be passed from beatmap state
128
-                      osu.trans 'beatmaps.discussions.message_placeholder_deleted_beatmap'
123
+                  placeholder: @messagePlaceholder()
129 124
 
130 125
                 el MessageLengthCounter,
131 126
                   key: 'counter'
@@ -215,7 +210,8 @@ export class NewDiscussion extends React.PureComponent
215 210
 
216 211
 
217 212
   canPost: =>
218
-    !@props.currentBeatmap.deleted_at? || @props.mode == 'generalAll'
213
+    (!@props.beatmapset.discussion_locked || BeatmapDiscussionHelper.canModeratePosts(@props.currentUser)) &&
214
+    (!@props.currentBeatmap.deleted_at? || @props.mode == 'generalAll')
219 215
 
220 216
 
221 217
   cssTop: (sticky) =>
@@ -234,6 +230,16 @@ export class NewDiscussion extends React.PureComponent
234 230
     @props.mode == 'timeline'
235 231
 
236 232
 
233
+  messagePlaceholder: =>
234
+    if @canPost()
235
+      osu.trans "beatmaps.discussions.message_placeholder.#{@props.mode}", version: @props.currentBeatmap.version
236
+    else
237
+      if @props.beatmapset.discussion_locked
238
+        osu.trans 'beatmaps.discussions.message_placeholder_locked'
239
+      else
240
+        osu.trans 'beatmaps.discussions.message_placeholder_deleted_beatmap'
241
+
242
+
237 243
   nearbyDiscussions: =>
238 244
     return [] if !@state.timestamp?
239 245
 
@@ -287,8 +293,6 @@ export class NewDiscussion extends React.PureComponent
287 293
 
288 294
     type = e.currentTarget.dataset.type
289 295
 
290
-    userCanResetNominations = currentUser.is_admin || currentUser.is_qat || currentUser.is_bng
291
-
292 296
     if type == 'problem'
293 297
       problemType = @problemType()
294 298
 
@@ -331,12 +335,12 @@ export class NewDiscussion extends React.PureComponent
331 335
 
332 336
 
333 337
   problemType: =>
334
-    canDisqualify = currentUser.is_admin || currentUser.is_qat
338
+    canDisqualify = currentUser.is_admin || currentUser.is_nat || currentUser.is_full_bn || currentUser.is_gmt
335 339
     willDisqualify = @props.beatmapset.status == 'qualified'
336 340
 
337 341
     return 'disqualify' if canDisqualify && willDisqualify
338 342
 
339
-    canReset = currentUser.is_admin || currentUser.is_qat || currentUser.is_bng
343
+    canReset = currentUser.is_admin || currentUser.is_nat || currentUser.is_bng
340 344
     willReset = @props.beatmapset.status == 'pending' && @props.beatmapset.nominations.current > 0
341 345
 
342 346
     return 'nomination_reset' if canReset && willReset

+ 90
- 10
resources/assets/coffee/react/beatmap-discussions/nominations.coffee View File

@@ -24,12 +24,18 @@ el = React.createElement
24 24
 bn = 'beatmap-discussion-nomination'
25 25
 
26 26
 export class Nominations extends React.PureComponent
27
+  constructor: (props) ->
28
+    super props
29
+
30
+    @xhr = {}
31
+
32
+
27 33
   componentDidMount: =>
28 34
     osu.pageChange()
29 35
 
30 36
 
31 37
   componentWillUnmount: =>
32
-    @xhr?.abort()
38
+    xhr?.abort() for _name, xhr of @xhr
33 39
     Timeout.clear @hypeFocusTimeout if @hypeFocusTimeout
34 40
 
35 41
 
@@ -93,7 +99,7 @@ export class Nominations extends React.PureComponent
93 99
                       osu.trans 'beatmaps.discussions.status-messages.graveyard',
94 100
                         date: moment(@props.beatmapset.last_updated).format(dateFormat)
95 101
 
96
-          if currentUser.id?
102
+          if currentUser.id? && !@props.beatmapset.discussion_locked
97 103
             div className: "#{bn}__row-right",
98 104
               el BigButton,
99 105
                 modifiers: ['full', 'wrap-text']
@@ -195,6 +201,8 @@ export class Nominations extends React.PureComponent
195 201
               props:
196 202
                 onClick: @delete
197 203
 
204
+      @renderLockArea()
205
+
198 206
       if showHype
199 207
         div
200 208
           className: "#{bn}__footer #{if mapCanBeNominated then "#{bn}__footer--extended" else ''}",
@@ -233,6 +241,41 @@ export class Nominations extends React.PureComponent
233 241
                           'data-user-id': user.id
234 242
 
235 243
 
244
+  renderLockArea: =>
245
+    canModeratePost = BeatmapDiscussionHelper.canModeratePosts(currentUser)
246
+
247
+    return null if !@props.beatmapset.discussion_locked && !canModeratePost
248
+
249
+    if @props.beatmapset.discussion_locked
250
+      lockEvent = _.findLast @props.events, type: 'discussion_lock'
251
+
252
+    div className: "#{bn}__row #{bn}__row--status-message",
253
+      div className: "#{bn}__row-left",
254
+        if lockEvent?
255
+          div className: "#{bn}__header",
256
+            span
257
+              className: "#{bn}__status-message"
258
+              dangerouslySetInnerHTML: __html: osu.trans 'beatmapset_events.event.discussion_lock',
259
+                text: BeatmapDiscussionHelper.format(lockEvent.comment.reason, newlines: false)
260
+
261
+      if canModeratePost
262
+        if @props.beatmapset.discussion_locked
263
+          action = 'unlock'
264
+          icon = 'fas fa-unlock'
265
+          onClick = @discussionUnlock
266
+        else
267
+          action = 'lock'
268
+          icon = 'fas fa-lock'
269
+          onClick = @discussionLock
270
+
271
+        div className: "#{bn}__row-right",
272
+          el BigButton,
273
+            modifiers: ['full', 'wrap-text']
274
+            text: osu.trans "beatmaps.discussions.lock.button.#{action}"
275
+            icon: icon
276
+            props: { onClick }
277
+
278
+
236 279
   renderLights: (lightsOn, lightsTotal) ->
237 280
     lightsOff = lightsTotal - lightsOn
238 281
 
@@ -258,29 +301,66 @@ export class Nominations extends React.PureComponent
258 301
 
259 302
     LoadingOverlay.show()
260 303
 
261
-    @xhr?.abort()
304
+    @xhr.delete?.abort()
262 305
 
263 306
     user = @props.beatmapset.user_id
264 307
     url = laroute.route('beatmapsets.destroy', beatmapset: @props.beatmapset.id)
265 308
     params = method: 'DELETE'
266 309
 
267
-    @xhr = $.ajax(url, params)
310
+    @xhr.delete = $.ajax(url, params)
268 311
       .done ->
269 312
         Turbolinks.visit laroute.route('users.show', { user })
270 313
       .fail osu.ajaxError
271 314
       .always LoadingOverlay.hide
272 315
 
316
+
317
+  discussionLock: =>
318
+    reason = osu.presence(prompt(osu.trans('beatmaps.discussions.lock.prompt.lock')))
319
+
320
+    return unless reason?
321
+
322
+    @xhr.discussionLock?.abort()
323
+
324
+    url = laroute.route('beatmapsets.discussion-lock', beatmapset: @props.beatmapset.id)
325
+    params =
326
+      method: 'POST'
327
+      data: { reason }
328
+
329
+    @xhr.discussionLock = $.ajax(url, params)
330
+      .done (response) =>
331
+        $.publish 'beatmapsetDiscussions:update', beatmapset: response
332
+      .fail osu.ajaxError
333
+      .always LoadingOverlay.hide
334
+
335
+
336
+  discussionUnlock: =>
337
+    return unless confirm(osu.trans('beatmaps.discussions.lock.prompt.unlock'))
338
+
339
+    LoadingOverlay.show()
340
+
341
+    @xhr.discussionLock?.abort()
342
+
343
+    url = laroute.route('beatmapsets.discussion-unlock', beatmapset: @props.beatmapset.id)
344
+    params = method: 'POST'
345
+
346
+    @xhr.discussionLock = $.ajax(url, params)
347
+      .done (response) =>
348
+        $.publish 'beatmapsetDiscussions:update', beatmapset: response
349
+      .fail osu.ajaxError
350
+      .always LoadingOverlay.hide
351
+
352
+
273 353
   love: =>
274 354
     return unless confirm(osu.trans('beatmaps.nominations.love_confirm'))
275 355
 
276 356
     LoadingOverlay.show()
277 357
 
278
-    @xhr?.abort()
358
+    @xhr.love?.abort()
279 359
 
280 360
     url = laroute.route('beatmapsets.love', beatmapset: @props.beatmapset.id)
281 361
     params = method: 'PUT'
282 362
 
283
-    @xhr = $.ajax(url, params)
363
+    @xhr.love = $.ajax(url, params)
284 364
       .done (response) =>
285 365
         $.publish 'beatmapsetDiscussions:update', beatmapset: response
286 366
       .fail osu.ajaxError
@@ -292,12 +372,12 @@ export class Nominations extends React.PureComponent
292 372
 
293 373
     LoadingOverlay.show()
294 374
 
295
-    @xhr?.abort()
375
+    @xhr.nominate?.abort()
296 376
 
297 377
     url = laroute.route('beatmapsets.nominate', beatmapset: @props.beatmapset.id)
298 378
     params = method: 'PUT'
299 379
 
300
-    @xhr = $.ajax(url, params)
380
+    @xhr.nominate = $.ajax(url, params)
301 381
       .done (response) =>
302 382
         $.publish 'beatmapsetDiscussions:update', beatmapset: response
303 383
       .fail osu.ajaxError
@@ -381,11 +461,11 @@ export class Nominations extends React.PureComponent
381 461
 
382 462
 
383 463
   userCanNominate: =>
384
-    !@userIsOwner() && (@props.currentUser.is_admin || @props.currentUser.is_bng || @props.currentUser.is_qat)
464
+    !@userIsOwner() && (@props.currentUser.is_admin || @props.currentUser.is_bng || @props.currentUser.is_nat)
385 465
 
386 466
 
387 467
   userCanDisqualify: =>
388
-    !@userIsOwner() && (@props.currentUser.is_admin || @props.currentUser.is_qat)
468
+    !@userIsOwner() && (@props.currentUser.is_admin || @props.currentUser.is_nat || @props.currentUser.is_full_bn || @props.currentUser.is_gmt)
389 469
 
390 470
 
391 471
   userIsOwner: =>

+ 2
- 0
resources/assets/coffee/react/beatmap-discussions/post.coffee View File

@@ -70,6 +70,8 @@ export class Post extends React.PureComponent
70 70
     userBadge =
71 71
       if @isOwner()
72 72
         'owner'
73
+      else if @userModerationGroup() == 'bng_limited'
74
+        'bng'
73 75
       else
74 76
         @userModerationGroup()
75 77
 

+ 0
- 15
resources/assets/coffee/react/beatmap-discussions/user-filter.coffee View File

@@ -46,8 +46,6 @@ export class UserFilter extends React.PureComponent
46 46
 
47 47
   mapUserProperties: (user) ->
48 48
     id: user.id
49
-    colour: user.profile_colour
50
-    groups: user.groups
51 49
     text: user.username
52 50
 
53 51
 
@@ -60,18 +58,5 @@ export class UserFilter extends React.PureComponent
60 58
       children
61 59
 
62 60
 
63
-  isOwner: (user) =>
64
-    user? && user.id == @props.ownerId
65
-
66
-
67
-  userGroup: (user) =>
68
-    return unless user?
69
-
70
-    if @isOwner(user)
71
-      'owner'
72
-    else
73
-      BeatmapDiscussionHelper.moderationGroup(user)
74
-
75
-
76 61
   onItemSelected: (item) ->
77 62
     $.publish 'beatmapsetDiscussions:update', selectedUserId: item.id

+ 2
- 1
resources/assets/coffee/react/contest-voting/art-entry-list.coffee View File

@@ -16,10 +16,11 @@
16 16
 #    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
17 17
 ###
18 18
 
19
+import { ArtEntry } from './art-entry'
19 20
 import { BaseEntryList } from './base-entry-list'
20 21
 import { VoteSummary } from './vote-summary'
21 22
 import * as React from 'react'
22
-import { div,span } from 'react-dom-factories'
23
+import { div, span } from 'react-dom-factories'
23 24
 el = React.createElement
24 25
 
25 26
 export class ArtEntryList extends BaseEntryList

+ 1
- 1
resources/assets/coffee/react/mp-history/event.coffee View File

@@ -41,7 +41,7 @@ export class Event extends React.Component
41 41
 
42 42
     div className: 'mp-history-event',
43 43
       div className: 'mp-history-event__time',
44
-        moment(@props.event.timestamp).format 'LT'
44
+        moment(@props.event.timestamp).format 'LTS'
45 45
       div className: "mp-history-event__type mp-history-event__type--#{event_type}",
46 46
         @icons[event_type].map (m) ->
47 47
           i key: m, className: m

+ 1
- 1
resources/assets/coffee/react/mp-history/game-header.coffee View File

@@ -22,7 +22,7 @@ import { div, a, span, h1, h2 } from 'react-dom-factories'
22 22
 el = React.createElement
23 23
 
24 24
 export class GameHeader extends React.Component
25
-  timeFormat: 'LT'
25
+  timeFormat: 'LTS'
26 26
 
27 27
   render: ->
28 28
     timeStart = moment(@props.game.start_time).format @timeFormat

+ 1
- 1
resources/assets/coffee/react/mp-history/score.coffee View File

@@ -49,7 +49,7 @@ export class Score extends React.Component
49 49
           a
50 50
             href: laroute.route 'rankings',
51 51
               mode: @props.mode
52
-              country: user.country.code
52
+              country: user.country?.code
53 53
               type: 'performance'
54 54
             el FlagCountry, country: user.country
55 55
 

+ 6
- 4
resources/assets/less/bem-index.less View File

@@ -87,6 +87,8 @@
87 87
 @import "bem/beatmapsets-search-filter";
88 88
 @import "bem/beatmapsets-show-more";
89 89
 @import "bem/blackout";
90
+@import "bem/block-list";
91
+@import "bem/block-list-item";
90 92
 @import "bem/btn-circle";
91 93
 @import "bem/btn-home";
92 94
 @import "bem/btn-osu-big";
@@ -120,6 +122,7 @@
120 122
 @import "bem/contest-voting-list";
121 123
 @import "bem/countdown-timer";
122 124
 @import "bem/counter-box";
125
+@import "bem/deleted-comments-count";
123 126
 @import "bem/download-page";
124 127
 @import "bem/download-page-header";
125 128
 @import "bem/download-page-video";
@@ -256,6 +259,7 @@
256 259
 @import "bem/profile-stats";
257 260
 @import "bem/profile-tournament-banner";
258 261
 @import "bem/proportional-container";
262
+@import "bem/qtip";
259 263
 @import "bem/quick-info";
260 264
 @import "bem/ranking-page";
261 265
 @import "bem/ranking-page-header";
@@ -321,11 +325,11 @@
321 325
 @import "bem/turbolinks-progress-bar";
322 326
 @import "bem/update-streams-v2";
323 327
 @import "bem/user-action-button";
324
-@import "bem/user-friends";
328
+@import "bem/user-card";
329
+@import "bem/user-cards";
325 330
 @import "bem/user-home";
326 331
 @import "bem/user-home-beatmapset";
327 332
 @import "bem/user-list";
328
-@import "bem/user-list-item";
329 333
 @import "bem/user-name";
330 334
 @import "bem/user-profile-header";
331 335
 @import "bem/user-quick";
@@ -334,8 +338,6 @@
334 338
 @import "bem/user-session-list-session";
335 339
 @import "bem/user-verification";
336 340
 @import "bem/user-verification-popup";
337
-@import "bem/usercard";
338
-@import "bem/usercard-list";
339 341
 @import "bem/userinfo-small";
340 342
 @import "bem/value-display";
341 343
 @import "bem/warning-box";

+ 3
- 3
resources/assets/less/bem/beatmap-discussion-new-float.less View File

@@ -18,12 +18,12 @@
18 18
 
19 19
 .beatmap-discussion-new-float {
20 20
   .bg() {
21
-    .at2x('/images/backgrounds/page-ddd.png');
21
+    .at2x('/images/backgrounds/page-222.png');
22 22
 
23 23
     &::before {
24 24
       .full-size();
25
-      content: ' ';
26
-      background-color: fade(#000, 75%);
25
+      content: '';
26
+      background-color: fade(#000, 40%);
27 27
     }
28 28
   }
29 29
 

+ 3
- 3
resources/assets/less/bem/beatmap-discussion-post.less View File

@@ -281,7 +281,7 @@
281 281
       background-color: @beatmap-discussion--user-color-owner;
282 282
     }
283 283
 
284
-    .@{_top}--qat & {
284
+    .@{_top}--nat & {
285 285
       background-color: @beatmap-discussion--user-color-moderator;
286 286
     }
287 287
   }
@@ -339,7 +339,7 @@
339 339
       background-color: @beatmap-discussion--user-color-owner;
340 340
     }
341 341
 
342
-    .@{_top}--qat & {
342
+    .@{_top}--nat & {
343 343
       background-color: @beatmap-discussion--user-color-moderator;
344 344
     }
345 345
   }
@@ -362,7 +362,7 @@
362 362
       color: @beatmap-discussion--user-color-owner;
363 363
     }
364 364
 
365
-    .@{_top}--qat & {
365
+    .@{_top}--nat & {
366 366
       color: @beatmap-discussion--user-color-moderator;
367 367
     }
368 368
   }

+ 1
- 1
resources/assets/less/bem/beatmap-discussions-user-filter.less View File

@@ -43,7 +43,7 @@
43 43
       color: @beatmap-discussion--user-color-owner;
44 44
     }
45 45
 
46
-    &--qat {
46
+    &--nat {
47 47
       .link-hover({ color: @beatmap-discussion--user-color-moderator });
48 48
       color: @beatmap-discussion--user-color-moderator;
49 49
     }

+ 2
- 0
resources/assets/less/bem/beatmapset-event.less View File

@@ -41,9 +41,11 @@
41 41
 
42 42
     .type(approve, @blue);
43 43
     .type(discussion-delete, @yellow);
44
+    .type(discussion-lock, @red);
44 45
     .type(discussion-post-delete, @yellow);
45 46
     .type(discussion-post-restore, @blue);
46 47
     .type(discussion-restore, @blue);
48
+    .type(discussion-unlock, @green);
47 49
     .type(disqualify, @red);
48 50
     .type(issue-reopen, @yellow);
49 51
     .type(issue-resolve, @green);

+ 1
- 1
resources/assets/less/bem/beatmapset-panel.less View File

@@ -205,7 +205,7 @@
205 205
     width: calc(100% - (@padding * 2));
206 206
   }
207 207
 
208
-  &__video-icon {
208
+  &__extra-icon {
209 209
     font-size: 14px;
210 210
     line-height: 28px;
211 211
     background: fade(black, 50%);

resources/assets/less/bem/user-list-item.less → resources/assets/less/bem/block-list-item.less View File

@@ -16,8 +16,8 @@
16 16
  *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
17 17
  */
18 18
 
19
-.user-list-item {
20
-  @top: user-list-item;
19
+.block-list-item {
20
+  @top: block-list-item;
21 21
 
22 22
   display: flex;
23 23
   .default-border-radius();

resources/assets/less/bem/usercard-list.less → resources/assets/less/bem/block-list.less View File

@@ -16,12 +16,11 @@
16 16
  *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
17 17
  */
18 18
 
19
-.usercard-list {
20
-  max-width: @screen-sm-min;
21
-  margin: 0 auto;
19
+.block-list {
20
+  align-self: flex-start;
21
+  flex-grow: 1;
22 22
 
23
-  &__cards {
24
-    display: flex;
25
-    flex-wrap: wrap;
23
+  &__content {
24
+    max-width: 250px;
26 25
   }
27 26
 }

+ 2
- 2
resources/assets/less/bem/chat-conversation.less View File

@@ -90,7 +90,7 @@
90 90
     text-align: center;
91 91
     height: 1.5em;
92 92
 
93
-    &:before {
93
+    &::before {
94 94
       content: '';
95 95
       background: @yellow;
96 96
       position: absolute;
@@ -100,7 +100,7 @@
100 100
       height: 1px;
101 101
     }
102 102
 
103
-    &:after {
103
+    &::after {
104 104
       content: attr(data-content);
105 105
       position: relative;
106 106
       display: inline-block;

+ 6
- 0
resources/assets/less/bem/comments.less View File

@@ -63,6 +63,12 @@
63 63
     &--loading {
64 64
       opacity: 0.5;
65 65
     }
66
+
67
+    &--toolbar {
68
+      .default-gutter();
69
+      display: flex;
70
+      justify-content: space-between;
71
+    }
66 72
   }
67 73
 
68 74
   &__new {

+ 31
- 0
resources/assets/less/bem/deleted-comments-count.less View File

@@ -0,0 +1,31 @@
1
+/**
2
+ *    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
3
+ *
4
+ *    This file is part of osu!web. osu!web is distributed with the hope of
5
+ *    attracting more community contributions to the core ecosystem of osu!.
6
+ *
7
+ *    osu!web is free software: you can redistribute it and/or modify
8
+ *    it under the terms of the Affero GNU General Public License version 3
9
+ *    as published by the Free Software Foundation.
10
+ *
11
+ *    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
12
+ *    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13
+ *    See the GNU Affero General Public License for more details.
14
+ *
15
+ *    You should have received a copy of the GNU Affero General Public License
16
+ *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
17
+ */
18
+
19
+.deleted-comments-count {
20
+  font-size: @font-size--title-small;
21
+  padding-top: 10px;
22
+
23
+  &--top {
24
+    .default-gutter();
25
+    padding-bottom: 10px;
26
+  }
27
+
28
+  &__icon {
29
+    margin-right: 5px;
30
+  }
31
+}

+ 15
- 0
resources/assets/less/bem/notification-popup-item.less View File

@@ -17,6 +17,8 @@
17 17
  */
18 18
 
19 19
 .notification-popup-item {
20
+  @_top: notification-popup-item;
21
+
20 22
   display: flex;
21 23
   width: 100%;
22 24
 
@@ -56,6 +58,13 @@
56 58
     padding: 5px 0 5px 10px;
57 59
     display: flex;
58 60
     min-width: 0;
61
+
62
+    .@{_top}:hover &::before {
63
+      .full-size();
64
+      content: '';
65
+      background-color: fade(#fff, 10%);
66
+      border-radius: 0 @border-radius-large @border-radius-large 0;
67
+    }
59 68
   }
60 69
 
61 70
   &__message {
@@ -74,6 +83,12 @@
74 83
 
75 84
   &__read-button {
76 85
     .reset-input();
86
+    color: @greysky-light;
87
+    padding: 2px;
88
+
89
+    &:hover {
90
+      color: #fff;
91
+    }
77 92
   }
78 93
 
79 94
   &__side-buttons {

+ 2
- 2
resources/assets/less/bem/osu-layout.less View File

@@ -42,7 +42,7 @@
42 42
     // https://fourword.fourkitchens.com/article/fix-scrolling-performance-css-will-change-property
43 43
     // using backface-visibility since IE doesn't have will-change property yet.
44 44
     &::before {
45
-      .at2x("/images/backgrounds/page-light.png", 500px, 500px);
45
+      .at2x("/images/backgrounds/page-222.png", 500px, 500px);
46 46
       position: fixed;
47 47
       backface-visibility: hidden;
48 48
       width: 100%;
@@ -295,7 +295,7 @@
295 295
     &--extra {
296 296
       .inner-shadow-top();
297 297
       flex: 1 0 auto;
298
-      background-color: fade(#000, 75%);
298
+      background-color: fade(#000, 40%);
299 299
       padding: 10px 0;
300 300
     }
301 301
 

+ 1
- 1
resources/assets/less/bem/page-extra-tabs-before.less View File

@@ -19,5 +19,5 @@
19 19
 .page-extra-tabs-before {
20 20
   height: 15px;
21 21
   width: 100%;
22
-  background-color: #333;
22
+  background-color: #111;
23 23
 }

+ 1
- 1
resources/assets/less/bem/page-extra-tabs.less View File

@@ -21,7 +21,7 @@
21 21
   top: @nav2-height--pinned;
22 22
   z-index: 1;
23 23
 
24
-  background-color: #333;
24
+  background-color: #111;
25 25
 
26 26
   border-bottom: 1px solid @yellow;
27 27
 

+ 24
- 0
resources/assets/less/bem/qtip.less View File

@@ -0,0 +1,24 @@
1
+/**
2
+ *    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
3
+ *
4
+ *    This file is part of osu!web. osu!web is distributed with the hope of
5
+ *    attracting more community contributions to the core ecosystem of osu!.
6
+ *
7
+ *    osu!web is free software: you can redistribute it and/or modify
8
+ *    it under the terms of the Affero GNU General Public License version 3
9
+ *    as published by the Free Software Foundation.
10
+ *
11
+ *    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
12
+ *    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13
+ *    See the GNU Affero General Public License for more details.
14
+ *
15
+ *    You should have received a copy of the GNU Affero General Public License
16
+ *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
17
+ */
18
+
19
+.qtip--user-card {
20
+  max-width: unset;
21
+  min-width: unset;
22
+  font-size: unset;
23
+  line-height: unset;
24
+}

+ 5
- 0
resources/assets/less/bem/search-result.less View File

@@ -55,6 +55,11 @@
55 55
     flex-wrap: wrap;
56 56
     margin: -5px;
57 57
     min-width: 0;
58
+
59
+    .@{top}--user & {
60
+      display: block;
61
+      margin: 0;
62
+    }
58 63
   }
59 64
 
60 65
   &__more-button {

+ 1
- 1
resources/assets/less/bem/search.less View File

@@ -19,7 +19,7 @@
19 19
 .search {
20 20
   .inner-shadow-top();
21 21
   .default-box-shadow();
22
-  padding: 0 40px 20px;
22
+  padding: 0 0 20px;
23 23
   background-color: #222;
24 24
   color: #fff;
25 25
   margin-bottom: 10px;