diff --git a/components/Posting/Posting.php b/components/Posting/Posting.php index e88decda31..ebf17030ba 100644 --- a/components/Posting/Posting.php +++ b/components/Posting/Posting.php @@ -357,26 +357,29 @@ class Posting extends Component ]); DB::persist($activity); + $attention_ids = []; foreach ($targets as $target) { - $target = \is_int($target) ? Actor::getById($target) : $target; - DB::persist(Attention::create(['note_id' => $note->getId(), 'target_id' => $target->getId()])); - $mentions[] = [ - '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() ?? ''])), - }, - 'text' => $target->getNickname(), - ]; + $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; } - - $attention_ids = F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId()))); + $attention_ids = array_keys($attention_ids); if ($flush_and_notify) { // Flush before notification 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]; diff --git a/plugins/ActivityPub/Controller/Inbox.php b/plugins/ActivityPub/Controller/Inbox.php index bbd8c0b05c..21f977d4b0 100644 --- a/plugins/ActivityPub/Controller/Inbox.php +++ b/plugins/ActivityPub/Controller/Inbox.php @@ -180,8 +180,8 @@ class Inbox extends Controller } DB::flush(); - if (Event::handle('ActivityPubNewNotification', [$actor, $ap_act->getActivity(), $already_known_ids, _m('{nickname} mentioned you.', ['{nickname}' => $actor->getNickname()])]) === Event::next) { - Event::handle('NewNotification', [$actor, $ap_act->getActivity(), $already_known_ids, _m('{nickname} mentioned you.', ['{nickname}' => $actor->getNickname()])]); + 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()])]); } dd($ap_act, $act = $ap_act->getActivity(), $act->getActor(), $act->getObject()); diff --git a/plugins/ActivityPub/Util/Model/Note.php b/plugins/ActivityPub/Util/Model/Note.php index 08482122b7..ebe4844475 100644 --- a/plugins/ActivityPub/Util/Model/Note.php +++ b/plugins/ActivityPub/Util/Model/Note.php @@ -155,7 +155,8 @@ class Note extends Model 'reply_to' => $reply_to = $handleInReplyTo($type_note), 'modified' => new DateTime(), 'type' => match ($type_note->get('type')) { - 'Page' => NoteType::PAGE, default => NoteType::NOTE + 'Page' => NoteType::PAGE, + default => NoteType::NOTE }, 'source' => $source, ]; @@ -177,14 +178,14 @@ class Note extends Model } else { // Either Followers-only or Direct if ($type_note->get('type') === 'ChatMessage' // Is DM explicitly? - || (empty($type_note->get('cc')))) { // Only has TO targets + || (empty($type_note->get('cc')))) { // Only has TO targets $map['scope'] = VisibilityScope::MESSAGE; } else { // Then is collection $map['scope'] = VisibilityScope::COLLECTION; } } - $object_mentions_ids = []; + $attention_ids = []; foreach ($to as $target) { if ($target === 'https://www.w3.org/ns/activitystreams#Public') { continue; @@ -219,6 +220,21 @@ class Note extends Model } $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 $processed_attachments = []; @@ -246,11 +262,10 @@ class Note extends Model } } - DB::persist($obj); - // Assign conversation to this note Conversation::assignLocalConversation($obj, $reply_to); + $object_mention_ids = []; foreach ($type_note->get('tag') ?? [] as $ap_tag) { switch ($ap_tag->get('type')) { case 'Mention': @@ -258,7 +273,7 @@ class Note extends Model try { $actors = $explorer->lookup($ap_tag->get('href')); 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) { 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 - 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); - $obj->setObjectMentionsIds($object_mentions_ids); + $object_mention_ids = array_keys($object_mention_ids); + $obj->setObjectMentionsIds($object_mention_ids); if ($processed_attachments !== []) { foreach ($processed_attachments as [$a, $fname]) { @@ -318,7 +333,9 @@ class Note extends Model /** * Get a JSON * - * @throws Exception + * @throws ClientException + * @throws InvalidArgumentException + * @throws ServerException */ public static function toJson(mixed $object, ?int $options = null): string { @@ -329,7 +346,8 @@ class Note extends Model $attr = [ '@context' => ActivityPub::$activity_streams_two_context, 'type' => $object->getScope() === VisibilityScope::MESSAGE ? 'ChatMessage' : (match ($object->getType()) { - NoteType::NOTE => 'Note', NoteType::PAGE => 'Page' + NoteType::NOTE => 'Note', + NoteType::PAGE => 'Page' }), 'id' => $object->getUrl(), 'published' => $object->getCreated()->format(DateTimeInterface::RFC3339), @@ -349,7 +367,7 @@ class Note extends Model 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)]; - break; + break; case VisibilityScope::LOCAL: throw new ClientException('This note was not federated.', 403); case VisibilityScope::ADDRESSEE: @@ -371,9 +389,9 @@ class Note extends Model throw new ServerException('Found an unknown visibility scope which cannot federate.'); } - $attention_cc = DB::findBy(Attention::class, ['note_id' => $object->getId()]); - foreach ($attention_cc as $cc_id) { - $target = \App\Entity\Actor::getById($cc_id->getTargetId()); + // 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 { @@ -382,7 +400,7 @@ class Note extends Model } // Mentions - foreach ($object->getNotificationTargets() as $mention) { + foreach ($object->getMentionTargets() as $mention) { $attr['tag'][] = [ 'type' => 'Mention', 'href' => ($href = $mention->getUri()), diff --git a/src/Entity/Note.php b/src/Entity/Note.php index e1881fb764..4e423935b6 100644 --- a/src/Entity/Note.php +++ b/src/Entity/Note.php @@ -28,6 +28,7 @@ use App\Core\Cache; use App\Core\DB\DB; use App\Core\Entity; use App\Core\Event; +use function App\Core\I18n\_m; use App\Core\Log; use App\Core\Router\Router; use App\Core\VisibilityScope; @@ -37,15 +38,17 @@ use App\Util\Formatting; use Component\Avatar\Avatar; use Component\Conversation\Entity\Conversation; 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 -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 PAGE = 2; // Larger content note, beginning of a thread, or an email message -}; - +} /** * Entity for notices @@ -63,20 +66,20 @@ class Note extends Entity // @codeCoverageIgnoreStart private int $id; private int $actor_id; - private ?string $content = null; + private ?string $content = null; private string $content_type = 'text/plain'; - private ?string $rendered = null; + private ?string $rendered = null; private int $conversation_id; private ?int $reply_to = null; private bool $is_local; - private ?string $source = null; - private int $scope = 1; //VisibilityScope::EVERYWHERE->value; - private ?string $url = null; + private ?string $source = null; + private int $scope = 1; //VisibilityScope::EVERYWHERE->value; + private ?string $url = null; private ?int $language_id = null; - private int $type = 1; //NoteType::NOTE->value; - private ?string $title = null; - private \DateTimeInterface $created; - private \DateTimeInterface $modified; + private int $type = 1; //NoteType::NOTE->value; + private ?string $title = null; + private DateTimeInterface $created; + private DateTimeInterface $modified; public function setId(int $id): self { @@ -113,7 +116,7 @@ class Note extends Entity 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; } @@ -168,7 +171,7 @@ class Note extends Entity 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; } @@ -179,7 +182,7 @@ class Note extends Entity 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; } @@ -212,7 +215,7 @@ class Note extends Entity 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; } @@ -223,7 +226,7 @@ class Note extends Entity 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; } @@ -232,24 +235,24 @@ class Note extends Entity return $this->title; } - public function setCreated(\DateTimeInterface $created): self + public function setCreated(DateTimeInterface $created): self { $this->created = $created; return $this; } - public function getCreated(): \DateTimeInterface + public function getCreated(): DateTimeInterface { return $this->created; } - public function setModified(\DateTimeInterface $modified): self + public function setModified(DateTimeInterface $modified): self { $this->modified = $modified; return $this; } - public function getModified(): \DateTimeInterface + public function getModified(): DateTimeInterface { return $this->modified; } @@ -260,12 +263,12 @@ class Note extends Entity public static function cacheKeys(int $note_id) { return [ - 'note' => "note-{$note_id}", - 'attachments' => "note-attachments-{$note_id}", + 'note' => "note-{$note_id}", + 'attachments' => "note-attachments-{$note_id}", 'attachments-title' => "note-attachments-with-title-{$note_id}", - 'links' => "note-links-{$note_id}", - 'tags' => "note-tags-{$note_id}", - 'replies' => "note-replies-{$note_id}", + 'links' => "note-links-{$note_id}", + 'tags' => "note-tags-{$note_id}", + 'replies' => "note-replies-{$note_id}", ]; } @@ -335,7 +338,7 @@ class Note extends Entity return DB::dql( <<<'EOF' select att from attachment att - join attachment_to_note atn with atn.attachment_id = att.id + join attachment_to_note atn with atn.attachment_id = att.id where atn.note_id = :note_id EOF, ['note_id' => $this->id], @@ -350,7 +353,7 @@ class Note extends Entity <<<'EOF' select att, atn.title from attachment att - join attachment_to_note atn with atn.attachment_id = att.id + join attachment_to_note atn with atn.attachment_id = att.id where atn.note_id = :note_id EOF, ['note_id' => $this->id], @@ -369,7 +372,7 @@ class Note extends Entity return DB::dql( <<<'EOF' select l from link l - join note_to_link ntl with ntl.link_id = l.id + join note_to_link ntl with ntl.link_id = l.id where ntl.note_id = :note_id EOF, ['note_id' => $this->id], @@ -396,7 +399,7 @@ class Note extends Entity */ 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,36 +422,34 @@ class Note extends Entity return true; case VisibilityScope::ADDRESSEE: // If the actor is logged in and - if (!\is_null($actor) + return (bool) (!\is_null($actor) && ( // Is either the author Or $this->getActorId() == $actor->getId() // one of the targets || \in_array($actor->getId(), $this->getNotificationTargetIds()) - )) { - return true; - } - return false; + )); 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. } // Only for the group to see return !\is_null($actor) && ( !($in->getRoles() & ActorLocalRoles::PRIVATE_GROUP) // Public Group - || DB::dql( // It's a member of the private group - <<<'EOF' - SELECT m FROM \Component\Group\Entity\GroupMember m - JOIN \Component\Notification\Entity\Notification att WITH m.group_id = att.target_id - JOIN \App\Entity\Activity a WITH att.activity_id = a.id - WHERE a.object_id = :note_id AND m.actor_id = :actor_id - EOF, - ['note_id' => $this->id, 'actor_id' => $in->getId()], - ) !== []); + || DB::dql( // It's a member of the private group + <<<'EOF' + SELECT m FROM \Component\Group\Entity\GroupMember m + JOIN \Component\Notification\Entity\Notification att WITH m.group_id = att.target_id + JOIN \App\Entity\Activity a WITH att.activity_id = a.id + WHERE a.object_id = :note_id AND m.actor_id = :actor_id + EOF, + ['note_id' => $this->id, 'actor_id' => $in->getId()], + ) !== [] + ); case VisibilityScope::COLLECTION: case VisibilityScope::MESSAGE: // 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: Log::error("Unknown scope found: {$this->getScope()->value}."); } @@ -457,36 +458,52 @@ class Note extends Entity // @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; + } + + public function getMentionTargetIds(): array + { + $target_ids = []; + $content = $this->getContent(); + if (!\is_null($content)) { + $mentions = Formatting::findMentions($content, $this->getActor()); + foreach ($mentions as $mention) { + foreach ($mention['mentioned'] as $m) { + $target_ids[] = $m->getId(); + } + } + } + return $target_ids; + } + /** * @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 ?? []; - if ($target_ids === []) { - $content = $this->getContent(); - if (!\array_key_exists('object', $ids_already_known)) { - if (!\is_null($content)) { - $mentions = Formatting::findMentions($content, $this->getActor()); - foreach ($mentions as $mention) { - foreach ($mention['mentioned'] as $m) { - $target_ids[] = $m->getId(); - } - } - } - } else { - $target_ids = $ids_already_known['object']; - } - } + // Parent 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(); array_push($target_ids, ...$parent->getNotificationTargetIds()); } @@ -494,6 +511,20 @@ class Note extends Entity 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']); @@ -502,38 +533,65 @@ class Note extends Entity 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 = []; + $mentions = Formatting::findMentions($this->getContent(), $this->getActor()); + foreach ($mentions as $mention) { + foreach ($mention['mentioned'] as $m) { + $mentioned[] = $m; + } + } + 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', ['id' => $target_ids]); + return $target_ids === [] ? [] : DB::findBy(Actor::class, ['id' => $target_ids]); } - $mentioned = []; - if (!\array_key_exists('object', $ids_already_known)) { - $mentions = Formatting::findMentions($this->getContent(), $this->getActor()); - foreach ($mentions as $mention) { - foreach ($mention['mentioned'] as $m) { - $mentioned[] = $m; - } - } - } else { - $mentioned = $ids_already_known['object'] === [] ? [] : DB::findBy('actor', ['id' => $ids_already_known['object']]); - } + $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())) { - $mentioned[] = $parent->getActor(); - array_push($mentioned, ...$parent->getNotificationTargets()); + if (!\is_null($parent = $this->getReplyToNote())) { + $targets[] = $parent->getActor(); + array_push($targets, ...$parent->getNotificationTargets()); } } else { - array_push($mentioned, ...$ids_already_known['object-related']); + array_push($targets, ...$ids_already_known['object-related']); } - return $mentioned; + // 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 @@ -550,7 +608,7 @@ class Note extends Entity 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)) { throw new NoSuchNoteException(); @@ -566,21 +624,21 @@ class Note extends Entity return [ 'name' => 'note', 'fields' => [ - 'id' => ['type' => 'serial', 'not null' => true], - 'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'who made the note'], - 'content' => ['type' => 'text', 'description' => 'note content'], - 'content_type' => ['type' => 'varchar', 'not null' => true, 'default' => 'text/plain', 'length' => 129, 'description' => 'A note can be written in a multitude of formats such as text/plain, text/markdown, application/x-latex, and text/html'], - 'rendered' => ['type' => 'text', 'description' => 'rendered note content, so we can keep the microtags (if not local)'], - 'conversation_id' => ['type' => 'serial', 'not null' => true, 'foreign key' => true, 'target' => 'Conversation.id', 'multiplicity' => 'one to one', 'description' => 'the conversation identifier'], - 'reply_to' => ['type' => 'int', 'not null' => false, 'default' => null, 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'description' => 'note replied to, null if root of a conversation'], - 'is_local' => ['type' => 'bool', 'not null' => true, 'description' => 'was this note generated by a local actor'], - 'source' => ['type' => 'varchar', 'foreign key' => true, 'length' => 32, 'target' => 'NoteSource.code', 'multiplicity' => 'many to one', 'description' => 'fkey to source of note, like "web", "im", or "clientname"'], - 'scope' => ['type' => 'int', 'not null' => true, 'default' => VisibilityScope::EVERYWHERE->value, 'description' => 'bit map for distribution scope; 1 = everywhere; 2 = this server only; 4 = addressees; 8 = groups; 16 = collection; 32 = messages'], - 'url' => ['type' => 'text', 'description' => 'Permalink to Note'], - 'language_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Language.id', 'multiplicity' => 'one to many', 'description' => 'The language for this note'], - 'type' => ['type' => 'int', 'not null' => true, 'default' => NoteType::NOTE->value, 'description' => 'bit map for note type; 1 = Note; 2 = Page'], - 'title' => ['type' => 'varchar', 'not null' => false, 'default' => null, 'length' => 129, 'description' => 'Title of a page or a note'], - 'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'], + 'id' => ['type' => 'serial', 'not null' => true], + 'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'who made the note'], + 'content' => ['type' => 'text', 'description' => 'note content'], + 'content_type' => ['type' => 'varchar', 'not null' => true, 'default' => 'text/plain', 'length' => 129, 'description' => 'A note can be written in a multitude of formats such as text/plain, text/markdown, application/x-latex, and text/html'], + 'rendered' => ['type' => 'text', 'description' => 'rendered note content, so we can keep the microtags (if not local)'], + 'conversation_id' => ['type' => 'serial', 'not null' => true, 'foreign key' => true, 'target' => 'Conversation.id', 'multiplicity' => 'one to one', 'description' => 'the conversation identifier'], + 'reply_to' => ['type' => 'int', 'not null' => false, 'default' => null, 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'description' => 'note replied to, null if root of a conversation'], + 'is_local' => ['type' => 'bool', 'not null' => true, 'description' => 'was this note generated by a local actor'], + 'source' => ['type' => 'varchar', 'foreign key' => true, 'length' => 32, 'target' => 'NoteSource.code', 'multiplicity' => 'many to one', 'description' => 'fkey to source of note, like "web", "im", or "clientname"'], + 'scope' => ['type' => 'int', 'not null' => true, 'default' => VisibilityScope::EVERYWHERE->value, 'description' => 'bit map for distribution scope; 1 = everywhere; 2 = this server only; 4 = addressees; 8 = groups; 16 = collection; 32 = messages'], + 'url' => ['type' => 'text', 'description' => 'Permalink to Note'], + 'language_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Language.id', 'multiplicity' => 'one to many', 'description' => 'The language for this note'], + 'type' => ['type' => 'int', 'not null' => true, 'default' => NoteType::NOTE->value, 'description' => 'bit map for note type; 1 = Note; 2 = Page'], + 'title' => ['type' => 'varchar', 'not null' => false, 'default' => null, 'length' => 129, 'description' => 'Title of a page or a note'], + 'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'], 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], ], 'primary key' => ['id'],