From 999999e3efab7b1aad7568e4fd114dc7bac04f38 Mon Sep 17 00:00:00 2001 From: Ola Hneini Date: Fri, 12 Aug 2022 16:02:56 +0000 Subject: [PATCH] feat: add notifications inbox for actors closes #215 --- .../2022-07-26-091451_AddNotifications.php | 75 ++++++++++++ ...2022-07-28-143030_AddActivitiesTrigger.php | 62 ++++++++++ app/Entities/Notification.php | 106 +++++++++++++++++ app/Models/NotificationModel.php | 47 ++++++++ app/Resources/icons/notification-bell.svg | 6 + app/Resources/icons/user-follow.svg | 6 + modules/Admin/Config/Routes.php | 13 +++ .../Controllers/NotificationController.php | 107 ++++++++++++++++++ modules/Admin/Language/en/Breadcrumb.php | 1 + modules/Admin/Language/en/Notifications.php | 19 ++++ modules/Auth/Entities/User.php | 28 +++++ modules/Fediverse/Models/ActivityModel.php | 2 +- themes/cp_admin/_partials/_nav_header.php | 81 ++++++++++--- themes/cp_admin/podcast/notifications.php | 104 +++++++++++++++++ themes/cp_app/_admin_navbar.php | 79 ++++++++++--- 15 files changed, 707 insertions(+), 29 deletions(-) create mode 100644 app/Database/Migrations/2022-07-26-091451_AddNotifications.php create mode 100644 app/Database/Migrations/2022-07-28-143030_AddActivitiesTrigger.php create mode 100644 app/Entities/Notification.php create mode 100644 app/Models/NotificationModel.php create mode 100644 app/Resources/icons/notification-bell.svg create mode 100644 app/Resources/icons/user-follow.svg create mode 100644 modules/Admin/Controllers/NotificationController.php create mode 100644 modules/Admin/Language/en/Notifications.php create mode 100644 themes/cp_admin/podcast/notifications.php diff --git a/app/Database/Migrations/2022-07-26-091451_AddNotifications.php b/app/Database/Migrations/2022-07-26-091451_AddNotifications.php new file mode 100644 index 00000000..cddfcdc9 --- /dev/null +++ b/app/Database/Migrations/2022-07-26-091451_AddNotifications.php @@ -0,0 +1,75 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'auto_increment' => true, + ], + 'actor_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'target_actor_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'post_id' => [ + 'type' => 'BINARY', + 'constraint' => 16, + 'null' => true, + ], + 'activity_id' => [ + 'type' => 'BINARY', + 'constraint' => 16, + ], + 'type' => [ + 'type' => 'ENUM', + 'constraint' => ['like', 'follow', 'share', 'reply'], + ], + 'read_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + ], + 'updated_at' => [ + 'type' => 'DATETIME', + ], + ]); + + $tablesPrefix = config('Fediverse') + ->tablesPrefix; + + $this->forge->addPrimaryKey('id'); + $this->forge->addForeignKey('actor_id', $tablesPrefix . 'actors', 'id', '', 'CASCADE'); + $this->forge->addForeignKey('target_actor_id', $tablesPrefix . 'actors', 'id', '', 'CASCADE'); + $this->forge->addForeignKey('post_id', $tablesPrefix . 'posts', 'id', '', 'CASCADE'); + $this->forge->addForeignKey('activity_id', $tablesPrefix . 'activities', 'id', '', 'CASCADE'); + $this->forge->createTable('notifications'); + } + + public function down(): void + { + $this->forge->dropTable('notifications'); + } +} diff --git a/app/Database/Migrations/2022-07-28-143030_AddActivitiesTrigger.php b/app/Database/Migrations/2022-07-28-143030_AddActivitiesTrigger.php new file mode 100644 index 00000000..1024ad7b --- /dev/null +++ b/app/Database/Migrations/2022-07-28-143030_AddActivitiesTrigger.php @@ -0,0 +1,62 @@ +db->prefixTable(config('Fediverse')->tablesPrefix . 'activities'); + $notificationsTable = $this->db->prefixTable('notifications'); + $createQuery = <<db->query($createQuery); + } + + public function down(): void + { + $activitiesTable = $this->db->prefixTable(config('Fediverse')->tablesPrefix . 'activities'); + $this->db->query("DROP TRIGGER IF EXISTS `{$activitiesTable}_after_insert`"); + } +} diff --git a/app/Entities/Notification.php b/app/Entities/Notification.php new file mode 100644 index 00000000..b878228f --- /dev/null +++ b/app/Entities/Notification.php @@ -0,0 +1,106 @@ + + */ + protected $casts = [ + 'id' => 'integer', + 'actor_id' => 'integer', + 'target_actor_id' => 'integer', + 'post_id' => '?string', + 'activity_id' => 'string', + 'type' => 'string', + ]; + + public function getActor(): ?Actor + { + if ($this->actor_id === null) { + throw new RuntimeException('Notification must have an actor_id before getting actor.'); + } + + if (! $this->actor instanceof Actor) { + $this->actor = (new ActorModel())->getActorById($this->actor_id); + } + + return $this->actor; + } + + public function getTargetActor(): ?Actor + { + if ($this->target_actor_id === null) { + throw new RuntimeException('Notification must have a target_actor_id before getting target actor.'); + } + + if (! $this->target_actor instanceof Actor) { + $this->target_actor = (new ActorModel())->getActorById($this->target_actor_id); + } + + return $this->target_actor; + } + + public function getPost(): ?Post + { + if ($this->post_id === null) { + throw new RuntimeException('Notification must have a post_id before getting post.'); + } + + if (! $this->post instanceof Post) { + $this->post = (new PostModel())->getPostById($this->post_id); + } + + return $this->post; + } +} diff --git a/app/Models/NotificationModel.php b/app/Models/NotificationModel.php new file mode 100644 index 00000000..10ce851a --- /dev/null +++ b/app/Models/NotificationModel.php @@ -0,0 +1,47 @@ + + + + + + \ No newline at end of file diff --git a/app/Resources/icons/user-follow.svg b/app/Resources/icons/user-follow.svg new file mode 100644 index 00000000..e8892f28 --- /dev/null +++ b/app/Resources/icons/user-follow.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php index d6c58357..8a2f0611 100644 --- a/modules/Admin/Config/Routes.php +++ b/modules/Admin/Config/Routes.php @@ -626,6 +626,19 @@ $routes->group( ], ); }); + + // Podcast notifications + $routes->group('notifications', function ($routes): void { + $routes->get('/', 'NotificationController::list/$1', [ + 'as' => 'notification-list', + ]); + $routes->get('(:num)/mark-as-read', 'NotificationController::markAsRead/$1/$2', [ + 'as' => 'notification-mark-as-read', + ]); + $routes->get('mark-all-as-read', 'NotificationController::markAllAsRead/$1', [ + 'as' => 'notification-mark-all-as-read', + ]); + }); }); }); diff --git a/modules/Admin/Controllers/NotificationController.php b/modules/Admin/Controllers/NotificationController.php new file mode 100644 index 00000000..c3191c84 --- /dev/null +++ b/modules/Admin/Controllers/NotificationController.php @@ -0,0 +1,107 @@ +getPodcastById((int) $params[0])) === null + ) { + throw PageNotFoundException::forPageNotFound(); + } + + $this->podcast = $podcast; + + if (count($params) > 1) { + if ( + ! ($notification = (new NotificationModel()) + ->where([ + 'id' => $params[1], + ]) + ->first()) + ) { + throw PageNotFoundException::forPageNotFound(); + } + + $this->notification = $notification; + + unset($params[1]); + unset($params[0]); + } + + return $this->{$method}(...$params); + } + + public function list(): string + { + $notifications = (new NotificationModel())->where('target_actor_id', $this->podcast->actor_id) + ->orderBy('created_at', 'desc'); + + $data = [ + 'podcast' => $this->podcast, + 'notifications' => $notifications->paginate(10), + 'pager' => $notifications->pager, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + ]); + + return view('podcast/notifications', $data); + } + + public function markAsRead(): RedirectResponse + { + $this->notification->read_at = new Time('now'); + $notificationModel = new NotificationModel(); + $notificationModel->update($this->notification->id, $this->notification); + + if ($this->notification->post_id === null) { + return redirect()->route('podcast-activity', [esc($this->podcast->handle)]); + } + + $post = (new PostModel())->getPostById($this->notification->post_id); + + return redirect()->route( + 'post', + [esc((new PodcastModel())->getPodcastByActorId($this->notification->actor_id)->handle), $post->id] + ); + } + + public function markAllAsRead(): RedirectResponse + { + $notifications = (new NotificationModel())->where('target_actor_id', $this->podcast->actor_id) + ->where('read_at', null) + ->findAll(); + + foreach ($notifications as $notification) { + $notification->read_at = new Time('now'); + (new NotificationModel())->update($notification->id, $notification); + } + + return redirect()->back(); + } +} diff --git a/modules/Admin/Language/en/Breadcrumb.php b/modules/Admin/Language/en/Breadcrumb.php index d9400ca7..24bece01 100644 --- a/modules/Admin/Language/en/Breadcrumb.php +++ b/modules/Admin/Language/en/Breadcrumb.php @@ -45,4 +45,5 @@ return [ 'soundbites' => 'soundbites', 'video-clips' => 'video clips', 'embed' => 'embeddable player', + 'notifications' => 'notifications', ]; diff --git a/modules/Admin/Language/en/Notifications.php b/modules/Admin/Language/en/Notifications.php new file mode 100644 index 00000000..1772ba76 --- /dev/null +++ b/modules/Admin/Language/en/Notifications.php @@ -0,0 +1,19 @@ + 'Notifications', + 'reply' => '{actor_username} replied to your post', + 'favourite' => '{actor_username} favourited your post', + 'reblog' => '{actor_username} shared your post', + 'follow' => '{actor_username} started following {target_actor_username}', + 'no_notifications' => 'No notifications', + 'mark_all_as_read' => 'Mark all as read', +]; diff --git a/modules/Auth/Entities/User.php b/modules/Auth/Entities/User.php index 37805b20..c94e23f6 100644 --- a/modules/Auth/Entities/User.php +++ b/modules/Auth/Entities/User.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Modules\Auth\Entities; use App\Entities\Podcast; +use App\Models\NotificationModel; use App\Models\PodcastModel; use Myth\Auth\Entities\User as MythAuthUser; use RuntimeException; @@ -26,6 +27,7 @@ use RuntimeException; * @property string|null $podcast_role * * @property Podcast[] $podcasts All podcasts the user is contributing to + * @property int[] $actorIdsWithUnreadNotifications Ids of the user's actors that have unread notifications */ class User extends MythAuthUser { @@ -34,6 +36,11 @@ class User extends MythAuthUser */ protected ?array $podcasts = null; + /** + * @var int[]|null + */ + protected ?array $actorIdsWithUnreadNotifications = null; + /** * Array of field names and the type of value to cast them as when they are accessed. * @@ -64,4 +71,25 @@ class User extends MythAuthUser return $this->podcasts; } + + /** + * Returns the ids of the user's actors that have unread notifications + * + * @return int[] + */ + public function getActorIdsWithUnreadNotifications(): array + { + if ($this->getPodcasts() === []) { + return []; + } + + $unreadNotifications = (new NotificationModel())->whereIn( + 'target_actor_id', + array_column($this->getPodcasts(), 'actor_id') + ) + ->where('read_at', null) + ->findAll(); + + return array_column($unreadNotifications, 'target_actor_id'); + } } diff --git a/modules/Fediverse/Models/ActivityModel.php b/modules/Fediverse/Models/ActivityModel.php index fd74f7ed..31b9659e 100644 --- a/modules/Fediverse/Models/ActivityModel.php +++ b/modules/Fediverse/Models/ActivityModel.php @@ -97,7 +97,7 @@ class ActivityModel extends BaseUuidModel 'actor_id' => $actorId, 'target_actor_id' => $targetActorId, 'post_id' => $postId, - 'type' => $type, + 'type' => $type === 'Undo' ? $type . '_' . (json_decode($payload, true))['object']['type'] : $type, 'payload' => $payload, 'scheduled_at' => $scheduledAt, 'status' => $taskStatus, diff --git a/themes/cp_admin/_partials/_nav_header.php b/themes/cp_admin/_partials/_nav_header.php index e054fd0a..b3711aa9 100644 --- a/themes/cp_admin/_partials/_nav_header.php +++ b/themes/cp_admin/_partials/_nav_header.php @@ -15,20 +15,73 @@ - +
+ + 'html', + 'content' => esc(<<{$notificationsTitle} + CODE_SAMPLE), + ], + ]; + + if (user()->podcasts !== []) { + foreach (user()->podcasts as $userPodcast) { + $userPodcastTitle = esc($userPodcast->title); + + $unreadNotificationDotDisplayClass = in_array($userPodcast->actor_id, user()->actorIdsWithUnreadNotifications, true) ? '' : 'hidden'; + + $items[] = [ + 'type' => 'link', + 'title' => << +
+ + +
+ {$userPodcastTitle} +
+ CODE_SAMPLE + , + 'uri' => route_to('notification-list', $userPodcast->id), + ]; + } + } else { + $noNotificationsText = lang('Notifications.no_notifications'); + $items[] = [ + 'type' => 'html', + 'content' => esc(<<{$noNotificationsText} + CODE_SAMPLE), + ]; + } + ?> + + + + podcasts as $userPodcast) { diff --git a/themes/cp_admin/podcast/notifications.php b/themes/cp_admin/podcast/notifications.php new file mode 100644 index 00000000..e5a4454e --- /dev/null +++ b/themes/cp_admin/podcast/notifications.php @@ -0,0 +1,104 @@ +extend('_layout') ?> + +section('title') ?> + +endSection() ?> + +section('pageTitle') ?> + +endSection() ?> + +section('headerRight') ?> + +endSection() ?> + +section('content') ?> + +
+ +
+ read_at === null ? 'bg-heading-background' : 'bg-base'; + ?> +
+ post_id !== null ? $notification->post : null; + + $actorUsername = '@' . esc($notification->actor + ->username) . + ($notification->actor->is_local + ? '' + : '@' . esc($notification->actor->domain)); + + $actorUsernameHtml = <<{$actorUsername} + CODE_SAMPLE; + + $targetActorUsername = '@' . esc($notification->target_actor->username); + + $targetActorUsernameHtml = <<{$targetActorUsername} + CODE_SAMPLE; + + $notificationTitle = match ($notification->type) { + 'reply' => lang('Notifications.reply', [ + 'actor_username' => $actorUsernameHtml, + ], null, false), + 'like' => lang('Notifications.favourite', [ + 'actor_username' => $actorUsernameHtml, + ], null, false), + 'share' => lang('Notifications.reblog', [ + 'actor_username' => $actorUsernameHtml, + ], null, false), + 'follow' => lang('Notifications.follow', [ + 'actor_username' => $actorUsernameHtml, + 'target_actor_username' => $targetActorUsernameHtml, + ], null, false), + default => '', + }; + $notificationContent = $post !== null ? $post->message_html : null; + + $postLink = $post !== null ? route_to('post', esc($podcast->handle), $post->id) : route_to('podcast-activity', esc($podcast->handle)); + $link = $notification->read_at !== null ? $postLink : route_to('notification-mark-as-read', $podcast->id, $notification->id); + ?> + +
+
+ +
+ <?= esc($notification->actor->display_name) ?> + + type) { + 'reply' => icon('chat', 'text-sky-500 text-base'), + 'like' => icon('heart', 'text-rose-500 text-base'), + 'share' => icon('repeat', 'text-green-500 text-base'), + 'follow' => icon('user-follow', 'text-violet-500 text-base'), + default => '', + }; + ?> + + +
+
+
+
+ + +

+ +
+ created_at) ?> +
+
+
+
+ +
+ +
links() ?>
+ + + +endsection() ?> diff --git a/themes/cp_app/_admin_navbar.php b/themes/cp_app/_admin_navbar.php index da1def26..15f542f4 100644 --- a/themes/cp_app/_admin_navbar.php +++ b/themes/cp_app/_admin_navbar.php @@ -10,20 +10,71 @@
- + + 'html', + 'content' => esc(<<{$notificationsTitle} + CODE_SAMPLE), + ], + ]; + + if (user()->podcasts !== []) { + foreach (user()->podcasts as $userPodcast) { + $userPodcastTitle = esc($userPodcast->title); + + $unreadNotificationDotDisplayClass = in_array($userPodcast->actor_id, user()->actorIdsWithUnreadNotifications, true) ? '' : 'hidden'; + + $items[] = [ + 'type' => 'link', + 'title' => << +
+ + +
+ {$userPodcastTitle} +
+ CODE_SAMPLE + , + 'uri' => route_to('notification-list', $userPodcast->id), + ]; + } + } else { + $noNotificationsText = lang('Notifications.no_notifications'); + $items[] = [ + 'type' => 'html', + 'content' => esc(<<{$noNotificationsText} + CODE_SAMPLE), + ]; + } + ?> + + + podcasts as $userPodcast) {