Compare commits

..

17 Commits
v3 ... oauth1

Author SHA1 Message Date
Diogo Peralta Cordeiro
b999c1bd62
yet another iteration 2022-01-22 18:49:25 +00:00
Diogo Peralta Cordeiro
9dc6243822
fix firewalls a little 2022-01-22 18:49:25 +00:00
Diogo Peralta Cordeiro
ce8f54dc46
support json on oauth 2022-01-22 18:49:24 +00:00
Diogo Peralta Cordeiro
9e7db08e50
more grants 2022-01-22 18:49:24 +00:00
Diogo Peralta Cordeiro
841d10cde0
buah 2022-01-22 18:49:24 +00:00
Diogo Peralta Cordeiro
95c8f3bdc7
damn 2022-01-22 18:49:24 +00:00
Diogo Peralta Cordeiro
b82818646f
wip42 2022-01-22 18:49:24 +00:00
Diogo Peralta Cordeiro
5ac764f3e5
move to plugin 2022-01-22 18:49:24 +00:00
Diogo Peralta Cordeiro
4ad1de2616
some logic 2022-01-22 18:49:23 +00:00
Diogo Peralta Cordeiro
29f53bb698
wip5 2022-01-22 18:49:23 +00:00
Diogo Peralta Cordeiro
cb16b627b4
cenas 2022-01-22 18:49:23 +00:00
Diogo Peralta Cordeiro
19dd4ba368
wip4 2022-01-22 18:49:23 +00:00
Diogo Peralta Cordeiro
53a1a3fad1
wip3 2022-01-22 18:49:23 +00:00
Diogo Peralta Cordeiro
737648359d
wip2 2022-01-22 18:49:23 +00:00
Diogo Peralta Cordeiro
57c09c6f8f
wip 2022-01-22 18:49:22 +00:00
Diogo Peralta Cordeiro
08e3da092b
[OAuth2] Add scopes 2022-01-22 18:49:22 +00:00
Diogo Peralta Cordeiro
7959ea497b
[PLUGIN][OAuth2] Add OAuth2 support 2022-01-22 18:49:22 +00:00
538 changed files with 11301 additions and 22271 deletions

View File

@ -3,4 +3,4 @@ KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st' APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999 SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther PANTHER_APP_ENV=panther
DATABASE_URL=postgresql://postgres:password@db:5432/test DATABASE_URL=postgresql://postgres:password@db:5432/social

View File

@ -178,7 +178,7 @@ return $config
// There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis. // There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis.
'no_spaces_inside_parenthesis' => true, 'no_spaces_inside_parenthesis' => true,
// Removes `@param`, `@return` and `@var` tags that don't provide any useful information. // Removes `@param`, `@return` and `@var` tags that don't provide any useful information.
'no_superfluous_phpdoc_tags' => false, 'no_superfluous_phpdoc_tags' => true,
// Remove trailing commas in list function calls. // Remove trailing commas in list function calls.
'no_trailing_comma_in_list_call' => true, 'no_trailing_comma_in_list_call' => true,
// PHP single-line arrays should not have trailing comma. // PHP single-line arrays should not have trailing comma.

View File

@ -36,31 +36,28 @@ database-force-nuke:
database-force-schema-update: 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" 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-up: .PHONY tooling-docker: .PHONY
@sh -c 'if [ ! docker container inspect $(call translate-container-name,tooling_php_1) > /dev/null 2>&1 ]; then cd docker/tooling && docker-compose up -d --build > /dev/null 2>&1; fi' @cd docker/tooling && docker-compose up -d --build > /dev/null 2>&1
tooling-docker-down: .PHONY stop-tooling: .PHONY
cd docker/tooling && docker-compose down cd docker/tooling && docker-compose down
test: tooling-docker-up tooling-php-shell: tooling-docker
@docker exec $(call translate-container-name,tooling_php_1) /var/tooling/coverage.sh $(call args,'')
test-database-force-nuke: tooling-docker-up
docker exec -it $(call translate-container-name,tooling_php_1) sh -c 'cd /var/www/social; bin/console doctrine:database:drop --force'
tooling-php-shell: tooling-docker-up
docker exec -it $(call translate-container-name,tooling_php_1) sh docker exec -it $(call translate-container-name,tooling_php_1) sh
test-accesibility: tooling-docker-up test-accesibility: tooling-docker
cd docker/tooling && docker-compose run pa11y /accessibility.sh cd docker/tooling && docker-compose run pa11y /accessibility.sh
cs-fixer: tooling-docker-up test: tooling-docker
docker exec $(call translate-container-name,tooling_php_1) /var/tooling/coverage.sh $(call args,'')
cs-fixer: tooling-docker
@bin/php-cs-fixer $${CS_FIXER_FILE} @bin/php-cs-fixer $${CS_FIXER_FILE}
doc-check: tooling-docker-up doc-check: tooling-docker
bin/php-doc-check bin/php-doc-check
phpstan: tooling-docker-up phpstan: tooling-docker
bin/phpstan bin/phpstan
remove-var: remove-var:
@ -72,54 +69,4 @@ remove-file:
flush-redis-cache: flush-redis-cache:
docker exec -it $(call translate-container-name,$(strip $(DIR))_redis_1) sh -c 'redis-cli flushall' docker exec -it $(call translate-container-name,$(strip $(DIR))_redis_1) sh -c 'redis-cli flushall'
install-plugins: force-nuke-everything: down up flush-redis-cache database-force-nuke remove-var remove-file
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) /var/www/social/bin/install_plugins.sh
update-dependencies:
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c 'cd /var/www/social && composer update'
update-autocode:
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c 'cd /var/www/social && bin/update_autocode'
backup-actors:
docker exec -it $(call translate-container-name,$(strip $(DIR))_db_1) \
sh -c 'su postgres -c "mkdir -p /tmp/backup"' && \
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) \
sh -c "cd /var/www/social && bin/console doctrine:query:sql \"\
copy actor to '/tmp/backup/actor.csv';\
copy local_user to '/tmp/backup/local_user.csv';\
copy local_group to '/tmp/backup/local_group.csv';\
\
copy activitypub_actor to '/tmp/backup/ap_actor.csv';\
copy activitypub_rsa to '/tmp/backup/ap_rsa.csv';\
\
copy actor_subscription to '/tmp/backup/actor_subscription.csv';\
copy group_member to '/tmp/backup/group_member.csv';\
\
copy feed to '/tmp/backup/feed.csv';\
copy (SELECT 'ALTER SEQUENCE ' || c.relname || ' RESTART WITH ' || nextval(c.relname::regclass) || ';'\
FROM pg_class c WHERE c.relkind = 'S') to '/tmp/backup/sequences';\"" && \
mkdir -p /tmp/social-sql-backup && \
docker cp $(call translate-container-name,$(strip $(DIR))_db_1):/tmp/backup/. /tmp/social-sql-backup
restore-actors:
docker cp /tmp/social-sql-backup/. $(call translate-container-name,$(strip $(DIR))_db_1):/tmp/backup
docker exec -it $(call translate-container-name,$(strip $(DIR))_db_1) sh -c 'chown postgres /tmp/backup' && \
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) \
sh -c "cd /var/www/social && bin/console doctrine:query:sql \"\
copy actor from '/tmp/backup/actor.csv';\
copy local_user from '/tmp/backup/local_user.csv';\
copy local_group from '/tmp/backup/local_group.csv';\
\
copy activitypub_actor from '/tmp/backup/ap_actor.csv';\
copy activitypub_rsa from '/tmp/backup/ap_rsa.csv';\
\
copy actor_subscription from '/tmp/backup/actor_subscription.csv';\
copy group_member from '/tmp/backup/group_member.csv';\
\
copy feed from '/tmp/backup/feed.csv';\
`cat /tmp/social-sql-backup/sequences`\""
force-nuke-everything: down remove-var remove-file up flush-redis-cache database-force-nuke install-plugins
force-delete-content: backup-actors force-nuke-everything restore-actors

4
bin/configure vendored
View File

@ -352,8 +352,8 @@ SOCIAL_DBMS=${DBMS}
SOCIAL_DB=${DB_NAME} SOCIAL_DB=${DB_NAME}
SOCIAL_USER=${DB_USER} SOCIAL_USER=${DB_USER}
SOCIAL_PASSWORD=${DB_PASSWORD} SOCIAL_PASSWORD=${DB_PASSWORD}
CONFIG_DOMAIN=${DOMAIN} SOCIAL_DOMAIN=${DOMAIN}
CONFIG_NODE_NAME=${NODE_NAME} SOCIAL_NODE_NAME=${NODE_NAME}
SOCIAL_ADMIN_EMAIL=${EMAIL} SOCIAL_ADMIN_EMAIL=${EMAIL}
SOCIAL_SITE_PROFILE=${PROFILE} SOCIAL_SITE_PROFILE=${PROFILE}
MAILER_DSN=${MAILER_DSN} MAILER_DSN=${MAILER_DSN}

View File

@ -20,23 +20,26 @@ const types = [
'text' => 'string', 'text' => 'string',
'varchar' => 'string', 'varchar' => 'string',
'phone_number' => 'PhoneNumber', 'phone_number' => 'PhoneNumber',
'float' => 'float', // TODO REMOVE THIS
]; ];
$files = array_merge(glob(ROOT . '/src/Entity/*.php'), $files = array_merge(glob(ROOT . '/src/Entity/*.php'),
array_merge(glob(ROOT . '/components/*/Entity/*.php'), array_merge(glob(ROOT . '/components/*/Entity/*.php'),
glob(ROOT . '/plugins/*/Entity/*.php'))); glob(ROOT . '/plugins/*/Entity/*.php')));
$classes = [];
$nullable_no_defaults_warning = []; $nullable_no_defaults_warning = [];
foreach ($files as $file) { foreach ($files as $file) {
require_once $file; require_once $file;
$class = str_replace(['/', 'src', 'components', 'plugins'], ['\\', 'App', 'Component', 'Plugin'], substr($file, strlen(ROOT) + 1, -4)); $declared = get_declared_classes();
foreach ($declared as $dc) {
if (!method_exists($class, 'schemaDef')) { if (preg_match('/(App|(Component|Plugin)\\\\[^\\\\]+)\\\\Entity/', $dc) && !in_array($dc, $classes)) {
continue; $class = $dc;
$classes[] = $class;
break;
}
} }
$no_ns_class = preg_replace('/.*?\\\\/', '', $class); $no_ns_class = preg_replace('/.*?\\\\/', '', $class);

View File

@ -1,11 +0,0 @@
#!/bin/sh
for plugin in plugins/*; do
install="${plugin}/bin/install.sh"
if [ -x "${install}" ]; then
( # subshell, to clear options/environment
set -x
"${install}"
)
fi
done

10
codeception.yml Normal file
View 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

View File

@ -22,10 +22,10 @@ declare(strict_types = 1);
namespace Component\Attachment; namespace Component\Attachment;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router; use App\Core\Router\RouteLoader;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Formatting; use App\Util\Formatting;
@ -34,11 +34,10 @@ use Component\Attachment\Entity as E;
use Doctrine\Common\Collections\ExpressionBuilder; use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr; use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use EventResult;
class Attachment extends Component class Attachment extends Component
{ {
public function onAddRoute(Router $r): EventResult public function onAddRoute(RouteLoader $r): bool
{ {
$r->connect('note_attachment_show', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}', [C\Attachment::class, 'attachmentShowWithNote']); $r->connect('note_attachment_show', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}', [C\Attachment::class, 'attachmentShowWithNote']);
$r->connect('note_attachment_view', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/view', [C\Attachment::class, 'attachmentViewWithNote']); $r->connect('note_attachment_view', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/view', [C\Attachment::class, 'attachmentViewWithNote']);
@ -52,13 +51,13 @@ class Attachment extends Component
* *
* This can be used in the future to deduplicate images by visual content * This can be used in the future to deduplicate images by visual content
*/ */
public function onHashFile(string $filename, ?string &$out_hash): EventResult public function onHashFile(string $filename, ?string &$out_hash): bool
{ {
$out_hash = hash_file(E\Attachment::FILEHASH_ALGO, $filename); $out_hash = hash_file(E\Attachment::FILEHASH_ALGO, $filename);
return Event::stop; return Event::stop;
} }
public function onNoteDeleteRelated(Note &$note, Actor $actor): EventResult public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
{ {
Cache::delete("note-attachments-{$note->getId()}"); Cache::delete("note-attachments-{$note->getId()}");
foreach ($note->getAttachments() as $attachment) { foreach ($note->getAttachments() as $attachment) {
@ -69,23 +68,16 @@ class Attachment extends Component
return Event::next; return Event::next;
} }
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): EventResult public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{ {
if (!\in_array('attachment_to_note', $note_qb->getAllAliases())) { $note_qb->leftJoin(E\AttachmentToNote::class, 'attachment_to_note', Expr\Join::WITH, 'note.id = attachment_to_note.note_id');
$note_qb->leftJoin(
join: E\AttachmentToNote::class,
alias: 'attachment_to_note',
conditionType: Expr\Join::WITH,
condition: 'note.id = attachment_to_note.note_id',
);
}
return Event::next; return Event::next;
} }
/** /**
* Populate $note_expr with the criteria for looking for notes with attachments * Populate $note_expr with the criteria for looking for notes with attachments
*/ */
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult 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; $include_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) { if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) {

View File

@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Attachment\Controller; namespace Component\Attachment\Controller;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\GSFile; use App\Core\GSFile;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
@ -35,7 +35,6 @@ use App\Util\Exception\NoSuchFileException;
use App\Util\Exception\NotFoundException; use App\Util\Exception\NotFoundException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use Component\Attachment\Entity\AttachmentThumbnail; use Component\Attachment\Entity\AttachmentThumbnail;
use Component\Attachment\Entity\AttachmentToNote;
use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -51,12 +50,7 @@ class Attachment extends Controller
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]); $attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
$note = \is_int($note) ? Note::getById($note) : $note; $note = \is_int($note) ? Note::getById($note) : $note;
// Before anything, two very important things! // Before anything, ensure proper scope
// first: ensure this attachment is associated with this note
if (DB::count(AttachmentToNote::class, ['attachment_id' => $attachment->getId(), 'note_id' => $note->getId()]) <= 0) {
throw new ClientException(_m('No such attachment.'), 404);
}
// second: ensure proper scope
if (!$note->isVisibleTo(Common::actor())) { if (!$note->isVisibleTo(Common::actor())) {
throw new ClientException(_m('You don\'t have permissions to view this attachment.'), 401); throw new ClientException(_m('You don\'t have permissions to view this attachment.'), 401);
} }
@ -95,7 +89,7 @@ class Attachment extends Controller
try { try {
return $this->attachment($attachment_id, $note_id, function ($res) use ($note_id, $attachment_id) { return $this->attachment($attachment_id, $note_id, function ($res) use ($note_id, $attachment_id) {
return [ return [
'_template' => 'attachment/view.html.twig', '_template' => '/cards/attachments/show.html.twig',
'download' => $res['attachment']->getDownloadUrl(note: $note_id), 'download' => $res['attachment']->getDownloadUrl(note: $note_id),
'title' => $res['title'], 'title' => $res['title'],
'attachment' => $res['attachment'], 'attachment' => $res['attachment'],
@ -151,18 +145,12 @@ class Attachment extends Controller
*/ */
public function attachmentThumbnailWithNote(Request $request, int $note_id, int $attachment_id, string $size = 'small'): Response public function attachmentThumbnailWithNote(Request $request, int $note_id, int $attachment_id, string $size = 'small'): Response
{ {
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]); // Before anything, ensure proper scope
$note = Note::getById($note_id); if (!Note::getById($note_id)->isVisibleTo(Common::actor())) {
throw new ClientException(_m('You don\'t have permissions to view this thumbnail.'), 401);
}
// Before anything, two very important things! $attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
// first: ensure this attachment is associated with this note
if (DB::count(AttachmentToNote::class, ['attachment_id' => $attachment->getId(), 'note_id' => $note->getId()]) <= 0) {
throw new ClientException(_m('No such attachment.'), 404);
}
// second: ensure proper scope
if (!$note->isVisibleTo(Common::actor())) {
throw new ClientException(_m('You don\'t have permissions to view this attachment.'), 401);
}
$crop = Common::config('thumbnail', 'smart_crop'); $crop = Common::config('thumbnail', 'smart_crop');

View File

@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Attachment\Entity; namespace Component\Attachment\Entity;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use DateTimeInterface; use DateTimeInterface;

View File

@ -24,18 +24,17 @@ declare(strict_types = 1);
namespace Component\Attachment\Entity; namespace Component\Attachment\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Core\Event; use App\Core\Event;
use App\Core\GSFile; use App\Core\GSFile;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException; use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\NoSuchFileException;
use App\Util\Exception\NotFoundException; use App\Util\Exception\NotFoundException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use DateTimeInterface; use DateTimeInterface;
@ -224,7 +223,8 @@ class Attachment extends Entity
$this->setFilename(null); $this->setFilename(null);
$this->setSize(null); $this->setSize(null);
// Important not to null neither width nor height // Important not to null neither width nor height
DB::wrapInTransaction(fn () => DB::persist($this)); DB::persist($this);
DB::flush();
} }
} else { } else {
// @codeCoverageIgnoreStart // @codeCoverageIgnoreStart
@ -339,11 +339,7 @@ class Attachment extends Entity
*/ */
public function getThumbnails() public function getThumbnails()
{ {
return DB::findBy( return DB::findBy('attachment_thumbnail', ['attachment_id' => $this->id]);
AttachmentThumbnail::class,
['attachment_id' => $this->id],
order_by: ['size' => 'ASC', 'mimetype' => 'ASC'],
);
} }
public function getPath() public function getPath()
@ -371,17 +367,15 @@ class Attachment extends Entity
* @throws ClientException * @throws ClientException
* @throws NotFoundException * @throws NotFoundException
* @throws ServerException * @throws ServerException
*
* @return AttachmentThumbnail
*/ */
public function getThumbnail(?string $size = null, bool $crop = false): ?AttachmentThumbnail public function getThumbnail(?string $size = null, bool $crop = false): ?AttachmentThumbnail
{ {
try {
return AttachmentThumbnail::getOrCreate(attachment: $this, size: $size, crop: $crop); return AttachmentThumbnail::getOrCreate(attachment: $this, size: $size, crop: $crop);
} catch (NoSuchFileException) {
return null;
}
} }
public function getThumbnailUrl(Note|int $note, ?string $size = null): string public function getThumbnailUrl(Note|int $note, ?string $size = null)
{ {
return Router::url('note_attachment_thumbnail', ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId(), 'size' => $size ?? Common::config('thumbnail', 'default_size')]); return Router::url('note_attachment_thumbnail', ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId(), 'size' => $size ?? Common::config('thumbnail', 'default_size')]);
} }

View File

@ -24,13 +24,12 @@ declare(strict_types = 1);
namespace Component\Attachment\Entity; namespace Component\Attachment\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Core\Event; use App\Core\Event;
use App\Core\GSFile; use App\Core\GSFile;
use App\Core\Log; use App\Core\Log;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
use App\Util\Exception\NotFoundException; use App\Util\Exception\NotFoundException;
@ -180,7 +179,7 @@ class AttachmentThumbnail extends Entity
if (isset($this->attachment) && !\is_null($this->attachment)) { if (isset($this->attachment) && !\is_null($this->attachment)) {
return $this->attachment; return $this->attachment;
} else { } else {
return $this->attachment = DB::findOneBy(Attachment::class, ['id' => $this->attachment_id]); return $this->attachment = DB::findOneBy('attachment', ['id' => $this->attachment_id]);
} }
} }
@ -205,7 +204,7 @@ class AttachmentThumbnail extends Entity
try { try {
return Cache::get( return Cache::get(
self::getCacheKey($attachment->getId(), $size_int), self::getCacheKey($attachment->getId(), $size_int),
fn () => DB::findOneBy(self::class, ['attachment_id' => $attachment->getId(), 'size' => $size_int]), fn () => DB::findOneBy('attachment_thumbnail', ['attachment_id' => $attachment->getId(), 'size' => $size_int]),
); );
} catch (NotFoundException) { } catch (NotFoundException) {
if (\is_null($attachment->getWidth()) || \is_null($attachment->getHeight())) { if (\is_null($attachment->getWidth()) || \is_null($attachment->getHeight())) {
@ -214,7 +213,7 @@ class AttachmentThumbnail extends Entity
[$predicted_width, $predicted_height] = self::predictScalingValues($attachment->getWidth(), $attachment->getHeight(), $size, $crop); [$predicted_width, $predicted_height] = self::predictScalingValues($attachment->getWidth(), $attachment->getHeight(), $size, $crop);
if (\is_null($attachment->getPath()) || !file_exists($attachment->getPath())) { if (\is_null($attachment->getPath()) || !file_exists($attachment->getPath())) {
// Before we quit, check if there's any other thumb // Before we quit, check if there's any other thumb
$alternative_thumbs = DB::findBy(self::class, ['attachment_id' => $attachment->getId()]); $alternative_thumbs = DB::findBy('attachment_thumbnail', ['attachment_id' => $attachment->getId()]);
usort($alternative_thumbs, fn ($l, $r) => $r->getSize() <=> $l->getSize()); usort($alternative_thumbs, fn ($l, $r) => $r->getSize() <=> $l->getSize());
if (empty($alternative_thumbs)) { if (empty($alternative_thumbs)) {
throw new NotStoredLocallyException(); throw new NotStoredLocallyException();
@ -254,14 +253,14 @@ class AttachmentThumbnail extends Entity
} }
} }
public function getPath(): string public function getPath()
{ {
return Common::config('thumbnail', 'dir') . \DIRECTORY_SEPARATOR . $this->getFilename(); return Common::config('thumbnail', 'dir') . \DIRECTORY_SEPARATOR . $this->getFilename();
} }
public function getUrl(Note|int $note): string public function getUrl()
{ {
return Router::url('note_attachment_thumbnail', ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getAttachmentId(), 'size' => self::sizeIntToStr($this->getSize())]); return Router::url('attachment_thumbnail', ['id' => $this->getAttachmentId(), 'size' => self::sizeIntToStr($this->getSize())]);
} }
/** /**
@ -278,10 +277,9 @@ class AttachmentThumbnail extends Entity
} }
} }
Cache::delete(self::getCacheKey($this->getAttachmentId(), $this->getSize())); Cache::delete(self::getCacheKey($this->getAttachmentId(), $this->getSize()));
if ($flush) {
DB::wrapInTransaction(fn () => DB::remove($this));
} else {
DB::remove($this); DB::remove($this);
if ($flush) {
DB::flush();
} }
} }

View File

@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Attachment\Entity; namespace Component\Attachment\Entity;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use DateTimeInterface; use DateTimeInterface;

View File

@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Attachment\Entity; namespace Component\Attachment\Entity;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use DateTimeInterface; use DateTimeInterface;

View File

@ -22,27 +22,26 @@ declare(strict_types = 1);
namespace Component\Avatar; namespace Component\Avatar;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\GSFile; use App\Core\GSFile;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router; use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Util\Common; use App\Util\Common;
use Component\Attachment\Entity\Attachment; use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentThumbnail; use Component\Attachment\Entity\AttachmentThumbnail;
use Component\Avatar\Controller as C; use Component\Avatar\Controller as C;
use Component\Avatar\Exception\NoAvatarException; use Component\Avatar\Exception\NoAvatarException;
use EventResult;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Avatar extends Component class Avatar extends Component
{ {
public function onInitializeComponent(): EventResult public function onInitializeComponent()
{ {
return EventResult::next;
} }
public function onAddRoute(Router $r): EventResult public function onAddRoute(RouteLoader $r): bool
{ {
$r->connect('avatar_actor', '/actor/{actor_id<\d+>}/avatar/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'avatar_view']); $r->connect('avatar_actor', '/actor/{actor_id<\d+>}/avatar/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'avatar_view']);
$r->connect('avatar_default', '/avatar/default/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'default_avatar_view']); $r->connect('avatar_default', '/avatar/default/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'default_avatar_view']);
@ -51,11 +50,9 @@ class Avatar extends Component
} }
/** /**
* @param SettingsTabsType $tabs
*
* @throws \App\Util\Exception\ClientException * @throws \App\Util\Exception\ClientException
*/ */
public function onPopulateSettingsTabs(Request $request, string $section, &$tabs): EventResult public function onPopulateSettingsTabs(Request $request, string $section, &$tabs): bool
{ {
if ($section === 'profile') { if ($section === 'profile') {
$tabs[] = [ $tabs[] = [
@ -68,7 +65,7 @@ class Avatar extends Component
return Event::next; return Event::next;
} }
public function onAvatarUpdate(int $actor_id): EventResult public function onAvatarUpdate(int $actor_id): bool
{ {
Cache::delete("avatar-{$actor_id}"); Cache::delete("avatar-{$actor_id}");
foreach (['full', 'big', 'medium', 'small'] as $size) { foreach (['full', 'big', 'medium', 'small'] as $size) {
@ -131,8 +128,6 @@ class Avatar extends Component
* *
* Returns the avatar file's hash, mimetype, title and path. * Returns the avatar file's hash, mimetype, title and path.
* Ensures exactly one cached value exists * Ensures exactly one cached value exists
*
* @return array{id: null|int, filename: null|string, title: string, mimetype: string, filepath?: string}
*/ */
public static function getAvatarFileInfo(int $actor_id, string $size = 'medium'): array public static function getAvatarFileInfo(int $actor_id, string $size = 'medium'): array
{ {

View File

@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Avatar\Controller; namespace Component\Avatar\Controller;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\Form; use App\Core\Form;
use App\Core\GSFile; use App\Core\GSFile;

View File

@ -24,10 +24,10 @@ declare(strict_types = 1);
namespace Component\Avatar\Entity; namespace Component\Avatar\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Core\Event; use App\Core\Event;
use App\Core\Router; use App\Core\Router\Router;
use App\Util\Common; use App\Util\Common;
use Component\Attachment\Entity\Attachment; use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentThumbnail; use Component\Attachment\Entity\AttachmentThumbnail;

View File

@ -24,14 +24,16 @@ declare(strict_types = 1);
namespace Component\Circle; namespace Component\Circle;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router; use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Feed; use App\Entity\Feed;
use App\Entity\LocalUser; use App\Entity\LocalUser;
use App\Util\Common;
use App\Util\Nickname; use App\Util\Nickname;
use Component\Circle\Controller as CircleController; use Component\Circle\Controller as CircleController;
use Component\Circle\Entity\ActorCircle; use Component\Circle\Entity\ActorCircle;
@ -39,7 +41,6 @@ use Component\Circle\Entity\ActorCircleSubscription;
use Component\Circle\Entity\ActorTag; use Component\Circle\Entity\ActorTag;
use Component\Collection\Util\MetaCollectionTrait; use Component\Collection\Util\MetaCollectionTrait;
use Component\Tag\Tag; use Component\Tag\Tag;
use EventResult;
use Functional as F; use Functional as F;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -54,13 +55,12 @@ use Symfony\Component\HttpFoundation\Request;
*/ */
class Circle extends Component class Circle extends Component
{ {
/** @phpstan-use MetaCollectionTrait<ActorCircle> */
use MetaCollectionTrait; use MetaCollectionTrait;
public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/'; public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/';
protected const SLUG = 'circle'; protected string $slug = 'circle';
protected const PLURAL_SLUG = 'circles'; protected string $plural_slug = 'circles';
public function onAddRoute(Router $r): EventResult public function onAddRoute(RouteLoader $r): bool
{ {
$r->connect('actor_circle_view_by_circle_id', '/circle/{circle_id<\d+>}', [CircleController\Circle::class, 'circleById']); $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 // View circle members by (tagger id or nickname) and tag
@ -95,23 +95,20 @@ class Circle extends Component
]; ];
} }
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): EventResult public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool
{ {
if ($section === 'profile' && \in_array($request->get('_route'), ['person_actor_settings', 'group_actor_settings'])) { if ($section === 'profile' && $request->get('_route') === 'settings') {
$tabs[] = [ $tabs[] = [
'title' => _m('Self tags'), 'title' => 'Self tags',
'desc' => _m('Add or remove tags to this actor'), 'desc' => 'Add or remove tags on yourself',
'id' => 'settings-self-tags', 'id' => 'settings-self-tags',
'controller' => CircleController\SelfTagsSettings::settingsSelfTags($request, Actor::getById((int) $request->get('id')), 'settings-self-tags-details'), 'controller' => CircleController\SelfTagsSettings::settingsSelfTags($request, Common::actor(), 'settings-self-tags-details'),
]; ];
} }
return Event::next; return Event::next;
} }
/** public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): bool
* @param Actor[] $targets
*/
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): EventResult
{ {
$circles = $actor->getCircles(); $circles = $actor->getCircles();
foreach ($circles as $circle) { foreach ($circles as $circle) {
@ -123,9 +120,6 @@ class Circle extends Component
// Meta Collection ------------------------------------------------------------------- // Meta Collection -------------------------------------------------------------------
/**
* @param array<string, mixed> $vars
*/
private function getActorIdFromVars(array $vars): int private function getActorIdFromVars(array $vars): int
{ {
$id = $vars['request']->get('id', null); $id = $vars['request']->get('id', null);
@ -137,7 +131,7 @@ class Circle extends Component
return $user->getId(); return $user->getId();
} }
public static function createCircle(Actor|int $tagger_id, string $tag): int|null public static function createCircle(Actor|int $tagger_id, string $tag): int
{ {
$tagger_id = \is_int($tagger_id) ? $tagger_id : $tagger_id->getId(); $tagger_id = \is_int($tagger_id) ? $tagger_id : $tagger_id->getId();
$circle = ActorCircle::create([ $circle = ActorCircle::create([
@ -153,10 +147,7 @@ class Circle extends Component
return $circle->getId(); return $circle->getId();
} }
/** protected function createCollection(Actor $owner, array $vars, string $name)
* @param array<string, mixed> $vars
*/
protected function createCollection(Actor $owner, array $vars, string $name): void
{ {
$this->createCircle($owner, $name); $this->createCircle($owner, $name);
DB::persist(ActorTag::create([ DB::persist(ActorTag::create([
@ -166,12 +157,7 @@ class Circle extends Component
])); ]));
} }
/** protected function removeItem(Actor $owner, array $vars, $items, array $collections)
* @param array<string, mixed> $vars
* @param array<int> $items
* @param array<mixed> $collections
*/
protected function removeItem(Actor $owner, array $vars, array $items, array $collections): bool
{ {
$tagger_id = $owner->getId(); $tagger_id = $owner->getId();
$tagged_id = $this->getActorIdFromVars($vars); $tagged_id = $this->getActorIdFromVars($vars);
@ -184,15 +170,9 @@ class Circle extends Component
DB::removeBy(ActorTag::class, ['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]); DB::removeBy(ActorTag::class, ['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]);
} }
Cache::delete(Actor::cacheKeys($tagger_id)['circles']); Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
return true;
} }
/** protected function addItem(Actor $owner, array $vars, $items, array $collections)
* @param array<string, mixed> $vars
* @param array<int> $items
* @param array<mixed> $collections
*/
protected function addItem(Actor $owner, array $vars, array $items, array $collections): void
{ {
$tagger_id = $owner->getId(); $tagger_id = $owner->getId();
$tagged_id = $this->getActorIdFromVars($vars); $tagged_id = $this->getActorIdFromVars($vars);
@ -209,27 +189,12 @@ class Circle extends Component
/** /**
* @see MetaCollectionPlugin->shouldAddToRightPanel * @see MetaCollectionPlugin->shouldAddToRightPanel
*
* @param array<string, mixed> $vars
*/ */
protected function shouldAddToRightPanel(Actor $user, array $vars, Request $request): bool protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool
{ {
return \in_array($vars['path'], ['actor_view_nickname', 'actor_view_id']); return \in_array($vars['path'], ['actor_view_nickname', 'actor_view_id']);
} }
/**
* Retrieves an array of Collections owned by an Actor.
* In this case, Collections of those within Actor's own circle of Actors, aka ActorCircle.
*
* Differs from the overwritten method in MetaCollectionsTrait, since retrieved Collections come from the $owner
* itself, and from every Actor that is a part of its ActorCircle.
*
* @param Actor $owner the Actor, and by extension its own circle of Actors
* @param null|array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
* @param bool $ids_only true if only the Collections ids are to be returned
*
* @return ($ids_only is true ? int[] : ActorCircle[])
*/
protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array
{ {
$tagged_id = !\is_null($vars) ? $this->getActorIdFromVars($vars) : null; $tagged_id = !\is_null($vars) ? $this->getActorIdFromVars($vars) : null;
@ -244,7 +209,7 @@ class Circle extends Component
return $ids_only ? array_map(fn ($x) => $x->getId(), $circles) : $circles; return $ids_only ? array_map(fn ($x) => $x->getId(), $circles) : $circles;
} }
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): EventResult public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
{ {
DB::persist(Feed::create([ DB::persist(Feed::create([
'actor_id' => $actor_id, 'actor_id' => $actor_id,

View File

@ -31,16 +31,6 @@ use Component\Collection\Util\Controller\CircleController;
class Circle extends CircleController class Circle extends CircleController
{ {
/**
* Render an existing ActorCircle with the given id as a Collection of Actors
*
* @param ActorCircle|int $circle_id the desired ActorCircle id
*
* @throws \App\Util\Exception\ServerException
* @throws ClientException
*
* @return ControllerResultType
*/
public function circleById(int|ActorCircle $circle_id): array public function circleById(int|ActorCircle $circle_id): array
{ {
$circle = \is_int($circle_id) ? ActorCircle::getByPK(['id' => $circle_id]) : $circle_id; $circle = \is_int($circle_id) ? ActorCircle::getByPK(['id' => $circle_id]) : $circle_id;
@ -59,17 +49,11 @@ class Circle extends CircleController
} }
} }
/**
* @return ControllerResultType
*/
public function circleByTaggerIdAndTag(int $tagger_id, string $tag): array public function circleByTaggerIdAndTag(int $tagger_id, string $tag): array
{ {
return $this->circleById(ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])); return $this->circleById(ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag]));
} }
/**
* @return ControllerResultType
*/
public function circleByTaggerNicknameAndTag(string $tagger_nickname, string $tag): array public function circleByTaggerNicknameAndTag(string $tagger_nickname, string $tag): array
{ {
return $this->circleById(ActorCircle::getByPK(['tagger' => LocalUser::getByNickname($tagger_nickname)->getId(), 'tag' => $tag])); return $this->circleById(ActorCircle::getByPK(['tagger' => LocalUser::getByNickname($tagger_nickname)->getId(), 'tag' => $tag]));

View File

@ -24,27 +24,23 @@ declare(strict_types = 1);
namespace Component\Circle\Controller; namespace Component\Circle\Controller;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\LocalUser; use App\Entity\LocalUser;
use Component\Circle\Entity\ActorCircle; use Component\Circle\Entity\ActorCircle;
use Component\Collection\Util\Controller\MetaCollectionController; use Component\Collection\Util\Controller\MetaCollectionController;
/**
* @extends MetaCollectionController<Circles>
*/
class Circles extends MetaCollectionController class Circles extends MetaCollectionController
{ {
protected const SLUG = 'circle'; protected string $slug = 'circle';
protected const PLURAL_SLUG = 'circles'; protected string $plural_slug = 'circles';
protected string $page_title = 'Actor circles'; protected string $page_title = 'Actor circles';
public function createCollection(int $owner_id, string $name): bool public function createCollection(int $owner_id, string $name)
{ {
return !\is_null(\Component\Circle\Circle::createCircle($owner_id, $name)); return \Component\Circle\Circle::createCircle($owner_id, $name);
} }
public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string
{ {
return Router::url( return Router::url(
@ -53,26 +49,21 @@ class Circles extends MetaCollectionController
); );
} }
/** public function getCollectionItems(int $owner_id, $collection_id): array
* @return Circles[]
*/
public function getCollectionItems(int $owner_id, int $collection_id): array
{ {
return []; // TODO $notes = []; // TODO: Use Feed::query
return [
'_template' => 'collection/notes.html.twig',
'notes' => $notes,
];
} }
/**
* @return Circles[]
*/
public function feedByCircleId(int $circle_id) public function feedByCircleId(int $circle_id)
{ {
// Owner id isn't used // Owner id isn't used
return $this->getCollectionItems(0, $circle_id); return $this->getCollectionItems(0, $circle_id);
} }
/**
* @return Circles[]
*/
public function feedByTaggerIdAndTag(int $tagger_id, string $tag) public function feedByTaggerIdAndTag(int $tagger_id, string $tag)
{ {
// Owner id isn't used // Owner id isn't used
@ -80,9 +71,6 @@ class Circles extends MetaCollectionController
return $this->getCollectionItems($tagger_id, $circle_id); return $this->getCollectionItems($tagger_id, $circle_id);
} }
/**
* @return Circles[]
*/
public function feedByTaggerNicknameAndTag(string $tagger_nickname, string $tag) public function feedByTaggerNicknameAndTag(string $tagger_nickname, string $tag)
{ {
$tagger_id = LocalUser::getByNickname($tagger_nickname)->getId(); $tagger_id = LocalUser::getByNickname($tagger_nickname)->getId();
@ -94,13 +82,12 @@ class Circles extends MetaCollectionController
{ {
return DB::findBy(ActorCircle::class, ['tagger' => $owner_id], order_by: ['id' => 'desc']); return DB::findBy(ActorCircle::class, ['tagger' => $owner_id], order_by: ['id' => 'desc']);
} }
public function getCollectionBy(int $owner_id, int $collection_id): ActorCircle
public function getCollectionBy(int $owner_id, int $collection_id): self
{ {
return DB::findOneBy(ActorCircle::class, ['id' => $collection_id, 'actor_id' => $owner_id]); 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): void public function setCollectionName(int $actor_id, string $actor_nickname, ActorCircle $collection, string $name)
{ {
foreach ($collection->getActorTags(db_reference: true) as $at) { foreach ($collection->getActorTags(db_reference: true) as $at) {
$at->setTag($name); $at->setTag($name);
@ -109,7 +96,7 @@ class Circles extends MetaCollectionController
Cache::delete(Actor::cacheKeys($actor_id)['circles']); Cache::delete(Actor::cacheKeys($actor_id)['circles']);
} }
public function removeCollection(int $actor_id, string $actor_nickname, ActorCircle $collection): void public function removeCollection(int $actor_id, string $actor_nickname, ActorCircle $collection)
{ {
foreach ($collection->getActorTags(db_reference: true) as $at) { foreach ($collection->getActorTags(db_reference: true) as $at) {
DB::remove($at); DB::remove($at);

View File

@ -6,7 +6,7 @@ namespace Component\Circle\Controller;
use App\Core\Cache; use App\Core\Cache;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB; use App\Core\DB\DB;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Entity as E; use App\Entity as E;
use App\Util\Common; use App\Util\Common;
@ -23,12 +23,11 @@ class SelfTagsSettings extends Controller
{ {
/** /**
* Generic settings page for an Actor's self tags * Generic settings page for an Actor's self tags
* TODO: We should have $actor->setSelfTags(), $actor->addSelfTags(), $actor->removeSelfTags()
*/ */
public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id) public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id)
{ {
$actor = Common::actor(); $actor = Common::actor();
if (!$actor->canModerate($target)) { if (!$actor->canAdmin($target)) {
throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()])); throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()]));
} }
@ -45,7 +44,7 @@ class SelfTagsSettings extends Controller
foreach ($tags as $tag) { foreach ($tags as $tag) {
$tag = CompTag::sanitize($tag); $tag = CompTag::sanitize($tag);
[$actor_tag, $actor_tag_existed] = ActorTag::checkExistingAndCreateOrUpdate([ [$actor_tag, $actor_tag_existed] = ActorTag::createOrUpdate([
'tagger' => $target->getId(), // self tag means tagger = tagger in ActorTag 'tagger' => $target->getId(), // self tag means tagger = tagger in ActorTag
'tagged' => $target->getId(), 'tagged' => $target->getId(),
'tag' => $tag, 'tag' => $tag,

View File

@ -22,10 +22,9 @@ declare(strict_types = 1);
namespace Component\Circle\Entity; namespace Component\Circle\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Actor;
use DateTimeInterface; use DateTimeInterface;
/** /**
@ -50,7 +49,7 @@ class ActorCircle extends Entity
// {{{ Autocode // {{{ Autocode
// @codeCoverageIgnoreStart // @codeCoverageIgnoreStart
private int $id; private int $id;
private ?int $tagger = null; private ?int $tagger = null; // If null, is the special global self-tag circle
private string $tag; private string $tag;
private ?string $description = null; private ?string $description = null;
private ?bool $private = false; private ?bool $private = false;
@ -145,9 +144,6 @@ class ActorCircle extends Entity
return $this->tag; return $this->tag;
} }
/**
* @return ActorTag[]
*/
public function getActorTags(bool $db_reference = false): array public function getActorTags(bool $db_reference = false): array
{ {
$handle = fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]); $handle = fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]);
@ -160,10 +156,7 @@ class ActorCircle extends Entity
); );
} }
/** public function getTaggedActors()
* @return Actor[]
*/
public function getTaggedActors(): array
{ {
return Cache::get( return Cache::get(
"circle-{$this->getId()}-tagged-actors", "circle-{$this->getId()}-tagged-actors",
@ -177,9 +170,6 @@ class ActorCircle extends Entity
); );
} }
/**
* @return Actor[]
*/
public function getSubscribedActors(?int $offset = null, ?int $limit = null): array public function getSubscribedActors(?int $offset = null, ?int $limit = null): array
{ {
return Cache::get( return Cache::get(
@ -212,7 +202,7 @@ class ActorCircle extends Entity
'description' => 'An actor can have lists of actors, to separate their feed or quickly mention his friend', 'description' => 'An actor can have lists of actors, to separate their feed or quickly mention his friend',
'fields' => [ 'fields' => [
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], // An actor can be tagged by many actors '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. If null, is the special global self-tag circle'], '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 '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'], 'description' => ['type' => 'text', 'description' => 'description of the people tag'],
'private' => ['type' => 'bool', 'default' => false, 'description' => 'is this tag private'], 'private' => ['type' => 'bool', 'default' => false, 'description' => 'is this tag private'],

View File

@ -21,9 +21,9 @@ declare(strict_types = 1);
namespace Component\Circle\Entity; namespace Component\Circle\Entity;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Actor; use App\Entity\Actor;
use Component\Tag\Tag; use Component\Tag\Tag;
use DateTimeInterface; use DateTimeInterface;

View File

@ -7,18 +7,14 @@ namespace Component\Circle\Form;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Util\Form\ArrayTransformer; use App\Util\Form\ArrayTransformer;
use Component\Circle\Entity\ActorTag;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
abstract class SelfTagsForm abstract class SelfTagsForm
{ {
/** /**
* @param ActorTag[] $actor_self_tags * @return array [Form (add), ?Form (existing)]
*
* @return array{FormInterface, ?FormInterface} [Form (add), ?Form (existing)]
*/ */
public static function handleTags( public static function handleTags(
Request $request, Request $request,
@ -38,7 +34,7 @@ abstract class SelfTagsForm
$existing_form = !empty($form_definition) ? Form::create($form_definition) : null; $existing_form = !empty($form_definition) ? Form::create($form_definition) : null;
$add_form = Form::create([ $add_form = Form::create([
['new-tags', TextType::class, ['label' => ' ', 'data' => [], 'required' => false, 'help' => _m('Tags for this actor (letters, numbers, -, ., and _), comma- or space-separated.'), 'transformer' => ArrayTransformer::class]], ['new-tags', TextType::class, ['label' => ' ', 'data' => [], 'required' => false, 'help' => _m('Tags for yourself (letters, numbers, -, ., and _), comma- or space-separated.'), 'transformer' => ArrayTransformer::class]],
[$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]], [$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]],
]); ]);

View File

@ -4,18 +4,16 @@ declare(strict_types = 1);
namespace Component\Collection; namespace Component\Collection;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Formatting; use App\Util\Formatting;
use Component\Collection\Util\Parser; use Component\Collection\Util\Parser;
use Component\Subscription\Entity\ActorSubscription; use Component\Subscription\Entity\ActorSubscription;
use Doctrine\Common\Collections\ExpressionBuilder; use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr; use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use EventResult;
class Collection extends Component class Collection extends Component
{ {
@ -24,37 +22,21 @@ class Collection extends Component
* *
* Supports a variety of query terms and is used both in feeds and * Supports a variety of query terms and is used both in feeds and
* in search. Uses query builders to allow for extension * in search. Uses query builders to allow for extension
*
* @param array<string, OrderByType> $note_order_by
* @param array<string, OrderByType> $actor_order_by
*
* @return array{notes: null|Note[], actors: null|Actor[]}
*/ */
public static function query(string $query, int $page, ?string $locale = null, ?Actor $actor = null, array $note_order_by = [], array $actor_order_by = []): array public static function query(string $query, int $page, ?string $locale = null, ?Actor $actor = null): array
{ {
$note_criteria = null; $note_criteria = null;
$actor_criteria = null; $actor_criteria = null;
if (!empty($query = trim($query))) { if (!empty($query = trim($query))) {
[$note_criteria, $actor_criteria] = Parser::parse($query, $locale, $actor); [$note_criteria, $actor_criteria] = Parser::parse($query, $locale, $actor);
} }
$note_qb = DB::createQueryBuilder(); $note_qb = DB::createQueryBuilder();
$actor_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) // 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'); $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'); $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]); Event::handle('CollectionQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
// Handle ordering
$note_order_by = !empty($note_order_by) ? $note_order_by : ['note.created' => 'DESC', 'note.id' => 'DESC'];
$actor_order_by = !empty($actor_order_by) ? $actor_order_by : ['actor.created' => 'DESC', 'actor.id' => 'DESC'];
foreach ($note_order_by as $field => $order) {
$note_qb->addOrderBy($field, $order);
}
foreach ($actor_order_by as $field => $order) {
$actor_qb->addOrderBy($field, $order);
}
$notes = []; $notes = [];
$actors = []; $actors = [];
if (!\is_null($note_criteria)) { if (!\is_null($note_criteria)) {
@ -71,26 +53,18 @@ class Collection extends Component
return ['notes' => $notes ?? null, 'actors' => $actors ?? null]; return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
} }
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): EventResult public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{ {
$note_aliases = $note_qb->getAllAliases(); $note_qb->leftJoin(ActorSubscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed_id')
if (!\in_array('subscription', $note_aliases)) { ->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
$note_qb->leftJoin(ActorSubscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed_id');
}
if (!\in_array('note_actor', $note_aliases)) {
$note_qb->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
}
return Event::next; return Event::next;
} }
/** /**
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text * 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 * notes, for different types of actors and for the content of text notes
*
* @param mixed $note_expr
* @param mixed $actor_expr
*/ */
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr)
{ {
if (str_contains($term, ':')) { if (str_contains($term, ':')) {
$term = explode(':', $term); $term = explode(':', $term);
@ -130,7 +104,7 @@ class Collection extends Component
if (\in_array($type, ['actor', 'actors'])) { if (\in_array($type, ['actor', 'actors'])) {
$type_consts = null; $type_consts = null;
} else { } else {
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type === 'organisation' ? 'group' : $type)); $type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type));
} }
} }
} }
@ -153,7 +127,8 @@ class Collection extends Component
foreach ( foreach (
[ [
Actor::PERSON => ['person', 'people'], Actor::PERSON => ['person', 'people'],
Actor::GROUP => ['group', 'groups', 'org', 'orgs', 'organisation', 'organisations', 'organization', 'organizations'], Actor::GROUP => ['group', 'groups'],
Actor::ORGANISATION => ['org', 'orgs', 'organization', 'organizations', 'organisation', 'organisations'],
Actor::BOT => ['bot', 'bots'], Actor::BOT => ['bot', 'bots'],
] as $type => $match) { ] as $type => $match) {
if (array_intersect(explode(',', $term[1]), $match) !== []) { if (array_intersect(explode(',', $term[1]), $match) !== []) {

View File

@ -0,0 +1,71 @@
<?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/>.
// }}}
/**
* Base class for feed controllers
*
* @package GNUsocial
* @category Controller
*
* @author Hugo Sales <hugo@hsal.es>
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Component\Collection\Util;
use App\Core\DB\DB;
use function App\Core\I18n\_m;
use App\Core\Router\Router;
use App\Util\Exception\ClientException;
trait ActorControllerTrait
{
/**
* Generic function that handles getting a representation for an actor from id
*/
protected function handleActorById(int $id, callable $handle)
{
$actor = DB::findOneBy('actor', ['id' => $id]);
if ($actor->getIsLocal()) {
return ['_redirect' => $actor->getUrl(Router::ABSOLUTE_PATH), 'actor' => $actor];
}
if (empty($actor)) {
throw new ClientException(_m('No such actor.'), 404);
} else {
return $handle($actor);
}
}
/**
* Generic function that handles getting a representation for an actor from nickname
*/
protected function handleActorByNickname(string $nickname, callable $handle)
{
$user = DB::findOneBy('local_user', ['nickname' => $nickname]);
$actor = DB::findOneBy('actor', ['id' => $user->getId()]);
if (empty($actor)) {
throw new ClientException(_m('No such actor.'), 404);
} else {
return $handle($actor);
}
}
}

View File

@ -4,9 +4,6 @@ declare(strict_types = 1);
namespace Component\Collection\Util\Controller; namespace Component\Collection\Util\Controller;
/**
* @extends OrderedCollection<\Component\Circle\Entity\ActorCircle>
*/
class CircleController extends OrderedCollection class CircleController extends OrderedCollection
{ {
} }

View File

@ -6,25 +6,15 @@ namespace Component\Collection\Util\Controller;
use App\Core\Controller; use App\Core\Controller;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use Component\Collection\Collection as CollectionComponent; use Component\Collection\Collection as CollectionModule;
/** class Collection extends Controller
* @template T
*/
abstract class Collection extends Controller
{ {
/** public function query(string $query, ?string $locale = null, ?Actor $actor = null): array
* @param array<string, OrderByType> $note_order_by
* @param array<string, OrderByType> $actor_order_by
*
* @return array{notes: null|Note[], actors: null|Actor[]}
*/
public function query(string $query, ?string $locale = null, ?Actor $actor = null, array $note_order_by = [], array $actor_order_by = []): array
{ {
$actor ??= Common::actor(); $actor ??= Common::actor();
$locale ??= Common::currentLanguage()->getLocale(); $locale ??= Common::currentLanguage()->getLocale();
return CollectionComponent::query($query, $this->int('page') ?? 1, $locale, $actor, $note_order_by, $actor_order_by); return CollectionModule::query($query, $this->int('page') ?? 1, $locale, $actor);
} }
} }

View File

@ -38,30 +38,19 @@ use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use Functional as F; use Functional as F;
/**
* @template T
*
* @extends OrderedCollection<T>
*/
abstract class FeedController extends OrderedCollection abstract class FeedController extends OrderedCollection
{ {
/** /**
* Post-processing of the result of a feed controller, to remove any * Post-processing of the result of a feed controller, to remove any
* notes or actors the user specified, as well as format the raw * notes or actors the user specified, as well as format the raw
* list of notes into a usable format * list of notes into a usable format
*
* @template NA of Note|Actor
*
* @param NA[] $result
*
* @return NA[]
*/ */
protected function postProcess(array $result): array protected function postProcess(array $result): array
{ {
$actor = Common::actor(); $actor = Common::actor();
if (\array_key_exists('notes', $result)) { if (\array_key_exists('notes', $result)) {
$notes = $result['notes']; $notes = $result['notes'];
self::enforceScope($notes, $actor, $result['actor'] ?? null); self::enforceScope($notes, $actor);
Event::handle('FilterNoteList', [$actor, &$notes, $result['request']]); Event::handle('FilterNoteList', [$actor, &$notes, $result['request']]);
Event::handle('FormatNoteList', [$notes, &$result['notes'], &$result['request']]); Event::handle('FormatNoteList', [$notes, &$result['notes'], &$result['request']]);
} }
@ -69,11 +58,8 @@ abstract class FeedController extends OrderedCollection
return $result; return $result;
} }
/** private static function enforceScope(array &$notes, ?Actor $actor): void
* @param Note[] $notes
*/
private static function enforceScope(array &$notes, ?Actor $actor, ?Actor $in = null): void
{ {
$notes = F\select($notes, fn (Note $n) => $n->isVisibleTo($actor, $in)); $notes = F\select($notes, fn (Note $n) => $n->isVisibleTo($actor));
} }
} }

View File

@ -31,7 +31,7 @@ declare(strict_types = 1);
namespace Component\Collection\Util\Controller; namespace Component\Collection\Util\Controller;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Entity\LocalUser; use App\Entity\LocalUser;
@ -39,51 +39,26 @@ use App\Util\Common;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
/**
* @template T of object
*
* @extends FeedController<T>
*/
abstract class MetaCollectionController extends FeedController abstract class MetaCollectionController extends FeedController
{ {
protected const SLUG = 'collectionsEntry'; protected string $slug = 'collectionsEntry';
protected const PLURAL_SLUG = 'collectionsList'; protected string $plural_slug = 'collectionsList';
protected string $page_title = 'Collections'; protected string $page_title = 'Collections';
abstract public function getCollectionUrl(int $owner_id, string $owner_nickname, int $collection_id): string; abstract public function getCollectionUrl(int $owner_id, string $owner_nickname, int $collection_id): string;
abstract public function getCollectionItems(int $owner_id, $collection_id): array;
/**
* @return T[]
*/
abstract public function getCollectionItems(int $owner_id, int $collection_id): array;
/**
* @return T[]
*/
abstract public function getCollectionsByActorId(int $owner_id): array; abstract public function getCollectionsByActorId(int $owner_id): array;
abstract public function getCollectionBy(int $owner_id, int $collection_id);
abstract public function createCollection(int $owner_id, string $name);
/**
* @return T A collection
*/
abstract public function getCollectionBy(int $owner_id, int $collection_id): object;
abstract public function createCollection(int $owner_id, string $name): bool;
/**
* @return ControllerResultType
*/
public function collectionsViewByActorNickname(Request $request, string $nickname): array public function collectionsViewByActorNickname(Request $request, string $nickname): array
{ {
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]); $user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
return self::collectionsView($request, $user->getId(), $nickname); return self::collectionsView($request, $user->getId(), $nickname);
} }
/**
* @return ControllerResultType
*/
public function collectionsViewByActorId(Request $request, int $id): array public function collectionsViewByActorId(Request $request, int $id): array
{ {
return self::collectionsView($request, $id, null); return self::collectionsView($request, $id, null);
@ -95,14 +70,14 @@ abstract class MetaCollectionController extends FeedController
* @param int $id actor id * @param int $id actor id
* @param ?string $nickname actor nickname * @param ?string $nickname actor nickname
* *
* @return ControllerResultType twig template options * @return array twig template options
*/ */
public function collectionsView(Request $request, int $id, ?string $nickname): array public function collectionsView(Request $request, int $id, ?string $nickname): array
{ {
$collections = $this->getCollectionsByActorId($id); $collections = $this->getCollectionsByActorId($id);
$create_title = _m('Create a ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', static::SLUG))); $create_title = _m('Create a ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->slug)));
$collections_title = _m('The ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', static::PLURAL_SLUG))); $collections_title = _m('The ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->plural_slug)));
// create collection form // create collection form
$create = null; $create = null;
if (Common::user()?->getId() === $id) { if (Common::user()?->getId() === $id) {
@ -136,25 +111,36 @@ abstract class MetaCollectionController extends FeedController
// //
// Instead, I'm using an anonymous class to encapsulate // Instead, I'm using an anonymous class to encapsulate
// the functions and passing that class to the template. // the functions and passing that class to the template.
// This is suggested at https://web.archive.org/web/20220226132328/https://stackoverflow.com/questions/3595727/twig-pass-function-into-template/50364502 // This is suggested at https://stackoverflow.com/a/50364502.
$fn = new class($id, $nickname, $request, $this, static::SLUG) { $fn = new class($id, $nickname, $request, $this, $this->slug) {
public function __construct(private int $id, private string $nickname, private Request $request, private object $parent, private string $slug) private $id;
private $nick;
private $request;
private $parent;
private $slug;
public function __construct($id, $nickname, $request, $parent, $slug)
{ {
$this->id = $id;
$this->nick = $nickname;
$this->request = $request;
$this->parent = $parent;
$this->slug = $slug;
} }
// there's already an injected function called path, // there's already an injected function called path,
// that maps to Router::url(name, args), but since // that maps to Router::url(name, args), but since
// I want to preserve nicknames, I think it's better // I want to preserve nicknames, I think it's better
// to use that getUrl function // to use that getUrl function
public function getUrl(int $cid): string public function getUrl($cid)
{ {
return $this->parent->getCollectionUrl($this->id, $this->nickname, $cid); return $this->parent->getCollectionUrl($this->id, $this->nick, $cid);
} }
// There are many collections in this page and we need two // There are many collections in this page and we need two
// forms for each one of them: one form to edit the collection's // forms for each one of them: one form to edit the collection's
// name and another to remove the collection. // name and another to remove the collection.
// creating the edit form // creating the edit form
public function editForm(object $collection): FormView public function editForm($collection)
{ {
$edit = Form::create([ $edit = Form::create([
['name', TextType::class, [ ['name', TextType::class, [
@ -173,7 +159,7 @@ abstract class MetaCollectionController extends FeedController
]); ]);
$edit->handleRequest($this->request); $edit->handleRequest($this->request);
if ($edit->isSubmitted() && $edit->isValid()) { if ($edit->isSubmitted() && $edit->isValid()) {
$this->parent->setCollectionName($this->id, $this->nickname, $collection, $edit->getData()['name']); $this->parent->setCollectionName($this->id, $this->nick, $collection, $edit->getData()['name']);
DB::flush(); DB::flush();
throw new RedirectException(); throw new RedirectException();
} }
@ -181,7 +167,7 @@ abstract class MetaCollectionController extends FeedController
} }
// creating the remove form // creating the remove form
public function rmForm(object $collection): FormView public function rmForm($collection)
{ {
$rm = Form::create([ $rm = Form::create([
['remove_' . $collection->getId(), SubmitType::class, [ ['remove_' . $collection->getId(), SubmitType::class, [
@ -194,7 +180,7 @@ abstract class MetaCollectionController extends FeedController
]); ]);
$rm->handleRequest($this->request); $rm->handleRequest($this->request);
if ($rm->isSubmitted()) { if ($rm->isSubmitted()) {
$this->parent->removeCollection($this->id, $this->nickname, $collection); $this->parent->removeCollection($this->id, $this->nick, $collection);
DB::flush(); DB::flush();
throw new RedirectException(); throw new RedirectException();
} }
@ -212,18 +198,12 @@ abstract class MetaCollectionController extends FeedController
]; ];
} }
/**
* @return ControllerResultType
*/
public function collectionsEntryViewNotesByNickname(Request $request, string $nickname, int $cid): array public function collectionsEntryViewNotesByNickname(Request $request, string $nickname, int $cid): array
{ {
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]); $user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
return self::collectionsEntryViewNotesByActorId($request, $user->getId(), $cid); return self::collectionsEntryViewNotesByActorId($request, $user->getId(), $cid);
} }
/**
* @return ControllerResultType
*/
public function collectionsEntryViewNotesByActorId(Request $request, int $id, int $cid): array public function collectionsEntryViewNotesByActorId(Request $request, int $id, int $cid): array
{ {
$collection = $this->getCollectionBy($id, $cid); $collection = $this->getCollectionBy($id, $cid);

View File

@ -4,11 +4,6 @@ declare(strict_types = 1);
namespace Component\Collection\Util\Controller; namespace Component\Collection\Util\Controller;
/** class OrderedCollection extends Collection
* @template T
*
* @extends Collection<T>
*/
abstract class OrderedCollection extends Collection
{ {
} }

View File

@ -31,7 +31,7 @@ declare(strict_types = 1);
namespace Component\Collection\Util; namespace Component\Collection\Util;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
@ -39,61 +39,53 @@ use App\Entity\Actor;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use App\Util\Formatting; use App\Util\Formatting;
use EventResult;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
/**
* @template T
* */
trait MetaCollectionTrait trait MetaCollectionTrait
{ {
//protected const SLUG = 'collection'; //protected string $slug = 'collection';
//protected const PLURAL_SLUG = 'collections'; //protected string $plural_slug = 'collections';
/** /**
* create a collection owned by Actor $owner. * create a collection owned by Actor $owner.
* *
* @param Actor $owner The collection's owner * @param Actor $owner The collection's owner
* @param array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event * @param array $vars Page vars sent by AppendRightPanelBlock event
* @param string $name Collection's name * @param string $name Collection's name
*/ */
abstract protected function createCollection(Actor $owner, array $vars, string $name): void; abstract protected function createCollection(Actor $owner, array $vars, string $name);
/** /**
* remove item from collections. * remove item from collections.
* *
* @param Actor $owner Current user * @param Actor $owner Current user
* @param array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event * @param array $vars Page vars sent by AppendRightPanelBlock event
* @param int[] $items Array of collections's ids to remove the current item from * @param array $items Array of collections's ids to remove the current item from
* @param int[] $collections List of ids of collections owned by $owner * @param array $collections List of ids of collections owned by $owner
*/ */
abstract protected function removeItem(Actor $owner, array $vars, array $items, array $collections): bool; abstract protected function removeItem(Actor $owner, array $vars, array $items, array $collections);
/** /**
* add item to collections. * add item to collections.
* *
* @param Actor $owner Current user * @param Actor $owner Current user
* @param array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event * @param array $vars Page vars sent by AppendRightPanelBlock event
* @param int[] $items Array of collections's ids to add the current item to * @param array $items Array of collections's ids to add the current item to
* @param int[] $collections List of ids of collections owned by $owner * @param array $collections List of ids of collections owned by $owner
*/ */
abstract protected function addItem(Actor $owner, array $vars, array $items, array $collections): void; abstract protected function addItem(Actor $owner, array $vars, array $items, array $collections);
/** /**
* Check the route to determine whether the widget should be added * Check the route to determine whether the widget should be added
*
* @param array<string, mixed> $vars
*/ */
abstract protected function shouldAddToRightPanel(Actor $user, array $vars, Request $request): bool; abstract protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool;
/** /**
* Get array of collections's owned by $actor * Get array of collections's owned by $actor
* *
* @param Actor $owner Collection's owner * @param Actor $owner Collection's owner
* @param null|array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event * @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 * @param bool $ids_only if true, the function must return only the primary key or each collections
*
* @return int[]|T[]
*/ */
abstract protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array; abstract protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array;
@ -101,11 +93,8 @@ trait MetaCollectionTrait
* Append Collections widget to the right panel. * Append Collections widget to the right panel.
* It's compose of two forms: one to select collections to add * It's compose of two forms: one to select collections to add
* the current item to, and another to create a new collection. * the current item to, and another to create a new collection.
*
* @param array<string, mixed> $vars
* @param string[] $res
*/ */
public function onAppendRightPanelBlock(Request $request, array $vars, array &$res): EventResult public function onAppendRightPanelBlock(Request $request, $vars, &$res): bool
{ {
$user = Common::actor(); $user = Common::actor();
if (\is_null($user)) { if (\is_null($user)) {
@ -138,9 +127,9 @@ trait MetaCollectionTrait
}, },
]], ]],
['add', SubmitType::class, [ ['add', SubmitType::class, [
'label' => _m('Add to ' . static::PLURAL_SLUG), 'label' => _m('Add to ' . $this->plural_slug),
'attr' => [ 'attr' => [
'title' => _m('Add to ' . static::PLURAL_SLUG), 'title' => _m('Add to ' . $this->plural_slug),
], ],
]], ]],
]); ]);
@ -162,17 +151,17 @@ trait MetaCollectionTrait
// form: add to new collection // form: add to new collection
$create_form = Form::create([ $create_form = Form::create([
['name', TextType::class, [ ['name', TextType::class, [
'label' => _m('Add to a new ' . static::SLUG), 'label' => _m('Add to a new ' . $this->slug),
'attr' => [ 'attr' => [
'placeholder' => _m('New ' . static::SLUG . ' name'), 'placeholder' => _m('New ' . $this->slug . ' name'),
'required' => 'required', 'required' => 'required',
], ],
'data' => '', 'data' => '',
]], ]],
['create', SubmitType::class, [ ['create', SubmitType::class, [
'label' => _m('Create a new ' . static::SLUG), 'label' => _m('Create a new ' . $this->slug),
'attr' => [ 'attr' => [
'title' => _m('Create a new ' . static::SLUG), 'title' => _m('Create a new ' . $this->slug),
], ],
]], ]],
]); ]);
@ -187,7 +176,7 @@ trait MetaCollectionTrait
$res[] = Formatting::twigRenderFile( $res[] = Formatting::twigRenderFile(
'collection/widget_add_to.html.twig', 'collection/widget_add_to.html.twig',
[ [
'ctitle' => _m('Add to ' . static::PLURAL_SLUG), 'ctitle' => _m('Add to ' . $this->plural_slug),
'user' => $user, 'user' => $user,
'has_collections' => \count($collections) > 0, 'has_collections' => \count($collections) > 0,
'add_form' => $add_form->createView(), 'add_form' => $add_form->createView(),
@ -197,10 +186,7 @@ trait MetaCollectionTrait
return Event::next; return Event::next;
} }
/** public function onEndShowStyles(array &$styles, string $route): bool
* @param string[] $styles
*/
public function onEndShowStyles(array &$styles, string $route): EventResult
{ {
$styles[] = 'components/Collection/assets/css/widget.css'; $styles[] = 'components/Collection/assets/css/widget.css';
$styles[] = 'components/Collection/assets/css/pages.css'; $styles[] = 'components/Collection/assets/css/pages.css';

View File

@ -32,9 +32,6 @@ abstract class Parser
{ {
/** /**
* Merge $parts into $criteria_arr * Merge $parts into $criteria_arr
*
* @param mixed[] $parts
* @param Criteria[] $criteria_arr
*/ */
private static function connectParts(array &$parts, array &$criteria_arr, string $last_op, mixed $eb, bool $force = false): void private static function connectParts(array &$parts, array &$criteria_arr, string $last_op, mixed $eb, bool $force = false): void
{ {
@ -53,9 +50,8 @@ abstract class Parser
* recognises either spaces (currently `or`, should be fuzzy match), `OR` or `|` (`or`) and `AND` or `&` (`and`) * recognises either spaces (currently `or`, should be fuzzy match), `OR` or `|` (`or`) and `AND` or `&` (`and`)
* *
* TODO: Better fuzzy match, implement exact match with quotes and nesting with parens * TODO: Better fuzzy match, implement exact match with quotes and nesting with parens
* TODO: Proper parser, tokenize better. Mostly a rewrite
* *
* @return array{?Criteria, ?Criteria} [?$note_criteria, ?$actor_criteria] * @return Criteria[]
*/ */
public static function parse(string $input, ?string $locale = null, ?Actor $actor = null, int $level = 0): array public static function parse(string $input, ?string $locale = null, ?Actor $actor = null, int $level = 0): array
{ {
@ -84,16 +80,15 @@ abstract class Parser
$actor_res = null; $actor_res = null;
Event::handle('CollectionQueryCreateExpression', [$eb, $term, $locale, $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 if (\is_null($note_res) && \is_null($actor_res)) { // @phpstan-ignore-line
//throw new ServerException("No one claimed responsibility for a match term: {$term}"); throw new ServerException("No one claimed responsibility for a match term: {$term}");
// It's okay if the term doesn't exist, just perform a regular search
} }
if (!empty($note_res)) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234 if (!empty($note_res)) { // @phpstan-ignore-line
if (\is_array($note_res)) { if (\is_array($note_res)) {
$note_res = $eb->orX(...$note_res); $note_res = $eb->orX(...$note_res);
} }
$note_parts[] = $note_res; $note_parts[] = $note_res;
} }
if (!empty($actor_res)) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234 if (!empty($actor_res)) {
if (\is_array($actor_res)) { if (\is_array($actor_res)) {
$actor_res = $eb->orX(...$actor_res); $actor_res = $eb->orX(...$actor_res);
} }
@ -112,18 +107,18 @@ abstract class Parser
} }
} }
// TODO // TODO
if (!$match) { if (!$match) { // @phpstan-ignore-line
++$right; ++$right;
} }
} }
$note_criteria = null; $note_criteria = null;
$actor_criteria = null; $actor_criteria = null;
if (!empty($note_parts)) { if (!empty($note_parts)) { // @phpstan-ignore-line
self::connectParts($note_parts, $note_criteria_arr, $last_op, $eb, force: true); self::connectParts($note_parts, $note_criteria_arr, $last_op, $eb, force: true);
$note_criteria = new Criteria($eb->orX(...$note_criteria_arr)); $note_criteria = new Criteria($eb->orX(...$note_criteria_arr));
} }
if (!empty($actor_parts)) { // @phpstan-ignore-line weird, but this whole thing needs a rewrite if (!empty($actor_parts)) { // @phpstan-ignore-line
self::connectParts($actor_parts, $actor_criteria_arr, $last_op, $eb, force: true); self::connectParts($actor_parts, $actor_criteria_arr, $last_op, $eb, force: true);
$actor_criteria = new Criteria($eb->orX(...$actor_criteria_arr)); $actor_criteria = new Criteria($eb->orX(...$actor_criteria_arr));
} }

View File

@ -3,17 +3,8 @@
{% block title %}{{ title }}{% endblock %} {% block title %}{{ title }}{% endblock %}
{% block body %} {% block body %}
<section class="frame-section frame-section-padding"> <div class="frame-section frame-section-padding">
<header class="feed-header"> <h1 class="frame-section-title">{{ title }}</h1>
{% if actors_feed_title is defined %}
{{ actors_feed_title.getHtml() }}
{% endif %}
</header>
{% set prepend_actors_collection = handle_event('PrependActorsCollection', request) %}
{% for widget in prepend_actors_collection %}
{{ widget | raw }}
{% endfor %}
<details class="frame-section section-details-title"> <details class="frame-section section-details-title">
<summary class="details-summary-title"> <summary class="details-summary-title">
@ -54,17 +45,16 @@
</form> </form>
</details> </details>
<section class="frame-section frame-section-padding"> <section class="frame-section-padding">
<h2>{% trans %}Results{% endtrans %}</h2>
{% if actors is defined and actors is not empty %} {% if actors is defined and actors is not empty %}
{% for actor in actors %} {% for actor in actors %}
{% block profile_view %}{% include 'cards/blocks/profile.html.twig' %}{% endblock profile_view %} {% block profile_view %}{% include 'cards/profile/view.html.twig' %}{% endblock profile_view %}
<hr> <hr>
{% endfor %} {% endfor %}
<span class="frame-section-button-like">{% trans %}Page: %page%{% endtrans %}</span> <p>{% trans %}Page: %page%{% endtrans %}</p>
{% else %} {% else %}
<span>{{ empty_message }}</span> <h2>{{ empty_message }}</h2>
{% endif %} {% endif %}
</section> </section>
</section> </div>
{% endblock body %} {% endblock body %}

View File

@ -1,10 +1,10 @@
{% extends '/collection/notes.html.twig' %} {% extends '/collection/notes.html.twig' %}
{% block title %}{% trans %}%page_title%{% endtrans %}{% endblock %} {% block title %}{{ page_title | trans }}{% endblock %}
{% block body %} {% block body %}
<div class="frame-section frame-section-padding"> <div class="frame-section frame-section-padding">
<h2 class="frame-section-title">{% trans %}%page_title%{% endtrans %}</h2> <h2 class="frame-section-title">{{ page_title | trans }}</h2>
{% block collection_items %} {% block collection_items %}
{% endblock collection_items %} {% endblock collection_items %}
</div> </div>

View File

@ -1,17 +1,17 @@
{% extends 'stdgrid.html.twig' %} {% extends 'stdgrid.html.twig' %}
{% block title %}{% trans %}%page_title%{% endtrans %}{% endblock %} {% block title %}{{ page_title | trans }}{% endblock %}
{% block body %} {% block body %}
<div class="frame-section frame-section-padding"> <div class="frame-section frame-section-padding">
<h2 class="frame-section-title">{% trans %}%page_title%{% endtrans %}</h2> <h2 class="frame-section-title">{{ page_title | trans }}</h2>
{% if add_collection %} {% if add_collection %}
<div class="frame-section section-form"> <div class="frame-section section-form">
{{ form(add_collection) }} {{ form(add_collection) }}
</div> </div>
{% endif %} {% endif %}
<div class="frame-section collections-list"> <div class="frame-section collections-list">
<h3>{% trans %}%list_title%{% endtrans %}</h3> <h3>{{ list_title | trans }}</h3>
{% for col in collections %} {% for col in collections %}
<div class="collection-item"> <div class="collection-item">
<a class="name" href="{{ fn.getUrl(col.id) }}">{{ col.name }}</a> <a class="name" href="{{ fn.getUrl(col.id) }}">{{ col.name }}</a>

View File

@ -1,11 +1,11 @@
{% extends 'stdgrid.html.twig' %} {% extends 'stdgrid.html.twig' %}
{% import '/cards/macros/note/factory.html.twig' as NoteFactory %} {% import '/cards/note/view.html.twig' as noteView %}
{% block title %}{% if page_title is defined %}{% trans %}%page_title%{% endtrans %}{% endif %}{% endblock %} {% block title %}{% if page_title is defined %}{{ page_title | trans }}{% endif %}{% endblock %}
{% block stylesheets %} {% block stylesheets %}
{{ parent() }} {{ parent() }}
<link rel="stylesheet" href="{{ asset('assets/default_theme/feeds.css') }}" type="text/css"> <link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
{% endblock stylesheets %} {% endblock stylesheets %}
{% block body %} {% block body %}
@ -15,44 +15,41 @@
{% if notes is defined %} {% if notes is defined %}
<header class="feed-header"> <header class="feed-header">
{% set current_path = app.request.get('_route') %} {% if page_title is defined %}
{% if notes_feed_title is defined %} <h1 class="heading-no-margin">{{ page_title | trans }}</h1>
{{ notes_feed_title.getHtml() }} {% else %}
<h3 class="heading-no-margin">{{ 'Notes' | trans }}</h3>
{% endif %} {% endif %}
<nav class="feed-actions" title="{% trans %}Actions that change how the feed behaves{% endtrans %}"> <nav class="feed-actions">
<details class="feed-actions-details" role="group"> <details class="feed-actions-details">
<summary> <summary>
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #} {{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
</summary> </summary>
<menu class="feed-actions-details-dropdown" role="toolbar"> <div class="feed-actions-details-dropdown">
<menu>
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %} {% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
{{ block | raw }} {{ block | raw }}
{% endfor %} {% endfor %}
</menu> </menu>
</div>
</details> </details>
</nav> </nav>
</header> </header>
{% if notes is not empty %} {% if notes is not empty %}
{# Backwards compatibility with hAtom 0.1 #} {# Backwards compatibility with hAtom 0.1 #}
<section class="feed h-feed hfeed notes" role="feed" aria-busy="false" title="{% trans %}Feed content{% endtrans %}"> <section class="feed h-feed hfeed notes" tabindex="0" role="feed">
{% for conversation in notes %} {% for conversation in notes %}
{% block current_note %} {% block current_note %}
{% if conversation is instanceof('array') %} {% if conversation is instanceof('array') %}
{% set args = conversation | merge({'type': 'vanilla_full'}) %} {{ noteView.macro_note(conversation['note'], conversation['replies']) }}
{{ NoteFactory.constructor(args) }} {% else %}
{# {% else %} {{ noteView.macro_note(conversation) }}
{% set args = { 'type': 'vanilla_full', 'note': conversation, 'extra': { 'depth': 0 } } %}
{{ NoteFactory.constructor(args) }}#}
{% endif %} {% endif %}
<hr class="hr-replies-end" role="separator" aria-label="{% trans %}Marks the end of previous conversation's initial note{% endtrans %}"> <hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
{% endblock current_note %} {% endblock current_note %}
{% endfor %} {% endfor %}
</section> </section>
{% else %}
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
<span>{% trans %}No notes here...{% endtrans %}</span>
</section>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endblock body %} {% endblock body %}

View File

@ -1,7 +1,7 @@
<section class="frame-section collections"> <section class="frame-section collections">
<details class="section-details-title" title="Expand if you want to access more options."> <details class="section-details-title" title="Expand if you want to access more options.">
<summary class="details-summary-title"> <summary class="details-summary-title">
<span>{{ctitle}}</span> <h2>{{ctitle}}</h2>
</summary> </summary>
{% if has_collections %} {% if has_collections %}
<section class="section-form"> <section class="section-form">

View File

@ -28,11 +28,11 @@ declare(strict_types = 1);
namespace Component\Conversation\Controller; namespace Component\Conversation\Controller;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
@ -40,15 +40,11 @@ use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\NoSuchNoteException; use App\Util\Exception\NoSuchNoteException;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController; use Component\Collection\Util\Controller\FeedController;
use Component\Conversation\Entity\ConversationMute; use Component\Conversation\Entity\ConversationMute;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
/**
* @extends FeedController<\App\Entity\Note>
*/
class Conversation extends FeedController class Conversation extends FeedController
{ {
/** /**
@ -58,24 +54,15 @@ class Conversation extends FeedController
* *
* @throws \App\Util\Exception\ServerException * @throws \App\Util\Exception\ServerException
* *
* @return ControllerResultType Array containing keys: 'notes' (all known * @return array Array containing keys: 'notes' (all known notes in the given Conversation), 'should_format' (boolean, stating if onFormatNoteList events may or not format given notes), 'page_title' (used as the title header)
* notes in the given Conversation), 'should_format' (boolean, stating if
* onFormatNoteList events may or not format given notes), 'page_title'
* (used as the title header)
*/ */
public function showConversation(Request $request, int $conversation_id): array public function showConversation(Request $request, int $conversation_id): array
{ {
$page_title = _m('Conversation');
return [ return [
'_template' => 'collection/notes.html.twig', '_template' => 'collection/notes.html.twig',
'notes' => $this->query( 'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [],
query: "note-conversation:{$conversation_id}",
note_order_by: ['note.created' => 'ASC', 'note.id' => 'ASC'],
)['notes'] ?? [],
'should_format' => false, 'should_format' => false,
'page_title' => $page_title, 'page_title' => _m('Conversation'),
'notes_feed_title' => (new Heading(1, [], $page_title)),
]; ];
} }
@ -89,7 +76,7 @@ class Conversation extends FeedController
* @throws NoSuchNoteException * @throws NoSuchNoteException
* @throws ServerException * @throws ServerException
* *
* @return ControllerResultType * @return array
*/ */
public function addReply(Request $request) public function addReply(Request $request)
{ {
@ -109,7 +96,7 @@ class Conversation extends FeedController
* @throws \App\Util\Exception\RedirectException * @throws \App\Util\Exception\RedirectException
* @throws \App\Util\Exception\ServerException * @throws \App\Util\Exception\ServerException
* *
* @return ControllerResultType Array containing templating where the form is to be rendered, and the form itself * @return array Array containing templating where the form is to be rendered, and the form itself
*/ */
public function muteConversation(Request $request, int $conversation_id) public function muteConversation(Request $request, int $conversation_id)
{ {
@ -147,10 +134,7 @@ class Conversation extends FeedController
return [ return [
'_template' => 'conversation/mute.html.twig', '_template' => 'conversation/mute.html.twig',
'notes' => $this->query( 'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [],
query: "note-conversation:{$conversation_id}",
note_order_by: ['note.created' => 'ASC', 'note.id' => 'ASC'],
)['notes'] ?? [],
'is_muted' => $is_muted, 'is_muted' => $is_muted,
'form' => $form->createView(), 'form' => $form->createView(),
]; ];

View File

@ -1,7 +1,9 @@
<?php <?php
declare(strict_types = 1); declare(strict_types = 1);
// {{{ License // {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social // 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 // GNU social is free software: you can redistribute it and/or modify
@ -16,37 +18,30 @@ declare(strict_types = 1);
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
/** // }}}
* @author Hugo Sales <hugo@hsal.es>
* @author Eliseu Amaro <mail@eliseuama.ro>
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Component\Conversation; namespace Component\Conversation;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router; use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use App\Util\Formatting;
use Component\Conversation\Entity\Conversation as ConversationEntity; use Component\Conversation\Entity\Conversation as ConversationEntity;
use Component\Conversation\Entity\ConversationMute; use Component\Conversation\Entity\ConversationMute;
use EventResult;
use Functional as F; use Functional as F;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Conversation extends Component class Conversation extends Component
{ {
public function onAddRoute(Router $r): EventResult public function onAddRoute(RouteLoader $r): bool
{ {
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']); $r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
$r->connect('conversation_mute', '/conversation/{conversation_id<\d+>}/mute', [Controller\Conversation::class, 'muteConversation']); $r->connect('conversation_mute', '/conversation/{conversation_id<\d+>}/mute', [Controller\Conversation::class, 'muteConversation']);
@ -83,12 +78,14 @@ class Conversation extends Component
} else { } else {
// It's a reply for sure // It's a reply for sure
// Set reply_to property in newly created Note to parent's id // Set reply_to property in newly created Note to parent's id
$current_note->setReplyTo($parent_id);
// Parent will have a conversation of its own, the reply should have the same one // Parent will have a conversation of its own, the reply should have the same one
$parent_note = Note::getById($parent_id); $parent_note = Note::getById($parent_id);
$current_note->setConversationId($parent_note->getConversationId()); $current_note->setConversationId($parent_note->getConversationId());
} }
DB::persist($current_note); DB::merge($current_note);
} }
/** /**
@ -96,16 +93,14 @@ class Conversation extends Component
* action, if a user is logged in. * action, if a user is logged in.
* *
* @param \App\Entity\Note $note The Note being rendered * @param \App\Entity\Note $note The Note being rendered
* @param array{url: string, title: string, classes: string, id: string} $actions * @param array $actions Contains keys 'url' (linking 'conversation_reply_to'
* Contains keys 'url' (linking 'conversation_reply_to' route), * route), 'title' (used as title for aforementioned url),
* 'title' (used as title for aforementioned url), 'classes' (CSS styling * 'classes' (CSS styling classes used to visually inform the user of action context),
* classes used to visually inform the user of action context), 'id' (HTML * 'id' (HTML markup id used to redirect user to this anchor upon performing the action)
* markup id used to redirect user to this anchor upon performing the
* action)
* *
* @throws \App\Util\Exception\ServerException * @throws \App\Util\Exception\ServerException
*/ */
public function onAddNoteActions(Request $request, Note $note, array &$actions): EventResult public function onAddNoteActions(Request $request, Note $note, array &$actions): bool
{ {
if (\is_null(Common::user())) { if (\is_null(Common::user())) {
return Event::next; return Event::next;
@ -119,8 +114,7 @@ class Conversation extends Component
'conversation_reply_to', 'conversation_reply_to',
[ [
'reply_to_id' => $note->getId(), 'reply_to_id' => $note->getId(),
'from' => $from, 'from' => $from . '#note-anchor-' . $note->getId(),
'_fragment' => 'note-anchor-' . $note->getId(),
], ],
Router::ABSOLUTE_PATH, Router::ABSOLUTE_PATH,
); );
@ -138,20 +132,28 @@ class Conversation extends Component
} }
/** /**
* Append on note information about user actions. * Posting event to add extra info to a note
*
* @param array<string, mixed> $vars Contains information related to Note currently being rendered
* @param array{actors: Actor[], action: string} $result
*cContains keys 'actors', and 'action'. Needed to construct a string,
* stating who ($result['actors']), has already performed a reply
* ($result['action']), in the given Note (vars['note'])
*/ */
public function onAppendCardNote(array $vars, array &$result): EventResult public function onPostingModifyData(Request $request, Actor $actor, array &$data): bool
{ {
if (str_contains($vars['request']->getPathInfo(), 'conversation')) { $data['reply_to_id'] = $request->get('_route') === 'conversation_reply_to' && $request->query->has('reply_to_id')
? $request->query->getInt('reply_to_id')
: null;
if (!\is_null($data['reply_to_id'])) {
Note::ensureCanInteract(Note::getById($data['reply_to_id']), $actor);
}
return Event::next; return Event::next;
} }
/**
* Append on note information about user actions.
*
* @param array $vars Contains information related to Note currently being rendered
* @param array $result Contains keys 'actors', and 'action'. Needed to construct a string, stating who ($result['actors']), has already performed a reply ($result['action']), in the given Note (vars['note'])
*/
public function onAppendCardNote(array $vars, array &$result): bool
{
// The current Note being rendered // The current Note being rendered
$note = $vars['note']; $note = $vars['note'];
@ -173,22 +175,6 @@ class Conversation extends Component
return Event::next; return Event::next;
} }
private function getReplyToIdFromRequest(Request $request): ?int
{
if (!\is_array($request->get('post_note')) || !\array_key_exists('_next', $request->get('post_note'))) {
return null;
}
$next = parse_url($request->get('post_note')['_next']);
if (!\array_key_exists('query', $next)) {
return null;
}
parse_str($next['query'], $query);
if (!\array_key_exists('reply_to_id', $query)) {
return null;
}
return (int) $query['reply_to_id'];
}
/** /**
* Informs **\App\Component\Posting::onAppendRightPostingBlock**, of the **current page context** in which the given * Informs **\App\Component\Posting::onAppendRightPostingBlock**, of the **current page context** in which the given
* Actor is in. This is valuable when posting within a group route, allowing \App\Component\Posting to create a * Actor is in. This is valuable when posting within a group route, allowing \App\Component\Posting to create a
@ -197,9 +183,9 @@ class Conversation extends Component
* @param \App\Entity\Actor $actor The Actor currently attempting to post a Note * @param \App\Entity\Actor $actor The Actor currently attempting to post a Note
* @param null|\App\Entity\Actor $context_actor The 'owner' of the current route (e.g. Group or Actor), used to target it * @param null|\App\Entity\Actor $context_actor The 'owner' of the current route (e.g. Group or Actor), used to target it
*/ */
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor): EventResult public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor)
{ {
$to_note_id = $this->getReplyToIdFromRequest($request); $to_note_id = $request->query->get('reply_to_id');
if (!\is_null($to_note_id)) { if (!\is_null($to_note_id)) {
// Getting the actor itself // Getting the actor itself
$context_actor = Actor::getById(Note::getById((int) $to_note_id)->getActorId()); $context_actor = Actor::getById(Note::getById((int) $to_note_id)->getActorId());
@ -208,36 +194,6 @@ class Conversation extends Component
return Event::next; return Event::next;
} }
/**
* Posting event to add extra information to Component\Posting form data
*
* @param array{reply_to_id: int} $data Transport data to be filled with reply_to_id
*
* @throws \App\Util\Exception\ClientException
* @throws \App\Util\Exception\NoSuchNoteException
*/
public function onPostingModifyData(Request $request, Actor $actor, array &$data): EventResult
{
$to_note_id = $this->getReplyToIdFromRequest($request);
if (!\is_null($to_note_id)) {
Note::ensureCanInteract(Note::getById($to_note_id), $actor);
$data['reply_to_id'] = $to_note_id;
}
return Event::next;
}
/**
* Add minimal Note card to RightPanel template
*
* @param string[] $elements
*/
public function onPrependPostingForm(Request $request, array &$elements): EventResult
{
$elements[] = Formatting::twigRenderFile('cards/blocks/note_compact_wrapper.html.twig', ['note' => Note::getById((int) $request->query->get('reply_to_id'))]);
return Event::next;
}
/** /**
* Event launched when deleting given Note, it's deletion implies further changes to object related to this Note. * Event launched when deleting given Note, it's deletion implies further changes to object related to this Note.
* Please note, **replies are NOT deleted**, their reply_to is only set to null since this Note no longer exists. * Please note, **replies are NOT deleted**, their reply_to is only set to null since this Note no longer exists.
@ -245,7 +201,7 @@ class Conversation extends Component
* @param \App\Entity\Note $note Note being deleted * @param \App\Entity\Note $note Note being deleted
* @param \App\Entity\Actor $actor Actor that performed the delete action * @param \App\Entity\Actor $actor Actor that performed the delete action
*/ */
public function onNoteDeleteRelated(Note &$note, Actor $actor): EventResult public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
{ {
// Ensure we have the most up to date replies // Ensure we have the most up to date replies
Cache::delete(Note::cacheKeys($note->getId())['replies']); Cache::delete(Note::cacheKeys($note->getId())['replies']);
@ -258,13 +214,13 @@ class Conversation extends Component
* Adds extra actions related to Conversation Component, that act upon/from the given Note. * Adds extra actions related to Conversation Component, that act upon/from the given Note.
* *
* @param \App\Entity\Note $note Current Note being rendered * @param \App\Entity\Note $note Current Note being rendered
* @param array{url: string, title: string, classes?: string} $actions Containing 'url' (Controller connected * @param array $actions Containing 'url' (Controller connected route), 'title' (used in anchor link containing the url), ?'classes' (CSS classes required for styling, if needed)
* route), 'title' (used in anchor link containing the url), ?'classes' (CSS classes required for styling, if
* needed)
* *
* @throws \App\Util\Exception\ServerException * @throws \App\Util\Exception\ServerException
*
* @return bool EventHook
*/ */
public function onAddExtraNoteActions(Request $request, Note $note, array &$actions): EventResult public function onAddExtraNoteActions(Request $request, Note $note, array &$actions)
{ {
if (\is_null($user = Common::user())) { if (\is_null($user = Common::user())) {
return Event::next; return Event::next;
@ -278,8 +234,7 @@ class Conversation extends Component
'conversation_mute', 'conversation_mute',
[ [
'conversation_id' => $note->getConversationId(), 'conversation_id' => $note->getConversationId(),
'from' => $from, 'from' => $from . '#note-anchor-' . $note->getId(),
'_fragment' => 'note-anchor-' . $note->getId(),
], ],
Router::ABSOLUTE_PATH, Router::ABSOLUTE_PATH,
); );
@ -293,12 +248,7 @@ class Conversation extends Component
return Event::next; return Event::next;
} }
/** public function onNewNotificationShould(Activity $activity, Actor $actor)
* Prevents new Notifications to appear for muted conversations
*
* @param Activity $activity Notification Activity
*/
public function onNewNotificationShould(Activity $activity, Actor $actor): EventResult
{ {
if ($activity->getObjectType() === 'note' && ConversationMute::isMuted($activity, $actor)) { if ($activity->getObjectType() === 'note' && ConversationMute::isMuted($activity, $actor)) {
return Event::stop; return Event::stop;

View File

@ -23,9 +23,10 @@ declare(strict_types = 1);
namespace Component\Conversation\Entity; namespace Component\Conversation\Entity;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Note;
/** /**
* Entity class for Conversations * Entity class for Conversations

View File

@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Conversation\Entity; namespace Component\Conversation\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;

View File

@ -37,36 +37,26 @@ namespace Component\Feed\Controller;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Util\Common; use App\Util\Common;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController; use Component\Collection\Util\Controller\FeedController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
/**
* @extends FeedController<\App\Entity\Note>
*/
class Feeds extends FeedController class Feeds extends FeedController
{ {
/** /**
* The Planet feed represents every local post. Which is what this instance has to share with the universe. * The Planet feed represents every local post. Which is what this instance has to share with the universe.
*
* @return ControllerResultType
*/ */
public function public(Request $request): array public function public(Request $request): array
{ {
$data = $this->query('note-local:true'); $data = $this->query('note-local:true');
$page_title = _m(\is_null(Common::user()) ? 'Feed' : 'Planet');
return [ return [
'_template' => 'collection/notes.html.twig', '_template' => 'collection/notes.html.twig',
'page_title' => $page_title, 'page_title' => _m(\is_null(Common::user()) ? 'Feed' : 'Planet'),
'notes_feed_title' => (new Heading(level: 1, classes: ['feed-title'], text: $page_title)),
'notes' => $data['notes'], 'notes' => $data['notes'],
]; ];
} }
/** /**
* The Home feed represents everything that concerns a certain actor (its subscriptions) * The Home feed represents everything that concerns a certain actor (its subscriptions)
*
* @return ControllerResultType
*/ */
public function home(Request $request): array public function home(Request $request): array
{ {
@ -75,7 +65,6 @@ class Feeds extends FeedController
return [ return [
'_template' => 'collection/notes.html.twig', '_template' => 'collection/notes.html.twig',
'page_title' => _m('Home'), 'page_title' => _m('Home'),
'notes_feed_title' => (new Heading(level: 1, classes: ['feed-title'], text: 'Home')),
'notes' => $data['notes'], 'notes' => $data['notes'],
]; ];
} }

View File

@ -25,13 +25,12 @@ namespace Component\Feed;
use App\Core\Event; use App\Core\Event;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router; use App\Core\Router\RouteLoader;
use Component\Feed\Controller as C; use Component\Feed\Controller as C;
use EventResult;
class Feed extends Component class Feed extends Component
{ {
public function onAddRoute(Router $r): EventResult public function onAddRoute(RouteLoader $r): bool
{ {
$r->connect('feed_public', '/feed/public', [C\Feeds::class, 'public']); $r->connect('feed_public', '/feed/public', [C\Feeds::class, 'public']);
$r->connect('feed_home', '/feed/home', [C\Feeds::class, 'home']); $r->connect('feed_home', '/feed/home', [C\Feeds::class, 'home']);

View File

@ -1,54 +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 Component\Feed\tests\Controller;
use App\Core\Router;
use App\Util\GNUsocialTestCase;
use Component\Feed\Controller\Feeds;
use Jchook\AssertThrows\AssertThrows;
class FeedsTest extends GNUsocialTestCase
{
use AssertThrows;
public function testPublic()
{
// This calls static::bootKernel(), and creates a "client" that is acting as the browser
$client = static::createClient();
$crawler = $client->request('GET', Router::url('feed_public'));
$this->assertResponseIsSuccessful();
}
public function testHome()
{
// This calls static::bootKernel(), and creates a "client" that is acting as the browser
$client = static::createClient();
$crawler = $client->request('GET', Router::url('feed_home'));
$this->assertResponseStatusCodeSame(302);
}
// TODO: It would be nice to actually test whether the feeds are respecting scopes and spitting
// out the expected notes... The ActivityPub plugin have a somewhat obvious way of testing it so,
// for now, having that, might fill that need, let's see
}

View File

@ -34,7 +34,7 @@ declare(strict_types = 1);
namespace Component\FreeNetwork\Controller; namespace Component\FreeNetwork\Controller;
use App\Core\DB; use App\Core\DB\DB;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Util\Common; use App\Util\Common;
use Component\Collection\Util\Controller\FeedController; use Component\Collection\Util\Controller\FeedController;

View File

@ -20,7 +20,7 @@ class HostMeta extends XrdController
public function setXRD() 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]); Event::handle('EndHostMetaLinks', [&$this->xrd->links]);
} }
} }

View File

@ -32,7 +32,7 @@ declare(strict_types = 1);
namespace Component\FreeNetwork\Entity; namespace Component\FreeNetwork\Entity;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Entity\Actor; use App\Entity\Actor;
use Component\FreeNetwork\Util\Discovery; use Component\FreeNetwork\Util\Discovery;
@ -125,7 +125,7 @@ class FreeNetworkActorProtocol extends Entity
} else { } else {
$attributed_protocol->setProtocol($protocol); $attributed_protocol->setProtocol($protocol);
} }
DB::persist($attributed_protocol); DB::wrapInTransaction(fn () => DB::persist($attributed_protocol));
} }
public static function canIActor(string $protocol, int|Actor $actor_id): bool public static function canIActor(string $protocol, int|Actor $actor_id): bool

View File

@ -21,14 +21,15 @@ declare(strict_types = 1);
namespace Component\FreeNetwork; namespace Component\FreeNetwork;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\GSFile; use App\Core\GSFile;
use App\Core\HTTPClient; use App\Core\HTTPClient;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router; use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\LocalUser; use App\Entity\LocalUser;
@ -43,7 +44,6 @@ use App\Util\Exception\NicknameTakenException;
use App\Util\Exception\NicknameTooLongException; use App\Util\Exception\NicknameTooLongException;
use App\Util\Exception\NoSuchActorException; use App\Util\Exception\NoSuchActorException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use App\Util\Formatting;
use App\Util\Nickname; use App\Util\Nickname;
use Component\FreeNetwork\Controller\Feeds; use Component\FreeNetwork\Controller\Feeds;
use Component\FreeNetwork\Controller\HostMeta; use Component\FreeNetwork\Controller\HostMeta;
@ -53,8 +53,6 @@ use Component\FreeNetwork\Util\Discovery;
use Component\FreeNetwork\Util\WebfingerResource; use Component\FreeNetwork\Util\WebfingerResource;
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceActor; use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceActor;
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceNote; use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceNote;
use Doctrine\Common\Collections\ExpressionBuilder;
use EventResult;
use Exception; use Exception;
use const PREG_SET_ORDER; use const PREG_SET_ORDER;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@ -78,15 +76,8 @@ class FreeNetwork extends Component
public const OAUTH_ACCESS_TOKEN_REL = 'http://apinamespace.org/oauth/access_token'; public const OAUTH_ACCESS_TOKEN_REL = 'http://apinamespace.org/oauth/access_token';
public const OAUTH_REQUEST_TOKEN_REL = 'http://apinamespace.org/oauth/request_token'; public const OAUTH_REQUEST_TOKEN_REL = 'http://apinamespace.org/oauth/request_token';
public const OAUTH_AUTHORIZE_REL = 'http://apinamespace.org/oauth/authorize'; public const OAUTH_AUTHORIZE_REL = 'http://apinamespace.org/oauth/authorize';
private static array $protocols = [];
public function onInitializeComponent(): EventResult public function onAddRoute(RouteLoader $m): bool
{
Event::handle('AddFreeNetworkProtocol', [&self::$protocols]);
return Event::next;
}
public function onAddRoute(Router $m): EventResult
{ {
// Feeds // Feeds
$m->connect('feed_network', '/feed/network', [Feeds::class, 'network']); $m->connect('feed_network', '/feed/network', [Feeds::class, 'network']);
@ -112,7 +103,7 @@ class FreeNetwork extends Component
return Event::next; return Event::next;
} }
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): EventResult public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
{ {
DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_network'), 'route' => $route, 'title' => _m('Meteorites'), 'ordering' => $ordering++])); DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_network'), 'route' => $route, 'title' => _m('Meteorites'), 'ordering' => $ordering++]));
DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_clique'), 'route' => $route, 'title' => _m('Planetary System'), 'ordering' => $ordering++])); DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_clique'), 'route' => $route, 'title' => _m('Planetary System'), 'ordering' => $ordering++]));
@ -120,7 +111,7 @@ class FreeNetwork extends Component
return Event::next; return Event::next;
} }
public function onStartGetProfileAcctUri(Actor $profile, &$acct): EventResult public function onStartGetProfileAcctUri(Actor $profile, &$acct): bool
{ {
$wfr = new WebFingerResourceActor($profile); $wfr = new WebFingerResourceActor($profile);
try { try {
@ -148,7 +139,7 @@ class FreeNetwork extends Component
* @throws NoSuchActorException * @throws NoSuchActorException
* @throws ServerException * @throws ServerException
*/ */
public function onEndGetWebFingerResource(string $resource, ?WebfingerResource &$target = null, array $args = []): EventResult public function onEndGetWebFingerResource(string $resource, ?WebfingerResource &$target = null, array $args = []): bool
{ {
// * Either we didn't find the profile, then we want to make // * Either we didn't find the profile, then we want to make
// the $profile variable null for clarity. // the $profile variable null for clarity.
@ -161,7 +152,7 @@ class FreeNetwork extends Component
$parts = explode('@', mb_substr(urldecode($resource), 5)); // 5 is strlen of 'acct:' $parts = explode('@', mb_substr(urldecode($resource), 5)); // 5 is strlen of 'acct:'
if (\count($parts) === 2) { if (\count($parts) === 2) {
[$nick, $domain] = $parts; [$nick, $domain] = $parts;
if ($domain !== Common::config('site', 'server')) { if ($domain !== $_ENV['SOCIAL_DOMAIN']) {
throw new ServerException(_m('Remote profiles not supported via WebFinger yet.')); throw new ServerException(_m('Remote profiles not supported via WebFinger yet.'));
} }
@ -178,7 +169,7 @@ class FreeNetwork extends Component
// This means $resource is a valid url // This means $resource is a valid url
$resource_parts = parse_url($resource); $resource_parts = parse_url($resource);
// TODO: Use URLMatcher // TODO: Use URLMatcher
if ($resource_parts['host'] === Common::config('site', 'server')) { if ($resource_parts['host'] === $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
$str = $resource_parts['path']; $str = $resource_parts['path'];
// actor_view_nickname // actor_view_nickname
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m'; $renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m';
@ -224,7 +215,7 @@ class FreeNetwork extends Component
return Event::next; return Event::next;
} }
public function onStartHostMetaLinks(array &$links): EventResult public function onStartHostMetaLinks(array &$links): bool
{ {
foreach (Discovery::supportedMimeTypes() as $type) { foreach (Discovery::supportedMimeTypes() as $type) {
$links[] = new XML_XRD_Element_Link( $links[] = new XML_XRD_Element_Link(
@ -244,10 +235,8 @@ class FreeNetwork extends Component
/** /**
* Add a link header for LRDD Discovery * Add a link header for LRDD Discovery
*
* @param mixed $action
*/ */
public function onStartShowHTML($action): EventResult public function onStartShowHTML($action): bool
{ {
if ($action instanceof ShowstreamAction) { if ($action instanceof ShowstreamAction) {
$resource = $action->getTarget()->getUri(); $resource = $action->getTarget()->getUri();
@ -260,13 +249,13 @@ class FreeNetwork extends Component
return Event::next; return Event::next;
} }
public function onStartDiscoveryMethodRegistration(Discovery $disco): EventResult public function onStartDiscoveryMethodRegistration(Discovery $disco): bool
{ {
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodWebfinger'); $disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodWebfinger');
return Event::next; return Event::next;
} }
public function onEndDiscoveryMethodRegistration(Discovery $disco): EventResult public function onEndDiscoveryMethodRegistration(Discovery $disco): bool
{ {
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodHostMeta'); $disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodHostMeta');
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodLinkHeader'); $disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodLinkHeader');
@ -278,7 +267,7 @@ class FreeNetwork extends Component
* @throws ClientException * @throws ClientException
* @throws ServerException * @throws ServerException
*/ */
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?Response &$response = null): EventResult public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?Response &$response = null): bool
{ {
if (!\in_array($route, ['freenetwork_hostmeta', 'freenetwork_hostmeta_format', 'freenetwork_webfinger', 'freenetwork_webfinger_format', 'freenetwork_ownerxrd'])) { if (!\in_array($route, ['freenetwork_hostmeta', 'freenetwork_hostmeta_format', 'freenetwork_webfinger', 'freenetwork_webfinger_format', 'freenetwork_ownerxrd'])) {
return Event::next; return Event::next;
@ -346,7 +335,6 @@ class FreeNetwork extends Component
* @param string $preMention Character(s) that signals a mention ('@', '!'...) * @param string $preMention Character(s) that signals a mention ('@', '!'...)
* *
* @return array the matching URLs (without @ or acct:) and each respective position in the given string * @return array the matching URLs (without @ or acct:) and each respective position in the given string
*
* @example.com/mublog/user * @example.com/mublog/user
*/ */
public static function extractUrlMentions(string $text, string $preMention = '@'): array public static function extractUrlMentions(string $text, string $preMention = '@'): array
@ -378,10 +366,9 @@ class FreeNetwork extends Component
* @param $mentions * @param $mentions
* *
* @return bool hook return value * @return bool hook return value
*
* @example.com/mublog/user * @example.com/mublog/user
*/ */
public function onEndFindMentions(Actor $sender, string $text, array &$mentions): EventResult public function onEndFindMentions(Actor $sender, string $text, array &$mentions): bool
{ {
$matches = []; $matches = [];
@ -392,7 +379,7 @@ class FreeNetwork extends Component
$actor = null; $actor = null;
$resource_parts = explode($preMention, $target); $resource_parts = explode($preMention, $target);
if ($resource_parts[1] === Common::config('site', 'server')) { if ($resource_parts[1] === $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
$actor = LocalUser::getByPK(['nickname' => $resource_parts[0]])->getActor(); $actor = LocalUser::getByPK(['nickname' => $resource_parts[0]])->getActor();
} else { } else {
Event::handle('FreeNetworkFindMentions', [$target, &$actor]); Event::handle('FreeNetworkFindMentions', [$target, &$actor]);
@ -421,7 +408,7 @@ class FreeNetwork extends Component
// This means $resource is a valid url // This means $resource is a valid url
$resource_parts = parse_url($url); $resource_parts = parse_url($url);
// TODO: Use URLMatcher // TODO: Use URLMatcher
if ($resource_parts['host'] === Common::config('site', 'server')) { if ($resource_parts['host'] === $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
$str = $resource_parts['path']; $str = $resource_parts['path'];
// actor_view_nickname // actor_view_nickname
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m'; $renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m';
@ -500,48 +487,25 @@ class FreeNetwork extends Component
return Event::next; return Event::next;
} }
/**
* @param Actor[] $targets
*/
public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
{ {
foreach (self::$protocols as $protocol) { $protocols = [];
$protocol::freeNetworkDistribute($sender, $activity, $targets, $reason); Event::handle('AddFreeNetworkProtocol', [&$protocols]);
$delivered = [];
foreach ($protocols as $protocol) {
$protocol::freeNetworkDistribute($sender, $activity, $targets, $reason, $delivered);
} }
$failed_targets = array_udiff($targets, $delivered, fn (Actor $a, Actor $b): int => $a->getId() <=> $b->getId());
// TODO: Implement failed queues
return false; return false;
} }
public static function mentionTagToName(string $nickname, string $uri): string public static function mentionToName(string $nickname, string $uri): string
{ {
return '@' . $nickname . '@' . parse_url($uri, \PHP_URL_HOST); return '@' . $nickname . '@' . parse_url($uri, \PHP_URL_HOST);
} }
public static function groupTagToName(string $nickname, string $uri): string public function onPluginVersion(array &$versions): bool
{
return '!' . $nickname . '@' . parse_url($uri, \PHP_URL_HOST);
}
/**
* Add fediverse: query expression
* // TODO: adding WebFinger would probably be nice
*
* @param mixed $note_expr
* @param mixed $actor_expr
*/
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult
{
if (Formatting::startsWith($term, ['fediverse:'])) {
foreach (self::$protocols as $protocol) {
// 10 is strlen of `fediverse:`
if ($protocol::freeNetworkGrabRemote(mb_substr($term, 10))) {
break;
}
}
}
return Event::next;
}
public function onPluginVersion(array &$versions): EventResult
{ {
$versions[] = [ $versions[] = [
'name' => 'WebFinger', 'name' => 'WebFinger',

View File

@ -6,7 +6,7 @@ namespace Component\FreeNetwork\Util\WebfingerResource;
use App\Core\Event; use App\Core\Event;
use App\Core\Log; use App\Core\Log;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Util\Common; use App\Util\Common;
use Component\FreeNetwork\Exception\WebfingerReconstructionException; use Component\FreeNetwork\Exception\WebfingerReconstructionException;

View File

@ -23,54 +23,155 @@ declare(strict_types = 1);
namespace Component\Group\Controller; namespace Component\Group\Controller;
use App\Core\ActorLocalRoles;
use App\Core\Cache; use App\Core\Cache;
use App\Core\Controller; use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\UserRoles;
use App\Entity as E; use App\Entity as E;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\NicknameEmptyException; use App\Util\Exception\NicknameEmptyException;
use App\Util\Exception\NicknameException;
use App\Util\Exception\NicknameInvalidException; use App\Util\Exception\NicknameInvalidException;
use App\Util\Exception\NicknameNotAllowedException; use App\Util\Exception\NicknameNotAllowedException;
use App\Util\Exception\NicknameTakenException; use App\Util\Exception\NicknameTakenException;
use App\Util\Exception\NicknameTooLongException; use App\Util\Exception\NicknameTooLongException;
use App\Util\Exception\NotFoundException; use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use App\Util\Form\ActorForms; use App\Util\Form\ActorForms;
use App\Util\Nickname; use App\Util\Nickname;
use Component\Collection\Util\Controller\FeedController;
use Component\Group\Entity\GroupMember; use Component\Group\Entity\GroupMember;
use Component\Group\Entity\LocalGroup; use Component\Group\Entity\LocalGroup;
use Component\Subscription\Entity\ActorSubscription; use Component\Subscription\Entity\ActorSubscription;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Group extends Controller class Group extends FeedController
{ {
/**
* 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)
{
Nickname::validate($nickname, which: Nickname::CHECK_LOCAL_GROUP); // throws
$group = LocalGroup::getActorByNickname($nickname);
$actor = Common::actor();
$subscribe_form = null;
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']);
}
}
$notes = !\is_null($group) ? DB::dql(
<<<'EOF'
select n from note n
join activity a with n.id = a.object_id
join group_inbox gi with a.id = gi.activity_id
where a.object_type = 'note' and gi.group_id = :group_id
order by a.created desc, a.id desc
EOF,
['group_id' => $group->getId()],
) : [];
return [
'_template' => 'group/view.html.twig',
'actor' => $group,
'nickname' => $group?->getNickname() ?? $nickname,
'notes' => $notes,
'subscribe_form' => $subscribe_form?->createView(),
];
}
/** /**
* Page that allows an actor to create a new group * Page that allows an actor to create a new group
* *
* @throws RedirectException * @throws RedirectException
* @throws ServerException * @throws ServerException
* *
* @return ControllerResultType * @return array
*/ */
public function groupCreate(Request $request): array public function groupCreate(Request $request)
{ {
if (\is_null($actor = Common::actor())) { if (\is_null($actor = Common::actor())) {
throw new RedirectException('security_login'); throw new RedirectException('security_login');
} }
$create_form = self::getGroupCreateForm($request, $actor); $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 [ return [
'_template' => 'group/create.html.twig', '_template' => 'group/create.html.twig',
@ -82,106 +183,30 @@ class Group extends Controller
* Settings page for the group with the provided nickname, checks if the current actor can administrate given group * Settings page for the group with the provided nickname, checks if the current actor can administrate given group
* *
* @throws ClientException * @throws ClientException
* @throws DuplicateFoundException
* @throws NicknameEmptyException * @throws NicknameEmptyException
* @throws NicknameException
* @throws NicknameInvalidException * @throws NicknameInvalidException
* @throws NicknameNotAllowedException * @throws NicknameNotAllowedException
* @throws NicknameTakenException * @throws NicknameTakenException
* @throws NicknameTooLongException * @throws NicknameTooLongException
* @throws NotFoundException * @throws NoLoggedInUser
* @throws ServerException * @throws ServerException
* *
* @return ControllerResultType * @return array
*/ */
public function groupSettings(Request $request, int $id): array public function groupSettings(Request $request, string $nickname)
{ {
$local_group = DB::findOneBy(LocalGroup::class, ['actor_id' => $id]); $local_group = LocalGroup::getByNickname($nickname);
$group_actor = $local_group->getActor(); $group_actor = $local_group->getActor();
$actor = Common::actor(); $actor = Common::actor();
if (!\is_null($group_actor) && $actor->canModerate($group_actor)) { if (!\is_null($group_actor) && $actor->canAdmin($group_actor)) {
return [ return [
'_template' => 'group/settings.html.twig', '_template' => 'group/settings.html.twig',
'group' => $group_actor, 'group' => $group_actor,
'personal_info_form' => ActorForms::personalInfo(request: $request, scope: $actor, target: $group_actor)->createView(), 'personal_info_form' => ActorForms::personalInfo($request, $actor, $local_group)->createView(),
'open_details_query' => $this->string('open'), 'open_details_query' => $this->string('open'),
]; ];
} else { } else {
throw new ClientException(_m('You do not have permission to edit settings for the group "{group}"', ['{group}' => $id]), code: 404); throw new ClientException(_m('You do not have permission to edit settings for the group "{group}"', ['{group}' => $nickname]), code: 404);
} }
} }
/**
* Create a new Group FormInterface getter
*
* @throws RedirectException
* @throws ServerException
*/
public static function getGroupCreateForm(Request $request, E\Actor $actor): FormInterface
{
$create_form = Form::create([
['group_nickname', TextType::class, ['label' => _m('Group nickname')]],
['group_type', ChoiceType::class, ['label' => _m('Type:'), 'multiple' => false, 'expanded' => false, 'choices' => [
_m('Group') => 'group',
_m('Organisation') => 'organisation',
]]],
['group_scope', ChoiceType::class, ['label' => _m('Is this a private group:'), 'multiple' => false, 'expanded' => false, 'choices' => [
_m('No') => 'public',
_m('Yes') => 'private',
]]],
['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 = Nickname::normalize(
nickname: $data['group_nickname'],
check_already_used: true,
which: Nickname::CHECK_LOCAL_GROUP,
check_is_allowed: true,
);
$roles = ActorLocalRoles::VISITOR; // Can send direct messages to other actors
if ($data['group_scope'] === 'private') {
$roles |= ActorLocalRoles::PRIVATE_GROUP;
}
Log::info(
_m(
'Actor id:{actor_id} nick:{actor_nick} created the ' . ($roles & ActorLocalRoles::PRIVATE_GROUP ? 'private' : 'public') . ' 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' => $roles,
]));
DB::persist(LocalGroup::create([
'actor_id' => $group->getId(),
'type' => $data['group_type'],
'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(),
// Group Owner
'roles' => ActorLocalRoles::OPERATOR | ActorLocalRoles::MODERATOR | ActorLocalRoles::PARTICIPANT | ActorLocalRoles::VISITOR,
]));
DB::flush();
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribers']);
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
throw new RedirectException();
}
return $create_form;
}
} }

View File

@ -1,140 +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 Component\Group\Controller;
use App\Core\Cache;
use App\Core\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Router;
use App\Entity\Actor;
use App\Entity as E;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\ServerException;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController;
use Component\Group\Entity\LocalGroup;
use Component\Subscription\Entity\ActorSubscription;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
/**
* @extends FeedController<\App\Entity\Note>
*/
class GroupFeed extends FeedController
{
/**
* @throws ServerException
*
* @return ControllerResultType
*/
public function groupView(Request $request, Actor $group): array
{
$actor = Common::actor();
$subscribe_form = null;
if (!\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']);
}
}
$notes = DB::dql(<<<'EOF'
SELECT n FROM \App\Entity\Note AS n
WHERE n.id IN (
SELECT act.object_id FROM \App\Entity\Activity AS act
WHERE act.object_type = 'note' AND act.id IN
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :id)
)
ORDER BY n.created DESC
EOF, ['id' => $group->getId()]);
return [
'_template' => 'group/view.html.twig',
'actor' => $group,
'nickname' => $group->getNickname(),
'notes' => $notes,
'notes_feed_title' => (new Heading(1, [], $group->getNickname() . '\'s feed')),
'subscribe_form' => $subscribe_form?->createView(),
];
}
/**
* @throws ClientException
* @throws ServerException
*
* @return ControllerResultType
*/
public function groupViewId(Request $request, int $id): array
{
$group = Actor::getById($id);
if (\is_null($group) || !$group->isGroup()) {
throw new ClientException(_m('No such group.'), 404);
}
if ($group->getIsLocal()) {
return [
'_redirect' => Router::url('group_actor_view_nickname', ['nickname' => $group->getNickname()]),
'actor' => $group,
];
}
return $this->groupView($request, $group);
}
/**
* View a group feed by its nickname
*
* @param string $nickname The group's nickname to be shown
*
* @throws ClientException
* @throws ServerException
*
* @return ControllerResultType
*/
public function groupViewNickname(Request $request, string $nickname): array
{
$group = LocalGroup::getActorByNickname($nickname);
if (\is_null($group)) {
throw new ClientException(_m('No such group.'), 404);
}
return $this->groupView($request, $group);
}
}

View File

@ -0,0 +1,100 @@
<?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\Group\Entity;
use App\Core\Entity;
use DateTimeInterface;
/**
* Entity for Group Alias
*
* @category DB
* @package GNUsocial
*
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet Inc.
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
* @author Hugo Sales <hugo@hsal.es>
* @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 GroupAlias extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private string $alias;
private int $group_id;
private DateTimeInterface $modified;
public function setAlias(string $alias): self
{
$this->alias = mb_substr($alias, 0, 64);
return $this;
}
public function getAlias(): string
{
return $this->alias;
}
public function setGroupId(int $group_id): self
{
$this->group_id = $group_id;
return $this;
}
public function getGroupId(): int
{
return $this->group_id;
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
return $this;
}
public function getModified(): DateTimeInterface
{
return $this->modified;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function schemaDef(): array
{
return [
'name' => 'group_alias',
'fields' => [
'alias' => ['type' => 'varchar', 'length' => 64, 'not null' => true, 'description' => 'additional nickname for the group'],
'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'group id which this is an alias of'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['alias'],
'indexes' => [
'group_alias_group_id_idx' => ['group_id'],
],
];
}
}

View File

@ -0,0 +1,110 @@
<?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\Group\Entity;
use App\Core\Entity;
use DateTimeInterface;
/**
* Entity for Group Block
*
* @category DB
* @package GNUsocial
*
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet Inc.
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
* @author Hugo Sales <hugo@hsal.es>
* @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 GroupBlock extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $group_id;
private int $blocked_actor;
private int $blocker_user;
private DateTimeInterface $modified;
public function setGroupId(int $group_id): self
{
$this->group_id = $group_id;
return $this;
}
public function getGroupId(): int
{
return $this->group_id;
}
public function setBlockedActor(int $blocked_actor): self
{
$this->blocked_actor = $blocked_actor;
return $this;
}
public function getBlockedActor(): int
{
return $this->blocked_actor;
}
public function setBlockerUser(int $blocker_user): self
{
$this->blocker_user = $blocker_user;
return $this;
}
public function getBlockerUser(): int
{
return $this->blocker_user;
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
return $this;
}
public function getModified(): DateTimeInterface
{
return $this->modified;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function schemaDef(): array
{
return [
'name' => 'group_block',
'fields' => [
'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'group actor is blocked from'],
'blocked_actor' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'actor that is blocked'],
'blocker_user' => ['type' => 'int', 'foreign key' => true, 'target' => 'LocalUser.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'user making the block'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['group_id', 'blocked_actor'],
];
}
}

View File

@ -0,0 +1,103 @@
<?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\Group\Entity;
use App\Core\Entity;
use DateTimeInterface;
/**
* Entity for Group's inbox
*
* @category DB
* @package GNUsocial
*
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet Inc.
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
* @author Hugo Sales <hugo@hsal.es>
* @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 GroupInbox extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $group_id;
private int $activity_id;
private DateTimeInterface $created;
public function setGroupId(int $group_id): self
{
$this->group_id = $group_id;
return $this;
}
public function getGroupId(): int
{
return $this->group_id;
}
public function setActivityId(int $activity_id): self
{
$this->activity_id = $activity_id;
return $this;
}
public function getActivityId(): int
{
return $this->activity_id;
}
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getCreated(): DateTimeInterface
{
return $this->created;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function schemaDef(): array
{
return [
'name' => 'group_inbox',
'description' => 'Many-many table listing activities posted to a given group, or which groups a given activity was posted to',
'fields' => [
'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'one to one', 'name' => 'group_inbox_group_id_fkey', 'not null' => true, 'description' => 'group receiving the activity'],
'activity_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Activity.id', 'multiplicity' => 'many to one', 'name' => 'group_inbox_activity_id_fkey', 'not null' => true, 'description' => 'activity received'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
],
'primary key' => ['group_id', 'activity_id'],
'indexes' => [
'group_inbox_activity_id_idx' => ['activity_id'],
'group_inbox_group_id_created_activity_id_idx' => ['group_id', 'created', 'activity_id'],
'group_inbox_created_idx' => ['created'],
],
];
}
}

View File

@ -21,7 +21,6 @@ declare(strict_types = 1);
namespace Component\Group\Entity; namespace Component\Group\Entity;
use App\Core\ActorLocalRoles;
use App\Core\Entity; use App\Core\Entity;
use DateTimeInterface; use DateTimeInterface;
@ -45,7 +44,8 @@ class GroupMember extends Entity
// @codeCoverageIgnoreStart // @codeCoverageIgnoreStart
private int $group_id; private int $group_id;
private int $actor_id; private int $actor_id;
private int $roles = ActorLocalRoles::VISITOR; private ?bool $is_admin = false;
private ?string $uri = null;
private DateTimeInterface $created; private DateTimeInterface $created;
private DateTimeInterface $modified; private DateTimeInterface $modified;
@ -71,15 +71,26 @@ class GroupMember extends Entity
return $this->actor_id; return $this->actor_id;
} }
public function setRoles(int $roles): self public function setIsAdmin(?bool $is_admin): self
{ {
$this->roles = $roles; $this->is_admin = $is_admin;
return $this; return $this;
} }
public function getRoles(): int public function getIsAdmin(): ?bool
{ {
return $this->roles; return $this->is_admin;
}
public function setUri(?string $uri): self
{
$this->uri = \is_null($uri) ? null : mb_substr($uri, 0, 191);
return $this;
}
public function getUri(): ?string
{
return $this->uri;
} }
public function setCreated(DateTimeInterface $created): self public function setCreated(DateTimeInterface $created): self
@ -114,11 +125,15 @@ class GroupMember extends Entity
'fields' => [ 'fields' => [
'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'group_member_group_id_fkey', 'not null' => true, 'description' => 'foreign key to group table'], 'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'group_member_group_id_fkey', 'not null' => true, 'description' => 'foreign key to group table'],
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'group_member_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' => 'group_member_actor_id_fkey', 'not null' => true, 'description' => 'foreign key to actor table'],
'roles' => ['type' => 'int', 'not null' => true, 'default' => ActorLocalRoles::VISITOR, 'description' => 'Bitmap of permissions this actor has'], 'is_admin' => ['type' => 'bool', 'default' => false, 'description' => 'is this actor an admin?'],
'uri' => ['type' => 'varchar', 'length' => 191, 'description' => 'universal identifier'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'], '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'], 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
], ],
'primary key' => ['group_id', 'actor_id'], 'primary key' => ['group_id', 'actor_id'],
'unique keys' => [
'group_member_uri_key' => ['uri'],
],
'indexes' => [ 'indexes' => [
'group_member_actor_id_idx' => ['actor_id'], 'group_member_actor_id_idx' => ['actor_id'],
'group_member_created_idx' => ['created'], 'group_member_created_idx' => ['created'],

View File

@ -21,8 +21,10 @@ declare(strict_types = 1);
namespace Component\Group\Entity; namespace Component\Group\Entity;
use App\Core\DB; use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Util\Exception\NicknameEmptyException; use App\Util\Exception\NicknameEmptyException;
use App\Util\Exception\NicknameException; use App\Util\Exception\NicknameException;
@ -51,45 +53,33 @@ class LocalGroup extends Entity
{ {
// {{{ Autocode // {{{ Autocode
// @codeCoverageIgnoreStart // @codeCoverageIgnoreStart
private int $actor_id; private int $group_id;
private string $nickname; private ?string $nickname = null;
private string $type = 'group';
private DateTimeInterface $created; private DateTimeInterface $created;
private DateTimeInterface $modified; private DateTimeInterface $modified;
public function setActorId(int $actor_id): self public function setGroupId(int $group_id): self
{ {
$this->actor_id = $actor_id; $this->group_id = $group_id;
return $this; return $this;
} }
public function getActorId(): int public function getGroupId(): int
{ {
return $this->actor_id; return $this->group_id;
} }
public function setNickname(string $nickname): self public function setNickname(?string $nickname): self
{ {
$this->nickname = mb_substr($nickname, 0, 64); $this->nickname = \is_null($nickname) ? null : mb_substr($nickname, 0, 64);
return $this; return $this;
} }
public function getNickname(): string public function getNickname(): ?string
{ {
return $this->nickname; return $this->nickname;
} }
public function setType(string $type): self
{
$this->type = mb_substr($type, 0, 64);
return $this;
}
public function getType(): string
{
return $this->type;
}
public function setCreated(DateTimeInterface $created): self public function setCreated(DateTimeInterface $created): self
{ {
$this->created = $created; $this->created = $created;
@ -117,17 +107,19 @@ class LocalGroup extends Entity
public function getActor() public function getActor()
{ {
return DB::findOneBy(Actor::class, ['id' => $this->actor_id]); return DB::find('actor', ['id' => $this->group_id]);
} }
public static function getByNickname(string $nickname): ?self public static function getByNickname(string $nickname): ?self
{ {
return DB::findOneBy(self::class, ['nickname' => $nickname]); $res = DB::findBy(self::class, ['nickname' => $nickname]);
return $res === [] ? null : $res[0];
} }
public static function getActorByNickname(string $nickname): ?Actor public static function getActorByNickname(string $nickname): ?Actor
{ {
return DB::findOneBy(Actor::class, ['nickname' => $nickname, 'type' => Actor::GROUP]); $res = DB::findBy(Actor::class, ['nickname' => $nickname, 'type' => Actor::GROUP]);
return $res === [] ? null : $res[0];
} }
/** /**
@ -159,13 +151,12 @@ class LocalGroup extends Entity
'name' => 'local_group', 'name' => 'local_group',
'description' => 'Record for a user group on the local site, with some additional info not in user_group', 'description' => 'Record for a user group on the local site, with some additional info not in user_group',
'fields' => [ 'fields' => [
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'one to one', 'name' => 'local_group_group_id_fkey', 'not null' => true, 'description' => 'group represented'], 'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'one to one', 'name' => 'local_group_group_id_fkey', 'not null' => true, 'description' => 'group represented'],
'nickname' => ['type' => 'varchar', 'not null' => true, 'length' => 64, 'description' => 'group represented'], 'nickname' => ['type' => 'varchar', 'length' => 64, 'description' => 'group represented'],
'type' => ['type' => 'varchar', 'not null' => true, 'default' => 'group', 'length' => 64, 'description' => 'Group or Organisation'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'], 'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
'modified' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], 'modified' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
], ],
'primary key' => ['actor_id'], 'primary key' => ['group_id'],
'unique keys' => [ 'unique keys' => [
'local_group_nickname_key' => ['nickname'], 'local_group_nickname_key' => ['nickname'],
], ],

View File

@ -24,67 +24,52 @@ namespace Component\Group;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router; use App\Core\Router\RouteLoader;
use App\Entity\Activity; use App\Core\Router\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Util\Common; use App\Util\Common;
use App\Util\HTML; use App\Util\HTML;
use App\Util\Nickname; use App\Util\Nickname;
use Component\Circle\Controller\SelfTagsSettings;
use Component\Group\Controller as C; use Component\Group\Controller as C;
use Component\Group\Entity\LocalGroup; use Component\Group\Entity\LocalGroup;
use Component\Notification\Notification;
use EventResult;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Group extends Component class Group extends Component
{ {
public function onAddRoute(Router $r): EventResult public function onAddRoute(RouteLoader $r): bool
{ {
$r->connect(id: 'group_actor_view_id', uri_path: '/group/{id<\d+>}', target: [C\GroupFeed::class, 'groupViewId']);
$r->connect(id: 'group_actor_view_nickname', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\GroupFeed::class, 'groupViewNickname']);
$r->connect(id: 'group_create', uri_path: '/group/new', target: [C\Group::class, 'groupCreate']); $r->connect(id: 'group_create', uri_path: '/group/new', target: [C\Group::class, 'groupCreate']);
$r->connect(id: 'group_actor_settings', uri_path: '/group/{id<\d+>}/settings', target: [C\Group::class, 'groupSettings']); $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; return Event::next;
} }
/** /**
* Enqueues a notification for an Actor (such as person or group) which means * Add an <a href=group_settings> to the profile card for groups, if the current actor can access them
* it shows up in their home feed and such.
*
* @param Actor[] $targets
*/ */
public function onNewNotificationStart(Actor $sender, Activity $activity, array $targets = [], ?string $reason = null): EventResult public function onAppendCardProfile(array $vars, array &$res): bool
{
foreach ($targets as $target) {
if ($target->isGroup()) {
// The Group announces to its subscribers
Notification::notify(
sender: $target,
activity: $activity,
targets: $target->getSubscribers(),
reason: $reason,
);
}
}
return Event::next;
}
/**
* Add an <a href=group_actor_settings> to the profile card for groups, if the current actor can access them
*
* @param array<string, mixed> $vars
* @param string[] $res
*/
public function onAppendCardProfile(array $vars, array &$res): EventResult
{ {
$actor = Common::actor(); $actor = Common::actor();
$group = $vars['actor']; $group = $vars['actor'];
if (!\is_null($actor) && $group->isGroup()) { if (!\is_null($actor) && $group->isGroup() && $actor->canAdmin($group)) {
if ($actor->canModerate($group)) { $url = Router::url('group_settings', ['nickname' => $group->getNickname()]);
$url = Router::url('group_actor_settings', ['id' => $group->getId()]);
$res[] = HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings'), 'class' => 'profile-extra-actions'], _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;
}
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs)
{
if ($section === 'profile' && $request->get('_route') === 'group_settings') {
$nickname = $request->get('nickname');
$group = LocalGroup::getActorByNickname($nickname);
$tabs[] = [
'title' => 'Self tags',
'desc' => 'Add or remove tags on this group',
'id' => 'settings-self-tags',
'controller' => SelfTagsSettings::settingsSelfTags($request, $group, 'settings-self-tags-details'),
];
} }
return Event::next; return Event::next;
} }
@ -94,49 +79,27 @@ class Group extends Component
*/ */
private function getGroupFromContext(Request $request): ?Actor private function getGroupFromContext(Request $request): ?Actor
{ {
if (\is_array($request->get('post_note')) && \array_key_exists('_next', $request->get('post_note'))) { if (str_starts_with($request->get('_route'), 'group_actor_view_')) {
$next = parse_url($request->get('post_note')['_next']); if (!\is_null($id = $request->get('id'))) {
$match = Router::match($next['path']); return Actor::getById((int) $id);
$route = $match['_route']; } elseif (!\is_null($nickname = $request->get('nickname'))) {
$identifier = $match['id'] ?? $match['nickname'] ?? null; return LocalGroup::getActorByNickname($nickname);
} else {
$route = $request->get('_route');
$identifier = $request->get('id') ?? $request->get('nickname');
}
if (str_starts_with($route, 'group_actor_view_')) {
switch ($route) {
case 'group_actor_view_nickname':
return LocalGroup::getActorByNickname($identifier);
case 'group_actor_view_id':
return Actor::getById((int) $identifier);
} }
} }
return null; return null;
} }
/** public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets)
* @param Actor[] $targets
*/
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): EventResult
{ {
$group = $this->getGroupFromContext($request); $group = $this->getGroupFromContext($request);
if (!\is_null($group)) { if (!\is_null($group)) {
$nick = "!{$group->getNickname()}"; $nick = '!' . $group->getNickname();
$targets[$nick] = $group->getId(); $targets[$nick] = $group->getId();
} }
return Event::next; return Event::next;
} }
/** public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor $context_actor)
* Indicates the context in which Posting's form is to be presented. Passing on $context_actor to Posting's
* onAppendRightPostingBlock event, the Group a given $actor is currently browsing.
*
* Makes it possible to automagically fill in the targets (aka the Group which this $request route is connected to)
* in the Posting's form.
*
* @param null|Actor $context_actor Actor group, if current route is part of an existing Group set of routes
*/
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor): EventResult
{ {
$ctx = $this->getGroupFromContext($request); $ctx = $this->getGroupFromContext($request);
if (!\is_null($ctx)) { if (!\is_null($ctx)) {

View File

@ -1,10 +0,0 @@
<details class="frame-section section-details-title">
<summary class="details-summary-title">
<strong>
{% trans %}Create a group{% endtrans %}
</strong>
</summary>
<form method="POST" class="section-form">
{{ form(create_form) }}
</form>
</details>

View File

@ -1,13 +1,19 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% import 'cards/macros/settings.html.twig' as macros %} {% import 'settings/macros.html.twig' as macros %}
{% block stylesheets %}
{{ parent() }}
<link rel="preload" href="{{ asset('assets/default_theme/css/pages/settings.css') }}" as="style" type="text/css">
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/settings.css') }}">
{% endblock stylesheets %}
{% block body %} {% block body %}
<nav class='section-settings'> <nav class='section-settings'>
<h1>Settings</h1> <h2>Settings</h2>
<ul> <ul>
<li> <li>
{% set profile_tabs = [{'title': 'Personal Info', 'desc': 'Nickname, Homepage, Bio and more.', 'id': 'settings-personal-info', 'form': personal_info_form}] %} {% set profile_tabs = [{'title': 'Personal Info', 'desc': 'Nickname, Homepage, Bio, Self Tags and more.', 'id': 'settings-personal-info', 'form': personal_info_form}] %}
{% set profile_tabs = profile_tabs|merge(handle_event('PopulateSettingsTabs', app.request, 'profile')) %} {% set profile_tabs = profile_tabs|merge(handle_event('PopulateSettingsTabs', app.request, 'profile')) %}
{{ macros.settings_details_container('Profile', 'Personal Information, Avatar and Profile', 'settings-profile-details', profile_tabs, _context) }} {{ macros.settings_details_container('Profile', 'Personal Information, Avatar and Profile', 'settings-profile-details', profile_tabs, _context) }}
</li> </li>

View File

@ -1,16 +1,65 @@
{% extends 'collection/notes.html.twig' %} {% extends 'stdgrid.html.twig' %}
{% import '/cards/note/view.html.twig' as noteView %}
{% set nickname = nickname|escape %} {% set nickname = nickname|escape %}
{% block title %}{{ nickname }}{% endblock %} {% block title %}{{ nickname }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
{% endblock stylesheets %}
{% block body %} {% block body %}
{% if actor is defined and actor is not null %} {% if actor is defined and actor is not null %}
{% block profile_view %} {% block profile_view %}
{% include 'cards/blocks/profile.html.twig' with { 'actor': actor } only %} {% include 'cards/profile/view.html.twig' with { 'actor': actor } only %}
{% endblock profile_view %} {% endblock profile_view %}
<hr>
{% if notes is defined %} {% if notes is defined %}
{{ parent() }} <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 %}
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
<strong>{% trans %}No notes yet...{% endtrans %}</strong>
</section>
{% endif %}
</article>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endblock body %} {% endblock body %}

View File

@ -25,7 +25,7 @@ namespace Component\Language\Controller;
use App\Core\Cache; use App\Core\Cache;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Util\Common; use App\Util\Common;
@ -100,8 +100,6 @@ class Language extends Controller
* @throws NoLoggedInUser * @throws NoLoggedInUser
* @throws RedirectException * @throws RedirectException
* @throws ServerException * @throws ServerException
*
* @return ControllerResultType
*/ */
public function sortLanguages(Request $request): array public function sortLanguages(Request $request): array
{ {
@ -136,7 +134,7 @@ class Language extends Controller
// Stay on same page, but force update and prevent resubmission // Stay on same page, but force update and prevent resubmission
throw new RedirectException('settings_sort_languages'); throw new RedirectException('settings_sort_languages');
} else { } else {
throw new RedirectException('person_actor_settings', ['id' => $user->getId(), 'open' => 'settings-language-details', '_fragment' => 'settings-language-details']); throw new RedirectException('settings', ['open' => 'account', '_fragment' => 'save_account_info_languages']);
} }
} }

View File

@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Language\Entity; namespace Component\Language\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\LocalUser; use App\Entity\LocalUser;
@ -119,9 +119,6 @@ class ActorLanguage extends Entity
) ?: [Language::getByLocale(Common::config('site', 'language'))]; ) ?: [Language::getByLocale(Common::config('site', 'language'))];
} }
/**
* @return int[]
*/
public static function getActorRelatedLanguagesIds(Actor $actor): array public static function getActorRelatedLanguagesIds(Actor $actor): array
{ {
return Cache::getList( return Cache::getList(

View File

@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Language\Entity; namespace Component\Language\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Entity\Actor; use App\Entity\Actor;
@ -116,7 +116,7 @@ class Language extends Entity
return Cache::getHashMapKey( return Cache::getHashMapKey(
map_key: 'languages-id', map_key: 'languages-id',
key: (string) $id, key: (string) $id,
calculate_map: fn () => F\reindex(DB::dql('SELECT l FROM \Component\Language\Entity\Language AS l'), fn (self $l) => (string) $l->getId()), calculate_map: fn () => F\reindex(DB::dql('select l from language l'), fn (self $l) => (string) $l->getId()),
); );
} }
@ -125,7 +125,7 @@ class Language extends Entity
return Cache::getHashMapKey( return Cache::getHashMapKey(
'languages', 'languages',
$locale, $locale,
calculate_map: fn () => F\reindex(DB::dql('SELECT l FROM \Component\Language\Entity\Language AS l'), fn (self $l) => $l->getLocale()), calculate_map: fn () => F\reindex(DB::dql('select l from language l'), fn (self $l) => $l->getLocale()),
); );
} }
@ -134,21 +134,16 @@ class Language extends Entity
return self::getById($note->getLanguageId()); return self::getById($note->getLanguageId());
} }
/**
* @return array<string, string>
*/
public static function getLanguageChoices(): array public static function getLanguageChoices(): array
{ {
$langs = Cache::getHashMap( $langs = Cache::getHashMap(
'languages', 'languages',
fn () => F\reindex(DB::dql('SELECT l FROM \Component\Language\Entity\Language AS l'), fn (self $l) => $l->getLocale()), fn () => F\reindex(DB::dql('select l from language l'), fn (self $l) => $l->getLocale()),
); );
return array_merge(...F\map(array_values($langs), fn ($l) => $l->toChoiceFormat())); return array_merge(...F\map(array_values($langs), fn ($l) => $l->toChoiceFormat()));
} }
/**
* @return array<string, string> */
public function toChoiceFormat(): array public function toChoiceFormat(): array
{ {
return [_m($this->getLongDisplay()) => $this->getLocale()]; return [_m($this->getLongDisplay()) => $this->getLocale()];
@ -157,8 +152,6 @@ class Language extends Entity
/** /**
* Get all the available languages as well as the languages $actor * Get all the available languages as well as the languages $actor
* prefers and are appropriate for posting in/to $context_actor * prefers and are appropriate for posting in/to $context_actor
*
* @return array{array<string, string>, array<string, string>}
*/ */
public static function getSortedLanguageChoices(?Actor $actor, ?Actor $context_actor, ?bool $use_short_display): array public static function getSortedLanguageChoices(?Actor $actor, ?Actor $context_actor, ?bool $use_short_display): array
{ {

View File

@ -23,7 +23,7 @@ namespace Component\Language;
use App\Core\Event; use App\Core\Event;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router; use App\Core\Router\RouteLoader;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Formatting; use App\Util\Formatting;
@ -33,22 +33,18 @@ use Component\Language\Entity\ActorLanguage;
use Doctrine\Common\Collections\ExpressionBuilder; use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr; use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use EventResult;
use Functional as F; use Functional as F;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Language extends Component class Language extends Component
{ {
public function onAddRoute(Router $r): EventResult public function onAddRoute(RouteLoader $r): bool
{ {
$r->connect('settings_sort_languages', '/settings/sort_languages', [C\Language::class, 'sortLanguages']); $r->connect('settings_sort_languages', '/settings/sort_languages', [C\Language::class, 'sortLanguages']);
return Event::next; return Event::next;
} }
/** public function onFilterNoteList(?Actor $actor, array &$notes, Request $request): bool
* @param Note[] $notes
*/
public function onFilterNoteList(?Actor $actor, array &$notes, Request $request): EventResult
{ {
if (\is_null($actor)) { if (\is_null($actor)) {
return Event::next; return Event::next;
@ -63,11 +59,8 @@ class Language extends Component
/** /**
* Populate $note_expr or $actor_expr with an expression to match a language * Populate $note_expr or $actor_expr with an expression to match a language
*
* @param mixed $note_expr
* @param mixed $actor_expr
*/ */
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult 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; $search_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
@ -110,27 +103,13 @@ class Language extends Component
return Event::next; return Event::next;
} }
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): EventResult public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{ {
$note_aliases = $note_qb->getAllAliases(); $note_qb->leftJoin('Component\Language\Entity\Language', 'note_language', Expr\Join::WITH, 'note.language_id = note_language.id')
if (!\in_array('note_language', $note_aliases)) { ->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'note.actor_id = actor_language.actor_id')
$note_qb->leftJoin('Component\Language\Entity\Language', 'note_language', Expr\Join::WITH, 'note.language_id = note_language.id'); ->leftJoin('Component\Language\Entity\Language', 'note_actor_language', Expr\Join::WITH, 'note_actor_language.id = actor_language.language_id');
} $actor_qb->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'actor.id = actor_language.actor_id')
if (!\in_array('actor_language', $note_aliases)) { ->leftJoin('Component\Language\Entity\Language', 'language', Expr\Join::WITH, 'actor_language.language_id = language.id');
$note_qb->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'note.actor_id = actor_language.actor_id');
}
if (!\in_array('note_actor_language', $note_aliases)) {
$note_qb->leftJoin('Component\Language\Entity\Language', 'note_actor_language', Expr\Join::WITH, 'note_actor_language.id = actor_language.language_id');
}
$actor_aliases = $actor_qb->getAllAliases();
if (!\in_array('actor_language', $actor_aliases)) {
$actor_qb->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'actor.id = actor_language.actor_id');
}
if (!\in_array('language', $actor_aliases)) {
$actor_qb->leftJoin('Component\Language\Entity\Language', 'language', Expr\Join::WITH, 'actor_language.language_id = language.id');
}
return Event::next; return Event::next;
} }
} }

View File

@ -2,7 +2,7 @@
{% block body %} {% block body %}
<div class="frame-section frame-section-padding"> <div class="frame-section frame-section-padding">
<h3>{% trans %}Put the languages in the order you'd like to see them in your language selection dropdown, when posting{% endtrans %}</h3> <h3>{{ 'Put the languages in the order you\'d like to see them in your language selection dropdown, when posting' | trans}}</h3>
{{ form(form) }} {{ form(form) }}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -25,10 +25,10 @@ namespace Component\LeftPanel\Controller;
use App\Core\Cache; use App\Core\Cache;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Feed; use App\Entity\Feed;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
@ -104,6 +104,7 @@ class EditFeeds extends Controller
$feed->setUrl($fd[$md5 . '-url']); $feed->setUrl($fd[$md5 . '-url']);
$feed->setOrdering($fd[$md5 . '-order']); $feed->setOrdering($fd[$md5 . '-order']);
$feed->setTitle($fd[$md5 . '-title']); $feed->setTitle($fd[$md5 . '-title']);
DB::merge($feed);
} }
DB::flush(); DB::flush();
Cache::delete($key); Cache::delete($key);
@ -118,6 +119,7 @@ class EditFeeds extends Controller
/** @var SubmitButton $remove_button */ /** @var SubmitButton $remove_button */
$remove_button = $form->get($remove_id); $remove_button = $form->get($remove_id);
if ($remove_button->isClicked()) { if ($remove_button->isClicked()) {
// @phpstan-ignore-next-line -- Doesn't quite understand that _this_ $opts for the current $form_definitions does have 'data'
DB::remove(DB::getReference('feed', ['actor_id' => $user->getId(), 'url' => $opts['data']])); DB::remove(DB::getReference('feed', ['actor_id' => $user->getId(), 'url' => $opts['data']]));
DB::flush(); DB::flush();
Cache::delete($key); Cache::delete($key);

View File

@ -22,34 +22,27 @@ declare(strict_types = 1);
namespace Component\LeftPanel; namespace Component\LeftPanel;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router; use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Feed; use App\Entity\Feed;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
use App\Util\Exception\NotFoundException; use App\Util\Exception\NotFoundException;
use Component\LeftPanel\Controller as C; use Component\LeftPanel\Controller as C;
use EventResult;
class LeftPanel extends Component class LeftPanel extends Component
{ {
public function onAddRoute(Router $r): EventResult public function onAddRoute(RouteLoader $r): bool
{ {
$r->connect('edit_feeds', '/edit-feeds', C\EditFeeds::class); $r->connect('edit_feeds', '/edit-feeds', C\EditFeeds::class);
return Event::next; return Event::next;
} }
/** public function onAppendFeed(Actor $actor, string $title, string $route, array $route_params)
* @param array<string, string> $route_params
*
* @throws \App\Util\Exception\DuplicateFoundException
* @throws \App\Util\Exception\ServerException
* @throws ClientException
*/
public function onAppendFeed(Actor $actor, string $title, string $route, array $route_params): EventResult
{ {
$cache_key = Feed::cacheKey($actor); $cache_key = Feed::cacheKey($actor);
$feeds = Feed::getFeeds($actor); $feeds = Feed::getFeeds($actor);
@ -74,4 +67,17 @@ class LeftPanel extends Component
return Event::stop; return Event::stop;
} }
} }
/**
* Output our dedicated stylesheet
*
* @param array $styles stylesheets path
*
* @return bool hook value; true means continue processing, false means stop
*/
public function onEndShowStyles(array &$styles, string $route): bool
{
$styles[] = 'components/LeftPanel/assets/css/view.css';
return Event::next;
}
} }

View File

@ -2,7 +2,7 @@
{% block stylesheets %} {% block stylesheets %}
{{ parent() }} {{ parent() }}
<link rel="stylesheet" href="{{ asset('assets/default_theme/pages/feeds.css') }}" type="text/css"> <link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
{% endblock stylesheets %} {% endblock stylesheets %}
{% macro edit_feeds_form_row(child) %} {% macro edit_feeds_form_row(child) %}
@ -15,7 +15,7 @@
{% block body %} {% block body %}
<div class="frame-section"> <div class="frame-section">
<form class="section-form" action="{{ path('edit_feeds') }}" method="post"> <form class="section-form" action="{{ path('edit_feeds') }}" method="post">
<h1 class="frame-section-title">{% trans %}Edit feed navigation links{% endtrans %}</h1> <h1 class="frame-section-title">{{ "Edit feed navigation links" | trans }}</h1>
{# Since the form is not separated into individual groups, this happened #} {# Since the form is not separated into individual groups, this happened #}
{{ form_start(edit_feeds) }} {{ form_start(edit_feeds) }}
{{ form_errors(edit_feeds) }} {{ form_errors(edit_feeds) }}

View File

@ -1,24 +1,24 @@
{% block leftpanel %} {% block leftpanel %}
<label class="panel-left-icon" for="toggle-panel-left" tabindex="-1">{{ icon('menu', 'icon icon-left') | raw }}</label> <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="{% trans %}Press tab followed by a space to access left panel{% endtrans %}"></a> <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="{% trans %}Open left panel{% endtrans %}"> <input type="checkbox" id="toggle-panel-left" tabindex="0" title="{{ 'Open left panel' | trans }}">
<aside class="section-panel section-panel-left"> <aside class="section-panel section-panel-left">
<section class="panel-content accessibility-target"> <section class="panel-content accessibility-target">
{% if app.user %} {% if app.user %}
<section class='frame-section frame-section-padding' title="{% trans %}Your profile information{% endtrans %}"> <section class='frame-section frame-section-padding' title="{{ 'Your profile information.' | trans }}">
{% block profile_view %}{% include 'cards/blocks/profile.html.twig' with { actor: current_actor } %}{% endblock profile_view %} {% block profile_view %}{% include 'cards/profile/view.html.twig' with { actor: current_actor } %}{% endblock profile_view %}
{{ block("profile_current_actor", "cards/blocks/navigation.html.twig") }} {{ block("profile_current_actor", "cards/navigation/view.html.twig") }}
</section> </section>
{% else %} {% else %}
<section> <section>
{{ block("profile_security", "cards/blocks/navigation.html.twig") }} {{ block("profile_security", "cards/navigation/view.html.twig") }}
</section> </section>
{% endif %} {% endif %}
{{ block("feeds", "cards/blocks/navigation.html.twig") }} {{ block("feeds", "cards/navigation/view.html.twig") }}
{{ block("footer", "cards/blocks/navigation.html.twig") }} {{ block("footer", "cards/navigation/view.html.twig") }}
</section> </section>
</aside> </aside>
{% endblock leftpanel %} {% endblock leftpanel %}

View File

@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Link\Entity; namespace Component\Link\Entity;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Core\Event; use App\Core\Event;
use App\Core\GSFile; use App\Core\GSFile;
@ -126,7 +126,7 @@ class Link extends Entity
{ {
if (Common::isValidHttpUrl($url)) { if (Common::isValidHttpUrl($url)) {
// If the URL is a local one, do not create a Link to it // If the URL is a local one, do not create a Link to it
if (parse_url($url, \PHP_URL_HOST) === Common::config('site', 'server')) { if (parse_url($url, \PHP_URL_HOST) === $_ENV['SOCIAL_DOMAIN']) {
Log::warning("It was attempted to create a Link to a local location {$url}."); Log::warning("It was attempted to create a Link to a local location {$url}.");
// Forbidden // Forbidden
throw new InvalidArgumentException(message: "A Link can't point to a local location ({$url}), it must be a remote one", code: 400); throw new InvalidArgumentException(message: "A Link can't point to a local location ({$url}), it must be a remote one", code: 400);

View File

@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Link\Entity; namespace Component\Link\Entity;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Core\Event; use App\Core\Event;
use DateTimeInterface; use DateTimeInterface;
@ -85,15 +85,15 @@ class NoteToLink extends Entity
* properties of $obj with the associative array $args. Doesn't * properties of $obj with the associative array $args. Doesn't
* persist the result * persist the result
* *
* @param (array{link_id: int, note_id: int} & array<string, mixed>) $args * @param null|mixed $obj
*/ */
public static function create(array $args, bool $_delegated_call = false): static public static function create(array $args, $obj = null)
{ {
$link = DB::find('link', ['id' => $args['link_id']]); $link = DB::find('link', ['id' => $args['link_id']]);
$note = DB::find('note', ['id' => $args['note_id']]); $note = DB::find('note', ['id' => $args['note_id']]);
Event::handle('NewLinkFromNote', [$link, $note]); Event::handle('NewLinkFromNote', [$link, $note]);
$obj = new self(); $obj = new self();
return parent::createOrUpdate(obj: $obj, args: $args); return parent::create($args, $obj);
} }
public static function removeWhereNoteId(int $note_id): mixed public static function removeWhereNoteId(int $note_id): mixed

View File

@ -23,7 +23,7 @@ declare(strict_types = 1);
namespace Component\Link; namespace Component\Link;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Entity\Actor; use App\Entity\Actor;
@ -31,50 +31,33 @@ use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use App\Util\HTML; use App\Util\HTML;
use Component\Link\Entity\NoteToLink; use Component\Link\Entity\NoteToLink;
use EventResult;
use InvalidArgumentException; use InvalidArgumentException;
class Link extends Component class Link extends Component
{ {
/**
* Note that this persists both a Link and a NoteToLink
*
* @return array{ link: ?Entity\Link, note_to_link: ?NoteToLink }
*/
public static function maybeCreateLink(string $url, int $note_id): array
{
try {
$link = Entity\Link::getOrCreate($url);
DB::persist($note_link = NoteToLink::create(['link_id' => $link->getId(), 'note_id' => $note_id]));
return ['link' => $link, 'note_to_link' => $note_link];
} catch (InvalidArgumentException) {
return ['link' => null, 'note_to_link' => null];
}
}
/** /**
* Extract URLs from $content and create the appropriate Link and NoteToLink entities * Extract URLs from $content and create the appropriate Link and NoteToLink entities
*
* @param array{ignoreLinks?: string[]} $process_note_content_extra_args
*/ */
public function onProcessNoteContent(Note $note, string $content, string $content_type, array $process_note_content_extra_args = []): EventResult public function onProcessNoteContent(Note $note, string $content): bool
{ {
$ignore = $process_note_content_extra_args['ignoreLinks'] ?? [];
if (Common::config('attachments', 'process_links')) { if (Common::config('attachments', 'process_links')) {
$matched_urls = []; $matched_urls = [];
preg_match_all($this->getURLRegex(), $content, $matched_urls); // TODO: This solution to ignore mentions when content is in html is far from ideal
preg_match_all($this->getURLRegex(), preg_replace('#<a href="(.*?)" class="u-url mention">#', '', $content), $matched_urls);
$matched_urls = array_unique($matched_urls[1]); $matched_urls = array_unique($matched_urls[1]);
foreach ($matched_urls as $match) { foreach ($matched_urls as $match) {
if (\in_array($match, $ignore)) { try {
$link_id = Entity\Link::getOrCreate($match)->getId();
DB::persist(NoteToLink::create(['link_id' => $link_id, 'note_id' => $note->getId()]));
} catch (InvalidArgumentException) {
continue; continue;
} }
self::maybeCreateLink($match, $note->getId());
} }
} }
return Event::next; return Event::next;
} }
public function onRenderPlainTextNoteContent(string &$text): EventResult public function onRenderPlainTextNoteContent(string &$text): bool
{ {
$text = $this->replaceURLs($text); $text = $this->replaceURLs($text);
return Event::next; return Event::next;
@ -152,12 +135,7 @@ class Link extends Component
public const URL_SCHEME_NO_DOMAIN = 4; public const URL_SCHEME_NO_DOMAIN = 4;
public const URL_SCHEME_COLON_COORDINATES = 8; public const URL_SCHEME_COLON_COORDINATES = 8;
/** public function URLSchemes($filter = null)
* @param self::URL_SCHEME_COLON_COORDINATES|self::URL_SCHEME_COLON_DOUBLE_SLASH|self::URL_SCHEME_NO_DOMAIN|self::URL_SCHEME_SINGLE_COLON $filter
*
* @return string[]
*/
public function URLSchemes(?int $filter = null): array
{ {
// TODO: move these to config // TODO: move these to config
$schemes = [ $schemes = [
@ -204,7 +182,6 @@ class Link extends Component
* Intermediate callback for `replaceURLs()`, which helps resolve some * Intermediate callback for `replaceURLs()`, which helps resolve some
* ambiguous link forms before passing on to the final callback. * ambiguous link forms before passing on to the final callback.
* *
* @param string[] $matches
* @param callable(string $text): string $callback: return replacement text * @param callable(string $text): string $callback: return replacement text
*/ */
private function callbackHelper(array $matches, callable $callback): string private function callbackHelper(array $matches, callable $callback): string
@ -284,7 +261,7 @@ class Link extends Component
return HTML::html(['a' => ['attrs' => $attrs, $url]], options: ['indent' => false]); return HTML::html(['a' => ['attrs' => $attrs, $url]], options: ['indent' => false]);
} }
public function onNoteDeleteRelated(Note &$note, Actor $actor): EventResult public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
{ {
DB::wrapInTransaction(fn () => NoteToLink::removeWhereNoteId($note->getId())); DB::wrapInTransaction(fn () => NoteToLink::removeWhereNoteId($note->getId()));
return Event::next; return Event::next;

View File

@ -35,7 +35,7 @@ declare(strict_types = 1);
namespace Component\Notification\Controller; namespace Component\Notification\Controller;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB; use App\Core\DB\DB;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Util\Common; use App\Util\Common;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -44,8 +44,6 @@ class Feed extends Controller
{ {
/** /**
* Everything with attention to current user * Everything with attention to current user
*
* @return ControllerResultType
*/ */
public function notifications(Request $request): array public function notifications(Request $request): array
{ {
@ -55,9 +53,9 @@ class Feed extends Controller
WHERE n.id IN ( WHERE n.id IN (
SELECT act.object_id FROM \App\Entity\Activity AS act SELECT act.object_id FROM \App\Entity\Activity AS act
WHERE act.object_type = 'note' AND act.id IN WHERE act.object_type = 'note' AND act.id IN
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :target_id) (SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :id)
) )
EOF, [':target_id' => $user->getId()]); EOF, ['id' => $user->getId()]);
return [ return [
'_template' => 'collection/notes.html.twig', '_template' => 'collection/notes.html.twig',
'page_title' => _m('Notifications'), 'page_title' => _m('Notifications'),

View File

@ -1,105 +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 Component\Notification\Entity;
use App\Core\Entity;
/**
* Entity for object attentions
*
* An attention is a form of persistent notification.
* It exists together and for as long as the object it belongs to.
* Creating an attention requires creating a Notification.
*
* @category DB
* @package GNUsocial
*
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet Inc.
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class Attention extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private string $object_type;
private int $object_id;
private int $target_id;
public function setObjectType(string $object_type): self
{
$this->object_type = mb_substr($object_type, 0, 32);
return $this;
}
public function getObjectType(): string
{
return $this->object_type;
}
public function setObjectId(int $object_id): self
{
$this->object_id = $object_id;
return $this;
}
public function getObjectId(): int
{
return $this->object_id;
}
public function setTargetId(int $target_id): self
{
$this->target_id = $target_id;
return $this;
}
public function getTargetId(): int
{
return $this->target_id;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function schemaDef(): array
{
return [
'name' => 'attention',
'description' => 'Attentions to actors (these are not mentions)',
'fields' => [
'object_type' => ['type' => 'varchar', 'length' => 32, 'not null' => true, 'description' => 'the name of the table this object refers to'],
'object_id' => ['type' => 'int', 'not null' => true, 'description' => 'id in the referenced table'],
'target_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'actor_id for feed receiver'],
],
'primary key' => ['object_type', 'object_id', 'target_id'],
'indexes' => [
'attention_object_id_idx' => ['object_id'],
'attention_target_id_idx' => ['target_id'],
],
];
}
}

View File

@ -21,24 +21,24 @@ declare(strict_types = 1);
namespace Component\Notification\Entity; namespace Component\Notification\Entity;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use DateTimeInterface; use DateTimeInterface;
/** /**
* Entity for Notifications * Entity for attentions
*
* A Notification when isolated is a form of transient notification.
* When together with a persistent form of notification such as attentions or mentions,
* it records that the target was notified - which avoids re-notifying upon objects reconstructions.
* *
* @category DB * @category DB
* @package GNUsocial * @package GNUsocial
* *
* @author Diogo Peralta Cordeiro <@diogo.site> * @author Zach Copley <zach@status.net>
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org * @copyright 2010 StatusNet Inc.
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
* @author Hugo Sales <hugo@hsal.es>
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/ */
class Notification extends Entity class Notification extends Entity
@ -117,11 +117,11 @@ class Notification extends Entity
/** /**
* Pull the complete list of known activity context notifications for this activity. * Pull the complete list of known activity context notifications for this activity.
* *
* @return int[] actor ids (also group profiles) * @return array of integer actor ids (also group profiles)
*/ */
public static function getNotificationTargetIdsByActivity(int|Activity $activity_id): array public static function getNotificationTargetIdsByActivity(int|Activity $activity_id): array
{ {
$notifications = DB::findBy(self::class, ['activity_id' => \is_int($activity_id) ? $activity_id : $activity_id->getId()]); $notifications = DB::findBy('notification', ['activity_id' => \is_int($activity_id) ? $activity_id : $activity_id->getId()]);
$targets = []; $targets = [];
foreach ($notifications as $notification) { foreach ($notifications as $notification) {
$targets[] = $notification->getTargetId(); $targets[] = $notification->getTargetId();
@ -129,24 +129,9 @@ class Notification extends Entity
return $targets; return $targets;
} }
/**
* @return int[]
*/
public function getNotificationTargetsByActivity(int|Activity $activity_id): array public function getNotificationTargetsByActivity(int|Activity $activity_id): array
{ {
return DB::findBy(Actor::class, ['id' => $this->getNotificationTargetIdsByActivity($activity_id)]); return DB::findBy('actor', ['id' => $this->getNotificationTargetIdsByActivity($activity_id)]);
}
/**
* @return int[]
*/
public static function getAllActivitiesTargetedAtActor(Actor $actor): array
{
return DB::dql(<<<'EOF'
SELECT act FROM \App\Entity\Activity AS act
WHERE act.object_type = 'note' AND act.id IN
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :id)
EOF, ['id' => $actor->getId()]);
} }
public static function schemaDef(): array public static function schemaDef(): array

View File

@ -21,35 +21,29 @@ declare(strict_types = 1);
namespace Component\Notification; namespace Component\Notification;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Queue; use App\Core\Router\RouteLoader;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\LocalUser; use App\Entity\LocalUser;
use App\Util\Exception\ServerException;
use Component\FreeNetwork\FreeNetwork; use Component\FreeNetwork\FreeNetwork;
use Component\Group\Entity\GroupInbox;
use Component\Notification\Controller\Feed; use Component\Notification\Controller\Feed;
use EventResult;
use Exception;
use Throwable;
class Notification extends Component class Notification extends Component
{ {
public function onAddRoute(Router $m): EventResult public function onAddRoute(RouteLoader $m): bool
{ {
$m->connect('feed_notifications', '/feed/notifications', [Feed::class, 'notifications']); $m->connect('feed_notifications', '/feed/notifications', [Feed::class, 'notifications']);
return Event::next; return Event::next;
} }
/** public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
* @throws ServerException
*/
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): EventResult
{ {
DB::persist(\App\Entity\Feed::create([ DB::persist(\App\Entity\Feed::create([
'actor_id' => $actor_id, 'actor_id' => $actor_id,
@ -62,127 +56,58 @@ class Notification extends Component
} }
/** /**
* Enqueues a notification for an Actor (such as person or group) which means * Enqueues a notification for an Actor (user or group) which means
* it shows up in their home feed and such. * it shows up in their home feed and such.
* WARNING: It's highly advisable to have flushed any relevant objects before triggering this event.
*
* $targets should be of the shape:
* (int|Actor)[] // Prefer Actor whenever possible
* Example of $targets:
* [42, $actor_alice, $actor_bob] // Avoid repeating actors or ids
*
* @param Actor $sender The one responsible for this activity, take care not to include it in targets
* @param Activity $activity The activity responsible for the object being given to known to targets
* @param non-empty-array<Actor|int> $targets Attentions, Mentions, any other source. Should never be empty, you usually want to register an attention to every $sender->getSubscribers()
* @param null|string $reason An optional reason explaining why this notification exists
*/ */
public function onNewNotification(Actor $sender, Activity $activity, array $targets, ?string $reason = null): EventResult public function onNewNotification(Actor $sender, Activity $activity, array $ids_already_known = [], ?string $reason = null): bool
{ {
// Ensure targets are all actor objects and unique $targets = $activity->getNotificationTargets(ids_already_known: $ids_already_known, sender_id: $sender->getId());
$effective_targets = []; $this->notify($sender, $activity, $targets, $reason);
foreach ($targets as $target) {
if (\is_int($target)) {
$target_id = $target;
$target_object = null;
} else {
$target_id = $target->getId();
$target_object = $target;
}
if (!\array_key_exists(key: $target_id, array: $effective_targets)) {
$target_object ??= Actor::getById($target_id);
$effective_targets[$target_id] = $target_object;
}
}
unset($targets);
if (Event::handle('NewNotificationStart', [$sender, $activity, $effective_targets, $reason]) === Event::next) {
self::notify($sender, $activity, $effective_targets, $reason);
}
Event::handle('NewNotificationEnd', [$sender, $activity, $effective_targets, $reason]);
return Event::next; return Event::next;
} }
/** /**
* @param mixed[] $retry_args * Bring given Activity to Targets's attention
*/ */
public function onQueueNotificationLocal(Actor $sender, Activity $activity, Actor $target, ?string $reason, array &$retry_args): EventResult public function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
{
// TODO: use https://symfony.com/doc/current/notifier.html
return Event::stop;
}
/**
* @param Actor[] $targets
* @param mixed[] $retry_args
*/
public function onQueueNotificationRemote(Actor $sender, Activity $activity, array $targets, ?string $reason, array &$retry_args): EventResult
{
if (FreeNetwork::notify($sender, $activity, $targets, $reason)) {
return Event::stop;
} else {
return Event::next;
}
}
/**
* Bring given Activity to Targets' knowledge.
* This will flush a Notification to DB.
*
* @param Actor[] $targets
*
* @return bool true if successful, false otherwise
*/
public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
{ {
$remote_targets = []; $remote_targets = [];
foreach ($targets as $target) { foreach ($targets as $target) {
if ($target->getIsLocal()) { if ($target->getIsLocal()) {
if ($target->hasBlocked($author = $activity->getActor())) { if ($target->isGroup()) {
Log::info("Not saving notification to actor {$target->getId()} from sender {$sender->getId()} because receiver blocked author {$author->getId()}."); // FIXME: Make sure we check (for both local and remote) users are in the groups they send to!
DB::persist(GroupInbox::create([
'group_id' => $target->getId(),
'activity_id' => $activity->getId(),
]));
} else {
if ($target->hasBlocked($activity->getActor())) {
Log::info("Not saving reply to actor {$target->getId()} from sender {$sender->getId()} because of a block.");
continue; continue;
} }
}
if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) { if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) {
if ($sender->getId() === $target->getId() // TODO: use https://symfony.com/doc/current/notifier.html
|| $activity->getActorId() === $target->getId()) { // XXX: Unideal as in failures the rollback will leave behind a false notification,
// The target already knows about this, no need to bother with a notification // but most notifications (all) require flushing the objects first
continue; // 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,
])));
} }
}
Queue::enqueue(
payload: [$sender, $activity, $target, $reason],
queue: 'NotificationLocal',
priority: true,
);
} else { } else {
// We have no authority nor responsibility of notifying remote actors of a remote actor's doing // We have no authority nor responsibility of notifying remote actors of a remote actor's doing
if ($sender->getIsLocal()) { if ($sender->getIsLocal()) {
$remote_targets[] = $target; $remote_targets[] = $target;
} }
} }
// 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 implementations bear this in mind
try {
DB::wrapInTransaction(fn () => DB::persist(Entity\Notification::create([
'activity_id' => $activity->getId(),
'target_id' => $target->getId(),
'reason' => $reason,
])));
} catch (Exception|Throwable $e) {
// We do our best not to record duplicate notifications, but it's not insane that can happen
Log::error('It was attempted to record an invalid notification!', [$e]);
}
} }
if ($remote_targets !== []) { FreeNetwork::notify($sender, $activity, $remote_targets, $reason);
Queue::enqueue(
payload: [$sender, $activity, $remote_targets, $reason],
queue: 'NotificationRemote',
priority: false,
);
}
return true; return Event::next;
} }
} }

View File

@ -1,97 +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 Component\Person\Controller;
use function App\Core\I18n\_m;
use App\Core\Router;
use App\Entity\Actor;
use App\Entity as E;
use App\Entity\LocalUser;
use App\Util\Exception\ClientException;
use App\Util\Exception\ServerException;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController;
use Symfony\Component\HttpFoundation\Request;
/**
* @extends FeedController<\App\Entity\Note>
*/
class PersonFeed extends FeedController
{
/**
* @throws ClientException
* @throws ServerException
*
* @return ControllerResultType
*/
public function personViewId(Request $request, int $id): array
{
$person = Actor::getById($id);
if (\is_null($person) || !$person->isPerson()) {
throw new ClientException(_m('No such person.'), 404);
}
if ($person->getIsLocal()) {
return [
'_redirect' => Router::url('person_actor_view_nickname', ['nickname' => $person->getNickname()]),
'actor' => $person,
];
}
return $this->personView($request, $person);
}
/**
* View a group feed by its nickname
*
* @param string $nickname The group's nickname to be shown
*
* @throws ClientException
* @throws ServerException
*
* @return ControllerResultType
*/
public function personViewNickname(Request $request, string $nickname): array
{
$user = LocalUser::getByNickname($nickname);
if (\is_null($user)) {
throw new ClientException(_m('No such person.'), 404);
}
$person = Actor::getById($user->getId());
return $this->personView($request, $person);
}
/**
* @return ControllerResultType
*/
public function personView(Request $request, Actor $person): array
{
return [
'_template' => 'actor/view.html.twig',
'actor' => $person,
'nickname' => $person->getNickname(),
'notes' => E\Note::getAllNotesByActor($person),
'page_title' => _m('{nickname}\'s profile', ['{nickname}' => $person->getNickname()]),
'notes_feed_title' => (new Heading(level: 2, classes: ['section-title'], text: 'Notes by ' . $person->getNickname())),
];
}
}

View File

@ -1,40 +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 Component\Person;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Core\Router;
use App\Util\Nickname;
use Component\Person\Controller as C;
use EventResult;
class Person extends Component
{
public function onAddRoute(Router $r): EventResult
{
$r->connect(id: 'person_actor_view_id', uri_path: '/person/{id<\d+>}', target: [C\PersonFeed::class, 'personViewId']);
$r->connect(id: 'person_actor_view_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\PersonFeed::class, 'personViewNickname'], options: ['is_system_path' => false]);
$r->connect(id: 'person_actor_settings', uri_path: '/person/{id<\d+>}/settings', target: [C\PersonSettings::class, 'allSettings']);
return Event::next;
}
}

View File

@ -1,145 +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 Component\Person\tests\Controller;
use App\Core\DB;
use App\Core\Router;
use App\Entity\LocalUser;
use App\Util\GNUsocialTestCase;
use Jchook\AssertThrows\AssertThrows;
class PersonSettingsTest extends GNUsocialTestCase
{
use AssertThrows;
public function testPersonalInfo()
{
$client = static::createClient();
$user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_personal_info_test_user']);
$client->loginUser($user);
$client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
$this->assertResponseIsSuccessful();
$crawler = $client->submitForm('Save personal info', [
'save_personal_info[nickname]' => 'form_test_user_new_nickname',
'save_personal_info[full_name]' => 'Form User',
'save_personal_info[homepage]' => 'https://gnu.org',
'save_personal_info[bio]' => 'I was born at a very young age',
'save_personal_info[location]' => 'right here',
// 'save_personal_info[phone_number]' => '+351908555842', // from fakenumber.net
]);
$changed_user = DB::findOneBy(LocalUser::class, ['id' => $user->getId()]);
$actor = $changed_user->getActor();
static::assertSame('form_test_user_new_nickname', $changed_user->getNickname());
static::assertSame('form_test_user_new_nickname', $actor->getNickname());
static::assertSame('Form User', $actor->getFullName());
static::assertSame('https://gnu.org', $actor->getHomepage());
static::assertSame('I was born at a very young age', $actor->getBio());
static::assertSame('right here', $actor->getLocation());
// static::assertSame('908555842', $changed_user->getPhoneNumber()->getNationalNumber());
}
public function testEmail()
{
$client = static::createClient();
$user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_account_test_user']);
$client->loginUser($user);
$client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
$this->assertResponseIsSuccessful();
$crawler = $client->submitForm('Save email info', [
'save_email[outgoing_email_sanitized]' => 'outgoing@provider.any',
'save_email[incoming_email_sanitized]' => 'incoming@provider.any',
]);
$changed_user = DB::findOneBy(LocalUser::class, ['id' => $user->getId()]);
static::assertSame($changed_user->getOutgoingEmail(), 'outgoing@provider.any');
static::assertSame($changed_user->getIncomingEmail(), 'incoming@provider.any');
}
public function testCorrectPassword()
{
$client = static::createClient();
$user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_account_test_user']);
$client->loginUser($user);
$client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
$this->assertResponseIsSuccessful();
$crawler = $client->submitForm('Save new password', [
'save_password[old_password]' => 'foobar',
'save_password[password][first]' => 'this is some test password',
'save_password[password][second]' => 'this is some test password',
]);
$changed_user = DB::findOneBy(LocalUser::class, ['id' => $user->getId()]);
static::assertTrue($changed_user->checkPassword('this is some test password'));
}
public function testAccountWrongPassword()
{
$client = static::createClient();
$user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_account_test_user']);
$client->loginUser($user);
$client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
$this->assertResponseIsSuccessful();
$crawler = $client->submitForm('Save new password', [
'save_password[old_password]' => 'some wrong password',
'save_password[password][first]' => 'this is some test password',
'save_password[password][second]' => 'this is some test password',
]);
$this->assertResponseStatusCodeSame(500); // 401 in future
$this->assertSelectorTextContains('.stacktrace', 'AuthenticationException');
}
// TODO: First actually implement this functionality
// public function testNotifications()
// {
// $client = static::createClient();
// $user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_account_test_user']);
// $client->loginUser($user);
//
// $client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
// $this->assertResponseIsSuccessful();
// $crawler = $client->submitForm('Save notification settings for Email', [
// 'save_email[activity_by_subscribed]' => false,
// 'save_email[mention]' => true,
// 'save_email[reply]' => false,
// 'save_email[subscription]' => true,
// 'save_email[favorite]' => false,
// 'save_email[nudge]' => true,
// 'save_email[dm]' => false,
// 'save_email[enable_posting]' => true,
// ]);
// $settings = DB::findOneBy('user_notification_prefs', ['user_id' => $user->getId(), 'transport' => 'email']);
// static::assertSame($settings->getActivityBySubscribed(), false);
// static::assertSame($settings->getMention(), true);
// static::assertSame($settings->getReply(), false);
// static::assertSame($settings->getSubscription(), true);
// static::assertSame($settings->getFavorite(), false);
// static::assertSame($settings->getNudge(), true);
// static::assertSame($settings->getDm(), false);
// static::assertSame($settings->getEnablePosting(), true);
// }
}

View File

@ -1,69 +0,0 @@
<?php
declare(strict_types = 1);
namespace Component\Posting\Controller;
use App\Core;
use App\Core\Controller;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\VisibilityScope;
use App\Util\Common;
use App\Util\Exception\ClientException;
use Component\Posting\Form;
use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
class Posting extends Controller
{
public function onPost(Request $request): RedirectResponse
{
$actor = Common::actor();
$form = Form\Posting::create($request);
$form->handleRequest($request);
if ($form->isSubmitted()) {
try {
if ($form->isValid()) {
$data = $form->getData();
Event::handle('PostingModifyData', [$request, $actor, &$data, $form]);
if (empty($data['content']) && empty($data['attachments'])) {
// TODO Display error: At least one of `content` and `attachments` must be provided
throw new ClientException(_m('You must enter content or provide at least one attachment to post a note.'));
}
if (\is_null(VisibilityScope::tryFrom($data['visibility']))) {
throw new ClientException(_m('You have selected an impossible visibility.'));
}
$extra_args = [];
Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form]);
if (\array_key_exists('in', $data) && $data['in'] !== 'public') {
$target = $data['in'];
}
\Component\Posting\Posting::storeLocalNote(
actor: $actor,
content: $data['content'],
content_type: $data['content_type'],
locale: $data['language'],
scope: VisibilityScope::from($data['visibility']),
attentions: isset($target) ? [$target] : [],
reply_to: \array_key_exists('reply_to_id', $data) ? $data['reply_to_id'] : null,
attachments: $data['attachments'],
process_note_content_extra_args: $extra_args,
);
return Core\Form::forceRedirect($form, $request);
}
} catch (FormSizeFileException $e) {
throw new ClientException(_m('Invalid file size given.'), previous: $e);
}
}
throw new ClientException(_m('Invalid form submission.'));
}
}

View File

@ -1,103 +0,0 @@
<?php
declare(strict_types = 1);
namespace Component\Posting\Form;
use App\Core\ActorLocalRoles;
use App\Core\Event;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Router;
use App\Core\VisibilityScope;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Form\FormFields;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Constraints\Length;
class Posting
{
public static function create(Request $request)
{
$actor = Common::actor();
$placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
$placeholder = $placeholder_strings[array_rand($placeholder_strings)];
$initial_content = '';
Event::handle('PostingInitialContent', [&$initial_content]);
$available_content_types = [
_m('Plain Text') => 'text/plain',
];
Event::handle('PostingAvailableContentTypes', [&$available_content_types]);
$in_targets = [];
Event::handle('PostingFillTargetChoices', [$request, $actor, &$in_targets]);
$context_actor = null;
Event::handle('PostingGetContextActor', [$request, $actor, &$context_actor]);
$form_params = [];
if (!empty($in_targets)) { // @phpstan-ignore-line
// Add "none" option to the first of choices
$in_targets = array_merge([_m('Public') => 'public'], $in_targets);
// Make the context actor the first In target option
if (!\is_null($context_actor)) {
foreach ($in_targets as $it_nick => $it_id) {
if ($it_id === $context_actor->getId()) {
unset($in_targets[$it_nick]);
$in_targets = array_merge([$it_nick => $it_id], $in_targets);
break;
}
}
}
$form_params[] = ['in', ChoiceType::class, ['label' => _m('In:'), 'multiple' => false, 'expanded' => false, 'choices' => $in_targets]];
}
$visibility_options = [
_m('Public') => VisibilityScope::EVERYWHERE->value,
_m('Local') => VisibilityScope::LOCAL->value,
_m('Addressee') => VisibilityScope::ADDRESSEE->value,
];
if (!\is_null($context_actor) && $context_actor->isGroup()) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234
if ($actor->canModerate($context_actor)) {
if ($context_actor->getRoles() & ActorLocalRoles::PRIVATE_GROUP) {
$visibility_options = array_merge([_m('Group') => VisibilityScope::GROUP->value], $visibility_options);
} else {
$visibility_options[_m('Group')] = VisibilityScope::GROUP->value;
}
}
}
$form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'choices' => $visibility_options]];
$form_params[] = ['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]];
$form_params[] = ['attachments', FileType::class, ['label' => _m('Attachments:'), 'multiple' => true, 'required' => false, 'invalid_message' => _m('Attachment not valid.')]];
$form_params[] = FormFields::language($actor, $context_actor, label: _m('Note language'), help: _m('The selected language will be federated and added as a lang attribute, preferred language can be set up in settings'));
if (\count($available_content_types) > 1) {
$form_params[] = ['content_type', ChoiceType::class,
[
'label' => _m('Text format:'), 'multiple' => false, 'expanded' => false,
'data' => $available_content_types[array_key_first($available_content_types)],
'choices' => $available_content_types,
],
];
} else {
$form_params[] = ['content_type', HiddenType::class, ['data' => $available_content_types[array_key_first($available_content_types)]]];
}
Event::handle('PostingAddFormEntries', [$request, $actor, &$form_params]);
$form_params[] = ['post_note', SubmitType::class, ['label' => _m('Post')]];
return Form::create($form_params, form_options: ['action' => Router::url(\Component\Posting\Posting::route)]);
}
}

View File

@ -23,17 +23,16 @@ declare(strict_types = 1);
namespace Component\Posting; namespace Component\Posting;
use App\Core\Cache; use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\Form;
use App\Core\GSFile; use App\Core\GSFile;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router; use App\Core\Router\Router;
use App\Core\VisibilityScope; use App\Core\VisibilityScope;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\BugFoundException; use App\Util\Exception\BugFoundException;
@ -41,122 +40,150 @@ use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException; use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use App\Util\Form\FormFields;
use App\Util\Formatting; use App\Util\Formatting;
use App\Util\HTML; use App\Util\HTML;
use Component\Attachment\Entity\ActorToAttachment; use Component\Attachment\Entity\ActorToAttachment;
use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentToNote; use Component\Attachment\Entity\AttachmentToNote;
use Component\Conversation\Conversation; use Component\Conversation\Conversation;
use Component\Language\Entity\Language; use Component\Language\Entity\Language;
use Component\Notification\Entity\Attention; use Functional as F;
use EventResult; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Validator\Constraints\Length;
class Posting extends Component class Posting extends Component
{ {
public const route = 'posting_form_action';
public function onAddRoute(Router $r): EventResult
{
$r->connect(self::route, '/form/posting', Controller\Posting::class);
return Event::next;
}
/** /**
* HTML render event handler responsible for adding and handling * HTML render event handler responsible for adding and handling
* the result of adding the note submission form, only if a user is logged in * the result of adding the note submission form, only if a user is logged in
* *
* @param array{post_form?: FormInterface} $res
*
* @throws BugFoundException
* @throws ClientException * @throws ClientException
* @throws DuplicateFoundException
* @throws RedirectException * @throws RedirectException
* @throws ServerException * @throws ServerException
*/ */
public function onAddMainRightPanelBlock(Request $request, array &$res): EventResult public function onAppendRightPostingBlock(Request $request, array &$res): bool
{ {
if (\is_null($user = Common::user()) || preg_match('(feed|conversation|group|view)', $request->get('_route')) === 0) { if (\is_null($user = Common::user())) {
return Event::next; return Event::next;
} }
$res['post_form'] = Form\Posting::create($request)->createView(); $actor = $user->getActor();
return Event::next; $placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
$placeholder = $placeholder_strings[array_rand($placeholder_strings)];
$initial_content = '';
Event::handle('PostingInitialContent', [&$initial_content]);
$available_content_types = [
_m('Plain Text') => 'text/plain',
];
Event::handle('PostingAvailableContentTypes', [&$available_content_types]);
$in_targets = [];
Event::handle('PostingFillTargetChoices', [$request, $actor, &$in_targets]);
$context_actor = null;
Event::handle('PostingGetContextActor', [$request, $actor, &$context_actor]);
$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]];
} }
/** // TODO: if in group page, add GROUP visibility to the choices.
* @param Actor $actor The Actor responsible for the creation of this Note $form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [
* @param null|string $content The raw text content _m('Public') => VisibilityScope::EVERYWHERE->value,
* @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...) _m('Local') => VisibilityScope::LOCAL->value,
* @param null|string $locale Note's written text language, set by the default Actor language or upon filling _m('Addressee') => VisibilityScope::ADDRESSEE->value,
* @param null|VisibilityScope $scope The visibility of this Note ]]];
* @param Actor[]|int[] $attentions Actor|int[]: In Group/To Person or Bot, registers an attention between note and target $form_params[] = ['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]];
* @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself $form_params[] = ['attachments', FileType::class, ['label' => _m('Attachments:'), 'multiple' => true, 'required' => false, 'invalid_message' => _m('Attachment not valid.')]];
* @param UploadedFile[] $attachments UploadedFile[] to be stored as GSFiles associated to this note $form_params[] = FormFields::language($actor, $context_actor, label: _m('Note language'), help: _m('The selected language will be federated and added as a lang attribute, preferred language can be set up in settings'));
* @param array<array{Attachment, string}> $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note
* @param array{note?: Note, content?: string, content_type?: string, extra_args?: array<string, mixed>} $process_note_content_extra_args Extra arguments for the event ProcessNoteContent if (\count($available_content_types) > 1) {
* @param bool $flush_and_notify True if the newly created Note activity should be passed on as a Notification $form_params[] = ['content_type', ChoiceType::class,
* @param null|string $rendered The Note's content post RenderNoteContent event, which sanitizes and processes the raw content sent [
* @param string $source The source of this Note 'label' => _m('Text format:'), 'multiple' => false, 'expanded' => false,
* 'data' => $available_content_types[array_key_first($available_content_types)],
* @throws ClientException 'choices' => $available_content_types,
* @throws DuplicateFoundException ],
* @throws ServerException ];
* }
* @return array{\App\Entity\Activity, \App\Entity\Note, array<int, \App\Entity\Actor>}
*/ Event::handle('PostingAddFormEntries', [$request, $actor, &$form_params]);
public static function storeLocalArticle(
Actor $actor, $form_params[] = ['post_note', SubmitType::class, ['label' => _m('Post')]];
?string $content, $form = Form::create($form_params);
string $content_type,
?string $locale = null, $form->handleRequest($request);
?VisibilityScope $scope = null, if ($form->isSubmitted()) {
array $attentions = [], try {
null|int|Note $reply_to = null, if ($form->isValid()) {
array $attachments = [], $data = $form->getData();
array $processed_attachments = [], Event::handle('PostingModifyData', [$request, $actor, &$data, $form_params, $form]);
array $process_note_content_extra_args = [],
bool $flush_and_notify = true, if (empty($data['content']) && empty($data['attachments'])) {
?string $rendered = null, // TODO Display error: At least one of `content` and `attachments` must be provided
string $source = 'web', throw new ClientException(_m('You must enter content or provide at least one attachment to post a note.'));
?string $title = null, }
): array {
[$activity, $note, $effective_attentions] = self::storeLocalNote( if (\is_null(VisibilityScope::tryFrom($data['visibility']))) {
actor: $actor, throw new ClientException(_m('You have selected an impossible visibility.'));
content: $content, }
$content_type = $data['content_type'] ?? $available_content_types[array_key_first($available_content_types)];
$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, content_type: $content_type,
locale: $locale, locale: $data['language'],
scope: $scope, scope: VisibilityScope::from($data['visibility']),
attentions: $attentions, target: $target ?? null, // @phpstan-ignore-line
reply_to: $reply_to, reply_to_id: $data['reply_to_id'],
attachments: $attachments, attachments: $data['attachments'],
processed_attachments: $processed_attachments, process_note_content_extra_args: $extra_args,
process_note_content_extra_args: $process_note_content_extra_args,
flush_and_notify: false,
rendered: $rendered,
source: $source,
); );
$note->setType('article');
$note->setTitle($title);
if ($flush_and_notify) { try {
// Flush before notification if ($request->query->has('from')) {
DB::flush(); $from = $request->query->get('from');
Event::handle('NewNotification', [ if (str_contains($from, '#')) {
$actor, [$from, $fragment] = explode('#', $from);
$activity, }
$effective_attentions, Router::match($from);
_m('Actor {actor_id} created article {note_id}.', [ throw new RedirectException(url: $from . (isset($fragment) ? '#' . $fragment : ''));
'{actor_id}' => $actor->getId(), }
'{note_id}' => $activity->getObjectId(), } catch (ResourceNotFoundException $e) {
]), // continue
]); }
throw new RedirectException();
}
} catch (FormSizeFileException $e) {
throw new ClientException(_m('Invalid file size given'), previous: $e);
}
} }
return [$activity, $note, $effective_attentions]; $res['post_form'] = $form->createView();
return Event::next;
} }
/** /**
@ -164,25 +191,14 @@ class Posting extends Component
* $actor_id, possibly as a reply to note $reply_to and with flag * $actor_id, possibly as a reply to note $reply_to and with flag
* $is_local. Sanitizes $content and $attachments * $is_local. Sanitizes $content and $attachments
* *
* @param Actor $actor The Actor responsible for the creation of this Note * @param array $attachments Array of UploadedFile to be stored as GSFiles associated to this note
* @param null|string $content The raw text content * @param array $processed_attachments Array of [Attachment, Attachment's name] to be associated to this $actor and Note
* @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...) * @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
* @param null|string $locale Note's written text language, set by the default Actor language or upon filling
* @param null|VisibilityScope $scope The visibility of this Note
* @param Actor[]|int[] $attentions Actor|int[]: In Group/To Person or Bot, registers an attention between note and targte
* @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself
* @param UploadedFile[] $attachments UploadedFile[] to be stored as GSFiles associated to this note
* @param array<array{Attachment, string}> $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note
* @param array{note?: Note, content?: string, content_type?: string, extra_args?: array<string, mixed>} $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
* @param bool $flush_and_notify True if the newly created Note activity should be passed on as a Notification
* @param null|string $rendered The Note's content post RenderNoteContent event, which sanitizes and processes the raw content sent
* @param string $source The source of this Note
* *
* @throws BugFoundException
* @throws ClientException * @throws ClientException
* @throws DuplicateFoundException * @throws DuplicateFoundException
* @throws ServerException * @throws ServerException
*
* @return array{\App\Entity\Activity, \App\Entity\Note, array<int, \App\Entity\Actor>}
*/ */
public static function storeLocalNote( public static function storeLocalNote(
Actor $actor, Actor $actor,
@ -190,19 +206,16 @@ class Posting extends Component
string $content_type, string $content_type,
?string $locale = null, ?string $locale = null,
?VisibilityScope $scope = null, ?VisibilityScope $scope = null,
array $attentions = [], null|Actor|int $target = null,
null|int|Note $reply_to = null, ?int $reply_to_id = null,
array $attachments = [], array $attachments = [],
array $processed_attachments = [], array $processed_attachments = [],
array $process_note_content_extra_args = [], array $process_note_content_extra_args = [],
bool $flush_and_notify = true, bool $notify = true,
?string $rendered = null, ?string $rendered = null,
string $source = 'web', string $source = 'web',
): array { ): Note {
$scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL $scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL
$reply_to_id = \is_null($reply_to) ? null : (\is_int($reply_to) ? $reply_to : $reply_to->getId());
/** @var array<int, array{ mentioned?: array<int, Actor|LocalUser> }> $mentions */
$mentions = []; $mentions = [];
if (\is_null($rendered) && !empty($content)) { if (\is_null($rendered) && !empty($content)) {
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]); Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]);
@ -233,17 +246,6 @@ class Posting extends Component
} }
DB::persist($note); DB::persist($note);
Conversation::assignLocalConversation($note, $reply_to_id);
// Update replies cache
if (!\is_null($reply_to_id)) {
Cache::incr(Note::cacheKeys($reply_to_id)['replies-count']);
// Not having them cached doesn't mean replies don't exist, but don't push it to the
// list, as that means they need to be re-fetched, or some would be missed
if (Cache::exists(Note::cacheKeys($reply_to_id)['replies'])) {
Cache::listPushRight(Note::cacheKeys($reply_to_id)['replies'], $note);
}
}
// Need file and note ids for the next step // Need file and note ids for the next step
$note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL)); $note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL));
@ -251,18 +253,18 @@ class Posting extends Component
Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]); Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]);
} }
// These are note attachments now, and not just attachments, ensure these relations are respected
if ($processed_attachments !== []) { if ($processed_attachments !== []) {
foreach ($processed_attachments as [$a, $fname]) { foreach ($processed_attachments as [$a, $fname]) {
// Most attachments should already be associated with its author, but maybe it didn't make sense if (DB::count('actor_to_attachment', $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) {
//for this attachment, or it's simply a repost of an attachment by a different actor
if (DB::count(ActorToAttachment::class, $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) {
DB::persist(ActorToAttachment::create($args)); DB::persist(ActorToAttachment::create($args));
} }
DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname])); DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname]));
$a->livesIncrementAndGet();
} }
} }
Conversation::assignLocalConversation($note, $reply_to_id);
$activity = Activity::create([ $activity = Activity::create([
'actor_id' => $actor->getId(), 'actor_id' => $actor->getId(),
'verb' => 'create', 'verb' => 'create',
@ -272,60 +274,32 @@ class Posting extends Component
]); ]);
DB::persist($activity); DB::persist($activity);
$effective_attentions = []; if (!\is_null($target)) {
foreach ($attentions as $target) { $target = \is_int($target) ? Actor::getById($target) : $target;
if (\is_int($target)) { $mentions[] = [
$target_id = $target; 'mentioned' => [$target],
$add = !\array_key_exists($target_id, $effective_attentions); 'type' => match ($target->getType()) {
$effective_attentions[$target_id] = $target; Actor::PERSON => 'mention',
} else { Actor::GROUP => 'group',
$target_id = $target->getId(); default => throw new ClientException(_m('Unknown target type give in \'In\' field: {target}', ['{target}' => $target?->getNickname() ?? '<null>'])),
if ($add = !\array_key_exists($target_id, $effective_attentions)) { },
$effective_attentions[$target_id] = $target_id; 'text' => $target->getNickname(),
} ];
}
if ($add) {
DB::persist(Attention::create(['object_type' => Note::schemaName(), 'object_id' => $note->getId(), 'target_id' => $target_id]));
}
} }
foreach ($mentions as $m) { $mention_ids = F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId())));
foreach ($m['mentioned'] ?? [] as $mentioned) {
$target_id = $mentioned->getId();
if (!\array_key_exists($target_id, $effective_attentions)) {
DB::persist(Attention::create(['object_type' => Note::schemaName(), 'object_id' => $note->getId(), 'target_id' => $target_id]));
}
$effective_attentions[$target_id] = $mentioned;
}
}
foreach ($actor->getSubscribers() as $subscriber) {
$target_id = $subscriber->getId();
DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $target_id]));
$effective_attentions[$target_id] = $subscriber;
}
if ($flush_and_notify) {
// Flush before notification // Flush before notification
DB::flush(); DB::flush();
Event::handle('NewNotification', [
$actor, if ($notify) {
$activity, Event::handle('NewNotification', [$actor, $activity, ['object' => $mention_ids], _m('{nickname} created a note {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $activity->getObjectId()])]);
$effective_attentions,
_m('Actor {actor_id} created note {note_id}.', [
'{actor_id}' => $actor->getId(),
'{note_id}' => $activity->getObjectId(),
]),
]);
} }
return [$activity, $note, $effective_attentions]; return $note;
} }
/** public function onRenderNoteContent(string $content, string $content_type, ?string &$rendered, Actor $author, ?string $language = null, array &$mentions = [])
* @param array<int, \App\Entity\Actor> $mentions
*/
public function onRenderNoteContent(string $content, string $content_type, ?string &$rendered, Actor $author, ?string $language = null, array &$mentions = []): EventResult
{ {
switch ($content_type) { switch ($content_type) {
case 'text/plain': case 'text/plain':

View File

@ -3,7 +3,6 @@
declare(strict_types = 1); declare(strict_types = 1);
// {{{ License // {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social // 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 // GNU social is free software: you can redistribute it and/or modify
@ -18,17 +17,25 @@ declare(strict_types = 1);
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}} // }}}
namespace App\Util\Exception; namespace Component\RightPanel;
use function App\Core\I18n\_m; use App\Core\Event;
use App\Core\Modules\Component;
class InvalidRequestException extends ClientException class RightPanel extends Component
{ {
public function __construct() /**
* Output our dedicated stylesheet
*
* @param array $styles stylesheets path
*
* @return bool hook value; true means continue processing, false means stop
*/
public function onEndShowStyles(array &$styles, string $route): bool
{ {
parent::__construct(_m('Invalid request')); $styles[] = 'components/RightPanel/assets/css/view.css';
return Event::next;
} }
} }

View File

@ -1,97 +1,65 @@
{% macro posting(form) %} {% block rightpanel %}
<section class="section-form"> <label class="panel-right-icon" for="toggle-panel-right" tabindex="-1">{{ icon('chevron-left', 'icon icon-right') | raw }}</label>
{{ form_start(form) }} <a id="anchor-right-panel" class="anchor-hidden" tabindex="0" title="{{ 'Press tab followed by a space to access right panel' | trans }}"></a>
{{ form_errors(form) }} <input type="checkbox" id="toggle-panel-right" tabindex="0" title="{{ 'Open right panel' | trans }}">
{% if form.in is defined %}
{{ form_row(form.in) }}
{% endif %}
{{ form_row(form.visibility) }}
{{ form_row(form.content_type) }}
{{ form_row(form.content) }}
{{ form_row(form.attachments) }}
<details class="section-details-subtitle frame-section"> <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 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>
<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-details-subtitle">
<summary class="details-summary-subtitle"> <summary class="details-summary-subtitle">
<strong> <strong>
{% trans %}Additional options{% endtrans %} {{ "Additional options" | trans }}
</strong> </strong>
</summary> </summary>
<section class="section-form"> <section class="section-form">
{{ form_row(form.language) }} {{ form_row(blocks['post_form'].language) }}
{{ form_row(form.tag_use_canonical) }} {{ form_row(blocks['post_form'].tag_use_canonical) }}
</section> </section>
</details> </details>
{{ form_rest(form) }} {{ form_rest(blocks['post_form']) }}
{{ form_end(form) }} {{ form_end(blocks['post_form']) }}
</section> </section>
{% endmacro %}
{% macro posting_section_vanilla(widget) %}
<section class="frame-section" title="{% trans %}Create a new note{% endtrans %}">
<details class="section-details-title" open="open"
title="{% trans %}Expand if you want to access more options{% endtrans %}">
<summary class="details-summary-title">
<span>
{% trans %}Create a note{% endtrans %}
</span>
</summary>
{% import _self as forms %}
{{ forms.posting(widget) }}
</details> </details>
</section> </section>
{% endmacro %} {% endif %}
{% macro posting_section_reply(widget, extra) %} {% set extra_blocks = get_right_panel_blocks({'path': current_path, 'request': app.request, 'vars': (right_panel_vars | default)}) %}
<section class="frame-section" title="{% trans %}Create a new note{% endtrans %}"> {% for block in extra_blocks %}
<details class="section-details-title" open="open"
title="{% trans %}Expand if you want to access more options{% endtrans %}">
<summary class="details-summary-title">
<span>
{% trans %}Reply to note{% endtrans %}
</span>
</summary>
{% for block in extra %}
<section class="posting-extra">
{{ block | raw }} {{ block | raw }}
</section>
{% endfor %}
{% import _self as forms %}
{{ forms.posting(widget) }}
</details>
</section>
{% endmacro %}
{% block rightpanel %}
{% import _self as this %}
<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="{% trans %}Press tab followed by a space to access right panel{% endtrans %}"></a>
<input type="checkbox" id="toggle-panel-right" tabindex="0" title="{% trans %}Open right panel{% endtrans %}"
{% if app.request.get('_route') == 'conversation_reply_to' %}checked{% endif %}>
<aside class="section-panel section-panel-right">
{% set var_list = {'path': app.request.get('_route'), 'request': app.request, 'vars': right_panel_vars | default } %}
{% set blocks = add_right_panel_block('prepend', var_list) %}
{% set blocks = blocks|merge(add_right_panel_block('main', var_list)) %}
{% set blocks = blocks|merge(add_right_panel_block('append', var_list)) %}
<section class="panel-content accessibility-target">
{% for widget in blocks %}
{% if widget is iterable and widget.vars.id == 'post_note' %}
{% if app.request.get('_route') == 'conversation_reply_to' %}
{% set extra = handle_event('PrependPostingForm', request) %}
{{ this.posting_section_reply(widget, extra) }}
{% else %}
{{ this.posting_section_vanilla(widget) }}
{% endif %}
{% else %}
{{ widget | raw }}
{% endif %}
{% endfor %} {% endfor %}
</section> </section>
</aside> </aside>
{% endblock rightpanel %} {% endblock rightpanel %}

View File

@ -30,7 +30,6 @@ use App\Util\Exception\BugFoundException;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use App\Util\Form\FormFields; use App\Util\Form\FormFields;
use App\Util\Formatting; use App\Util\Formatting;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController; use Component\Collection\Util\Controller\FeedController;
use Component\Search as Comp; use Component\Search as Comp;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@ -38,17 +37,12 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
/**
* @extends FeedController<\App\Entity\Note>
*/
class Search extends FeedController class Search extends FeedController
{ {
/** /**
* Handle a search query * Handle a search query
*
* @return ControllerResultType
*/ */
public function handle(Request $request): array public function handle(Request $request)
{ {
$actor = Common::actor(); $actor = Common::actor();
$language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null; $language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null;
@ -141,8 +135,6 @@ class Search extends FeedController
'search_form' => Comp\Search::searchForm($request, query: $q, add_subscribe: !\is_null($actor)), 'search_form' => Comp\Search::searchForm($request, query: $q, add_subscribe: !\is_null($actor)),
'search_builder_form' => $search_builder_form->createView(), 'search_builder_form' => $search_builder_form->createView(),
'notes' => $notes ?? [], 'notes' => $notes ?? [],
'notes_feed_title' => (new Heading(level: 3, classes: ['section-title'], text: 'Notes found')),
'actors_feed_title' => (new Heading(level: 3, classes: ['section-title'], text: 'Actors found')),
'actors' => $actors ?? [], 'actors' => $actors ?? [],
'page' => 1, // TODO paginate 'page' => 1, // TODO paginate
]; ];

View File

@ -27,12 +27,9 @@ use App\Core\Event;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use App\Util\Formatting; use App\Util\Formatting;
use EventResult;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormView; use Symfony\Component\Form\FormView;
@ -41,10 +38,9 @@ use Symfony\Component\HttpFoundation\Request;
class Search extends Component class Search extends Component
{ {
public function onAddRoute(Router $r): EventResult public function onAddRoute($r)
{ {
$r->connect('search', '/search', Controller\Search::class); $r->connect('search', '/search', Controller\Search::class);
return EventResult::next;
} }
/** /**
@ -67,22 +63,13 @@ class Search extends Component
if ($add_subscribe) { if ($add_subscribe) {
$form_definition[] = [ $form_definition[] = [
'title', TextType::class, 'title', TextType::class, ['label' => _m('Title'), 'required' => false, 'attr' => ['title' => _m('Title for this new feed in your left panel')]],
[
'label' => _m('Subscribe to search query'),
'help' => _m('By subscribing to a search query, a new feed link will be added to left panel\'s feed navigation menu'),
'required' => false,
'attr' => [
'title' => _m('Title for this new feed in your left panel'),
'placeholder' => _m('Input desired title...'),
],
],
]; ];
$form_definition[] = [ $form_definition[] = [
'subscribe_to_search', 'subscribe_to_search',
SubmitType::class, SubmitType::class,
[ [
'label' => _m('Subscribe'), 'label' => _m('Subscribe to this search'),
'attr' => [ 'attr' => [
'title' => _m('Add this search as a feed in your feeds section of the left panel'), 'title' => _m('Add this search as a feed in your feeds section of the left panel'),
], ],
@ -113,11 +100,8 @@ class Search extends Component
/** @var SubmitButton $subscribe */ /** @var SubmitButton $subscribe */
$subscribe = $form->get('subscribe_to_search'); $subscribe = $form->get('subscribe_to_search');
if ($subscribe->isClicked()) { if ($subscribe->isClicked()) {
if (!\is_null($data['title'])) { // TODO ensure title is set
Event::handle('AppendFeed', [$actor, $data['title'], 'search', ['q' => $data['search_query']]]); Event::handle('AppendFeed', [$actor, $data['title'], 'search', ['q' => $data['search_query']]]);
} else {
throw new ClientException(_m('Empty title is not allowed.'));
}
$redirect = true; $redirect = true;
} }
} }
@ -134,11 +118,9 @@ class Search extends Component
/** /**
* Add the search form to the site header * Add the search form to the site header
* *
* @param string[] $elements
*
* @throws RedirectException * @throws RedirectException
*/ */
public function onPrependRightPanelBlock(Request $request, array &$elements): EventResult public function onPrependRightPanel(Request $request, array &$elements)
{ {
$elements[] = Formatting::twigRenderFile('cards/search/view.html.twig', ['search' => self::searchForm($request)]); $elements[] = Formatting::twigRenderFile('cards/search/view.html.twig', ['search' => self::searchForm($request)]);
return Event::next; return Event::next;
@ -147,9 +129,11 @@ class Search extends Component
/** /**
* Output our dedicated stylesheet * Output our dedicated stylesheet
* *
* @param string[] $styles stylesheets path * @param array $styles stylesheets path
*
* @return bool hook value; true means continue processing, false means stop
*/ */
public function onEndShowStyles(array &$styles, string $route): EventResult public function onEndShowStyles(array &$styles, string $route): bool
{ {
$styles[] = 'components/Search/assets/css/view.css'; $styles[] = 'components/Search/assets/css/view.css';
return Event::next; return Event::next;

View File

@ -1,4 +1,4 @@
<section class="section-form form-search" title="{% trans %}Search for notes, actors, and beyond{% endtrans %}"> <section class="section-form form-search" title="{{ 'Search for notes, actors, and beyond' | trans }}">
{{ form_start(search) }} {{ form_start(search) }}
<span>{{ form_row(search.search_query) }}{{ form_row(search.submit_search) }}</span> <span>{{ form_row(search.search_query) }}{{ form_row(search.submit_search) }}</span>
{{ form_rest(search) }} {{ form_rest(search) }}

View File

@ -1,45 +1,60 @@
{% extends 'collection/notes.html.twig' %} {% extends 'collection/notes.html.twig' %}
{% block search_query_simple %}
<section> {% block body %}
<h1 class="section-title">{% trans %}Search{% endtrans %}</h1> {% 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) }} {{ form_start(search_form) }}
<section class="frame-section section-form">
{{ form_errors(search_form) }} {{ form_errors(search_form) }}
{{ form_row(search_form.search_query) }} {{ form_row(search_form.search_query) }}
{% if actor is not null %} {% if actor is not null %}
<details class="section-details-subtitle frame-section"> <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"> <summary class="details-summary-subtitle">
<strong> <strong>
{% trans %}Extra options{% endtrans %} {% trans %}Save query as a feed{% endtrans %}
</strong> </strong>
</summary> </summary>
<div class="section-form"> <div class="section-form">
{{ form_row(search_form.title) }} {{ form_row(search_form.title) }}
{{ form_row(search_form.subscribe_to_search) }} {{ form_row(search_form.subscribe_to_search) }}
</div> </div>
<hr>
</details>
</div>
</details> </details>
{% endif %} {% endif %}
{{ form_row(search_form.submit_search) }} {{ form_row(search_form.submit_search) }}
{{ form_rest(search_form) }} </section>
{{ form_end(search_form)}} {{ form_end(search_form)}}
</section>
{% endblock search_query_simple %}
{% block search_query_advanced %} <section class="frame-section">
{{ form_start(search_builder_form) }} <details class="section-details-subtitle">
<details class="section-details section-details-title frame-section"> <summary class="details-summary-subtitle">
<summary class="details-summary-title"> <strong>{% trans %}Build a search query{% endtrans %}</strong>
<span>{% trans %}Advanced search{% endtrans %}</span>
</summary> </summary>
<section class="frame-section-padding"> {{ form_start(search_builder_form) }}
<details class="section-details-subtitle frame-section"> <div class="section-form">
{# actor options, display if first checked, with checkbox trick #}
<details class="section-details-subtitle">
<summary class="details-summary-subtitle"> <summary class="details-summary-subtitle">
<strong>{% trans %}People search options{% endtrans %}</strong> <strong>{% trans %}People search options{% endtrans %}</strong>
</summary> </summary>
<div class="section-form"> <div class="section-form">
<div class="section-checkbox-flex">
{{ form_row(search_builder_form.include_actors) }} {{ form_row(search_builder_form.include_actors) }}
{{ form_row(search_builder_form.include_actors_people) }} {{ form_row(search_builder_form.include_actors_people) }}
{{ form_row(search_builder_form.include_actors_groups) }} {{ form_row(search_builder_form.include_actors_groups) }}
@ -47,84 +62,58 @@
{{ form_row(search_builder_form.include_actors_businesses) }} {{ form_row(search_builder_form.include_actors_businesses) }}
{{ form_row(search_builder_form.include_actors_organizations) }} {{ form_row(search_builder_form.include_actors_organizations) }}
{{ form_row(search_builder_form.include_actors_bots) }} {{ form_row(search_builder_form.include_actors_bots) }}
</div>
<hr>
{{ form_row(search_builder_form.actor_tags) }}
<hr>
{{ form_row(search_builder_form.actor_langs) }} {{ form_row(search_builder_form.actor_langs) }}
{{ form_row(search_builder_form.actor_tags) }}
</div> </div>
<hr>
</details> </details>
<details class="section-details-subtitle frame-section"> <details class="section-details-subtitle">
<summary class="details-summary-subtitle"> <summary class="details-summary-subtitle">
<strong>{% trans %}Note search options{% endtrans %}</strong> <strong>{% trans %}Note search options{% endtrans %}</strong>
</summary> </summary>
<div class="section-form"> <div class="section-form">
<div class="section-checkbox-flex">
{{ form_row(search_builder_form.include_notes) }} {{ form_row(search_builder_form.include_notes) }}
{{ form_row(search_builder_form.include_notes_text) }} {{ form_row(search_builder_form.include_notes_text) }}
{{ form_row(search_builder_form.include_notes_media) }} {{ form_row(search_builder_form.include_notes_media) }}
{{ form_row(search_builder_form.include_notes_polls) }} {{ form_row(search_builder_form.include_notes_polls) }}
{{ form_row(search_builder_form.include_notes_bookmarks) }} {{ form_row(search_builder_form.include_notes_bookmarks) }}
</div>
<hr>
{{ form_row(search_builder_form.note_tags) }}
<hr>
{{ form_row(search_builder_form.note_langs) }} {{ form_row(search_builder_form.note_langs) }}
<hr> {{ form_row(search_builder_form.note_tags) }}
{{ form_row(search_builder_form.note_actor_tags) }}
<hr>
{{ form_row(search_builder_form.note_actor_langs) }} {{ form_row(search_builder_form.note_actor_langs) }}
{{ form_row(search_builder_form.note_actor_tags) }}
</div> </div>
</details>
{{ form_rest(search_builder_form) }}
</section>
</details>
{{ form_end(search_builder_form) }}
{% endblock search_query_advanced %}
{% block search %}
<section class="frame-section frame-section-padding">
{% if error is defined %}
<label class="alert alert-danger">
{{ error.getMessage() }}
</label>
{% endif %}
{{ block('search_query_simple') }}
<hr> <hr>
{{ block('search_query_advanced') }} </details>
</div>
{{ form_end(search_builder_form) }}
</details>
</section>
</section> </section>
{% endblock search %}
{% block body %} <section class="frame-section frame-section-padding">
{{ block('search') }} <h2>{% trans %}Results{% endtrans %}</h2>
<div class="frame-section frame-section-padding"> <div class="frame-section frame-section-padding feed-empty">
<h1 class="section-title">{% trans %}Results{% endtrans %}</h1>
<section>
{% if notes is defined and notes is not empty %} {% if notes is defined and notes is not empty %}
{{ parent() }} {{ parent() }}
{% else %} {% else %}
<h3>{% trans %}No notes found{% endtrans %}</h3> <h3>{% trans %}No notes found{% endtrans %}</h3>
<em>{% trans %}No notes were found for the specified query...{% endtrans %}</em> <em>{% trans %}No notes were found for the specified query...{% endtrans %}</em>
{% endif %} {% endif %}
</section> </div>
<hr>
<section> <div class="frame-section frame-section-padding feed-empty">
<h3>{% trans %}Actors found{% endtrans %}</h3> <h3>{% trans %}Actors found{% endtrans %}</h3>
{% if actors is defined and actors is not empty %} {% if actors is defined and actors is not empty %}
{% for actor in actors %} {% for actor in actors %}
{% include 'cards/blocks/profile.html.twig' with {'actor': actor} %} {% include 'cards/profile/view.html.twig' with {'actor': actor} %}
{% endfor %} {% endfor %}
{% else %} {% else %}
<em>{% trans %}No Actors were found for the specified query...{% endtrans %}</em> <em>{% trans %}No Actors were found for the specified query...{% endtrans %}</em>
{% endif %} {% endif %}
</div>
</section> </section>
<div class="frame-section-button-like">
{{ "Page: " ~ page }} {{ "Page: " ~ page }}
</div>
</div>
{% endblock body %} {% endblock body %}

View File

@ -23,16 +23,16 @@ declare(strict_types = 1);
namespace Component\Subscription\Controller; namespace Component\Subscription\Controller;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException; use Component\Collection\Util\ActorControllerTrait;
use Component\Collection\Util\Controller\CircleController; use Component\Collection\Util\Controller\CircleController;
use Component\Subscription\Subscription as SubscriptionComponent; use Component\Subscription\Subscription as SubscriptionComponent;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@ -43,36 +43,30 @@ use Symfony\Component\HttpFoundation\Request;
*/ */
class Subscribers extends CircleController class Subscribers extends CircleController
{ {
/** use ActorControllerTrait;
* @throws ServerException public function subscribersByActorId(Request $request, int $id)
*
* @return ControllerResultType
*/
public function subscribersByActor(Request $request, Actor $actor): array
{ {
return [ return $this->handleActorById(
$id,
fn ($actor) => [
'actor' => $actor,
],
);
}
public function subscribersByActorNickname(Request $request, string $nickname)
{
return $this->handleActorByNickname(
$nickname,
fn ($actor) => [
'_template' => 'collection/actors.html.twig', '_template' => 'collection/actors.html.twig',
'title' => _m('Subscribers'), 'title' => _m('Subscribers'),
'empty_message' => _m('No subscribers.'), 'empty_message' => _m('No subscribers.'),
'sort_form_fields' => [], 'sort_form_fields' => [],
'page' => $this->int('page') ?? 1, 'page' => $this->int('page') ?? 1,
'actors' => $actor->getSubscribers(), 'actors' => $actor->getSubscribers(),
]; ],
} );
/**
* @throws ClientException
* @throws ServerException
*
* @return ControllerResultType
*/
public function subscribersByActorId(Request $request, int $id): array
{
$actor = Actor::getById($id);
if (\is_null($actor)) {
throw new ClientException(_m('No such actor.'), 404);
}
return $this->subscribersByActor($request, $actor);
} }
/** /**
@ -82,8 +76,6 @@ class Subscribers extends CircleController
* @throws \App\Util\Exception\ServerException * @throws \App\Util\Exception\ServerException
* @throws ClientException * @throws ClientException
* @throws RedirectException * @throws RedirectException
*
* @return ControllerResultType
*/ */
public function subscribersAdd(Request $request, int $object_id): array public function subscribersAdd(Request $request, int $object_id): array
{ {
@ -132,8 +124,6 @@ class Subscribers extends CircleController
* @throws \App\Util\Exception\ServerException * @throws \App\Util\Exception\ServerException
* @throws ClientException * @throws ClientException
* @throws RedirectException * @throws RedirectException
*
* @return ControllerResultType
*/ */
public function subscribersRemove(Request $request, int $object_id): array public function subscribersRemove(Request $request, int $object_id): array
{ {

View File

@ -24,9 +24,7 @@ declare(strict_types = 1);
namespace Component\Subscription\Controller; namespace Component\Subscription\Controller;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Entity\Actor; use Component\Collection\Util\ActorControllerTrait;
use App\Util\Exception\ClientException;
use App\Util\Exception\ServerException;
use Component\Collection\Util\Controller\CircleController; use Component\Collection\Util\Controller\CircleController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -35,33 +33,29 @@ use Symfony\Component\HttpFoundation\Request;
*/ */
class Subscriptions extends CircleController class Subscriptions extends CircleController
{ {
/** use ActorControllerTrait;
* @throws ClientException public function subscriptionsByActorId(Request $request, int $id)
* @throws ServerException
*
* @return ControllerResultType
*/
public function subscriptionsByActorId(Request $request, int $id): array
{ {
$actor = Actor::getById($id); return $this->handleActorById(
if (\is_null($actor)) { $id,
throw new ClientException(_m('No such actor.'), 404); fn ($actor) => [
} 'actor' => $actor,
return $this->subscriptionsByActor($request, $actor); ],
);
} }
/** public function subscriptionsByActorNickname(Request $request, string $nickname)
* @return ControllerResultType
*/
public function subscriptionsByActor(Request $request, Actor $actor): array
{ {
return [ return $this->handleActorByNickname(
$nickname,
fn ($actor) => [
'_template' => 'collection/actors.html.twig', '_template' => 'collection/actors.html.twig',
'title' => _m('Subscriptions'), 'title' => _m('Subscriptions'),
'empty_message' => _m('Haven\'t subscribed anyone.'), 'empty_message' => _m('Haven\'t subscribed anyone.'),
'sort_form_fields' => [], 'sort_form_fields' => [],
'page' => $this->int('page') ?? 1, 'page' => $this->int('page') ?? 1,
'actors' => $actor->getSubscribers(), 'actors' => $actor->getSubscribers(),
]; ],
);
} }
} }

View File

@ -114,6 +114,27 @@ class ActorSubscription 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 public static function schemaDef(): array
{ {
return [ return [

View File

@ -24,11 +24,12 @@ declare(strict_types = 1);
namespace Component\Subscription; namespace Component\Subscription;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router; use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\LocalUser; use App\Entity\LocalUser;
@ -36,20 +37,22 @@ use App\Util\Common;
use App\Util\Exception\DuplicateFoundException; use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\NotFoundException; use App\Util\Exception\NotFoundException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use Component\Notification\Entity\Attention; use App\Util\Nickname;
use Component\Subscription\Controller\Subscribers as SubscribersController; use Component\Subscription\Controller\Subscribers as SubscribersController;
use Component\Subscription\Controller\Subscriptions as SubscriptionsController; use Component\Subscription\Controller\Subscriptions as SubscriptionsController;
use EventResult;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Subscription extends Component class Subscription extends Component
{ {
public function onAddRoute(Router $r): EventResult 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_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_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_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_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; return Event::next;
} }
@ -58,8 +61,6 @@ class Subscription extends Component
* *
* @param Actor|int|LocalUser $subject The Actor who subscribed or unsubscribed * @param Actor|int|LocalUser $subject The Actor who subscribed or unsubscribed
* @param Actor|int|LocalUser $object The Actor who was subscribed or unsubscribed from * @param Actor|int|LocalUser $object The Actor who was subscribed or unsubscribed from
*
* @return array{bool, bool}
*/ */
public static function refreshSubscriptionCount(int|Actor|LocalUser $subject, int|Actor|LocalUser $object): array public static function refreshSubscriptionCount(int|Actor|LocalUser $subject, int|Actor|LocalUser $object): array
{ {
@ -69,7 +70,7 @@ class Subscription extends Component
$cache_subscriber = Cache::delete(Actor::cacheKeys($subscriber_id)['subscribed']); $cache_subscriber = Cache::delete(Actor::cacheKeys($subscriber_id)['subscribed']);
$cache_subscribed = Cache::delete(Actor::cacheKeys($subscribed_id)['subscribers']); $cache_subscribed = Cache::delete(Actor::cacheKeys($subscribed_id)['subscribers']);
return [$cache_subscriber, $cache_subscribed]; return [$cache_subscriber,$cache_subscribed];
} }
/** /**
@ -99,24 +100,22 @@ class Subscription extends Component
$subscription = DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true); $subscription = DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true);
$activity = null; $activity = null;
if (\is_null($subscription)) { if (\is_null($subscription)) {
DB::persist($subscription = Entity\ActorSubscription::create($opts)); DB::persist(Entity\ActorSubscription::create($opts));
$activity = Activity::create([ $activity = Activity::create([
'actor_id' => $subscriber_id, 'actor_id' => $subscriber_id,
'verb' => 'subscribe', 'verb' => 'subscribe',
'object_type' => Actor::schemaName(), 'object_type' => 'actor',
'object_id' => $subscribed_id, 'object_id' => $subscribed_id,
'source' => $source, 'source' => $source,
]); ]);
DB::persist($activity); DB::persist($activity);
DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $subscribed_id]));
Event::handle('NewNotification', [ Event::handle('NewNotification', [
\is_int($subject) ? $subject : Actor::getById($subscriber_id), \is_int($subject) ? $subject : Actor::getById($subscriber_id),
$activity, $activity,
[$subscribed_id], ['object' => [$activity->getObjectId()]],
$reason = _m('{subject} subscribed to {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $activity->getObjectId()]), _m('{subject} subscribed to {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $activity->getObjectId()]),
]); ]);
Event::handle('NewSubscriptionEnd', [$subject, $activity, $object, $reason]);
} }
return $activity; return $activity;
} }
@ -150,22 +149,21 @@ class Subscription extends Component
if (!\is_null($subscription)) { if (!\is_null($subscription)) {
// Remove Subscription // Remove Subscription
DB::remove($subscription); DB::remove($subscription);
$previous_follow_activity = DB::findBy(Activity::class, ['verb' => 'subscribe', 'object_type' => Actor::schemaName(), 'object_id' => $subscribed_id], order_by: ['created' => 'DESC'])[0]; $previous_follow_activity = DB::findBy('activity', ['verb' => 'subscribe', 'object_type' => 'actor', 'object_id' => $subscribed_id], order_by: ['created' => 'DESC'])[0];
// Store Activity // Store Activity
$activity = Activity::create([ $activity = Activity::create([
'actor_id' => $subscriber_id, 'actor_id' => $subscriber_id,
'verb' => 'undo', 'verb' => 'undo',
'object_type' => Activity::schemaName(), 'object_type' => 'activity',
'object_id' => $previous_follow_activity->getId(), 'object_id' => $previous_follow_activity->getId(),
'source' => $source, 'source' => $source,
]); ]);
DB::persist($activity); DB::persist($activity);
DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $subscribed_id]));
Event::handle('NewNotification', [ Event::handle('NewNotification', [
\is_int($subject) ? $subject : Actor::getById($subscriber_id), \is_int($subject) ? $subject : Actor::getById($subscriber_id),
$activity, $activity,
[$subscribed_id], ['object' => [$previous_follow_activity->getObjectId()]],
_m('{subject} unsubscribed from {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $previous_follow_activity->getObjectId()]), _m('{subject} unsubscribed from {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $previous_follow_activity->getObjectId()]),
]); ]);
} }
@ -180,15 +178,16 @@ class Subscription extends Component
* **unsubscribe** a given **Actor**. * **unsubscribe** a given **Actor**.
* *
* @param Actor $object The Actor on which the action is to be performed * @param Actor $object The Actor on which the action is to be performed
* @param array<array{url: string, title: string, classes: string, id: string}> $actions * @param array $actions An array containing all actions added to the
* An array containing all actions added to the
* current profile, this event adds an action to it * current profile, this event adds an action to it
* *
* @throws DuplicateFoundException * @throws DuplicateFoundException
* @throws NotFoundException * @throws NotFoundException
* @throws ServerException * @throws ServerException
*
* @return bool EventHook
*/ */
public function onAddProfileActions(Request $request, Actor $object, array &$actions): EventResult public function onAddProfileActions(Request $request, Actor $object, array &$actions): bool
{ {
// Action requires a user to be logged in // Action requires a user to be logged in
// We know it's a LocalUser, which has the same id as Actor // We know it's a LocalUser, which has the same id as Actor

View File

@ -2,7 +2,7 @@
{% block body %} {% block body %}
{% block profile_view %} {% block profile_view %}
{% include 'cards/blocks/profile.html.twig' with { actor: object } %} {% include 'cards/profile/view.html.twig' with { actor: object } %}
{% endblock profile_view %} {% endblock profile_view %}
{{ form(form) }} {{ form(form) }}
{% endblock body %} {% endblock body %}

View File

@ -2,7 +2,7 @@
{% block body %} {% block body %}
{% block profile_view %} {% block profile_view %}
{% include 'cards/blocks/profile.html.twig' with { actor: object } %} {% include 'cards/profile/view.html.twig' with { actor: object } %}
{% endblock profile_view %} {% endblock profile_view %}
{{ form(form) }} {{ form(form) }}
{% endblock body %} {% endblock body %}

View File

@ -15,12 +15,7 @@ class Tag extends Controller
// TODO: Use Feed::query // TODO: Use Feed::query
// TODO: If ?canonical=something, respect // TODO: If ?canonical=something, respect
// TODO: Allow to set locale of tag being selected // 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)
* @param (null|string|string[]) $tag_single_or_multi
*
* @return ControllerResultType
*/
private function process(null|string|array $tag_single_or_multi, string $key, string $query, string $template, bool $include_locale = false): array
{ {
$actor = Common::actor(); $actor = Common::actor();
$page = $this->int('page') ?: 1; $page = $this->int('page') ?: 1;
@ -51,10 +46,7 @@ class Tag extends Controller
]; ];
} }
/** public function single_note_tag(string $tag)
* @return ControllerResultType
*/
public function single_note_tag(string $tag): array
{ {
return $this->process( return $this->process(
tag_single_or_multi: $tag, tag_single_or_multi: $tag,
@ -65,10 +57,7 @@ class Tag extends Controller
); );
} }
/** public function multi_note_tags(string $tags)
* @return ControllerResultType
*/
public function multi_note_tags(string $tags): array
{ {
return $this->process( return $this->process(
tag_single_or_multi: explode(',', $tags), tag_single_or_multi: explode(',', $tags),

View File

@ -22,9 +22,9 @@ declare(strict_types = 1);
namespace Component\Tag\Entity; namespace Component\Tag\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note; use App\Entity\Note;
use Component\Language\Entity\Language; use Component\Language\Entity\Language;
@ -50,24 +50,13 @@ class NoteTag extends Entity
{ {
// {{{ Autocode // {{{ Autocode
// @codeCoverageIgnoreStart // @codeCoverageIgnoreStart
private int $note_id;
private string $tag; private string $tag;
private string $canonical; private string $canonical;
private int $note_id;
private bool $use_canonical; private bool $use_canonical;
private ?int $language_id = null; private ?int $language_id = null;
private DateTimeInterface $created; private DateTimeInterface $created;
public function setNoteId(int $note_id): self
{
$this->note_id = $note_id;
return $this;
}
public function getNoteId(): int
{
return $this->note_id;
}
public function setTag(string $tag): self public function setTag(string $tag): self
{ {
$this->tag = mb_substr($tag, 0, 64); $this->tag = mb_substr($tag, 0, 64);
@ -90,6 +79,17 @@ class NoteTag extends Entity
return $this->canonical; return $this->canonical;
} }
public function setNoteId(int $note_id): self
{
$this->note_id = $note_id;
return $this;
}
public function getNoteId(): int
{
return $this->note_id;
}
public function setUseCanonical(bool $use_canonical): self public function setUseCanonical(bool $use_canonical): self
{ {
$this->use_canonical = $use_canonical; $this->use_canonical = $use_canonical;
@ -134,9 +134,6 @@ class NoteTag extends Entity
return "note-tags-{$note_id}"; return "note-tags-{$note_id}";
} }
/**
* @return NoteTag[]
*/
public static function getByNoteId(int $note_id): array public static function getByNoteId(int $note_id): array
{ {
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])); 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]));

View File

@ -22,7 +22,7 @@ declare(strict_types = 1);
namespace Component\Tag\Entity; namespace Component\Tag\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Entity; use App\Core\Entity;
use Component\Tag\Tag; use Component\Tag\Tag;
use DateTimeInterface; use DateTimeInterface;
@ -119,8 +119,6 @@ class NoteTagBlock extends Entity
/** /**
* Check whether $note_tag is considered blocked by one of * Check whether $note_tag is considered blocked by one of
* $note_tag_blocks * $note_tag_blocks
*
* @param NoteTagBlock[] $note_tag_blocks
*/ */
public static function checkBlocksNoteTag(NoteTag $note_tag, array $note_tag_blocks): bool public static function checkBlocksNoteTag(NoteTag $note_tag, array $note_tag_blocks): bool
{ {

View File

@ -24,11 +24,11 @@ declare(strict_types = 1);
namespace Component\Tag; namespace Component\Tag;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB; use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router; use App\Core\Router\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
@ -42,7 +42,6 @@ use Component\Tag\Entity\NoteTag;
use Doctrine\Common\Collections\ExpressionBuilder; use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr; use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use EventResult;
use Functional as F; use Functional as F;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -61,70 +60,17 @@ class Tag extends Component
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_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 const TAG_SLUG_REGEX = '[A-Za-z0-9]{1,64}';
public function onAddRoute(Router $r): EventResult public function onAddRoute($r): bool
{ {
$r->connect('single_note_tag', '/note-tag/{tag<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_note_tag']); $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']); $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; return Event::next;
} }
/**
* @param array{tag_use_canonical?: bool} $extra_args
*/
public static function maybeCreateTag(string $tag, int $note_id, ?int $lang_id, array $extra_args = []): ?NoteTag
{
if (!self::validate($tag)) {
return null; // Ignore invalid tag candidates
}
$canonical_tag = self::canonicalTag($tag, \is_null($lang_id) ? null : Language::getById($lang_id)->getLocale());
DB::persist($note_tag = NoteTag::create([
'tag' => $tag,
'canonical' => $canonical_tag,
'note_id' => $note_id,
'use_canonical' => $extra_args['tag_use_canonical'] ?? false,
'language_id' => $lang_id,
]));
foreach (self::cacheKeys($canonical_tag) as $key) {
Cache::delete($key);
}
return $note_tag;
}
/**
* @return NoteTag[]
*/
public static function getNoteTags(int $actor_id, ?string $note_type): array
{
$query = <<<'EOF'
select nt from \App\Entity\Note n
join \Component\Tag\Entity\NoteTag nt with n.id = nt.note_id
where n.actor_id = :id
EOF;
if (\is_null($note_type)) {
return Cache::getList(
Actor::cacheKeys($actor_id, 'any')['note-tags'],
fn () => DB::dql(
$query,
['id' => $actor_id],
),
);
} else {
return Cache::getList(
Actor::cacheKeys($actor_id, $note_type)['note-tags'],
fn () => DB::dql(
$query . ' and n.type = :type',
['id' => $actor_id, 'type' => $note_type],
),
);
}
}
/** /**
* Process note by extracting any tags present * Process note by extracting any tags present
*
* @param array{TagProcessed?: bool} $extra_args
*/ */
public function onProcessNoteContent(Note $note, string $content, string $content_type, array $extra_args): EventResult public function onProcessNoteContent(Note $note, string $content, string $content_type, array $extra_args): bool
{ {
if ($extra_args['TagProcessed'] ?? false) { if ($extra_args['TagProcessed'] ?? false) {
return Event::next; return Event::next;
@ -136,12 +82,26 @@ class Tag extends Component
$matched_tags = array_unique(F\map($matched_tags, fn ($m) => $m[2])); $matched_tags = array_unique(F\map($matched_tags, fn ($m) => $m[2]));
foreach ($matched_tags as $match) { foreach ($matched_tags as $match) {
$tag = self::extract($match); $tag = self::extract($match);
self::maybeCreateTag(tag: $tag, note_id: $note->getId(), lang_id: $note->getLanguageId()); 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,
'canonical' => $canonical_tag,
'note_id' => $note->getId(),
'use_canonical' => $extra_args['tag_use_canonical'] ?? false,
'language_id' => $lang_id,
]));
Cache::pushList("tag-{$canonical_tag}", $note);
foreach (self::cacheKeys($canonical_tag) as $key) {
Cache::delete($key);
}
} }
return Event::next; return Event::next;
} }
public function onRenderPlainTextNoteContent(string &$text, ?string $locale = null): EventResult public function onRenderPlainTextNoteContent(string &$text, ?string $locale = null): bool
{ {
$text = preg_replace_callback(self::TAG_REGEX, fn ($m) => $m[1] . self::tagLink($m[2], $locale), $text); $text = preg_replace_callback(self::TAG_REGEX, fn ($m) => $m[1] . self::tagLink($m[2], $locale), $text);
return Event::next; return Event::next;
@ -218,11 +178,8 @@ class Tag extends Component
* Populate $note_expr with an expression to match a tag, if the term looks like a tag * Populate $note_expr with an expression to match a tag, if the term looks like a tag
* *
* $term /^(note|tag|people|actor)/ means we want to match only either a note or an actor * $term /^(note|tag|people|actor)/ means we want to match only either a note or an actor
*
* @param mixed $note_expr
* @param mixed $actor_expr
*/ */
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
{ {
if (!str_contains($term, ':')) { if (!str_contains($term, ':')) {
return Event::next; return Event::next;
@ -261,31 +218,20 @@ class Tag extends Component
return Event::next; return Event::next;
} }
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): EventResult public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{ {
if (!\in_array('note_tag', $note_qb->getAllAliases())) {
$note_qb->leftJoin(NoteTag::class, 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id'); $note_qb->leftJoin(NoteTag::class, 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id');
}
if (!\in_array('actor_tag', $actor_qb->getAllAliases())) {
$actor_qb->leftJoin(ActorTag::class, 'actor_tag', Expr\Join::WITH, 'actor_tag.tagger = actor.id'); $actor_qb->leftJoin(ActorTag::class, 'actor_tag', Expr\Join::WITH, 'actor_tag.tagger = actor.id');
}
return Event::next; return Event::next;
} }
/** public function onPostingAddFormEntries(Request $request, Actor $actor, array &$form_params): bool
* @param array{string, class-string, array<string, mixed>} $form_params
*/
public function onPostingAddFormEntries(Request $request, Actor $actor, array &$form_params): EventResult
{ {
$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)')]]; $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; return Event::next;
} }
/** public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): bool
* @param array{tag_use_canonical?: bool} $data
* @param array{tag_use_canonical?: bool} $extra_args
*/
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): EventResult
{ {
if (!isset($data['tag_use_canonical'])) { if (!isset($data['tag_use_canonical'])) {
throw new ClientException(_m('Missing Use Canonical preference for Tags.')); throw new ClientException(_m('Missing Use Canonical preference for Tags.'));

View File

@ -2,16 +2,16 @@
{% block stylesheets %} {% block stylesheets %}
{{ parent() }} {{ parent() }}
<link rel="stylesheet" href="{{ asset('assets/default_theme/pages/feeds.css') }}" type="text/css"> <link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
{% endblock stylesheets %} {% endblock stylesheets %}
{% block body %} {% block body %}
{% if tag_name is defined and tag_name is not null %} {% if tag_name is defined and tag_name is not null %}
{% if tag_name is instanceof('string') %} {% if tag_name is instanceof('string') %}
<h1>{% trans %}Actors with tag: %tag_name%{% endtrans %}</h1> <h2>{% trans %}Actors with tag: %tag_name%{% endtrans %}</h2>
{% else %} {% else %}
{% set tags = tag_name|join(',') %} {# TODO Not ideal, hard to translate #} {% set tags = tag_name|join(',') %} {# TODO Not ideal, hard to translate #}
<h1>{% trans %}Actors with tags: %tags%{% endtrans %}</h1> <h2>{% trans %}Actors with tags: %tags%{% endtrans %}</h2>
{% endif %} {% endif %}
{% endif %} {% endif %}
@ -20,10 +20,10 @@
{% endfor %} {% endfor %}
{% for actor in results %} {% for actor in results %}
{% block profile_view %}{% include 'cards/blocks/profile.html.twig' %}{% endblock profile_view %} {% block profile_view %}{% include 'cards/profile/view.html.twig' %}{% endblock profile_view %}
{% endfor %} {% endfor %}
<div class="frame-section-button-like"> <div class="frame-section frame-section-padding">
{{ "Page: " ~ page }} {{ "Page: " ~ page }}
</div> </div>
{% endblock %} {% endblock %}

Some files were not shown because too many files have changed in this diff Show More