Compare commits
105 Commits
d444ea7963
...
oauth1
Author | SHA1 | Date | |
---|---|---|---|
b999c1bd62
|
|||
9dc6243822
|
|||
ce8f54dc46
|
|||
9e7db08e50
|
|||
841d10cde0
|
|||
95c8f3bdc7
|
|||
b82818646f
|
|||
5ac764f3e5
|
|||
4ad1de2616
|
|||
29f53bb698
|
|||
cb16b627b4
|
|||
19dd4ba368
|
|||
53a1a3fad1
|
|||
737648359d
|
|||
57c09c6f8f
|
|||
08e3da092b
|
|||
7959ea497b
|
|||
559f6d650b
|
|||
3d9edd1db8
|
|||
402300fe93
|
|||
e2e1b0172d
|
|||
f731850f5c
|
|||
7d546e8901
|
|||
bdeb3bcff5
|
|||
25b2847201
|
|||
23d45ffab7
|
|||
b253ce5e70
|
|||
c4f9e58e8d
|
|||
6ab740d780
|
|||
de795b78f9
|
|||
29d498770c
|
|||
d7039b1c5c
|
|||
1856af68b3
|
|||
9bd1f42843
|
|||
145c88d43f
|
|||
4717dde12e
|
|||
c028a601a5
|
|||
692ecf1c99
|
|||
242fe3fd6e
|
|||
dbdf1d9b0b
|
|||
7daa61500d
|
|||
077cbcf424
|
|||
04431885aa
|
|||
b8a35f9d6d
|
|||
184d0246a5
|
|||
da7ae5e1f5
|
|||
9e4aed84f8
|
|||
db42ade2b6
|
|||
06d11d8337
|
|||
148dd6db50
|
|||
21c7912702
|
|||
f7cbfbff8c
|
|||
3f0d996dc9
|
|||
9e891ed020
|
|||
6c6c0270c5
|
|||
a59997b41f
|
|||
d542be1df4
|
|||
eff9318c1d
|
|||
fa9df9962e
|
|||
859bf0c0bf
|
|||
d29e28b829
|
|||
14b03c7137
|
|||
480f570238
|
|||
968b1751fd
|
|||
c8daa82c1d
|
|||
600a1511cb
|
|||
59b8bdf99b
|
|||
f3a7e8f04d
|
|||
65504b72bb
|
|||
|
d713429d88
|
||
1056bc661f
|
|||
f40eb3955f
|
|||
b2b445d21e
|
|||
528f6df240
|
|||
894c78bf99
|
|||
38baa192d8
|
|||
a697399a6f
|
|||
cdf1d67d0f
|
|||
06ece5b72e
|
|||
da6d3bd351
|
|||
c835fc6aca
|
|||
57604b3851
|
|||
b1abd81aca
|
|||
5cfed3d536
|
|||
0758d6145b
|
|||
d17f276419
|
|||
fc57b3290e
|
|||
1438433859
|
|||
cb1dc4c10f | |||
9cf8970603 | |||
c3d58c350e | |||
e056920de4
|
|||
0c245fcb6e
|
|||
0d1ab2c9cf
|
|||
3f8fab0021
|
|||
cd6ce3542e
|
|||
627d92b290
|
|||
ee007befa4
|
|||
9df9c6a19c
|
|||
754135743e
|
|||
5a0bbfc795
|
|||
6247dd4c1a
|
|||
de8eab2cf8
|
|||
b7e4f79ccc
|
|||
a5b5362be2
|
17
Makefile
17
Makefile
@@ -37,10 +37,16 @@ database-force-schema-update:
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c "/var/www/social/bin/console doctrine:schema:update --dump-sql --force"
|
||||
|
||||
tooling-docker: .PHONY
|
||||
@cd docker/tooling && docker-compose up -d > /dev/null 2>&1
|
||||
@cd docker/tooling && docker-compose up -d --build > /dev/null 2>&1
|
||||
|
||||
accessibility: .PHONY
|
||||
@cd docker/accessibility && docker-compose up
|
||||
stop-tooling: .PHONY
|
||||
cd docker/tooling && docker-compose down
|
||||
|
||||
tooling-php-shell: tooling-docker
|
||||
docker exec -it $(call translate-container-name,tooling_php_1) sh
|
||||
|
||||
test-accesibility: tooling-docker
|
||||
cd docker/tooling && docker-compose run pa11y /accessibility.sh
|
||||
|
||||
test: tooling-docker
|
||||
docker exec $(call translate-container-name,tooling_php_1) /var/tooling/coverage.sh $(call args,'')
|
||||
@@ -54,14 +60,11 @@ doc-check: tooling-docker
|
||||
phpstan: tooling-docker
|
||||
bin/phpstan
|
||||
|
||||
stop-tooling: .PHONY
|
||||
cd docker/tooling && docker-compose down
|
||||
|
||||
remove-var:
|
||||
rm -rf var/*
|
||||
|
||||
remove-file:
|
||||
rm -rf file/*
|
||||
sudo rm -rf file/*
|
||||
|
||||
flush-redis-cache:
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_redis_1) sh -c 'redis-cli flushall'
|
||||
|
10
codeception.yml
Normal file
10
codeception.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
paths:
|
||||
tests: tests/CodeCeption
|
||||
output: tests/CodeCeption/_output
|
||||
data: tests/CodeCeption/_data
|
||||
support: tests/CodeCeption/_support
|
||||
envs: tests/CodeCeption/_envs
|
||||
actor_suffix: Tester
|
||||
extensions:
|
||||
enabled:
|
||||
- Codeception\Extension\RunFailed
|
@@ -68,7 +68,7 @@ class Attachment extends Component
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
{
|
||||
$note_qb->leftJoin(E\AttachmentToNote::class, 'attachment_to_note', Expr\Join::WITH, 'note.id = attachment_to_note.note_id');
|
||||
return Event::next;
|
||||
@@ -77,7 +77,7 @@ class Attachment extends Component
|
||||
/**
|
||||
* Populate $note_expr with the criteria for looking for notes with attachments
|
||||
*/
|
||||
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr): bool
|
||||
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
|
||||
{
|
||||
$include_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
|
||||
if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) {
|
||||
|
223
components/Circle/Circle.php
Normal file
223
components/Circle/Circle.php
Normal 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 removeItem(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 addItem(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']);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
61
components/Circle/Controller/Circle.php
Normal file
61
components/Circle/Controller/Circle.php
Normal 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]));
|
||||
}
|
||||
}
|
107
components/Circle/Controller/Circles.php
Normal file
107
components/Circle/Controller/Circles.php
Normal 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']);
|
||||
}
|
||||
}
|
117
components/Circle/Controller/SelfTagsSettings.php
Normal file
117
components/Circle/Controller/SelfTagsSettings.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -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'],
|
||||
],
|
||||
];
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -2,13 +2,11 @@
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Tag\Form;
|
||||
namespace Component\Circle\Form;
|
||||
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity as E;
|
||||
use App\Util\Form\ArrayTransformer;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -16,31 +14,27 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
abstract class SelfTagsForm
|
||||
{
|
||||
/**
|
||||
* @param E\ActorTag[]|E\ActorTagBlock[]|E\NoteTagBlock[] $tags
|
||||
*
|
||||
* @return array [Form (add), ?Form (existing)]
|
||||
*/
|
||||
public static function handleTags(
|
||||
Request $request,
|
||||
array $tags,
|
||||
array $actor_self_tags,
|
||||
callable $handle_new,
|
||||
callable $handle_existing,
|
||||
string $remove_label,
|
||||
string $add_label,
|
||||
): array {
|
||||
$form_definition = [];
|
||||
foreach ($tags as $tag) {
|
||||
$canon = $tag->getCanonical();
|
||||
$form_definition[] = ["{$canon}:old-tag", TextType::class, ['data' => '#' . $tag->getTag(), 'label' => ' ', 'disabled' => true]];
|
||||
$form_definition[] = ["{$canon}:toggle-canon", SubmitType::class, ['attr' => ['data' => $tag->getUseCanonical()], 'label' => $tag->getUseCanonical() ? _m('Set non-canonical') : _m('Set canonical')]];
|
||||
$form_definition[] = [$existing_form_name = "{$canon}:remove", SubmitType::class, ['label' => $remove_label]];
|
||||
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]],
|
||||
['new-tags-use-canon', CheckboxType::class, ['label' => _m('Use canonical'), 'help' => _m('Assume this tag is the same as similar tags'), 'required' => false, 'data' => true]],
|
||||
[$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]],
|
||||
]);
|
||||
|
@@ -4,8 +4,145 @@ declare(strict_types = 1);
|
||||
|
||||
namespace Component\Collection;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Formatting;
|
||||
use Component\Collection\Util\Parser;
|
||||
use Component\Subscription\Entity\ActorSubscription;
|
||||
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||
use Doctrine\ORM\Query\Expr;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class Collection extends Component
|
||||
{
|
||||
/**
|
||||
* Perform a high level query on notes or actors
|
||||
*
|
||||
* Supports a variety of query terms and is used both in feeds and
|
||||
* in search. Uses query builders to allow for extension
|
||||
*/
|
||||
public static function query(string $query, int $page, ?string $locale = null, ?Actor $actor = null): array
|
||||
{
|
||||
$note_criteria = null;
|
||||
$actor_criteria = null;
|
||||
if (!empty($query = trim($query))) {
|
||||
[$note_criteria, $actor_criteria] = Parser::parse($query, $locale, $actor);
|
||||
}
|
||||
$note_qb = DB::createQueryBuilder();
|
||||
$actor_qb = DB::createQueryBuilder();
|
||||
// TODO consider selecting note related stuff, to avoid separate queries (though they're cached, so maybe it's okay)
|
||||
$note_qb->select('note')->from('App\Entity\Note', 'note')->orderBy('note.created', 'DESC')->addOrderBy('note.id', 'DESC');
|
||||
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor')->orderBy('actor.created', 'DESC')->addOrderBy('actor.id', 'DESC');
|
||||
Event::handle('CollectionQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
|
||||
|
||||
$notes = [];
|
||||
$actors = [];
|
||||
if (!\is_null($note_criteria)) {
|
||||
$note_qb->addCriteria($note_criteria);
|
||||
$notes = $note_qb->getQuery()->execute();
|
||||
}
|
||||
|
||||
if (!\is_null($actor_criteria)) {
|
||||
$actor_qb->addCriteria($actor_criteria);
|
||||
$actors = $actor_qb->getQuery()->execute();
|
||||
}
|
||||
|
||||
// N.B.: Scope is only enforced at FeedController level
|
||||
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
|
||||
}
|
||||
|
||||
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
{
|
||||
$note_qb->leftJoin(ActorSubscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed_id')
|
||||
->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text
|
||||
* notes, for different types of actors and for the content of text notes
|
||||
*/
|
||||
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr)
|
||||
{
|
||||
if (str_contains($term, ':')) {
|
||||
$term = explode(':', $term);
|
||||
if (Formatting::startsWith($term[0], 'note')) {
|
||||
switch ($term[0]) {
|
||||
case 'notes-all':
|
||||
$note_expr = $eb->neq('note.created', null);
|
||||
break;
|
||||
case 'note-local':
|
||||
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
|
||||
break;
|
||||
case 'note-types':
|
||||
case 'notes-include':
|
||||
case 'note-filter':
|
||||
if (\is_null($note_expr)) {
|
||||
$note_expr = [];
|
||||
}
|
||||
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
|
||||
$note_expr[] = $eb->neq('note.content', null);
|
||||
} else {
|
||||
$note_expr[] = $eb->eq('note.content', null);
|
||||
}
|
||||
break;
|
||||
case 'note-conversation':
|
||||
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
|
||||
break;
|
||||
case 'note-from':
|
||||
case 'notes-from':
|
||||
$subscribed_expr = $eb->eq('subscription.subscriber_id', $actor->getId());
|
||||
$type_consts = [];
|
||||
if ($term[1] === 'subscribed') {
|
||||
$type_consts = null;
|
||||
}
|
||||
foreach (explode(',', $term[1]) as $from) {
|
||||
if (str_starts_with($from, 'subscribed-')) {
|
||||
[, $type] = explode('-', $from);
|
||||
if (\in_array($type, ['actor', 'actors'])) {
|
||||
$type_consts = null;
|
||||
} else {
|
||||
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (\is_null($type_consts)) {
|
||||
$note_expr = $subscribed_expr;
|
||||
} elseif (!empty($type_consts)) {
|
||||
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
|
||||
}
|
||||
break;
|
||||
}
|
||||
} elseif (Formatting::startsWith($term, 'actor-')) {
|
||||
switch ($term[0]) {
|
||||
case 'actor-types':
|
||||
case 'actors-include':
|
||||
case 'actor-filter':
|
||||
case 'actor-local':
|
||||
if (\is_null($actor_expr)) {
|
||||
$actor_expr = [];
|
||||
}
|
||||
foreach (
|
||||
[
|
||||
Actor::PERSON => ['person', 'people'],
|
||||
Actor::GROUP => ['group', 'groups'],
|
||||
Actor::ORGANISATION => ['org', 'orgs', 'organization', 'organizations', 'organisation', 'organisations'],
|
||||
Actor::BOT => ['bot', 'bots'],
|
||||
] as $type => $match) {
|
||||
if (array_intersect(explode(',', $term[1]), $match) !== []) {
|
||||
$actor_expr[] = $eb->eq('actor.type', $type);
|
||||
} else {
|
||||
$actor_expr[] = $eb->neq('actor.type', $type);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$note_expr = $eb->contains('note.content', $term);
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
}
|
||||
|
@@ -7,14 +7,14 @@ namespace Component\Collection\Util\Controller;
|
||||
use App\Core\Controller;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Common;
|
||||
use Component\Feed\Feed;
|
||||
use Component\Collection\Collection as CollectionModule;
|
||||
|
||||
class Collection extends Controller
|
||||
{
|
||||
public function query(string $query, ?string $language = null, ?Actor $actor = null)
|
||||
public function query(string $query, ?string $locale = null, ?Actor $actor = null): array
|
||||
{
|
||||
$actor ??= Common::actor();
|
||||
$language ??= $actor->getTopLanguage()->getLocale();
|
||||
return Feed::query($query, $this->int('page') ?? 1, $language, $actor);
|
||||
$actor ??= Common::actor();
|
||||
$locale ??= Common::currentLanguage()->getLocale();
|
||||
return CollectionModule::query($query, $this->int('page') ?? 1, $locale, $actor);
|
||||
}
|
||||
}
|
||||
|
@@ -77,7 +77,7 @@ abstract class MetaCollectionController extends FeedController
|
||||
$collections = $this->getCollectionsByActorId($id);
|
||||
|
||||
$create_title = _m('Create a ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->slug)));
|
||||
$collections_title = _m('Your ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->plural_slug)));
|
||||
$collections_title = _m('The ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->plural_slug)));
|
||||
// create collection form
|
||||
$create = null;
|
||||
if (Common::user()?->getId() === $id) {
|
||||
@@ -127,7 +127,7 @@ abstract class MetaCollectionController extends FeedController
|
||||
$this->parent = $parent;
|
||||
$this->slug = $slug;
|
||||
}
|
||||
// there's already a injected function called path,
|
||||
// there's already an injected function called path,
|
||||
// that maps to Router::url(name, args), but since
|
||||
// I want to preserve nicknames, I think it's better
|
||||
// to use that getUrl function
|
||||
@@ -159,8 +159,7 @@ abstract class MetaCollectionController extends FeedController
|
||||
]);
|
||||
$edit->handleRequest($this->request);
|
||||
if ($edit->isSubmitted() && $edit->isValid()) {
|
||||
$collection->setName($edit->getData()['name']);
|
||||
DB::persist($collection);
|
||||
$this->parent->setCollectionName($this->id, $this->nick, $collection, $edit->getData()['name']);
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
||||
}
|
||||
@@ -181,7 +180,7 @@ abstract class MetaCollectionController extends FeedController
|
||||
]);
|
||||
$rm->handleRequest($this->request);
|
||||
if ($rm->isSubmitted()) {
|
||||
DB::remove($collection);
|
||||
$this->parent->removeCollection($this->id, $this->nick, $collection);
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
||||
}
|
||||
|
@@ -35,7 +35,6 @@ use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Modules\Plugin;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\RedirectException;
|
||||
@@ -45,19 +44,49 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
abstract class MetaCollectionPlugin extends Plugin
|
||||
trait MetaCollectionTrait
|
||||
{
|
||||
protected string $slug = 'collection';
|
||||
protected string $plural_slug = 'collections';
|
||||
//protected string $slug = 'collection';
|
||||
//protected string $plural_slug = 'collections';
|
||||
|
||||
/**
|
||||
* create a collection owned by Actor $owner.
|
||||
*
|
||||
* @param Actor $owner The collection's owner
|
||||
* @param array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param string $name Collection's name
|
||||
*/
|
||||
abstract protected function createCollection(Actor $owner, array $vars, string $name);
|
||||
abstract protected function removeItems(Actor $owner, array $vars, $items, array $collections);
|
||||
abstract protected function addItems(Actor $owner, array $vars, $items, array $collections);
|
||||
/**
|
||||
* remove item from collections.
|
||||
*
|
||||
* @param Actor $owner Current user
|
||||
* @param array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param array $items Array of collections's ids to remove the current item from
|
||||
* @param array $collections List of ids of collections owned by $owner
|
||||
*/
|
||||
abstract protected function removeItem(Actor $owner, array $vars, array $items, array $collections);
|
||||
/**
|
||||
* add item to collections.
|
||||
*
|
||||
* @param Actor $owner Current user
|
||||
* @param array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param array $items Array of collections's ids to add the current item to
|
||||
* @param array $collections List of ids of collections owned by $owner
|
||||
*/
|
||||
abstract protected function addItem(Actor $owner, array $vars, array $items, array $collections);
|
||||
|
||||
/**
|
||||
* Check the route to determine whether the widget should be added
|
||||
*/
|
||||
abstract protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool;
|
||||
/**
|
||||
* Get array of collections's owned by $actor
|
||||
*
|
||||
* @param Actor $owner Collection's owner
|
||||
* @param ?array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param bool $ids_only if true, the function must return only the primary key or each collections
|
||||
*/
|
||||
abstract protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array;
|
||||
|
||||
/**
|
||||
@@ -65,7 +94,7 @@ abstract class MetaCollectionPlugin extends Plugin
|
||||
* It's compose of two forms: one to select collections to add
|
||||
* the current item to, and another to create a new collection.
|
||||
*/
|
||||
public function onAppendRightPanelBlock($vars, Request $request, &$res): bool
|
||||
public function onAppendRightPanelBlock(Request $request, $vars, &$res): bool
|
||||
{
|
||||
$user = Common::actor();
|
||||
if (\is_null($user)) {
|
||||
@@ -110,10 +139,10 @@ abstract class MetaCollectionPlugin extends Plugin
|
||||
$removed = array_filter($already_selected, fn ($x) => !\in_array($x, $selected));
|
||||
$added = array_filter($selected, fn ($x) => !\in_array($x, $already_selected));
|
||||
if (\count($removed) > 0) {
|
||||
$this->removeItems($user, $vars, $removed, $collections);
|
||||
$this->removeItem($user, $vars, $removed, $collections);
|
||||
}
|
||||
if (\count($added) > 0) {
|
||||
$this->addItems($user, $vars, $added, $collections);
|
||||
$this->addItem($user, $vars, $added, $collections);
|
||||
}
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
@@ -21,7 +21,7 @@ declare(strict_types = 1);
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Search\Util;
|
||||
namespace Component\Collection\Util;
|
||||
|
||||
use App\Core\Event;
|
||||
use App\Entity\Actor;
|
||||
@@ -53,7 +53,7 @@ abstract class Parser
|
||||
*
|
||||
* @return Criteria[]
|
||||
*/
|
||||
public static function parse(string $input, ?string $language = null, ?Actor $actor = null, int $level = 0): array
|
||||
public static function parse(string $input, ?string $locale = null, ?Actor $actor = null, int $level = 0): array
|
||||
{
|
||||
if ($level === 0) {
|
||||
$input = trim(preg_replace(['/\s+/', '/\s+AND\s+/', '/\s+OR\s+/'], [' ', '&', '|'], $input), ' |&');
|
||||
@@ -78,17 +78,17 @@ abstract class Parser
|
||||
$term = mb_substr($input, $left, $end ? null : $right - $left);
|
||||
$note_res = null;
|
||||
$actor_res = null;
|
||||
Event::handle('SearchCreateExpression', [$eb, $term, $language, $actor, &$note_res, &$actor_res]);
|
||||
Event::handle('CollectionQueryCreateExpression', [$eb, $term, $locale, $actor, &$note_res, &$actor_res]);
|
||||
if (\is_null($note_res) && \is_null($actor_res)) { // @phpstan-ignore-line
|
||||
throw new ServerException("No one claimed responsibility for a match term: {$term}");
|
||||
}
|
||||
if (!\is_null($note_res) && !empty($note_res)) { // @phpstan-ignore-line
|
||||
if (!empty($note_res)) { // @phpstan-ignore-line
|
||||
if (\is_array($note_res)) {
|
||||
$note_res = $eb->orX(...$note_res);
|
||||
}
|
||||
$note_parts[] = $note_res;
|
||||
}
|
||||
if (!\is_null($actor_res) && !empty($actor_res)) {
|
||||
if (!empty($actor_res)) {
|
||||
if (\is_array($actor_res)) {
|
||||
$actor_res = $eb->orX(...$actor_res);
|
||||
}
|
@@ -3,22 +3,49 @@
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<section class="section-widget section-padding">
|
||||
<h2 class="section-title">{{ title }}</h2>
|
||||
<div class="frame-section frame-section-padding">
|
||||
<h1 class="frame-section-title">{{ title }}</h1>
|
||||
|
||||
<div>
|
||||
<p>{% trans %}Sort by:{% endtrans %}</p>
|
||||
<form method="GET">
|
||||
{% for field in sort_form_fields %}
|
||||
<label for="order_by_{{ field.value }}">{{ field.label }}</label>
|
||||
<input id="order_by_{{ field.value }}" type="radio" name="order_by" value="{{ field.value }}" {% if field.checked %}checked="checked"{% endif %}>
|
||||
{% endfor %}
|
||||
<button type="submit" name="order_op" value="ASC">{% trans %}Ascending{% endtrans %}</button>
|
||||
<button type="submit" name="order_op" value="DESC">{% trans %}Descending{% endtrans %}</button>
|
||||
<details class="frame-section section-details-title">
|
||||
<summary class="details-summary-title">
|
||||
<strong>
|
||||
{% trans %}Ordering rules{% endtrans %}
|
||||
</strong>
|
||||
</summary>
|
||||
<form method="GET" class="section-form">
|
||||
<div class="container-grid">
|
||||
<section class="frame-section frame-section-padding">
|
||||
<strong>{% trans %}Sort by{% endtrans %}</strong>
|
||||
<hr>
|
||||
<div class="container-block">
|
||||
{% for field in sort_form_fields %}
|
||||
<span class="container-block">
|
||||
<label for="order_by_{{ field.value }}">{{ field.label }}</label>
|
||||
<input id="order_by_{{ field.value }}" type="radio" name="order_by" value="{{ field.value }}" {% if field.checked %}checked="checked"{% endif %}>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="frame-section frame-section-padding">
|
||||
<strong class="section-title">{% trans %}Order{% endtrans %}</strong>
|
||||
<hr>
|
||||
<section class="container-block">
|
||||
<span class="container-block">
|
||||
<label for="order_op_asc">{% trans %}Ascending{% endtrans %}</label>
|
||||
<input id="order_op_asc" type="radio" name="order_op" value="ASC">
|
||||
</span>
|
||||
<span class="container-block">
|
||||
<label for="order_op_desc">{% trans %}Descending{% endtrans %}</label>
|
||||
<input id="order_op_desc" type="radio" name="order_op" value="DESC" checked="checked">
|
||||
</span>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
<button type="submit">{% trans %}Order{% endtrans %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="section-padding">
|
||||
<section class="frame-section-padding">
|
||||
{% if actors is defined and actors is not empty %}
|
||||
{% for actor in actors %}
|
||||
{% block profile_view %}{% include 'cards/profile/view.html.twig' %}{% endblock profile_view %}
|
||||
@@ -26,8 +53,8 @@
|
||||
{% endfor %}
|
||||
<p>{% trans %}Page: %page%{% endtrans %}</p>
|
||||
{% else %}
|
||||
<h3>{{ empty_message }}</h3>
|
||||
<h2>{{ empty_message }}</h2>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
@@ -3,8 +3,8 @@
|
||||
{% block title %}{{ page_title | trans }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="section-widget section-padding">
|
||||
<h2 class="section-widget-title">{{ page_title | trans }}</h2>
|
||||
<div class="frame-section frame-section-padding">
|
||||
<h2 class="frame-section-title">{{ page_title | trans }}</h2>
|
||||
{% block collection_items %}
|
||||
{% endblock collection_items %}
|
||||
</div>
|
||||
|
@@ -3,14 +3,14 @@
|
||||
{% block title %}{{ page_title | trans }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="section-widget section-padding">
|
||||
<h2 class="section-widget-title">{{ page_title | trans }}</h2>
|
||||
<div class="frame-section frame-section-padding">
|
||||
<h2 class="frame-section-title">{{ page_title | trans }}</h2>
|
||||
{% if add_collection %}
|
||||
<div class="section-widget section-form">
|
||||
<div class="frame-section section-form">
|
||||
{{ form(add_collection) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="section-widget collections-list">
|
||||
<div class="frame-section collections-list">
|
||||
<h3>{{ list_title | trans }}</h3>
|
||||
{% for col in collections %}
|
||||
<div class="collection-item">
|
||||
|
@@ -9,21 +9,36 @@
|
||||
{% endblock stylesheets %}
|
||||
|
||||
{% block body %}
|
||||
<header class="feed-header">
|
||||
{% if page_title is defined %}
|
||||
<h1>{{ page_title | trans }}</h1>
|
||||
{% endif %}
|
||||
<nav class="feed-actions">
|
||||
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</header>
|
||||
{% for block in handle_event('BeforeFeed', app.request) %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
|
||||
{# Backwards compatibility with hAtom 0.1 #}
|
||||
<main class="feed" tabindex="0" role="feed">
|
||||
<div class="h-feed hfeed notes">
|
||||
{% if notes is defined and notes is not empty %}
|
||||
{% if notes is defined %}
|
||||
<header class="feed-header">
|
||||
{% if page_title is defined %}
|
||||
<h1 class="heading-no-margin">{{ page_title | trans }}</h1>
|
||||
{% else %}
|
||||
<h3 class="heading-no-margin">{{ 'Notes' | trans }}</h3>
|
||||
{% endif %}
|
||||
<nav class="feed-actions">
|
||||
<details class="feed-actions-details">
|
||||
<summary>
|
||||
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
|
||||
</summary>
|
||||
<div class="feed-actions-details-dropdown">
|
||||
<menu>
|
||||
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
</menu>
|
||||
</div>
|
||||
</details>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% if notes is not empty %}
|
||||
{# Backwards compatibility with hAtom 0.1 #}
|
||||
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
|
||||
{% for conversation in notes %}
|
||||
{% block current_note %}
|
||||
{% if conversation is instanceof('array') %}
|
||||
@@ -34,11 +49,7 @@
|
||||
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
|
||||
{% endblock current_note %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="feed-empty">
|
||||
{{ icon('logo', 'icon feed-background') | raw }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock body %}
|
||||
|
@@ -1,28 +1,26 @@
|
||||
<section class="section-widget collections">
|
||||
<details class="section-widget-title-details" title="Expand if you want to access more options.">
|
||||
<summary class="section-title-summary">
|
||||
<section class="frame-section collections">
|
||||
<details class="section-details-title" title="Expand if you want to access more options.">
|
||||
<summary class="details-summary-title">
|
||||
<h2>{{ctitle}}</h2>
|
||||
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
|
||||
</summary>
|
||||
{% if has_collections %}
|
||||
<fieldset class="section-form">
|
||||
<section class="section-form">
|
||||
{{ form(add_form) }}
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<details class="section-widget-subtitle-details section-padding"
|
||||
<details class="frame-section-padding section-details-subtitle"
|
||||
title="Expand if you want to access more options.">
|
||||
<summary class="section-subtitle-summary">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>{% trans %}Other options{% endtrans %}</strong>
|
||||
{{ icon('arrow-down', 'icon icon-details-close') | raw }}
|
||||
</summary>
|
||||
<fieldset class="section-form">
|
||||
<section class="section-form">
|
||||
{{ form(create_form) }}
|
||||
</fieldset>
|
||||
</section>
|
||||
</details>
|
||||
{% else %}
|
||||
<fieldset class="section-form">
|
||||
<section class="section-form">
|
||||
{{ form(create_form) }}
|
||||
</fieldset>
|
||||
</section>
|
||||
{% endif %}
|
||||
</details>
|
||||
</section>
|
||||
|
@@ -31,6 +31,8 @@ use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
@@ -101,23 +103,39 @@ class Conversation extends FeedController
|
||||
$user = Common::ensureLoggedIn();
|
||||
$is_muted = ConversationMute::isMuted($conversation_id, $user);
|
||||
$form = Form::create([
|
||||
['mute_conversation', SubmitType::class, ['label' => $is_muted ? _m('Mute conversation') : _m('Unmute conversation')]],
|
||||
['mute_conversation', SubmitType::class, ['label' => $is_muted ? _m('Unmute') : _m('Mute'), 'attr' => ['class' => '']]],
|
||||
]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if ($is_muted) {
|
||||
if (!$is_muted) {
|
||||
DB::persist(ConversationMute::create(['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]));
|
||||
} else {
|
||||
DB::removeBy('conversation_mute', ['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]);
|
||||
}
|
||||
DB::flush();
|
||||
Cache::delete(ConversationMute::cacheKeys($conversation_id, $user->getId())['mute']);
|
||||
throw new RedirectException();
|
||||
|
||||
// Redirect user to where they came from
|
||||
// Prevent open redirect
|
||||
if (!\is_null($from = $this->string('from'))) {
|
||||
if (Router::isAbsolute($from)) {
|
||||
Log::warning("Actor {$user->getId()} attempted to mute conversation {$conversation_id} and then get redirected to another host, or the URL was invalid ({$from})");
|
||||
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
|
||||
} else {
|
||||
// TODO anchor on element id
|
||||
throw new RedirectException(url: $from);
|
||||
}
|
||||
} else {
|
||||
// If we don't have a URL to return to, go to the instance root
|
||||
throw new RedirectException('root');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'conversation/mute.html.twig',
|
||||
'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [],
|
||||
'is_muted' => $is_muted,
|
||||
'form' => $form->createView(),
|
||||
];
|
||||
}
|
||||
|
@@ -226,10 +226,23 @@ class Conversation extends Component
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
$from = $request->query->has('from')
|
||||
? $request->query->get('from')
|
||||
: $request->getPathInfo();
|
||||
|
||||
$mute_extra_action_url = Router::url(
|
||||
'conversation_mute',
|
||||
[
|
||||
'conversation_id' => $note->getConversationId(),
|
||||
'from' => $from . '#note-anchor-' . $note->getId(),
|
||||
],
|
||||
Router::ABSOLUTE_PATH,
|
||||
);
|
||||
|
||||
$actions[] = [
|
||||
'title' => ConversationMute::isMuted($note, $user) ? _m('Mute conversation') : _m('Unmute conversation'),
|
||||
'title' => ConversationMute::isMuted($note, $user) ? _m('Unmute conversation') : _m('Mute conversation'),
|
||||
'classes' => '',
|
||||
'url' => Router::url('conversation_mute', ['conversation_id' => $note->getConversationId()]),
|
||||
'url' => $mute_extra_action_url,
|
||||
];
|
||||
|
||||
return Event::next;
|
||||
|
@@ -118,11 +118,15 @@ class ConversationMute extends Entity
|
||||
return [
|
||||
'name' => 'conversation_mute',
|
||||
'fields' => [
|
||||
'conversation_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Conversation.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'The conversation being blocked'],
|
||||
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Who blocked the conversation'],
|
||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
||||
'conversation_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Conversation.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'The conversation being blocked'],
|
||||
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Who blocked the conversation'],
|
||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
||||
],
|
||||
'primary key' => ['conversation_id', 'actor_id'],
|
||||
'foreign keys' => [
|
||||
'conversation_id_to_id_fkey' => ['conversation', ['conversation_id' => 'id']],
|
||||
'actor_id_to_id_fkey' => ['actor', ['actor_id' => 'id']],
|
||||
],
|
||||
'primary key' => ['conversation_id', 'actor_id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,21 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% extends 'collection/notes.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
{{ form(form) }}
|
||||
<div class="frame-section frame-section-padding">
|
||||
{% if is_muted %}
|
||||
<span class="frame-section-padding alert">
|
||||
<label>Do you wish to <b>unmute</b> this conversation?</label>
|
||||
{{ form(form) }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="frame-section-padding alert">
|
||||
<label>Do you wish to <b>mute</b> this conversation?</label>
|
||||
{{ form(form) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
{{ parent() }}
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
@@ -47,10 +47,7 @@ class Feeds extends FeedController
|
||||
*/
|
||||
public function public(Request $request): array
|
||||
{
|
||||
$data = $this->query(
|
||||
query: 'note-local:true',
|
||||
language: Common::actor()?->getTopLanguage()?->getLocale(),
|
||||
);
|
||||
$data = $this->query('note-local:true');
|
||||
return [
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m(\is_null(Common::user()) ? 'Feed' : 'Planet'),
|
||||
@@ -63,13 +60,8 @@ class Feeds extends FeedController
|
||||
*/
|
||||
public function home(Request $request): array
|
||||
{
|
||||
$user = Common::ensureLoggedIn();
|
||||
$actor = $user->getActor();
|
||||
$data = $this->query(
|
||||
query: 'note-from:subscribed-person,subscribed-group,subscribed-organization,subscribed-business',
|
||||
language: $actor->getTopLanguage()->getLocale(),
|
||||
actor: $actor,
|
||||
);
|
||||
Common::ensureLoggedIn();
|
||||
$data = $this->query('note-from:subscribed-person,subscribed-group,subscribed-organisation');
|
||||
return [
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m('Home'),
|
||||
|
@@ -23,18 +23,10 @@ declare(strict_types = 1);
|
||||
|
||||
namespace Component\Feed;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\RouteLoader;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Formatting;
|
||||
use Component\Feed\Controller as C;
|
||||
use Component\Search\Util\Parser;
|
||||
use Component\Subscription\Entity\Subscription;
|
||||
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||
use Doctrine\ORM\Query\Expr;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class Feed extends Component
|
||||
{
|
||||
@@ -44,130 +36,4 @@ class Feed extends Component
|
||||
$r->connect('feed_home', '/feed/home', [C\Feeds::class, 'home']);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a high level query on notes or actors
|
||||
*
|
||||
* Supports a variety of query terms and is used both in feeds and
|
||||
* in search. Uses query builders to allow for extension
|
||||
*/
|
||||
public static function query(string $query, int $page, ?string $language = null, ?Actor $actor = null): array
|
||||
{
|
||||
$note_criteria = null;
|
||||
$actor_criteria = null;
|
||||
if (!empty($query = trim($query))) {
|
||||
[$note_criteria, $actor_criteria] = Parser::parse($query, $language, $actor);
|
||||
}
|
||||
$note_qb = DB::createQueryBuilder();
|
||||
$actor_qb = DB::createQueryBuilder();
|
||||
$note_qb->select('note')->from('App\Entity\Note', 'note')->orderBy('note.created', 'DESC')->addOrderBy('note.id', 'DESC');
|
||||
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor')->orderBy('actor.created', 'DESC')->addOrderBy('actor.id', 'DESC');
|
||||
Event::handle('SearchQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
|
||||
|
||||
$notes = [];
|
||||
$actors = [];
|
||||
if (!\is_null($note_criteria)) {
|
||||
$note_qb->addCriteria($note_criteria);
|
||||
}
|
||||
$notes = $note_qb->getQuery()->execute();
|
||||
|
||||
if (!\is_null($actor_criteria)) {
|
||||
$actor_qb->addCriteria($actor_criteria);
|
||||
}
|
||||
$actors = $actor_qb->getQuery()->execute();
|
||||
|
||||
// N.B.: Scope is only enforced at FeedController level
|
||||
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
|
||||
}
|
||||
|
||||
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
{
|
||||
$note_qb->leftJoin(Subscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed_id')
|
||||
->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text
|
||||
* notes, for different types of actors and for the content of text notes
|
||||
*/
|
||||
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr)
|
||||
{
|
||||
if (str_contains($term, ':')) {
|
||||
$term = explode(':', $term);
|
||||
if (Formatting::startsWith($term[0], 'note-')) {
|
||||
switch ($term[0]) {
|
||||
case 'note-local':
|
||||
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
|
||||
break;
|
||||
case 'note-types':
|
||||
case 'notes-include':
|
||||
case 'note-filter':
|
||||
if (\is_null($note_expr)) {
|
||||
$note_expr = [];
|
||||
}
|
||||
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
|
||||
$note_expr[] = $eb->neq('note.content', null);
|
||||
} else {
|
||||
$note_expr[] = $eb->eq('note.content', null);
|
||||
}
|
||||
break;
|
||||
case 'note-conversation':
|
||||
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
|
||||
break;
|
||||
case 'note-from':
|
||||
case 'notes-from':
|
||||
$subscribed_expr = $eb->eq('subscription.subscriber_id', $actor->getId());
|
||||
$type_consts = [];
|
||||
if ($term[1] === 'subscribed') {
|
||||
$type_consts = null;
|
||||
}
|
||||
foreach (explode(',', $term[1]) as $from) {
|
||||
if (str_starts_with($from, 'subscribed-')) {
|
||||
[, $type] = explode('-', $from);
|
||||
if (\in_array($type, ['actor', 'actors'])) {
|
||||
$type_consts = null;
|
||||
} else {
|
||||
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (\is_null($type_consts)) {
|
||||
$note_expr = $subscribed_expr;
|
||||
} elseif (!empty($type_consts)) {
|
||||
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
|
||||
}
|
||||
break;
|
||||
}
|
||||
} elseif (Formatting::startsWith($term, 'actor-')) {
|
||||
switch ($term[0]) {
|
||||
case 'actor-types':
|
||||
case 'actors-include':
|
||||
case 'actor-filter':
|
||||
case 'actor-local':
|
||||
if (\is_null($actor_expr)) {
|
||||
$actor_expr = [];
|
||||
}
|
||||
foreach (
|
||||
[
|
||||
Actor::PERSON => ['person', 'people'],
|
||||
Actor::GROUP => ['group', 'groups'],
|
||||
Actor::ORGANIZATION => ['org', 'orgs', 'organization', 'organizations', 'organisation', 'organisations'],
|
||||
Actor::BUSINESS => ['business', 'businesses'],
|
||||
Actor::BOT => ['bot', 'bots'],
|
||||
] as $type => $match) {
|
||||
if (array_intersect(explode(',', $term[1]), $match) !== []) {
|
||||
$actor_expr[] = $eb->eq('actor.type', $type);
|
||||
} else {
|
||||
$actor_expr[] = $eb->neq('actor.type', $type);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$note_expr = $eb->contains('note.content', $term);
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
}
|
||||
|
@@ -51,10 +51,7 @@ class Feeds extends FeedController
|
||||
public function network(Request $request): array
|
||||
{
|
||||
Common::ensureLoggedIn();
|
||||
$data = $this->query(
|
||||
query: 'note-local:false',
|
||||
language: Common::actor()?->getTopLanguage()?->getLocale(),
|
||||
);
|
||||
$data = $this->query('note-local:false');
|
||||
return [
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m('Meteorites'),
|
||||
@@ -71,6 +68,7 @@ class Feeds extends FeedController
|
||||
public function clique(Request $request): array
|
||||
{
|
||||
Common::ensureLoggedIn();
|
||||
// TODO: maybe make this a Collection::query
|
||||
$notes = DB::dql(
|
||||
<<<'EOF'
|
||||
SELECT n FROM \App\Entity\Note AS n
|
||||
@@ -99,10 +97,7 @@ class Feeds extends FeedController
|
||||
public function federated(Request $request): array
|
||||
{
|
||||
Common::ensureLoggedIn();
|
||||
$data = $this->query(
|
||||
query: '',
|
||||
language: Common::actor()?->getTopLanguage()?->getLocale(),
|
||||
);
|
||||
$data = $this->query('notes-all:yeah');
|
||||
return [
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m('Galaxy'),
|
||||
|
@@ -20,7 +20,7 @@ class HostMeta extends XrdController
|
||||
|
||||
public function setXRD()
|
||||
{
|
||||
if (Event::handle('StartHostMetaLinks', [&$this->xrd->links])) {
|
||||
if (Event::handle('StartHostMetaLinks', [&$this->xrd->links]) !== Event::stop) {
|
||||
Event::handle('EndHostMetaLinks', [&$this->xrd->links]);
|
||||
}
|
||||
}
|
||||
|
@@ -32,33 +32,38 @@ use App\Core\UserRoles;
|
||||
use App\Entity as E;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\NicknameEmptyException;
|
||||
use App\Util\Exception\NicknameInvalidException;
|
||||
use App\Util\Exception\NicknameNotAllowedException;
|
||||
use App\Util\Exception\NicknameTakenException;
|
||||
use App\Util\Exception\NicknameTooLongException;
|
||||
use App\Util\Exception\NoLoggedInUser;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use App\Util\Form\ActorForms;
|
||||
use App\Util\Nickname;
|
||||
use Component\Collection\Util\ActorControllerTrait;
|
||||
use Component\Collection\Util\Controller\FeedController;
|
||||
use Component\Group\Entity\GroupMember;
|
||||
use Component\Group\Entity\LocalGroup;
|
||||
use Component\Subscription\Entity\Subscription;
|
||||
use Component\Subscription\Entity\ActorSubscription;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Group extends FeedController
|
||||
{
|
||||
use ActorControllerTrait;
|
||||
public function groupViewId(Request $request, int $id)
|
||||
{
|
||||
return $this->handleActorById(
|
||||
$id,
|
||||
fn ($actor) => [
|
||||
'_template' => 'group/view.html.twig',
|
||||
'actor' => $actor,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* View a group feed and give the option of creating it if it doesn't exist
|
||||
* View a group feed by its nickname
|
||||
*
|
||||
* @param string $nickname The group's nickname to be shown
|
||||
*
|
||||
* @throws NicknameEmptyException
|
||||
* @throws NicknameNotAllowedException
|
||||
* @throws NicknameTakenException
|
||||
* @throws NicknameTooLongException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function groupViewNickname(Request $request, string $nickname)
|
||||
{
|
||||
@@ -67,74 +72,27 @@ class Group extends FeedController
|
||||
$actor = Common::actor();
|
||||
$subscribe_form = null;
|
||||
|
||||
if (\is_null($group)) {
|
||||
if (!\is_null($actor)) {
|
||||
$create_form = Form::create([
|
||||
['create', SubmitType::class, ['label' => _m('Create this group')]],
|
||||
]);
|
||||
|
||||
$create_form->handleRequest($request);
|
||||
if ($create_form->isSubmitted() && $create_form->isValid()) {
|
||||
Log::info(
|
||||
_m(
|
||||
'Actor id:{actor_id} nick:{actor_nick} created the group {nickname}',
|
||||
['{actor_id}' => $actor->getId(), 'actor_nick' => $actor->getNickname(), 'nickname' => $nickname],
|
||||
),
|
||||
);
|
||||
|
||||
DB::persist($group = E\Actor::create([
|
||||
'nickname' => $nickname,
|
||||
'type' => E\Actor::GROUP,
|
||||
'is_local' => true,
|
||||
'roles' => UserRoles::BOT,
|
||||
]));
|
||||
DB::persist(LocalGroup::create([
|
||||
'group_id' => $group->getId(),
|
||||
'nickname' => $nickname,
|
||||
]));
|
||||
DB::persist(Subscription::create([
|
||||
'subscriber' => $group->getId(),
|
||||
'subscribed' => $group->getId(),
|
||||
]));
|
||||
DB::persist(GroupMember::create([
|
||||
'group_id' => $group->getId(),
|
||||
'actor_id' => $actor->getId(),
|
||||
'is_admin' => true,
|
||||
]));
|
||||
DB::flush();
|
||||
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscriber']);
|
||||
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
|
||||
throw new RedirectException();
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'group/view.html.twig',
|
||||
'nickname' => $nickname,
|
||||
'create_form' => $create_form->createView(),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
if (!\is_null($actor)
|
||||
&& \is_null(Cache::get(
|
||||
Subscription::cacheKeys($actor, $group)['subscribed'],
|
||||
fn () => DB::findOneBy('subscription', [
|
||||
'subscriber' => $actor->getId(),
|
||||
'subscribed' => $group->getId(),
|
||||
], return_null: true),
|
||||
))
|
||||
) {
|
||||
$subscribe_form = Form::create([['subscribe', SubmitType::class, ['label' => _m('Subscribe to this group')]]]);
|
||||
$subscribe_form->handleRequest($request);
|
||||
if ($subscribe_form->isSubmitted() && $subscribe_form->isValid()) {
|
||||
DB::persist(Subscription::create([
|
||||
'subscriber' => $actor->getId(),
|
||||
'subscribed' => $group->getId(),
|
||||
]));
|
||||
DB::flush();
|
||||
Cache::delete(E\Actor::cacheKeys($group->getId())['subscriber']);
|
||||
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
|
||||
Cache::delete(Subscription::cacheKeys($actor, $group)['subscribed']);
|
||||
}
|
||||
if (!\is_null($group)
|
||||
&& !\is_null($actor)
|
||||
&& \is_null(Cache::get(
|
||||
ActorSubscription::cacheKeys($actor, $group)['subscribed'],
|
||||
fn () => DB::findOneBy('actor_subscription', [
|
||||
'subscriber_id' => $actor->getId(),
|
||||
'subscribed_id' => $group->getId(),
|
||||
], return_null: true),
|
||||
))
|
||||
) {
|
||||
$subscribe_form = Form::create([['subscribe', SubmitType::class, ['label' => _m('Subscribe to this group')]]]);
|
||||
$subscribe_form->handleRequest($request);
|
||||
if ($subscribe_form->isSubmitted() && $subscribe_form->isValid()) {
|
||||
DB::persist(ActorSubscription::create([
|
||||
'subscriber_id' => $actor->getId(),
|
||||
'subscribed_id' => $group->getId(),
|
||||
]));
|
||||
DB::flush();
|
||||
Cache::delete(E\Actor::cacheKeys($group->getId())['subscribers']);
|
||||
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
|
||||
Cache::delete(ActorSubscription::cacheKeys($actor, $group)['subscribed']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,15 +116,93 @@ class Group extends FeedController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Page that allows an actor to create a new group
|
||||
*
|
||||
* @throws RedirectException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function groupCreate(Request $request)
|
||||
{
|
||||
if (\is_null($actor = Common::actor())) {
|
||||
throw new RedirectException('security_login');
|
||||
}
|
||||
|
||||
$create_form = Form::create([
|
||||
['group_nickname', TextType::class, ['label' => _m('Group nickname')]],
|
||||
['group_create', SubmitType::class, ['label' => _m('Create this group!')]],
|
||||
]);
|
||||
|
||||
$create_form->handleRequest($request);
|
||||
if ($create_form->isSubmitted() && $create_form->isValid()) {
|
||||
$data = $create_form->getData();
|
||||
$nickname = $data['group_nickname'];
|
||||
|
||||
Log::info(
|
||||
_m(
|
||||
'Actor id:{actor_id} nick:{actor_nick} created the group {nickname}',
|
||||
['{actor_id}' => $actor->getId(), 'actor_nick' => $actor->getNickname(), 'nickname' => $nickname],
|
||||
),
|
||||
);
|
||||
|
||||
DB::persist($group = E\Actor::create([
|
||||
'nickname' => $nickname,
|
||||
'type' => E\Actor::GROUP,
|
||||
'is_local' => true,
|
||||
'roles' => UserRoles::BOT,
|
||||
]));
|
||||
DB::persist(LocalGroup::create([
|
||||
'group_id' => $group->getId(),
|
||||
'nickname' => $nickname,
|
||||
]));
|
||||
DB::persist(ActorSubscription::create([
|
||||
'subscriber_id' => $group->getId(),
|
||||
'subscribed_id' => $group->getId(),
|
||||
]));
|
||||
DB::persist(GroupMember::create([
|
||||
'group_id' => $group->getId(),
|
||||
'actor_id' => $actor->getId(),
|
||||
'is_admin' => true,
|
||||
]));
|
||||
DB::flush();
|
||||
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribers']);
|
||||
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
|
||||
|
||||
throw new RedirectException();
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'group/create.html.twig',
|
||||
'create_form' => $create_form->createView(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings page for the group with the provided nickname, checks if the current actor can administrate given group
|
||||
*
|
||||
* @throws ClientException
|
||||
* @throws NicknameEmptyException
|
||||
* @throws NicknameInvalidException
|
||||
* @throws NicknameNotAllowedException
|
||||
* @throws NicknameTakenException
|
||||
* @throws NicknameTooLongException
|
||||
* @throws NoLoggedInUser
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function groupSettings(Request $request, string $nickname)
|
||||
{
|
||||
$group = LocalGroup::getActorByNickname($nickname);
|
||||
$actor = Common::actor();
|
||||
if (!\is_null($group) && $actor->canAdmin($group)) {
|
||||
$local_group = LocalGroup::getByNickname($nickname);
|
||||
$group_actor = $local_group->getActor();
|
||||
$actor = Common::actor();
|
||||
if (!\is_null($group_actor) && $actor->canAdmin($group_actor)) {
|
||||
return [
|
||||
'_template' => 'group/settings.html.twig',
|
||||
'group' => $group,
|
||||
'personal_info_form' => ActorForms::personalInfo($request, $group)->createView(),
|
||||
'group' => $group_actor,
|
||||
'personal_info_form' => ActorForms::personalInfo($request, $actor, $local_group)->createView(),
|
||||
'open_details_query' => $this->string('open'),
|
||||
];
|
||||
} else {
|
||||
|
@@ -21,10 +21,18 @@ declare(strict_types = 1);
|
||||
|
||||
namespace Component\Group\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Exception\NicknameEmptyException;
|
||||
use App\Util\Exception\NicknameException;
|
||||
use App\Util\Exception\NicknameInvalidException;
|
||||
use App\Util\Exception\NicknameNotAllowedException;
|
||||
use App\Util\Exception\NicknameTakenException;
|
||||
use App\Util\Exception\NicknameTooLongException;
|
||||
use App\Util\Nickname;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
@@ -114,6 +122,29 @@ class LocalGroup extends Entity
|
||||
return $res === [] ? null : $res[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if desired nickname is allowed, and in case it is, it sets Actor's nickname cache to newly set nickname
|
||||
*
|
||||
* @param string $nickname Desired NEW nickname (do not use in local user creation)
|
||||
*
|
||||
* @throws NicknameEmptyException
|
||||
* @throws NicknameException
|
||||
* @throws NicknameInvalidException
|
||||
* @throws NicknameNotAllowedException
|
||||
* @throws NicknameTakenException
|
||||
* @throws NicknameTooLongException
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setNicknameSanitizedAndCached(string $nickname): self
|
||||
{
|
||||
$nickname = Nickname::normalize($nickname, check_already_used: true, which: Nickname::CHECK_LOCAL_GROUP, check_is_allowed: true);
|
||||
$this->setNickname($nickname);
|
||||
$this->getActor()->setNickname($nickname);
|
||||
/// XXX: cache?
|
||||
return $this;
|
||||
}
|
||||
|
||||
public static function schemaDef(): array
|
||||
{
|
||||
return [
|
||||
|
@@ -30,16 +30,16 @@ 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
|
||||
{
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$r->connect(id: 'group_actor_view_id', uri_path: '/group/{id<\d+>}', target: [C\Group::class, 'groupViewId']);
|
||||
$r->connect(id: 'group_create', uri_path: '/group/new', target: [C\Group::class, 'groupCreate']);
|
||||
$r->connect(id: 'group_actor_view_nickname', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\Group::class, 'groupViewNickname'], options: ['is_system_path' => false]);
|
||||
$r->connect(id: 'group_settings', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}/settings', target: [C\Group::class, 'groupSettings'], options: ['is_system_path' => false]);
|
||||
return Event::next;
|
||||
@@ -54,7 +54,7 @@ class Group extends Component
|
||||
$group = $vars['actor'];
|
||||
if (!\is_null($actor) && $group->isGroup() && $actor->canAdmin($group)) {
|
||||
$url = Router::url('group_settings', ['nickname' => $group->getNickname()]);
|
||||
$res[] = HTML::html(['hr' => '', 'a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings')], 'p' => _m('Group settings')]]);
|
||||
$res[] = HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings'), 'class' => 'profile-extra-actions'], _m('Group settings')]]);
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
@@ -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;
|
||||
|
5
components/Group/templates/group/create.html.twig
Normal file
5
components/Group/templates/group/create.html.twig
Normal file
@@ -0,0 +1,5 @@
|
||||
{% extends 'stdgrid.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
{{ form(create_form) }}
|
||||
{% endblock body %}
|
@@ -10,41 +10,56 @@
|
||||
{% endblock stylesheets %}
|
||||
|
||||
{% block body %}
|
||||
{% if subscribe_form is defined and subscribe_form is not null %}
|
||||
{{ form(subscribe_form) }}
|
||||
{% endif %}
|
||||
|
||||
{% if actor is defined and actor is not null %}
|
||||
{% block profile_view %}
|
||||
{% include 'cards/profile/view.html.twig' with { 'actor': actor } only %}
|
||||
{% endblock profile_view %}
|
||||
|
||||
<main class="feed" tabindex="0" role="feed">
|
||||
<div class="h-feed hfeed notes">
|
||||
{% if notes is defined and notes is not empty %}
|
||||
{% for conversation in notes %}
|
||||
{% block current_note %}
|
||||
{% if conversation is instanceof('array') %}
|
||||
{{ noteView.macro_note(conversation['note'], conversation['replies']) }}
|
||||
{% else %}
|
||||
{{ noteView.macro_note(conversation) }}
|
||||
{% endif %}
|
||||
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
|
||||
{% endblock current_note %}
|
||||
{% endfor %}
|
||||
{% if notes is defined %}
|
||||
<article>
|
||||
<header class="feed-header">
|
||||
{% if page_title is defined %}
|
||||
<h1 class="heading-no-margin">{{ page_title | trans }}</h1>
|
||||
{% else %}
|
||||
<h1 class="heading-no-margin">{{ 'Notes' | trans }}</h1>
|
||||
{% endif %}
|
||||
<nav class="feed-actions">
|
||||
<details class="feed-actions-details">
|
||||
<summary>
|
||||
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
|
||||
</summary>
|
||||
<div class="feed-actions-details-dropdown">
|
||||
<menu>
|
||||
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
</menu>
|
||||
</div>
|
||||
</details>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% if notes is not empty %}
|
||||
{# Backwards compatibility with hAtom 0.1 #}
|
||||
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
|
||||
{% for conversation in notes %}
|
||||
{% block current_note %}
|
||||
{% if conversation is instanceof('array') %}
|
||||
{{ noteView.macro_note(conversation['note'], conversation['replies']) }}
|
||||
{% else %}
|
||||
{{ noteView.macro_note(conversation) }}
|
||||
{% endif %}
|
||||
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
|
||||
{% endblock current_note %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<div id="empty-notes"><h1>{% trans %}No notes here.{% endtrans %}</h1></div>
|
||||
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
|
||||
<strong>{% trans %}No notes yet...{% endtrans %}</strong>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% else %}
|
||||
<div class="section-padding section-widget">
|
||||
<p>{% trans with { '%group%': nickname } %}The group <em>%group%</em> doesn't exist.{% endtrans %}</p>
|
||||
{% if create_form is defined and create_form is not null %}
|
||||
<p>{% trans %}Would you like to create it?{% endtrans %}</p>
|
||||
{{ form(create_form) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock body %}
|
||||
|
@@ -139,7 +139,7 @@ class Language extends Controller
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'settings/sort_languages.html.twig',
|
||||
'_template' => 'language/sort.html.twig',
|
||||
'form' => $form->createView(),
|
||||
];
|
||||
}
|
||||
|
@@ -60,7 +60,7 @@ class Language extends Component
|
||||
/**
|
||||
* Populate $note_expr or $actor_expr with an expression to match a language
|
||||
*/
|
||||
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr): bool
|
||||
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
|
||||
{
|
||||
$search_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
|
||||
|
||||
@@ -103,7 +103,7 @@ class Language extends Component
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
{
|
||||
$note_qb->leftJoin('Component\Language\Entity\Language', 'note_language', Expr\Join::WITH, 'note.language_id = note_language.id')
|
||||
->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'note.actor_id = actor_language.actor_id')
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="section-widget section-padding">
|
||||
<div class="frame-section frame-section-padding">
|
||||
<h3>{{ 'Put the languages in the order you\'d like to see them in your language selection dropdown, when posting' | trans}}</h3>
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
|
@@ -77,7 +77,7 @@ class LeftPanel extends Component
|
||||
*/
|
||||
public function onEndShowStyles(array &$styles, string $route): bool
|
||||
{
|
||||
$styles[] = 'components/Left/assets/css/view.css';
|
||||
$styles[] = 'components/LeftPanel/assets/css/view.css';
|
||||
return Event::next;
|
||||
}
|
||||
}
|
||||
|
@@ -5,35 +5,49 @@
|
||||
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
|
||||
{% endblock stylesheets %}
|
||||
|
||||
{% macro edit_feeds_form_row(child) %}
|
||||
<div class="form-row">
|
||||
{{ form_label(child) }}
|
||||
{{ form_widget(child) }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block body %}
|
||||
<div class="section-widget">
|
||||
<div class="frame-section">
|
||||
<form class="section-form" action="{{ path('edit_feeds') }}" method="post">
|
||||
|
||||
<fieldset>
|
||||
<legend class="section-form-legend">{{ "Edit feed navigation links" | trans }}</legend>
|
||||
|
||||
<h1 class="frame-section-title">{{ "Edit feed navigation links" | trans }}</h1>
|
||||
{# Since the form is not separated into individual groups, this happened #}
|
||||
{{ form_start(edit_feeds) }}
|
||||
{% for child in edit_feeds.children %}
|
||||
{% if 'row_url' in child.vars.block_prefixes %}
|
||||
<div class="section-widget section-padding">
|
||||
{{ form_label(child) }}
|
||||
{{ form_widget(child) }}
|
||||
{% elseif 'row_title' in child.vars.block_prefixes %}
|
||||
{{ form_label(child) }}
|
||||
{{ form_widget(child) }}
|
||||
{% elseif 'row_order' in child.vars.block_prefixes %}
|
||||
{{ form_label(child) }}
|
||||
{{ form_widget(child) }}
|
||||
{% elseif 'row_remove' in child.vars.block_prefixes %}
|
||||
{{ form_label(child) }}
|
||||
{{ form_widget(child) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ form_errors(edit_feeds) }}
|
||||
<section class="container-grid">
|
||||
{% for child in edit_feeds.children %}
|
||||
{% if 'row_url' in child.vars.block_prefixes %}
|
||||
<div class="frame-section frame-section-padding">
|
||||
{{ _self.edit_feeds_form_row(child) }}
|
||||
{% elseif 'row_title' in child.vars.block_prefixes %}
|
||||
{{ _self.edit_feeds_form_row(child) }}
|
||||
{% elseif 'row_order' in child.vars.block_prefixes %}
|
||||
{{ _self.edit_feeds_form_row(child) }}
|
||||
{% elseif 'row_remove' in child.vars.block_prefixes %}
|
||||
{{ _self.edit_feeds_form_row(child) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<div class="form-row">
|
||||
{{ form_row(edit_feeds.update_exisiting) }}
|
||||
{{ form_row(edit_feeds.reset) }}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<section class="frame-section frame-section-padding">
|
||||
<h2>{% trans %}Add a new feed{% endtrans %}</h2>
|
||||
{{ form_rest(edit_feeds) }}
|
||||
</section>
|
||||
{{ form_end(edit_feeds) }}
|
||||
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
@@ -1,25 +1,24 @@
|
||||
{% block leftpanel %}
|
||||
<label class="panel-left-icon" for="panel-left-toggle" aria-hidden="true"
|
||||
tabindex="-1">{{ icon('menu', 'icon icon-left') | raw }}</label>
|
||||
<input type="checkbox" id="panel-left-toggle" aria-hidden="true" tabindex="-1">
|
||||
<label class="panel-left-icon" for="toggle-panel-left" tabindex="-1">{{ icon('menu', 'icon icon-left') | raw }}</label>
|
||||
<a id="anchor-left-panel" class="anchor-hidden" tabindex="0" title="{{ 'Press tab followed by a space to access left panel' | trans }}"></a>
|
||||
<input type="checkbox" id="toggle-panel-left" tabindex="0" title="{{ 'Open left panel' | trans }}">
|
||||
|
||||
<section class="header-panel section-panel-left">
|
||||
<a id="anchor-left-panel" class="anchor-hidden" title="{{ 'Press tab to access selected region!' | trans }}"></a>
|
||||
<aside class="panel-content accessibility-target">
|
||||
{% if app.user %}
|
||||
<section class='section-widget section-padding' title="{{ 'Your profile information.' | trans }}">
|
||||
{% block profile_view %}{% include 'cards/profile/view.html.twig' with { actor: current_actor } %}{% endblock profile_view %}
|
||||
{{ block("profile_current_actor", "cards/navigation/view.html.twig") }}
|
||||
</section>
|
||||
{% else %}
|
||||
<section>
|
||||
{{ block("profile_security", "cards/navigation/view.html.twig") }}
|
||||
</section>
|
||||
{% endif %}
|
||||
<aside class="section-panel section-panel-left">
|
||||
<section class="panel-content accessibility-target">
|
||||
{% if app.user %}
|
||||
<section class='frame-section frame-section-padding' title="{{ 'Your profile information.' | trans }}">
|
||||
{% block profile_view %}{% include 'cards/profile/view.html.twig' with { actor: current_actor } %}{% endblock profile_view %}
|
||||
{{ block("profile_current_actor", "cards/navigation/view.html.twig") }}
|
||||
</section>
|
||||
{% else %}
|
||||
<section>
|
||||
{{ block("profile_security", "cards/navigation/view.html.twig") }}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{{ block("feeds", "cards/navigation/view.html.twig") }}
|
||||
{{ block("feeds", "cards/navigation/view.html.twig") }}
|
||||
|
||||
{{ block("footer", "cards/navigation/view.html.twig") }}
|
||||
</aside>
|
||||
{{ block("footer", "cards/navigation/view.html.twig") }}
|
||||
</section>
|
||||
</aside>
|
||||
{% endblock leftpanel %}
|
||||
|
@@ -89,11 +89,14 @@ class Notification extends Component
|
||||
}
|
||||
if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) {
|
||||
// TODO: use https://symfony.com/doc/current/notifier.html
|
||||
DB::persist(Entity\Notification::create([
|
||||
// XXX: Unideal as in failures the rollback will leave behind a false notification,
|
||||
// but most notifications (all) require flushing the objects first
|
||||
// Should be okay as long as implementors bear this in mind
|
||||
DB::wrapInTransaction(fn() => DB::persist(Entity\Notification::create([
|
||||
'activity_id' => $activity->getId(),
|
||||
'target_id' => $target->getId(),
|
||||
'reason' => $reason,
|
||||
]));
|
||||
])));
|
||||
}
|
||||
} else {
|
||||
// We have no authority nor responsibility of notifying remote actors of a remote actor's doing
|
||||
|
@@ -30,7 +30,6 @@ use App\Core\GSFile;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\Router;
|
||||
use App\Core\Security;
|
||||
use App\Core\VisibilityScope;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Actor;
|
||||
@@ -43,6 +42,7 @@ use App\Util\Exception\RedirectException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use App\Util\Form\FormFields;
|
||||
use App\Util\Formatting;
|
||||
use App\Util\HTML;
|
||||
use Component\Attachment\Entity\ActorToAttachment;
|
||||
use Component\Attachment\Entity\AttachmentToNote;
|
||||
use Component\Conversation\Conversation;
|
||||
@@ -74,8 +74,7 @@ class Posting extends Component
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
$actor = $user->getActor();
|
||||
$actor_id = $user->getId();
|
||||
$actor = $user->getActor();
|
||||
|
||||
$placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
|
||||
Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
|
||||
@@ -97,6 +96,8 @@ class Posting extends Component
|
||||
|
||||
$form_params = [];
|
||||
if (!empty($in_targets)) { // @phpstan-ignore-line
|
||||
// Add "none" option to the top of choices
|
||||
$in_targets = array_merge([_m('Public') => 'public'], $in_targets);
|
||||
$form_params[] = ['in', ChoiceType::class, ['label' => _m('In:'), 'multiple' => false, 'expanded' => false, 'choices' => $in_targets]];
|
||||
}
|
||||
|
||||
@@ -145,13 +146,17 @@ class Posting extends Component
|
||||
$extra_args = [];
|
||||
Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form_params, $form]);
|
||||
|
||||
if (\array_key_exists('in', $data) && $data['in'] !== 'public') {
|
||||
$target = $data['in'];
|
||||
}
|
||||
|
||||
self::storeLocalNote(
|
||||
actor: $user->getActor(),
|
||||
content: $data['content'],
|
||||
content_type: $content_type,
|
||||
language: $data['language'],
|
||||
locale: $data['language'],
|
||||
scope: VisibilityScope::from($data['visibility']),
|
||||
target: $data['in'] ?? $context_actor,
|
||||
target: $target ?? null, // @phpstan-ignore-line
|
||||
reply_to_id: $data['reply_to_id'],
|
||||
attachments: $data['attachments'],
|
||||
process_note_content_extra_args: $extra_args,
|
||||
@@ -199,20 +204,21 @@ class Posting extends Component
|
||||
Actor $actor,
|
||||
?string $content,
|
||||
string $content_type,
|
||||
?string $language = null,
|
||||
?string $locale = null,
|
||||
?VisibilityScope $scope = null,
|
||||
null|int|Actor $target = null,
|
||||
null|Actor|int $target = null,
|
||||
?int $reply_to_id = null,
|
||||
array $attachments = [],
|
||||
array $processed_attachments = [],
|
||||
array $process_note_content_extra_args = [],
|
||||
bool $notify = true,
|
||||
?string $rendered = null,
|
||||
string $source = 'web',
|
||||
): Note {
|
||||
$scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL
|
||||
$rendered = null;
|
||||
$mentions = [];
|
||||
if (!empty($content)) {
|
||||
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $language, &$mentions]);
|
||||
if (\is_null($rendered) && !empty($content)) {
|
||||
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]);
|
||||
}
|
||||
|
||||
$note = Note::create([
|
||||
@@ -220,10 +226,11 @@ class Posting extends Component
|
||||
'content' => $content,
|
||||
'content_type' => $content_type,
|
||||
'rendered' => $rendered,
|
||||
'language_id' => !\is_null($language) ? Language::getByLocale($language)->getId() : null,
|
||||
'language_id' => !\is_null($locale) ? Language::getByLocale($locale)->getId() : null,
|
||||
'is_local' => true,
|
||||
'scope' => $scope,
|
||||
'reply_to' => $reply_to_id,
|
||||
'source' => $source,
|
||||
]);
|
||||
|
||||
/** @var UploadedFile[] $attachments */
|
||||
@@ -252,6 +259,7 @@ class Posting extends Component
|
||||
DB::persist(ActorToAttachment::create($args));
|
||||
}
|
||||
DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname]));
|
||||
$a->livesIncrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,12 +270,12 @@ class Posting extends Component
|
||||
'verb' => 'create',
|
||||
'object_type' => 'note',
|
||||
'object_id' => $note->getId(),
|
||||
'source' => 'web',
|
||||
'source' => $source,
|
||||
]);
|
||||
DB::persist($activity);
|
||||
|
||||
if (!\is_null($target)) {
|
||||
$target = is_numeric($target) ? Actor::getById((int) $target) : $target;
|
||||
$target = \is_int($target) ? Actor::getById($target) : $target;
|
||||
$mentions[] = [
|
||||
'mentioned' => [$target],
|
||||
'type' => match ($target->getType()) {
|
||||
@@ -281,6 +289,7 @@ class Posting extends Component
|
||||
|
||||
$mention_ids = F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId())));
|
||||
|
||||
// Flush before notification
|
||||
DB::flush();
|
||||
|
||||
if ($notify) {
|
||||
@@ -299,7 +308,7 @@ class Posting extends Component
|
||||
return Event::stop;
|
||||
case 'text/html':
|
||||
// TODO: It has to linkify and stuff as well
|
||||
$rendered = Security::sanitize($content);
|
||||
$rendered = HTML::sanitize($content);
|
||||
return Event::stop;
|
||||
default:
|
||||
return Event::next;
|
||||
|
@@ -35,7 +35,7 @@ class RightPanel extends Component
|
||||
*/
|
||||
public function onEndShowStyles(array &$styles, string $route): bool
|
||||
{
|
||||
$styles[] = 'components/Right/assets/css/view.css';
|
||||
$styles[] = 'components/RightPanel/assets/css/view.css';
|
||||
return Event::next;
|
||||
}
|
||||
}
|
||||
|
@@ -1,62 +1,65 @@
|
||||
{% block rightpanel %}
|
||||
<label class="panel-right-icon" for="panel-right-toggle" aria-hidden="true"
|
||||
tabindex="-1">{{ icon('chevron-left', 'icon icon-right') | raw }}</label>
|
||||
<input type="checkbox" id="panel-right-toggle" aria-hidden="true" tabindex="-1">
|
||||
<label class="panel-right-icon" for="toggle-panel-right" tabindex="-1">{{ icon('chevron-left', 'icon icon-right') | raw }}</label>
|
||||
<a id="anchor-right-panel" class="anchor-hidden" tabindex="0" title="{{ 'Press tab followed by a space to access right panel' | trans }}"></a>
|
||||
<input type="checkbox" id="toggle-panel-right" tabindex="0" title="{{ 'Open right panel' | trans }}">
|
||||
|
||||
<div class="header-panel section-panel-right">
|
||||
<a id="anchor-right-panel" class="anchor-hidden" title="{{ 'Press tab to access selected region!' | trans }}"></a>
|
||||
<aside class="panel-content accessibility-target">
|
||||
<aside class="section-panel section-panel-right">
|
||||
<section class="panel-content accessibility-target">
|
||||
{% set prepend_right_panel = handle_event('PrependRightPanel', request) %}
|
||||
{% for widget in prepend_right_panel %}
|
||||
{{ widget | raw }}
|
||||
{% endfor %}
|
||||
|
||||
{% set blocks = handle_event('AppendRightPostingBlock', request) %}
|
||||
{% if blocks['post_form'] is defined %}
|
||||
<section class="section-widget" title="{{ 'Create a new note.' | trans }}">
|
||||
<details class="section-widget-title-details" open="open"
|
||||
title="{{ 'Expand if you want to access more options.' | trans }}">
|
||||
<summary class="section-title-summary">
|
||||
<h2>
|
||||
{% set current_path = app.request.get('_route') %}
|
||||
{% if current_path == 'conversation_reply_to' %}
|
||||
{{ "Reply to note" | trans }}
|
||||
{% else %}
|
||||
{{ "Create a note" | trans }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
|
||||
</summary>
|
||||
{% set current_path = app.request.get('_route') %}
|
||||
{% set blocks = handle_event('AppendRightPostingBlock', request) %}
|
||||
{% if blocks['post_form'] is defined %}
|
||||
<section class="frame-section" title="{{ 'Create a new note.' | trans }}">
|
||||
<details class="section-details-title" open="open"
|
||||
title="{{ 'Expand if you want to access more options.' | trans }}">
|
||||
<summary class="details-summary-title">
|
||||
<h2>
|
||||
{% set current_path = app.request.get('_route') %}
|
||||
{% if current_path == 'conversation_reply_to' %}
|
||||
{{ "Reply to note" | trans }}
|
||||
{% else %}
|
||||
{{ "Create a note" | trans }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
</summary>
|
||||
|
||||
<div class="section-form">
|
||||
<fieldset>
|
||||
{{ form_start(blocks['post_form']) }}
|
||||
{% if blocks['post_form'].in is defined %}
|
||||
{{ form_row(blocks['post_form'].in) }}
|
||||
{% endif %}
|
||||
{{ form_row(blocks['post_form'].visibility) }}
|
||||
{{ form_row(blocks['post_form'].content) }}
|
||||
{{ form_row(blocks['post_form'].attachments) }}
|
||||
<section class="section-form">
|
||||
{{ form_start(blocks['post_form']) }}
|
||||
{{ form_errors(blocks['post_form']) }}
|
||||
{% if blocks['post_form'].in is defined %}
|
||||
{{ form_row(blocks['post_form'].in) }}
|
||||
{% endif %}
|
||||
{{ form_row(blocks['post_form'].visibility) }}
|
||||
{{ form_row(blocks['post_form'].content_type) }}
|
||||
{{ form_row(blocks['post_form'].content) }}
|
||||
{{ form_row(blocks['post_form'].attachments) }}
|
||||
|
||||
<details class="section-widget-subtitle-details">
|
||||
<summary class="section-subtitle-summary">
|
||||
<strong>
|
||||
{{ "Additional options" | trans }}
|
||||
</strong>
|
||||
{{ icon('arrow-down', 'icon icon-details-close') | raw }}
|
||||
</summary>
|
||||
{{ form_row(blocks['post_form'].language) }}
|
||||
{{ form_row(blocks['post_form'].tag_use_canonical) }}
|
||||
</details>
|
||||
{{ form_rest(blocks['post_form']) }}
|
||||
{{ form_end(blocks['post_form']) }}
|
||||
</fieldset>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
{% endif %}
|
||||
<details class="section-details-subtitle">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>
|
||||
{{ "Additional options" | trans }}
|
||||
</strong>
|
||||
</summary>
|
||||
<section class="section-form">
|
||||
{{ form_row(blocks['post_form'].language) }}
|
||||
{{ form_row(blocks['post_form'].tag_use_canonical) }}
|
||||
</section>
|
||||
</details>
|
||||
{{ form_rest(blocks['post_form']) }}
|
||||
{{ form_end(blocks['post_form']) }}
|
||||
</section>
|
||||
</details>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% set current_path = app.request.get('_route') %}
|
||||
{% for block in handle_event('AppendRightPanelBlock', {'path': current_path, 'request': request, 'vars': right_panel_vars | default }, request) %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
{% set extra_blocks = get_right_panel_blocks({'path': current_path, 'request': app.request, 'vars': (right_panel_vars | default)}) %}
|
||||
{% for block in extra_blocks %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
</section>
|
||||
</aside>
|
||||
{% endblock rightpanel %}
|
||||
|
@@ -48,7 +48,7 @@ class Search extends FeedController
|
||||
$language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null;
|
||||
$q = $this->string('q');
|
||||
|
||||
$data = $this->query(query: $q, language: $language);
|
||||
$data = $this->query(query: $q, locale: $language);
|
||||
$notes = $data['notes'];
|
||||
$actors = $data['actors'];
|
||||
|
||||
@@ -130,7 +130,7 @@ class Search extends FeedController
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'search/show.html.twig',
|
||||
'_template' => 'search/view.html.twig',
|
||||
'actor' => $actor,
|
||||
'search_form' => Comp\Search::searchForm($request, query: $q, add_subscribe: !\is_null($actor)),
|
||||
'search_builder_form' => $search_builder_form->createView(),
|
||||
|
@@ -29,6 +29,7 @@ use function App\Core\I18n\_m;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Formatting;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormView;
|
||||
@@ -83,7 +84,7 @@ class Search extends Component
|
||||
'label' => _m('Search'),
|
||||
'attr' => [
|
||||
//'class' => 'button-container search-button-container',
|
||||
'title' => _m('Query notes for specific tags.'),
|
||||
'title' => _m('Perform search'),
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -119,9 +120,9 @@ class Search extends Component
|
||||
*
|
||||
* @throws RedirectException
|
||||
*/
|
||||
public function onAddExtraHeaderForms(Request $request, array &$elements)
|
||||
public function onPrependRightPanel(Request $request, array &$elements)
|
||||
{
|
||||
$elements[] = self::searchForm($request);
|
||||
$elements[] = Formatting::twigRenderFile('cards/search/view.html.twig', ['search' => self::searchForm($request)]);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
|
6
components/Search/templates/cards/search/view.html.twig
Normal file
6
components/Search/templates/cards/search/view.html.twig
Normal file
@@ -0,0 +1,6 @@
|
||||
<section class="section-form form-search" title="{{ 'Search for notes, actors, and beyond' | trans }}">
|
||||
{{ form_start(search) }}
|
||||
<span>{{ form_row(search.search_query) }}{{ form_row(search.submit_search) }}</span>
|
||||
{{ form_rest(search) }}
|
||||
{{ form_end(search) }}
|
||||
</section>
|
@@ -1,91 +0,0 @@
|
||||
{% extends 'collection/notes.html.twig' %}
|
||||
|
||||
|
||||
{% block body %}
|
||||
{% if error is defined %}
|
||||
<label class="alert alert-danger">
|
||||
{{ error.getMessage() }}
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-padding">
|
||||
{{ form_start(search_form) }}
|
||||
<div>
|
||||
{{ form_row(search_form.search_query) }}
|
||||
{{ form_row(search_form.submit_search) }}
|
||||
</div>
|
||||
{% if actor is not null %}
|
||||
<details class="section-widget">
|
||||
<summary>
|
||||
<h3>
|
||||
{% trans %}Save this search as a feed{% endtrans %}
|
||||
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
|
||||
</h3>
|
||||
</summary>
|
||||
{{ form_row(search_form.title) }}
|
||||
{{ form_row(search_form.subscribe_to_search) }}
|
||||
</details>
|
||||
{% endif %}
|
||||
{{ form_end(search_form)}}
|
||||
<hr>
|
||||
<details class="section-widget">
|
||||
<summary>
|
||||
<h3>
|
||||
{% trans %}Build a search query{% endtrans %}
|
||||
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
|
||||
</h3>
|
||||
</summary>
|
||||
|
||||
{{ form_start(search_builder_form) }}
|
||||
|
||||
{# actor options, display if first checked, with checkbox trick #}
|
||||
<details class="section-widget">
|
||||
<summary>
|
||||
<h3>
|
||||
{% trans %}People search options{% endtrans %}
|
||||
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
|
||||
</h3>
|
||||
</summary>
|
||||
{{ form_row(search_builder_form.include_actors) }}
|
||||
{{ form_row(search_builder_form.include_actors_people) }}
|
||||
{{ form_row(search_builder_form.include_actors_groups) }}
|
||||
{{ form_row(search_builder_form.include_actors_lists) }}
|
||||
{{ form_row(search_builder_form.include_actors_businesses) }}
|
||||
{{ form_row(search_builder_form.include_actors_organizations) }}
|
||||
{{ form_row(search_builder_form.include_actors_bots) }}
|
||||
{{ form_row(search_builder_form.actor_langs) }}
|
||||
{{ form_row(search_builder_form.actor_tags) }}
|
||||
</details>
|
||||
|
||||
<details class="section-widget">
|
||||
<summary>
|
||||
<h3>
|
||||
{% trans %}Note search options{% endtrans %}
|
||||
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
|
||||
</h3>
|
||||
</summary>
|
||||
{{ form_row(search_builder_form.include_notes) }}
|
||||
{{ form_row(search_builder_form.include_notes_text) }}
|
||||
{{ form_row(search_builder_form.include_notes_media) }}
|
||||
{{ form_row(search_builder_form.include_notes_polls) }}
|
||||
{{ form_row(search_builder_form.include_notes_bookmarks) }}
|
||||
{{ form_row(search_builder_form.note_langs) }}
|
||||
{{ form_row(search_builder_form.note_tags) }}
|
||||
{{ form_row(search_builder_form.note_actor_langs) }}
|
||||
{{ form_row(search_builder_form.note_actor_tags) }}
|
||||
</details>
|
||||
{{ form_end(search_builder_form) }}
|
||||
</details>
|
||||
<hr>
|
||||
</div>
|
||||
|
||||
{# Backwards compatibility with hAtom 0.1 #}
|
||||
{{ parent() }}
|
||||
|
||||
{% for actor in actors %}
|
||||
{% include 'cards/profile/view.html.twig' with {'actor': actor} %}
|
||||
{% endfor %}
|
||||
|
||||
{{ "Page: " ~ page }}
|
||||
{% endblock body %}
|
||||
|
119
components/Search/templates/search/view.html.twig
Normal file
119
components/Search/templates/search/view.html.twig
Normal file
@@ -0,0 +1,119 @@
|
||||
{% extends 'collection/notes.html.twig' %}
|
||||
|
||||
|
||||
{% block body %}
|
||||
{% if error is defined %}
|
||||
<label class="alert alert-danger">
|
||||
{{ error.getMessage() }}
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
<section class="frame-section frame-section-padding">
|
||||
<h2>{% trans %}Search{% endtrans %}</h2>
|
||||
|
||||
{{ form_start(search_form) }}
|
||||
<section class="frame-section section-form">
|
||||
{{ form_errors(search_form) }}
|
||||
{{ form_row(search_form.search_query) }}
|
||||
{% if actor is not null %}
|
||||
<details class="section-details-subtitle">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>{% trans %}Other options{% endtrans %}</strong>
|
||||
</summary>
|
||||
|
||||
<div class="section-form">
|
||||
<details class="section-details-subtitle">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>
|
||||
{% trans %}Save query as a feed{% endtrans %}
|
||||
</strong>
|
||||
</summary>
|
||||
<div class="section-form">
|
||||
{{ form_row(search_form.title) }}
|
||||
{{ form_row(search_form.subscribe_to_search) }}
|
||||
</div>
|
||||
<hr>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{{ form_row(search_form.submit_search) }}
|
||||
</section>
|
||||
{{ form_end(search_form)}}
|
||||
|
||||
<section class="frame-section">
|
||||
<details class="section-details-subtitle">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>{% trans %}Build a search query{% endtrans %}</strong>
|
||||
</summary>
|
||||
|
||||
{{ form_start(search_builder_form) }}
|
||||
<div class="section-form">
|
||||
{# actor options, display if first checked, with checkbox trick #}
|
||||
<details class="section-details-subtitle">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>{% trans %}People search options{% endtrans %}</strong>
|
||||
</summary>
|
||||
<div class="section-form">
|
||||
{{ form_row(search_builder_form.include_actors) }}
|
||||
{{ form_row(search_builder_form.include_actors_people) }}
|
||||
{{ form_row(search_builder_form.include_actors_groups) }}
|
||||
{{ form_row(search_builder_form.include_actors_lists) }}
|
||||
{{ form_row(search_builder_form.include_actors_businesses) }}
|
||||
{{ form_row(search_builder_form.include_actors_organizations) }}
|
||||
{{ form_row(search_builder_form.include_actors_bots) }}
|
||||
{{ form_row(search_builder_form.actor_langs) }}
|
||||
{{ form_row(search_builder_form.actor_tags) }}
|
||||
</div>
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<details class="section-details-subtitle">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>{% trans %}Note search options{% endtrans %}</strong>
|
||||
</summary>
|
||||
<div class="section-form">
|
||||
{{ form_row(search_builder_form.include_notes) }}
|
||||
{{ form_row(search_builder_form.include_notes_text) }}
|
||||
{{ form_row(search_builder_form.include_notes_media) }}
|
||||
{{ form_row(search_builder_form.include_notes_polls) }}
|
||||
{{ form_row(search_builder_form.include_notes_bookmarks) }}
|
||||
{{ form_row(search_builder_form.note_langs) }}
|
||||
{{ form_row(search_builder_form.note_tags) }}
|
||||
{{ form_row(search_builder_form.note_actor_langs) }}
|
||||
{{ form_row(search_builder_form.note_actor_tags) }}
|
||||
</div>
|
||||
<hr>
|
||||
</details>
|
||||
</div>
|
||||
{{ form_end(search_builder_form) }}
|
||||
</details>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="frame-section frame-section-padding">
|
||||
<h2>{% trans %}Results{% endtrans %}</h2>
|
||||
<div class="frame-section frame-section-padding feed-empty">
|
||||
{% if notes is defined and notes is not empty %}
|
||||
{{ parent() }}
|
||||
{% else %}
|
||||
<h3>{% trans %}No notes found{% endtrans %}</h3>
|
||||
<em>{% trans %}No notes were found for the specified query...{% endtrans %}</em>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="frame-section frame-section-padding feed-empty">
|
||||
<h3>{% trans %}Actors found{% endtrans %}</h3>
|
||||
{% if actors is defined and actors is not empty %}
|
||||
{% for actor in actors %}
|
||||
{% include 'cards/profile/view.html.twig' with {'actor': actor} %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<em>{% trans %}No Actors were found for the specified query...{% endtrans %}</em>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ "Page: " ~ page }}
|
||||
{% endblock body %}
|
||||
|
@@ -23,9 +23,19 @@ declare(strict_types = 1);
|
||||
|
||||
namespace Component\Subscription\Controller;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use Component\Collection\Util\ActorControllerTrait;
|
||||
use Component\Collection\Util\Controller\CircleController;
|
||||
use Component\Subscription\Subscription as SubscriptionComponent;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
@@ -49,13 +59,109 @@ class Subscribers extends CircleController
|
||||
return $this->handleActorByNickname(
|
||||
$nickname,
|
||||
fn ($actor) => [
|
||||
'_template' => 'collection/actors.html.twig',
|
||||
'title' => _m('Subscribers'),
|
||||
'empty_message' => _m('No subscribers'),
|
||||
'sort_options' => [],
|
||||
'page' => $this->int('page') ?? 1,
|
||||
'actors' => $actor->getSubscribers(),
|
||||
'_template' => 'collection/actors.html.twig',
|
||||
'title' => _m('Subscribers'),
|
||||
'empty_message' => _m('No subscribers.'),
|
||||
'sort_form_fields' => [],
|
||||
'page' => $this->int('page') ?? 1,
|
||||
'actors' => $actor->getSubscribers(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \App\Util\Exception\DuplicateFoundException
|
||||
* @throws \App\Util\Exception\NoLoggedInUser
|
||||
* @throws \App\Util\Exception\NotFoundException
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
* @throws ClientException
|
||||
* @throws RedirectException
|
||||
*/
|
||||
public function subscribersAdd(Request $request, int $object_id): array
|
||||
{
|
||||
$subject = Common::ensureLoggedIn();
|
||||
$object = Actor::getById($object_id);
|
||||
$form = Form::create(
|
||||
[
|
||||
['subscriber_add', SubmitType::class, ['label' => _m('Subscribe!')]],
|
||||
],
|
||||
);
|
||||
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (!\is_null(SubscriptionComponent::subscribe($subject, $object))) {
|
||||
DB::flush();
|
||||
SubscriptionComponent::refreshSubscriptionCount($subject, $object);
|
||||
}
|
||||
|
||||
// Redirect user to where they came from
|
||||
// Prevent open redirect
|
||||
if (!\is_null($from = $this->string('from'))) {
|
||||
if (Router::isAbsolute($from)) {
|
||||
Log::warning("Actor {$object_id} attempted to reply to a note and then get redirected to another host, or the URL was invalid ({$from})");
|
||||
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
|
||||
}
|
||||
|
||||
// TODO anchor on element id
|
||||
throw new RedirectException(url: $from);
|
||||
}
|
||||
|
||||
// If we don't have a URL to return to, go to the instance root
|
||||
throw new RedirectException('root');
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'subscription/add_subscriber.html.twig',
|
||||
'form' => $form->createView(),
|
||||
'object' => $object,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \App\Util\Exception\DuplicateFoundException
|
||||
* @throws \App\Util\Exception\NoLoggedInUser
|
||||
* @throws \App\Util\Exception\NotFoundException
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
* @throws ClientException
|
||||
* @throws RedirectException
|
||||
*/
|
||||
public function subscribersRemove(Request $request, int $object_id): array
|
||||
{
|
||||
$subject = Common::ensureLoggedIn();
|
||||
$object = Actor::getById($object_id);
|
||||
$form = Form::create(
|
||||
[
|
||||
['subscriber_remove', SubmitType::class, ['label' => _m('Unsubscribe')]],
|
||||
],
|
||||
);
|
||||
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (!\is_null(SubscriptionComponent::unsubscribe($subject, $object))) {
|
||||
DB::flush();
|
||||
SubscriptionComponent::refreshSubscriptionCount($subject, $object);
|
||||
}
|
||||
|
||||
// Redirect user to where they came from
|
||||
// Prevent open redirect
|
||||
if (!\is_null($from = $this->string('from'))) {
|
||||
if (Router::isAbsolute($from)) {
|
||||
Log::warning("Actor {$object_id} attempted to subscribe an actor and then get redirected to another host, or the URL was invalid ({$from})");
|
||||
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
|
||||
}
|
||||
|
||||
// TODO anchor on element id
|
||||
throw new RedirectException(url: $from);
|
||||
}
|
||||
|
||||
// If we don't have a URL to return to, go to the instance root
|
||||
throw new RedirectException('root');
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'subscription/remove_subscriber.html.twig',
|
||||
'form' => $form->createView(),
|
||||
'object' => $object,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -49,12 +49,12 @@ class Subscriptions extends CircleController
|
||||
return $this->handleActorByNickname(
|
||||
$nickname,
|
||||
fn ($actor) => [
|
||||
'_template' => 'collection/actors.html.twig',
|
||||
'title' => _m('Subscribers'),
|
||||
'empty_message' => _m('No subscribers'),
|
||||
'sort_options' => [],
|
||||
'page' => $this->int('page') ?? 1,
|
||||
'actors' => $actor->getSubscribers(),
|
||||
'_template' => 'collection/actors.html.twig',
|
||||
'title' => _m('Subscriptions'),
|
||||
'empty_message' => _m('Haven\'t subscribed anyone.'),
|
||||
'sort_form_fields' => [],
|
||||
'page' => $this->int('page') ?? 1,
|
||||
'actors' => $actor->getSubscribers(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@@ -41,7 +41,7 @@ use DateTimeInterface;
|
||||
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
class Subscription extends Entity
|
||||
class ActorSubscription extends Entity
|
||||
{
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
@@ -114,10 +114,31 @@ class Subscription extends Entity
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Entity->getNotificationTargetIds
|
||||
*/
|
||||
public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array
|
||||
{
|
||||
if (!\array_key_exists('object', $ids_already_known)) {
|
||||
$target_ids = [$this->getSubscribedId()]; // The object of any subscription is the one subscribed (or unsubscribed)
|
||||
} else {
|
||||
$target_ids = $ids_already_known['object'];
|
||||
}
|
||||
|
||||
// Additional actors that should know about this
|
||||
if ($include_additional && \array_key_exists('additional', $ids_already_known)) {
|
||||
array_push($target_ids, ...$ids_already_known['additional']);
|
||||
} else {
|
||||
return $target_ids;
|
||||
}
|
||||
|
||||
return array_unique($target_ids);
|
||||
}
|
||||
|
||||
public static function schemaDef(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'subscription',
|
||||
'name' => 'actor_subscription',
|
||||
'fields' => [
|
||||
'subscriber_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'subscription_subscriber_fkey', 'not null' => true, 'description' => 'actor listening'],
|
||||
'subscribed_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'subscription_subscribed_fkey', 'not null' => true, 'description' => 'actor being listened to'],
|
@@ -23,36 +23,84 @@ declare(strict_types = 1);
|
||||
|
||||
namespace Component\Subscription;
|
||||
|
||||
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\Activity;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\DuplicateFoundException;
|
||||
use App\Util\Exception\NotFoundException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use App\Util\Nickname;
|
||||
use Component\Subscription\Controller\Subscribers as SubscribersController;
|
||||
use Component\Subscription\Controller\Subscriptions as SubscriptionsController;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Subscription extends Component
|
||||
{
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$r->connect(id: 'actor_subscribe_add', uri_path: '/actor/subscribe/{object_id<\d+>}', target: [SubscribersController::class, 'subscribersAdd']);
|
||||
$r->connect(id: 'actor_subscribe_remove', uri_path: '/actor/unsubscribe/{object_id<\d+>}', target: [SubscribersController::class, 'subscribersRemove']);
|
||||
$r->connect(id: 'actor_subscriptions_id', uri_path: '/actor/{id<\d+>}/subscriptions', target: [SubscriptionsController::class, 'subscriptionsByActorId']);
|
||||
$r->connect(id: 'actor_subscriptions_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/subscriptions', target: [SubscriptionsController::class, 'subscriptionsByActorNickname']);
|
||||
$r->connect(id: 'actor_subscribers_id', uri_path: '/actor/{id<\d+>}/subscribers', target: [SubscribersController::class, 'subscribersByActorId']);
|
||||
$r->connect(id: 'actor_subscribers_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/subscribers', target: [SubscribersController::class, 'subscribersByActorNickname']);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a new Subscription Entity from Subscriber to Subject (Actor being subscribed) and Activity
|
||||
* To use after Subscribe/Unsubscribe and DB::flush()
|
||||
*
|
||||
* @param Actor|int|LocalUser $subject The Actor who subscribed or unsubscribed
|
||||
* @param Actor|int|LocalUser $object The Actor who was subscribed or unsubscribed from
|
||||
*/
|
||||
public static function refreshSubscriptionCount(int|Actor|LocalUser $subject, int|Actor|LocalUser $object): array
|
||||
{
|
||||
$subscriber_id = \is_int($subject) ? $subject : $subject->getId();
|
||||
$subscribed_id = \is_int($object) ? $object : $object->getId();
|
||||
|
||||
$cache_subscriber = Cache::delete(Actor::cacheKeys($subscriber_id)['subscribed']);
|
||||
$cache_subscribed = Cache::delete(Actor::cacheKeys($subscribed_id)['subscribers']);
|
||||
|
||||
return [$cache_subscriber,$cache_subscribed];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a new Subscription Entity from Subject to Object (Actor being subscribed) and Activity
|
||||
*
|
||||
* A new notification is then handled, informing all interested Actors of this action
|
||||
*
|
||||
* @param Actor|int|LocalUser $subject The actor performing the subscription
|
||||
* @param Actor|int|LocalUser $object The target of the subscription
|
||||
*
|
||||
* @throws DuplicateFoundException
|
||||
* @throws NotFoundException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return null|Activity a new Activity if changes were made
|
||||
*
|
||||
* @see self::refreshSubscriptionCount() to delete cache after this action
|
||||
*/
|
||||
public static function subscribe(int|Actor|LocalUser $subscriber, int|Actor|LocalUser $subject, string $source = 'web'): ?Activity
|
||||
public static function subscribe(int|Actor|LocalUser $subject, int|Actor|LocalUser $object, string $source = 'web'): ?Activity
|
||||
{
|
||||
$subscriber_id = \is_int($subscriber) ? $subscriber : $subscriber->getId();
|
||||
$subscribed_id = \is_int($subject) ? $subject : $subject->getId();
|
||||
$subscriber_id = \is_int($subject) ? $subject : $subject->getId();
|
||||
$subscribed_id = \is_int($object) ? $object : $object->getId();
|
||||
$opts = [
|
||||
'subscriber_id' => $subscriber_id,
|
||||
'subscribed_id' => $subscribed_id,
|
||||
];
|
||||
$subscription = DB::findOneBy(table: \Component\Subscription\Entity\Subscription::class, criteria: $opts, return_null: true);
|
||||
$subscription = DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true);
|
||||
$activity = null;
|
||||
if (\is_null($subscription)) {
|
||||
DB::persist(\Component\Subscription\Entity\Subscription::create($opts));
|
||||
DB::persist(Entity\ActorSubscription::create($opts));
|
||||
$activity = Activity::create([
|
||||
'actor_id' => $subscriber_id,
|
||||
'verb' => 'subscribe',
|
||||
@@ -63,31 +111,40 @@ class Subscription extends Component
|
||||
DB::persist($activity);
|
||||
|
||||
Event::handle('NewNotification', [
|
||||
$actor = ($subscriber instanceof Actor ? $subscriber : Actor::getById($subscribed_id)),
|
||||
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
|
||||
$activity,
|
||||
['object' => [$subscribed_id]],
|
||||
_m('{nickname} subscribed to {subject}.', ['{actor}' => $actor->getId(), '{subject}' => $activity->getObjectId()]),
|
||||
['object' => [$activity->getObjectId()]],
|
||||
_m('{subject} subscribed to {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $activity->getObjectId()]),
|
||||
]);
|
||||
}
|
||||
return $activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the Subscription Entity created beforehand, by the same Actor, and on the same subject
|
||||
* Removes the Subscription Entity created beforehand, by the same Actor, and on the same object
|
||||
*
|
||||
* Informs all interested Actors of this action, handling out the NewNotification event
|
||||
*
|
||||
* @param Actor|int|LocalUser $subject The actor undoing the subscription
|
||||
* @param Actor|int|LocalUser $object The target of the subscription
|
||||
*
|
||||
* @throws DuplicateFoundException
|
||||
* @throws NotFoundException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return null|Activity a new Activity if changes were made
|
||||
*
|
||||
* @see self::refreshSubscriptionCount() to delete cache after this action
|
||||
*/
|
||||
public static function unsubscribe(int|Actor|LocalUser $subscriber, int|Actor|LocalUser $subject, string $source = 'web'): ?Activity
|
||||
public static function unsubscribe(int|Actor|LocalUser $subject, int|Actor|LocalUser $object, string $source = 'web'): ?Activity
|
||||
{
|
||||
$subscriber_id = \is_int($subscriber) ? $subscriber : $subscriber->getId();
|
||||
$subscribed_id = \is_int($subject) ? $subject : $subject->getId();
|
||||
$subscriber_id = \is_int($subject) ? $subject : $subject->getId();
|
||||
$subscribed_id = \is_int($object) ? $object : $object->getId();
|
||||
$opts = [
|
||||
'subscriber_id' => $subscriber_id,
|
||||
'subscribed_id' => $subscribed_id,
|
||||
];
|
||||
$subscription = DB::findOneBy(table: \Component\Subscription\Entity\Subscription::class, criteria: $opts, return_null: true);
|
||||
$subscription = DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true);
|
||||
$activity = null;
|
||||
if (!\is_null($subscription)) {
|
||||
// Remove Subscription
|
||||
@@ -102,7 +159,88 @@ class Subscription extends Component
|
||||
'source' => $source,
|
||||
]);
|
||||
DB::persist($activity);
|
||||
|
||||
Event::handle('NewNotification', [
|
||||
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
|
||||
$activity,
|
||||
['object' => [$previous_follow_activity->getObjectId()]],
|
||||
_m('{subject} unsubscribed from {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $previous_follow_activity->getObjectId()]),
|
||||
]);
|
||||
}
|
||||
return $activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides ``\App\templates\cards\profile\view.html.twig`` an **additional action** to be performed **on the given
|
||||
* Actor** (which the profile card of is currently being rendered).
|
||||
*
|
||||
* In the case of ``\App\Component\Subscription``, the action added allows a **LocalUser** to **subscribe** or
|
||||
* **unsubscribe** a given **Actor**.
|
||||
*
|
||||
* @param Actor $object The Actor on which the action is to be performed
|
||||
* @param array $actions An array containing all actions added to the
|
||||
* current profile, this event adds an action to it
|
||||
*
|
||||
* @throws DuplicateFoundException
|
||||
* @throws NotFoundException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return bool EventHook
|
||||
*/
|
||||
public function onAddProfileActions(Request $request, Actor $object, array &$actions): bool
|
||||
{
|
||||
// Action requires a user to be logged in
|
||||
// We know it's a LocalUser, which has the same id as Actor
|
||||
// We don't want the Actor to unfollow itself
|
||||
if ((\is_null($subject = Common::user())) || ($subject->getId() === $object->getId())) {
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
// Let's retrieve from here this subject came from to redirect it to previous location
|
||||
$from = $request->query->has('from')
|
||||
? $request->query->get('from')
|
||||
: $request->getPathInfo();
|
||||
|
||||
// Who is the subject attempting to subscribe to?
|
||||
$object_id = $object->getId();
|
||||
|
||||
// The id of both the subject and object
|
||||
$opts = [
|
||||
'subscriber_id' => $subject->getId(),
|
||||
'subscribed_id' => $object_id,
|
||||
];
|
||||
|
||||
// If subject is not subbed to object already, then route it to add subscription
|
||||
// Else, route to remove subscription
|
||||
$subscribe_action_url = ($not_subscribed_already = \is_null(DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true))) ? Router::url(
|
||||
'actor_subscribe_add',
|
||||
[
|
||||
'object_id' => $object_id,
|
||||
'from' => $from . '#profile-' . $object_id,
|
||||
],
|
||||
Router::ABSOLUTE_PATH,
|
||||
) : Router::url(
|
||||
'actor_subscribe_remove',
|
||||
[
|
||||
'object_id' => $object_id,
|
||||
'from' => $from . '#profile-' . $object_id,
|
||||
],
|
||||
Router::ABSOLUTE_PATH,
|
||||
);
|
||||
|
||||
// Finally, create an array with proper keys set accordingly
|
||||
// to provide Profile Card template, the info it needs in order to render it properly
|
||||
$action_extra_class = $not_subscribed_already ? 'add-actor-button-container' : 'remove-actor-button-container';
|
||||
$title = $not_subscribed_already ? 'Subscribe ' . $object->getNickname() : 'Unsubscribe ' . $object->getNickname();
|
||||
$subscribe_action = [
|
||||
'url' => $subscribe_action_url,
|
||||
'title' => _m($title),
|
||||
'classes' => 'button-container note-actions-unset ' . $action_extra_class,
|
||||
'id' => 'add-actor-button-container-' . $object_id,
|
||||
];
|
||||
|
||||
$actions[] = $subscribe_action;
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,8 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
{% block profile_view %}
|
||||
{% include 'cards/profile/view.html.twig' with { actor: object } %}
|
||||
{% endblock profile_view %}
|
||||
{{ form(form) }}
|
||||
{% endblock body %}
|
@@ -0,0 +1,8 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
{% block profile_view %}
|
||||
{% include 'cards/profile/view.html.twig' with { actor: object } %}
|
||||
{% endblock profile_view %}
|
||||
{{ form(form) }}
|
||||
{% endblock body %}
|
@@ -6,31 +6,34 @@ namespace Component\Tag\Controller;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\Controller;
|
||||
use App\Core\DB\DB;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity as E;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\BugFoundException;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Formatting;
|
||||
use Component\Tag\Form\SelfTagsForm;
|
||||
use Component\Language\Entity\Language;
|
||||
use Component\Tag\Tag as CompTag;
|
||||
use Symfony\Component\Form\SubmitButton;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Tag extends Controller
|
||||
{
|
||||
private function process(string|array $canon_single_or_multi, null|string|array $tag_single_or_multi, string $key, string $query, string $template)
|
||||
// TODO: Use Feed::query
|
||||
// TODO: If ?canonical=something, respect
|
||||
// TODO: Allow to set locale of tag being selected
|
||||
private function process(null|string|array $tag_single_or_multi, string $key, string $query, string $template, bool $include_locale = false)
|
||||
{
|
||||
$actor = Common::actor();
|
||||
$page = $this->int('page') ?: 1;
|
||||
$lang = $this->string('lang');
|
||||
|
||||
$query_args = ['tag' => $tag_single_or_multi];
|
||||
|
||||
if ($include_locale) {
|
||||
if (!\is_null($locale = $this->string('locale'))) {
|
||||
$query_args['language_id'] = Language::getByLocale($locale)->getId();
|
||||
} else {
|
||||
$query_args['language_id'] = Common::actor()->getTopLanguage()->getId();
|
||||
}
|
||||
}
|
||||
|
||||
$results = Cache::pagedStream(
|
||||
key: $key,
|
||||
query: $query,
|
||||
query_args: ['canon' => $canon_single_or_multi],
|
||||
query_args: $query_args,
|
||||
actor: $actor,
|
||||
page: $page,
|
||||
);
|
||||
@@ -43,179 +46,25 @@ class Tag extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
public function single_note_tag(string $canon)
|
||||
public function single_note_tag(string $tag)
|
||||
{
|
||||
return $this->process(
|
||||
canon_single_or_multi: $canon,
|
||||
tag_single_or_multi: $this->string('tag'),
|
||||
key: CompTag::cacheKeys($canon)['note_single'],
|
||||
query: 'select n from note n join note_tag nt with n.id = nt.note_id where nt.canonical = :canon order by nt.created DESC, nt.note_id DESC',
|
||||
tag_single_or_multi: $tag,
|
||||
key: CompTag::cacheKeys($tag)['note_single'],
|
||||
query: 'SELECT n FROM note AS n JOIN note_tag AS nt WITH n.id = nt.note_id WHERE nt.tag = :tag AND nt.language_id = :language_id ORDER BY nt.created DESC, nt.note_id DESC',
|
||||
template: 'note_tag_feed.html.twig',
|
||||
include_locale: true,
|
||||
);
|
||||
}
|
||||
|
||||
public function multi_note_tags(string $canons)
|
||||
public function multi_note_tags(string $tags)
|
||||
{
|
||||
return $this->process(
|
||||
canon_single_or_multi: explode(',', $canons),
|
||||
tag_single_or_multi: !\is_null($this->string('tags')) ? explode(',', $this->string('tags')) : null,
|
||||
key: CompTag::cacheKeys(str_replace(',', '-', $canons))['note_multi'],
|
||||
query: 'select n from note n join note_tag nt with n.id = nt.note_id where nt.canonical in (:canon) order by nt.created DESC, nt.note_id DESC',
|
||||
tag_single_or_multi: explode(',', $tags),
|
||||
key: CompTag::cacheKeys(str_replace(',', '-', $tags))['note_multi'],
|
||||
query: 'select n from note n join note_tag nt with n.id = nt.note_id where nt.tag in (:tag) AND nt.language_id = :language_id order by nt.created DESC, nt.note_id DESC',
|
||||
template: 'note_tag_feed.html.twig',
|
||||
include_locale: true,
|
||||
);
|
||||
}
|
||||
|
||||
public function single_actor_tag(string $canon)
|
||||
{
|
||||
return $this->process(
|
||||
canon_single_or_multi: $canon,
|
||||
tag_single_or_multi: $this->string('tag'),
|
||||
key: CompTag::cacheKeys($canon)['actor_single'],
|
||||
query: 'select a from actor a join actor_tag at with a.id = at.tagged where at.canonical = :canon order by at.modified DESC',
|
||||
template: 'actor_tag_feed.html.twig',
|
||||
);
|
||||
}
|
||||
|
||||
public function multi_actor_tag(string $canons)
|
||||
{
|
||||
return $this->process(
|
||||
canon_single_or_multi: explode(',', $canons),
|
||||
tag_single_or_multi: !\is_null($this->string('tags')) ? explode(',', $this->string('tags')) : null,
|
||||
key: CompTag::cacheKeys(str_replace(',', '-', $canons))['actor_multi'],
|
||||
query: 'select a from actor a join actor_tag at with a.id = at.tagged where at.canonical = :canon order by at.modified DESC',
|
||||
template: 'actor_tag_feed.html.twig',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic settings page for an Actor's self tags
|
||||
*/
|
||||
public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id)
|
||||
{
|
||||
$actor = Common::actor();
|
||||
if (!$actor->canAdmin($target)) {
|
||||
throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()]));
|
||||
}
|
||||
|
||||
$actor_tags = $target->getSelfTags();
|
||||
|
||||
[$add_form, $existing_form] = SelfTagsForm::handleTags(
|
||||
$request,
|
||||
$actor_tags,
|
||||
handle_new: /**
|
||||
* Handle adding tags
|
||||
*/
|
||||
function ($form) use ($request, $target, $details_id) {
|
||||
$data = $form->getData();
|
||||
$tags = $data['new-tags'];
|
||||
$language = $target->getTopLanguage()->getLocale();
|
||||
foreach ($tags as $tag) {
|
||||
$tag = CompTag::ensureValid($tag);
|
||||
$canon_tag = CompTag::canonicalTag($tag, language: $language);
|
||||
$use_canon = $data['new-tags-use-canon'];
|
||||
|
||||
[$actor_tag, $actor_tag_existed] = E\ActorTag::createOrUpdate([
|
||||
'tagger' => $target->getId(),
|
||||
'tagged' => $target->getId(),
|
||||
'tag' => $tag,
|
||||
'canonical' => $canon_tag,
|
||||
'use_canonical' => $use_canon,
|
||||
]);
|
||||
DB::persist($actor_tag);
|
||||
|
||||
$actor_circle = DB::findBy(
|
||||
'actor_circle',
|
||||
[
|
||||
'tagger' => null,
|
||||
'tagged' => $target->getId(),
|
||||
'in' => ['tag' => [$tag, $canon_tag]],
|
||||
'use_canonical' => $use_canon,
|
||||
],
|
||||
);
|
||||
if (empty($actor_circle)) {
|
||||
if ($actor_tag_existed) {
|
||||
throw new BugFoundException('Actor tag existed but generic actor circle did not');
|
||||
}
|
||||
DB::persist(E\ActorCircle::create([
|
||||
'tagger' => null,
|
||||
'tagged' => $target->getId(),
|
||||
'tag' => $use_canon ? $canon_tag : $tag,
|
||||
'use_canonical' => $use_canon,
|
||||
'private' => false,
|
||||
'description' => null,
|
||||
]));
|
||||
}
|
||||
}
|
||||
DB::flush();
|
||||
Cache::delete(E\Actor::cacheKeys($target->getId(), $target->getId())['tags']);
|
||||
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id]);
|
||||
},
|
||||
handle_existing: /**
|
||||
* Handle changes to the existing tags
|
||||
*/
|
||||
function ($form, array $form_definition) use ($request, $target, $details_id) {
|
||||
$data = $form->getData();
|
||||
$changed = false;
|
||||
$language = $target->getTopLanguage()->getLocale();
|
||||
foreach (array_chunk($form_definition, 3) as $entry) {
|
||||
$tag = Formatting::removePrefix($entry[0][2]['data'], '#');
|
||||
$canon_tag = CompTag::canonicalTag($tag, language: $language);
|
||||
$use_canon = $entry[1][2]['attr']['data'];
|
||||
|
||||
/** @var SubmitButton $remove */
|
||||
$remove = $form->get($entry[2][0]);
|
||||
if ($remove->isClicked()) {
|
||||
$changed = true;
|
||||
DB::removeBy(
|
||||
'actor_tag',
|
||||
[
|
||||
'tagger' => $target->getId(),
|
||||
'tagged' => $target->getId(),
|
||||
'tag' => $tag,
|
||||
'use_canonical' => $use_canon,
|
||||
],
|
||||
);
|
||||
DB::removeBy(
|
||||
'actor_circle',
|
||||
[
|
||||
'tagger' => null,
|
||||
'tagged' => $target->getId(),
|
||||
'tag' => $use_canon ? $canon_tag : $tag,
|
||||
'use_canonical' => $use_canon,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** @var SubmitButton $toggle_canon */
|
||||
$toggle_canon = $form->get($entry[1][0]);
|
||||
if ($toggle_canon->isSubmitted()) {
|
||||
$changed = true;
|
||||
$actor_tag = DB::find(
|
||||
'actor_tag',
|
||||
[
|
||||
'tagger' => $target->getId(),
|
||||
'tagged' => $target->getId(),
|
||||
'tag' => $tag,
|
||||
'use_canonical' => $use_canon,
|
||||
],
|
||||
);
|
||||
DB::persist($actor_tag->setUseCanonical(!$use_canon));
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
DB::flush();
|
||||
Cache::delete(E\Actor::cacheKeys($target->getId(), $target->getId())['tags']);
|
||||
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id]);
|
||||
}
|
||||
},
|
||||
remove_label: _m('Remove self tag'),
|
||||
add_label: _m('Add self tag'),
|
||||
);
|
||||
|
||||
return [
|
||||
'_template' => 'self_tags_settings.fragment.html.twig',
|
||||
'add_self_tags_form' => $add_form->createView(),
|
||||
'existing_self_tags_form' => $existing_form?->createView(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -19,12 +19,15 @@ declare(strict_types = 1);
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace App\Entity;
|
||||
namespace Component\Tag\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Note;
|
||||
use Component\Language\Entity\Language;
|
||||
use Component\Tag\Tag;
|
||||
use DateTimeInterface;
|
||||
|
||||
@@ -39,6 +42,7 @@ use DateTimeInterface;
|
||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
||||
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @author Hugo Sales <hugo@hsal.es>
|
||||
* @author Diogo Peralta Cordeiro <@diogo.site>
|
||||
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
@@ -51,11 +55,11 @@ class NoteTag extends Entity
|
||||
private int $note_id;
|
||||
private bool $use_canonical;
|
||||
private ?int $language_id = null;
|
||||
private \DateTimeInterface $created;
|
||||
private DateTimeInterface $created;
|
||||
|
||||
public function setTag(string $tag): self
|
||||
{
|
||||
$this->tag = \mb_substr($tag, 0, 64);
|
||||
$this->tag = mb_substr($tag, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -66,7 +70,7 @@ class NoteTag extends Entity
|
||||
|
||||
public function setCanonical(string $canonical): self
|
||||
{
|
||||
$this->canonical = \mb_substr($canonical, 0, 64);
|
||||
$this->canonical = mb_substr($canonical, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -108,13 +112,13 @@ class NoteTag extends Entity
|
||||
return $this->language_id;
|
||||
}
|
||||
|
||||
public function setCreated(\DateTimeInterface $created): self
|
||||
public function setCreated(DateTimeInterface $created): self
|
||||
{
|
||||
$this->created = $created;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreated(): \DateTimeInterface
|
||||
public function getCreated(): DateTimeInterface
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
@@ -132,15 +136,24 @@ class NoteTag extends Entity
|
||||
|
||||
public static function getByNoteId(int $note_id): array
|
||||
{
|
||||
return Cache::getList(self::cacheKey($note_id), fn () => DB::dql('select nt from note_tag nt join note n with n.id = nt.note_id where n.id = :id', ['id' => $note_id]));
|
||||
return Cache::getList(self::cacheKey($note_id), fn () => DB::dql('SELECT nt FROM note_tag AS nt JOIN note AS n WITH n.id = nt.note_id WHERE n.id = :id', ['id' => $note_id]));
|
||||
}
|
||||
|
||||
public function getUrl(?Actor $actor = null, int $type = Router::ABSOLUTE_PATH): string
|
||||
{
|
||||
$params = ['canon' => $this->getCanonical(), 'tag' => $this->getTag()];
|
||||
if (!\is_null($actor)) {
|
||||
$params['lang'] = $actor->getTopLanguage()->getLocale();
|
||||
$params['tag'] = $this->getTag();
|
||||
|
||||
if (\is_null($this->getLanguageId())) {
|
||||
if (!\is_null($actor)) {
|
||||
$params['locale'] = $actor->getTopLanguage()->getLocale();
|
||||
}
|
||||
} else {
|
||||
$params['locale'] = Language::getById($this->getLanguageId())->getLocale();
|
||||
}
|
||||
if ($this->getUseCanonical()) {
|
||||
$params['canonical'] = $this->getCanonical();
|
||||
}
|
||||
|
||||
return Router::url(id: 'single_note_tag', args: $params, type: $type);
|
||||
}
|
||||
|
||||
@@ -150,18 +163,18 @@ class NoteTag extends Entity
|
||||
'name' => 'note_tag',
|
||||
'description' => 'Hash tags on notes',
|
||||
'fields' => [
|
||||
'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to tagged note'],
|
||||
'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hash tag associated with this note'],
|
||||
'canonical' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'ascii slug of tag'],
|
||||
'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to tagged note'],
|
||||
'use_canonical' => ['type' => 'bool', 'not null' => true, 'description' => 'whether the user wanted to use canonical tags in this note. Separate for blocks'],
|
||||
'language_id' => ['type' => 'int', 'not null' => false, 'foreign key' => true, 'target' => 'Language.id', 'multiplicity' => 'many to many', 'description' => 'the language this entry refers to'],
|
||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
||||
],
|
||||
'primary key' => ['tag', 'note_id'],
|
||||
'primary key' => ['note_id', 'tag'], // No need to require language in this association because all the related tags will be in the note's language already
|
||||
'indexes' => [
|
||||
'note_tag_created_idx' => ['created'],
|
||||
'note_tag_note_id_idx' => ['note_id'],
|
||||
'note_tag_canonical_idx' => ['canonical'],
|
||||
'note_tag_tag_language_id_idx' => ['tag', 'language_id'],
|
||||
'note_tag_tag_created_note_id_idx' => ['tag', 'created', 'note_id'],
|
||||
],
|
||||
];
|
@@ -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\Tag\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
@@ -46,7 +46,7 @@ class NoteTagBlock extends Entity
|
||||
private string $tag;
|
||||
private string $canonical;
|
||||
private bool $use_canonical;
|
||||
private \DateTimeInterface $modified;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setBlocker(int $blocker): self
|
||||
{
|
||||
@@ -61,7 +61,7 @@ class NoteTagBlock extends Entity
|
||||
|
||||
public function setTag(string $tag): self
|
||||
{
|
||||
$this->tag = \mb_substr($tag, 0, 64);
|
||||
$this->tag = mb_substr($tag, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ class NoteTagBlock extends Entity
|
||||
|
||||
public function setCanonical(string $canonical): self
|
||||
{
|
||||
$this->canonical = \mb_substr($canonical, 0, 64);
|
||||
$this->canonical = mb_substr($canonical, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -92,13 +92,13 @@ class NoteTagBlock extends Entity
|
||||
return $this->use_canonical;
|
||||
}
|
||||
|
||||
public function setModified(\DateTimeInterface $modified): self
|
||||
public function setModified(DateTimeInterface $modified): self
|
||||
{
|
||||
$this->modified = $modified;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getModified(): \DateTimeInterface
|
||||
public function getModified(): DateTimeInterface
|
||||
{
|
||||
return $this->modified;
|
||||
}
|
@@ -30,18 +30,15 @@ use function App\Core\I18n\_m;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\ActorCircle;
|
||||
use App\Entity\ActorTag;
|
||||
use App\Entity\Note;
|
||||
use App\Entity\NoteTag;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Formatting;
|
||||
use App\Util\Functional as GSF;
|
||||
use App\Util\HTML;
|
||||
use App\Util\Nickname;
|
||||
use Component\Circle\Entity\ActorTag;
|
||||
use Component\Language\Entity\Language;
|
||||
use Component\Tag\Controller as C;
|
||||
use Component\Tag\Entity\NoteTag;
|
||||
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||
use Doctrine\ORM\Query\Expr;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
@@ -53,22 +50,20 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
* Component responsible for extracting tags from posted notes, as well as normalizing them
|
||||
*
|
||||
* @author Hugo Sales <hugo@hsal.es>
|
||||
* @author Diogo Peralta Cordeiro <@diogo.site>
|
||||
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
class Tag extends Component
|
||||
{
|
||||
public const MAX_TAG_LENGTH = 64;
|
||||
public const TAG_REGEX = '/(^|\\s)(#[\\pL\\pN_\\-]{1,64})/u'; // Brion Vibber 2011-02-23 v2:classes/Notice.php:367 function saveTags
|
||||
public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/';
|
||||
public const TAG_SLUG_REGEX = '[A-Za-z0-9]{1,64}';
|
||||
public const MAX_TAG_LENGTH = 64;
|
||||
public const TAG_REGEX = '/(^|\\s)(#[\\pL\\pN_\\-]{1,64})/u'; // Brion Vibber 2011-02-23 v2:classes/Notice.php:367 function saveTags
|
||||
public const TAG_SLUG_REGEX = '[A-Za-z0-9]{1,64}';
|
||||
|
||||
public function onAddRoute($r): bool
|
||||
{
|
||||
$r->connect('single_note_tag', '/note-tag/{canon<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_note_tag']);
|
||||
$r->connect('multi_note_tags', '/note-tags/{canons<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_note_tags']);
|
||||
$r->connect('single_actor_tag', '/actor-tag/{canon<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_actor_tag']);
|
||||
$r->connect('multi_actor_tags', '/actor-tags/{canons<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_actor_tags']);
|
||||
$r->connect('single_note_tag', '/note-tag/{tag<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_note_tag']);
|
||||
$r->connect('multi_note_tags', '/note-tags/{tags<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_note_tags']);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
@@ -86,7 +81,10 @@ class Tag extends Component
|
||||
preg_match_all(self::TAG_REGEX, $content, $matched_tags, \PREG_SET_ORDER);
|
||||
$matched_tags = array_unique(F\map($matched_tags, fn ($m) => $m[2]));
|
||||
foreach ($matched_tags as $match) {
|
||||
$tag = self::ensureValid($match);
|
||||
$tag = self::extract($match);
|
||||
if (!self::validate($tag)) {
|
||||
continue; // Ignore invalid tag candidates
|
||||
}
|
||||
$canonical_tag = self::canonicalTag($tag, \is_null($lang_id = $note->getLanguageId()) ? null : Language::getById($lang_id)->getLocale());
|
||||
DB::persist(NoteTag::create([
|
||||
'tag' => $tag,
|
||||
@@ -103,38 +101,54 @@ class Tag extends Component
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onRenderPlainTextNoteContent(string &$text, ?string $language = null): bool
|
||||
public function onRenderPlainTextNoteContent(string &$text, ?string $locale = null): bool
|
||||
{
|
||||
$text = preg_replace_callback(self::TAG_REGEX, fn ($m) => $m[1] . self::tagLink($m[2], $language), $text);
|
||||
$text = preg_replace_callback(self::TAG_REGEX, fn ($m) => $m[1] . self::tagLink($m[2], $locale), $text);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public static function cacheKeys(string $canon_single_or_multi): array
|
||||
public static function cacheKeys(string $tag_single_or_multi): array
|
||||
{
|
||||
return [
|
||||
'note_single' => "note-tag-feed-{$canon_single_or_multi}",
|
||||
'note_multi' => "note-tags-feed-{$canon_single_or_multi}",
|
||||
'actor_single' => "actor-tag-feed-{$canon_single_or_multi}",
|
||||
'actor_multi' => "actor-tags-feed-{$canon_single_or_multi}",
|
||||
'note_single' => "note-tag-feed-{$tag_single_or_multi}",
|
||||
'note_multi' => "note-tags-feed-{$tag_single_or_multi}",
|
||||
'actor_single' => "actor-tag-feed-{$tag_single_or_multi}",
|
||||
'actor_multi' => "actor-tags-feed-{$tag_single_or_multi}",
|
||||
];
|
||||
}
|
||||
|
||||
private static function tagLink(string $tag, ?string $language): string
|
||||
private static function tagLink(string $tag, ?string $locale): string
|
||||
{
|
||||
$tag = self::ensureLength($tag);
|
||||
$canonical = self::canonicalTag($tag, $language);
|
||||
$url = Router::url('single_note_tag', !\is_null($language) ? ['canon' => $canonical, 'lang' => $language, 'tag' => $tag] : ['canon' => $canonical, 'tag' => $tag]);
|
||||
return HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => $tag, 'rel' => 'tag'], $tag]], options: ['indent' => false]);
|
||||
$tag = self::extract($tag);
|
||||
$url = Router::url('single_note_tag', !\is_null($locale) ? ['tag' => $tag, 'locale' => $locale] : ['tag' => $tag]);
|
||||
return HTML::html(['span' => ['attrs' => ['class' => 'tag'],
|
||||
'#' . HTML::html(['a' => [
|
||||
'attrs' => [
|
||||
'href' => $url,
|
||||
'rel' => 'tag', // https://microformats.org/wiki/rel-tag
|
||||
],
|
||||
$tag,
|
||||
]], options: ['indent' => false]),
|
||||
]], options: ['indent' => false, 'raw' => true]);
|
||||
}
|
||||
|
||||
public static function ensureValid(string $tag)
|
||||
public static function extract(string $tag): string
|
||||
{
|
||||
$tag = self::ensureLength(Formatting::removePrefix($tag, '#'));
|
||||
if (preg_match(self::TAG_REGEX, '#' . $tag)) {
|
||||
return $tag;
|
||||
} else {
|
||||
return self::ensureLength(Formatting::removePrefix($tag, '#'));
|
||||
}
|
||||
|
||||
public static function validate(string $tag): bool
|
||||
{
|
||||
return preg_match(self::TAG_REGEX, '#' . $tag) === 1;
|
||||
}
|
||||
|
||||
public static function sanitize(string $tag): string
|
||||
{
|
||||
$tag = self::extract($tag);
|
||||
if (!self::validate($tag)) {
|
||||
throw new ClientException(_m('Invalid tag given: {tag}', ['{tag}' => $tag]));
|
||||
}
|
||||
return $tag;
|
||||
}
|
||||
|
||||
public static function ensureLength(string $tag): string
|
||||
@@ -143,11 +157,11 @@ class Tag extends Component
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a tag to it's canonical representation, by splitting it
|
||||
* Convert a tag to its canonical representation, by splitting it
|
||||
* into words, stemming it in the given language (if enabled) and
|
||||
* sluggifying it (turning it into an ASCII representation)
|
||||
*/
|
||||
public static function canonicalTag(string $tag, ?string $language): string
|
||||
public static function canonicalTag(string $tag, ?string $language = null): string
|
||||
{
|
||||
$result = '';
|
||||
foreach (Formatting::splitWords(str_replace('#', '', $tag)) as $word) {
|
||||
@@ -165,17 +179,20 @@ class Tag extends Component
|
||||
*
|
||||
* $term /^(note|tag|people|actor)/ means we want to match only either a note or an actor
|
||||
*/
|
||||
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr): bool
|
||||
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
|
||||
{
|
||||
if (!str_contains($term, ':')) {
|
||||
return Event::next;
|
||||
}
|
||||
if (\is_null($locale)) {
|
||||
$locale = Common::currentLanguage();
|
||||
}
|
||||
[$search_type, $search_term] = explode(':', $term);
|
||||
if (str_starts_with($search_term, '#')) {
|
||||
$search_term = self::ensureValid($search_term);
|
||||
$canon_search_term = self::canonicalTag($search_term, $language);
|
||||
$temp_note_expr = $eb->eq('note_tag.canonical', $canon_search_term);
|
||||
$temp_actor_expr = $eb->eq('actor_tag.canonical', $canon_search_term);
|
||||
$search_term = self::sanitize($search_term);
|
||||
$canonical_search_term = self::canonicalTag($search_term, $locale);
|
||||
$temp_note_expr = $eb->eq('note_tag.canonical', $canonical_search_term);
|
||||
$temp_actor_expr = $eb->eq('actor_tag.canonical', $canonical_search_term);
|
||||
if (Formatting::startsWith($term, ['note:', 'tag:', 'people:'])) {
|
||||
$note_expr = $temp_note_expr;
|
||||
} elseif (Formatting::startsWith($term, ['people:', 'actor:'])) {
|
||||
@@ -183,7 +200,7 @@ class Tag extends Component
|
||||
} elseif (Formatting::startsWith($term, GSF::cartesianProduct([['people', 'actor'], ['circle', 'list'], [':']], separator: ['-', '_']))) {
|
||||
$null_tagger_expr = $eb->isNull('actor_circle.tagger');
|
||||
$tagger_expr = \is_null($actor_expr) ? $null_tagger_expr : $eb->orX($null_tagger_expr, $eb->eq('actor_circle.tagger', $actor->getId()));
|
||||
$tags = array_unique([$search_term, $canon_search_term]);
|
||||
$tags = array_unique([$search_term, $canonical_search_term]);
|
||||
$tag_expr = \count($tags) === 1 ? $eb->eq('actor_circle.tag', $tags[0]) : $eb->in('actor_circle.tag', $tags);
|
||||
$search_expr = $eb->andX(
|
||||
$tagger_expr,
|
||||
@@ -196,58 +213,30 @@ class Tag extends Component
|
||||
$actor_expr = $temp_actor_expr;
|
||||
return Event::next;
|
||||
}
|
||||
return Event::stop;
|
||||
}
|
||||
return Event::stop;
|
||||
}
|
||||
|
||||
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
{
|
||||
$note_qb->leftJoin(NoteTag::class, 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id')
|
||||
->leftJoin(ActorCircle::class, 'actor_circle', Expr\Join::WITH, 'note_actor.id = actor_circle.tagged');
|
||||
$actor_qb->leftJoin(ActorTag::class, 'actor_tag', Expr\Join::WITH, 'actor_tag.tagger = actor.id')
|
||||
->leftJoin(ActorCircle::class, 'actor_circle', Expr\Join::WITH, 'actor.id = actor_circle.tagged');
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onPostingAddFormEntries(Request $request, Actor $actor, array &$form_params)
|
||||
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
{
|
||||
$note_qb->leftJoin(NoteTag::class, 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id');
|
||||
$actor_qb->leftJoin(ActorTag::class, 'actor_tag', Expr\Join::WITH, 'actor_tag.tagger = actor.id');
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onPostingAddFormEntries(Request $request, Actor $actor, array &$form_params): bool
|
||||
{
|
||||
$form_params[] = ['tag_use_canonical', CheckboxType::class, ['required' => false, 'data' => true, 'label' => _m('Make note tags canonical'), 'help' => _m('Canonical tags will be treated as a version of an existing tag with the same root/stem (e.g. \'#great_tag\' will be considered as a version of \'#great\', if it already exists)')]];
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args)
|
||||
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): bool
|
||||
{
|
||||
if (!isset($data['tag_use_canonical'])) {
|
||||
throw new ClientException;
|
||||
throw new ClientException(_m('Missing Use Canonical preference for Tags.'));
|
||||
}
|
||||
$extra_args['tag_use_canonical'] = $data['tag_use_canonical'];
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs)
|
||||
{
|
||||
if ($section === 'profile' && $request->get('_route') === 'settings') {
|
||||
$tabs[] = [
|
||||
'title' => 'Self tags',
|
||||
'desc' => 'Add or remove tags on yourself',
|
||||
'id' => 'settings-self-tags',
|
||||
'controller' => C\Tag::settingsSelfTags($request, Common::actor(), 'settings-self-tags-details'),
|
||||
];
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets)
|
||||
{
|
||||
$actor_id = $actor->getId();
|
||||
$tags = Cache::get(
|
||||
"actor-circle-{$actor_id}",
|
||||
fn () => DB::dql('select c.tag from actor_circle c where c.tagger = :tagger', ['tagger' => $actor_id]),
|
||||
);
|
||||
foreach ($tags as $t) {
|
||||
$t = '#' . $t['tag'];
|
||||
$targets[$t] = $t;
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
}
|
||||
|
@@ -23,7 +23,7 @@
|
||||
{% block profile_view %}{% include 'cards/profile/view.html.twig' %}{% endblock profile_view %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="section-widget section-padding">
|
||||
<div class="frame-section frame-section-padding">
|
||||
{{ "Page: " ~ page }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@@ -26,7 +26,7 @@
|
||||
{% endblock current_note %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="section-widget section-padding section-widget-paging">
|
||||
<div class="frame-section frame-section-padding frame-section-paging">
|
||||
{{ "Page " ~ page }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
413
composer.json
413
composer.json
@@ -1,189 +1,234 @@
|
||||
{
|
||||
"type": "project",
|
||||
"name": "gnu/social",
|
||||
"description": "Free software social networking platform.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"ext-ctype": "*",
|
||||
"ext-curl": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-openssl": "*",
|
||||
"composer/package-versions-deprecated": "1.11.*",
|
||||
"doctrine/annotations": "^1.0",
|
||||
"doctrine/doctrine-bundle": "^2.4",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.1",
|
||||
"doctrine/orm": "^2.9",
|
||||
"erusev/parsedown": "^1.7",
|
||||
"knplabs/knp-time-bundle": "^1.17",
|
||||
"lstrojny/functional-php": "^1.17",
|
||||
"nyholm/psr7": "^1.4",
|
||||
"odolbeau/phone-number-bundle": "^3.1",
|
||||
"oro/doctrine-extensions": "^2.0",
|
||||
"php-ds/php-ds": "^1.2",
|
||||
"phpdocumentor/reflection-docblock": "^5.2",
|
||||
"sensio/framework-extra-bundle": "6.*",
|
||||
"someonewithpc/memcached-polyfill": "^1.0",
|
||||
"someonewithpc/redis-polyfill": "dev-master",
|
||||
"symfony/asset": "5.4.*",
|
||||
"symfony/cache": "5.4.*",
|
||||
"symfony/config": "5.4.*",
|
||||
"symfony/console": "5.4.*",
|
||||
"symfony/dom-crawler": "5.4.*",
|
||||
"symfony/dotenv": "5.4.*",
|
||||
"symfony/event-dispatcher": "5.4.*",
|
||||
"symfony/expression-language": "5.4.*",
|
||||
"symfony/filesystem": "5.4.*",
|
||||
"symfony/flex": "^1.3.1",
|
||||
"symfony/form": "5.4.*",
|
||||
"symfony/framework-bundle": "5.4.*",
|
||||
"symfony/http-client": "5.4.*",
|
||||
"symfony/intl": "5.4.*",
|
||||
"symfony/mailer": "5.4.*",
|
||||
"symfony/messenger": "5.4.*",
|
||||
"symfony/mime": "5.4.*",
|
||||
"symfony/monolog-bundle": "^3.1",
|
||||
"symfony/notifier": "5.4.*",
|
||||
"symfony/process": "5.4.*",
|
||||
"symfony/property-access": "5.4.*",
|
||||
"symfony/property-info": "5.4.*",
|
||||
"symfony/proxy-manager-bridge": "5.4.*",
|
||||
"symfony/security-bundle": "5.4.*",
|
||||
"symfony/serializer": "5.4.*",
|
||||
"symfony/string": "5.4.*",
|
||||
"symfony/translation": "5.4.*",
|
||||
"symfony/twig-bundle": "5.4.*",
|
||||
"symfony/validator": "5.4.*",
|
||||
"symfony/var-exporter": "5.4.*",
|
||||
"symfony/web-link": "5.4.*",
|
||||
"symfony/yaml": "5.4.*",
|
||||
"symfonycasts/reset-password-bundle": "^1.9",
|
||||
"symfonycasts/verify-email-bundle": "^1.0",
|
||||
"tgalopin/html-sanitizer-bundle": "^1.2",
|
||||
"theofidry/psysh-bundle": "^4.4",
|
||||
"twig/extra-bundle": "^2.12|^3.0",
|
||||
"twig/markdown-extra": "^3.0",
|
||||
"twig/twig": "^2.12|^3.0",
|
||||
"wikimedia/composer-merge-plugin": "^2.0"
|
||||
"type": "project",
|
||||
"name": "gnu/social",
|
||||
"description": "Free software social networking platform.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"ext-ctype": "*",
|
||||
"ext-curl": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-openssl": "*",
|
||||
"composer/package-versions-deprecated": "1.11.*",
|
||||
"doctrine/annotations": "^1.0",
|
||||
"doctrine/doctrine-bundle": "^2.4",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.1",
|
||||
"doctrine/orm": "^2.9",
|
||||
"erusev/parsedown": "^1.7",
|
||||
"knplabs/knp-time-bundle": "^1.17",
|
||||
"lstrojny/functional-php": "^1.17",
|
||||
"masterminds/html5": "^2.7",
|
||||
"mf2/mf2": "^0.4.6",
|
||||
"odolbeau/phone-number-bundle": "^3.1",
|
||||
"oro/doctrine-extensions": "^2.0",
|
||||
"php-ds/php-ds": "^1.2",
|
||||
"phpdocumentor/reflection-docblock": "^5.2",
|
||||
"sensio/framework-extra-bundle": "^5.5",
|
||||
"someonewithpc/memcached-polyfill": "^1.0",
|
||||
"someonewithpc/redis-polyfill": "dev-master",
|
||||
"symfony/asset": "5.4.*",
|
||||
"symfony/cache": "5.4.*",
|
||||
"symfony/config": "5.4.*",
|
||||
"symfony/console": "5.4.*",
|
||||
"symfony/dom-crawler": "5.4.*",
|
||||
"symfony/dotenv": "5.4.*",
|
||||
"symfony/event-dispatcher": "5.4.*",
|
||||
"symfony/expression-language": "5.4.*",
|
||||
"symfony/filesystem": "5.4.*",
|
||||
"symfony/flex": "^1.3.1",
|
||||
"symfony/form": "5.4.*",
|
||||
"symfony/framework-bundle": "5.4.*",
|
||||
"symfony/http-client": "5.4.*",
|
||||
"symfony/intl": "5.4.*",
|
||||
"symfony/mailer": "5.4.*",
|
||||
"symfony/messenger": "5.4.*",
|
||||
"symfony/mime": "5.4.*",
|
||||
"symfony/monolog-bundle": "^3.1",
|
||||
"symfony/notifier": "5.4.*",
|
||||
"symfony/process": "5.4.*",
|
||||
"symfony/property-access": "5.4.*",
|
||||
"symfony/property-info": "5.4.*",
|
||||
"symfony/proxy-manager-bridge": "5.4.*",
|
||||
"symfony/security-bundle": "5.4.*",
|
||||
"symfony/serializer": "5.4.*",
|
||||
"symfony/string": "5.4.*",
|
||||
"symfony/translation": "5.4.*",
|
||||
"symfony/twig-bundle": "5.4.*",
|
||||
"symfony/validator": "5.4.*",
|
||||
"symfony/var-exporter": "5.4.*",
|
||||
"symfony/web-link": "5.4.*",
|
||||
"symfony/yaml": "5.4.*",
|
||||
"symfonycasts/reset-password-bundle": "^1.9",
|
||||
"symfonycasts/verify-email-bundle": "^1.0",
|
||||
"tgalopin/html-sanitizer-bundle": "^1.2",
|
||||
"theofidry/psysh-bundle": "^4.4",
|
||||
"twig/extra-bundle": "^2.12|^3.0",
|
||||
"twig/markdown-extra": "^3.0",
|
||||
"twig/twig": "^2.12|^3.0",
|
||||
"wikimedia/composer-merge-plugin": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"codeception/codeception": "^4.1",
|
||||
"codeception/module-phpbrowser": "^2.0",
|
||||
"codeception/module-symfony": "^2.1",
|
||||
"doctrine/doctrine-fixtures-bundle": "^3.4",
|
||||
"friendsofphp/php-cs-fixer": "^3.2.1",
|
||||
"jchook/phpunit-assert-throws": "^1.0",
|
||||
"niels-de-blaauw/php-doc-check": "^0.2.2",
|
||||
"phpstan/phpstan": "dev-master",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"symfony/browser-kit": "^5.4.",
|
||||
"symfony/css-selector": "^5.4.",
|
||||
"symfony/debug-bundle": "^5.4.",
|
||||
"symfony/error-handler": "^5.4.",
|
||||
"symfony/maker-bundle": "^1.14",
|
||||
"symfony/phpunit-bridge": "^5.4.",
|
||||
"symfony/stopwatch": "5.4.*",
|
||||
"symfony/web-profiler-bundle": "^5.4.",
|
||||
"ulrichsg/getopt-php": "*",
|
||||
"wp-cli/php-cli-tools": "^0.11.13",
|
||||
"codeception/module-asserts": "^1.0.0"
|
||||
},
|
||||
"config": {
|
||||
"preferred-install": {
|
||||
"*": "dist"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^3.4",
|
||||
"friendsofphp/php-cs-fixer": "^3.2.1",
|
||||
"jchook/phpunit-assert-throws": "^1.0",
|
||||
"niels-de-blaauw/php-doc-check": "^0.2.2",
|
||||
"phpstan/phpstan": "dev-master",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"symfony/browser-kit": "^5.4.",
|
||||
"symfony/css-selector": "^5.4.",
|
||||
"symfony/debug-bundle": "^5.4.",
|
||||
"symfony/error-handler": "^5.4.",
|
||||
"symfony/maker-bundle": "^1.14",
|
||||
"symfony/phpunit-bridge": "^5.4.",
|
||||
"symfony/stopwatch": "5.4.*",
|
||||
"symfony/web-profiler-bundle": "^5.4.",
|
||||
"ulrichsg/getopt-php": "*",
|
||||
"wp-cli/php-cli-tools": "^0.11.13"
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"composer/package-versions-deprecated": true,
|
||||
"symfony/flex": true,
|
||||
"wikimedia/composer-merge-plugin": true
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Core/I18n/I18n.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"App\\": "src/",
|
||||
"Plugin\\": "plugins/",
|
||||
"Component\\": "components/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"App\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"replace": {
|
||||
"paragonie/random_compat": "2.*",
|
||||
"symfony/polyfill-ctype": "*",
|
||||
"symfony/polyfill-iconv": "*",
|
||||
"symfony/polyfill-php72": "*",
|
||||
"symfony/polyfill-php71": "*",
|
||||
"symfony/polyfill-php70": "*",
|
||||
"symfony/polyfill-php56": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"auto-scripts": {
|
||||
"cache:clear": "symfony-cmd",
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||
},
|
||||
"config": {
|
||||
"preferred-install": {
|
||||
"*": "dist"
|
||||
},
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"composer/package-versions-deprecated": true,
|
||||
"symfony/flex": true,
|
||||
"wikimedia/composer-merge-plugin": true
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Core/I18n/I18n.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"App\\": "src/",
|
||||
"Plugin\\": "plugins/",
|
||||
"Component\\": "components/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"App\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"replace": {
|
||||
"paragonie/random_compat": "2.*",
|
||||
"symfony/polyfill-ctype": "*",
|
||||
"symfony/polyfill-iconv": "*",
|
||||
"symfony/polyfill-php72": "*",
|
||||
"symfony/polyfill-php71": "*",
|
||||
"symfony/polyfill-php70": "*",
|
||||
"symfony/polyfill-php56": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"auto-scripts": {
|
||||
"cache:clear": "symfony-cmd",
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||
},
|
||||
"post-install-cmd": [
|
||||
"@auto-scripts",
|
||||
"cp -fu bin/pre-commit .git/hooks"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
]
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/symfony": "*"
|
||||
},
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "5.4.*"
|
||||
},
|
||||
"merge-plugin": {
|
||||
"include": [
|
||||
"components/*/composer.json",
|
||||
"plugins/*/composer.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "package",
|
||||
"package": {
|
||||
"name": "niels-de-blaauw/php-doc-check",
|
||||
"version": "0.2.2",
|
||||
"bin": [
|
||||
"bin/php-doc-check"
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"NdB\\PhpDocCheck\\": "src"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"url": "https://github.com/someonewithpc/php-doc-check.git",
|
||||
"type": "git",
|
||||
"reference": "master"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "package",
|
||||
"package": {
|
||||
"name": "ulrichsg/getopt-php",
|
||||
"version": "4.0.0",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"GetOpt\\": "src"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"url": "https://github.com/someonewithpc/getopt-php.git",
|
||||
"type": "git",
|
||||
"reference": "master"
|
||||
}
|
||||
}
|
||||
}
|
||||
"post-install-cmd": [
|
||||
"@auto-scripts",
|
||||
"cp -fu bin/pre-commit .git/hooks"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
]
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/symfony": "*"
|
||||
},
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "5.4.*"
|
||||
},
|
||||
"merge-plugin": {
|
||||
"include": [
|
||||
"components/*/composer.json",
|
||||
"plugins/*/composer.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "package",
|
||||
"package": {
|
||||
"name": "niels-de-blaauw/php-doc-check",
|
||||
"version": "0.2.2",
|
||||
"bin": [
|
||||
"bin/php-doc-check"
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"NdB\\PhpDocCheck\\": "src"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"url": "https://github.com/someonewithpc/php-doc-check.git",
|
||||
"type": "git",
|
||||
"reference": "master"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "package",
|
||||
"package": {
|
||||
"name": "ulrichsg/getopt-php",
|
||||
"version": "4.0.0",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"GetOpt\\": "src"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"url": "https://github.com/someonewithpc/getopt-php.git",
|
||||
"type": "git",
|
||||
"reference": "master"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "package",
|
||||
"package": {
|
||||
"name": "codeception/codeception",
|
||||
"version": "4.1.30",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Codeception\\": "src/Codeception",
|
||||
"Codeception\\Extension\\": "ext"
|
||||
},
|
||||
"files": [
|
||||
"functions.php"
|
||||
]
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.6.0 <9.0",
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"codeception/lib-asserts": "^1.0 | 2.0.*@dev",
|
||||
"guzzlehttp/psr7": "^1.4 | ^2.0",
|
||||
"symfony/finder": ">=2.7 <6.0",
|
||||
"symfony/console": ">=2.7 <6.0",
|
||||
"symfony/event-dispatcher": ">=2.7 <6.0",
|
||||
"symfony/yaml": ">=2.7 <6.0",
|
||||
"symfony/css-selector": ">=2.7 <6.0",
|
||||
"behat/gherkin": "^4.4.0",
|
||||
"codeception/phpunit-wrapper": ">6.0.15 <6.1.0 | ^6.6.1 | ^7.7.1 | ^8.1.1 | ^9.0",
|
||||
"codeception/stub": "^2.0 | ^3.0 | ^4.0"
|
||||
},
|
||||
"bin": [
|
||||
"codecept"
|
||||
],
|
||||
"source": {
|
||||
"url": "https://github.com/someonewithpc/Codeception.git",
|
||||
"type": "git",
|
||||
"reference": "4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
1535
composer.lock
generated
1535
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,18 @@ security:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
api_apps:
|
||||
pattern: ^/api/v1/apps$
|
||||
security: false
|
||||
api_token:
|
||||
pattern: ^/oauth/token$
|
||||
security: false
|
||||
api:
|
||||
provider: local_user
|
||||
pattern: ^/api/
|
||||
security: true
|
||||
stateless: true
|
||||
|
||||
main:
|
||||
entry_point: App\Security\Authenticator
|
||||
guard:
|
||||
@@ -53,3 +65,4 @@ security:
|
||||
access_control:
|
||||
- { path: ^/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/settings, roles: ROLE_USER }
|
||||
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED }
|
||||
|
Submodule docker/accessibility deleted from abcd45f8ec
@@ -5,18 +5,21 @@ RUN apk update && apk add git autoconf make gcc g++ file gettext-dev icu-dev zli
|
||||
|
||||
ARG MAKEFLAGS="-j$(cat /proc/cpuinfo | grep processor | wc -l)"
|
||||
|
||||
RUN apk add --virtual .phpize-deps $PHPIZE_DEPS \
|
||||
&& cd /tmp && git clone https://github.com/krakjoe/apcu && cd apcu && phpize && ./configure --enable-apcu && make install \
|
||||
&& cd /tmp && git clone https://github.com/php-ds/ext-ds && cd ext-ds && phpize && ./configure && make install \
|
||||
&& cd /tmp && git clone https://github.com/msgpack/msgpack-php && cd msgpack-php && phpize && ./configure && make install \
|
||||
&& cd /tmp && git clone https://github.com/lz4/lz4 && cd lz4 && make install \
|
||||
&& cd /tmp && git clone https://github.com/phpredis/phpredis && cd phpredis && phpize && ./configure --enable-redis-msgpack --enable-redis-lz4 --with-liblz4=yes && make install \
|
||||
&& cd /tmp && git clone https://github.com/libvips/php-vips-ext && cd php-vips-ext && phpize && ./configure && make install \
|
||||
&& rm -rf /usr/share/php7 \
|
||||
&& rm -rf /tmp/* \
|
||||
&& apk del .phpize-deps gcc g++ git autoconf > /dev/null
|
||||
RUN apk add --virtual .phpize-deps $PHPIZE_DEPS
|
||||
|
||||
RUN docker-php-ext-install bcmath exif gd gettext gmp intl mysqli opcache pdo pdo_mysql mysqli pdo_pgsql pgsql \
|
||||
&& docker-php-ext-enable ds msgpack redis apcu vips
|
||||
RUN docker-php-ext-install bcmath exif gd gettext gmp intl mysqli opcache pdo pdo_mysql mysqli pdo_pgsql pgsql
|
||||
|
||||
RUN cd /tmp && git clone https://github.com/krakjoe/apcu && cd apcu && phpize && ./configure --enable-apcu && make install
|
||||
RUN cd /tmp && git clone https://github.com/php-ds/ext-ds && cd ext-ds && phpize && ./configure && make install
|
||||
RUN cd /tmp && git clone https://github.com/msgpack/msgpack-php && cd msgpack-php && phpize && ./configure && make install
|
||||
RUN cd /tmp && git clone https://github.com/lz4/lz4 && cd lz4 && make install
|
||||
RUN cd /tmp && git clone https://github.com/phpredis/phpredis && cd phpredis && phpize && ./configure --enable-redis-msgpack --enable-redis-lz4 --with-liblz4=yes && make install
|
||||
RUN cd /tmp && git clone https://github.com/libvips/php-vips-ext && cd php-vips-ext && phpize && ./configure && make install
|
||||
|
||||
RUN apk add --no-cache ocaml && cd /tmp && git clone https://github.com/dlitz/texvc.git && cd texvc && make && cp texvc /usr/local/bin/texvc
|
||||
|
||||
RUN docker-php-ext-enable opcache ds msgpack redis apcu vips
|
||||
|
||||
RUN rm -rf /usr/share/php7 && rm -rf /tmp/* && apk del .phpize-deps gcc g++ autoconf > /dev/null
|
||||
|
||||
WORKDIR /var/www/social
|
||||
|
3
docker/tooling/acceptance.sh
Executable file
3
docker/tooling/acceptance.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
SYMFONY_DEPRECATIONS_HELPER=weak vendor/bin/codecept run
|
20
docker/tooling/accessibility.sh
Executable file
20
docker/tooling/accessibility.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
rm -rf /screenshots/diff
|
||||
mv -fn /screenshots/new /screenshots/old
|
||||
mkdir -p /screenshots/diff
|
||||
mkdir -p /screenshots/new
|
||||
chmod 777 -R /screenshots
|
||||
|
||||
/generate_pa11y-ci-config.php
|
||||
|
||||
su puppet -c '/usr/local/bin/pa11y-ci -c /pa11y/config.json'
|
||||
|
||||
cd /screenshots/new || exit 1
|
||||
|
||||
for f in *; do
|
||||
XC=$(compare -metric NCC "/screenshots/old/${f}" "${f}" "/screenshots/diff/${f}" 2>&1)
|
||||
if [ 1 -eq "$(echo "${XC} < 0.999" | bc)" ]; then
|
||||
printf '\e[33mCheck file for differences: \e]8;;%s\e\\%s\e]8;;\e\\\e[0m\n' "file:tests/screenshots/diff/${f}" "tests/screenshots/diff/${f}"
|
||||
fi
|
||||
done
|
@@ -2,7 +2,7 @@ version: '3'
|
||||
|
||||
services:
|
||||
php:
|
||||
build: .
|
||||
build: php
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
- ../social/install.sh:/var/entrypoint.d/0_social_install.sh
|
||||
- ./coverage.sh:/var/tooling/coverage.sh
|
||||
- ./phpstan.sh:/var/tooling/phpstan.sh
|
||||
- ./acceptance.sh:/var/tooling/acceptance.sh
|
||||
# Main files
|
||||
- ../../:/var/www/social
|
||||
- /var/www/social/docker # exclude docker folder
|
||||
@@ -22,6 +23,37 @@ services:
|
||||
- db.env
|
||||
command: /entrypoint.sh
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
depends_on:
|
||||
- php
|
||||
restart: always
|
||||
tty: false
|
||||
volumes:
|
||||
# Nginx
|
||||
- ../nginx/nginx.conf:/var/nginx/social.conf
|
||||
- ../nginx/domain.sh:/var/nginx/domain.sh
|
||||
# Certbot
|
||||
- ../certbot/www:/var/www/certbot
|
||||
- ../certbot/.files:/etc/letsencrypt
|
||||
# social
|
||||
- ../../public:/var/www/social/public
|
||||
env_file:
|
||||
- ../bootstrap/bootstrap.env
|
||||
command: /bin/sh -c '/var/nginx/domain.sh; nginx -g "daemon off;"'
|
||||
|
||||
pa11y:
|
||||
build: pa11y
|
||||
depends_on:
|
||||
- nginx
|
||||
volumes:
|
||||
- ../../tests/screenshots:/screenshots
|
||||
- ./accessibility.sh:/accessibility.sh
|
||||
- ./generate_pa11y-ci-config.php:/generate_pa11y-ci-config.php
|
||||
- /pa11y
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
|
||||
db:
|
||||
image: postgres:alpine
|
||||
environment:
|
||||
|
70
docker/tooling/generate_pa11y-ci-config.php
Executable file
70
docker/tooling/generate_pa11y-ci-config.php
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
$urls = [];
|
||||
foreach ([[360, 640, true], [1280, 720, true], [1280, 720, false], [2560, 1080, false]] as $viewport) {
|
||||
[$x, $y, $is_mobile] = $viewport;
|
||||
$gen = function (string $url, string $actions = "") use ($x, $y, $is_mobile) {
|
||||
$path = "/screenshots/new/{$x}x{$y}" . ($is_mobile ? '-mobile' : '') . '-' . ($url === '' ? 'root' : str_replace('/', '-', $url)) . ".png";
|
||||
$is_mobile = $is_mobile ? 'true' : 'false';
|
||||
return <<<EOU
|
||||
{
|
||||
"url": "https://nginx/{$url}",
|
||||
"screenCapture": "{$path}",
|
||||
"viewport": {
|
||||
"width": {$x},
|
||||
"height": {$y},
|
||||
"isMobile": {$is_mobile}
|
||||
}{$actions}
|
||||
}
|
||||
EOU;
|
||||
};
|
||||
|
||||
foreach ([
|
||||
'', 'feed/public',
|
||||
'doc/faq', 'doc/tos', 'doc/privacy', 'doc/source', 'doc/version',
|
||||
'main/login', 'main/register',
|
||||
] as $url) {
|
||||
$urls[] = $gen($url);
|
||||
}
|
||||
|
||||
$urls[] = $gen('main/login', <<<EOA
|
||||
,
|
||||
"actions": [
|
||||
"navigate to https://nginx/main/login",
|
||||
"set field #inputNicknameOrEmail to taken_user",
|
||||
"set field #inputPassword to foobar",
|
||||
"click element #signIn",
|
||||
"wait for path to not be /login"
|
||||
]
|
||||
EOA);
|
||||
|
||||
foreach (['feed/public', 'feed/home', '@taken_user/circles',
|
||||
'feed/network', 'feed/clique', 'feed/federated', 'feed/notifications',
|
||||
'@taken_user/collections', '@taken_user/favourites', '@taken_user/reverse_favourites',
|
||||
'directory/people', 'directory/groups', 'settings', 'main/logout'
|
||||
] as $url) {
|
||||
$urls[] = $gen($url);
|
||||
}
|
||||
}
|
||||
|
||||
$urls = implode(",\n", $urls);
|
||||
$config = <<<EOF
|
||||
{
|
||||
"defaults": {
|
||||
"chromeLaunchConfig": {
|
||||
"ignoreHTTPSErrors": true
|
||||
},
|
||||
"standard": "WCAG2AAA",
|
||||
"timeout": 10000
|
||||
},
|
||||
"concurrency": 4,
|
||||
"urls": [
|
||||
{$urls}
|
||||
]
|
||||
}
|
||||
EOF;
|
||||
|
||||
file_put_contents('/pa11y/config.json', $config);
|
17
docker/tooling/pa11y/Dockerfile
Normal file
17
docker/tooling/pa11y/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
apt-transport-https ca-certificates curl fontconfig \
|
||||
fonts-ipafont-gothic fonts-kacst fonts-liberation fonts-thai-tlwg \
|
||||
fonts-wqy-zenhei gconf-service libgbm-dev libasound2 \
|
||||
libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 \
|
||||
libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 \
|
||||
libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 \
|
||||
libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 \
|
||||
libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 \
|
||||
libxtst6 locales lsb-release unzip xdg-utils wget imagemagick php bc \
|
||||
&& apt-get clean \
|
||||
&& apt-get autoremove -q \
|
||||
&& npm install -g pa11y-ci
|
||||
|
||||
RUN useradd -ms /bin/bash puppet
|
7
docker/tooling/pa11y/sshd_config
Normal file
7
docker/tooling/pa11y/sshd_config
Normal file
@@ -0,0 +1,7 @@
|
||||
ChallengeResponseAuthentication no
|
||||
ListenAddress 0.0.0.0
|
||||
PasswordAuthentication yes
|
||||
PermitEmptyPasswords yes
|
||||
PermitRootLogin yes
|
||||
Port 22
|
||||
|
@@ -6,3 +6,5 @@ RUN apk update \
|
||||
&& apk add --no-cache $PHPIZE_DEPS runuser \
|
||||
&& pecl install xdebug \
|
||||
&& docker-php-ext-enable xdebug
|
||||
|
||||
RUN apk add --no-cache openssh sshpass
|
BIN
docs/designer/imgs/basic-layout.png
Normal file
BIN
docs/designer/imgs/basic-layout.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 23 KiB |
208
docs/designer/imgs/basic-layout.svg
Normal file
208
docs/designer/imgs/basic-layout.svg
Normal file
@@ -0,0 +1,208 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="1366"
|
||||
height="768"
|
||||
viewBox="0 0 1366 768"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20, custom)"
|
||||
sodipodi:docname="basic-layout.svg"
|
||||
inkscape:export-filename="/home/booh/Work/gnu-social-documentation/basic-layou.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#070707"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:pageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:document-units="px"
|
||||
showgrid="false"
|
||||
showguides="true"
|
||||
inkscape:zoom="0.8125"
|
||||
inkscape:cx="667.07692"
|
||||
inkscape:cy="422.15385"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1007"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g138495" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;stroke-dasharray:0.1,0.1;stroke-opacity:1;paint-order:stroke markers fill;stroke-miterlimit:4;stroke-dashoffset:0"
|
||||
id="rect918"
|
||||
width="1344.7627"
|
||||
height="46.717339"
|
||||
x="10.618625"
|
||||
y="10.618625" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:32px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20';fill:#ffffff"
|
||||
x="626.19995"
|
||||
y="43.577293"
|
||||
id="text8019"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan8017"
|
||||
x="626.19995"
|
||||
y="43.577293">Header</tspan></text>
|
||||
<rect
|
||||
style="fill:none;stroke:#ffffff;stroke-width:0.977782;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.0977782, 0.0977782;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
|
||||
id="rect16129"
|
||||
width="301.02222"
|
||||
height="700.70471"
|
||||
x="10.607515"
|
||||
y="57.478703" />
|
||||
<rect
|
||||
style="fill:none;stroke:#ffffff;stroke-width:0.977782;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.0977782, 0.0977782;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
|
||||
id="rect16129-3"
|
||||
width="301.02222"
|
||||
height="700.70471"
|
||||
x="1054.3591"
|
||||
y="57.335964" />
|
||||
<rect
|
||||
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.1, 0.1;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
|
||||
id="rect49857"
|
||||
width="742.72937"
|
||||
height="700.56195"
|
||||
x="311.62973"
|
||||
y="57.478703" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:21.3333px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
|
||||
x="597.01569"
|
||||
y="412.55966"
|
||||
id="text57880"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan57878"
|
||||
x="597.01569"
|
||||
y="412.55966"
|
||||
style="fill:#ffffff">Current page</tspan></text>
|
||||
<rect
|
||||
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.1, 0.1;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
|
||||
id="rect62142-5"
|
||||
width="26"
|
||||
height="26"
|
||||
x="1319.381"
|
||||
y="21.461538" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:13.3333px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
|
||||
x="50.911686"
|
||||
y="36.977287"
|
||||
id="text68176"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan68174"
|
||||
x="50.911686"
|
||||
y="36.977287"
|
||||
style="font-size:13.3333px;fill:#ffffff">-> Opens Left panel</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:13.3333px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
|
||||
x="1156.0417"
|
||||
y="36.977287"
|
||||
id="text88173"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan88171"
|
||||
x="1156.0417"
|
||||
y="36.977287"
|
||||
style="font-size:13.3333px;fill:#ffffff">Opens Right panel <-</tspan></text>
|
||||
<g
|
||||
id="g98259">
|
||||
<rect
|
||||
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.1, 0.1;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
|
||||
id="rect62142"
|
||||
width="26"
|
||||
height="26"
|
||||
x="20.5"
|
||||
y="20.977295" />
|
||||
<path
|
||||
style="fill:#ffffff;stroke:#feffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
|
||||
d="m 20.5,46.977295 c 26,-26 26,-26 26,-26 l -26,26 26,-26"
|
||||
id="path97234" />
|
||||
<path
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#feffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 46.5,46.977295 c -26,-26 -26,-26 -26,-26 l 26,26 -26,-26"
|
||||
id="path97234-6" />
|
||||
</g>
|
||||
<g
|
||||
id="g98259-2"
|
||||
transform="translate(1060.3462,-63.208064)">
|
||||
<path
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#feffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 259.03478,110.6696 c 26,-25.999998 26,-25.999998 26,-25.999998 l -26,25.999998 26,-25.999998"
|
||||
id="path97234-1" />
|
||||
<path
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#feffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 285.03478,110.6696 c -26,-25.999996 -26,-25.999996 -26,-25.999996 l 26,25.999996 -26,-25.999996"
|
||||
id="path97234-6-2" />
|
||||
<g
|
||||
id="g138454"
|
||||
transform="translate(0,-14.04183)">
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:21.3333px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
|
||||
x="98.718689"
|
||||
y="420.67288"
|
||||
id="text24170"
|
||||
transform="translate(-1060.3462,63.208064)"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan24168"
|
||||
x="98.718689"
|
||||
y="420.67288"
|
||||
style="fill:#ffffff">Left panel</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:16px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
|
||||
x="-1000.0276"
|
||||
y="496.68094"
|
||||
id="text110837"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan110835"
|
||||
x="-1000.0276"
|
||||
y="496.68094"
|
||||
style="fill:#ffffff">[.section-panel-left]</tspan></text>
|
||||
</g>
|
||||
<g
|
||||
id="g138495"
|
||||
transform="translate(0,-19.454344)">
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:21.3333px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
|
||||
x="1136.0703"
|
||||
y="425.94266"
|
||||
id="text24170-6"
|
||||
transform="translate(-1060.3462,63.208064)"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan24168-7"
|
||||
x="1136.0703"
|
||||
y="425.94266"
|
||||
style="fill:#ffffff">Right panel</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:16px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
|
||||
x="38.923981"
|
||||
y="501.95071"
|
||||
id="text110837-7"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan110835-0"
|
||||
x="38.923981"
|
||||
y="501.95071"
|
||||
style="fill:#ffffff">[.section-panel-right]</tspan></text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 8.2 KiB |
@@ -1,3 +1,4 @@
|
||||
# Summary
|
||||
|
||||
- [Why a design language?](./design_language.md)
|
||||
- [Layout and CSS classes](./guidelines.md)
|
||||
|
@@ -1,31 +0,0 @@
|
||||
# Why a design language?
|
||||
Humans have an innate understanding for common, predictable and repeatable concepts. Our brains are, in fact, hardwired
|
||||
to take advantage of such phenomena, which is sometimes taken to great effects in optical illusions for example.
|
||||
|
||||
Patterns emerge when concepts and actions, interlinked, construct a predictable outcome. With a common design language,
|
||||
we hope to achieve such predictability, and supply an innate understanding of user interaction.
|
||||
|
||||
The goal isn't to have one and only design language, but to encourage new themes/interfaces to take similar steps on their
|
||||
design processes.
|
||||
|
||||
## Predictability and user experience
|
||||
A good book implies meaning, perhaps through environmental storytelling, or any other thought exercise that assumes
|
||||
a conscious, and rational reader capable of processing information. Not just present it.
|
||||
The same is true for a good UI, it shouldn't be explained, there should be an innate understanding.
|
||||
|
||||
### User Interface Universal Language
|
||||
Web technologies as a whole contain a set of constraints for organizing web pages. This implies that all web pages have
|
||||
a common structural basis.
|
||||
|
||||
Users accustomed to surfing the Web know which user interactions are acceptable and which aren't.
|
||||
The key puzzle is how users come to know these restrictions of their Web UI. This is the crux of any
|
||||
accessible Web page, an hierarchy needs to be followed as well as common standards.
|
||||
|
||||
### Canons of page construction
|
||||
The aforementioned comparison between books and Web pages isn't just a coincidence, given the resemblance between the
|
||||
two mediums. From their presentation to fundamental theory, it's only natural to apply core book design ideas to the Web.
|
||||
|
||||
|
||||
### User customization
|
||||
|
||||
|
38
docs/designer/src/guidelines.md
Normal file
38
docs/designer/src/guidelines.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Design considerations
|
||||
Humans have an innate understanding for common, predictable and repeatable concepts. Our brains are, in fact, hardwired
|
||||
to take advantage of such phenomena. Patterns emerge when concepts and actions, interlinked, construct a predictable outcome.
|
||||
This basic idea should always be employed into the design of a user interface, because it inherently supplies an innate
|
||||
understanding of user interaction.
|
||||
|
||||
So, just bear this in mind. Try not to reinvent HTML elements, use them properly.
|
||||
|
||||
The designer docs are intended to help out in the development processes of both the Core components, and Plugins.
|
||||
With an emphasis on the frontend side of things, of course.
|
||||
|
||||
## Basic layout
|
||||
_Bear in mind **any** of the following assumptions are based upon the **default theme**, your mileage may vary._
|
||||
|
||||
The layout is subdivided in 4 distinct areas:
|
||||
- **Header**
|
||||
- Left panel ~~checkbox~~ button :)
|
||||
- **Left panel**
|
||||
- Main instance link / header 1
|
||||
- Right panel ~~checkbox~~... I mean, button...
|
||||
- **Right panel**
|
||||
- **Current page content**
|
||||
|
||||

|
||||
|
||||
Each one of these areas **are selectable** with CSS by **using a limited set of classes**. You can use whatever classes
|
||||
you may want, but bear in mind that any external code made by someone else other than yourself may not account
|
||||
for your specific class names.
|
||||
|
||||
### CSS classes reference
|
||||
|
||||
| Name | Function | Dependencies | Examples | Sub-classes |
|
||||
|----------------------|----------------------------------------------------------------------------|--------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------|
|
||||
| section-panel | Side panel | - Preceded by a checkbox hack (hide/show panel); | Left panel `\App\Component\LeftPanel\templates\left_panel\view.html.twig`<br/>Right `\App\Component\RightPanel\templates\right_panel\view.html.twig` | `section-panel-left`, `section-panel-right` |
|
||||
| frame-section | A sub-section of a page, commonly a template block of a component / plugin | None | Login template `\App\templates\security\login.html.twig` | `frame-section-title`, `frame-section-subtitle` |
|
||||
| frame-section-title | A template block's title | - Part of a `frame-section`; | Settings template `\App\templates\settings\base.html.twig` | None |
|
||||
|
||||
_**still in construction...**_
|
11
phpstan.neon
11
phpstan.neon
@@ -11,6 +11,7 @@ parameters:
|
||||
- plugins/ActivityPub
|
||||
- plugins/Poll
|
||||
- components/FreeNetwork
|
||||
- tests/CodeCeption/_support/
|
||||
earlyTerminatingMethodCalls:
|
||||
App\Core\Log:
|
||||
- unexpected_exception
|
||||
@@ -24,6 +25,16 @@ parameters:
|
||||
message: '/^Property App\\PHPStan\\ClassFromTableNameDynamicStaticMethodReturnTypeExtension::\$provider is never read, only written\./'
|
||||
path: src/PHPStan/ClassFromTableNameDynamicStaticMethodReturnTypeExtension.php
|
||||
|
||||
-
|
||||
message: '/Parameter \$I of method [a-zA-Z]+::[a-zA-Z_]+\(\) has invalid type AcceptanceTester\./'
|
||||
paths:
|
||||
- *
|
||||
|
||||
-
|
||||
message: '/Call to method [a-zA-Z]+\(\) on an unknown class AcceptanceTester\./'
|
||||
paths:
|
||||
- *
|
||||
|
||||
# -
|
||||
# message: '/has no return typehint specified/'
|
||||
# paths:
|
||||
|
@@ -47,17 +47,20 @@ use App\Util\Common;
|
||||
use App\Util\Exception\BugFoundException;
|
||||
use App\Util\Exception\NoSuchActorException;
|
||||
use App\Util\Nickname;
|
||||
use Component\Collection\Util\Controller\OrderedCollection;
|
||||
use Component\FreeNetwork\Entity\FreeNetworkActorProtocol;
|
||||
use Component\FreeNetwork\Util\Discovery;
|
||||
use Exception;
|
||||
use InvalidArgumentException;
|
||||
use const PHP_URL_HOST;
|
||||
use Plugin\ActivityPub\Controller\Inbox;
|
||||
use Plugin\ActivityPub\Controller\Outbox;
|
||||
use Plugin\ActivityPub\Entity\ActivitypubActivity;
|
||||
use Plugin\ActivityPub\Entity\ActivitypubActor;
|
||||
use Plugin\ActivityPub\Entity\ActivitypubObject;
|
||||
use Plugin\ActivityPub\Util\HTTPSignature;
|
||||
use Plugin\ActivityPub\Util\Model;
|
||||
use Plugin\ActivityPub\Util\OrderedCollectionController;
|
||||
use Plugin\ActivityPub\Util\Response\ActorResponse;
|
||||
use Plugin\ActivityPub\Util\Response\NoteResponse;
|
||||
use Plugin\ActivityPub\Util\TypeResponse;
|
||||
@@ -127,7 +130,7 @@ class ActivityPub extends Plugin
|
||||
$r->connect(
|
||||
'activitypub_actor_outbox',
|
||||
'/actor/{gsactor_id<\d+>}/outbox.json',
|
||||
[Inbox::class, 'handle'],
|
||||
[Outbox::class, 'viewOutboxByActorId'],
|
||||
options: ['accept' => self::$accept_headers, 'format' => self::$accept_headers[0]],
|
||||
);
|
||||
return Event::next;
|
||||
@@ -185,16 +188,21 @@ class ActivityPub extends Plugin
|
||||
case 'actor_view_id':
|
||||
case 'actor_view_nickname':
|
||||
$response = ActorResponse::handle($vars['actor']);
|
||||
return Event::stop;
|
||||
break;
|
||||
case 'note_view':
|
||||
$response = NoteResponse::handle($vars['note']);
|
||||
return Event::stop;
|
||||
break;
|
||||
case 'activitypub_actor_outbox':
|
||||
$response = new TypeResponse($vars['type']);
|
||||
break;
|
||||
default:
|
||||
if (Event::handle('ActivityPubActivityStreamsTwoResponse', [$route, $vars, &$response]) === Event::stop) {
|
||||
return Event::stop;
|
||||
if (Event::handle('ActivityPubActivityStreamsTwoResponse', [$route, $vars, &$response]) !== Event::stop) {
|
||||
if (is_subclass_of($vars['controller'][0], OrderedCollection::class)) {
|
||||
$response = new TypeResponse(OrderedCollectionController::fromControllerVars($vars)['type']);
|
||||
}
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
return Event::stop;
|
||||
}
|
||||
|
||||
/**
|
||||
|
79
plugins/ActivityPub/Controller/Outbox.php
Normal file
79
plugins/ActivityPub/Controller/Outbox.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?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/>.
|
||||
// }}}
|
||||
|
||||
/**
|
||||
* ActivityPub implementation for GNU social
|
||||
*
|
||||
* @package GNUsocial
|
||||
* @category ActivityPub
|
||||
*
|
||||
* @author Diogo Peralta Cordeiro <@diogo.site>
|
||||
* @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\ActivityPub\Controller;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Activity;
|
||||
use App\Util\Exception\ClientException;
|
||||
use Exception;
|
||||
use Plugin\ActivityPub\Util\OrderedCollectionController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* ActivityPub Outbox Handler
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
class Outbox extends OrderedCollectionController
|
||||
{
|
||||
/**
|
||||
* Create an Inbox Handler to receive something from someone.
|
||||
*/
|
||||
public function viewOutboxByActorId(Request $request, int $gsactor_id): array
|
||||
{
|
||||
try {
|
||||
$user = DB::findOneBy('local_user', ['id' => $gsactor_id]);
|
||||
} catch (Exception $e) {
|
||||
throw new ClientException(_m('No such actor.'), 404, $e);
|
||||
}
|
||||
|
||||
$this->actor_id = $gsactor_id;
|
||||
|
||||
Log::debug('ActivityPub Outbox: Received a GET request.');
|
||||
|
||||
$activities = DB::findBy(Activity::class, ['actor_id' => $user->getId()], order_by: ['created' => 'DESC']);
|
||||
|
||||
foreach ($activities as $act) {
|
||||
$this->ordered_items[] = Router::url('activity_view', ['id' => $act->getId()], ROUTER::ABSOLUTE_URL);
|
||||
}
|
||||
|
||||
$this->route = 'activitypub_actor_outbox';
|
||||
$this->route_args = ['gsactor_id' => $user->getId(), 'page' => $this->int('page') ?? 0];
|
||||
|
||||
return $this->handle($request);
|
||||
}
|
||||
}
|
@@ -61,6 +61,20 @@ use Plugin\ActivityPub\Util\Model;
|
||||
*/
|
||||
class Actor extends Model
|
||||
{
|
||||
private static array $_gs_actor_type_to_as2_actor_type = [
|
||||
GSActor::PERSON => 'Person',
|
||||
GSActor::GROUP => 'Group',
|
||||
GSActor::ORGANISATION => 'Organization',
|
||||
GSActor::BOT => 'Application',
|
||||
];
|
||||
private static array $_as2_actor_type_to_gs_actor_type = [
|
||||
'Person' => GSActor::PERSON,
|
||||
'Group' => GSActor::GROUP,
|
||||
'Organization' => GSActor::ORGANISATION,
|
||||
'Application' => GSActor::BOT,
|
||||
'Service' => null,
|
||||
];
|
||||
|
||||
/**
|
||||
* Create an Entity from an ActivityStreams 2.0 JSON string
|
||||
* This will persist a new GSActor, ActivityPubRSA, and ActivityPubActor
|
||||
@@ -77,8 +91,8 @@ class Actor extends Model
|
||||
'fullname' => !empty($person->get('name')) ? $person->get('name') : null,
|
||||
'created' => new DateTime($person->get('published') ?? 'now'),
|
||||
'bio' => $person->get('summary'),
|
||||
'is_local' => false,
|
||||
'type' => GSActor::PERSON,
|
||||
'is_local' => false, // duh!
|
||||
'type' => self::$_as2_actor_type_to_gs_actor_type[$person->get('type')],
|
||||
'roles' => UserRoles::USER,
|
||||
'modified' => new DateTime(),
|
||||
];
|
||||
@@ -184,7 +198,7 @@ class Actor extends Model
|
||||
$uri = $object->getUri(Router::ABSOLUTE_URL);
|
||||
$attr = [
|
||||
'@context' => 'https://www.w3.org/ns/activitystreams',
|
||||
'type' => 'Person',
|
||||
'type' => self::$_gs_actor_type_to_as2_actor_type[$object->getType()],
|
||||
'id' => $uri,
|
||||
'inbox' => Router::url('activitypub_actor_inbox', ['gsactor_id' => $object->getId()], Router::ABSOLUTE_URL),
|
||||
'outbox' => Router::url('activitypub_actor_outbox', ['gsactor_id' => $object->getId()], Router::ABSOLUTE_URL),
|
||||
|
@@ -44,7 +44,6 @@ use App\Core\Log;
|
||||
use App\Core\Router\Router;
|
||||
use App\Core\VisibilityScope;
|
||||
use App\Entity\Note as GSNote;
|
||||
use App\Entity\NoteTag;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\DuplicateFoundException;
|
||||
@@ -57,6 +56,7 @@ use Component\Attachment\Entity\AttachmentToNote;
|
||||
use Component\Conversation\Conversation;
|
||||
use Component\FreeNetwork\FreeNetwork;
|
||||
use Component\Language\Entity\Language;
|
||||
use Component\Tag\Entity\NoteTag;
|
||||
use Component\Tag\Tag;
|
||||
use DateTime;
|
||||
use DateTimeInterface;
|
||||
@@ -254,7 +254,7 @@ class Note extends Model
|
||||
break;
|
||||
case 'Hashtag':
|
||||
$match = ltrim($ap_tag->get('name'), '#');
|
||||
$tag = Tag::ensureValid($match);
|
||||
$tag = Tag::extract($match);
|
||||
$canonical_tag = $ap_tag->get('canonical') ?? Tag::canonicalTag($tag, \is_null($lang_id = $obj->getLanguageId()) ? null : Language::getById($lang_id)->getLocale());
|
||||
DB::persist(NoteTag::create([
|
||||
'tag' => $tag,
|
||||
|
112
plugins/ActivityPub/Util/OrderedCollectionController.php
Normal file
112
plugins/ActivityPub/Util/OrderedCollectionController.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?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/>.
|
||||
// }}}
|
||||
|
||||
/**
|
||||
* ActivityPub implementation for GNU social
|
||||
*
|
||||
* @package GNUsocial
|
||||
* @category ActivityPub
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
|
||||
namespace Plugin\ActivityPub\Util;
|
||||
|
||||
use ActivityPhp\Type\Core\OrderedCollection;
|
||||
use ActivityPhp\Type\Core\OrderedCollectionPage;
|
||||
use App\Core\Router\Router;
|
||||
use Component\Collection\Util\Controller\CircleController;
|
||||
use Component\Collection\Util\Controller\FeedController;
|
||||
use Component\Collection\Util\Controller\OrderedCollection as GSOrderedCollection;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Provides a response in application/ld+json to GSActivity
|
||||
*
|
||||
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
abstract class OrderedCollectionController extends GSOrderedCollection
|
||||
{
|
||||
protected array $ordered_items = [];
|
||||
protected string $route;
|
||||
protected array $route_args = [];
|
||||
protected int $actor_id;
|
||||
|
||||
public static function fromControllerVars(array $vars): array
|
||||
{
|
||||
$route = $vars['request']->get('_route');
|
||||
$route_args = array_merge($vars['request']->query->all(), $vars['request']->attributes->get('_route_params'));
|
||||
unset($route_args['is_system_path'], $route_args['template'], $route_args['_format'], $route_args['accept'], $route_args['p']);
|
||||
if (is_subclass_of($vars['controller'][0], FeedController::class)) {
|
||||
$notes = [];
|
||||
foreach ($vars['notes'] as $note_replies) {
|
||||
$notes[] = Router::url('note_view', ['id' => $note_replies['note']->getId()], type: Router::ABSOLUTE_URL);
|
||||
}
|
||||
$type = self::setupType(route: $route, route_args: $route_args, ordered_items: $notes);
|
||||
} elseif (is_subclass_of($vars['controller'][0], CircleController::class)) {
|
||||
$actors = [];
|
||||
foreach ($vars['actors'] as $actor) {
|
||||
$actors[] = Router::url('actor_view_id', ['id' => $actor->getId()], type: Router::ABSOLUTE_URL);
|
||||
}
|
||||
$type = self::setupType(route: $route, route_args: $route_args, ordered_items: $actors);
|
||||
} else {
|
||||
$type = self::setupType(route: $route, route_args: $route_args, ordered_items: []);
|
||||
}
|
||||
return ['type' => $type];
|
||||
}
|
||||
|
||||
protected static function setupType(string $route, array $route_args = [], array $ordered_items = []): OrderedCollectionPage|OrderedCollection
|
||||
{
|
||||
$page = $route_args['page'] ?? 0;
|
||||
$type = $page === 0 ? new OrderedCollection() : new OrderedCollectionPage();
|
||||
$type->set('@context', 'https://www.w3.org/ns/activitystreams');
|
||||
$type->set('items', $ordered_items);
|
||||
$type->set('orderedItems', $ordered_items);
|
||||
$type->set('totalItems', \count($ordered_items));
|
||||
if ($page === 0) {
|
||||
$route_args['page'] = 1;
|
||||
$type->set('first', Router::url($route, $route_args, type: ROUTER::ABSOLUTE_URL));
|
||||
} else {
|
||||
$type->set('partOf', Router::url($route, $route_args, type: ROUTER::ABSOLUTE_URL));
|
||||
|
||||
if ($page + 1 < $total_pages = 1) { // TODO: do proper pagination
|
||||
$route_args['page'] = ($page + 1 == 1 ? 2 : $page + 1);
|
||||
$type->set('next', Router::url($route, $route_args, type: ROUTER::ABSOLUTE_URL));
|
||||
}
|
||||
|
||||
if ($page > 1) {
|
||||
$route_args['page'] = ($page - 1 <= 0 ? 1 : $page - 1);
|
||||
$type->set('prev', Router::url($route, $route_args, type: ROUTER::ABSOLUTE_URL));
|
||||
}
|
||||
}
|
||||
return $type;
|
||||
}
|
||||
|
||||
public function handle(Request $request): array
|
||||
{
|
||||
$type = self::setupType($this->route, $this->route_args, $this->ordered_items, $this->actor_id);
|
||||
|
||||
return ['type' => $type];
|
||||
}
|
||||
}
|
@@ -49,7 +49,7 @@ class TypeResponse extends JsonResponse
|
||||
* @param null|AbstractObject|string $json
|
||||
* @param int $status The response status code
|
||||
*/
|
||||
public function __construct(string|AbstractObject|null $json = null, int $status = 202)
|
||||
public function __construct(string|AbstractObject|null $json = null, int $status = 200)
|
||||
{
|
||||
parent::__construct(
|
||||
data: \is_object($json) ? $json->toJson() : $json,
|
||||
|
@@ -1,7 +1,5 @@
|
||||
{
|
||||
"require": {
|
||||
"landrok/activitypub": "^0.5.6",
|
||||
"masterminds/html5": "^2.7",
|
||||
"mf2/mf2": "^0.4.6"
|
||||
"landrok/activitypub": "^0.5.6"
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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]);
|
||||
}
|
||||
}
|
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
@@ -34,20 +34,24 @@ namespace Plugin\AttachmentCollections;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Modules\Plugin;
|
||||
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 Component\Collection\Util\MetaCollectionTrait;
|
||||
use Plugin\AttachmentCollections\Controller\AttachmentCollections as AttachmentCollectionsController;
|
||||
use Plugin\AttachmentCollections\Entity\AttachmentCollection;
|
||||
use Plugin\AttachmentCollections\Entity\AttachmentCollectionEntry;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class AttachmentCollections extends MetaCollectionPlugin
|
||||
class AttachmentCollections extends Plugin
|
||||
{
|
||||
use MetaCollectionTrait;
|
||||
protected string $slug = 'collection';
|
||||
protected string $plural_slug = 'collections';
|
||||
protected function createCollection(Actor $owner, array $vars, string $name)
|
||||
{
|
||||
$col = AttachmentCollection::create([
|
||||
@@ -61,7 +65,7 @@ class AttachmentCollections extends MetaCollectionPlugin
|
||||
'collection_id' => $col->getId(),
|
||||
]));
|
||||
}
|
||||
protected function removeItems(Actor $owner, array $vars, $items, array $collections)
|
||||
protected function removeItem(Actor $owner, array $vars, array $items, array $collections)
|
||||
{
|
||||
return DB::dql(<<<'EOF'
|
||||
DELETE FROM \Plugin\AttachmentCollections\Entity\AttachmentCollectionEntry AS entry
|
||||
@@ -79,7 +83,7 @@ class AttachmentCollections extends MetaCollectionPlugin
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addItems(Actor $owner, array $vars, $items, array $collections)
|
||||
protected function addItem(Actor $owner, array $vars, array $items, array $collections)
|
||||
{
|
||||
foreach ($items as $id) {
|
||||
// prevent user from putting something in a collection (s)he doesn't own:
|
||||
|
@@ -77,4 +77,15 @@ class AttachmentCollections extends MetaCollectionController
|
||||
{
|
||||
return DB::findOneBy(AttachmentCollection::class, ['id' => $collection_id]);
|
||||
}
|
||||
|
||||
public function setCollectionName(int $actor_id, string $actor_nickname, AttachmentCollection $collection, string $name)
|
||||
{
|
||||
$collection->setName($name);
|
||||
DB::persist($collection);
|
||||
}
|
||||
|
||||
public function removeCollection(int $actor_id, string $actor_nickname, AttachmentCollection $collection)
|
||||
{
|
||||
DB::remove($collection);
|
||||
}
|
||||
}
|
||||
|
@@ -2,9 +2,9 @@
|
||||
|
||||
{% block collection_items %}
|
||||
{% for key, attachment in attachments %}
|
||||
<section class="section-widget section-padding">
|
||||
<section class="frame-section frame-section-padding">
|
||||
{% include '/cards/attachments/view.html.twig' with {'attachment': attachment, 'note': bare_notes[key], 'title': attachment.getBestTitle(bare_notes[key])} only %}
|
||||
<a class="section-widget-button-like"
|
||||
<a class="frame-section-button-like"
|
||||
href="{{ attachment.getDownloadUrl(bare_notes[key]) }}"> {{ 'Download link' | trans }}</a>
|
||||
</section>
|
||||
{% else %}
|
||||
|
@@ -28,10 +28,11 @@ use App\Core\Event;
|
||||
use App\Core\Modules\Plugin;
|
||||
use App\Util\Common;
|
||||
use App\Util\Formatting;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class AttachmentShowRelated extends Plugin
|
||||
{
|
||||
public function onAppendRightPanelBlock($vars, $request, &$res): bool
|
||||
public function onAppendRightPanelBlock(Request $request, $vars, &$res): bool
|
||||
{
|
||||
if ($vars['path'] === 'note_attachment_show') {
|
||||
$related_notes = DB::dql('select n from attachment_to_note an '
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{% import '/cards/note/view.html.twig' as noteView %}
|
||||
|
||||
<section class="section-widget section-padding">
|
||||
<section class="frame-section frame-section-padding">
|
||||
<div class="section-title">
|
||||
<h2 class="heading-no-margin">
|
||||
{{ 'Notes related' | trans }}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<section class="section-widget section-padding">
|
||||
<section class="frame-section frame-section-padding">
|
||||
<div class="section-title">
|
||||
<h2 class="heading-no-margin">
|
||||
{{ 'Attachment tags' | trans }}
|
||||
|
@@ -22,6 +22,7 @@ declare(strict_types = 1);
|
||||
namespace Plugin\DeleteNote;
|
||||
|
||||
use ActivityPhp\Type\AbstractObject;
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use function App\Core\I18n\_m;
|
||||
@@ -51,6 +52,14 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
*/
|
||||
class DeleteNote extends NoteHandlerPlugin
|
||||
{
|
||||
public static function cacheKeys(int|Note $note_id): array
|
||||
{
|
||||
$note_id = \is_int($note_id) ? $note_id : $note_id->getId();
|
||||
return [
|
||||
'activity' => "deleted-note-activity-{$note_id}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* **Checks actor permissions for the DeleteNote action, deletes given Note
|
||||
* and creates respective Activity and Notification**
|
||||
@@ -85,6 +94,7 @@ class DeleteNote extends NoteHandlerPlugin
|
||||
|
||||
// Undertaker believes the actor can terminate this note
|
||||
$activity = $note->delete(actor: $actor, source: 'web');
|
||||
Cache::delete(self::cacheKeys($note)['activity']);
|
||||
|
||||
// Undertaker successful
|
||||
Event::handle('NewNotification', [$actor, $activity, [], _m('{nickname} deleted note {note_id}.', ['nickname' => $actor->getNickname(), 'note_id' => $activity->getObjectId()])]);
|
||||
@@ -106,8 +116,13 @@ class DeleteNote extends NoteHandlerPlugin
|
||||
{
|
||||
$actor = \is_int($actor) ? Actor::getById($actor) : $actor;
|
||||
$note = \is_int($note) ? Note::getById($note) : $note;
|
||||
// Try and find if note was already deleted
|
||||
if (\is_null(DB::findOneBy(Activity::class, ['verb' => 'delete', 'object_type' => 'note', 'object_id' => $note->getId()], return_null: true))) {
|
||||
// Try to find if note was already deleted
|
||||
if (\is_null(
|
||||
Cache::get(
|
||||
self::cacheKeys($note)['activity'],
|
||||
fn () => DB::findOneBy(Activity::class, ['verb' => 'delete', 'object_type' => 'note', 'object_id' => $note->getId()], return_null: true),
|
||||
),
|
||||
)) {
|
||||
// If none found, then undertaker has a job to do
|
||||
return self::undertaker($actor, $note);
|
||||
} else {
|
||||
@@ -145,8 +160,12 @@ class DeleteNote extends NoteHandlerPlugin
|
||||
if (\is_null($actor = Common::actor())) {
|
||||
return Event::next;
|
||||
}
|
||||
// Only add action if note wasn't already deleted!
|
||||
if (\is_null(DB::findOneBy(Activity::class, ['verb' => 'delete', 'object_type' => 'note', 'object_id' => $note->getId()], return_null: true))
|
||||
if (
|
||||
// Only add action if note wasn't already deleted!
|
||||
\is_null(Cache::get(
|
||||
self::cacheKeys($note)['activity'],
|
||||
fn () => DB::findOneBy(Activity::class, ['verb' => 'delete', 'object_type' => 'note', 'object_id' => $note->getId()], return_null: true),
|
||||
))
|
||||
// And has permissions
|
||||
&& $actor->canAdmin($note->getActor())) {
|
||||
$delete_action_url = Router::url('delete_note_action', ['note_id' => $note->getId()]);
|
||||
|
@@ -82,7 +82,7 @@ class Favourite extends FeedController
|
||||
// Prevent open redirect
|
||||
if (!\is_null($from = $this->string('from'))) {
|
||||
if (Router::isAbsolute($from)) {
|
||||
Log::warning("Actor {$actor_id} attempted to reply to a note and then get redirected to another host, or the URL was invalid ({$from})");
|
||||
Log::warning("Actor {$actor_id} attempted to favourite a note and then get redirected to another host, or the URL was invalid ({$from})");
|
||||
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
|
||||
} else {
|
||||
// TODO anchor on element id
|
||||
@@ -161,12 +161,12 @@ class Favourite extends FeedController
|
||||
];
|
||||
}
|
||||
|
||||
public function favouritesByActorId(Request $request, int $id)
|
||||
public function favouritesViewByActorId(Request $request, int $id)
|
||||
{
|
||||
$notes = DB::dql(
|
||||
<<< 'EOF'
|
||||
select n from note n
|
||||
join favourite f with n.id = f.note_id
|
||||
join note_favourite f with n.id = f.note_id
|
||||
where f.actor_id = :id
|
||||
order by f.created DESC
|
||||
EOF,
|
||||
@@ -180,10 +180,10 @@ class Favourite extends FeedController
|
||||
];
|
||||
}
|
||||
|
||||
public function favouritesByActorNickname(Request $request, string $nickname)
|
||||
public function favouritesViewByActorNickname(Request $request, string $nickname)
|
||||
{
|
||||
$user = DB::findOneBy('local_user', ['nickname' => $nickname]);
|
||||
return self::favouritesByActorId($request, $user->getId());
|
||||
return self::favouritesViewByActorId($request, $user->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,12 +193,12 @@ class Favourite extends FeedController
|
||||
*
|
||||
* @return array template
|
||||
*/
|
||||
public function reverseFavouritesByActorId(Request $request, int $id): array
|
||||
public function reverseFavouritesViewByActorId(Request $request, int $id): array
|
||||
{
|
||||
$notes = DB::dql(
|
||||
<<< 'EOF'
|
||||
select n from note n
|
||||
join favourite f with n.id = f.note_id
|
||||
join note_favourite f with n.id = f.note_id
|
||||
where f.actor_id != :id
|
||||
and n.actor_id = :id
|
||||
order by f.created DESC
|
||||
@@ -213,9 +213,9 @@ class Favourite extends FeedController
|
||||
];
|
||||
}
|
||||
|
||||
public function reverseFavouritesByActorNickname(Request $request, string $nickname)
|
||||
public function reverseFavouritesViewByActorNickname(Request $request, string $nickname)
|
||||
{
|
||||
$user = DB::findOneBy('local_user', ['nickname' => $nickname]);
|
||||
return self::reverseFavouritesByActorId($request, $user->getId());
|
||||
return self::reverseFavouritesViewByActorId($request, $user->getId());
|
||||
}
|
||||
}
|
||||
|
@@ -21,8 +21,11 @@ declare(strict_types = 1);
|
||||
|
||||
namespace Plugin\Favourite\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Entity\Note;
|
||||
use DateTimeInterface;
|
||||
|
||||
@@ -82,22 +85,39 @@ class NoteFavourite extends Entity
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
public static function cacheKeys(int|Note $note_id, int|Actor|LocalUser|null $actor_id = null): array
|
||||
{
|
||||
$note_id = \is_int($note_id) ? $note_id : $note_id->getId();
|
||||
$actor_id = \is_null($actor_id) ? null : (\is_int($actor_id) ? $actor_id : $actor_id->getId());
|
||||
return [
|
||||
'favourite' => "note-favourite-{$note_id}-{$actor_id}",
|
||||
'favourites' => "note-favourites-{$note_id}",
|
||||
'favourites-actors' => "note-favourites-actors-{$note_id}",
|
||||
];
|
||||
}
|
||||
|
||||
public static function getNoteFavourites(Note $note): array
|
||||
{
|
||||
return DB::findBy('note_favourite', ['note_id' => $note->getId()]);
|
||||
return Cache::getList(
|
||||
self::cacheKeys($note)['favourites'],
|
||||
fn () => DB::findBy('note_favourite', ['note_id' => $note->getId()]),
|
||||
);
|
||||
}
|
||||
|
||||
public static function getNoteFavouriteActors(Note $note): array
|
||||
{
|
||||
return DB::dql(
|
||||
<<<'EOF'
|
||||
select a from actor as a
|
||||
inner join note_favourite as nf
|
||||
with nf.note_id = :note_id
|
||||
where a.id = nf.actor_id
|
||||
order by nf.created DESC
|
||||
EOF,
|
||||
['note_id' => $note->getId()],
|
||||
return Cache::getList(
|
||||
self::cacheKeys($note)['favourites-actors'],
|
||||
fn () => DB::dql(
|
||||
<<<'EOF'
|
||||
select a from actor a
|
||||
inner join note_favourite nf
|
||||
with a.id = nf.actor_id
|
||||
where nf.note_id = :note_id
|
||||
order by nf.created DESC
|
||||
EOF,
|
||||
['note_id' => $note->getId()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -23,6 +23,7 @@ declare(strict_types = 1);
|
||||
|
||||
namespace Plugin\Favourite;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use function App\Core\I18n\_m;
|
||||
@@ -56,10 +57,14 @@ class Favourite extends NoteHandlerPlugin
|
||||
public static function favourNote(int $note_id, int $actor_id, string $source = 'web'): ?Activity
|
||||
{
|
||||
$opts = ['note_id' => $note_id, 'actor_id' => $actor_id];
|
||||
$note_already_favoured = DB::findOneBy('note_favourite', $opts, return_null: true);
|
||||
$activity = null;
|
||||
$note_already_favoured = Cache::get(
|
||||
FavouriteEntity::cacheKeys($note_id, $actor_id)['favourite'],
|
||||
fn () => DB::findOneBy('note_favourite', $opts, return_null: true),
|
||||
);
|
||||
$activity = null;
|
||||
if (\is_null($note_already_favoured)) {
|
||||
DB::persist(FavouriteEntity::create($opts));
|
||||
Cache::delete(FavouriteEntity::cacheKeys($note_id, $actor_id)['favourite']);
|
||||
$activity = Activity::create([
|
||||
'actor_id' => $actor_id,
|
||||
'verb' => 'favourite',
|
||||
@@ -86,11 +91,15 @@ class Favourite extends NoteHandlerPlugin
|
||||
*/
|
||||
public static function unfavourNote(int $note_id, int $actor_id, string $source = 'web'): ?Activity
|
||||
{
|
||||
$note_already_favoured = DB::findOneBy('note_favourite', ['note_id' => $note_id, 'actor_id' => $actor_id], return_null: true);
|
||||
$activity = null;
|
||||
$note_already_favoured = Cache::get(
|
||||
FavouriteEntity::cacheKeys($note_id, $actor_id)['favourite'],
|
||||
fn () => DB::findOneBy('note_favourite', ['note_id' => $note_id, 'actor_id' => $actor_id], return_null: true),
|
||||
);
|
||||
$activity = null;
|
||||
if (!\is_null($note_already_favoured)) {
|
||||
DB::remove($note_already_favoured);
|
||||
$favourite_activity = DB::findBy('activity', ['verb' => 'favourite', 'object_type' => 'note', 'object_id' => $note_id], order_by: ['created' => 'DESC'])[0];
|
||||
Cache::delete(FavouriteEntity::cacheKeys($note_id, $actor_id)['favourite']);
|
||||
$favourite_activity = DB::findBy('activity', ['verb' => 'favourite', 'object_type' => 'note', 'actor_id' => $actor_id, 'object_id' => $note_id], order_by: ['created' => 'DESC'])[0];
|
||||
$activity = Activity::create([
|
||||
'actor_id' => $actor_id,
|
||||
'verb' => 'undo', // 'undo_favourite',
|
||||
@@ -123,7 +132,12 @@ class Favourite extends NoteHandlerPlugin
|
||||
|
||||
// If note is favourite, "is_favourite" is 1
|
||||
$opts = ['note_id' => $note->getId(), 'actor_id' => $user->getId()];
|
||||
$is_favourite = !\is_null(DB::findOneBy('note_favourite', $opts, return_null: true));
|
||||
$is_favourite = !\is_null(
|
||||
Cache::get(
|
||||
FavouriteEntity::cacheKeys($note->getId(), $user->getId())['favourite'],
|
||||
fn () => DB::findOneBy('note_favourite', $opts, return_null: true),
|
||||
),
|
||||
);
|
||||
|
||||
// Generating URL for favourite action route
|
||||
$args = ['id' => $note->getId()];
|
||||
@@ -195,8 +209,8 @@ class Favourite extends NoteHandlerPlugin
|
||||
$r->connect(id: 'favourites_reverse_view_by_actor_id', uri_path: '/actor/{id<\d+>}/reverse_favourites', target: [Controller\Favourite::class, 'favouritesReverseViewByActorId']);
|
||||
|
||||
// View all favourites by nickname
|
||||
$r->connect(id: 'favourites_view_by_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/favourites', target: [Controller\Favourite::class, 'favouritesByActorNickname']);
|
||||
$r->connect(id: 'favourites_reverse_view_by_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/reverse_favourites', target: [Controller\Favourite::class, 'reverseFavouritesByActorNickname']);
|
||||
$r->connect(id: 'favourites_view_by_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/favourites', target: [Controller\Favourite::class, 'favouritesViewByActorNickname']);
|
||||
$r->connect(id: 'favourites_reverse_view_by_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/reverse_favourites', target: [Controller\Favourite::class, 'reverseFavouritesViewByActorNickname']);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
|
85
plugins/IndieAuth/Controller/Apps.php
Normal file
85
plugins/IndieAuth/Controller/Apps.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?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/>.
|
||||
// }}}
|
||||
|
||||
/**
|
||||
* ActivityPub implementation for GNU social
|
||||
*
|
||||
* @package OAuth2
|
||||
* @category API
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
|
||||
namespace Plugin\IndieAuth\Controller;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Log;
|
||||
use App\Util\Common;
|
||||
use Plugin\IndieAuth\Entity\OAuth2Client;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
|
||||
/**
|
||||
* App Management Endpoint
|
||||
*
|
||||
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
class Apps extends Controller
|
||||
{
|
||||
public function onPost(): JsonResponse
|
||||
{
|
||||
Log::debug('OAuth2 Apps: Received a POST request.');
|
||||
Log::debug('OAuth2 Apps: Request content: ', [$body = $this->request->getContent()]);
|
||||
$args = json_decode($body, true);
|
||||
|
||||
$identifier = hash('md5', random_bytes(16));
|
||||
// Random string Length should be between 43 and 128
|
||||
$secret = Common::base64url_encode(hash('sha256', random_bytes(57)));
|
||||
|
||||
DB::persist($app = OAuth2Client::create([
|
||||
'identifier' => $identifier,
|
||||
'secret' => $secret,
|
||||
'redirect_uris' => $args['redirect_uris'],
|
||||
'grants' => 'client_credentials authorization_code',
|
||||
'scopes' => $args['scopes'],
|
||||
'active' => true,
|
||||
'allow_plain_text_pkce' => false,
|
||||
'client_name' => $args['client_name'],
|
||||
'website' => $args['website'],
|
||||
]));
|
||||
|
||||
Log::debug('OAuth2 Apps: Created App: ', [$app]);
|
||||
|
||||
DB::flush();
|
||||
|
||||
// Success
|
||||
return new JsonResponse([
|
||||
'name' => $app->getClientName(),
|
||||
'website' => $app->getWebsite(),
|
||||
'redirect_uri' => $app->getRedirectUris()[0],
|
||||
'client_id' => $app->getIdentifier(),
|
||||
'client_secret' => $app->getSecret(),
|
||||
], status: 200, headers: ['content_type' => 'application/json; charset=utf-8']);
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user