diff options
| author | winter | 2025-01-19 22:17:12 +0000 |
|---|---|---|
| committer | winter | 2025-01-19 22:17:12 +0000 |
| commit | 8c6dad8c48d5d730b102d9de0079fee9752b0d35 (patch) | |
| tree | 2cfe07a8c7958f78c767fd0d1444c3f8fef70511 | |
| parent | 2d9bd87fb1c565ee161f93219ce2533e8dbffe06 (diff) | |
implement notifications
| -rw-r--r-- | Digitigrade/Job/ProcessIncomingPush.php | 2 | ||||
| -rw-r--r-- | Digitigrade/Model/HomeTimelineItem.php (renamed from Digitigrade/Timeline/HomeTimelineItem.php) | 3 | ||||
| -rw-r--r-- | Digitigrade/Model/Interaction.php | 37 | ||||
| -rw-r--r-- | Digitigrade/Model/Note.php | 32 | ||||
| -rw-r--r-- | Digitigrade/Model/Notification.php | 57 | ||||
| -rw-r--r-- | Digitigrade/Notification/Notifyable.php | 12 | ||||
| -rw-r--r-- | Digitigrade/Timeline/HomeTimeline.php | 1 | ||||
| -rwxr-xr-x | bin/cleandb | 3 | ||||
| -rw-r--r-- | locale/en_GB.json | 5 | ||||
| -rw-r--r-- | migrations/20250119_194145_create_notification.php | 16 | ||||
| -rw-r--r-- | routes/note_fragment.php | 5 | ||||
| -rw-r--r-- | routes/notifications.php | 31 | ||||
| -rw-r--r-- | static/form.css | 4 | ||||
| -rw-r--r-- | static/notifications.css | 69 | ||||
| -rw-r--r-- | static/skeleton.css | 9 | ||||
| -rw-r--r-- | static/style.css | 1 | ||||
| -rw-r--r-- | templates/live_notifications_updater.php | 4 | ||||
| -rw-r--r-- | templates/notification.php | 23 | ||||
| -rw-r--r-- | templates/notifications_panel.php | 26 | ||||
| -rw-r--r-- | templates/skeleton.php | 72 | ||||
| -rw-r--r-- | templates/write_note_form.php | 4 |
21 files changed, 371 insertions, 45 deletions
diff --git a/Digitigrade/Job/ProcessIncomingPush.php b/Digitigrade/Job/ProcessIncomingPush.php index 1306ea5..eb6bdd5 100644 --- a/Digitigrade/Job/ProcessIncomingPush.php +++ b/Digitigrade/Job/ProcessIncomingPush.php @@ -35,11 +35,13 @@ class ProcessIncomingPush extends Job { $log->info('importing note with uri ' . $this->object->self); $note = Note::importFromReceivedObject($this->object); $note?->processTimelineAdditions(); + $note?->processNotifications(); break; case 'interaction': $log->info('importing interaction with uri ' . $this->object->self); $interaction = Interaction::importFromReceivedObject($this->object); $interaction?->processTimelineAdditions(); + $interaction?->processNotifications(); break; case 'extension': throw new \RuntimeException('object type ' . $this->object->type . ' not yet implemented :('); diff --git a/Digitigrade/Timeline/HomeTimelineItem.php b/Digitigrade/Model/HomeTimelineItem.php index cb2b6e4..7e2c062 100644 --- a/Digitigrade/Timeline/HomeTimelineItem.php +++ b/Digitigrade/Model/HomeTimelineItem.php @@ -1,8 +1,9 @@ <?php -namespace Digitigrade\Timeline; +namespace Digitigrade\Model; use Digitigrade\Model; use Digitigrade\Model\UserAccount; +use Digitigrade\Timeline\TimelineItem; class HomeTimelineItem extends Model { public ?int $id; diff --git a/Digitigrade/Model/Interaction.php b/Digitigrade/Model/Interaction.php index e5619bb..cf2be01 100644 --- a/Digitigrade/Model/Interaction.php +++ b/Digitigrade/Model/Interaction.php @@ -1,11 +1,12 @@ <?php namespace Digitigrade\Model; -use Digitigrade\Timeline\HomeTimelineItem; +use Digitigrade\Model\HomeTimelineItem; +use Digitigrade\Notification\Notifyable; use Digitigrade\Timeline\ReasonReshared; use Digitigrade\Timeline\TimelineItem; -class Interaction extends PushableModel { +class Interaction extends PushableModel implements Notifyable { public ?int $id; public string $uri; public \DateTimeImmutable $created; @@ -61,6 +62,30 @@ class Interaction extends PushableModel { return array_unique($instances); } + public function getNotificationTitle(): string { + return sprintf(__('notifications.interaction.' . $this->kind->value . '.title'), $this->author->displayName); + } + public function getNotificationTitleLink(): ?string { + return '/@/' . $this->target->author->getFullHandle() . '/note/' . $this->target->id; + } + public function getNotificationBody(): ?string { + return $this->target->plainContent; + } + public function getNotificationImageUrl(): ?string { + return $this->author->avatar; + } + public function getNotificationImageLink(): ?string { + return '/@/' . $this->author->getFullHandle(); + } + + public function toJsonReference(): mixed { + return $this->id; + } + + public static function fromJsonReference(mixed $reference): self { + return self::find($reference); + } + public function processTimelineAdditions() { // basically, if this is a reshare, we need to put a home timeline item // for all followers who can see the shared note @@ -80,6 +105,14 @@ class Interaction extends PushableModel { } } + public function processNotifications() { + if (!$this->target->author->isLocal || $this->author == $this->target->author) { + return; + } + $recipient = UserAccount::findByLinkedActor($this->target->author); + Notification::fromNotifyable($this, $recipient)->send(); + } + public function jsonSerialize(): array { if ($this->deleted) { return [ diff --git a/Digitigrade/Model/Note.php b/Digitigrade/Model/Note.php index f6f492d..cf84ee0 100644 --- a/Digitigrade/Model/Note.php +++ b/Digitigrade/Model/Note.php @@ -2,11 +2,12 @@ namespace Digitigrade\Model; use Digitigrade\Db; -use Digitigrade\Timeline\HomeTimelineItem; +use Digitigrade\Model\HomeTimelineItem; +use Digitigrade\Notification\Notifyable; use Digitigrade\Timeline\TimelineIncludeable; use Digitigrade\Timeline\TimelineItem; -class Note extends PushableModel implements TimelineIncludeable { +class Note extends PushableModel implements TimelineIncludeable, Notifyable { public ?int $id; public string $uri; public \DateTimeImmutable $created; @@ -195,6 +196,33 @@ class Note extends PushableModel implements TimelineIncludeable { return self::find($reference); } + public function getNotificationTitle(): string { + // assuming that this notification is because the note is a reply for now + return sprintf(__('notifications.interaction.reply.title'), $this->author->displayName); + } + public function getNotificationTitleLink(): ?string { + return '/@/' . $this->author->getFullHandle() . '/note/' . $this->id; + } + public function getNotificationBody(): ?string { + return $this->plainContent; + } + public function getNotificationImageUrl(): ?string { + return $this->author->avatar; + } + public function getNotificationImageLink(): ?string { + return '/@/' . $this->author->getFullHandle(); + } + + public function processNotifications() { + foreach ($this->mentions as $a) { + if (!$a->isLocal) { + continue; + } + $user = UserAccount::findByLinkedActor($a); + Notification::fromNotifyable($this, $user)->send(); + } + } + public function processTimelineAdditions() { $item = new TimelineItem($this); foreach ($this->getRelevantActors() as $actor) { diff --git a/Digitigrade/Model/Notification.php b/Digitigrade/Model/Notification.php new file mode 100644 index 0000000..ee9d64a --- /dev/null +++ b/Digitigrade/Model/Notification.php @@ -0,0 +1,57 @@ +<?php +namespace Digitigrade\Model; + +use Digitigrade\Model; +use Digitigrade\Notification\Notifyable; + +class Notification extends Model { + public ?int $id; + public UserAccount $userAccount; + public string $itemType; + public string $itemData; + public \DateTimeImmutable $created; + + /** + * Creates a new Notification from a Notifyable object, but does not send it. + * @param Notifyable $item the item to send in the notification + * @param UserAccount $user the user to send the notification to + * @return Notification + */ + public static function fromNotifyable(Notifyable $item, UserAccount $user): self { + $notif = new self(); + $notif->userAccount = $user; + $notif->itemType = $item::class; + $notif->itemData = json_encode($item->toJsonReference()); + $notif->created = new \DateTimeImmutable(); + + $notif->save(); + return $notif; + } + + public function toNotifyable(): Notifyable { + return $this->itemType::fromJsonReference(json_decode($this->itemData)); + } + + /** + * Saves this object and delivers the notification to the recipient user. + * @return void + */ + public function send() { + $this->save(); + // TODO: send it via any push mechanisms + } + + /** + * @return self[] + */ + public static function findAllForUser(UserAccount $user, ?int $limit = null, int $offset = 0, ?\DateTimeInterface $since = null) { + $where = 'user_account = ?'; + $params = [$user->id]; + if (isset($since)) { + $where .= ' AND created > ?'; + $params[] = $since->format('c'); + } + return self::findAllWhere($where, $params, $limit, $offset, 'id DESC'); + } + +}
\ No newline at end of file diff --git a/Digitigrade/Notification/Notifyable.php b/Digitigrade/Notification/Notifyable.php new file mode 100644 index 0000000..510437e --- /dev/null +++ b/Digitigrade/Notification/Notifyable.php @@ -0,0 +1,12 @@ +<?php +namespace Digitigrade\Notification; + +interface Notifyable { + public function toJsonReference(): mixed; + public static function fromJsonReference(mixed $reference): self; + public function getNotificationTitle(): string; + public function getNotificationTitleLink(): ?string; + public function getNotificationBody(): ?string; + public function getNotificationImageUrl(): ?string; + public function getNotificationImageLink(): ?string; +}
\ No newline at end of file diff --git a/Digitigrade/Timeline/HomeTimeline.php b/Digitigrade/Timeline/HomeTimeline.php index c2baf3d..0867541 100644 --- a/Digitigrade/Timeline/HomeTimeline.php +++ b/Digitigrade/Timeline/HomeTimeline.php @@ -2,6 +2,7 @@ namespace Digitigrade\Timeline; use Digitigrade\Model\UserAccount; +use Digitigrade\Model\HomeTimelineItem; class HomeTimeline extends Timeline { private UserAccount $user; diff --git a/bin/cleandb b/bin/cleandb index b33f994..4aa7b37 100755 --- a/bin/cleandb +++ b/bin/cleandb @@ -12,8 +12,9 @@ DELETE FROM actor WHERE deleted = true; DELETE FROM note WHERE deleted = true; DELETE FROM interaction WHERE deleted = true; --- remove old timeline items +-- remove old timeline items and notifications DELETE FROM home_timeline_item WHERE modified < (current_timestamp - interval '6 months'); +DELETE FROM notification WHERE created < (current_timestamp - interval '1 month'); END); $db->commit();
\ No newline at end of file diff --git a/locale/en_GB.json b/locale/en_GB.json index 9fea62c..fa98b49 100644 --- a/locale/en_GB.json +++ b/locale/en_GB.json @@ -41,6 +41,11 @@ "navigation.logout.confirmation": "Are you sure you want to log out?", "navigation.followRequests": "Follow requests", "navigation.instanceSettings": "Instance settings", + "notifications.heading": "Notifications", + "notifications.interaction.like.title": "%s liked your note", + "notifications.interaction.dislike.title": "%s disliked your note", + "notifications.interaction.reshare.title": "%s reshared your note", + "notifications.interaction.reply.title": "%s replied to you", "login.pageTitle": "Log in", "login.email": "Email address", "login.password": "Password", diff --git a/migrations/20250119_194145_create_notification.php b/migrations/20250119_194145_create_notification.php new file mode 100644 index 0000000..f4b3ae4 --- /dev/null +++ b/migrations/20250119_194145_create_notification.php @@ -0,0 +1,16 @@ +<?php + +use \Digitigrade\Db\Migrator; + +Migrator::getInstance()->register(20250119_194145, function (PDO $db) { + // table for notifications sent to users + $db->exec(<<<END + CREATE TABLE notification ( + id bigserial primary key, + user_account bigint not null references user_account, + item_type text not null, + item_data jsonb not null, + created timestamp with time zone not null + ); + END); +}); diff --git a/routes/note_fragment.php b/routes/note_fragment.php index 84f68c6..5fe979a 100644 --- a/routes/note_fragment.php +++ b/routes/note_fragment.php @@ -24,6 +24,7 @@ Router::getInstance()->mount('/fragment/note/:id/:action', function (array $args $interaction = Interaction::create($user->actor, InteractionKind::from($action), $note); $interaction->publish(); $interaction->processTimelineAdditions(); + $interaction->processNotifications(); $active = true; } else { // we are removing the existing one @@ -55,9 +56,12 @@ Router::getInstance()->mount('/fragment/note/:id/reply', function (array $args) ); $reply->inReplyTo = $note; $reply->threadApex = $note->threadApex ?? $note; + // TODO: do mentions properly and not just like this + $reply->mentions = [$note->author]; $reply->save(); $reply->publish(); $reply->processTimelineAdditions(); + $reply->processNotifications(); } render_template('reply_form', ['note' => $note]); }); @@ -78,6 +82,7 @@ Router::getInstance()->mount('/fragment/note', function (array $args) { ); $note->publish(); $note->processTimelineAdditions(); + $note->processNotifications(); } render_template('write_note_form'); });
\ No newline at end of file diff --git a/routes/notifications.php b/routes/notifications.php new file mode 100644 index 0000000..05fb89d --- /dev/null +++ b/routes/notifications.php @@ -0,0 +1,31 @@ +<?php + +use Digitigrade\HttpResponseStatus\BadRequest; +use Digitigrade\Model\Notification; +use Digitigrade\Model\UserAccount; +use Digitigrade\Router; + +Router::getInstance()->mount('/notifications/partial', function (array $args) { + $user = UserAccount::requireByCurrentSession(); + if (!isset($_GET['since'])) { + throw new BadRequest('get parameter `since` required'); + } + $when = new \DateTimeImmutable($_GET['since']); + $notifs = Notification::findAllForUser($user, since: $when); + + foreach ($notifs as $notif) { + $notif = $notif->toNotifyable(); + render_template('notification', [ + 'title' => $notif->getNotificationTitle(), + 'body' => $notif->getNotificationBody(), + 'imageUrl' => $notif->getNotificationImageUrl(), + 'titleLink' => $notif->getNotificationTitleLink(), + 'imageLink' => $notif->getNotificationImageLink() + ]); + } + + render_template('live_notifications_updater', [ + 'isUpdate' => true, + 'since' => new \DateTimeImmutable() + ]); +});
\ No newline at end of file diff --git a/static/form.css b/static/form.css index 79509d7..00a4366 100644 --- a/static/form.css +++ b/static/form.css @@ -58,6 +58,10 @@ form { } } + &#writeNoteForm { + max-width: 100%; + } + &.settings { padding: 0; gap: 0; diff --git a/static/notifications.css b/static/notifications.css new file mode 100644 index 0000000..98a8fbb --- /dev/null +++ b/static/notifications.css @@ -0,0 +1,69 @@ +#notificationsPanel { + border: var(--border); + border-radius: var(--border-radius); + background: var(--clr-panel-background); + margin: var(--spacing-double); + height: calc(100vh - 64px - (2 * var(--spacing-double))); + display: grid; + grid-template-rows: max-content 1fr; + + h2 { + margin: 0; + padding: var(--spacing-double); + font-size: var(--font-big-size); + font-weight: bold; + border-bottom: var(--border); + } + + .items { + overflow-y: scroll; + height: 100%; + box-sizing: border-box; + } + + .notification { + padding: var(--spacing-single); + border-bottom: var(--border); + display: grid; + grid-template-columns: max-content 1fr; + gap: var(--spacing-single); + background: var(--clr-note-background); + overflow: hidden; + filter: linear-gradient( + to bottom, + black 50%, + var(--clr-note-background) 100% + ); + + img { + width: var(--avatar-size); + border-radius: var(--border-radius); + aspect-ratio: 1; + } + .notifContent { + display: grid; + grid-template-rows: max-content 1fr; + align-content: start; + } + .title { + font-weight: bold; + a:has(&) { + color: inherit; + text-decoration: none; + &:hover { + text-decoration: underline; + } + } + } + .body { + overflow: hidden; + min-height: 100%; + max-height: 4lh; + mask-image: linear-gradient(to bottom, black 50%, transparent 100%); + &:active { + max-height: unset; + mask-image: none; + } + } + } +} diff --git a/static/skeleton.css b/static/skeleton.css index 12bc1e5..e9e39b2 100644 --- a/static/skeleton.css +++ b/static/skeleton.css @@ -125,9 +125,9 @@ ul.sideNavigation { } main { - width: min(1300px, 100%); + width: min(1500px, 100%); display: grid; - grid-template-columns: 1fr min(700px, 100%) 1fr; + grid-template-columns: 1fr min(100%, 700px) 1fr; height: 100%; #leftPane, #rightPane { @@ -135,11 +135,10 @@ main { top: 56px; height: max-content; } - @media (width < 1300px) { + @media (width < 1200px) { #leftPane, #rightPane { - max-width: 0; - visibility: hidden; + display: none; } } } diff --git a/static/style.css b/static/style.css index d5078bb..0c2bf9d 100644 --- a/static/style.css +++ b/static/style.css @@ -1,5 +1,6 @@ @import url(theme.css); @import url(skeleton.css); +@import url(notifications.css); @import url(note.css); @import url(form.css); @import url(profile.css); diff --git a/templates/live_notifications_updater.php b/templates/live_notifications_updater.php new file mode 100644 index 0000000..bbf31b4 --- /dev/null +++ b/templates/live_notifications_updater.php @@ -0,0 +1,4 @@ +<form hidden id="liveNotificationsUpdater" hx-trigger="load delay:10s, update" hx-get="/notifications/partial" + hx-target="next .items" hx-swap="afterbegin" <?= ($isUpdate ?? false) ? 'hx-swap-oob="true"' : '' ?>> + <input type="hidden" name="since" value="<?= $since->format('c') ?>"> +</form>
\ No newline at end of file diff --git a/templates/notification.php b/templates/notification.php new file mode 100644 index 0000000..a010ff9 --- /dev/null +++ b/templates/notification.php @@ -0,0 +1,23 @@ +<div class="notification"> + <div class="notifImage"> + <?php if ($imageUrl != null): ?> + <?php if ($imageLink != null): ?> + <a href="<?= $imageLink ?>"> + <?php endif; ?> + <img src="<?= $imageUrl ?>"> + <?php if ($imageLink != null): ?> + </a> + <?php endif; ?> + <?php endif; ?> + </div> + <div class="notifContent"> + <?php if ($titleLink != null): ?> + <a href="<?= $titleLink ?>"> + <?php endif; ?> + <span class="title"><?= htmlspecialchars($title) ?></span> + <?php if ($titleLink != null): ?> + </a> + <?php endif; ?> + <span class="body"><?= htmlspecialchars($body) ?></span> + </div> +</div>
\ No newline at end of file diff --git a/templates/notifications_panel.php b/templates/notifications_panel.php new file mode 100644 index 0000000..3ca8faf --- /dev/null +++ b/templates/notifications_panel.php @@ -0,0 +1,26 @@ +<?php +use Digitigrade\Model\Notification; + +$notifications = Notification::findAllForUser($user, 50); +?> +<div id="notificationsPanel"> + <h2><?= __('notifications.heading') ?></h2> + <?php call_template('live_notifications_updater', ['since' => new \DateTimeImmutable()]); ?> + <div class="items"> + <?php + if (count($notifications) <= 0) { + call_template('placeholder_text'); + } + foreach ($notifications as $notif) { + $notif = $notif->toNotifyable(); + call_template('notification', [ + 'title' => $notif->getNotificationTitle(), + 'body' => $notif->getNotificationBody(), + 'imageUrl' => $notif->getNotificationImageUrl(), + 'titleLink' => $notif->getNotificationTitleLink(), + 'imageLink' => $notif->getNotificationImageLink() + ]); + } + ?> + </div> +</div>
\ No newline at end of file diff --git a/templates/skeleton.php b/templates/skeleton.php index 9e27386..3b2a963 100644 --- a/templates/skeleton.php +++ b/templates/skeleton.php @@ -46,39 +46,47 @@ $settings = GlobalSettings::getInstance(); </nav> </header> <main> - <section id="leftPane"> - <?php - if ($user != null) { - call_template('write_note_form'); - $links = [[ - 'href' => '/followrequests', - 'icon' => 'person_add', - 'label' => __('navigation.followRequests'), - 'unreadCount' => FollowRelation::countWhere("object = ? AND status = 'pending'", [$user->actor->id]) - ]]; - if ($user->isAdmin) { - $links[] = ['href' => '/admin/settings/instance', 'icon' => 'settings', 'label' => __('navigation.instanceSettings')]; + <div> + <section id="leftPane"> + <?php + if ($user != null) { + call_template('write_note_form'); + $links = [[ + 'href' => '/followrequests', + 'icon' => 'person_add', + 'label' => __('navigation.followRequests'), + 'unreadCount' => FollowRelation::countWhere("object = ? AND status = 'pending'", [$user->actor->id]) + ]]; + if ($user->isAdmin) { + $links[] = ['href' => '/admin/settings/instance', 'icon' => 'settings', 'label' => __('navigation.instanceSettings')]; + } + $links[] = [ + 'href' => '/logout', + 'icon' => 'logout', + 'label' => __('navigation.logout'), + 'confirmation' => __('navigation.logout.confirmation') + ]; + call_template('side_navigation', ['links' => $links]); } - $links[] = [ - 'href' => '/logout', - 'icon' => 'logout', - 'label' => __('navigation.logout'), - 'confirmation' => __('navigation.logout.confirmation') - ]; - call_template('side_navigation', ['links' => $links]); - } - ?> - <p class="versionInfo"><?= sprintf(__('version'), get_version()) ?></p> - </section> - <section id="centrePane"> - <?php if (!isset($renderTitleHeading) || $renderTitleHeading): ?> - <h1><?= $pageTitle ?></h1> - <?php endif; ?> - <?php slot(); ?> - </section> - <section id="rightPane"> - - </section> + ?> + <p class="versionInfo"><?= sprintf(__('version'), get_version()) ?></p> + </section> + </div> + <div> + <section id="centrePane"> + <?php if (!isset($renderTitleHeading) || $renderTitleHeading): ?> + <h1><?= $pageTitle ?></h1> + <?php endif; ?> + <?php slot(); ?> + </section> + </div> + <div> + <section id="rightPane"> + <?php if ($user != null) { + call_template('notifications_panel', ['user' => $user]); + } ?> + </section> + </div> </main> </body> diff --git a/templates/write_note_form.php b/templates/write_note_form.php index b74de69..2d50896 100644 --- a/templates/write_note_form.php +++ b/templates/write_note_form.php @@ -1,5 +1,5 @@ -<form action="/todo" method="post" hx-post="/fragment/note" hx-swap="outerHTML" hx-disabled-elt="find button" - _="on submit wait 1s then trigger update on #liveTimelineUpdater"> +<form id="writeNoteForm" action="/todo" method="post" hx-post="/fragment/note" hx-swap="outerHTML" + hx-disabled-elt="find button" _="on submit wait 1s then trigger update on #liveTimelineUpdater"> <div class="row"> <label for="noteContent"><?= __('writeNote.label') ?></label> <textarea name="content" id="noteContent" required autocomplete="off"></textarea> |
