[COMPONENT][Notification] Consider attention properly in notes

This commit is contained in:
Diogo Peralta Cordeiro 2022-02-24 20:20:05 +00:00
parent f5e92de62d
commit d5731e6351
No known key found for this signature in database
GPG Key ID: 18D2D35001FBFAB0
4 changed files with 207 additions and 128 deletions

View File

@ -357,26 +357,29 @@ class Posting extends Component
]); ]);
DB::persist($activity); DB::persist($activity);
$attention_ids = [];
foreach ($targets as $target) { foreach ($targets as $target) {
$target = \is_int($target) ? Actor::getById($target) : $target; $target_id = \is_int($target) ? $target : $target->getId();
DB::persist(Attention::create(['note_id' => $note->getId(), 'target_id' => $target->getId()])); DB::persist(Attention::create(['note_id' => $note->getId(), 'target_id' => $target_id]));
$mentions[] = [ $attention_ids[$target_id] = true;
'mentioned' => [$target],
'type' => match ($target->getType()) {
Actor::PERSON => 'mention',
Actor::GROUP => 'group',
default => throw new ClientException(_m('Unknown target type give in \'In\' field: {target}', ['{target}' => $target?->getNickname() ?? '<null>'])),
},
'text' => $target->getNickname(),
];
} }
$attention_ids = array_keys($attention_ids);
$attention_ids = F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId())));
if ($flush_and_notify) { if ($flush_and_notify) {
// Flush before notification // Flush before notification
DB::flush(); DB::flush();
Event::handle('NewNotification', [$actor, $activity, ['object' => $attention_ids], _m('{nickname} created a note {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $activity->getObjectId()])]); 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(),
'{note_id}' => $activity->getObjectId(),
]),
]);
} }
return [$activity, $note, $attention_ids]; return [$activity, $note, $attention_ids];

View File

@ -180,8 +180,8 @@ class Inbox extends Controller
} }
DB::flush(); DB::flush();
if (Event::handle('ActivityPubNewNotification', [$actor, $ap_act->getActivity(), $already_known_ids, _m('{nickname} mentioned you.', ['{nickname}' => $actor->getNickname()])]) === Event::next) { 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} mentioned you.', ['{nickname}' => $actor->getNickname()])]); Event::handle('NewNotification', [$actor, $ap_act->getActivity(), $already_known_ids, _m('{nickname} attentioned you.', ['{nickname}' => $actor->getNickname()])]);
} }
dd($ap_act, $act = $ap_act->getActivity(), $act->getActor(), $act->getObject()); dd($ap_act, $act = $ap_act->getActivity(), $act->getActor(), $act->getObject());

View File

@ -155,7 +155,8 @@ class Note extends Model
'reply_to' => $reply_to = $handleInReplyTo($type_note), 'reply_to' => $reply_to = $handleInReplyTo($type_note),
'modified' => new DateTime(), 'modified' => new DateTime(),
'type' => match ($type_note->get('type')) { 'type' => match ($type_note->get('type')) {
'Page' => NoteType::PAGE, default => NoteType::NOTE 'Page' => NoteType::PAGE,
default => NoteType::NOTE
}, },
'source' => $source, 'source' => $source,
]; ];
@ -184,7 +185,7 @@ class Note extends Model
} }
} }
$object_mentions_ids = []; $attention_ids = [];
foreach ($to as $target) { foreach ($to as $target) {
if ($target === 'https://www.w3.org/ns/activitystreams#Public') { if ($target === 'https://www.w3.org/ns/activitystreams#Public') {
continue; continue;
@ -219,6 +220,21 @@ class Note extends Model
} }
$obj = GSNote::create($map); $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]));
}
} catch (Exception $e) {
Log::debug('ActivityPub->Model->Note->fromJson->Attention->Explorer', [$e]);
}
}
$attention_ids = array_keys($attention_ids);
// Attachments // Attachments
$processed_attachments = []; $processed_attachments = [];
@ -246,11 +262,10 @@ class Note extends Model
} }
} }
DB::persist($obj);
// Assign conversation to this note // Assign conversation to this note
Conversation::assignLocalConversation($obj, $reply_to); Conversation::assignLocalConversation($obj, $reply_to);
$object_mention_ids = [];
foreach ($type_note->get('tag') ?? [] as $ap_tag) { foreach ($type_note->get('tag') ?? [] as $ap_tag) {
switch ($ap_tag->get('type')) { switch ($ap_tag->get('type')) {
case 'Mention': case 'Mention':
@ -258,7 +273,7 @@ class Note extends Model
try { try {
$actors = $explorer->lookup($ap_tag->get('href')); $actors = $explorer->lookup($ap_tag->get('href'));
foreach ($actors as $actor) { foreach ($actors as $actor) {
$object_mentions_ids[$actor->getId()] = $ap_tag->get('href'); $object_mention_ids[$actor->getId()] = $ap_tag->get('href');
} }
} catch (Exception $e) { } catch (Exception $e) {
Log::debug('ActivityPub->Model->Note->fromJson->Mention->Explorer', [$e]); Log::debug('ActivityPub->Model->Note->fromJson->Mention->Explorer', [$e]);
@ -284,10 +299,10 @@ class Note extends Model
} }
// The content would be non-sanitized text/html // 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_mentions_ids]]); Event::handle('ProcessNoteContent', [$obj, $obj->getRendered(), $obj->getContentType(), $process_note_content_extra_args = ['TagProcessed' => true, 'ignoreLinks' => $object_mention_ids]]);
$object_mentions_ids = array_keys($object_mentions_ids); $object_mention_ids = array_keys($object_mention_ids);
$obj->setObjectMentionsIds($object_mentions_ids); $obj->setObjectMentionsIds($object_mention_ids);
if ($processed_attachments !== []) { if ($processed_attachments !== []) {
foreach ($processed_attachments as [$a, $fname]) { foreach ($processed_attachments as [$a, $fname]) {
@ -318,7 +333,9 @@ class Note extends Model
/** /**
* Get a JSON * Get a JSON
* *
* @throws Exception * @throws ClientException
* @throws InvalidArgumentException
* @throws ServerException
*/ */
public static function toJson(mixed $object, ?int $options = null): string public static function toJson(mixed $object, ?int $options = null): string
{ {
@ -329,7 +346,8 @@ class Note extends Model
$attr = [ $attr = [
'@context' => ActivityPub::$activity_streams_two_context, '@context' => ActivityPub::$activity_streams_two_context,
'type' => $object->getScope() === VisibilityScope::MESSAGE ? 'ChatMessage' : (match ($object->getType()) { 'type' => $object->getScope() === VisibilityScope::MESSAGE ? 'ChatMessage' : (match ($object->getType()) {
NoteType::NOTE => 'Note', NoteType::PAGE => 'Page' NoteType::NOTE => 'Note',
NoteType::PAGE => 'Page'
}), }),
'id' => $object->getUrl(), 'id' => $object->getUrl(),
'published' => $object->getCreated()->format(DateTimeInterface::RFC3339), 'published' => $object->getCreated()->format(DateTimeInterface::RFC3339),
@ -371,9 +389,9 @@ class Note extends Model
throw new ServerException('Found an unknown visibility scope which cannot federate.'); throw new ServerException('Found an unknown visibility scope which cannot federate.');
} }
$attention_cc = DB::findBy(Attention::class, ['note_id' => $object->getId()]); // Notification Targets without Mentions
foreach ($attention_cc as $cc_id) { $attentions = $object->getNotificationTargets(ids_already_known: ['object' => []]);
$target = \App\Entity\Actor::getById($cc_id->getTargetId()); foreach ($attentions as $target) {
if ($object->getScope() === VisibilityScope::GROUP && $target->isGroup()) { if ($object->getScope() === VisibilityScope::GROUP && $target->isGroup()) {
$attr['to'][] = $target->getUri(Router::ABSOLUTE_URL); $attr['to'][] = $target->getUri(Router::ABSOLUTE_URL);
} else { } else {
@ -382,7 +400,7 @@ class Note extends Model
} }
// Mentions // Mentions
foreach ($object->getNotificationTargets() as $mention) { foreach ($object->getMentionTargets() as $mention) {
$attr['tag'][] = [ $attr['tag'][] = [
'type' => 'Mention', 'type' => 'Mention',
'href' => ($href = $mention->getUri()), 'href' => ($href = $mention->getUri()),

View File

@ -28,6 +28,7 @@ use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Router\Router; use App\Core\Router\Router;
use App\Core\VisibilityScope; use App\Core\VisibilityScope;
@ -37,15 +38,17 @@ use App\Util\Formatting;
use Component\Avatar\Avatar; use Component\Avatar\Avatar;
use Component\Conversation\Entity\Conversation; use Component\Conversation\Entity\Conversation;
use Component\Language\Entity\Language; use Component\Language\Entity\Language;
use function App\Core\I18n\_m; use Component\Notification\Entity\Attention;
use DateTimeInterface;
use function mb_substr;
use const PREG_SPLIT_NO_EMPTY;
// The domain of this enum are Notes // The domain of this enum are Notes
enum NoteType: int // having an int is just convenient enum NoteType : int // having an int is just convenient
{ {
case NOTE = 1; // Is an element of microblogging, a direct message, or a reply to another note or page case NOTE = 1; // Is an element of microblogging, a direct message, or a reply to another note or page
case PAGE = 2; // Larger content note, beginning of a thread, or an email message case PAGE = 2; // Larger content note, beginning of a thread, or an email message
}; }
/** /**
* Entity for notices * Entity for notices
@ -75,8 +78,8 @@ class Note extends Entity
private ?int $language_id = null; private ?int $language_id = null;
private int $type = 1; //NoteType::NOTE->value; private int $type = 1; //NoteType::NOTE->value;
private ?string $title = null; private ?string $title = null;
private \DateTimeInterface $created; private DateTimeInterface $created;
private \DateTimeInterface $modified; private DateTimeInterface $modified;
public function setId(int $id): self public function setId(int $id): self
{ {
@ -113,7 +116,7 @@ class Note extends Entity
public function setContentType(string $content_type): self public function setContentType(string $content_type): self
{ {
$this->content_type = \mb_substr($content_type, 0, 129); $this->content_type = mb_substr($content_type, 0, 129);
return $this; return $this;
} }
@ -168,7 +171,7 @@ class Note extends Entity
public function setSource(?string $source): self public function setSource(?string $source): self
{ {
$this->source = \is_null($source) ? null : \mb_substr($source, 0, 32); $this->source = \is_null($source) ? null : mb_substr($source, 0, 32);
return $this; return $this;
} }
@ -179,7 +182,7 @@ class Note extends Entity
public function setScope(VisibilityScope|int $scope): self public function setScope(VisibilityScope|int $scope): self
{ {
$this->scope = is_int($scope) ? $scope : $scope->value; $this->scope = \is_int($scope) ? $scope : $scope->value;
return $this; return $this;
} }
@ -212,7 +215,7 @@ class Note extends Entity
public function setType(NoteType|int $type): self public function setType(NoteType|int $type): self
{ {
$this->type = is_int($type) ? $type : $type->value; $this->type = \is_int($type) ? $type : $type->value;
return $this; return $this;
} }
@ -223,7 +226,7 @@ class Note extends Entity
public function setTitle(?string $title): self public function setTitle(?string $title): self
{ {
$this->title = \is_null($title) ? null : \mb_substr($title, 0, 129); $this->title = \is_null($title) ? null : mb_substr($title, 0, 129);
return $this; return $this;
} }
@ -232,24 +235,24 @@ class Note extends Entity
return $this->title; return $this->title;
} }
public function setCreated(\DateTimeInterface $created): self public function setCreated(DateTimeInterface $created): self
{ {
$this->created = $created; $this->created = $created;
return $this; return $this;
} }
public function getCreated(): \DateTimeInterface public function getCreated(): DateTimeInterface
{ {
return $this->created; return $this->created;
} }
public function setModified(\DateTimeInterface $modified): self public function setModified(DateTimeInterface $modified): self
{ {
$this->modified = $modified; $this->modified = $modified;
return $this; return $this;
} }
public function getModified(): \DateTimeInterface public function getModified(): DateTimeInterface
{ {
return $this->modified; return $this->modified;
} }
@ -396,7 +399,7 @@ class Note extends Entity
*/ */
public function getReplyToNote(): ?self public function getReplyToNote(): ?self
{ {
return is_null($this->getReplyTo()) ? null : self::getById($this->getReplyTo()); return \is_null($this->getReplyTo()) ? null : self::getById($this->getReplyTo());
} }
/** /**
@ -419,18 +422,15 @@ class Note extends Entity
return true; return true;
case VisibilityScope::ADDRESSEE: case VisibilityScope::ADDRESSEE:
// If the actor is logged in and // If the actor is logged in and
if (!\is_null($actor) return (bool) (!\is_null($actor)
&& ( && (
// Is either the author Or // Is either the author Or
$this->getActorId() == $actor->getId() $this->getActorId() == $actor->getId()
// one of the targets // one of the targets
|| \in_array($actor->getId(), $this->getNotificationTargetIds()) || \in_array($actor->getId(), $this->getNotificationTargetIds())
)) { ));
return true;
}
return false;
case VisibilityScope::GROUP: case VisibilityScope::GROUP:
if (is_null($in)) { if (\is_null($in)) {
return false; // If we don't have a context, don't risk leaking this note. return false; // If we don't have a context, don't risk leaking this note.
} }
// Only for the group to see // Only for the group to see
@ -444,11 +444,12 @@ class Note extends Entity
WHERE a.object_id = :note_id AND m.actor_id = :actor_id WHERE a.object_id = :note_id AND m.actor_id = :actor_id
EOF, EOF,
['note_id' => $this->id, 'actor_id' => $in->getId()], ['note_id' => $this->id, 'actor_id' => $in->getId()],
) !== []); ) !== []
);
case VisibilityScope::COLLECTION: case VisibilityScope::COLLECTION:
case VisibilityScope::MESSAGE: case VisibilityScope::MESSAGE:
// Only for the collection to see // Only for the collection to see
return !\is_null($actor) && in_array($actor->getId(), $this->getNotificationTargetIds()); return !\is_null($actor) && \in_array($actor->getId(), $this->getNotificationTargetIds());
default: default:
Log::error("Unknown scope found: {$this->getScope()->value}."); Log::error("Unknown scope found: {$this->getScope()->value}.");
} }
@ -457,21 +458,31 @@ class Note extends Entity
// @return array of ids of Actors // @return array of ids of Actors
public array $_object_mentions_ids = []; public array $_object_mentions_ids = [];
public function setObjectMentionsIds(array $mentions): self public function setObjectMentionsIds(array $mentions): self
{ {
$this->_object_mentions_ids = $mentions; $this->_object_mentions_ids = $mentions;
return $this; return $this;
} }
/** public function getAttentionTargetIds(?int $sender_id = null): array
* @see Entity->getNotificationTargetIds
*/
public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array
{ {
$target_ids = $this->_object_mentions_ids ?? []; $attentioned = [];
if ($target_ids === []) { $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;
}
public function getMentionTargetIds(): array
{
$target_ids = [];
$content = $this->getContent(); $content = $this->getContent();
if (!\array_key_exists('object', $ids_already_known)) {
if (!\is_null($content)) { if (!\is_null($content)) {
$mentions = Formatting::findMentions($content, $this->getActor()); $mentions = Formatting::findMentions($content, $this->getActor());
foreach ($mentions as $mention) { foreach ($mentions as $mention) {
@ -480,13 +491,19 @@ class Note extends Entity
} }
} }
} }
} else { return $target_ids;
$target_ids = $ids_already_known['object'];
}
} }
/**
* @see Entity->getNotificationTargetIds
*/
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 (!\array_key_exists('object-related', $ids_already_known)) {
if (!is_null($parent = $this->getReplyToNote())) { if (!\is_null($parent = $this->getReplyToNote())) {
$target_ids[] = $parent->getActorId(); $target_ids[] = $parent->getActorId();
array_push($target_ids, ...$parent->getNotificationTargetIds()); array_push($target_ids, ...$parent->getNotificationTargetIds());
} }
@ -494,6 +511,20 @@ class Note extends Entity
array_push($target_ids, ...$ids_already_known['object-related']); 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 // Additional actors that should know about this
if ($include_additional && \array_key_exists('additional', $ids_already_known)) { if ($include_additional && \array_key_exists('additional', $ids_already_known)) {
array_push($target_ids, ...$ids_already_known['additional']); array_push($target_ids, ...$ids_already_known['additional']);
@ -502,40 +533,67 @@ class Note extends Entity
return array_unique($target_ids); return array_unique($target_ids);
} }
/** public function getAttentionTargets(?int $sender_id = null): array
* @return array of Actors
*/
public function getNotificationTargets(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array
{ {
if ($include_additional && \array_key_exists('additional', $ids_already_known)) { $attentioned = $this->getAttentionTargetIds();
$target_ids = $this->getNotificationTargetIds($ids_already_known, $sender_id); return DB::findBy('actor', ['id' => $attentioned]);
return $target_ids === [] ? [] : DB::findBy('actor', ['id' => $target_ids]);
} }
public function getMentionTargets(): array
{
$mentioned = []; $mentioned = [];
if (!\array_key_exists('object', $ids_already_known)) {
$mentions = Formatting::findMentions($this->getContent(), $this->getActor()); $mentions = Formatting::findMentions($this->getContent(), $this->getActor());
foreach ($mentions as $mention) { foreach ($mentions as $mention) {
foreach ($mention['mentioned'] as $m) { foreach ($mention['mentioned'] as $m) {
$mentioned[] = $m; $mentioned[] = $m;
} }
} }
} else {
$mentioned = $ids_already_known['object'] === [] ? [] : DB::findBy('actor', ['id' => $ids_already_known['object']]);
}
if (!\array_key_exists('object-related', $ids_already_known)) {
if (!is_null($parent = $this->getReplyToNote())) {
$mentioned[] = $parent->getActor();
array_push($mentioned, ...$parent->getNotificationTargets());
}
} else {
array_push($mentioned, ...$ids_already_known['object-related']);
}
return $mentioned; 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 public function delete(?Actor $actor = null, string $source = 'web'): Activity
{ {
Event::handle('NoteDeleteRelated', [&$this, $actor]); Event::handle('NoteDeleteRelated', [&$this, $actor]);
@ -550,7 +608,7 @@ class Note extends Entity
return $activity; return $activity;
} }
public static function ensureCanInteract(?Note $note, LocalUser|Actor $actor): Note public static function ensureCanInteract(?self $note, LocalUser|Actor $actor): self
{ {
if (\is_null($note)) { if (\is_null($note)) {
throw new NoSuchNoteException(); throw new NoSuchNoteException();