[COMPONENT][Circle] Move circles to a component, various bug fixes

Mention links are now correct
This commit is contained in:
Diogo Peralta Cordeiro 2022-01-04 22:22:48 +00:00
parent 627d92b290
commit cd6ce3542e
Signed by: diogo
GPG Key ID: 18D2D35001FBFAB0
19 changed files with 693 additions and 697 deletions

View File

@ -0,0 +1,223 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
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 <hugo@hsal.es>
* @author Phablulo <phablulo@gmail.com>
* @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;
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
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]));
}
}

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
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']);
}
}

View File

@ -0,0 +1,117 @@
<?php
declare(strict_types = 1);
namespace Component\Circle\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\ClientException;
use App\Util\Exception\RedirectException;
use Component\Circle\Entity\ActorCircle;
use Component\Circle\Entity\ActorTag;
use Component\Circle\Form\SelfTagsForm;
use Component\Tag\Tag as CompTag;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
class SelfTagsSettings extends Controller
{
/**
* 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_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(),
];
}
}

View File

@ -19,7 +19,7 @@ declare(strict_types = 1);
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
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();
}
}

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@ -17,7 +19,7 @@
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
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'],
],
];
}

View File

@ -19,12 +19,12 @@ declare(strict_types = 1);
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
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();
}
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types = 1);
namespace Component\Circle\Form;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Util\Form\ArrayTransformer;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
abstract class SelfTagsForm
{
/**
* @return array [Form (add), ?Form (existing)]
*/
public static function handleTags(
Request $request,
array $actor_self_tags,
callable $handle_new,
callable $handle_existing,
string $remove_label,
string $add_label,
): array {
$form_definition = [];
foreach ($actor_self_tags as $tag) {
$tag = $tag->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];
}
}

View File

@ -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;

View File

@ -1,181 +0,0 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
/**
* Actor Circles for GNU social
*
* @package GNUsocial
* @category Plugin
*
* @author Phablulo <phablulo@gmail.com>
* @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;
}
}

View File

@ -1,84 +0,0 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
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]);
}
}

View File

@ -1,65 +0,0 @@
<?php
declare(strict_types = 1);
namespace Plugin\ActorCircles\Entity;
use App\Core\Entity;
class ActorCircles extends Entity
{
// These tags are meant to be literally included and will be populated with the appropriate fields, setters and getters by `bin/generate_entity_fields`
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $id;
private string $name;
private int $actor_id;
public function setId(int $id): self
{
$this->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'],
];
}
}

View File

@ -1,66 +0,0 @@
<?php
declare(strict_types = 1);
namespace Plugin\ActorCircles\Entity;
use App\Core\Entity;
class ActorCirclesEntry extends Entity
{
// These tags are meant to be literally included and will be populated with the appropriate fields, setters and getters by `bin/generate_entity_fields`
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $id;
private int $actor_id;
private int $circle_id;
public function setId(int $id): self
{
$this->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'],
];
}
}

View File

@ -1,142 +0,0 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
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 <hugo@hsal.es>
* @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'],
];
}
}

View File

@ -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('<span>', '', $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]);
}

View File

@ -28,8 +28,8 @@
</section>
<section class="profile-info-stats">
<div><strong>{{ 'Subscribed' | trans }}</strong>{{ actor.getSubscribedCount() }}</div>
<div><strong>{{ 'Subscribers' | trans }}</strong>{{ actor.getSubscribersCount() }}</div>
<div><strong><a href="{{ actor.getSubscriptionsUrl() }}">{{ 'Subscribed' | trans }}</a></strong>{{ actor.getSubscribedCount() }}</div>
<div><strong><a href="{{ actor.getSubscribersUrl() }}">{{ 'Subscribers' | trans }}</a></strong>{{ actor.getSubscribersCount() }}</div>
</section>
<nav class="profile-info-tags">

View File

@ -1 +1 @@
<a href="{{ tag.getUrl(actor) }}" title="{{ tag.getTag() }}" rel="tag"><em>#{{ tag.getTag() }}</em></a>
<span class="tag">#<a href="{{ tag.getCircle().getUrl() }}" title="{{ tag.getTag() }}" rel="tag"><em>{{ tag.getTag() }}</em></a></span>

View File

@ -1 +1 @@
<a href="{{ tag.getUrl() }}" title="{{ tag.getTag() }}" rel="tag">#{{ tag.getTag() }}</a>
<span class="tag">#<a href="{{ tag.getUrl() }}" title="{{ tag.getTag() }}" rel="tag">{{ tag.getTag() }}</a></span>

View File

@ -22,8 +22,8 @@ declare(strict_types = 1);
namespace App\Tests\Entity;
use App\Core\DB\DB;
use App\Entity\ActorTag;
use App\Util\GNUsocialTestCase;
use Component\Circle\Entity\ActorTag;
use Functional as F;
use Jchook\AssertThrows\AssertThrows;
@ -43,7 +43,7 @@ class ActorTest extends GNUsocialTestCase
$tags = $actor->getSelfTags();
$actor->setSelfTags(['foo'], $tags);
DB::flush();
$get_tags = fn ($tags) => F\map($tags, fn (ActorTag $t) => (string) $t);
$get_tags = fn ($tags) => F\map($tags, fn (ActorTag $t) => $t->getTag());
static::assertSame(['foo'], $get_tags($tags = $actor->getSelfTags()));
$actor->setSelfTags(['bar'], $tags);
DB::flush();