From 888c3798b7306c30f17764d3aa4cf66010b18f6a Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Sun, 13 Mar 2022 18:23:19 +0000 Subject: [PATCH] [COMPONENT][Notification] Make logic more generic and robust Fixed various bugs Some important concepts to bear in mind: * Notification: Associated with activities, won't be reconstructed together with objects, can be thought of as transient * Attention: Associated with objects, will be reconstructed with them, can be thought as persistent * Notifications and Attentions have no direct implications. * Mentions are a specific form of attentions in notes, leads to the creation of Attentions. Finally, Potential PHP issue detected and reported: https://github.com/php/php-src/issues/8199 `static::method()` from a non static context (such as a class method) calls `__call`, rather than the expected `__callStatic`. Can be fixed by using `(static fn() => static::method())()`, but the usage of the magic method is strictly unnecessary in this case. --- components/Blog/Controller/Post.php | 2 +- components/Group/Group.php | 9 +- components/Notification/Controller/Feed.php | 4 +- components/Notification/Entity/Attention.php | 47 +++++-- .../Notification/Entity/Notification.php | 14 +- components/Notification/Notification.php | 52 ++++++-- components/Posting/Controller/Posting.php | 6 +- components/Posting/Posting.php | 79 +++++++---- .../Subscription/Entity/ActorSubscription.php | 21 --- components/Subscription/Subscription.php | 15 ++- plugins/ActivityPub/ActivityPub.php | 85 +++++++----- plugins/ActivityPub/Controller/Inbox.php | 3 +- .../Entity/ActivitypubActivity.php | 20 +-- plugins/ActivityPub/Util/Model/Activity.php | 26 ++-- .../ActivityPub/Util/Model/ActivityCreate.php | 1 - plugins/ActivityPub/Util/Model/Note.php | 101 +++++++------- plugins/DeleteNote/DeleteNote.php | 6 +- plugins/Favourite/Controller/Favourite.php | 16 ++- plugins/Favourite/Entity/NoteFavourite.php | 21 --- plugins/Favourite/Favourite.php | 41 +++--- plugins/RepeatNote/Controller/Repeat.php | 4 +- plugins/RepeatNote/Entity/NoteRepeat.php | 21 --- plugins/RepeatNote/RepeatNote.php | 29 +++- .../Entity/WebMonetization.php | 15 --- src/Controller/Activity.php | 6 +- src/Core/DB/DB.php | 26 ++-- src/Core/Entity.php | 46 ++++--- src/Core/Log.php | 4 +- src/Entity/Activity.php | 62 ++++----- src/Entity/Note.php | 124 ++---------------- src/Util/HTML.php | 2 +- templates/cards/blocks/note.html.twig | 24 +++- 32 files changed, 438 insertions(+), 494 deletions(-) diff --git a/components/Blog/Controller/Post.php b/components/Blog/Controller/Post.php index 067b9baeb9..d70fb424ae 100644 --- a/components/Blog/Controller/Post.php +++ b/components/Blog/Controller/Post.php @@ -145,7 +145,7 @@ class Post extends Controller content_type: $content_type, locale: $data['language'], scope: VisibilityScope::from($data['visibility']), - targets: [(int) $data['in']], + attentions: [(int) $data['in']], reply_to: $data['reply_to_id'], attachments: $data['attachments'], process_note_content_extra_args: $extra_args, diff --git a/components/Group/Group.php b/components/Group/Group.php index 99911fdf42..2be246d166 100644 --- a/components/Group/Group.php +++ b/components/Group/Group.php @@ -51,12 +51,17 @@ class Group extends Component * Enqueues a notification for an Actor (such as person or group) which means * it shows up in their home feed and such. */ - public function onNewNotificationWithTargets(Actor $sender, Activity $activity, array $targets = [], ?string $reason = null): bool + public function onNewNotificationStart(Actor $sender, Activity $activity, array $targets = [], ?string $reason = null): bool { foreach ($targets as $target) { if ($target->isGroup()) { // The Group announces to its subscribers - Notification::notify($target, $activity, $target->getSubscribers(), $reason); + Notification::notify( + sender: $target, + activity: $activity, + targets: $target->getSubscribers(), + reason: $reason, + ); } } diff --git a/components/Notification/Controller/Feed.php b/components/Notification/Controller/Feed.php index d0955e0fb7..c9ba7c4fb7 100644 --- a/components/Notification/Controller/Feed.php +++ b/components/Notification/Controller/Feed.php @@ -53,9 +53,9 @@ class Feed extends Controller WHERE n.id IN ( SELECT act.object_id FROM \App\Entity\Activity AS act WHERE act.object_type = 'note' AND act.id IN - (SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :id) + (SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :target_id) ) - EOF, ['id' => $user->getId()]); + EOF, [':target' => $user->getId()]); return [ '_template' => 'collection/notes.html.twig', 'page_title' => _m('Notifications'), diff --git a/components/Notification/Entity/Attention.php b/components/Notification/Entity/Attention.php index 9f05f7841c..d5b9823fdb 100644 --- a/components/Notification/Entity/Attention.php +++ b/components/Notification/Entity/Attention.php @@ -24,11 +24,21 @@ namespace Component\Notification\Entity; use App\Core\Entity; /** - * Entity for note attentions + * Entity for object attentions + * + * An attention is a form of persistent notification. + * It exists together and for as long as the object it belongs to. + * Creating an attention requires creating a Notification. * * @category DB * @package GNUsocial * + * @author Zach Copley + * @copyright 2010 StatusNet Inc. + * @author Mikael Nordfeldth + * @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org + * @author Diogo Peralta Cordeiro <@diogo.site> + * @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org * @author Diogo Peralta Cordeiro <@diogo.site> * @copyright 2022 Free Software Foundation, Inc http://www.fsf.org * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later @@ -37,18 +47,30 @@ class Attention extends Entity { // {{{ Autocode // @codeCoverageIgnoreStart - private int $note_id; + private string $object_type; + private int $object_id; private int $target_id; - public function setNoteId(int $note_id): self + public function setObjectType(string $object_type): self { - $this->note_id = $note_id; + $this->object_type = mb_substr($object_type, 0, 32); return $this; } - public function getNoteId(): int + public function getObjectType(): string { - return $this->note_id; + return $this->object_type; + } + + public function setObjectId(int $object_id): self + { + $this->object_id = $object_id; + return $this; + } + + public function getObjectId(): int + { + return $this->object_id; } public function setTargetId(int $target_id): self @@ -68,15 +90,16 @@ class Attention extends Entity public static function schemaDef(): array { return [ - 'name' => 'note_attention', - 'description' => 'Note attentions to actors (that are not a mention)', + 'name' => 'attention', + 'description' => 'Attentions to actors (these are not mentions)', 'fields' => [ - 'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'note_id to give attention'], - 'target_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'actor_id for feed receiver'], + 'object_type' => ['type' => 'varchar', 'length' => 32, 'not null' => true, 'description' => 'the name of the table this object refers to'], + 'object_id' => ['type' => 'int', 'not null' => true, 'description' => 'id in the referenced table'], + 'target_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'actor_id for feed receiver'], ], - 'primary key' => ['note_id', 'target_id'], + 'primary key' => ['object_type', 'object_id', 'target_id'], 'indexes' => [ - 'attention_note_id_idx' => ['note_id'], + 'attention_object_id_idx' => ['object_id'], 'attention_target_id_idx' => ['target_id'], ], ]; diff --git a/components/Notification/Entity/Notification.php b/components/Notification/Entity/Notification.php index 10e7590716..e3fe758383 100644 --- a/components/Notification/Entity/Notification.php +++ b/components/Notification/Entity/Notification.php @@ -28,17 +28,17 @@ use App\Entity\Actor; use DateTimeInterface; /** - * Entity for attentions + * Entity for Notifications + * + * A Notification when isolated is a form of transient notification. + * When together with a persistent form of notification such as attentions or mentions, + * it records that the target was notified - which avoids re-notifying upon objects reconstructions. * * @category DB * @package GNUsocial * - * @author Zach Copley - * @copyright 2010 StatusNet Inc. - * @author Mikael Nordfeldth - * @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org - * @author Hugo Sales - * @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org + * @author Diogo Peralta Cordeiro <@diogo.site> + * @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later */ class Notification extends Entity diff --git a/components/Notification/Notification.php b/components/Notification/Notification.php index a9ac55a782..12dd805aae 100644 --- a/components/Notification/Notification.php +++ b/components/Notification/Notification.php @@ -64,14 +64,43 @@ class Notification extends Component /** * Enqueues a notification for an Actor (such as person or group) which means * it shows up in their home feed and such. + * WARNING: It's highly advisable to have flushed any relevant objects before triggering this event. + * OBSERVATION: $sender->getSubscribers() will always be pre-included, thus why $targets=[] is normal + * + * $targets should be of the shape: + * ['source' => (int|Actor)[]] // Prefer Actor whenever possible + * Example of $targets: + * [[42, $actor_alice, $actor_bob]] // Avoid repeating actors or ids + * + * @param Actor $sender The one responsible for this activity, take care not to include it in targets + * @param Activity $activity The activity responsible for the object being given to known to targets + * @param array $targets attentions, Mentions, any other source + * @param null|string $reason An optional reason explaining why this notification exists */ - public function onNewNotification(Actor $sender, Activity $activity, array $ids_already_known = [], ?string $reason = null): bool + public function onNewNotification(Actor $sender, Activity $activity, array $targets = [], ?string $reason = null): bool { - $targets = $activity->getNotificationTargets(ids_already_known: $ids_already_known, sender_id: $sender->getId()); - if (Event::handle('NewNotificationWithTargets', [$sender, $activity, $targets, $reason]) === Event::next) { - self::notify($sender, $activity, $targets, $reason); + // Ensure targets are all actor objects and unique + $effective_targets = []; + foreach ($targets as $target) { + if (\is_int($target)) { + $target_id = $target; + $target_object = null; + } else { + $target_id = $target->getId(); + $target_object = $target; + } + if (!\array_key_exists(key: $target_id, array: $effective_targets)) { + $target_object ??= Actor::getById($target_id); + $effective_targets[$target_id] = $target_object; + } + } + unset($targets); + + if (Event::handle('NewNotificationStart', [$sender, $activity, $effective_targets, $reason]) === Event::next) { + self::notify($sender, $activity, $effective_targets, $reason); } + Event::handle('NewNotificationEnd', [$sender, $activity, $effective_targets, $reason]); return Event::next; } @@ -91,7 +120,8 @@ class Notification extends Component } /** - * Bring given Activity to Targets's attention + * Bring given Activity to Targets' knowledge. + * This will flush a Notification to DB. * * @return true if successful, false otherwise */ @@ -100,8 +130,8 @@ class Notification extends Component $remote_targets = []; foreach ($targets as $target) { if ($target->getIsLocal()) { - if ($target->hasBlocked($activity->getActor())) { - Log::info("Not saving reply to actor {$target->getId()} from sender {$sender->getId()} because of a block."); + if ($target->hasBlocked($author = $activity->getActor())) { + Log::info("Not saving notification to actor {$target->getId()} from sender {$sender->getId()} because receiver blocked author {$author->getId()}."); continue; } if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) { @@ -113,7 +143,7 @@ class Notification extends Component } Queue::enqueue( payload: [$sender, $activity, $target, $reason], - queue: 'notification_local', + queue: 'NotificationLocal', priority: true, ); } else { @@ -124,7 +154,7 @@ class Notification extends Component } // XXX: Unideal as in failures the rollback will leave behind a false notification, // but most notifications (all) require flushing the objects first - // Should be okay as long as implementors bear this in mind + // Should be okay as long as implementations bear this in mind try { DB::wrapInTransaction(fn () => DB::persist(Entity\Notification::create([ 'activity_id' => $activity->getId(), @@ -132,7 +162,7 @@ class Notification extends Component 'reason' => $reason, ]))); } catch (Exception|Throwable $e) { - // We do our best not to record duplicated notifications, but it's not insane that can happen + // We do our best not to record duplicate notifications, but it's not insane that can happen Log::error('It was attempted to record an invalid notification!', [$e]); } } @@ -140,7 +170,7 @@ class Notification extends Component if ($remote_targets !== []) { Queue::enqueue( payload: [$sender, $activity, $remote_targets, $reason], - queue: 'notification_remote', + queue: 'NotificationRemote', priority: false, ); } diff --git a/components/Posting/Controller/Posting.php b/components/Posting/Controller/Posting.php index 5f5e8bdac7..6d26e7e9e5 100644 --- a/components/Posting/Controller/Posting.php +++ b/components/Posting/Controller/Posting.php @@ -52,7 +52,7 @@ class Posting extends Controller content_type: $data['content_type'], locale: $data['language'], scope: VisibilityScope::from($data['visibility']), - targets: isset($target) ? [$target] : [], + attentions: isset($target) ? [$target] : [], reply_to: \array_key_exists('reply_to_id', $data) ? $data['reply_to_id'] : null, attachments: $data['attachments'], process_note_content_extra_args: $extra_args, @@ -61,9 +61,9 @@ class Posting extends Controller return Core\Form::forceRedirect($form, $request); } } catch (FormSizeFileException $e) { - throw new ClientException(_m('Invalid file size given'), previous: $e); + throw new ClientException(_m('Invalid file size given.'), previous: $e); } } - throw new ClientException(_m('Invalid form submission')); + throw new ClientException(_m('Invalid form submission.')); } } diff --git a/components/Posting/Posting.php b/components/Posting/Posting.php index 6898f149a7..98ef5c8037 100644 --- a/components/Posting/Posting.php +++ b/components/Posting/Posting.php @@ -48,7 +48,6 @@ use Component\Attachment\Entity\AttachmentToNote; use Component\Conversation\Conversation; use Component\Language\Entity\Language; use Component\Notification\Entity\Attention; -use Functional as F; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; @@ -94,7 +93,7 @@ class Posting extends Component string $content_type, ?string $locale = null, ?VisibilityScope $scope = null, - array $targets = [], + array $attentions = [], null|int|Note $reply_to = null, array $attachments = [], array $processed_attachments = [], @@ -104,13 +103,13 @@ class Posting extends Component string $source = 'web', ?string $title = null, ): array { - [$activity, $note, $attention_ids] = self::storeLocalNote( + [$activity, $note, $effective_attentions] = self::storeLocalNote( actor: $actor, content: $content, content_type: $content_type, locale: $locale, scope: $scope, - targets: $targets, + attentions: $attentions, reply_to: $reply_to, attachments: $attachments, processed_attachments: $processed_attachments, @@ -125,10 +124,18 @@ class Posting extends Component if ($flush_and_notify) { // Flush before notification DB::flush(); - Event::handle('NewNotification', [$actor, $activity, ['object' => $attention_ids], _m('{nickname} created a page {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $activity->getObjectId()])]); + Event::handle('NewNotification', [ + $actor, + $activity, + $effective_attentions, + _m('Actor {actor_id} created page {note_id}.', [ + '{actor_id}' => $actor->getId(), + '{note_id}' => $activity->getObjectId(), + ]), + ]); } - return [$activity, $note, $attention_ids]; + return [$activity, $note, $effective_attentions]; } /** @@ -141,7 +148,7 @@ class Posting extends Component * @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...) * @param null|string $locale Note's written text language, set by the default Actor language or upon filling * @param null|VisibilityScope $scope The visibility of this Note - * @param array $targets Actor|int[]: In Group/To Person or Bot, registers an attention between note and target + * @param array $attentions Actor|int[]: In Group/To Person or Bot, registers an attention between note and target * @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself * @param array $attachments UploadedFile[] to be stored as GSFiles associated to this note * @param array $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note @@ -154,7 +161,7 @@ class Posting extends Component * @throws DuplicateFoundException * @throws ServerException * - * @return array [Activity, Note, int[]] Activity, Note, Attention Ids + * @return array [Activity, Note, Effective Attentions] */ public static function storeLocalNote( Actor $actor, @@ -162,7 +169,7 @@ class Posting extends Component string $content_type, ?string $locale = null, ?VisibilityScope $scope = null, - array $targets = [], + array $attentions = [], null|int|Note $reply_to = null, array $attachments = [], array $processed_attachments = [], @@ -209,7 +216,7 @@ class Posting extends Component if (!\is_null($reply_to_id)) { Cache::incr(Note::cacheKeys($reply_to_id)['replies-count']); // Not having them cached doesn't mean replies don't exist, but don't push it to the - // list, as that means they need to be refetched, or some would be missed + // list, as that means they need to be re-fetched, or some would be missed if (Cache::exists(Note::cacheKeys($reply_to_id)['replies'])) { Cache::listPushRight(Note::cacheKeys($reply_to_id)['replies'], $note); } @@ -221,12 +228,12 @@ class Posting extends Component Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]); } - // These are note attachments now, and not just attachments, ensure these relations are ensured + // These are note attachments now, and not just attachments, ensure these relations are respected if ($processed_attachments !== []) { foreach ($processed_attachments as [$a, $fname]) { // Most attachments should already be associated with its author, but maybe it didn't make sense //for this attachment, or it's simply a repost of an attachment by a different actor - if (DB::count('actor_to_attachment', $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) { + if (DB::count(ActorToAttachment::class, $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) { DB::persist(ActorToAttachment::create($args)); } DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname])); @@ -242,13 +249,38 @@ class Posting extends Component ]); DB::persist($activity); - $attention_ids = []; - foreach ($targets as $target) { - $target_id = \is_int($target) ? $target : $target->getId(); - DB::persist(Attention::create(['note_id' => $note->getId(), 'target_id' => $target_id])); - $attention_ids[$target_id] = true; + $effective_attentions = []; + foreach ($attentions as $target) { + if (\is_int($target)) { + $target_id = $target; + $add = !\array_key_exists($target_id, $effective_attentions); + $effective_attentions[$target_id] = $target; + } else { + $target_id = $target->getId(); + if ($add = !\array_key_exists($target_id, $effective_attentions)) { + $effective_attentions[$target_id] = $target_id; + } + } + if ($add) { + DB::persist(Attention::create(['object_type' => Note::schemaName(), 'object_id' => $note->getId(), 'target_id' => $target_id])); + } + } + + foreach ($mentions as $m) { + foreach ($m['mentioned'] ?? [] as $mentioned) { + $target_id = $mentioned->getId(); + if (!\array_key_exists($target_id, $effective_attentions)) { + DB::persist(Attention::create(['object_type' => Note::schemaName(), 'object_id' => $note->getId(), 'target_id' => $target_id])); + } + $effective_attentions[$target_id] = $mentioned; + } + } + + foreach ($actor->getSubscribers() as $subscriber) { + $target_id = $subscriber->getId(); + DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $target_id])); + $effective_attentions[$target_id] = $subscriber; } - $attention_ids = array_keys($attention_ids); if ($flush_and_notify) { // Flush before notification @@ -256,18 +288,15 @@ class Posting extends Component Event::handle('NewNotification', [ $actor, $activity, - [ - 'note-attention' => $attention_ids, - 'object' => F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId()))), - ], - _m('{nickname} created a note {note_id}.', [ - '{nickname}' => $actor->getNickname(), + $effective_attentions, + _m('Actor {actor_id} created note {note_id}.', [ + '{actor_id}' => $actor->getId(), '{note_id}' => $activity->getObjectId(), ]), ]); } - return [$activity, $note, $attention_ids]; + return [$activity, $note, $effective_attentions]; } public function onRenderNoteContent(string $content, string $content_type, ?string &$rendered, Actor $author, ?string $language = null, array &$mentions = []) diff --git a/components/Subscription/Entity/ActorSubscription.php b/components/Subscription/Entity/ActorSubscription.php index f820d5da01..38d6e82424 100644 --- a/components/Subscription/Entity/ActorSubscription.php +++ b/components/Subscription/Entity/ActorSubscription.php @@ -114,27 +114,6 @@ class ActorSubscription extends Entity ]; } - /** - * @see Entity->getNotificationTargetIds - */ - public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array - { - if (!\array_key_exists('object', $ids_already_known)) { - $target_ids = [$this->getSubscribedId()]; // The object of any subscription is the one subscribed (or unsubscribed) - } else { - $target_ids = $ids_already_known['object']; - } - - // Additional actors that should know about this - if ($include_additional && \array_key_exists('additional', $ids_already_known)) { - array_push($target_ids, ...$ids_already_known['additional']); - } else { - return $target_ids; - } - - return array_unique($target_ids); - } - public static function schemaDef(): array { return [ diff --git a/components/Subscription/Subscription.php b/components/Subscription/Subscription.php index 37f91c6beb..0b40189f45 100644 --- a/components/Subscription/Subscription.php +++ b/components/Subscription/Subscription.php @@ -37,6 +37,7 @@ use App\Util\Common; use App\Util\Exception\DuplicateFoundException; use App\Util\Exception\NotFoundException; use App\Util\Exception\ServerException; +use Component\Notification\Entity\Attention; use Component\Subscription\Controller\Subscribers as SubscribersController; use Component\Subscription\Controller\Subscriptions as SubscriptionsController; @@ -97,20 +98,21 @@ class Subscription extends Component $subscription = DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true); $activity = null; if (\is_null($subscription)) { - DB::persist(Entity\ActorSubscription::create($opts)); + DB::persist($subscription = Entity\ActorSubscription::create($opts)); $activity = Activity::create([ 'actor_id' => $subscriber_id, 'verb' => 'subscribe', - 'object_type' => 'actor', + 'object_type' => Actor::schemaName(), 'object_id' => $subscribed_id, 'source' => $source, ]); DB::persist($activity); + DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $subscribed_id])); Event::handle('NewNotification', [ \is_int($subject) ? $subject : Actor::getById($subscriber_id), $activity, - ['object' => [$activity->getObjectId()]], + [$subscribed_id], _m('{subject} subscribed to {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $activity->getObjectId()]), ]); } @@ -146,21 +148,22 @@ class Subscription extends Component if (!\is_null($subscription)) { // Remove Subscription DB::remove($subscription); - $previous_follow_activity = DB::findBy('activity', ['verb' => 'subscribe', 'object_type' => 'actor', 'object_id' => $subscribed_id], order_by: ['created' => 'DESC'])[0]; + $previous_follow_activity = DB::findBy(Activity::class, ['verb' => 'subscribe', 'object_type' => Actor::schemaName(), 'object_id' => $subscribed_id], order_by: ['created' => 'DESC'])[0]; // Store Activity $activity = Activity::create([ 'actor_id' => $subscriber_id, 'verb' => 'undo', - 'object_type' => 'activity', + 'object_type' => Activity::schemaName(), 'object_id' => $previous_follow_activity->getId(), 'source' => $source, ]); DB::persist($activity); + DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $subscribed_id])); Event::handle('NewNotification', [ \is_int($subject) ? $subject : Actor::getById($subscriber_id), $activity, - ['object' => [$previous_follow_activity->getObjectId()]], + [], _m('{subject} unsubscribed from {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $previous_follow_activity->getObjectId()]), ]); } diff --git a/plugins/ActivityPub/ActivityPub.php b/plugins/ActivityPub/ActivityPub.php index 0430126903..e11a6ada97 100644 --- a/plugins/ActivityPub/ActivityPub.php +++ b/plugins/ActivityPub/ActivityPub.php @@ -141,14 +141,12 @@ class ActivityPub extends Plugin $ap_actor->getActorId(), Discovery::normalize($actor->getNickname() . '@' . parse_url($ap_actor->getInboxUri(), \PHP_URL_HOST)), ); - $already_known_ids = []; - if (!empty($ap_act->_object_mention_ids)) { - $already_known_ids = $ap_act->_object_mention_ids; - } DB::flush(); - if (Event::handle('ActivityPubNewNotification', [$actor, $ap_act->getActivity(), $already_known_ids, _m('{nickname} attentioned you.', ['{nickname}' => $actor->getNickname()])]) === Event::next) { - Event::handle('NewNotification', [$actor, $ap_act->getActivity(), $already_known_ids, _m('{nickname} attentioned you.', ['{nickname}' => $actor->getNickname()])]); + if ($ap_act->getToNotifyTargets() !== []) { + if (Event::handle('ActivityPubNewNotification', [$actor, $ap_act->getActivity(), $ap_act->getAttentionTargets(), _m('{actor_id} triggered a notification via ActivityPub.', ['{actor_id}' => $actor->getId()])]) === Event::next) { + Event::handle('NewNotification', [$actor, $ap_act->getActivity(), $ap_act->getAttentionTargets(), _m('{actor_id} triggered a notification via ActivityPub.', ['{nickname}' => $actor->getId()])]); + } } return Event::stop; @@ -324,8 +322,7 @@ class ActivityPub extends Plugin string $inbox, array $to_actors, array &$retry_args, - ): bool - { + ): bool { try { $data = Model::toJson($activity); if ($sender->isGroup()) { @@ -391,7 +388,7 @@ class ActivityPub extends Plugin foreach ($to_addr as $inbox => $to_actors) { Queue::enqueue( payload: [$sender, $activity, $inbox, $to_actors], - queue: 'activitypub_postman', + queue: 'ActivitypubPostman', priority: false, ); } @@ -530,7 +527,7 @@ class ActivityPub extends Plugin * * @return null|Actor|mixed|Note got from URI */ - public static function getObjectByUri(string $resource, bool $try_online = true) + public static function getObjectByUri(string $resource, bool $try_online = true): mixed { // Try known object $known_object = DB::findOneBy(ActivitypubObject::class, ['object_uri' => $resource], return_null: true); @@ -544,18 +541,6 @@ class ActivityPub extends Plugin return $known_activity->getActivity(); } - // Try local Note - if (Common::isValidHttpUrl($resource)) { - $resource_parts = parse_url($resource); - // TODO: Use URLMatcher - if ($resource_parts['host'] === Common::config('site', 'server')) { - $local_note = DB::findOneBy('note', ['url' => $resource], return_null: true); - if (!\is_null($local_note)) { - return $local_note; - } - } - } - // Try Actor try { return Explorer::getOneFromUri($resource, try_online: false); @@ -563,20 +548,50 @@ class ActivityPub extends Plugin // Ignore, this is brute forcing, it's okay not to find } - // Try remote - if (!$try_online) { - return; + // Is it a HTTP URL? + if (Common::isValidHttpUrl($resource)) { + $resource_parts = parse_url($resource); + // If it is local + if ($resource_parts['host'] === Common::config('site', 'server')) { + // Try Local Note + $local_note = DB::findOneBy(Note::class, ['url' => $resource], return_null: true); + if (!\is_null($local_note)) { + return $local_note; + } + + // Try local Activity + try { + $match = Router::match($resource_parts['path']); + $local_activity = DB::findOneBy(Activity::class, ['id' => $match['id']], return_null: true); + if (!\is_null($local_activity)) { + return $local_activity; + } else { + throw new InvalidArgumentException('Tried to retrieve a non-existent local activity.'); + } + } catch (\Exception) { + // Ignore, this is brute forcing, it's okay not to find + } + + throw new BugFoundException('ActivityPub failed to retrieve local resource: "' . $resource . '". This is a big issue.'); + } else { + // Then it's remote + if (!$try_online) { + throw new Exception("Remote resource {$resource} not found without online resources."); + } + + $response = HTTPClient::get($resource, ['headers' => self::HTTP_CLIENT_HEADERS]); + // If it was deleted + if ($response->getStatusCode() == 410) { + //$obj = Type::create('Tombstone', ['id' => $resource]); + return null; + } elseif (!HTTPClient::statusCodeIsOkay($response)) { // If it is unavailable + throw new Exception('Non Ok Status Code for given Object id.'); + } else { + return Model::jsonToType($response->getContent()); + } + } } - $response = HTTPClient::get($resource, ['headers' => self::HTTP_CLIENT_HEADERS]); - // If it was deleted - if ($response->getStatusCode() == 410) { - //$obj = Type::create('Tombstone', ['id' => $resource]); - return; - } elseif (!HTTPClient::statusCodeIsOkay($response)) { // If it is unavailable - throw new Exception('Non Ok Status Code for given Object id.'); - } else { - return Model::jsonToType($response->getContent()); - } + return null; } } diff --git a/plugins/ActivityPub/Controller/Inbox.php b/plugins/ActivityPub/Controller/Inbox.php index 6026620bf5..d166486692 100644 --- a/plugins/ActivityPub/Controller/Inbox.php +++ b/plugins/ActivityPub/Controller/Inbox.php @@ -38,7 +38,6 @@ use function App\Core\I18n\_m; use App\Core\Log; use App\Core\Queue\Queue; use App\Core\Router\Router; -use App\Entity\Actor; use App\Util\Common; use App\Util\Exception\ClientException; use Exception; @@ -164,7 +163,7 @@ class Inbox extends Controller Queue::enqueue( payload: [$ap_actor, $actor, $type], - queue: 'activitypub_inbox', + queue: 'ActivitypubInbox', priority: false, ); diff --git a/plugins/ActivityPub/Entity/ActivitypubActivity.php b/plugins/ActivityPub/Entity/ActivitypubActivity.php index 288e162075..c4718a5870 100644 --- a/plugins/ActivityPub/Entity/ActivitypubActivity.php +++ b/plugins/ActivityPub/Entity/ActivitypubActivity.php @@ -101,27 +101,17 @@ class ActivitypubActivity extends Entity public function getActivity(): Activity { - return DB::findOneBy('activity', ['id' => $this->getActivityId()]); + return DB::findOneBy(Activity::class, ['id' => $this->getActivityId()]); } - public array $_object_mention_ids = []; - public function setObjectMentionIds(array $mentions): self + public function getAttentionTargetIds(): array { - $this->_object_mention_ids = $mentions; - return $this; + return $this->getActivity()->getAttentionTargetIds(); } - /** - * @see Entity->getNotificationTargetIds - */ - public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array + public function getAttentionTargets(): array { - // Additional actors that should know about this - if (\array_key_exists('additional', $ids_already_known)) { - return $ids_already_known['additional']; - } else { - return $this->_object_mention_ids; - } + return $this->getActivity()->getAttentionTargets(); } public static function schemaDef(): array diff --git a/plugins/ActivityPub/Util/Model/Activity.php b/plugins/ActivityPub/Util/Model/Activity.php index 32eb8c95d4..1208dc1ef4 100644 --- a/plugins/ActivityPub/Util/Model/Activity.php +++ b/plugins/ActivityPub/Util/Model/Activity.php @@ -44,6 +44,7 @@ use App\Util\Exception\NotFoundException; use App\Util\Exception\NotImplementedException; use DateTimeInterface; use InvalidArgumentException; +use const JSON_UNESCAPED_SLASHES; use Plugin\ActivityPub\ActivityPub; use Plugin\ActivityPub\Entity\ActivitypubActivity; use Plugin\ActivityPub\Util\Explorer; @@ -145,7 +146,7 @@ class Activity extends Model * * @throws ClientException */ - public static function toJson(mixed $object, int $options = \JSON_UNESCAPED_SLASHES): string + public static function toJson(mixed $object, int $options = JSON_UNESCAPED_SLASHES): string { if ($object::class !== GSActivity::class) { throw new InvalidArgumentException('First argument type must be an Activity.'); @@ -169,10 +170,16 @@ class Activity extends Model 'actor' => $object->getActor()->getUri(Router::ABSOLUTE_URL), ]; + $attr['to'] = []; + $attr['cc'] = ['https://www.w3.org/ns/activitystreams#Public']; + foreach ($object->getAttentionTargets() as $target) { + $attr['cc'][] = $target->getUri(); + } + // Get object or Tombstone try { - $object = $object->getObject(); // Throws NotFoundException - $attr['object'] = ($attr['type'] === 'Create') ? self::jsonToType(Model::toJson($object)) : ActivityPub::getUriByObject($object); + $child = $object->getObject(); // Throws NotFoundException + $attr['object'] = ($attr['type'] === 'Create') ? self::jsonToType(Model::toJson($child)) : ActivityPub::getUriByObject($child); } catch (NotFoundException) { // It seems this object was deleted, refer to it as a Tombstone $uri = match ($object->getObjectType()) { @@ -180,18 +187,7 @@ class Activity extends Model 'actor' => Router::url('actor_view_id', ['id' => $object->getObjectId()], type: Router::ABSOLUTE_URL), default => throw new NotImplementedException(), }; - $attr['object'] = Type::create('Tombstone', [ - 'id' => $uri, - ]); - } - - // If embedded non tombstone Object - if (!\is_string($attr['object']) && $attr['object']->get('type') !== 'Tombstone') { - // Little special case - if ($attr['type'] === 'Create' && ($attr['object']->get('type') === 'Note' || $attr['object']->get('type') === 'Page')) { - $attr['to'] = $attr['object']->get('to') ?? []; - $attr['cc'] = $attr['object']->get('cc') ?? []; - } + $attr['object'] = Type::create('Tombstone', ['id' => $uri]); } if (!\is_string($attr['object'])) { diff --git a/plugins/ActivityPub/Util/Model/ActivityCreate.php b/plugins/ActivityPub/Util/Model/ActivityCreate.php index 4cd4b69ca6..ddba48ddba 100644 --- a/plugins/ActivityPub/Util/Model/ActivityCreate.php +++ b/plugins/ActivityPub/Util/Model/ActivityCreate.php @@ -94,7 +94,6 @@ class ActivityCreate extends Activity 'modified' => new DateTime(), ]); DB::persist($ap_act); - $ap_act->setObjectMentionIds($note->_object_mentions_ids); return $ap_act; } } diff --git a/plugins/ActivityPub/Util/Model/Note.php b/plugins/ActivityPub/Util/Model/Note.php index ecfa25baf3..dad78b13da 100644 --- a/plugins/ActivityPub/Util/Model/Note.php +++ b/plugins/ActivityPub/Util/Model/Note.php @@ -184,14 +184,24 @@ class Note extends Model } } - $attention_ids = []; + $explorer = new Explorer(); + + $attention_targets = []; foreach ($to as $target) { if ($target === 'https://www.w3.org/ns/activitystreams#Public') { continue; } try { - $actor = Explorer::getOneFromUri($target); - $attention_ids[$actor->getId()] = $target; + try { + $actor_targets = $explorer->lookup($target); + foreach ($actor_targets as $actor) { + $attention_targets[$actor->getId()] = $actor; + } + } catch (Exception $e) { + Log::debug('ActivityPub->Model->Note->fromJson->Attention->Explorer', [$e]); + } + $actor = Explorer::getOneFromUri($target); + $attention_targets[$actor->getId()] = $actor; // If $to is a group and note is unlisted, set note's scope as Group if ($actor->isGroup() && $map['scope'] === 'unlisted') { $map['scope'] = VisibilityScope::GROUP; @@ -211,29 +221,17 @@ class Note extends Model continue; } try { - $actor = Explorer::getOneFromUri($target); - $attention_ids[$actor->getId()] = $target; - } catch (Exception $e) { - Log::debug('ActivityPub->Model->Note->fromJson->getActorByUri', [$e]); - } - } - - $obj = GSNote::create($map); - DB::persist($obj); - - foreach ($attention_ids as $attention_uri) { - $explorer = new Explorer(); - try { - $actors = $explorer->lookup($attention_uri); - foreach ($actors as $actor) { - $object_mention_ids[$target_id = $actor->getId()] = $attention_uri; - DB::persist(Attention::create(['note_id' => $obj->getId(), 'target_id' => $target_id])); + $actor_targets = $explorer->lookup($target); + foreach ($actor_targets as $actor) { + $attention_targets[$actor->getId()] = $actor; } } catch (Exception $e) { Log::debug('ActivityPub->Model->Note->fromJson->Attention->Explorer', [$e]); } } - $attention_ids = array_keys($attention_ids); + + $obj = GSNote::create($map); + DB::persist($obj); // Attachments $processed_attachments = []; @@ -272,15 +270,15 @@ class Note extends Model } } - $object_mention_ids = []; + $mention_uris = []; foreach ($type_note->get('tag') ?? [] as $ap_tag) { switch ($ap_tag->get('type')) { case 'Mention': - $explorer = new Explorer(); try { - $actors = $explorer->lookup($ap_tag->get('href')); - foreach ($actors as $actor) { - $object_mention_ids[$actor->getId()] = $ap_tag->get('href'); + $mention_uris[] = $resource = $ap_tag->get('href'); + $actor_targets = $explorer->lookup($resource); + foreach ($actor_targets as $actor) { + $attention_targets[$actor->getId()] = $actor; } } catch (Exception $e) { Log::debug('ActivityPub->Model->Note->fromJson->Mention->Explorer', [$e]); @@ -306,14 +304,15 @@ class Note extends Model } // The content would be non-sanitized text/html - Event::handle('ProcessNoteContent', [$obj, $obj->getRendered(), $obj->getContentType(), $process_note_content_extra_args = ['TagProcessed' => true, 'ignoreLinks' => $object_mention_ids]]); + Event::handle('ProcessNoteContent', [$obj, $obj->getRendered(), $obj->getContentType(), $process_note_content_extra_args = ['TagProcessed' => true, 'ignoreLinks' => $mention_uris]]); - $object_mention_ids = array_keys($object_mention_ids); - $obj->setObjectMentionsIds($object_mention_ids); + foreach ($attention_targets as $target) { + DB::persist(Attention::create(['object_type' => GSNote::schemaName(), 'object_id' => $obj->getId(), 'target_id' => $target->getId()])); + } if ($processed_attachments !== []) { foreach ($processed_attachments as [$a, $fname]) { - if (DB::count('actor_to_attachment', $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor_id]) === 0) { + if (DB::count(ActorToAttachment::class, $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor_id]) === 0) { DB::persist(ActorToAttachment::create($args)); } DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $obj->getId(), 'title' => $fname])); @@ -351,11 +350,11 @@ class Note extends Model } $attr = [ - '@context' => ActivityPub::$activity_streams_two_context, - 'type' => $object->getScope() === VisibilityScope::MESSAGE ? 'ChatMessage' : (match ($object->getType()) { - 'note' => 'Note', - 'page' => 'Page', - default => throw new \Exception('Unsupported note type.') + '@context' => ActivityPub::$activity_streams_two_context, + 'type' => $object->getScope() === VisibilityScope::MESSAGE ? 'ChatMessage' : (match ($object->getType()) { + 'note' => 'Note', + 'page' => 'Page', + default => throw new Exception('Unsupported note type.') }), 'id' => $object->getUrl(), 'published' => $object->getCreated()->format(DateTimeInterface::RFC3339), @@ -370,17 +369,28 @@ class Note extends Model 'inConversation' => $object->getConversationUri(), ]; + $attentions = $object->getAttentionTargets(); // Target scope switch ($object->getScope()) { case VisibilityScope::EVERYWHERE: $attr['to'] = ['https://www.w3.org/ns/activitystreams#Public']; - $attr['cc'] = [Router::url('actor_subscribers_id', ['id' => $object->getActor()->getId()], Router::ABSOLUTE_URL)]; + $attr['cc'] = []; + foreach ($attentions as $target) { + if ($object->getScope() === VisibilityScope::GROUP && $target->isGroup()) { + $attr['to'][] = $target->getUri(Router::ABSOLUTE_URL); + } else { + $attr['cc'][] = $target->getUri(Router::ABSOLUTE_URL); + } + } break; case VisibilityScope::LOCAL: throw new ClientException('This note was not federated.', 403); case VisibilityScope::ADDRESSEE: case VisibilityScope::MESSAGE: - $attr['to'] = []; // Will be filled later + $attr['to'] = []; + foreach ($attentions as $target) { + $attr['to'][] = $target->getUri(Router::ABSOLUTE_URL); + } $attr['cc'] = []; break; case VisibilityScope::GROUP: @@ -391,22 +401,19 @@ class Note extends Model // of posts. In this situation, it's safer to always send answers of type unlisted. $attr['to'] = []; $attr['cc'] = ['https://www.w3.org/ns/activitystreams#Public']; + foreach ($attentions as $target) { + if ($object->getScope() === VisibilityScope::GROUP && $target->isGroup()) { + $attr['to'][] = $target->getUri(Router::ABSOLUTE_URL); + } else { + $attr['cc'][] = $target->getUri(Router::ABSOLUTE_URL); + } + } break; default: Log::error('ActivityPub->Note->toJson: Found an unknown visibility scope.'); throw new ServerException('Found an unknown visibility scope which cannot federate.'); } - // Notification Targets without Mentions - $attentions = $object->getNotificationTargets(ids_already_known: ['object' => []]); - foreach ($attentions as $target) { - if ($object->getScope() === VisibilityScope::GROUP && $target->isGroup()) { - $attr['to'][] = $target->getUri(Router::ABSOLUTE_URL); - } else { - $attr['cc'][] = $target->getUri(Router::ABSOLUTE_URL); - } - } - // Mentions foreach ($object->getMentionTargets() as $mention) { $attr['tag'][] = [ diff --git a/plugins/DeleteNote/DeleteNote.php b/plugins/DeleteNote/DeleteNote.php index 775822bcda..289839afc4 100644 --- a/plugins/DeleteNote/DeleteNote.php +++ b/plugins/DeleteNote/DeleteNote.php @@ -97,7 +97,7 @@ class DeleteNote extends NoteHandlerPlugin Cache::delete(self::cacheKeys($note)['activity']); // Undertaker successful - Event::handle('NewNotification', [$actor, $activity, [], _m('{nickname} deleted note {note_id}.', ['nickname' => $actor->getNickname(), 'note_id' => $activity->getObjectId()])]); + Event::handle('NewNotification', [$actor, $activity, $note->getAttentionTargets(), _m('{actor_id} deleted note {note_id}.', ['actor_id' => $actor->getId(), 'note_id' => $activity->getObjectId()])]); return $activity; } @@ -120,7 +120,7 @@ class DeleteNote extends NoteHandlerPlugin if (\is_null( Cache::get( self::cacheKeys($note)['activity'], - fn () => DB::findOneBy(Activity::class, ['verb' => 'delete', 'object_type' => 'note', 'object_id' => $note->getId()], return_null: true), + fn () => DB::findOneBy(Activity::class, ['verb' => 'delete', 'object_type' => Note::schemaName(), 'object_id' => $note->getId()], return_null: true), ), )) { // If none found, then undertaker has a job to do @@ -164,7 +164,7 @@ class DeleteNote extends NoteHandlerPlugin // Only add action if note wasn't already deleted! \is_null(Cache::get( self::cacheKeys($note)['activity'], - fn () => DB::findOneBy(Activity::class, ['verb' => 'delete', 'object_type' => 'note', 'object_id' => $note->getId()], return_null: true), + fn () => DB::findOneBy(Activity::class, ['verb' => 'delete', 'object_type' => Note::schemaName(), 'object_id' => $note->getId()], return_null: true), )) // And has permissions && $actor->canModerate($note->getActor())) { diff --git a/plugins/Favourite/Controller/Favourite.php b/plugins/Favourite/Controller/Favourite.php index ed4f156cac..bd2bb4f549 100644 --- a/plugins/Favourite/Controller/Favourite.php +++ b/plugins/Favourite/Controller/Favourite.php @@ -30,6 +30,8 @@ use function App\Core\I18n\_m; use App\Core\Log; use App\Core\Router\Router; use App\Entity\Actor; +use App\Entity\LocalUser; +use App\Entity\Note; use App\Util\Common; use App\Util\Exception\ClientException; use App\Util\Exception\InvalidFormException; @@ -55,7 +57,7 @@ class Favourite extends FeedController $user = Common::ensureLoggedIn(); $actor_id = $user->getId(); $opts = ['id' => $id]; - $add_favourite_note = DB::find('note', $opts); + $add_favourite_note = DB::findOneBy(Note::class, $opts); if (\is_null($add_favourite_note)) { throw new NoSuchNoteException(); } @@ -76,7 +78,7 @@ class Favourite extends FeedController if ($form_add_to_favourite->isSubmitted()) { if (!\is_null($activity = \Plugin\Favourite\Favourite::favourNote(note_id: $id, actor_id: $actor_id))) { DB::flush(); - Event::handle('NewNotification', [$actor = Actor::getById($actor_id), $activity, [], _m('{nickname} favoured note {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $activity->getObjectId()])]); + Event::handle('NewNotification', [$actor = Actor::getById($actor_id), $activity, [], _m('{actor_id} favoured note {note_id}.', ['{actor_id}' => $actor->getId(), '{note_id}' => $activity->getObjectId()])]); } else { throw new ClientException(_m('Note already favoured!')); } @@ -116,7 +118,7 @@ class Favourite extends FeedController $user = Common::ensureLoggedIn(); $actor_id = $user->getId(); $opts = ['id' => $id]; - $remove_favourite_note = DB::find('note', $opts); + $remove_favourite_note = DB::findOneBy(Note::class, $opts); if (\is_null($remove_favourite_note)) { throw new NoSuchNoteException(); } @@ -136,7 +138,7 @@ class Favourite extends FeedController if ($form_remove_favourite->isSubmitted()) { if (!\is_null($activity = \Plugin\Favourite\Favourite::unfavourNote(note_id: $id, actor_id: $actor_id))) { DB::flush(); - Event::handle('NewNotification', [$actor = Actor::getById($actor_id), $activity, [], _m('{nickname} unfavoured note {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $activity->getObjectId()])]); + Event::handle('NewNotification', [$actor = Actor::getById($actor_id), $activity, [], _m('{actor_id} unfavoured note {note_id}.', ['{actor_id}' => $actor->getId(), '{note_id}' => $activity->getObjectId()])]); } else { throw new ClientException(_m('Note already unfavoured!')); } @@ -157,7 +159,7 @@ class Favourite extends FeedController } } - $note = DB::find('note', ['id' => $id]); + $note = DB::findOneBy(Note::class, ['id' => $id]); return [ '_template' => 'favourite/remove_from_favourites.html.twig', 'note' => $note, @@ -186,7 +188,7 @@ class Favourite extends FeedController public function favouritesViewByActorNickname(Request $request, string $nickname) { - $user = DB::findOneBy('local_user', ['nickname' => $nickname]); + $user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]); return self::favouritesViewByActorId($request, $user->getId()); } @@ -219,7 +221,7 @@ class Favourite extends FeedController public function reverseFavouritesViewByActorNickname(Request $request, string $nickname) { - $user = DB::findOneBy('local_user', ['nickname' => $nickname]); + $user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]); return self::reverseFavouritesViewByActorId($request, $user->getId()); } } diff --git a/plugins/Favourite/Entity/NoteFavourite.php b/plugins/Favourite/Entity/NoteFavourite.php index 0e8fcdf923..e6bd0806c9 100644 --- a/plugins/Favourite/Entity/NoteFavourite.php +++ b/plugins/Favourite/Entity/NoteFavourite.php @@ -121,27 +121,6 @@ class NoteFavourite extends Entity ); } - /** - * @see Entity->getNotificationTargetIds - */ - public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array - { - if (!\array_key_exists('object', $ids_already_known)) { - $target_ids = Note::getById($this->getNoteId())->getNotificationTargetIds(); - } else { - $target_ids = $ids_already_known['object']; - } - - // Additional actors that should know about this - if ($include_additional && \array_key_exists('additional', $ids_already_known)) { - array_push($target_ids, ...$ids_already_known['additional']); - } else { - return $target_ids; - } - - return array_unique($target_ids); - } - public static function schemaDef(): array { return [ diff --git a/plugins/Favourite/Favourite.php b/plugins/Favourite/Favourite.php index e9eb6801a3..82d9e27177 100644 --- a/plugins/Favourite/Favourite.php +++ b/plugins/Favourite/Favourite.php @@ -44,8 +44,9 @@ use App\Entity\LocalUser; use App\Entity\Note; use App\Util\Common; use App\Util\Nickname; +use Component\Notification\Entity\Attention; use DateTime; -use Plugin\Favourite\Entity\NoteFavourite as FavouriteEntity; +use Plugin\Favourite\Entity\NoteFavourite; use Symfony\Component\HttpFoundation\Request; class Favourite extends NoteHandlerPlugin @@ -54,8 +55,6 @@ class Favourite extends NoteHandlerPlugin * Creates a new Favourite Entity, upon the given Actor performs a Favourite * action on the given Note object * - * A new notification is then handled, informing all interested Actors of this action - * * @param int $note_id Note id being favoured * @param int $actor_id Actor performing favourite Activity * @@ -65,13 +64,13 @@ class Favourite extends NoteHandlerPlugin { $opts = ['note_id' => $note_id, 'actor_id' => $actor_id]; $note_already_favoured = Cache::get( - FavouriteEntity::cacheKeys($note_id, $actor_id)['favourite'], - fn () => DB::findOneBy('note_favourite', $opts, return_null: true), + NoteFavourite::cacheKeys($note_id, $actor_id)['favourite'], + fn () => DB::findOneBy(NoteFavourite::class, $opts, return_null: true), ); $activity = null; if (\is_null($note_already_favoured)) { - DB::persist(FavouriteEntity::create($opts)); - Cache::delete(FavouriteEntity::cacheKeys($note_id, $actor_id)['favourite']); + DB::persist(NoteFavourite::create($opts)); + Cache::delete(NoteFavourite::cacheKeys($note_id, $actor_id)['favourite']); $activity = Activity::create([ 'actor_id' => $actor_id, 'verb' => 'favourite', @@ -80,6 +79,7 @@ class Favourite extends NoteHandlerPlugin 'source' => $source, ]); DB::persist($activity); + DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => Note::getById($note_id)->getActorId()])); } return $activity; } @@ -87,8 +87,6 @@ class Favourite extends NoteHandlerPlugin /** * Removes the Favourite Entity created beforehand, by the same Actor, and on the same Note * - * Informs all interested Actors of this action, handling out the NewNotification event - * * @param int $note_id Note id being unfavoured * @param int $actor_id Actor undoing favourite Activity * @@ -97,15 +95,15 @@ class Favourite extends NoteHandlerPlugin public static function unfavourNote(int $note_id, int $actor_id, string $source = 'web'): ?Activity { $note_already_favoured = Cache::get( - FavouriteEntity::cacheKeys($note_id, $actor_id)['favourite'], - static fn () => DB::findOneBy('note_favourite', ['note_id' => $note_id, 'actor_id' => $actor_id], return_null: true), + NoteFavourite::cacheKeys($note_id, $actor_id)['favourite'], + static fn () => DB::findOneBy(NoteFavourite::class, ['note_id' => $note_id, 'actor_id' => $actor_id], return_null: true), ); $activity = null; if (!\is_null($note_already_favoured)) { - DB::removeBy(FavouriteEntity::class, ['note_id' => $note_id, 'actor_id' => $actor_id]); + DB::removeBy(NoteFavourite::class, ['note_id' => $note_id, 'actor_id' => $actor_id]); - Cache::delete(FavouriteEntity::cacheKeys($note_id, $actor_id)['favourite']); - $favourite_activity = DB::findBy('activity', ['verb' => 'favourite', 'object_type' => 'note', 'actor_id' => $actor_id, 'object_id' => $note_id], order_by: ['created' => 'DESC'])[0]; + Cache::delete(NoteFavourite::cacheKeys($note_id, $actor_id)['favourite']); + $favourite_activity = DB::findBy(Activity::class, ['verb' => 'favourite', 'object_type' => 'note', 'actor_id' => $actor_id, 'object_id' => $note_id], order_by: ['created' => 'DESC'])[0]; $activity = Activity::create([ 'actor_id' => $actor_id, 'verb' => 'undo', // 'undo_favourite', @@ -114,6 +112,7 @@ class Favourite extends NoteHandlerPlugin 'source' => $source, ]); DB::persist($activity); + DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => Note::getById($note_id)->getActorId()])); } return $activity; } @@ -137,8 +136,8 @@ class Favourite extends NoteHandlerPlugin $opts = ['note_id' => $note->getId(), 'actor_id' => $user->getId()]; $is_favourite = !\is_null( Cache::get( - FavouriteEntity::cacheKeys($note->getId(), $user->getId())['favourite'], - static fn () => DB::findOneBy('note_favourite', $opts, return_null: true), + NoteFavourite::cacheKeys($note->getId(), $user->getId())['favourite'], + static fn () => DB::findOneBy(NoteFavourite::class, $opts, return_null: true), ), ); @@ -184,7 +183,7 @@ class Favourite extends NoteHandlerPlugin // Will have actors array, and action string // Actors are the subjects, action is the verb (in the final phrase) - $favourite_actors = FavouriteEntity::getNoteFavouriteActors($note); + $favourite_actors = NoteFavourite::getNoteFavouriteActors($note); if (\count($favourite_actors) < 1) { return Event::next; @@ -201,7 +200,7 @@ class Favourite extends NoteHandlerPlugin */ public function onNoteDeleteRelated(Note &$note, Actor $actor): bool { - $note_favourites_list = FavouriteEntity::getNoteFavourites($note); + $note_favourites_list = NoteFavourite::getNoteFavourites($note); foreach ($note_favourites_list as $favourite_entity) { DB::remove($favourite_entity); } @@ -331,17 +330,17 @@ class Favourite extends NoteHandlerPlugin return Event::stop; } - public function onActivityPubNewNotification(Actor $sender, Activity $activity, array $ids_already_known = [], ?string $reason = null): bool + public function onActivityPubNewNotification(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool { switch ($activity->getVerb()) { case 'favourite': - Event::handle('NewNotification', [$sender, $activity, [], _m('{nickname} favoured note {note_id}.', ['{nickname}' => $sender->getNickname(), '{note_id}' => $activity->getObjectId()])]); + Event::handle('NewNotification', [$sender, $activity, $targets, _m('{actor_id} favoured note {note_id}.', ['{nickname}' => $sender->getId(), '{note_id}' => $activity->getObjectId()])]); return Event::stop; case 'undo': if ($activity->getObjectType() === 'activity') { $undone_favourite = $activity->getObject(); if ($undone_favourite->getVerb() === 'favourite') { - Event::handle('NewNotification', [$sender, $activity, [], _m('{nickname} unfavoured note {note_id}.', ['{nickname}' => $sender->getNickname(), '{note_id}' => $activity->getObjectId()])]); + Event::handle('NewNotification', [$sender, $activity, $targets, _m('{actor_id} unfavoured note {note_id}.', ['{nickname}' => $sender->getId(), '{note_id}' => $activity->getObjectId()])]); return Event::stop; } } diff --git a/plugins/RepeatNote/Controller/Repeat.php b/plugins/RepeatNote/Controller/Repeat.php index b447fb02aa..9f15b05f79 100644 --- a/plugins/RepeatNote/Controller/Repeat.php +++ b/plugins/RepeatNote/Controller/Repeat.php @@ -75,7 +75,7 @@ class Repeat extends Controller if ($form_add_to_repeat->isSubmitted()) { $repeat_activity = \Plugin\RepeatNote\RepeatNote::repeatNote(note: $note, actor_id: $actor_id); DB::flush(); - Event::handle('NewNotification', [$actor = Actor::getById($actor_id), $repeat_activity, [], _m('{nickname} repeated note {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $repeat_activity->getObjectId()])]); + Event::handle('NewNotification', [$actor = Actor::getById($actor_id), $repeat_activity, [], _m('{actor_id} repeated note {note_id}.', ['{actor_id}' => $actor->getId(), '{note_id}' => $repeat_activity->getObjectId()])]); // Redirect user to where they came from // Prevent open redirect @@ -132,7 +132,7 @@ class Repeat extends Controller if ($form_remove_repeat->isSubmitted()) { if (!\is_null($undo_repeat_activity = \Plugin\RepeatNote\RepeatNote::unrepeatNote(note_id: $note_id, actor_id: $actor_id))) { DB::flush(); - Event::handle('NewNotification', [$actor = Actor::getById($actor_id), $undo_repeat_activity, [], _m('{nickname} unrepeated note {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $note_id])]); + Event::handle('NewNotification', [$actor = Actor::getById($actor_id), $undo_repeat_activity, [], _m('{actor_id} unrepeated note {note_id}.', ['{actor_id}' => $actor->getId(), '{note_id}' => $note_id])]); } else { throw new ClientException(_m('Note wasn\'t repeated!')); } diff --git a/plugins/RepeatNote/Entity/NoteRepeat.php b/plugins/RepeatNote/Entity/NoteRepeat.php index c0f92d2bcf..6505cc4cc3 100644 --- a/plugins/RepeatNote/Entity/NoteRepeat.php +++ b/plugins/RepeatNote/Entity/NoteRepeat.php @@ -132,27 +132,6 @@ class NoteRepeat extends Entity ); } - /** - * @see Entity->getNotificationTargetIds - */ - public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array - { - if (!\array_key_exists('object', $ids_already_known)) { - $target_ids = Note::getById($this->getNoteId())->getNotificationTargetIds(); - } else { - $target_ids = $ids_already_known['object']; - } - - // Additional actors that should know about this - if ($include_additional && \array_key_exists('additional', $ids_already_known)) { - array_push($target_ids, ...$ids_already_known['additional']); - } else { - return $target_ids; - } - - return array_unique($target_ids); - } - public static function schemaDef(): array { return [ diff --git a/plugins/RepeatNote/RepeatNote.php b/plugins/RepeatNote/RepeatNote.php index 5ced1f13f5..c02651e2a6 100644 --- a/plugins/RepeatNote/RepeatNote.php +++ b/plugins/RepeatNote/RepeatNote.php @@ -43,6 +43,7 @@ use App\Util\Exception\ClientException; use App\Util\Exception\DuplicateFoundException; use App\Util\Exception\ServerException; use Component\Language\Entity\Language; +use Component\Notification\Entity\Attention; use Component\Posting\Posting; use DateTime; use Plugin\RepeatNote\Entity\NoteRepeat as RepeatEntity; @@ -81,7 +82,7 @@ class RepeatNote extends NoteHandlerPlugin // Create a new note with the same content as the original [, $repeat, ] = Posting::storeLocalNote( - actor: Actor::getById($actor_id), + actor: $actor = Actor::getById($actor_id), content: $note->getContent(), content_type: $note->getContentType(), locale: \is_null($lang_id = $note->getLanguageId()) ? null : Language::getById($lang_id)->getLocale(), @@ -116,6 +117,13 @@ class RepeatNote extends NoteHandlerPlugin 'source' => $source, ]); DB::persist($repeat_activity); + DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $repeat_activity->getId(), 'target_id' => $note->getActorId()])); + + foreach ($actor->getSubscribers() as $subscriber) { + $target_id = $subscriber->getId(); + DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $repeat_activity->getId(), 'target_id' => $target_id])); + $effective_attentions[$target_id] = $subscriber; + } return $repeat_activity; } @@ -166,17 +174,24 @@ class RepeatNote extends NoteHandlerPlugin 'source' => $source, ]); DB::persist($undo_repeat_activity); + DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $undo_repeat_activity->getId(), 'target_id' => Note::getById($note_id)->getActorId()])); + + foreach (Actor::getById($actor_id)->getSubscribers() as $subscriber) { + $target_id = $subscriber->getId(); + DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $undo_repeat_activity->getId(), 'target_id' => $target_id])); + $effective_attentions[$target_id] = $subscriber; + } return $undo_repeat_activity; } else { // Either was undoed already - if (!\is_null($already_repeated_activity = DB::findOneBy('activity', [ + if (!\is_null($already_repeated_activity = DB::findOneBy(Activity::class, [ 'actor_id' => $actor_id, 'verb' => 'repeat', 'object_type' => 'note', 'object_id' => $note_id, ], return_null: true))) { - return DB::findOneBy('activity', [ + return DB::findOneBy(Activity::class, [ 'actor_id' => $actor_id, 'verb' => 'undo', 'object_type' => 'activity', @@ -376,7 +391,7 @@ class RepeatNote extends NoteHandlerPlugin } else { return Event::next; } - $activity = self::repeatNote($note ?? Note::getById($note_id), $actor->getId(), source: 'ActivityPub'); + $activity = self::repeatNote($note, $actor->getId(), source: 'ActivityPub'); } elseif ($type_activity->get('type') === 'Undo') { // Undo Repeat if ($type_object instanceof \ActivityPhp\Type\AbstractObject) { $ap_prev_repeat_act = \Plugin\ActivityPub\Util\Model\Activity::fromJson($type_object); @@ -413,17 +428,17 @@ class RepeatNote extends NoteHandlerPlugin return Event::stop; } - public function onActivityPubNewNotification(Actor $sender, Activity $activity, array $ids_already_known = [], ?string $reason = null): bool + public function onActivityPubNewNotification(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool { switch ($activity->getVerb()) { case 'repeat': - Event::handle('NewNotification', [$sender, $activity, [], _m('{nickname} repeated note {note_id}.', ['{nickname}' => $sender->getNickname(), '{note_id}' => $activity->getObjectId()])]); + Event::handle('NewNotification', [$sender, $activity, $targets, _m('{actor_id} repeated note {note_id}.', ['{actor_id}' => $sender->getId(), '{note_id}' => $activity->getObjectId()])]); return Event::stop; case 'undo': if ($activity->getObjectType() === 'activity') { $undone_repeat = $activity->getObject(); if ($undone_repeat->getVerb() === 'repeat') { - Event::handle('NewNotification', [$sender, $activity, [], _m('{nickname} unrepeated note {note_id}.', ['{nickname}' => $sender->getNickname(), '{note_id}' => $undone_repeat->getObjectId()])]); + Event::handle('NewNotification', [$sender, $activity, $targets, _m('{actor_id} unrepeated note {note_id}.', ['{actor_id}' => $sender->getId(), '{note_id}' => $undone_repeat->getObjectId()])]); return Event::stop; } } diff --git a/plugins/WebMonetization/Entity/WebMonetization.php b/plugins/WebMonetization/Entity/WebMonetization.php index 7d93152ecf..dcc38e1dfb 100644 --- a/plugins/WebMonetization/Entity/WebMonetization.php +++ b/plugins/WebMonetization/Entity/WebMonetization.php @@ -75,21 +75,6 @@ class WebMonetization extends Entity // @codeCoverageIgnoreEnd // }}} Autocode - public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array - { - if (\array_key_exists('object', $ids_already_known)) { - $target_ids = $ids_already_known['object']; - } else { - $target_ids = [$this->getReceiver()]; - } - // Additional actors that should know about this - if ($include_additional && \array_key_exists('additional', $ids_already_known)) { - array_push($target_ids, ...$ids_already_known['additional']); - return array_unique($target_ids); - } - return $target_ids; - } - public static function schemaDef(): array { return [ diff --git a/src/Controller/Activity.php b/src/Controller/Activity.php index 4d5c8d637b..88b668826f 100644 --- a/src/Controller/Activity.php +++ b/src/Controller/Activity.php @@ -36,9 +36,9 @@ class Activity extends Controller */ private function activity(int $id, callable $handle) { - $activity = DB::findOneBy('activity', ['id' => $id]); - if (empty($activity)) { - throw new ClientException(_m('No such activity.'), 404); + $activity = DB::findOneBy('activity', ['id' => $id], return_null: true); + if (\is_null($activity)) { + throw new ClientException(_m('No such activity.'), code: 404); } else { return $handle($activity); } diff --git a/src/Core/DB/DB.php b/src/Core/DB/DB.php index 680070d630..afc8d9f6e8 100644 --- a/src/Core/DB/DB.php +++ b/src/Core/DB/DB.php @@ -52,23 +52,31 @@ use Functional as F; * @mixin EntityManager * @template T of Entity * - * // Finds an Entity by its identifier. You probably want to use DB::findBy instead + * Finds an Entity by its identifier. You probably want to use DB::findBy instead. * * @method static ?T find(string $class, array $values) - * // Special cases: It's like find but does not load the object if it has not been loaded yet, it only returns a proxy to the object. (https://www.doctrine-project.org/projects/doctrine-orm/en/2.10/reference/unitofwork.html) + * + * It's like find but does not load the object if it has not been loaded yet (can be helpful in special cases), + * it only returns a proxy to the object. (https://www.doctrine-project.org/projects/doctrine-orm/en/2.10/reference/unitofwork.html) * @method static ?T getReference(string $class, array $values) - * // Removes an entity instance. + * + * Removes an entity instance. * @method static void remove(object $entity) - * // Refreshes the persistent state of a managed entity, i.e., overriding any local changes + * + * Refreshes the persistent state of a managed entity, i.e., overriding any local changes * @method static void refresh(object $entity) - * // Tells the EntityManager to make an instance managed and persistent. + * + * Tells the EntityManager to make an instance managed and persistent. * @method static void persist(object $entity) - * // Determines whether an entity instance is managed in this EntityManager. + * + * Determines whether an entity instance is managed in this EntityManager. * @method static bool contains(object $entity) - * // Flushes the in-memory state of persisted objects to the database. + * + * Flushes the in-memory state of persisted objects to the database. * @method static void flush() - * // Executes a function in a transaction. Warning: suppresses exceptions. Returns the result of the callable. - * @method mixed wrapInTransaction(callable $func) + * + * Executes a function in a transaction. Warning: suppresses exceptions. Returns the result of the callable. + * @method mixed wrapInTransaction(callable $func) */ class DB { diff --git a/src/Core/Entity.php b/src/Core/Entity.php index bf49d566a2..b736e62983 100644 --- a/src/Core/Entity.php +++ b/src/Core/Entity.php @@ -24,12 +24,15 @@ declare(strict_types = 1); namespace App\Core; use App\Core\DB\DB; +use App\Entity\Actor; use App\Util\Exception\NotFoundException; use App\Util\Formatting; use BadMethodCallException; +use Component\Notification\Entity\Attention; use DateTime; use DateTimeInterface; use Exception; +use Functional as F; use InvalidArgumentException; /** @@ -37,6 +40,14 @@ use InvalidArgumentException; */ abstract class Entity { + /** + * @return string Returns the name of this entity's DB table + */ + public static function schemaName(): string + { + return static::schemaDef()['name']; + } + public function __call(string $name, array $arguments): mixed { if (Formatting::startsWith($name, 'has')) { @@ -46,7 +57,7 @@ abstract class Entity $private_property_accessor = $private_property_accessor->bindTo($this, static::class); return $private_property_accessor($prop); } - throw new BadMethodCallException('Non existent method ' . static::class . "::{$name} called with arguments: " . print_r($arguments, true)); + throw new BadMethodCallException('Non existent non-static method ' . static::class . "->{$name} called with arguments: " . var_export($arguments, true)); } abstract public static function schemaDef(): array; @@ -55,10 +66,8 @@ abstract class Entity * Create an instance of the called class or fill in the * properties of $obj with the associative array $args. Doesn't * persist the result - * - * @param null|mixed $obj */ - public static function create(array $args, $obj = null) + public static function create(array $args, mixed $obj = null) { $class = static::class; @@ -126,7 +135,7 @@ abstract class Entity * Entity::getByPK([42, 'foo']); * Entity::getByPK(['key1' => 42, 'key2' => 'foo']) * - * @return null|static + * @throws \App\Util\Exception\DuplicateFoundException */ public static function getByPK(mixed $values): ?self { @@ -175,27 +184,28 @@ abstract class Entity } /** - * Who should be notified about this object? + * Ids of the Actors that should be informed about this object. + * BEWARE: If you call this, your object must have a serial integer id! * - * @return array of ids of Actors + * @return array int[] of Actor's id */ - public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array + public function getAttentionTargetIds(): array { - // Additional actors that should know about this - if (\array_key_exists('additional', $ids_already_known)) { - return $ids_already_known['additional']; - } - return []; + $attention = DB::findBy(Attention::class, [ + 'object_type' => static::schemaName(), + 'object_id' => $this->getId(), + ]); + return F\map($attention, fn ($cc) => $cc->getTargetId()); } /** - * Who should be notified about this object? + * To whom should this be brought attention to? * - * @return array of Actors + * @return array Actor[] */ - public function getNotificationTargets(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array + public function getAttentionTargets(): array { - $target_ids = $this->getNotificationTargetIds($ids_already_known, $sender_id, $include_additional); - return $target_ids === [] ? [] : DB::findBy('actor', ['id' => $target_ids]); + $target_ids = $this->getAttentionTargetIds(); + return DB::findBy(Actor::class, ['id' => $target_ids]); } } diff --git a/src/Core/Log.php b/src/Core/Log.php index ea146702ba..2a7f99eb40 100644 --- a/src/Core/Log.php +++ b/src/Core/Log.php @@ -74,10 +74,10 @@ abstract class Log /** * Simple static wrappers around Monolog's functions */ - public static function __callStatic(string $name, array $args) + public static function __callStatic(string $name, array $arguments) { if (isset(self::$logger)) { - return self::$logger->{$name}(...$args); + return self::$logger->{$name}(...$arguments); } else { // @codeCoverageIgnoreStart return; diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index 8582d156ae..654d3bd8ea 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -25,6 +25,7 @@ namespace App\Entity; use App\Core\DB\DB; use App\Core\Entity; +use Component\Notification\Entity\Attention; use Component\Notification\Entity\Notification; use DateTimeInterface; use Functional as F; @@ -143,48 +144,33 @@ class Activity extends Entity } /** - * Who should be notified about this object? + * Actors that should be informed about this object. * - * @return array of ids of Actors + * @return array int[] of Actor's id */ - public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array + public function getAttentionTargetIds(): array { - $target_ids = []; - - // Notifications - if (\array_key_exists('notification_activity', $ids_already_known)) { - array_push($target_ids, ...$ids_already_known['notification_activity']); - } else { - array_push($target_ids, ...Notification::getNotificationTargetIdsByActivity($this->getId())); + $answer = []; + switch($this->getVerb()) { + case 'undo': + $answer = $this->getObject()->getAttentionTargetIds(); + break; + case 'create': + $object_attention = $this->getObject()->getAttentionTargetIds(); + foreach ($object_attention as $att) { + $answer[$att] = $att; + } + // no break + default: + $attention = DB::findBy(Attention::class, [ + 'object_type' => static::schemaName(), + 'object_id' => $this->getId(), + ]); + foreach ($attention as $att) { + $answer[$att->getTargetId()] = $att->getTargetId(); + } } - - // Object's targets - $object_included_already = false; - if (\array_key_exists('object', $ids_already_known)) { - array_push($target_ids, ...$ids_already_known['object']); - } else { - if (!\is_null($author = $this->getObject()?->getActorId()) && $author !== $sender_id) { - $target_ids[] = $this->getObject()->getActorId(); - } - array_push($target_ids, ...$this->getObject()->getNotificationTargetIds($ids_already_known, include_additional: false)); - $object_included_already = true; - } - - // Object's related targets - if (\array_key_exists('object-related', $ids_already_known)) { - array_push($target_ids, ...$ids_already_known['object-related']); - } else { - if (!$object_included_already) { - array_push($target_ids, ...$this->getObject()->getNotificationTargetIds($ids_already_known, include_additional: false)); - } - } - - // Additional actors that should know about this - if (\array_key_exists('additional', $ids_already_known)) { - array_push($target_ids, ...$ids_already_known['additional']); - } - - return array_unique($target_ids); + return $answer; } public static function getAllActivitiesByActor(Actor $actor): array diff --git a/src/Entity/Note.php b/src/Entity/Note.php index 96436fafa5..dc261a96b0 100644 --- a/src/Entity/Note.php +++ b/src/Entity/Note.php @@ -39,7 +39,6 @@ use App\Util\Formatting; use Component\Avatar\Avatar; use Component\Conversation\Entity\Conversation; use Component\Language\Entity\Language; -use Component\Notification\Entity\Attention; use const PREG_SPLIT_NO_EMPTY; use DateTimeInterface; use function App\Core\I18n\_m; @@ -275,7 +274,7 @@ class Note extends Entity public function getConversation(): Conversation { - return Conversation::getByPK(['id' => $this->getConversationId()]); + return DB::findOneBy(Conversation::class, ['id' => $this->getConversationId()]); } public function getConversationUrl(int $type = Router::ABSOLUTE_URL): ?string @@ -310,7 +309,7 @@ class Note extends Entity public static function getById(int $note_id): self { - return Cache::get(self::cacheKeys($note_id)['note'], fn () => DB::findOneBy('note', ['id' => $note_id])); + return Cache::get(self::cacheKeys($note_id)['note'], fn () => DB::findOneBy(self::class, ['id' => $note_id])); } public function getNoteLanguageShortDisplay(): ?string @@ -457,7 +456,7 @@ class Note extends Entity // Is either the author Or $this->getActorId() == $actor->getId() // one of the targets - || \in_array($actor->getId(), $this->getNotificationTargetIds()) + || \in_array($actor->getId(), $this->getAttentionTargetIds()) )); case VisibilityScope::GROUP: @@ -482,32 +481,12 @@ class Note extends Entity default: throw new BugFoundException("Unknown scope found: {$this->getScope()->value}"); } - return false; - } - - // @return array of ids of Actors - public array $_object_mentions_ids = []; - - public function setObjectMentionsIds(array $mentions): self - { - $this->_object_mentions_ids = $mentions; - return $this; - } - - public function getAttentionTargetIds(?int $sender_id = null): array - { - $attentioned = []; - $attention_cc = DB::findBy(Attention::class, ['note_id' => $this->getId()]); - foreach ($attention_cc as $cc) { - $cc_id = $cc->getTargetId(); - if ($cc_id === $sender_id) { - continue; - } - $attentioned[] = $cc_id; - } - return $attentioned; + //return false; } + /** + * @return array int[] of Actor's id + */ public function getMentionTargetIds(): array { $target_ids = []; @@ -524,50 +503,10 @@ class Note extends Entity } /** - * @see Entity->getNotificationTargetIds + * All the actors mentioned in this note. + * + * @return array Actor[] */ - public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array - { - $target_ids = $this->_object_mentions_ids ?? []; - - // Parent - if (!\array_key_exists('object-related', $ids_already_known)) { - if (!\is_null($parent = $this->getReplyToNote())) { - $target_ids[] = $parent->getActorId(); - array_push($target_ids, ...$parent->getNotificationTargetIds()); - } - } else { - array_push($target_ids, ...$ids_already_known['object-related']); - } - - // Mentions - if (!\array_key_exists('object', $ids_already_known)) { - array_push($target_ids, ...$this->getMentionTargetIds()); - } else { - array_push($target_ids, ...$ids_already_known['object']); - } - - // Attentions - if (!\array_key_exists('note-attention', $ids_already_known)) { - array_push($target_ids, ...$this->getAttentionTargetIds($sender_id)); - } else { - array_push($target_ids, ...$ids_already_known['note-attention']); - } - - // Additional actors that should know about this - if ($include_additional && \array_key_exists('additional', $ids_already_known)) { - array_push($target_ids, ...$ids_already_known['additional']); - } - - return array_unique($target_ids); - } - - public function getAttentionTargets(?int $sender_id = null): array - { - $attentioned = $this->getAttentionTargetIds(); - return DB::findBy('actor', ['id' => $attentioned]); - } - public function getMentionTargets(): array { $mentioned = []; @@ -580,49 +519,6 @@ class Note extends Entity return $mentioned; } - /** - * @return array of Actors - */ - public function getNotificationTargets(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array - { - // Additional (if we have additional, we will just return all the actors from ids) - if ($include_additional && \array_key_exists('additional', $ids_already_known)) { - $target_ids = $this->getNotificationTargetIds($ids_already_known, $sender_id); - return $target_ids === [] ? [] : DB::findBy(Actor::class, ['id' => $target_ids]); - } - - $targets = $this->_object_mentions_ids === [] ? [] : DB::findBy(Actor::class, ['id' => $this->_object_mentions_ids]); - - // Parent - if (!\array_key_exists('object-related', $ids_already_known)) { - if (!\is_null($parent = $this->getReplyToNote())) { - $targets[] = $parent->getActor(); - array_push($targets, ...$parent->getNotificationTargets()); - } - } else { - array_push($targets, ...$ids_already_known['object-related']); - } - - // Mentions - if (!\array_key_exists('object', $ids_already_known)) { - array_push($targets, ...$this->getMentionTargets()); - } elseif ($ids_already_known['object'] !== []) { - array_push($targets, ...DB::findBy('actor', ['id' => $ids_already_known['object']])); - } - - // Attentions - if (!\array_key_exists('note-attention', $ids_already_known)) { - array_push($targets, ...$this->getAttentionTargets($sender_id)); - } else { - $attentioned = $ids_already_known['note-attention'] ?? []; - if ($attentioned !== []) { - array_push($targets, ...DB::findBy('actor', ['id' => $attentioned])); - } - } - - return $targets; - } - public function delete(?Actor $actor = null, string $source = 'web'): Activity { Event::handle('NoteDeleteRelated', [&$this, $actor]); diff --git a/src/Util/HTML.php b/src/Util/HTML.php index 86c5441bec..2c5f81fc70 100644 --- a/src/Util/HTML.php +++ b/src/Util/HTML.php @@ -153,6 +153,6 @@ abstract class HTML return self::$sanitizer->{$name}(...$args); } - throw new BadMethodCallException("Method Security::{$name} doesn't exist"); + throw new BadMethodCallException("Method HTML::{$name} doesn't exist."); } } diff --git a/templates/cards/blocks/note.html.twig b/templates/cards/blocks/note.html.twig index 07edc625cc..42b5e605c4 100644 --- a/templates/cards/blocks/note.html.twig +++ b/templates/cards/blocks/note.html.twig @@ -94,9 +94,9 @@ {% endblock note_info %} {% block note_context %} - {% set notification_targets = note.getNotificationTargets() %} - {% if notification_targets is not empty %} - {% for target in notification_targets %} + {% set attentions = note.getAttentionTargets() %} + {% if attentions is not empty %} + {% for target in attentions %} {% if target.isGroup() %} - {% set replyAnchor %}{% endset %} + title="{% trans %}The recipient to whom this note was addressed to{% endtrans %}"> {% set mentionAnchor %}{{ mention(target) | raw }}{% endset %} - {% trans %}%replyAnchor% in reply to %mentionAnchor%{% endtrans %} + {% trans %}to %mentionAnchor%{% endtrans %} {% endif %} {% endfor %} {% endif %} + {% set reply_to = note.getReplyTo() %} + {% if reply_to is not empty %} + + {% set replyAnchor %}{% endset %} + {% set target = note.getActor() %} + {% set mentionAnchor %}{{ mention(target) | raw }}{% endset %} + {% trans %}%replyAnchor% in reply to %mentionAnchor%{% endtrans %} + + {% endif %} {% endblock note_context %} {% block note_complementary %}