forked from GNUsocial/gnu-social
Compare commits
280 Commits
Author | SHA1 | Date | |
---|---|---|---|
b999c1bd62
|
|||
9dc6243822
|
|||
ce8f54dc46
|
|||
9e7db08e50
|
|||
841d10cde0
|
|||
95c8f3bdc7
|
|||
b82818646f
|
|||
5ac764f3e5
|
|||
4ad1de2616
|
|||
29f53bb698
|
|||
cb16b627b4
|
|||
19dd4ba368
|
|||
53a1a3fad1
|
|||
737648359d
|
|||
57c09c6f8f
|
|||
08e3da092b
|
|||
7959ea497b
|
|||
559f6d650b
|
|||
3d9edd1db8
|
|||
402300fe93
|
|||
e2e1b0172d
|
|||
f731850f5c
|
|||
7d546e8901
|
|||
bdeb3bcff5
|
|||
25b2847201
|
|||
23d45ffab7
|
|||
b253ce5e70
|
|||
c4f9e58e8d
|
|||
6ab740d780
|
|||
de795b78f9
|
|||
29d498770c
|
|||
d7039b1c5c
|
|||
1856af68b3
|
|||
9bd1f42843
|
|||
145c88d43f
|
|||
4717dde12e
|
|||
c028a601a5
|
|||
692ecf1c99
|
|||
242fe3fd6e
|
|||
dbdf1d9b0b
|
|||
7daa61500d
|
|||
077cbcf424
|
|||
04431885aa
|
|||
b8a35f9d6d
|
|||
184d0246a5
|
|||
da7ae5e1f5
|
|||
9e4aed84f8
|
|||
db42ade2b6
|
|||
06d11d8337
|
|||
148dd6db50
|
|||
21c7912702
|
|||
f7cbfbff8c
|
|||
3f0d996dc9
|
|||
9e891ed020
|
|||
6c6c0270c5
|
|||
a59997b41f
|
|||
d542be1df4
|
|||
eff9318c1d
|
|||
fa9df9962e
|
|||
859bf0c0bf
|
|||
d29e28b829
|
|||
14b03c7137
|
|||
480f570238
|
|||
968b1751fd
|
|||
c8daa82c1d
|
|||
600a1511cb
|
|||
59b8bdf99b
|
|||
f3a7e8f04d
|
|||
65504b72bb
|
|||
|
d713429d88
|
||
1056bc661f
|
|||
f40eb3955f
|
|||
b2b445d21e
|
|||
528f6df240
|
|||
894c78bf99
|
|||
38baa192d8
|
|||
a697399a6f
|
|||
cdf1d67d0f
|
|||
06ece5b72e
|
|||
da6d3bd351
|
|||
c835fc6aca
|
|||
57604b3851
|
|||
b1abd81aca
|
|||
5cfed3d536
|
|||
0758d6145b
|
|||
d17f276419
|
|||
fc57b3290e
|
|||
1438433859
|
|||
cb1dc4c10f | |||
9cf8970603 | |||
c3d58c350e | |||
e056920de4
|
|||
0c245fcb6e
|
|||
0d1ab2c9cf
|
|||
3f8fab0021
|
|||
cd6ce3542e
|
|||
627d92b290
|
|||
ee007befa4
|
|||
9df9c6a19c
|
|||
754135743e
|
|||
5a0bbfc795
|
|||
6247dd4c1a
|
|||
de8eab2cf8
|
|||
b7e4f79ccc
|
|||
a5b5362be2
|
|||
d444ea7963
|
|||
a729a8eddb
|
|||
a8a8cc4046
|
|||
7d38c927e1
|
|||
135bf8bc68
|
|||
5a31258190
|
|||
f5fc7b6cd1
|
|||
141c5f6785
|
|||
07b65584ff
|
|||
4ae160b0f8
|
|||
a622b175bc
|
|||
9ea230d12b
|
|||
fe087b2217
|
|||
a9ea49d34c
|
|||
9e0a2dd4a0
|
|||
8fa04bb47d
|
|||
d5a6fa924b
|
|||
ba0b0629b7
|
|||
27276ba379
|
|||
ea5a4df1a4
|
|||
6cfb69d64b
|
|||
5fa8056899
|
|||
def5f36c25
|
|||
afb7ae0f75
|
|||
064288e33b
|
|||
c7ea56d571
|
|||
17b46b9aeb
|
|||
28424402ec
|
|||
7ad39fdc83
|
|||
d5080890ac
|
|||
f42e91d2bc
|
|||
362fc6c7dd
|
|||
046731a05a
|
|||
d27e8610d6
|
|||
b7574500f8
|
|||
6ea45df3b8
|
|||
d6cd33019d
|
|||
5662210a2d
|
|||
b1fbf7d6ef
|
|||
9f11d270f4
|
|||
e7940a21ee
|
|||
f6311debbf
|
|||
175c98b043
|
|||
acc84d757c
|
|||
fc76a00908
|
|||
1f01923aa1
|
|||
1a99762699
|
|||
f346cd8167
|
|||
7aa90954eb
|
|||
0050371de7
|
|||
b7872ba4ee
|
|||
ba078b7b76
|
|||
d7b46735ac
|
|||
6dd31926ad
|
|||
34cc010136
|
|||
9a6bdf74dc
|
|||
0ae24f6088
|
|||
5f4968ac05
|
|||
2e0bfc0bcd
|
|||
2dbc35fcc3
|
|||
8831276489
|
|||
5229d4cd8c
|
|||
cbb70a5054
|
|||
f16df759a9
|
|||
2c31f2e440 | |||
85e31c684d | |||
bdd8cbf36d | |||
d7f70d288d | |||
49d247aec2
|
|||
f28ed5e359
|
|||
6b82708968
|
|||
836560f55f
|
|||
0caec6ab9e | |||
01d5e84a08 | |||
f9bc1c790f | |||
25120c6630 | |||
137723e59a
|
|||
8274e93ed5
|
|||
ce3c6a7f23
|
|||
846ec37cd9
|
|||
4d8e39bf69
|
|||
182c6265a3
|
|||
1d1d169a5c
|
|||
9cda64f275
|
|||
3e83387e98
|
|||
9585472679
|
|||
b7c82b9dcb
|
|||
5c2b46a71d
|
|||
46d121ef7b
|
|||
bf4a0008ef
|
|||
bb4149e092
|
|||
a03429ba03
|
|||
f5b06e2c7e
|
|||
c40e38c5ba
|
|||
d74a9ad373
|
|||
76440961ca
|
|||
8796885fa0
|
|||
5c10448080
|
|||
559ec3df39
|
|||
20d89f0f24
|
|||
19975b8d8d
|
|||
65a3d738ca | |||
7ddfe92773 | |||
e932ff43d0 | |||
672df5165c | |||
72a19d7eac
|
|||
b84315c95b
|
|||
edd996d281
|
|||
cf2f87fc1d
|
|||
c9d05d71f5
|
|||
d03572e366
|
|||
de148c1f78
|
|||
2a902d6a7e
|
|||
195618801b
|
|||
80afc0fa6c
|
|||
eb761609aa
|
|||
c4dacd7626
|
|||
fd44bc3ac5
|
|||
65676d3980
|
|||
ea42ba9f26
|
|||
a1a6f5f4fd
|
|||
93276ce8d0
|
|||
0df423e84b
|
|||
7eff22d548
|
|||
52e2231661
|
|||
9d3c01312f
|
|||
ce23660dba
|
|||
58715f1733
|
|||
838510ced2
|
|||
b30198413c
|
|||
7402e749cb
|
|||
18cfcc0796
|
|||
045ff6fb68
|
|||
fec1861b80
|
|||
d891089945
|
|||
0c421116a6
|
|||
78cc9c4659
|
|||
e10a38a3e2
|
|||
feb2631f00
|
|||
a1d9909379
|
|||
6f0d9add08
|
|||
6883e51fc8
|
|||
3d9141f4ce
|
|||
4df80be095
|
|||
d37f38a1ea
|
|||
dd268ba8db
|
|||
8e7c94fe1d
|
|||
94e216a943
|
|||
fdf506b9f9
|
|||
726613cd96
|
|||
000ec680e6
|
|||
97243151fa
|
|||
c79b1e4c94
|
|||
68076d73dd
|
|||
29bb11e8bc
|
|||
ec28f23025
|
|||
5e42723624
|
|||
f5f7fc6056
|
|||
625618b4e0
|
|||
91f8c86efa
|
|||
21f585ef7e
|
|||
9d5e149dec
|
|||
19502050e0
|
|||
3e13765f62
|
|||
d4bc1d097d
|
|||
78fddaf86a
|
|||
9d0b39e680
|
|||
36483a6ecd
|
|||
0d5e545a6e
|
|||
3275a989db
|
|||
ab640b110b
|
|||
7891461d36
|
|||
ce3b677833
|
|||
8651bd44c2
|
|||
6ada5e60d2
|
22
Makefile
22
Makefile
@@ -37,16 +37,22 @@ database-force-schema-update:
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c "/var/www/social/bin/console doctrine:schema:update --dump-sql --force"
|
||||
|
||||
tooling-docker: .PHONY
|
||||
@cd docker/tooling && docker-compose up -d > /dev/null 2>&1
|
||||
@cd docker/tooling && docker-compose up -d --build > /dev/null 2>&1
|
||||
|
||||
accessibility: .PHONY
|
||||
@cd docker/accessibility && docker-compose up
|
||||
stop-tooling: .PHONY
|
||||
cd docker/tooling && docker-compose down
|
||||
|
||||
tooling-php-shell: tooling-docker
|
||||
docker exec -it $(call translate-container-name,tooling_php_1) sh
|
||||
|
||||
test-accesibility: tooling-docker
|
||||
cd docker/tooling && docker-compose run pa11y /accessibility.sh
|
||||
|
||||
test: tooling-docker
|
||||
docker exec $(call translate-container-name,tooling_php_1) /var/tooling/coverage.sh $(call args,'')
|
||||
|
||||
cs-fixer: tooling-docker
|
||||
bin/php-cs-fixer
|
||||
@bin/php-cs-fixer $${CS_FIXER_FILE}
|
||||
|
||||
doc-check: tooling-docker
|
||||
bin/php-doc-check
|
||||
@@ -54,13 +60,13 @@ doc-check: tooling-docker
|
||||
phpstan: tooling-docker
|
||||
bin/phpstan
|
||||
|
||||
stop-tooling: .PHONY
|
||||
cd docker/tooling && docker-compose down
|
||||
|
||||
remove-var:
|
||||
rm -rf var/*
|
||||
|
||||
remove-file:
|
||||
sudo rm -rf file/*
|
||||
|
||||
flush-redis-cache:
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_redis_1) sh -c 'redis-cli flushall'
|
||||
|
||||
force-nuke-everything: remove-var flush-redis-cache database-force-nuke
|
||||
force-nuke-everything: down up flush-redis-cache database-force-nuke remove-var remove-file
|
||||
|
@@ -1,4 +1,4 @@
|
||||
#!/usr/local/bin/php
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
@@ -27,6 +27,7 @@ $files = array_merge(glob(ROOT . '/src/Entity/*.php'),
|
||||
glob(ROOT . '/plugins/*/Entity/*.php')));
|
||||
|
||||
$classes = [];
|
||||
$nullable_no_defaults_warning = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
|
||||
@@ -47,16 +48,32 @@ foreach ($files as $file) {
|
||||
$fields_code = [];
|
||||
$methods_code = [];
|
||||
foreach ($fields as $field) {
|
||||
$nullable = !@$schema['fields'][$field]['not null'] ? '?' : '';
|
||||
$type = types[$schema['fields'][$field]['type']];
|
||||
$field_schema = $schema['fields'][$field];
|
||||
$nullable = ($field_schema['not null'] ?? false) ? '' : '?';
|
||||
$type = types[$field_schema['type']];
|
||||
$type = $type !== '' ? $nullable . $type : $type;
|
||||
$method_name = str_replace([' ', 'actor'], ['', 'Actor'], ucwords(str_replace('_', ' ', $field)));
|
||||
$default = @$schema['fields'][$field]['default'];
|
||||
$length = $field_schema['length'] ?? null;
|
||||
|
||||
if (isset($default) && $nullable != '?' && $type != '\DateTimeInterface') {
|
||||
if (is_string($default)) {
|
||||
$field_setter = "\${$field}";
|
||||
if (\is_int($length)) {
|
||||
if ($nullable === '?') {
|
||||
$field_setter = "\is_null(\${$field}) ? null : \mb_substr(\${$field}, 0, $length)";
|
||||
} else {
|
||||
$field_setter = "\mb_substr(\${$field}, 0, $length)";
|
||||
}
|
||||
}
|
||||
|
||||
if (($nullable === '?' || \array_key_exists('default', $field_schema)) && $type != '\DateTimeInterface') {
|
||||
if (!\array_key_exists('default', $field_schema)) {
|
||||
$nullable_no_defaults_warning[] = "{$class}::{$field}";
|
||||
}
|
||||
$default = $field_schema['default'] ?? null;
|
||||
if (\is_string($default)) {
|
||||
$default = "'{$default}'";
|
||||
} elseif ($type == 'bool') {
|
||||
} elseif (\is_null($default)) {
|
||||
$default = "null";
|
||||
} elseif ($type === 'bool' || $type === '?bool') {
|
||||
$default = $default ? 'true' : 'false';
|
||||
}
|
||||
|
||||
@@ -66,13 +83,13 @@ foreach ($files as $file) {
|
||||
}
|
||||
|
||||
$methods_code[] = " public function set{$method_name}({$type} \${$field}): self" .
|
||||
"\n {\n \$this->{$field} = \${$field};\n return \$this;\n }" . "\n\n" .
|
||||
"\n {\n \$this->{$field} = {$field_setter};\n return \$this;\n }" . "\n\n" .
|
||||
" public function get{$method_name}()" . ($type !== '' ? ": {$type}" : '') .
|
||||
"\n {\n return \$this->{$field};\n }" . "\n";
|
||||
}
|
||||
|
||||
$fields_code = implode("\n", $fields_code);
|
||||
$methods_code = implode("\n", $methods_code) . "\n";
|
||||
$methods_code = implode("\n", $methods_code);
|
||||
|
||||
$begin = '// {{{ Autocode';
|
||||
$end = '// }}} Autocode';
|
||||
@@ -91,7 +108,13 @@ foreach ($files as $file) {
|
||||
}
|
||||
|
||||
$in_file = file_get_contents($file);
|
||||
$out_file = preg_replace("/\\s*{$begin}[^\\/]*{$end}/m", $code, $in_file);
|
||||
|
||||
$out_file = preg_replace("%\\s*{$begin}.*{$end}%smu", $code, $in_file);
|
||||
file_put_contents($file, $out_file);
|
||||
}
|
||||
|
||||
if (!empty($nullable_no_defaults_warning)) {
|
||||
echo "Warning: The following don't have a default value, but we're assigning it `null`. Doctrine might not like this, so update it\n";
|
||||
foreach ($nullable_no_defaults_warning as $n) {
|
||||
echo " {$n}\n";
|
||||
}
|
||||
}
|
||||
|
@@ -2,8 +2,8 @@
|
||||
|
||||
. bin/translate_container_name.sh
|
||||
|
||||
if [ $# -eq 1 ]; then
|
||||
docker exec -it "$(translate_container_name tooling_php_1)" /var/www/social/vendor/bin/php-cs-fixer --ansi -n --config=".php-cs-fixer.php" fix $1
|
||||
if [ "$#" -eq 0 ] || [ -z "$*" ]; then
|
||||
docker exec "$(translate_container_name tooling_php_1)" /var/www/social/vendor/bin/php-cs-fixer -n --config=".php-cs-fixer.php" fix
|
||||
else
|
||||
docker exec -it "$(translate_container_name tooling_php_1)" /var/www/social/vendor/bin/php-cs-fixer -n --ansi --config=".php-cs-fixer.php" fix
|
||||
docker exec "$(translate_container_name tooling_php_1)" sh -c "/var/www/social/vendor/bin/php-cs-fixer -q -n --config=\".php-cs-fixer.php\" fix --path-mode=intersection -- $*"
|
||||
fi
|
@@ -2,32 +2,25 @@
|
||||
|
||||
root="$(git rev-parse --show-toplevel)"
|
||||
|
||||
# get the list of changed files
|
||||
# get the list of changed files that didn't get only partially added
|
||||
staged_files="$(git status --porcelain | sed -rn "s/^[^ ][ ] (.*)/\1/p")"
|
||||
|
||||
if (! (: "${SKIP_ALL?}") 2>/dev/null) && (! (: "${SKIP_CS_FIX?}") 2>/dev/null); then
|
||||
echo "Running php-cs-fixer on edited files"
|
||||
files=""
|
||||
for staged in ${staged_files}; do
|
||||
# work only with existing files
|
||||
if [ -f "${staged}" ] && expr "${staged}" : '^.*\.php$' > /dev/null; then
|
||||
# use php-cs-fixer and get flag of correction
|
||||
files="${staged} ${files}"
|
||||
CS_FIXER_FILE="${staged}" make cs-fixer
|
||||
git add "${staged}"
|
||||
fi
|
||||
done
|
||||
if [ -n "${files}" ]; then
|
||||
prev="${PWD}"
|
||||
cd "${root}" && make cs-fixer "${files}" || exit 1
|
||||
cd "${prev}" || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if (! (: "${SKIP_ALL?}") 2>/dev/null) && (! (: "${SKIP_DOC_CHECK?}") 2>/dev/null); then
|
||||
echo "Running php-doc-checker"
|
||||
if echo "${staged_files}" | grep -F ".php"; then
|
||||
prev="${PWD}"
|
||||
cd "${root}" && make doc-check || exit 1
|
||||
cd "${prev}" || exit 1
|
||||
if echo "${staged_files}" | grep -F ".php" > /dev/null 2>&1; then
|
||||
echo "Running php-doc-checker"
|
||||
make doc-check < /dev/tty
|
||||
fi
|
||||
fi
|
||||
|
||||
|
10
codeception.yml
Normal file
10
codeception.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
paths:
|
||||
tests: tests/CodeCeption
|
||||
output: tests/CodeCeption/_output
|
||||
data: tests/CodeCeption/_data
|
||||
support: tests/CodeCeption/_support
|
||||
envs: tests/CodeCeption/_envs
|
||||
actor_suffix: Tester
|
||||
extensions:
|
||||
enabled:
|
||||
- Codeception\Extension\RunFailed
|
@@ -22,6 +22,7 @@ declare(strict_types = 1);
|
||||
namespace Component\Attachment;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\RouteLoader;
|
||||
@@ -38,10 +39,10 @@ class Attachment extends Component
|
||||
{
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$r->connect('attachment_show', '/object/attachment/{id<\d+>}', [C\Attachment::class, 'attachment_show']);
|
||||
$r->connect('attachment_view', '/object/attachment/{id<\d+>}/view', [C\Attachment::class, 'attachment_view']);
|
||||
$r->connect('attachment_download', '/object/attachment/{id<\d+>}/download', [C\Attachment::class, 'attachment_download']);
|
||||
$r->connect('attachment_thumbnail', '/object/attachment/{id<\d+>}/thumbnail/{size<big|medium|small>}', [C\Attachment::class, 'attachment_thumbnail']);
|
||||
$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_download', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/download', [C\Attachment::class, 'attachmentDownloadWithNote']);
|
||||
$r->connect('note_attachment_thumbnail', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/thumbnail/{size<big|medium|small>}', [C\Attachment::class, 'attachmentThumbnailWithNote']);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
@@ -56,17 +57,18 @@ class Attachment extends Component
|
||||
return Event::stop;
|
||||
}
|
||||
|
||||
public function onNoteDeleteRelated(Note &$note): bool
|
||||
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
|
||||
{
|
||||
Cache::delete("note-attachments-{$note->getId()}");
|
||||
E\AttachmentToNote::removeWhereNoteId($note->getId());
|
||||
foreach ($note->getAttachments() as $attachment) {
|
||||
$attachment->kill();
|
||||
}
|
||||
DB::wrapInTransaction(fn () => E\AttachmentToNote::removeWhereNoteId($note->getId()));
|
||||
Cache::delete("note-attachments-{$note->getId()}");
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
{
|
||||
$note_qb->leftJoin(E\AttachmentToNote::class, 'attachment_to_note', Expr\Join::WITH, 'note.id = attachment_to_note.note_id');
|
||||
return Event::next;
|
||||
@@ -75,7 +77,7 @@ class Attachment extends Component
|
||||
/**
|
||||
* Populate $note_expr with the criteria for looking for notes with attachments
|
||||
*/
|
||||
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr)
|
||||
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
|
||||
{
|
||||
$include_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
|
||||
if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) {
|
||||
|
@@ -28,14 +28,13 @@ use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\GSFile;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Core\Router\Router;
|
||||
use Component\Attachment\Entity\AttachmentThumbnail;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\NoSuchFileException;
|
||||
use App\Util\Exception\NotFoundException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use Component\Attachment\Entity\AttachmentThumbnail;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -46,22 +45,23 @@ class Attachment extends Controller
|
||||
/**
|
||||
* Generic function that handles getting a representation for an attachment
|
||||
*/
|
||||
private function attachment(int $id, callable $handle)
|
||||
private function attachment(int $attachment_id, Note|int $note, callable $handle)
|
||||
{
|
||||
if ($id <= 0) { // This should never happen coming from the router, but let's bail if it does
|
||||
// @codeCoverageIgnoreStart
|
||||
Log::critical("Attachment controller called with {$id}, which should not be possible");
|
||||
throw new ClientException(_m('No such attachment.'), 404);
|
||||
// @codeCoverageIgnoreEnd
|
||||
} else {
|
||||
$res = null;
|
||||
if (Event::handle('AttachmentFileInfo', [$id, &$res]) != Event::stop) {
|
||||
// If no one else claims this attachment, use the default representation
|
||||
try {
|
||||
$res = GSFile::getAttachmentFileInfo($id);
|
||||
} catch (NoSuchFileException $e) {
|
||||
// Continue below
|
||||
}
|
||||
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
|
||||
$note = \is_int($note) ? Note::getById($note) : $note;
|
||||
|
||||
// Before anything, ensure proper scope
|
||||
if (!$note->isVisibleTo(Common::actor())) {
|
||||
throw new ClientException(_m('You don\'t have permissions to view this attachment.'), 401);
|
||||
}
|
||||
|
||||
$res = null;
|
||||
if (Event::handle('AttachmentFileInfo', [$attachment, $note, &$res]) !== Event::stop) {
|
||||
// If no one else claims this attachment, use the default representation
|
||||
try {
|
||||
$res = GSFile::getAttachmentFileInfo($attachment_id);
|
||||
} catch (NoSuchFileException $e) {
|
||||
// Continue below
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ class Attachment extends Controller
|
||||
throw new ServerException('This attachment is not stored locally.');
|
||||
// @codeCoverageIgnoreEnd
|
||||
} else {
|
||||
$res['attachment'] = $attachment;
|
||||
$res['note'] = $note;
|
||||
$res['title'] = $attachment->getBestTitle($note);
|
||||
return $handle($res);
|
||||
}
|
||||
}
|
||||
@@ -81,16 +84,17 @@ class Attachment extends Controller
|
||||
/**
|
||||
* The page where the attachment and it's info is shown
|
||||
*/
|
||||
public function attachment_show(Request $request, int $id)
|
||||
public function attachmentShowWithNote(Request $request, int $note_id, int $attachment_id)
|
||||
{
|
||||
try {
|
||||
$attachment = DB::findOneBy('attachment', ['id' => $id]);
|
||||
return $this->attachment($id, function ($res) use ($id, $attachment) {
|
||||
return $this->attachment($attachment_id, $note_id, function ($res) use ($note_id, $attachment_id) {
|
||||
return [
|
||||
'_template' => '/cards/attachments/show.html.twig',
|
||||
'download' => Router::url('attachment_download', ['id' => $id]),
|
||||
'attachment' => $attachment,
|
||||
'right_panel_vars' => ['attachment_id' => $id],
|
||||
'download' => $res['attachment']->getDownloadUrl(note: $note_id),
|
||||
'title' => $res['title'],
|
||||
'attachment' => $res['attachment'],
|
||||
'note' => $res['note'],
|
||||
'right_panel_vars' => ['attachment_id' => $attachment_id, 'note_id' => $note_id],
|
||||
];
|
||||
});
|
||||
} catch (NotFoundException) {
|
||||
@@ -101,27 +105,29 @@ class Attachment extends Controller
|
||||
/**
|
||||
* Display the attachment inline
|
||||
*/
|
||||
public function attachment_view(Request $request, int $id)
|
||||
public function attachmentViewWithNote(Request $request, int $note_id, int $attachment_id)
|
||||
{
|
||||
return $this->attachment(
|
||||
$id,
|
||||
$attachment_id,
|
||||
$note_id,
|
||||
fn (array $res) => GSFile::sendFile(
|
||||
$res['filepath'],
|
||||
$res['mimetype'],
|
||||
GSFile::ensureFilenameWithProperExtension($res['filename'], $res['mimetype']) ?? $res['filename'],
|
||||
GSFile::ensureFilenameWithProperExtension($res['title'], $res['mimetype']) ?? $res['filename'],
|
||||
HeaderUtils::DISPOSITION_INLINE,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function attachment_download(Request $request, int $id)
|
||||
public function attachmentDownloadWithNote(Request $request, int $note_id, int $attachment_id)
|
||||
{
|
||||
return $this->attachment(
|
||||
$id,
|
||||
$attachment_id,
|
||||
$note_id,
|
||||
fn (array $res) => GSFile::sendFile(
|
||||
$res['filepath'],
|
||||
$res['mimetype'],
|
||||
GSFile::ensureFilenameWithProperExtension($res['filename'], $res['mimetype']) ?? $res['filename'],
|
||||
GSFile::ensureFilenameWithProperExtension($res['title'], $res['mimetype']) ?? $res['filename'],
|
||||
HeaderUtils::DISPOSITION_ATTACHMENT,
|
||||
),
|
||||
);
|
||||
@@ -130,16 +136,21 @@ class Attachment extends Controller
|
||||
/**
|
||||
* Controller to produce a thumbnail for a given attachment id
|
||||
*
|
||||
* @param int $id Attachment ID
|
||||
* @param int $attachment_id Attachment ID
|
||||
*
|
||||
* @throws \App\Util\Exception\DuplicateFoundException
|
||||
* @throws ClientException
|
||||
* @throws NotFoundException
|
||||
* @throws ServerException
|
||||
*/
|
||||
public function attachment_thumbnail(Request $request, int $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' => $id]);
|
||||
// Before anything, ensure proper scope
|
||||
if (!Note::getById($note_id)->isVisibleTo(Common::actor())) {
|
||||
throw new ClientException(_m('You don\'t have permissions to view this thumbnail.'), 401);
|
||||
}
|
||||
|
||||
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
|
||||
|
||||
$crop = Common::config('thumbnail', 'smart_crop');
|
||||
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
@@ -39,7 +41,7 @@ class ActorToAttachment extends Entity
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $attachment_id;
|
||||
private int $actor_id;
|
||||
private \DateTimeInterface $modified;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setAttachmentId(int $attachment_id): self
|
||||
{
|
||||
@@ -77,10 +79,6 @@ class ActorToAttachment extends Entity
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
/**
|
||||
* @param int $attachment_id
|
||||
* @return mixed
|
||||
*/
|
||||
public static function removeWhereAttachmentId(int $attachment_id): mixed
|
||||
{
|
||||
return DB::dql(
|
||||
@@ -92,11 +90,6 @@ class ActorToAttachment extends Entity
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $actor_id
|
||||
* @param int $attachment_id
|
||||
* @return mixed
|
||||
*/
|
||||
public static function removeWhere(int $attachment_id, int $actor_id): mixed
|
||||
{
|
||||
return DB::dql(
|
||||
@@ -109,10 +102,6 @@ class ActorToAttachment extends Entity
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $actor_id
|
||||
* @return mixed
|
||||
*/
|
||||
public static function removeWhereActorId(int $actor_id): mixed
|
||||
{
|
||||
return DB::dql(
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
@@ -26,10 +28,10 @@ use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Core\Event;
|
||||
use App\Core\GSFile;
|
||||
use App\Entity\Note;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\DuplicateFoundException;
|
||||
@@ -57,13 +59,13 @@ class Attachment extends Entity
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $id;
|
||||
private int $lives = 1;
|
||||
private ?string $filehash;
|
||||
private ?string $mimetype;
|
||||
private ?string $filename;
|
||||
private ?int $size;
|
||||
private ?int $width;
|
||||
private ?int $height;
|
||||
private int $lives = 1;
|
||||
private ?string $filehash = null;
|
||||
private ?string $mimetype = null;
|
||||
private ?string $filename = null;
|
||||
private ?int $size = null;
|
||||
private ?int $width = null;
|
||||
private ?int $height = null;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setId(int $id): self
|
||||
@@ -77,25 +79,20 @@ class Attachment extends Entity
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function setLives(int $lives): self
|
||||
{
|
||||
$this->lives = $lives;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLives(): int
|
||||
{
|
||||
return $this->lives;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $lives
|
||||
*/
|
||||
public function setLives(int $lives): void
|
||||
{
|
||||
$this->lives = $lives;
|
||||
}
|
||||
|
||||
public function setFilehash(?string $filehash): self
|
||||
{
|
||||
$this->filehash = $filehash;
|
||||
$this->filehash = \is_null($filehash) ? null : mb_substr($filehash, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -106,7 +103,7 @@ class Attachment extends Entity
|
||||
|
||||
public function setMimetype(?string $mimetype): self
|
||||
{
|
||||
$this->mimetype = $mimetype;
|
||||
$this->mimetype = \is_null($mimetype) ? null : mb_substr($mimetype, 0, 255);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -117,7 +114,7 @@ class Attachment extends Entity
|
||||
|
||||
public function setFilename(?string $filename): self
|
||||
{
|
||||
$this->filename = $filename;
|
||||
$this->filename = \is_null($filename) ? null : mb_substr($filename, 0, 191);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -176,44 +173,36 @@ class Attachment extends Entity
|
||||
public function getMimetypeMajor(): ?string
|
||||
{
|
||||
$mime = $this->getMimetype();
|
||||
return is_null($mime) ? $mime : GSFile::mimetypeMajor($mime);
|
||||
return \is_null($mime) ? $mime : GSFile::mimetypeMajor($mime);
|
||||
}
|
||||
|
||||
public function getMimetypeMinor(): ?string
|
||||
{
|
||||
$mime = $this->getMimetype();
|
||||
return is_null($mime) ? $mime : GSFile::mimetypeMinor($mime);
|
||||
return \is_null($mime) ? $mime : GSFile::mimetypeMinor($mime);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function livesIncrementAndGet(): int
|
||||
{
|
||||
++$this->lives;
|
||||
return $this->lives;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function livesDecrementAndGet(): int
|
||||
{
|
||||
--$this->lives;
|
||||
return $this->lives;
|
||||
}
|
||||
|
||||
const FILEHASH_ALGO = 'sha256';
|
||||
public const FILEHASH_ALGO = 'sha256';
|
||||
|
||||
/**
|
||||
* Delete a file if safe, removes dependencies, cleanups and flushes
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function kill(): bool
|
||||
{
|
||||
if ($this->livesDecrementAndGet() <= 0) {
|
||||
return $this->delete();
|
||||
return DB::wrapInTransaction(fn () => $this->delete());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -223,7 +212,7 @@ class Attachment extends Entity
|
||||
*/
|
||||
public function deleteStorage(): bool
|
||||
{
|
||||
if (!is_null($filepath = $this->getPath())) {
|
||||
if (!\is_null($filepath = $this->getPath())) {
|
||||
if (file_exists($filepath)) {
|
||||
if (@unlink($filepath) === false) {
|
||||
// @codeCoverageIgnoreStart
|
||||
@@ -248,6 +237,8 @@ class Attachment extends Entity
|
||||
|
||||
/**
|
||||
* Attachment delete always removes dependencies, cleanups and flushes
|
||||
* WARNING: Wrap this function in a transaction!
|
||||
*
|
||||
* @see kill() It's more likely that you want to use that rather than call delete directly
|
||||
*/
|
||||
protected function delete(): bool
|
||||
@@ -261,7 +252,7 @@ class Attachment extends Entity
|
||||
|
||||
// Collect files starting with the one associated with this attachment
|
||||
$files = [];
|
||||
if (!is_null($filepath = $this->getPath())) {
|
||||
if (!\is_null($filepath = $this->getPath())) {
|
||||
$files[] = $filepath;
|
||||
}
|
||||
|
||||
@@ -306,25 +297,21 @@ class Attachment extends Entity
|
||||
/**
|
||||
* TODO: Maybe this isn't the best way of handling titles
|
||||
*
|
||||
* @param null|Note $note
|
||||
*
|
||||
* @throws DuplicateFoundException
|
||||
* @throws NotFoundException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getBestTitle(?Note $note = null): string
|
||||
{
|
||||
// If we have a note, then the best title is the title itself
|
||||
if (!is_null(($note))) {
|
||||
if (!\is_null(($note))) {
|
||||
$title = Cache::get('attachment-title-' . $this->getId() . '-' . $note->getId(), function () use ($note) {
|
||||
try {
|
||||
$attachment_to_note = DB::findOneBy('attachment_to_note', [
|
||||
'attachment_id' => $this->getId(),
|
||||
'note_id' => $note->getId(),
|
||||
]);
|
||||
if (!is_null($attachment_to_note->getTitle())) {
|
||||
if (!\is_null($attachment_to_note->getTitle())) {
|
||||
return $attachment_to_note->getTitle();
|
||||
}
|
||||
} catch (NotFoundException) {
|
||||
@@ -332,14 +319,13 @@ class Attachment extends Entity
|
||||
Event::handle('AttachmentGetBestTitle', [$this, $note, &$title]);
|
||||
return $title;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if ($title != null) {
|
||||
return $title;
|
||||
}
|
||||
}
|
||||
// Else
|
||||
if (!is_null($filename = $this->getFilename())) {
|
||||
if (!\is_null($filename = $this->getFilename())) {
|
||||
// A filename would do just as well
|
||||
return $filename;
|
||||
} else {
|
||||
@@ -359,18 +345,25 @@ class Attachment extends Entity
|
||||
public function getPath()
|
||||
{
|
||||
$filename = $this->getFilename();
|
||||
return is_null($filename) ? null : Common::config('attachments', 'dir') . DIRECTORY_SEPARATOR . $filename;
|
||||
return \is_null($filename) ? null : Common::config('attachments', 'dir') . \DIRECTORY_SEPARATOR . $filename;
|
||||
}
|
||||
|
||||
public function getUrl(int $type = Router::ABSOLUTE_URL): string
|
||||
public function getUrl(Note|int $note, int $type = Router::ABSOLUTE_URL): string
|
||||
{
|
||||
return Router::url(id: 'attachment_view', args: ['id' => $this->getId()], type: $type);
|
||||
return Router::url(id: 'note_attachment_view', args: ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId()], type: $type);
|
||||
}
|
||||
|
||||
public function getShowUrl(Note|int $note, int $type = Router::ABSOLUTE_URL): string
|
||||
{
|
||||
return Router::url(id: 'note_attachment_show', args: ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId()], type: $type);
|
||||
}
|
||||
|
||||
public function getDownloadUrl(Note|int $note, int $type = Router::ABSOLUTE_URL): string
|
||||
{
|
||||
return Router::url(id: 'note_attachment_download', args: ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId()], type: $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|string $size
|
||||
* @param bool $crop
|
||||
*
|
||||
* @throws ClientException
|
||||
* @throws NotFoundException
|
||||
* @throws ServerException
|
||||
@@ -382,9 +375,9 @@ class Attachment extends Entity
|
||||
return AttachmentThumbnail::getOrCreate(attachment: $this, size: $size, crop: $crop);
|
||||
}
|
||||
|
||||
public function getThumbnailUrl(?string $size = null)
|
||||
public function getThumbnailUrl(Note|int $note, ?string $size = null)
|
||||
{
|
||||
return Router::url('attachment_thumbnail', ['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')]);
|
||||
}
|
||||
|
||||
public static function schemaDef(): array
|
||||
@@ -393,7 +386,7 @@ class Attachment extends Entity
|
||||
'name' => 'attachment',
|
||||
'fields' => [
|
||||
'id' => ['type' => 'serial', 'not null' => true],
|
||||
'lives' => ['type' => 'int', 'not null' => true, 'description' => 'RefCount'],
|
||||
'lives' => ['type' => 'int', 'default' => 1, 'not null' => true, 'description' => 'RefCount, starts with 1'],
|
||||
'filehash' => ['type' => 'varchar', 'length' => 64, 'description' => 'sha256 of the file contents, if the file is stored locally'],
|
||||
'mimetype' => ['type' => 'varchar', 'length' => 255, 'description' => 'resource mime type 127+1+127 as per rfc6838#section-4.2'],
|
||||
'filename' => ['type' => 'varchar', 'length' => 191, 'description' => 'file name of resource when available'],
|
||||
|
@@ -66,12 +66,11 @@ class AttachmentThumbnail extends Entity
|
||||
'medium' => self::SIZE_MEDIUM,
|
||||
'big' => self::SIZE_BIG,
|
||||
];
|
||||
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $attachment_id;
|
||||
private ?string $mimetype;
|
||||
private int $size = self::SIZE_SMALL;
|
||||
private ?string $mimetype = null;
|
||||
private int $size = 0;
|
||||
private string $filename;
|
||||
private int $width;
|
||||
private int $height;
|
||||
@@ -90,7 +89,7 @@ class AttachmentThumbnail extends Entity
|
||||
|
||||
public function setMimetype(?string $mimetype): self
|
||||
{
|
||||
$this->mimetype = $mimetype;
|
||||
$this->mimetype = \is_null($mimetype) ? null : mb_substr($mimetype, 0, 129);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -99,20 +98,20 @@ class AttachmentThumbnail extends Entity
|
||||
return $this->mimetype;
|
||||
}
|
||||
|
||||
public function getSize(): int
|
||||
{
|
||||
return $this->size;
|
||||
}
|
||||
|
||||
public function setSize(int $size): self
|
||||
{
|
||||
$this->size = $size;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSize(): int
|
||||
{
|
||||
return $this->size;
|
||||
}
|
||||
|
||||
public function setFilename(string $filename): self
|
||||
{
|
||||
$this->filename = $filename;
|
||||
$this->filename = mb_substr($filename, 0, 191);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -121,6 +120,28 @@ class AttachmentThumbnail extends Entity
|
||||
return $this->filename;
|
||||
}
|
||||
|
||||
public function setWidth(int $width): self
|
||||
{
|
||||
$this->width = $width;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWidth(): int
|
||||
{
|
||||
return $this->width;
|
||||
}
|
||||
|
||||
public function setHeight(int $height): self
|
||||
{
|
||||
$this->height = $height;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHeight(): int
|
||||
{
|
||||
return $this->height;
|
||||
}
|
||||
|
||||
public function setModified(DateTimeInterface $modified): self
|
||||
{
|
||||
$this->modified = $modified;
|
||||
@@ -132,28 +153,6 @@ class AttachmentThumbnail extends Entity
|
||||
return $this->modified;
|
||||
}
|
||||
|
||||
public function getWidth(): int
|
||||
{
|
||||
return $this->width;
|
||||
}
|
||||
|
||||
public function setWidth(int $width): self
|
||||
{
|
||||
$this->width = $width;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHeight(): int
|
||||
{
|
||||
return $this->height;
|
||||
}
|
||||
|
||||
public function setHeight(int $height): self
|
||||
{
|
||||
$this->height = $height;
|
||||
return $this;
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
@@ -37,20 +39,9 @@ class AttachmentToLink extends Entity
|
||||
{
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $attachment_id;
|
||||
private int $link_id;
|
||||
private \DateTimeInterface $modified;
|
||||
|
||||
public function setAttachmentId(int $attachment_id): self
|
||||
{
|
||||
$this->attachment_id = $attachment_id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAttachmentId(): int
|
||||
{
|
||||
return $this->attachment_id;
|
||||
}
|
||||
private int $attachment_id;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setLinkId(int $link_id): self
|
||||
{
|
||||
@@ -63,6 +54,17 @@ class AttachmentToLink extends Entity
|
||||
return $this->link_id;
|
||||
}
|
||||
|
||||
public function setAttachmentId(int $attachment_id): self
|
||||
{
|
||||
$this->attachment_id = $attachment_id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAttachmentId(): int
|
||||
{
|
||||
return $this->attachment_id;
|
||||
}
|
||||
|
||||
public function setModified(DateTimeInterface $modified): self
|
||||
{
|
||||
$this->modified = $modified;
|
||||
@@ -77,10 +79,6 @@ class AttachmentToLink extends Entity
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
/**
|
||||
* @param int $attachment_id
|
||||
* @return mixed
|
||||
*/
|
||||
public static function removeWhereAttachmentId(int $attachment_id): mixed
|
||||
{
|
||||
return DB::dql(
|
||||
@@ -92,11 +90,6 @@ class AttachmentToLink extends Entity
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $link_id
|
||||
* @param int $attachment_id
|
||||
* @return mixed
|
||||
*/
|
||||
public static function removeWhere(int $link_id, int $attachment_id): mixed
|
||||
{
|
||||
return DB::dql(
|
||||
@@ -109,10 +102,6 @@ class AttachmentToLink extends Entity
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $link_id
|
||||
* @return mixed
|
||||
*/
|
||||
public static function removeWhereLinkId(int $link_id): mixed
|
||||
{
|
||||
return DB::dql(
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
@@ -19,7 +21,6 @@
|
||||
|
||||
namespace Component\Attachment\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use DateTimeInterface;
|
||||
@@ -44,8 +45,8 @@ class AttachmentToNote extends Entity
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $attachment_id;
|
||||
private int $note_id;
|
||||
private ?string $title;
|
||||
private \DateTimeInterface $modified;
|
||||
private ?string $title = null;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setAttachmentId(int $attachment_id): self
|
||||
{
|
||||
@@ -94,11 +95,6 @@ class AttachmentToNote extends Entity
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
/**
|
||||
* @param int $note_id
|
||||
* @param int $attachment_id
|
||||
* @return mixed
|
||||
*/
|
||||
public static function removeWhere(int $note_id, int $attachment_id): mixed
|
||||
{
|
||||
return DB::dql(
|
||||
@@ -111,10 +107,6 @@ class AttachmentToNote extends Entity
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $note_id
|
||||
* @return mixed
|
||||
*/
|
||||
public static function removeWhereNoteId(int $note_id): mixed
|
||||
{
|
||||
return DB::dql(
|
||||
@@ -126,10 +118,6 @@ class AttachmentToNote extends Entity
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $attachment_id
|
||||
* @return mixed
|
||||
*/
|
||||
public static function removeWhereAttachmentId(int $attachment_id): mixed
|
||||
{
|
||||
return DB::dql(
|
||||
|
@@ -29,6 +29,8 @@ use App\Core\Modules\Component;
|
||||
use App\Core\Router\RouteLoader;
|
||||
use App\Core\Router\Router;
|
||||
use App\Util\Common;
|
||||
use Component\Attachment\Entity\Attachment;
|
||||
use Component\Attachment\Entity\AttachmentThumbnail;
|
||||
use Component\Avatar\Controller as C;
|
||||
use Component\Avatar\Exception\NoAvatarException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -41,8 +43,8 @@ class Avatar extends Component
|
||||
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$r->connect('avatar_actor', '/actor/{actor_id<\d+>}/avatar/{size<full|big|medium|small>?full}', [Controller\Avatar::class, 'avatar_view']);
|
||||
$r->connect('avatar_default', '/avatar/default/{size<full|big|medium|small>?full}', [Controller\Avatar::class, 'default_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_settings', '/settings/avatar', [Controller\Avatar::class, 'settings_avatar']);
|
||||
return Event::next;
|
||||
}
|
||||
@@ -102,7 +104,7 @@ class Avatar extends Component
|
||||
/**
|
||||
* Get the cached avatar associated with the given Actor id, or the current user if not given
|
||||
*/
|
||||
public static function getUrl(int $actor_id, string $size = 'full', int $type = Router::ABSOLUTE_PATH): string
|
||||
public static function getUrl(int $actor_id, string $size = 'medium', int $type = Router::ABSOLUTE_PATH): string
|
||||
{
|
||||
try {
|
||||
return self::getAvatar($actor_id)->getUrl($size, $type);
|
||||
@@ -111,13 +113,13 @@ class Avatar extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public static function getDimensions(int $actor_id, string $size = 'full')
|
||||
public static function getDimensions(int $actor_id, string $size = 'medium')
|
||||
{
|
||||
try {
|
||||
$attachment = self::getAvatar($actor_id)->getAttachment();
|
||||
return ['width' => $attachment->getWidth(), 'height' => $attachment->getHeight()];
|
||||
return ['width' => (int) $attachment->getWidth(), 'height' => (int) $attachment->getHeight()];
|
||||
} catch (NoAvatarException) {
|
||||
return ['width' => Common::config('thumbnail', 'small'), 'height' => Common::config('thumbnail', 'small')];
|
||||
return ['width' => (int) (Common::config('thumbnail', 'small')), 'height' => (int) (Common::config('thumbnail', 'small'))];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +129,7 @@ class Avatar extends Component
|
||||
* Returns the avatar file's hash, mimetype, title and path.
|
||||
* Ensures exactly one cached value exists
|
||||
*/
|
||||
public static function getAvatarFileInfo(int $actor_id, string $size = 'full'): array
|
||||
public static function getAvatarFileInfo(int $actor_id, string $size = 'medium'): array
|
||||
{
|
||||
$res = Cache::get(
|
||||
"avatar-file-info-{$actor_id}-{$size}",
|
||||
@@ -151,8 +153,12 @@ class Avatar extends Component
|
||||
'title' => 'default_avatar.svg',
|
||||
];
|
||||
} else {
|
||||
$res = $res[0]; // A user must always only have one avatar.
|
||||
$res['filepath'] = DB::findOneBy('attachment', ['id' => $res['id']])->getPath();
|
||||
$res = $res[0]; // A user must always only have one avatar.
|
||||
if ($size === 'full') {
|
||||
$res['filepath'] = Attachment::getByPK(['id' => $res['id']])->getPath();
|
||||
} else {
|
||||
$res['filepath'] = AttachmentThumbnail::getOrCreate(Attachment::getByPK(['id' => $res['id']]), $size)->getPath();
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
|
@@ -33,7 +33,6 @@ use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\NotFoundException;
|
||||
use App\Util\TemporaryFile;
|
||||
use Component\Avatar\Entity\Avatar as AvatarEntity;
|
||||
use Exception;
|
||||
@@ -57,13 +56,8 @@ class Avatar extends Controller
|
||||
*/
|
||||
public function avatar_view(Request $request, int $actor_id, string $size): Response
|
||||
{
|
||||
switch ($size) {
|
||||
case 'full':
|
||||
$res = \Component\Avatar\Avatar::getAvatarFileInfo($actor_id);
|
||||
return M::sendFile($res['filepath'], $res['mimetype'], $res['title']);
|
||||
default:
|
||||
throw new Exception('Not implemented');
|
||||
}
|
||||
$res = \Component\Avatar\Avatar::getAvatarFileInfo($actor_id, $size);
|
||||
return M::sendFile($res['filepath'], $res['mimetype'], $res['title']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,15 +79,14 @@ class Avatar extends Controller
|
||||
$user = Common::user();
|
||||
$actor_id = $user->getId();
|
||||
if ($data['remove'] == true) {
|
||||
try {
|
||||
$avatar = DB::findOneBy('avatar', ['actor_id' => $actor_id]);
|
||||
if (\is_null($avatar = DB::findOneBy(AvatarEntity::class, ['actor_id' => $actor_id], return_null: true))) {
|
||||
$form->addError(new FormError(_m('No avatar set, so cannot delete.')));
|
||||
} else {
|
||||
$avatar->delete();
|
||||
Event::handle('AvatarUpdate', [$user->getId()]);
|
||||
} catch (NotFoundException) {
|
||||
$form->addError(new FormError(_m('No avatar set, so cannot delete')));
|
||||
}
|
||||
} else {
|
||||
$attachment = null;
|
||||
$title = $data['avatar']?->getClientOriginalName() ?? null;
|
||||
if (isset($data['hidden'])) {
|
||||
// Cropped client side
|
||||
$matches = [];
|
||||
@@ -113,15 +106,18 @@ class Avatar extends Controller
|
||||
$file = $data['avatar'];
|
||||
$attachment = GSFile::storeFileAsAttachment($file);
|
||||
} else {
|
||||
throw new ClientException('Invalid form');
|
||||
throw new ClientException(_m('Invalid form.'));
|
||||
}
|
||||
// Delete current avatar if there's one
|
||||
$avatar = DB::find('avatar', ['actor_id' => $actor_id]);
|
||||
$avatar?->delete();
|
||||
if (!\is_null($avatar = DB::findOneBy(AvatarEntity::class, ['actor_id' => $actor_id], return_null: true))) {
|
||||
$avatar->delete();
|
||||
}
|
||||
DB::persist($attachment);
|
||||
// Can only get new id after inserting
|
||||
DB::flush();
|
||||
DB::persist(AvatarEntity::create(['actor_id' => $actor_id, 'attachment_id' => $attachment->getId()]));
|
||||
DB::persist(AvatarEntity::create([
|
||||
'actor_id' => $actor_id,
|
||||
'attachment_id' => $attachment->getId(),
|
||||
'title' => $title,
|
||||
]));
|
||||
DB::flush();
|
||||
Event::handle('AvatarUpdate', [$user->getId()]);
|
||||
}
|
||||
|
@@ -26,9 +26,11 @@ namespace Component\Avatar\Entity;
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Core\Event;
|
||||
use App\Core\Router\Router;
|
||||
use Component\Attachment\Entity\Attachment;
|
||||
use App\Util\Common;
|
||||
use Component\Attachment\Entity\Attachment;
|
||||
use Component\Attachment\Entity\AttachmentThumbnail;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
@@ -51,7 +53,7 @@ class Avatar extends Entity
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $actor_id;
|
||||
private int $attachment_id;
|
||||
private ?string $title;
|
||||
private ?string $title = null;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
@@ -77,16 +79,17 @@ class Avatar extends Entity
|
||||
return $this->attachment_id;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title): self
|
||||
{
|
||||
$this->title = \is_null($title) ? null : mb_substr($title, 0, 191);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function setCreated(DateTimeInterface $created): self
|
||||
{
|
||||
$this->created = $created;
|
||||
@@ -114,7 +117,7 @@ class Avatar extends Entity
|
||||
|
||||
private ?Attachment $attachment = null;
|
||||
|
||||
public function getUrl(string $size = 'full', int $type = Router::ABSOLUTE_PATH): string
|
||||
public function getUrl(string $size = 'medium', int $type = Router::ABSOLUTE_PATH): string
|
||||
{
|
||||
$actor_id = $this->getActorId();
|
||||
return Cache::get("avatar-url-{$actor_id}-{$size}-{$type}", fn () => Router::url('avatar_actor', ['actor_id' => $actor_id, 'size' => $size], $type));
|
||||
@@ -122,10 +125,15 @@ class Avatar extends Entity
|
||||
|
||||
public function getAttachment(): Attachment
|
||||
{
|
||||
$this->attachment ??= DB::findOneBy('attachment', ['id' => $this->attachment_id]);
|
||||
$this->attachment ??= DB::findOneBy('attachment', ['id' => $this->getAttachmentId()]);
|
||||
return $this->attachment;
|
||||
}
|
||||
|
||||
public function getAttachmentThumbnail(string $size): ?AttachmentThumbnail
|
||||
{
|
||||
return AttachmentThumbnail::getOrCreate($this->getAttachment(), $size);
|
||||
}
|
||||
|
||||
public static function getFilePathStatic(string $filename): string
|
||||
{
|
||||
return Common::config('avatar', 'dir') . $filename;
|
||||
@@ -141,10 +149,11 @@ class Avatar extends Entity
|
||||
*/
|
||||
public function delete(): bool
|
||||
{
|
||||
DB::remove($this);
|
||||
$actor_id = $this->getActorId();
|
||||
$attachment = $this->getAttachment();
|
||||
DB::wrapInTransaction(fn () => DB::removeBy(static::class, ['actor_id' => $actor_id]));
|
||||
$attachment->kill();
|
||||
DB::flush();
|
||||
Event::handle('AvatarUpdate', [$actor_id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@@ -44,12 +44,12 @@ class ForeignLink
|
||||
private int $user_id;
|
||||
private int $foreign_id;
|
||||
private int $service;
|
||||
private ?string $credentials;
|
||||
private int $noticesync = 1;
|
||||
private int $friendsync = 2;
|
||||
private int $profilesync = 1;
|
||||
private ?DateTimeInterface $last_noticesync;
|
||||
private ?DateTimeInterface $last_friendsync;
|
||||
private ?string $credentials = null;
|
||||
private int $noticesync = 1;
|
||||
private int $friendsync = 2;
|
||||
private int $profilesync = 1;
|
||||
private ?DateTimeInterface $last_noticesync = null;
|
||||
private ?DateTimeInterface $last_friendsync = null;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
@@ -88,7 +88,7 @@ class ForeignLink
|
||||
|
||||
public function setCredentials(?string $credentials): self
|
||||
{
|
||||
$this->credentials = $credentials;
|
||||
$this->credentials = \is_null($credentials) ? null : mb_substr($credentials, 0, 191);
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
@@ -43,7 +43,7 @@ class ForeignService
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $id;
|
||||
private string $name;
|
||||
private ?string $description;
|
||||
private ?string $description = null;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
@@ -60,7 +60,7 @@ class ForeignService
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->name = mb_substr($name, 0, 32);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ class ForeignService
|
||||
|
||||
public function setDescription(?string $description): self
|
||||
{
|
||||
$this->description = $description;
|
||||
$this->description = \is_null($description) ? null : mb_substr($description, 0, 191);
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
@@ -44,7 +44,7 @@ class ForeignUser
|
||||
private int $id;
|
||||
private int $service;
|
||||
private string $uri;
|
||||
private ?string $nickname;
|
||||
private ?string $nickname = null;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
@@ -72,7 +72,7 @@ class ForeignUser
|
||||
|
||||
public function setUri(string $uri): self
|
||||
{
|
||||
$this->uri = $uri;
|
||||
$this->uri = mb_substr($uri, 0, 191);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ class ForeignUser
|
||||
|
||||
public function setNickname(?string $nickname): self
|
||||
{
|
||||
$this->nickname = $nickname;
|
||||
$this->nickname = \is_null($nickname) ? null : mb_substr($nickname, 0, 191);
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
223
components/Circle/Circle.php
Normal file
223
components/Circle/Circle.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Circle;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\RouteLoader;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Feed;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Util\Common;
|
||||
use App\Util\Nickname;
|
||||
use Component\Circle\Controller as CircleController;
|
||||
use Component\Circle\Entity\ActorCircle;
|
||||
use Component\Circle\Entity\ActorCircleSubscription;
|
||||
use Component\Circle\Entity\ActorTag;
|
||||
use Component\Collection\Util\MetaCollectionTrait;
|
||||
use Component\Tag\Tag;
|
||||
use Functional as F;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Component responsible for handling and representing ActorCircles and ActorTags
|
||||
*
|
||||
* @author Hugo Sales <hugo@hsal.es>
|
||||
* @author Phablulo <phablulo@gmail.com>
|
||||
* @author Diogo Peralta Cordeiro <@diogo.site>
|
||||
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
class Circle extends Component
|
||||
{
|
||||
use MetaCollectionTrait;
|
||||
public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/';
|
||||
protected string $slug = 'circle';
|
||||
protected string $plural_slug = 'circles';
|
||||
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$r->connect('actor_circle_view_by_circle_id', '/circle/{circle_id<\d+>}', [CircleController\Circle::class, 'circleById']);
|
||||
// View circle members by (tagger id or nickname) and tag
|
||||
$r->connect('actor_circle_view_by_circle_tagger_tag', '/circle/actor/{tagger_id<\d+>/{tag<' . Tag::TAG_SLUG_REGEX . '>}}', [CircleController\Circle::class, 'circleByTaggerIdAndTag']);
|
||||
$r->connect('actor_circle_view_by_circle_tagger_tag', '/circle/@{nickname<' . Nickname::DISPLAY_FMT . '>}/{tag<' . Tag::TAG_SLUG_REGEX . '>}', [CircleController\Circle::class, 'circleByTaggerNicknameAndTag']);
|
||||
|
||||
// View all circles by actor id or nickname
|
||||
$r->connect(
|
||||
id: 'actor_circles_view_by_actor_id',
|
||||
uri_path: '/actor/{tag<' . Tag::TAG_SLUG_REGEX . '>}/circles',
|
||||
target: [CircleController\Circles::class, 'collectionsViewByActorId'],
|
||||
);
|
||||
$r->connect(
|
||||
id: 'actor_circles_view_by_nickname',
|
||||
uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/circles',
|
||||
target: [CircleController\Circles::class, 'collectionsViewByActorNickname'],
|
||||
);
|
||||
|
||||
$r->connect('actor_circle_view_feed_by_circle_id', '/circle/{circle_id<\d+>}/feed', [CircleController\Circles::class, 'feedByCircleId']);
|
||||
// View circle feed by (tagger id or nickname) and tag
|
||||
$r->connect('actor_circle_view_feed_by_circle_tagger_tag', '/circle/actor/{tagger_id<\d+>/{tag<' . Tag::TAG_SLUG_REGEX . '>}}/feed', [CircleController\Circles::class, 'feedByTaggerIdAndTag']);
|
||||
$r->connect('actor_circle_view_feed_by_circle_tagger_tag', '/circle/@{nickname<' . Nickname::DISPLAY_FMT . '>}/{tag<' . Tag::TAG_SLUG_REGEX . '>}/feed', [CircleController\Circles::class, 'feedByTaggerNicknameAndTag']);
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public static function cacheKeys(string $tag_single_or_multi): array
|
||||
{
|
||||
return [
|
||||
'actor_single' => "actor-tag-feed-{$tag_single_or_multi}",
|
||||
'actor_multi' => "actor-tags-feed-{$tag_single_or_multi}",
|
||||
];
|
||||
}
|
||||
|
||||
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool
|
||||
{
|
||||
if ($section === 'profile' && $request->get('_route') === 'settings') {
|
||||
$tabs[] = [
|
||||
'title' => 'Self tags',
|
||||
'desc' => 'Add or remove tags on yourself',
|
||||
'id' => 'settings-self-tags',
|
||||
'controller' => CircleController\SelfTagsSettings::settingsSelfTags($request, Common::actor(), 'settings-self-tags-details'),
|
||||
];
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): bool
|
||||
{
|
||||
$circles = $actor->getCircles();
|
||||
foreach ($circles as $circle) {
|
||||
$tag = $circle->getTag();
|
||||
$targets["#{$tag}"] = $tag;
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
// Meta Collection -------------------------------------------------------------------
|
||||
|
||||
private function getActorIdFromVars(array $vars): int
|
||||
{
|
||||
$id = $vars['request']->get('id', null);
|
||||
if ($id) {
|
||||
return (int) $id;
|
||||
}
|
||||
$nick = $vars['request']->get('nickname');
|
||||
$user = LocalUser::getByNickname($nick);
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
public static function createCircle(Actor|int $tagger_id, string $tag): int
|
||||
{
|
||||
$tagger_id = \is_int($tagger_id) ? $tagger_id : $tagger_id->getId();
|
||||
$circle = ActorCircle::create([
|
||||
'tagger' => $tagger_id,
|
||||
'tag' => $tag,
|
||||
'description' => null, // TODO
|
||||
'private' => false, // TODO
|
||||
]);
|
||||
DB::persist($circle);
|
||||
|
||||
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
|
||||
|
||||
return $circle->getId();
|
||||
}
|
||||
|
||||
protected function createCollection(Actor $owner, array $vars, string $name)
|
||||
{
|
||||
$this->createCircle($owner, $name);
|
||||
DB::persist(ActorTag::create([
|
||||
'tagger' => $owner->getId(),
|
||||
'tagged' => self::getActorIdFromVars($vars),
|
||||
'tag' => $name,
|
||||
]));
|
||||
}
|
||||
|
||||
protected function removeItem(Actor $owner, array $vars, $items, array $collections)
|
||||
{
|
||||
$tagger_id = $owner->getId();
|
||||
$tagged_id = $this->getActorIdFromVars($vars);
|
||||
$circles_to_remove_tagged_from = DB::findBy(ActorCircle::class, ['id' => $items]);
|
||||
foreach ($circles_to_remove_tagged_from as $circle) {
|
||||
DB::removeBy(ActorCircleSubscription::class, ['actor_id' => $tagged_id, 'circle_id' => $circle->getId()]);
|
||||
}
|
||||
$tags = F\map($circles_to_remove_tagged_from, fn ($x) => $x->getTag());
|
||||
foreach ($tags as $tag) {
|
||||
DB::removeBy(ActorTag::class, ['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]);
|
||||
}
|
||||
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
|
||||
}
|
||||
|
||||
protected function addItem(Actor $owner, array $vars, $items, array $collections)
|
||||
{
|
||||
$tagger_id = $owner->getId();
|
||||
$tagged_id = $this->getActorIdFromVars($vars);
|
||||
$circles_to_add_tagged_to = DB::findBy(ActorCircle::class, ['id' => $items]);
|
||||
foreach ($circles_to_add_tagged_to as $circle) {
|
||||
DB::persist(ActorCircleSubscription::create(['actor_id' => $tagged_id, 'circle_id' => $circle->getId()]));
|
||||
}
|
||||
$tags = F\map($circles_to_add_tagged_to, fn ($x) => $x->getTag());
|
||||
foreach ($tags as $tag) {
|
||||
DB::persist(ActorTag::create(['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]));
|
||||
}
|
||||
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see MetaCollectionPlugin->shouldAddToRightPanel
|
||||
*/
|
||||
protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool
|
||||
{
|
||||
return \in_array($vars['path'], ['actor_view_nickname', 'actor_view_id']);
|
||||
}
|
||||
|
||||
protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array
|
||||
{
|
||||
$tagged_id = !\is_null($vars) ? $this->getActorIdFromVars($vars) : null;
|
||||
$circles = \is_null($tagged_id) ? $owner->getCircles() : F\select($owner->getCircles(), function ($x) use ($tagged_id) {
|
||||
foreach ($x->getActorTags() as $at) {
|
||||
if ($at->getTagged() === $tagged_id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return $ids_only ? array_map(fn ($x) => $x->getId(), $circles) : $circles;
|
||||
}
|
||||
|
||||
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
|
||||
{
|
||||
DB::persist(Feed::create([
|
||||
'actor_id' => $actor_id,
|
||||
'url' => Router::url($route = 'actor_circles_view_by_nickname', ['nickname' => $user->getNickname()]),
|
||||
'route' => $route,
|
||||
'title' => _m('Circles'),
|
||||
'ordering' => $ordering++,
|
||||
]));
|
||||
return Event::next;
|
||||
}
|
||||
}
|
61
components/Circle/Controller/Circle.php
Normal file
61
components/Circle/Controller/Circle.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Circle\Controller;
|
||||
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Util\Exception\ClientException;
|
||||
use Component\Circle\Entity\ActorCircle;
|
||||
use Component\Collection\Util\Controller\CircleController;
|
||||
|
||||
class Circle extends CircleController
|
||||
{
|
||||
public function circleById(int|ActorCircle $circle_id): array
|
||||
{
|
||||
$circle = \is_int($circle_id) ? ActorCircle::getByPK(['id' => $circle_id]) : $circle_id;
|
||||
unset($circle_id);
|
||||
if (\is_null($circle)) {
|
||||
throw new ClientException(_m('No such circle.'), 404);
|
||||
} else {
|
||||
return [
|
||||
'_template' => 'collection/actors.html.twig',
|
||||
'title' => _m('Circle'),
|
||||
'empty_message' => _m('No members.'),
|
||||
'sort_form_fields' => [],
|
||||
'page' => $this->int('page') ?? 1,
|
||||
'actors' => $circle->getTaggedActors(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function circleByTaggerIdAndTag(int $tagger_id, string $tag): array
|
||||
{
|
||||
return $this->circleById(ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag]));
|
||||
}
|
||||
|
||||
public function circleByTaggerNicknameAndTag(string $tagger_nickname, string $tag): array
|
||||
{
|
||||
return $this->circleById(ActorCircle::getByPK(['tagger' => LocalUser::getByNickname($tagger_nickname)->getId(), 'tag' => $tag]));
|
||||
}
|
||||
}
|
107
components/Circle/Controller/Circles.php
Normal file
107
components/Circle/Controller/Circles.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Circle\Controller;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\LocalUser;
|
||||
use Component\Circle\Entity\ActorCircle;
|
||||
use Component\Collection\Util\Controller\MetaCollectionController;
|
||||
|
||||
class Circles extends MetaCollectionController
|
||||
{
|
||||
protected string $slug = 'circle';
|
||||
protected string $plural_slug = 'circles';
|
||||
protected string $page_title = 'Actor circles';
|
||||
|
||||
public function createCollection(int $owner_id, string $name)
|
||||
{
|
||||
return \Component\Circle\Circle::createCircle($owner_id, $name);
|
||||
}
|
||||
public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string
|
||||
{
|
||||
return Router::url(
|
||||
'actor_circle_view_by_circle_id',
|
||||
['circle_id' => $collection_id],
|
||||
);
|
||||
}
|
||||
|
||||
public function getCollectionItems(int $owner_id, $collection_id): array
|
||||
{
|
||||
$notes = []; // TODO: Use Feed::query
|
||||
return [
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'notes' => $notes,
|
||||
];
|
||||
}
|
||||
|
||||
public function feedByCircleId(int $circle_id)
|
||||
{
|
||||
// Owner id isn't used
|
||||
return $this->getCollectionItems(0, $circle_id);
|
||||
}
|
||||
|
||||
public function feedByTaggerIdAndTag(int $tagger_id, string $tag)
|
||||
{
|
||||
// Owner id isn't used
|
||||
$circle_id = ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])->getId();
|
||||
return $this->getCollectionItems($tagger_id, $circle_id);
|
||||
}
|
||||
|
||||
public function feedByTaggerNicknameAndTag(string $tagger_nickname, string $tag)
|
||||
{
|
||||
$tagger_id = LocalUser::getByNickname($tagger_nickname)->getId();
|
||||
$circle_id = ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])->getId();
|
||||
return $this->getCollectionItems($tagger_id, $circle_id);
|
||||
}
|
||||
|
||||
public function getCollectionsByActorId(int $owner_id): array
|
||||
{
|
||||
return DB::findBy(ActorCircle::class, ['tagger' => $owner_id], order_by: ['id' => 'desc']);
|
||||
}
|
||||
public function getCollectionBy(int $owner_id, int $collection_id): ActorCircle
|
||||
{
|
||||
return DB::findOneBy(ActorCircle::class, ['id' => $collection_id, 'actor_id' => $owner_id]);
|
||||
}
|
||||
|
||||
public function setCollectionName(int $actor_id, string $actor_nickname, ActorCircle $collection, string $name)
|
||||
{
|
||||
foreach ($collection->getActorTags(db_reference: true) as $at) {
|
||||
$at->setTag($name);
|
||||
}
|
||||
$collection->setTag($name);
|
||||
Cache::delete(Actor::cacheKeys($actor_id)['circles']);
|
||||
}
|
||||
|
||||
public function removeCollection(int $actor_id, string $actor_nickname, ActorCircle $collection)
|
||||
{
|
||||
foreach ($collection->getActorTags(db_reference: true) as $at) {
|
||||
DB::remove($at);
|
||||
}
|
||||
DB::remove($collection);
|
||||
Cache::delete(Actor::cacheKeys($actor_id)['circles']);
|
||||
}
|
||||
}
|
117
components/Circle/Controller/SelfTagsSettings.php
Normal file
117
components/Circle/Controller/SelfTagsSettings.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Circle\Controller;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\Controller;
|
||||
use App\Core\DB\DB;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity as E;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use Component\Circle\Entity\ActorCircle;
|
||||
use Component\Circle\Entity\ActorTag;
|
||||
use Component\Circle\Form\SelfTagsForm;
|
||||
use Component\Tag\Tag as CompTag;
|
||||
use Symfony\Component\Form\SubmitButton;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class SelfTagsSettings extends Controller
|
||||
{
|
||||
/**
|
||||
* Generic settings page for an Actor's self tags
|
||||
*/
|
||||
public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id)
|
||||
{
|
||||
$actor = Common::actor();
|
||||
if (!$actor->canAdmin($target)) {
|
||||
throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()]));
|
||||
}
|
||||
|
||||
$actor_self_tags = $target->getSelfTags();
|
||||
[$add_form, $existing_form] = SelfTagsForm::handleTags(
|
||||
$request,
|
||||
$actor_self_tags,
|
||||
handle_new: /**
|
||||
* Handle adding tags
|
||||
*/
|
||||
function ($form) use ($request, $target, $details_id) {
|
||||
$data = $form->getData();
|
||||
$tags = $data['new-tags'];
|
||||
foreach ($tags as $tag) {
|
||||
$tag = CompTag::sanitize($tag);
|
||||
|
||||
[$actor_tag, $actor_tag_existed] = ActorTag::createOrUpdate([
|
||||
'tagger' => $target->getId(), // self tag means tagger = tagger in ActorTag
|
||||
'tagged' => $target->getId(),
|
||||
'tag' => $tag,
|
||||
]);
|
||||
if (!$actor_tag_existed) {
|
||||
DB::persist($actor_tag);
|
||||
// Try to find the self-tag circle
|
||||
$actor_circle = DB::findOneBy(
|
||||
ActorCircle::class,
|
||||
[
|
||||
'tagger' => null, // Self-tag circle
|
||||
'tag' => $tag,
|
||||
],
|
||||
return_null: true,
|
||||
);
|
||||
// It is the first time someone uses this self-tag!
|
||||
if (\is_null($actor_circle)) {
|
||||
DB::persist(ActorCircle::create([
|
||||
'tagger' => null, // Self-tag circle
|
||||
'tag' => $tag,
|
||||
'private' => false, // by definition
|
||||
'description' => null, // The controller can show this in every language as appropriate
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
DB::flush();
|
||||
Cache::delete(E\Actor::cacheKeys($target->getId())['self-tags']);
|
||||
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id, '_fragment' => $details_id]);
|
||||
},
|
||||
handle_existing: /**
|
||||
* Handle changes to the existing tags
|
||||
*/
|
||||
function ($form, array $form_definition) use ($request, $target, $details_id) {
|
||||
$changed = false;
|
||||
foreach (array_chunk($form_definition, 2) as $entry) {
|
||||
$tag = CompTag::sanitize($entry[0][2]['data']);
|
||||
|
||||
/** @var SubmitButton $remove */
|
||||
$remove = $form->get($entry[1][0]);
|
||||
if ($remove->isClicked()) {
|
||||
$changed = true;
|
||||
DB::removeBy(
|
||||
'actor_tag',
|
||||
[
|
||||
'tagger' => $target->getId(),
|
||||
'tagged' => $target->getId(),
|
||||
'tag' => $tag,
|
||||
],
|
||||
);
|
||||
// We intentionally leave the self-tag actor circle, even if it is now empty
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
DB::flush();
|
||||
Cache::delete(E\Actor::cacheKeys($target->getId())['self-tags']);
|
||||
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id, '_fragment' => $details_id]);
|
||||
}
|
||||
},
|
||||
remove_label: _m('Remove self tag'),
|
||||
add_label: _m('Add self tag'),
|
||||
);
|
||||
|
||||
return [
|
||||
'_template' => 'self_tags_settings.fragment.html.twig',
|
||||
'add_self_tags_form' => $add_form->createView(),
|
||||
'existing_self_tags_form' => $existing_form?->createView(),
|
||||
];
|
||||
}
|
||||
}
|
@@ -19,11 +19,12 @@ declare(strict_types = 1);
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace App\Entity;
|
||||
namespace Component\Circle\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Core\Router\Router;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
@@ -48,10 +49,10 @@ class ActorCircle extends Entity
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $id;
|
||||
private int $tagger;
|
||||
private ?int $tagger = null; // If null, is the special global self-tag circle
|
||||
private string $tag;
|
||||
private ?string $description;
|
||||
private ?bool $private;
|
||||
private ?string $description = null;
|
||||
private ?bool $private = false;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
@@ -66,20 +67,20 @@ class ActorCircle extends Entity
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setTagger(int $tagger): self
|
||||
public function setTagger(?int $tagger): self
|
||||
{
|
||||
$this->tagger = $tagger;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTagger(): int
|
||||
public function getTagger(): ?int
|
||||
{
|
||||
return $this->tagger;
|
||||
}
|
||||
|
||||
public function setTag(string $tag): self
|
||||
{
|
||||
$this->tag = $tag;
|
||||
$this->tag = mb_substr($tag, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -135,60 +136,86 @@ class ActorCircle extends Entity
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
public function getActorTag()
|
||||
/**
|
||||
* For use with MetaCollection trait only
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->tag;
|
||||
}
|
||||
|
||||
public function getActorTags(bool $db_reference = false): array
|
||||
{
|
||||
$handle = fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]);
|
||||
if ($db_reference) {
|
||||
return $handle();
|
||||
}
|
||||
return Cache::get(
|
||||
"circle-{$this->getId()}-tagged",
|
||||
$handle,
|
||||
);
|
||||
}
|
||||
|
||||
public function getTaggedActors()
|
||||
{
|
||||
return Cache::get(
|
||||
"actor-tag-{$this->getTag()}",
|
||||
fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'canonical' => $this->getTag()], limit: 1)[0], // TODO jank
|
||||
"circle-{$this->getId()}-tagged-actors",
|
||||
function () {
|
||||
if ($this->getTagger()) {
|
||||
return DB::dql('SELECT a FROM actor AS a JOIN actor_tag AS at WITH at.tagged = a.id WHERE at.tag = :tag AND at.tagger = :tagger', ['tag' => $this->getTag(), 'tagger' => $this->getTagger()]);
|
||||
} else { // Self-tag
|
||||
return DB::dql('SELECT a FROM actor AS a JOIN actor_tag AS at WITH at.tagged = a.id WHERE at.tag = :tag AND at.tagger = at.tagged', ['tag' => $this->getTag()]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function getSubscribedActors(?int $offset = null, ?int $limit = null): array
|
||||
{
|
||||
return Cache::get(
|
||||
"circle-{$this->getId()}",
|
||||
"circle-{$this->getId()}-subscribers",
|
||||
fn () => DB::dql(
|
||||
<<< 'EOQ'
|
||||
SELECT a
|
||||
FROM App\Entity\Actor a
|
||||
JOIN App\Entity\ActorCircleSubscription s
|
||||
FROM actor a
|
||||
JOIN actor_circle_subscription s
|
||||
WITH a.id = s.actor_id
|
||||
ORDER BY s.created DESC, a.id DESC
|
||||
EOQ,
|
||||
options: ['offset' => $offset,
|
||||
'limit' => $limit, ],
|
||||
options: [
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function getUrl(int $type = Router::ABSOLUTE_PATH): string
|
||||
{
|
||||
return Router::url('actor_circle_view_by_circle_id', ['circle_id' => $this->getId()], type: $type);
|
||||
}
|
||||
|
||||
public static function schemaDef(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'actor_circle',
|
||||
'description' => 'a actor can have lists of actors, to separate their feed',
|
||||
'description' => 'An actor can have lists of actors, to separate their feed or quickly mention his friend',
|
||||
'fields' => [
|
||||
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'],
|
||||
// An actor can be tagged by many actors
|
||||
'tagger' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'actor_list_tagger_fkey', 'not null' => true, 'description' => 'user making the tag'],
|
||||
// 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
|
||||
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], // An actor can be tagged by many actors
|
||||
'tagger' => ['type' => 'int', 'default' => null, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'actor_list_tagger_fkey', 'description' => 'user making the tag, null if self-tag'],
|
||||
'tag' => ['type' => 'varchar', 'length' => 64, 'foreign key' => true, 'target' => 'ActorTag.tag', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'actor tag'], // Join with ActorTag // // so, Doctrine doesn't like that the target is not unique, even though the pair is // Many Actor Circles can reference (and probably will) an Actor Tag
|
||||
'description' => ['type' => 'text', 'description' => 'description of the people tag'],
|
||||
'private' => ['type' => 'bool', 'default' => false, 'description' => 'is this tag private'],
|
||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
||||
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
||||
],
|
||||
'primary key' => ['id'],
|
||||
'primary key' => ['id'], // But we will mostly refer to them with `tagger` and `tag`
|
||||
'indexes' => [
|
||||
'actor_list_modified_idx' => ['modified'],
|
||||
'actor_list_tagger_tag_idx' => ['tagger', 'tag'], // The actual identifier we will use the most
|
||||
'actor_list_tag_idx' => ['tag'],
|
||||
'actor_list_tagger_tag_idx' => ['tagger', 'tag'],
|
||||
'actor_list_tagger_idx' => ['tagger'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
return $this->getTag();
|
||||
}
|
||||
}
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
@@ -17,7 +19,7 @@
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace App\Entity;
|
||||
namespace Component\Circle\Entity;
|
||||
|
||||
use App\Core\Entity;
|
||||
use DateTimeInterface;
|
||||
@@ -59,13 +61,13 @@ class ActorCircleSubscription extends Entity
|
||||
return $this->actor_id;
|
||||
}
|
||||
|
||||
public function setCircleid(int $circle_id): self
|
||||
public function setCircleId(int $circle_id): self
|
||||
{
|
||||
$this->circle_id = $circle_id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCircleid(): int
|
||||
public function getCircleId(): int
|
||||
{
|
||||
return $this->circle_id;
|
||||
}
|
||||
@@ -98,18 +100,18 @@ class ActorCircleSubscription extends Entity
|
||||
public static function schemaDef(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'actor_circle_subscription',
|
||||
'name' => 'actor_circle_subscription',
|
||||
'fields' => [
|
||||
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_circle_subscription_actor_id_fkey', 'not null' => true, 'description' => 'foreign key to actor table'],
|
||||
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_circle_subscription_actor_id_fkey', 'not null' => true, 'description' => 'foreign key to actor table'],
|
||||
// An actor subscribes many circles; A Circle is subscribed by many actors.
|
||||
'circle_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'ActorCircle.id', 'multiplicity' => 'one to many', 'name' => 'actor_circle_subscription_actor_circle_fkey', 'not null' => true, 'description' => 'foreign key to actor_circle'],
|
||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
||||
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
||||
],
|
||||
'primary key' => ['circle_id', 'actor_id'],
|
||||
'indexes' => [
|
||||
'indexes' => [
|
||||
'actor_circle_subscription_actor_id_idx' => ['actor_id'],
|
||||
'actor_circle_subscription_created_idx' => ['created'],
|
||||
'actor_circle_subscription_created_idx' => ['created'],
|
||||
],
|
||||
];
|
||||
}
|
@@ -19,12 +19,12 @@ declare(strict_types = 1);
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace App\Entity;
|
||||
namespace Component\Circle\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Actor;
|
||||
use Component\Tag\Tag;
|
||||
use DateTimeInterface;
|
||||
|
||||
@@ -54,8 +54,6 @@ class ActorTag extends Entity
|
||||
private int $tagger;
|
||||
private int $tagged;
|
||||
private string $tag;
|
||||
private string $canonical;
|
||||
private bool $use_canonical;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setTagger(int $tagger): self
|
||||
@@ -82,7 +80,7 @@ class ActorTag extends Entity
|
||||
|
||||
public function setTag(string $tag): self
|
||||
{
|
||||
$this->tag = $tag;
|
||||
$this->tag = mb_substr($tag, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -91,28 +89,6 @@ class ActorTag extends Entity
|
||||
return $this->tag;
|
||||
}
|
||||
|
||||
public function setCanonical(string $canonical): self
|
||||
{
|
||||
$this->canonical = $canonical;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCanonical(): string
|
||||
{
|
||||
return $this->canonical;
|
||||
}
|
||||
|
||||
public function setUseCanonical(bool $use_canonical): self
|
||||
{
|
||||
$this->use_canonical = $use_canonical;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUseCanonical(): bool
|
||||
{
|
||||
return $this->use_canonical;
|
||||
}
|
||||
|
||||
public function setModified(DateTimeInterface $modified): self
|
||||
{
|
||||
$this->modified = $modified;
|
||||
@@ -127,18 +103,22 @@ class ActorTag extends Entity
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
public static function getByActorId(int $actor_id): array
|
||||
public function getUrl(?Actor $actor = null, int $type = Router::ABSOLUTE_PATH): string
|
||||
{
|
||||
return Cache::getList(Actor::cacheKeys($actor_id)['tags'], fn () => DB::dql('select at from actor_tag at join actor a with a.id = at.tagger where a.id = :id', ['id' => $actor_id]));
|
||||
$params = ['tag' => $this->getTag()];
|
||||
if (!\is_null($actor)) {
|
||||
$params['locale'] = $actor->getTopLanguage()->getLocale();
|
||||
}
|
||||
return Router::url('single_actor_tag', $params, type: $type);
|
||||
}
|
||||
|
||||
public function getUrl(?Actor $actor = null): string
|
||||
public function getCircle(): ActorCircle
|
||||
{
|
||||
$params = ['canon' => $this->getCanonical(), 'tag' => $this->getTag()];
|
||||
if (!\is_null($actor)) {
|
||||
$params['lang'] = $actor->getTopLanguage()->getLocale();
|
||||
if ($this->getTagger() === $this->getTagged()) { // Self-tag
|
||||
return DB::findOneBy(ActorCircle::class, ['tagger' => null, 'tag' => $this->getTag()]);
|
||||
} else {
|
||||
return DB::findOneBy(ActorCircle::class, ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]);
|
||||
}
|
||||
return Router::url('single_actor_tag', $params);
|
||||
}
|
||||
|
||||
public static function schemaDef(): array
|
||||
@@ -146,24 +126,16 @@ class ActorTag extends Entity
|
||||
return [
|
||||
'name' => 'actor_tag',
|
||||
'fields' => [
|
||||
'tagger' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagger_fkey', 'not null' => true, 'description' => 'actor making the tag'],
|
||||
'tagged' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagged_fkey', 'not null' => true, 'description' => 'actor tagged'],
|
||||
'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hash tag associated with this actor'],
|
||||
'canonical' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'ascii slug of tag'],
|
||||
'use_canonical' => ['type' => 'bool', 'not null' => true, 'description' => 'whether the user wanted to block canonical tags'],
|
||||
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
||||
],
|
||||
'primary key' => ['tagger', 'tagged', 'tag', 'use_canonical'],
|
||||
'tagger' => ['type' => 'int', 'not null' => true, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagger_fkey', 'description' => 'actor making the tag'],
|
||||
'tagged' => ['type' => 'int', 'not null' => true, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagged_fkey', 'description' => 'actor tagged'],
|
||||
'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hashtag associated with this note'],
|
||||
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
||||
], // We will always assume the tagger's preferred language for tags and circles
|
||||
'primary key' => ['tagger', 'tagged', 'tag'],
|
||||
'indexes' => [
|
||||
'actor_tag_modified_idx' => ['modified'],
|
||||
'actor_tag_tagger_tag_idx' => ['tagger', 'tag'], // For Circles
|
||||
'actor_tag_tagged_idx' => ['tagged'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getTag();
|
||||
}
|
||||
}
|
@@ -2,13 +2,11 @@
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Tag\Form;
|
||||
namespace Component\Circle\Form;
|
||||
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity as E;
|
||||
use App\Util\Form\ArrayTransformer;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -16,31 +14,27 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
abstract class SelfTagsForm
|
||||
{
|
||||
/**
|
||||
* @param E\ActorTag[]|E\ActorTagBlock[]|E\NoteTagBlock[] $tags
|
||||
*
|
||||
* @return array [Form (add), ?Form (existing)]
|
||||
*/
|
||||
public static function handleTags(
|
||||
Request $request,
|
||||
array $tags,
|
||||
array $actor_self_tags,
|
||||
callable $handle_new,
|
||||
callable $handle_existing,
|
||||
string $remove_label,
|
||||
string $add_label,
|
||||
): array {
|
||||
$form_definition = [];
|
||||
foreach ($tags as $tag) {
|
||||
$canon = $tag->getCanonical();
|
||||
$form_definition[] = ["{$canon}:old-tag", TextType::class, ['data' => '#' . $tag->getTag(), 'label' => ' ', 'disabled' => true]];
|
||||
$form_definition[] = ["{$canon}:toggle-canon", SubmitType::class, ['attr' => ['data' => $tag->getUseCanonical()], 'label' => $tag->getUseCanonical() ? _m('Set non-canonical') : _m('Set canonical')]];
|
||||
$form_definition[] = [$existing_form_name = "{$canon}:remove", SubmitType::class, ['label' => $remove_label]];
|
||||
foreach ($actor_self_tags as $tag) {
|
||||
$tag = $tag->getTag();
|
||||
$form_definition[] = ["{$tag}:old-tag", TextType::class, ['data' => $tag, 'label' => ' ', 'disabled' => true]];
|
||||
$form_definition[] = [$existing_form_name = "{$tag}:remove", SubmitType::class, ['label' => $remove_label]];
|
||||
}
|
||||
|
||||
$existing_form = !empty($form_definition) ? Form::create($form_definition) : null;
|
||||
|
||||
$add_form = Form::create([
|
||||
['new-tags', TextType::class, ['label' => ' ', 'data' => [], 'required' => false, 'help' => _m('Tags for yourself (letters, numbers, -, ., and _), comma- or space-separated.'), 'transformer' => ArrayTransformer::class]],
|
||||
['new-tags-use-canon', CheckboxType::class, ['label' => _m('Use canonical'), 'help' => _m('Assume this tag is the same as similar tags'), 'required' => false, 'data' => true]],
|
||||
[$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]],
|
||||
]);
|
||||
|
148
components/Collection/Collection.php
Normal file
148
components/Collection/Collection.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Collection;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Formatting;
|
||||
use Component\Collection\Util\Parser;
|
||||
use Component\Subscription\Entity\ActorSubscription;
|
||||
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||
use Doctrine\ORM\Query\Expr;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class Collection extends Component
|
||||
{
|
||||
/**
|
||||
* Perform a high level query on notes or actors
|
||||
*
|
||||
* Supports a variety of query terms and is used both in feeds and
|
||||
* in search. Uses query builders to allow for extension
|
||||
*/
|
||||
public static function query(string $query, int $page, ?string $locale = null, ?Actor $actor = null): array
|
||||
{
|
||||
$note_criteria = null;
|
||||
$actor_criteria = null;
|
||||
if (!empty($query = trim($query))) {
|
||||
[$note_criteria, $actor_criteria] = Parser::parse($query, $locale, $actor);
|
||||
}
|
||||
$note_qb = DB::createQueryBuilder();
|
||||
$actor_qb = DB::createQueryBuilder();
|
||||
// TODO consider selecting note related stuff, to avoid separate queries (though they're cached, so maybe it's okay)
|
||||
$note_qb->select('note')->from('App\Entity\Note', 'note')->orderBy('note.created', 'DESC')->addOrderBy('note.id', 'DESC');
|
||||
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor')->orderBy('actor.created', 'DESC')->addOrderBy('actor.id', 'DESC');
|
||||
Event::handle('CollectionQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
|
||||
|
||||
$notes = [];
|
||||
$actors = [];
|
||||
if (!\is_null($note_criteria)) {
|
||||
$note_qb->addCriteria($note_criteria);
|
||||
$notes = $note_qb->getQuery()->execute();
|
||||
}
|
||||
|
||||
if (!\is_null($actor_criteria)) {
|
||||
$actor_qb->addCriteria($actor_criteria);
|
||||
$actors = $actor_qb->getQuery()->execute();
|
||||
}
|
||||
|
||||
// N.B.: Scope is only enforced at FeedController level
|
||||
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
|
||||
}
|
||||
|
||||
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
{
|
||||
$note_qb->leftJoin(ActorSubscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed_id')
|
||||
->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text
|
||||
* notes, for different types of actors and for the content of text notes
|
||||
*/
|
||||
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr)
|
||||
{
|
||||
if (str_contains($term, ':')) {
|
||||
$term = explode(':', $term);
|
||||
if (Formatting::startsWith($term[0], 'note')) {
|
||||
switch ($term[0]) {
|
||||
case 'notes-all':
|
||||
$note_expr = $eb->neq('note.created', null);
|
||||
break;
|
||||
case 'note-local':
|
||||
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
|
||||
break;
|
||||
case 'note-types':
|
||||
case 'notes-include':
|
||||
case 'note-filter':
|
||||
if (\is_null($note_expr)) {
|
||||
$note_expr = [];
|
||||
}
|
||||
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
|
||||
$note_expr[] = $eb->neq('note.content', null);
|
||||
} else {
|
||||
$note_expr[] = $eb->eq('note.content', null);
|
||||
}
|
||||
break;
|
||||
case 'note-conversation':
|
||||
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
|
||||
break;
|
||||
case 'note-from':
|
||||
case 'notes-from':
|
||||
$subscribed_expr = $eb->eq('subscription.subscriber_id', $actor->getId());
|
||||
$type_consts = [];
|
||||
if ($term[1] === 'subscribed') {
|
||||
$type_consts = null;
|
||||
}
|
||||
foreach (explode(',', $term[1]) as $from) {
|
||||
if (str_starts_with($from, 'subscribed-')) {
|
||||
[, $type] = explode('-', $from);
|
||||
if (\in_array($type, ['actor', 'actors'])) {
|
||||
$type_consts = null;
|
||||
} else {
|
||||
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (\is_null($type_consts)) {
|
||||
$note_expr = $subscribed_expr;
|
||||
} elseif (!empty($type_consts)) {
|
||||
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
|
||||
}
|
||||
break;
|
||||
}
|
||||
} elseif (Formatting::startsWith($term, 'actor-')) {
|
||||
switch ($term[0]) {
|
||||
case 'actor-types':
|
||||
case 'actors-include':
|
||||
case 'actor-filter':
|
||||
case 'actor-local':
|
||||
if (\is_null($actor_expr)) {
|
||||
$actor_expr = [];
|
||||
}
|
||||
foreach (
|
||||
[
|
||||
Actor::PERSON => ['person', 'people'],
|
||||
Actor::GROUP => ['group', 'groups'],
|
||||
Actor::ORGANISATION => ['org', 'orgs', 'organization', 'organizations', 'organisation', 'organisations'],
|
||||
Actor::BOT => ['bot', 'bots'],
|
||||
] as $type => $match) {
|
||||
if (array_intersect(explode(',', $term[1]), $match) !== []) {
|
||||
$actor_expr[] = $eb->eq('actor.type', $type);
|
||||
} else {
|
||||
$actor_expr[] = $eb->neq('actor.type', $type);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$note_expr = $eb->contains('note.content', $term);
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
}
|
@@ -30,16 +30,14 @@ declare(strict_types = 1);
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace App\Core\Controller;
|
||||
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;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use Component\Feed\Util\FeedController;
|
||||
|
||||
abstract class ActorController extends FeedController
|
||||
trait ActorControllerTrait
|
||||
{
|
||||
/**
|
||||
* Generic function that handles getting a representation for an actor from id
|
||||
@@ -48,7 +46,7 @@ abstract class ActorController extends FeedController
|
||||
{
|
||||
$actor = DB::findOneBy('actor', ['id' => $id]);
|
||||
if ($actor->getIsLocal()) {
|
||||
throw new RedirectException(url: $actor->getUrl(Router::ABSOLUTE_PATH));
|
||||
return ['_redirect' => $actor->getUrl(Router::ABSOLUTE_PATH), 'actor' => $actor];
|
||||
}
|
||||
if (empty($actor)) {
|
||||
throw new ClientException(_m('No such actor.'), 404);
|
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Collection\Util\Controller;
|
||||
|
||||
class CircleController extends OrderedCollection
|
||||
{
|
||||
}
|
20
components/Collection/Util/Controller/Collection.php
Normal file
20
components/Collection/Util/Controller/Collection.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Collection\Util\Controller;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Common;
|
||||
use Component\Collection\Collection as CollectionModule;
|
||||
|
||||
class Collection extends Controller
|
||||
{
|
||||
public function query(string $query, ?string $locale = null, ?Actor $actor = null): array
|
||||
{
|
||||
$actor ??= Common::actor();
|
||||
$locale ??= Common::currentLanguage()->getLocale();
|
||||
return CollectionModule::query($query, $this->int('page') ?? 1, $locale, $actor);
|
||||
}
|
||||
}
|
@@ -30,29 +30,36 @@ declare(strict_types = 1);
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace Component\Feed\Util;
|
||||
namespace Component\Collection\Util\Controller;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Core\Event;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use Functional as F;
|
||||
|
||||
abstract class FeedController extends Controller
|
||||
abstract class FeedController extends OrderedCollection
|
||||
{
|
||||
/**
|
||||
* Post process 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
|
||||
* list of notes into a usable format
|
||||
*/
|
||||
public static function post_process(array $result): array
|
||||
protected function postProcess(array $result): array
|
||||
{
|
||||
$actor = Common::actor();
|
||||
|
||||
if (\array_key_exists('notes', $result)) {
|
||||
$notes = $result['notes'];
|
||||
self::enforceScope($notes, $actor);
|
||||
Event::handle('FilterNoteList', [$actor, &$notes, $result['request']]);
|
||||
Event::handle('FormatNoteList', [$notes, &$result['notes']]);
|
||||
Event::handle('FormatNoteList', [$notes, &$result['notes'], &$result['request']]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function enforceScope(array &$notes, ?Actor $actor): void
|
||||
{
|
||||
$notes = F\select($notes, fn (Note $n) => $n->isVisibleTo($actor));
|
||||
}
|
||||
}
|
@@ -3,7 +3,6 @@
|
||||
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
|
||||
@@ -18,109 +17,123 @@ declare(strict_types = 1);
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
// }}}
|
||||
/**
|
||||
* Collections Controller for GNU social
|
||||
*
|
||||
* @package GNUsocial
|
||||
* @category Plugin
|
||||
*
|
||||
* @author Phablulo <phablulo@gmail.com>
|
||||
* @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace Plugin\AttachmentCollections\Controller;
|
||||
namespace Component\Collection\Util\Controller;
|
||||
|
||||
use App\Core\Form;
|
||||
use App\Core\DB\DB;
|
||||
use App\Util\Common;
|
||||
use App\Core\Router\Router;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use Component\Feed\Util\FeedController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Plugin\AttachmentCollections\Entity\Collection;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Controller extends FeedController
|
||||
abstract class MetaCollectionController extends FeedController
|
||||
{
|
||||
public function collectionsByActorNickname(Request $request, string $nickname): array
|
||||
protected string $slug = 'collectionsEntry';
|
||||
protected string $plural_slug = 'collectionsList';
|
||||
protected string $page_title = 'Collections';
|
||||
|
||||
abstract public function getCollectionUrl(int $owner_id, string $owner_nickname, int $collection_id): string;
|
||||
abstract public function getCollectionItems(int $owner_id, $collection_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);
|
||||
|
||||
public function collectionsViewByActorNickname(Request $request, string $nickname): array
|
||||
{
|
||||
$user = DB::findOneBy('local_user', ['nickname' => $nickname]);
|
||||
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
|
||||
return self::collectionsView($request, $user->getId(), $nickname);
|
||||
}
|
||||
|
||||
public function collectionsViewByActorId(Request $request, int $id): array
|
||||
{
|
||||
return self::collectionsView($request, $id, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Collections page
|
||||
*
|
||||
* @param int $id actor id
|
||||
* @param ?string $nickname actor nickname
|
||||
* @return array twig template options
|
||||
*
|
||||
* @return array twig template options
|
||||
*/
|
||||
public function collectionsView(Request $request, int $id, ?string $nickname): array
|
||||
{
|
||||
$collections = DB::dql(
|
||||
'select collection from Plugin\AttachmentCollections\Entity\Collection collection '
|
||||
. 'where collection.actor_id = :id',
|
||||
['id' => $id]
|
||||
);
|
||||
$collections = $this->getCollectionsByActorId($id);
|
||||
|
||||
$create_title = _m('Create a ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->slug)));
|
||||
$collections_title = _m('The ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->plural_slug)));
|
||||
// create collection form
|
||||
$create = null;
|
||||
if (Common::user()?->getId() === $id) {
|
||||
$create = Form::create([
|
||||
['name', TextType::class, [
|
||||
'label' => _m('Create collection'),
|
||||
'attr' => [
|
||||
'label' => $create_title,
|
||||
'attr' => [
|
||||
'placeholder' => _m('Name'),
|
||||
'required' => 'required'
|
||||
'required' => 'required',
|
||||
],
|
||||
'data' => '',
|
||||
]],
|
||||
['add_collection', SubmitType::class, [
|
||||
'label' => _m('Create collection'),
|
||||
'label' => $create_title,
|
||||
'attr' => [
|
||||
'title' => _m('Create collection'),
|
||||
'title' => $create_title,
|
||||
],
|
||||
]],
|
||||
]);
|
||||
$create->handleRequest($request);
|
||||
if ($create->isSubmitted() && $create->isValid()) {
|
||||
DB::persist(Collection::create([
|
||||
'name' => $create->getData()['name'],
|
||||
'actor_id' => $id,
|
||||
]));
|
||||
$this->createCollection($id, $create->getData()['name']);
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
||||
}
|
||||
}
|
||||
|
||||
// We need to inject some functions in twig,
|
||||
// but i don't want to create an enviroment for this
|
||||
// but I don't want to create an environment for this
|
||||
// as twig docs suggest in https://twig.symfony.com/doc/2.x/advanced.html#functions.
|
||||
//
|
||||
// Instead, I'm using an anonymous class to encapsulate
|
||||
// the functions and passing how the class to the template.
|
||||
// It's suggested at https://stackoverflow.com/a/50364502.
|
||||
$fn = new class ($id, $nickname, $request)
|
||||
{
|
||||
// the functions and passing that class to the template.
|
||||
// This is suggested at https://stackoverflow.com/a/50364502.
|
||||
$fn = new class($id, $nickname, $request, $this, $this->slug) {
|
||||
private $id;
|
||||
private $nick;
|
||||
private $request;
|
||||
public function __construct($id, $nickname, $request)
|
||||
private $parent;
|
||||
private $slug;
|
||||
|
||||
public function __construct($id, $nickname, $request, $parent, $slug)
|
||||
{
|
||||
$this->id = $id;
|
||||
$this->nick = $nickname;
|
||||
$this->id = $id;
|
||||
$this->nick = $nickname;
|
||||
$this->request = $request;
|
||||
$this->parent = $parent;
|
||||
$this->slug = $slug;
|
||||
}
|
||||
// there's already a injected function called path,
|
||||
// there's already an injected function called path,
|
||||
// that maps to Router::url(name, args), but since
|
||||
// I want to preserve nicknames, I think it's better
|
||||
// to use that getUrl function
|
||||
public function getUrl($cid)
|
||||
{
|
||||
if (\is_null($this->nick)) {
|
||||
return Router::url(
|
||||
'collection_notes_view_by_actor_id',
|
||||
['id' => $this->id, 'cid' => $cid]
|
||||
);
|
||||
}
|
||||
return Router::url(
|
||||
'collection_notes_view_by_nickname',
|
||||
['nickname' => $this->nick, 'cid' => $cid]
|
||||
);
|
||||
return $this->parent->getCollectionUrl($this->id, $this->nick, $cid);
|
||||
}
|
||||
// There are many collections in this page and we need two
|
||||
// forms for each one of them: one form to edit the collection's
|
||||
@@ -133,11 +146,11 @@ class Controller extends FeedController
|
||||
['name', TextType::class, [
|
||||
'attr' => [
|
||||
'placeholder' => 'New name',
|
||||
'required' => 'required'
|
||||
'required' => 'required',
|
||||
],
|
||||
'data' => '',
|
||||
]],
|
||||
['update_'.$collection->getId(), SubmitType::class, [
|
||||
['update_' . $collection->getId(), SubmitType::class, [
|
||||
'label' => _m('Save'),
|
||||
'attr' => [
|
||||
'title' => _m('Save'),
|
||||
@@ -146,61 +159,58 @@ class Controller extends FeedController
|
||||
]);
|
||||
$edit->handleRequest($this->request);
|
||||
if ($edit->isSubmitted() && $edit->isValid()) {
|
||||
$collection->setName($edit->getData()['name']);
|
||||
DB::persist($collection);
|
||||
$this->parent->setCollectionName($this->id, $this->nick, $collection, $edit->getData()['name']);
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
||||
}
|
||||
return $edit->createView();
|
||||
}
|
||||
|
||||
// creating the remove form
|
||||
public function rmForm($collection)
|
||||
{
|
||||
$rm = Form::create([
|
||||
['remove_'.$collection->getId(), SubmitType::class, [
|
||||
'label' => _m('Delete collection'),
|
||||
['remove_' . $collection->getId(), SubmitType::class, [
|
||||
'label' => _m('Delete ' . $this->slug),
|
||||
'attr' => [
|
||||
'title' => _m('Delete collection'),
|
||||
'title' => _m('Delete ' . $this->slug),
|
||||
'class' => 'danger',
|
||||
],
|
||||
]],
|
||||
]);
|
||||
$rm->handleRequest($this->request);
|
||||
if ($rm->isSubmitted()) {
|
||||
DB::remove($collection);
|
||||
$this->parent->removeCollection($this->id, $this->nick, $collection);
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
||||
}
|
||||
return $rm->createView();
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
'_template' => 'AttachmentCollections/collections.html.twig',
|
||||
'page_title' => 'Attachment Collections list',
|
||||
'_template' => 'collection/meta_collections.html.twig',
|
||||
'page_title' => $this->page_title,
|
||||
'list_title' => $collections_title,
|
||||
'add_collection' => $create?->createView(),
|
||||
'fn' => $fn,
|
||||
'collections' => $collections,
|
||||
];
|
||||
}
|
||||
|
||||
public function collectionNotesByNickname(Request $request, string $nickname, int $cid): array
|
||||
public function collectionsEntryViewNotesByNickname(Request $request, string $nickname, int $cid): array
|
||||
{
|
||||
$user = DB::findOneBy('local_user', ['nickname' => $nickname]);
|
||||
return self::collectionNotesByActorId($request, $user->getId(), $cid);
|
||||
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
|
||||
return self::collectionsEntryViewNotesByActorId($request, $user->getId(), $cid);
|
||||
}
|
||||
public function collectionNotesByActorId(Request $request, int $id, int $cid): array
|
||||
|
||||
public function collectionsEntryViewNotesByActorId(Request $request, int $id, int $cid): array
|
||||
{
|
||||
$collection = DB::findOneBy('attachment_collection', ['id' => $cid]);
|
||||
$attchs = DB::dql(
|
||||
'select attch from attachment_album_entry entry '
|
||||
. 'left join Component\Attachment\Entity\Attachment attch '
|
||||
. 'with entry.attachment_id = attch.id '
|
||||
. 'where entry.collection_id = :cid',
|
||||
['cid' => $cid]
|
||||
);
|
||||
return [
|
||||
'_template' => 'AttachmentCollections/collection.html.twig',
|
||||
'page_title' => $collection->getName(),
|
||||
'attachments' => $attchs,
|
||||
];
|
||||
$collection = $this->getCollectionBy($id, $cid);
|
||||
$vars = $this->getCollectionItems($id, $cid);
|
||||
return array_merge([
|
||||
'_template' => 'collections/collection_entry_view.html.twig',
|
||||
'page_title' => $collection->getName(),
|
||||
], $vars);
|
||||
}
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Collection\Util\Controller;
|
||||
|
||||
class OrderedCollection extends Collection
|
||||
{
|
||||
}
|
195
components/Collection/Util/MetaCollectionTrait.php
Normal file
195
components/Collection/Util/MetaCollectionTrait.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?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/>.
|
||||
// }}}
|
||||
/**
|
||||
* Collections for GNU social
|
||||
*
|
||||
* @package GNUsocial
|
||||
* @category Plugin
|
||||
*
|
||||
* @author Phablulo <phablulo@gmail.com>
|
||||
* @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace Component\Collection\Util;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Formatting;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
trait MetaCollectionTrait
|
||||
{
|
||||
//protected string $slug = 'collection';
|
||||
//protected string $plural_slug = 'collections';
|
||||
|
||||
/**
|
||||
* create a collection owned by Actor $owner.
|
||||
*
|
||||
* @param Actor $owner The collection's owner
|
||||
* @param array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param string $name Collection's name
|
||||
*/
|
||||
abstract protected function createCollection(Actor $owner, array $vars, string $name);
|
||||
/**
|
||||
* remove item from collections.
|
||||
*
|
||||
* @param Actor $owner Current user
|
||||
* @param array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param array $items Array of collections's ids to remove the current item from
|
||||
* @param array $collections List of ids of collections owned by $owner
|
||||
*/
|
||||
abstract protected function removeItem(Actor $owner, array $vars, array $items, array $collections);
|
||||
/**
|
||||
* add item to collections.
|
||||
*
|
||||
* @param Actor $owner Current user
|
||||
* @param array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param array $items Array of collections's ids to add the current item to
|
||||
* @param array $collections List of ids of collections owned by $owner
|
||||
*/
|
||||
abstract protected function addItem(Actor $owner, array $vars, array $items, array $collections);
|
||||
|
||||
/**
|
||||
* Check the route to determine whether the widget should be added
|
||||
*/
|
||||
abstract protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool;
|
||||
/**
|
||||
* Get array of collections's owned by $actor
|
||||
*
|
||||
* @param Actor $owner Collection's owner
|
||||
* @param ?array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param bool $ids_only if true, the function must return only the primary key or each collections
|
||||
*/
|
||||
abstract protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array;
|
||||
|
||||
/**
|
||||
* Append Collections widget to the right panel.
|
||||
* It's compose of two forms: one to select collections to add
|
||||
* the current item to, and another to create a new collection.
|
||||
*/
|
||||
public function onAppendRightPanelBlock(Request $request, $vars, &$res): bool
|
||||
{
|
||||
$user = Common::actor();
|
||||
if (\is_null($user)) {
|
||||
return Event::next;
|
||||
}
|
||||
if (!$this->shouldAddToRightPanel($user, $vars, $request)) {
|
||||
return Event::next;
|
||||
}
|
||||
$collections = $this->getCollectionsBy($user);
|
||||
|
||||
// form: add to collection
|
||||
$choices = [];
|
||||
foreach ($collections as $col) {
|
||||
$choices[$col->getName()] = $col->getId();
|
||||
}
|
||||
|
||||
$collections = array_map(fn ($x) => $x->getId(), $collections);
|
||||
|
||||
$already_selected = $this->getCollectionsBy($user, $vars, true);
|
||||
$add_form = Form::create([
|
||||
['collections', ChoiceType::class, [
|
||||
'choices' => $choices,
|
||||
'multiple' => true,
|
||||
'required' => false,
|
||||
'choice_attr' => function ($id) use ($already_selected) {
|
||||
if (\in_array($id, $already_selected)) {
|
||||
return ['selected' => 'selected'];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
]],
|
||||
['add', SubmitType::class, [
|
||||
'label' => _m('Add to ' . $this->plural_slug),
|
||||
'attr' => [
|
||||
'title' => _m('Add to ' . $this->plural_slug),
|
||||
],
|
||||
]],
|
||||
]);
|
||||
$add_form->handleRequest($request);
|
||||
if ($add_form->isSubmitted() && $add_form->isValid()) {
|
||||
$selected = $add_form->getData()['collections'];
|
||||
$removed = array_filter($already_selected, fn ($x) => !\in_array($x, $selected));
|
||||
$added = array_filter($selected, fn ($x) => !\in_array($x, $already_selected));
|
||||
if (\count($removed) > 0) {
|
||||
$this->removeItem($user, $vars, $removed, $collections);
|
||||
}
|
||||
if (\count($added) > 0) {
|
||||
$this->addItem($user, $vars, $added, $collections);
|
||||
}
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
||||
}
|
||||
|
||||
// form: add to new collection
|
||||
$create_form = Form::create([
|
||||
['name', TextType::class, [
|
||||
'label' => _m('Add to a new ' . $this->slug),
|
||||
'attr' => [
|
||||
'placeholder' => _m('New ' . $this->slug . ' name'),
|
||||
'required' => 'required',
|
||||
],
|
||||
'data' => '',
|
||||
]],
|
||||
['create', SubmitType::class, [
|
||||
'label' => _m('Create a new ' . $this->slug),
|
||||
'attr' => [
|
||||
'title' => _m('Create a new ' . $this->slug),
|
||||
],
|
||||
]],
|
||||
]);
|
||||
$create_form->handleRequest($request);
|
||||
if ($create_form->isSubmitted() && $create_form->isValid()) {
|
||||
$name = $create_form->getData()['name'];
|
||||
$this->createCollection($user, $vars, $name);
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
||||
}
|
||||
|
||||
$res[] = Formatting::twigRenderFile(
|
||||
'collection/widget_add_to.html.twig',
|
||||
[
|
||||
'ctitle' => _m('Add to ' . $this->plural_slug),
|
||||
'user' => $user,
|
||||
'has_collections' => \count($collections) > 0,
|
||||
'add_form' => $add_form->createView(),
|
||||
'create_form' => $create_form->createView(),
|
||||
],
|
||||
);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onEndShowStyles(array &$styles, string $route): bool
|
||||
{
|
||||
$styles[] = 'components/Collection/assets/css/widget.css';
|
||||
$styles[] = 'components/Collection/assets/css/pages.css';
|
||||
return Event::next;
|
||||
}
|
||||
}
|
@@ -21,7 +21,7 @@ declare(strict_types = 1);
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Search\Util;
|
||||
namespace Component\Collection\Util;
|
||||
|
||||
use App\Core\Event;
|
||||
use App\Entity\Actor;
|
||||
@@ -53,7 +53,7 @@ abstract class Parser
|
||||
*
|
||||
* @return Criteria[]
|
||||
*/
|
||||
public static function parse(string $input, ?string $language = null, ?Actor $actor = null, int $level = 0): array
|
||||
public static function parse(string $input, ?string $locale = null, ?Actor $actor = null, int $level = 0): array
|
||||
{
|
||||
if ($level === 0) {
|
||||
$input = trim(preg_replace(['/\s+/', '/\s+AND\s+/', '/\s+OR\s+/'], [' ', '&', '|'], $input), ' |&');
|
||||
@@ -78,17 +78,17 @@ abstract class Parser
|
||||
$term = mb_substr($input, $left, $end ? null : $right - $left);
|
||||
$note_res = null;
|
||||
$actor_res = null;
|
||||
Event::handle('SearchCreateExpression', [$eb, $term, $language, $actor, &$note_res, &$actor_res]);
|
||||
if (\is_null($note_res) && \is_null($actor_res)) {
|
||||
Event::handle('CollectionQueryCreateExpression', [$eb, $term, $locale, $actor, &$note_res, &$actor_res]);
|
||||
if (\is_null($note_res) && \is_null($actor_res)) { // @phpstan-ignore-line
|
||||
throw new ServerException("No one claimed responsibility for a match term: {$term}");
|
||||
}
|
||||
if (!\is_null($note_res) && !empty($note_res)) {
|
||||
if (!empty($note_res)) { // @phpstan-ignore-line
|
||||
if (\is_array($note_res)) {
|
||||
$note_res = $eb->orX(...$note_res);
|
||||
}
|
||||
$note_parts[] = $note_res;
|
||||
}
|
||||
if (!\is_null($actor_res) && !empty($note_res)) {
|
||||
if (!empty($actor_res)) {
|
||||
if (\is_array($actor_res)) {
|
||||
$actor_res = $eb->orX(...$actor_res);
|
||||
}
|
||||
@@ -103,21 +103,22 @@ abstract class Parser
|
||||
$last_op = $delimiter;
|
||||
}
|
||||
$match = true;
|
||||
continue 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$match) {
|
||||
// TODO
|
||||
if (!$match) { // @phpstan-ignore-line
|
||||
++$right;
|
||||
}
|
||||
}
|
||||
|
||||
$note_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);
|
||||
$note_criteria = new Criteria($eb->orX(...$note_criteria_arr));
|
||||
}
|
||||
if (!empty($actor_parts)) {
|
||||
if (!empty($actor_parts)) { // @phpstan-ignore-line
|
||||
self::connectParts($actor_parts, $actor_criteria_arr, $last_op, $eb, force: true);
|
||||
$actor_criteria = new Criteria($eb->orX(...$actor_criteria_arr));
|
||||
}
|
60
components/Collection/templates/collection/actors.html.twig
Normal file
60
components/Collection/templates/collection/actors.html.twig
Normal file
@@ -0,0 +1,60 @@
|
||||
{% extends 'stdgrid.html.twig' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="frame-section frame-section-padding">
|
||||
<h1 class="frame-section-title">{{ title }}</h1>
|
||||
|
||||
<details class="frame-section section-details-title">
|
||||
<summary class="details-summary-title">
|
||||
<strong>
|
||||
{% trans %}Ordering rules{% endtrans %}
|
||||
</strong>
|
||||
</summary>
|
||||
<form method="GET" class="section-form">
|
||||
<div class="container-grid">
|
||||
<section class="frame-section frame-section-padding">
|
||||
<strong>{% trans %}Sort by{% endtrans %}</strong>
|
||||
<hr>
|
||||
<div class="container-block">
|
||||
{% for field in sort_form_fields %}
|
||||
<span class="container-block">
|
||||
<label for="order_by_{{ field.value }}">{{ field.label }}</label>
|
||||
<input id="order_by_{{ field.value }}" type="radio" name="order_by" value="{{ field.value }}" {% if field.checked %}checked="checked"{% endif %}>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="frame-section frame-section-padding">
|
||||
<strong class="section-title">{% trans %}Order{% endtrans %}</strong>
|
||||
<hr>
|
||||
<section class="container-block">
|
||||
<span class="container-block">
|
||||
<label for="order_op_asc">{% trans %}Ascending{% endtrans %}</label>
|
||||
<input id="order_op_asc" type="radio" name="order_op" value="ASC">
|
||||
</span>
|
||||
<span class="container-block">
|
||||
<label for="order_op_desc">{% trans %}Descending{% endtrans %}</label>
|
||||
<input id="order_op_desc" type="radio" name="order_op" value="DESC" checked="checked">
|
||||
</span>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
<button type="submit">{% trans %}Order{% endtrans %}</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<section class="frame-section-padding">
|
||||
{% if actors is defined and actors is not empty %}
|
||||
{% for actor in actors %}
|
||||
{% block profile_view %}{% include 'cards/profile/view.html.twig' %}{% endblock profile_view %}
|
||||
<hr>
|
||||
{% endfor %}
|
||||
<p>{% trans %}Page: %page%{% endtrans %}</p>
|
||||
{% else %}
|
||||
<h2>{{ empty_message }}</h2>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock body %}
|
@@ -0,0 +1,11 @@
|
||||
{% extends '/collection/notes.html.twig' %}
|
||||
|
||||
{% block title %}{{ page_title | trans }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="frame-section frame-section-padding">
|
||||
<h2 class="frame-section-title">{{ page_title | trans }}</h2>
|
||||
{% block collection_items %}
|
||||
{% endblock collection_items %}
|
||||
</div>
|
||||
{% endblock body %}
|
@@ -0,0 +1,34 @@
|
||||
{% extends 'stdgrid.html.twig' %}
|
||||
|
||||
{% block title %}{{ page_title | trans }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="frame-section frame-section-padding">
|
||||
<h2 class="frame-section-title">{{ page_title | trans }}</h2>
|
||||
{% if add_collection %}
|
||||
<div class="frame-section section-form">
|
||||
{{ form(add_collection) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="frame-section collections-list">
|
||||
<h3>{{ list_title | trans }}</h3>
|
||||
{% for col in collections %}
|
||||
<div class="collection-item">
|
||||
<a class="name" href="{{ fn.getUrl(col.id) }}">{{ col.name }}</a>
|
||||
<details title="Expand if you want to edit the collection's name">
|
||||
<summary>
|
||||
<span class="collection-action">{{ icon('edit') | raw }}</span>
|
||||
</summary>
|
||||
{{ form(fn.editForm(col)) }}
|
||||
</details>
|
||||
<details title="Expand if you want to delete the collection">
|
||||
<summary>
|
||||
<span class="collection-action">{{ icon('delete') | raw }}</span>
|
||||
</summary>
|
||||
{{ form(fn.rmForm(col)) }}
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock body %}
|
55
components/Collection/templates/collection/notes.html.twig
Normal file
55
components/Collection/templates/collection/notes.html.twig
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends 'stdgrid.html.twig' %}
|
||||
{% import '/cards/note/view.html.twig' as noteView %}
|
||||
|
||||
{% block title %}{% if page_title is defined %}{{ page_title | trans }}{% endif %}{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
|
||||
{% endblock stylesheets %}
|
||||
|
||||
{% block body %}
|
||||
{% for block in handle_event('BeforeFeed', app.request) %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
|
||||
{% if notes is defined %}
|
||||
<header class="feed-header">
|
||||
{% if page_title is defined %}
|
||||
<h1 class="heading-no-margin">{{ page_title | trans }}</h1>
|
||||
{% else %}
|
||||
<h3 class="heading-no-margin">{{ 'Notes' | trans }}</h3>
|
||||
{% endif %}
|
||||
<nav class="feed-actions">
|
||||
<details class="feed-actions-details">
|
||||
<summary>
|
||||
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
|
||||
</summary>
|
||||
<div class="feed-actions-details-dropdown">
|
||||
<menu>
|
||||
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
</menu>
|
||||
</div>
|
||||
</details>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% if notes is not empty %}
|
||||
{# Backwards compatibility with hAtom 0.1 #}
|
||||
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
|
||||
{% for conversation in notes %}
|
||||
{% block current_note %}
|
||||
{% if conversation is instanceof('array') %}
|
||||
{{ 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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock body %}
|
@@ -0,0 +1,27 @@
|
||||
<section class="frame-section collections">
|
||||
<details class="section-details-title" title="Expand if you want to access more options.">
|
||||
<summary class="details-summary-title">
|
||||
<h2>{{ctitle}}</h2>
|
||||
</summary>
|
||||
{% if has_collections %}
|
||||
<section class="section-form">
|
||||
{{ form(add_form) }}
|
||||
</section>
|
||||
|
||||
<details class="frame-section-padding section-details-subtitle"
|
||||
title="Expand if you want to access more options.">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>{% trans %}Other options{% endtrans %}</strong>
|
||||
</summary>
|
||||
<section class="section-form">
|
||||
{{ form(create_form) }}
|
||||
</section>
|
||||
</details>
|
||||
{% else %}
|
||||
<section class="section-form">
|
||||
{{ form(create_form) }}
|
||||
</section>
|
||||
{% endif %}
|
||||
</details>
|
||||
</section>
|
||||
|
@@ -20,42 +20,123 @@ declare(strict_types = 1);
|
||||
|
||||
/**
|
||||
* @author Hugo Sales <hugo@hsal.es>
|
||||
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @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\Controller;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use Component\Feed\Feed;
|
||||
use Component\Feed\Util\FeedController;
|
||||
use App\Core\Log;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\NoLoggedInUser;
|
||||
use App\Util\Exception\NoSuchNoteException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use Component\Collection\Util\Controller\FeedController;
|
||||
use Component\Conversation\Entity\ConversationMute;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Conversation extends FeedController
|
||||
{
|
||||
/**
|
||||
* Render conversation page
|
||||
* Render conversation page.
|
||||
*
|
||||
* @return array
|
||||
* @param int $conversation_id To identify what Conversation is to be rendered
|
||||
*
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
*
|
||||
* @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)
|
||||
*/
|
||||
public function showConversation(Request $request, int $conversation_id)
|
||||
public function showConversation(Request $request, int $conversation_id): array
|
||||
{
|
||||
// TODO:
|
||||
// if note is root -> just link
|
||||
// if note is a reply -> link from above plus anchor
|
||||
|
||||
/* $notes = DB::findBy(
|
||||
table: 'note',
|
||||
criteria: ['conversation_id' => $conversation_id],
|
||||
order_by: ['created' => 'DESC', 'id' => 'DESC']
|
||||
);*/
|
||||
$data = Feed::query(query: "note-conversation:{$conversation_id}", page: $this->int('p') ?? 1);
|
||||
$notes = $data['notes'];
|
||||
return [
|
||||
'_template' => 'feed/feed.html.twig',
|
||||
'notes' => $notes,
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [],
|
||||
'should_format' => false,
|
||||
'page_title' => _m('Conversation'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller for the note reply non-JS page
|
||||
*
|
||||
* Leverages the `PostingModifyData` event to add the `reply_to_id` field from the GET variable 'reply_to_id'
|
||||
*
|
||||
* @throws ClientException
|
||||
* @throws NoLoggedInUser
|
||||
* @throws NoSuchNoteException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function addReply(Request $request)
|
||||
{
|
||||
$user = Common::ensureLoggedIn();
|
||||
$note_id = $this->int('reply_to_id', new ClientException(_m('Malformed query.')));
|
||||
$note = Note::ensureCanInteract(Note::getByPK($note_id), $user);
|
||||
$conversation_id = $note->getConversationId();
|
||||
return $this->showConversation($request, $conversation_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates form view for Muting Conversation extra action.
|
||||
*
|
||||
* @param int $conversation_id The Conversation id that this action targets
|
||||
*
|
||||
* @throws \App\Util\Exception\NoLoggedInUser
|
||||
* @throws \App\Util\Exception\RedirectException
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
*
|
||||
* @return array Array containing templating where the form is to be rendered, and the form itself
|
||||
*/
|
||||
public function muteConversation(Request $request, int $conversation_id)
|
||||
{
|
||||
$user = Common::ensureLoggedIn();
|
||||
$is_muted = ConversationMute::isMuted($conversation_id, $user);
|
||||
$form = Form::create([
|
||||
['mute_conversation', SubmitType::class, ['label' => $is_muted ? _m('Unmute') : _m('Mute'), 'attr' => ['class' => '']]],
|
||||
]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (!$is_muted) {
|
||||
DB::persist(ConversationMute::create(['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]));
|
||||
} else {
|
||||
DB::removeBy('conversation_mute', ['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]);
|
||||
}
|
||||
DB::flush();
|
||||
Cache::delete(ConversationMute::cacheKeys($conversation_id, $user->getId())['mute']);
|
||||
|
||||
// Redirect user to where they came from
|
||||
// Prevent open redirect
|
||||
if (!\is_null($from = $this->string('from'))) {
|
||||
if (Router::isAbsolute($from)) {
|
||||
Log::warning("Actor {$user->getId()} attempted to mute conversation {$conversation_id} and then get redirected to another host, or the URL was invalid ({$from})");
|
||||
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
|
||||
} else {
|
||||
// TODO anchor on element id
|
||||
throw new RedirectException(url: $from);
|
||||
}
|
||||
} else {
|
||||
// If we don't have a URL to return to, go to the instance root
|
||||
throw new RedirectException('root');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'conversation/mute.html.twig',
|
||||
'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [],
|
||||
'is_muted' => $is_muted,
|
||||
'form' => $form->createView(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -1,64 +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/>.
|
||||
// }}}
|
||||
|
||||
/**
|
||||
* @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\Conversation\Controller;
|
||||
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\NoLoggedInUser;
|
||||
use App\Util\Exception\NoSuchNoteException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use Component\Feed\Util\FeedController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Reply extends FeedController
|
||||
{
|
||||
/**
|
||||
* Controller for the note reply non-JS page
|
||||
*
|
||||
* @throws ClientException
|
||||
* @throws NoLoggedInUser
|
||||
* @throws NoSuchNoteException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function addReply(Request $request, int $note_id, int $actor_id)
|
||||
{
|
||||
$user = Common::ensureLoggedIn();
|
||||
|
||||
$note = Note::getByPK($note_id);
|
||||
if (\is_null($note) || !$note->isVisibleTo($user)) {
|
||||
throw new NoSuchNoteException();
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'reply/add_reply.html.twig',
|
||||
'note' => $note,
|
||||
];
|
||||
}
|
||||
}
|
@@ -23,24 +23,35 @@ declare(strict_types = 1);
|
||||
|
||||
namespace Component\Conversation;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\RouteLoader;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Formatting;
|
||||
use Component\Conversation\Controller\Reply as ReplyController;
|
||||
use Component\Conversation\Entity\Conversation as ConversationEntity;
|
||||
use Component\Conversation\Entity\ConversationMute;
|
||||
use Functional as F;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Conversation extends Component
|
||||
{
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$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_reply_to', '/conversation/reply', [Controller\Conversation::class, 'addReply']);
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* **Assigns** the given local Note it's corresponding **Conversation**
|
||||
* **Assigns** the given local Note it's corresponding **Conversation**.
|
||||
*
|
||||
* **If a _$parent_id_ is not given**, then the Actor is not attempting a reply,
|
||||
* therefore, we can assume (for now) that we need to create a new Conversation and assign it
|
||||
@@ -48,6 +59,9 @@ class Conversation extends Component
|
||||
*
|
||||
* **On the other hand**, given a _$parent_id_, the Actor is attempting to post a reply. Meaning that,
|
||||
* this Note conversation_id should be same as the parent Note
|
||||
*
|
||||
* @param \App\Entity\Note $current_note Local Note currently being assigned a Conversation
|
||||
* @param null|int $parent_id If present, it's a reply
|
||||
*/
|
||||
public static function assignLocalConversation(Note $current_note, ?int $parent_id): void
|
||||
{
|
||||
@@ -72,12 +86,19 @@ class Conversation extends Component
|
||||
}
|
||||
|
||||
DB::merge($current_note);
|
||||
DB::flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML rendering event that adds a reply link as a note
|
||||
* action, if a user is logged in
|
||||
* action, if a user is logged in.
|
||||
*
|
||||
* @param \App\Entity\Note $note The Note being rendered
|
||||
* @param array $actions Contains keys 'url' (linking 'conversation_reply_to'
|
||||
* route), 'title' (used as title for aforementioned url),
|
||||
* 'classes' (CSS styling classes used to visually inform the user of action context),
|
||||
* 'id' (HTML markup id used to redirect user to this anchor upon performing the action)
|
||||
*
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
*/
|
||||
public function onAddNoteActions(Request $request, Note $note, array &$actions): bool
|
||||
{
|
||||
@@ -85,15 +106,18 @@ class Conversation extends Component
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
// Generating URL for reply action route
|
||||
$args = ['note_id' => $note->getId(), 'actor_id' => $note->getActor()->getId()];
|
||||
$type = Router::ABSOLUTE_PATH;
|
||||
$reply_action_url = Router::url('reply_add', $args, $type);
|
||||
$from = $request->query->has('from')
|
||||
? $request->query->get('from')
|
||||
: $request->getPathInfo();
|
||||
|
||||
$query_string = $request->getQueryString();
|
||||
// Concatenating get parameter to redirect the user to where he came from
|
||||
$reply_action_url .= !\is_null($query_string) ? '?from=' : '&from=';
|
||||
$reply_action_url .= mb_substr($query_string, 2);
|
||||
$reply_action_url = Router::url(
|
||||
'conversation_reply_to',
|
||||
[
|
||||
'reply_to_id' => $note->getId(),
|
||||
'from' => $from . '#note-anchor-' . $note->getId(),
|
||||
],
|
||||
Router::ABSOLUTE_PATH,
|
||||
);
|
||||
|
||||
$reply_action = [
|
||||
'url' => $reply_action_url,
|
||||
@@ -103,79 +127,130 @@ class Conversation extends Component
|
||||
];
|
||||
|
||||
$actions[] = $reply_action;
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): bool
|
||||
{
|
||||
// If Actor is adding a reply, get parent's Note id
|
||||
// Else it's null
|
||||
$extra_args['reply_to'] = $request->get('_route') === 'reply_add' ? (int) $request->get('note_id') : null;
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append on note information about user actions
|
||||
* Posting event to add extra info to a note
|
||||
*/
|
||||
public function onPostingModifyData(Request $request, Actor $actor, array &$data): bool
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
// if note is the original, append on end "user replied to this"
|
||||
// if note is the reply itself: append on end "in response to user in conversation"
|
||||
$check_user = !\is_null(Common::user());
|
||||
$note = $vars['note'];
|
||||
// The current Note being rendered
|
||||
$note = $vars['note'];
|
||||
|
||||
$complementary_info = '';
|
||||
$reply_actor = [];
|
||||
$note_replies = $note->getReplies();
|
||||
// Will have actors array, and action string
|
||||
// Actors are the subjects, action is the verb (in the final phrase)
|
||||
$reply_actors = F\map(
|
||||
$note->getReplies(),
|
||||
fn (Note $reply) => Actor::getByPK($reply->getActorId()),
|
||||
);
|
||||
|
||||
// Get actors who replied
|
||||
foreach ($note_replies as $reply) {
|
||||
$reply_actor[] = Actor::getByPK($reply->getActorId());
|
||||
}
|
||||
if (\count($reply_actor) < 1) {
|
||||
if (empty($reply_actors)) {
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
// Filter out multiple replies from the same actor
|
||||
$reply_actor = array_unique($reply_actor, \SORT_REGULAR);
|
||||
$reply_actors = array_unique($reply_actors, \SORT_REGULAR);
|
||||
$result[] = ['actors' => $reply_actors, 'action' => 'replied to'];
|
||||
|
||||
// Add to complementary info
|
||||
foreach ($reply_actor as $actor) {
|
||||
$reply_actor_url = $actor->getUrl();
|
||||
$reply_actor_nickname = $actor->getNickname();
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
if ($check_user && $actor->getId() === (Common::actor())->getId()) {
|
||||
// If the reply is yours
|
||||
$you_translation = _m('You');
|
||||
$prepend = "<a href={$reply_actor_url}>{$you_translation}</a>, " . ($prepend = &$complementary_info);
|
||||
$complementary_info = $prepend;
|
||||
} else {
|
||||
// If the repeat is from someone else
|
||||
$complementary_info .= "<a href={$reply_actor_url}>{$reply_actor_nickname}</a>, ";
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
* Note **targeting** that specific Group.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor)
|
||||
{
|
||||
$to_note_id = $request->query->get('reply_to_id');
|
||||
if (!\is_null($to_note_id)) {
|
||||
// Getting the actor itself
|
||||
$context_actor = Actor::getById(Note::getById((int) $to_note_id)->getActorId());
|
||||
return Event::stop;
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param \App\Entity\Note $note Note being deleted
|
||||
* @param \App\Entity\Actor $actor Actor that performed the delete action
|
||||
*/
|
||||
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
|
||||
{
|
||||
// Ensure we have the most up to date replies
|
||||
Cache::delete(Note::cacheKeys($note->getId())['replies']);
|
||||
DB::wrapInTransaction(fn () => F\each($note->getReplies(), fn (Note $note) => $note->setReplyTo(null)));
|
||||
Cache::delete(Note::cacheKeys($note->getId())['replies']);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds extra actions related to Conversation Component, that act upon/from the given Note.
|
||||
*
|
||||
* @param \App\Entity\Note $note Current Note being rendered
|
||||
* @param array $actions Containing 'url' (Controller connected route), 'title' (used in anchor link containing the url), ?'classes' (CSS classes required for styling, if needed)
|
||||
*
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
*
|
||||
* @return bool EventHook
|
||||
*/
|
||||
public function onAddExtraNoteActions(Request $request, Note $note, array &$actions)
|
||||
{
|
||||
if (\is_null($user = Common::user())) {
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
$complementary_info = rtrim(trim($complementary_info), ',');
|
||||
$complementary_info .= _m(' replied to this note.');
|
||||
$result[] = Formatting::twigRenderString($complementary_info, []);
|
||||
$from = $request->query->has('from')
|
||||
? $request->query->get('from')
|
||||
: $request->getPathInfo();
|
||||
|
||||
$mute_extra_action_url = Router::url(
|
||||
'conversation_mute',
|
||||
[
|
||||
'conversation_id' => $note->getConversationId(),
|
||||
'from' => $from . '#note-anchor-' . $note->getId(),
|
||||
],
|
||||
Router::ABSOLUTE_PATH,
|
||||
);
|
||||
|
||||
$actions[] = [
|
||||
'title' => ConversationMute::isMuted($note, $user) ? _m('Unmute conversation') : _m('Mute conversation'),
|
||||
'classes' => '',
|
||||
'url' => $mute_extra_action_url,
|
||||
];
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
public function onNewNotificationShould(Activity $activity, Actor $actor)
|
||||
{
|
||||
$r->connect('reply_add', '/object/note/new?to={actor_id<\d+>}&reply_to={note_id<\d+>}', [ReplyController::class, 'addReply']);
|
||||
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor $context_actor)
|
||||
{
|
||||
$to_query = $request->get('actor_id');
|
||||
if (!\is_null($to_query)) {
|
||||
// Getting the actor itself
|
||||
$context_actor = Actor::getById((int) $to_query);
|
||||
if ($activity->getObjectType() === 'note' && ConversationMute::isMuted($activity, $actor)) {
|
||||
return Event::stop;
|
||||
}
|
||||
return Event::next;
|
||||
|
@@ -23,8 +23,10 @@ declare(strict_types = 1);
|
||||
|
||||
namespace Component\Conversation\Entity;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Note;
|
||||
|
||||
/**
|
||||
* Entity class for Conversations
|
||||
@@ -46,7 +48,6 @@ class Conversation extends Entity
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $id;
|
||||
private string $uri;
|
||||
private int $initial_note_id;
|
||||
|
||||
public function setId(int $id): self
|
||||
|
132
components/Conversation/Entity/ConversationMute.php
Normal file
132
components/Conversation/Entity/ConversationMute.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?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\Conversation\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Entity\Note;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* Entity class for Conversations Mutes
|
||||
*
|
||||
* @category DB
|
||||
* @package GNUsocial
|
||||
*
|
||||
* @author Hugo Sales <hugo@hsal.es>
|
||||
* @copyright 2022 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
class ConversationMute extends Entity
|
||||
{
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $conversation_id;
|
||||
private int $actor_id;
|
||||
private DateTimeInterface $created;
|
||||
|
||||
public function setConversationId(int $conversation_id): self
|
||||
{
|
||||
$this->conversation_id = $conversation_id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getConversationId(): int
|
||||
{
|
||||
return $this->conversation_id;
|
||||
}
|
||||
|
||||
public function setActorId(int $actor_id): self
|
||||
{
|
||||
$this->actor_id = $actor_id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getActorId(): int
|
||||
{
|
||||
return $this->actor_id;
|
||||
}
|
||||
|
||||
public function setCreated(DateTimeInterface $created): self
|
||||
{
|
||||
$this->created = $created;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreated(): DateTimeInterface
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
public static function cacheKeys(int $conversation_id, int $actor_id): array
|
||||
{
|
||||
return [
|
||||
'mute' => "conversation-mute-{$conversation_id}-{$actor_id}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a conversation referenced by $object is muted form $actor
|
||||
*/
|
||||
public static function isMuted(Activity|Note|int $object, Actor|LocalUser $actor): bool
|
||||
{
|
||||
$conversation_id = null;
|
||||
if (\is_int($object)) {
|
||||
$conversation_id = $object;
|
||||
} elseif ($object instanceof Note) {
|
||||
$conversation_id = $object->getConversationId();
|
||||
} elseif ($object instanceof Activity) {
|
||||
$conversation_id = Note::getById($object->getObjectId())->getConversationId();
|
||||
}
|
||||
|
||||
return Cache::get(
|
||||
self::cacheKeys($conversation_id, $actor->getId())['mute'],
|
||||
fn () => (bool) DB::count('conversation_mute', ['conversation_id' => $conversation_id, 'actor_id' => $actor->getId()]),
|
||||
);
|
||||
}
|
||||
|
||||
public static function schemaDef(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'conversation_mute',
|
||||
'fields' => [
|
||||
'conversation_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Conversation.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'The conversation being blocked'],
|
||||
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Who blocked the conversation'],
|
||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
||||
],
|
||||
'primary key' => ['conversation_id', 'actor_id'],
|
||||
'foreign keys' => [
|
||||
'conversation_id_to_id_fkey' => ['conversation', ['conversation_id' => 'id']],
|
||||
'actor_id_to_id_fkey' => ['actor', ['actor_id' => 'id']],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
{% extends 'collection/notes.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="frame-section frame-section-padding">
|
||||
{% if is_muted %}
|
||||
<span class="frame-section-padding alert">
|
||||
<label>Do you wish to <b>unmute</b> this conversation?</label>
|
||||
{{ form(form) }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="frame-section-padding alert">
|
||||
<label>Do you wish to <b>mute</b> this conversation?</label>
|
||||
{{ form(form) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
{{ parent() }}
|
||||
</div>
|
||||
{% endblock body %}
|
@@ -33,38 +33,25 @@ declare(strict_types = 1);
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace App\Controller;
|
||||
namespace Component\Feed\Controller;
|
||||
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\VisibilityScope;
|
||||
use App\Util\Common;
|
||||
use Component\Feed\Feed;
|
||||
use Component\Feed\Util\FeedController;
|
||||
use Component\Collection\Util\Controller\FeedController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Feeds extends FeedController
|
||||
{
|
||||
// Can't have constants inside herestring
|
||||
private $public_scope = VisibilityScope::PUBLIC;
|
||||
private $instance_scope = VisibilityScope::PUBLIC | VisibilityScope::SITE;
|
||||
private $message_scope = VisibilityScope::MESSAGE;
|
||||
private $subscriber_scope = VisibilityScope::PUBLIC | VisibilityScope::SUBSCRIBER;
|
||||
|
||||
/**
|
||||
* The Planet feed represents every local post. Which is what this instance has to share with the universe.
|
||||
*/
|
||||
public function public(Request $request): array
|
||||
{
|
||||
$data = Feed::query(
|
||||
query: 'note-local:true',
|
||||
page: $this->int('p'),
|
||||
language: Common::actor()?->getTopLanguage()?->getLocale(),
|
||||
);
|
||||
$data = $this->query('note-local:true');
|
||||
return [
|
||||
'_template' => 'feed/feed.html.twig',
|
||||
'page_title' => _m(\is_null(Common::user()) ? 'Feed' : 'Planet'),
|
||||
'should_format' => true,
|
||||
'notes' => $data['notes'],
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m(\is_null(Common::user()) ? 'Feed' : 'Planet'),
|
||||
'notes' => $data['notes'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -74,17 +61,11 @@ class Feeds extends FeedController
|
||||
public function home(Request $request): array
|
||||
{
|
||||
Common::ensureLoggedIn();
|
||||
$data = Feed::query(
|
||||
query: 'note-from:subscribed-person,subscribed-group,subscribed-organization,subscribed-business',
|
||||
page: $this->int('p'),
|
||||
language: Common::actor()?->getTopLanguage()?->getLocale(),
|
||||
actor: Common::actor(),
|
||||
);
|
||||
$data = $this->query('note-from:subscribed-person,subscribed-group,subscribed-organisation');
|
||||
return [
|
||||
'_template' => 'feed/feed.html.twig',
|
||||
'page_title' => _m('Home'),
|
||||
'should_format' => true,
|
||||
'notes' => $data['notes'],
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m('Home'),
|
||||
'notes' => $data['notes'],
|
||||
];
|
||||
}
|
||||
}
|
@@ -23,142 +23,17 @@ declare(strict_types = 1);
|
||||
|
||||
namespace Component\Feed;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Subscription;
|
||||
use App\Util\Formatting;
|
||||
use Component\Search\Util\Parser;
|
||||
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||
use Doctrine\ORM\Query\Expr;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use App\Core\Router\RouteLoader;
|
||||
use Component\Feed\Controller as C;
|
||||
|
||||
class Feed extends Component
|
||||
{
|
||||
/**
|
||||
* Perform a high level query on notes or actors
|
||||
*
|
||||
* Supports a variety of query terms and is used both in feeds and
|
||||
* in search. Uses query builders to allow for extension
|
||||
*/
|
||||
public static function query(string $query, int $page, ?string $language = null, ?Actor $actor = null): array
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$note_criteria = null;
|
||||
$actor_criteria = null;
|
||||
if (!empty($query = trim($query))) {
|
||||
[$note_criteria, $actor_criteria] = Parser::parse($query, $language, $actor);
|
||||
}
|
||||
$note_qb = DB::createQueryBuilder();
|
||||
$actor_qb = DB::createQueryBuilder();
|
||||
$note_qb->select('note')->from('App\Entity\Note', 'note')->orderBy('note.created', 'DESC')->orderBy('note.id', 'DESC');
|
||||
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor')->orderBy('actor.created', 'DESC')->orderBy('actor.id', 'DESC');
|
||||
Event::handle('SearchQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
|
||||
|
||||
$notes = [];
|
||||
$actors = [];
|
||||
if (!\is_null($note_criteria)) {
|
||||
$note_qb->addCriteria($note_criteria);
|
||||
}
|
||||
$notes = $note_qb->getQuery()->execute();
|
||||
|
||||
if (!\is_null($actor_criteria)) {
|
||||
$actor_qb->addCriteria($actor_criteria);
|
||||
}
|
||||
$actors = $actor_qb->getQuery()->execute();
|
||||
|
||||
// TODO: Enforce scoping on the notes before returning
|
||||
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
|
||||
}
|
||||
|
||||
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb)
|
||||
{
|
||||
$note_qb->leftJoin(Subscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed')
|
||||
->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text
|
||||
* notes, for different types of actors and for the content of text notes
|
||||
*/
|
||||
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr)
|
||||
{
|
||||
if (str_contains($term, ':')) {
|
||||
$term = explode(':', $term);
|
||||
if (Formatting::startsWith($term[0], 'note-')) {
|
||||
switch ($term[0]) {
|
||||
case 'note-local':
|
||||
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
|
||||
break;
|
||||
case 'note-types':
|
||||
case 'notes-include':
|
||||
case 'note-filter':
|
||||
if (\is_null($note_expr)) {
|
||||
$note_expr = [];
|
||||
}
|
||||
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
|
||||
$note_expr[] = $eb->neq('note.content', null);
|
||||
} else {
|
||||
$note_expr[] = $eb->eq('note.content', null);
|
||||
}
|
||||
break;
|
||||
case 'note-conversation':
|
||||
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
|
||||
break;
|
||||
case 'note-from':
|
||||
case 'notes-from':
|
||||
$subscribed_expr = $eb->eq('subscription.subscriber', $actor->getId());
|
||||
$type_consts = [];
|
||||
if ($term[1] === 'subscribed') {
|
||||
$type_consts = null;
|
||||
}
|
||||
foreach (explode(',', $term[1]) as $from) {
|
||||
if (str_starts_with($from, 'subscribed-')) {
|
||||
[, $type] = explode('-', $from);
|
||||
if (\in_array($type, ['actor', 'actors'])) {
|
||||
$type_consts = null;
|
||||
} else {
|
||||
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (\is_null($type_consts)) {
|
||||
$note_expr = $subscribed_expr;
|
||||
} elseif (!empty($type_consts)) {
|
||||
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
|
||||
}
|
||||
break;
|
||||
}
|
||||
} elseif (Formatting::startsWith($term, 'actor-')) {
|
||||
switch ($term[0]) {
|
||||
case 'actor-types':
|
||||
case 'actors-include':
|
||||
case 'actor-filter':
|
||||
case 'actor-local':
|
||||
if (\is_null($actor_expr)) {
|
||||
$actor_expr = [];
|
||||
}
|
||||
foreach (
|
||||
[
|
||||
Actor::PERSON => ['person', 'people'],
|
||||
Actor::GROUP => ['group', 'groups'],
|
||||
Actor::ORGANIZATION => ['org', 'orgs', 'organization', 'organizations', 'organisation', 'organisations'],
|
||||
Actor::BUSINESS => ['business', 'businesses'],
|
||||
Actor::BOT => ['bot', 'bots'],
|
||||
] as $type => $match) {
|
||||
if (array_intersect(explode(',', $term[1]), $match) !== []) {
|
||||
$actor_expr[] = $eb->eq('actor.type', $type);
|
||||
} else {
|
||||
$actor_expr[] = $eb->neq('actor.type', $type);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$note_expr = $eb->contains('note.content', $term);
|
||||
}
|
||||
$r->connect('feed_public', '/feed/public', [C\Feeds::class, 'public']);
|
||||
$r->connect('feed_home', '/feed/home', [C\Feeds::class, 'home']);
|
||||
return Event::next;
|
||||
}
|
||||
}
|
||||
|
@@ -1,42 +0,0 @@
|
||||
{% extends 'stdgrid.html.twig' %}
|
||||
{% import '/cards/note/view.html.twig' as noteView %}
|
||||
|
||||
{% block title %}{% if page_title is defined %}{{ page_title | trans }}{% endif %}{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
|
||||
{% endblock stylesheets %}
|
||||
|
||||
{% block body %}
|
||||
<header class="feed-header">
|
||||
{% if page_title is defined %}
|
||||
<h1>{{ page_title | trans }} {{ 'feed' | trans }}</h1>
|
||||
{% endif %}
|
||||
<nav class="feed-actions" role=navigation>
|
||||
{% for block in handle_event('AddFeedActions', app.request) %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{# Backwards compatibility with hAtom 0.1 #}
|
||||
<main class="feed" tabindex="0" role="feed">
|
||||
<div class="h-feed hfeed notes">
|
||||
{% if notes is defined and notes is not empty %}
|
||||
{% 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 %}
|
||||
{% else %}
|
||||
<div id="empty-notes"><h1>{% trans %}No notes here.{% endtrans %}</h1></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock body %}
|
@@ -37,8 +37,7 @@ namespace Component\FreeNetwork\Controller;
|
||||
use App\Core\DB\DB;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Util\Common;
|
||||
use Component\Feed\Feed;
|
||||
use Component\Feed\Util\FeedController;
|
||||
use Component\Collection\Util\Controller\FeedController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Feeds extends FeedController
|
||||
@@ -52,13 +51,9 @@ class Feeds extends FeedController
|
||||
public function network(Request $request): array
|
||||
{
|
||||
Common::ensureLoggedIn();
|
||||
$data = Feed::query(
|
||||
query: 'note-local:false',
|
||||
page: $this->int('p'),
|
||||
language: Common::actor()?->getTopLanguage()?->getLocale(),
|
||||
);
|
||||
$data = $this->query('note-local:false');
|
||||
return [
|
||||
'_template' => 'feed/feed.html.twig',
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m('Meteorites'),
|
||||
'should_format' => true,
|
||||
'notes' => $data['notes'],
|
||||
@@ -73,6 +68,7 @@ class Feeds extends FeedController
|
||||
public function clique(Request $request): array
|
||||
{
|
||||
Common::ensureLoggedIn();
|
||||
// TODO: maybe make this a Collection::query
|
||||
$notes = DB::dql(
|
||||
<<<'EOF'
|
||||
SELECT n FROM \App\Entity\Note AS n
|
||||
@@ -86,7 +82,7 @@ class Feeds extends FeedController
|
||||
EOF,
|
||||
);
|
||||
return [
|
||||
'_template' => 'feed/feed.html.twig',
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m('Planetary System'),
|
||||
'should_format' => true,
|
||||
'notes' => $notes,
|
||||
@@ -101,13 +97,9 @@ class Feeds extends FeedController
|
||||
public function federated(Request $request): array
|
||||
{
|
||||
Common::ensureLoggedIn();
|
||||
$data = Feed::query(
|
||||
query: '',
|
||||
page: $this->int('p'),
|
||||
language: Common::actor()?->getTopLanguage()?->getLocale(),
|
||||
);
|
||||
$data = $this->query('notes-all:yeah');
|
||||
return [
|
||||
'_template' => 'feed/feed.html.twig',
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m('Galaxy'),
|
||||
'should_format' => true,
|
||||
'notes' => $data['notes'],
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/**
|
||||
* @author James Walker <james@status.net>
|
||||
* @author Craig Andrews <candrews@integralblue.com>
|
||||
@@ -8,21 +10,17 @@
|
||||
|
||||
namespace Component\FreeNetwork\Controller;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Core\Event;
|
||||
use Component\FreeNetwork\Util\Discovery;
|
||||
use Component\FreeNetwork\Util\XrdController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use XML_XRD;
|
||||
|
||||
class HostMeta extends XrdController
|
||||
{
|
||||
protected string $default_mimetype = Discovery::XRD_MIMETYPE;
|
||||
protected string $default_mimetype = Discovery::JRD_MIMETYPE;
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
// 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
|
||||
@@ -30,7 +32,6 @@ use App\Util\Common;
|
||||
use Component\FreeNetwork\Util\Discovery;
|
||||
use Component\FreeNetwork\Util\XrdController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class OwnerXrd extends XrdController
|
||||
{
|
||||
|
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
/*
|
||||
* StatusNet - the distributed open-source microblogging tool
|
||||
* Copyright (C) 2010, StatusNet, Inc.
|
||||
@@ -69,7 +71,7 @@ class Webfinger extends XrdController
|
||||
$this->xrd->subject = $this->resource;
|
||||
|
||||
foreach ($this->target->getAliases() as $alias) {
|
||||
if ($alias != $this->xrd->subject && !in_array($alias, $this->xrd->aliases)) {
|
||||
if ($alias != $this->xrd->subject && !\in_array($alias, $this->xrd->aliases)) {
|
||||
$this->xrd->aliases[] = $alias;
|
||||
}
|
||||
}
|
||||
|
@@ -32,17 +32,11 @@ declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Entity\Actor;
|
||||
use Component\FreeNetwork\Util\Discovery;
|
||||
use DateTimeInterface;
|
||||
use Exception;
|
||||
use Plugin\ActivityPub\Util\DiscoveryHints;
|
||||
use Plugin\ActivityPub\Util\Explorer;
|
||||
|
||||
/**
|
||||
* Table Definition for free_network_actor_protocol
|
||||
@@ -53,27 +47,27 @@ use Plugin\ActivityPub\Util\Explorer;
|
||||
class FreeNetworkActorProtocol extends Entity
|
||||
{
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart;
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $actor_id;
|
||||
private ?string $protocol;
|
||||
private ?string $addr;
|
||||
private ?string $protocol = null;
|
||||
private string $addr;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function getActorId(): int
|
||||
{
|
||||
return $this->actor_id;
|
||||
}
|
||||
|
||||
public function setActorId(int $actor_id): self
|
||||
{
|
||||
$this->actor_id = $actor_id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getActorId(): int
|
||||
{
|
||||
return $this->actor_id;
|
||||
}
|
||||
|
||||
public function setProtocol(?string $protocol): self
|
||||
{
|
||||
$this->protocol = $protocol;
|
||||
$this->protocol = \is_null($protocol) ? null : mb_substr($protocol, 0, 32);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -93,14 +87,20 @@ class FreeNetworkActorProtocol extends Entity
|
||||
return $this->addr;
|
||||
}
|
||||
|
||||
public function setCreated(DateTimeInterface $created): self
|
||||
{
|
||||
$this->created = $created;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreated(): DateTimeInterface
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
public function setCreated(DateTimeInterface $created): self
|
||||
public function setModified(DateTimeInterface $modified): self
|
||||
{
|
||||
$this->created = $created;
|
||||
$this->modified = $modified;
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -109,19 +109,14 @@ class FreeNetworkActorProtocol extends Entity
|
||||
return $this->modified;
|
||||
}
|
||||
|
||||
public function setModified(DateTimeInterface $modified): self
|
||||
{
|
||||
$this->modified = $modified;
|
||||
return $this;
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
public static function protocolSucceeded(string $protocol, int|Actor $actor_id, string $addr): void
|
||||
{
|
||||
$actor_id = is_int($actor_id) ? $actor_id : $actor_id->getId();
|
||||
$actor_id = \is_int($actor_id) ? $actor_id : $actor_id->getId();
|
||||
$attributed_protocol = self::getByPK(['actor_id' => $actor_id]);
|
||||
if (is_null($attributed_protocol)) {
|
||||
if (\is_null($attributed_protocol)) {
|
||||
$attributed_protocol = self::create([
|
||||
'actor_id' => $actor_id,
|
||||
'protocol' => $protocol,
|
||||
@@ -130,14 +125,14 @@ class FreeNetworkActorProtocol extends Entity
|
||||
} else {
|
||||
$attributed_protocol->setProtocol($protocol);
|
||||
}
|
||||
DB::wrapInTransaction(fn() => DB::persist($attributed_protocol));
|
||||
DB::wrapInTransaction(fn () => DB::persist($attributed_protocol));
|
||||
}
|
||||
|
||||
public static function canIActor(string $protocol, int|Actor $actor_id): bool
|
||||
{
|
||||
$actor_id = is_int($actor_id) ? $actor_id : $actor_id->getId();
|
||||
$actor_id = \is_int($actor_id) ? $actor_id : $actor_id->getId();
|
||||
$attributed_protocol = self::getByPK(['actor_id' => $actor_id])?->getProtocol();
|
||||
if (is_null($attributed_protocol)) {
|
||||
if (\is_null($attributed_protocol)) {
|
||||
// If it is not attributed, you can go ahead.
|
||||
return true;
|
||||
} else {
|
||||
@@ -149,9 +144,9 @@ class FreeNetworkActorProtocol extends Entity
|
||||
public static function canIAddr(string $protocol, string $target): bool
|
||||
{
|
||||
// Normalize $addr, i.e. add 'acct:' if missing
|
||||
$addr = Discovery::normalize($target);
|
||||
$addr = Discovery::normalize($target);
|
||||
$attributed_protocol = self::getByPK(['addr' => $addr])?->getProtocol();
|
||||
if (is_null($attributed_protocol)) {
|
||||
if (\is_null($attributed_protocol)) {
|
||||
// If it is not attributed, you can go ahead.
|
||||
return true;
|
||||
} else {
|
||||
@@ -167,7 +162,7 @@ class FreeNetworkActorProtocol extends Entity
|
||||
'fields' => [
|
||||
'actor_id' => ['type' => 'int', 'not null' => true],
|
||||
'protocol' => ['type' => 'varchar', 'length' => 32, 'description' => 'the protocol plugin that should handle federation of this actor'],
|
||||
'addr' => ['type' => 'text', 'not null' => true, 'description' => 'webfinger acct'],
|
||||
'addr' => ['type' => 'text', 'not null' => true, 'description' => 'webfinger acct'],
|
||||
'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'],
|
||||
],
|
||||
|
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
/**
|
||||
* StatusNet, the distributed open-source microblogging tool
|
||||
*
|
||||
@@ -50,7 +52,7 @@ use Throwable;
|
||||
*/
|
||||
class WebfingerReconstructionException extends ServerException
|
||||
{
|
||||
public function __construct(string $message = '', int $code = 500, Throwable $previous = null)
|
||||
public function __construct(string $message = '', int $code = 500, ?Throwable $previous = null)
|
||||
{
|
||||
// We could log an entry here with the search parameters
|
||||
parent::__construct(_m('WebFinger URI generation failed.'));
|
||||
|
@@ -54,8 +54,6 @@ use Component\FreeNetwork\Util\WebfingerResource;
|
||||
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceActor;
|
||||
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceNote;
|
||||
use Exception;
|
||||
use Plugin\ActivityPub\Entity\ActivitypubActivity;
|
||||
use Plugin\ActivityPub\Util\TypeResponse;
|
||||
use const PREG_SET_ORDER;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -209,9 +207,8 @@ class FreeNetwork extends Component
|
||||
return Event::stop; // We got our target, stop handler execution
|
||||
}
|
||||
|
||||
$APNote = ActivitypubActivity::getByPK(['object_uri' => $resource]);
|
||||
if ($APNote instanceof ActivitypubActivity) {
|
||||
$target = new WebfingerResourceNote(Note::getByPK(['id' => $APNote->getObjectId()]));
|
||||
if (!\is_null($note = DB::findOneBy(Note::class, ['url' => $resource], return_null: true))) {
|
||||
$target = new WebfingerResourceNote($note);
|
||||
return Event::stop; // We got our target, stop handler execution
|
||||
}
|
||||
|
||||
@@ -270,7 +267,7 @@ class FreeNetwork extends Component
|
||||
* @throws ClientException
|
||||
* @throws ServerException
|
||||
*/
|
||||
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?TypeResponse &$response = null): bool
|
||||
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'])) {
|
||||
return Event::next;
|
||||
@@ -300,6 +297,9 @@ class FreeNetwork extends Component
|
||||
Discovery::XRD_MIMETYPE => new Response(content: $vars['xrd']->to('xml'), headers: $headers),
|
||||
Discovery::JRD_MIMETYPE, Discovery::JRD_MIMETYPE_OLD => new JsonResponse(data: $vars['xrd']->to('json'), headers: $headers, json: true),
|
||||
};
|
||||
|
||||
$response->headers->set('cache-control', 'no-store, no-cache, must-revalidate');
|
||||
|
||||
return Event::stop;
|
||||
}
|
||||
|
||||
@@ -500,6 +500,11 @@ class FreeNetwork extends Component
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function mentionToName(string $nickname, string $uri): string
|
||||
{
|
||||
return '@' . $nickname . '@' . parse_url($uri, \PHP_URL_HOST);
|
||||
}
|
||||
|
||||
public function onPluginVersion(array &$versions): bool
|
||||
{
|
||||
$versions[] = [
|
||||
|
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
/**
|
||||
* StatusNet - the distributed open-source microblogging tool
|
||||
* Copyright (C) 2010, StatusNet, Inc.
|
||||
@@ -69,7 +71,7 @@ class LinkHeader
|
||||
$this->type = null;
|
||||
|
||||
// remove uri-reference from header
|
||||
$str = substr($str, strlen($uri_reference[0]));
|
||||
$str = mb_substr($str, mb_strlen($uri_reference[0]));
|
||||
|
||||
// parse link-params
|
||||
$params = explode(';', $str);
|
||||
@@ -78,7 +80,7 @@ class LinkHeader
|
||||
if (empty($param)) {
|
||||
continue;
|
||||
}
|
||||
list($param_name, $param_value) = explode('=', $param, 2);
|
||||
[$param_name, $param_value] = explode('=', $param, 2);
|
||||
|
||||
$param_name = trim($param_name);
|
||||
$param_value = preg_replace('(^"|"$)', '', trim($param_value));
|
||||
@@ -110,18 +112,17 @@ class LinkHeader
|
||||
$headers = $response->getHeader('Link');
|
||||
if ($headers) {
|
||||
// Can get an array or string, so try to simplify the path
|
||||
if (!is_array($headers)) {
|
||||
if (!\is_array($headers)) {
|
||||
$headers = [$headers];
|
||||
}
|
||||
|
||||
foreach ($headers as $header) {
|
||||
$lh = new self($header);
|
||||
|
||||
if ((is_null($rel) || $lh->rel == $rel) && (is_null($type) || $lh->type == $type)) {
|
||||
if ((\is_null($rel) || $lh->rel == $rel) && (\is_null($type) || $lh->type == $type)) {
|
||||
return $lh->href;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util\LrddMethod;
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
@@ -39,29 +41,27 @@ class LrddMethodHostMeta extends LRDDMethod
|
||||
/**
|
||||
* For RFC6415 and HTTP URIs, fetch the host-meta file
|
||||
* and look for LRDD templates
|
||||
*
|
||||
* @param mixed $uri
|
||||
*/
|
||||
public function discover($uri)
|
||||
{
|
||||
// This is allowed for RFC6415 but not the 'WebFinger' RFC7033.
|
||||
$try_schemes = ['https', 'http'];
|
||||
|
||||
$scheme = mb_strtolower(parse_url($uri, PHP_URL_SCHEME));
|
||||
$scheme = mb_strtolower(parse_url($uri, \PHP_URL_SCHEME));
|
||||
switch ($scheme) {
|
||||
case 'acct':
|
||||
// We can't use parse_url data for this, since the 'host'
|
||||
// entry is only set if the scheme has '://' after it.
|
||||
$parts = explode('@', parse_url($uri, PHP_URL_PATH), 2);
|
||||
$parts = explode('@', parse_url($uri, \PHP_URL_PATH), 2);
|
||||
|
||||
if (!Discovery::isAcct($uri) || count($parts) != 2) {
|
||||
if (!Discovery::isAcct($uri) || \count($parts) != 2) {
|
||||
throw new Exception('Bad resource URI: ' . $uri);
|
||||
}
|
||||
[, $domain] = $parts;
|
||||
break;
|
||||
case 'http':
|
||||
case 'https':
|
||||
$domain = mb_strtolower(parse_url($uri, PHP_URL_HOST));
|
||||
$domain = mb_strtolower(parse_url($uri, \PHP_URL_HOST));
|
||||
$try_schemes = [$scheme];
|
||||
break;
|
||||
default:
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util\LrddMethod;
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
@@ -38,10 +40,7 @@ class LrddMethodLinkHtml extends LRDDMethod
|
||||
* For HTTP IDs, fetch the URL and look for <link> elements
|
||||
* in the HTML response.
|
||||
*
|
||||
* @param mixed $uri
|
||||
*
|
||||
* @todo fail out of WebFinger URIs faster
|
||||
*
|
||||
*/
|
||||
public function discover($uri)
|
||||
{
|
||||
@@ -65,7 +64,7 @@ class LrddMethodLinkHtml extends LRDDMethod
|
||||
|
||||
preg_match('/<head(\s[^>]*)?>(.*?)<\/head>/is', $html, $head_matches);
|
||||
|
||||
if (count($head_matches) != 3) {
|
||||
if (\count($head_matches) != 3) {
|
||||
return [];
|
||||
}
|
||||
[, , $head_html] = $head_matches;
|
||||
@@ -78,23 +77,23 @@ class LrddMethodLinkHtml extends LRDDMethod
|
||||
$link_type = null;
|
||||
|
||||
preg_match('/\srel=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $rel_matches);
|
||||
if (count($rel_matches) > 3) {
|
||||
if (\count($rel_matches) > 3) {
|
||||
$link_rel = $rel_matches[3];
|
||||
} elseif (count($rel_matches) > 1) {
|
||||
} elseif (\count($rel_matches) > 1) {
|
||||
$link_rel = $rel_matches[1];
|
||||
}
|
||||
|
||||
preg_match('/\shref=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $href_matches);
|
||||
if (count($href_matches) > 3) {
|
||||
if (\count($href_matches) > 3) {
|
||||
$link_uri = $href_matches[3];
|
||||
} elseif (count($href_matches) > 1) {
|
||||
} elseif (\count($href_matches) > 1) {
|
||||
$link_uri = $href_matches[1];
|
||||
}
|
||||
|
||||
preg_match('/\stype=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $type_matches);
|
||||
if (count($type_matches) > 3) {
|
||||
if (\count($type_matches) > 3) {
|
||||
$link_type = $type_matches[3];
|
||||
} elseif (count($type_matches) > 1) {
|
||||
} elseif (\count($type_matches) > 1) {
|
||||
$link_type = $type_matches[1];
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
// 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
|
||||
@@ -36,19 +38,17 @@ class LrddMethodWebfinger extends LRDDMethod
|
||||
/**
|
||||
* Simply returns the WebFinger URL over HTTPS at the uri's domain:
|
||||
* https://{domain}/.well-known/webfinger?resource={uri}
|
||||
*
|
||||
* @param mixed $uri
|
||||
*/
|
||||
public function discover($uri)
|
||||
{
|
||||
$parts = explode('@', parse_url($uri, PHP_URL_PATH), 2);
|
||||
$parts = explode('@', parse_url($uri, \PHP_URL_PATH), 2);
|
||||
|
||||
if (!Discovery::isAcct($uri) || count($parts) != 2) {
|
||||
if (!Discovery::isAcct($uri) || \count($parts) != 2) {
|
||||
throw new Exception('Bad resource URI: ' . $uri);
|
||||
}
|
||||
[, $domain] = $parts;
|
||||
if (!filter_var($domain, FILTER_VALIDATE_IP)
|
||||
&& !filter_var(gethostbyname($domain), FILTER_VALIDATE_IP)) {
|
||||
if (!filter_var($domain, \FILTER_VALIDATE_IP)
|
||||
&& !filter_var(gethostbyname($domain), \FILTER_VALIDATE_IP)) {
|
||||
throw new Exception('Bad resource host.');
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ class LrddMethodWebfinger extends LRDDMethod
|
||||
Discovery::LRDD_REL,
|
||||
'https://' . $domain . '/.well-known/webfinger?resource={uri}',
|
||||
Discovery::JRD_MIMETYPE,
|
||||
true // isTemplate
|
||||
true, // isTemplate
|
||||
);
|
||||
|
||||
return [$link];
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util;
|
||||
|
||||
use App\Core\Entity;
|
||||
@@ -40,8 +42,6 @@ abstract class WebfingerResource
|
||||
|
||||
/**
|
||||
* List of alternative IDs of a certain Actor
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getAliases(): array
|
||||
{
|
||||
@@ -53,7 +53,7 @@ abstract class WebfingerResource
|
||||
// you've run HTTPS all the time!
|
||||
if (Common::config('fix', 'legacy_http')) {
|
||||
foreach ($aliases as $alias => $id) {
|
||||
if (!strtolower(parse_url($alias, PHP_URL_SCHEME)) === 'https') {
|
||||
if (!mb_strtolower(parse_url($alias, \PHP_URL_SCHEME)) === 'https') {
|
||||
continue;
|
||||
}
|
||||
$aliases[preg_replace('/^https:/i', 'http:', $alias, 1)] = $id;
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util\WebfingerResource;
|
||||
|
||||
use App\Core\Event;
|
||||
@@ -24,9 +26,9 @@ use XML_XRD_Element_Link;
|
||||
*/
|
||||
class WebfingerResourceActor extends WebFingerResource
|
||||
{
|
||||
const PROFILEPAGE = 'http://webfinger.net/rel/profile-page';
|
||||
public const PROFILEPAGE = 'http://webfinger.net/rel/profile-page';
|
||||
|
||||
public function __construct(Actor $object = null)
|
||||
public function __construct(?Actor $object = null)
|
||||
{
|
||||
// The type argument above verifies that it's our class
|
||||
parent::__construct($object);
|
||||
@@ -49,8 +51,9 @@ class WebfingerResourceActor extends WebFingerResource
|
||||
/**
|
||||
* Reconstruct WebFinger acct: from object
|
||||
*
|
||||
* @return array|false|mixed|string|string[]|null
|
||||
* @throws WebfingerReconstructionException
|
||||
*
|
||||
* @return null|array|false|mixed|string|string[]
|
||||
*/
|
||||
public function reconstructAcct()
|
||||
{
|
||||
@@ -58,7 +61,7 @@ class WebfingerResourceActor extends WebFingerResource
|
||||
|
||||
if (Event::handle('StartWebFingerReconstruction', [$this->object, &$acct])) {
|
||||
// TODO: getUri may not always give us the correct host on remote users?
|
||||
$host = parse_url($this->object->getUri(Router::ABSOLUTE_URL), PHP_URL_HOST);
|
||||
$host = parse_url($this->object->getUri(Router::ABSOLUTE_URL), \PHP_URL_HOST);
|
||||
if (empty($this->object->getNickname()) || empty($host)) {
|
||||
throw new WebFingerReconstructionException(print_r($this->object, true));
|
||||
}
|
||||
@@ -75,8 +78,11 @@ class WebfingerResourceActor extends WebFingerResource
|
||||
if (Event::handle('StartWebFingerProfileLinks', [$xrd, $this->object])) {
|
||||
|
||||
// Profile page, can give more metadata from Link header or HTML parsing
|
||||
$xrd->links[] = new XML_XRD_Element_Link(self::PROFILEPAGE,
|
||||
$this->object->getUrl(Router::ABSOLUTE_URL), 'text/html');
|
||||
$xrd->links[] = new XML_XRD_Element_Link(
|
||||
self::PROFILEPAGE,
|
||||
$this->object->getUrl(Router::ABSOLUTE_URL),
|
||||
'text/html',
|
||||
);
|
||||
|
||||
// // XFN
|
||||
// $xrd->links[] = new XML_XRD_Element_Link('http://gmpg.org/xfn/11',
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util\WebfingerResource;
|
||||
|
||||
use App\Core\Event;
|
||||
@@ -22,7 +24,7 @@ use XML_XRD_Element_Link;
|
||||
*/
|
||||
class WebfingerResourceNote extends WebfingerResource
|
||||
{
|
||||
public function __construct(Note $object = null)
|
||||
public function __construct(?Note $object = null)
|
||||
{
|
||||
// The type argument above verifies that it's our class
|
||||
parent::__construct($object);
|
||||
@@ -30,29 +32,37 @@ class WebfingerResourceNote extends WebfingerResource
|
||||
|
||||
/**
|
||||
* Update given XRD with self's data
|
||||
*
|
||||
* @param XML_XRD $xrd
|
||||
*/
|
||||
public function updateXRD(XML_XRD $xrd)
|
||||
{
|
||||
if (Event::handle('StartWebFingerNoticeLinks', [$xrd, $this->object])) {
|
||||
if ($this->object->isLocal()) {
|
||||
$xrd->links[] = new XML_XRD_Element_Link('alternate',
|
||||
common_local_url('ApiStatusesShow',
|
||||
$xrd->links[] = new XML_XRD_Element_Link(
|
||||
'alternate',
|
||||
common_local_url(
|
||||
'ApiStatusesShow',
|
||||
['id' => $this->object->id,
|
||||
'format' => 'atom', ]),
|
||||
'application/atom+xml');
|
||||
'format' => 'atom', ],
|
||||
),
|
||||
'application/atom+xml',
|
||||
);
|
||||
|
||||
$xrd->links[] = new XML_XRD_Element_Link('alternate',
|
||||
common_local_url('ApiStatusesShow',
|
||||
$xrd->links[] = new XML_XRD_Element_Link(
|
||||
'alternate',
|
||||
common_local_url(
|
||||
'ApiStatusesShow',
|
||||
['id' => $this->object->id,
|
||||
'format' => 'json', ]),
|
||||
'application/json');
|
||||
'format' => 'json', ],
|
||||
),
|
||||
'application/json',
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
$xrd->links[] = new XML_XRD_Element_Link('alternate',
|
||||
$xrd->links[] = new XML_XRD_Element_Link(
|
||||
'alternate',
|
||||
$this->object->getUrl(),
|
||||
'text/html');
|
||||
'text/html',
|
||||
);
|
||||
} catch (InvalidUrlException $e) {
|
||||
// don't do a fallback in webfinger
|
||||
}
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util;
|
||||
|
||||
use App\Core\Controller;
|
||||
|
@@ -24,87 +24,75 @@ declare(strict_types = 1);
|
||||
namespace Component\Group\Controller;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\Controller\ActorController;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Entity\Actor;
|
||||
use App\Core\UserRoles;
|
||||
use App\Entity as E;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\NicknameEmptyException;
|
||||
use App\Util\Exception\NicknameInvalidException;
|
||||
use App\Util\Exception\NicknameNotAllowedException;
|
||||
use App\Util\Exception\NicknameTakenException;
|
||||
use App\Util\Exception\NicknameTooLongException;
|
||||
use App\Util\Exception\NoLoggedInUser;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use App\Util\Form\ActorForms;
|
||||
use App\Util\Nickname;
|
||||
use Component\Collection\Util\Controller\FeedController;
|
||||
use Component\Group\Entity\GroupMember;
|
||||
use Component\Group\Entity\LocalGroup;
|
||||
use Component\Subscription\Entity\ActorSubscription;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Group extends ActorController
|
||||
class Group extends FeedController
|
||||
{
|
||||
public function groupViewId(Request $request, int $id)
|
||||
{
|
||||
return $this->handleActorById(
|
||||
$id,
|
||||
fn ($actor) => [
|
||||
'_template' => 'group/view.html.twig',
|
||||
'actor' => $actor,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* View a group feed and give the option of creating it if it doesn't exist
|
||||
* View a group feed by its nickname
|
||||
*
|
||||
* @param string $nickname The group's nickname to be shown
|
||||
*
|
||||
* @throws NicknameEmptyException
|
||||
* @throws NicknameNotAllowedException
|
||||
* @throws NicknameTakenException
|
||||
* @throws NicknameTooLongException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function groupViewNickname(Request $request, string $nickname)
|
||||
{
|
||||
Nickname::validate($nickname, which: Nickname::CHECK_LOCAL_GROUP); // throws
|
||||
$group = Actor::getByNickname($nickname, type: Actor::GROUP);
|
||||
if (\is_null($group)) {
|
||||
$actor = Common::actor();
|
||||
if (!\is_null($actor)) {
|
||||
$form = Form::create([
|
||||
['create', SubmitType::class, ['label' => _m('Create this group')]],
|
||||
]);
|
||||
$group = LocalGroup::getActorByNickname($nickname);
|
||||
$actor = Common::actor();
|
||||
$subscribe_form = null;
|
||||
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
Log::info(
|
||||
_m(
|
||||
'Actor id:{actor_id} nick:{actor_nick} created the group {nickname}',
|
||||
['{actor_id}' => $actor->getId(), 'actor_nick' => $actor->getNickname(), 'nickname' => $nickname],
|
||||
),
|
||||
);
|
||||
|
||||
$group = Actor::create([
|
||||
'nickname' => $nickname,
|
||||
'type' => Actor::GROUP,
|
||||
'is_local' => true,
|
||||
]);
|
||||
DB::persist($group);
|
||||
DB::persist(E\Subscription::create([
|
||||
'subscriber' => $group->getId(),
|
||||
'subscribed' => $group->getId(),
|
||||
]));
|
||||
DB::persist(E\Subscription::create([
|
||||
'subscriber' => $actor->getId(),
|
||||
'subscribed' => $group->getId(),
|
||||
]));
|
||||
DB::persist(E\GroupMember::create([
|
||||
'group_id' => $group->getId(),
|
||||
'actor_id' => $actor->getId(),
|
||||
'is_admin' => true,
|
||||
]));
|
||||
DB::flush();
|
||||
Cache::delete(Actor::cacheKeys($actor->getId())['subscriber']);
|
||||
Cache::delete(Actor::cacheKeys($actor->getId())['subscribed']);
|
||||
throw new RedirectException;
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'group/view.html.twig',
|
||||
'nickname' => $nickname,
|
||||
'create_form' => $form->createView(),
|
||||
];
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,27 +102,107 @@ class Group extends ActorController
|
||||
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,
|
||||
'_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
|
||||
*
|
||||
* @throws RedirectException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function groupCreate(Request $request)
|
||||
{
|
||||
if (\is_null($actor = Common::actor())) {
|
||||
throw new RedirectException('security_login');
|
||||
}
|
||||
|
||||
$create_form = Form::create([
|
||||
['group_nickname', TextType::class, ['label' => _m('Group nickname')]],
|
||||
['group_create', SubmitType::class, ['label' => _m('Create this group!')]],
|
||||
]);
|
||||
|
||||
$create_form->handleRequest($request);
|
||||
if ($create_form->isSubmitted() && $create_form->isValid()) {
|
||||
$data = $create_form->getData();
|
||||
$nickname = $data['group_nickname'];
|
||||
|
||||
Log::info(
|
||||
_m(
|
||||
'Actor id:{actor_id} nick:{actor_nick} created the group {nickname}',
|
||||
['{actor_id}' => $actor->getId(), 'actor_nick' => $actor->getNickname(), 'nickname' => $nickname],
|
||||
),
|
||||
);
|
||||
|
||||
DB::persist($group = E\Actor::create([
|
||||
'nickname' => $nickname,
|
||||
'type' => E\Actor::GROUP,
|
||||
'is_local' => true,
|
||||
'roles' => UserRoles::BOT,
|
||||
]));
|
||||
DB::persist(LocalGroup::create([
|
||||
'group_id' => $group->getId(),
|
||||
'nickname' => $nickname,
|
||||
]));
|
||||
DB::persist(ActorSubscription::create([
|
||||
'subscriber_id' => $group->getId(),
|
||||
'subscribed_id' => $group->getId(),
|
||||
]));
|
||||
DB::persist(GroupMember::create([
|
||||
'group_id' => $group->getId(),
|
||||
'actor_id' => $actor->getId(),
|
||||
'is_admin' => true,
|
||||
]));
|
||||
DB::flush();
|
||||
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribers']);
|
||||
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
|
||||
|
||||
throw new RedirectException();
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'group/create.html.twig',
|
||||
'create_form' => $create_form->createView(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings page for the group with the provided nickname, checks if the current actor can administrate given group
|
||||
*
|
||||
* @throws ClientException
|
||||
* @throws NicknameEmptyException
|
||||
* @throws NicknameInvalidException
|
||||
* @throws NicknameNotAllowedException
|
||||
* @throws NicknameTakenException
|
||||
* @throws NicknameTooLongException
|
||||
* @throws NoLoggedInUser
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function groupSettings(Request $request, string $nickname)
|
||||
{
|
||||
$group = Actor::getByNickname($nickname, type: Actor::GROUP);
|
||||
$actor = Common::actor();
|
||||
if (!\is_null($group) && $actor->canAdmin($group)) {
|
||||
$local_group = LocalGroup::getByNickname($nickname);
|
||||
$group_actor = $local_group->getActor();
|
||||
$actor = Common::actor();
|
||||
if (!\is_null($group_actor) && $actor->canAdmin($group_actor)) {
|
||||
return [
|
||||
'_template' => 'group/settings.html.twig',
|
||||
'group' => $group,
|
||||
'personal_info_form' => ActorForms::personalInfo($request, $group)->createView(),
|
||||
'group' => $group_actor,
|
||||
'personal_info_form' => ActorForms::personalInfo($request, $actor, $local_group)->createView(),
|
||||
'open_details_query' => $this->string('open'),
|
||||
];
|
||||
} else {
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
@@ -17,7 +19,7 @@
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace App\Entity;
|
||||
namespace Component\Group\Entity;
|
||||
|
||||
use App\Core\Entity;
|
||||
use DateTimeInterface;
|
||||
@@ -42,11 +44,11 @@ class GroupAlias extends Entity
|
||||
// @codeCoverageIgnoreStart
|
||||
private string $alias;
|
||||
private int $group_id;
|
||||
private \DateTimeInterface $modified;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setAlias(string $alias): self
|
||||
{
|
||||
$this->alias = $alias;
|
||||
$this->alias = mb_substr($alias, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
@@ -17,7 +19,7 @@
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace App\Entity;
|
||||
namespace Component\Group\Entity;
|
||||
|
||||
use App\Core\Entity;
|
||||
use DateTimeInterface;
|
||||
@@ -43,7 +45,7 @@ class GroupBlock extends Entity
|
||||
private int $group_id;
|
||||
private int $blocked_actor;
|
||||
private int $blocker_user;
|
||||
private \DateTimeInterface $modified;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setGroupId(int $group_id): self
|
||||
{
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
@@ -17,7 +19,7 @@
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace App\Entity;
|
||||
namespace Component\Group\Entity;
|
||||
|
||||
use App\Core\Entity;
|
||||
use DateTimeInterface;
|
||||
@@ -42,7 +44,7 @@ class GroupInbox extends Entity
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $group_id;
|
||||
private int $activity_id;
|
||||
private \DateTimeInterface $created;
|
||||
private DateTimeInterface $created;
|
||||
|
||||
public function setGroupId(int $group_id): self
|
||||
{
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
@@ -17,7 +19,7 @@
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace App\Entity;
|
||||
namespace Component\Group\Entity;
|
||||
|
||||
use App\Core\Entity;
|
||||
|
@@ -19,7 +19,7 @@ declare(strict_types = 1);
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace App\Entity;
|
||||
namespace Component\Group\Entity;
|
||||
|
||||
use App\Core\Entity;
|
||||
use DateTimeInterface;
|
||||
@@ -44,8 +44,8 @@ class GroupMember extends Entity
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $group_id;
|
||||
private int $actor_id;
|
||||
private ?bool $is_admin;
|
||||
private ?string $uri;
|
||||
private ?bool $is_admin = false;
|
||||
private ?string $uri = null;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
@@ -84,7 +84,7 @@ class GroupMember extends Entity
|
||||
|
||||
public function setUri(?string $uri): self
|
||||
{
|
||||
$this->uri = $uri;
|
||||
$this->uri = \is_null($uri) ? null : mb_substr($uri, 0, 191);
|
||||
return $this;
|
||||
}
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/soci
|
||||
//
|
||||
@@ -17,11 +19,20 @@
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace App\Entity;
|
||||
namespace Component\Group\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Exception\NicknameEmptyException;
|
||||
use App\Util\Exception\NicknameException;
|
||||
use App\Util\Exception\NicknameInvalidException;
|
||||
use App\Util\Exception\NicknameNotAllowedException;
|
||||
use App\Util\Exception\NicknameTakenException;
|
||||
use App\Util\Exception\NicknameTooLongException;
|
||||
use App\Util\Nickname;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
@@ -43,9 +54,9 @@ class LocalGroup extends Entity
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $group_id;
|
||||
private ?string $nickname;
|
||||
private \DateTimeInterface $created;
|
||||
private \DateTimeInterface $modified;
|
||||
private ?string $nickname = null;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setGroupId(int $group_id): self
|
||||
{
|
||||
@@ -60,7 +71,7 @@ class LocalGroup extends Entity
|
||||
|
||||
public function setNickname(?string $nickname): self
|
||||
{
|
||||
$this->nickname = $nickname;
|
||||
$this->nickname = \is_null($nickname) ? null : mb_substr($nickname, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -99,6 +110,41 @@ class LocalGroup extends Entity
|
||||
return DB::find('actor', ['id' => $this->group_id]);
|
||||
}
|
||||
|
||||
public static function getByNickname(string $nickname): ?self
|
||||
{
|
||||
$res = DB::findBy(self::class, ['nickname' => $nickname]);
|
||||
return $res === [] ? null : $res[0];
|
||||
}
|
||||
|
||||
public static function getActorByNickname(string $nickname): ?Actor
|
||||
{
|
||||
$res = DB::findBy(Actor::class, ['nickname' => $nickname, 'type' => Actor::GROUP]);
|
||||
return $res === [] ? null : $res[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if desired nickname is allowed, and in case it is, it sets Actor's nickname cache to newly set nickname
|
||||
*
|
||||
* @param string $nickname Desired NEW nickname (do not use in local user creation)
|
||||
*
|
||||
* @throws NicknameEmptyException
|
||||
* @throws NicknameException
|
||||
* @throws NicknameInvalidException
|
||||
* @throws NicknameNotAllowedException
|
||||
* @throws NicknameTakenException
|
||||
* @throws NicknameTooLongException
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setNicknameSanitizedAndCached(string $nickname): self
|
||||
{
|
||||
$nickname = Nickname::normalize($nickname, check_already_used: true, which: Nickname::CHECK_LOCAL_GROUP, check_is_allowed: true);
|
||||
$this->setNickname($nickname);
|
||||
$this->getActor()->setNickname($nickname);
|
||||
/// XXX: cache?
|
||||
return $this;
|
||||
}
|
||||
|
||||
public static function schemaDef(): array
|
||||
{
|
||||
return [
|
@@ -30,15 +30,16 @@ use App\Entity\Actor;
|
||||
use App\Util\Common;
|
||||
use App\Util\HTML;
|
||||
use App\Util\Nickname;
|
||||
use Component\Circle\Controller\SelfTagsSettings;
|
||||
use Component\Group\Controller as C;
|
||||
use Component\Tag\Controller\Tag as TagController;
|
||||
use Component\Group\Entity\LocalGroup;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Group extends Component
|
||||
{
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$r->connect(id: 'group_actor_view_id', uri_path: '/group/{id<\d+>}', target: [C\Group::class, 'groupViewId']);
|
||||
$r->connect(id: 'group_create', uri_path: '/group/new', target: [C\Group::class, 'groupCreate']);
|
||||
$r->connect(id: 'group_actor_view_nickname', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\Group::class, 'groupViewNickname'], options: ['is_system_path' => false]);
|
||||
$r->connect(id: 'group_settings', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}/settings', target: [C\Group::class, 'groupSettings'], options: ['is_system_path' => false]);
|
||||
return Event::next;
|
||||
@@ -53,7 +54,7 @@ class Group extends Component
|
||||
$group = $vars['actor'];
|
||||
if (!\is_null($actor) && $group->isGroup() && $actor->canAdmin($group)) {
|
||||
$url = Router::url('group_settings', ['nickname' => $group->getNickname()]);
|
||||
$res[] = HTML::html(['hr' => '', 'a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings')], 'p' => _m('Group settings')]]);
|
||||
$res[] = HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings'), 'class' => 'profile-extra-actions'], _m('Group settings')]]);
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
@@ -62,12 +63,12 @@ class Group extends Component
|
||||
{
|
||||
if ($section === 'profile' && $request->get('_route') === 'group_settings') {
|
||||
$nickname = $request->get('nickname');
|
||||
$group = Actor::getByNickname($nickname, type: Actor::GROUP);
|
||||
$group = LocalGroup::getActorByNickname($nickname);
|
||||
$tabs[] = [
|
||||
'title' => 'Self tags',
|
||||
'desc' => 'Add or remove tags on this group',
|
||||
'id' => 'settings-self-tags',
|
||||
'controller' => TagController::settingsSelfTags($request, $group, 'settings-self-tags-details'),
|
||||
'controller' => SelfTagsSettings::settingsSelfTags($request, $group, 'settings-self-tags-details'),
|
||||
];
|
||||
}
|
||||
return Event::next;
|
||||
@@ -82,7 +83,7 @@ class Group extends Component
|
||||
if (!\is_null($id = $request->get('id'))) {
|
||||
return Actor::getById((int) $id);
|
||||
} elseif (!\is_null($nickname = $request->get('nickname'))) {
|
||||
return Actor::getByNickname($nickname, type: Actor::GROUP);
|
||||
return LocalGroup::getActorByNickname($nickname);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -93,7 +94,7 @@ class Group extends Component
|
||||
$group = $this->getGroupFromContext($request);
|
||||
if (!\is_null($group)) {
|
||||
$nick = '!' . $group->getNickname();
|
||||
$targets[$nick] = $nick;
|
||||
$targets[$nick] = $group->getId();
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
5
components/Group/templates/group/create.html.twig
Normal file
5
components/Group/templates/group/create.html.twig
Normal file
@@ -0,0 +1,5 @@
|
||||
{% extends 'stdgrid.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
{{ form(create_form) }}
|
||||
{% endblock body %}
|
@@ -16,32 +16,50 @@
|
||||
{% include 'cards/profile/view.html.twig' with { 'actor': actor } only %}
|
||||
{% endblock profile_view %}
|
||||
|
||||
<main class="feed" tabindex="0" role="feed">
|
||||
<div class="h-feed hfeed notes">
|
||||
{% if notes is defined and notes is not empty %}
|
||||
{% for conversation in notes %}
|
||||
{% block current_note %}
|
||||
{% if conversation is instanceof('array') %}
|
||||
{{ noteView.macro_note(conversation['note'], conversation['replies']) }}
|
||||
{% else %}
|
||||
{{ noteView.macro_note(conversation) }}
|
||||
{% endif %}
|
||||
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
|
||||
{% endblock current_note %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div id="empty-notes"><h1>{% trans %}No notes here.{% endtrans %}</h1></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
{% if notes is defined %}
|
||||
<article>
|
||||
<header class="feed-header">
|
||||
{% if page_title is defined %}
|
||||
<h1 class="heading-no-margin">{{ page_title | trans }}</h1>
|
||||
{% else %}
|
||||
<h1 class="heading-no-margin">{{ 'Notes' | trans }}</h1>
|
||||
{% endif %}
|
||||
<nav class="feed-actions">
|
||||
<details class="feed-actions-details">
|
||||
<summary>
|
||||
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
|
||||
</summary>
|
||||
<div class="feed-actions-details-dropdown">
|
||||
<menu>
|
||||
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
</menu>
|
||||
</div>
|
||||
</details>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% else %}
|
||||
<div class="section-padding section-widget">
|
||||
<p>{% trans with { '%group%': nickname } %}The group <em>%group%</em> doesn't exist.{% endtrans %}</p>
|
||||
{% if create_form is defined and create_form is not null %}
|
||||
<p>{% trans %}Would you like to create it?{% endtrans %}</p>
|
||||
{{ form(create_form) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
{% endblock body %}
|
||||
|
@@ -28,13 +28,13 @@ use App\Core\Controller;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity\ActorLanguage;
|
||||
use App\Entity\Language as LangEntity;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\NoLoggedInUser;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use App\Util\Form\FormFields;
|
||||
use Component\Language\Entity\ActorLanguage;
|
||||
use Component\Language\Entity\Language as LangEntity;
|
||||
use Functional as F;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
@@ -139,7 +139,7 @@ class Language extends Controller
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'settings/sort_languages.html.twig',
|
||||
'_template' => 'language/sort.html.twig',
|
||||
'form' => $form->createView(),
|
||||
];
|
||||
}
|
||||
|
@@ -21,11 +21,13 @@ declare(strict_types = 1);
|
||||
|
||||
// }}}
|
||||
|
||||
namespace App\Entity;
|
||||
namespace Component\Language\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Util\Common;
|
||||
use Functional as F;
|
||||
|
||||
@@ -69,16 +71,17 @@ class ActorLanguage extends Entity
|
||||
return $this->language_id;
|
||||
}
|
||||
|
||||
public function getOrdering(): int
|
||||
{
|
||||
return $this->ordering;
|
||||
}
|
||||
|
||||
public function setOrdering(int $ordering): self
|
||||
{
|
||||
$this->ordering = $ordering;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOrdering(): int
|
||||
{
|
||||
return $this->ordering;
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
@@ -21,12 +21,14 @@ declare(strict_types = 1);
|
||||
|
||||
// }}}
|
||||
|
||||
namespace App\Entity;
|
||||
namespace Component\Language\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use DateTimeInterface;
|
||||
use Functional as F;
|
||||
@@ -46,9 +48,9 @@ class Language extends Entity
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $id;
|
||||
private string $locale;
|
||||
private string $long_display;
|
||||
private string $short_display;
|
||||
private ?string $locale = null;
|
||||
private ?string $long_display = null;
|
||||
private ?string $short_display = null;
|
||||
private DateTimeInterface $created;
|
||||
|
||||
public function setId(int $id): self
|
||||
@@ -62,35 +64,35 @@ class Language extends Entity
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setLocale(string $locale): self
|
||||
public function setLocale(?string $locale): self
|
||||
{
|
||||
$this->locale = $locale;
|
||||
$this->locale = \is_null($locale) ? null : mb_substr($locale, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLocale(): string
|
||||
public function getLocale(): ?string
|
||||
{
|
||||
return $this->locale;
|
||||
}
|
||||
|
||||
public function setLongDisplay(string $long_display): self
|
||||
public function setLongDisplay(?string $long_display): self
|
||||
{
|
||||
$this->long_display = $long_display;
|
||||
$this->long_display = \is_null($long_display) ? null : mb_substr($long_display, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLongDisplay(): string
|
||||
public function getLongDisplay(): ?string
|
||||
{
|
||||
return $this->long_display;
|
||||
}
|
||||
|
||||
public function setShortDisplay(string $short_display): self
|
||||
public function setShortDisplay(?string $short_display): self
|
||||
{
|
||||
$this->short_display = $short_display;
|
||||
$this->short_display = \is_null($short_display) ? null : mb_substr($short_display, 0, 12);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getShortDisplay(): string
|
||||
public function getShortDisplay(): ?string
|
||||
{
|
||||
return $this->short_display;
|
||||
}
|
||||
@@ -105,6 +107,7 @@ class Language extends Entity
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
@@ -159,12 +162,12 @@ class Language extends Entity
|
||||
$preferred_language_choices = $actor->getPreferredLanguageChoices($context_actor);
|
||||
ksort($language_choices);
|
||||
if ($use_short_display ?? Common::config('posting', 'use_short_language_display')) {
|
||||
$key = array_key_first($preferred_language_choices);
|
||||
$locale = $preferred_language_choices[$key];
|
||||
$key = array_key_first($preferred_language_choices);
|
||||
$language = $preferred_language_choices[$key];
|
||||
unset($preferred_language_choices[$key], $language_choices[$key]);
|
||||
$short_display = self::getByLocale($locale)->getShortDisplay();
|
||||
$preferred_language_choices[$short_display] = trim($locale);
|
||||
$language_choices[$short_display] = trim($locale);
|
||||
$short_display = $language->getShortDisplay();
|
||||
$preferred_language_choices[$short_display] = ($locale = $language->getLocale());
|
||||
$language_choices[$short_display] = $locale;
|
||||
}
|
||||
return [$language_choices, $preferred_language_choices];
|
||||
}
|
||||
@@ -175,10 +178,10 @@ class Language extends Entity
|
||||
'name' => 'language',
|
||||
'description' => 'all known languages',
|
||||
'fields' => [
|
||||
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'],
|
||||
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'],
|
||||
'locale' => ['type' => 'varchar', 'length' => 64, 'description' => 'The locale identifier for the language of a note. 2-leter-iso-language-code_4-leter-script-code_2-leter-iso-country-code, but kept longer in case we get a different format'],
|
||||
'long_display' => ['type' => 'varchar', 'length' => 64, 'description' => 'The long display string for the language, in english (translated later)'],
|
||||
'short_display' => ['type' => 'varchar', 'length' => 12, 'description' => 'The short display string for the language (used for the first option)'],
|
||||
'short_display' => ['type' => 'varchar', 'length' => 12, 'description' => 'The short display string for the language (used for the first option)'],
|
||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
||||
],
|
||||
'primary key' => ['id'],
|
@@ -25,11 +25,11 @@ use App\Core\Event;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\RouteLoader;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\ActorLanguage;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Formatting;
|
||||
use App\Util\Functional as GSF;
|
||||
use Component\Language\Controller as C;
|
||||
use Component\Language\Entity\ActorLanguage;
|
||||
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||
use Doctrine\ORM\Query\Expr;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
@@ -38,20 +38,20 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Language extends Component
|
||||
{
|
||||
public function onAddRoute(RouteLoader $r)
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$r->connect('settings_sort_languages', '/settings/sort_languages', [C\Language::class, 'sortLanguages']);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onFilterNoteList(?Actor $actor, array &$notes, Request $request)
|
||||
public function onFilterNoteList(?Actor $actor, array &$notes, Request $request): bool
|
||||
{
|
||||
if (\is_null($actor)) {
|
||||
return Event::next;
|
||||
}
|
||||
$notes = F\select(
|
||||
$notes,
|
||||
fn (Note $n) => \in_array($n->getLanguageId(), ActorLanguage::getActorRelatedLanguagesIds($actor)),
|
||||
fn (Note $n) => \is_null($n->getLanguageId()) || \in_array($n->getLanguageId(), ActorLanguage::getActorRelatedLanguagesIds($actor)),
|
||||
);
|
||||
|
||||
return Event::next;
|
||||
@@ -60,7 +60,7 @@ class Language extends Component
|
||||
/**
|
||||
* Populate $note_expr or $actor_expr with an expression to match a language
|
||||
*/
|
||||
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr)
|
||||
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;
|
||||
|
||||
@@ -90,26 +90,26 @@ class Language extends Component
|
||||
$note_expr = $temp_note_expr;
|
||||
$actor_expr = $temp_actor_expr;
|
||||
return Event::stop;
|
||||
} elseif (Formatting::startsWith($term, GSF::cartesianProduct(['-', '_'], ['note', 'post'], ['lang', 'language'], [':']))) {
|
||||
} elseif (Formatting::startsWith($term, GSF::cartesianProduct([['note', 'post'], ['lang', 'language'], [':']], separator: ['-', '_']))) {
|
||||
$note_expr = $temp_note_expr;
|
||||
return Event::stop;
|
||||
} elseif (Formatting::startsWith($term, GSF::cartesianProduct(['-', '_'], ['note', 'post'], ['author', 'actor', 'people', 'person'], ['lang', 'language'], [':']))) {
|
||||
} elseif (Formatting::startsWith($term, GSF::cartesianProduct([['note', 'post'], ['author', 'actor', 'people', 'person'], ['lang', 'language'], [':']], separator: ['-', '_']))) {
|
||||
$note_expr = $temp_note_actor_expr;
|
||||
return Event::stop;
|
||||
} elseif (Formatting::startsWith($term, GSF::cartesianProduct(['-', '_'], ['actor', 'people', 'person'], ['lang', 'language'], [':']))) {
|
||||
} elseif (Formatting::startsWith($term, GSF::cartesianProduct([['actor', 'people', 'person'], ['lang', 'language'], [':']], separator: ['-', '_']))) {
|
||||
$actor_expr = $temp_actor_expr;
|
||||
return Event::stop;
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
{
|
||||
$note_qb->leftJoin('App\Entity\Language', 'note_language', Expr\Join::WITH, 'note.language_id = note_language.id')
|
||||
->leftJoin('App\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'note.actor_id = actor_language.actor_id')
|
||||
->leftJoin('App\Entity\Language', 'note_actor_language', Expr\Join::WITH, 'note_actor_language.id = actor_language.language_id');
|
||||
$actor_qb->leftJoin('App\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'actor.id = actor_language.actor_id')
|
||||
->leftJoin('App\Entity\Language', 'language', Expr\Join::WITH, 'actor_language.language_id = language.id');
|
||||
$note_qb->leftJoin('Component\Language\Entity\Language', 'note_language', Expr\Join::WITH, 'note.language_id = note_language.id')
|
||||
->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'note.actor_id = actor_language.actor_id')
|
||||
->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')
|
||||
->leftJoin('Component\Language\Entity\Language', 'language', Expr\Join::WITH, 'actor_language.language_id = language.id');
|
||||
return Event::next;
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="section-widget section-widget-padded">
|
||||
<div class="frame-section frame-section-padding">
|
||||
<h3>{{ 'Put the languages in the order you\'d like to see them in your language selection dropdown, when posting' | trans}}</h3>
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
|
@@ -33,7 +33,7 @@ use App\Entity\Feed;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use Component\Feed\Util\FeedController;
|
||||
use Component\Collection\Util\Controller\FeedController;
|
||||
use Functional as F;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
|
@@ -77,7 +77,7 @@ class LeftPanel extends Component
|
||||
*/
|
||||
public function onEndShowStyles(array &$styles, string $route): bool
|
||||
{
|
||||
$styles[] = 'components/Left/assets/css/view.css';
|
||||
$styles[] = 'components/LeftPanel/assets/css/view.css';
|
||||
return Event::next;
|
||||
}
|
||||
}
|
||||
|
@@ -5,35 +5,49 @@
|
||||
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
|
||||
{% endblock stylesheets %}
|
||||
|
||||
{% macro edit_feeds_form_row(child) %}
|
||||
<div class="form-row">
|
||||
{{ form_label(child) }}
|
||||
{{ form_widget(child) }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block body %}
|
||||
<div class="section-widget">
|
||||
<div class="frame-section">
|
||||
<form class="section-form" action="{{ path('edit_feeds') }}" method="post">
|
||||
|
||||
<fieldset>
|
||||
<legend class="section-form-legend">{{ "Edit feed navigation links" | trans }}</legend>
|
||||
|
||||
<h1 class="frame-section-title">{{ "Edit feed navigation links" | trans }}</h1>
|
||||
{# Since the form is not separated into individual groups, this happened #}
|
||||
{{ form_start(edit_feeds) }}
|
||||
{% for child in edit_feeds.children %}
|
||||
{% if 'row_url' in child.vars.block_prefixes %}
|
||||
<div class="section-widget section-padding">
|
||||
{{ form_label(child) }}
|
||||
{{ form_widget(child) }}
|
||||
{% elseif 'row_title' in child.vars.block_prefixes %}
|
||||
{{ form_label(child) }}
|
||||
{{ form_widget(child) }}
|
||||
{% elseif 'row_order' in child.vars.block_prefixes %}
|
||||
{{ form_label(child) }}
|
||||
{{ form_widget(child) }}
|
||||
{% elseif 'row_remove' in child.vars.block_prefixes %}
|
||||
{{ form_label(child) }}
|
||||
{{ form_widget(child) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ form_errors(edit_feeds) }}
|
||||
<section class="container-grid">
|
||||
{% for child in edit_feeds.children %}
|
||||
{% if 'row_url' in child.vars.block_prefixes %}
|
||||
<div class="frame-section frame-section-padding">
|
||||
{{ _self.edit_feeds_form_row(child) }}
|
||||
{% elseif 'row_title' in child.vars.block_prefixes %}
|
||||
{{ _self.edit_feeds_form_row(child) }}
|
||||
{% elseif 'row_order' in child.vars.block_prefixes %}
|
||||
{{ _self.edit_feeds_form_row(child) }}
|
||||
{% elseif 'row_remove' in child.vars.block_prefixes %}
|
||||
{{ _self.edit_feeds_form_row(child) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<div class="form-row">
|
||||
{{ form_row(edit_feeds.update_exisiting) }}
|
||||
{{ form_row(edit_feeds.reset) }}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<section class="frame-section frame-section-padding">
|
||||
<h2>{% trans %}Add a new feed{% endtrans %}</h2>
|
||||
{{ form_rest(edit_feeds) }}
|
||||
</section>
|
||||
{{ form_end(edit_feeds) }}
|
||||
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
@@ -1,25 +1,24 @@
|
||||
{% block leftpanel %}
|
||||
<label class="panel-left-icon" for="panel-left-toggle" aria-hidden="true"
|
||||
tabindex="-1">{{ icon('menu', 'icon icon-left') | raw }}</label>
|
||||
<input type="checkbox" id="panel-left-toggle" aria-hidden="true" tabindex="-1">
|
||||
<label class="panel-left-icon" for="toggle-panel-left" tabindex="-1">{{ icon('menu', 'icon icon-left') | raw }}</label>
|
||||
<a id="anchor-left-panel" class="anchor-hidden" tabindex="0" title="{{ 'Press tab followed by a space to access left panel' | trans }}"></a>
|
||||
<input type="checkbox" id="toggle-panel-left" tabindex="0" title="{{ 'Open left panel' | trans }}">
|
||||
|
||||
<section class="header-panel section-panel-left">
|
||||
<a id="anchor-left-panel" class="anchor-hidden" title="{{ 'Press tab to access selected region!' | trans }}"></a>
|
||||
<aside class="panel-content accessibility-target">
|
||||
{% if app.user %}
|
||||
<section class='section-widget section-padding' title="{{ 'Your profile information.' | trans }}">
|
||||
{% block profile_view %}{% include 'cards/profile/view.html.twig' with { actor: current_actor } %}{% endblock profile_view %}
|
||||
{{ block("profile_current_actor", "cards/navigation/view.html.twig") }}
|
||||
</section>
|
||||
{% else %}
|
||||
<section>
|
||||
{{ block("profile_security", "cards/navigation/view.html.twig") }}
|
||||
</section>
|
||||
{% endif %}
|
||||
<aside class="section-panel section-panel-left">
|
||||
<section class="panel-content accessibility-target">
|
||||
{% if app.user %}
|
||||
<section class='frame-section frame-section-padding' title="{{ 'Your profile information.' | trans }}">
|
||||
{% block profile_view %}{% include 'cards/profile/view.html.twig' with { actor: current_actor } %}{% endblock profile_view %}
|
||||
{{ block("profile_current_actor", "cards/navigation/view.html.twig") }}
|
||||
</section>
|
||||
{% else %}
|
||||
<section>
|
||||
{{ block("profile_security", "cards/navigation/view.html.twig") }}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{{ block("feeds", "cards/navigation/view.html.twig") }}
|
||||
{{ block("feeds", "cards/navigation/view.html.twig") }}
|
||||
|
||||
{{ block("footer", "cards/navigation/view.html.twig") }}
|
||||
</aside>
|
||||
{{ block("footer", "cards/navigation/view.html.twig") }}
|
||||
</section>
|
||||
</aside>
|
||||
{% endblock leftpanel %}
|
||||
|
@@ -49,9 +49,9 @@ class Link extends Entity
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $id;
|
||||
private ?string $url;
|
||||
private ?string $url_hash;
|
||||
private ?string $mimetype;
|
||||
private ?string $url = null;
|
||||
private ?string $url_hash = null;
|
||||
private ?string $mimetype = null;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setId(int $id): self
|
||||
@@ -65,20 +65,20 @@ class Link extends Entity
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUrl(): ?string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function setUrl(?string $url): self
|
||||
{
|
||||
$this->url = $url;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUrl(): ?string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function setUrlHash(?string $url_hash): self
|
||||
{
|
||||
$this->url_hash = $url_hash;
|
||||
$this->url_hash = \is_null($url_hash) ? null : mb_substr($url_hash, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class Link extends Entity
|
||||
|
||||
public function setMimetype(?string $mimetype): self
|
||||
{
|
||||
$this->mimetype = $mimetype;
|
||||
$this->mimetype = \is_null($mimetype) ? null : mb_substr($mimetype, 0, 50);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -98,18 +98,6 @@ class Link extends Entity
|
||||
return $this->mimetype;
|
||||
}
|
||||
|
||||
public function getMimetypeMajor(): ?string
|
||||
{
|
||||
$mime = $this->getMimetype();
|
||||
return \is_null($mime) ? $mime : GSFile::mimetypeMajor($mime);
|
||||
}
|
||||
|
||||
public function getMimetypeMinor(): ?string
|
||||
{
|
||||
$mime = $this->getMimetype();
|
||||
return \is_null($mime) ? $mime : GSFile::mimetypeMinor($mime);
|
||||
}
|
||||
|
||||
public function setModified(DateTimeInterface $modified): self
|
||||
{
|
||||
$this->modified = $modified;
|
||||
@@ -172,6 +160,18 @@ class Link extends Entity
|
||||
}
|
||||
}
|
||||
|
||||
public function getMimetypeMajor(): ?string
|
||||
{
|
||||
$mime = $this->getMimetype();
|
||||
return \is_null($mime) ? $mime : GSFile::mimetypeMajor($mime);
|
||||
}
|
||||
|
||||
public function getMimetypeMinor(): ?string
|
||||
{
|
||||
$mime = $this->getMimetype();
|
||||
return \is_null($mime) ? $mime : GSFile::mimetypeMinor($mime);
|
||||
}
|
||||
|
||||
public static function schemaDef(): array
|
||||
{
|
||||
return [
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
@@ -40,7 +42,7 @@ class NoteToLink extends Entity
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $link_id;
|
||||
private int $note_id;
|
||||
private \DateTimeInterface $modified;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setLinkId(int $link_id): self
|
||||
{
|
||||
@@ -74,6 +76,7 @@ class NoteToLink extends Entity
|
||||
{
|
||||
return $this->modified;
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
@@ -93,10 +96,6 @@ class NoteToLink extends Entity
|
||||
return parent::create($args, $obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $note_id
|
||||
* @return mixed
|
||||
*/
|
||||
public static function removeWhereNoteId(int $note_id): mixed
|
||||
{
|
||||
return DB::dql(
|
||||
@@ -108,11 +107,6 @@ class NoteToLink extends Entity
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $link_id
|
||||
* @param int $note_id
|
||||
* @return mixed
|
||||
*/
|
||||
public static function removeWhere(int $link_id, int $note_id): mixed
|
||||
{
|
||||
return DB::dql(
|
||||
@@ -125,10 +119,6 @@ class NoteToLink extends Entity
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $link_id
|
||||
* @return mixed
|
||||
*/
|
||||
public static function removeWhereLinkId(int $link_id): mixed
|
||||
{
|
||||
return DB::dql(
|
||||
|
@@ -26,6 +26,7 @@ namespace Component\Link;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\HTML;
|
||||
@@ -41,7 +42,8 @@ class Link extends Component
|
||||
{
|
||||
if (Common::config('attachments', 'process_links')) {
|
||||
$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]);
|
||||
foreach ($matched_urls as $match) {
|
||||
try {
|
||||
@@ -259,9 +261,9 @@ class Link extends Component
|
||||
return HTML::html(['a' => ['attrs' => $attrs, $url]], options: ['indent' => false]);
|
||||
}
|
||||
|
||||
public function onNoteDeleteRelated(Note &$note): bool
|
||||
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
|
||||
{
|
||||
NoteToLink::removeWhereNoteId($note->getId());
|
||||
DB::wrapInTransaction(fn () => NoteToLink::removeWhereNoteId($note->getId()));
|
||||
return Event::next;
|
||||
}
|
||||
}
|
||||
|
@@ -57,7 +57,7 @@ class Feed extends Controller
|
||||
)
|
||||
EOF, ['id' => $user->getId()]);
|
||||
return [
|
||||
'_template' => 'feed/feed.html.twig',
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m('Notifications'),
|
||||
'should_format' => true,
|
||||
'notes' => $notes,
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
@@ -45,9 +47,9 @@ class Notification extends Entity
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $activity_id;
|
||||
private int $target_id;
|
||||
private ?string $reason;
|
||||
private \DateTimeInterface $created;
|
||||
private \DateTimeInterface $modified;
|
||||
private ?string $reason = null;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setActivityId(int $activity_id): self
|
||||
{
|
||||
@@ -73,7 +75,7 @@ class Notification extends Entity
|
||||
|
||||
public function setReason(?string $reason): self
|
||||
{
|
||||
$this->reason = $reason;
|
||||
$this->reason = \is_null($reason) ? null : mb_substr($reason, 0, 191);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -107,9 +109,6 @@ class Notification extends Entity
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
/**
|
||||
* @return Actor
|
||||
*/
|
||||
public function getTarget(): Actor
|
||||
{
|
||||
return Actor::getById($this->getTargetId());
|
||||
@@ -122,18 +121,14 @@ class Notification extends Entity
|
||||
*/
|
||||
public static function getNotificationTargetIdsByActivity(int|Activity $activity_id): array
|
||||
{
|
||||
$notifications = DB::findBy('notification', ['activity_id' => is_int($activity_id) ? $activity_id : $activity_id->getId()]);
|
||||
$targets = [];
|
||||
$notifications = DB::findBy('notification', ['activity_id' => \is_int($activity_id) ? $activity_id : $activity_id->getId()]);
|
||||
$targets = [];
|
||||
foreach ($notifications as $notification) {
|
||||
$targets[] = $notification->getTargetId();
|
||||
}
|
||||
return $targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|Activity $activity_id
|
||||
* @return array
|
||||
*/
|
||||
public function getNotificationTargetsByActivity(int|Activity $activity_id): array
|
||||
{
|
||||
return DB::findBy('actor', ['id' => $this->getNotificationTargetIdsByActivity($activity_id)]);
|
||||
@@ -154,7 +149,7 @@ class Notification extends Entity
|
||||
'primary key' => ['activity_id', 'target_id'],
|
||||
'indexes' => [
|
||||
'attention_activity_id_idx' => ['activity_id'],
|
||||
'attention_target_id_idx' => ['target_id'],
|
||||
'attention_target_id_idx' => ['target_id'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
@@ -38,7 +40,7 @@ class UserNotificationPrefs extends Entity
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $user_id;
|
||||
private string $transport;
|
||||
private ?int $target_actor_id;
|
||||
private ?int $target_actor_id = null;
|
||||
private bool $activity_by_subscribed = true;
|
||||
private bool $mention = true;
|
||||
private bool $reply = true;
|
||||
@@ -47,9 +49,9 @@ class UserNotificationPrefs extends Entity
|
||||
private bool $nudge = false;
|
||||
private bool $dm = true;
|
||||
private bool $post_on_status_change = false;
|
||||
private ?bool $enable_posting;
|
||||
private \DateTimeInterface $created;
|
||||
private \DateTimeInterface $modified;
|
||||
private ?bool $enable_posting = true;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setUserId(int $user_id): self
|
||||
{
|
||||
@@ -64,7 +66,7 @@ class UserNotificationPrefs extends Entity
|
||||
|
||||
public function setTransport(string $transport): self
|
||||
{
|
||||
$this->transport = $transport;
|
||||
$this->transport = mb_substr($transport, 0, 191);
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
@@ -32,6 +32,7 @@ use App\Entity\Activity;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\LocalUser;
|
||||
use Component\FreeNetwork\FreeNetwork;
|
||||
use Component\Group\Entity\GroupInbox;
|
||||
use Component\Notification\Controller\Feed;
|
||||
|
||||
class Notification extends Component
|
||||
@@ -46,7 +47,7 @@ class Notification extends Component
|
||||
{
|
||||
DB::persist(\App\Entity\Feed::create([
|
||||
'actor_id' => $actor_id,
|
||||
'url' => Router::url($route = 'feed_notifications', ['nickname' => $user->getNickname()]),
|
||||
'url' => Router::url($route = 'feed_notifications'),
|
||||
'route' => $route,
|
||||
'title' => _m('Notifications'),
|
||||
'ordering' => $ordering++,
|
||||
@@ -60,7 +61,7 @@ class Notification extends Component
|
||||
*/
|
||||
public function onNewNotification(Actor $sender, Activity $activity, array $ids_already_known = [], ?string $reason = null): bool
|
||||
{
|
||||
$targets = $activity->getNotificationTargets($ids_already_known, sender_id: $sender->getId());
|
||||
$targets = $activity->getNotificationTargets(ids_already_known: $ids_already_known, sender_id: $sender->getId());
|
||||
$this->notify($sender, $activity, $targets, $reason);
|
||||
|
||||
return Event::next;
|
||||
@@ -76,15 +77,32 @@ class Notification extends Component
|
||||
if ($target->getIsLocal()) {
|
||||
if ($target->isGroup()) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// TODO: use https://symfony.com/doc/current/notifier.html
|
||||
if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) {
|
||||
// TODO: use https://symfony.com/doc/current/notifier.html
|
||||
// XXX: Unideal as in failures the rollback will leave behind a false notification,
|
||||
// but most notifications (all) require flushing the objects first
|
||||
// Should be okay as long as implementors bear this in mind
|
||||
DB::wrapInTransaction(fn() => DB::persist(Entity\Notification::create([
|
||||
'activity_id' => $activity->getId(),
|
||||
'target_id' => $target->getId(),
|
||||
'reason' => $reason,
|
||||
])));
|
||||
}
|
||||
} else {
|
||||
$remote_targets[] = $target;
|
||||
// We have no authority nor responsibility of notifying remote actors of a remote actor's doing
|
||||
if ($sender->getIsLocal()) {
|
||||
$remote_targets[] = $target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -24,30 +24,29 @@ declare(strict_types = 1);
|
||||
namespace Component\Posting;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Core\Event;
|
||||
use App\Core\Form;
|
||||
use App\Core\GSFile;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\Router;
|
||||
use App\Core\Security;
|
||||
use App\Core\VisibilityScope;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\GroupInbox;
|
||||
use App\Entity\Language;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\BugFoundException;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\DuplicateFoundException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use App\Util\Form\FormFields;
|
||||
use App\Util\Formatting;
|
||||
use App\Util\HTML;
|
||||
use Component\Attachment\Entity\ActorToAttachment;
|
||||
use Component\Attachment\Entity\Attachment;
|
||||
use Component\Attachment\Entity\AttachmentToNote;
|
||||
use Component\Conversation\Conversation;
|
||||
use Component\Language\Entity\Language;
|
||||
use Functional as F;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||
@@ -56,6 +55,7 @@ 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\Request;
|
||||
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
|
||||
class Posting extends Component
|
||||
@@ -74,8 +74,7 @@ class Posting extends Component
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
$actor = $user->getActor();
|
||||
$actor_id = $user->getId();
|
||||
$actor = $user->getActor();
|
||||
|
||||
$placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
|
||||
Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
|
||||
@@ -96,14 +95,21 @@ class Posting extends Component
|
||||
Event::handle('PostingGetContextActor', [$request, $actor, &$context_actor]);
|
||||
|
||||
$form_params = [];
|
||||
if (!empty($in_targets)) {
|
||||
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]];
|
||||
}
|
||||
|
||||
$form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [_m('Public') => 'public', _m('Instance') => 'instance', _m('Private') => 'private']]];
|
||||
// TODO: if in group page, add GROUP visibility to the choices.
|
||||
$form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [
|
||||
_m('Public') => VisibilityScope::EVERYWHERE->value,
|
||||
_m('Local') => VisibilityScope::LOCAL->value,
|
||||
_m('Addressee') => VisibilityScope::ADDRESSEE->value,
|
||||
]]];
|
||||
$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'));
|
||||
$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,
|
||||
@@ -125,29 +131,53 @@ class Posting extends Component
|
||||
try {
|
||||
if ($form->isValid()) {
|
||||
$data = $form->getData();
|
||||
Event::handle('PostingModifyData', [$request, $actor, &$data, $form_params, $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'));
|
||||
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.'));
|
||||
}
|
||||
|
||||
$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(
|
||||
$user->getActor(),
|
||||
$data['content'],
|
||||
$content_type,
|
||||
$data['language'],
|
||||
$data['attachments'],
|
||||
target: $data['in'] ?? null,
|
||||
actor: $user->getActor(),
|
||||
content: $data['content'],
|
||||
content_type: $content_type,
|
||||
locale: $data['language'],
|
||||
scope: VisibilityScope::from($data['visibility']),
|
||||
target: $target ?? null, // @phpstan-ignore-line
|
||||
reply_to_id: $data['reply_to_id'],
|
||||
attachments: $data['attachments'],
|
||||
process_note_content_extra_args: $extra_args,
|
||||
);
|
||||
|
||||
try {
|
||||
if ($request->query->has('from')) {
|
||||
$from = $request->query->get('from');
|
||||
if (str_contains($from, '#')) {
|
||||
[$from, $fragment] = explode('#', $from);
|
||||
}
|
||||
Router::match($from);
|
||||
throw new RedirectException(url: $from . (isset($fragment) ? '#' . $fragment : ''));
|
||||
}
|
||||
} catch (ResourceNotFoundException $e) {
|
||||
// continue
|
||||
}
|
||||
throw new RedirectException();
|
||||
}
|
||||
} catch (FormSizeFileException $sizeFileException) {
|
||||
throw new FormSizeFileException();
|
||||
} catch (FormSizeFileException $e) {
|
||||
throw new ClientException(_m('Invalid file size given'), previous: $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,26 +195,30 @@ class Posting extends Component
|
||||
* @param array $processed_attachments Array of [Attachment, Attachment's name] to be associated to this $actor and Note
|
||||
* @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
|
||||
*
|
||||
* @throws BugFoundException
|
||||
* @throws ClientException
|
||||
* @throws DuplicateFoundException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return Entity|mixed
|
||||
*/
|
||||
public static function storeLocalNote(
|
||||
Actor $actor,
|
||||
?string $content,
|
||||
string $content_type,
|
||||
?string $language = null,
|
||||
?string $locale = null,
|
||||
?VisibilityScope $scope = null,
|
||||
null|Actor|int $target = null,
|
||||
?int $reply_to_id = null,
|
||||
array $attachments = [],
|
||||
array $processed_attachments = [],
|
||||
?string $target = null,
|
||||
array $process_note_content_extra_args = [],
|
||||
) {
|
||||
$rendered = null;
|
||||
bool $notify = true,
|
||||
?string $rendered = null,
|
||||
string $source = 'web',
|
||||
): Note {
|
||||
$scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL
|
||||
$mentions = [];
|
||||
if (!empty($content)) {
|
||||
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $language, &$mentions]);
|
||||
if (\is_null($rendered) && !empty($content)) {
|
||||
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]);
|
||||
}
|
||||
|
||||
$note = Note::create([
|
||||
@@ -192,8 +226,11 @@ class Posting extends Component
|
||||
'content' => $content,
|
||||
'content_type' => $content_type,
|
||||
'rendered' => $rendered,
|
||||
'language_id' => !\is_null($language) ? Language::getByLocale($language)->getId() : null,
|
||||
'language_id' => !\is_null($locale) ? Language::getByLocale($locale)->getId() : null,
|
||||
'is_local' => true,
|
||||
'scope' => $scope,
|
||||
'reply_to' => $reply_to_id,
|
||||
'source' => $source,
|
||||
]);
|
||||
|
||||
/** @var UploadedFile[] $attachments */
|
||||
@@ -210,11 +247,6 @@ class Posting extends Component
|
||||
|
||||
DB::persist($note);
|
||||
|
||||
// Assign conversation to this note
|
||||
// AddExtraArgsToNoteContent already added the info we need
|
||||
$reply_to = $process_note_content_extra_args['reply_to'];
|
||||
Conversation::assignLocalConversation($note, $reply_to);
|
||||
|
||||
// Need file and note ids for the next step
|
||||
$note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL));
|
||||
if (!empty($content)) {
|
||||
@@ -227,48 +259,42 @@ class Posting extends Component
|
||||
DB::persist(ActorToAttachment::create($args));
|
||||
}
|
||||
DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname]));
|
||||
$a->livesIncrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
Conversation::assignLocalConversation($note, $reply_to_id);
|
||||
|
||||
$activity = Activity::create([
|
||||
'actor_id' => $actor->getId(),
|
||||
'verb' => 'create',
|
||||
'object_type' => 'note',
|
||||
'object_id' => $note->getId(),
|
||||
'source' => 'web',
|
||||
'source' => $source,
|
||||
]);
|
||||
DB::persist($activity);
|
||||
|
||||
if (!\is_null($target)) {
|
||||
switch ($target[0]) {
|
||||
case '!':
|
||||
$mentions[] = [
|
||||
'mentioned' => [Actor::getByNickname(mb_substr($target, 1), type: Actor::GROUP)],
|
||||
'type' => 'group',
|
||||
'text' => mb_substr($target, 1),
|
||||
];
|
||||
break;
|
||||
default:
|
||||
throw new ClientException(_m('Unkown target type give in \'in\' field: ' . $target));
|
||||
}
|
||||
$target = \is_int($target) ? Actor::getById($target) : $target;
|
||||
$mentions[] = [
|
||||
'mentioned' => [$target],
|
||||
'type' => match ($target->getType()) {
|
||||
Actor::PERSON => 'mention',
|
||||
Actor::GROUP => 'group',
|
||||
default => throw new ClientException(_m('Unknown target type give in \'In\' field: {target}', ['{target}' => $target?->getNickname() ?? '<null>'])),
|
||||
},
|
||||
'text' => $target->getNickname(),
|
||||
];
|
||||
}
|
||||
|
||||
$mentioned = [];
|
||||
foreach (F\unique(F\flat_map($mentions, fn (array $m) => $m['mentioned'] ?? []), fn (Actor $a) => $a->getId()) as $m) {
|
||||
if (!\is_null($m)) {
|
||||
$mentioned[] = $m->getId();
|
||||
|
||||
if ($m->isGroup()) {
|
||||
DB::persist(GroupInbox::create([
|
||||
'group_id' => $m->getId(),
|
||||
'activity_id' => $activity->getId(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
$mention_ids = F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId())));
|
||||
|
||||
// Flush before notification
|
||||
DB::flush();
|
||||
Event::handle('NewNotification', [$actor, $activity, ['object' => $mentioned], "{$actor->getNickname()} created note {$note->getUrl()}"]);
|
||||
|
||||
if ($notify) {
|
||||
Event::handle('NewNotification', [$actor, $activity, ['object' => $mention_ids], _m('{nickname} created a note {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $activity->getObjectId()])]);
|
||||
}
|
||||
|
||||
return $note;
|
||||
}
|
||||
@@ -282,7 +308,7 @@ class Posting extends Component
|
||||
return Event::stop;
|
||||
case 'text/html':
|
||||
// TODO: It has to linkify and stuff as well
|
||||
$rendered = Security::sanitize($content);
|
||||
$rendered = HTML::sanitize($content);
|
||||
return Event::stop;
|
||||
default:
|
||||
return Event::next;
|
||||
|
@@ -35,7 +35,7 @@ class RightPanel extends Component
|
||||
*/
|
||||
public function onEndShowStyles(array &$styles, string $route): bool
|
||||
{
|
||||
$styles[] = 'components/Right/assets/css/view.css';
|
||||
$styles[] = 'components/RightPanel/assets/css/view.css';
|
||||
return Event::next;
|
||||
}
|
||||
}
|
||||
|
@@ -1,57 +1,65 @@
|
||||
{% block rightpanel %}
|
||||
<label class="panel-right-icon" for="panel-right-toggle" aria-hidden="true"
|
||||
tabindex="-1">{{ icon('chevron-left', 'icon icon-right') | raw }}</label>
|
||||
<input type="checkbox" id="panel-right-toggle" aria-hidden="true" tabindex="-1">
|
||||
<label class="panel-right-icon" for="toggle-panel-right" tabindex="-1">{{ icon('chevron-left', 'icon icon-right') | raw }}</label>
|
||||
<a id="anchor-right-panel" class="anchor-hidden" tabindex="0" title="{{ 'Press tab followed by a space to access right panel' | trans }}"></a>
|
||||
<input type="checkbox" id="toggle-panel-right" tabindex="0" title="{{ 'Open right panel' | trans }}">
|
||||
|
||||
<div class="header-panel section-panel-right">
|
||||
<a id="anchor-right-panel" class="anchor-hidden" title="{{ 'Press tab to access selected region!' | trans }}"></a>
|
||||
<aside class="panel-content accessibility-target">
|
||||
<aside class="section-panel section-panel-right">
|
||||
<section class="panel-content accessibility-target">
|
||||
{% set prepend_right_panel = handle_event('PrependRightPanel', request) %}
|
||||
{% for widget in prepend_right_panel %}
|
||||
{{ widget | raw }}
|
||||
{% endfor %}
|
||||
|
||||
{% set blocks = handle_event('AppendRightPostingBlock', request) %}
|
||||
{% if blocks['post_form'] is defined %}
|
||||
<section class="section-widget" title="{{ 'Create a new note.' | trans }}">
|
||||
<details class="section-widget-title-details" open="open"
|
||||
title="{{ 'Expand if you want to access more options.' | trans }}">
|
||||
<summary class="section-title-summary">
|
||||
<h2>
|
||||
{% set current_path = app.request.get('_route') %}
|
||||
{% 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 }}
|
||||
</h2>
|
||||
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
|
||||
</summary>
|
||||
{% endif %}
|
||||
</h2>
|
||||
</summary>
|
||||
|
||||
<div class="section-form">
|
||||
<fieldset>
|
||||
{{ form_start(blocks['post_form']) }}
|
||||
{% if blocks['post_form'].in is defined %}
|
||||
{{ form_row(blocks['post_form'].in) }}
|
||||
{% endif %}
|
||||
{{ form_row(blocks['post_form'].visibility) }}
|
||||
{{ form_row(blocks['post_form'].content) }}
|
||||
{{ form_row(blocks['post_form'].attachments) }}
|
||||
<section class="section-form">
|
||||
{{ form_start(blocks['post_form']) }}
|
||||
{{ form_errors(blocks['post_form']) }}
|
||||
{% if blocks['post_form'].in is defined %}
|
||||
{{ form_row(blocks['post_form'].in) }}
|
||||
{% endif %}
|
||||
{{ form_row(blocks['post_form'].visibility) }}
|
||||
{{ form_row(blocks['post_form'].content_type) }}
|
||||
{{ form_row(blocks['post_form'].content) }}
|
||||
{{ form_row(blocks['post_form'].attachments) }}
|
||||
|
||||
<details class="section-widget-subtitle-details">
|
||||
<summary class="section-subtitle-summary">
|
||||
<strong>
|
||||
{{ "Additional options" | trans }}
|
||||
</strong>
|
||||
{{ icon('arrow-down', 'icon icon-details-close') | raw }}
|
||||
</summary>
|
||||
{{ form_row(blocks['post_form'].language) }}
|
||||
{{ form_row(blocks['post_form'].tag_use_canonical) }}
|
||||
</details>
|
||||
{{ form_rest(blocks['post_form']) }}
|
||||
{{ form_end(blocks['post_form']) }}
|
||||
</fieldset>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
{% endif %}
|
||||
<details class="section-details-subtitle">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>
|
||||
{{ "Additional options" | trans }}
|
||||
</strong>
|
||||
</summary>
|
||||
<section class="section-form">
|
||||
{{ form_row(blocks['post_form'].language) }}
|
||||
{{ form_row(blocks['post_form'].tag_use_canonical) }}
|
||||
</section>
|
||||
</details>
|
||||
{{ form_rest(blocks['post_form']) }}
|
||||
{{ form_end(blocks['post_form']) }}
|
||||
</section>
|
||||
</details>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% set current_path = app.request.get('_route') %}
|
||||
{% for block in handle_event('AppendRightPanelBlock', {'path': current_path, 'vars': right_panel_vars | default }, request) %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
{% set extra_blocks = get_right_panel_blocks({'path': current_path, 'request': app.request, 'vars': (right_panel_vars | default)}) %}
|
||||
{% for block in extra_blocks %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
</section>
|
||||
</aside>
|
||||
{% endblock rightpanel %}
|
||||
|
@@ -30,8 +30,7 @@ use App\Util\Exception\BugFoundException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Form\FormFields;
|
||||
use App\Util\Formatting;
|
||||
use Component\Feed\Feed;
|
||||
use Component\Feed\Util\FeedController;
|
||||
use Component\Collection\Util\Controller\FeedController;
|
||||
use Component\Search as Comp;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
@@ -49,7 +48,7 @@ class Search extends FeedController
|
||||
$language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null;
|
||||
$q = $this->string('q');
|
||||
|
||||
$data = Feed::query(query: $q, page: $this->int('p'), language: $language);
|
||||
$data = $this->query(query: $q, locale: $language);
|
||||
$notes = $data['notes'];
|
||||
$actors = $data['actors'];
|
||||
|
||||
@@ -131,7 +130,7 @@ class Search extends FeedController
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'search/show.html.twig',
|
||||
'_template' => 'search/view.html.twig',
|
||||
'actor' => $actor,
|
||||
'search_form' => Comp\Search::searchForm($request, query: $q, add_subscribe: !\is_null($actor)),
|
||||
'search_builder_form' => $search_builder_form->createView(),
|
||||
|
@@ -29,6 +29,7 @@ use function App\Core\I18n\_m;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Formatting;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormView;
|
||||
@@ -83,7 +84,7 @@ class Search extends Component
|
||||
'label' => _m('Search'),
|
||||
'attr' => [
|
||||
//'class' => 'button-container search-button-container',
|
||||
'title' => _m('Query notes for specific tags.'),
|
||||
'title' => _m('Perform search'),
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -119,9 +120,9 @@ class Search extends Component
|
||||
*
|
||||
* @throws RedirectException
|
||||
*/
|
||||
public function onAddExtraHeaderForms(Request $request, array &$elements)
|
||||
public function onPrependRightPanel(Request $request, array &$elements)
|
||||
{
|
||||
$elements[] = self::searchForm($request);
|
||||
$elements[] = Formatting::twigRenderFile('cards/search/view.html.twig', ['search' => self::searchForm($request)]);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
|
6
components/Search/templates/cards/search/view.html.twig
Normal file
6
components/Search/templates/cards/search/view.html.twig
Normal file
@@ -0,0 +1,6 @@
|
||||
<section class="section-form form-search" title="{{ 'Search for notes, actors, and beyond' | trans }}">
|
||||
{{ form_start(search) }}
|
||||
<span>{{ form_row(search.search_query) }}{{ form_row(search.submit_search) }}</span>
|
||||
{{ form_rest(search) }}
|
||||
{{ form_end(search) }}
|
||||
</section>
|
@@ -1,91 +0,0 @@
|
||||
{% extends 'feed/feed.html.twig' %}
|
||||
|
||||
|
||||
{% block body %}
|
||||
{% if error is defined %}
|
||||
<label class="alert alert-danger">
|
||||
{{ error.getMessage() }}
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-padding">
|
||||
{{ form_start(search_form) }}
|
||||
<div>
|
||||
{{ form_row(search_form.search_query) }}
|
||||
{{ form_row(search_form.submit_search) }}
|
||||
</div>
|
||||
{% if actor is not null %}
|
||||
<details class="section-widget">
|
||||
<summary>
|
||||
<h3>
|
||||
{% trans %}Save this search as a feed{% endtrans %}
|
||||
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
|
||||
</h3>
|
||||
</summary>
|
||||
{{ form_row(search_form.title) }}
|
||||
{{ form_row(search_form.subscribe_to_search) }}
|
||||
</details>
|
||||
{% endif %}
|
||||
{{ form_end(search_form)}}
|
||||
<hr>
|
||||
<details class="section-widget">
|
||||
<summary>
|
||||
<h3>
|
||||
{% trans %}Build a search query{% endtrans %}
|
||||
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
|
||||
</h3>
|
||||
</summary>
|
||||
|
||||
{{ form_start(search_builder_form) }}
|
||||
|
||||
{# actor options, display if first checked, with checkbox trick #}
|
||||
<details class="section-widget">
|
||||
<summary>
|
||||
<h3>
|
||||
{% trans %}People search options{% endtrans %}
|
||||
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
|
||||
</h3>
|
||||
</summary>
|
||||
{{ form_row(search_builder_form.include_actors) }}
|
||||
{{ form_row(search_builder_form.include_actors_people) }}
|
||||
{{ form_row(search_builder_form.include_actors_groups) }}
|
||||
{{ form_row(search_builder_form.include_actors_lists) }}
|
||||
{{ form_row(search_builder_form.include_actors_businesses) }}
|
||||
{{ form_row(search_builder_form.include_actors_organizations) }}
|
||||
{{ form_row(search_builder_form.include_actors_bots) }}
|
||||
{{ form_row(search_builder_form.actor_langs) }}
|
||||
{{ form_row(search_builder_form.actor_tags) }}
|
||||
</details>
|
||||
|
||||
<details class="section-widget">
|
||||
<summary>
|
||||
<h3>
|
||||
{% trans %}Note search options{% endtrans %}
|
||||
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
|
||||
</h3>
|
||||
</summary>
|
||||
{{ form_row(search_builder_form.include_notes) }}
|
||||
{{ form_row(search_builder_form.include_notes_text) }}
|
||||
{{ form_row(search_builder_form.include_notes_media) }}
|
||||
{{ form_row(search_builder_form.include_notes_polls) }}
|
||||
{{ form_row(search_builder_form.include_notes_bookmarks) }}
|
||||
{{ form_row(search_builder_form.note_langs) }}
|
||||
{{ form_row(search_builder_form.note_tags) }}
|
||||
{{ form_row(search_builder_form.note_actor_langs) }}
|
||||
{{ form_row(search_builder_form.note_actor_tags) }}
|
||||
</details>
|
||||
{{ form_end(search_builder_form) }}
|
||||
</details>
|
||||
<hr>
|
||||
</div>
|
||||
|
||||
{# Backwards compatibility with hAtom 0.1 #}
|
||||
{{ parent() }}
|
||||
|
||||
{% for actor in actors %}
|
||||
{% include 'cards/profile/view.html.twig' with {'actor': actor} %}
|
||||
{% endfor %}
|
||||
|
||||
{{ "Page: " ~ page }}
|
||||
{% endblock body %}
|
||||
|
119
components/Search/templates/search/view.html.twig
Normal file
119
components/Search/templates/search/view.html.twig
Normal file
@@ -0,0 +1,119 @@
|
||||
{% extends 'collection/notes.html.twig' %}
|
||||
|
||||
|
||||
{% block body %}
|
||||
{% if error is defined %}
|
||||
<label class="alert alert-danger">
|
||||
{{ error.getMessage() }}
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
<section class="frame-section frame-section-padding">
|
||||
<h2>{% trans %}Search{% endtrans %}</h2>
|
||||
|
||||
{{ form_start(search_form) }}
|
||||
<section class="frame-section section-form">
|
||||
{{ form_errors(search_form) }}
|
||||
{{ form_row(search_form.search_query) }}
|
||||
{% if actor is not null %}
|
||||
<details class="section-details-subtitle">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>{% trans %}Other options{% endtrans %}</strong>
|
||||
</summary>
|
||||
|
||||
<div class="section-form">
|
||||
<details class="section-details-subtitle">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>
|
||||
{% trans %}Save query as a feed{% endtrans %}
|
||||
</strong>
|
||||
</summary>
|
||||
<div class="section-form">
|
||||
{{ form_row(search_form.title) }}
|
||||
{{ form_row(search_form.subscribe_to_search) }}
|
||||
</div>
|
||||
<hr>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{{ form_row(search_form.submit_search) }}
|
||||
</section>
|
||||
{{ form_end(search_form)}}
|
||||
|
||||
<section class="frame-section">
|
||||
<details class="section-details-subtitle">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>{% trans %}Build a search query{% endtrans %}</strong>
|
||||
</summary>
|
||||
|
||||
{{ form_start(search_builder_form) }}
|
||||
<div class="section-form">
|
||||
{# actor options, display if first checked, with checkbox trick #}
|
||||
<details class="section-details-subtitle">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>{% trans %}People search options{% endtrans %}</strong>
|
||||
</summary>
|
||||
<div class="section-form">
|
||||
{{ form_row(search_builder_form.include_actors) }}
|
||||
{{ form_row(search_builder_form.include_actors_people) }}
|
||||
{{ form_row(search_builder_form.include_actors_groups) }}
|
||||
{{ form_row(search_builder_form.include_actors_lists) }}
|
||||
{{ form_row(search_builder_form.include_actors_businesses) }}
|
||||
{{ form_row(search_builder_form.include_actors_organizations) }}
|
||||
{{ form_row(search_builder_form.include_actors_bots) }}
|
||||
{{ form_row(search_builder_form.actor_langs) }}
|
||||
{{ form_row(search_builder_form.actor_tags) }}
|
||||
</div>
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<details class="section-details-subtitle">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>{% trans %}Note search options{% endtrans %}</strong>
|
||||
</summary>
|
||||
<div class="section-form">
|
||||
{{ form_row(search_builder_form.include_notes) }}
|
||||
{{ form_row(search_builder_form.include_notes_text) }}
|
||||
{{ form_row(search_builder_form.include_notes_media) }}
|
||||
{{ form_row(search_builder_form.include_notes_polls) }}
|
||||
{{ form_row(search_builder_form.include_notes_bookmarks) }}
|
||||
{{ form_row(search_builder_form.note_langs) }}
|
||||
{{ form_row(search_builder_form.note_tags) }}
|
||||
{{ form_row(search_builder_form.note_actor_langs) }}
|
||||
{{ form_row(search_builder_form.note_actor_tags) }}
|
||||
</div>
|
||||
<hr>
|
||||
</details>
|
||||
</div>
|
||||
{{ form_end(search_builder_form) }}
|
||||
</details>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="frame-section frame-section-padding">
|
||||
<h2>{% trans %}Results{% endtrans %}</h2>
|
||||
<div class="frame-section frame-section-padding feed-empty">
|
||||
{% if notes is defined and notes is not empty %}
|
||||
{{ parent() }}
|
||||
{% else %}
|
||||
<h3>{% trans %}No notes found{% endtrans %}</h3>
|
||||
<em>{% trans %}No notes were found for the specified query...{% endtrans %}</em>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="frame-section frame-section-padding feed-empty">
|
||||
<h3>{% trans %}Actors found{% endtrans %}</h3>
|
||||
{% if actors is defined and actors is not empty %}
|
||||
{% for actor in actors %}
|
||||
{% include 'cards/profile/view.html.twig' with {'actor': actor} %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<em>{% trans %}No Actors were found for the specified query...{% endtrans %}</em>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ "Page: " ~ page }}
|
||||
{% endblock body %}
|
||||
|
167
components/Subscription/Controller/Subscribers.php
Normal file
167
components/Subscription/Controller/Subscribers.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?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\Subscription\Controller;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use Component\Collection\Util\ActorControllerTrait;
|
||||
use Component\Collection\Util\Controller\CircleController;
|
||||
use Component\Subscription\Subscription as SubscriptionComponent;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Collection of an actor's subscribers
|
||||
*/
|
||||
class Subscribers extends CircleController
|
||||
{
|
||||
use ActorControllerTrait;
|
||||
public function subscribersByActorId(Request $request, int $id)
|
||||
{
|
||||
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',
|
||||
'title' => _m('Subscribers'),
|
||||
'empty_message' => _m('No subscribers.'),
|
||||
'sort_form_fields' => [],
|
||||
'page' => $this->int('page') ?? 1,
|
||||
'actors' => $actor->getSubscribers(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \App\Util\Exception\DuplicateFoundException
|
||||
* @throws \App\Util\Exception\NoLoggedInUser
|
||||
* @throws \App\Util\Exception\NotFoundException
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
* @throws ClientException
|
||||
* @throws RedirectException
|
||||
*/
|
||||
public function subscribersAdd(Request $request, int $object_id): array
|
||||
{
|
||||
$subject = Common::ensureLoggedIn();
|
||||
$object = Actor::getById($object_id);
|
||||
$form = Form::create(
|
||||
[
|
||||
['subscriber_add', SubmitType::class, ['label' => _m('Subscribe!')]],
|
||||
],
|
||||
);
|
||||
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (!\is_null(SubscriptionComponent::subscribe($subject, $object))) {
|
||||
DB::flush();
|
||||
SubscriptionComponent::refreshSubscriptionCount($subject, $object);
|
||||
}
|
||||
|
||||
// Redirect user to where they came from
|
||||
// Prevent open redirect
|
||||
if (!\is_null($from = $this->string('from'))) {
|
||||
if (Router::isAbsolute($from)) {
|
||||
Log::warning("Actor {$object_id} attempted to reply to a note and then get redirected to another host, or the URL was invalid ({$from})");
|
||||
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
|
||||
}
|
||||
|
||||
// TODO anchor on element id
|
||||
throw new RedirectException(url: $from);
|
||||
}
|
||||
|
||||
// If we don't have a URL to return to, go to the instance root
|
||||
throw new RedirectException('root');
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'subscription/add_subscriber.html.twig',
|
||||
'form' => $form->createView(),
|
||||
'object' => $object,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \App\Util\Exception\DuplicateFoundException
|
||||
* @throws \App\Util\Exception\NoLoggedInUser
|
||||
* @throws \App\Util\Exception\NotFoundException
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
* @throws ClientException
|
||||
* @throws RedirectException
|
||||
*/
|
||||
public function subscribersRemove(Request $request, int $object_id): array
|
||||
{
|
||||
$subject = Common::ensureLoggedIn();
|
||||
$object = Actor::getById($object_id);
|
||||
$form = Form::create(
|
||||
[
|
||||
['subscriber_remove', SubmitType::class, ['label' => _m('Unsubscribe')]],
|
||||
],
|
||||
);
|
||||
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (!\is_null(SubscriptionComponent::unsubscribe($subject, $object))) {
|
||||
DB::flush();
|
||||
SubscriptionComponent::refreshSubscriptionCount($subject, $object);
|
||||
}
|
||||
|
||||
// Redirect user to where they came from
|
||||
// Prevent open redirect
|
||||
if (!\is_null($from = $this->string('from'))) {
|
||||
if (Router::isAbsolute($from)) {
|
||||
Log::warning("Actor {$object_id} attempted to subscribe an actor and then get redirected to another host, or the URL was invalid ({$from})");
|
||||
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
|
||||
}
|
||||
|
||||
// TODO anchor on element id
|
||||
throw new RedirectException(url: $from);
|
||||
}
|
||||
|
||||
// If we don't have a URL to return to, go to the instance root
|
||||
throw new RedirectException('root');
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'subscription/remove_subscriber.html.twig',
|
||||
'form' => $form->createView(),
|
||||
'object' => $object,
|
||||
];
|
||||
}
|
||||
}
|
@@ -21,23 +21,25 @@ declare(strict_types = 1);
|
||||
|
||||
// }}}
|
||||
|
||||
namespace App\Controller;
|
||||
namespace Component\Subscription\Controller;
|
||||
|
||||
use App\Core\Controller\ActorController;
|
||||
use function App\Core\I18n\_m;
|
||||
use Component\Collection\Util\ActorControllerTrait;
|
||||
use Component\Collection\Util\Controller\CircleController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Collection of an actor's subscriptions
|
||||
*/
|
||||
class Subscriptions extends ActorController
|
||||
class Subscriptions extends CircleController
|
||||
{
|
||||
use ActorControllerTrait;
|
||||
public function subscriptionsByActorId(Request $request, int $id)
|
||||
{
|
||||
return $this->handleActorById(
|
||||
$id,
|
||||
fn ($actor) => [
|
||||
'_template' => 'subscriptions/view.html.twig',
|
||||
'actor' => $actor,
|
||||
'actor' => $actor,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -47,8 +49,12 @@ class Subscriptions extends ActorController
|
||||
return $this->handleActorByNickname(
|
||||
$nickname,
|
||||
fn ($actor) => [
|
||||
'_template' => 'subscriptions/view.html.twig',
|
||||
'actor' => $actor,
|
||||
'_template' => 'collection/actors.html.twig',
|
||||
'title' => _m('Subscriptions'),
|
||||
'empty_message' => _m('Haven\'t subscribed anyone.'),
|
||||
'sort_form_fields' => [],
|
||||
'page' => $this->int('page') ?? 1,
|
||||
'actors' => $actor->getSubscribers(),
|
||||
],
|
||||
);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user