diff options
27 files changed, 286 insertions, 76 deletions
diff --git a/Digitigrade/Job/ProcessIncomingPush.php b/Digitigrade/Job/ProcessIncomingPush.php index cd297ca..2d27f80 100644 --- a/Digitigrade/Job/ProcessIncomingPush.php +++ b/Digitigrade/Job/ProcessIncomingPush.php @@ -33,11 +33,13 @@ class ProcessIncomingPush extends Job { break; case 'note': $log->info('importing note with uri ' . $this->object->self); - Note::importFromReceivedObject($this->object); + $note = Note::importFromReceivedObject($this->object); + $note->processTimelineAdditions(); break; case 'interaction': $log->info('importing interaction with uri ' . $this->object->self); - Interaction::importFromReceivedObject($this->object); + $interaction = Interaction::importFromReceivedObject($this->object); + $interaction->processTimelineAdditions(); break; case 'extension': throw new \RuntimeException('object type ' . $this->object->type . ' not yet implemented :('); diff --git a/Digitigrade/Model/Interaction.php b/Digitigrade/Model/Interaction.php index 4ca1a0c..ed4ef45 100644 --- a/Digitigrade/Model/Interaction.php +++ b/Digitigrade/Model/Interaction.php @@ -1,6 +1,10 @@ <?php namespace Digitigrade\Model; +use Digitigrade\Timeline\HomeTimelineItem; +use Digitigrade\Timeline\ReasonReshared; +use Digitigrade\Timeline\TimelineItem; + class Interaction extends PushableModel { public ?int $id; public string $uri; @@ -57,6 +61,22 @@ class Interaction extends PushableModel { return array_unique($instances); } + 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 + if ($this->kind != InteractionKind::RESHARE) + return; + $reason = new ReasonReshared($this->author); + $item = new TimelineItem($this->target, $reason); + + foreach ($this->target->getRelevantActors() as $actor) { + if (!$actor->isLocal) + return; + $user = UserAccount::findByLinkedActor($actor); + HomeTimelineItem::fromTimelineItem($item, $user)->save(); + } + } + public function jsonSerialize(): array { if ($this->deleted) { return [ diff --git a/Digitigrade/Model/Note.php b/Digitigrade/Model/Note.php index 71b25ae..7fb1221 100644 --- a/Digitigrade/Model/Note.php +++ b/Digitigrade/Model/Note.php @@ -2,8 +2,9 @@ namespace Digitigrade\Model; use Digitigrade\Db; -use Digitigrade\Job\PushObject; -use Digitigrade\TimelineIncludeable; +use Digitigrade\Timeline\HomeTimelineItem; +use Digitigrade\Timeline\TimelineIncludeable; +use Digitigrade\Timeline\TimelineItem; class Note extends PushableModel implements TimelineIncludeable { public ?int $id; @@ -139,13 +140,19 @@ class Note extends PushableModel implements TimelineIncludeable { return self::countWhere('in_reply_to = ? AND deleted = false', [$this->id]); } - protected function getRelevantServers(): array { - $recipientActors = match ($this->privacy->scope) { + /** + * @return Actor[] actors who this note can be seen by, are mentioned, etc + */ + public function getRelevantActors(): array { + return array_unique(array_merge(match ($this->privacy->scope) { NotePrivacyScope::NONE => [], NotePrivacyScope::MUTUALS => $this->author->findMutualFollows(), default => $this->author->findFollowers() - }; - $recipientActors = array_merge($recipientActors, $this->mentions, $this->privacy->alsoVisibleTo); + }, $this->mentions, $this->privacy->alsoVisibleTo, [$this->author])); + } + + protected function getRelevantServers(): array { + $recipientActors = $this->getRelevantActors(); // reply-to and thread apex author should be covered by mentions but in case they're not: if (isset($this->inReplyTo)) $recipientActors[] = $this->inReplyTo->author; @@ -170,6 +177,24 @@ class Note extends PushableModel implements TimelineIncludeable { render_template('note', ['note' => $this]); } + public function toJsonReference(): mixed { + return $this->id; + } + + public static function fromJsonReference(mixed $reference): self { + return self::find($reference); + } + + public function processTimelineAdditions() { + $item = new TimelineItem($this); + foreach ($this->getRelevantActors() as $actor) { + if (!$actor->isLocal) + continue; + $user = UserAccount::findByLinkedActor($actor); + HomeTimelineItem::fromTimelineItem($item, $user)->save(); + } + } + public function jsonSerialize(): array { if ($this->deleted) { return [ diff --git a/Digitigrade/Model/UserAccount.php b/Digitigrade/Model/UserAccount.php index 72a9ce9..d0fa94b 100644 --- a/Digitigrade/Model/UserAccount.php +++ b/Digitigrade/Model/UserAccount.php @@ -59,6 +59,10 @@ class UserAccount extends Model { return $user; } + public static function findByLinkedActor(Actor $actor): ?self { + return self::findWhere('actor = ?', [$actor->id]); + } + public function verifyPassword(#[\SensitiveParameter] $attemptedPassword): bool { return password_verify($attemptedPassword, $this->passwordHash); } diff --git a/Digitigrade/Timeline/GlobalTimeline.php b/Digitigrade/Timeline/GlobalTimeline.php index 278b557..81eccfb 100644 --- a/Digitigrade/Timeline/GlobalTimeline.php +++ b/Digitigrade/Timeline/GlobalTimeline.php @@ -3,8 +3,6 @@ namespace Digitigrade\Timeline; use Digitigrade\Db; use Digitigrade\Model\Note; -use Digitigrade\Timeline; -use Digitigrade\TimelineItem; class GlobalTimeline extends Timeline { protected function getItems(int $limit, int $offset): array { diff --git a/Digitigrade/Timeline/HomeTimeline.php b/Digitigrade/Timeline/HomeTimeline.php new file mode 100644 index 0000000..c2baf3d --- /dev/null +++ b/Digitigrade/Timeline/HomeTimeline.php @@ -0,0 +1,26 @@ +<?php +namespace Digitigrade\Timeline; + +use Digitigrade\Model\UserAccount; + +class HomeTimeline extends Timeline { + private UserAccount $user; + + public function __construct(UserAccount $user) { + $this->user = $user; + } + + protected function getItems(int $limit, int $offset): array { + return array_map( + fn(HomeTimelineItem $hti) => $hti->toTimelineItem(), + HomeTimelineItem::findAllForUser($this->user, $limit, $offset) + ); + } + + public function getItemsSince(\DateTimeInterface $when, ?int $limit = null, int $offset = 0): array { + return array_map( + fn(HomeTimelineItem $hti) => $hti->toTimelineItem(), + HomeTimelineItem::findAllForUser($this->user, $limit, $offset, $when) + ); + } +}
\ No newline at end of file diff --git a/Digitigrade/Timeline/HomeTimelineItem.php b/Digitigrade/Timeline/HomeTimelineItem.php new file mode 100644 index 0000000..cb2b6e4 --- /dev/null +++ b/Digitigrade/Timeline/HomeTimelineItem.php @@ -0,0 +1,52 @@ +<?php +namespace Digitigrade\Timeline; + +use Digitigrade\Model; +use Digitigrade\Model\UserAccount; + +class HomeTimelineItem extends Model { + public ?int $id; + public UserAccount $userAccount; + public ?string $reasonKind; + public ?string $reasonData; + public string $itemKind; + public string $itemData; + public \DateTimeImmutable $modified; + + /** + * Creates a new home timeline entry for a particular user, but does not save it. + */ + public static function fromTimelineItem(TimelineItem $item, UserAccount $user): self { + $hti = new self(); + $hti->userAccount = $user; + $reason = $item->getReason(); + if ($reason != null) { + $hti->reasonKind = $reason::class; + $hti->reasonData = json_encode($reason->toJsonReference()); + } + $object = $item->getObject(); + $hti->itemKind = $object::class; + $hti->itemData = json_encode($object->toJsonReference()); + $hti->modified = new \DateTimeImmutable(); + return $hti; + } + + public function toTimelineItem(): TimelineItem { + $reason = null; + if (isset($this->reasonKind)) { + $reason = $this->reasonKind::fromJsonReference(json_decode($this->reasonData)); + } + $object = $this->itemKind::fromJsonReference(json_decode($this->itemData)); + return new TimelineItem($object, $reason); + } + + 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 modified > ?'; + $params[] = $since->format('c'); + } + return self::findAllWhere($where, $params, $limit, $offset, 'id DESC'); + } +}
\ No newline at end of file diff --git a/Digitigrade/Timeline/LocalTimeline.php b/Digitigrade/Timeline/LocalTimeline.php index c20ada6..6129576 100644 --- a/Digitigrade/Timeline/LocalTimeline.php +++ b/Digitigrade/Timeline/LocalTimeline.php @@ -3,8 +3,6 @@ namespace Digitigrade\Timeline; use Digitigrade\Db; use Digitigrade\Model\Note; -use Digitigrade\Timeline; -use Digitigrade\TimelineItem; class LocalTimeline extends Timeline { protected function getItems(int $limit, int $offset): array { diff --git a/Digitigrade/Timeline/ReasonReshared.php b/Digitigrade/Timeline/ReasonReshared.php new file mode 100644 index 0000000..2ffe1b2 --- /dev/null +++ b/Digitigrade/Timeline/ReasonReshared.php @@ -0,0 +1,24 @@ +<?php +namespace Digitigrade\Timeline; + +use Digitigrade\Model\Actor; + +class ReasonReshared extends TimelineInclusionReason { + private Actor $resharer; + + public function __construct(Actor $resharer) { + $this->resharer = $resharer; + } + + public function renderAsHtml() { + render_template('reason_reshared', ['actor' => $this->resharer]); + } + + public function toJsonReference(): mixed { + return $this->resharer->id; + } + + public static function fromJsonReference(mixed $reference): self { + return new self(Actor::find($reference)); + } +}
\ No newline at end of file diff --git a/Digitigrade/Timeline.php b/Digitigrade/Timeline/Timeline.php index 64aabe1..b3def28 100644 --- a/Digitigrade/Timeline.php +++ b/Digitigrade/Timeline/Timeline.php @@ -1,7 +1,5 @@ <?php -namespace Digitigrade; - -use Digitigrade\Model\Note; +namespace Digitigrade\Timeline; abstract class Timeline { // TODO: currently making this number up. probably should be modifiable somewhere diff --git a/Digitigrade/Timeline/TimelineIncludeable.php b/Digitigrade/Timeline/TimelineIncludeable.php new file mode 100644 index 0000000..5406543 --- /dev/null +++ b/Digitigrade/Timeline/TimelineIncludeable.php @@ -0,0 +1,8 @@ +<?php +namespace Digitigrade\Timeline; + +interface TimelineIncludeable { + public function renderAsHtml(); + public function toJsonReference(): mixed; + public static function fromJsonReference(mixed $reference): self; +}
\ No newline at end of file diff --git a/Digitigrade/Timeline/TimelineInclusionReason.php b/Digitigrade/Timeline/TimelineInclusionReason.php new file mode 100644 index 0000000..6ffe76d --- /dev/null +++ b/Digitigrade/Timeline/TimelineInclusionReason.php @@ -0,0 +1,8 @@ +<?php +namespace Digitigrade\Timeline; + +abstract class TimelineInclusionReason { + abstract public function renderAsHtml(); + abstract public function toJsonReference(): mixed; + abstract public static function fromJsonReference(mixed $reference): self; +}
\ No newline at end of file diff --git a/Digitigrade/TimelineItem.php b/Digitigrade/Timeline/TimelineItem.php index e59fbec..d6ba563 100644 --- a/Digitigrade/TimelineItem.php +++ b/Digitigrade/Timeline/TimelineItem.php @@ -1,5 +1,5 @@ <?php -namespace Digitigrade; +namespace Digitigrade\Timeline; class TimelineItem { private ?TimelineInclusionReason $reason; diff --git a/Digitigrade/TimelineIncludeable.php b/Digitigrade/TimelineIncludeable.php deleted file mode 100644 index 44433b4..0000000 --- a/Digitigrade/TimelineIncludeable.php +++ /dev/null @@ -1,6 +0,0 @@ -<?php -namespace Digitigrade; - -interface TimelineIncludeable { - public function renderAsHtml(); -}
\ No newline at end of file diff --git a/Digitigrade/TimelineInclusionReason.php b/Digitigrade/TimelineInclusionReason.php deleted file mode 100644 index e9840d1..0000000 --- a/Digitigrade/TimelineInclusionReason.php +++ /dev/null @@ -1,6 +0,0 @@ -<?php -namespace Digitigrade; - -abstract class TimelineInclusionReason { - abstract public function renderAsHtml(); -}
\ No newline at end of file diff --git a/Digitigrade/TimelineInclusionReason/Reshared.php b/Digitigrade/TimelineInclusionReason/Reshared.php deleted file mode 100644 index f185149..0000000 --- a/Digitigrade/TimelineInclusionReason/Reshared.php +++ /dev/null @@ -1,17 +0,0 @@ -<?php -namespace Digitigrade\TimelineInclusionReason; - -use Digitigrade\Model\Actor; -use Digitigrade\TimelineInclusionReason; - -class Reshared extends TimelineInclusionReason { - private Actor $resharer; - - public function __construct(Actor $resharer) { - $this->resharer = $resharer; - } - - public function renderAsHtml() { - render_template('reason_reshared', ['actor' => $this->resharer]); - } -}
\ No newline at end of file diff --git a/locale/en_GB.json b/locale/en_GB.json index d7860db..06da83e 100644 --- a/locale/en_GB.json +++ b/locale/en_GB.json @@ -10,18 +10,21 @@ "user.notes.placeholder": "This user hasn't posted anything yet.", "timeline.global": "Global timeline", "timeline.global.description": "This timeline shows all known public indexable notes.", + "timeline.global.shortName": "Global", + "timeline.local": "Local timeline", + "timeline.local.description": "This timeline shows all public indexable notes posted by users on this server.", + "timeline.local.shortName": "Local", + "timeline.home": "Home timeline", + "timeline.home.description": "This timeline shows all activity from users you follow.", + "timeline.home.shortName": "Home", "note.action.like": "Like", "note.action.dislike": "Dislike", "note.action.reshare": "Reshare", "note.action.reply": "Reply", "note.info.replyTo": "In reply to %s", - "timeline.local": "Local timeline", - "timeline.local.description": "This timeline shows all public indexable notes posted by users on this server.", "placeholder": "There's nothing here.", "placeholder.moreItems": "…and %d more", "placeholder.noMoreItems": "No more items.", - "timeline.global.shortName": "Global", - "timeline.local.shortName": "Local", "digitigrade": "Digitigrade", "note.resharedBy": "Reshared by %s", "navigation.login": "Log in", diff --git a/migrations/20250115_173712_create_home_timeline.php b/migrations/20250115_173712_create_home_timeline.php new file mode 100644 index 0000000..becd1a9 --- /dev/null +++ b/migrations/20250115_173712_create_home_timeline.php @@ -0,0 +1,18 @@ +<?php + +use \Digitigrade\Db\Migrator; + +Migrator::getInstance()->register(20250115_173712, function (PDO $db) { + // items in each user's home timeline + $db->exec(<<<END + CREATE TABLE home_timeline_item ( + id bigserial primary key, + user_account bigint not null references user_account, + reason_kind text, + reason_data jsonb, + item_kind text not null, + item_data jsonb not null, + modified timestamp with time zone not null + ); + END); +}); diff --git a/routes/note_fragment.php b/routes/note_fragment.php index a992a61..4d8c1a6 100644 --- a/routes/note_fragment.php +++ b/routes/note_fragment.php @@ -22,6 +22,7 @@ Router::getInstance()->mount('/fragment/note/:id/:action', function (array $args // we are adding a new interaction $interaction = Interaction::create($user->actor, InteractionKind::from($action), $note); $interaction->publish(); + $interaction->processTimelineAdditions(); $active = true; } else { // we are removing the existing one @@ -47,6 +48,7 @@ Router::getInstance()->mount('/fragment/note/:id/reply', function (array $args) $reply->threadApex = $note->threadApex ?? $note; $reply->save(); $reply->publish(); + $reply->processTimelineAdditions(); } render_template('reply_form', ['note' => $note]); }); @@ -58,6 +60,7 @@ Router::getInstance()->mount('/fragment/note', function (array $args) { // TODO: summary field, post language, privacy, etc $note = Note::create($author->actor, $_POST['content'], 'unk'); $note->publish(); + $note->processTimelineAdditions(); } render_template('write_note_form'); });
\ No newline at end of file diff --git a/routes/public_timelines.php b/routes/public_timelines.php deleted file mode 100644 index b60f141..0000000 --- a/routes/public_timelines.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -use Digitigrade\Router; -use Digitigrade\Timeline\GlobalTimeline; -use Digitigrade\Timeline\LocalTimeline; - -Router::getInstance()->mount('/feed/global', function (array $args) { - $items = (new GlobalTimeline())->getPage(); - render_template('timeline', ['kind' => 'global', 'items' => $items]); -}); - -Router::getInstance()->mount('/feed/local', function (array $args) { - $items = (new LocalTimeline())->getPage(); - render_template('timeline', ['kind' => 'local', 'items' => $items]); -});
\ No newline at end of file diff --git a/routes/timelines.php b/routes/timelines.php new file mode 100644 index 0000000..f70c0d6 --- /dev/null +++ b/routes/timelines.php @@ -0,0 +1,48 @@ +<?php + +use Digitigrade\HttpResponseStatus\BadRequest; +use Digitigrade\HttpResponseStatus\TemporaryRedirect; +use Digitigrade\Model\UserAccount; +use Digitigrade\Router; +use Digitigrade\Timeline\GlobalTimeline; +use Digitigrade\Timeline\HomeTimeline; +use Digitigrade\Timeline\LocalTimeline; + +Router::getInstance()->mount('/feed/global', function (array $args) { + $items = (new GlobalTimeline())->getPage(); + render_template('timeline', ['kind' => 'global', 'items' => $items]); +}); + +Router::getInstance()->mount('/feed/local', function (array $args) { + $items = (new LocalTimeline())->getPage(); + render_template('timeline', ['kind' => 'local', 'items' => $items]); +}); + +Router::getInstance()->mount('/feed/home', function (array $args) { + $user = UserAccount::findByCurrentSession(); + if ($user == null) { + throw new TemporaryRedirect('/feed/global'); + } + $items = (new HomeTimeline($user))->getPage(); + render_template('timeline', ['kind' => 'home', 'items' => $items]); +}); + +Router::getInstance()->mount('/feed/home/partial', function (array $args) { + $user = UserAccount::requireByCurrentSession(); + if (!isset($_GET['since'])) { + throw new BadRequest('get parameter `since` required'); + } + $when = new \DateTimeImmutable($_GET['since']); + $items = (new HomeTimeline($user))->getItemsSince($when); + + foreach ($items as $item) { + $item->getReason()?->renderAsHtml(); + $item->getObject()->renderAsHtml(); + } + + render_template('live_timeline_updater', [ + 'kind' => 'home', + 'isUpdate' => true, + 'since' => new \DateTimeImmutable() + ]); +});
\ No newline at end of file diff --git a/templates/interaction_button.php b/templates/interaction_button.php index edd1a25..b3d072d 100644 --- a/templates/interaction_button.php +++ b/templates/interaction_button.php @@ -15,7 +15,8 @@ $id = "interactButton-$action-$note->id"; <div class="interactButtonContainer <?= $action . ($active ? ' active' : '') ?>"> <button title="<?= $tooltip ?>" class="icon material-symbols-outlined <?= $active ? 'active' : '' ?>" hx-post="<?= "/fragment/note/$note->id/$action" ?>" hx-swap="outerHTML" - hx-target="closest .interactButtonContainer" hx-disabled-elt="this" id="<?= $id ?>"> + hx-target="closest .interactButtonContainer" hx-disabled-elt="this" id="<?= $id ?>" + _="on click wait 1s then trigger update on #liveTimelineUpdater"> <?= $icon ?> </button> <label for="<?= $id ?>" class="interactionCount"><?= $count ?? '' ?></label> diff --git a/templates/live_timeline_updater.php b/templates/live_timeline_updater.php new file mode 100644 index 0000000..366b417 --- /dev/null +++ b/templates/live_timeline_updater.php @@ -0,0 +1,4 @@ +<form hidden id="liveTimelineUpdater" hx-trigger="load delay:10s, update" hx-get="/feed/<?= $kind ?>/partial" + hx-target="next #timelineItems" 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/reply_form.php b/templates/reply_form.php index f96eb5a..9a1d7e5 100644 --- a/templates/reply_form.php +++ b/templates/reply_form.php @@ -1,9 +1,9 @@ <form class="noteReplyForm" id="replyForm-<?= $note->id ?>" - _="on submit send toggle to previous <button.reply/> on toggle or submit toggle @hidden" hidden - hx-post="/fragment/note/<?= $note->id ?>/reply" hx-disabled-elt="find button" hx-swap="outerHTML"> + _="on submit send toggle to previous <button.reply/> then wait 1s then trigger update on #liveTimelineUpdater on toggle or submit toggle @hidden" + hidden hx-post="/fragment/note/<?= $note->id ?>/reply" hx-disabled-elt="find button" hx-swap="outerHTML"> <div class="row"> <label for="replyContent-<?= $note->id ?>"><?= __('writeReply.label') ?></label> - <textarea id="replyContent-<?= $note->id ?>" name="content" required></textarea> + <textarea id="replyContent-<?= $note->id ?>" name="content" required autocomplete="off"></textarea> </div> <div class="row"> <button class="primary"><?= __('writeReply.action') ?></button> diff --git a/templates/skeleton.php b/templates/skeleton.php index e678957..6eb6bd1 100644 --- a/templates/skeleton.php +++ b/templates/skeleton.php @@ -18,10 +18,14 @@ $user = Digitigrade\Model\UserAccount::findByCurrentSession(); <body hx-boost="true"> <header> <nav> - <?php call_template('navigation_links', ['links' => [ + <?php + $links = $user == null ? [] : ['/feed/home' => __('timeline.home.shortName')]; + $links = array_merge($links, [ '/feed/global' => __('timeline.global.shortName'), '/feed/local' => __('timeline.local.shortName'), - ]]); ?> + ]); + call_template('navigation_links', ['links' => $links]); + ?> <span id="siteTitle"><?= __('digitigrade') ?></span> <?php if ($user == null): call_template('navigation_links', ['links' => [ diff --git a/templates/timeline.php b/templates/timeline.php index 7ba2fbf..a65b4ce 100644 --- a/templates/timeline.php +++ b/templates/timeline.php @@ -1,11 +1,20 @@ <?php call_template('skeleton', ['pageTitle' => __("timeline.$kind")], function () { global $items, $kind; - call_template('alertbox', [ - 'variety' => 'info', - 'message' => __("timeline.$kind.description") - ]); + if ($kind != 'home') { + call_template('alertbox', [ + 'variety' => 'info', + 'message' => __("timeline.$kind.description") + ]); + } else { + call_template('live_timeline_updater', [ + 'kind' => 'home', + 'since' => new DateTimeImmutable() + ]); + } + echo '<div id="timelineItems">'; foreach ($items as $item) { $item->getReason()?->renderAsHtml(); $item->getObject()->renderAsHtml(); } + echo '</div>'; });
\ No newline at end of file diff --git a/templates/write_note_form.php b/templates/write_note_form.php index 9b7c61c..c28dc12 100644 --- a/templates/write_note_form.php +++ b/templates/write_note_form.php @@ -1,7 +1,8 @@ -<form action="/todo" method="post" hx-post="/fragment/note" hx-swap="outerHTML" hx-disabled-elt="find button"> +<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"> <div class="row"> <label for="noteContent"><?= __('writeNote.label') ?></label> - <textarea name="content" id="noteContent" required></textarea> + <textarea name="content" id="noteContent" required autocomplete="off"></textarea> </div> <div class="row"> <button class="primary"><?= __('writeNote.action') ?></button> |
