Browse Source

Add notification system

pull/4287/head
nanaya 4 months ago
parent
commit
b07810e275
65 changed files with 2562 additions and 127 deletions
  1. 4
    0
      .env.example
  2. 4
    0
      .travis.yml
  3. 62
    0
      app/Events/NewNotificationEvent.php
  4. 64
    0
      app/Events/NotificationReadEvent.php
  5. 67
    0
      app/Events/UserSubscriptionChangeEvent.php
  6. 7
    0
      app/Http/Controllers/BeatmapDiscussionPostsController.php
  7. 5
    0
      app/Http/Controllers/BeatmapsetWatchesController.php
  8. 88
    0
      app/Http/Controllers/NotificationsController.php
  9. 62
    0
      app/Jobs/MarkNotificationsRead/Beatmapset.php
  10. 68
    0
      app/Jobs/MarkNotificationsRead/ForumTopic.php
  11. 196
    0
      app/Jobs/Notify.php
  12. 2
    1
      app/Jobs/RemoveBeatmapsetBestScores.php
  13. 2
    0
      app/Libraries/ForumUpdateNotifier.php
  14. 2
    0
      app/Libraries/MorphMap.php
  15. 5
    0
      app/Models/Beatmapset.php
  16. 3
    0
      app/Models/BeatmapsetWatch.php
  17. 3
    0
      app/Models/Forum/Post.php
  18. 6
    0
      app/Models/Forum/Topic.php
  19. 5
    0
      app/Models/Forum/TopicWatch.php
  20. 63
    0
      app/Models/Notification.php
  21. 6
    1
      app/Models/User.php
  22. 38
    0
      app/Models/UserNotification.php
  23. 50
    0
      app/Transformers/NotificationTransformer.php
  24. 8
    0
      app/Transformers/UserTransformer.php
  25. 18
    1
      bin/run_dusk.sh
  26. 2
    1
      config/app.php
  27. 2
    2
      config/broadcasting.php
  28. 7
    0
      config/database.php
  29. 7
    1
      database/factories/BeatmapMirrorFactory.php
  30. 42
    0
      database/migrations/2019_01_17_065444_add_notifications.php
  31. 36
    0
      database/migrations/2019_01_23_031727_add_user_notifications.php
  32. 10
    0
      resources/assets/coffee/main.coffee
  33. 5
    5
      resources/assets/coffee/react/_components/show-more-link.coffee
  34. 6
    0
      resources/assets/less/bem-index.less
  35. 65
    0
      resources/assets/less/bem/nav-button.less
  36. 30
    0
      resources/assets/less/bem/nav-click-popup.less
  37. 1
    1
      resources/assets/less/bem/nav2-header.less
  38. 0
    53
      resources/assets/less/bem/nav2.less
  39. 1
    1
      resources/assets/less/bem/navbar-mobile.less
  40. 44
    0
      resources/assets/less/bem/notification-category-group.less
  41. 6
    10
      resources/assets/less/bem/notification-icon.less
  42. 90
    0
      resources/assets/less/bem/notification-popup-item.less
  43. 58
    0
      resources/assets/less/bem/notification-popup.less
  44. 58
    0
      resources/assets/less/bem/notification-type-group.less
  45. 18
    10
      resources/assets/less/bem/show-more-link.less
  46. 6
    0
      resources/assets/less/colors.less
  47. 1
    0
      resources/assets/less/variables.less
  48. 7
    3
      resources/assets/lib/globals.d.ts
  49. 5
    1
      resources/assets/lib/import-shims.coffee
  50. 28
    0
      resources/assets/lib/interfaces/notification-json.ts
  51. 21
    0
      resources/assets/lib/interfaces/xhr-collection.ts
  52. 78
    0
      resources/assets/lib/models/notification.ts
  53. 94
    0
      resources/assets/lib/notification-widget/category-group.tsx
  54. 250
    0
      resources/assets/lib/notification-widget/item.tsx
  55. 143
    0
      resources/assets/lib/notification-widget/main.tsx
  56. 130
    0
      resources/assets/lib/notification-widget/type-group.tsx
  57. 281
    0
      resources/assets/lib/notification-widget/worker.ts
  58. 63
    0
      resources/lang/en/notifications.php
  59. 1
    7
      resources/views/layout/_header_mobile.blade.php
  60. 8
    26
      resources/views/layout/_nav2.blade.php
  61. 2
    0
      routes/web.php
  62. 9
    0
      tests/Controllers/BeatmapDiscussionPostsControllerTest.php
  63. 105
    0
      tests/Models/BeatmapsetTest.php
  64. 1
    0
      webpack.mix.js
  65. 3
    3
      yarn.lock

+ 4
- 0
.env.example View File

@@ -13,6 +13,8 @@ DB_USERNAME=osuweb
13 13
 # DB_PASSWORD=
14 14
 
15 15
 # REDIS_HOST=127.0.0.1
16
+# REDIS_HOST_BROADCAST=127.0.0.1
17
+# REDIS_PORT_BROADCAST=6379
16 18
 
17 19
 # MEMCACHED_PERSISTENT_ID=
18 20
 # MEMCACHED_USERNAME=
@@ -188,3 +190,5 @@ SITE_SWITCHER_JS_HASH=
188 190
 
189 191
 # USER_MAX_MULTIPLAYER_ROOMS=1
190 192
 # USER_MAX_MULTIPLAYER_ROOMS_SUPPORTER=5
193
+
194
+# WEBSOCKET_URL=/home/notifications/streaming

+ 4
- 0
.travis.yml View File

@@ -28,6 +28,7 @@ env:
28 28
     - APP_ENV=testing
29 29
     - APP_KEY=base64:q7U5qyAkedR1F6UhN0SQlUxBpAMDyfHy3NNFkqmiMqA=
30 30
     - APP_URL=http://127.0.0.1:8000
31
+    - BROADCAST_DRIVER=log
31 32
     - CACHE_DRIVER=redis
32 33
     - DB_USERNAME=root
33 34
     - ES_VERSION=6.6.0 ES_DOWNLOAD_URL=https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-${ES_VERSION}.zip
@@ -38,6 +39,8 @@ env:
38 39
     - SHOPIFY_DOMAIN=notarealdomainortld
39 40
     - SHOPIFY_STOREFRONT_TOKEN=notreal
40 41
     - SLACK_ENDPOINT=https://myconan.net/null/
42
+    - WEBSOCKET_PORT=3000
43
+    - WEBSOCKET_URL=ws://127.0.0.1:3000
41 44
 
42 45
 install:
43 46
   # elasticsearch setup (part 1)
@@ -65,3 +68,4 @@ jobs:
65 68
 
66 69
 after_script:
67 70
   - cat elasticsearch.log
71
+  - test -f osu-notification-server/server.log && cat osu-notification-server/server.log

+ 62
- 0
app/Events/NewNotificationEvent.php View File

@@ -0,0 +1,62 @@
1
+<?php
2
+
3
+/**
4
+ *    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
5
+ *
6
+ *    This file is part of osu!web. osu!web is distributed with the hope of
7
+ *    attracting more community contributions to the core ecosystem of osu!.
8
+ *
9
+ *    osu!web is free software: you can redistribute it and/or modify
10
+ *    it under the terms of the Affero GNU General Public License version 3
11
+ *    as published by the Free Software Foundation.
12
+ *
13
+ *    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
14
+ *    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
15
+ *    See the GNU Affero General Public License for more details.
16
+ *
17
+ *    You should have received a copy of the GNU Affero General Public License
18
+ *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
19
+ */
20
+
21
+namespace App\Events;
22
+
23
+use Illuminate\Broadcasting\Channel;
24
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
25
+use Illuminate\Queue\SerializesModels;
26
+
27
+class NewNotificationEvent implements ShouldBroadcast
28
+{
29
+    use SerializesModels;
30
+
31
+    public $notification;
32
+
33
+    /**
34
+     * Create a new event instance.
35
+     *
36
+     * @return void
37
+     */
38
+    public function __construct($notification)
39
+    {
40
+        $this->notification = $notification;
41
+    }
42
+
43
+    public function broadcastAs()
44
+    {
45
+        return 'new';
46
+    }
47
+
48
+    /**
49
+     * Get the channels the event should broadcast on.
50
+     *
51
+     * @return Channel|array
52
+     */
53
+    public function broadcastOn()
54
+    {
55
+        return new Channel($this->notification->channelName());
56
+    }
57
+
58
+    public function broadcastWith()
59
+    {
60
+        return json_item($this->notification, 'Notification');
61
+    }
62
+}

+ 64
- 0
app/Events/NotificationReadEvent.php View File

@@ -0,0 +1,64 @@
1
+<?php
2
+
3
+/**
4
+ *    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
5
+ *
6
+ *    This file is part of osu!web. osu!web is distributed with the hope of
7
+ *    attracting more community contributions to the core ecosystem of osu!.
8
+ *
9
+ *    osu!web is free software: you can redistribute it and/or modify
10
+ *    it under the terms of the Affero GNU General Public License version 3
11
+ *    as published by the Free Software Foundation.
12
+ *
13
+ *    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
14
+ *    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
15
+ *    See the GNU Affero General Public License for more details.
16
+ *
17
+ *    You should have received a copy of the GNU Affero General Public License
18
+ *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
19
+ */
20
+
21
+namespace App\Events;
22
+
23
+use Illuminate\Broadcasting\Channel;
24
+use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
25
+use Illuminate\Queue\SerializesModels;
26
+
27
+class NotificationReadEvent implements ShouldBroadcastNow
28
+{
29
+    use SerializesModels;
30
+
31
+    public $notificationIds;
32
+    public $userId;
33
+
34
+    /**
35
+     * Create a new event instance.
36
+     *
37
+     * @return void
38
+     */
39
+    public function __construct($userId, $notificationIds)
40
+    {
41
+        $this->notificationIds = $notificationIds;
42
+        $this->userId = $userId;
43
+    }
44
+
45
+    public function broadcastAs()
46
+    {
47
+        return 'read';
48
+    }
49
+
50
+    /**
51
+     * Get the channels the event should broadcast on.
52
+     *
53
+     * @return Channel|array
54
+     */
55
+    public function broadcastOn()
56
+    {
57
+        return new Channel("notification_read:{$this->userId}");
58
+    }
59
+
60
+    public function broadcastWith()
61
+    {
62
+        return ['ids' => $this->notificationIds];
63
+    }
64
+}

+ 67
- 0
app/Events/UserSubscriptionChangeEvent.php View File

@@ -0,0 +1,67 @@
1
+<?php
2
+
3
+/**
4
+ *    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
5
+ *
6
+ *    This file is part of osu!web. osu!web is distributed with the hope of
7
+ *    attracting more community contributions to the core ecosystem of osu!.
8
+ *
9
+ *    osu!web is free software: you can redistribute it and/or modify
10
+ *    it under the terms of the Affero GNU General Public License version 3
11
+ *    as published by the Free Software Foundation.
12
+ *
13
+ *    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
14
+ *    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
15
+ *    See the GNU Affero General Public License for more details.
16
+ *
17
+ *    You should have received a copy of the GNU Affero General Public License
18
+ *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
19
+ */
20
+
21
+namespace App\Events;
22
+
23
+use App\Models\Notification;
24
+use Illuminate\Broadcasting\Channel;
25
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
26
+
27
+class UserSubscriptionChangeEvent implements ShouldBroadcast
28
+{
29
+    public $action;
30
+    public $userId;
31
+    public $channelName;
32
+
33
+    /**
34
+     * Create a new event instance.
35
+     *
36
+     * @return void
37
+     */
38
+    public function __construct($action, $user, $notifiable)
39
+    {
40
+        $this->action = $action;
41
+        $this->userId = $user->getKey();
42
+        $this->channelName = Notification::generateChannelName($notifiable);
43
+    }
44
+
45
+    public function broadcastAs()
46
+    {
47
+        return 'change';
48
+    }
49
+
50
+    /**
51
+     * Get the channels the event should broadcast on.
52
+     *
53
+     * @return Channel|array
54
+     */
55
+    public function broadcastOn()
56
+    {
57
+        return new Channel("user_subscription:{$this->userId}");
58
+    }
59
+
60
+    public function broadcastWith()
61
+    {
62
+        return [
63
+            'action' => $this->action,
64
+            'channel' => $this->channelName,
65
+        ];
66
+    }
67
+}

+ 7
- 0
app/Http/Controllers/BeatmapDiscussionPostsController.php View File

@@ -21,6 +21,7 @@
21 21
 namespace App\Http\Controllers;
22 22
 
23 23
 use App\Exceptions\ModelNotSavedException;
24
+use App\Jobs\Notify;
24 25
 use App\Jobs\NotifyBeatmapsetUpdate;
25 26
 use App\Models\BeatmapDiscussion;
26 27
 use App\Models\BeatmapDiscussionPost;
@@ -165,6 +166,11 @@ class BeatmapDiscussionPostsController extends Controller
165 166
 
166 167
                 if ($disqualify) {
167 168
                     $discussion->beatmapset->setApproved('pending', Auth::user());
169
+                    dispatch(new Notify('BeatmapsetDisqualify', $discussion->beatmapset, Auth::user()));
170
+                }
171
+
172
+                if ($resetNominations) {
173
+                    dispatch(new Notify('BeatmapsetResetNominations', $discussion->beatmapset, Auth::user()));
168 174
                 }
169 175
 
170 176
                 // feels like a controller shouldn't be calling refreshCache on a model?
@@ -179,6 +185,7 @@ class BeatmapDiscussionPostsController extends Controller
179 185
         $beatmapset = $discussion->beatmapset;
180 186
 
181 187
         BeatmapsetWatch::markRead($beatmapset, Auth::user());
188
+        dispatch(new Notify('BeatmapsetDiscussionPostNew', $post, Auth::user()));
182 189
         (new NotifyBeatmapsetUpdate([
183 190
             'user' => Auth::user(),
184 191
             'beatmapset' => $beatmapset,

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

@@ -20,6 +20,7 @@
20 20
 
21 21
 namespace App\Http\Controllers;
22 22
 
23
+use App\Events\UserSubscriptionChangeEvent;
23 24
 use App\Models\Beatmapset;
24 25
 use Auth;
25 26
 use Exception;
@@ -56,6 +57,8 @@ class BeatmapsetWatchesController extends Controller
56 57
             }
57 58
         }
58 59
 
60
+        event(new UserSubscriptionChangeEvent('add', Auth::user(), $beatmapset));
61
+
59 62
         return response([], 204);
60 63
     }
61 64
 
@@ -65,6 +68,8 @@ class BeatmapsetWatchesController extends Controller
65 68
 
66 69
         $beatmapset->watches()->where('user_id', '=', Auth::user()->getKey())->delete();
67 70
 
71
+        event(new UserSubscriptionChangeEvent('remove', Auth::user(), $beatmapset));
72
+
68 73
         return response([], 204);
69 74
     }
70 75
 }

+ 88
- 0
app/Http/Controllers/NotificationsController.php View File

@@ -0,0 +1,88 @@
1
+<?php
2
+
3
+/**
4
+ *    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
5
+ *
6
+ *    This file is part of osu!web. osu!web is distributed with the hope of
7
+ *    attracting more community contributions to the core ecosystem of osu!.
8
+ *
9
+ *    osu!web is free software: you can redistribute it and/or modify
10
+ *    it under the terms of the Affero GNU General Public License version 3
11
+ *    as published by the Free Software Foundation.
12
+ *
13
+ *    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
14
+ *    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
15
+ *    See the GNU Affero General Public License for more details.
16
+ *
17
+ *    You should have received a copy of the GNU Affero General Public License
18
+ *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
19
+ */
20
+
21
+namespace App\Http\Controllers;
22
+
23
+use App\Events\NotificationReadEvent;
24
+use App\Models\Notification;
25
+
26
+class NotificationsController extends Controller
27
+{
28
+    protected $section = 'community';
29
+    protected $actionPrefix = 'notifications_';
30
+
31
+    public function __construct()
32
+    {
33
+        parent::__construct();
34
+
35
+        $this->middleware('auth');
36
+    }
37
+
38
+    public function index()
39
+    {
40
+        $limit = 51;
41
+        $hasMore = false;
42
+        $userNotificationsQuery = auth()
43
+            ->user()
44
+            ->userNotifications()
45
+            ->with('notification.notifiable')
46
+            ->with('notification.source')
47
+            ->where('is_read', false)
48
+            ->orderBy('notification_id', 'DESC')
49
+            ->limit($limit);
50
+
51
+        $maxId = get_int(request('max_id'));
52
+        if (isset($maxId)) {
53
+            $userNotificationsQuery->where('id', '<=', $maxId);
54
+        }
55
+
56
+        $userNotifications = $userNotificationsQuery->get();
57
+
58
+        if ($userNotifications->count() === $limit) {
59
+            $hasMore = true;
60
+            $userNotifications->pop();
61
+        }
62
+
63
+        $json = json_collection($userNotifications, 'Notification');
64
+
65
+        $unreadCount = auth()->user()->userNotifications()->where('is_read', false)->count();
66
+
67
+        return [
68
+            'has_more' => $hasMore,
69
+            'notifications' => $json,
70
+            'unread_count' => $unreadCount,
71
+        ];
72
+    }
73
+
74
+    public function markRead()
75
+    {
76
+        $user = auth()->user();
77
+        $ids = get_params(request()->all(), null, ['ids:int[]'])['ids'] ?? [];
78
+        $itemsQuery = $user->userNotifications()->whereIn('notification_id', $ids);
79
+
80
+        if ($itemsQuery->update(['is_read' => true])) {
81
+            event(new NotificationReadEvent($user->getKey(), $ids));
82
+
83
+            return response(null, 204);
84
+        } else {
85
+            return response(null, 422);
86
+        }
87
+    }
88
+}

+ 62
- 0
app/Jobs/MarkNotificationsRead/Beatmapset.php View File

@@ -0,0 +1,62 @@
1
+<?php
2
+
3
+/**
4
+ *    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
5
+ *
6
+ *    This file is part of osu!web. osu!web is distributed with the hope of
7
+ *    attracting more community contributions to the core ecosystem of osu!.
8
+ *
9
+ *    osu!web is free software: you can redistribute it and/or modify
10
+ *    it under the terms of the Affero GNU General Public License version 3
11
+ *    as published by the Free Software Foundation.
12
+ *
13
+ *    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
14
+ *    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
15
+ *    See the GNU Affero General Public License for more details.
16
+ *
17
+ *    You should have received a copy of the GNU Affero General Public License
18
+ *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
19
+ */
20
+
21
+namespace App\Jobs\MarkNotificationsRead;
22
+
23
+use App\Events\NotificationReadEvent;
24
+use App\Libraries\MorphMap;
25
+use App\Models\Notification;
26
+use Illuminate\Bus\Queueable;
27
+use Illuminate\Contracts\Queue\ShouldQueue;
28
+use Illuminate\Queue\SerializesModels;
29
+
30
+class Beatmapset implements ShouldQueue
31
+{
32
+    use Queueable, SerializesModels;
33
+
34
+    private $beatmapset;
35
+    private $user;
36
+
37
+    public function __construct($beatmapset, $user)
38
+    {
39
+        $this->beatmapset = $beatmapset;
40
+        $this->user = $user;
41
+    }
42
+
43
+    public function handle()
44
+    {
45
+        $notifications = Notification
46
+            ::where('notifiable_type', '=', MorphMap::getType($this->beatmapset))
47
+            ->where('notifiable_id', '=', $this->beatmapset->getKey())
48
+            ->where('created_at', '<=', now());
49
+        $userNotifications = $this->user
50
+            ->userNotifications()
51
+            ->where('is_read', '=', false)
52
+            ->whereIn('notification_id', $notifications->select('id'))
53
+            ->get();
54
+
55
+        $notificationIds = $userNotifications->pluck('notification_id')->all();
56
+        $userNotifications->each->update(['is_read' => true]);
57
+
58
+        if (!empty($notificationIds)) {
59
+            event(new NotificationReadEvent($this->user->getKey(), $notificationIds));
60
+        }
61
+    }
62
+}

+ 68
- 0
app/Jobs/MarkNotificationsRead/ForumTopic.php View File

@@ -0,0 +1,68 @@
1
+<?php
2
+
3
+/**
4
+ *    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
5
+ *
6
+ *    This file is part of osu!web. osu!web is distributed with the hope of
7
+ *    attracting more community contributions to the core ecosystem of osu!.
8
+ *
9
+ *    osu!web is free software: you can redistribute it and/or modify
10
+ *    it under the terms of the Affero GNU General Public License version 3
11
+ *    as published by the Free Software Foundation.
12
+ *
13
+ *    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
14
+ *    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
15
+ *    See the GNU Affero General Public License for more details.
16
+ *
17
+ *    You should have received a copy of the GNU Affero General Public License
18
+ *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
19
+ */
20
+
21
+namespace App\Jobs\MarkNotificationsRead;
22
+
23
+use App\Events\NotificationReadEvent;
24
+use App\Libraries\MorphMap;
25
+use App\Models\Notification;
26
+use Illuminate\Bus\Queueable;
27
+use Illuminate\Contracts\Queue\ShouldQueue;
28
+use Illuminate\Queue\SerializesModels;
29
+
30
+class ForumTopic implements ShouldQueue
31
+{
32
+    use Queueable, SerializesModels;
33
+
34
+    private $post;
35
+    private $user;
36
+
37
+    public function __construct($post, $user)
38
+    {
39
+        $this->post = $post;
40
+        $this->user = $user;
41
+    }
42
+
43
+    public function handle()
44
+    {
45
+        $topic = $this->post->topic()->withTrashed()->first();
46
+
47
+        if ($topic === null) {
48
+            return;
49
+        }
50
+
51
+        $notifications = Notification
52
+            ::where('notifiable_type', '=', MorphMap::getType($topic))
53
+            ->where('notifiable_id', '=', $topic->getKey())
54
+            ->where('created_at', '<=', $this->post->post_time);
55
+        $userNotifications = $this->user
56
+            ->userNotifications()
57
+            ->where('is_read', '=', false)
58
+            ->whereIn('notification_id', $notifications->select('id'))
59
+            ->get();
60
+
61
+        $notificationIds = $userNotifications->pluck('notification_id')->all();
62
+        $userNotifications->each->update(['is_read' => true]);
63
+
64
+        if (!empty($notificationIds)) {
65
+            event(new NotificationReadEvent($this->user->getKey(), $notificationIds));
66
+        }
67
+    }
68
+}

+ 196
- 0
app/Jobs/Notify.php View File

@@ -0,0 +1,196 @@
1
+<?php
2
+
3
+/**
4
+ *    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
5
+ *
6
+ *    This file is part of osu!web. osu!web is distributed with the hope of
7
+ *    attracting more community contributions to the core ecosystem of osu!.
8
+ *
9
+ *    osu!web is free software: you can redistribute it and/or modify
10
+ *    it under the terms of the Affero GNU General Public License version 3
11
+ *    as published by the Free Software Foundation.
12
+ *
13
+ *    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
14
+ *    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
15
+ *    See the GNU Affero General Public License for more details.
16
+ *
17
+ *    You should have received a copy of the GNU Affero General Public License
18
+ *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
19
+ */
20
+
21
+namespace App\Jobs;
22
+
23
+use App\Events\NewNotificationEvent;
24
+use App\Models\Notification;
25
+use App\Models\User;
26
+use DB;
27
+use Illuminate\Bus\Queueable;
28
+use Illuminate\Contracts\Queue\ShouldQueue;
29
+use Illuminate\Queue\SerializesModels;
30
+
31
+class Notify implements ShouldQueue
32
+{
33
+    use Queueable, SerializesModels;
34
+
35
+    private $event;
36
+    private $notifiable;
37
+    private $object;
38
+    private $params = [];
39
+    private $receiverIds;
40
+    private $source;
41
+
42
+    private static function beatmapsetReceiverIds($beatmapset)
43
+    {
44
+        return $beatmapset
45
+            ->watches()
46
+            ->pluck('user_id')
47
+            ->all();
48
+    }
49
+
50
+    public function __construct($event, $object, $source)
51
+    {
52
+        $this->event = $event;
53
+        $this->object = $object;
54
+        $this->source = $source;
55
+    }
56
+
57
+    public function handle()
58
+    {
59
+        $this->prepare();
60
+        $this->notifiable = $this->notifiable ?? $this->object;
61
+
62
+        if (is_array($this->receiverIds)) {
63
+            switch (count($this->receiverIds)) {
64
+                case 0:
65
+                    return;
66
+                case 1:
67
+                    if ($this->receiverIds[0] === $this->source->getKey()) {
68
+                        return;
69
+                    }
70
+            }
71
+        }
72
+
73
+        $notification = new Notification($this->params);
74
+        $notification->notifiable()->associate($this->notifiable);
75
+        $notification->source()->associate($this->source);
76
+
77
+        $notification->save();
78
+
79
+        event(new NewNotificationEvent($notification));
80
+
81
+        if (is_array($this->receiverIds)) {
82
+            DB::transaction(function () use ($notification) {
83
+                $receivers = User::whereIn('user_id', $this->receiverIds)->get();
84
+
85
+                foreach ($receivers as $receiver) {
86
+                    if ($receiver->getKey() !== $this->source->getKey()) {
87
+                        $notification->userNotifications()->create(['user_id' => $receiver->getKey()]);
88
+                    }
89
+                }
90
+            });
91
+        }
92
+    }
93
+
94
+    private function onBeatmapsetDiscussionPostNew()
95
+    {
96
+        $this->params['name'] = Notification::NAME_BEATMAPSET_DISCUSSION_POST_NEW;
97
+
98
+        $this->notifiable = $this->object->beatmapset;
99
+        $this->receiverIds = static::beatmapsetReceiverIds($this->notifiable);
100
+
101
+        $this->params['details'] = [
102
+            'username' => $this->source->username,
103
+            'title' => $this->notifiable->title,
104
+            'post_id' => $this->object->getKey(),
105
+            'discussion_id' => $this->object->beatmapDiscussion->getKey(),
106
+        ];
107
+    }
108
+
109
+    private function onBeatmapsetDisqualify()
110
+    {
111
+        $this->params['name'] = Notification::NAME_BEATMAPSET_DISQUALIFY;
112
+        $this->receiverIds = static::beatmapsetReceiverIds($this->object);
113
+
114
+        $this->params['details'] = [
115
+            'username' => $this->source->username,
116
+            'title' => $this->object->title,
117
+            'cover_url' => $this->object->coverURL('card'),
118
+        ];
119
+    }
120
+
121
+    private function onBeatmapsetLove()
122
+    {
123
+        $this->params['name'] = Notification::NAME_BEATMAPSET_LOVE;
124
+        $this->receiverIds = static::beatmapsetReceiverIds($this->object);
125
+
126
+        $this->params['details'] = [
127
+            'username' => $this->source->username,
128
+            'title' => $this->object->title,
129
+            'cover_url' => $this->object->coverURL('card'),
130
+        ];
131
+    }
132
+
133
+    private function onBeatmapsetNominate()
134
+    {
135
+        $this->params['name'] = Notification::NAME_BEATMAPSET_NOMINATE;
136
+        $this->receiverIds = static::beatmapsetReceiverIds($this->object);
137
+
138
+        $this->params['details'] = [
139
+            'username' => $this->source->username,
140
+            'title' => $this->object->title,
141
+            'cover_url' => $this->object->coverURL('card'),
142
+        ];
143
+    }
144
+
145
+    private function onBeatmapsetQualify()
146
+    {
147
+        $this->params['name'] = Notification::NAME_BEATMAPSET_QUALIFY;
148
+        $this->receiverIds = static::beatmapsetReceiverIds($this->object);
149
+
150
+        $this->params['details'] = [
151
+            'username' => $this->source->username,
152
+            'title' => $this->object->title,
153
+            'cover_url' => $this->object->coverURL('card'),
154
+        ];
155
+    }
156
+
157
+    private function onBeatmapsetResetNominations()
158
+    {
159
+        $this->params['name'] = Notification::NAME_BEATMAPSET_RESET_NOMINATIONS;
160
+        $this->receiverIds = static::beatmapsetReceiverIds($this->object);
161
+
162
+        $this->params['details'] = [
163
+            'username' => $this->source->username,
164
+            'title' => $this->object->title,
165
+            'cover_url' => $this->object->coverURL('card'),
166
+        ];
167
+    }
168
+
169
+    private function onForumTopicReply()
170
+    {
171
+        $this->params['name'] = Notification::NAME_FORUM_TOPIC_REPLY;
172
+        $this->notifiable = $this->object->topic;
173
+
174
+        $this->receiverIds = $this->object
175
+            ->topic
176
+            ->watches()
177
+            ->where('user_id', '<>', $this->source->getKey())
178
+            ->pluck('user_id')
179
+            ->all();
180
+
181
+        $this->params['details'] = [
182
+            'username' => $this->source->username,
183
+            'title' => $this->notifiable->topic_title,
184
+            'post_id' => $this->object->getKey(),
185
+            'cover_url' => optional($this->notifiable->cover)->fileUrl(),
186
+        ];
187
+
188
+        $this->params['created_at'] = $this->object->post_time;
189
+    }
190
+
191
+    private function prepare()
192
+    {
193
+        $function = "on{$this->event}";
194
+        $this->$function();
195
+    }
196
+}

+ 2
- 1
app/Jobs/RemoveBeatmapsetBestScores.php View File

@@ -74,13 +74,14 @@ class RemoveBeatmapsetBestScores implements ShouldQueue
74 74
             Es::getClient('scores')->deleteByQuery([
75 75
                 'index' => config('osu.elasticsearch.prefix')."high_scores_{$mode}",
76 76
                 'body' => ['query' => $query->toArray()],
77
+                'client' => ['ignore' => 404],
77 78
             ]);
78 79
 
79 80
             $class = static::scoreClass($mode);
80 81
             $table = (new $class)->getTable();
81 82
             $class::whereIn('beatmap_id', $beatmapIds)
82 83
                 ->orderBy('score_id')
83
-                ->where('score_id', '<=', $this->maxScoreIds[$mode])
84
+                ->where('score_id', '<=', $this->maxScoreIds[$mode] ?? 0)
84 85
                 ->from(DB::raw("{$table} FORCE INDEX (beatmap_score_lookup)")) // TODO: fixes an issue with MySQL 5.6; remove after updating.
85 86
                 ->chunkById(100, function ($scores) {
86 87
                     $scores->each->delete();

+ 2
- 0
app/Libraries/ForumUpdateNotifier.php View File

@@ -20,6 +20,7 @@
20 20
 
21 21
 namespace App\Libraries;
22 22
 
23
+use App\Jobs\Notify;
23 24
 use App\Jobs\NotifyForumUpdateMail;
24 25
 use App\Jobs\NotifyForumUpdateSlack;
25 26
 
@@ -32,6 +33,7 @@ class ForumUpdateNotifier
32 33
 
33 34
     public static function onReply($data)
34 35
     {
36
+        dispatch(new Notify('ForumTopicReply', $data['post'], $data['user']));
35 37
         dispatch(new NotifyForumUpdateMail($data));
36 38
 
37 39
         (new NotifyForumUpdateSlack($data, 'reply'))->dispatchIfNeeded();

+ 2
- 0
app/Libraries/MorphMap.php View File

@@ -23,6 +23,7 @@ namespace App\Libraries;
23 23
 use App\Models\Beatmapset;
24 24
 use App\Models\Build;
25 25
 use App\Models\Comment;
26
+use App\Models\Forum;
26 27
 use App\Models\NewsPost;
27 28
 use App\Models\Score;
28 29
 use App\Models\User;
@@ -33,6 +34,7 @@ class MorphMap
33 34
         Beatmapset::class => 'beatmapset',
34 35
         Build::class => 'build',
35 36
         Comment::class => 'comment',
37
+        Forum\Topic::class => 'forum_topic',
36 38
         NewsPost::class => 'news_post',
37 39
         Score\Best\Fruits::class => 'score_best_fruits',
38 40
         Score\Best\Mania::class => 'score_best_mania',

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

@@ -23,6 +23,7 @@ namespace App\Models;
23 23
 use App\Exceptions\BeatmapProcessorException;
24 24
 use App\Jobs\CheckBeatmapsetCovers;
25 25
 use App\Jobs\EsIndexDocument;
26
+use App\Jobs\Notify;
26 27
 use App\Jobs\RemoveBeatmapsetBestScores;
27 28
 use App\Libraries\BBCodeFromDB;
28 29
 use App\Libraries\ImageProcessorService;
@@ -583,6 +584,7 @@ class Beatmapset extends Model implements AfterCommit
583 584
 
584 585
             // remove current scores
585 586
             dispatch(new RemoveBeatmapsetBestScores($this));
587
+            dispatch(new Notify('BeatmapsetQualify', $this, $user));
586 588
         });
587 589
 
588 590
         return true;
@@ -612,6 +614,8 @@ class Beatmapset extends Model implements AfterCommit
612 614
                 $this->events()->create(['type' => BeatmapsetEvent::NOMINATE, 'user_id' => $user->user_id]);
613 615
                 if ($this->currentNominationCount() >= $this->requiredNominationCount()) {
614 616
                     $this->qualify($user);
617
+                } else {
618
+                    dispatch(new Notify('BeatmapsetNominate', $this, $user));
615 619
                 }
616 620
             }
617 621
             $this->refreshCache();
@@ -640,6 +644,7 @@ class Beatmapset extends Model implements AfterCommit
640 644
 
641 645
             dispatch((new CheckBeatmapsetCovers($this))->onQueue('beatmap_high'));
642 646
             dispatch(new RemoveBeatmapsetBestScores($this));
647
+            dispatch(new Notify('BeatmapsetLove', $this, $user));
643 648
         });
644 649
 
645 650
         return [

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

@@ -20,6 +20,7 @@
20 20
 
21 21
 namespace App\Models;
22 22
 
23
+use App\Jobs\MarkNotificationsRead;
23 24
 use Carbon\Carbon;
24 25
 
25 26
 /**
@@ -55,6 +56,8 @@ class BeatmapsetWatch extends Model
55 56
             return;
56 57
         }
57 58
 
59
+        dispatch(new MarkNotificationsRead\Beatmapset($beatmapset, $user));
60
+
58 61
         return static
59 62
             ::where('beatmapset_id', '=', $beatmapset->getKey())
60 63
             ->where('user_id', '=', $user->getKey())

+ 3
- 0
app/Models/Forum/Post.php View File

@@ -21,6 +21,7 @@
21 21
 namespace App\Models\Forum;
22 22
 
23 23
 use App\Jobs\EsIndexDocument;
24
+use App\Jobs\MarkNotificationsRead;
24 25
 use App\Libraries\BBCodeForDB;
25 26
 use App\Libraries\BBCodeFromDB;
26 27
 use App\Libraries\Transactions\AfterCommit;
@@ -351,5 +352,7 @@ class Post extends Model implements AfterCommit
351 352
         if ($topic->topic_last_post_id === $this->getKey()) {
352 353
             TopicWatch::lookupQuery($topic, $user)->update(['notify_status' => false]);
353 354
         }
355
+
356
+        dispatch(new MarkNotificationsRead\ForumTopic($this, $user));
354 357
     }
355 358
 }

+ 6
- 0
app/Models/Forum/Topic.php View File

@@ -28,6 +28,7 @@ use App\Libraries\Transactions\AfterCommit;
28 28
 use App\Models\Beatmapset;
29 29
 use App\Models\Elasticsearch;
30 30
 use App\Models\Log;
31
+use App\Models\Notification;
31 32
 use App\Models\User;
32 33
 use App\Traits\Validatable;
33 34
 use Carbon\Carbon;
@@ -325,6 +326,11 @@ class Topic extends Model implements AfterCommit
325 326
         return $this->hasMany(Log::class, 'topic_id');
326 327
     }
327 328
 
329
+    public function notifications()
330
+    {
331
+        return $this->morphMany(Notification::class, 'notifiable');
332
+    }
333
+
328 334
     public function featureVotes()
329 335
     {
330 336
         return $this->hasMany(FeatureVote::class, 'topic_id');

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

@@ -20,6 +20,7 @@
20 20
 
21 21
 namespace App\Models\Forum;
22 22
 
23
+use App\Events\UserSubscriptionChangeEvent;
23 24
 use App\Models\User;
24 25
 
25 26
 /**
@@ -97,13 +98,17 @@ class TopicWatch extends Model
97 98
 
98 99
             try {
99 100
                 if ($state === 'not_watching') {
101
+                    $event = 'remove';
100 102
                     $watch->delete();
101 103
                 } else {
104
+                    $event = 'add';
102 105
                     $mail = $state === 'watching_mail';
103 106
 
104 107
                     $watch->fill(['mail' => $mail])->saveOrExplode();
105 108
                 }
106 109
 
110
+                event(new UserSubscriptionChangeEvent($event, $user, $topic));
111
+
107 112
                 return $watch;
108 113
             } catch (Exception $e) {
109 114
                 if (is_sql_unique_exception($e) && $tries < 2) {

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

@@ -0,0 +1,63 @@
1
+<?php
2
+
3
+/**
4
+ *    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
5
+ *
6
+ *    This file is part of osu!web. osu!web is distributed with the hope of
7
+ *    attracting more community contributions to the core ecosystem of osu!.
8
+ *
9
+ *    osu!web is free software: you can redistribute it and/or modify
10
+ *    it under the terms of the Affero GNU General Public License version 3
11
+ *    as published by the Free Software Foundation.
12
+ *
13
+ *    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
14
+ *    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
15
+ *    See the GNU Affero General Public License for more details.
16
+ *
17
+ *    You should have received a copy of the GNU Affero General Public License
18
+ *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
19
+ */
20
+
21
+namespace App\Models;
22
+
23
+use App\Libraries\MorphMap;
24
+
25
+class Notification extends Model
26
+{
27
+    const NAME_BEATMAPSET_DISCUSSION_POST_NEW = 'beatmapset_discussion_post_new';
28
+    const NAME_BEATMAPSET_DISQUALIFY = 'beatmapset_disqualify';
29
+    const NAME_BEATMAPSET_LOVE = 'beatmapset_love';
30
+    const NAME_BEATMAPSET_NOMINATE = 'beatmapset_nominate';
31
+    const NAME_BEATMAPSET_QUALIFY = 'beatmapset_qualify';
32
+    const NAME_BEATMAPSET_RESET_NOMINATIONS = 'beatmapset_reset_nominations';
33
+    const NAME_FORUM_TOPIC_REPLY = 'forum_topic_reply';
34
+
35
+    protected $casts = [
36
+        'details' => 'array',
37
+    ];
38
+
39
+    public static function generateChannelName($notifiable)
40
+    {
41
+        return 'new:'.MorphMap::getType($notifiable).':'.$notifiable->getKey();
42
+    }
43
+
44
+    public function notifiable()
45
+    {
46
+        return $this->morphTo();
47
+    }
48
+
49
+    public function source()
50
+    {
51
+        return $this->belongsTo(User::class);
52
+    }
53
+
54
+    public function userNotifications()
55
+    {
56
+        return $this->hasMany(UserNotification::class);
57
+    }
58
+
59
+    public function channelName()
60
+    {
61
+        return static::generateChannelName($this->notifiable);
62
+    }
63
+}

+ 6
- 1
app/Models/User.php View File

@@ -1104,6 +1104,11 @@ class User extends Model implements AuthenticatableContract
1104 1104
         return $this->hasMany(UserAchievement::class, 'user_id');
1105 1105
     }
1106 1106
 
1107
+    public function userNotifications()
1108
+    {
1109
+        return $this->hasMany(UserNotification::class, 'user_id');
1110
+    }
1111
+
1107 1112
     public function usernameChangeHistory()
1108 1113
     {
1109 1114
         return $this->hasMany(UsernameChangeHistory::class, 'user_id');
@@ -1375,7 +1380,7 @@ class User extends Model implements AuthenticatableContract
1375 1380
     // TODO: we should rename this to currentUserJson or something.
1376 1381
     public function defaultJson()
1377 1382
     {
1378
-        return json_item($this, 'User', ['blocks', 'friends', 'is_admin']);
1383
+        return json_item($this, 'User', ['blocks', 'friends', 'is_admin', 'unread_pm_count']);
1379 1384
     }
1380 1385
 
1381 1386
     public function supportLength()

+ 38
- 0
app/Models/UserNotification.php View File

@@ -0,0 +1,38 @@
1
+<?php
2
+
3
+/**
4
+ *    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
5
+ *
6
+ *    This file is part of osu!web. osu!web is distributed with the hope of
7
+ *    attracting more community contributions to the core ecosystem of osu!.
8
+ *
9
+ *    osu!web is free software: you can redistribute it and/or modify
10
+ *    it under the terms of the Affero GNU General Public License version 3
11
+ *    as published by the Free Software Foundation.
12
+ *
13
+ *    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
14
+ *    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
15
+ *    See the GNU Affero General Public License for more details.
16
+ *
17
+ *    You should have received a copy of the GNU Affero General Public License
18
+ *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
19
+ */
20
+
21
+namespace App\Models;
22
+
23
+class UserNotification extends Model
24
+{
25
+    protected $casts = [
26
+        'is_read' => 'boolean',
27
+    ];
28
+
29
+    public function notification()
30
+    {
31
+        return $this->belongsTo(Notification::class);
32
+    }
33
+
34
+    public function user()
35
+    {
36
+        return $this->belongsTo(User::class, 'user_id');
37
+    }
38
+}

+ 50
- 0
app/Transformers/NotificationTransformer.php View File

@@ -0,0 +1,50 @@
1
+<?php
2
+
3
+/**
4
+ *    Copyright (c) ppy Pty Ltd <contact@ppy.sh>.
5
+ *
6
+ *    This file is part of osu!web. osu!web is distributed with the hope of
7
+ *    attracting more community contributions to the core ecosystem of osu!.
8
+ *
9
+ *    osu!web is free software: you can redistribute it and/or modify
10
+ *    it under the terms of the Affero GNU General Public License version 3
11
+ *    as published by the Free Software Foundation.
12
+ *
13
+ *    osu!web is distributed WITHOUT ANY WARRANTY; without even the implied
14
+ *    warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
15
+ *    See the GNU Affero General Public License for more details.
16
+ *
17
+ *    You should have received a copy of the GNU Affero General Public License
18
+ *    along with osu!web.  If not, see <http://www.gnu.org/licenses/>.
19
+ */
20
+
21
+namespace App\Transformers;
22
+
23
+use App\Models\Notification;
24
+use App\Models\UserNotification;
25
+use League\Fractal;
26
+
27
+class NotificationTransformer extends Fractal\TransformerAbstract
28
+{
29
+    public function transform($object)
30
+    {
31
+        if ($object instanceof UserNotification) {
32
+            $notification = $object->notification;
33
+            $isRead = $object->is_read;
34
+        } elseif ($object instanceof Notification) {
35
+            $notification = $object;
36
+            $isRead = false;
37
+        } // otherwise just explode from accessing null
38
+
39
+        return [
40
+            'id' => $notification->getKey(),
41
+            'name' => $notification->name,
42
+            'created_at' => json_time($notification->created_at),
43
+            'object_type' => $notification->notifiable_type,
44
+            'object_id' => $notification->notifiable_id,
45
+            'source_user_id' => $notification->source_user_id,
46
+            'is_read' => $isRead,
47
+            'details' => $notification->details,
48
+        ];
49
+    }
50
+}

+ 8
- 0
app/Transformers/UserTransformer.php View File

@@ -46,6 +46,7 @@ class UserTransformer extends Fractal\TransformerAbstract
46 46
         'statistics',
47 47
         'support_level',
48 48
         'unranked_beatmapset_count',
49
+        'unread_pm_count',
49 50
         'user_achievements',
50 51
     ];
51 52
 
@@ -285,6 +286,13 @@ class UserTransformer extends Fractal\TransformerAbstract
285 286
         });
286 287
     }
287 288
 
289
+    public function includeUnreadPmCount(User $user)
290
+    {
291
+        return $this->primitive($user, function ($user) {
292
+            return $user->notificationCount();
293
+        });
294
+    }
295
+
288 296
     public function includeUserAchievements(User $user)
289 297
     {
290 298
         return $this->collection(

+ 18
- 1
bin/run_dusk.sh View File

@@ -1,8 +1,25 @@
1 1
 #!/bin/sh
2 2
 
3
-# start the headless driver and standalone server that the tests use
3
+# wrapped in () so it doesn't mess up caller working directory
4
+start_notification_server() { (
5
+    if [ -d osu-notification-server ]; then
6
+        cd osu-notification-server
7
+        git pull
8
+    else
9
+        git clone https://github.com/ppy/osu-notification-server
10
+        cd osu-notification-server
11
+    fi
12
+    ln -sf ../.env
13
+    ln -sf ../storage/oauth-public.key
14
+    yarn
15
+    yarn build
16
+    yarn serve > server.log 2>&1 &
17
+) }
18
+
19
+# start the headless driver, standalone server, and notification server that the tests use
4 20
 google-chrome-stable --headless --disable-gpu --remote-debugging-port=9222 http://localhost &
5 21
 php artisan serve > /dev/null 2>&1 &
22
+start_notification_server
6 23
 
7 24
 # run the tests
8 25
 php artisan dusk --verbose

+ 2
- 1
config/app.php View File

@@ -181,6 +181,7 @@ return [
181 181
          * Laravel Framework Service Providers...
182 182
          */
183 183
         Illuminate\Auth\AuthServiceProvider::class,
184
+        Illuminate\Broadcasting\BroadcastServiceProvider::class,
184 185
         Illuminate\Bus\BusServiceProvider::class,
185 186
         Illuminate\Cache\CacheServiceProvider::class,
186 187
         Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
@@ -218,7 +219,7 @@ return [
218 219
          * Application Service Providers...
219 220
          */
220 221
         App\Providers\AppServiceProvider::class,
221
-        // App\Providers\BroadcastServiceProvider::class,
222
+        App\Providers\BroadcastServiceProvider::class,
222 223
         App\Providers\EventServiceProvider::class,
223 224
         App\Providers\RouteServiceProvider::class,
224 225
 

+ 2
- 2
config/broadcasting.php View File

@@ -15,7 +15,7 @@ return [
15 15
     |
16 16
     */
17 17
 
18
-    'default' => env('BROADCAST_DRIVER', 'null'),
18
+    'default' => env('BROADCAST_DRIVER', 'redis'),
19 19
 
20 20
     /*
21 21
     |--------------------------------------------------------------------------
@@ -42,7 +42,7 @@ return [
42 42
 
43 43
         'redis' => [
44 44
             'driver' => 'redis',
45
-            'connection' => 'default',
45
+            'connection' => 'broadcast',
46 46
         ],
47 47
 
48 48
         'log' => [

+ 7
- 0
config/database.php View File

@@ -124,6 +124,13 @@ return [
124 124
             'persistent' => true,
125 125
         ],
126 126
 
127
+        'broadcast' => [
128
+            'host' => env('REDIS_HOST_BROADCAST', '127.0.0.1'),
129
+            'port' => get_int(env('REDIS_PORT_BROADCAST')) ?? 6379,
130
+            'database' => 0,
131
+            'persistent' => true,
132
+        ],
133
+
127 134
     ],
128 135
 
129 136
 ];

+ 7
- 1
database/factories/BeatmapMirrorFactory.php View File

@@ -2,7 +2,7 @@
2 2
 
3 3
 use App\Models\BeatmapMirror;
4 4
 
5
-$factory->define(App\Models\BeatmapMirror::class, function (Faker\Generator $faker) {
5
+$factory->define(BeatmapMirror::class, function (Faker\Generator $faker) {
6 6
     return  [
7 7
         'base_url' => 'http://beatmap-download.test/',
8 8
         'traffic_used' => rand(0, pow(2, 32)),
@@ -14,3 +14,9 @@ $factory->define(App\Models\BeatmapMirror::class, function (Faker\Generator $fak
14 14
         'version' => BeatmapMirror::MIN_VERSION_TO_USE,
15 15
     ];
16 16
 });
17
+
18
+$factory->state(BeatmapMirror::class, 'default', function () {
19
+    return [
20
+        'mirror_id' => config('osu.beatmap_processor.mirrors_to_use')[0],
21
+    ];
22
+});

+ 42
- 0
database/migrations/2019_01_17_065444_add_notifications.php View File

@@ -0,0 +1,42 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+class AddNotifications extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     *
12
+     * @return void
13
+     */
14
+    public function up()
15
+    {
16
+        Schema::create('notifications', function (Blueprint $table) {
17
+            $table->bigIncrements('id');
18
+
19
+            $table->string('notifiable_type', 255);
20
+            $table->unsignedBigInteger('notifiable_id');
21
+            $table->unsignedBigInteger('source_user_id')->nullable();
22
+            $table->integer('priority')->default(0);
23
+
24
+            $table->string('name', 255);
25
+            $table->json('details');
26
+
27
+            $table->timestampsTz();
28
+
29
+            $table->index(['notifiable_type', 'notifiable_id', 'created_at']);
30
+        });
31
+    }
32
+
33
+    /**
34
+     * Reverse the migrations.
35
+     *
36
+     * @return void
37
+     */
38
+    public function down()
39
+    {
40
+        Schema::drop('notifications');
41
+    }
42
+}

+ 36
- 0
database/migrations/2019_01_23_031727_add_user_notifications.php View File

@@ -0,0 +1,36 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+class AddUserNotifications extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     *
12
+     * @return void
13
+     */
14
+    public function up()
15
+    {
16
+        Schema::create('user_notifications', function (Blueprint $table) {
17
+            $table->bigIncrements('id');
18
+            $table->unsignedBigInteger('notification_id');
19
+            $table->unsignedBigInteger('user_id');
20
+            $table->boolean('is_read')->default(false);
21
+            $table->timestampsTz();
22
+
23
+            $table->unique(['user_id', 'notification_id'], 'unique_user_notification');
24
+        });
25
+    }
26
+
27
+    /**
28
+     * Reverse the migrations.
29
+     *
30
+     * @return void
31
+     */
32
+    public function down()
33
+    {
34
+        Schema::drop('user_notifications');
35
+    }
36
+}

+ 10
- 0
resources/assets/coffee/main.coffee View File

@@ -124,6 +124,16 @@ reactTurbolinks.register 'comments', CommentsManager, (el) ->
124 124
 
125 125
   props
126 126
 
127
+notificationWidgetWorker = new _exported.NotificationWidgetWorker()
128
+
129
+$(document).ready ->
130
+  notificationWidgetWorker.userId = currentUser.id
131
+  notificationWidgetWorker.boot()
132
+
133
+reactTurbolinks.registerPersistent 'notification', _exported.NotificationWidget, true, (el) ->
134
+  type: el.dataset.notificationType
135
+  worker: notificationWidgetWorker
136
+
127 137
 reactTurbolinks.register 'user-card', _exported.UserCard, (el) ->
128 138
   modifiers: try JSON.parse(el.dataset.modifiers)
129 139
   user: try JSON.parse(el.dataset.user)

+ 5
- 5
resources/assets/coffee/react/_components/show-more-link.coffee View File

@@ -24,6 +24,8 @@ bn = 'show-more-link'
24 24
 
25 25
   onClick = props.callback
26 26
   onClick ?= -> $.publish props.event, props.data
27
+  icon = span className: "#{bn}__label-icon",
28
+    span className: "fas fa-angle-#{props.direction ? 'down'}"
27 29
 
28 30
   button
29 31
     ref: ref
@@ -34,12 +36,10 @@ bn = 'show-more-link'
34 36
     span className: "#{bn}__spinner",
35 37
       el Spinner
36 38
     span className: "#{bn}__label",
37
-      span className: "#{bn}__label-icon",
38
-        span className: 'fas fa-angle-down'
39
+      icon
39 40
       span className: "#{bn}__label-text",
40
-        osu.trans('common.buttons.show_more')
41
+        props.label ? osu.trans('common.buttons.show_more')
41 42
 
42 43
         if props.remaining?
43 44
           " (#{props.remaining})"
44
-      span className: "#{bn}__label-icon",
45
-        span className: 'fas fa-angle-down'
45
+      icon

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

@@ -187,6 +187,8 @@
187 187
 @import "bem/mp-history-events";
188 188
 @import "bem/mp-history-game";
189 189
 @import "bem/mp-history-player-score";
190
+@import "bem/nav-button";
191
+@import "bem/nav-click-popup";
190 192
 @import "bem/nav2";
191 193
 @import "bem/nav2-header";
192 194
 @import "bem/nav2-header-legacy-padding";
@@ -200,7 +202,11 @@
200 202
 @import "bem/news-post-preview";
201 203
 @import "bem/notification-banner";
202 204
 @import "bem/notification-banner-v2";
205
+@import "bem/notification-category-group";
203 206
 @import "bem/notification-icon";
207
+@import "bem/notification-popup";
208
+@import "bem/notification-popup-item";
209
+@import "bem/notification-type-group";
204 210
 @import "bem/oauth-form";
205 211
 @import "bem/osu-checkbox";
206 212
 @import "bem/osu-layout";

+ 65
- 0
resources/assets/less/bem/nav-button.less View File

@@ -0,0 +1,65 @@
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
+.nav-button {
20
+  @_button-content-size: 16px;
21
+  @_icon-size: 40px;
22
+
23
+  .reset-input();
24
+  .circle(@_icon-size);
25
+  .center-content();
26
+  .link-plain();
27
+  border: 3px solid fade(#fff, 25%);
28
+  font-size: @_button-content-size;
29
+  color: #fff;
30
+  margin: 0 3px;
31
+
32
+  &.js-click-menu--active, &:hover {
33
+    background-color: fade(#000, 50%);
34
+    border-color: #fff;
35
+    color: #fff;
36
+  }
37
+
38
+  &--mobile {
39
+    .default-border-radius();
40
+    margin: 0 10px 0 0;
41
+    border: none;
42
+  }
43
+
44
+  &--social {
45
+    &:hover {
46
+      color: #fff;
47
+    }
48
+  }
49
+
50
+  &--stadium {
51
+    width: auto;
52
+    border-radius: @_icon-size;
53
+    padding: 0 10px;
54
+  }
55
+
56
+  &--support {
57
+    &:hover {
58
+      color: @pink;
59
+    }
60
+  }
61
+
62
+  &__locale-current-flag {
63
+    height: @_button-content-size;
64
+  }
65
+}

+ 30
- 0
resources/assets/less/bem/nav-click-popup.less View File

@@ -0,0 +1,30 @@
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
+
20
+.nav-click-popup {
21
+  position: absolute;
22
+  top: 100%;
23
+  right: 0; // avoid overflow before repositioned
24
+  height: 0; // avoid covering page when the content is hidden
25
+  margin-top: -5px;
26
+
27
+  &--user {
28
+    margin-top: 5px;
29
+  }
30
+}

+ 1
- 1
resources/assets/less/bem/nav2-header.less View File

@@ -21,7 +21,7 @@
21 21
   position: fixed;
22 22
   top: 0;
23 23
   width: 100%;
24
-  z-index: @z-index--fixed-bar;
24
+  z-index: @z-index--nav-bar;
25 25
 
26 26
   &__body {
27 27
     width: 100%;

+ 0
- 53
resources/assets/less/bem/nav2.less View File

@@ -18,8 +18,6 @@
18 18
 
19 19
 .nav2 {
20 20
   @_top: nav2;
21
-  @_icon-size: 40px;
22
-  @_button-content-size: 16px;
23 21
   @_menu-gutter: 10px;
24 22
   @_link-padding-vertical: 6px;
25 23
   @_link-highlight-margin-vertical: 2px;
@@ -39,53 +37,6 @@
39 37
     height: @nav2-height--pinned;
40 38
   }
41 39
 
42
-  &__button {
43
-    .reset-input();
44
-    .circle(@_icon-size);
45
-    .center-content();
46
-    .link-plain();
47
-    border: 3px solid fade(#fff, 25%);
48
-    font-size: @_button-content-size;
49
-    color: #fff;
50
-    margin: 0 3px;
51
-
52
-    &.js-click-menu--active, &:hover {
53
-      background-color: fade(#000, 50%);
54
-      border-color: #fff;
55
-      color: #fff;
56
-    }
57
-
58
-    &--social {
59
-      &:hover {
60
-        color: #fff;
61
-      }
62
-    }
63
-
64
-    &--stadium {
65
-      width: auto;
66
-      border-radius: @_icon-size;
67
-      padding: 0 10px;
68
-    }
69
-
70
-    &--support {
71
-      &:hover {
72
-        color: @pink;
73
-      }
74
-    }
75
-  }
76
-
77
-  &__click-popup {
78
-    position: absolute;
79
-    top: 100%;
80
-    right: 0; // avoid overflow before repositioned
81
-    height: 0; // avoid covering page when the content is hidden
82
-    margin-top: -5px;
83
-
84
-    &--user {
85
-      margin-top: 5px;
86
-    }
87
-  }
88
-
89 40
   &__col {
90 41
     display: flex;
91 42
     align-items: center;
@@ -113,10 +64,6 @@
113 64
     }
114 65
   }
115 66
 
116
-  &__locale-current-flag {
117
-    height: @_button-content-size;
118
-  }
119
-
120 67
   &__logo {
121 68
     .full-size();
122 69
     background-size: contain;

+ 1
- 1
resources/assets/less/bem/navbar-mobile.less View File

@@ -20,7 +20,7 @@
20 20
   position: fixed;
21 21
   top: 0;
22 22
   width: 100%;
23
-  z-index: @z-index--fixed-bar;
23
+  z-index: @z-index--nav-bar;
24 24
   color: #fff;
25 25
 
26 26
   &__brand {

+ 44
- 0
resources/assets/less/bem/notification-category-group.less View File

@@ -0,0 +1,44 @@
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
+.notification-category-group {
20
+  @_top: notification-category-group;
21
+  background-color: @graysky-dark;
22
+  border-radius: @border-radius-large;
23
+  display: flex;
24
+  flex-direction: column;
25
+
26
+  & + & {
27
+    margin-top: 2px;
28
+  }
29
+
30
+  &--single {
31
+    background-color: transparent;
32
+  }
33
+
34
+  &__expand-button {
35
+    align-self: center;
36
+    margin: 5px 0;
37
+  }
38
+
39
+  &__item {
40
+    & + & {
41
+      margin-top: 2px;
42
+    }
43
+  }
44
+}

+ 6
- 10
resources/assets/less/bem/notification-icon.less View File

@@ -23,7 +23,13 @@
23 23
   .center-content();
24 24
   display: flex;
25 25
 
26
+  &--glow {
27
+    text-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
28
+  }
29
+
26 30
   &--mobile {
31
+    flex-direction: column;
32
+    padding-top: 10px;
27 33
     padding-bottom: 10px;
28 34
   }
29 35
 
@@ -43,14 +49,4 @@
43 49
       padding-bottom: 2px;
44 50
     }
45 51
   }
46
-
47
-  &--mobile {
48
-    flex-direction: column;
49
-    padding-top: 10px;
50
-    margin-right: 10px;
51
-  }
52
-
53
-  &--glow {
54
-    text-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
55
-  }
56 52
 }

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

@@ -0,0 +1,90 @@
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
+.notification-popup-item {
20
+  display: flex;
21
+  width: 100%;
22
+
23
+  &__content {
24
+    flex: 1;
25
+    display: flex;
26
+    flex-direction: column;
27
+    min-width: 0;
28
+  }
29
+
30
+  &__cover {
31
+    border-radius: @border-radius-large 0 0 @border-radius-large;
32
+    background-size: cover;
33
+    background-position: center;
34
+    background-repeat: no-repeat;
35
+    width: 60px;
36
+    flex: none;
37
+  }
38
+
39
+  &__cover-icon {
40
+    & + & {
41
+      margin-left: 2px;
42
+    }
43
+  }
44
+
45
+  &__cover-overlay {
46
+    .full-size();
47
+    .center-content();
48
+    border-radius: @border-radius-large 0 0 @border-radius-large;
49
+    background-color: fade(#000, 60%);
50
+  }
51
+
52
+  &__main {
53
+    border-radius: 0 @border-radius-large @border-radius-large 0;
54
+    background-color: @graysky;
55
+    flex: 1;
56
+    padding: 5px 0 5px 10px;
57
+    display: flex;
58
+    min-width: 0;
59
+  }
60
+
61
+  &__message {
62
+    .link-plain();
63
+    .link-white();
64
+    margin: 5px 0;
65
+    font-size: @font-size--title-small;
66
+    word-wrap: break-word;
67
+  }
68
+
69
+  &__name {
70
+    font-weight: bold;
71
+    font-size: @font-size--small;
72
+    text-transform: uppercase;
73
+  }
74
+
75
+  &__read-button {
76
+    .reset-input();
77
+  }
78
+
79
+  &__side-buttons {
80
+    .center-content();
81
+    width: 16px;
82
+    flex: none;
83
+    margin: 0 10px;
84
+  }
85
+
86
+  &__time {
87
+    font-size: @font-size--small;
88
+    color: @graysky-light;
89
+  }
90
+}

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

@@ -0,0 +1,58 @@
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
+.notification-popup {
20
+  background-color: @graysky-darker;
21
+  color: #fff;
22
+  padding: 10px;
23
+  border-radius: @border-radius-large;
24
+  display: flex;
25
+  flex-direction: column;
26
+
27
+  margin-top: 10px;
28
+  max-width: 400px;
29
+  width: 90vw;
30
+
31
+  @media @desktop {
32
+    margin-top: 0;
33
+  }
34
+
35
+  &__scroll-container {
36
+    -webkit-overflow-scrolling: touch;
37
+    overflow: auto;
38
+    padding: 0 10px;
39
+    max-height: calc(var(--vh, 1vh) * 100 - (@nav2-height + 20px));
40
+  }
41
+
42
+  &__show-more {
43
+    margin-top: 10px;
44
+    display: flex;
45
+    flex-direction: column;
46
+  }
47
+
48
+  &__empty {
49
+    margin: 5px 0;
50
+    white-space: nowrap;
51
+  }
52
+
53
+  &__item {
54
+    & + & {
55
+      margin-top: 10px;
56
+    }
57
+  }
58
+}

+ 58
- 0
resources/assets/less/bem/notification-type-group.less View File

@@ -0,0 +1,58 @@
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
+.notification-type-group {
20
+  width: 100%;
21
+
22
+  &__clear-all {
23
+    .reset-input();
24
+    font-size: @font-size--title-small;
25
+    text-transform: uppercase;
26
+
27
+    &--disabled {
28
+      pointer-events: none;
29
+    }
30
+  }
31
+
32
+  &__clear-all-spinner {
33
+    margin-right: 5px;
34
+    font-size: 75%;
35
+  }
36
+
37
+  &__count {
38
+    color: @yellow;
39
+    margin-left: 10px;
40
+  }
41
+
42
+  &__header {
43
+    display: flex;
44
+    justify-content: space-between;
45
+    margin: 0 0 5px;
46
+  }
47
+
48
+  &__items {
49
+    display: flex;
50
+    flex-direction: column;
51
+  }
52
+
53
+  &__type {
54
+    font-size: @font-size--title-small;
55
+    font-weight: bold;
56
+    text-transform: uppercase;
57
+  }
58
+}

+ 18
- 10
resources/assets/less/bem/show-more-link.less View File

@@ -30,9 +30,9 @@
30 30
   border-radius: 10000px;
31 31
   color: inherit;
32 32
 
33
-  .link-hover({
33
+  &:hover {
34 34
     color: inherit;
35
-  });
35
+  };
36 36
 
37 37
   &--beatmapsets {
38 38
     margin-bottom: 10px;
@@ -57,33 +57,41 @@
57 57
   &--t-community-user-graygreen-darker {
58 58
     background-color: @community-user-graygreen-darker;
59 59
 
60
-    .link-hover({
60
+    &:hover {
61 61
       background-color: @community-user-graygreen-dark;
62
-    });
62
+    };
63 63
   }
64 64
 
65 65
   &--t-dark-purple-dark {
66 66
     background-color: @dark-purple-dark;
67 67
 
68
-    .link-hover({
68
+    &:hover {
69 69
       background-color: @dark-graypurple;
70
-    });
70
+    };
71 71
   }
72 72
 
73 73
   &--t-dark-purple-darker {
74 74
     background-color: @dark-purple-darker;
75 75
 
76
-    .link-hover({
76
+    &:hover {
77 77
       background-color: #000;
78
-    });
78
+    };
79 79
   }
80 80
 
81 81
   &--t-ddd {
82 82
     background-color: #ddd;
83 83
 
84
-    .link-hover({
84
+    &:hover {
85 85
       background-color: #ccc;
86
-    });
86
+    };
87
+  }
88
+
89
+  &--t-graysky {
90
+    background-color: @graysky;
91
+
92
+    &:hover {
93
+      background-color: @graysky-light;
94
+    };
87 95
   }
88 96
 
89 97
   &[disabled], &.js-disabled {

+ 6
- 0
resources/assets/less/colors.less View File

@@ -116,6 +116,12 @@
116 116
 @community-user-graygreen-darker: #2c3532;
117 117
 @community-user-graygreen-darkest: #1e2422;
118 118
 
119
+// 2019
120
+@graysky-light: #8ab3cc;
121
+@graysky: #405461;
122
+@graysky-dark: #303d47;
123
+@graysky-darker: #21272c;
124
+
119 125
 .colors(@name) {
120 126
   @lighter: "@{name}-lighter";
121 127
   @light: "@{name}-light";

+ 1
- 0
resources/assets/less/variables.less View File

@@ -95,6 +95,7 @@
95 95
 @z-index--admin-menu: 499;
96 96
 @z-index--back-to-top: 500;
97 97
 @z-index--fixed-bar: 501;
98
+@z-index--nav-bar: 502;
98 99
 @z-index--blackout: 510;
99 100
 @z-index--blackout-visible: 511;
100 101
 @z-index--nav-float: 999;

+ 7
- 3
resources/assets/lib/globals.d.ts View File

@@ -55,10 +55,13 @@ interface OsuCommon {
55 55
   popup: (message: string, type: string) => void;
56 56
   presence: (str?: string | null) => string | null;
57 57
   promisify: (xhr: JQueryXHR) => Promise<any>;
58
-  timeago: (time: string) => string;
58
+  timeago: (time?: string) => string;
59 59
   trans: (...args: any[]) => string;
60
-  urlPresence: (url: string) => string;
60
+  transChoice: (key: string, count: number, replacements?: any, locale?: string) => string;
61
+  urlPresence: (url?: string) => string;
61 62
   uuid: () => string;
63
+  formatNumber: (num: number, precision?: number, options?: Intl.NumberFormatOptions, locale?: string) => string;
64
+  formatNumber: (num?: number, precision?: number, options?: Intl.NumberFormatOptions, locale?: string) => string | null;
62 65
 }
63 66
 
64 67
 interface Country {
@@ -94,7 +97,8 @@ interface User {
94 97
   last_visit?: string;
95 98
   pm_friends_only: boolean;
96 99
   profile_colour?: string;
97
-  username: string
100
+  unread_pm_count?: number;
101
+  username: string;
98 102
 }
99 103
 
100 104
 interface TooltipDefault {

+ 5
- 1
resources/assets/lib/import-shims.coffee View File

@@ -36,6 +36,8 @@ import TextareaAutosize from 'react-autosize-textarea'
36 36
 import VirtualList from 'react-virtual-list'
37 37
 import GalleryContest from 'gallery-contest'
38 38
 import WindowVHPatcher from 'window-vh-patcher'
39
+import NotificationWidget from 'notification-widget/main'
40
+import NotificationWidgetWorker from 'notification-widget/worker'
39 41
 
40 42
 # polyfill non-Edge IE
41 43
 window.Promise ?= Promise
@@ -46,6 +48,8 @@ window._exported = {
46 48
   ContainerContext
47 49
   GalleryContest
48 50
   KeyContext
51
+  NotificationWidget
52
+  NotificationWidgetWorker
49 53
   PlayDetailList
50 54
   PlayDetailMenu
51 55
   ReportComment
@@ -53,10 +57,10 @@ window._exported = {
53 57
   ScoreHelper
54 58
   SelectOptions
55 59
   SpotlightSelectOptions
56
-  WindowVHPatcher
57 60
   UserCard
58 61
   UserCardStore
59 62
   UserCardTooltip
63
+  WindowVHPatcher
60 64
 }
61 65
 
62 66
 # refer to variables.less

+ 28
- 0
resources/assets/lib/interfaces/notification-json.ts View File

@@ -0,0 +1,28 @@
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
+export default interface NotificationJson {
20
+  id: number;
21
+  name: string;
22
+  created_at?: string;
23
+  object_type: string;
24
+  object_id: number;
25
+  source_user_id?: number;
26
+  is_read: boolean;
27
+  details: any;
28
+}

+ 21
- 0
resources/assets/lib/interfaces/xhr-collection.ts View File

@@ -0,0 +1,21 @@
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
+export default interface XHRCollection {
20
+  [key: string]: JQueryXHR;
21
+}

+ 78
- 0
resources/assets/lib/models/notification.ts View File

@@ -0,0 +1,78 @@
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 _ from 'lodash';
20
+import { computed, observable } from 'mobx';
21
+import * as moment from 'moment';
22
+import NotificationJson from '../interfaces/notification-json';
23
+
24
+interface CategoryMap {
25
+  [key: string]: string;
26
+}
27
+
28
+const CATEGORY_MAP: CategoryMap = {
29
+  beatmapset_discussion_post_new: 'beatmapset_discussion',
30
+  beatmapset_disqualify: 'beatmapset_state',
31
+  beatmapset_love: 'beatmapset_state',
32
+  beatmapset_nominate: 'beatmapset_state',
33
+  beatmapset_qualify: 'beatmapset_state',
34
+  beatmapset_reset_nominations: 'beatmapset_state',
35
+  forum_topic_reply: 'forum_topic_reply',
36
+  legacy_pm: 'legacy_pm',
37
+};
38
+
39
+export default class Notification {
40
+  createdAtJson?: string;
41
+  details?: any;
42
+  id: number;
43
+  name?: string;
44
+  objectId?: number;
45
+  objectType?: string;
46
+  sourceUserId?: number;
47
+
48
+  @observable isRead: boolean = false;
49
+
50
+  constructor(id: number) {
51
+    this.id = id;
52
+  }
53
+
54
+  updateFromJson = (json: NotificationJson) => {
55
+    this.createdAtJson = json.created_at;
56
+    this.isRead = json.is_read;
57
+    this.name = json.name;
58
+    this.objectId = json.object_id;
59
+    this.objectType = json.object_type;
60
+    this.sourceUserId = json.source_user_id;
61
+
62
+    this.details = {};
63
+
64
+    if (typeof json.details === 'object') {
65
+      _.forEach(json.details, (value, key) => {
66
+        this.details[_.camelCase(key)] = value;
67
+      });
68
+    }
69
+  }
70
+
71
+  @computed get category() {
72
+    if (this.name == null) {
73
+      return;
74
+    }
75
+
76
+    return CATEGORY_MAP[this.name];
77
+  }
78
+}

+ 94
- 0
resources/assets/lib/notification-widget/category-group.tsx View File

@@ -0,0 +1,94 @@
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 _ from 'lodash';
20
+import { observer } from 'mobx-react';
21
+import * as React from 'react';
22
+import Notification from '../models/notification';
23
+import Item from './item';
24
+import Worker from './worker';
25
+
26
+interface Props {
27
+  items: Notification[];
28
+  worker: Worker;
29
+}
30
+
31
+interface State {
32
+  expanded: boolean;
33
+}
34
+
35
+const bn = 'notification-category-group';
36
+
37
+@observer
38
+export default class CategoryGroup extends React.Component<Props, State> {
39
+  state = {
40
+    expanded: false,
41
+  };
42
+
43
+  render() {
44
+    if (this.props.items.length === 0) {
45
+      return null;
46
+    }
47
+
48
+    let items: Notification[][];
49
+    let expandButton: React.ReactNode = null;
50
+    let blockClass = bn;
51
+    const hasToggle = this.props.items.length > 1;
52
+
53
+    if (this.props.items.length === 1) {
54
+      blockClass += ` ${bn}--single`;
55
+      items = [this.props.items];
56
+    } else {
57
+      let buttonText: string;
58
+      let buttonDirection: string;
59
+
60
+      if (this.state.expanded) {
61
+        items = this.props.items.map((item) => [item]);
62
+        buttonText = osu.trans('common.buttons.collapse');
63
+        buttonDirection = 'up';
64
+      } else {
65
+        items = [this.props.items];
66
+        buttonText = osu.formatNumber(this.props.items.length);
67
+        buttonDirection = 'down';
68
+      }
69
+
70
+      expandButton = <div className={`${bn}__expand-button`}>
71
+        <ShowMoreLink
72
+          hasMore={true}
73
+          label={buttonText}
74
+          direction={buttonDirection}
75
+          callback={this.toggleExpand}
76
+          modifiers={['t-graysky']}
77
+        />
78
+      </div>;
79
+    }
80
+
81
+    return <div className={blockClass}>
82
+      {items.map((item) => {
83
+        return <div key={item[0].id} className='notification-category-group__item'>
84
+          <Item items={item} worker={this.props.worker} />
85
+        </div>;
86
+      })}
87
+      {expandButton}
88
+    </div>;
89
+  }
90
+
91
+  toggleExpand = () => {
92
+    this.setState({ expanded: !this.state.expanded });
93
+  }
94
+}

+ 250
- 0
resources/assets/lib/notification-widget/item.tsx View File

@@ -0,0 +1,250 @@
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 _ from 'lodash';
20
+import { observer } from 'mobx-react';
21
+import * as React from 'react';
22
+import Notification from '../models/notification';
23
+import Worker from './worker';
24
+
25
+interface Props {
26
+  items: Notification[];
27
+  worker: Worker;
28
+}
29
+
30
+interface State {
31
+  markingAsRead: boolean;
32
+}
33
+
34
+interface IconsMap {
35
+  [key: string]: string[];
36
+}
37
+
38
+const ITEM_CATEGORY_ICONS: IconsMap = {
39
+  beatmapset_discussion: ['fas fa-drafting-compass', 'fas fa-comment-medical'],
40
+  beatmapset_state: ['fas fa-drafting-compass'],
41
+  forum_topic_reply: ['fas fa-comment-medical'],
42
+};
43
+
44
+const ITEM_NAME_ICONS: IconsMap = {
45
+  beatmapset_discussion_post_new: ['fas fa-drafting-compass', 'fas fa-comment-medical'],
46
+  beatmapset_disqualify: ['fas fa-drafting-compass', 'far fa-times-circle'],
47
+  beatmapset_love: ['fas fa-drafting-compass', 'fas fa-heart'],
48
+  beatmapset_nominate: ['fas fa-drafting-compass', 'fas fa-vote-yea'],
49
+  beatmapset_qualify: ['fas fa-drafting-compass', 'fas fa-check'],
50
+  beatmapset_reset_nominations: ['fas fa-drafting-compass', 'fas fa-undo'],
51
+  forum_topic_reply: ['fas fa-comment-medical'],
52
+};
53
+
54
+@observer
55
+export default class Item extends React.Component<Props, State> {
56
+  state = {
57
+    markingAsRead: false,
58
+  };
59
+
60
+  render() {
61
+    if (this.props.items.length === 0) {
62
+      return null;
63
+    }
64
+
65
+    const item = this.props.items[0];
66
+
67
+    let blockClass = 'notification-popup-item';
68
+
69
+    if (this.props.items.length > 0) {
70
+      blockClass += ' notification-popup-item--multi';
71
+    }
72
+
73
+    return <div className='notification-popup-item'>
74
+      <div
75
+        className='notification-popup-item__cover'
76
+        style={{
77
+          backgroundImage: osu.urlPresence(item.details.coverUrl),
78
+        }}
79
+      >
80
+        <div className='notification-popup-item__cover-overlay'>
81
+          {this.renderCoverIcon()}
82
+        </div>
83
+      </div>
84
+      <div className='notification-popup-item__main'>
85
+        <div className='notification-popup-item__content'>
86
+          <div className='notification-popup-item__name'>
87
+            {osu.trans(`notifications.item.${item.objectType}.${item.category}._`)}
88
+          </div>
89
+          {this.renderMessage()}
90
+          <div
91
+            className='notification-popup-item__time'
92
+            dangerouslySetInnerHTML={{
93
+              __html: osu.timeago(item.createdAtJson),
94
+            }}
95
+          />
96
+        </div>
97
+        <div className='notification-popup-item__side-buttons'>
98
+          {this.renderMarkAsReadButton()}
99
+        </div>
100
+      </div>
101
+    </div>;
102
+  }
103
+
104
+  private renderCoverIcon() {
105
+    if (this.props.items.length === 0) {
106
+      return null;
107
+    }
108
+
109
+    const item = this.props.items[0];
110
+
111
+    if (item.name == null || item.category == null) {
112
+      return null;
113
+    }
114
+
115
+    const icons = this.props.items.length === 1
116
+      ? ITEM_NAME_ICONS[item.name]
117
+      : ITEM_CATEGORY_ICONS[item.category];
118
+
119
+    if (icons == null) {
120
+      return null;
121
+    }
122
+
123
+    return icons.map((icon) => {
124
+      return <div key={icon} className='notification-popup-item__cover-icon'>
125
+        <span className={icon} />
126
+      </div>;
127
+    });
128
+  }
129
+
130
+  private renderMarkAsReadButton() {
131
+    if (this.props.items[0].id < 0) {
132
+      return null;
133
+    }
134
+
135
+    if (this.state.markingAsRead) {
136
+      return <div className='notification-popup-item__read-button'>
137
+          <Spinner />
138
+        </div>;
139
+    } else {
140
+      return <button
141
+          type='button'
142
+          className='notification-popup-item__read-button'
143
+          onClick={this.markRead}
144
+        >
145
+          <span className='fas fa-times' />
146
+        </button>;
147
+    }
148
+  }
149
+
150
+  private renderMessage() {
151
+    if (this.props.items.length === 0) {
152
+      return null;
153
+    }
154
+
155
+    const item = this.props.items[0];
156
+    let message: string;
157
+
158
+    const replacements = {
159
+      title: item.details.title,
160
+      username: item.details.username,
161
+    };
162
+
163
+    if (this.props.items.length === 1) {
164
+      const key = `notifications.item.${item.objectType}.${item.category}.${item.name}`;
165
+
166
+      if (item.name === 'legacy_pm') {
167
+        message = osu.transChoice(key, item.details.count, replacements);
168
+      } else {
169
+        message = osu.trans(key, replacements);
170
+      }
171
+    } else {
172
+      message = osu.transChoice(`notifications.message_multi`, this.props.items.length, replacements);
173
+    }
174
+
175
+    return <a href={this.url()} className='notification-popup-item__message'>
176
+      {message}
177
+    </a>;
178
+  }
179
+
180
+  private markRead = () => {
181
+    this.setState({ markingAsRead: true });
182
+    const ids = this.props.items.map((i) => i.id);
183
+
184
+    this.props.worker.sendMarkRead(ids)
185
+    .fail(() => this.setState({ markingAsRead: false }));
186
+  }
187
+
188
+  private url() {
189
+    if (this.props.items.length === 0) {
190
+      return;
191
+    }
192
+
193
+    const item = this.props.items[0];
194
+
195
+    if (item.name === 'legacy_pm') {
196
+      return '/forum/ucp.php?i=pm&folder=inbox';
197
+    }
198
+
199
+    let route: string = '';
200
+    let params: any;
201
+
202
+    if (this.props.items.length === 1) {
203
+      switch (item.name) {
204
+        case 'beatmapset_discussion_post_new':
205
+          route = 'beatmap-discussions.show';
206
+          params = { beatmap_discussion: item.details.discussionId };
207
+          break;
208
+        case 'beatmapset_disqualify':
209
+          route = 'beatmapsets.discussion';
210
+          params = { beatmapset: item.objectId };
211
+          break;
212
+        case 'beatmapset_love':
213
+          route = 'beatmapsets.show';
214
+          params = { beatmapset: item.objectId };
215
+          break;
216
+        case 'beatmapset_nominate':
217
+          route = 'beatmapsets.discussion';
218
+          params = { beatmapset: item.objectId };
219
+          break;
220
+        case 'beatmapset_qualify':
221
+          route = 'beatmapsets.discussion';
222
+          params = { beatmapset: item.objectId };
223
+          break;
224
+        case 'beatmapset_reset_nominations':
225
+          route = 'beatmapsets.discussion';
226
+          params = { beatmapset: item.objectId };
227
+          break;
228
+        case 'forum_topic_reply':
229
+          route = 'forum.posts.show';
230
+          params = { post: item.details.postId };
231
+          break;
232
+      }
233
+    } else {
234
+      switch (item.objectType) {
235
+        case 'beatmapset':
236
+          route = 'beatmapsets.discussion';
237
+          params = { beatmapset: item.objectId };
238
+          break;
239
+        case 'forum_topic':
240
+          route = 'forum.topics.show';
241
+          params = { topic: item.objectId };
242
+          break;
243
+      }
244
+    }
245
+
246
+    if (route != null) {
247
+      return laroute.route(route, params);
248
+    }
249
+  }
250
+}

+ 143
- 0
resources/assets/lib/notification-widget/main.tsx View File

@@ -0,0 +1,143 @@
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 _ from 'lodash';
20
+import { observer } from 'mobx-react';
21
+import * as React from 'react';
22
+import Notification from '../models/notification';
23
+import Item from './item';
24
+import TypeGroup from './type-group';
25
+import Worker from './worker';
26
+
27
+interface Props {
28
+  worker: Worker;
29
+  type?: string;
30
+}
31
+
32
+@observer
33
+export default class Main extends React.Component<Props, {}> {
34
+  private menuId: string;
35
+
36
+  constructor(props: Props) {
37
+    super(props);
38
+
39
+    this.menuId = `nav-notification-popup-${osu.uuid()}`;
40
+  }
41
+
42
+  render() {
43
+    if (currentUser.id == null) {
44
+      return null;
45
+    }
46
+
47
+    return <>
48
+      <button
49
+        className={this.buttonClass()}
50
+        data-click-menu-target={this.menuId}
51
+      >
52
+        <span className={this.mainClass()}>
53
+          <i className='fas fa-inbox' />
54
+          <span className='notification-icon__count'>
55
+            {this.unreadCount()}
56
+          </span>
57
+        </span>
58