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 0000000000000000000000000000000000000000..cddfcdc97ae3ad8a746707c999d1eae6d0815941 --- /dev/null +++ b/app/Database/Migrations/2022-07-26-091451_AddNotifications.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/** + * Class AddNotifications Creates notifications table in database + * + * @copyright 2021 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Database\Migrations; + +use CodeIgniter\Database\Migration; + +class AddNotifications extends Migration +{ + public function up(): void + { + $this->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 0000000000000000000000000000000000000000..1024ad7b7ffae6ef10ff647e16b765db31d308b3 --- /dev/null +++ b/app/Database/Migrations/2022-07-28-143030_AddActivitiesTrigger.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +/** + * Class AddActivitiesTrigger Creates activities trigger in database + * + * @copyright 2020 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Database\Migrations; + +use CodeIgniter\Database\Migration; + +class AddNotificationsTrigger extends Migration +{ + public function up(): void + { + $activitiesTable = $this->db->prefixTable(config('Fediverse')->tablesPrefix . 'activities'); + $notificationsTable = $this->db->prefixTable('notifications'); + $createQuery = <<<CODE_SAMPLE + CREATE TRIGGER `{$activitiesTable}_after_insert` + AFTER INSERT ON `{$activitiesTable}` + FOR EACH ROW + BEGIN + -- only create notification if new incoming activity with NULL status is created + IF NEW.target_actor_id AND NEW.target_actor_id != NEW.actor_id AND NEW.status IS NULL THEN + IF NEW.type IN ( 'Create', 'Like', 'Announce', 'Follow' ) THEN + SET @type = (CASE + WHEN NEW.type = 'Create' THEN 'reply' + WHEN NEW.type = 'Like' THEN 'like' + WHEN NEW.type = 'Announce' THEN 'share' + WHEN NEW.type = 'Follow' THEN 'follow' + END); + INSERT INTO `{$notificationsTable}` (`actor_id`, `target_actor_id`, `post_id`, `activity_id`, `type`, `created_at`, `updated_at`) + VALUES (NEW.actor_id, NEW.target_actor_id, NEW.post_id, NEW.id, @type, NEW.created_at, NEW.created_at); + ELSE + DELETE FROM `{$notificationsTable}` + WHERE `actor_id` = NEW.actor_id + AND `target_actor_id` = NEW.target_actor_id + AND ((`type` = (CASE WHEN NEW.type = 'Undo_Follow' THEN 'follow' END) AND `post_id` IS NULL) + OR (`type` = (CASE + WHEN NEW.type = 'Delete' THEN 'reply' + WHEN NEW.type = 'Undo_Like' THEN 'like' + WHEN NEW.type = 'Undo_Announce' THEN 'share' + END) + AND `post_id` = NEW.post_id)); + END IF; + END IF; + END + CODE_SAMPLE; + $this->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 0000000000000000000000000000000000000000..b878228fa861227bd5a1f9eb9c5c9b02ee93fd04 --- /dev/null +++ b/app/Entities/Notification.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities; + +use Michalsn\Uuid\UuidEntity; +use Modules\Fediverse\Entities\Activity; +use Modules\Fediverse\Entities\Actor; +use Modules\Fediverse\Entities\Post; +use Modules\Fediverse\Models\ActorModel; +use Modules\Fediverse\Models\PostModel; +use RuntimeException; + +/** + * @property int $id + * @property int $actor_id + * @property Actor $actor + * @property int $target_actor_id + * @property Actor $target_actor + * @property string|null $post_id + * @property Post $post + * @property string $activity_id + * @property Activity $activity + * @property 'like'|'follow'|'share'|'reply' $type + * @property Time|null $read_at + * @property Time $created_at + * @property Time $updated_at + */ +class Notification extends UuidEntity +{ + protected ?Actor $actor = null; + + protected ?Actor $target_actor = null; + + protected ?Post $post = null; + + protected ?Activity $activity = null; + + /** + * @var string[] + */ + protected $uuids = ['post_id', 'activity_id']; + + /** + * @var string[] + */ + protected $dates = ['read_at', 'created_at', 'updated_at']; + + /** + * @var array<string, string> + */ + 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 0000000000000000000000000000000000000000..10ce851ade76334ce54767f9c621145d00f18f32 --- /dev/null +++ b/app/Models/NotificationModel.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Models; + +use App\Entities\Notification; +use Michalsn\Uuid\UuidModel; + +class NotificationModel extends UuidModel +{ + /** + * @var string + */ + protected $table = 'notifications'; + + /** + * @var string + */ + protected $primaryKey = 'id'; + + /** + * @var string + */ + protected $returnType = Notification::class; + + /** + * @var bool + */ + protected $useTimestamps = true; + + /** + * @var string[] + */ + protected $uuidFields = ['post_id', 'activity_id']; + + /** + * @var string[] + */ + protected $allowedFields = ['read_at']; +} diff --git a/app/Resources/icons/notification-bell.svg b/app/Resources/icons/notification-bell.svg new file mode 100644 index 0000000000000000000000000000000000000000..ea792a4d93f140b764d4709b1f549f61e51da316 --- /dev/null +++ b/app/Resources/icons/notification-bell.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g> + <path fill="none" d="M0 0h24v24H0z"/> + <path d="M20 17h2v2H2v-2h2v-7a8 8 0 1 1 16 0v7zM9 21h6v2H9v-2z"/> + </g> +</svg> \ 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 0000000000000000000000000000000000000000..e8892f2823138e863a2c759c6f9d8aaba76f8ea3 --- /dev/null +++ b/app/Resources/icons/user-follow.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g> + <path fill="none" d="M0 0h24v24H0z"/> + <path d="M13 14.062V22H4a8 8 0 0 1 9-7.938zM12 13c-3.315 0-6-2.685-6-6s2.685-6 6-6 6 2.685 6 6-2.685 6-6 6zm5.793 6.914l3.535-3.535 1.415 1.414-4.95 4.95-3.536-3.536 1.415-1.414 2.12 2.121z"/> + </g> +</svg> \ No newline at end of file diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php index d6c58357a576a3a26fa7e71814c2e620652049b5..8a2f0611fbf4bd399705e086bbd88668076d9b7d 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 0000000000000000000000000000000000000000..c3191c840ef6e866262749608681a4be446cb579 --- /dev/null +++ b/modules/Admin/Controllers/NotificationController.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\Admin\Controllers; + +use App\Entities\Notification; +use App\Entities\Podcast; +use App\Models\NotificationModel; +use App\Models\PodcastModel; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\I18n\Time; +use Modules\Fediverse\Models\PostModel; + +class NotificationController extends BaseController +{ + protected Podcast $podcast; + + protected Notification $notification; + + public function _remap(string $method, string ...$params): mixed + { + if ( + ($podcast = (new PodcastModel())->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 d9400ca786ad22820c4bda166f6f11e61a0cb199..24bece0140b8e7cbd6edf835d096f76a43f97e70 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 0000000000000000000000000000000000000000..1772ba76b290dc6ec713315c062d70e95dcde28a --- /dev/null +++ b/modules/Admin/Language/en/Notifications.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +return [ + 'title' => '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 37805b20f0b0c781ab735591488621c4bc5c1165..c94e23f603cc07c428245476667c988e435495de 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 fd74f7edd988b276583c22a7c1daa56e09ac3e83..31b9659ead5a25d1a145db987af305825422ea17 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 e054fd0a855def499dd7af7614d088bae19ff78b..b3711aa956ab4f8b2dc778a5d70d9ef1227e730d 100644 --- a/themes/cp_admin/_partials/_nav_header.php +++ b/themes/cp_admin/_partials/_nav_header.php @@ -15,20 +15,73 @@ <?= icon('external-link', 'ml-1 opacity-60') ?> </a> </div> - <button - type="button" - class="inline-flex items-center h-full px-3 ml-auto text-sm font-semibold focus:ring-inset focus:ring-accent gap-x-2" - id="my-account-dropdown" - data-dropdown="button" - data-dropdown-target="my-account-dropdown-menu" - aria-haspopup="true" - aria-expanded="false"><div class="relative mr-1"> - <?= icon('account-circle', 'text-3xl opacity-60') ?> - <?= user() - ->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" loading="lazy" />' ?> - </div> - <?= esc(user()->username) ?> - <?= icon('caret-down', 'ml-auto text-2xl') ?></button> + <div class="inline-flex items-center h-full ml-auto"> + <button type="button" class="relative h-full px-2 focus:ring-accent focus:ring-inset" id="notifications-dropdown" data-dropdown="button" data-dropdown-target="notifications-dropdown-menu" aria-haspopup="true" aria-expanded="false"> + <?= icon('notification-bell', 'text-2xl opacity-80') ?> + <?php if (user()->actorIdsWithUnreadNotifications !== []): ?> + <span class="absolute top-2 right-2 w-2.5 h-2.5 bg-red-500 rounded-full border border-navigation-bg"></span> + <?php endif ?> + </button> + <?php + $notificationsTitle = lang('Notifications.title'); + + $items = [ + [ + 'type' => 'html', + 'content' => esc(<<<CODE_SAMPLE + <span class="px-4 my-2 text-xs font-semibold tracking-wider uppercase text-skin-muted">{$notificationsTitle}</span> + 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' => <<<CODE_SAMPLE + <div class="inline-flex items-center flex-1 text-sm align-middle"> + <div class="relative"> + <img src="{$userPodcast->cover->tiny_url}" class="w-6 h-6 mr-2 rounded-full" loading="lazy" /> + <span class="absolute top-0 right-1 w-2.5 h-2.5 bg-red-500 rounded-full border border-background-elevated {$unreadNotificationDotDisplayClass}"></span> + </div> + <span class="max-w-xs truncate">{$userPodcastTitle}</span> + </div> + CODE_SAMPLE + , + 'uri' => route_to('notification-list', $userPodcast->id), + ]; + } + } else { + $noNotificationsText = lang('Notifications.no_notifications'); + $items[] = [ + 'type' => 'html', + 'content' => esc(<<<CODE_SAMPLE + <span class="mx-4 my-2 text-sm italic text-center text-skin-muted">{$noNotificationsText}</span> + CODE_SAMPLE), + ]; + } + ?> + <DropdownMenu id="notifications-dropdown-menu" labelledby="notifications-dropdown" items="<?= esc(json_encode($items)) ?>" placement="bottom"/> + + <button + type="button" + class="inline-flex items-center h-full px-3 text-sm font-semibold focus:ring-inset focus:ring-accent gap-x-2" + id="my-account-dropdown" + data-dropdown="button" + data-dropdown-target="my-account-dropdown-menu" + aria-haspopup="true" + aria-expanded="false"><div class="relative mr-1"> + <?= icon('account-circle', 'text-3xl opacity-60') ?> + <?= user() + ->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" loading="lazy" />' ?> + </div> + <?= esc(user()->username) ?> + <?= icon('caret-down', 'ml-auto text-2xl') ?></button> + </div> <?php $interactButtons = ''; foreach (user()->podcasts as $userPodcast) { diff --git a/themes/cp_admin/podcast/notifications.php b/themes/cp_admin/podcast/notifications.php new file mode 100644 index 0000000000000000000000000000000000000000..e5a4454e561f686cd3400b4d89ec166e9fc0580a --- /dev/null +++ b/themes/cp_admin/podcast/notifications.php @@ -0,0 +1,104 @@ +<?= $this->extend('_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Notifications.title') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Notifications.title') ?> +<?= $this->endSection() ?> + +<?= $this->section('headerRight') ?> +<Button uri="<?= route_to('notification-mark-all-as-read', $podcast->actor_id) ?>" variant="primary"><?= lang('Notifications.mark_all_as_read') ?></Button> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> +<?php if ($notifications === []): ?> + <div class="text-sm italic text-center text-skin-muted"><?= lang('Notifications.no_notifications') ?></div> +<?php else: ?> + <div class="-mx-2 -mt-8 border-b divide-y md:-mx-12"> + <?php + foreach ($notifications as $notification): + $backgroundColor = $notification->read_at === null ? 'bg-heading-background' : 'bg-base'; + ?> + <div class="py-3 hover:bg-white px-4 <?= $backgroundColor ?> group"> + <?php + $post = $notification->post_id !== null ? $notification->post : null; + + $actorUsername = '@' . esc($notification->actor + ->username) . + ($notification->actor->is_local + ? '' + : '@' . esc($notification->actor->domain)); + + $actorUsernameHtml = <<<CODE_SAMPLE + <strong class="break-all">{$actorUsername}</strong> + CODE_SAMPLE; + + $targetActorUsername = '@' . esc($notification->target_actor->username); + + $targetActorUsernameHtml = <<<CODE_SAMPLE + <strong class="break-all">{$targetActorUsername}</strong> + 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); + ?> + <a href="<?= $link ?>"> + <div class="flex items-start md:items-center"> + <div class="flex items-center shrink-0"> + <span class="w-2 h-2 bg-red-500 rounded-full <?= $notification->read_at === null ? '' : 'invisible' ?>"></span> + <div class="relative ml-4"> + <img src="<?= $notification->actor->avatar_image_url ?>" alt="<?= esc($notification->actor->display_name) ?>" class="rounded-full shadow-inner w-14 h-14 aspect-square" loading="lazy" /> + <span class="absolute bottom-0 w-6 h-6 rounded-full -right-2.5 flex justify-center items-center <?= $backgroundColor ?> group-hover:bg-white"> + <?php + $icon = match ($notification->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 => '', + }; + ?> + <?= $icon ?> + </span> + </div> + </div> + <div class="ml-5 md:flex md:items-center grow"> + <div class="grow"> + <?= $notificationTitle ?> + <?php if ($notificationContent !== null): ?> + <p class="overflow-y-hidden text-skin-muted line-clamp-2 md:line-clamp-1"><?= $notificationContent ?></p> + <?php endif; ?> + </div> + <span class="text-xs text-skin-muted md:ml-auto md:mr-4 whitespace-nowrap"><?= relative_time($notification->created_at) ?></span> + </div> + </div> + </a> + </div> + <?php endforeach; ?> + </div> + + <div class="mt-6"><?= $pager->links() ?></div> + +<?php endif ?> + +<?= $this->endsection() ?> diff --git a/themes/cp_app/_admin_navbar.php b/themes/cp_app/_admin_navbar.php index da1def26d02c4a360f1941ef7db0f94b06503531..15f542f4456fa93260a55a79c97c47386f2e4597 100644 --- a/themes/cp_app/_admin_navbar.php +++ b/themes/cp_app/_admin_navbar.php @@ -10,20 +10,71 @@ </div> <div class="inline-flex items-center h-full"> - <button - type="button" - class="inline-flex items-center h-full px-3 text-sm font-semibold focus:ring-inset focus:ring-accent gap-x-2" - id="my-account-dropdown" - data-dropdown="button" - data-dropdown-target="my-account-dropdown-menu" - aria-haspopup="true" - aria-expanded="false"><div class="relative mr-1"> - <?= icon('account-circle', 'text-3xl opacity-60') ?> - <?= user() - ->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" loading="lazy" />' ?> - </div> - <?= esc(user()->username) ?> - <?= icon('caret-down', 'ml-auto text-2xl') ?></button> + <button type="button" class="relative h-full px-2 focus:ring-accent focus:ring-inset" id="notifications-dropdown" data-dropdown="button" data-dropdown-target="notifications-dropdown-menu" aria-haspopup="true" aria-expanded="false"> + <?= icon('notification-bell', 'text-2xl opacity-80') ?> + <?php if (user()->actorIdsWithUnreadNotifications !== []): ?> + <span class="absolute top-2 right-2 w-2.5 h-2.5 bg-red-500 rounded-full border border-navigation-bg"></span> + <?php endif ?> + </button> + <?php + $notificationsTitle = lang('Notifications.title'); + + $items = [ + [ + 'type' => 'html', + 'content' => esc(<<<CODE_SAMPLE + <span class="px-4 my-2 text-xs font-semibold tracking-wider uppercase text-skin-muted">{$notificationsTitle}</span> + 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' => <<<CODE_SAMPLE + <div class="inline-flex items-center flex-1 text-sm align-middle"> + <div class="relative"> + <img src="{$userPodcast->cover->tiny_url}" class="w-6 h-6 mr-2 rounded-full" loading="lazy" /> + <span class="absolute top-0 right-1 w-2.5 h-2.5 bg-red-500 rounded-full border border-background-elevated {$unreadNotificationDotDisplayClass}"></span> + </div> + <span class="max-w-xs truncate">{$userPodcastTitle}</span> + </div> + CODE_SAMPLE + , + 'uri' => route_to('notification-list', $userPodcast->id), + ]; + } + } else { + $noNotificationsText = lang('Notifications.no_notifications'); + $items[] = [ + 'type' => 'html', + 'content' => esc(<<<CODE_SAMPLE + <span class="mx-4 my-2 text-sm italic text-center text-skin-muted">{$noNotificationsText}</span> + CODE_SAMPLE), + ]; + } + ?> + <DropdownMenu id="notifications-dropdown-menu" labelledby="notifications-dropdown" items="<?= esc(json_encode($items)) ?>" placement="bottom"/> + + <button + type="button" + class="inline-flex items-center h-full px-3 text-sm font-semibold focus:ring-inset focus:ring-accent gap-x-2" + id="my-account-dropdown" + data-dropdown="button" + data-dropdown-target="my-account-dropdown-menu" + aria-haspopup="true" + aria-expanded="false"><div class="relative mr-1"> + <?= icon('account-circle', 'text-3xl opacity-60') ?> + <?= user() + ->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" loading="lazy" />' ?> + </div> + <?= esc(user()->username) ?> + <?= icon('caret-down', 'ml-auto text-2xl') ?></button> <?php $interactButtons = ''; foreach (user()->podcasts as $userPodcast) {