diff --git a/components/Circle/Circle.php b/components/Circle/Circle.php new file mode 100644 index 0000000000..fda3713b38 --- /dev/null +++ b/components/Circle/Circle.php @@ -0,0 +1,223 @@ +. + +// }}} + +namespace Component\Circle; + +use App\Core\Cache; +use App\Core\DB\DB; +use App\Core\Event; +use function App\Core\I18n\_m; +use App\Core\Modules\Component; +use App\Core\Router\RouteLoader; +use App\Core\Router\Router; +use App\Entity\Actor; +use App\Entity\Feed; +use App\Entity\LocalUser; +use App\Util\Common; +use App\Util\Nickname; +use Component\Circle\Controller as CircleController; +use Component\Circle\Entity\ActorCircle; +use Component\Circle\Entity\ActorCircleSubscription; +use Component\Circle\Entity\ActorTag; +use Component\Collection\Util\MetaCollectionTrait; +use Component\Tag\Tag; +use Functional as F; +use Symfony\Component\HttpFoundation\Request; + +/** + * Component responsible for handling and representing ActorCircles and ActorTags + * + * @author Hugo Sales + * @author Phablulo + * @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 Circle extends Component +{ + use MetaCollectionTrait; + public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/'; + protected string $slug = 'circle'; + protected string $plural_slug = 'circles'; + + public function onAddRoute(RouteLoader $r): bool + { + $r->connect('actor_circle_view_by_circle_id', '/circle/{circle_id<\d+>}', [CircleController\Circle::class, 'circleById']); + // View circle members by (tagger id or nickname) and tag + $r->connect('actor_circle_view_by_circle_tagger_tag', '/circle/actor/{tagger_id<\d+>/{tag<' . Tag::TAG_SLUG_REGEX . '>}}', [CircleController\Circle::class, 'circleByTaggerIdAndTag']); + $r->connect('actor_circle_view_by_circle_tagger_tag', '/circle/@{nickname<' . Nickname::DISPLAY_FMT . '>}/{tag<' . Tag::TAG_SLUG_REGEX . '>}', [CircleController\Circle::class, 'circleByTaggerNicknameAndTag']); + + // View all circles by actor id or nickname + $r->connect( + id: 'actor_circles_view_by_actor_id', + uri_path: '/actor/{tag<' . Tag::TAG_SLUG_REGEX . '>}/circles', + target: [CircleController\Circles::class, 'collectionsViewByActorId'], + ); + $r->connect( + id: 'actor_circles_view_by_nickname', + uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/circles', + target: [CircleController\Circles::class, 'collectionsViewByActorNickname'], + ); + + $r->connect('actor_circle_view_feed_by_circle_id', '/circle/{circle_id<\d+>}/feed', [CircleController\Circles::class, 'feedByCircleId']); + // View circle feed by (tagger id or nickname) and tag + $r->connect('actor_circle_view_feed_by_circle_tagger_tag', '/circle/actor/{tagger_id<\d+>/{tag<' . Tag::TAG_SLUG_REGEX . '>}}/feed', [CircleController\Circles::class, 'feedByTaggerIdAndTag']); + $r->connect('actor_circle_view_feed_by_circle_tagger_tag', '/circle/@{nickname<' . Nickname::DISPLAY_FMT . '>}/{tag<' . Tag::TAG_SLUG_REGEX . '>}/feed', [CircleController\Circles::class, 'feedByTaggerNicknameAndTag']); + + return Event::next; + } + + public static function cacheKeys(string $tag_single_or_multi): array + { + return [ + 'actor_single' => "actor-tag-feed-{$tag_single_or_multi}", + 'actor_multi' => "actor-tags-feed-{$tag_single_or_multi}", + ]; + } + + public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool + { + if ($section === 'profile' && $request->get('_route') === 'settings') { + $tabs[] = [ + 'title' => 'Self tags', + 'desc' => 'Add or remove tags on yourself', + 'id' => 'settings-self-tags', + 'controller' => CircleController\SelfTagsSettings::settingsSelfTags($request, Common::actor(), 'settings-self-tags-details'), + ]; + } + return Event::next; + } + + public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): bool + { + $circles = $actor->getCircles(); + foreach ($circles as $circle) { + $tag = $circle->getTag(); + $targets["#{$tag}"] = $tag; + } + return Event::next; + } + + // Meta Collection ------------------------------------------------------------------- + + private function getActorIdFromVars(array $vars): int + { + $id = $vars['request']->get('id', null); + if ($id) { + return (int) $id; + } + $nick = $vars['request']->get('nickname'); + $user = LocalUser::getByNickname($nick); + return $user->getId(); + } + + public static function createCircle(Actor|int $tagger_id, string $tag): int + { + $tagger_id = \is_int($tagger_id) ? $tagger_id : $tagger_id->getId(); + $circle = ActorCircle::create([ + 'tagger' => $tagger_id, + 'tag' => $tag, + 'description' => null, // TODO + 'private' => false, // TODO + ]); + DB::persist($circle); + + Cache::delete(Actor::cacheKeys($tagger_id)['circles']); + + return $circle->getId(); + } + + protected function createCollection(Actor $owner, array $vars, string $name) + { + $this->createCircle($owner, $name); + DB::persist(ActorTag::create([ + 'tagger' => $owner->getId(), + 'tagged' => self::getActorIdFromVars($vars), + 'tag' => $name, + ])); + } + + protected function removeItems(Actor $owner, array $vars, $items, array $collections) + { + $tagger_id = $owner->getId(); + $tagged_id = $this->getActorIdFromVars($vars); + $circles_to_remove_tagged_from = DB::findBy(ActorCircle::class, ['id' => $items]); + foreach ($circles_to_remove_tagged_from as $circle) { + DB::removeBy(ActorCircleSubscription::class, ['actor_id' => $tagged_id, 'circle_id' => $circle->getId()]); + } + $tags = F\map($circles_to_remove_tagged_from, fn ($x) => $x->getTag()); + foreach ($tags as $tag) { + DB::removeBy(ActorTag::class, ['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]); + } + Cache::delete(Actor::cacheKeys($tagger_id)['circles']); + } + + protected function addItems(Actor $owner, array $vars, $items, array $collections) + { + $tagger_id = $owner->getId(); + $tagged_id = $this->getActorIdFromVars($vars); + $circles_to_add_tagged_to = DB::findBy(ActorCircle::class, ['id' => $items]); + foreach ($circles_to_add_tagged_to as $circle) { + DB::persist(ActorCircleSubscription::create(['actor_id' => $tagged_id, 'circle_id' => $circle->getId()])); + } + $tags = F\map($circles_to_add_tagged_to, fn ($x) => $x->getTag()); + foreach ($tags as $tag) { + DB::persist(ActorTag::create(['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag])); + } + Cache::delete(Actor::cacheKeys($tagger_id)['circles']); + } + + /** + * @see MetaCollectionPlugin->shouldAddToRightPanel + */ + protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool + { + return \in_array($vars['path'], ['actor_view_nickname', 'actor_view_id', 'group_actor_view_nickname', 'group_actor_view_id']); + } + + protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array + { + $tagged_id = !\is_null($vars) ? $this->getActorIdFromVars($vars) : null; + $circles = \is_null($tagged_id) ? $owner->getCircles() : F\select($owner->getCircles(), function ($x) use ($tagged_id) { + foreach ($x->getActorTags() as $at) { + if ($at->getTagged() === $tagged_id) { + return true; + } + } + return false; + }); + return $ids_only ? array_map(fn ($x) => $x->getId(), $circles) : $circles; + } + + public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering) + { + DB::persist(Feed::create([ + 'actor_id' => $actor_id, + 'url' => Router::url($route = 'actor_circles_view_by_nickname', ['nickname' => $user->getNickname()]), + 'route' => $route, + 'title' => _m('Circles'), + 'ordering' => $ordering++, + ])); + return Event::next; + } +} diff --git a/components/Circle/Controller/Circle.php b/components/Circle/Controller/Circle.php new file mode 100644 index 0000000000..05d22b83b6 --- /dev/null +++ b/components/Circle/Controller/Circle.php @@ -0,0 +1,61 @@ +. + +// }}} + +namespace Component\Circle\Controller; + +use function App\Core\I18n\_m; +use App\Entity\LocalUser; +use App\Util\Exception\ClientException; +use Component\Circle\Entity\ActorCircle; +use Component\Collection\Util\Controller\CircleController; + +class Circle extends CircleController +{ + public function circleById(int|ActorCircle $circle_id): array + { + $circle = \is_int($circle_id) ? ActorCircle::getByPK(['id' => $circle_id]) : $circle_id; + unset($circle_id); + if (\is_null($circle)) { + throw new ClientException(_m('No such circle.'), 404); + } else { + return [ + '_template' => 'collection/actors.html.twig', + 'title' => _m('Circle'), + 'empty_message' => _m('No members.'), + 'sort_form_fields' => [], + 'page' => $this->int('page') ?? 1, + 'actors' => $circle->getTaggedActors(), + ]; + } + } + + public function circleByTaggerIdAndTag(int $tagger_id, string $tag): array + { + return $this->circleById(ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])); + } + + public function circleByTaggerNicknameAndTag(string $tagger_nickname, string $tag): array + { + return $this->circleById(ActorCircle::getByPK(['tagger' => LocalUser::getByNickname($tagger_nickname)->getId(), 'tag' => $tag])); + } +} diff --git a/components/Circle/Controller/Circles.php b/components/Circle/Controller/Circles.php new file mode 100644 index 0000000000..6b316c49b8 --- /dev/null +++ b/components/Circle/Controller/Circles.php @@ -0,0 +1,107 @@ +. + +// }}} + +namespace Component\Circle\Controller; + +use App\Core\Cache; +use App\Core\DB\DB; +use App\Core\Router\Router; +use App\Entity\Actor; +use App\Entity\LocalUser; +use Component\Circle\Entity\ActorCircle; +use Component\Collection\Util\Controller\MetaCollectionController; + +class Circles extends MetaCollectionController +{ + protected string $slug = 'circle'; + protected string $plural_slug = 'circles'; + protected string $page_title = 'Actor circles'; + + public function createCollection(int $owner_id, string $name) + { + return \Component\Circle\Circle::createCircle($owner_id, $name); + } + public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string + { + return Router::url( + 'actor_circle_view_by_circle_id', + ['circle_id' => $collection_id], + ); + } + + public function getCollectionItems(int $owner_id, $collection_id): array + { + $notes = []; // TODO: Use Feed::query + return [ + '_template' => 'collection/notes.html.twig', + 'notes' => $notes, + ]; + } + + public function feedByCircleId(int $circle_id) + { + // Owner id isn't used + return $this->getCollectionItems(0, $circle_id); + } + + public function feedByTaggerIdAndTag(int $tagger_id, string $tag) + { + // Owner id isn't used + $circle_id = ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])->getId(); + return $this->getCollectionItems($tagger_id, $circle_id); + } + + public function feedByTaggerNicknameAndTag(string $tagger_nickname, string $tag) + { + $tagger_id = LocalUser::getByNickname($tagger_nickname)->getId(); + $circle_id = ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])->getId(); + return $this->getCollectionItems($tagger_id, $circle_id); + } + + public function getCollectionsByActorId(int $owner_id): array + { + return DB::findBy(ActorCircle::class, ['tagger' => $owner_id], order_by: ['id' => 'desc']); + } + public function getCollectionBy(int $owner_id, int $collection_id): ActorCircle + { + return DB::findOneBy(ActorCircle::class, ['id' => $collection_id, 'actor_id' => $owner_id]); + } + + public function setCollectionName(int $actor_id, string $actor_nickname, ActorCircle $collection, string $name) + { + foreach ($collection->getActorTags(db_reference: true) as $at) { + $at->setTag($name); + } + $collection->setTag($name); + Cache::delete(Actor::cacheKeys($actor_id)['circles']); + } + + public function removeCollection(int $actor_id, string $actor_nickname, ActorCircle $collection) + { + foreach ($collection->getActorTags(db_reference: true) as $at) { + DB::remove($at); + } + DB::remove($collection); + Cache::delete(Actor::cacheKeys($actor_id)['circles']); + } +} diff --git a/components/Circle/Controller/SelfTagsSettings.php b/components/Circle/Controller/SelfTagsSettings.php new file mode 100644 index 0000000000..bc8aa5293d --- /dev/null +++ b/components/Circle/Controller/SelfTagsSettings.php @@ -0,0 +1,117 @@ +canAdmin($target)) { + throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()])); + } + + $actor_self_tags = $target->getSelfTags(); + [$add_form, $existing_form] = SelfTagsForm::handleTags( + $request, + $actor_self_tags, + handle_new: /** + * Handle adding tags + */ + function ($form) use ($request, $target, $details_id) { + $data = $form->getData(); + $tags = $data['new-tags']; + foreach ($tags as $tag) { + $tag = CompTag::sanitize($tag); + + [$actor_tag, $actor_tag_existed] = ActorTag::createOrUpdate([ + 'tagger' => $target->getId(), // self tag means tagger = tagger in ActorTag + 'tagged' => $target->getId(), + 'tag' => $tag, + ]); + if (!$actor_tag_existed) { + DB::persist($actor_tag); + // Try to find the self-tag circle + $actor_circle = DB::findOneBy( + ActorCircle::class, + [ + 'tagger' => null, // Self-tag circle + 'tag' => $tag, + ], + return_null: true, + ); + // It is the first time someone uses this self-tag! + if (\is_null($actor_circle)) { + DB::persist(ActorCircle::create([ + 'tagger' => null, // Self-tag circle + 'tag' => $tag, + 'private' => false, // by definition + 'description' => null, // The controller can show this in every language as appropriate + ])); + } + } + } + DB::flush(); + Cache::delete(E\Actor::cacheKeys($target->getId())['self-tags']); + throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id, '_fragment' => $details_id]); + }, + handle_existing: /** + * Handle changes to the existing tags + */ + function ($form, array $form_definition) use ($request, $target, $details_id) { + $changed = false; + foreach (array_chunk($form_definition, 2) as $entry) { + $tag = CompTag::sanitize($entry[0][2]['data']); + + /** @var SubmitButton $remove */ + $remove = $form->get($entry[1][0]); + if ($remove->isClicked()) { + $changed = true; + DB::removeBy( + 'actor_tag', + [ + 'tagger' => $target->getId(), + 'tagged' => $target->getId(), + 'tag' => $tag, + ], + ); + // We intentionally leave the self-tag actor circle, even if it is now empty + } + } + if ($changed) { + DB::flush(); + Cache::delete(E\Actor::cacheKeys($target->getId())['self-tags']); + throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id, '_fragment' => $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/ActorCircle.php b/components/Circle/Entity/ActorCircle.php similarity index 52% rename from src/Entity/ActorCircle.php rename to components/Circle/Entity/ActorCircle.php index 507db766d0..30fd5a1b29 100644 --- a/src/Entity/ActorCircle.php +++ b/components/Circle/Entity/ActorCircle.php @@ -19,7 +19,7 @@ declare(strict_types = 1); // along with GNU social. If not, see . // }}} -namespace App\Entity; +namespace Component\Circle\Entity; use App\Core\Cache; use App\Core\DB\DB; @@ -49,14 +49,12 @@ class ActorCircle extends Entity // {{{ Autocode // @codeCoverageIgnoreStart private int $id; - private ?int $tagger = null; - private int $tagged; + private ?int $tagger = null; // If null, is the special global self-tag circle private string $tag; - private bool $use_canonical; private ?string $description = null; - private ?bool $private = false; - private \DateTimeInterface $created; - private \DateTimeInterface $modified; + private ?bool $private = false; + private DateTimeInterface $created; + private DateTimeInterface $modified; public function setId(int $id): self { @@ -80,20 +78,9 @@ class ActorCircle extends Entity return $this->tagger; } - public function setTagged(int $tagged): self - { - $this->tagged = $tagged; - return $this; - } - - public function getTagged(): int - { - return $this->tagged; - } - public function setTag(string $tag): self { - $this->tag = \mb_substr($tag, 0, 64); + $this->tag = mb_substr($tag, 0, 64); return $this; } @@ -102,17 +89,6 @@ class ActorCircle extends Entity return $this->tag; } - public function setUseCanonical(bool $use_canonical): self - { - $this->use_canonical = $use_canonical; - return $this; - } - - public function getUseCanonical(): bool - { - return $this->use_canonical; - } - public function setDescription(?string $description): self { $this->description = $description; @@ -135,24 +111,24 @@ class ActorCircle extends Entity return $this->private; } - 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; } @@ -160,64 +136,86 @@ class ActorCircle extends Entity // @codeCoverageIgnoreEnd // }}} Autocode - public function getActorTag() + /** + * For use with MetaCollection trait only + */ + public function getName(): string + { + return $this->tag; + } + + public function getActorTags(bool $db_reference = false): array + { + $handle = fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]); + if ($db_reference) { + return $handle(); + } + return Cache::get( + "circle-{$this->getId()}-tagged", + $handle, + ); + } + + public function getTaggedActors() { return Cache::get( - "actor-tag-{$this->getTag()}", - fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'canonical' => $this->getTag()], limit: 1)[0], // TODO jank + "circle-{$this->getId()}-tagged-actors", + function () { + if ($this->getTagger()) { + return DB::dql('SELECT a FROM actor AS a JOIN actor_tag AS at WITH at.tagged = a.id WHERE at.tag = :tag AND at.tagger = :tagger', ['tag' => $this->getTag(), 'tagger' => $this->getTagger()]); + } else { // Self-tag + return DB::dql('SELECT a FROM actor AS a JOIN actor_tag AS at WITH at.tagged = a.id WHERE at.tag = :tag AND at.tagger = at.tagged', ['tag' => $this->getTag()]); + } + }, ); } public function getSubscribedActors(?int $offset = null, ?int $limit = null): array { return Cache::get( - "circle-{$this->getId()}", + "circle-{$this->getId()}-subscribers", fn () => DB::dql( <<< 'EOQ' SELECT a - FROM App\Entity\Actor a - JOIN App\Entity\ActorCircleSubscription s + FROM actor a + JOIN actor_circle_subscription s WITH a.id = s.actor_id ORDER BY s.created DESC, a.id DESC EOQ, - options: ['offset' => $offset, - 'limit' => $limit, ], + options: [ + 'offset' => $offset, + 'limit' => $limit, + ], ), ); } - public function getUrl(int $type = Router::ABSOLUTE_PATH): string { - return Router::url('actor_circle', ['actor_id' => $this->getTagger(), 'tag' => $this->getTag()]); + public function getUrl(int $type = Router::ABSOLUTE_PATH): string + { + return Router::url('actor_circle_view_by_circle_id', ['circle_id' => $this->getId()], type: $type); } public static function schemaDef(): array { return [ 'name' => 'actor_circle', - 'description' => 'a actor can have lists of actors, to separate their feed', + 'description' => 'An actor can have lists of actors, to separate their feed or quickly mention his friend', 'fields' => [ - 'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], // An actor can be tagged by many actors - 'tagger' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'actor_list_tagger_fkey', 'description' => 'user making the tag'], - 'tagged' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagged_fkey', 'not null' => true, 'description' => 'actor tagged'], - 'tag' => ['type' => 'varchar', 'length' => 64, 'foreign key' => true, 'target' => 'ActorTag.tag', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'actor tag'], // Join with ActorTag // // so, Doctrine doesn't like that the target is not unique, even though the pair is // Many Actor Circles can reference (and probably will) an Actor Tag - 'use_canonical' => ['type' => 'bool', 'not null' => true, 'description' => 'whether the user wanted to block canonical tags'], - 'description' => ['type' => 'text', 'description' => 'description of the people tag'], - 'private' => ['type' => 'bool', 'default' => false, 'description' => 'is this tag private'], - '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'], + 'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], // An actor can be tagged by many actors + 'tagger' => ['type' => 'int', 'default' => null, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'actor_list_tagger_fkey', 'description' => 'user making the tag, null if self-tag'], + 'tag' => ['type' => 'varchar', 'length' => 64, 'foreign key' => true, 'target' => 'ActorTag.tag', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'actor tag'], // Join with ActorTag // // so, Doctrine doesn't like that the target is not unique, even though the pair is // Many Actor Circles can reference (and probably will) an Actor Tag + 'description' => ['type' => 'text', 'description' => 'description of the people tag'], + 'private' => ['type' => 'bool', 'default' => false, 'description' => 'is this tag private'], + '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'], + 'primary key' => ['id'], // But we will mostly refer to them with `tagger` and `tag` 'indexes' => [ 'actor_list_modified_idx' => ['modified'], + 'actor_list_tagger_tag_idx' => ['tagger', 'tag'], // The actual identifier we will use the most 'actor_list_tag_idx' => ['tag'], - 'actor_list_tagger_tag_idx' => ['tagger', 'tag'], 'actor_list_tagger_idx' => ['tagger'], ], ]; } - - public function __toString() - { - return $this->getTag(); - } } diff --git a/src/Entity/ActorCircleSubscription.php b/components/Circle/Entity/ActorCircleSubscription.php similarity index 80% rename from src/Entity/ActorCircleSubscription.php rename to components/Circle/Entity/ActorCircleSubscription.php index 539e9cc419..549a7016f0 100644 --- a/src/Entity/ActorCircleSubscription.php +++ b/components/Circle/Entity/ActorCircleSubscription.php @@ -1,5 +1,7 @@ . // }}} -namespace App\Entity; +namespace Component\Circle\Entity; use App\Core\Entity; use DateTimeInterface; @@ -45,8 +47,8 @@ class ActorCircleSubscription extends Entity // @codeCoverageIgnoreStart private int $actor_id; private int $circle_id; - private \DateTimeInterface $created; - private \DateTimeInterface $modified; + private DateTimeInterface $created; + private DateTimeInterface $modified; public function setActorId(int $actor_id): self { @@ -70,24 +72,24 @@ class ActorCircleSubscription extends Entity return $this->circle_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; } - 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; } @@ -98,18 +100,18 @@ class ActorCircleSubscription extends Entity public static function schemaDef(): array { return [ - 'name' => 'actor_circle_subscription', + 'name' => 'actor_circle_subscription', 'fields' => [ - 'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_circle_subscription_actor_id_fkey', 'not null' => true, 'description' => 'foreign key to actor table'], + 'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_circle_subscription_actor_id_fkey', 'not null' => true, 'description' => 'foreign key to actor table'], // An actor subscribes many circles; A Circle is subscribed by many actors. 'circle_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'ActorCircle.id', 'multiplicity' => 'one to many', 'name' => 'actor_circle_subscription_actor_circle_fkey', 'not null' => true, 'description' => 'foreign key to actor_circle'], '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' => ['circle_id', 'actor_id'], - 'indexes' => [ + 'indexes' => [ 'actor_circle_subscription_actor_id_idx' => ['actor_id'], - 'actor_circle_subscription_created_idx' => ['created'], + 'actor_circle_subscription_created_idx' => ['created'], ], ]; } diff --git a/src/Entity/ActorTag.php b/components/Circle/Entity/ActorTag.php similarity index 52% rename from src/Entity/ActorTag.php rename to components/Circle/Entity/ActorTag.php index 66e84da6c4..190958a8d9 100644 --- a/src/Entity/ActorTag.php +++ b/components/Circle/Entity/ActorTag.php @@ -19,12 +19,12 @@ declare(strict_types = 1); // along with GNU social. If not, see . // }}} -namespace App\Entity; +namespace Component\Circle\Entity; -use App\Core\Cache; use App\Core\DB\DB; use App\Core\Entity; use App\Core\Router\Router; +use App\Entity\Actor; use Component\Tag\Tag; use DateTimeInterface; @@ -54,9 +54,7 @@ class ActorTag extends Entity private int $tagger; private int $tagged; private string $tag; - private string $canonical; - private bool $use_canonical; - private \DateTimeInterface $modified; + private DateTimeInterface $modified; public function setTagger(int $tagger): self { @@ -82,7 +80,7 @@ class ActorTag extends Entity public function setTag(string $tag): self { - $this->tag = \mb_substr($tag, 0, 64); + $this->tag = mb_substr($tag, 0, 64); return $this; } @@ -91,35 +89,13 @@ class ActorTag extends Entity return $this->tag; } - public function setCanonical(string $canonical): self - { - $this->canonical = \mb_substr($canonical, 0, 64); - return $this; - } - - public function getCanonical(): string - { - return $this->canonical; - } - - public function setUseCanonical(bool $use_canonical): self - { - $this->use_canonical = $use_canonical; - return $this; - } - - public function getUseCanonical(): bool - { - 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; } @@ -127,18 +103,22 @@ class ActorTag extends Entity // @codeCoverageIgnoreEnd // }}} Autocode - public static function getByActorId(int $actor_id): array + public function getUrl(?Actor $actor = null, int $type = Router::ABSOLUTE_PATH): string { - return Cache::getList(Actor::cacheKeys($actor_id)['tags'], fn () => DB::dql('select at from actor_tag at join actor a with a.id = at.tagger where a.id = :id', ['id' => $actor_id])); + $params = ['tag' => $this->getTag()]; + if (!\is_null($actor)) { + $params['locale'] = $actor->getTopLanguage()->getLocale(); + } + return Router::url('single_actor_tag', $params, type: $type); } - public function getUrl(?Actor $actor = null): string + public function getCircle(): ActorCircle { - $params = ['canon' => $this->getCanonical(), 'tag' => $this->getTag()]; - if (!\is_null($actor)) { - $params['lang'] = $actor->getTopLanguage()->getLocale(); + if ($this->getTagger() === $this->getTagged()) { // Self-tag + return DB::findOneBy(ActorCircle::class, ['tagger' => null, 'tag' => $this->getTag()]); + } else { + return DB::findOneBy(ActorCircle::class, ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]); } - return Router::url('single_actor_tag', $params); } public static function schemaDef(): array @@ -146,24 +126,16 @@ class ActorTag extends Entity return [ 'name' => 'actor_tag', 'fields' => [ - 'tagger' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagger_fkey', 'not null' => true, 'description' => 'actor making the tag'], - 'tagged' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagged_fkey', 'not null' => true, 'description' => 'actor tagged'], - 'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hash tag associated with this actor'], - 'canonical' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'ascii slug of tag'], - 'use_canonical' => ['type' => 'bool', 'not null' => true, 'description' => 'whether the user wanted to block canonical tags'], - 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], - ], - 'primary key' => ['tagger', 'tagged', 'tag', 'use_canonical'], + 'tagger' => ['type' => 'int', 'not null' => true, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagger_fkey', 'description' => 'actor making the tag'], + 'tagged' => ['type' => 'int', 'not null' => true, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagged_fkey', 'description' => 'actor tagged'], + 'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hashtag associated with this note'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], + ], // We will always assume the tagger's preferred language for tags and circles + 'primary key' => ['tagger', 'tagged', 'tag'], 'indexes' => [ - 'actor_tag_modified_idx' => ['modified'], 'actor_tag_tagger_tag_idx' => ['tagger', 'tag'], // For Circles 'actor_tag_tagged_idx' => ['tagged'], ], ]; } - - public function __toString(): string - { - return $this->getTag(); - } } diff --git a/components/Circle/Form/SelfTagsForm.php b/components/Circle/Form/SelfTagsForm.php new file mode 100644 index 0000000000..b31bbad9ce --- /dev/null +++ b/components/Circle/Form/SelfTagsForm.php @@ -0,0 +1,57 @@ +getTag(); + $form_definition[] = ["{$tag}:old-tag", TextType::class, ['data' => $tag, 'label' => ' ', 'disabled' => true]]; + $form_definition[] = [$existing_form_name = "{$tag}: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]], + [$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/Group/Group.php b/components/Group/Group.php index 61a9b31ac9..692635aebb 100644 --- a/components/Group/Group.php +++ b/components/Group/Group.php @@ -30,9 +30,9 @@ use App\Entity\Actor; use App\Util\Common; use App\Util\HTML; use App\Util\Nickname; +use Component\Circle\Controller\SelfTagsSettings; use Component\Group\Controller as C; use Component\Group\Entity\LocalGroup; -use Component\Tag\Controller\Tag as TagController; use Symfony\Component\HttpFoundation\Request; class Group extends Component @@ -68,7 +68,7 @@ class Group extends Component 'title' => 'Self tags', 'desc' => 'Add or remove tags on this group', 'id' => 'settings-self-tags', - 'controller' => TagController::settingsSelfTags($request, $group, 'settings-self-tags-details'), + 'controller' => SelfTagsSettings::settingsSelfTags($request, $group, 'settings-self-tags-details'), ]; } return Event::next; diff --git a/plugins/ActorCircles/ActorCircles.php b/plugins/ActorCircles/ActorCircles.php deleted file mode 100644 index 5788e4efac..0000000000 --- a/plugins/ActorCircles/ActorCircles.php +++ /dev/null @@ -1,181 +0,0 @@ -. -// }}} -/** - * Actor Circles for GNU social - * - * @package GNUsocial - * @category Plugin - * - * @author Phablulo - * @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org - * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later - */ - -namespace Plugin\ActorCircles; - -use App\Core\DB\DB; -use App\Core\Event; -use function App\Core\I18n\_m; -use App\Core\Router\RouteLoader; -use App\Core\Router\Router; -use App\Entity\Actor; -use App\Entity\Feed; -use App\Entity\LocalUser; -use App\Util\Nickname; -use Component\Collection\Util\MetaCollectionPlugin; -use Plugin\ActorCircles\Controller as C; -use Plugin\ActorCircles\Entity as E; -use Symfony\Component\HttpFoundation\Request; - -class ActorCircles extends MetaCollectionPlugin -{ - protected string $slug = 'circle'; - protected string $plural_slug = 'circles'; - - private function getActorIdFromVars(array $vars): int - { - $id = $vars['request']->get('id', null); - if ($id) { - return (int) $id; - } - $nick = $vars['request']->get('nickname'); - $actor = DB::findOneBy(Actor::class, ['nickname' => $nick]); - return $actor->getId(); - } - - protected function createCollection(Actor $owner, array $vars, string $name) - { - $actor_id = $this->getActorIdFromVars($vars); - $col = E\ActorCircles::create([ - 'name' => $name, - 'actor_id' => $owner->getId(), - ]); - DB::persist($col); - DB::persist(E\ActorCirclesEntry::create([ - 'actor_id' => $actor_id, - 'circle_id' => $col->getId(), - ])); - } - - protected function removeItems(Actor $owner, array $vars, $items, array $collections) - { - $actor_id = $this->getActorIdFromVars($vars); - // can only delete what you own - $items = array_filter($items, fn ($x) => \in_array($x, $collections)); - DB::dql(<<<'EOF' - DELETE FROM \Plugin\ActorCircles\Entity\ActorCirclesEntry AS entry - WHERE entry.actor_id = :actor_id AND entry.circle_id IN (:ids) - EOF, [ - 'actor_id' => $actor_id, - 'ids' => $items, - ]); - } - - protected function addItems(Actor $owner, array $vars, $items, array $collections) - { - $actor_id = $this->getActorIdFromVars($vars); - foreach ($items as $id) { - // prevent user from putting something in a collection (s)he doesn't own: - if (\in_array($id, $collections)) { - DB::persist(E\ActorCirclesEntry::create([ - 'actor_id' => $actor_id, - 'circle_id' => $id, - ])); - } - } - } - - /** - * @see MetaCollectionPlugin->shouldAddToRightPanel - */ - protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool - { - return - $vars['path'] === 'actor_view_nickname' - || $vars['path'] === 'actor_view_id' - || $vars['path'] === 'group_actor_view_nickname' - || $vars['path'] === 'group_actor_view_id'; - } - - protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array - { - if (\is_null($vars)) { - $res = DB::findBy(E\ActorCircles::class, ['actor_id' => $owner->getId()]); - } else { - $actor_id = $this->getActorIdFromVars($vars); - $res = DB::dql( - <<<'EOF' - SELECT entry.circle_id FROM \Plugin\ActorCircles\Entity\ActorCirclesEntry AS entry - INNER JOIN \Plugin\ActorCircles\Entity\ActorCircles AS circle - WITH circle.id = entry.circle_id - WHERE circle.actor_id = :owner_id AND entry.actor_id = :actor_id - EOF, - [ - 'owner_id' => $owner->getId(), - 'actor_id' => $actor_id, - ], - ); - } - if (!$ids_only) { - return $res; - } - return array_map(fn ($x) => $x['circle_id'], $res); - } - - public function onAddRoute(RouteLoader $r): bool - { - // View all circles by actor id and nickname - $r->connect( - id: 'actor_circles_view_by_actor_id', - uri_path: '/actor/{id<\d+>}/circles', - target: [C\Circles::class, 'collectionsViewByActorId'], - ); - $r->connect( - id: 'actor_circles_view_by_nickname', - uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/circles', - target: [C\Circles::class, 'collectionsViewByActorNickname'], - ); - // View notes from a circle by actor id and nickname - $r->connect( - id: 'actor_circles_notes_view_by_actor_id', - uri_path: '/actor/{id<\d+>}/circles/{cid<\d+>}', - target: [C\Circles::class, 'collectionsEntryViewNotesByActorId'], - ); - $r->connect( - id: 'actor_circles_notes_view_by_nickname', - uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/circles/{cid<\d+>}', - target: [C\Circles::class, 'collectionsEntryViewNotesByNickname'], - ); - return Event::next; - } - - public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering) - { - DB::persist(Feed::create([ - 'actor_id' => $actor_id, - 'url' => Router::url($route = 'actor_circles_view_by_nickname', ['nickname' => $user->getNickname()]), - 'route' => $route, - 'title' => _m('Circles'), - 'ordering' => $ordering++, - ])); - return Event::next; - } -} diff --git a/plugins/ActorCircles/Controller/Circles.php b/plugins/ActorCircles/Controller/Circles.php deleted file mode 100644 index 5f86eafccc..0000000000 --- a/plugins/ActorCircles/Controller/Circles.php +++ /dev/null @@ -1,84 +0,0 @@ -. - -// }}} - -namespace Plugin\ActorCircles\Controller; - -use App\Core\DB\DB; -use App\Core\Router\Router; -use Component\Collection\Util\Controller\MetaCollectionController; -use Plugin\ActorCircles\Entity\ActorCircles; - -class Circles extends MetaCollectionController -{ - protected string $slug = 'circle'; - protected string $plural_slug = 'circles'; - protected string $page_title = 'Actor circles'; - - public function createCollection(int $owner_id, string $name) - { - DB::persist(ActorCircles::create([ - 'name' => $name, - 'actor_id' => $owner_id, - ])); - } - public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string - { - if (\is_null($owner_nickname)) { - return Router::url( - 'actor_circles_notes_view_by_actor_id', - ['id' => $owner_id, 'cid' => $collection_id], - ); - } - return Router::url( - 'actor_circles_notes_view_by_nickname', - ['nickname' => $owner_nickname, 'cid' => $collection_id], - ); - } - public function getCollectionItems(int $owner_id, $collection_id): array - { - $notes = DB::dql( - <<<'EOF' - SELECT n FROM \App\Entity\Note as n WHERE n.actor_id in ( - SELECT entry.actor_id FROM \Plugin\ActorCircles\Entity\ActorCirclesEntry as entry - inner join \Plugin\ActorCircles\Entity\ActorCircles as ac - with ac.id = entry.circle_id - WHERE ac.id = :circle_id - ) - ORDER BY n.created DESC, n.id DESC - EOF, - ['circle_id' => $collection_id], - ); - return [ - '_template' => 'collection/notes.html.twig', - 'notes' => array_values($notes), - ]; - } - public function getCollectionsByActorId(int $owner_id): array - { - return DB::findBy(ActorCircles::class, ['actor_id' => $owner_id], order_by: ['id' => 'desc']); - } - public function getCollectionBy(int $owner_id, int $collection_id): ActorCircles - { - return DB::findOneBy(ActorCircles::class, ['id' => $collection_id, 'actor_id' => $owner_id]); - } -} diff --git a/plugins/ActorCircles/Entity/ActorCircles.php b/plugins/ActorCircles/Entity/ActorCircles.php deleted file mode 100644 index 592d9926b7..0000000000 --- a/plugins/ActorCircles/Entity/ActorCircles.php +++ /dev/null @@ -1,65 +0,0 @@ -id = $id; - return $this; - } - - public function getId(): int - { - return $this->id; - } - - public function setName(string $name): self - { - $this->name = mb_substr($name, 0, 255); - return $this; - } - - public function getName(): string - { - return $this->name; - } - - public function setActorId(int $actor_id): self - { - $this->actor_id = $actor_id; - return $this; - } - - public function getActorId(): int - { - return $this->actor_id; - } - - // @codeCoverageIgnoreEnd - // }}} Autocode - public static function schemaDef() - { - return [ - 'name' => 'actor_circles_a', - 'fields' => [ - 'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], - 'name' => ['type' => 'varchar', 'length' => 255, 'not null' => true, 'description' => 'collection\'s name'], - 'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to many', 'not null' => true, 'description' => 'foreign key to actor table'], - ], - 'primary key' => ['id'], - ]; - } -} diff --git a/plugins/ActorCircles/Entity/ActorCirclesEntry.php b/plugins/ActorCircles/Entity/ActorCirclesEntry.php deleted file mode 100644 index 859d10f4f1..0000000000 --- a/plugins/ActorCircles/Entity/ActorCirclesEntry.php +++ /dev/null @@ -1,66 +0,0 @@ -id = $id; - return $this; - } - - public function getId(): int - { - return $this->id; - } - - public function setActorId(int $actor_id): self - { - $this->actor_id = $actor_id; - return $this; - } - - public function getActorId(): int - { - return $this->actor_id; - } - - public function setCircleId(int $circle_id): self - { - $this->circle_id = $circle_id; - return $this; - } - - public function getCircleId(): int - { - return $this->circle_id; - } - - // @codeCoverageIgnoreEnd - // }}} Autocode - - public static function schemaDef() - { - return [ - 'name' => 'actor_circles_entry', - 'fields' => [ - 'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], - 'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to attachment table'], - 'circle_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'actor_circles_a.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to collection table'], - ], - 'primary key' => ['id'], - ]; - } -} diff --git a/src/Entity/ActorTagBlock.php b/src/Entity/ActorTagBlock.php deleted file mode 100644 index 70ddd5f53b..0000000000 --- a/src/Entity/ActorTagBlock.php +++ /dev/null @@ -1,142 +0,0 @@ -. -// }}} - -namespace App\Entity; - -use App\Core\Cache; -use App\Core\DB\DB; -use App\Core\Entity; -use Component\Tag\Tag; -use DateTimeInterface; -use Functional as F; - -/** - * Entity for User's Note Tag block - * - * @category DB - * @package GNUsocial - * - * @author Hugo Sales - * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org - * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later - */ -class ActorTagBlock extends Entity -{ - // {{{ Autocode - // @codeCoverageIgnoreStart - private int $blocker; - private string $tag; - private string $canonical; - private bool $use_canonical; - private \DateTimeInterface $modified; - - public function setBlocker(int $blocker): self - { - $this->blocker = $blocker; - return $this; - } - - public function getBlocker(): int - { - return $this->blocker; - } - - public function setTag(string $tag): self - { - $this->tag = \mb_substr($tag, 0, 64); - return $this; - } - - public function getTag(): string - { - return $this->tag; - } - - public function setCanonical(string $canonical): self - { - $this->canonical = \mb_substr($canonical, 0, 64); - return $this; - } - - public function getCanonical(): string - { - return $this->canonical; - } - - public function setUseCanonical(bool $use_canonical): self - { - $this->use_canonical = $use_canonical; - return $this; - } - - public function getUseCanonical(): bool - { - return $this->use_canonical; - } - - public function setModified(\DateTimeInterface $modified): self - { - $this->modified = $modified; - return $this; - } - - public function getModified(): \DateTimeInterface - { - return $this->modified; - } - - // @codeCoverageIgnoreEnd - // }}} Autocode - - public static function cacheKey(int $actor_id) - { - return "actor-tag-blocks-{$actor_id}"; - } - - public static function getByActorId(int $actor_id) - { - return Cache::getList(self::cacheKey($actor_id), fn () => DB::findBy('actor_tag_block', ['blocker' => $actor_id])); - } - - /** - * Check whether $actor_tag is considered blocked by one of - * $actor_tag_blocks - */ - public static function checkBlocksActorTag(ActorTag $actor_tag, array $actor_tag_blocks): bool - { - return F\some($actor_tag_blocks, fn ($ntb) => ($ntb->getUseCanonical() && $actor_tag->getCanonical() === $ntb->getCanonical()) || $actor_tag->getTag() === $ntb->getTag()); - } - - public static function schemaDef(): array - { - return [ - 'name' => 'actor_tag_block', - 'fields' => [ - 'blocker' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'actor_block_blocker_fkey', 'not null' => true, 'description' => 'user making the block'], - 'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hash tag this is blocking'], - 'canonical' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'foreign key' => true, 'target' => 'NoteTag.canonical', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'ascii slug of tag'], - 'use_canonical' => ['type' => 'bool', 'not null' => true, 'description' => 'whether the user wanted to block canonical tags'], - 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], - ], - 'primary key' => ['blocker', 'canonical'], - ]; - } -} diff --git a/src/Util/Formatting.php b/src/Util/Formatting.php index b66757c9e7..757595acf7 100644 --- a/src/Util/Formatting.php +++ b/src/Util/Formatting.php @@ -32,19 +32,20 @@ declare(strict_types = 1); namespace App\Util; +use App\Core\DB\DB; use App\Core\Event; use App\Core\Log; use App\Entity\Actor; use App\Entity\Note; use App\Util\Exception\NicknameException; use App\Util\Exception\ServerException; +use Component\Circle\Circle; +use Component\Circle\Entity\ActorCircle; use Component\Group\Entity\LocalGroup; use Component\Tag\Tag; use Exception; use Functional as F; use InvalidArgumentException; -use App\Core\DB\DB; -use App\Entity\ActorCircle; abstract class Formatting { @@ -298,7 +299,7 @@ abstract class Formatting $text = str_replace('', '', $text); if (Event::handle('StartFindMentions', [$actor, $text, &$mentions])) { - // Person mentions + // @person mentions $person_matches = self::findMentionsRaw($text, '@'); foreach ($person_matches as $match) { try { @@ -330,45 +331,39 @@ abstract class Formatting } } - @#/tag - // TODO Tag subscriptions - // @#tag => mention of all subscriptions tagged 'tag' + // @#circle/self-tag => mention of all subscribed circles tagged 'tag' $tag_matches = []; preg_match_all( - Tag::TAG_CIRCLE_REGEX, + Circle::TAG_CIRCLE_REGEX, $text, $tag_matches, - PREG_OFFSET_CAPTURE + \PREG_OFFSET_CAPTURE, ); foreach ($tag_matches[1] as $tag_match) { - $tag = Tag::ensureValid($tag_match[0]); - $ac = DB::findOneBy(ActorCircle::class, [ - 'or' => [ - 'tagger' => $actor->getID(), - 'and' => [ - 'tagger' => null, - 'tagged' => $actor->getID(), - ] - ], - 'tag' => $tag, + $tag = Tag::extract($tag_match[0]); + if (!Tag::validate($tag)) { + continue; // Ignore invalid tags + } + $ac = DB::findOneBy(ActorCircle::class, [ + 'tag' => $tag, // Notify circle of name tag WHERE + 'tagger' => $actor->getID(), // Circle was created by Actor ], return_null: true); if (\is_null($ac) || $ac->getPrivate()) { continue; } - $tagged = $ac->getSubscribedActors(); - $url = $ac->getUrl(); + $mentions[] = [ - 'mentioned' => $tagged, + 'mentioned' => $ac->getSubscribedActors(), 'type' => 'list', 'text' => $tag_match[0], 'position' => $tag_match[1], 'length' => mb_strlen($tag_match[0]), - 'url' => $url, + 'url' => $ac->getUrl(), ]; } - // Group mentions + // !group/!org mentions $group_matches = self::findMentionsRaw($text, '!'); foreach ($group_matches as $match) { try { @@ -436,7 +431,7 @@ abstract class Formatting * * @return array [partially-rendered HTML, array of mentions] */ - public static function linkifyMentions(string $text, Actor $author, string $language): array + public static function linkifyMentions(string $text, Actor $author, string $locale): array { $mentions = self::findMentions($text, $author); @@ -455,7 +450,7 @@ abstract class Formatting foreach ($points as $position => $mention) { $linkText = self::linkifyMentionArray($mention); - $text = substr_replace($text, $linkText, $position, $mention['length']); + $text = substr_replace($text, $linkText, $position-1, $mention['length']+1); } return [$text, $mentions]; @@ -465,17 +460,19 @@ abstract class Formatting { $output = null; - if (Event::handle('StartLinkifyMention', [$mention, &$output])) { + if (Event::handle('StartLinkifyMention', [$mention, &$output]) === Event::next) { $attrs = [ 'href' => $mention['url'], - 'class' => 'h-card u-url p-nickname ' . $mention['type'], + 'class' => 'h-card u-url p-nickname ' . $mention['type'], // https://microformats.org/wiki/h-card ]; if (!empty($mention['title'])) { $attrs['title'] = $mention['title']; } - $output = HTML::html(['a' => ['attrs' => $attrs, $mention['text']]]); + $output = HTML::html(['span' => ['attrs' => ['class' => 'h-card'], + '@' . HTML::html(['a' => ['attrs' => $attrs, $mention['title']]], options: ['indent' => false]), + ]], options: ['indent' => false, 'raw' => true]); Event::handle('EndLinkifyMention', [$mention, &$output]); } diff --git a/templates/cards/profile/view.html.twig b/templates/cards/profile/view.html.twig index 8ad48da1c9..4b8ff79ff1 100644 --- a/templates/cards/profile/view.html.twig +++ b/templates/cards/profile/view.html.twig @@ -28,8 +28,8 @@
-
{{ 'Subscribed' | trans }}{{ actor.getSubscribedCount() }}
-
{{ 'Subscribers' | trans }}{{ actor.getSubscribersCount() }}
+
{{ 'Subscribed' | trans }}{{ actor.getSubscribedCount() }}
+
{{ 'Subscribers' | trans }}{{ actor.getSubscribersCount() }}