From 627d92b29069d556eb9e3ca93586fb04ddd6b196 Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Tue, 4 Jan 2022 22:20:12 +0000 Subject: [PATCH] [COMPONENT][Tag] Improve Note Tag Handling and start extracting Circles logic out of the plugin, various bug fixes --- components/Search/Controller/Search.php | 2 +- components/Tag/Controller/Tag.php | 203 +++--------------- {src => components/Tag}/Entity/NoteTag.php | 39 ++-- .../Tag}/Entity/NoteTagBlock.php | 12 +- components/Tag/Form/SelfTagsForm.php | 63 ------ components/Tag/Tag.php | 134 ++++++------ plugins/ActivityPub/Util/Model/Note.php | 4 +- plugins/RelatedTags/RelatedTags.php | 25 ++- .../Controller/AddBlocked.php | 38 +--- .../Controller/EditBlocked.php | 16 +- .../TagBasedFiltering/TagBasedFiltering.php | 20 +- src/Entity/Activity.php | 13 -- src/Entity/Actor.php | 84 +++----- 13 files changed, 180 insertions(+), 473 deletions(-) rename {src => components/Tag}/Entity/NoteTag.php (80%) rename {src => components/Tag}/Entity/NoteTagBlock.php (93%) delete mode 100644 components/Tag/Form/SelfTagsForm.php diff --git a/components/Search/Controller/Search.php b/components/Search/Controller/Search.php index 13c7a8f8cc..0fa2adf3fc 100644 --- a/components/Search/Controller/Search.php +++ b/components/Search/Controller/Search.php @@ -48,7 +48,7 @@ class Search extends FeedController $language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null; $q = $this->string('q'); - $data = $this->query(query: $q, language: $language); + $data = $this->query(query: $q, locale: $language); $notes = $data['notes']; $actors = $data['actors']; diff --git a/components/Tag/Controller/Tag.php b/components/Tag/Controller/Tag.php index 49c52224ac..b93f9d211b 100644 --- a/components/Tag/Controller/Tag.php +++ b/components/Tag/Controller/Tag.php @@ -6,31 +6,34 @@ namespace Component\Tag\Controller; use App\Core\Cache; use App\Core\Controller; -use App\Core\DB\DB; -use function App\Core\I18n\_m; -use App\Entity as E; use App\Util\Common; -use App\Util\Exception\BugFoundException; -use App\Util\Exception\ClientException; -use App\Util\Exception\RedirectException; -use App\Util\Formatting; -use Component\Tag\Form\SelfTagsForm; +use Component\Language\Entity\Language; use Component\Tag\Tag as CompTag; -use Symfony\Component\Form\SubmitButton; -use Symfony\Component\HttpFoundation\Request; class Tag extends Controller { - private function process(string|array $canon_single_or_multi, null|string|array $tag_single_or_multi, string $key, string $query, string $template) + // TODO: Use Feed::query + // TODO: If ?canonical=something, respect + // TODO: Allow to set locale of tag being selected + private function process(null|string|array $tag_single_or_multi, string $key, string $query, string $template, bool $include_locale = false) { $actor = Common::actor(); $page = $this->int('page') ?: 1; - $lang = $this->string('lang'); + + $query_args = ['tag' => $tag_single_or_multi]; + + if ($include_locale) { + if (!\is_null($locale = $this->string('locale'))) { + $query_args['language_id'] = Language::getByLocale($locale)->getId(); + } else { + $query_args['language_id'] = Common::actor()->getTopLanguage()->getId(); + } + } $results = Cache::pagedStream( key: $key, query: $query, - query_args: ['canon' => $canon_single_or_multi], + query_args: $query_args, actor: $actor, page: $page, ); @@ -43,179 +46,25 @@ class Tag extends Controller ]; } - public function single_note_tag(string $canon) + public function single_note_tag(string $tag) { return $this->process( - canon_single_or_multi: $canon, - tag_single_or_multi: $this->string('tag'), - key: CompTag::cacheKeys($canon)['note_single'], - query: 'select n from note n join note_tag nt with n.id = nt.note_id where nt.canonical = :canon order by nt.created DESC, nt.note_id DESC', + tag_single_or_multi: $tag, + key: CompTag::cacheKeys($tag)['note_single'], + query: 'SELECT n FROM note AS n JOIN note_tag AS nt WITH n.id = nt.note_id WHERE nt.tag = :tag AND nt.language_id = :language_id ORDER BY nt.created DESC, nt.note_id DESC', template: 'note_tag_feed.html.twig', + include_locale: true, ); } - public function multi_note_tags(string $canons) + public function multi_note_tags(string $tags) { return $this->process( - canon_single_or_multi: explode(',', $canons), - tag_single_or_multi: !\is_null($this->string('tags')) ? explode(',', $this->string('tags')) : null, - key: CompTag::cacheKeys(str_replace(',', '-', $canons))['note_multi'], - query: 'select n from note n join note_tag nt with n.id = nt.note_id where nt.canonical in (:canon) order by nt.created DESC, nt.note_id DESC', + tag_single_or_multi: explode(',', $tags), + key: CompTag::cacheKeys(str_replace(',', '-', $tags))['note_multi'], + query: 'select n from note n join note_tag nt with n.id = nt.note_id where nt.tag in (:tag) AND nt.language_id = :language_id order by nt.created DESC, nt.note_id DESC', template: 'note_tag_feed.html.twig', + include_locale: true, ); } - - public function single_actor_tag(string $canon) - { - return $this->process( - canon_single_or_multi: $canon, - tag_single_or_multi: $this->string('tag'), - key: CompTag::cacheKeys($canon)['actor_single'], - query: 'select a from actor a join actor_tag at with a.id = at.tagged where at.canonical = :canon order by at.modified DESC', - template: 'actor_tag_feed.html.twig', - ); - } - - public function multi_actor_tag(string $canons) - { - return $this->process( - canon_single_or_multi: explode(',', $canons), - tag_single_or_multi: !\is_null($this->string('tags')) ? explode(',', $this->string('tags')) : null, - key: CompTag::cacheKeys(str_replace(',', '-', $canons))['actor_multi'], - query: 'select a from actor a join actor_tag at with a.id = at.tagged where at.canonical = :canon order by at.modified DESC', - template: 'actor_tag_feed.html.twig', - ); - } - - /** - * Generic settings page for an Actor's self tags - */ - public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id) - { - $actor = Common::actor(); - if (!$actor->canAdmin($target)) { - throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()])); - } - - $actor_tags = $target->getSelfTags(); - - [$add_form, $existing_form] = SelfTagsForm::handleTags( - $request, - $actor_tags, - handle_new: /** - * Handle adding tags - */ - function ($form) use ($request, $target, $details_id) { - $data = $form->getData(); - $tags = $data['new-tags']; - $language = $target->getTopLanguage()->getLocale(); - foreach ($tags as $tag) { - $tag = CompTag::ensureValid($tag); - $canon_tag = CompTag::canonicalTag($tag, language: $language); - $use_canon = $data['new-tags-use-canon']; - - [$actor_tag, $actor_tag_existed] = E\ActorTag::createOrUpdate([ - 'tagger' => $target->getId(), - 'tagged' => $target->getId(), - 'tag' => $tag, - 'canonical' => $canon_tag, - 'use_canonical' => $use_canon, - ]); - DB::persist($actor_tag); - - $actor_circle = DB::findBy( - 'actor_circle', - [ - 'tagger' => null, - 'tagged' => $target->getId(), - 'in' => ['tag' => [$tag, $canon_tag]], - 'use_canonical' => $use_canon, - ], - ); - if (empty($actor_circle)) { - if ($actor_tag_existed) { - throw new BugFoundException('Actor tag existed but generic actor circle did not'); - } - DB::persist(E\ActorCircle::create([ - 'tagger' => null, - 'tagged' => $target->getId(), - 'tag' => $use_canon ? $canon_tag : $tag, - 'use_canonical' => $use_canon, - 'private' => false, - 'description' => null, - ])); - } - } - DB::flush(); - Cache::delete(E\Actor::cacheKeys($target->getId(), $target->getId())['tags']); - throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id]); - }, - handle_existing: /** - * Handle changes to the existing tags - */ - function ($form, array $form_definition) use ($request, $target, $details_id) { - $data = $form->getData(); - $changed = false; - $language = $target->getTopLanguage()->getLocale(); - foreach (array_chunk($form_definition, 3) as $entry) { - $tag = Formatting::removePrefix($entry[0][2]['data'], '#'); - $canon_tag = CompTag::canonicalTag($tag, language: $language); - $use_canon = $entry[1][2]['attr']['data']; - - /** @var SubmitButton $remove */ - $remove = $form->get($entry[2][0]); - if ($remove->isClicked()) { - $changed = true; - DB::removeBy( - 'actor_tag', - [ - 'tagger' => $target->getId(), - 'tagged' => $target->getId(), - 'tag' => $tag, - 'use_canonical' => $use_canon, - ], - ); - DB::removeBy( - 'actor_circle', - [ - 'tagger' => null, - 'tagged' => $target->getId(), - 'tag' => $use_canon ? $canon_tag : $tag, - 'use_canonical' => $use_canon, - ], - ); - } - - /** @var SubmitButton $toggle_canon */ - $toggle_canon = $form->get($entry[1][0]); - if ($toggle_canon->isSubmitted()) { - $changed = true; - $actor_tag = DB::find( - 'actor_tag', - [ - 'tagger' => $target->getId(), - 'tagged' => $target->getId(), - 'tag' => $tag, - 'use_canonical' => $use_canon, - ], - ); - DB::persist($actor_tag->setUseCanonical(!$use_canon)); - } - } - if ($changed) { - DB::flush(); - Cache::delete(E\Actor::cacheKeys($target->getId(), $target->getId())['tags']); - throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id]); - } - }, - remove_label: _m('Remove self tag'), - add_label: _m('Add self tag'), - ); - - return [ - '_template' => 'self_tags_settings.fragment.html.twig', - 'add_self_tags_form' => $add_form->createView(), - 'existing_self_tags_form' => $existing_form?->createView(), - ]; - } } diff --git a/src/Entity/NoteTag.php b/components/Tag/Entity/NoteTag.php similarity index 80% rename from src/Entity/NoteTag.php rename to components/Tag/Entity/NoteTag.php index a0260b81e9..01f4eb6ef9 100644 --- a/src/Entity/NoteTag.php +++ b/components/Tag/Entity/NoteTag.php @@ -19,12 +19,15 @@ declare(strict_types = 1); // along with GNU social. If not, see . // }}} -namespace App\Entity; +namespace Component\Tag\Entity; use App\Core\Cache; use App\Core\DB\DB; use App\Core\Entity; use App\Core\Router\Router; +use App\Entity\Actor; +use App\Entity\Note; +use Component\Language\Entity\Language; use Component\Tag\Tag; use DateTimeInterface; @@ -39,6 +42,7 @@ use DateTimeInterface; * @author Mikael Nordfeldth * @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org * @author Hugo Sales + * @author Diogo Peralta Cordeiro <@diogo.site> * @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later */ @@ -51,11 +55,11 @@ class NoteTag extends Entity private int $note_id; private bool $use_canonical; private ?int $language_id = null; - private \DateTimeInterface $created; + private DateTimeInterface $created; public function setTag(string $tag): self { - $this->tag = \mb_substr($tag, 0, 64); + $this->tag = mb_substr($tag, 0, 64); return $this; } @@ -66,7 +70,7 @@ class NoteTag extends Entity public function setCanonical(string $canonical): self { - $this->canonical = \mb_substr($canonical, 0, 64); + $this->canonical = mb_substr($canonical, 0, 64); return $this; } @@ -108,13 +112,13 @@ class NoteTag extends Entity return $this->language_id; } - 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; } @@ -132,15 +136,24 @@ class NoteTag extends Entity public static function getByNoteId(int $note_id): array { - return Cache::getList(self::cacheKey($note_id), fn () => DB::dql('select nt from note_tag nt join note n with n.id = nt.note_id where n.id = :id', ['id' => $note_id])); + return Cache::getList(self::cacheKey($note_id), fn () => DB::dql('SELECT nt FROM note_tag AS nt JOIN note AS n WITH n.id = nt.note_id WHERE n.id = :id', ['id' => $note_id])); } public function getUrl(?Actor $actor = null, int $type = Router::ABSOLUTE_PATH): string { - $params = ['canon' => $this->getCanonical(), 'tag' => $this->getTag()]; - if (!\is_null($actor)) { - $params['lang'] = $actor->getTopLanguage()->getLocale(); + $params['tag'] = $this->getTag(); + + if (\is_null($this->getLanguageId())) { + if (!\is_null($actor)) { + $params['locale'] = $actor->getTopLanguage()->getLocale(); + } + } else { + $params['locale'] = Language::getById($this->getLanguageId())->getLocale(); } + if ($this->getUseCanonical()) { + $params['canonical'] = $this->getCanonical(); + } + return Router::url(id: 'single_note_tag', args: $params, type: $type); } @@ -150,18 +163,18 @@ class NoteTag extends Entity 'name' => 'note_tag', 'description' => 'Hash tags on notes', 'fields' => [ + 'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to tagged note'], 'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hash tag associated with this note'], 'canonical' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'ascii slug of tag'], - 'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to tagged note'], 'use_canonical' => ['type' => 'bool', 'not null' => true, 'description' => 'whether the user wanted to use canonical tags in this note. Separate for blocks'], 'language_id' => ['type' => 'int', 'not null' => false, 'foreign key' => true, 'target' => 'Language.id', 'multiplicity' => 'many to many', 'description' => 'the language this entry refers to'], 'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], ], - 'primary key' => ['tag', 'note_id'], + 'primary key' => ['note_id', 'tag'], // No need to require language in this association because all the related tags will be in the note's language already 'indexes' => [ 'note_tag_created_idx' => ['created'], 'note_tag_note_id_idx' => ['note_id'], - 'note_tag_canonical_idx' => ['canonical'], + 'note_tag_tag_language_id_idx' => ['tag', 'language_id'], 'note_tag_tag_created_note_id_idx' => ['tag', 'created', 'note_id'], ], ]; diff --git a/src/Entity/NoteTagBlock.php b/components/Tag/Entity/NoteTagBlock.php similarity index 93% rename from src/Entity/NoteTagBlock.php rename to components/Tag/Entity/NoteTagBlock.php index dc0c195af6..7daf15abdf 100644 --- a/src/Entity/NoteTagBlock.php +++ b/components/Tag/Entity/NoteTagBlock.php @@ -19,7 +19,7 @@ declare(strict_types = 1); // along with GNU social. If not, see . // }}} -namespace App\Entity; +namespace Component\Tag\Entity; use App\Core\Cache; use App\Core\DB\DB; @@ -46,7 +46,7 @@ class NoteTagBlock extends Entity private string $tag; private string $canonical; private bool $use_canonical; - private \DateTimeInterface $modified; + private DateTimeInterface $modified; public function setBlocker(int $blocker): self { @@ -61,7 +61,7 @@ class NoteTagBlock extends Entity public function setTag(string $tag): self { - $this->tag = \mb_substr($tag, 0, 64); + $this->tag = mb_substr($tag, 0, 64); return $this; } @@ -72,7 +72,7 @@ class NoteTagBlock extends Entity public function setCanonical(string $canonical): self { - $this->canonical = \mb_substr($canonical, 0, 64); + $this->canonical = mb_substr($canonical, 0, 64); return $this; } @@ -92,13 +92,13 @@ class NoteTagBlock extends Entity return $this->use_canonical; } - 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; } diff --git a/components/Tag/Form/SelfTagsForm.php b/components/Tag/Form/SelfTagsForm.php deleted file mode 100644 index 499becf8bc..0000000000 --- a/components/Tag/Form/SelfTagsForm.php +++ /dev/null @@ -1,63 +0,0 @@ -getCanonical(); - $form_definition[] = ["{$canon}:old-tag", TextType::class, ['data' => '#' . $tag->getTag(), 'label' => ' ', 'disabled' => true]]; - $form_definition[] = ["{$canon}:toggle-canon", SubmitType::class, ['attr' => ['data' => $tag->getUseCanonical()], 'label' => $tag->getUseCanonical() ? _m('Set non-canonical') : _m('Set canonical')]]; - $form_definition[] = [$existing_form_name = "{$canon}:remove", SubmitType::class, ['label' => $remove_label]]; - } - - $existing_form = !empty($form_definition) ? Form::create($form_definition) : null; - - $add_form = Form::create([ - ['new-tags', TextType::class, ['label' => ' ', 'data' => [], 'required' => false, 'help' => _m('Tags for yourself (letters, numbers, -, ., and _), comma- or space-separated.'), 'transformer' => ArrayTransformer::class]], - ['new-tags-use-canon', CheckboxType::class, ['label' => _m('Use canonical'), 'help' => _m('Assume this tag is the same as similar tags'), 'required' => false, 'data' => true]], - [$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]], - ]); - - if ($request->getMethod() === 'POST' && $request->request->has($add_form_name)) { - $add_form->handleRequest($request); - if ($add_form->isSubmitted() && $add_form->isValid()) { - $handle_new($add_form); - } - } - - if (!\is_null($existing_form) && $request->getMethod() === 'POST' && $request->request->has($existing_form_name ?? '')) { - $existing_form->handleRequest($request); - if ($existing_form->isSubmitted() && $existing_form->isValid()) { - $handle_existing($existing_form, $form_definition); - } - } - - return [$add_form, $existing_form]; - } -} diff --git a/components/Tag/Tag.php b/components/Tag/Tag.php index ca654a4957..c21829cb62 100644 --- a/components/Tag/Tag.php +++ b/components/Tag/Tag.php @@ -30,18 +30,15 @@ use function App\Core\I18n\_m; use App\Core\Modules\Component; use App\Core\Router\Router; use App\Entity\Actor; -use App\Entity\ActorCircle; -use App\Entity\ActorTag; use App\Entity\Note; -use App\Entity\NoteTag; use App\Util\Common; use App\Util\Exception\ClientException; use App\Util\Formatting; use App\Util\Functional as GSF; use App\Util\HTML; -use App\Util\Nickname; +use Component\Circle\Entity\ActorTag; use Component\Language\Entity\Language; -use Component\Tag\Controller as C; +use Component\Tag\Entity\NoteTag; use Doctrine\Common\Collections\ExpressionBuilder; use Doctrine\ORM\Query\Expr; use Doctrine\ORM\QueryBuilder; @@ -53,22 +50,20 @@ use Symfony\Component\HttpFoundation\Request; * Component responsible for extracting tags from posted notes, as well as normalizing them * * @author Hugo Sales + * @author Diogo Peralta Cordeiro <@diogo.site> * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later */ class Tag extends Component { - public const MAX_TAG_LENGTH = 64; - public const TAG_REGEX = '/(^|\\s)(#[\\pL\\pN_\\-]{1,64})/u'; // Brion Vibber 2011-02-23 v2:classes/Notice.php:367 function saveTags - public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/'; - public const TAG_SLUG_REGEX = '[A-Za-z0-9]{1,64}'; + public const MAX_TAG_LENGTH = 64; + public const TAG_REGEX = '/(^|\\s)(#[\\pL\\pN_\\-]{1,64})/u'; // Brion Vibber 2011-02-23 v2:classes/Notice.php:367 function saveTags + public const TAG_SLUG_REGEX = '[A-Za-z0-9]{1,64}'; public function onAddRoute($r): bool { - $r->connect('single_note_tag', '/note-tag/{canon<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_note_tag']); - $r->connect('multi_note_tags', '/note-tags/{canons<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_note_tags']); - $r->connect('single_actor_tag', '/actor-tag/{canon<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_actor_tag']); - $r->connect('multi_actor_tags', '/actor-tags/{canons<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_actor_tags']); + $r->connect('single_note_tag', '/note-tag/{tag<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_note_tag']); + $r->connect('multi_note_tags', '/note-tags/{tags<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_note_tags']); return Event::next; } @@ -86,7 +81,10 @@ class Tag extends Component preg_match_all(self::TAG_REGEX, $content, $matched_tags, \PREG_SET_ORDER); $matched_tags = array_unique(F\map($matched_tags, fn ($m) => $m[2])); foreach ($matched_tags as $match) { - $tag = self::ensureValid($match); + $tag = self::extract($match); + if (!self::validate($tag)) { + continue; // Ignore invalid tag candidates + } $canonical_tag = self::canonicalTag($tag, \is_null($lang_id = $note->getLanguageId()) ? null : Language::getById($lang_id)->getLocale()); DB::persist(NoteTag::create([ 'tag' => $tag, @@ -103,38 +101,54 @@ class Tag extends Component return Event::next; } - public function onRenderPlainTextNoteContent(string &$text, ?string $language = null): bool + public function onRenderPlainTextNoteContent(string &$text, ?string $locale = null): bool { - $text = preg_replace_callback(self::TAG_REGEX, fn ($m) => $m[1] . self::tagLink($m[2], $language), $text); + $text = preg_replace_callback(self::TAG_REGEX, fn ($m) => $m[1] . self::tagLink($m[2], $locale), $text); return Event::next; } - public static function cacheKeys(string $canon_single_or_multi): array + public static function cacheKeys(string $tag_single_or_multi): array { return [ - 'note_single' => "note-tag-feed-{$canon_single_or_multi}", - 'note_multi' => "note-tags-feed-{$canon_single_or_multi}", - 'actor_single' => "actor-tag-feed-{$canon_single_or_multi}", - 'actor_multi' => "actor-tags-feed-{$canon_single_or_multi}", + 'note_single' => "note-tag-feed-{$tag_single_or_multi}", + 'note_multi' => "note-tags-feed-{$tag_single_or_multi}", + 'actor_single' => "actor-tag-feed-{$tag_single_or_multi}", + 'actor_multi' => "actor-tags-feed-{$tag_single_or_multi}", ]; } - private static function tagLink(string $tag, ?string $language): string + private static function tagLink(string $tag, ?string $locale): string { - $tag = self::ensureLength($tag); - $canonical = self::canonicalTag($tag, $language); - $url = Router::url('single_note_tag', !\is_null($language) ? ['canon' => $canonical, 'lang' => $language, 'tag' => $tag] : ['canon' => $canonical, 'tag' => $tag]); - return HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => $tag, 'rel' => 'tag'], $tag]], options: ['indent' => false]); + $tag = self::extract($tag); + $url = Router::url('single_note_tag', !\is_null($locale) ? ['tag' => $tag, 'locale' => $locale] : ['tag' => $tag]); + return HTML::html(['span' => ['attrs' => ['class' => 'tag'], + '#' . HTML::html(['a' => [ + 'attrs' => [ + 'href' => $url, + 'rel' => 'tag', // https://microformats.org/wiki/rel-tag + ], + $tag, + ]], options: ['indent' => false]), + ]], options: ['indent' => false, 'raw' => true]); } - public static function ensureValid(string $tag) + public static function extract(string $tag): string { - $tag = self::ensureLength(Formatting::removePrefix($tag, '#')); - if (preg_match(self::TAG_REGEX, '#' . $tag)) { - return $tag; - } else { + return self::ensureLength(Formatting::removePrefix($tag, '#')); + } + + public static function validate(string $tag): bool + { + return preg_match(self::TAG_REGEX, '#' . $tag) === 1; + } + + public static function sanitize(string $tag): string + { + $tag = self::extract($tag); + if (!self::validate($tag)) { throw new ClientException(_m('Invalid tag given: {tag}', ['{tag}' => $tag])); } + return $tag; } public static function ensureLength(string $tag): string @@ -143,11 +157,11 @@ class Tag extends Component } /** - * Convert a tag to it's canonical representation, by splitting it + * Convert a tag to its canonical representation, by splitting it * into words, stemming it in the given language (if enabled) and * sluggifying it (turning it into an ASCII representation) */ - public static function canonicalTag(string $tag, ?string $language): string + public static function canonicalTag(string $tag, ?string $language = null): string { $result = ''; foreach (Formatting::splitWords(str_replace('#', '', $tag)) as $word) { @@ -165,17 +179,20 @@ class Tag extends Component * * $term /^(note|tag|people|actor)/ means we want to match only either a note or an actor */ - public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr): bool + public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool { if (!str_contains($term, ':')) { return Event::next; } + if (\is_null($locale)) { + $locale = Common::currentLanguage(); + } [$search_type, $search_term] = explode(':', $term); if (str_starts_with($search_term, '#')) { - $search_term = self::ensureValid($search_term); - $canon_search_term = self::canonicalTag($search_term, $language); - $temp_note_expr = $eb->eq('note_tag.canonical', $canon_search_term); - $temp_actor_expr = $eb->eq('actor_tag.canonical', $canon_search_term); + $search_term = self::sanitize($search_term); + $canonical_search_term = self::canonicalTag($search_term, $locale); + $temp_note_expr = $eb->eq('note_tag.canonical', $canonical_search_term); + $temp_actor_expr = $eb->eq('actor_tag.canonical', $canonical_search_term); if (Formatting::startsWith($term, ['note:', 'tag:', 'people:'])) { $note_expr = $temp_note_expr; } elseif (Formatting::startsWith($term, ['people:', 'actor:'])) { @@ -183,7 +200,7 @@ class Tag extends Component } elseif (Formatting::startsWith($term, GSF::cartesianProduct([['people', 'actor'], ['circle', 'list'], [':']], separator: ['-', '_']))) { $null_tagger_expr = $eb->isNull('actor_circle.tagger'); $tagger_expr = \is_null($actor_expr) ? $null_tagger_expr : $eb->orX($null_tagger_expr, $eb->eq('actor_circle.tagger', $actor->getId())); - $tags = array_unique([$search_term, $canon_search_term]); + $tags = array_unique([$search_term, $canonical_search_term]); $tag_expr = \count($tags) === 1 ? $eb->eq('actor_circle.tag', $tags[0]) : $eb->in('actor_circle.tag', $tags); $search_expr = $eb->andX( $tagger_expr, @@ -202,52 +219,23 @@ class Tag extends Component public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool { - $note_qb->leftJoin(NoteTag::class, 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id') - ->leftJoin(ActorCircle::class, 'actor_circle', Expr\Join::WITH, 'note_actor.id = actor_circle.tagged'); - $actor_qb->leftJoin(ActorTag::class, 'actor_tag', Expr\Join::WITH, 'actor_tag.tagger = actor.id') - ->leftJoin(ActorCircle::class, 'actor_circle', Expr\Join::WITH, 'actor.id = actor_circle.tagged'); + $note_qb->leftJoin(NoteTag::class, 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id'); + $actor_qb->leftJoin(ActorTag::class, 'actor_tag', Expr\Join::WITH, 'actor_tag.tagger = actor.id'); return Event::next; } - public function onPostingAddFormEntries(Request $request, Actor $actor, array &$form_params) + public function onPostingAddFormEntries(Request $request, Actor $actor, array &$form_params): bool { $form_params[] = ['tag_use_canonical', CheckboxType::class, ['required' => false, 'data' => true, 'label' => _m('Make note tags canonical'), 'help' => _m('Canonical tags will be treated as a version of an existing tag with the same root/stem (e.g. \'#great_tag\' will be considered as a version of \'#great\', if it already exists)')]]; return Event::next; } - public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args) + public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): bool { if (!isset($data['tag_use_canonical'])) { - throw new ClientException; + throw new ClientException(_m('Missing Use Canonical preference for Tags.')); } $extra_args['tag_use_canonical'] = $data['tag_use_canonical']; return Event::next; } - - public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs) - { - if ($section === 'profile' && $request->get('_route') === 'settings') { - $tabs[] = [ - 'title' => 'Self tags', - 'desc' => 'Add or remove tags on yourself', - 'id' => 'settings-self-tags', - 'controller' => C\Tag::settingsSelfTags($request, Common::actor(), 'settings-self-tags-details'), - ]; - } - return Event::next; - } - - public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets) - { - $actor_id = $actor->getId(); - $tags = Cache::get( - "actor-circle-{$actor_id}", - fn () => DB::dql('select c.tag from actor_circle c where c.tagger = :tagger', ['tagger' => $actor_id]), - ); - foreach ($tags as $t) { - $t = '#' . $t['tag']; - $targets[$t] = $t; - } - return Event::next; - } } diff --git a/plugins/ActivityPub/Util/Model/Note.php b/plugins/ActivityPub/Util/Model/Note.php index f2389d57c1..0ea3dfb367 100644 --- a/plugins/ActivityPub/Util/Model/Note.php +++ b/plugins/ActivityPub/Util/Model/Note.php @@ -44,7 +44,6 @@ use App\Core\Log; use App\Core\Router\Router; use App\Core\VisibilityScope; use App\Entity\Note as GSNote; -use App\Entity\NoteTag; use App\Util\Common; use App\Util\Exception\ClientException; use App\Util\Exception\DuplicateFoundException; @@ -57,6 +56,7 @@ use Component\Attachment\Entity\AttachmentToNote; use Component\Conversation\Conversation; use Component\FreeNetwork\FreeNetwork; use Component\Language\Entity\Language; +use Component\Tag\Entity\NoteTag; use Component\Tag\Tag; use DateTime; use DateTimeInterface; @@ -254,7 +254,7 @@ class Note extends Model break; case 'Hashtag': $match = ltrim($ap_tag->get('name'), '#'); - $tag = Tag::ensureValid($match); + $tag = Tag::extract($match); $canonical_tag = $ap_tag->get('canonical') ?? Tag::canonicalTag($tag, \is_null($lang_id = $obj->getLanguageId()) ? null : Language::getById($lang_id)->getLocale()); DB::persist(NoteTag::create([ 'tag' => $tag, diff --git a/plugins/RelatedTags/RelatedTags.php b/plugins/RelatedTags/RelatedTags.php index a70931ac98..9913ca6857 100644 --- a/plugins/RelatedTags/RelatedTags.php +++ b/plugins/RelatedTags/RelatedTags.php @@ -25,9 +25,8 @@ use App\Core\Cache; use App\Core\DB\DB; use App\Core\Event; use App\Core\Modules\Plugin; -use App\Entity\ActorTag; -use App\Entity\NoteTag; - +use Component\Circle\Entity\ActorTag; +use Component\Tag\Entity\NoteTag; use Symfony\Component\HttpFoundation\Request; class RelatedTags extends Plugin @@ -37,21 +36,25 @@ class RelatedTags extends Plugin */ public function onAddPinnedFeedContent(Request $request, array &$pinned) { - $tags = $request->attributes->get('canons'); - $tags = !\is_null($tags) ? explode(',', $tags) : [$request->attributes->get('canon')]; + // Lets not use language, probably wouldn't make it more helpful + //$locale = $request->attributes->get('locale'); + //$language_id = !empty($locale) ? Language::getByLocale($locale)->getId() : Common::actor()->getTopLanguage()->getId(); + $tags = $request->attributes->get('tags'); + $tags = !\is_null($tags) ? explode(',', $tags) : [$request->attributes->get('tag')]; switch ($request->attributes->get('_route')) { case 'single_note_tag': // fall-through case 'multi_note_tags': $related = Cache::getList( + //"related-note-tags-{$language_id}-" . implode('-', $tags), 'related-note-tags-' . implode('-', $tags), fn () => DB::sql( <<<'EOQ' - select distinct on (nt.canonical) canonical, nt.tag, nt.note_id, nt.created + select distinct on (nt.canonical) canonical, nt.tag, nt.note_id, nt.canonical, nt.use_canonical, nt.created from note_tag nt - where nt.note_id in (select n.id from note n join note_tag nt on n.id = nt.note_id where nt.canonical in (:tags)) - and not nt.canonical in (:tags) + where nt.note_id in (select n.id from note n join note_tag nt on n.id = nt.note_id where nt.tag in (:tags)) + and not nt.tag in (:tags) limit 5 EOQ, ['tags' => $tags], @@ -68,10 +71,10 @@ class RelatedTags extends Plugin 'related-actor-tags-' . implode('-', $tags), fn () => DB::sql( <<<'EOQ' - select distinct on (at.canonical) canonical, at.tagger, at.tagged, at.tag, at.use_canonical, at.modified + select distinct on (at.tag) tag, at.tagger, at.tagged, at.tag, at.modified from actor_tag at - where at.tagged in (select at.tagged from actor_tag at where at.canonical in (:tags)) - and not at.canonical in (:tags) + where at.tagged in (select at.tagged from actor_tag at where at.tag in (:tags)) + and not at.tag in (:tags) limit 5 EOQ, ['tags' => $tags], diff --git a/plugins/TagBasedFiltering/Controller/AddBlocked.php b/plugins/TagBasedFiltering/Controller/AddBlocked.php index 6926a069dc..d50e8d480d 100644 --- a/plugins/TagBasedFiltering/Controller/AddBlocked.php +++ b/plugins/TagBasedFiltering/Controller/AddBlocked.php @@ -29,14 +29,12 @@ use App\Core\DB\DB; use App\Core\Form; use function App\Core\I18n\_m; use App\Entity\Actor; -use App\Entity\ActorTag; -use App\Entity\ActorTagBlock; use App\Entity\Note; -use App\Entity\NoteTag; -use App\Entity\NoteTagBlock; use App\Util\Common; use App\Util\Exception\RedirectException; use Component\Language\Entity\Language; +use Component\Tag\Entity\NoteTag; +use Component\Tag\Entity\NoteTagBlock; use Component\Tag\Tag; use Functional as F; use Plugin\TagBasedFiltering\TagBasedFiltering as TagFilerPlugin; @@ -67,10 +65,10 @@ class AddBlocked extends Controller $form_definition = []; foreach ($blockable_tags as $nt) { - $canon = $nt->getCanonical(); - $form_definition[] = ["{$canon}:tag", TextType::class, ['data' => '#' . $nt->getTag(), 'label' => ' ']]; - $form_definition[] = ["{$canon}:use-canon", CheckboxType::class, ['label' => _m('Use canonical'), 'help' => _m('Block all similar tags'), 'required' => false, 'data' => true]]; - $form_definition[] = ["{$canon}:add", SubmitType::class, ['label' => _m('Block')]]; + $canonical = $nt->getCanonical(); + $form_definition[] = ["{$canonical}:tag", TextType::class, ['data' => '#' . $nt->getTag(), 'label' => ' ']]; + $form_definition[] = ["{$canonical}:use-canonical", CheckboxType::class, ['label' => _m('Use canonical'), 'help' => _m('Block all similar tags'), 'required' => false, 'data' => true]]; + $form_definition[] = ["{$canonical}:add", SubmitType::class, ['label' => _m('Block')]]; } $form = null; @@ -80,24 +78,24 @@ class AddBlocked extends Controller if ($form->isSubmitted() && $form->isValid()) { $data = $form->getData(); foreach ($form_definition as [$id, $_, $opts]) { - [$canon, $type] = explode(':', $id); + [$canonical, $type] = explode(':', $id); if ($type === 'add') { /** @var SubmitButton $button */ $button = $form->get($id); if ($button->isClicked()) { Cache::delete($block_class::cacheKey($user->getId())); Cache::delete(TagFilerPlugin::cacheKeys($user->getId())[$type_name]); - $new_tag = Tag::ensureValid($data[$canon . ':tag']); + $new_tag = Tag::sanitize($data[$canonical . ':tag']); $language = $target instanceof Note ? Language::getByNote($target)->getLocale() : $user->getActor()->getTopLanguage()->getLocale(); $canonical_tag = Tag::canonicalTag($new_tag, $language); DB::persist($block_class::create([ 'blocker' => $user->getId(), 'tag' => $new_tag, 'canonical' => $canonical_tag, - 'use_canonical' => $data[$canon . ':use-canon'], + 'use_canonical' => $data[$canonical . ':use-canonical'], ])); DB::flush(); - throw new RedirectException; + throw new RedirectException(); } } } @@ -131,20 +129,4 @@ class AddBlocked extends Controller block_class: NoteTagBlock::class, ); } - - public function addBlockedActorTags(Request $request, int $actor_id) - { - return self::addBlocked( - request: $request, - type_name: 'actor', - calculate_target: fn () => Actor::getById($actor_id), - calculate_blocks: fn ($user) => ActorTagBlock::getByActorId($user->getId()), - calculate_tags: fn ($blocks) => F\reject( - ActorTag::getByActorId($actor_id), - fn (ActorTag $nt) => ActorTagBlock::checkBlocksActorTag($nt, $blocks), - ), - label: _m('Tags of the account above:'), - block_class: ActorTagBlock::class, - ); - } } diff --git a/plugins/TagBasedFiltering/Controller/EditBlocked.php b/plugins/TagBasedFiltering/Controller/EditBlocked.php index 5ab4162530..d0910d57ec 100644 --- a/plugins/TagBasedFiltering/Controller/EditBlocked.php +++ b/plugins/TagBasedFiltering/Controller/EditBlocked.php @@ -28,10 +28,9 @@ use App\Core\Controller; use App\Core\DB\DB; use App\Core\Form; use function App\Core\I18n\_m; -use App\Entity\ActorTagBlock; -use App\Entity\NoteTagBlock; use App\Util\Common; use App\Util\Exception\RedirectException; +use Component\Tag\Entity\NoteTagBlock; use Component\Tag\Tag; use Plugin\TagBasedFiltering\TagBasedFiltering as TagFilerPlugin; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -105,7 +104,7 @@ class EditBlocked extends Controller $data = $add_block_form->getData(); Cache::delete($block_class::cacheKey($user->getId())); Cache::delete(TagFilerPlugin::cacheKeys($user->getId())[$type_name]); - $new_tag = Tag::ensureValid($data['tag']); + $new_tag = Tag::sanitize($data['tag']); $language = $user->getActor()->getTopLanguage()->getLocale(); $canonical_tag = Tag::canonicalTag($new_tag, $language); DB::persist($block_class::create([ @@ -137,15 +136,4 @@ class EditBlocked extends Controller block_class: NoteTagBlock::class, ); } - - public static function editBlockedActorTags(Request $request) - { - return self::editBlocked( - request: $request, - type_name: 'actor', - calculate_blocks: fn ($user) => ActorTagBlock::getByActorId($user->getId()), - label: _m('Add blocked people tag:'), - block_class: ActorTagBlock::class, - ); - } } diff --git a/plugins/TagBasedFiltering/TagBasedFiltering.php b/plugins/TagBasedFiltering/TagBasedFiltering.php index 759da83ecd..af609d0677 100644 --- a/plugins/TagBasedFiltering/TagBasedFiltering.php +++ b/plugins/TagBasedFiltering/TagBasedFiltering.php @@ -31,12 +31,10 @@ use App\Core\Modules\Plugin; use App\Core\Router\RouteLoader; use App\Core\Router\Router; use App\Entity\Actor; -use App\Entity\ActorTag; -use App\Entity\ActorTagBlock; use App\Entity\LocalUser; use App\Entity\Note; -use App\Entity\NoteTag; -use App\Entity\NoteTagBlock; +use Component\Tag\Entity\NoteTag; +use Component\Tag\Entity\NoteTagBlock; use Functional as F; use Plugin\TagBasedFiltering\Controller as C; use Symfony\Component\HttpFoundation\Request; @@ -88,10 +86,6 @@ class TagBasedFiltering extends Plugin self::cacheKeys($actor)['note'], fn () => DB::dql('select ntb from note_tag_block ntb where ntb.blocker = :blocker', ['blocker' => $actor->getId()]), ); - $blocked_actor_tags = Cache::get( - self::cacheKeys($actor)['actor'], - fn () => DB::dql('select atb from actor_tag_block atb where atb.blocker = :blocker', ['blocker' => $actor->getId()]), - ); $notes = F\reject( $notes, @@ -102,10 +96,6 @@ class TagBasedFiltering extends Plugin NoteTag::getByNoteId($n->getId()), fn ($nt) => NoteTagBlock::checkBlocksNoteTag($nt, $blocked_note_tags), ) - || F\some( - ActorTag::getByActorId($n->getActor()->getId()), - fn ($at) => ActorTagBlock::checkBlocksActorTag($at, $blocked_actor_tags), - ) ) ), ); @@ -122,12 +112,6 @@ class TagBasedFiltering extends Plugin 'id' => 'settings-muting-note-tags', 'controller' => C\EditBlocked::editBlockedNoteTags($request), ]; - $tabs[] = [ - 'title' => 'Muted people tags', - 'desc' => 'Edit your muted people tags', - 'id' => 'settings-muting-actor-tags', - 'controller' => C\EditBlocked::editBlockedActorTags($request), - ]; } return Event::next; } diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index 1ddacc990a..ab9e157d7f 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -142,12 +142,6 @@ class Activity extends Entity return DB::findOneBy($this->getObjectType(), ['id' => $this->getObjectId()]); } - public function getNotificationTargetIdsFromActorTags(): array - { - $actor_circles = $this->getActor()->getActorCircles(); - return F\flat_map($actor_circles, fn ($circle) => $circle->getSubscribedActors()); - } - /** * Who should be notified about this object? * @@ -157,13 +151,6 @@ class Activity extends Entity { $target_ids = []; - // Actor Circles - if (\array_key_exists('actor_circle', $ids_already_known)) { - array_push($target_ids, ...$ids_already_known['actor_circle']); - } else { - array_push($target_ids, ...$this->getNotificationTargetIdsFromActorTags()); - } - // Notifications if (\array_key_exists('notification_activity', $ids_already_known)) { array_push($target_ids, ...$ids_already_known['notification_activity']); diff --git a/src/Entity/Actor.php b/src/Entity/Actor.php index 58a31ebe35..9b821b5973 100644 --- a/src/Entity/Actor.php +++ b/src/Entity/Actor.php @@ -257,7 +257,7 @@ class Actor extends Entity 'id' => "actor-id-{$actor_id}", 'nickname' => "actor-nickname-id-{$actor_id}", 'fullname' => "actor-fullname-id-{$actor_id}", - 'tags' => \is_null($other) ? "actor-tags-{$actor_id}" : "actor-tags-{$actor_id}-by-{$other}", // $other is $context_id + 'self-tags' => "actor-self-tags-{$actor_id}", 'circles' => "actor-circles-{$actor_id}", 'subscriber' => "subscriber-{$actor_id}", 'subscribed' => "subscribed-{$actor_id}", @@ -309,62 +309,20 @@ class Actor extends Entity } /** - * Tags attributed to self, shortcut function for increased legibility - * - * @return ActorTag[] resulting lists + * @return array ActorTag[] Self Tag Circles of which this actor is a member */ - public function getSelfTags(bool $_test_force_recompute = false): array + public function getSelfTags(): array { - return $this->getOtherTags(context: $this->getId(), _test_force_recompute: $_test_force_recompute); + return Cache::getList( + self::cacheKeys($this->getId())['self-tags'], + fn() => DB::findBy('actor_tag', ['tagger' => $this->getId(), 'tagged' => $this->getId()], order_by: ['modified' => 'DESC']), + ); } /** - * Get tags that other people put on this actor, in reverse-chron order - * - * @param null|Actor|int $context Actor we are requesting as: - * - If null = All tags attributed to self by other actors (excludes self tags) - * - If self = Same as getSelfTags - * - otherwise = Tags that $context attributed to $this - * @param null|int $offset Offset from latest - * @param null|int $limit Max number to get - * - * @return ActorTag[] resulting lists + * @return array ActorCircle[] */ - public function getOtherTags(self|int|null $context = null, ?int $offset = null, ?int $limit = null, bool $_test_force_recompute = false): array - { - if (\is_null($context)) { - return Cache::getList( - self::cacheKeys($this->getId())['tags'], - fn() => DB::dql( - <<< 'EOQ' - SELECT tag - FROM actor_tag tag - WHERE tag.tagged = :id - ORDER BY tag.modified DESC, tag.tagged DESC - EOQ, - ['id' => $this->getId()], - options: ['offset' => $offset, 'limit' => $limit], - ), - ); - } else { - $context_id = \is_int($context) ? $context : $context->getId(); - return Cache::getList( - self::cacheKeys($this->getId(), $context_id)['tags'], - fn() => DB::dql( - <<< 'EOQ' - SELECT tag - FROM actor_tag tag - WHERE tag.tagged = :tagged_id AND tag.tagger = :tagger_id - ORDER BY tag.modified DESC, tag.tagged DESC - EOQ, - ['tagged_id' => $this->getId(), 'tagger_id' => $context_id], - options: ['offset' => $offset, 'limit' => $limit], - ), - ); - } - } - - public function getActorCircles() + public function getCircles(): array { return Cache::getList( self::cacheKeys($this->getId())['circles'], @@ -410,6 +368,24 @@ class Actor extends Entity EOF, ['self' => $this->getId()]); } + public function getSubscriptionsUrl(): string + { + if ($this->getIsLocal()) { + return Router::url('actor_subscriptions_nickname', ['nickname' => $this->getNickname()]); + } else { + return Router::url('actor_subscriptions_id', ['id' => $this->getId()]); + } + } + + public function getSubscribersUrl(): string + { + if ($this->getIsLocal()) { + return Router::url('actor_subscribers_nickname', ['nickname' => $this->getNickname()]); + } else { + return Router::url('actor_subscribers_id', ['id' => $this->getId()]); + } + } + /** * Resolve an ambiguous nickname reference, checking in following order: * - Actors that $sender subscribes to @@ -428,9 +404,9 @@ class Actor extends Entity self::cacheKeys($this->getId(), $nickname)['relative-nickname'], fn () => DB::dql( <<<'EOF' - select a from actor a where - a.id in (select fa.subscribed_id from subscription fa join actor aa with fa.subscribed = aa.id where fa.subscriber = :actor_id and aa.nickname = :nickname) or - a.id in (select fb.subscriber_id from subscription fb join actor ab with fb.subscriber = ab.id where fb.subscribed = :actor_id and ab.nickname = :nickname) or + SELECT a FROM actor AS a WHERE + a.id IN (SELECT sa.subscribed_id FROM subscription sa JOIN actor aa WITH sa.subscribed_id = aa.id WHERE sa.subscriber_id = :actor_id AND aa.nickname = :nickname) OR + a.id IN (SELECT sb.subscriber_id FROM subscription sb JOIN actor ab WITH sb.subscriber_id = ab.id WHERE sb.subscribed_id = :actor_id AND ab.nickname = :nickname) OR a.nickname = :nickname EOF, ['nickname' => $nickname, 'actor_id' => $this->getId()],