feat: add notifications inbox for actors

closes #215
This commit is contained in:
Ola Hneini 2022-08-12 16:02:56 +00:00 committed by Yassine Doghri
parent c29c018c7a
commit 999999e3ef
15 changed files with 707 additions and 29 deletions

View File

@ -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');
}
}

View File

@ -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`");
}
}

View File

@ -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;
}
}

View File

@ -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'];
}

View File

@ -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>

After

Width:  |  Height:  |  Size: 204 B

View File

@ -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>

After

Width:  |  Height:  |  Size: 331 B

View File

@ -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',
]);
});
});
});

View File

@ -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();
}
}

View File

@ -45,4 +45,5 @@ return [
'soundbites' => 'soundbites',
'video-clips' => 'video clips',
'embed' => 'embeddable player',
'notifications' => 'notifications',
];

View File

@ -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',
];

View File

@ -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');
}
}

View File

@ -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,

View File

@ -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) {

View File

@ -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() ?>

View File

@ -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) {