Compare commits
312 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
9d3ddfe916 | ||
|
87559e9a8c | ||
|
9cf83db62a | ||
|
360a95c7aa | ||
|
b41de11364 | ||
|
88e513c610 | ||
|
5829e77edd | ||
|
334de7c739 | ||
|
fe21796e79 | ||
|
789b1499c5 | ||
|
daec26f2d8 | ||
|
46fcab2e94 | ||
|
e44bef6de7 | ||
|
857e5a9c6f | ||
|
1749cd81e4 | ||
|
5521792169 | ||
|
9679e766d2 | ||
|
adb7fbe1b0 | ||
|
af1779fdd8 | ||
|
584a0d8fed | ||
|
a44e64ed7a | ||
|
fb127e2d05 | ||
|
9703b059da | ||
|
293c6fe078 | ||
|
56cdc192ca | ||
|
a123e152d5 | ||
|
f61cb2d4f6 | ||
|
2da614e344 | ||
|
c3477ea56b | ||
|
49a80a3c40 | ||
|
97114e38e0 | ||
|
2df30e2987 | ||
|
3b3ded5212 | ||
|
dc240fae49 | ||
|
5cbb1627f2 | ||
|
46ff8aacd2 | ||
|
c4d6df4637 | ||
|
053bc38792 | ||
|
2fd46ca886 | ||
|
c31f3d4997 | ||
|
e6bb418fe6 | ||
|
fed2242a56 | ||
|
edeee49af9 | ||
|
4d7742e0e1 | ||
|
76f2cdd212 | ||
|
a2aa45fb1f | ||
|
d4b7e990ce | ||
|
aef1fac536 | ||
|
556ac85061 | ||
|
539104ec33 | ||
|
74ffd261b8 | ||
|
ca9945a4be | ||
|
08587b6942 | ||
|
1664293cf7 | ||
|
94ab4ce8c4 | ||
|
dd70de20da | ||
|
ded9c86054 | ||
|
20e07c9140 | ||
|
4e2f6545ec | ||
|
f6a8f44420 | ||
|
fd71d6ee7d | ||
|
dfc5918c2c | ||
|
83599ef866 | ||
|
fa82306f6f | ||
|
10f71e9fed | ||
|
e2501ee927 | ||
|
a9665177ea | ||
|
41861d284c | ||
|
bd868a2675 | ||
|
87e35716c1 | ||
|
dac94f53cd | ||
|
b10c359dec | ||
|
483983790a | ||
|
60af9f5e9b | ||
|
abe35428da | ||
|
ca5520edbf | ||
|
e3e14c53ef | ||
|
be33c20614 | ||
|
7305a725cb | ||
|
fd4c3b0e68 | ||
|
16f51e5143 | ||
|
ba4230447e | ||
|
7463044971 | ||
|
7027633ed5 | ||
|
48b42c539c | ||
|
d41a67a9f9 | ||
|
13f22c911c | ||
|
56b8710b26 | ||
|
e63c310d70 | ||
|
03f449035a | ||
|
8808195a80 | ||
|
45344c80d1 | ||
|
7eddbd343d | ||
|
259d2da05a | ||
|
2f7fdf6ee4 | ||
|
6955872e05 | ||
|
23e88b30a6 | ||
|
60713878f0 | ||
|
06c67b31c2 | ||
|
a08b661779 | ||
|
0649a5154c | ||
|
91fecd77ba | ||
|
e22fe55bbe | ||
|
dd62825169 | ||
|
27706d63f4 | ||
|
20f690c532 | ||
|
888c3798b7 | ||
|
e1cceac150 | ||
|
63ef9292f3 | ||
|
cbae649991 | ||
|
1d8bba3949 | ||
|
18864ca9fa | ||
|
390c532456 | ||
|
636cb681d6 | ||
|
7d84323df4 | ||
|
2d7850ccfb | ||
|
d8108dbc32 | ||
|
cf05d3dbb0 | ||
|
eb3c848fc8 | ||
|
5c708af272 | ||
|
8433771465 | ||
|
0ce5eba355 | ||
|
9a9eed1457 | ||
|
f540711948 | ||
|
c870fd44e3 | ||
|
c30fcead74 | ||
|
301421ea15 | ||
|
4d77f3497d | ||
|
f735e6b31c | ||
|
893d299e29 | ||
|
d857baa0f1 | ||
|
0441f030ab | ||
|
cac68a6372 | ||
|
28453c585f | ||
|
5c7b079df5 | ||
|
47f03d4c9f | ||
|
cc4f967186 | ||
|
ff06a2656a | ||
|
d5fd7da707 | ||
|
1bdeac7076 | ||
|
e67ed58286 | ||
|
487791d606 | ||
|
813e66e83e | ||
|
88ace68627 | ||
|
416665d830 | ||
|
808a3b219e | ||
|
df40dd7c66 | ||
|
afa8443949 | ||
|
46de2d47e9 | ||
|
372cf91fbc | ||
|
9c9e86649a | ||
|
a37ce86d05 | ||
|
9a0c74cb0c | ||
|
46c91a4b39 | ||
|
3f14ad0f69 | ||
|
6ddc176faf | ||
|
d629976322 | ||
|
1a0c9e720f | ||
|
6fa5ec3218 | ||
|
626b4263f1 | ||
|
1daa314c55 | ||
|
7814697f82 | ||
|
7a8d67f1e2 | ||
|
94449c9153 | ||
|
7c9b01c516 | ||
|
6cae6c925d | ||
|
12fb876a6d | ||
|
7ca4330f17 | ||
|
802a8d124a | ||
|
87354c06bf | ||
|
5600218924 | ||
|
90f9378bca | ||
|
070f53c10e | ||
|
f73e9c12ba | ||
|
fc203e2e38 | ||
|
b3374333f3 | ||
|
0b864e85fd | ||
|
a9a60bbd92 | ||
|
4cc4d06b11 | ||
|
f8c1b0f71d | ||
|
43ae3add43 | ||
|
d5f90a1206 | ||
|
85ce6bfd41 | ||
|
46c4bd9099 | ||
|
35f3781a32 | ||
|
45c7888676 | ||
|
255c44bbf0 | ||
|
5188a473d0 | ||
|
8c15d21591 | ||
|
df640f60d2 | ||
|
6e85a4adbb | ||
|
eccf21edef | ||
|
9b86794cda | ||
|
077975136e | ||
|
5495a3c5ec | ||
|
a9b34b75b6 | ||
|
2f539d176d | ||
|
d4c908c194 | ||
|
b630d530f4 | ||
|
26a50618b0 | ||
|
d5731e6351 | ||
|
f5e92de62d | ||
|
7c80277436 | ||
|
4754593cde | ||
|
d12038a9f8 | ||
|
af02bc7b32 | ||
|
bc3d5245f5 | ||
|
f3c2e49e3f | ||
|
05b7f2c28b | ||
|
338ea0ea58 | ||
|
57a07ef74f | ||
|
c380cbd846 | ||
|
7678e155d9 | ||
|
59380ed2ac | ||
|
1e310aa124 | ||
|
8b0e9c7890 | ||
|
f1caabd296 | ||
|
a71c16d654 | ||
|
ecfd6b5ad2 | ||
|
496701ce73 | ||
|
e86dbad6d6 | ||
|
6f3e760c63 | ||
|
51cccd0155 | ||
|
9523927b8e | ||
|
ebbd8bf1e4 | ||
|
7a59d5a002 | ||
|
52ae5fa690 | ||
|
99f7e7cd79 | ||
|
27635d8ec2 | ||
|
0a741903a1 | ||
|
8f60fc4685 | ||
|
8cf60275e6 | ||
|
75837af412 | ||
|
03a475b642 | ||
|
b69f4a46c5 | ||
|
b6ed0b4c6c | ||
|
cee2d143c9 | ||
|
2d5fac7a89 | ||
|
e70acd5c3b | ||
|
f66e178dfc | ||
|
397b54a207 | ||
|
33e1d3eb20 | ||
|
54b9ec48b4 | ||
|
40590bbd11 | ||
|
5b94973079 | ||
|
9d9abf8afb | ||
|
be0a2d27e2 | ||
|
bf23ae2dcf | ||
|
33e768c298 | ||
|
3f9c86f0df | ||
|
bc63c3727a | ||
|
090a087832 | ||
|
262b14a977 | ||
|
10d1a7ed2a | ||
|
3ae8f8213f | ||
|
66323c5a73 | ||
|
56c884026f | ||
|
62bf788b90 | ||
|
6500e99b69 | ||
|
cda1568db5 | ||
|
1f2638d15a | ||
|
6b4fa8c303 | ||
|
17733f32d6 | ||
|
fb3e900b28 | ||
|
416451a519 | ||
|
1f1524c2b3 | ||
|
35e907f7b2 | ||
|
79bb258ba6 | ||
|
80dfea6812 | ||
|
f6b19d2a0f | ||
|
67a2387b31 | ||
|
5d0b8930e1 | ||
|
22741702bf | ||
|
ba131bdb16 | ||
|
7b0667109d | ||
|
5cd3bc3206 | ||
|
79d022e850 | ||
|
cb393ca554 | ||
|
99593a19ef | ||
|
9a53f94b77 | ||
|
d6666cf209 | ||
|
b3d582f665 | ||
|
2b9f70f89f | ||
|
e0ceddc2e6 | ||
|
81f6d496c6 | ||
|
4dd976eb22 | ||
|
fb76775716 | ||
|
162b01e2c5 | ||
|
afd1211852 | ||
|
8f8070036c | ||
|
2e6f91f34e | ||
|
5036b72a71 | ||
|
a17a514bfd | ||
|
1576d253a5 | ||
|
64a698d255 | ||
|
ab6dabf4f7 | ||
|
222e1fbb2b | ||
|
117549bf1e | ||
|
adf484f58a | ||
|
16e7d6cff7 | ||
|
6a5312aca9 | ||
|
14bb1b2876 | ||
|
c7c5fe7979 | ||
|
fa0d02a9ac | ||
|
4736146b80 | ||
|
e3bfb1ebc5 | ||
|
ee04571f4d | ||
|
bf07fa1ade | ||
|
e4a3438d55 | ||
|
6b1c6f603e | ||
|
5f243f68be | ||
|
68c3204e71 |
|
@ -3,4 +3,4 @@ KERNEL_CLASS='App\Kernel'
|
||||||
APP_SECRET='$ecretf0rt3st'
|
APP_SECRET='$ecretf0rt3st'
|
||||||
SYMFONY_DEPRECATIONS_HELPER=999999
|
SYMFONY_DEPRECATIONS_HELPER=999999
|
||||||
PANTHER_APP_ENV=panther
|
PANTHER_APP_ENV=panther
|
||||||
DATABASE_URL=postgresql://postgres:password@db:5432/social
|
DATABASE_URL=postgresql://postgres:password@db:5432/test
|
||||||
|
|
|
@ -178,7 +178,7 @@ return $config
|
||||||
// There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis.
|
// There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis.
|
||||||
'no_spaces_inside_parenthesis' => true,
|
'no_spaces_inside_parenthesis' => true,
|
||||||
// Removes `@param`, `@return` and `@var` tags that don't provide any useful information.
|
// Removes `@param`, `@return` and `@var` tags that don't provide any useful information.
|
||||||
'no_superfluous_phpdoc_tags' => true,
|
'no_superfluous_phpdoc_tags' => false,
|
||||||
// Remove trailing commas in list function calls.
|
// Remove trailing commas in list function calls.
|
||||||
'no_trailing_comma_in_list_call' => true,
|
'no_trailing_comma_in_list_call' => true,
|
||||||
// PHP single-line arrays should not have trailing comma.
|
// PHP single-line arrays should not have trailing comma.
|
||||||
|
|
77
Makefile
77
Makefile
|
@ -36,28 +36,31 @@ database-force-nuke:
|
||||||
database-force-schema-update:
|
database-force-schema-update:
|
||||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c "/var/www/social/bin/console doctrine:schema:update --dump-sql --force"
|
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c "/var/www/social/bin/console doctrine:schema:update --dump-sql --force"
|
||||||
|
|
||||||
tooling-docker: .PHONY
|
tooling-docker-up: .PHONY
|
||||||
@cd docker/tooling && docker-compose up -d --build > /dev/null 2>&1
|
@sh -c 'if [ ! docker container inspect $(call translate-container-name,tooling_php_1) > /dev/null 2>&1 ]; then cd docker/tooling && docker-compose up -d --build > /dev/null 2>&1; fi'
|
||||||
|
|
||||||
stop-tooling: .PHONY
|
tooling-docker-down: .PHONY
|
||||||
cd docker/tooling && docker-compose down
|
cd docker/tooling && docker-compose down
|
||||||
|
|
||||||
tooling-php-shell: tooling-docker
|
test: tooling-docker-up
|
||||||
|
@docker exec $(call translate-container-name,tooling_php_1) /var/tooling/coverage.sh $(call args,'')
|
||||||
|
|
||||||
|
test-database-force-nuke: tooling-docker-up
|
||||||
|
docker exec -it $(call translate-container-name,tooling_php_1) sh -c 'cd /var/www/social; bin/console doctrine:database:drop --force'
|
||||||
|
|
||||||
|
tooling-php-shell: tooling-docker-up
|
||||||
docker exec -it $(call translate-container-name,tooling_php_1) sh
|
docker exec -it $(call translate-container-name,tooling_php_1) sh
|
||||||
|
|
||||||
test-accesibility: tooling-docker
|
test-accesibility: tooling-docker-up
|
||||||
cd docker/tooling && docker-compose run pa11y /accessibility.sh
|
cd docker/tooling && docker-compose run pa11y /accessibility.sh
|
||||||
|
|
||||||
test: tooling-docker
|
cs-fixer: tooling-docker-up
|
||||||
docker exec $(call translate-container-name,tooling_php_1) /var/tooling/coverage.sh $(call args,'')
|
|
||||||
|
|
||||||
cs-fixer: tooling-docker
|
|
||||||
@bin/php-cs-fixer $${CS_FIXER_FILE}
|
@bin/php-cs-fixer $${CS_FIXER_FILE}
|
||||||
|
|
||||||
doc-check: tooling-docker
|
doc-check: tooling-docker-up
|
||||||
bin/php-doc-check
|
bin/php-doc-check
|
||||||
|
|
||||||
phpstan: tooling-docker
|
phpstan: tooling-docker-up
|
||||||
bin/phpstan
|
bin/phpstan
|
||||||
|
|
||||||
remove-var:
|
remove-var:
|
||||||
|
@ -69,4 +72,54 @@ remove-file:
|
||||||
flush-redis-cache:
|
flush-redis-cache:
|
||||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_redis_1) sh -c 'redis-cli flushall'
|
docker exec -it $(call translate-container-name,$(strip $(DIR))_redis_1) sh -c 'redis-cli flushall'
|
||||||
|
|
||||||
force-nuke-everything: down up flush-redis-cache database-force-nuke remove-var remove-file
|
install-plugins:
|
||||||
|
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) /var/www/social/bin/install_plugins.sh
|
||||||
|
|
||||||
|
update-dependencies:
|
||||||
|
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c 'cd /var/www/social && composer update'
|
||||||
|
|
||||||
|
update-autocode:
|
||||||
|
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c 'cd /var/www/social && bin/update_autocode'
|
||||||
|
|
||||||
|
backup-actors:
|
||||||
|
docker exec -it $(call translate-container-name,$(strip $(DIR))_db_1) \
|
||||||
|
sh -c 'su postgres -c "mkdir -p /tmp/backup"' && \
|
||||||
|
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) \
|
||||||
|
sh -c "cd /var/www/social && bin/console doctrine:query:sql \"\
|
||||||
|
copy actor to '/tmp/backup/actor.csv';\
|
||||||
|
copy local_user to '/tmp/backup/local_user.csv';\
|
||||||
|
copy local_group to '/tmp/backup/local_group.csv';\
|
||||||
|
\
|
||||||
|
copy activitypub_actor to '/tmp/backup/ap_actor.csv';\
|
||||||
|
copy activitypub_rsa to '/tmp/backup/ap_rsa.csv';\
|
||||||
|
\
|
||||||
|
copy actor_subscription to '/tmp/backup/actor_subscription.csv';\
|
||||||
|
copy group_member to '/tmp/backup/group_member.csv';\
|
||||||
|
\
|
||||||
|
copy feed to '/tmp/backup/feed.csv';\
|
||||||
|
copy (SELECT 'ALTER SEQUENCE ' || c.relname || ' RESTART WITH ' || nextval(c.relname::regclass) || ';'\
|
||||||
|
FROM pg_class c WHERE c.relkind = 'S') to '/tmp/backup/sequences';\"" && \
|
||||||
|
mkdir -p /tmp/social-sql-backup && \
|
||||||
|
docker cp $(call translate-container-name,$(strip $(DIR))_db_1):/tmp/backup/. /tmp/social-sql-backup
|
||||||
|
|
||||||
|
restore-actors:
|
||||||
|
docker cp /tmp/social-sql-backup/. $(call translate-container-name,$(strip $(DIR))_db_1):/tmp/backup
|
||||||
|
docker exec -it $(call translate-container-name,$(strip $(DIR))_db_1) sh -c 'chown postgres /tmp/backup' && \
|
||||||
|
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) \
|
||||||
|
sh -c "cd /var/www/social && bin/console doctrine:query:sql \"\
|
||||||
|
copy actor from '/tmp/backup/actor.csv';\
|
||||||
|
copy local_user from '/tmp/backup/local_user.csv';\
|
||||||
|
copy local_group from '/tmp/backup/local_group.csv';\
|
||||||
|
\
|
||||||
|
copy activitypub_actor from '/tmp/backup/ap_actor.csv';\
|
||||||
|
copy activitypub_rsa from '/tmp/backup/ap_rsa.csv';\
|
||||||
|
\
|
||||||
|
copy actor_subscription from '/tmp/backup/actor_subscription.csv';\
|
||||||
|
copy group_member from '/tmp/backup/group_member.csv';\
|
||||||
|
\
|
||||||
|
copy feed from '/tmp/backup/feed.csv';\
|
||||||
|
`cat /tmp/social-sql-backup/sequences`\""
|
||||||
|
|
||||||
|
force-nuke-everything: down remove-var remove-file up flush-redis-cache database-force-nuke install-plugins
|
||||||
|
|
||||||
|
force-delete-content: backup-actors force-nuke-everything restore-actors
|
||||||
|
|
4
bin/configure
vendored
4
bin/configure
vendored
|
@ -352,8 +352,8 @@ SOCIAL_DBMS=${DBMS}
|
||||||
SOCIAL_DB=${DB_NAME}
|
SOCIAL_DB=${DB_NAME}
|
||||||
SOCIAL_USER=${DB_USER}
|
SOCIAL_USER=${DB_USER}
|
||||||
SOCIAL_PASSWORD=${DB_PASSWORD}
|
SOCIAL_PASSWORD=${DB_PASSWORD}
|
||||||
SOCIAL_DOMAIN=${DOMAIN}
|
CONFIG_DOMAIN=${DOMAIN}
|
||||||
SOCIAL_NODE_NAME=${NODE_NAME}
|
CONFIG_NODE_NAME=${NODE_NAME}
|
||||||
SOCIAL_ADMIN_EMAIL=${EMAIL}
|
SOCIAL_ADMIN_EMAIL=${EMAIL}
|
||||||
SOCIAL_SITE_PROFILE=${PROFILE}
|
SOCIAL_SITE_PROFILE=${PROFILE}
|
||||||
MAILER_DSN=${MAILER_DSN}
|
MAILER_DSN=${MAILER_DSN}
|
||||||
|
|
11
bin/install_plugins.sh
Executable file
11
bin/install_plugins.sh
Executable file
|
@ -0,0 +1,11 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
for plugin in plugins/*; do
|
||||||
|
install="${plugin}/bin/install.sh"
|
||||||
|
if [ -x "${install}" ]; then
|
||||||
|
( # subshell, to clear options/environment
|
||||||
|
set -x
|
||||||
|
"${install}"
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
done
|
|
@ -20,26 +20,23 @@ const types = [
|
||||||
'text' => 'string',
|
'text' => 'string',
|
||||||
'varchar' => 'string',
|
'varchar' => 'string',
|
||||||
'phone_number' => 'PhoneNumber',
|
'phone_number' => 'PhoneNumber',
|
||||||
|
'float' => 'float', // TODO REMOVE THIS
|
||||||
];
|
];
|
||||||
|
|
||||||
$files = array_merge(glob(ROOT . '/src/Entity/*.php'),
|
$files = array_merge(glob(ROOT . '/src/Entity/*.php'),
|
||||||
array_merge(glob(ROOT . '/components/*/Entity/*.php'),
|
array_merge(glob(ROOT . '/components/*/Entity/*.php'),
|
||||||
glob(ROOT . '/plugins/*/Entity/*.php')));
|
glob(ROOT . '/plugins/*/Entity/*.php')));
|
||||||
|
|
||||||
$classes = [];
|
|
||||||
$nullable_no_defaults_warning = [];
|
$nullable_no_defaults_warning = [];
|
||||||
|
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
|
|
||||||
require_once $file;
|
require_once $file;
|
||||||
|
|
||||||
$declared = get_declared_classes();
|
$class = str_replace(['/', 'src', 'components', 'plugins'], ['\\', 'App', 'Component', 'Plugin'], substr($file, strlen(ROOT) + 1, -4));
|
||||||
foreach ($declared as $dc) {
|
|
||||||
if (preg_match('/(App|(Component|Plugin)\\\\[^\\\\]+)\\\\Entity/', $dc) && !in_array($dc, $classes)) {
|
if (!method_exists($class, 'schemaDef')) {
|
||||||
$class = $dc;
|
continue;
|
||||||
$classes[] = $class;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$no_ns_class = preg_replace('/.*?\\\\/', '', $class);
|
$no_ns_class = preg_replace('/.*?\\\\/', '', $class);
|
|
@ -1,10 +0,0 @@
|
||||||
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,10 +22,10 @@ declare(strict_types = 1);
|
||||||
namespace Component\Attachment;
|
namespace Component\Attachment;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Core\Router\RouteLoader;
|
use App\Core\Router;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\Note;
|
use App\Entity\Note;
|
||||||
use App\Util\Formatting;
|
use App\Util\Formatting;
|
||||||
|
@ -34,10 +34,11 @@ use Component\Attachment\Entity as E;
|
||||||
use Doctrine\Common\Collections\ExpressionBuilder;
|
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||||
use Doctrine\ORM\Query\Expr;
|
use Doctrine\ORM\Query\Expr;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use EventResult;
|
||||||
|
|
||||||
class Attachment extends Component
|
class Attachment extends Component
|
||||||
{
|
{
|
||||||
public function onAddRoute(RouteLoader $r): bool
|
public function onAddRoute(Router $r): EventResult
|
||||||
{
|
{
|
||||||
$r->connect('note_attachment_show', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}', [C\Attachment::class, 'attachmentShowWithNote']);
|
$r->connect('note_attachment_show', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}', [C\Attachment::class, 'attachmentShowWithNote']);
|
||||||
$r->connect('note_attachment_view', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/view', [C\Attachment::class, 'attachmentViewWithNote']);
|
$r->connect('note_attachment_view', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/view', [C\Attachment::class, 'attachmentViewWithNote']);
|
||||||
|
@ -51,13 +52,13 @@ class Attachment extends Component
|
||||||
*
|
*
|
||||||
* This can be used in the future to deduplicate images by visual content
|
* This can be used in the future to deduplicate images by visual content
|
||||||
*/
|
*/
|
||||||
public function onHashFile(string $filename, ?string &$out_hash): bool
|
public function onHashFile(string $filename, ?string &$out_hash): EventResult
|
||||||
{
|
{
|
||||||
$out_hash = hash_file(E\Attachment::FILEHASH_ALGO, $filename);
|
$out_hash = hash_file(E\Attachment::FILEHASH_ALGO, $filename);
|
||||||
return Event::stop;
|
return Event::stop;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
|
public function onNoteDeleteRelated(Note &$note, Actor $actor): EventResult
|
||||||
{
|
{
|
||||||
Cache::delete("note-attachments-{$note->getId()}");
|
Cache::delete("note-attachments-{$note->getId()}");
|
||||||
foreach ($note->getAttachments() as $attachment) {
|
foreach ($note->getAttachments() as $attachment) {
|
||||||
|
@ -68,16 +69,23 @@ class Attachment extends Component
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): EventResult
|
||||||
{
|
{
|
||||||
$note_qb->leftJoin(E\AttachmentToNote::class, 'attachment_to_note', Expr\Join::WITH, 'note.id = attachment_to_note.note_id');
|
if (!\in_array('attachment_to_note', $note_qb->getAllAliases())) {
|
||||||
|
$note_qb->leftJoin(
|
||||||
|
join: E\AttachmentToNote::class,
|
||||||
|
alias: 'attachment_to_note',
|
||||||
|
conditionType: Expr\Join::WITH,
|
||||||
|
condition: 'note.id = attachment_to_note.note_id',
|
||||||
|
);
|
||||||
|
}
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populate $note_expr with the criteria for looking for notes with attachments
|
* Populate $note_expr with the criteria for looking for notes with attachments
|
||||||
*/
|
*/
|
||||||
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
|
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult
|
||||||
{
|
{
|
||||||
$include_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
|
$include_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
|
||||||
if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) {
|
if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) {
|
||||||
|
|
|
@ -24,7 +24,7 @@ declare(strict_types = 1);
|
||||||
namespace Component\Attachment\Controller;
|
namespace Component\Attachment\Controller;
|
||||||
|
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\GSFile;
|
use App\Core\GSFile;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
|
@ -35,6 +35,7 @@ use App\Util\Exception\NoSuchFileException;
|
||||||
use App\Util\Exception\NotFoundException;
|
use App\Util\Exception\NotFoundException;
|
||||||
use App\Util\Exception\ServerException;
|
use App\Util\Exception\ServerException;
|
||||||
use Component\Attachment\Entity\AttachmentThumbnail;
|
use Component\Attachment\Entity\AttachmentThumbnail;
|
||||||
|
use Component\Attachment\Entity\AttachmentToNote;
|
||||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
@ -50,7 +51,12 @@ class Attachment extends Controller
|
||||||
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
|
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
|
||||||
$note = \is_int($note) ? Note::getById($note) : $note;
|
$note = \is_int($note) ? Note::getById($note) : $note;
|
||||||
|
|
||||||
// Before anything, ensure proper scope
|
// Before anything, two very important things!
|
||||||
|
// first: ensure this attachment is associated with this note
|
||||||
|
if (DB::count(AttachmentToNote::class, ['attachment_id' => $attachment->getId(), 'note_id' => $note->getId()]) <= 0) {
|
||||||
|
throw new ClientException(_m('No such attachment.'), 404);
|
||||||
|
}
|
||||||
|
// second: ensure proper scope
|
||||||
if (!$note->isVisibleTo(Common::actor())) {
|
if (!$note->isVisibleTo(Common::actor())) {
|
||||||
throw new ClientException(_m('You don\'t have permissions to view this attachment.'), 401);
|
throw new ClientException(_m('You don\'t have permissions to view this attachment.'), 401);
|
||||||
}
|
}
|
||||||
|
@ -89,7 +95,7 @@ class Attachment extends Controller
|
||||||
try {
|
try {
|
||||||
return $this->attachment($attachment_id, $note_id, function ($res) use ($note_id, $attachment_id) {
|
return $this->attachment($attachment_id, $note_id, function ($res) use ($note_id, $attachment_id) {
|
||||||
return [
|
return [
|
||||||
'_template' => '/cards/attachments/show.html.twig',
|
'_template' => 'attachment/view.html.twig',
|
||||||
'download' => $res['attachment']->getDownloadUrl(note: $note_id),
|
'download' => $res['attachment']->getDownloadUrl(note: $note_id),
|
||||||
'title' => $res['title'],
|
'title' => $res['title'],
|
||||||
'attachment' => $res['attachment'],
|
'attachment' => $res['attachment'],
|
||||||
|
@ -145,12 +151,18 @@ class Attachment extends Controller
|
||||||
*/
|
*/
|
||||||
public function attachmentThumbnailWithNote(Request $request, int $note_id, int $attachment_id, string $size = 'small'): Response
|
public function attachmentThumbnailWithNote(Request $request, int $note_id, int $attachment_id, string $size = 'small'): Response
|
||||||
{
|
{
|
||||||
// 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]);
|
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
|
||||||
|
$note = Note::getById($note_id);
|
||||||
|
|
||||||
|
// Before anything, two very important things!
|
||||||
|
// first: ensure this attachment is associated with this note
|
||||||
|
if (DB::count(AttachmentToNote::class, ['attachment_id' => $attachment->getId(), 'note_id' => $note->getId()]) <= 0) {
|
||||||
|
throw new ClientException(_m('No such attachment.'), 404);
|
||||||
|
}
|
||||||
|
// second: ensure proper scope
|
||||||
|
if (!$note->isVisibleTo(Common::actor())) {
|
||||||
|
throw new ClientException(_m('You don\'t have permissions to view this attachment.'), 401);
|
||||||
|
}
|
||||||
|
|
||||||
$crop = Common::config('thumbnail', 'smart_crop');
|
$crop = Common::config('thumbnail', 'smart_crop');
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Attachment\Entity;
|
namespace Component\Attachment\Entity;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
|
|
||||||
|
|
|
@ -24,17 +24,18 @@ declare(strict_types = 1);
|
||||||
namespace Component\Attachment\Entity;
|
namespace Component\Attachment\Entity;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\GSFile;
|
use App\Core\GSFile;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Log;
|
use App\Core\Log;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
use App\Entity\Note;
|
use App\Entity\Note;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use App\Util\Exception\ClientException;
|
use App\Util\Exception\ClientException;
|
||||||
use App\Util\Exception\DuplicateFoundException;
|
use App\Util\Exception\DuplicateFoundException;
|
||||||
|
use App\Util\Exception\NoSuchFileException;
|
||||||
use App\Util\Exception\NotFoundException;
|
use App\Util\Exception\NotFoundException;
|
||||||
use App\Util\Exception\ServerException;
|
use App\Util\Exception\ServerException;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
|
@ -223,8 +224,7 @@ class Attachment extends Entity
|
||||||
$this->setFilename(null);
|
$this->setFilename(null);
|
||||||
$this->setSize(null);
|
$this->setSize(null);
|
||||||
// Important not to null neither width nor height
|
// Important not to null neither width nor height
|
||||||
DB::persist($this);
|
DB::wrapInTransaction(fn () => DB::persist($this));
|
||||||
DB::flush();
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// @codeCoverageIgnoreStart
|
// @codeCoverageIgnoreStart
|
||||||
|
@ -339,7 +339,11 @@ class Attachment extends Entity
|
||||||
*/
|
*/
|
||||||
public function getThumbnails()
|
public function getThumbnails()
|
||||||
{
|
{
|
||||||
return DB::findBy('attachment_thumbnail', ['attachment_id' => $this->id]);
|
return DB::findBy(
|
||||||
|
AttachmentThumbnail::class,
|
||||||
|
['attachment_id' => $this->id],
|
||||||
|
order_by: ['size' => 'ASC', 'mimetype' => 'ASC'],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPath()
|
public function getPath()
|
||||||
|
@ -367,15 +371,17 @@ class Attachment extends Entity
|
||||||
* @throws ClientException
|
* @throws ClientException
|
||||||
* @throws NotFoundException
|
* @throws NotFoundException
|
||||||
* @throws ServerException
|
* @throws ServerException
|
||||||
*
|
|
||||||
* @return AttachmentThumbnail
|
|
||||||
*/
|
*/
|
||||||
public function getThumbnail(?string $size = null, bool $crop = false): ?AttachmentThumbnail
|
public function getThumbnail(?string $size = null, bool $crop = false): ?AttachmentThumbnail
|
||||||
{
|
{
|
||||||
return AttachmentThumbnail::getOrCreate(attachment: $this, size: $size, crop: $crop);
|
try {
|
||||||
|
return AttachmentThumbnail::getOrCreate(attachment: $this, size: $size, crop: $crop);
|
||||||
|
} catch (NoSuchFileException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getThumbnailUrl(Note|int $note, ?string $size = null)
|
public function getThumbnailUrl(Note|int $note, ?string $size = null): string
|
||||||
{
|
{
|
||||||
return Router::url('note_attachment_thumbnail', ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId(), 'size' => $size ?? Common::config('thumbnail', 'default_size')]);
|
return Router::url('note_attachment_thumbnail', ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId(), 'size' => $size ?? Common::config('thumbnail', 'default_size')]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,12 +24,13 @@ declare(strict_types = 1);
|
||||||
namespace Component\Attachment\Entity;
|
namespace Component\Attachment\Entity;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\GSFile;
|
use App\Core\GSFile;
|
||||||
use App\Core\Log;
|
use App\Core\Log;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
|
use App\Entity\Note;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use App\Util\Exception\ClientException;
|
use App\Util\Exception\ClientException;
|
||||||
use App\Util\Exception\NotFoundException;
|
use App\Util\Exception\NotFoundException;
|
||||||
|
@ -179,7 +180,7 @@ class AttachmentThumbnail extends Entity
|
||||||
if (isset($this->attachment) && !\is_null($this->attachment)) {
|
if (isset($this->attachment) && !\is_null($this->attachment)) {
|
||||||
return $this->attachment;
|
return $this->attachment;
|
||||||
} else {
|
} else {
|
||||||
return $this->attachment = DB::findOneBy('attachment', ['id' => $this->attachment_id]);
|
return $this->attachment = DB::findOneBy(Attachment::class, ['id' => $this->attachment_id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -204,7 +205,7 @@ class AttachmentThumbnail extends Entity
|
||||||
try {
|
try {
|
||||||
return Cache::get(
|
return Cache::get(
|
||||||
self::getCacheKey($attachment->getId(), $size_int),
|
self::getCacheKey($attachment->getId(), $size_int),
|
||||||
fn () => DB::findOneBy('attachment_thumbnail', ['attachment_id' => $attachment->getId(), 'size' => $size_int]),
|
fn () => DB::findOneBy(self::class, ['attachment_id' => $attachment->getId(), 'size' => $size_int]),
|
||||||
);
|
);
|
||||||
} catch (NotFoundException) {
|
} catch (NotFoundException) {
|
||||||
if (\is_null($attachment->getWidth()) || \is_null($attachment->getHeight())) {
|
if (\is_null($attachment->getWidth()) || \is_null($attachment->getHeight())) {
|
||||||
|
@ -213,7 +214,7 @@ class AttachmentThumbnail extends Entity
|
||||||
[$predicted_width, $predicted_height] = self::predictScalingValues($attachment->getWidth(), $attachment->getHeight(), $size, $crop);
|
[$predicted_width, $predicted_height] = self::predictScalingValues($attachment->getWidth(), $attachment->getHeight(), $size, $crop);
|
||||||
if (\is_null($attachment->getPath()) || !file_exists($attachment->getPath())) {
|
if (\is_null($attachment->getPath()) || !file_exists($attachment->getPath())) {
|
||||||
// Before we quit, check if there's any other thumb
|
// Before we quit, check if there's any other thumb
|
||||||
$alternative_thumbs = DB::findBy('attachment_thumbnail', ['attachment_id' => $attachment->getId()]);
|
$alternative_thumbs = DB::findBy(self::class, ['attachment_id' => $attachment->getId()]);
|
||||||
usort($alternative_thumbs, fn ($l, $r) => $r->getSize() <=> $l->getSize());
|
usort($alternative_thumbs, fn ($l, $r) => $r->getSize() <=> $l->getSize());
|
||||||
if (empty($alternative_thumbs)) {
|
if (empty($alternative_thumbs)) {
|
||||||
throw new NotStoredLocallyException();
|
throw new NotStoredLocallyException();
|
||||||
|
@ -253,14 +254,14 @@ class AttachmentThumbnail extends Entity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPath()
|
public function getPath(): string
|
||||||
{
|
{
|
||||||
return Common::config('thumbnail', 'dir') . \DIRECTORY_SEPARATOR . $this->getFilename();
|
return Common::config('thumbnail', 'dir') . \DIRECTORY_SEPARATOR . $this->getFilename();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUrl()
|
public function getUrl(Note|int $note): string
|
||||||
{
|
{
|
||||||
return Router::url('attachment_thumbnail', ['id' => $this->getAttachmentId(), 'size' => self::sizeIntToStr($this->getSize())]);
|
return Router::url('note_attachment_thumbnail', ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getAttachmentId(), 'size' => self::sizeIntToStr($this->getSize())]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -277,9 +278,10 @@ class AttachmentThumbnail extends Entity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Cache::delete(self::getCacheKey($this->getAttachmentId(), $this->getSize()));
|
Cache::delete(self::getCacheKey($this->getAttachmentId(), $this->getSize()));
|
||||||
DB::remove($this);
|
|
||||||
if ($flush) {
|
if ($flush) {
|
||||||
DB::flush();
|
DB::wrapInTransaction(fn () => DB::remove($this));
|
||||||
|
} else {
|
||||||
|
DB::remove($this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Attachment\Entity;
|
namespace Component\Attachment\Entity;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Attachment\Entity;
|
namespace Component\Attachment\Entity;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
|
|
||||||
|
|
|
@ -21,10 +21,12 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
// }}}
|
// }}}
|
||||||
|
|
||||||
namespace App\Tests\Controller;
|
namespace Component\Attachment\tests\Controller;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Util\GNUsocialTestCase;
|
use App\Util\GNUsocialTestCase;
|
||||||
|
use Component\Attachment\Entity\Attachment;
|
||||||
|
use Component\Attachment\Entity\AttachmentToNote;
|
||||||
|
|
||||||
class AttachmentTest extends GNUsocialTestCase
|
class AttachmentTest extends GNUsocialTestCase
|
||||||
{
|
{
|
||||||
|
@ -32,31 +34,33 @@ class AttachmentTest extends GNUsocialTestCase
|
||||||
{
|
{
|
||||||
// This calls static::bootKernel(), and creates a "client" that is acting as the browser
|
// This calls static::bootKernel(), and creates a "client" that is acting as the browser
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$client->request('GET', '/attachment');
|
//$client->request('GET', '/attachment');
|
||||||
|
//$this->assertResponseStatusCodeSame(404);
|
||||||
|
$client->request('GET', '/object/note/1/attachment/-1');
|
||||||
$this->assertResponseStatusCodeSame(404);
|
$this->assertResponseStatusCodeSame(404);
|
||||||
$client->request('GET', '/attachment/-1');
|
$client->request('GET', '/object/note/1/attachment/asd');
|
||||||
$this->assertResponseStatusCodeSame(404);
|
$this->assertResponseStatusCodeSame(404);
|
||||||
$client->request('GET', '/attachment/asd');
|
$client->request('GET', '/object/note/1/attachment/0');
|
||||||
$this->assertResponseStatusCodeSame(404);
|
|
||||||
$client->request('GET', '/attachment/0');
|
|
||||||
// In the meantime, throwing ClientException doesn't actually result in the reaching the UI, as it's intercepted
|
// In the meantime, throwing ClientException doesn't actually result in the reaching the UI, as it's intercepted
|
||||||
// by the helpful framework that displays the stack traces and such. This should be easily fixable when we have
|
// by the helpful framework that displays the stack traces and such. This should be easily fixable when we have
|
||||||
// our own error pages
|
// our own error pages
|
||||||
$this->assertSelectorTextContains('.stacktrace', 'ClientException');
|
$this->assertResponseStatusCodeSame(500); // TODO (exception page) 404
|
||||||
|
$this->assertSelectorTextContains('.stacktrace', 'No such attachment.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function testAttachment(string $suffix = '')
|
private function testAttachment(string $suffix = '')
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$id = DB::findOneBy('attachment', ['filehash' => '5d8ee7ead51a28803b4ee5cb2306a0b90b6ba570f1e5bcc2209926f6ab08e7ea'])->getId();
|
$attachment_id = DB::findOneBy(Attachment::class, ['filehash' => '5d8ee7ead51a28803b4ee5cb2306a0b90b6ba570f1e5bcc2209926f6ab08e7ea'])->getId();
|
||||||
$crawler = $client->request('GET', "/attachment/{$id}{$suffix}");
|
$note_id = DB::findOneBy(AttachmentToNote::class, ['attachment_id' => $attachment_id])->getNoteId();
|
||||||
|
$crawler = $client->request('GET', "/object/note/{$note_id}/attachment/{$attachment_id}{$suffix}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAttachmentShow()
|
public function testAttachmentShow()
|
||||||
{
|
{
|
||||||
$this->testAttachment();
|
$this->testAttachment();
|
||||||
$this->assertResponseIsSuccessful();
|
$this->assertResponseIsSuccessful();
|
||||||
$this->assertSelectorTextContains('figure figcaption', '5d8ee7ead51a28803b4ee5cb2306a0b90b6ba570f1e5bcc2209926f6ab08e7ea');
|
$this->assertSelectorTextContains('figure figcaption', 'image.jpg');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAttachmentView()
|
public function testAttachmentView()
|
||||||
|
@ -65,16 +69,6 @@ class AttachmentTest extends GNUsocialTestCase
|
||||||
$this->assertResponseIsSuccessful();
|
$this->assertResponseIsSuccessful();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAttachmentViewNotStored()
|
|
||||||
{
|
|
||||||
$client = static::createClient();
|
|
||||||
$last_attachment = DB::findBy('attachment', [], order_by: ['id' => 'DESC'], limit: 1)[0];
|
|
||||||
$id = $last_attachment->getId() + 1;
|
|
||||||
$crawler = $client->request('GET', "/attachment/{$id}/view");
|
|
||||||
$this->assertResponseStatusCodeSame(500); // TODO (exception page) 404
|
|
||||||
$this->assertSelectorTextContains('.stacktrace', 'ClientException');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAttachmentDownload()
|
public function testAttachmentDownload()
|
||||||
{
|
{
|
||||||
$this->testAttachment('/download');
|
$this->testAttachment('/download');
|
|
@ -19,15 +19,17 @@ declare(strict_types = 1);
|
||||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||||
// }}}
|
// }}}
|
||||||
|
|
||||||
namespace App\Tests\Entity;
|
namespace Component\Attachment\tests\Entity;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\GSFile;
|
use App\Core\GSFile;
|
||||||
|
use App\Core\Router;
|
||||||
use App\Entity\Note;
|
use App\Entity\Note;
|
||||||
use App\Util\GNUsocialTestCase;
|
use App\Util\GNUsocialTestCase;
|
||||||
use App\Util\TemporaryFile;
|
use App\Util\TemporaryFile;
|
||||||
use Component\Attachment\Entity\AttachmentToNote;
|
use Component\Attachment\Entity\AttachmentToNote;
|
||||||
|
use Component\Conversation\Conversation;
|
||||||
use Jchook\AssertThrows\AssertThrows;
|
use Jchook\AssertThrows\AssertThrows;
|
||||||
use SplFileInfo;
|
use SplFileInfo;
|
||||||
use Symfony\Component\HttpFoundation\File\File;
|
use Symfony\Component\HttpFoundation\File\File;
|
||||||
|
@ -61,15 +63,18 @@ class AttachmentTest extends GNUsocialTestCase
|
||||||
$repeated_attachment = GSFile::storeFileAsAttachment($file, check_is_supported_mimetype: false);
|
$repeated_attachment = GSFile::storeFileAsAttachment($file, check_is_supported_mimetype: false);
|
||||||
$path = $attachment->getPath();
|
$path = $attachment->getPath();
|
||||||
static::assertSame(2, $repeated_attachment->getLives());
|
static::assertSame(2, $repeated_attachment->getLives());
|
||||||
|
static::assertNotNull($path);
|
||||||
static::assertFileExists($path);
|
static::assertFileExists($path);
|
||||||
|
|
||||||
// Garbage collect the attachment
|
// Garbage collect the attachment
|
||||||
$attachment->kill();
|
$attachment->kill();
|
||||||
|
static::assertNotNull($path);
|
||||||
static::assertFileExists($path);
|
static::assertFileExists($path);
|
||||||
static::assertSame(1, $repeated_attachment->getLives());
|
static::assertSame(1, $repeated_attachment->getLives());
|
||||||
|
|
||||||
// Garbage collect the second attachment, which should delete everything
|
// Garbage collect the second attachment, which should delete everything
|
||||||
$repeated_attachment->kill();
|
$repeated_attachment->kill();
|
||||||
|
static::assertNotNull($path);
|
||||||
static::assertSame(0, $repeated_attachment->getLives());
|
static::assertSame(0, $repeated_attachment->getLives());
|
||||||
static::assertFileDoesNotExist($path);
|
static::assertFileDoesNotExist($path);
|
||||||
static::assertSame([], DB::findBy('attachment', ['filehash' => $hash]));
|
static::assertSame([], DB::findBy('attachment', ['filehash' => $hash]));
|
||||||
|
@ -107,6 +112,7 @@ class AttachmentTest extends GNUsocialTestCase
|
||||||
|
|
||||||
$actor = DB::findOneBy('actor', ['nickname' => 'taken_user']);
|
$actor = DB::findOneBy('actor', ['nickname' => 'taken_user']);
|
||||||
DB::persist($note = Note::create(['actor_id' => $actor->getId(), 'content' => 'attachment: some content', 'content_type' => 'text/plain', 'is_local' => true]));
|
DB::persist($note = Note::create(['actor_id' => $actor->getId(), 'content' => 'attachment: some content', 'content_type' => 'text/plain', 'is_local' => true]));
|
||||||
|
Conversation::assignLocalConversation($note, null);
|
||||||
DB::persist(AttachmentToNote::create(['attachment_id' => $attachment->getId(), 'note_id' => $note->getId(), 'title' => 'A title']));
|
DB::persist(AttachmentToNote::create(['attachment_id' => $attachment->getId(), 'note_id' => $note->getId(), 'title' => 'A title']));
|
||||||
DB::flush();
|
DB::flush();
|
||||||
|
|
||||||
|
@ -118,7 +124,7 @@ class AttachmentTest extends GNUsocialTestCase
|
||||||
static::bootKernel();
|
static::bootKernel();
|
||||||
$attachment = DB::findBy('attachment', ['mimetype' => 'image/png'], limit: 1)[0];
|
$attachment = DB::findBy('attachment', ['mimetype' => 'image/png'], limit: 1)[0];
|
||||||
$id = $attachment->getId();
|
$id = $attachment->getId();
|
||||||
static::assertSame("/attachment/{$id}/view", $attachment->getUrl());
|
static::assertSame("/object/note/42/attachment/{$id}/view", $attachment->getUrl(note: 42, type: Router::ABSOLUTE_PATH));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMimetype()
|
public function testMimetype()
|
|
@ -19,12 +19,13 @@ declare(strict_types = 1);
|
||||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||||
// }}}
|
// }}}
|
||||||
|
|
||||||
namespace App\Tests\Entity;
|
namespace Component\Attachment\tests\Entity;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Util\Exception\NotStoredLocallyException;
|
use App\Util\Exception\NotStoredLocallyException;
|
||||||
use App\Util\GNUsocialTestCase;
|
use App\Util\GNUsocialTestCase;
|
||||||
|
use Component\Attachment\Entity\Attachment;
|
||||||
use Component\Attachment\Entity\AttachmentThumbnail;
|
use Component\Attachment\Entity\AttachmentThumbnail;
|
||||||
use Functional as F;
|
use Functional as F;
|
||||||
use Jchook\AssertThrows\AssertThrows;
|
use Jchook\AssertThrows\AssertThrows;
|
||||||
|
@ -42,9 +43,9 @@ class AttachmentThumbnailTest extends GNUsocialTestCase
|
||||||
$file = new SplFileInfo(INSTALLDIR . '/tests/sample-uploads/attachment-lifecycle-target.jpg');
|
$file = new SplFileInfo(INSTALLDIR . '/tests/sample-uploads/attachment-lifecycle-target.jpg');
|
||||||
$hash = null;
|
$hash = null;
|
||||||
Event::handle('HashFile', [$file->getPathname(), &$hash]);
|
Event::handle('HashFile', [$file->getPathname(), &$hash]);
|
||||||
$attachment = DB::findOneBy('attachment', ['filehash' => $hash]);
|
$attachment = DB::findOneBy(Attachment::class, ['filehash' => $hash]);
|
||||||
|
|
||||||
$thumbs = [
|
$expected = [
|
||||||
AttachmentThumbnail::getOrCreate($attachment, 'small', crop: false),
|
AttachmentThumbnail::getOrCreate($attachment, 'small', crop: false),
|
||||||
AttachmentThumbnail::getOrCreate($attachment, 'medium', crop: false),
|
AttachmentThumbnail::getOrCreate($attachment, 'medium', crop: false),
|
||||||
$thumb = AttachmentThumbnail::getOrCreate($attachment, 'big', crop: false),
|
$thumb = AttachmentThumbnail::getOrCreate($attachment, 'big', crop: false),
|
||||||
|
@ -54,21 +55,29 @@ class AttachmentThumbnailTest extends GNUsocialTestCase
|
||||||
$thumb->setAttachment(null);
|
$thumb->setAttachment(null);
|
||||||
static::assertSame($attachment, $thumb->getAttachment());
|
static::assertSame($attachment, $thumb->getAttachment());
|
||||||
|
|
||||||
$sort = fn ($l, $r) => [$l->getWidth(), $l->getHeight()] <=> [$r->getWidth(), $r->getHeight()];
|
$actual = $attachment->getThumbnails();
|
||||||
$at_thumbs = F\sort($attachment->getThumbnails(), $sort);
|
static::assertSame(\count($expected), \count($actual));
|
||||||
static::assertSame($thumbs, $at_thumbs);
|
foreach ($expected as $e) {
|
||||||
array_pop($thumbs);
|
$a = array_shift($actual);
|
||||||
$thumb->delete(flush: true);
|
static::assertObjectEquals($e, $a);
|
||||||
$at_thumbs = F\sort($attachment->getThumbnails(), $sort);
|
}
|
||||||
static::assertSame($thumbs, $at_thumbs);
|
|
||||||
|
array_pop($expected);
|
||||||
|
$thumb->delete();
|
||||||
|
$actual = $attachment->getThumbnails();
|
||||||
|
static::assertSame(\count($expected), \count($actual));
|
||||||
|
foreach ($expected as $e) {
|
||||||
|
$a = array_shift($actual);
|
||||||
|
static::assertObjectEquals($e, $a);
|
||||||
|
}
|
||||||
|
|
||||||
$attachment->deleteStorage();
|
$attachment->deleteStorage();
|
||||||
|
|
||||||
foreach (array_reverse($thumbs) as $t) {
|
foreach (array_reverse($attachment->getThumbnails()) as $t) {
|
||||||
// Since we still have thumbnails, those will be used as the new thumbnail, even though we don't have the original
|
// Since we still have thumbnails, those will be used as the new thumbnail, even though we don't have the original
|
||||||
$new = AttachmentThumbnail::getOrCreate($attachment, 'big', crop: false);
|
$new = AttachmentThumbnail::getOrCreate($attachment, 'big', crop: false);
|
||||||
static::assertSame([$t->getFilename(), $t->getSize()], [$new->getFilename(), $new->getSize()]);
|
static::assertSame([$t->getFilename(), $t->getSize()], [$new->getFilename(), $new->getSize()]);
|
||||||
$t->delete(flush: true);
|
$t->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Since the backed storage was deleted and we don't have any more previous thumnbs, we can't generate another thumbnail
|
// Since the backed storage was deleted and we don't have any more previous thumnbs, we can't generate another thumbnail
|
||||||
|
@ -159,10 +168,10 @@ class AttachmentThumbnailTest extends GNUsocialTestCase
|
||||||
public function testGetUrl()
|
public function testGetUrl()
|
||||||
{
|
{
|
||||||
parent::bootKernel();
|
parent::bootKernel();
|
||||||
$attachment = DB::findBy('attachment', ['mimetype' => 'image/png'], limit: 1)[0];
|
$attachment = DB::findBy(Attachment::class, ['mimetype' => 'image/png'], limit: 1)[0];
|
||||||
$thumb = AttachmentThumbnail::getOrCreate($attachment, 'big', crop: false);
|
$thumb = AttachmentThumbnail::getOrCreate($attachment, 'big', crop: false);
|
||||||
$id = $attachment->getId();
|
$id = $attachment->getId();
|
||||||
$url = "/attachment/{$id}/thumbnail/big";
|
$expected = "/object/note/42/attachment/{$id}/thumbnail/big";
|
||||||
static::assertSame($url, $thumb->getUrl());
|
static::assertSame($expected, $thumb->getUrl(note: 42));
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -22,26 +22,27 @@ declare(strict_types = 1);
|
||||||
namespace Component\Avatar;
|
namespace Component\Avatar;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\GSFile;
|
use App\Core\GSFile;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Core\Router\RouteLoader;
|
use App\Core\Router;
|
||||||
use App\Core\Router\Router;
|
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use Component\Attachment\Entity\Attachment;
|
use Component\Attachment\Entity\Attachment;
|
||||||
use Component\Attachment\Entity\AttachmentThumbnail;
|
use Component\Attachment\Entity\AttachmentThumbnail;
|
||||||
use Component\Avatar\Controller as C;
|
use Component\Avatar\Controller as C;
|
||||||
use Component\Avatar\Exception\NoAvatarException;
|
use Component\Avatar\Exception\NoAvatarException;
|
||||||
|
use EventResult;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
class Avatar extends Component
|
class Avatar extends Component
|
||||||
{
|
{
|
||||||
public function onInitializeComponent()
|
public function onInitializeComponent(): EventResult
|
||||||
{
|
{
|
||||||
|
return EventResult::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onAddRoute(RouteLoader $r): bool
|
public function onAddRoute(Router $r): EventResult
|
||||||
{
|
{
|
||||||
$r->connect('avatar_actor', '/actor/{actor_id<\d+>}/avatar/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'avatar_view']);
|
$r->connect('avatar_actor', '/actor/{actor_id<\d+>}/avatar/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'avatar_view']);
|
||||||
$r->connect('avatar_default', '/avatar/default/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'default_avatar_view']);
|
$r->connect('avatar_default', '/avatar/default/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'default_avatar_view']);
|
||||||
|
@ -50,9 +51,11 @@ class Avatar extends Component
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param SettingsTabsType $tabs
|
||||||
|
*
|
||||||
* @throws \App\Util\Exception\ClientException
|
* @throws \App\Util\Exception\ClientException
|
||||||
*/
|
*/
|
||||||
public function onPopulateSettingsTabs(Request $request, string $section, &$tabs): bool
|
public function onPopulateSettingsTabs(Request $request, string $section, &$tabs): EventResult
|
||||||
{
|
{
|
||||||
if ($section === 'profile') {
|
if ($section === 'profile') {
|
||||||
$tabs[] = [
|
$tabs[] = [
|
||||||
|
@ -65,7 +68,7 @@ class Avatar extends Component
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onAvatarUpdate(int $actor_id): bool
|
public function onAvatarUpdate(int $actor_id): EventResult
|
||||||
{
|
{
|
||||||
Cache::delete("avatar-{$actor_id}");
|
Cache::delete("avatar-{$actor_id}");
|
||||||
foreach (['full', 'big', 'medium', 'small'] as $size) {
|
foreach (['full', 'big', 'medium', 'small'] as $size) {
|
||||||
|
@ -128,6 +131,8 @@ class Avatar extends Component
|
||||||
*
|
*
|
||||||
* Returns the avatar file's hash, mimetype, title and path.
|
* Returns the avatar file's hash, mimetype, title and path.
|
||||||
* Ensures exactly one cached value exists
|
* Ensures exactly one cached value exists
|
||||||
|
*
|
||||||
|
* @return array{id: null|int, filename: null|string, title: string, mimetype: string, filepath?: string}
|
||||||
*/
|
*/
|
||||||
public static function getAvatarFileInfo(int $actor_id, string $size = 'medium'): array
|
public static function getAvatarFileInfo(int $actor_id, string $size = 'medium'): array
|
||||||
{
|
{
|
||||||
|
|
|
@ -24,7 +24,7 @@ declare(strict_types = 1);
|
||||||
namespace Component\Avatar\Controller;
|
namespace Component\Avatar\Controller;
|
||||||
|
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\Form;
|
use App\Core\Form;
|
||||||
use App\Core\GSFile;
|
use App\Core\GSFile;
|
||||||
|
|
|
@ -24,10 +24,10 @@ declare(strict_types = 1);
|
||||||
namespace Component\Avatar\Entity;
|
namespace Component\Avatar\Entity;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use Component\Attachment\Entity\Attachment;
|
use Component\Attachment\Entity\Attachment;
|
||||||
use Component\Attachment\Entity\AttachmentThumbnail;
|
use Component\Attachment\Entity\AttachmentThumbnail;
|
||||||
|
|
|
@ -24,16 +24,14 @@ declare(strict_types = 1);
|
||||||
namespace Component\Circle;
|
namespace Component\Circle;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Core\Router\RouteLoader;
|
use App\Core\Router;
|
||||||
use App\Core\Router\Router;
|
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\Feed;
|
use App\Entity\Feed;
|
||||||
use App\Entity\LocalUser;
|
use App\Entity\LocalUser;
|
||||||
use App\Util\Common;
|
|
||||||
use App\Util\Nickname;
|
use App\Util\Nickname;
|
||||||
use Component\Circle\Controller as CircleController;
|
use Component\Circle\Controller as CircleController;
|
||||||
use Component\Circle\Entity\ActorCircle;
|
use Component\Circle\Entity\ActorCircle;
|
||||||
|
@ -41,6 +39,7 @@ use Component\Circle\Entity\ActorCircleSubscription;
|
||||||
use Component\Circle\Entity\ActorTag;
|
use Component\Circle\Entity\ActorTag;
|
||||||
use Component\Collection\Util\MetaCollectionTrait;
|
use Component\Collection\Util\MetaCollectionTrait;
|
||||||
use Component\Tag\Tag;
|
use Component\Tag\Tag;
|
||||||
|
use EventResult;
|
||||||
use Functional as F;
|
use Functional as F;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
@ -55,12 +54,13 @@ use Symfony\Component\HttpFoundation\Request;
|
||||||
*/
|
*/
|
||||||
class Circle extends Component
|
class Circle extends Component
|
||||||
{
|
{
|
||||||
|
/** @phpstan-use MetaCollectionTrait<ActorCircle> */
|
||||||
use MetaCollectionTrait;
|
use MetaCollectionTrait;
|
||||||
public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/';
|
public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/';
|
||||||
protected string $slug = 'circle';
|
protected const SLUG = 'circle';
|
||||||
protected string $plural_slug = 'circles';
|
protected const PLURAL_SLUG = 'circles';
|
||||||
|
|
||||||
public function onAddRoute(RouteLoader $r): bool
|
public function onAddRoute(Router $r): EventResult
|
||||||
{
|
{
|
||||||
$r->connect('actor_circle_view_by_circle_id', '/circle/{circle_id<\d+>}', [CircleController\Circle::class, 'circleById']);
|
$r->connect('actor_circle_view_by_circle_id', '/circle/{circle_id<\d+>}', [CircleController\Circle::class, 'circleById']);
|
||||||
// View circle members by (tagger id or nickname) and tag
|
// View circle members by (tagger id or nickname) and tag
|
||||||
|
@ -95,20 +95,23 @@ class Circle extends Component
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool
|
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): EventResult
|
||||||
{
|
{
|
||||||
if ($section === 'profile' && $request->get('_route') === 'settings') {
|
if ($section === 'profile' && \in_array($request->get('_route'), ['person_actor_settings', 'group_actor_settings'])) {
|
||||||
$tabs[] = [
|
$tabs[] = [
|
||||||
'title' => 'Self tags',
|
'title' => _m('Self tags'),
|
||||||
'desc' => 'Add or remove tags on yourself',
|
'desc' => _m('Add or remove tags to this actor'),
|
||||||
'id' => 'settings-self-tags',
|
'id' => 'settings-self-tags',
|
||||||
'controller' => CircleController\SelfTagsSettings::settingsSelfTags($request, Common::actor(), 'settings-self-tags-details'),
|
'controller' => CircleController\SelfTagsSettings::settingsSelfTags($request, Actor::getById((int) $request->get('id')), 'settings-self-tags-details'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): bool
|
/**
|
||||||
|
* @param Actor[] $targets
|
||||||
|
*/
|
||||||
|
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): EventResult
|
||||||
{
|
{
|
||||||
$circles = $actor->getCircles();
|
$circles = $actor->getCircles();
|
||||||
foreach ($circles as $circle) {
|
foreach ($circles as $circle) {
|
||||||
|
@ -120,6 +123,9 @@ class Circle extends Component
|
||||||
|
|
||||||
// Meta Collection -------------------------------------------------------------------
|
// Meta Collection -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $vars
|
||||||
|
*/
|
||||||
private function getActorIdFromVars(array $vars): int
|
private function getActorIdFromVars(array $vars): int
|
||||||
{
|
{
|
||||||
$id = $vars['request']->get('id', null);
|
$id = $vars['request']->get('id', null);
|
||||||
|
@ -131,7 +137,7 @@ class Circle extends Component
|
||||||
return $user->getId();
|
return $user->getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function createCircle(Actor|int $tagger_id, string $tag): int
|
public static function createCircle(Actor|int $tagger_id, string $tag): int|null
|
||||||
{
|
{
|
||||||
$tagger_id = \is_int($tagger_id) ? $tagger_id : $tagger_id->getId();
|
$tagger_id = \is_int($tagger_id) ? $tagger_id : $tagger_id->getId();
|
||||||
$circle = ActorCircle::create([
|
$circle = ActorCircle::create([
|
||||||
|
@ -147,7 +153,10 @@ class Circle extends Component
|
||||||
return $circle->getId();
|
return $circle->getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function createCollection(Actor $owner, array $vars, string $name)
|
/**
|
||||||
|
* @param array<string, mixed> $vars
|
||||||
|
*/
|
||||||
|
protected function createCollection(Actor $owner, array $vars, string $name): void
|
||||||
{
|
{
|
||||||
$this->createCircle($owner, $name);
|
$this->createCircle($owner, $name);
|
||||||
DB::persist(ActorTag::create([
|
DB::persist(ActorTag::create([
|
||||||
|
@ -157,7 +166,12 @@ class Circle extends Component
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function removeItem(Actor $owner, array $vars, $items, array $collections)
|
/**
|
||||||
|
* @param array<string, mixed> $vars
|
||||||
|
* @param array<int> $items
|
||||||
|
* @param array<mixed> $collections
|
||||||
|
*/
|
||||||
|
protected function removeItem(Actor $owner, array $vars, array $items, array $collections): bool
|
||||||
{
|
{
|
||||||
$tagger_id = $owner->getId();
|
$tagger_id = $owner->getId();
|
||||||
$tagged_id = $this->getActorIdFromVars($vars);
|
$tagged_id = $this->getActorIdFromVars($vars);
|
||||||
|
@ -170,9 +184,15 @@ class Circle extends Component
|
||||||
DB::removeBy(ActorTag::class, ['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]);
|
DB::removeBy(ActorTag::class, ['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]);
|
||||||
}
|
}
|
||||||
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
|
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function addItem(Actor $owner, array $vars, $items, array $collections)
|
/**
|
||||||
|
* @param array<string, mixed> $vars
|
||||||
|
* @param array<int> $items
|
||||||
|
* @param array<mixed> $collections
|
||||||
|
*/
|
||||||
|
protected function addItem(Actor $owner, array $vars, array $items, array $collections): void
|
||||||
{
|
{
|
||||||
$tagger_id = $owner->getId();
|
$tagger_id = $owner->getId();
|
||||||
$tagged_id = $this->getActorIdFromVars($vars);
|
$tagged_id = $this->getActorIdFromVars($vars);
|
||||||
|
@ -189,12 +209,27 @@ class Circle extends Component
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see MetaCollectionPlugin->shouldAddToRightPanel
|
* @see MetaCollectionPlugin->shouldAddToRightPanel
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $vars
|
||||||
*/
|
*/
|
||||||
protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool
|
protected function shouldAddToRightPanel(Actor $user, array $vars, Request $request): bool
|
||||||
{
|
{
|
||||||
return \in_array($vars['path'], ['actor_view_nickname', 'actor_view_id']);
|
return \in_array($vars['path'], ['actor_view_nickname', 'actor_view_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves an array of Collections owned by an Actor.
|
||||||
|
* In this case, Collections of those within Actor's own circle of Actors, aka ActorCircle.
|
||||||
|
*
|
||||||
|
* Differs from the overwritten method in MetaCollectionsTrait, since retrieved Collections come from the $owner
|
||||||
|
* itself, and from every Actor that is a part of its ActorCircle.
|
||||||
|
*
|
||||||
|
* @param Actor $owner the Actor, and by extension its own circle of Actors
|
||||||
|
* @param null|array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
|
||||||
|
* @param bool $ids_only true if only the Collections ids are to be returned
|
||||||
|
*
|
||||||
|
* @return ($ids_only is true ? int[] : ActorCircle[])
|
||||||
|
*/
|
||||||
protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array
|
protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array
|
||||||
{
|
{
|
||||||
$tagged_id = !\is_null($vars) ? $this->getActorIdFromVars($vars) : null;
|
$tagged_id = !\is_null($vars) ? $this->getActorIdFromVars($vars) : null;
|
||||||
|
@ -209,7 +244,7 @@ class Circle extends Component
|
||||||
return $ids_only ? array_map(fn ($x) => $x->getId(), $circles) : $circles;
|
return $ids_only ? array_map(fn ($x) => $x->getId(), $circles) : $circles;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
|
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): EventResult
|
||||||
{
|
{
|
||||||
DB::persist(Feed::create([
|
DB::persist(Feed::create([
|
||||||
'actor_id' => $actor_id,
|
'actor_id' => $actor_id,
|
||||||
|
|
|
@ -31,6 +31,16 @@ use Component\Collection\Util\Controller\CircleController;
|
||||||
|
|
||||||
class Circle extends CircleController
|
class Circle extends CircleController
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Render an existing ActorCircle with the given id as a Collection of Actors
|
||||||
|
*
|
||||||
|
* @param ActorCircle|int $circle_id the desired ActorCircle id
|
||||||
|
*
|
||||||
|
* @throws \App\Util\Exception\ServerException
|
||||||
|
* @throws ClientException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
public function circleById(int|ActorCircle $circle_id): array
|
public function circleById(int|ActorCircle $circle_id): array
|
||||||
{
|
{
|
||||||
$circle = \is_int($circle_id) ? ActorCircle::getByPK(['id' => $circle_id]) : $circle_id;
|
$circle = \is_int($circle_id) ? ActorCircle::getByPK(['id' => $circle_id]) : $circle_id;
|
||||||
|
@ -49,11 +59,17 @@ class Circle extends CircleController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
public function circleByTaggerIdAndTag(int $tagger_id, string $tag): array
|
public function circleByTaggerIdAndTag(int $tagger_id, string $tag): array
|
||||||
{
|
{
|
||||||
return $this->circleById(ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag]));
|
return $this->circleById(ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
public function circleByTaggerNicknameAndTag(string $tagger_nickname, string $tag): array
|
public function circleByTaggerNicknameAndTag(string $tagger_nickname, string $tag): array
|
||||||
{
|
{
|
||||||
return $this->circleById(ActorCircle::getByPK(['tagger' => LocalUser::getByNickname($tagger_nickname)->getId(), 'tag' => $tag]));
|
return $this->circleById(ActorCircle::getByPK(['tagger' => LocalUser::getByNickname($tagger_nickname)->getId(), 'tag' => $tag]));
|
||||||
|
|
|
@ -24,23 +24,27 @@ declare(strict_types = 1);
|
||||||
namespace Component\Circle\Controller;
|
namespace Component\Circle\Controller;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\LocalUser;
|
use App\Entity\LocalUser;
|
||||||
use Component\Circle\Entity\ActorCircle;
|
use Component\Circle\Entity\ActorCircle;
|
||||||
use Component\Collection\Util\Controller\MetaCollectionController;
|
use Component\Collection\Util\Controller\MetaCollectionController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends MetaCollectionController<Circles>
|
||||||
|
*/
|
||||||
class Circles extends MetaCollectionController
|
class Circles extends MetaCollectionController
|
||||||
{
|
{
|
||||||
protected string $slug = 'circle';
|
protected const SLUG = 'circle';
|
||||||
protected string $plural_slug = 'circles';
|
protected const PLURAL_SLUG = 'circles';
|
||||||
protected string $page_title = 'Actor circles';
|
protected string $page_title = 'Actor circles';
|
||||||
|
|
||||||
public function createCollection(int $owner_id, string $name)
|
public function createCollection(int $owner_id, string $name): bool
|
||||||
{
|
{
|
||||||
return \Component\Circle\Circle::createCircle($owner_id, $name);
|
return !\is_null(\Component\Circle\Circle::createCircle($owner_id, $name));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string
|
public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string
|
||||||
{
|
{
|
||||||
return Router::url(
|
return Router::url(
|
||||||
|
@ -49,21 +53,26 @@ class Circles extends MetaCollectionController
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCollectionItems(int $owner_id, $collection_id): array
|
/**
|
||||||
|
* @return Circles[]
|
||||||
|
*/
|
||||||
|
public function getCollectionItems(int $owner_id, int $collection_id): array
|
||||||
{
|
{
|
||||||
$notes = []; // TODO: Use Feed::query
|
return []; // TODO
|
||||||
return [
|
|
||||||
'_template' => 'collection/notes.html.twig',
|
|
||||||
'notes' => $notes,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Circles[]
|
||||||
|
*/
|
||||||
public function feedByCircleId(int $circle_id)
|
public function feedByCircleId(int $circle_id)
|
||||||
{
|
{
|
||||||
// Owner id isn't used
|
// Owner id isn't used
|
||||||
return $this->getCollectionItems(0, $circle_id);
|
return $this->getCollectionItems(0, $circle_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Circles[]
|
||||||
|
*/
|
||||||
public function feedByTaggerIdAndTag(int $tagger_id, string $tag)
|
public function feedByTaggerIdAndTag(int $tagger_id, string $tag)
|
||||||
{
|
{
|
||||||
// Owner id isn't used
|
// Owner id isn't used
|
||||||
|
@ -71,6 +80,9 @@ class Circles extends MetaCollectionController
|
||||||
return $this->getCollectionItems($tagger_id, $circle_id);
|
return $this->getCollectionItems($tagger_id, $circle_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Circles[]
|
||||||
|
*/
|
||||||
public function feedByTaggerNicknameAndTag(string $tagger_nickname, string $tag)
|
public function feedByTaggerNicknameAndTag(string $tagger_nickname, string $tag)
|
||||||
{
|
{
|
||||||
$tagger_id = LocalUser::getByNickname($tagger_nickname)->getId();
|
$tagger_id = LocalUser::getByNickname($tagger_nickname)->getId();
|
||||||
|
@ -82,12 +94,13 @@ class Circles extends MetaCollectionController
|
||||||
{
|
{
|
||||||
return DB::findBy(ActorCircle::class, ['tagger' => $owner_id], order_by: ['id' => 'desc']);
|
return DB::findBy(ActorCircle::class, ['tagger' => $owner_id], order_by: ['id' => 'desc']);
|
||||||
}
|
}
|
||||||
public function getCollectionBy(int $owner_id, int $collection_id): ActorCircle
|
|
||||||
|
public function getCollectionBy(int $owner_id, int $collection_id): self
|
||||||
{
|
{
|
||||||
return DB::findOneBy(ActorCircle::class, ['id' => $collection_id, 'actor_id' => $owner_id]);
|
return DB::findOneBy(ActorCircle::class, ['id' => $collection_id, 'actor_id' => $owner_id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setCollectionName(int $actor_id, string $actor_nickname, ActorCircle $collection, string $name)
|
public function setCollectionName(int $actor_id, string $actor_nickname, ActorCircle $collection, string $name): void
|
||||||
{
|
{
|
||||||
foreach ($collection->getActorTags(db_reference: true) as $at) {
|
foreach ($collection->getActorTags(db_reference: true) as $at) {
|
||||||
$at->setTag($name);
|
$at->setTag($name);
|
||||||
|
@ -96,7 +109,7 @@ class Circles extends MetaCollectionController
|
||||||
Cache::delete(Actor::cacheKeys($actor_id)['circles']);
|
Cache::delete(Actor::cacheKeys($actor_id)['circles']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeCollection(int $actor_id, string $actor_nickname, ActorCircle $collection)
|
public function removeCollection(int $actor_id, string $actor_nickname, ActorCircle $collection): void
|
||||||
{
|
{
|
||||||
foreach ($collection->getActorTags(db_reference: true) as $at) {
|
foreach ($collection->getActorTags(db_reference: true) as $at) {
|
||||||
DB::remove($at);
|
DB::remove($at);
|
||||||
|
|
|
@ -6,7 +6,7 @@ namespace Component\Circle\Controller;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Entity as E;
|
use App\Entity as E;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
|
@ -23,11 +23,12 @@ class SelfTagsSettings extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Generic settings page for an Actor's self tags
|
* Generic settings page for an Actor's self tags
|
||||||
|
* TODO: We should have $actor->setSelfTags(), $actor->addSelfTags(), $actor->removeSelfTags()
|
||||||
*/
|
*/
|
||||||
public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id)
|
public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id)
|
||||||
{
|
{
|
||||||
$actor = Common::actor();
|
$actor = Common::actor();
|
||||||
if (!$actor->canAdmin($target)) {
|
if (!$actor->canModerate($target)) {
|
||||||
throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()]));
|
throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,7 +45,7 @@ class SelfTagsSettings extends Controller
|
||||||
foreach ($tags as $tag) {
|
foreach ($tags as $tag) {
|
||||||
$tag = CompTag::sanitize($tag);
|
$tag = CompTag::sanitize($tag);
|
||||||
|
|
||||||
[$actor_tag, $actor_tag_existed] = ActorTag::createOrUpdate([
|
[$actor_tag, $actor_tag_existed] = ActorTag::checkExistingAndCreateOrUpdate([
|
||||||
'tagger' => $target->getId(), // self tag means tagger = tagger in ActorTag
|
'tagger' => $target->getId(), // self tag means tagger = tagger in ActorTag
|
||||||
'tagged' => $target->getId(),
|
'tagged' => $target->getId(),
|
||||||
'tag' => $tag,
|
'tag' => $tag,
|
||||||
|
|
|
@ -22,9 +22,10 @@ declare(strict_types = 1);
|
||||||
namespace Component\Circle\Entity;
|
namespace Component\Circle\Entity;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
|
use App\Entity\Actor;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -49,7 +50,7 @@ class ActorCircle extends Entity
|
||||||
// {{{ Autocode
|
// {{{ Autocode
|
||||||
// @codeCoverageIgnoreStart
|
// @codeCoverageIgnoreStart
|
||||||
private int $id;
|
private int $id;
|
||||||
private ?int $tagger = null; // If null, is the special global self-tag circle
|
private ?int $tagger = null;
|
||||||
private string $tag;
|
private string $tag;
|
||||||
private ?string $description = null;
|
private ?string $description = null;
|
||||||
private ?bool $private = false;
|
private ?bool $private = false;
|
||||||
|
@ -144,6 +145,9 @@ class ActorCircle extends Entity
|
||||||
return $this->tag;
|
return $this->tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ActorTag[]
|
||||||
|
*/
|
||||||
public function getActorTags(bool $db_reference = false): array
|
public function getActorTags(bool $db_reference = false): array
|
||||||
{
|
{
|
||||||
$handle = fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]);
|
$handle = fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]);
|
||||||
|
@ -156,7 +160,10 @@ class ActorCircle extends Entity
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTaggedActors()
|
/**
|
||||||
|
* @return Actor[]
|
||||||
|
*/
|
||||||
|
public function getTaggedActors(): array
|
||||||
{
|
{
|
||||||
return Cache::get(
|
return Cache::get(
|
||||||
"circle-{$this->getId()}-tagged-actors",
|
"circle-{$this->getId()}-tagged-actors",
|
||||||
|
@ -170,6 +177,9 @@ class ActorCircle extends Entity
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Actor[]
|
||||||
|
*/
|
||||||
public function getSubscribedActors(?int $offset = null, ?int $limit = null): array
|
public function getSubscribedActors(?int $offset = null, ?int $limit = null): array
|
||||||
{
|
{
|
||||||
return Cache::get(
|
return Cache::get(
|
||||||
|
@ -202,7 +212,7 @@ class ActorCircle extends Entity
|
||||||
'description' => 'An actor can have lists of actors, to separate their feed or quickly mention his friend',
|
'description' => 'An actor can have lists of actors, to separate their feed or quickly mention his friend',
|
||||||
'fields' => [
|
'fields' => [
|
||||||
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], // An actor can be tagged by many actors
|
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], // An actor can be tagged by many actors
|
||||||
'tagger' => ['type' => 'int', 'default' => null, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'actor_list_tagger_fkey', 'description' => 'user making the tag, null if self-tag'],
|
'tagger' => ['type' => 'int', 'default' => null, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'actor_list_tagger_fkey', 'description' => 'user making the tag, null if self-tag. If null, is the special global self-tag circle'],
|
||||||
'tag' => ['type' => 'varchar', 'length' => 64, 'foreign key' => true, 'target' => 'ActorTag.tag', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'actor tag'], // Join with ActorTag // // so, Doctrine doesn't like that the target is not unique, even though the pair is // Many Actor Circles can reference (and probably will) an Actor Tag
|
'tag' => ['type' => 'varchar', 'length' => 64, 'foreign key' => true, 'target' => 'ActorTag.tag', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'actor tag'], // Join with ActorTag // // so, Doctrine doesn't like that the target is not unique, even though the pair is // Many Actor Circles can reference (and probably will) an Actor Tag
|
||||||
'description' => ['type' => 'text', 'description' => 'description of the people tag'],
|
'description' => ['type' => 'text', 'description' => 'description of the people tag'],
|
||||||
'private' => ['type' => 'bool', 'default' => false, 'description' => 'is this tag private'],
|
'private' => ['type' => 'bool', 'default' => false, 'description' => 'is this tag private'],
|
||||||
|
|
|
@ -21,9 +21,9 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Circle\Entity;
|
namespace Component\Circle\Entity;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use Component\Tag\Tag;
|
use Component\Tag\Tag;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
|
|
|
@ -7,14 +7,18 @@ namespace Component\Circle\Form;
|
||||||
use App\Core\Form;
|
use App\Core\Form;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Util\Form\ArrayTransformer;
|
use App\Util\Form\ArrayTransformer;
|
||||||
|
use Component\Circle\Entity\ActorTag;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
abstract class SelfTagsForm
|
abstract class SelfTagsForm
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return array [Form (add), ?Form (existing)]
|
* @param ActorTag[] $actor_self_tags
|
||||||
|
*
|
||||||
|
* @return array{FormInterface, ?FormInterface} [Form (add), ?Form (existing)]
|
||||||
*/
|
*/
|
||||||
public static function handleTags(
|
public static function handleTags(
|
||||||
Request $request,
|
Request $request,
|
||||||
|
@ -34,7 +38,7 @@ abstract class SelfTagsForm
|
||||||
$existing_form = !empty($form_definition) ? Form::create($form_definition) : null;
|
$existing_form = !empty($form_definition) ? Form::create($form_definition) : null;
|
||||||
|
|
||||||
$add_form = Form::create([
|
$add_form = Form::create([
|
||||||
['new-tags', TextType::class, ['label' => ' ', 'data' => [], 'required' => false, 'help' => _m('Tags for yourself (letters, numbers, -, ., and _), comma- or space-separated.'), 'transformer' => ArrayTransformer::class]],
|
['new-tags', TextType::class, ['label' => ' ', 'data' => [], 'required' => false, 'help' => _m('Tags for this actor (letters, numbers, -, ., and _), comma- or space-separated.'), 'transformer' => ArrayTransformer::class]],
|
||||||
[$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]],
|
[$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
@ -4,16 +4,18 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Collection;
|
namespace Component\Collection;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Note;
|
||||||
use App\Util\Formatting;
|
use App\Util\Formatting;
|
||||||
use Component\Collection\Util\Parser;
|
use Component\Collection\Util\Parser;
|
||||||
use Component\Subscription\Entity\ActorSubscription;
|
use Component\Subscription\Entity\ActorSubscription;
|
||||||
use Doctrine\Common\Collections\ExpressionBuilder;
|
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||||
use Doctrine\ORM\Query\Expr;
|
use Doctrine\ORM\Query\Expr;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use EventResult;
|
||||||
|
|
||||||
class Collection extends Component
|
class Collection extends Component
|
||||||
{
|
{
|
||||||
|
@ -22,21 +24,37 @@ class Collection extends Component
|
||||||
*
|
*
|
||||||
* Supports a variety of query terms and is used both in feeds and
|
* Supports a variety of query terms and is used both in feeds and
|
||||||
* in search. Uses query builders to allow for extension
|
* in search. Uses query builders to allow for extension
|
||||||
|
*
|
||||||
|
* @param array<string, OrderByType> $note_order_by
|
||||||
|
* @param array<string, OrderByType> $actor_order_by
|
||||||
|
*
|
||||||
|
* @return array{notes: null|Note[], actors: null|Actor[]}
|
||||||
*/
|
*/
|
||||||
public static function query(string $query, int $page, ?string $locale = null, ?Actor $actor = null): array
|
public static function query(string $query, int $page, ?string $locale = null, ?Actor $actor = null, array $note_order_by = [], array $actor_order_by = []): array
|
||||||
{
|
{
|
||||||
$note_criteria = null;
|
$note_criteria = null;
|
||||||
$actor_criteria = null;
|
$actor_criteria = null;
|
||||||
if (!empty($query = trim($query))) {
|
if (!empty($query = trim($query))) {
|
||||||
[$note_criteria, $actor_criteria] = Parser::parse($query, $locale, $actor);
|
[$note_criteria, $actor_criteria] = Parser::parse($query, $locale, $actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
$note_qb = DB::createQueryBuilder();
|
$note_qb = DB::createQueryBuilder();
|
||||||
$actor_qb = DB::createQueryBuilder();
|
$actor_qb = DB::createQueryBuilder();
|
||||||
// TODO consider selecting note related stuff, to avoid separate queries (though they're cached, so maybe it's okay)
|
// TODO consider selecting note related stuff, to avoid separate queries (though they're cached, so maybe it's okay)
|
||||||
$note_qb->select('note')->from('App\Entity\Note', 'note')->orderBy('note.created', 'DESC')->addOrderBy('note.id', 'DESC');
|
$note_qb->select('note')->from('App\Entity\Note', 'note');
|
||||||
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor')->orderBy('actor.created', 'DESC')->addOrderBy('actor.id', 'DESC');
|
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor');
|
||||||
Event::handle('CollectionQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
|
Event::handle('CollectionQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
|
||||||
|
|
||||||
|
// Handle ordering
|
||||||
|
$note_order_by = !empty($note_order_by) ? $note_order_by : ['note.created' => 'DESC', 'note.id' => 'DESC'];
|
||||||
|
$actor_order_by = !empty($actor_order_by) ? $actor_order_by : ['actor.created' => 'DESC', 'actor.id' => 'DESC'];
|
||||||
|
foreach ($note_order_by as $field => $order) {
|
||||||
|
$note_qb->addOrderBy($field, $order);
|
||||||
|
}
|
||||||
|
foreach ($actor_order_by as $field => $order) {
|
||||||
|
$actor_qb->addOrderBy($field, $order);
|
||||||
|
}
|
||||||
|
|
||||||
$notes = [];
|
$notes = [];
|
||||||
$actors = [];
|
$actors = [];
|
||||||
if (!\is_null($note_criteria)) {
|
if (!\is_null($note_criteria)) {
|
||||||
|
@ -53,67 +71,75 @@ class Collection extends Component
|
||||||
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
|
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): EventResult
|
||||||
{
|
{
|
||||||
$note_qb->leftJoin(ActorSubscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed_id')
|
$note_aliases = $note_qb->getAllAliases();
|
||||||
->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
|
if (!\in_array('subscription', $note_aliases)) {
|
||||||
|
$note_qb->leftJoin(ActorSubscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed_id');
|
||||||
|
}
|
||||||
|
if (!\in_array('note_actor', $note_aliases)) {
|
||||||
|
$note_qb->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
|
||||||
|
}
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text
|
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text
|
||||||
* notes, for different types of actors and for the content of text notes
|
* notes, for different types of actors and for the content of text notes
|
||||||
|
*
|
||||||
|
* @param mixed $note_expr
|
||||||
|
* @param mixed $actor_expr
|
||||||
*/
|
*/
|
||||||
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr)
|
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult
|
||||||
{
|
{
|
||||||
if (str_contains($term, ':')) {
|
if (str_contains($term, ':')) {
|
||||||
$term = explode(':', $term);
|
$term = explode(':', $term);
|
||||||
if (Formatting::startsWith($term[0], 'note')) {
|
if (Formatting::startsWith($term[0], 'note')) {
|
||||||
switch ($term[0]) {
|
switch ($term[0]) {
|
||||||
case 'notes-all':
|
case 'notes-all':
|
||||||
$note_expr = $eb->neq('note.created', null);
|
$note_expr = $eb->neq('note.created', null);
|
||||||
break;
|
break;
|
||||||
case 'note-local':
|
case 'note-local':
|
||||||
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
|
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
|
||||||
break;
|
break;
|
||||||
case 'note-types':
|
case 'note-types':
|
||||||
case 'notes-include':
|
case 'notes-include':
|
||||||
case 'note-filter':
|
case 'note-filter':
|
||||||
if (\is_null($note_expr)) {
|
if (\is_null($note_expr)) {
|
||||||
$note_expr = [];
|
$note_expr = [];
|
||||||
}
|
}
|
||||||
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
|
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
|
||||||
$note_expr[] = $eb->neq('note.content', null);
|
$note_expr[] = $eb->neq('note.content', null);
|
||||||
} else {
|
} else {
|
||||||
$note_expr[] = $eb->eq('note.content', null);
|
$note_expr[] = $eb->eq('note.content', null);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'note-conversation':
|
case 'note-conversation':
|
||||||
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
|
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
|
||||||
break;
|
break;
|
||||||
case 'note-from':
|
case 'note-from':
|
||||||
case 'notes-from':
|
case 'notes-from':
|
||||||
$subscribed_expr = $eb->eq('subscription.subscriber_id', $actor->getId());
|
$subscribed_expr = $eb->eq('subscription.subscriber_id', $actor->getId());
|
||||||
$type_consts = [];
|
$type_consts = [];
|
||||||
if ($term[1] === 'subscribed') {
|
if ($term[1] === 'subscribed') {
|
||||||
$type_consts = null;
|
$type_consts = null;
|
||||||
}
|
}
|
||||||
foreach (explode(',', $term[1]) as $from) {
|
foreach (explode(',', $term[1]) as $from) {
|
||||||
if (str_starts_with($from, 'subscribed-')) {
|
if (str_starts_with($from, 'subscribed-')) {
|
||||||
[, $type] = explode('-', $from);
|
[, $type] = explode('-', $from);
|
||||||
if (\in_array($type, ['actor', 'actors'])) {
|
if (\in_array($type, ['actor', 'actors'])) {
|
||||||
$type_consts = null;
|
$type_consts = null;
|
||||||
} else {
|
} else {
|
||||||
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type));
|
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type === 'organisation' ? 'group' : $type));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (\is_null($type_consts)) {
|
||||||
if (\is_null($type_consts)) {
|
$note_expr = $subscribed_expr;
|
||||||
$note_expr = $subscribed_expr;
|
} elseif (!empty($type_consts)) {
|
||||||
} elseif (!empty($type_consts)) {
|
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
|
||||||
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
|
}
|
||||||
}
|
break;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
} elseif (Formatting::startsWith($term, 'actor-')) {
|
} elseif (Formatting::startsWith($term, 'actor-')) {
|
||||||
switch ($term[0]) {
|
switch ($term[0]) {
|
||||||
|
@ -127,9 +153,8 @@ class Collection extends Component
|
||||||
foreach (
|
foreach (
|
||||||
[
|
[
|
||||||
Actor::PERSON => ['person', 'people'],
|
Actor::PERSON => ['person', 'people'],
|
||||||
Actor::GROUP => ['group', 'groups'],
|
Actor::GROUP => ['group', 'groups', 'org', 'orgs', 'organisation', 'organisations', 'organization', 'organizations'],
|
||||||
Actor::ORGANISATION => ['org', 'orgs', 'organization', 'organizations', 'organisation', 'organisations'],
|
Actor::BOT => ['bot', 'bots'],
|
||||||
Actor::BOT => ['bot', 'bots'],
|
|
||||||
] as $type => $match) {
|
] as $type => $match) {
|
||||||
if (array_intersect(explode(',', $term[1]), $match) !== []) {
|
if (array_intersect(explode(',', $term[1]), $match) !== []) {
|
||||||
$actor_expr[] = $eb->eq('actor.type', $type);
|
$actor_expr[] = $eb->eq('actor.type', $type);
|
||||||
|
|
|
@ -1,71 +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/>.
|
|
||||||
// }}}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class for feed controllers
|
|
||||||
*
|
|
||||||
* @package GNUsocial
|
|
||||||
* @category Controller
|
|
||||||
*
|
|
||||||
* @author Hugo Sales <hugo@hsal.es>
|
|
||||||
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
|
|
||||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Component\Collection\Util;
|
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
|
||||||
use function App\Core\I18n\_m;
|
|
||||||
use App\Core\Router\Router;
|
|
||||||
use App\Util\Exception\ClientException;
|
|
||||||
|
|
||||||
trait ActorControllerTrait
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Generic function that handles getting a representation for an actor from id
|
|
||||||
*/
|
|
||||||
protected function handleActorById(int $id, callable $handle)
|
|
||||||
{
|
|
||||||
$actor = DB::findOneBy('actor', ['id' => $id]);
|
|
||||||
if ($actor->getIsLocal()) {
|
|
||||||
return ['_redirect' => $actor->getUrl(Router::ABSOLUTE_PATH), 'actor' => $actor];
|
|
||||||
}
|
|
||||||
if (empty($actor)) {
|
|
||||||
throw new ClientException(_m('No such actor.'), 404);
|
|
||||||
} else {
|
|
||||||
return $handle($actor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic function that handles getting a representation for an actor from nickname
|
|
||||||
*/
|
|
||||||
protected function handleActorByNickname(string $nickname, callable $handle)
|
|
||||||
{
|
|
||||||
$user = DB::findOneBy('local_user', ['nickname' => $nickname]);
|
|
||||||
$actor = DB::findOneBy('actor', ['id' => $user->getId()]);
|
|
||||||
if (empty($actor)) {
|
|
||||||
throw new ClientException(_m('No such actor.'), 404);
|
|
||||||
} else {
|
|
||||||
return $handle($actor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,6 +4,9 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Collection\Util\Controller;
|
namespace Component\Collection\Util\Controller;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends OrderedCollection<\Component\Circle\Entity\ActorCircle>
|
||||||
|
*/
|
||||||
class CircleController extends OrderedCollection
|
class CircleController extends OrderedCollection
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,15 +6,25 @@ namespace Component\Collection\Util\Controller;
|
||||||
|
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Note;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use Component\Collection\Collection as CollectionModule;
|
use Component\Collection\Collection as CollectionComponent;
|
||||||
|
|
||||||
class Collection extends Controller
|
/**
|
||||||
|
* @template T
|
||||||
|
*/
|
||||||
|
abstract class Collection extends Controller
|
||||||
{
|
{
|
||||||
public function query(string $query, ?string $locale = null, ?Actor $actor = null): array
|
/**
|
||||||
|
* @param array<string, OrderByType> $note_order_by
|
||||||
|
* @param array<string, OrderByType> $actor_order_by
|
||||||
|
*
|
||||||
|
* @return array{notes: null|Note[], actors: null|Actor[]}
|
||||||
|
*/
|
||||||
|
public function query(string $query, ?string $locale = null, ?Actor $actor = null, array $note_order_by = [], array $actor_order_by = []): array
|
||||||
{
|
{
|
||||||
$actor ??= Common::actor();
|
$actor ??= Common::actor();
|
||||||
$locale ??= Common::currentLanguage()->getLocale();
|
$locale ??= Common::currentLanguage()->getLocale();
|
||||||
return CollectionModule::query($query, $this->int('page') ?? 1, $locale, $actor);
|
return CollectionComponent::query($query, $this->int('page') ?? 1, $locale, $actor, $note_order_by, $actor_order_by);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,19 +38,30 @@ use App\Entity\Note;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use Functional as F;
|
use Functional as F;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
*
|
||||||
|
* @extends OrderedCollection<T>
|
||||||
|
*/
|
||||||
abstract class FeedController extends OrderedCollection
|
abstract class FeedController extends OrderedCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Post-processing of the result of a feed controller, to remove any
|
* Post-processing of the result of a feed controller, to remove any
|
||||||
* notes or actors the user specified, as well as format the raw
|
* notes or actors the user specified, as well as format the raw
|
||||||
* list of notes into a usable format
|
* list of notes into a usable format
|
||||||
|
*
|
||||||
|
* @template NA of Note|Actor
|
||||||
|
*
|
||||||
|
* @param NA[] $result
|
||||||
|
*
|
||||||
|
* @return NA[]
|
||||||
*/
|
*/
|
||||||
protected function postProcess(array $result): array
|
protected function postProcess(array $result): array
|
||||||
{
|
{
|
||||||
$actor = Common::actor();
|
$actor = Common::actor();
|
||||||
if (\array_key_exists('notes', $result)) {
|
if (\array_key_exists('notes', $result)) {
|
||||||
$notes = $result['notes'];
|
$notes = $result['notes'];
|
||||||
self::enforceScope($notes, $actor);
|
self::enforceScope($notes, $actor, $result['actor'] ?? null);
|
||||||
Event::handle('FilterNoteList', [$actor, &$notes, $result['request']]);
|
Event::handle('FilterNoteList', [$actor, &$notes, $result['request']]);
|
||||||
Event::handle('FormatNoteList', [$notes, &$result['notes'], &$result['request']]);
|
Event::handle('FormatNoteList', [$notes, &$result['notes'], &$result['request']]);
|
||||||
}
|
}
|
||||||
|
@ -58,8 +69,11 @@ abstract class FeedController extends OrderedCollection
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function enforceScope(array &$notes, ?Actor $actor): void
|
/**
|
||||||
|
* @param Note[] $notes
|
||||||
|
*/
|
||||||
|
private static function enforceScope(array &$notes, ?Actor $actor, ?Actor $in = null): void
|
||||||
{
|
{
|
||||||
$notes = F\select($notes, fn (Note $n) => $n->isVisibleTo($actor));
|
$notes = F\select($notes, fn (Note $n) => $n->isVisibleTo($actor, $in));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Collection\Util\Controller;
|
namespace Component\Collection\Util\Controller;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Form;
|
use App\Core\Form;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Entity\LocalUser;
|
use App\Entity\LocalUser;
|
||||||
|
@ -39,26 +39,51 @@ use App\Util\Common;
|
||||||
use App\Util\Exception\RedirectException;
|
use App\Util\Exception\RedirectException;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormView;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T of object
|
||||||
|
*
|
||||||
|
* @extends FeedController<T>
|
||||||
|
*/
|
||||||
abstract class MetaCollectionController extends FeedController
|
abstract class MetaCollectionController extends FeedController
|
||||||
{
|
{
|
||||||
protected string $slug = 'collectionsEntry';
|
protected const SLUG = 'collectionsEntry';
|
||||||
protected string $plural_slug = 'collectionsList';
|
protected const PLURAL_SLUG = 'collectionsList';
|
||||||
protected string $page_title = 'Collections';
|
protected string $page_title = 'Collections';
|
||||||
|
|
||||||
abstract public function getCollectionUrl(int $owner_id, string $owner_nickname, int $collection_id): string;
|
abstract public function getCollectionUrl(int $owner_id, string $owner_nickname, int $collection_id): string;
|
||||||
abstract public function getCollectionItems(int $owner_id, $collection_id): array;
|
|
||||||
abstract public function getCollectionsByActorId(int $owner_id): array;
|
|
||||||
abstract public function getCollectionBy(int $owner_id, int $collection_id);
|
|
||||||
abstract public function createCollection(int $owner_id, string $name);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return T[]
|
||||||
|
*/
|
||||||
|
abstract public function getCollectionItems(int $owner_id, int $collection_id): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return T[]
|
||||||
|
*/
|
||||||
|
abstract public function getCollectionsByActorId(int $owner_id): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return T A collection
|
||||||
|
*/
|
||||||
|
abstract public function getCollectionBy(int $owner_id, int $collection_id): object;
|
||||||
|
|
||||||
|
abstract public function createCollection(int $owner_id, string $name): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
public function collectionsViewByActorNickname(Request $request, string $nickname): array
|
public function collectionsViewByActorNickname(Request $request, string $nickname): array
|
||||||
{
|
{
|
||||||
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
|
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
|
||||||
return self::collectionsView($request, $user->getId(), $nickname);
|
return self::collectionsView($request, $user->getId(), $nickname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
public function collectionsViewByActorId(Request $request, int $id): array
|
public function collectionsViewByActorId(Request $request, int $id): array
|
||||||
{
|
{
|
||||||
return self::collectionsView($request, $id, null);
|
return self::collectionsView($request, $id, null);
|
||||||
|
@ -70,14 +95,14 @@ abstract class MetaCollectionController extends FeedController
|
||||||
* @param int $id actor id
|
* @param int $id actor id
|
||||||
* @param ?string $nickname actor nickname
|
* @param ?string $nickname actor nickname
|
||||||
*
|
*
|
||||||
* @return array twig template options
|
* @return ControllerResultType twig template options
|
||||||
*/
|
*/
|
||||||
public function collectionsView(Request $request, int $id, ?string $nickname): array
|
public function collectionsView(Request $request, int $id, ?string $nickname): array
|
||||||
{
|
{
|
||||||
$collections = $this->getCollectionsByActorId($id);
|
$collections = $this->getCollectionsByActorId($id);
|
||||||
|
|
||||||
$create_title = _m('Create a ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->slug)));
|
$create_title = _m('Create a ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', static::SLUG)));
|
||||||
$collections_title = _m('The ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->plural_slug)));
|
$collections_title = _m('The ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', static::PLURAL_SLUG)));
|
||||||
// create collection form
|
// create collection form
|
||||||
$create = null;
|
$create = null;
|
||||||
if (Common::user()?->getId() === $id) {
|
if (Common::user()?->getId() === $id) {
|
||||||
|
@ -111,36 +136,25 @@ abstract class MetaCollectionController extends FeedController
|
||||||
//
|
//
|
||||||
// Instead, I'm using an anonymous class to encapsulate
|
// Instead, I'm using an anonymous class to encapsulate
|
||||||
// the functions and passing that class to the template.
|
// the functions and passing that class to the template.
|
||||||
// This is suggested at https://stackoverflow.com/a/50364502.
|
// This is suggested at https://web.archive.org/web/20220226132328/https://stackoverflow.com/questions/3595727/twig-pass-function-into-template/50364502
|
||||||
$fn = new class($id, $nickname, $request, $this, $this->slug) {
|
$fn = new class($id, $nickname, $request, $this, static::SLUG) {
|
||||||
private $id;
|
public function __construct(private int $id, private string $nickname, private Request $request, private object $parent, private string $slug)
|
||||||
private $nick;
|
|
||||||
private $request;
|
|
||||||
private $parent;
|
|
||||||
private $slug;
|
|
||||||
|
|
||||||
public function __construct($id, $nickname, $request, $parent, $slug)
|
|
||||||
{
|
{
|
||||||
$this->id = $id;
|
|
||||||
$this->nick = $nickname;
|
|
||||||
$this->request = $request;
|
|
||||||
$this->parent = $parent;
|
|
||||||
$this->slug = $slug;
|
|
||||||
}
|
}
|
||||||
// there's already an injected function called path,
|
// there's already an injected function called path,
|
||||||
// that maps to Router::url(name, args), but since
|
// that maps to Router::url(name, args), but since
|
||||||
// I want to preserve nicknames, I think it's better
|
// I want to preserve nicknames, I think it's better
|
||||||
// to use that getUrl function
|
// to use that getUrl function
|
||||||
public function getUrl($cid)
|
public function getUrl(int $cid): string
|
||||||
{
|
{
|
||||||
return $this->parent->getCollectionUrl($this->id, $this->nick, $cid);
|
return $this->parent->getCollectionUrl($this->id, $this->nickname, $cid);
|
||||||
}
|
}
|
||||||
// There are many collections in this page and we need two
|
// There are many collections in this page and we need two
|
||||||
// forms for each one of them: one form to edit the collection's
|
// forms for each one of them: one form to edit the collection's
|
||||||
// name and another to remove the collection.
|
// name and another to remove the collection.
|
||||||
|
|
||||||
// creating the edit form
|
// creating the edit form
|
||||||
public function editForm($collection)
|
public function editForm(object $collection): FormView
|
||||||
{
|
{
|
||||||
$edit = Form::create([
|
$edit = Form::create([
|
||||||
['name', TextType::class, [
|
['name', TextType::class, [
|
||||||
|
@ -159,7 +173,7 @@ abstract class MetaCollectionController extends FeedController
|
||||||
]);
|
]);
|
||||||
$edit->handleRequest($this->request);
|
$edit->handleRequest($this->request);
|
||||||
if ($edit->isSubmitted() && $edit->isValid()) {
|
if ($edit->isSubmitted() && $edit->isValid()) {
|
||||||
$this->parent->setCollectionName($this->id, $this->nick, $collection, $edit->getData()['name']);
|
$this->parent->setCollectionName($this->id, $this->nickname, $collection, $edit->getData()['name']);
|
||||||
DB::flush();
|
DB::flush();
|
||||||
throw new RedirectException();
|
throw new RedirectException();
|
||||||
}
|
}
|
||||||
|
@ -167,7 +181,7 @@ abstract class MetaCollectionController extends FeedController
|
||||||
}
|
}
|
||||||
|
|
||||||
// creating the remove form
|
// creating the remove form
|
||||||
public function rmForm($collection)
|
public function rmForm(object $collection): FormView
|
||||||
{
|
{
|
||||||
$rm = Form::create([
|
$rm = Form::create([
|
||||||
['remove_' . $collection->getId(), SubmitType::class, [
|
['remove_' . $collection->getId(), SubmitType::class, [
|
||||||
|
@ -180,7 +194,7 @@ abstract class MetaCollectionController extends FeedController
|
||||||
]);
|
]);
|
||||||
$rm->handleRequest($this->request);
|
$rm->handleRequest($this->request);
|
||||||
if ($rm->isSubmitted()) {
|
if ($rm->isSubmitted()) {
|
||||||
$this->parent->removeCollection($this->id, $this->nick, $collection);
|
$this->parent->removeCollection($this->id, $this->nickname, $collection);
|
||||||
DB::flush();
|
DB::flush();
|
||||||
throw new RedirectException();
|
throw new RedirectException();
|
||||||
}
|
}
|
||||||
|
@ -198,12 +212,18 @@ abstract class MetaCollectionController extends FeedController
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
public function collectionsEntryViewNotesByNickname(Request $request, string $nickname, int $cid): array
|
public function collectionsEntryViewNotesByNickname(Request $request, string $nickname, int $cid): array
|
||||||
{
|
{
|
||||||
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
|
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
|
||||||
return self::collectionsEntryViewNotesByActorId($request, $user->getId(), $cid);
|
return self::collectionsEntryViewNotesByActorId($request, $user->getId(), $cid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
public function collectionsEntryViewNotesByActorId(Request $request, int $id, int $cid): array
|
public function collectionsEntryViewNotesByActorId(Request $request, int $id, int $cid): array
|
||||||
{
|
{
|
||||||
$collection = $this->getCollectionBy($id, $cid);
|
$collection = $this->getCollectionBy($id, $cid);
|
||||||
|
|
|
@ -4,6 +4,11 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Collection\Util\Controller;
|
namespace Component\Collection\Util\Controller;
|
||||||
|
|
||||||
class OrderedCollection extends Collection
|
/**
|
||||||
|
* @template T
|
||||||
|
*
|
||||||
|
* @extends Collection<T>
|
||||||
|
*/
|
||||||
|
abstract class OrderedCollection extends Collection
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Collection\Util;
|
namespace Component\Collection\Util;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\Form;
|
use App\Core\Form;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
|
@ -39,53 +39,61 @@ use App\Entity\Actor;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use App\Util\Exception\RedirectException;
|
use App\Util\Exception\RedirectException;
|
||||||
use App\Util\Formatting;
|
use App\Util\Formatting;
|
||||||
|
use EventResult;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* */
|
||||||
trait MetaCollectionTrait
|
trait MetaCollectionTrait
|
||||||
{
|
{
|
||||||
//protected string $slug = 'collection';
|
//protected const SLUG = 'collection';
|
||||||
//protected string $plural_slug = 'collections';
|
//protected const PLURAL_SLUG = 'collections';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* create a collection owned by Actor $owner.
|
* create a collection owned by Actor $owner.
|
||||||
*
|
*
|
||||||
* @param Actor $owner The collection's owner
|
* @param Actor $owner The collection's owner
|
||||||
* @param array $vars Page vars sent by AppendRightPanelBlock event
|
* @param array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
|
||||||
* @param string $name Collection's name
|
* @param string $name Collection's name
|
||||||
*/
|
*/
|
||||||
abstract protected function createCollection(Actor $owner, array $vars, string $name);
|
abstract protected function createCollection(Actor $owner, array $vars, string $name): void;
|
||||||
/**
|
/**
|
||||||
* remove item from collections.
|
* remove item from collections.
|
||||||
*
|
*
|
||||||
* @param Actor $owner Current user
|
* @param Actor $owner Current user
|
||||||
* @param array $vars Page vars sent by AppendRightPanelBlock event
|
* @param array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
|
||||||
* @param array $items Array of collections's ids to remove the current item from
|
* @param int[] $items Array of collections's ids to remove the current item from
|
||||||
* @param array $collections List of ids of collections owned by $owner
|
* @param int[] $collections List of ids of collections owned by $owner
|
||||||
*/
|
*/
|
||||||
abstract protected function removeItem(Actor $owner, array $vars, array $items, array $collections);
|
abstract protected function removeItem(Actor $owner, array $vars, array $items, array $collections): bool;
|
||||||
/**
|
/**
|
||||||
* add item to collections.
|
* add item to collections.
|
||||||
*
|
*
|
||||||
* @param Actor $owner Current user
|
* @param Actor $owner Current user
|
||||||
* @param array $vars Page vars sent by AppendRightPanelBlock event
|
* @param array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
|
||||||
* @param array $items Array of collections's ids to add the current item to
|
* @param int[] $items Array of collections's ids to add the current item to
|
||||||
* @param array $collections List of ids of collections owned by $owner
|
* @param int[] $collections List of ids of collections owned by $owner
|
||||||
*/
|
*/
|
||||||
abstract protected function addItem(Actor $owner, array $vars, array $items, array $collections);
|
abstract protected function addItem(Actor $owner, array $vars, array $items, array $collections): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check the route to determine whether the widget should be added
|
* Check the route to determine whether the widget should be added
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $vars
|
||||||
*/
|
*/
|
||||||
abstract protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool;
|
abstract protected function shouldAddToRightPanel(Actor $user, array $vars, Request $request): bool;
|
||||||
/**
|
/**
|
||||||
* Get array of collections's owned by $actor
|
* Get array of collections's owned by $actor
|
||||||
*
|
*
|
||||||
* @param Actor $owner Collection's owner
|
* @param Actor $owner Collection's owner
|
||||||
* @param ?array $vars Page vars sent by AppendRightPanelBlock event
|
* @param null|array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
|
||||||
* @param bool $ids_only if true, the function must return only the primary key or each collections
|
* @param bool $ids_only if true, the function must return only the primary key or each collections
|
||||||
|
*
|
||||||
|
* @return int[]|T[]
|
||||||
*/
|
*/
|
||||||
abstract protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array;
|
abstract protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array;
|
||||||
|
|
||||||
|
@ -93,8 +101,11 @@ trait MetaCollectionTrait
|
||||||
* Append Collections widget to the right panel.
|
* Append Collections widget to the right panel.
|
||||||
* It's compose of two forms: one to select collections to add
|
* It's compose of two forms: one to select collections to add
|
||||||
* the current item to, and another to create a new collection.
|
* the current item to, and another to create a new collection.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $vars
|
||||||
|
* @param string[] $res
|
||||||
*/
|
*/
|
||||||
public function onAppendRightPanelBlock(Request $request, $vars, &$res): bool
|
public function onAppendRightPanelBlock(Request $request, array $vars, array &$res): EventResult
|
||||||
{
|
{
|
||||||
$user = Common::actor();
|
$user = Common::actor();
|
||||||
if (\is_null($user)) {
|
if (\is_null($user)) {
|
||||||
|
@ -127,9 +138,9 @@ trait MetaCollectionTrait
|
||||||
},
|
},
|
||||||
]],
|
]],
|
||||||
['add', SubmitType::class, [
|
['add', SubmitType::class, [
|
||||||
'label' => _m('Add to ' . $this->plural_slug),
|
'label' => _m('Add to ' . static::PLURAL_SLUG),
|
||||||
'attr' => [
|
'attr' => [
|
||||||
'title' => _m('Add to ' . $this->plural_slug),
|
'title' => _m('Add to ' . static::PLURAL_SLUG),
|
||||||
],
|
],
|
||||||
]],
|
]],
|
||||||
]);
|
]);
|
||||||
|
@ -151,17 +162,17 @@ trait MetaCollectionTrait
|
||||||
// form: add to new collection
|
// form: add to new collection
|
||||||
$create_form = Form::create([
|
$create_form = Form::create([
|
||||||
['name', TextType::class, [
|
['name', TextType::class, [
|
||||||
'label' => _m('Add to a new ' . $this->slug),
|
'label' => _m('Add to a new ' . static::SLUG),
|
||||||
'attr' => [
|
'attr' => [
|
||||||
'placeholder' => _m('New ' . $this->slug . ' name'),
|
'placeholder' => _m('New ' . static::SLUG . ' name'),
|
||||||
'required' => 'required',
|
'required' => 'required',
|
||||||
],
|
],
|
||||||
'data' => '',
|
'data' => '',
|
||||||
]],
|
]],
|
||||||
['create', SubmitType::class, [
|
['create', SubmitType::class, [
|
||||||
'label' => _m('Create a new ' . $this->slug),
|
'label' => _m('Create a new ' . static::SLUG),
|
||||||
'attr' => [
|
'attr' => [
|
||||||
'title' => _m('Create a new ' . $this->slug),
|
'title' => _m('Create a new ' . static::SLUG),
|
||||||
],
|
],
|
||||||
]],
|
]],
|
||||||
]);
|
]);
|
||||||
|
@ -176,7 +187,7 @@ trait MetaCollectionTrait
|
||||||
$res[] = Formatting::twigRenderFile(
|
$res[] = Formatting::twigRenderFile(
|
||||||
'collection/widget_add_to.html.twig',
|
'collection/widget_add_to.html.twig',
|
||||||
[
|
[
|
||||||
'ctitle' => _m('Add to ' . $this->plural_slug),
|
'ctitle' => _m('Add to ' . static::PLURAL_SLUG),
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'has_collections' => \count($collections) > 0,
|
'has_collections' => \count($collections) > 0,
|
||||||
'add_form' => $add_form->createView(),
|
'add_form' => $add_form->createView(),
|
||||||
|
@ -186,7 +197,10 @@ trait MetaCollectionTrait
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onEndShowStyles(array &$styles, string $route): bool
|
/**
|
||||||
|
* @param string[] $styles
|
||||||
|
*/
|
||||||
|
public function onEndShowStyles(array &$styles, string $route): EventResult
|
||||||
{
|
{
|
||||||
$styles[] = 'components/Collection/assets/css/widget.css';
|
$styles[] = 'components/Collection/assets/css/widget.css';
|
||||||
$styles[] = 'components/Collection/assets/css/pages.css';
|
$styles[] = 'components/Collection/assets/css/pages.css';
|
||||||
|
|
|
@ -32,6 +32,9 @@ abstract class Parser
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Merge $parts into $criteria_arr
|
* Merge $parts into $criteria_arr
|
||||||
|
*
|
||||||
|
* @param mixed[] $parts
|
||||||
|
* @param Criteria[] $criteria_arr
|
||||||
*/
|
*/
|
||||||
private static function connectParts(array &$parts, array &$criteria_arr, string $last_op, mixed $eb, bool $force = false): void
|
private static function connectParts(array &$parts, array &$criteria_arr, string $last_op, mixed $eb, bool $force = false): void
|
||||||
{
|
{
|
||||||
|
@ -50,8 +53,9 @@ abstract class Parser
|
||||||
* recognises either spaces (currently `or`, should be fuzzy match), `OR` or `|` (`or`) and `AND` or `&` (`and`)
|
* recognises either spaces (currently `or`, should be fuzzy match), `OR` or `|` (`or`) and `AND` or `&` (`and`)
|
||||||
*
|
*
|
||||||
* TODO: Better fuzzy match, implement exact match with quotes and nesting with parens
|
* TODO: Better fuzzy match, implement exact match with quotes and nesting with parens
|
||||||
|
* TODO: Proper parser, tokenize better. Mostly a rewrite
|
||||||
*
|
*
|
||||||
* @return Criteria[]
|
* @return array{?Criteria, ?Criteria} [?$note_criteria, ?$actor_criteria]
|
||||||
*/
|
*/
|
||||||
public static function parse(string $input, ?string $locale = null, ?Actor $actor = null, int $level = 0): array
|
public static function parse(string $input, ?string $locale = null, ?Actor $actor = null, int $level = 0): array
|
||||||
{
|
{
|
||||||
|
@ -80,15 +84,16 @@ abstract class Parser
|
||||||
$actor_res = null;
|
$actor_res = null;
|
||||||
Event::handle('CollectionQueryCreateExpression', [$eb, $term, $locale, $actor, &$note_res, &$actor_res]);
|
Event::handle('CollectionQueryCreateExpression', [$eb, $term, $locale, $actor, &$note_res, &$actor_res]);
|
||||||
if (\is_null($note_res) && \is_null($actor_res)) { // @phpstan-ignore-line
|
if (\is_null($note_res) && \is_null($actor_res)) { // @phpstan-ignore-line
|
||||||
throw new ServerException("No one claimed responsibility for a match term: {$term}");
|
//throw new ServerException("No one claimed responsibility for a match term: {$term}");
|
||||||
|
// It's okay if the term doesn't exist, just perform a regular search
|
||||||
}
|
}
|
||||||
if (!empty($note_res)) { // @phpstan-ignore-line
|
if (!empty($note_res)) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234
|
||||||
if (\is_array($note_res)) {
|
if (\is_array($note_res)) {
|
||||||
$note_res = $eb->orX(...$note_res);
|
$note_res = $eb->orX(...$note_res);
|
||||||
}
|
}
|
||||||
$note_parts[] = $note_res;
|
$note_parts[] = $note_res;
|
||||||
}
|
}
|
||||||
if (!empty($actor_res)) {
|
if (!empty($actor_res)) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234
|
||||||
if (\is_array($actor_res)) {
|
if (\is_array($actor_res)) {
|
||||||
$actor_res = $eb->orX(...$actor_res);
|
$actor_res = $eb->orX(...$actor_res);
|
||||||
}
|
}
|
||||||
|
@ -107,18 +112,18 @@ abstract class Parser
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO
|
// TODO
|
||||||
if (!$match) { // @phpstan-ignore-line
|
if (!$match) {
|
||||||
++$right;
|
++$right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$note_criteria = null;
|
$note_criteria = null;
|
||||||
$actor_criteria = null;
|
$actor_criteria = null;
|
||||||
if (!empty($note_parts)) { // @phpstan-ignore-line
|
if (!empty($note_parts)) {
|
||||||
self::connectParts($note_parts, $note_criteria_arr, $last_op, $eb, force: true);
|
self::connectParts($note_parts, $note_criteria_arr, $last_op, $eb, force: true);
|
||||||
$note_criteria = new Criteria($eb->orX(...$note_criteria_arr));
|
$note_criteria = new Criteria($eb->orX(...$note_criteria_arr));
|
||||||
}
|
}
|
||||||
if (!empty($actor_parts)) { // @phpstan-ignore-line
|
if (!empty($actor_parts)) { // @phpstan-ignore-line weird, but this whole thing needs a rewrite
|
||||||
self::connectParts($actor_parts, $actor_criteria_arr, $last_op, $eb, force: true);
|
self::connectParts($actor_parts, $actor_criteria_arr, $last_op, $eb, force: true);
|
||||||
$actor_criteria = new Criteria($eb->orX(...$actor_criteria_arr));
|
$actor_criteria = new Criteria($eb->orX(...$actor_criteria_arr));
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,8 +3,17 @@
|
||||||
{% block title %}{{ title }}{% endblock %}
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="frame-section frame-section-padding">
|
<section class="frame-section frame-section-padding">
|
||||||
<h1 class="frame-section-title">{{ title }}</h1>
|
<header class="feed-header">
|
||||||
|
{% if actors_feed_title is defined %}
|
||||||
|
{{ actors_feed_title.getHtml() }}
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% set prepend_actors_collection = handle_event('PrependActorsCollection', request) %}
|
||||||
|
{% for widget in prepend_actors_collection %}
|
||||||
|
{{ widget | raw }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<details class="frame-section section-details-title">
|
<details class="frame-section section-details-title">
|
||||||
<summary class="details-summary-title">
|
<summary class="details-summary-title">
|
||||||
|
@ -45,16 +54,17 @@
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<section class="frame-section-padding">
|
<section class="frame-section frame-section-padding">
|
||||||
|
<h2>{% trans %}Results{% endtrans %}</h2>
|
||||||
{% if actors is defined and actors is not empty %}
|
{% if actors is defined and actors is not empty %}
|
||||||
{% for actor in actors %}
|
{% for actor in actors %}
|
||||||
{% block profile_view %}{% include 'cards/profile/view.html.twig' %}{% endblock profile_view %}
|
{% block profile_view %}{% include 'cards/blocks/profile.html.twig' %}{% endblock profile_view %}
|
||||||
<hr>
|
<hr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<p>{% trans %}Page: %page%{% endtrans %}</p>
|
<span class="frame-section-button-like">{% trans %}Page: %page%{% endtrans %}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h2>{{ empty_message }}</h2>
|
<span>{{ empty_message }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</section>
|
||||||
{% endblock body %}
|
{% endblock body %}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
{% extends '/collection/notes.html.twig' %}
|
{% extends '/collection/notes.html.twig' %}
|
||||||
|
|
||||||
{% block title %}{{ page_title | trans }}{% endblock %}
|
{% block title %}{% trans %}%page_title%{% endtrans %}{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="frame-section frame-section-padding">
|
<div class="frame-section frame-section-padding">
|
||||||
<h2 class="frame-section-title">{{ page_title | trans }}</h2>
|
<h2 class="frame-section-title">{% trans %}%page_title%{% endtrans %}</h2>
|
||||||
{% block collection_items %}
|
{% block collection_items %}
|
||||||
{% endblock collection_items %}
|
{% endblock collection_items %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
{% extends 'stdgrid.html.twig' %}
|
{% extends 'stdgrid.html.twig' %}
|
||||||
|
|
||||||
{% block title %}{{ page_title | trans }}{% endblock %}
|
{% block title %}{% trans %}%page_title%{% endtrans %}{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="frame-section frame-section-padding">
|
<div class="frame-section frame-section-padding">
|
||||||
<h2 class="frame-section-title">{{ page_title | trans }}</h2>
|
<h2 class="frame-section-title">{% trans %}%page_title%{% endtrans %}</h2>
|
||||||
{% if add_collection %}
|
{% if add_collection %}
|
||||||
<div class="frame-section section-form">
|
<div class="frame-section section-form">
|
||||||
{{ form(add_collection) }}
|
{{ form(add_collection) }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="frame-section collections-list">
|
<div class="frame-section collections-list">
|
||||||
<h3>{{ list_title | trans }}</h3>
|
<h3>{% trans %}%list_title%{% endtrans %}</h3>
|
||||||
{% for col in collections %}
|
{% for col in collections %}
|
||||||
<div class="collection-item">
|
<div class="collection-item">
|
||||||
<a class="name" href="{{ fn.getUrl(col.id) }}">{{ col.name }}</a>
|
<a class="name" href="{{ fn.getUrl(col.id) }}">{{ col.name }}</a>
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
{% extends 'stdgrid.html.twig' %}
|
{% extends 'stdgrid.html.twig' %}
|
||||||
{% import '/cards/note/view.html.twig' as noteView %}
|
{% import '/cards/macros/note/factory.html.twig' as NoteFactory %}
|
||||||
|
|
||||||
{% block title %}{% if page_title is defined %}{{ page_title | trans }}{% endif %}{% endblock %}
|
{% block title %}{% if page_title is defined %}{% trans %}%page_title%{% endtrans %}{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block stylesheets %}
|
{% block stylesheets %}
|
||||||
{{ parent() }}
|
{{ parent() }}
|
||||||
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
|
<link rel="stylesheet" href="{{ asset('assets/default_theme/feeds.css') }}" type="text/css">
|
||||||
{% endblock stylesheets %}
|
{% endblock stylesheets %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
@ -15,41 +15,44 @@
|
||||||
|
|
||||||
{% if notes is defined %}
|
{% if notes is defined %}
|
||||||
<header class="feed-header">
|
<header class="feed-header">
|
||||||
{% if page_title is defined %}
|
{% set current_path = app.request.get('_route') %}
|
||||||
<h1 class="heading-no-margin">{{ page_title | trans }}</h1>
|
{% if notes_feed_title is defined %}
|
||||||
{% else %}
|
{{ notes_feed_title.getHtml() }}
|
||||||
<h3 class="heading-no-margin">{{ 'Notes' | trans }}</h3>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<nav class="feed-actions">
|
<nav class="feed-actions" title="{% trans %}Actions that change how the feed behaves{% endtrans %}">
|
||||||
<details class="feed-actions-details">
|
<details class="feed-actions-details" role="group">
|
||||||
<summary>
|
<summary>
|
||||||
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
|
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
|
||||||
</summary>
|
</summary>
|
||||||
<div class="feed-actions-details-dropdown">
|
<menu class="feed-actions-details-dropdown" role="toolbar">
|
||||||
<menu>
|
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
|
||||||
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
|
{{ block | raw }}
|
||||||
{{ block | raw }}
|
{% endfor %}
|
||||||
{% endfor %}
|
</menu>
|
||||||
</menu>
|
|
||||||
</div>
|
|
||||||
</details>
|
</details>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if notes is not empty %}
|
{% if notes is not empty %}
|
||||||
{# Backwards compatibility with hAtom 0.1 #}
|
{# Backwards compatibility with hAtom 0.1 #}
|
||||||
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
|
<section class="feed h-feed hfeed notes" role="feed" aria-busy="false" title="{% trans %}Feed content{% endtrans %}">
|
||||||
{% for conversation in notes %}
|
{% for conversation in notes %}
|
||||||
{% block current_note %}
|
{% block current_note %}
|
||||||
{% if conversation is instanceof('array') %}
|
{% if conversation is instanceof('array') %}
|
||||||
{{ noteView.macro_note(conversation['note'], conversation['replies']) }}
|
{% set args = conversation | merge({'type': 'vanilla_full'}) %}
|
||||||
{% else %}
|
{{ NoteFactory.constructor(args) }}
|
||||||
{{ noteView.macro_note(conversation) }}
|
{# {% else %}
|
||||||
|
{% set args = { 'type': 'vanilla_full', 'note': conversation, 'extra': { 'depth': 0 } } %}
|
||||||
|
{{ NoteFactory.constructor(args) }}#}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
|
<hr class="hr-replies-end" role="separator" aria-label="{% trans %}Marks the end of previous conversation's initial note{% endtrans %}">
|
||||||
{% endblock current_note %}
|
{% endblock current_note %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
|
||||||
|
<span>{% trans %}No notes here...{% endtrans %}</span>
|
||||||
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock body %}
|
{% endblock body %}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<section class="frame-section collections">
|
<section class="frame-section collections">
|
||||||
<details class="section-details-title" title="Expand if you want to access more options.">
|
<details class="section-details-title" title="Expand if you want to access more options.">
|
||||||
<summary class="details-summary-title">
|
<summary class="details-summary-title">
|
||||||
<h2>{{ctitle}}</h2>
|
<span>{{ctitle}}</span>
|
||||||
</summary>
|
</summary>
|
||||||
{% if has_collections %}
|
{% if has_collections %}
|
||||||
<section class="section-form">
|
<section class="section-form">
|
||||||
|
|
|
@ -28,11 +28,11 @@ declare(strict_types = 1);
|
||||||
namespace Component\Conversation\Controller;
|
namespace Component\Conversation\Controller;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Form;
|
use App\Core\Form;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Log;
|
use App\Core\Log;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
use App\Entity\Note;
|
use App\Entity\Note;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use App\Util\Exception\ClientException;
|
use App\Util\Exception\ClientException;
|
||||||
|
@ -40,11 +40,15 @@ use App\Util\Exception\NoLoggedInUser;
|
||||||
use App\Util\Exception\NoSuchNoteException;
|
use App\Util\Exception\NoSuchNoteException;
|
||||||
use App\Util\Exception\RedirectException;
|
use App\Util\Exception\RedirectException;
|
||||||
use App\Util\Exception\ServerException;
|
use App\Util\Exception\ServerException;
|
||||||
|
use App\Util\HTML\Heading;
|
||||||
use Component\Collection\Util\Controller\FeedController;
|
use Component\Collection\Util\Controller\FeedController;
|
||||||
use Component\Conversation\Entity\ConversationMute;
|
use Component\Conversation\Entity\ConversationMute;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends FeedController<\App\Entity\Note>
|
||||||
|
*/
|
||||||
class Conversation extends FeedController
|
class Conversation extends FeedController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
@ -54,15 +58,24 @@ class Conversation extends FeedController
|
||||||
*
|
*
|
||||||
* @throws \App\Util\Exception\ServerException
|
* @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)
|
* @return ControllerResultType 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): array
|
public function showConversation(Request $request, int $conversation_id): array
|
||||||
{
|
{
|
||||||
|
$page_title = _m('Conversation');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'_template' => 'collection/notes.html.twig',
|
'_template' => 'collection/notes.html.twig',
|
||||||
'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [],
|
'notes' => $this->query(
|
||||||
'should_format' => false,
|
query: "note-conversation:{$conversation_id}",
|
||||||
'page_title' => _m('Conversation'),
|
note_order_by: ['note.created' => 'ASC', 'note.id' => 'ASC'],
|
||||||
|
)['notes'] ?? [],
|
||||||
|
'should_format' => false,
|
||||||
|
'page_title' => $page_title,
|
||||||
|
'notes_feed_title' => (new Heading(1, [], $page_title)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,7 +89,7 @@ class Conversation extends FeedController
|
||||||
* @throws NoSuchNoteException
|
* @throws NoSuchNoteException
|
||||||
* @throws ServerException
|
* @throws ServerException
|
||||||
*
|
*
|
||||||
* @return array
|
* @return ControllerResultType
|
||||||
*/
|
*/
|
||||||
public function addReply(Request $request)
|
public function addReply(Request $request)
|
||||||
{
|
{
|
||||||
|
@ -96,7 +109,7 @@ class Conversation extends FeedController
|
||||||
* @throws \App\Util\Exception\RedirectException
|
* @throws \App\Util\Exception\RedirectException
|
||||||
* @throws \App\Util\Exception\ServerException
|
* @throws \App\Util\Exception\ServerException
|
||||||
*
|
*
|
||||||
* @return array Array containing templating where the form is to be rendered, and the form itself
|
* @return ControllerResultType Array containing templating where the form is to be rendered, and the form itself
|
||||||
*/
|
*/
|
||||||
public function muteConversation(Request $request, int $conversation_id)
|
public function muteConversation(Request $request, int $conversation_id)
|
||||||
{
|
{
|
||||||
|
@ -134,9 +147,12 @@ class Conversation extends FeedController
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'_template' => 'conversation/mute.html.twig',
|
'_template' => 'conversation/mute.html.twig',
|
||||||
'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [],
|
'notes' => $this->query(
|
||||||
'is_muted' => $is_muted,
|
query: "note-conversation:{$conversation_id}",
|
||||||
'form' => $form->createView(),
|
note_order_by: ['note.created' => 'ASC', 'note.id' => 'ASC'],
|
||||||
|
)['notes'] ?? [],
|
||||||
|
'is_muted' => $is_muted,
|
||||||
|
'form' => $form->createView(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types = 1);
|
declare(strict_types = 1);
|
||||||
|
|
||||||
// {{{ License
|
// {{{ License
|
||||||
|
|
||||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||||
//
|
//
|
||||||
// GNU social is free software: you can redistribute it and/or modify
|
// GNU social is free software: you can redistribute it and/or modify
|
||||||
|
@ -18,30 +16,37 @@ declare(strict_types = 1);
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
// }}}
|
// }}}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Hugo Sales <hugo@hsal.es>
|
||||||
|
* @author Eliseu Amaro <mail@eliseuama.ro>
|
||||||
|
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
|
||||||
|
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||||
|
*/
|
||||||
|
|
||||||
namespace Component\Conversation;
|
namespace Component\Conversation;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Core\Router\RouteLoader;
|
use App\Core\Router;
|
||||||
use App\Core\Router\Router;
|
|
||||||
use App\Entity\Activity;
|
use App\Entity\Activity;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\Note;
|
use App\Entity\Note;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
|
use App\Util\Formatting;
|
||||||
use Component\Conversation\Entity\Conversation as ConversationEntity;
|
use Component\Conversation\Entity\Conversation as ConversationEntity;
|
||||||
use Component\Conversation\Entity\ConversationMute;
|
use Component\Conversation\Entity\ConversationMute;
|
||||||
|
use EventResult;
|
||||||
use Functional as F;
|
use Functional as F;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
class Conversation extends Component
|
class Conversation extends Component
|
||||||
{
|
{
|
||||||
public function onAddRoute(RouteLoader $r): bool
|
public function onAddRoute(Router $r): EventResult
|
||||||
{
|
{
|
||||||
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
|
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
|
||||||
$r->connect('conversation_mute', '/conversation/{conversation_id<\d+>}/mute', [Controller\Conversation::class, 'muteConversation']);
|
$r->connect('conversation_mute', '/conversation/{conversation_id<\d+>}/mute', [Controller\Conversation::class, 'muteConversation']);
|
||||||
|
@ -78,29 +83,29 @@ class Conversation extends Component
|
||||||
} else {
|
} else {
|
||||||
// It's a reply for sure
|
// It's a reply for sure
|
||||||
// Set reply_to property in newly created Note to parent's id
|
// Set reply_to property in newly created Note to parent's id
|
||||||
$current_note->setReplyTo($parent_id);
|
|
||||||
|
|
||||||
// Parent will have a conversation of its own, the reply should have the same one
|
// Parent will have a conversation of its own, the reply should have the same one
|
||||||
$parent_note = Note::getById($parent_id);
|
$parent_note = Note::getById($parent_id);
|
||||||
$current_note->setConversationId($parent_note->getConversationId());
|
$current_note->setConversationId($parent_note->getConversationId());
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::merge($current_note);
|
DB::persist($current_note);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTML rendering event that adds a reply link as a note
|
* 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 \App\Entity\Note $note The Note being rendered
|
||||||
* @param array $actions Contains keys 'url' (linking 'conversation_reply_to'
|
* @param array{url: string, title: string, classes: string, id: string} $actions
|
||||||
* route), 'title' (used as title for aforementioned url),
|
* Contains keys 'url' (linking 'conversation_reply_to' route),
|
||||||
* 'classes' (CSS styling classes used to visually inform the user of action context),
|
* 'title' (used as title for aforementioned url), 'classes' (CSS styling
|
||||||
* 'id' (HTML markup id used to redirect user to this anchor upon performing the action)
|
* 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
|
* @throws \App\Util\Exception\ServerException
|
||||||
*/
|
*/
|
||||||
public function onAddNoteActions(Request $request, Note $note, array &$actions): bool
|
public function onAddNoteActions(Request $request, Note $note, array &$actions): EventResult
|
||||||
{
|
{
|
||||||
if (\is_null(Common::user())) {
|
if (\is_null(Common::user())) {
|
||||||
return Event::next;
|
return Event::next;
|
||||||
|
@ -114,7 +119,8 @@ class Conversation extends Component
|
||||||
'conversation_reply_to',
|
'conversation_reply_to',
|
||||||
[
|
[
|
||||||
'reply_to_id' => $note->getId(),
|
'reply_to_id' => $note->getId(),
|
||||||
'from' => $from . '#note-anchor-' . $note->getId(),
|
'from' => $from,
|
||||||
|
'_fragment' => 'note-anchor-' . $note->getId(),
|
||||||
],
|
],
|
||||||
Router::ABSOLUTE_PATH,
|
Router::ABSOLUTE_PATH,
|
||||||
);
|
);
|
||||||
|
@ -131,29 +137,21 @@ class Conversation extends Component
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
* Append on note information about user actions.
|
||||||
*
|
*
|
||||||
* @param array $vars Contains information related to Note currently being rendered
|
* @param array<string, mixed> $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'])
|
* @param array{actors: Actor[], action: string} $result
|
||||||
|
*cContains keys 'actors', and 'action'. Needed to construct a string,
|
||||||
|
* stating who ($result['actors']), has already performed a reply
|
||||||
|
* ($result['action']), in the given Note (vars['note'])
|
||||||
*/
|
*/
|
||||||
public function onAppendCardNote(array $vars, array &$result): bool
|
public function onAppendCardNote(array $vars, array &$result): EventResult
|
||||||
{
|
{
|
||||||
|
if (str_contains($vars['request']->getPathInfo(), 'conversation')) {
|
||||||
|
return Event::next;
|
||||||
|
}
|
||||||
|
|
||||||
// The current Note being rendered
|
// The current Note being rendered
|
||||||
$note = $vars['note'];
|
$note = $vars['note'];
|
||||||
|
|
||||||
|
@ -175,6 +173,22 @@ class Conversation extends Component
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getReplyToIdFromRequest(Request $request): ?int
|
||||||
|
{
|
||||||
|
if (!\is_array($request->get('post_note')) || !\array_key_exists('_next', $request->get('post_note'))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$next = parse_url($request->get('post_note')['_next']);
|
||||||
|
if (!\array_key_exists('query', $next)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
parse_str($next['query'], $query);
|
||||||
|
if (!\array_key_exists('reply_to_id', $query)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (int) $query['reply_to_id'];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Informs **\App\Component\Posting::onAppendRightPostingBlock**, of the **current page context** in which the given
|
* Informs **\App\Component\Posting::onAppendRightPostingBlock**, of the **current page context** in which the given
|
||||||
* Actor is in. This is valuable when posting within a group route, allowing \App\Component\Posting to create a
|
* Actor is in. This is valuable when posting within a group route, allowing \App\Component\Posting to create a
|
||||||
|
@ -183,9 +197,9 @@ class Conversation extends Component
|
||||||
* @param \App\Entity\Actor $actor The Actor currently attempting to post a Note
|
* @param \App\Entity\Actor $actor The Actor currently attempting to post a Note
|
||||||
* @param null|\App\Entity\Actor $context_actor The 'owner' of the current route (e.g. Group or Actor), used to target it
|
* @param null|\App\Entity\Actor $context_actor The 'owner' of the current route (e.g. Group or Actor), used to target it
|
||||||
*/
|
*/
|
||||||
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor)
|
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor): EventResult
|
||||||
{
|
{
|
||||||
$to_note_id = $request->query->get('reply_to_id');
|
$to_note_id = $this->getReplyToIdFromRequest($request);
|
||||||
if (!\is_null($to_note_id)) {
|
if (!\is_null($to_note_id)) {
|
||||||
// Getting the actor itself
|
// Getting the actor itself
|
||||||
$context_actor = Actor::getById(Note::getById((int) $to_note_id)->getActorId());
|
$context_actor = Actor::getById(Note::getById((int) $to_note_id)->getActorId());
|
||||||
|
@ -194,6 +208,36 @@ class Conversation extends Component
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posting event to add extra information to Component\Posting form data
|
||||||
|
*
|
||||||
|
* @param array{reply_to_id: int} $data Transport data to be filled with reply_to_id
|
||||||
|
*
|
||||||
|
* @throws \App\Util\Exception\ClientException
|
||||||
|
* @throws \App\Util\Exception\NoSuchNoteException
|
||||||
|
*/
|
||||||
|
public function onPostingModifyData(Request $request, Actor $actor, array &$data): EventResult
|
||||||
|
{
|
||||||
|
$to_note_id = $this->getReplyToIdFromRequest($request);
|
||||||
|
if (!\is_null($to_note_id)) {
|
||||||
|
Note::ensureCanInteract(Note::getById($to_note_id), $actor);
|
||||||
|
$data['reply_to_id'] = $to_note_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Event::next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add minimal Note card to RightPanel template
|
||||||
|
*
|
||||||
|
* @param string[] $elements
|
||||||
|
*/
|
||||||
|
public function onPrependPostingForm(Request $request, array &$elements): EventResult
|
||||||
|
{
|
||||||
|
$elements[] = Formatting::twigRenderFile('cards/blocks/note_compact_wrapper.html.twig', ['note' => Note::getById((int) $request->query->get('reply_to_id'))]);
|
||||||
|
return Event::next;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event launched when deleting given Note, it's deletion implies further changes to object related to this Note.
|
* Event launched when deleting given Note, it's deletion implies further changes to object related to this Note.
|
||||||
* Please note, **replies are NOT deleted**, their reply_to is only set to null since this Note no longer exists.
|
* Please note, **replies are NOT deleted**, their reply_to is only set to null since this Note no longer exists.
|
||||||
|
@ -201,7 +245,7 @@ class Conversation extends Component
|
||||||
* @param \App\Entity\Note $note Note being deleted
|
* @param \App\Entity\Note $note Note being deleted
|
||||||
* @param \App\Entity\Actor $actor Actor that performed the delete action
|
* @param \App\Entity\Actor $actor Actor that performed the delete action
|
||||||
*/
|
*/
|
||||||
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
|
public function onNoteDeleteRelated(Note &$note, Actor $actor): EventResult
|
||||||
{
|
{
|
||||||
// Ensure we have the most up to date replies
|
// Ensure we have the most up to date replies
|
||||||
Cache::delete(Note::cacheKeys($note->getId())['replies']);
|
Cache::delete(Note::cacheKeys($note->getId())['replies']);
|
||||||
|
@ -213,14 +257,14 @@ class Conversation extends Component
|
||||||
/**
|
/**
|
||||||
* Adds extra actions related to Conversation Component, that act upon/from the given Note.
|
* Adds extra actions related to Conversation Component, that act upon/from the given Note.
|
||||||
*
|
*
|
||||||
* @param \App\Entity\Note $note Current Note being rendered
|
* @param \App\Entity\Note $note Current Note being rendered
|
||||||
* @param array $actions Containing 'url' (Controller connected route), 'title' (used in anchor link containing the url), ?'classes' (CSS classes required for styling, if needed)
|
* @param array{url: string, title: string, classes?: string} $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
|
* @throws \App\Util\Exception\ServerException
|
||||||
*
|
|
||||||
* @return bool EventHook
|
|
||||||
*/
|
*/
|
||||||
public function onAddExtraNoteActions(Request $request, Note $note, array &$actions)
|
public function onAddExtraNoteActions(Request $request, Note $note, array &$actions): EventResult
|
||||||
{
|
{
|
||||||
if (\is_null($user = Common::user())) {
|
if (\is_null($user = Common::user())) {
|
||||||
return Event::next;
|
return Event::next;
|
||||||
|
@ -234,7 +278,8 @@ class Conversation extends Component
|
||||||
'conversation_mute',
|
'conversation_mute',
|
||||||
[
|
[
|
||||||
'conversation_id' => $note->getConversationId(),
|
'conversation_id' => $note->getConversationId(),
|
||||||
'from' => $from . '#note-anchor-' . $note->getId(),
|
'from' => $from,
|
||||||
|
'_fragment' => 'note-anchor-' . $note->getId(),
|
||||||
],
|
],
|
||||||
Router::ABSOLUTE_PATH,
|
Router::ABSOLUTE_PATH,
|
||||||
);
|
);
|
||||||
|
@ -248,7 +293,12 @@ class Conversation extends Component
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onNewNotificationShould(Activity $activity, Actor $actor)
|
/**
|
||||||
|
* Prevents new Notifications to appear for muted conversations
|
||||||
|
*
|
||||||
|
* @param Activity $activity Notification Activity
|
||||||
|
*/
|
||||||
|
public function onNewNotificationShould(Activity $activity, Actor $actor): EventResult
|
||||||
{
|
{
|
||||||
if ($activity->getObjectType() === 'note' && ConversationMute::isMuted($activity, $actor)) {
|
if ($activity->getObjectType() === 'note' && ConversationMute::isMuted($activity, $actor)) {
|
||||||
return Event::stop;
|
return Event::stop;
|
||||||
|
|
|
@ -23,10 +23,9 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Conversation\Entity;
|
namespace Component\Conversation\Entity;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
use App\Entity\Note;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity class for Conversations
|
* Entity class for Conversations
|
||||||
|
|
|
@ -24,7 +24,7 @@ declare(strict_types = 1);
|
||||||
namespace Component\Conversation\Entity;
|
namespace Component\Conversation\Entity;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use App\Entity\Activity;
|
use App\Entity\Activity;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
|
|
|
@ -37,35 +37,46 @@ namespace Component\Feed\Controller;
|
||||||
|
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
|
use App\Util\HTML\Heading;
|
||||||
use Component\Collection\Util\Controller\FeedController;
|
use Component\Collection\Util\Controller\FeedController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends FeedController<\App\Entity\Note>
|
||||||
|
*/
|
||||||
class Feeds extends FeedController
|
class Feeds extends FeedController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The Planet feed represents every local post. Which is what this instance has to share with the universe.
|
* The Planet feed represents every local post. Which is what this instance has to share with the universe.
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
*/
|
*/
|
||||||
public function public(Request $request): array
|
public function public(Request $request): array
|
||||||
{
|
{
|
||||||
$data = $this->query('note-local:true');
|
$data = $this->query('note-local:true');
|
||||||
|
$page_title = _m(\is_null(Common::user()) ? 'Feed' : 'Planet');
|
||||||
return [
|
return [
|
||||||
'_template' => 'collection/notes.html.twig',
|
'_template' => 'collection/notes.html.twig',
|
||||||
'page_title' => _m(\is_null(Common::user()) ? 'Feed' : 'Planet'),
|
'page_title' => $page_title,
|
||||||
'notes' => $data['notes'],
|
'notes_feed_title' => (new Heading(level: 1, classes: ['feed-title'], text: $page_title)),
|
||||||
|
'notes' => $data['notes'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Home feed represents everything that concerns a certain actor (its subscriptions)
|
* The Home feed represents everything that concerns a certain actor (its subscriptions)
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
*/
|
*/
|
||||||
public function home(Request $request): array
|
public function home(Request $request): array
|
||||||
{
|
{
|
||||||
Common::ensureLoggedIn();
|
Common::ensureLoggedIn();
|
||||||
$data = $this->query('note-from:subscribed-person,subscribed-group,subscribed-organisation');
|
$data = $this->query('note-from:subscribed-person,subscribed-group,subscribed-organisation');
|
||||||
return [
|
return [
|
||||||
'_template' => 'collection/notes.html.twig',
|
'_template' => 'collection/notes.html.twig',
|
||||||
'page_title' => _m('Home'),
|
'page_title' => _m('Home'),
|
||||||
'notes' => $data['notes'],
|
'notes_feed_title' => (new Heading(level: 1, classes: ['feed-title'], text: 'Home')),
|
||||||
|
'notes' => $data['notes'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,12 +25,13 @@ namespace Component\Feed;
|
||||||
|
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Core\Router\RouteLoader;
|
use App\Core\Router;
|
||||||
use Component\Feed\Controller as C;
|
use Component\Feed\Controller as C;
|
||||||
|
use EventResult;
|
||||||
|
|
||||||
class Feed extends Component
|
class Feed extends Component
|
||||||
{
|
{
|
||||||
public function onAddRoute(RouteLoader $r): bool
|
public function onAddRoute(Router $r): EventResult
|
||||||
{
|
{
|
||||||
$r->connect('feed_public', '/feed/public', [C\Feeds::class, 'public']);
|
$r->connect('feed_public', '/feed/public', [C\Feeds::class, 'public']);
|
||||||
$r->connect('feed_home', '/feed/home', [C\Feeds::class, 'home']);
|
$r->connect('feed_home', '/feed/home', [C\Feeds::class, 'home']);
|
||||||
|
|
54
components/Feed/tests/Controller/FeedsTest.php
Normal file
54
components/Feed/tests/Controller/FeedsTest.php
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
// {{{ License
|
||||||
|
|
||||||
|
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||||
|
//
|
||||||
|
// GNU social is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// GNU social is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// }}}
|
||||||
|
|
||||||
|
namespace Component\Feed\tests\Controller;
|
||||||
|
|
||||||
|
use App\Core\Router;
|
||||||
|
use App\Util\GNUsocialTestCase;
|
||||||
|
use Component\Feed\Controller\Feeds;
|
||||||
|
use Jchook\AssertThrows\AssertThrows;
|
||||||
|
|
||||||
|
class FeedsTest extends GNUsocialTestCase
|
||||||
|
{
|
||||||
|
use AssertThrows;
|
||||||
|
|
||||||
|
public function testPublic()
|
||||||
|
{
|
||||||
|
// This calls static::bootKernel(), and creates a "client" that is acting as the browser
|
||||||
|
$client = static::createClient();
|
||||||
|
$crawler = $client->request('GET', Router::url('feed_public'));
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHome()
|
||||||
|
{
|
||||||
|
// This calls static::bootKernel(), and creates a "client" that is acting as the browser
|
||||||
|
$client = static::createClient();
|
||||||
|
$crawler = $client->request('GET', Router::url('feed_home'));
|
||||||
|
$this->assertResponseStatusCodeSame(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: It would be nice to actually test whether the feeds are respecting scopes and spitting
|
||||||
|
// out the expected notes... The ActivityPub plugin have a somewhat obvious way of testing it so,
|
||||||
|
// for now, having that, might fill that need, let's see
|
||||||
|
}
|
|
@ -34,7 +34,7 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\FreeNetwork\Controller;
|
namespace Component\FreeNetwork\Controller;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use Component\Collection\Util\Controller\FeedController;
|
use Component\Collection\Util\Controller\FeedController;
|
||||||
|
|
|
@ -20,7 +20,7 @@ class HostMeta extends XrdController
|
||||||
|
|
||||||
public function setXRD()
|
public function setXRD()
|
||||||
{
|
{
|
||||||
if (Event::handle('StartHostMetaLinks', [&$this->xrd->links]) !== Event::stop) {
|
if (Event::handle('StartHostMetaLinks', [&$this->xrd->links])) {
|
||||||
Event::handle('EndHostMetaLinks', [&$this->xrd->links]);
|
Event::handle('EndHostMetaLinks', [&$this->xrd->links]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,7 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\FreeNetwork\Entity;
|
namespace Component\FreeNetwork\Entity;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use Component\FreeNetwork\Util\Discovery;
|
use Component\FreeNetwork\Util\Discovery;
|
||||||
|
@ -125,7 +125,7 @@ class FreeNetworkActorProtocol extends Entity
|
||||||
} else {
|
} else {
|
||||||
$attributed_protocol->setProtocol($protocol);
|
$attributed_protocol->setProtocol($protocol);
|
||||||
}
|
}
|
||||||
DB::wrapInTransaction(fn () => DB::persist($attributed_protocol));
|
DB::persist($attributed_protocol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canIActor(string $protocol, int|Actor $actor_id): bool
|
public static function canIActor(string $protocol, int|Actor $actor_id): bool
|
||||||
|
|
|
@ -21,15 +21,14 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\FreeNetwork;
|
namespace Component\FreeNetwork;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\GSFile;
|
use App\Core\GSFile;
|
||||||
use App\Core\HTTPClient;
|
use App\Core\HTTPClient;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Log;
|
use App\Core\Log;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Core\Router\RouteLoader;
|
use App\Core\Router;
|
||||||
use App\Core\Router\Router;
|
|
||||||
use App\Entity\Activity;
|
use App\Entity\Activity;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\LocalUser;
|
use App\Entity\LocalUser;
|
||||||
|
@ -44,6 +43,7 @@ use App\Util\Exception\NicknameTakenException;
|
||||||
use App\Util\Exception\NicknameTooLongException;
|
use App\Util\Exception\NicknameTooLongException;
|
||||||
use App\Util\Exception\NoSuchActorException;
|
use App\Util\Exception\NoSuchActorException;
|
||||||
use App\Util\Exception\ServerException;
|
use App\Util\Exception\ServerException;
|
||||||
|
use App\Util\Formatting;
|
||||||
use App\Util\Nickname;
|
use App\Util\Nickname;
|
||||||
use Component\FreeNetwork\Controller\Feeds;
|
use Component\FreeNetwork\Controller\Feeds;
|
||||||
use Component\FreeNetwork\Controller\HostMeta;
|
use Component\FreeNetwork\Controller\HostMeta;
|
||||||
|
@ -53,6 +53,8 @@ use Component\FreeNetwork\Util\Discovery;
|
||||||
use Component\FreeNetwork\Util\WebfingerResource;
|
use Component\FreeNetwork\Util\WebfingerResource;
|
||||||
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceActor;
|
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceActor;
|
||||||
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceNote;
|
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceNote;
|
||||||
|
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||||
|
use EventResult;
|
||||||
use Exception;
|
use Exception;
|
||||||
use const PREG_SET_ORDER;
|
use const PREG_SET_ORDER;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
@ -76,8 +78,15 @@ class FreeNetwork extends Component
|
||||||
public const OAUTH_ACCESS_TOKEN_REL = 'http://apinamespace.org/oauth/access_token';
|
public const OAUTH_ACCESS_TOKEN_REL = 'http://apinamespace.org/oauth/access_token';
|
||||||
public const OAUTH_REQUEST_TOKEN_REL = 'http://apinamespace.org/oauth/request_token';
|
public const OAUTH_REQUEST_TOKEN_REL = 'http://apinamespace.org/oauth/request_token';
|
||||||
public const OAUTH_AUTHORIZE_REL = 'http://apinamespace.org/oauth/authorize';
|
public const OAUTH_AUTHORIZE_REL = 'http://apinamespace.org/oauth/authorize';
|
||||||
|
private static array $protocols = [];
|
||||||
|
|
||||||
public function onAddRoute(RouteLoader $m): bool
|
public function onInitializeComponent(): EventResult
|
||||||
|
{
|
||||||
|
Event::handle('AddFreeNetworkProtocol', [&self::$protocols]);
|
||||||
|
return Event::next;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAddRoute(Router $m): EventResult
|
||||||
{
|
{
|
||||||
// Feeds
|
// Feeds
|
||||||
$m->connect('feed_network', '/feed/network', [Feeds::class, 'network']);
|
$m->connect('feed_network', '/feed/network', [Feeds::class, 'network']);
|
||||||
|
@ -103,7 +112,7 @@ class FreeNetwork extends Component
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
|
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): EventResult
|
||||||
{
|
{
|
||||||
DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_network'), 'route' => $route, 'title' => _m('Meteorites'), 'ordering' => $ordering++]));
|
DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_network'), 'route' => $route, 'title' => _m('Meteorites'), 'ordering' => $ordering++]));
|
||||||
DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_clique'), 'route' => $route, 'title' => _m('Planetary System'), 'ordering' => $ordering++]));
|
DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_clique'), 'route' => $route, 'title' => _m('Planetary System'), 'ordering' => $ordering++]));
|
||||||
|
@ -111,7 +120,7 @@ class FreeNetwork extends Component
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onStartGetProfileAcctUri(Actor $profile, &$acct): bool
|
public function onStartGetProfileAcctUri(Actor $profile, &$acct): EventResult
|
||||||
{
|
{
|
||||||
$wfr = new WebFingerResourceActor($profile);
|
$wfr = new WebFingerResourceActor($profile);
|
||||||
try {
|
try {
|
||||||
|
@ -139,7 +148,7 @@ class FreeNetwork extends Component
|
||||||
* @throws NoSuchActorException
|
* @throws NoSuchActorException
|
||||||
* @throws ServerException
|
* @throws ServerException
|
||||||
*/
|
*/
|
||||||
public function onEndGetWebFingerResource(string $resource, ?WebfingerResource &$target = null, array $args = []): bool
|
public function onEndGetWebFingerResource(string $resource, ?WebfingerResource &$target = null, array $args = []): EventResult
|
||||||
{
|
{
|
||||||
// * Either we didn't find the profile, then we want to make
|
// * Either we didn't find the profile, then we want to make
|
||||||
// the $profile variable null for clarity.
|
// the $profile variable null for clarity.
|
||||||
|
@ -152,7 +161,7 @@ class FreeNetwork extends Component
|
||||||
$parts = explode('@', mb_substr(urldecode($resource), 5)); // 5 is strlen of 'acct:'
|
$parts = explode('@', mb_substr(urldecode($resource), 5)); // 5 is strlen of 'acct:'
|
||||||
if (\count($parts) === 2) {
|
if (\count($parts) === 2) {
|
||||||
[$nick, $domain] = $parts;
|
[$nick, $domain] = $parts;
|
||||||
if ($domain !== $_ENV['SOCIAL_DOMAIN']) {
|
if ($domain !== Common::config('site', 'server')) {
|
||||||
throw new ServerException(_m('Remote profiles not supported via WebFinger yet.'));
|
throw new ServerException(_m('Remote profiles not supported via WebFinger yet.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -169,7 +178,7 @@ class FreeNetwork extends Component
|
||||||
// This means $resource is a valid url
|
// This means $resource is a valid url
|
||||||
$resource_parts = parse_url($resource);
|
$resource_parts = parse_url($resource);
|
||||||
// TODO: Use URLMatcher
|
// TODO: Use URLMatcher
|
||||||
if ($resource_parts['host'] === $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
|
if ($resource_parts['host'] === Common::config('site', 'server')) {
|
||||||
$str = $resource_parts['path'];
|
$str = $resource_parts['path'];
|
||||||
// actor_view_nickname
|
// actor_view_nickname
|
||||||
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m';
|
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m';
|
||||||
|
@ -215,7 +224,7 @@ class FreeNetwork extends Component
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onStartHostMetaLinks(array &$links): bool
|
public function onStartHostMetaLinks(array &$links): EventResult
|
||||||
{
|
{
|
||||||
foreach (Discovery::supportedMimeTypes() as $type) {
|
foreach (Discovery::supportedMimeTypes() as $type) {
|
||||||
$links[] = new XML_XRD_Element_Link(
|
$links[] = new XML_XRD_Element_Link(
|
||||||
|
@ -235,8 +244,10 @@ class FreeNetwork extends Component
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a link header for LRDD Discovery
|
* Add a link header for LRDD Discovery
|
||||||
|
*
|
||||||
|
* @param mixed $action
|
||||||
*/
|
*/
|
||||||
public function onStartShowHTML($action): bool
|
public function onStartShowHTML($action): EventResult
|
||||||
{
|
{
|
||||||
if ($action instanceof ShowstreamAction) {
|
if ($action instanceof ShowstreamAction) {
|
||||||
$resource = $action->getTarget()->getUri();
|
$resource = $action->getTarget()->getUri();
|
||||||
|
@ -249,13 +260,13 @@ class FreeNetwork extends Component
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onStartDiscoveryMethodRegistration(Discovery $disco): bool
|
public function onStartDiscoveryMethodRegistration(Discovery $disco): EventResult
|
||||||
{
|
{
|
||||||
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodWebfinger');
|
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodWebfinger');
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onEndDiscoveryMethodRegistration(Discovery $disco): bool
|
public function onEndDiscoveryMethodRegistration(Discovery $disco): EventResult
|
||||||
{
|
{
|
||||||
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodHostMeta');
|
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodHostMeta');
|
||||||
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodLinkHeader');
|
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodLinkHeader');
|
||||||
|
@ -267,7 +278,7 @@ class FreeNetwork extends Component
|
||||||
* @throws ClientException
|
* @throws ClientException
|
||||||
* @throws ServerException
|
* @throws ServerException
|
||||||
*/
|
*/
|
||||||
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?Response &$response = null): bool
|
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?Response &$response = null): EventResult
|
||||||
{
|
{
|
||||||
if (!\in_array($route, ['freenetwork_hostmeta', 'freenetwork_hostmeta_format', 'freenetwork_webfinger', 'freenetwork_webfinger_format', 'freenetwork_ownerxrd'])) {
|
if (!\in_array($route, ['freenetwork_hostmeta', 'freenetwork_hostmeta_format', 'freenetwork_webfinger', 'freenetwork_webfinger_format', 'freenetwork_ownerxrd'])) {
|
||||||
return Event::next;
|
return Event::next;
|
||||||
|
@ -335,6 +346,7 @@ class FreeNetwork extends Component
|
||||||
* @param string $preMention Character(s) that signals a mention ('@', '!'...)
|
* @param string $preMention Character(s) that signals a mention ('@', '!'...)
|
||||||
*
|
*
|
||||||
* @return array the matching URLs (without @ or acct:) and each respective position in the given string
|
* @return array the matching URLs (without @ or acct:) and each respective position in the given string
|
||||||
|
*
|
||||||
* @example.com/mublog/user
|
* @example.com/mublog/user
|
||||||
*/
|
*/
|
||||||
public static function extractUrlMentions(string $text, string $preMention = '@'): array
|
public static function extractUrlMentions(string $text, string $preMention = '@'): array
|
||||||
|
@ -366,9 +378,10 @@ class FreeNetwork extends Component
|
||||||
* @param $mentions
|
* @param $mentions
|
||||||
*
|
*
|
||||||
* @return bool hook return value
|
* @return bool hook return value
|
||||||
|
*
|
||||||
* @example.com/mublog/user
|
* @example.com/mublog/user
|
||||||
*/
|
*/
|
||||||
public function onEndFindMentions(Actor $sender, string $text, array &$mentions): bool
|
public function onEndFindMentions(Actor $sender, string $text, array &$mentions): EventResult
|
||||||
{
|
{
|
||||||
$matches = [];
|
$matches = [];
|
||||||
|
|
||||||
|
@ -379,7 +392,7 @@ class FreeNetwork extends Component
|
||||||
$actor = null;
|
$actor = null;
|
||||||
|
|
||||||
$resource_parts = explode($preMention, $target);
|
$resource_parts = explode($preMention, $target);
|
||||||
if ($resource_parts[1] === $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
|
if ($resource_parts[1] === Common::config('site', 'server')) {
|
||||||
$actor = LocalUser::getByPK(['nickname' => $resource_parts[0]])->getActor();
|
$actor = LocalUser::getByPK(['nickname' => $resource_parts[0]])->getActor();
|
||||||
} else {
|
} else {
|
||||||
Event::handle('FreeNetworkFindMentions', [$target, &$actor]);
|
Event::handle('FreeNetworkFindMentions', [$target, &$actor]);
|
||||||
|
@ -408,7 +421,7 @@ class FreeNetwork extends Component
|
||||||
// This means $resource is a valid url
|
// This means $resource is a valid url
|
||||||
$resource_parts = parse_url($url);
|
$resource_parts = parse_url($url);
|
||||||
// TODO: Use URLMatcher
|
// TODO: Use URLMatcher
|
||||||
if ($resource_parts['host'] === $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
|
if ($resource_parts['host'] === Common::config('site', 'server')) {
|
||||||
$str = $resource_parts['path'];
|
$str = $resource_parts['path'];
|
||||||
// actor_view_nickname
|
// actor_view_nickname
|
||||||
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m';
|
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m';
|
||||||
|
@ -487,25 +500,48 @@ class FreeNetwork extends Component
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Actor[] $targets
|
||||||
|
*/
|
||||||
public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
|
public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
|
||||||
{
|
{
|
||||||
$protocols = [];
|
foreach (self::$protocols as $protocol) {
|
||||||
Event::handle('AddFreeNetworkProtocol', [&$protocols]);
|
$protocol::freeNetworkDistribute($sender, $activity, $targets, $reason);
|
||||||
$delivered = [];
|
|
||||||
foreach ($protocols as $protocol) {
|
|
||||||
$protocol::freeNetworkDistribute($sender, $activity, $targets, $reason, $delivered);
|
|
||||||
}
|
}
|
||||||
$failed_targets = array_udiff($targets, $delivered, fn (Actor $a, Actor $b): int => $a->getId() <=> $b->getId());
|
|
||||||
// TODO: Implement failed queues
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function mentionToName(string $nickname, string $uri): string
|
public static function mentionTagToName(string $nickname, string $uri): string
|
||||||
{
|
{
|
||||||
return '@' . $nickname . '@' . parse_url($uri, \PHP_URL_HOST);
|
return '@' . $nickname . '@' . parse_url($uri, \PHP_URL_HOST);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onPluginVersion(array &$versions): bool
|
public static function groupTagToName(string $nickname, string $uri): string
|
||||||
|
{
|
||||||
|
return '!' . $nickname . '@' . parse_url($uri, \PHP_URL_HOST);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add fediverse: query expression
|
||||||
|
* // TODO: adding WebFinger would probably be nice
|
||||||
|
*
|
||||||
|
* @param mixed $note_expr
|
||||||
|
* @param mixed $actor_expr
|
||||||
|
*/
|
||||||
|
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult
|
||||||
|
{
|
||||||
|
if (Formatting::startsWith($term, ['fediverse:'])) {
|
||||||
|
foreach (self::$protocols as $protocol) {
|
||||||
|
// 10 is strlen of `fediverse:`
|
||||||
|
if ($protocol::freeNetworkGrabRemote(mb_substr($term, 10))) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Event::next;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onPluginVersion(array &$versions): EventResult
|
||||||
{
|
{
|
||||||
$versions[] = [
|
$versions[] = [
|
||||||
'name' => 'WebFinger',
|
'name' => 'WebFinger',
|
||||||
|
|
|
@ -6,7 +6,7 @@ namespace Component\FreeNetwork\Util\WebfingerResource;
|
||||||
|
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\Log;
|
use App\Core\Log;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use Component\FreeNetwork\Exception\WebfingerReconstructionException;
|
use Component\FreeNetwork\Exception\WebfingerReconstructionException;
|
||||||
|
|
|
@ -23,155 +23,54 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Group\Controller;
|
namespace Component\Group\Controller;
|
||||||
|
|
||||||
|
use App\Core\ActorLocalRoles;
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\Controller;
|
||||||
|
use App\Core\DB;
|
||||||
use App\Core\Form;
|
use App\Core\Form;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Log;
|
use App\Core\Log;
|
||||||
use App\Core\UserRoles;
|
|
||||||
use App\Entity as E;
|
use App\Entity as E;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use App\Util\Exception\ClientException;
|
use App\Util\Exception\ClientException;
|
||||||
|
use App\Util\Exception\DuplicateFoundException;
|
||||||
use App\Util\Exception\NicknameEmptyException;
|
use App\Util\Exception\NicknameEmptyException;
|
||||||
|
use App\Util\Exception\NicknameException;
|
||||||
use App\Util\Exception\NicknameInvalidException;
|
use App\Util\Exception\NicknameInvalidException;
|
||||||
use App\Util\Exception\NicknameNotAllowedException;
|
use App\Util\Exception\NicknameNotAllowedException;
|
||||||
use App\Util\Exception\NicknameTakenException;
|
use App\Util\Exception\NicknameTakenException;
|
||||||
use App\Util\Exception\NicknameTooLongException;
|
use App\Util\Exception\NicknameTooLongException;
|
||||||
use App\Util\Exception\NoLoggedInUser;
|
use App\Util\Exception\NotFoundException;
|
||||||
use App\Util\Exception\RedirectException;
|
use App\Util\Exception\RedirectException;
|
||||||
use App\Util\Exception\ServerException;
|
use App\Util\Exception\ServerException;
|
||||||
use App\Util\Form\ActorForms;
|
use App\Util\Form\ActorForms;
|
||||||
use App\Util\Nickname;
|
use App\Util\Nickname;
|
||||||
use Component\Collection\Util\Controller\FeedController;
|
|
||||||
use Component\Group\Entity\GroupMember;
|
use Component\Group\Entity\GroupMember;
|
||||||
use Component\Group\Entity\LocalGroup;
|
use Component\Group\Entity\LocalGroup;
|
||||||
use Component\Subscription\Entity\ActorSubscription;
|
use Component\Subscription\Entity\ActorSubscription;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
class Group extends FeedController
|
class Group extends Controller
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* View a group feed by its nickname
|
|
||||||
*
|
|
||||||
* @param string $nickname The group's nickname to be shown
|
|
||||||
*
|
|
||||||
* @throws NicknameEmptyException
|
|
||||||
* @throws NicknameNotAllowedException
|
|
||||||
* @throws NicknameTakenException
|
|
||||||
* @throws NicknameTooLongException
|
|
||||||
* @throws ServerException
|
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function groupViewNickname(Request $request, string $nickname)
|
|
||||||
{
|
|
||||||
Nickname::validate($nickname, which: Nickname::CHECK_LOCAL_GROUP); // throws
|
|
||||||
$group = LocalGroup::getActorByNickname($nickname);
|
|
||||||
$actor = Common::actor();
|
|
||||||
$subscribe_form = null;
|
|
||||||
|
|
||||||
if (!\is_null($group)
|
|
||||||
&& !\is_null($actor)
|
|
||||||
&& \is_null(Cache::get(
|
|
||||||
ActorSubscription::cacheKeys($actor, $group)['subscribed'],
|
|
||||||
fn () => DB::findOneBy('actor_subscription', [
|
|
||||||
'subscriber_id' => $actor->getId(),
|
|
||||||
'subscribed_id' => $group->getId(),
|
|
||||||
], return_null: true),
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
$subscribe_form = Form::create([['subscribe', SubmitType::class, ['label' => _m('Subscribe to this group')]]]);
|
|
||||||
$subscribe_form->handleRequest($request);
|
|
||||||
if ($subscribe_form->isSubmitted() && $subscribe_form->isValid()) {
|
|
||||||
DB::persist(ActorSubscription::create([
|
|
||||||
'subscriber_id' => $actor->getId(),
|
|
||||||
'subscribed_id' => $group->getId(),
|
|
||||||
]));
|
|
||||||
DB::flush();
|
|
||||||
Cache::delete(E\Actor::cacheKeys($group->getId())['subscribers']);
|
|
||||||
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
|
|
||||||
Cache::delete(ActorSubscription::cacheKeys($actor, $group)['subscribed']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$notes = !\is_null($group) ? DB::dql(
|
|
||||||
<<<'EOF'
|
|
||||||
select n from note n
|
|
||||||
join activity a with n.id = a.object_id
|
|
||||||
join group_inbox gi with a.id = gi.activity_id
|
|
||||||
where a.object_type = 'note' and gi.group_id = :group_id
|
|
||||||
order by a.created desc, a.id desc
|
|
||||||
EOF,
|
|
||||||
['group_id' => $group->getId()],
|
|
||||||
) : [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'_template' => 'group/view.html.twig',
|
|
||||||
'actor' => $group,
|
|
||||||
'nickname' => $group?->getNickname() ?? $nickname,
|
|
||||||
'notes' => $notes,
|
|
||||||
'subscribe_form' => $subscribe_form?->createView(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page that allows an actor to create a new group
|
* Page that allows an actor to create a new group
|
||||||
*
|
*
|
||||||
* @throws RedirectException
|
* @throws RedirectException
|
||||||
* @throws ServerException
|
* @throws ServerException
|
||||||
*
|
*
|
||||||
* @return array
|
* @return ControllerResultType
|
||||||
*/
|
*/
|
||||||
public function groupCreate(Request $request)
|
public function groupCreate(Request $request): array
|
||||||
{
|
{
|
||||||
if (\is_null($actor = Common::actor())) {
|
if (\is_null($actor = Common::actor())) {
|
||||||
throw new RedirectException('security_login');
|
throw new RedirectException('security_login');
|
||||||
}
|
}
|
||||||
|
|
||||||
$create_form = Form::create([
|
$create_form = self::getGroupCreateForm($request, $actor);
|
||||||
['group_nickname', TextType::class, ['label' => _m('Group nickname')]],
|
|
||||||
['group_create', SubmitType::class, ['label' => _m('Create this group!')]],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$create_form->handleRequest($request);
|
|
||||||
if ($create_form->isSubmitted() && $create_form->isValid()) {
|
|
||||||
$data = $create_form->getData();
|
|
||||||
$nickname = $data['group_nickname'];
|
|
||||||
|
|
||||||
Log::info(
|
|
||||||
_m(
|
|
||||||
'Actor id:{actor_id} nick:{actor_nick} created the group {nickname}',
|
|
||||||
['{actor_id}' => $actor->getId(), 'actor_nick' => $actor->getNickname(), 'nickname' => $nickname],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
DB::persist($group = E\Actor::create([
|
|
||||||
'nickname' => $nickname,
|
|
||||||
'type' => E\Actor::GROUP,
|
|
||||||
'is_local' => true,
|
|
||||||
'roles' => UserRoles::BOT,
|
|
||||||
]));
|
|
||||||
DB::persist(LocalGroup::create([
|
|
||||||
'group_id' => $group->getId(),
|
|
||||||
'nickname' => $nickname,
|
|
||||||
]));
|
|
||||||
DB::persist(ActorSubscription::create([
|
|
||||||
'subscriber_id' => $group->getId(),
|
|
||||||
'subscribed_id' => $group->getId(),
|
|
||||||
]));
|
|
||||||
DB::persist(GroupMember::create([
|
|
||||||
'group_id' => $group->getId(),
|
|
||||||
'actor_id' => $actor->getId(),
|
|
||||||
'is_admin' => true,
|
|
||||||
]));
|
|
||||||
DB::flush();
|
|
||||||
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribers']);
|
|
||||||
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
|
|
||||||
|
|
||||||
throw new RedirectException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'_template' => 'group/create.html.twig',
|
'_template' => 'group/create.html.twig',
|
||||||
|
@ -183,30 +82,106 @@ class Group extends FeedController
|
||||||
* Settings page for the group with the provided nickname, checks if the current actor can administrate given group
|
* Settings page for the group with the provided nickname, checks if the current actor can administrate given group
|
||||||
*
|
*
|
||||||
* @throws ClientException
|
* @throws ClientException
|
||||||
|
* @throws DuplicateFoundException
|
||||||
* @throws NicknameEmptyException
|
* @throws NicknameEmptyException
|
||||||
|
* @throws NicknameException
|
||||||
* @throws NicknameInvalidException
|
* @throws NicknameInvalidException
|
||||||
* @throws NicknameNotAllowedException
|
* @throws NicknameNotAllowedException
|
||||||
* @throws NicknameTakenException
|
* @throws NicknameTakenException
|
||||||
* @throws NicknameTooLongException
|
* @throws NicknameTooLongException
|
||||||
* @throws NoLoggedInUser
|
* @throws NotFoundException
|
||||||
* @throws ServerException
|
* @throws ServerException
|
||||||
*
|
*
|
||||||
* @return array
|
* @return ControllerResultType
|
||||||
*/
|
*/
|
||||||
public function groupSettings(Request $request, string $nickname)
|
public function groupSettings(Request $request, int $id): array
|
||||||
{
|
{
|
||||||
$local_group = LocalGroup::getByNickname($nickname);
|
$local_group = DB::findOneBy(LocalGroup::class, ['actor_id' => $id]);
|
||||||
$group_actor = $local_group->getActor();
|
$group_actor = $local_group->getActor();
|
||||||
$actor = Common::actor();
|
$actor = Common::actor();
|
||||||
if (!\is_null($group_actor) && $actor->canAdmin($group_actor)) {
|
if (!\is_null($group_actor) && $actor->canModerate($group_actor)) {
|
||||||
return [
|
return [
|
||||||
'_template' => 'group/settings.html.twig',
|
'_template' => 'group/settings.html.twig',
|
||||||
'group' => $group_actor,
|
'group' => $group_actor,
|
||||||
'personal_info_form' => ActorForms::personalInfo($request, $actor, $local_group)->createView(),
|
'personal_info_form' => ActorForms::personalInfo(request: $request, scope: $actor, target: $group_actor)->createView(),
|
||||||
'open_details_query' => $this->string('open'),
|
'open_details_query' => $this->string('open'),
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
throw new ClientException(_m('You do not have permission to edit settings for the group "{group}"', ['{group}' => $nickname]), code: 404);
|
throw new ClientException(_m('You do not have permission to edit settings for the group "{group}"', ['{group}' => $id]), code: 404);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Group FormInterface getter
|
||||||
|
*
|
||||||
|
* @throws RedirectException
|
||||||
|
* @throws ServerException
|
||||||
|
*/
|
||||||
|
public static function getGroupCreateForm(Request $request, E\Actor $actor): FormInterface
|
||||||
|
{
|
||||||
|
$create_form = Form::create([
|
||||||
|
['group_nickname', TextType::class, ['label' => _m('Group nickname')]],
|
||||||
|
['group_type', ChoiceType::class, ['label' => _m('Type:'), 'multiple' => false, 'expanded' => false, 'choices' => [
|
||||||
|
_m('Group') => 'group',
|
||||||
|
_m('Organisation') => 'organisation',
|
||||||
|
]]],
|
||||||
|
['group_scope', ChoiceType::class, ['label' => _m('Is this a private group:'), 'multiple' => false, 'expanded' => false, 'choices' => [
|
||||||
|
_m('No') => 'public',
|
||||||
|
_m('Yes') => 'private',
|
||||||
|
]]],
|
||||||
|
['group_create', SubmitType::class, ['label' => _m('Create this group!')]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$create_form->handleRequest($request);
|
||||||
|
if ($create_form->isSubmitted() && $create_form->isValid()) {
|
||||||
|
$data = $create_form->getData();
|
||||||
|
$nickname = Nickname::normalize(
|
||||||
|
nickname: $data['group_nickname'],
|
||||||
|
check_already_used: true,
|
||||||
|
which: Nickname::CHECK_LOCAL_GROUP,
|
||||||
|
check_is_allowed: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$roles = ActorLocalRoles::VISITOR; // Can send direct messages to other actors
|
||||||
|
|
||||||
|
if ($data['group_scope'] === 'private') {
|
||||||
|
$roles |= ActorLocalRoles::PRIVATE_GROUP;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info(
|
||||||
|
_m(
|
||||||
|
'Actor id:{actor_id} nick:{actor_nick} created the ' . ($roles & ActorLocalRoles::PRIVATE_GROUP ? 'private' : 'public') . ' group {nickname}',
|
||||||
|
['{actor_id}' => $actor->getId(), 'actor_nick' => $actor->getNickname(), 'nickname' => $nickname],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
DB::persist($group = E\Actor::create([
|
||||||
|
'nickname' => $nickname,
|
||||||
|
'type' => E\Actor::GROUP,
|
||||||
|
'is_local' => true,
|
||||||
|
'roles' => $roles,
|
||||||
|
]));
|
||||||
|
DB::persist(LocalGroup::create([
|
||||||
|
'actor_id' => $group->getId(),
|
||||||
|
'type' => $data['group_type'],
|
||||||
|
'nickname' => $nickname,
|
||||||
|
]));
|
||||||
|
DB::persist(ActorSubscription::create([
|
||||||
|
'subscriber_id' => $group->getId(),
|
||||||
|
'subscribed_id' => $group->getId(),
|
||||||
|
]));
|
||||||
|
DB::persist(GroupMember::create([
|
||||||
|
'group_id' => $group->getId(),
|
||||||
|
'actor_id' => $actor->getId(),
|
||||||
|
// Group Owner
|
||||||
|
'roles' => ActorLocalRoles::OPERATOR | ActorLocalRoles::MODERATOR | ActorLocalRoles::PARTICIPANT | ActorLocalRoles::VISITOR,
|
||||||
|
]));
|
||||||
|
DB::flush();
|
||||||
|
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribers']);
|
||||||
|
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
|
||||||
|
|
||||||
|
throw new RedirectException();
|
||||||
|
}
|
||||||
|
return $create_form;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
140
components/Group/Controller/GroupFeed.php
Normal file
140
components/Group/Controller/GroupFeed.php
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
// {{{ License
|
||||||
|
|
||||||
|
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||||
|
//
|
||||||
|
// GNU social is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// GNU social is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// }}}
|
||||||
|
|
||||||
|
namespace Component\Group\Controller;
|
||||||
|
|
||||||
|
use App\Core\Cache;
|
||||||
|
use App\Core\DB;
|
||||||
|
use App\Core\Form;
|
||||||
|
use function App\Core\I18n\_m;
|
||||||
|
use App\Core\Router;
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity as E;
|
||||||
|
use App\Util\Common;
|
||||||
|
use App\Util\Exception\ClientException;
|
||||||
|
use App\Util\Exception\ServerException;
|
||||||
|
use App\Util\HTML\Heading;
|
||||||
|
use Component\Collection\Util\Controller\FeedController;
|
||||||
|
use Component\Group\Entity\LocalGroup;
|
||||||
|
use Component\Subscription\Entity\ActorSubscription;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends FeedController<\App\Entity\Note>
|
||||||
|
*/
|
||||||
|
class GroupFeed extends FeedController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
|
public function groupView(Request $request, Actor $group): array
|
||||||
|
{
|
||||||
|
$actor = Common::actor();
|
||||||
|
$subscribe_form = null;
|
||||||
|
|
||||||
|
if (!\is_null($actor)
|
||||||
|
&& \is_null(Cache::get(
|
||||||
|
ActorSubscription::cacheKeys($actor, $group)['subscribed'],
|
||||||
|
fn () => DB::findOneBy('actor_subscription', [
|
||||||
|
'subscriber_id' => $actor->getId(),
|
||||||
|
'subscribed_id' => $group->getId(),
|
||||||
|
], return_null: true),
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
$subscribe_form = Form::create([['subscribe', SubmitType::class, ['label' => _m('Subscribe to this group')]]]);
|
||||||
|
$subscribe_form->handleRequest($request);
|
||||||
|
if ($subscribe_form->isSubmitted() && $subscribe_form->isValid()) {
|
||||||
|
DB::persist(ActorSubscription::create([
|
||||||
|
'subscriber_id' => $actor->getId(),
|
||||||
|
'subscribed_id' => $group->getId(),
|
||||||
|
]));
|
||||||
|
DB::flush();
|
||||||
|
Cache::delete(E\Actor::cacheKeys($group->getId())['subscribers']);
|
||||||
|
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
|
||||||
|
Cache::delete(ActorSubscription::cacheKeys($actor, $group)['subscribed']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$notes = DB::dql(<<<'EOF'
|
||||||
|
SELECT n FROM \App\Entity\Note AS n
|
||||||
|
WHERE n.id IN (
|
||||||
|
SELECT act.object_id FROM \App\Entity\Activity AS act
|
||||||
|
WHERE act.object_type = 'note' AND act.id IN
|
||||||
|
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :id)
|
||||||
|
)
|
||||||
|
ORDER BY n.created DESC
|
||||||
|
EOF, ['id' => $group->getId()]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'_template' => 'group/view.html.twig',
|
||||||
|
'actor' => $group,
|
||||||
|
'nickname' => $group->getNickname(),
|
||||||
|
'notes' => $notes,
|
||||||
|
'notes_feed_title' => (new Heading(1, [], $group->getNickname() . '\'s feed')),
|
||||||
|
'subscribe_form' => $subscribe_form?->createView(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ClientException
|
||||||
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
|
public function groupViewId(Request $request, int $id): array
|
||||||
|
{
|
||||||
|
$group = Actor::getById($id);
|
||||||
|
if (\is_null($group) || !$group->isGroup()) {
|
||||||
|
throw new ClientException(_m('No such group.'), 404);
|
||||||
|
}
|
||||||
|
if ($group->getIsLocal()) {
|
||||||
|
return [
|
||||||
|
'_redirect' => Router::url('group_actor_view_nickname', ['nickname' => $group->getNickname()]),
|
||||||
|
'actor' => $group,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $this->groupView($request, $group);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View a group feed by its nickname
|
||||||
|
*
|
||||||
|
* @param string $nickname The group's nickname to be shown
|
||||||
|
*
|
||||||
|
* @throws ClientException
|
||||||
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
|
public function groupViewNickname(Request $request, string $nickname): array
|
||||||
|
{
|
||||||
|
$group = LocalGroup::getActorByNickname($nickname);
|
||||||
|
if (\is_null($group)) {
|
||||||
|
throw new ClientException(_m('No such group.'), 404);
|
||||||
|
}
|
||||||
|
return $this->groupView($request, $group);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,100 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types = 1);
|
|
||||||
|
|
||||||
// {{{ License
|
|
||||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
|
||||||
//
|
|
||||||
// GNU social is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// GNU social is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
// }}}
|
|
||||||
|
|
||||||
namespace Component\Group\Entity;
|
|
||||||
|
|
||||||
use App\Core\Entity;
|
|
||||||
use DateTimeInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity for Group Alias
|
|
||||||
*
|
|
||||||
* @category DB
|
|
||||||
* @package GNUsocial
|
|
||||||
*
|
|
||||||
* @author Zach Copley <zach@status.net>
|
|
||||||
* @copyright 2010 StatusNet Inc.
|
|
||||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
|
||||||
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
|
|
||||||
* @author Hugo Sales <hugo@hsal.es>
|
|
||||||
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
|
|
||||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
|
||||||
*/
|
|
||||||
class GroupAlias extends Entity
|
|
||||||
{
|
|
||||||
// {{{ Autocode
|
|
||||||
// @codeCoverageIgnoreStart
|
|
||||||
private string $alias;
|
|
||||||
private int $group_id;
|
|
||||||
private DateTimeInterface $modified;
|
|
||||||
|
|
||||||
public function setAlias(string $alias): self
|
|
||||||
{
|
|
||||||
$this->alias = mb_substr($alias, 0, 64);
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAlias(): string
|
|
||||||
{
|
|
||||||
return $this->alias;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setGroupId(int $group_id): self
|
|
||||||
{
|
|
||||||
$this->group_id = $group_id;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getGroupId(): int
|
|
||||||
{
|
|
||||||
return $this->group_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setModified(DateTimeInterface $modified): self
|
|
||||||
{
|
|
||||||
$this->modified = $modified;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getModified(): DateTimeInterface
|
|
||||||
{
|
|
||||||
return $this->modified;
|
|
||||||
}
|
|
||||||
|
|
||||||
// @codeCoverageIgnoreEnd
|
|
||||||
// }}} Autocode
|
|
||||||
|
|
||||||
public static function schemaDef(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'name' => 'group_alias',
|
|
||||||
'fields' => [
|
|
||||||
'alias' => ['type' => 'varchar', 'length' => 64, 'not null' => true, 'description' => 'additional nickname for the group'],
|
|
||||||
'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'group id which this is an alias of'],
|
|
||||||
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
|
||||||
],
|
|
||||||
'primary key' => ['alias'],
|
|
||||||
'indexes' => [
|
|
||||||
'group_alias_group_id_idx' => ['group_id'],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,110 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types = 1);
|
|
||||||
|
|
||||||
// {{{ License
|
|
||||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
|
||||||
//
|
|
||||||
// GNU social is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// GNU social is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
// }}}
|
|
||||||
|
|
||||||
namespace Component\Group\Entity;
|
|
||||||
|
|
||||||
use App\Core\Entity;
|
|
||||||
use DateTimeInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity for Group Block
|
|
||||||
*
|
|
||||||
* @category DB
|
|
||||||
* @package GNUsocial
|
|
||||||
*
|
|
||||||
* @author Zach Copley <zach@status.net>
|
|
||||||
* @copyright 2010 StatusNet Inc.
|
|
||||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
|
||||||
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
|
|
||||||
* @author Hugo Sales <hugo@hsal.es>
|
|
||||||
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
|
|
||||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
|
||||||
*/
|
|
||||||
class GroupBlock extends Entity
|
|
||||||
{
|
|
||||||
// {{{ Autocode
|
|
||||||
// @codeCoverageIgnoreStart
|
|
||||||
private int $group_id;
|
|
||||||
private int $blocked_actor;
|
|
||||||
private int $blocker_user;
|
|
||||||
private DateTimeInterface $modified;
|
|
||||||
|
|
||||||
public function setGroupId(int $group_id): self
|
|
||||||
{
|
|
||||||
$this->group_id = $group_id;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getGroupId(): int
|
|
||||||
{
|
|
||||||
return $this->group_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setBlockedActor(int $blocked_actor): self
|
|
||||||
{
|
|
||||||
$this->blocked_actor = $blocked_actor;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getBlockedActor(): int
|
|
||||||
{
|
|
||||||
return $this->blocked_actor;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setBlockerUser(int $blocker_user): self
|
|
||||||
{
|
|
||||||
$this->blocker_user = $blocker_user;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getBlockerUser(): int
|
|
||||||
{
|
|
||||||
return $this->blocker_user;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setModified(DateTimeInterface $modified): self
|
|
||||||
{
|
|
||||||
$this->modified = $modified;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getModified(): DateTimeInterface
|
|
||||||
{
|
|
||||||
return $this->modified;
|
|
||||||
}
|
|
||||||
|
|
||||||
// @codeCoverageIgnoreEnd
|
|
||||||
// }}} Autocode
|
|
||||||
|
|
||||||
public static function schemaDef(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'name' => 'group_block',
|
|
||||||
'fields' => [
|
|
||||||
'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'group actor is blocked from'],
|
|
||||||
'blocked_actor' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'actor that is blocked'],
|
|
||||||
'blocker_user' => ['type' => 'int', 'foreign key' => true, 'target' => 'LocalUser.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'user making the block'],
|
|
||||||
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
|
||||||
],
|
|
||||||
'primary key' => ['group_id', 'blocked_actor'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,103 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types = 1);
|
|
||||||
|
|
||||||
// {{{ License
|
|
||||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
|
||||||
//
|
|
||||||
// GNU social is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// GNU social is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
// }}}
|
|
||||||
|
|
||||||
namespace Component\Group\Entity;
|
|
||||||
|
|
||||||
use App\Core\Entity;
|
|
||||||
use DateTimeInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity for Group's inbox
|
|
||||||
*
|
|
||||||
* @category DB
|
|
||||||
* @package GNUsocial
|
|
||||||
*
|
|
||||||
* @author Zach Copley <zach@status.net>
|
|
||||||
* @copyright 2010 StatusNet Inc.
|
|
||||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
|
||||||
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
|
|
||||||
* @author Hugo Sales <hugo@hsal.es>
|
|
||||||
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
|
|
||||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
|
||||||
*/
|
|
||||||
class GroupInbox extends Entity
|
|
||||||
{
|
|
||||||
// {{{ Autocode
|
|
||||||
// @codeCoverageIgnoreStart
|
|
||||||
private int $group_id;
|
|
||||||
private int $activity_id;
|
|
||||||
private DateTimeInterface $created;
|
|
||||||
|
|
||||||
public function setGroupId(int $group_id): self
|
|
||||||
{
|
|
||||||
$this->group_id = $group_id;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getGroupId(): int
|
|
||||||
{
|
|
||||||
return $this->group_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setActivityId(int $activity_id): self
|
|
||||||
{
|
|
||||||
$this->activity_id = $activity_id;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getActivityId(): int
|
|
||||||
{
|
|
||||||
return $this->activity_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setCreated(DateTimeInterface $created): self
|
|
||||||
{
|
|
||||||
$this->created = $created;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCreated(): DateTimeInterface
|
|
||||||
{
|
|
||||||
return $this->created;
|
|
||||||
}
|
|
||||||
|
|
||||||
// @codeCoverageIgnoreEnd
|
|
||||||
// }}} Autocode
|
|
||||||
|
|
||||||
public static function schemaDef(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'name' => 'group_inbox',
|
|
||||||
'description' => 'Many-many table listing activities posted to a given group, or which groups a given activity was posted to',
|
|
||||||
'fields' => [
|
|
||||||
'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'one to one', 'name' => 'group_inbox_group_id_fkey', 'not null' => true, 'description' => 'group receiving the activity'],
|
|
||||||
'activity_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Activity.id', 'multiplicity' => 'many to one', 'name' => 'group_inbox_activity_id_fkey', 'not null' => true, 'description' => 'activity received'],
|
|
||||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
|
||||||
],
|
|
||||||
'primary key' => ['group_id', 'activity_id'],
|
|
||||||
'indexes' => [
|
|
||||||
'group_inbox_activity_id_idx' => ['activity_id'],
|
|
||||||
'group_inbox_group_id_created_activity_id_idx' => ['group_id', 'created', 'activity_id'],
|
|
||||||
'group_inbox_created_idx' => ['created'],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -76,7 +76,7 @@ class GroupJoinQueue extends Entity
|
||||||
'description' => 'Holder for group join requests awaiting moderation.',
|
'description' => 'Holder for group join requests awaiting moderation.',
|
||||||
'fields' => [
|
'fields' => [
|
||||||
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'group_join_queue_actor_id_fkey', 'not null' => true, 'description' => 'remote or local actor making the request'],
|
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'group_join_queue_actor_id_fkey', 'not null' => true, 'description' => 'remote or local actor making the request'],
|
||||||
'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'many to one', 'name' => 'group_join_queue_group_id_fkey', 'not null' => true, 'description' => 'remote or local group to join, if any'],
|
'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'many to one', 'name' => 'group_join_queue_group_id_fkey', 'not null' => true, 'description' => 'remote or local group to join, if any'],
|
||||||
],
|
],
|
||||||
'primary key' => ['actor_id', 'group_id'],
|
'primary key' => ['actor_id', 'group_id'],
|
||||||
'indexes' => [
|
'indexes' => [
|
||||||
|
|
|
@ -21,6 +21,7 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Group\Entity;
|
namespace Component\Group\Entity;
|
||||||
|
|
||||||
|
use App\Core\ActorLocalRoles;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
|
|
||||||
|
@ -44,8 +45,7 @@ class GroupMember extends Entity
|
||||||
// @codeCoverageIgnoreStart
|
// @codeCoverageIgnoreStart
|
||||||
private int $group_id;
|
private int $group_id;
|
||||||
private int $actor_id;
|
private int $actor_id;
|
||||||
private ?bool $is_admin = false;
|
private int $roles = ActorLocalRoles::VISITOR;
|
||||||
private ?string $uri = null;
|
|
||||||
private DateTimeInterface $created;
|
private DateTimeInterface $created;
|
||||||
private DateTimeInterface $modified;
|
private DateTimeInterface $modified;
|
||||||
|
|
||||||
|
@ -71,26 +71,15 @@ class GroupMember extends Entity
|
||||||
return $this->actor_id;
|
return $this->actor_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setIsAdmin(?bool $is_admin): self
|
public function setRoles(int $roles): self
|
||||||
{
|
{
|
||||||
$this->is_admin = $is_admin;
|
$this->roles = $roles;
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getIsAdmin(): ?bool
|
public function getRoles(): int
|
||||||
{
|
{
|
||||||
return $this->is_admin;
|
return $this->roles;
|
||||||
}
|
|
||||||
|
|
||||||
public function setUri(?string $uri): self
|
|
||||||
{
|
|
||||||
$this->uri = \is_null($uri) ? null : mb_substr($uri, 0, 191);
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUri(): ?string
|
|
||||||
{
|
|
||||||
return $this->uri;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setCreated(DateTimeInterface $created): self
|
public function setCreated(DateTimeInterface $created): self
|
||||||
|
@ -123,18 +112,14 @@ class GroupMember extends Entity
|
||||||
return [
|
return [
|
||||||
'name' => 'group_member',
|
'name' => 'group_member',
|
||||||
'fields' => [
|
'fields' => [
|
||||||
'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'group_member_group_id_fkey', 'not null' => true, 'description' => 'foreign key to group table'],
|
'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'group_member_group_id_fkey', 'not null' => true, 'description' => 'foreign key to group table'],
|
||||||
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'group_member_actor_id_fkey', 'not null' => true, 'description' => 'foreign key to actor table'],
|
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'group_member_actor_id_fkey', 'not null' => true, 'description' => 'foreign key to actor table'],
|
||||||
'is_admin' => ['type' => 'bool', 'default' => false, 'description' => 'is this actor an admin?'],
|
'roles' => ['type' => 'int', 'not null' => true, 'default' => ActorLocalRoles::VISITOR, 'description' => 'Bitmap of permissions this actor has'],
|
||||||
'uri' => ['type' => 'varchar', 'length' => 191, 'description' => 'universal identifier'],
|
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
||||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
||||||
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
|
||||||
],
|
],
|
||||||
'primary key' => ['group_id', 'actor_id'],
|
'primary key' => ['group_id', 'actor_id'],
|
||||||
'unique keys' => [
|
'indexes' => [
|
||||||
'group_member_uri_key' => ['uri'],
|
|
||||||
],
|
|
||||||
'indexes' => [
|
|
||||||
'group_member_actor_id_idx' => ['actor_id'],
|
'group_member_actor_id_idx' => ['actor_id'],
|
||||||
'group_member_created_idx' => ['created'],
|
'group_member_created_idx' => ['created'],
|
||||||
'group_member_actor_id_created_idx' => ['actor_id', 'created'],
|
'group_member_actor_id_created_idx' => ['actor_id', 'created'],
|
||||||
|
|
|
@ -21,10 +21,8 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Group\Entity;
|
namespace Component\Group\Entity;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\DB;
|
||||||
use App\Core\DB\DB;
|
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
|
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Util\Exception\NicknameEmptyException;
|
use App\Util\Exception\NicknameEmptyException;
|
||||||
use App\Util\Exception\NicknameException;
|
use App\Util\Exception\NicknameException;
|
||||||
|
@ -53,33 +51,45 @@ class LocalGroup extends Entity
|
||||||
{
|
{
|
||||||
// {{{ Autocode
|
// {{{ Autocode
|
||||||
// @codeCoverageIgnoreStart
|
// @codeCoverageIgnoreStart
|
||||||
private int $group_id;
|
private int $actor_id;
|
||||||
private ?string $nickname = null;
|
private string $nickname;
|
||||||
|
private string $type = 'group';
|
||||||
private DateTimeInterface $created;
|
private DateTimeInterface $created;
|
||||||
private DateTimeInterface $modified;
|
private DateTimeInterface $modified;
|
||||||
|
|
||||||
public function setGroupId(int $group_id): self
|
public function setActorId(int $actor_id): self
|
||||||
{
|
{
|
||||||
$this->group_id = $group_id;
|
$this->actor_id = $actor_id;
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getGroupId(): int
|
public function getActorId(): int
|
||||||
{
|
{
|
||||||
return $this->group_id;
|
return $this->actor_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setNickname(?string $nickname): self
|
public function setNickname(string $nickname): self
|
||||||
{
|
{
|
||||||
$this->nickname = \is_null($nickname) ? null : mb_substr($nickname, 0, 64);
|
$this->nickname = mb_substr($nickname, 0, 64);
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getNickname(): ?string
|
public function getNickname(): string
|
||||||
{
|
{
|
||||||
return $this->nickname;
|
return $this->nickname;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setType(string $type): self
|
||||||
|
{
|
||||||
|
$this->type = mb_substr($type, 0, 64);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getType(): string
|
||||||
|
{
|
||||||
|
return $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
public function setCreated(DateTimeInterface $created): self
|
public function setCreated(DateTimeInterface $created): self
|
||||||
{
|
{
|
||||||
$this->created = $created;
|
$this->created = $created;
|
||||||
|
@ -107,19 +117,17 @@ class LocalGroup extends Entity
|
||||||
|
|
||||||
public function getActor()
|
public function getActor()
|
||||||
{
|
{
|
||||||
return DB::find('actor', ['id' => $this->group_id]);
|
return DB::findOneBy(Actor::class, ['id' => $this->actor_id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getByNickname(string $nickname): ?self
|
public static function getByNickname(string $nickname): ?self
|
||||||
{
|
{
|
||||||
$res = DB::findBy(self::class, ['nickname' => $nickname]);
|
return DB::findOneBy(self::class, ['nickname' => $nickname]);
|
||||||
return $res === [] ? null : $res[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getActorByNickname(string $nickname): ?Actor
|
public static function getActorByNickname(string $nickname): ?Actor
|
||||||
{
|
{
|
||||||
$res = DB::findBy(Actor::class, ['nickname' => $nickname, 'type' => Actor::GROUP]);
|
return DB::findOneBy(Actor::class, ['nickname' => $nickname, 'type' => Actor::GROUP]);
|
||||||
return $res === [] ? null : $res[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -151,12 +159,13 @@ class LocalGroup extends Entity
|
||||||
'name' => 'local_group',
|
'name' => 'local_group',
|
||||||
'description' => 'Record for a user group on the local site, with some additional info not in user_group',
|
'description' => 'Record for a user group on the local site, with some additional info not in user_group',
|
||||||
'fields' => [
|
'fields' => [
|
||||||
'group_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'one to one', 'name' => 'local_group_group_id_fkey', 'not null' => true, 'description' => 'group represented'],
|
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'one to one', 'name' => 'local_group_group_id_fkey', 'not null' => true, 'description' => 'group represented'],
|
||||||
'nickname' => ['type' => 'varchar', 'length' => 64, 'description' => 'group represented'],
|
'nickname' => ['type' => 'varchar', 'not null' => true, 'length' => 64, 'description' => 'group represented'],
|
||||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
'type' => ['type' => 'varchar', 'not null' => true, 'default' => 'group', 'length' => 64, 'description' => 'Group or Organisation'],
|
||||||
'modified' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
||||||
|
'modified' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
||||||
],
|
],
|
||||||
'primary key' => ['group_id'],
|
'primary key' => ['actor_id'],
|
||||||
'unique keys' => [
|
'unique keys' => [
|
||||||
'local_group_nickname_key' => ['nickname'],
|
'local_group_nickname_key' => ['nickname'],
|
||||||
],
|
],
|
||||||
|
|
|
@ -24,52 +24,67 @@ namespace Component\Group;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Core\Router\RouteLoader;
|
use App\Core\Router;
|
||||||
use App\Core\Router\Router;
|
use App\Entity\Activity;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use App\Util\HTML;
|
use App\Util\HTML;
|
||||||
use App\Util\Nickname;
|
use App\Util\Nickname;
|
||||||
use Component\Circle\Controller\SelfTagsSettings;
|
|
||||||
use Component\Group\Controller as C;
|
use Component\Group\Controller as C;
|
||||||
use Component\Group\Entity\LocalGroup;
|
use Component\Group\Entity\LocalGroup;
|
||||||
|
use Component\Notification\Notification;
|
||||||
|
use EventResult;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
class Group extends Component
|
class Group extends Component
|
||||||
{
|
{
|
||||||
public function onAddRoute(RouteLoader $r): bool
|
public function onAddRoute(Router $r): EventResult
|
||||||
{
|
{
|
||||||
|
$r->connect(id: 'group_actor_view_id', uri_path: '/group/{id<\d+>}', target: [C\GroupFeed::class, 'groupViewId']);
|
||||||
|
$r->connect(id: 'group_actor_view_nickname', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\GroupFeed::class, 'groupViewNickname']);
|
||||||
$r->connect(id: 'group_create', uri_path: '/group/new', target: [C\Group::class, 'groupCreate']);
|
$r->connect(id: 'group_create', uri_path: '/group/new', target: [C\Group::class, 'groupCreate']);
|
||||||
$r->connect(id: 'group_actor_view_nickname', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\Group::class, 'groupViewNickname'], options: ['is_system_path' => false]);
|
$r->connect(id: 'group_actor_settings', uri_path: '/group/{id<\d+>}/settings', target: [C\Group::class, 'groupSettings']);
|
||||||
$r->connect(id: 'group_settings', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}/settings', target: [C\Group::class, 'groupSettings'], options: ['is_system_path' => false]);
|
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add an <a href=group_settings> to the profile card for groups, if the current actor can access them
|
* Enqueues a notification for an Actor (such as person or group) which means
|
||||||
|
* it shows up in their home feed and such.
|
||||||
|
*
|
||||||
|
* @param Actor[] $targets
|
||||||
*/
|
*/
|
||||||
public function onAppendCardProfile(array $vars, array &$res): bool
|
public function onNewNotificationStart(Actor $sender, Activity $activity, array $targets = [], ?string $reason = null): EventResult
|
||||||
{
|
{
|
||||||
$actor = Common::actor();
|
foreach ($targets as $target) {
|
||||||
$group = $vars['actor'];
|
if ($target->isGroup()) {
|
||||||
if (!\is_null($actor) && $group->isGroup() && $actor->canAdmin($group)) {
|
// The Group announces to its subscribers
|
||||||
$url = Router::url('group_settings', ['nickname' => $group->getNickname()]);
|
Notification::notify(
|
||||||
$res[] = HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings'), 'class' => 'profile-extra-actions'], _m('Group settings')]]);
|
sender: $target,
|
||||||
|
activity: $activity,
|
||||||
|
targets: $target->getSubscribers(),
|
||||||
|
reason: $reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs)
|
/**
|
||||||
|
* Add an <a href=group_actor_settings> to the profile card for groups, if the current actor can access them
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $vars
|
||||||
|
* @param string[] $res
|
||||||
|
*/
|
||||||
|
public function onAppendCardProfile(array $vars, array &$res): EventResult
|
||||||
{
|
{
|
||||||
if ($section === 'profile' && $request->get('_route') === 'group_settings') {
|
$actor = Common::actor();
|
||||||
$nickname = $request->get('nickname');
|
$group = $vars['actor'];
|
||||||
$group = LocalGroup::getActorByNickname($nickname);
|
if (!\is_null($actor) && $group->isGroup()) {
|
||||||
$tabs[] = [
|
if ($actor->canModerate($group)) {
|
||||||
'title' => 'Self tags',
|
$url = Router::url('group_actor_settings', ['id' => $group->getId()]);
|
||||||
'desc' => 'Add or remove tags on this group',
|
$res[] = HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings'), 'class' => 'profile-extra-actions'], _m('Group settings')]]);
|
||||||
'id' => 'settings-self-tags',
|
}
|
||||||
'controller' => SelfTagsSettings::settingsSelfTags($request, $group, 'settings-self-tags-details'),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
@ -79,27 +94,49 @@ class Group extends Component
|
||||||
*/
|
*/
|
||||||
private function getGroupFromContext(Request $request): ?Actor
|
private function getGroupFromContext(Request $request): ?Actor
|
||||||
{
|
{
|
||||||
if (str_starts_with($request->get('_route'), 'group_actor_view_')) {
|
if (\is_array($request->get('post_note')) && \array_key_exists('_next', $request->get('post_note'))) {
|
||||||
if (!\is_null($id = $request->get('id'))) {
|
$next = parse_url($request->get('post_note')['_next']);
|
||||||
return Actor::getById((int) $id);
|
$match = Router::match($next['path']);
|
||||||
} elseif (!\is_null($nickname = $request->get('nickname'))) {
|
$route = $match['_route'];
|
||||||
return LocalGroup::getActorByNickname($nickname);
|
$identifier = $match['id'] ?? $match['nickname'] ?? null;
|
||||||
|
} else {
|
||||||
|
$route = $request->get('_route');
|
||||||
|
$identifier = $request->get('id') ?? $request->get('nickname');
|
||||||
|
}
|
||||||
|
if (str_starts_with($route, 'group_actor_view_')) {
|
||||||
|
switch ($route) {
|
||||||
|
case 'group_actor_view_nickname':
|
||||||
|
return LocalGroup::getActorByNickname($identifier);
|
||||||
|
case 'group_actor_view_id':
|
||||||
|
return Actor::getById((int) $identifier);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets)
|
/**
|
||||||
|
* @param Actor[] $targets
|
||||||
|
*/
|
||||||
|
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): EventResult
|
||||||
{
|
{
|
||||||
$group = $this->getGroupFromContext($request);
|
$group = $this->getGroupFromContext($request);
|
||||||
if (!\is_null($group)) {
|
if (!\is_null($group)) {
|
||||||
$nick = '!' . $group->getNickname();
|
$nick = "!{$group->getNickname()}";
|
||||||
$targets[$nick] = $group->getId();
|
$targets[$nick] = $group->getId();
|
||||||
}
|
}
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor $context_actor)
|
/**
|
||||||
|
* Indicates the context in which Posting's form is to be presented. Passing on $context_actor to Posting's
|
||||||
|
* onAppendRightPostingBlock event, the Group a given $actor is currently browsing.
|
||||||
|
*
|
||||||
|
* Makes it possible to automagically fill in the targets (aka the Group which this $request route is connected to)
|
||||||
|
* in the Posting's form.
|
||||||
|
*
|
||||||
|
* @param null|Actor $context_actor Actor group, if current route is part of an existing Group set of routes
|
||||||
|
*/
|
||||||
|
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor): EventResult
|
||||||
{
|
{
|
||||||
$ctx = $this->getGroupFromContext($request);
|
$ctx = $this->getGroupFromContext($request);
|
||||||
if (!\is_null($ctx)) {
|
if (!\is_null($ctx)) {
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
<details class="frame-section section-details-title">
|
||||||
|
<summary class="details-summary-title">
|
||||||
|
<strong>
|
||||||
|
{% trans %}Create a group{% endtrans %}
|
||||||
|
</strong>
|
||||||
|
</summary>
|
||||||
|
<form method="POST" class="section-form">
|
||||||
|
{{ form(create_form) }}
|
||||||
|
</form>
|
||||||
|
</details>
|
|
@ -1,19 +1,13 @@
|
||||||
{% extends 'base.html.twig' %}
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
{% import 'settings/macros.html.twig' as macros %}
|
{% import 'cards/macros/settings.html.twig' as macros %}
|
||||||
|
|
||||||
{% block stylesheets %}
|
|
||||||
{{ parent() }}
|
|
||||||
<link rel="preload" href="{{ asset('assets/default_theme/css/pages/settings.css') }}" as="style" type="text/css">
|
|
||||||
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/settings.css') }}">
|
|
||||||
{% endblock stylesheets %}
|
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<nav class='section-settings'>
|
<nav class='section-settings'>
|
||||||
<h2>Settings</h2>
|
<h1>Settings</h1>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
{% set profile_tabs = [{'title': 'Personal Info', 'desc': 'Nickname, Homepage, Bio, Self Tags and more.', 'id': 'settings-personal-info', 'form': personal_info_form}] %}
|
{% set profile_tabs = [{'title': 'Personal Info', 'desc': 'Nickname, Homepage, Bio and more.', 'id': 'settings-personal-info', 'form': personal_info_form}] %}
|
||||||
{% set profile_tabs = profile_tabs|merge(handle_event('PopulateSettingsTabs', app.request, 'profile')) %}
|
{% set profile_tabs = profile_tabs|merge(handle_event('PopulateSettingsTabs', app.request, 'profile')) %}
|
||||||
{{ macros.settings_details_container('Profile', 'Personal Information, Avatar and Profile', 'settings-profile-details', profile_tabs, _context) }}
|
{{ macros.settings_details_container('Profile', 'Personal Information, Avatar and Profile', 'settings-profile-details', profile_tabs, _context) }}
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -1,65 +1,16 @@
|
||||||
{% extends 'stdgrid.html.twig' %}
|
{% extends 'collection/notes.html.twig' %}
|
||||||
{% import '/cards/note/view.html.twig' as noteView %}
|
|
||||||
|
|
||||||
{% set nickname = nickname|escape %}
|
{% set nickname = nickname|escape %}
|
||||||
{% block title %}{{ nickname }}{% endblock %}
|
{% block title %}{{ nickname }}{% endblock %}
|
||||||
|
|
||||||
{% block stylesheets %}
|
|
||||||
{{ parent() }}
|
|
||||||
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
|
|
||||||
{% endblock stylesheets %}
|
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
|
||||||
{% if actor is defined and actor is not null %}
|
{% if actor is defined and actor is not null %}
|
||||||
{% block profile_view %}
|
{% block profile_view %}
|
||||||
{% include 'cards/profile/view.html.twig' with { 'actor': actor } only %}
|
{% include 'cards/blocks/profile.html.twig' with { 'actor': actor } only %}
|
||||||
{% endblock profile_view %}
|
{% endblock profile_view %}
|
||||||
|
<hr>
|
||||||
{% if notes is defined %}
|
{% if notes is defined %}
|
||||||
<article>
|
{{ parent() }}
|
||||||
<header class="feed-header">
|
|
||||||
{% if page_title is defined %}
|
|
||||||
<h1 class="heading-no-margin">{{ page_title | trans }}</h1>
|
|
||||||
{% else %}
|
|
||||||
<h1 class="heading-no-margin">{{ 'Notes' | trans }}</h1>
|
|
||||||
{% endif %}
|
|
||||||
<nav class="feed-actions">
|
|
||||||
<details class="feed-actions-details">
|
|
||||||
<summary>
|
|
||||||
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
|
|
||||||
</summary>
|
|
||||||
<div class="feed-actions-details-dropdown">
|
|
||||||
<menu>
|
|
||||||
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
|
|
||||||
{{ block | raw }}
|
|
||||||
{% endfor %}
|
|
||||||
</menu>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% if notes is not empty %}
|
|
||||||
{# Backwards compatibility with hAtom 0.1 #}
|
|
||||||
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
|
|
||||||
{% for conversation in notes %}
|
|
||||||
{% block current_note %}
|
|
||||||
{% if conversation is instanceof('array') %}
|
|
||||||
{{ noteView.macro_note(conversation['note'], conversation['replies']) }}
|
|
||||||
{% else %}
|
|
||||||
{{ noteView.macro_note(conversation) }}
|
|
||||||
{% endif %}
|
|
||||||
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
|
|
||||||
{% endblock current_note %}
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
|
||||||
{% else %}
|
|
||||||
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
|
|
||||||
<strong>{% trans %}No notes yet...{% endtrans %}</strong>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock body %}
|
{% endblock body %}
|
||||||
|
|
|
@ -19,10 +19,12 @@ declare(strict_types = 1);
|
||||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||||
// }}}
|
// }}}
|
||||||
|
|
||||||
namespace App\Tests\Entity;
|
namespace Component\Group\tests\Entity;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
|
use App\Entity\Actor;
|
||||||
use App\Util\GNUsocialTestCase;
|
use App\Util\GNUsocialTestCase;
|
||||||
|
use Component\Group\Entity\LocalGroup;
|
||||||
use Jchook\AssertThrows\AssertThrows;
|
use Jchook\AssertThrows\AssertThrows;
|
||||||
|
|
||||||
class GroupTest extends GNUsocialTestCase
|
class GroupTest extends GNUsocialTestCase
|
||||||
|
@ -31,8 +33,8 @@ class GroupTest extends GNUsocialTestCase
|
||||||
|
|
||||||
public function testGetActor()
|
public function testGetActor()
|
||||||
{
|
{
|
||||||
$group = DB::findOneBy('local_group', ['nickname' => 'taken_group']);
|
$group = DB::findOneBy(LocalGroup::class, ['nickname' => 'taken_public_group']);
|
||||||
$actor = DB::findOneBy('actor', ['nickname' => 'taken_group']);
|
$actor = DB::findOneBy(Actor::class, ['nickname' => 'taken_public_group']);
|
||||||
static::assertSame($actor, $group->getActor());
|
static::assertObjectEquals($actor, $group->getActor());
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -25,7 +25,7 @@ namespace Component\Language\Controller;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Form;
|
use App\Core\Form;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
|
@ -100,6 +100,8 @@ class Language extends Controller
|
||||||
* @throws NoLoggedInUser
|
* @throws NoLoggedInUser
|
||||||
* @throws RedirectException
|
* @throws RedirectException
|
||||||
* @throws ServerException
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
*/
|
*/
|
||||||
public function sortLanguages(Request $request): array
|
public function sortLanguages(Request $request): array
|
||||||
{
|
{
|
||||||
|
@ -134,7 +136,7 @@ class Language extends Controller
|
||||||
// Stay on same page, but force update and prevent resubmission
|
// Stay on same page, but force update and prevent resubmission
|
||||||
throw new RedirectException('settings_sort_languages');
|
throw new RedirectException('settings_sort_languages');
|
||||||
} else {
|
} else {
|
||||||
throw new RedirectException('settings', ['open' => 'account', '_fragment' => 'save_account_info_languages']);
|
throw new RedirectException('person_actor_settings', ['id' => $user->getId(), 'open' => 'settings-language-details', '_fragment' => 'settings-language-details']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ declare(strict_types = 1);
|
||||||
namespace Component\Language\Entity;
|
namespace Component\Language\Entity;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\LocalUser;
|
use App\Entity\LocalUser;
|
||||||
|
@ -119,6 +119,9 @@ class ActorLanguage extends Entity
|
||||||
) ?: [Language::getByLocale(Common::config('site', 'language'))];
|
) ?: [Language::getByLocale(Common::config('site', 'language'))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
public static function getActorRelatedLanguagesIds(Actor $actor): array
|
public static function getActorRelatedLanguagesIds(Actor $actor): array
|
||||||
{
|
{
|
||||||
return Cache::getList(
|
return Cache::getList(
|
||||||
|
|
|
@ -24,7 +24,7 @@ declare(strict_types = 1);
|
||||||
namespace Component\Language\Entity;
|
namespace Component\Language\Entity;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
|
@ -116,7 +116,7 @@ class Language extends Entity
|
||||||
return Cache::getHashMapKey(
|
return Cache::getHashMapKey(
|
||||||
map_key: 'languages-id',
|
map_key: 'languages-id',
|
||||||
key: (string) $id,
|
key: (string) $id,
|
||||||
calculate_map: fn () => F\reindex(DB::dql('select l from language l'), fn (self $l) => (string) $l->getId()),
|
calculate_map: fn () => F\reindex(DB::dql('SELECT l FROM \Component\Language\Entity\Language AS l'), fn (self $l) => (string) $l->getId()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -125,7 +125,7 @@ class Language extends Entity
|
||||||
return Cache::getHashMapKey(
|
return Cache::getHashMapKey(
|
||||||
'languages',
|
'languages',
|
||||||
$locale,
|
$locale,
|
||||||
calculate_map: fn () => F\reindex(DB::dql('select l from language l'), fn (self $l) => $l->getLocale()),
|
calculate_map: fn () => F\reindex(DB::dql('SELECT l FROM \Component\Language\Entity\Language AS l'), fn (self $l) => $l->getLocale()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,16 +134,21 @@ class Language extends Entity
|
||||||
return self::getById($note->getLanguageId());
|
return self::getById($note->getLanguageId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
public static function getLanguageChoices(): array
|
public static function getLanguageChoices(): array
|
||||||
{
|
{
|
||||||
$langs = Cache::getHashMap(
|
$langs = Cache::getHashMap(
|
||||||
'languages',
|
'languages',
|
||||||
fn () => F\reindex(DB::dql('select l from language l'), fn (self $l) => $l->getLocale()),
|
fn () => F\reindex(DB::dql('SELECT l FROM \Component\Language\Entity\Language AS l'), fn (self $l) => $l->getLocale()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return array_merge(...F\map(array_values($langs), fn ($l) => $l->toChoiceFormat()));
|
return array_merge(...F\map(array_values($langs), fn ($l) => $l->toChoiceFormat()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string> */
|
||||||
public function toChoiceFormat(): array
|
public function toChoiceFormat(): array
|
||||||
{
|
{
|
||||||
return [_m($this->getLongDisplay()) => $this->getLocale()];
|
return [_m($this->getLongDisplay()) => $this->getLocale()];
|
||||||
|
@ -152,6 +157,8 @@ class Language extends Entity
|
||||||
/**
|
/**
|
||||||
* Get all the available languages as well as the languages $actor
|
* Get all the available languages as well as the languages $actor
|
||||||
* prefers and are appropriate for posting in/to $context_actor
|
* prefers and are appropriate for posting in/to $context_actor
|
||||||
|
*
|
||||||
|
* @return array{array<string, string>, array<string, string>}
|
||||||
*/
|
*/
|
||||||
public static function getSortedLanguageChoices(?Actor $actor, ?Actor $context_actor, ?bool $use_short_display): array
|
public static function getSortedLanguageChoices(?Actor $actor, ?Actor $context_actor, ?bool $use_short_display): array
|
||||||
{
|
{
|
||||||
|
|
|
@ -23,7 +23,7 @@ namespace Component\Language;
|
||||||
|
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Core\Router\RouteLoader;
|
use App\Core\Router;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\Note;
|
use App\Entity\Note;
|
||||||
use App\Util\Formatting;
|
use App\Util\Formatting;
|
||||||
|
@ -33,18 +33,22 @@ use Component\Language\Entity\ActorLanguage;
|
||||||
use Doctrine\Common\Collections\ExpressionBuilder;
|
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||||
use Doctrine\ORM\Query\Expr;
|
use Doctrine\ORM\Query\Expr;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use EventResult;
|
||||||
use Functional as F;
|
use Functional as F;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
class Language extends Component
|
class Language extends Component
|
||||||
{
|
{
|
||||||
public function onAddRoute(RouteLoader $r): bool
|
public function onAddRoute(Router $r): EventResult
|
||||||
{
|
{
|
||||||
$r->connect('settings_sort_languages', '/settings/sort_languages', [C\Language::class, 'sortLanguages']);
|
$r->connect('settings_sort_languages', '/settings/sort_languages', [C\Language::class, 'sortLanguages']);
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onFilterNoteList(?Actor $actor, array &$notes, Request $request): bool
|
/**
|
||||||
|
* @param Note[] $notes
|
||||||
|
*/
|
||||||
|
public function onFilterNoteList(?Actor $actor, array &$notes, Request $request): EventResult
|
||||||
{
|
{
|
||||||
if (\is_null($actor)) {
|
if (\is_null($actor)) {
|
||||||
return Event::next;
|
return Event::next;
|
||||||
|
@ -59,8 +63,11 @@ class Language extends Component
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populate $note_expr or $actor_expr with an expression to match a language
|
* Populate $note_expr or $actor_expr with an expression to match a language
|
||||||
|
*
|
||||||
|
* @param mixed $note_expr
|
||||||
|
* @param mixed $actor_expr
|
||||||
*/
|
*/
|
||||||
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
|
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult
|
||||||
{
|
{
|
||||||
$search_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
|
$search_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
|
||||||
|
|
||||||
|
@ -103,13 +110,27 @@ class Language extends Component
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): EventResult
|
||||||
{
|
{
|
||||||
$note_qb->leftJoin('Component\Language\Entity\Language', 'note_language', Expr\Join::WITH, 'note.language_id = note_language.id')
|
$note_aliases = $note_qb->getAllAliases();
|
||||||
->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'note.actor_id = actor_language.actor_id')
|
if (!\in_array('note_language', $note_aliases)) {
|
||||||
->leftJoin('Component\Language\Entity\Language', 'note_actor_language', Expr\Join::WITH, 'note_actor_language.id = actor_language.language_id');
|
$note_qb->leftJoin('Component\Language\Entity\Language', 'note_language', Expr\Join::WITH, 'note.language_id = note_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');
|
if (!\in_array('actor_language', $note_aliases)) {
|
||||||
|
$note_qb->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'note.actor_id = actor_language.actor_id');
|
||||||
|
}
|
||||||
|
if (!\in_array('note_actor_language', $note_aliases)) {
|
||||||
|
$note_qb->leftJoin('Component\Language\Entity\Language', 'note_actor_language', Expr\Join::WITH, 'note_actor_language.id = actor_language.language_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor_aliases = $actor_qb->getAllAliases();
|
||||||
|
if (!\in_array('actor_language', $actor_aliases)) {
|
||||||
|
$actor_qb->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'actor.id = actor_language.actor_id');
|
||||||
|
}
|
||||||
|
if (!\in_array('language', $actor_aliases)) {
|
||||||
|
$actor_qb->leftJoin('Component\Language\Entity\Language', 'language', Expr\Join::WITH, 'actor_language.language_id = language.id');
|
||||||
|
}
|
||||||
|
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
{% extends 'base.html.twig' %}
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="frame-section frame-section-padding">
|
<div class="frame-section frame-section-padding">
|
||||||
<h3>{{ 'Put the languages in the order you\'d like to see them in your language selection dropdown, when posting' | trans}}</h3>
|
<h3>{% trans %}Put the languages in the order you'd like to see them in your language selection dropdown, when posting{% endtrans %}</h3>
|
||||||
{{ form(form) }}
|
{{ form(form) }}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -25,10 +25,10 @@ namespace Component\LeftPanel\Controller;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Form;
|
use App\Core\Form;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
use App\Entity\Feed;
|
use App\Entity\Feed;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use App\Util\Exception\ClientException;
|
use App\Util\Exception\ClientException;
|
||||||
|
@ -104,7 +104,6 @@ class EditFeeds extends Controller
|
||||||
$feed->setUrl($fd[$md5 . '-url']);
|
$feed->setUrl($fd[$md5 . '-url']);
|
||||||
$feed->setOrdering($fd[$md5 . '-order']);
|
$feed->setOrdering($fd[$md5 . '-order']);
|
||||||
$feed->setTitle($fd[$md5 . '-title']);
|
$feed->setTitle($fd[$md5 . '-title']);
|
||||||
DB::merge($feed);
|
|
||||||
}
|
}
|
||||||
DB::flush();
|
DB::flush();
|
||||||
Cache::delete($key);
|
Cache::delete($key);
|
||||||
|
@ -119,7 +118,6 @@ class EditFeeds extends Controller
|
||||||
/** @var SubmitButton $remove_button */
|
/** @var SubmitButton $remove_button */
|
||||||
$remove_button = $form->get($remove_id);
|
$remove_button = $form->get($remove_id);
|
||||||
if ($remove_button->isClicked()) {
|
if ($remove_button->isClicked()) {
|
||||||
// @phpstan-ignore-next-line -- Doesn't quite understand that _this_ $opts for the current $form_definitions does have 'data'
|
|
||||||
DB::remove(DB::getReference('feed', ['actor_id' => $user->getId(), 'url' => $opts['data']]));
|
DB::remove(DB::getReference('feed', ['actor_id' => $user->getId(), 'url' => $opts['data']]));
|
||||||
DB::flush();
|
DB::flush();
|
||||||
Cache::delete($key);
|
Cache::delete($key);
|
||||||
|
|
|
@ -22,27 +22,34 @@ declare(strict_types = 1);
|
||||||
namespace Component\LeftPanel;
|
namespace Component\LeftPanel;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Core\Router\RouteLoader;
|
use App\Core\Router;
|
||||||
use App\Core\Router\Router;
|
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\Feed;
|
use App\Entity\Feed;
|
||||||
use App\Util\Exception\ClientException;
|
use App\Util\Exception\ClientException;
|
||||||
use App\Util\Exception\NotFoundException;
|
use App\Util\Exception\NotFoundException;
|
||||||
use Component\LeftPanel\Controller as C;
|
use Component\LeftPanel\Controller as C;
|
||||||
|
use EventResult;
|
||||||
|
|
||||||
class LeftPanel extends Component
|
class LeftPanel extends Component
|
||||||
{
|
{
|
||||||
public function onAddRoute(RouteLoader $r): bool
|
public function onAddRoute(Router $r): EventResult
|
||||||
{
|
{
|
||||||
$r->connect('edit_feeds', '/edit-feeds', C\EditFeeds::class);
|
$r->connect('edit_feeds', '/edit-feeds', C\EditFeeds::class);
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onAppendFeed(Actor $actor, string $title, string $route, array $route_params)
|
/**
|
||||||
|
* @param array<string, string> $route_params
|
||||||
|
*
|
||||||
|
* @throws \App\Util\Exception\DuplicateFoundException
|
||||||
|
* @throws \App\Util\Exception\ServerException
|
||||||
|
* @throws ClientException
|
||||||
|
*/
|
||||||
|
public function onAppendFeed(Actor $actor, string $title, string $route, array $route_params): EventResult
|
||||||
{
|
{
|
||||||
$cache_key = Feed::cacheKey($actor);
|
$cache_key = Feed::cacheKey($actor);
|
||||||
$feeds = Feed::getFeeds($actor);
|
$feeds = Feed::getFeeds($actor);
|
||||||
|
@ -67,17 +74,4 @@ class LeftPanel extends Component
|
||||||
return Event::stop;
|
return Event::stop;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Output our dedicated stylesheet
|
|
||||||
*
|
|
||||||
* @param array $styles stylesheets path
|
|
||||||
*
|
|
||||||
* @return bool hook value; true means continue processing, false means stop
|
|
||||||
*/
|
|
||||||
public function onEndShowStyles(array &$styles, string $route): bool
|
|
||||||
{
|
|
||||||
$styles[] = 'components/LeftPanel/assets/css/view.css';
|
|
||||||
return Event::next;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
{% block stylesheets %}
|
{% block stylesheets %}
|
||||||
{{ parent() }}
|
{{ parent() }}
|
||||||
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
|
<link rel="stylesheet" href="{{ asset('assets/default_theme/pages/feeds.css') }}" type="text/css">
|
||||||
{% endblock stylesheets %}
|
{% endblock stylesheets %}
|
||||||
|
|
||||||
{% macro edit_feeds_form_row(child) %}
|
{% macro edit_feeds_form_row(child) %}
|
||||||
|
@ -15,7 +15,7 @@
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="frame-section">
|
<div class="frame-section">
|
||||||
<form class="section-form" action="{{ path('edit_feeds') }}" method="post">
|
<form class="section-form" action="{{ path('edit_feeds') }}" method="post">
|
||||||
<h1 class="frame-section-title">{{ "Edit feed navigation links" | trans }}</h1>
|
<h1 class="frame-section-title">{% trans %}Edit feed navigation links{% endtrans %}</h1>
|
||||||
{# Since the form is not separated into individual groups, this happened #}
|
{# Since the form is not separated into individual groups, this happened #}
|
||||||
{{ form_start(edit_feeds) }}
|
{{ form_start(edit_feeds) }}
|
||||||
{{ form_errors(edit_feeds) }}
|
{{ form_errors(edit_feeds) }}
|
||||||
|
|
|
@ -1,24 +1,24 @@
|
||||||
{% block leftpanel %}
|
{% block leftpanel %}
|
||||||
<label class="panel-left-icon" for="toggle-panel-left" tabindex="-1">{{ icon('menu', 'icon icon-left') | raw }}</label>
|
<label class="panel-left-icon" for="toggle-panel-left" tabindex="-1">{{ icon('menu', 'icon icon-left') | raw }}</label>
|
||||||
<a id="anchor-left-panel" class="anchor-hidden" tabindex="0" title="{{ 'Press tab followed by a space to access left panel' | trans }}"></a>
|
<a id="anchor-left-panel" class="anchor-hidden" tabindex="0" title="{% trans %}Press tab followed by a space to access left panel{% endtrans %}"></a>
|
||||||
<input type="checkbox" id="toggle-panel-left" tabindex="0" title="{{ 'Open left panel' | trans }}">
|
<input type="checkbox" id="toggle-panel-left" tabindex="0" title="{% trans %}Open left panel{% endtrans %}">
|
||||||
|
|
||||||
<aside class="section-panel section-panel-left">
|
<aside class="section-panel section-panel-left">
|
||||||
<section class="panel-content accessibility-target">
|
<section class="panel-content accessibility-target">
|
||||||
{% if app.user %}
|
{% if app.user %}
|
||||||
<section class='frame-section frame-section-padding' title="{{ 'Your profile information.' | trans }}">
|
<section class='frame-section frame-section-padding' title="{% trans %}Your profile information{% endtrans %}">
|
||||||
{% block profile_view %}{% include 'cards/profile/view.html.twig' with { actor: current_actor } %}{% endblock profile_view %}
|
{% block profile_view %}{% include 'cards/blocks/profile.html.twig' with { actor: current_actor } %}{% endblock profile_view %}
|
||||||
{{ block("profile_current_actor", "cards/navigation/view.html.twig") }}
|
{{ block("profile_current_actor", "cards/blocks/navigation.html.twig") }}
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section>
|
<section>
|
||||||
{{ block("profile_security", "cards/navigation/view.html.twig") }}
|
{{ block("profile_security", "cards/blocks/navigation.html.twig") }}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ block("feeds", "cards/navigation/view.html.twig") }}
|
{{ block("feeds", "cards/blocks/navigation.html.twig") }}
|
||||||
|
|
||||||
{{ block("footer", "cards/navigation/view.html.twig") }}
|
{{ block("footer", "cards/blocks/navigation.html.twig") }}
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
{% endblock leftpanel %}
|
{% endblock leftpanel %}
|
||||||
|
|
|
@ -21,7 +21,7 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Link\Entity;
|
namespace Component\Link\Entity;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\GSFile;
|
use App\Core\GSFile;
|
||||||
|
@ -126,7 +126,7 @@ class Link extends Entity
|
||||||
{
|
{
|
||||||
if (Common::isValidHttpUrl($url)) {
|
if (Common::isValidHttpUrl($url)) {
|
||||||
// If the URL is a local one, do not create a Link to it
|
// If the URL is a local one, do not create a Link to it
|
||||||
if (parse_url($url, \PHP_URL_HOST) === $_ENV['SOCIAL_DOMAIN']) {
|
if (parse_url($url, \PHP_URL_HOST) === Common::config('site', 'server')) {
|
||||||
Log::warning("It was attempted to create a Link to a local location {$url}.");
|
Log::warning("It was attempted to create a Link to a local location {$url}.");
|
||||||
// Forbidden
|
// Forbidden
|
||||||
throw new InvalidArgumentException(message: "A Link can't point to a local location ({$url}), it must be a remote one", code: 400);
|
throw new InvalidArgumentException(message: "A Link can't point to a local location ({$url}), it must be a remote one", code: 400);
|
||||||
|
|
|
@ -21,7 +21,7 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Link\Entity;
|
namespace Component\Link\Entity;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
|
@ -85,15 +85,15 @@ class NoteToLink extends Entity
|
||||||
* properties of $obj with the associative array $args. Doesn't
|
* properties of $obj with the associative array $args. Doesn't
|
||||||
* persist the result
|
* persist the result
|
||||||
*
|
*
|
||||||
* @param null|mixed $obj
|
* @param (array{link_id: int, note_id: int} & array<string, mixed>) $args
|
||||||
*/
|
*/
|
||||||
public static function create(array $args, $obj = null)
|
public static function create(array $args, bool $_delegated_call = false): static
|
||||||
{
|
{
|
||||||
$link = DB::find('link', ['id' => $args['link_id']]);
|
$link = DB::find('link', ['id' => $args['link_id']]);
|
||||||
$note = DB::find('note', ['id' => $args['note_id']]);
|
$note = DB::find('note', ['id' => $args['note_id']]);
|
||||||
Event::handle('NewLinkFromNote', [$link, $note]);
|
Event::handle('NewLinkFromNote', [$link, $note]);
|
||||||
$obj = new self();
|
$obj = new self();
|
||||||
return parent::create($args, $obj);
|
return parent::createOrUpdate(obj: $obj, args: $args);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function removeWhereNoteId(int $note_id): mixed
|
public static function removeWhereNoteId(int $note_id): mixed
|
||||||
|
|
|
@ -23,7 +23,7 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Link;
|
namespace Component\Link;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
|
@ -31,33 +31,50 @@ use App\Entity\Note;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use App\Util\HTML;
|
use App\Util\HTML;
|
||||||
use Component\Link\Entity\NoteToLink;
|
use Component\Link\Entity\NoteToLink;
|
||||||
|
use EventResult;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
class Link extends Component
|
class Link extends Component
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Extract URLs from $content and create the appropriate Link and NoteToLink entities
|
* Note that this persists both a Link and a NoteToLink
|
||||||
|
*
|
||||||
|
* @return array{ link: ?Entity\Link, note_to_link: ?NoteToLink }
|
||||||
*/
|
*/
|
||||||
public function onProcessNoteContent(Note $note, string $content): bool
|
public static function maybeCreateLink(string $url, int $note_id): array
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
|
$link = Entity\Link::getOrCreate($url);
|
||||||
|
DB::persist($note_link = NoteToLink::create(['link_id' => $link->getId(), 'note_id' => $note_id]));
|
||||||
|
return ['link' => $link, 'note_to_link' => $note_link];
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return ['link' => null, 'note_to_link' => null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract URLs from $content and create the appropriate Link and NoteToLink entities
|
||||||
|
*
|
||||||
|
* @param array{ignoreLinks?: string[]} $process_note_content_extra_args
|
||||||
|
*/
|
||||||
|
public function onProcessNoteContent(Note $note, string $content, string $content_type, array $process_note_content_extra_args = []): EventResult
|
||||||
|
{
|
||||||
|
$ignore = $process_note_content_extra_args['ignoreLinks'] ?? [];
|
||||||
if (Common::config('attachments', 'process_links')) {
|
if (Common::config('attachments', 'process_links')) {
|
||||||
$matched_urls = [];
|
$matched_urls = [];
|
||||||
// TODO: This solution to ignore mentions when content is in html is far from ideal
|
preg_match_all($this->getURLRegex(), $content, $matched_urls);
|
||||||
preg_match_all($this->getURLRegex(), preg_replace('#<a href="(.*?)" class="u-url mention">#', '', $content), $matched_urls);
|
|
||||||
$matched_urls = array_unique($matched_urls[1]);
|
$matched_urls = array_unique($matched_urls[1]);
|
||||||
foreach ($matched_urls as $match) {
|
foreach ($matched_urls as $match) {
|
||||||
try {
|
if (\in_array($match, $ignore)) {
|
||||||
$link_id = Entity\Link::getOrCreate($match)->getId();
|
|
||||||
DB::persist(NoteToLink::create(['link_id' => $link_id, 'note_id' => $note->getId()]));
|
|
||||||
} catch (InvalidArgumentException) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
self::maybeCreateLink($match, $note->getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onRenderPlainTextNoteContent(string &$text): bool
|
public function onRenderPlainTextNoteContent(string &$text): EventResult
|
||||||
{
|
{
|
||||||
$text = $this->replaceURLs($text);
|
$text = $this->replaceURLs($text);
|
||||||
return Event::next;
|
return Event::next;
|
||||||
|
@ -135,7 +152,12 @@ class Link extends Component
|
||||||
public const URL_SCHEME_NO_DOMAIN = 4;
|
public const URL_SCHEME_NO_DOMAIN = 4;
|
||||||
public const URL_SCHEME_COLON_COORDINATES = 8;
|
public const URL_SCHEME_COLON_COORDINATES = 8;
|
||||||
|
|
||||||
public function URLSchemes($filter = null)
|
/**
|
||||||
|
* @param self::URL_SCHEME_COLON_COORDINATES|self::URL_SCHEME_COLON_DOUBLE_SLASH|self::URL_SCHEME_NO_DOMAIN|self::URL_SCHEME_SINGLE_COLON $filter
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function URLSchemes(?int $filter = null): array
|
||||||
{
|
{
|
||||||
// TODO: move these to config
|
// TODO: move these to config
|
||||||
$schemes = [
|
$schemes = [
|
||||||
|
@ -182,6 +204,7 @@ class Link extends Component
|
||||||
* Intermediate callback for `replaceURLs()`, which helps resolve some
|
* Intermediate callback for `replaceURLs()`, which helps resolve some
|
||||||
* ambiguous link forms before passing on to the final callback.
|
* ambiguous link forms before passing on to the final callback.
|
||||||
*
|
*
|
||||||
|
* @param string[] $matches
|
||||||
* @param callable(string $text): string $callback: return replacement text
|
* @param callable(string $text): string $callback: return replacement text
|
||||||
*/
|
*/
|
||||||
private function callbackHelper(array $matches, callable $callback): string
|
private function callbackHelper(array $matches, callable $callback): string
|
||||||
|
@ -261,7 +284,7 @@ class Link extends Component
|
||||||
return HTML::html(['a' => ['attrs' => $attrs, $url]], options: ['indent' => false]);
|
return HTML::html(['a' => ['attrs' => $attrs, $url]], options: ['indent' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
|
public function onNoteDeleteRelated(Note &$note, Actor $actor): EventResult
|
||||||
{
|
{
|
||||||
DB::wrapInTransaction(fn () => NoteToLink::removeWhereNoteId($note->getId()));
|
DB::wrapInTransaction(fn () => NoteToLink::removeWhereNoteId($note->getId()));
|
||||||
return Event::next;
|
return Event::next;
|
||||||
|
|
|
@ -35,7 +35,7 @@ declare(strict_types = 1);
|
||||||
namespace Component\Notification\Controller;
|
namespace Component\Notification\Controller;
|
||||||
|
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
@ -44,6 +44,8 @@ class Feed extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Everything with attention to current user
|
* Everything with attention to current user
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
*/
|
*/
|
||||||
public function notifications(Request $request): array
|
public function notifications(Request $request): array
|
||||||
{
|
{
|
||||||
|
@ -53,9 +55,9 @@ class Feed extends Controller
|
||||||
WHERE n.id IN (
|
WHERE n.id IN (
|
||||||
SELECT act.object_id FROM \App\Entity\Activity AS act
|
SELECT act.object_id FROM \App\Entity\Activity AS act
|
||||||
WHERE act.object_type = 'note' AND act.id IN
|
WHERE act.object_type = 'note' AND act.id IN
|
||||||
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :id)
|
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :target_id)
|
||||||
)
|
)
|
||||||
EOF, ['id' => $user->getId()]);
|
EOF, [':target_id' => $user->getId()]);
|
||||||
return [
|
return [
|
||||||
'_template' => 'collection/notes.html.twig',
|
'_template' => 'collection/notes.html.twig',
|
||||||
'page_title' => _m('Notifications'),
|
'page_title' => _m('Notifications'),
|
||||||
|
|
105
components/Notification/Entity/Attention.php
Normal file
105
components/Notification/Entity/Attention.php
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
// {{{ License
|
||||||
|
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||||
|
//
|
||||||
|
// GNU social is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// GNU social is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
// }}}
|
||||||
|
|
||||||
|
namespace Component\Notification\Entity;
|
||||||
|
|
||||||
|
use App\Core\Entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity for object attentions
|
||||||
|
*
|
||||||
|
* An attention is a form of persistent notification.
|
||||||
|
* It exists together and for as long as the object it belongs to.
|
||||||
|
* Creating an attention requires creating a Notification.
|
||||||
|
*
|
||||||
|
* @category DB
|
||||||
|
* @package GNUsocial
|
||||||
|
*
|
||||||
|
* @author Zach Copley <zach@status.net>
|
||||||
|
* @copyright 2010 StatusNet Inc.
|
||||||
|
* @author Mikael Nordfeldth <mmn@hethane.se>
|
||||||
|
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
|
||||||
|
* @author Diogo Peralta Cordeiro <@diogo.site>
|
||||||
|
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
|
||||||
|
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||||
|
*/
|
||||||
|
class Attention extends Entity
|
||||||
|
{
|
||||||
|
// {{{ Autocode
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
|
private string $object_type;
|
||||||
|
private int $object_id;
|
||||||
|
private int $target_id;
|
||||||
|
|
||||||
|
public function setObjectType(string $object_type): self
|
||||||
|
{
|
||||||
|
$this->object_type = mb_substr($object_type, 0, 32);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getObjectType(): string
|
||||||
|
{
|
||||||
|
return $this->object_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setObjectId(int $object_id): self
|
||||||
|
{
|
||||||
|
$this->object_id = $object_id;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getObjectId(): int
|
||||||
|
{
|
||||||
|
return $this->object_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTargetId(int $target_id): self
|
||||||
|
{
|
||||||
|
$this->target_id = $target_id;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTargetId(): int
|
||||||
|
{
|
||||||
|
return $this->target_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
|
// }}} Autocode
|
||||||
|
|
||||||
|
public static function schemaDef(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'attention',
|
||||||
|
'description' => 'Attentions to actors (these are not mentions)',
|
||||||
|
'fields' => [
|
||||||
|
'object_type' => ['type' => 'varchar', 'length' => 32, 'not null' => true, 'description' => 'the name of the table this object refers to'],
|
||||||
|
'object_id' => ['type' => 'int', 'not null' => true, 'description' => 'id in the referenced table'],
|
||||||
|
'target_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'actor_id for feed receiver'],
|
||||||
|
],
|
||||||
|
'primary key' => ['object_type', 'object_id', 'target_id'],
|
||||||
|
'indexes' => [
|
||||||
|
'attention_object_id_idx' => ['object_id'],
|
||||||
|
'attention_target_id_idx' => ['target_id'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,24 +21,24 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Notification\Entity;
|
namespace Component\Notification\Entity;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Entity;
|
use App\Core\Entity;
|
||||||
use App\Entity\Activity;
|
use App\Entity\Activity;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity for attentions
|
* Entity for Notifications
|
||||||
|
*
|
||||||
|
* A Notification when isolated is a form of transient notification.
|
||||||
|
* When together with a persistent form of notification such as attentions or mentions,
|
||||||
|
* it records that the target was notified - which avoids re-notifying upon objects reconstructions.
|
||||||
*
|
*
|
||||||
* @category DB
|
* @category DB
|
||||||
* @package GNUsocial
|
* @package GNUsocial
|
||||||
*
|
*
|
||||||
* @author Zach Copley <zach@status.net>
|
* @author Diogo Peralta Cordeiro <@diogo.site>
|
||||||
* @copyright 2010 StatusNet Inc.
|
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
|
||||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
|
||||||
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
|
|
||||||
* @author Hugo Sales <hugo@hsal.es>
|
|
||||||
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
|
|
||||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||||
*/
|
*/
|
||||||
class Notification extends Entity
|
class Notification extends Entity
|
||||||
|
@ -117,11 +117,11 @@ class Notification extends Entity
|
||||||
/**
|
/**
|
||||||
* Pull the complete list of known activity context notifications for this activity.
|
* Pull the complete list of known activity context notifications for this activity.
|
||||||
*
|
*
|
||||||
* @return array of integer actor ids (also group profiles)
|
* @return int[] actor ids (also group profiles)
|
||||||
*/
|
*/
|
||||||
public static function getNotificationTargetIdsByActivity(int|Activity $activity_id): array
|
public static function getNotificationTargetIdsByActivity(int|Activity $activity_id): array
|
||||||
{
|
{
|
||||||
$notifications = DB::findBy('notification', ['activity_id' => \is_int($activity_id) ? $activity_id : $activity_id->getId()]);
|
$notifications = DB::findBy(self::class, ['activity_id' => \is_int($activity_id) ? $activity_id : $activity_id->getId()]);
|
||||||
$targets = [];
|
$targets = [];
|
||||||
foreach ($notifications as $notification) {
|
foreach ($notifications as $notification) {
|
||||||
$targets[] = $notification->getTargetId();
|
$targets[] = $notification->getTargetId();
|
||||||
|
@ -129,9 +129,24 @@ class Notification extends Entity
|
||||||
return $targets;
|
return $targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
public function getNotificationTargetsByActivity(int|Activity $activity_id): array
|
public function getNotificationTargetsByActivity(int|Activity $activity_id): array
|
||||||
{
|
{
|
||||||
return DB::findBy('actor', ['id' => $this->getNotificationTargetIdsByActivity($activity_id)]);
|
return DB::findBy(Actor::class, ['id' => $this->getNotificationTargetIdsByActivity($activity_id)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
public static function getAllActivitiesTargetedAtActor(Actor $actor): array
|
||||||
|
{
|
||||||
|
return DB::dql(<<<'EOF'
|
||||||
|
SELECT act FROM \App\Entity\Activity AS act
|
||||||
|
WHERE act.object_type = 'note' AND act.id IN
|
||||||
|
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :id)
|
||||||
|
EOF, ['id' => $actor->getId()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function schemaDef(): array
|
public static function schemaDef(): array
|
||||||
|
|
|
@ -21,29 +21,35 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Notification;
|
namespace Component\Notification;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Log;
|
use App\Core\Log;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Core\Router\RouteLoader;
|
use App\Core\Queue;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
use App\Entity\Activity;
|
use App\Entity\Activity;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\LocalUser;
|
use App\Entity\LocalUser;
|
||||||
|
use App\Util\Exception\ServerException;
|
||||||
use Component\FreeNetwork\FreeNetwork;
|
use Component\FreeNetwork\FreeNetwork;
|
||||||
use Component\Group\Entity\GroupInbox;
|
|
||||||
use Component\Notification\Controller\Feed;
|
use Component\Notification\Controller\Feed;
|
||||||
|
use EventResult;
|
||||||
|
use Exception;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class Notification extends Component
|
class Notification extends Component
|
||||||
{
|
{
|
||||||
public function onAddRoute(RouteLoader $m): bool
|
public function onAddRoute(Router $m): EventResult
|
||||||
{
|
{
|
||||||
$m->connect('feed_notifications', '/feed/notifications', [Feed::class, 'notifications']);
|
$m->connect('feed_notifications', '/feed/notifications', [Feed::class, 'notifications']);
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
|
/**
|
||||||
|
* @throws ServerException
|
||||||
|
*/
|
||||||
|
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): EventResult
|
||||||
{
|
{
|
||||||
DB::persist(\App\Entity\Feed::create([
|
DB::persist(\App\Entity\Feed::create([
|
||||||
'actor_id' => $actor_id,
|
'actor_id' => $actor_id,
|
||||||
|
@ -56,58 +62,127 @@ class Notification extends Component
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueues a notification for an Actor (user or group) which means
|
* Enqueues a notification for an Actor (such as person or group) which means
|
||||||
* it shows up in their home feed and such.
|
* it shows up in their home feed and such.
|
||||||
|
* WARNING: It's highly advisable to have flushed any relevant objects before triggering this event.
|
||||||
|
*
|
||||||
|
* $targets should be of the shape:
|
||||||
|
* (int|Actor)[] // Prefer Actor whenever possible
|
||||||
|
* Example of $targets:
|
||||||
|
* [42, $actor_alice, $actor_bob] // Avoid repeating actors or ids
|
||||||
|
*
|
||||||
|
* @param Actor $sender The one responsible for this activity, take care not to include it in targets
|
||||||
|
* @param Activity $activity The activity responsible for the object being given to known to targets
|
||||||
|
* @param non-empty-array<Actor|int> $targets Attentions, Mentions, any other source. Should never be empty, you usually want to register an attention to every $sender->getSubscribers()
|
||||||
|
* @param null|string $reason An optional reason explaining why this notification exists
|
||||||
*/
|
*/
|
||||||
public function onNewNotification(Actor $sender, Activity $activity, array $ids_already_known = [], ?string $reason = null): bool
|
public function onNewNotification(Actor $sender, Activity $activity, array $targets, ?string $reason = null): EventResult
|
||||||
{
|
{
|
||||||
$targets = $activity->getNotificationTargets(ids_already_known: $ids_already_known, sender_id: $sender->getId());
|
// Ensure targets are all actor objects and unique
|
||||||
$this->notify($sender, $activity, $targets, $reason);
|
$effective_targets = [];
|
||||||
|
foreach ($targets as $target) {
|
||||||
|
if (\is_int($target)) {
|
||||||
|
$target_id = $target;
|
||||||
|
$target_object = null;
|
||||||
|
} else {
|
||||||
|
$target_id = $target->getId();
|
||||||
|
$target_object = $target;
|
||||||
|
}
|
||||||
|
if (!\array_key_exists(key: $target_id, array: $effective_targets)) {
|
||||||
|
$target_object ??= Actor::getById($target_id);
|
||||||
|
$effective_targets[$target_id] = $target_object;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($targets);
|
||||||
|
|
||||||
|
if (Event::handle('NewNotificationStart', [$sender, $activity, $effective_targets, $reason]) === Event::next) {
|
||||||
|
self::notify($sender, $activity, $effective_targets, $reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
Event::handle('NewNotificationEnd', [$sender, $activity, $effective_targets, $reason]);
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bring given Activity to Targets's attention
|
* @param mixed[] $retry_args
|
||||||
*/
|
*/
|
||||||
public function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
|
public function onQueueNotificationLocal(Actor $sender, Activity $activity, Actor $target, ?string $reason, array &$retry_args): EventResult
|
||||||
|
{
|
||||||
|
// TODO: use https://symfony.com/doc/current/notifier.html
|
||||||
|
return Event::stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Actor[] $targets
|
||||||
|
* @param mixed[] $retry_args
|
||||||
|
*/
|
||||||
|
public function onQueueNotificationRemote(Actor $sender, Activity $activity, array $targets, ?string $reason, array &$retry_args): EventResult
|
||||||
|
{
|
||||||
|
if (FreeNetwork::notify($sender, $activity, $targets, $reason)) {
|
||||||
|
return Event::stop;
|
||||||
|
} else {
|
||||||
|
return Event::next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring given Activity to Targets' knowledge.
|
||||||
|
* This will flush a Notification to DB.
|
||||||
|
*
|
||||||
|
* @param Actor[] $targets
|
||||||
|
*
|
||||||
|
* @return bool true if successful, false otherwise
|
||||||
|
*/
|
||||||
|
public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
|
||||||
{
|
{
|
||||||
$remote_targets = [];
|
$remote_targets = [];
|
||||||
foreach ($targets as $target) {
|
foreach ($targets as $target) {
|
||||||
if ($target->getIsLocal()) {
|
if ($target->getIsLocal()) {
|
||||||
if ($target->isGroup()) {
|
if ($target->hasBlocked($author = $activity->getActor())) {
|
||||||
// FIXME: Make sure we check (for both local and remote) users are in the groups they send to!
|
Log::info("Not saving notification to actor {$target->getId()} from sender {$sender->getId()} because receiver blocked author {$author->getId()}.");
|
||||||
DB::persist(GroupInbox::create([
|
continue;
|
||||||
'group_id' => $target->getId(),
|
}
|
||||||
'activity_id' => $activity->getId(),
|
if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) {
|
||||||
]));
|
if ($sender->getId() === $target->getId()
|
||||||
} else {
|
|| $activity->getActorId() === $target->getId()) {
|
||||||
if ($target->hasBlocked($activity->getActor())) {
|
// The target already knows about this, no need to bother with a notification
|
||||||
Log::info("Not saving reply to actor {$target->getId()} from sender {$sender->getId()} because of a block.");
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) {
|
Queue::enqueue(
|
||||||
// TODO: use https://symfony.com/doc/current/notifier.html
|
payload: [$sender, $activity, $target, $reason],
|
||||||
// XXX: Unideal as in failures the rollback will leave behind a false notification,
|
queue: 'NotificationLocal',
|
||||||
// but most notifications (all) require flushing the objects first
|
priority: true,
|
||||||
// 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 {
|
} else {
|
||||||
// We have no authority nor responsibility of notifying remote actors of a remote actor's doing
|
// We have no authority nor responsibility of notifying remote actors of a remote actor's doing
|
||||||
if ($sender->getIsLocal()) {
|
if ($sender->getIsLocal()) {
|
||||||
$remote_targets[] = $target;
|
$remote_targets[] = $target;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// XXX: Unideal as in failures the rollback will leave behind a false notification,
|
||||||
|
// but most notifications (all) require flushing the objects first
|
||||||
|
// Should be okay as long as implementations bear this in mind
|
||||||
|
try {
|
||||||
|
DB::wrapInTransaction(fn () => DB::persist(Entity\Notification::create([
|
||||||
|
'activity_id' => $activity->getId(),
|
||||||
|
'target_id' => $target->getId(),
|
||||||
|
'reason' => $reason,
|
||||||
|
])));
|
||||||
|
} catch (Exception|Throwable $e) {
|
||||||
|
// We do our best not to record duplicate notifications, but it's not insane that can happen
|
||||||
|
Log::error('It was attempted to record an invalid notification!', [$e]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FreeNetwork::notify($sender, $activity, $remote_targets, $reason);
|
if ($remote_targets !== []) {
|
||||||
|
Queue::enqueue(
|
||||||
|
payload: [$sender, $activity, $remote_targets, $reason],
|
||||||
|
queue: 'NotificationRemote',
|
||||||
|
priority: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Event::next;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
97
components/Person/Controller/PersonFeed.php
Normal file
97
components/Person/Controller/PersonFeed.php
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
// {{{ License
|
||||||
|
|
||||||
|
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||||
|
//
|
||||||
|
// GNU social is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// GNU social is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// }}}
|
||||||
|
|
||||||
|
namespace Component\Person\Controller;
|
||||||
|
|
||||||
|
use function App\Core\I18n\_m;
|
||||||
|
use App\Core\Router;
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity as E;
|
||||||
|
use App\Entity\LocalUser;
|
||||||
|
use App\Util\Exception\ClientException;
|
||||||
|
use App\Util\Exception\ServerException;
|
||||||
|
use App\Util\HTML\Heading;
|
||||||
|
use Component\Collection\Util\Controller\FeedController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends FeedController<\App\Entity\Note>
|
||||||
|
*/
|
||||||
|
class PersonFeed extends FeedController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws ClientException
|
||||||
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
|
public function personViewId(Request $request, int $id): array
|
||||||
|
{
|
||||||
|
$person = Actor::getById($id);
|
||||||
|
if (\is_null($person) || !$person->isPerson()) {
|
||||||
|
throw new ClientException(_m('No such person.'), 404);
|
||||||
|
}
|
||||||
|
if ($person->getIsLocal()) {
|
||||||
|
return [
|
||||||
|
'_redirect' => Router::url('person_actor_view_nickname', ['nickname' => $person->getNickname()]),
|
||||||
|
'actor' => $person,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $this->personView($request, $person);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View a group feed by its nickname
|
||||||
|
*
|
||||||
|
* @param string $nickname The group's nickname to be shown
|
||||||
|
*
|
||||||
|
* @throws ClientException
|
||||||
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
|
public function personViewNickname(Request $request, string $nickname): array
|
||||||
|
{
|
||||||
|
$user = LocalUser::getByNickname($nickname);
|
||||||
|
if (\is_null($user)) {
|
||||||
|
throw new ClientException(_m('No such person.'), 404);
|
||||||
|
}
|
||||||
|
$person = Actor::getById($user->getId());
|
||||||
|
return $this->personView($request, $person);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
|
public function personView(Request $request, Actor $person): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'_template' => 'actor/view.html.twig',
|
||||||
|
'actor' => $person,
|
||||||
|
'nickname' => $person->getNickname(),
|
||||||
|
'notes' => E\Note::getAllNotesByActor($person),
|
||||||
|
'page_title' => _m('{nickname}\'s profile', ['{nickname}' => $person->getNickname()]),
|
||||||
|
'notes_feed_title' => (new Heading(level: 2, classes: ['section-title'], text: 'Notes by ' . $person->getNickname())),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -33,19 +33,26 @@ declare(strict_types = 1);
|
||||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Controller;
|
namespace Component\Person\Controller;
|
||||||
|
|
||||||
// {{{ Imports
|
// {{{ Imports
|
||||||
|
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\Form;
|
use App\Core\Form;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Log;
|
use App\Core\Log;
|
||||||
|
use App\Entity\Actor;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use App\Util\Exception\AuthenticationException;
|
use App\Util\Exception\AuthenticationException;
|
||||||
|
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\NoLoggedInUser;
|
||||||
|
use App\Util\Exception\RedirectException;
|
||||||
use App\Util\Exception\ServerException;
|
use App\Util\Exception\ServerException;
|
||||||
use App\Util\Form\ActorArrayTransformer;
|
use App\Util\Form\ActorArrayTransformer;
|
||||||
use App\Util\Form\ActorForms;
|
use App\Util\Form\ActorForms;
|
||||||
|
@ -64,36 +71,41 @@ use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
// }}} Imports
|
// }}} Imports
|
||||||
|
|
||||||
class UserPanel extends Controller
|
class PersonSettings extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Return main settings page forms
|
* Return main settings page forms
|
||||||
*
|
*
|
||||||
* @throws \App\Util\Exception\NicknameEmptyException
|
* @throws \App\Util\Exception\ClientException
|
||||||
* @throws \App\Util\Exception\NicknameInvalidException
|
* @throws \App\Util\Exception\NicknameException
|
||||||
* @throws \App\Util\Exception\NicknameNotAllowedException
|
|
||||||
* @throws \App\Util\Exception\NicknameTakenException
|
|
||||||
* @throws \App\Util\Exception\NicknameTooLongException
|
|
||||||
* @throws \App\Util\Exception\RedirectException
|
|
||||||
* @throws \Doctrine\DBAL\Exception
|
* @throws \Doctrine\DBAL\Exception
|
||||||
* @throws AuthenticationException
|
* @throws AuthenticationException
|
||||||
|
* @throws NicknameEmptyException
|
||||||
|
* @throws NicknameInvalidException
|
||||||
|
* @throws NicknameNotAllowedException
|
||||||
|
* @throws NicknameTakenException
|
||||||
|
* @throws NicknameTooLongException
|
||||||
* @throws NoLoggedInUser
|
* @throws NoLoggedInUser
|
||||||
|
* @throws RedirectException
|
||||||
* @throws ServerException
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
*/
|
*/
|
||||||
public function allSettings(Request $request, LanguageController $language): array
|
public function allSettings(Request $request, LanguageController $language): array
|
||||||
{
|
{
|
||||||
// Ensure the user is logged in and retrieve Actor object for given user
|
// Ensure the user is logged in and retrieve Actor object for given user
|
||||||
$user = Common::ensureLoggedIn();
|
$user = Common::ensureLoggedIn();
|
||||||
$actor = $user->getActor();
|
// Must be persisted
|
||||||
|
$actor = DB::findOneBy(Actor::class, ['id' => $user->getId()]);
|
||||||
|
|
||||||
$personal_form = ActorForms::personalInfo($request, $actor, $user);
|
$personal_form = ActorForms::personalInfo(request: $request, scope: $actor, target: $actor);
|
||||||
$email_form = self::email($request);
|
$email_form = self::email($request);
|
||||||
$password_form = self::password($request);
|
$password_form = self::password($request);
|
||||||
$notifications_form_array = self::notifications($request);
|
$notifications_form_array = self::notifications($request);
|
||||||
$language_form = $language->settings($request);
|
$language_form = $language->settings($request);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'_template' => 'settings/base.html.twig',
|
'_template' => 'person/settings.html.twig',
|
||||||
'personal_info_form' => $personal_form->createView(),
|
'personal_info_form' => $personal_form->createView(),
|
||||||
'email_form' => $email_form->createView(),
|
'email_form' => $email_form->createView(),
|
||||||
'password_form' => $password_form->createView(),
|
'password_form' => $password_form->createView(),
|
||||||
|
@ -115,8 +127,22 @@ class UserPanel extends Controller
|
||||||
// TODO Add support missing settings
|
// TODO Add support missing settings
|
||||||
|
|
||||||
$form = Form::create([
|
$form = Form::create([
|
||||||
['outgoing_email', TextType::class, ['label' => _m('Outgoing email'), 'required' => false, 'help' => _m('Change the email we use to contact you')]],
|
['outgoing_email_sanitized', TextType::class,
|
||||||
['incoming_email', TextType::class, ['label' => _m('Incoming email'), 'required' => false, 'help' => _m('Change the email you use to contact us (for posting, for instance)')]],
|
[
|
||||||
|
'label' => _m('Outgoing email'),
|
||||||
|
'required' => false,
|
||||||
|
'help' => _m('Change the email we use to contact you'),
|
||||||
|
'data' => $user->getOutgoingEmail() ?: '',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
['incoming_email_sanitized', TextType::class,
|
||||||
|
[
|
||||||
|
'label' => _m('Incoming email'),
|
||||||
|
'required' => false,
|
||||||
|
'help' => _m('Change the email you use to contact us (for posting, for instance)'),
|
||||||
|
'data' => $user->getIncomingEmail() ?: '',
|
||||||
|
],
|
||||||
|
],
|
||||||
['save_email', SubmitType::class, ['label' => _m('Save email info')]],
|
['save_email', SubmitType::class, ['label' => _m('Save email info')]],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -181,6 +207,8 @@ class UserPanel extends Controller
|
||||||
* @throws \Doctrine\DBAL\Exception
|
* @throws \Doctrine\DBAL\Exception
|
||||||
* @throws NoLoggedInUser
|
* @throws NoLoggedInUser
|
||||||
* @throws ServerException
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType[]
|
||||||
*/
|
*/
|
||||||
private static function notifications(Request $request): array
|
private static function notifications(Request $request): array
|
||||||
{
|
{
|
||||||
|
@ -227,7 +255,7 @@ class UserPanel extends Controller
|
||||||
// @codeCoverageIgnoreStart
|
// @codeCoverageIgnoreStart
|
||||||
Log::critical("Structure of table user_notification_prefs changed in a way not accounted to in notification settings ({$name}): " . $type_str);
|
Log::critical("Structure of table user_notification_prefs changed in a way not accounted to in notification settings ({$name}): " . $type_str);
|
||||||
throw new ServerException(_m('Internal server error'));
|
throw new ServerException(_m('Internal server error'));
|
||||||
// @codeCoverageIgnoreEnd
|
// @codeCoverageIgnoreEnd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -262,7 +290,7 @@ class UserPanel extends Controller
|
||||||
$data = $form->getData();
|
$data = $form->getData();
|
||||||
unset($data['translation_domain']);
|
unset($data['translation_domain']);
|
||||||
try {
|
try {
|
||||||
[$entity, $is_update] = UserNotificationPrefs::createOrUpdate(
|
[$entity, $is_update] = UserNotificationPrefs::checkExistingAndCreateOrUpdate(
|
||||||
array_merge(['user_id' => $user->getId(), 'transport' => $transport_name], $data),
|
array_merge(['user_id' => $user->getId(), 'transport' => $transport_name], $data),
|
||||||
find_by_keys: ['user_id', 'transport'],
|
find_by_keys: ['user_id', 'transport'],
|
||||||
);
|
);
|
40
components/Person/Person.php
Normal file
40
components/Person/Person.php
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
// {{{ License
|
||||||
|
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||||
|
//
|
||||||
|
// GNU social is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// GNU social is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
// }}}
|
||||||
|
|
||||||
|
namespace Component\Person;
|
||||||
|
|
||||||
|
use App\Core\Event;
|
||||||
|
use App\Core\Modules\Component;
|
||||||
|
use App\Core\Router;
|
||||||
|
use App\Util\Nickname;
|
||||||
|
use Component\Person\Controller as C;
|
||||||
|
use EventResult;
|
||||||
|
|
||||||
|
class Person extends Component
|
||||||
|
{
|
||||||
|
public function onAddRoute(Router $r): EventResult
|
||||||
|
{
|
||||||
|
$r->connect(id: 'person_actor_view_id', uri_path: '/person/{id<\d+>}', target: [C\PersonFeed::class, 'personViewId']);
|
||||||
|
$r->connect(id: 'person_actor_view_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\PersonFeed::class, 'personViewNickname'], options: ['is_system_path' => false]);
|
||||||
|
$r->connect(id: 'person_actor_settings', uri_path: '/person/{id<\d+>}/settings', target: [C\PersonSettings::class, 'allSettings']);
|
||||||
|
return Event::next;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,14 +1,8 @@
|
||||||
{% extends '/stdgrid.html.twig' %}
|
{% extends '/stdgrid.html.twig' %}
|
||||||
|
|
||||||
{% import 'settings/macros.html.twig' as macros %}
|
{% import 'cards/macros/settings.html.twig' as macros %}
|
||||||
|
|
||||||
{% block title %}{{ 'Settings' | trans }}{% endblock %}
|
{% block title %}{% trans %}Settings{% endtrans %}{% endblock %}
|
||||||
|
|
||||||
{% block stylesheets %}
|
|
||||||
{{ parent() }}
|
|
||||||
<link rel="preload" href="{{ asset('assets/default_theme/css/pages/settings.css') }}" as="style" type="text/css">
|
|
||||||
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/settings.css') }}">
|
|
||||||
{% endblock stylesheets %}
|
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<nav class='frame-section frame-section-padding'>
|
<nav class='frame-section frame-section-padding'>
|
||||||
|
@ -39,6 +33,10 @@
|
||||||
<li>
|
<li>
|
||||||
{{ macros.settings_details_container('Notifications', 'Enable/disable notifications (Email, XMPP, Replies...)', 'notifications', tabbed_forms_notify, _context) }}
|
{{ macros.settings_details_container('Notifications', 'Enable/disable notifications (Email, XMPP, Replies...)', 'notifications', tabbed_forms_notify, _context) }}
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
{% set other_tabs = handle_event('PopulateSettingsTabs', app.request, 'api') %}
|
||||||
|
{{ macros.settings_details_container('API', 'API settings', 'settings-other-details', other_tabs, _context) }}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
{% endblock body %}
|
{% endblock body %}
|
145
components/Person/tests/Controller/PersonSettingsTest.php
Normal file
145
components/Person/tests/Controller/PersonSettingsTest.php
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
// {{{ License
|
||||||
|
|
||||||
|
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||||
|
//
|
||||||
|
// GNU social is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// GNU social is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// }}}
|
||||||
|
|
||||||
|
namespace Component\Person\tests\Controller;
|
||||||
|
|
||||||
|
use App\Core\DB;
|
||||||
|
use App\Core\Router;
|
||||||
|
use App\Entity\LocalUser;
|
||||||
|
use App\Util\GNUsocialTestCase;
|
||||||
|
use Jchook\AssertThrows\AssertThrows;
|
||||||
|
|
||||||
|
class PersonSettingsTest extends GNUsocialTestCase
|
||||||
|
{
|
||||||
|
use AssertThrows;
|
||||||
|
|
||||||
|
public function testPersonalInfo()
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_personal_info_test_user']);
|
||||||
|
$client->loginUser($user);
|
||||||
|
|
||||||
|
$client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$crawler = $client->submitForm('Save personal info', [
|
||||||
|
'save_personal_info[nickname]' => 'form_test_user_new_nickname',
|
||||||
|
'save_personal_info[full_name]' => 'Form User',
|
||||||
|
'save_personal_info[homepage]' => 'https://gnu.org',
|
||||||
|
'save_personal_info[bio]' => 'I was born at a very young age',
|
||||||
|
'save_personal_info[location]' => 'right here',
|
||||||
|
// 'save_personal_info[phone_number]' => '+351908555842', // from fakenumber.net
|
||||||
|
]);
|
||||||
|
$changed_user = DB::findOneBy(LocalUser::class, ['id' => $user->getId()]);
|
||||||
|
$actor = $changed_user->getActor();
|
||||||
|
static::assertSame('form_test_user_new_nickname', $changed_user->getNickname());
|
||||||
|
static::assertSame('form_test_user_new_nickname', $actor->getNickname());
|
||||||
|
static::assertSame('Form User', $actor->getFullName());
|
||||||
|
static::assertSame('https://gnu.org', $actor->getHomepage());
|
||||||
|
static::assertSame('I was born at a very young age', $actor->getBio());
|
||||||
|
static::assertSame('right here', $actor->getLocation());
|
||||||
|
// static::assertSame('908555842', $changed_user->getPhoneNumber()->getNationalNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmail()
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_account_test_user']);
|
||||||
|
$client->loginUser($user);
|
||||||
|
|
||||||
|
$client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$crawler = $client->submitForm('Save email info', [
|
||||||
|
'save_email[outgoing_email_sanitized]' => 'outgoing@provider.any',
|
||||||
|
'save_email[incoming_email_sanitized]' => 'incoming@provider.any',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$changed_user = DB::findOneBy(LocalUser::class, ['id' => $user->getId()]);
|
||||||
|
static::assertSame($changed_user->getOutgoingEmail(), 'outgoing@provider.any');
|
||||||
|
static::assertSame($changed_user->getIncomingEmail(), 'incoming@provider.any');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCorrectPassword()
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_account_test_user']);
|
||||||
|
$client->loginUser($user);
|
||||||
|
|
||||||
|
$client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$crawler = $client->submitForm('Save new password', [
|
||||||
|
'save_password[old_password]' => 'foobar',
|
||||||
|
'save_password[password][first]' => 'this is some test password',
|
||||||
|
'save_password[password][second]' => 'this is some test password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$changed_user = DB::findOneBy(LocalUser::class, ['id' => $user->getId()]);
|
||||||
|
static::assertTrue($changed_user->checkPassword('this is some test password'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAccountWrongPassword()
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_account_test_user']);
|
||||||
|
$client->loginUser($user);
|
||||||
|
|
||||||
|
$client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$crawler = $client->submitForm('Save new password', [
|
||||||
|
'save_password[old_password]' => 'some wrong password',
|
||||||
|
'save_password[password][first]' => 'this is some test password',
|
||||||
|
'save_password[password][second]' => 'this is some test password',
|
||||||
|
]);
|
||||||
|
$this->assertResponseStatusCodeSame(500); // 401 in future
|
||||||
|
$this->assertSelectorTextContains('.stacktrace', 'AuthenticationException');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: First actually implement this functionality
|
||||||
|
// public function testNotifications()
|
||||||
|
// {
|
||||||
|
// $client = static::createClient();
|
||||||
|
// $user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_account_test_user']);
|
||||||
|
// $client->loginUser($user);
|
||||||
|
//
|
||||||
|
// $client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
|
||||||
|
// $this->assertResponseIsSuccessful();
|
||||||
|
// $crawler = $client->submitForm('Save notification settings for Email', [
|
||||||
|
// 'save_email[activity_by_subscribed]' => false,
|
||||||
|
// 'save_email[mention]' => true,
|
||||||
|
// 'save_email[reply]' => false,
|
||||||
|
// 'save_email[subscription]' => true,
|
||||||
|
// 'save_email[favorite]' => false,
|
||||||
|
// 'save_email[nudge]' => true,
|
||||||
|
// 'save_email[dm]' => false,
|
||||||
|
// 'save_email[enable_posting]' => true,
|
||||||
|
// ]);
|
||||||
|
// $settings = DB::findOneBy('user_notification_prefs', ['user_id' => $user->getId(), 'transport' => 'email']);
|
||||||
|
// static::assertSame($settings->getActivityBySubscribed(), false);
|
||||||
|
// static::assertSame($settings->getMention(), true);
|
||||||
|
// static::assertSame($settings->getReply(), false);
|
||||||
|
// static::assertSame($settings->getSubscription(), true);
|
||||||
|
// static::assertSame($settings->getFavorite(), false);
|
||||||
|
// static::assertSame($settings->getNudge(), true);
|
||||||
|
// static::assertSame($settings->getDm(), false);
|
||||||
|
// static::assertSame($settings->getEnablePosting(), true);
|
||||||
|
// }
|
||||||
|
}
|
69
components/Posting/Controller/Posting.php
Normal file
69
components/Posting/Controller/Posting.php
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace Component\Posting\Controller;
|
||||||
|
|
||||||
|
use App\Core;
|
||||||
|
use App\Core\Controller;
|
||||||
|
use App\Core\Event;
|
||||||
|
use function App\Core\I18n\_m;
|
||||||
|
use App\Core\VisibilityScope;
|
||||||
|
use App\Util\Common;
|
||||||
|
use App\Util\Exception\ClientException;
|
||||||
|
use Component\Posting\Form;
|
||||||
|
use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
class Posting extends Controller
|
||||||
|
{
|
||||||
|
public function onPost(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$actor = Common::actor();
|
||||||
|
$form = Form\Posting::create($request);
|
||||||
|
|
||||||
|
$form->handleRequest($request);
|
||||||
|
if ($form->isSubmitted()) {
|
||||||
|
try {
|
||||||
|
if ($form->isValid()) {
|
||||||
|
$data = $form->getData();
|
||||||
|
Event::handle('PostingModifyData', [$request, $actor, &$data, $form]);
|
||||||
|
|
||||||
|
if (empty($data['content']) && empty($data['attachments'])) {
|
||||||
|
// TODO Display error: At least one of `content` and `attachments` must be provided
|
||||||
|
throw new ClientException(_m('You must enter content or provide at least one attachment to post a note.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_null(VisibilityScope::tryFrom($data['visibility']))) {
|
||||||
|
throw new ClientException(_m('You have selected an impossible visibility.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$extra_args = [];
|
||||||
|
Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form]);
|
||||||
|
|
||||||
|
if (\array_key_exists('in', $data) && $data['in'] !== 'public') {
|
||||||
|
$target = $data['in'];
|
||||||
|
}
|
||||||
|
|
||||||
|
\Component\Posting\Posting::storeLocalNote(
|
||||||
|
actor: $actor,
|
||||||
|
content: $data['content'],
|
||||||
|
content_type: $data['content_type'],
|
||||||
|
locale: $data['language'],
|
||||||
|
scope: VisibilityScope::from($data['visibility']),
|
||||||
|
attentions: isset($target) ? [$target] : [],
|
||||||
|
reply_to: \array_key_exists('reply_to_id', $data) ? $data['reply_to_id'] : null,
|
||||||
|
attachments: $data['attachments'],
|
||||||
|
process_note_content_extra_args: $extra_args,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Core\Form::forceRedirect($form, $request);
|
||||||
|
}
|
||||||
|
} catch (FormSizeFileException $e) {
|
||||||
|
throw new ClientException(_m('Invalid file size given.'), previous: $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ClientException(_m('Invalid form submission.'));
|
||||||
|
}
|
||||||
|
}
|
103
components/Posting/Form/Posting.php
Normal file
103
components/Posting/Form/Posting.php
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace Component\Posting\Form;
|
||||||
|
|
||||||
|
use App\Core\ActorLocalRoles;
|
||||||
|
use App\Core\Event;
|
||||||
|
use App\Core\Form;
|
||||||
|
use function App\Core\I18n\_m;
|
||||||
|
use App\Core\Router;
|
||||||
|
use App\Core\VisibilityScope;
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Util\Common;
|
||||||
|
use App\Util\Form\FormFields;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Validator\Constraints\Length;
|
||||||
|
|
||||||
|
class Posting
|
||||||
|
{
|
||||||
|
public static function create(Request $request)
|
||||||
|
{
|
||||||
|
$actor = Common::actor();
|
||||||
|
|
||||||
|
$placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
|
||||||
|
Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
|
||||||
|
$placeholder = $placeholder_strings[array_rand($placeholder_strings)];
|
||||||
|
|
||||||
|
$initial_content = '';
|
||||||
|
Event::handle('PostingInitialContent', [&$initial_content]);
|
||||||
|
|
||||||
|
$available_content_types = [
|
||||||
|
_m('Plain Text') => 'text/plain',
|
||||||
|
];
|
||||||
|
Event::handle('PostingAvailableContentTypes', [&$available_content_types]);
|
||||||
|
|
||||||
|
$in_targets = [];
|
||||||
|
Event::handle('PostingFillTargetChoices', [$request, $actor, &$in_targets]);
|
||||||
|
|
||||||
|
$context_actor = null;
|
||||||
|
Event::handle('PostingGetContextActor', [$request, $actor, &$context_actor]);
|
||||||
|
|
||||||
|
$form_params = [];
|
||||||
|
if (!empty($in_targets)) { // @phpstan-ignore-line
|
||||||
|
// Add "none" option to the first of choices
|
||||||
|
$in_targets = array_merge([_m('Public') => 'public'], $in_targets);
|
||||||
|
// Make the context actor the first In target option
|
||||||
|
if (!\is_null($context_actor)) {
|
||||||
|
foreach ($in_targets as $it_nick => $it_id) {
|
||||||
|
if ($it_id === $context_actor->getId()) {
|
||||||
|
unset($in_targets[$it_nick]);
|
||||||
|
$in_targets = array_merge([$it_nick => $it_id], $in_targets);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$form_params[] = ['in', ChoiceType::class, ['label' => _m('In:'), 'multiple' => false, 'expanded' => false, 'choices' => $in_targets]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$visibility_options = [
|
||||||
|
_m('Public') => VisibilityScope::EVERYWHERE->value,
|
||||||
|
_m('Local') => VisibilityScope::LOCAL->value,
|
||||||
|
_m('Addressee') => VisibilityScope::ADDRESSEE->value,
|
||||||
|
];
|
||||||
|
if (!\is_null($context_actor) && $context_actor->isGroup()) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234
|
||||||
|
if ($actor->canModerate($context_actor)) {
|
||||||
|
if ($context_actor->getRoles() & ActorLocalRoles::PRIVATE_GROUP) {
|
||||||
|
$visibility_options = array_merge([_m('Group') => VisibilityScope::GROUP->value], $visibility_options);
|
||||||
|
} else {
|
||||||
|
$visibility_options[_m('Group')] = VisibilityScope::GROUP->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'choices' => $visibility_options]];
|
||||||
|
|
||||||
|
$form_params[] = ['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]];
|
||||||
|
$form_params[] = ['attachments', FileType::class, ['label' => _m('Attachments:'), 'multiple' => true, 'required' => false, 'invalid_message' => _m('Attachment not valid.')]];
|
||||||
|
$form_params[] = FormFields::language($actor, $context_actor, label: _m('Note language'), help: _m('The selected language will be federated and added as a lang attribute, preferred language can be set up in settings'));
|
||||||
|
|
||||||
|
if (\count($available_content_types) > 1) {
|
||||||
|
$form_params[] = ['content_type', ChoiceType::class,
|
||||||
|
[
|
||||||
|
'label' => _m('Text format:'), 'multiple' => false, 'expanded' => false,
|
||||||
|
'data' => $available_content_types[array_key_first($available_content_types)],
|
||||||
|
'choices' => $available_content_types,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$form_params[] = ['content_type', HiddenType::class, ['data' => $available_content_types[array_key_first($available_content_types)]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
Event::handle('PostingAddFormEntries', [$request, $actor, &$form_params]);
|
||||||
|
|
||||||
|
$form_params[] = ['post_note', SubmitType::class, ['label' => _m('Post')]];
|
||||||
|
|
||||||
|
return Form::create($form_params, form_options: ['action' => Router::url(\Component\Posting\Posting::route)]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -23,16 +23,17 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Posting;
|
namespace Component\Posting;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\Cache;
|
||||||
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use App\Core\Form;
|
|
||||||
use App\Core\GSFile;
|
use App\Core\GSFile;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
use App\Core\VisibilityScope;
|
use App\Core\VisibilityScope;
|
||||||
use App\Entity\Activity;
|
use App\Entity\Activity;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\LocalUser;
|
||||||
use App\Entity\Note;
|
use App\Entity\Note;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use App\Util\Exception\BugFoundException;
|
use App\Util\Exception\BugFoundException;
|
||||||
|
@ -40,165 +41,148 @@ use App\Util\Exception\ClientException;
|
||||||
use App\Util\Exception\DuplicateFoundException;
|
use App\Util\Exception\DuplicateFoundException;
|
||||||
use App\Util\Exception\RedirectException;
|
use App\Util\Exception\RedirectException;
|
||||||
use App\Util\Exception\ServerException;
|
use App\Util\Exception\ServerException;
|
||||||
use App\Util\Form\FormFields;
|
|
||||||
use App\Util\Formatting;
|
use App\Util\Formatting;
|
||||||
use App\Util\HTML;
|
use App\Util\HTML;
|
||||||
use Component\Attachment\Entity\ActorToAttachment;
|
use Component\Attachment\Entity\ActorToAttachment;
|
||||||
|
use Component\Attachment\Entity\Attachment;
|
||||||
use Component\Attachment\Entity\AttachmentToNote;
|
use Component\Attachment\Entity\AttachmentToNote;
|
||||||
use Component\Conversation\Conversation;
|
use Component\Conversation\Conversation;
|
||||||
use Component\Language\Entity\Language;
|
use Component\Language\Entity\Language;
|
||||||
use Functional as F;
|
use Component\Notification\Entity\Attention;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
use EventResult;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
|
||||||
use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
|
|
||||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
|
|
||||||
use Symfony\Component\Validator\Constraints\Length;
|
|
||||||
|
|
||||||
class Posting extends Component
|
class Posting extends Component
|
||||||
{
|
{
|
||||||
|
public const route = 'posting_form_action';
|
||||||
|
|
||||||
|
public function onAddRoute(Router $r): EventResult
|
||||||
|
{
|
||||||
|
$r->connect(self::route, '/form/posting', Controller\Posting::class);
|
||||||
|
return Event::next;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTML render event handler responsible for adding and handling
|
* HTML render event handler responsible for adding and handling
|
||||||
* the result of adding the note submission form, only if a user is logged in
|
* the result of adding the note submission form, only if a user is logged in
|
||||||
*
|
*
|
||||||
|
* @param array{post_form?: FormInterface} $res
|
||||||
|
*
|
||||||
|
* @throws BugFoundException
|
||||||
* @throws ClientException
|
* @throws ClientException
|
||||||
|
* @throws DuplicateFoundException
|
||||||
* @throws RedirectException
|
* @throws RedirectException
|
||||||
* @throws ServerException
|
* @throws ServerException
|
||||||
*/
|
*/
|
||||||
public function onAppendRightPostingBlock(Request $request, array &$res): bool
|
public function onAddMainRightPanelBlock(Request $request, array &$res): EventResult
|
||||||
{
|
{
|
||||||
if (\is_null($user = Common::user())) {
|
if (\is_null($user = Common::user()) || preg_match('(feed|conversation|group|view)', $request->get('_route')) === 0) {
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
$actor = $user->getActor();
|
$res['post_form'] = Form\Posting::create($request)->createView();
|
||||||
|
|
||||||
$placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
|
|
||||||
Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
|
|
||||||
$placeholder = $placeholder_strings[array_rand($placeholder_strings)];
|
|
||||||
|
|
||||||
$initial_content = '';
|
|
||||||
Event::handle('PostingInitialContent', [&$initial_content]);
|
|
||||||
|
|
||||||
$available_content_types = [
|
|
||||||
_m('Plain Text') => 'text/plain',
|
|
||||||
];
|
|
||||||
Event::handle('PostingAvailableContentTypes', [&$available_content_types]);
|
|
||||||
|
|
||||||
$in_targets = [];
|
|
||||||
Event::handle('PostingFillTargetChoices', [$request, $actor, &$in_targets]);
|
|
||||||
|
|
||||||
$context_actor = null;
|
|
||||||
Event::handle('PostingGetContextActor', [$request, $actor, &$context_actor]);
|
|
||||||
|
|
||||||
$form_params = [];
|
|
||||||
if (!empty($in_targets)) { // @phpstan-ignore-line
|
|
||||||
// Add "none" option to the top of choices
|
|
||||||
$in_targets = array_merge([_m('Public') => 'public'], $in_targets);
|
|
||||||
$form_params[] = ['in', ChoiceType::class, ['label' => _m('In:'), 'multiple' => false, 'expanded' => false, 'choices' => $in_targets]];
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: if in group page, add GROUP visibility to the choices.
|
|
||||||
$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'));
|
|
||||||
|
|
||||||
if (\count($available_content_types) > 1) {
|
|
||||||
$form_params[] = ['content_type', ChoiceType::class,
|
|
||||||
[
|
|
||||||
'label' => _m('Text format:'), 'multiple' => false, 'expanded' => false,
|
|
||||||
'data' => $available_content_types[array_key_first($available_content_types)],
|
|
||||||
'choices' => $available_content_types,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
Event::handle('PostingAddFormEntries', [$request, $actor, &$form_params]);
|
|
||||||
|
|
||||||
$form_params[] = ['post_note', SubmitType::class, ['label' => _m('Post')]];
|
|
||||||
$form = Form::create($form_params);
|
|
||||||
|
|
||||||
$form->handleRequest($request);
|
|
||||||
if ($form->isSubmitted()) {
|
|
||||||
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.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
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 $e) {
|
|
||||||
throw new ClientException(_m('Invalid file size given'), previous: $e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$res['post_form'] = $form->createView();
|
|
||||||
|
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Actor $actor The Actor responsible for the creation of this Note
|
||||||
|
* @param null|string $content The raw text content
|
||||||
|
* @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...)
|
||||||
|
* @param null|string $locale Note's written text language, set by the default Actor language or upon filling
|
||||||
|
* @param null|VisibilityScope $scope The visibility of this Note
|
||||||
|
* @param Actor[]|int[] $attentions Actor|int[]: In Group/To Person or Bot, registers an attention between note and target
|
||||||
|
* @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself
|
||||||
|
* @param UploadedFile[] $attachments UploadedFile[] to be stored as GSFiles associated to this note
|
||||||
|
* @param array<array{Attachment, string}> $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note
|
||||||
|
* @param array{note?: Note, content?: string, content_type?: string, extra_args?: array<string, mixed>} $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
|
||||||
|
* @param bool $flush_and_notify True if the newly created Note activity should be passed on as a Notification
|
||||||
|
* @param null|string $rendered The Note's content post RenderNoteContent event, which sanitizes and processes the raw content sent
|
||||||
|
* @param string $source The source of this Note
|
||||||
|
*
|
||||||
|
* @throws ClientException
|
||||||
|
* @throws DuplicateFoundException
|
||||||
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return array{\App\Entity\Activity, \App\Entity\Note, array<int, \App\Entity\Actor>}
|
||||||
|
*/
|
||||||
|
public static function storeLocalArticle(
|
||||||
|
Actor $actor,
|
||||||
|
?string $content,
|
||||||
|
string $content_type,
|
||||||
|
?string $locale = null,
|
||||||
|
?VisibilityScope $scope = null,
|
||||||
|
array $attentions = [],
|
||||||
|
null|int|Note $reply_to = null,
|
||||||
|
array $attachments = [],
|
||||||
|
array $processed_attachments = [],
|
||||||
|
array $process_note_content_extra_args = [],
|
||||||
|
bool $flush_and_notify = true,
|
||||||
|
?string $rendered = null,
|
||||||
|
string $source = 'web',
|
||||||
|
?string $title = null,
|
||||||
|
): array {
|
||||||
|
[$activity, $note, $effective_attentions] = self::storeLocalNote(
|
||||||
|
actor: $actor,
|
||||||
|
content: $content,
|
||||||
|
content_type: $content_type,
|
||||||
|
locale: $locale,
|
||||||
|
scope: $scope,
|
||||||
|
attentions: $attentions,
|
||||||
|
reply_to: $reply_to,
|
||||||
|
attachments: $attachments,
|
||||||
|
processed_attachments: $processed_attachments,
|
||||||
|
process_note_content_extra_args: $process_note_content_extra_args,
|
||||||
|
flush_and_notify: false,
|
||||||
|
rendered: $rendered,
|
||||||
|
source: $source,
|
||||||
|
);
|
||||||
|
$note->setType('article');
|
||||||
|
$note->setTitle($title);
|
||||||
|
|
||||||
|
if ($flush_and_notify) {
|
||||||
|
// Flush before notification
|
||||||
|
DB::flush();
|
||||||
|
Event::handle('NewNotification', [
|
||||||
|
$actor,
|
||||||
|
$activity,
|
||||||
|
$effective_attentions,
|
||||||
|
_m('Actor {actor_id} created article {note_id}.', [
|
||||||
|
'{actor_id}' => $actor->getId(),
|
||||||
|
'{note_id}' => $activity->getObjectId(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$activity, $note, $effective_attentions];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store the given note with $content and $attachments, created by
|
* Store the given note with $content and $attachments, created by
|
||||||
* $actor_id, possibly as a reply to note $reply_to and with flag
|
* $actor_id, possibly as a reply to note $reply_to and with flag
|
||||||
* $is_local. Sanitizes $content and $attachments
|
* $is_local. Sanitizes $content and $attachments
|
||||||
*
|
*
|
||||||
* @param array $attachments Array of UploadedFile to be stored as GSFiles associated to this note
|
* @param Actor $actor The Actor responsible for the creation of this Note
|
||||||
* @param array $processed_attachments Array of [Attachment, Attachment's name] to be associated to this $actor and Note
|
* @param null|string $content The raw text content
|
||||||
* @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
|
* @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...)
|
||||||
|
* @param null|string $locale Note's written text language, set by the default Actor language or upon filling
|
||||||
|
* @param null|VisibilityScope $scope The visibility of this Note
|
||||||
|
* @param Actor[]|int[] $attentions Actor|int[]: In Group/To Person or Bot, registers an attention between note and targte
|
||||||
|
* @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself
|
||||||
|
* @param UploadedFile[] $attachments UploadedFile[] to be stored as GSFiles associated to this note
|
||||||
|
* @param array<array{Attachment, string}> $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note
|
||||||
|
* @param array{note?: Note, content?: string, content_type?: string, extra_args?: array<string, mixed>} $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
|
||||||
|
* @param bool $flush_and_notify True if the newly created Note activity should be passed on as a Notification
|
||||||
|
* @param null|string $rendered The Note's content post RenderNoteContent event, which sanitizes and processes the raw content sent
|
||||||
|
* @param string $source The source of this Note
|
||||||
*
|
*
|
||||||
* @throws BugFoundException
|
|
||||||
* @throws ClientException
|
* @throws ClientException
|
||||||
* @throws DuplicateFoundException
|
* @throws DuplicateFoundException
|
||||||
* @throws ServerException
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return array{\App\Entity\Activity, \App\Entity\Note, array<int, \App\Entity\Actor>}
|
||||||
*/
|
*/
|
||||||
public static function storeLocalNote(
|
public static function storeLocalNote(
|
||||||
Actor $actor,
|
Actor $actor,
|
||||||
|
@ -206,16 +190,19 @@ class Posting extends Component
|
||||||
string $content_type,
|
string $content_type,
|
||||||
?string $locale = null,
|
?string $locale = null,
|
||||||
?VisibilityScope $scope = null,
|
?VisibilityScope $scope = null,
|
||||||
null|Actor|int $target = null,
|
array $attentions = [],
|
||||||
?int $reply_to_id = null,
|
null|int|Note $reply_to = null,
|
||||||
array $attachments = [],
|
array $attachments = [],
|
||||||
array $processed_attachments = [],
|
array $processed_attachments = [],
|
||||||
array $process_note_content_extra_args = [],
|
array $process_note_content_extra_args = [],
|
||||||
bool $notify = true,
|
bool $flush_and_notify = true,
|
||||||
?string $rendered = null,
|
?string $rendered = null,
|
||||||
string $source = 'web',
|
string $source = 'web',
|
||||||
): Note {
|
): array {
|
||||||
$scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL
|
$scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL
|
||||||
|
$reply_to_id = \is_null($reply_to) ? null : (\is_int($reply_to) ? $reply_to : $reply_to->getId());
|
||||||
|
|
||||||
|
/** @var array<int, array{ mentioned?: array<int, Actor|LocalUser> }> $mentions */
|
||||||
$mentions = [];
|
$mentions = [];
|
||||||
if (\is_null($rendered) && !empty($content)) {
|
if (\is_null($rendered) && !empty($content)) {
|
||||||
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]);
|
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]);
|
||||||
|
@ -246,6 +233,17 @@ class Posting extends Component
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::persist($note);
|
DB::persist($note);
|
||||||
|
Conversation::assignLocalConversation($note, $reply_to_id);
|
||||||
|
|
||||||
|
// Update replies cache
|
||||||
|
if (!\is_null($reply_to_id)) {
|
||||||
|
Cache::incr(Note::cacheKeys($reply_to_id)['replies-count']);
|
||||||
|
// Not having them cached doesn't mean replies don't exist, but don't push it to the
|
||||||
|
// list, as that means they need to be re-fetched, or some would be missed
|
||||||
|
if (Cache::exists(Note::cacheKeys($reply_to_id)['replies'])) {
|
||||||
|
Cache::listPushRight(Note::cacheKeys($reply_to_id)['replies'], $note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Need file and note ids for the next step
|
// Need file and note ids for the next step
|
||||||
$note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL));
|
$note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL));
|
||||||
|
@ -253,18 +251,18 @@ class Posting extends Component
|
||||||
Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]);
|
Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// These are note attachments now, and not just attachments, ensure these relations are respected
|
||||||
if ($processed_attachments !== []) {
|
if ($processed_attachments !== []) {
|
||||||
foreach ($processed_attachments as [$a, $fname]) {
|
foreach ($processed_attachments as [$a, $fname]) {
|
||||||
if (DB::count('actor_to_attachment', $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) {
|
// Most attachments should already be associated with its author, but maybe it didn't make sense
|
||||||
|
//for this attachment, or it's simply a repost of an attachment by a different actor
|
||||||
|
if (DB::count(ActorToAttachment::class, $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) {
|
||||||
DB::persist(ActorToAttachment::create($args));
|
DB::persist(ActorToAttachment::create($args));
|
||||||
}
|
}
|
||||||
DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname]));
|
DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname]));
|
||||||
$a->livesIncrementAndGet();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Conversation::assignLocalConversation($note, $reply_to_id);
|
|
||||||
|
|
||||||
$activity = Activity::create([
|
$activity = Activity::create([
|
||||||
'actor_id' => $actor->getId(),
|
'actor_id' => $actor->getId(),
|
||||||
'verb' => 'create',
|
'verb' => 'create',
|
||||||
|
@ -274,32 +272,60 @@ class Posting extends Component
|
||||||
]);
|
]);
|
||||||
DB::persist($activity);
|
DB::persist($activity);
|
||||||
|
|
||||||
if (!\is_null($target)) {
|
$effective_attentions = [];
|
||||||
$target = \is_int($target) ? Actor::getById($target) : $target;
|
foreach ($attentions as $target) {
|
||||||
$mentions[] = [
|
if (\is_int($target)) {
|
||||||
'mentioned' => [$target],
|
$target_id = $target;
|
||||||
'type' => match ($target->getType()) {
|
$add = !\array_key_exists($target_id, $effective_attentions);
|
||||||
Actor::PERSON => 'mention',
|
$effective_attentions[$target_id] = $target;
|
||||||
Actor::GROUP => 'group',
|
} else {
|
||||||
default => throw new ClientException(_m('Unknown target type give in \'In\' field: {target}', ['{target}' => $target?->getNickname() ?? '<null>'])),
|
$target_id = $target->getId();
|
||||||
},
|
if ($add = !\array_key_exists($target_id, $effective_attentions)) {
|
||||||
'text' => $target->getNickname(),
|
$effective_attentions[$target_id] = $target_id;
|
||||||
];
|
}
|
||||||
|
}
|
||||||
|
if ($add) {
|
||||||
|
DB::persist(Attention::create(['object_type' => Note::schemaName(), 'object_id' => $note->getId(), 'target_id' => $target_id]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$mention_ids = F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId())));
|
foreach ($mentions as $m) {
|
||||||
|
foreach ($m['mentioned'] ?? [] as $mentioned) {
|
||||||
// Flush before notification
|
$target_id = $mentioned->getId();
|
||||||
DB::flush();
|
if (!\array_key_exists($target_id, $effective_attentions)) {
|
||||||
|
DB::persist(Attention::create(['object_type' => Note::schemaName(), 'object_id' => $note->getId(), 'target_id' => $target_id]));
|
||||||
if ($notify) {
|
}
|
||||||
Event::handle('NewNotification', [$actor, $activity, ['object' => $mention_ids], _m('{nickname} created a note {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $activity->getObjectId()])]);
|
$effective_attentions[$target_id] = $mentioned;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $note;
|
foreach ($actor->getSubscribers() as $subscriber) {
|
||||||
|
$target_id = $subscriber->getId();
|
||||||
|
DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $target_id]));
|
||||||
|
$effective_attentions[$target_id] = $subscriber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($flush_and_notify) {
|
||||||
|
// Flush before notification
|
||||||
|
DB::flush();
|
||||||
|
Event::handle('NewNotification', [
|
||||||
|
$actor,
|
||||||
|
$activity,
|
||||||
|
$effective_attentions,
|
||||||
|
_m('Actor {actor_id} created note {note_id}.', [
|
||||||
|
'{actor_id}' => $actor->getId(),
|
||||||
|
'{note_id}' => $activity->getObjectId(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$activity, $note, $effective_attentions];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onRenderNoteContent(string $content, string $content_type, ?string &$rendered, Actor $author, ?string $language = null, array &$mentions = [])
|
/**
|
||||||
|
* @param array<int, \App\Entity\Actor> $mentions
|
||||||
|
*/
|
||||||
|
public function onRenderNoteContent(string $content, string $content_type, ?string &$rendered, Actor $author, ?string $language = null, array &$mentions = []): EventResult
|
||||||
{
|
{
|
||||||
switch ($content_type) {
|
switch ($content_type) {
|
||||||
case 'text/plain':
|
case 'text/plain':
|
||||||
|
|
|
@ -1,65 +1,97 @@
|
||||||
{% block rightpanel %}
|
{% macro posting(form) %}
|
||||||
<label class="panel-right-icon" for="toggle-panel-right" tabindex="-1">{{ icon('chevron-left', 'icon icon-right') | raw }}</label>
|
<section class="section-form">
|
||||||
<a id="anchor-right-panel" class="anchor-hidden" tabindex="0" title="{{ 'Press tab followed by a space to access right panel' | trans }}"></a>
|
{{ form_start(form) }}
|
||||||
<input type="checkbox" id="toggle-panel-right" tabindex="0" title="{{ 'Open right panel' | trans }}">
|
{{ form_errors(form) }}
|
||||||
|
{% if form.in is defined %}
|
||||||
<aside class="section-panel section-panel-right">
|
{{ form_row(form.in) }}
|
||||||
<section class="panel-content accessibility-target">
|
|
||||||
{% set prepend_right_panel = handle_event('PrependRightPanel', request) %}
|
|
||||||
{% for widget in prepend_right_panel %}
|
|
||||||
{{ widget | raw }}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% set current_path = app.request.get('_route') %}
|
|
||||||
{% set blocks = handle_event('AppendRightPostingBlock', request) %}
|
|
||||||
{% if blocks['post_form'] is defined %}
|
|
||||||
<section class="frame-section" title="{{ 'Create a new note.' | trans }}">
|
|
||||||
<details class="section-details-title" open="open"
|
|
||||||
title="{{ 'Expand if you want to access more options.' | trans }}">
|
|
||||||
<summary class="details-summary-title">
|
|
||||||
<h2>
|
|
||||||
{% set current_path = app.request.get('_route') %}
|
|
||||||
{% if current_path == 'conversation_reply_to' %}
|
|
||||||
{{ "Reply to note" | trans }}
|
|
||||||
{% else %}
|
|
||||||
{{ "Create a note" | trans }}
|
|
||||||
{% endif %}
|
|
||||||
</h2>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<section class="section-form">
|
|
||||||
{{ form_start(blocks['post_form']) }}
|
|
||||||
{{ form_errors(blocks['post_form']) }}
|
|
||||||
{% if blocks['post_form'].in is defined %}
|
|
||||||
{{ form_row(blocks['post_form'].in) }}
|
|
||||||
{% endif %}
|
|
||||||
{{ form_row(blocks['post_form'].visibility) }}
|
|
||||||
{{ form_row(blocks['post_form'].content_type) }}
|
|
||||||
{{ form_row(blocks['post_form'].content) }}
|
|
||||||
{{ form_row(blocks['post_form'].attachments) }}
|
|
||||||
|
|
||||||
<details class="section-details-subtitle">
|
|
||||||
<summary class="details-summary-subtitle">
|
|
||||||
<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 %}
|
{% endif %}
|
||||||
|
{{ form_row(form.visibility) }}
|
||||||
|
{{ form_row(form.content_type) }}
|
||||||
|
{{ form_row(form.content) }}
|
||||||
|
{{ form_row(form.attachments) }}
|
||||||
|
|
||||||
{% set extra_blocks = get_right_panel_blocks({'path': current_path, 'request': app.request, 'vars': (right_panel_vars | default)}) %}
|
<details class="section-details-subtitle frame-section">
|
||||||
{% for block in extra_blocks %}
|
<summary class="details-summary-subtitle">
|
||||||
{{ block | raw }}
|
<strong>
|
||||||
{% endfor %}
|
{% trans %}Additional options{% endtrans %}
|
||||||
|
</strong>
|
||||||
|
</summary>
|
||||||
|
<section class="section-form">
|
||||||
|
{{ form_row(form.language) }}
|
||||||
|
{{ form_row(form.tag_use_canonical) }}
|
||||||
|
</section>
|
||||||
|
</details>
|
||||||
|
{{ form_rest(form) }}
|
||||||
|
{{ form_end(form) }}
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro posting_section_vanilla(widget) %}
|
||||||
|
<section class="frame-section" title="{% trans %}Create a new note{% endtrans %}">
|
||||||
|
<details class="section-details-title" open="open"
|
||||||
|
title="{% trans %}Expand if you want to access more options{% endtrans %}">
|
||||||
|
<summary class="details-summary-title">
|
||||||
|
<span>
|
||||||
|
{% trans %}Create a note{% endtrans %}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
{% import _self as forms %}
|
||||||
|
{{ forms.posting(widget) }}
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro posting_section_reply(widget, extra) %}
|
||||||
|
<section class="frame-section" title="{% trans %}Create a new note{% endtrans %}">
|
||||||
|
<details class="section-details-title" open="open"
|
||||||
|
title="{% trans %}Expand if you want to access more options{% endtrans %}">
|
||||||
|
<summary class="details-summary-title">
|
||||||
|
<span>
|
||||||
|
{% trans %}Reply to note{% endtrans %}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
{% for block in extra %}
|
||||||
|
<section class="posting-extra">
|
||||||
|
{{ block | raw }}
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% import _self as forms %}
|
||||||
|
{{ forms.posting(widget) }}
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block rightpanel %}
|
||||||
|
{% import _self as this %}
|
||||||
|
<label class="panel-right-icon" for="toggle-panel-right"
|
||||||
|
tabindex="-1">{{ icon('chevron-left', 'icon icon-right') | raw }}</label>
|
||||||
|
<a id="anchor-right-panel" class="anchor-hidden" tabindex="0"
|
||||||
|
title="{% trans %}Press tab followed by a space to access right panel{% endtrans %}"></a>
|
||||||
|
<input type="checkbox" id="toggle-panel-right" tabindex="0" title="{% trans %}Open right panel{% endtrans %}"
|
||||||
|
{% if app.request.get('_route') == 'conversation_reply_to' %}checked{% endif %}>
|
||||||
|
|
||||||
|
<aside class="section-panel section-panel-right">
|
||||||
|
{% set var_list = {'path': app.request.get('_route'), 'request': app.request, 'vars': right_panel_vars | default } %}
|
||||||
|
{% set blocks = add_right_panel_block('prepend', var_list) %}
|
||||||
|
{% set blocks = blocks|merge(add_right_panel_block('main', var_list)) %}
|
||||||
|
{% set blocks = blocks|merge(add_right_panel_block('append', var_list)) %}
|
||||||
|
|
||||||
|
<section class="panel-content accessibility-target">
|
||||||
|
{% for widget in blocks %}
|
||||||
|
{% if widget is iterable and widget.vars.id == 'post_note' %}
|
||||||
|
{% if app.request.get('_route') == 'conversation_reply_to' %}
|
||||||
|
{% set extra = handle_event('PrependPostingForm', request) %}
|
||||||
|
{{ this.posting_section_reply(widget, extra) }}
|
||||||
|
{% else %}
|
||||||
|
{{ this.posting_section_vanilla(widget) }}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ widget | raw }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
{% endblock rightpanel %}
|
{% endblock rightpanel %}
|
||||||
|
|
|
@ -30,6 +30,7 @@ use App\Util\Exception\BugFoundException;
|
||||||
use App\Util\Exception\RedirectException;
|
use App\Util\Exception\RedirectException;
|
||||||
use App\Util\Form\FormFields;
|
use App\Util\Form\FormFields;
|
||||||
use App\Util\Formatting;
|
use App\Util\Formatting;
|
||||||
|
use App\Util\HTML\Heading;
|
||||||
use Component\Collection\Util\Controller\FeedController;
|
use Component\Collection\Util\Controller\FeedController;
|
||||||
use Component\Search as Comp;
|
use Component\Search as Comp;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
|
@ -37,12 +38,17 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends FeedController<\App\Entity\Note>
|
||||||
|
*/
|
||||||
class Search extends FeedController
|
class Search extends FeedController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Handle a search query
|
* Handle a search query
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request)
|
public function handle(Request $request): array
|
||||||
{
|
{
|
||||||
$actor = Common::actor();
|
$actor = Common::actor();
|
||||||
$language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null;
|
$language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null;
|
||||||
|
@ -135,6 +141,8 @@ class Search extends FeedController
|
||||||
'search_form' => Comp\Search::searchForm($request, query: $q, add_subscribe: !\is_null($actor)),
|
'search_form' => Comp\Search::searchForm($request, query: $q, add_subscribe: !\is_null($actor)),
|
||||||
'search_builder_form' => $search_builder_form->createView(),
|
'search_builder_form' => $search_builder_form->createView(),
|
||||||
'notes' => $notes ?? [],
|
'notes' => $notes ?? [],
|
||||||
|
'notes_feed_title' => (new Heading(level: 3, classes: ['section-title'], text: 'Notes found')),
|
||||||
|
'actors_feed_title' => (new Heading(level: 3, classes: ['section-title'], text: 'Actors found')),
|
||||||
'actors' => $actors ?? [],
|
'actors' => $actors ?? [],
|
||||||
'page' => 1, // TODO paginate
|
'page' => 1, // TODO paginate
|
||||||
];
|
];
|
||||||
|
|
|
@ -27,9 +27,12 @@ use App\Core\Event;
|
||||||
use App\Core\Form;
|
use App\Core\Form;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
|
use App\Core\Router;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
|
use App\Util\Exception\ClientException;
|
||||||
use App\Util\Exception\RedirectException;
|
use App\Util\Exception\RedirectException;
|
||||||
use App\Util\Formatting;
|
use App\Util\Formatting;
|
||||||
|
use EventResult;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormView;
|
use Symfony\Component\Form\FormView;
|
||||||
|
@ -38,9 +41,10 @@ use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
class Search extends Component
|
class Search extends Component
|
||||||
{
|
{
|
||||||
public function onAddRoute($r)
|
public function onAddRoute(Router $r): EventResult
|
||||||
{
|
{
|
||||||
$r->connect('search', '/search', Controller\Search::class);
|
$r->connect('search', '/search', Controller\Search::class);
|
||||||
|
return EventResult::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -63,13 +67,22 @@ class Search extends Component
|
||||||
|
|
||||||
if ($add_subscribe) {
|
if ($add_subscribe) {
|
||||||
$form_definition[] = [
|
$form_definition[] = [
|
||||||
'title', TextType::class, ['label' => _m('Title'), 'required' => false, 'attr' => ['title' => _m('Title for this new feed in your left panel')]],
|
'title', TextType::class,
|
||||||
|
[
|
||||||
|
'label' => _m('Subscribe to search query'),
|
||||||
|
'help' => _m('By subscribing to a search query, a new feed link will be added to left panel\'s feed navigation menu'),
|
||||||
|
'required' => false,
|
||||||
|
'attr' => [
|
||||||
|
'title' => _m('Title for this new feed in your left panel'),
|
||||||
|
'placeholder' => _m('Input desired title...'),
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
$form_definition[] = [
|
$form_definition[] = [
|
||||||
'subscribe_to_search',
|
'subscribe_to_search',
|
||||||
SubmitType::class,
|
SubmitType::class,
|
||||||
[
|
[
|
||||||
'label' => _m('Subscribe to this search'),
|
'label' => _m('Subscribe'),
|
||||||
'attr' => [
|
'attr' => [
|
||||||
'title' => _m('Add this search as a feed in your feeds section of the left panel'),
|
'title' => _m('Add this search as a feed in your feeds section of the left panel'),
|
||||||
],
|
],
|
||||||
|
@ -100,8 +113,11 @@ class Search extends Component
|
||||||
/** @var SubmitButton $subscribe */
|
/** @var SubmitButton $subscribe */
|
||||||
$subscribe = $form->get('subscribe_to_search');
|
$subscribe = $form->get('subscribe_to_search');
|
||||||
if ($subscribe->isClicked()) {
|
if ($subscribe->isClicked()) {
|
||||||
// TODO ensure title is set
|
if (!\is_null($data['title'])) {
|
||||||
Event::handle('AppendFeed', [$actor, $data['title'], 'search', ['q' => $data['search_query']]]);
|
Event::handle('AppendFeed', [$actor, $data['title'], 'search', ['q' => $data['search_query']]]);
|
||||||
|
} else {
|
||||||
|
throw new ClientException(_m('Empty title is not allowed.'));
|
||||||
|
}
|
||||||
$redirect = true;
|
$redirect = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -118,9 +134,11 @@ class Search extends Component
|
||||||
/**
|
/**
|
||||||
* Add the search form to the site header
|
* Add the search form to the site header
|
||||||
*
|
*
|
||||||
|
* @param string[] $elements
|
||||||
|
*
|
||||||
* @throws RedirectException
|
* @throws RedirectException
|
||||||
*/
|
*/
|
||||||
public function onPrependRightPanel(Request $request, array &$elements)
|
public function onPrependRightPanelBlock(Request $request, array &$elements): EventResult
|
||||||
{
|
{
|
||||||
$elements[] = Formatting::twigRenderFile('cards/search/view.html.twig', ['search' => self::searchForm($request)]);
|
$elements[] = Formatting::twigRenderFile('cards/search/view.html.twig', ['search' => self::searchForm($request)]);
|
||||||
return Event::next;
|
return Event::next;
|
||||||
|
@ -129,11 +147,9 @@ class Search extends Component
|
||||||
/**
|
/**
|
||||||
* Output our dedicated stylesheet
|
* Output our dedicated stylesheet
|
||||||
*
|
*
|
||||||
* @param array $styles stylesheets path
|
* @param string[] $styles stylesheets path
|
||||||
*
|
|
||||||
* @return bool hook value; true means continue processing, false means stop
|
|
||||||
*/
|
*/
|
||||||
public function onEndShowStyles(array &$styles, string $route): bool
|
public function onEndShowStyles(array &$styles, string $route): EventResult
|
||||||
{
|
{
|
||||||
$styles[] = 'components/Search/assets/css/view.css';
|
$styles[] = 'components/Search/assets/css/view.css';
|
||||||
return Event::next;
|
return Event::next;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="section-form form-search" title="{{ 'Search for notes, actors, and beyond' | trans }}">
|
<section class="section-form form-search" title="{% trans %}Search for notes, actors, and beyond{% endtrans %}">
|
||||||
{{ form_start(search) }}
|
{{ form_start(search) }}
|
||||||
<span>{{ form_row(search.search_query) }}{{ form_row(search.submit_search) }}</span>
|
<span>{{ form_row(search.search_query) }}{{ form_row(search.submit_search) }}</span>
|
||||||
{{ form_rest(search) }}
|
{{ form_rest(search) }}
|
||||||
|
|
|
@ -1,119 +1,130 @@
|
||||||
{% extends 'collection/notes.html.twig' %}
|
{% extends 'collection/notes.html.twig' %}
|
||||||
|
|
||||||
|
{% block search_query_simple %}
|
||||||
|
<section>
|
||||||
|
<h1 class="section-title">{% trans %}Search{% endtrans %}</h1>
|
||||||
|
|
||||||
|
{{ form_start(search_form) }}
|
||||||
|
{{ form_errors(search_form) }}
|
||||||
|
{{ form_row(search_form.search_query) }}
|
||||||
|
{% if actor is not null %}
|
||||||
|
<details class="section-details-subtitle frame-section">
|
||||||
|
<summary class="details-summary-subtitle">
|
||||||
|
<strong>
|
||||||
|
{% trans %}Extra options{% endtrans %}
|
||||||
|
</strong>
|
||||||
|
</summary>
|
||||||
|
<div class="section-form">
|
||||||
|
{{ form_row(search_form.title) }}
|
||||||
|
{{ form_row(search_form.subscribe_to_search) }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
{{ form_row(search_form.submit_search) }}
|
||||||
|
{{ form_rest(search_form) }}
|
||||||
|
{{ form_end(search_form)}}
|
||||||
|
</section>
|
||||||
|
{% endblock search_query_simple %}
|
||||||
|
|
||||||
|
{% block search_query_advanced %}
|
||||||
|
{{ form_start(search_builder_form) }}
|
||||||
|
<details class="section-details section-details-title frame-section">
|
||||||
|
<summary class="details-summary-title">
|
||||||
|
<span>{% trans %}Advanced search{% endtrans %}</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<section class="frame-section-padding">
|
||||||
|
<details class="section-details-subtitle frame-section">
|
||||||
|
<summary class="details-summary-subtitle">
|
||||||
|
<strong>{% trans %}People search options{% endtrans %}</strong>
|
||||||
|
</summary>
|
||||||
|
<div class="section-form">
|
||||||
|
<div class="section-checkbox-flex">
|
||||||
|
{{ 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) }}
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
{{ form_row(search_builder_form.actor_tags) }}
|
||||||
|
<hr>
|
||||||
|
{{ form_row(search_builder_form.actor_langs) }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="section-details-subtitle frame-section">
|
||||||
|
<summary class="details-summary-subtitle">
|
||||||
|
<strong>{% trans %}Note search options{% endtrans %}</strong>
|
||||||
|
</summary>
|
||||||
|
<div class="section-form">
|
||||||
|
<div class="section-checkbox-flex">
|
||||||
|
{{ 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) }}
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
{{ form_row(search_builder_form.note_tags) }}
|
||||||
|
<hr>
|
||||||
|
{{ form_row(search_builder_form.note_langs) }}
|
||||||
|
<hr>
|
||||||
|
{{ form_row(search_builder_form.note_actor_tags) }}
|
||||||
|
<hr>
|
||||||
|
{{ form_row(search_builder_form.note_actor_langs) }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{{ form_rest(search_builder_form) }}
|
||||||
|
</section>
|
||||||
|
</details>
|
||||||
|
{{ form_end(search_builder_form) }}
|
||||||
|
{% endblock search_query_advanced %}
|
||||||
|
|
||||||
|
{% block search %}
|
||||||
|
<section class="frame-section frame-section-padding">
|
||||||
|
{% if error is defined %}
|
||||||
|
<label class="alert alert-danger">
|
||||||
|
{{ error.getMessage() }}
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{{ block('search_query_simple') }}
|
||||||
|
<hr>
|
||||||
|
{{ block('search_query_advanced') }}
|
||||||
|
</section>
|
||||||
|
{% endblock search %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% if error is defined %}
|
{{ block('search') }}
|
||||||
<label class="alert alert-danger">
|
<div class="frame-section frame-section-padding">
|
||||||
{{ error.getMessage() }}
|
<h1 class="section-title">{% trans %}Results{% endtrans %}</h1>
|
||||||
</label>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section class="frame-section frame-section-padding">
|
<section>
|
||||||
<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 %}
|
{% if notes is defined and notes is not empty %}
|
||||||
{{ parent() }}
|
{{ parent() }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<h3>{% trans %}No notes found{% endtrans %}</h3>
|
<h3>{% trans %}No notes found{% endtrans %}</h3>
|
||||||
<em>{% trans %}No notes were found for the specified query...{% endtrans %}</em>
|
<em>{% trans %}No notes were found for the specified query...{% endtrans %}</em>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</section>
|
||||||
|
<hr>
|
||||||
<div class="frame-section frame-section-padding feed-empty">
|
<section>
|
||||||
<h3>{% trans %}Actors found{% endtrans %}</h3>
|
<h3>{% trans %}Actors found{% endtrans %}</h3>
|
||||||
{% if actors is defined and actors is not empty %}
|
{% if actors is defined and actors is not empty %}
|
||||||
{% for actor in actors %}
|
{% for actor in actors %}
|
||||||
{% include 'cards/profile/view.html.twig' with {'actor': actor} %}
|
{% include 'cards/blocks/profile.html.twig' with {'actor': actor} %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<em>{% trans %}No Actors were found for the specified query...{% endtrans %}</em>
|
<em>{% trans %}No Actors were found for the specified query...{% endtrans %}</em>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
{{ "Page: " ~ page }}
|
<div class="frame-section-button-like">
|
||||||
|
{{ "Page: " ~ page }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock body %}
|
{% endblock body %}
|
||||||
|
|
||||||
|
|
|
@ -23,16 +23,16 @@ declare(strict_types = 1);
|
||||||
|
|
||||||
namespace Component\Subscription\Controller;
|
namespace Component\Subscription\Controller;
|
||||||
|
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Form;
|
use App\Core\Form;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Log;
|
use App\Core\Log;
|
||||||
use App\Core\Router\Router;
|
use App\Core\Router;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Util\Common;
|
use App\Util\Common;
|
||||||
use App\Util\Exception\ClientException;
|
use App\Util\Exception\ClientException;
|
||||||
use App\Util\Exception\RedirectException;
|
use App\Util\Exception\RedirectException;
|
||||||
use Component\Collection\Util\ActorControllerTrait;
|
use App\Util\Exception\ServerException;
|
||||||
use Component\Collection\Util\Controller\CircleController;
|
use Component\Collection\Util\Controller\CircleController;
|
||||||
use Component\Subscription\Subscription as SubscriptionComponent;
|
use Component\Subscription\Subscription as SubscriptionComponent;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
|
@ -43,30 +43,36 @@ use Symfony\Component\HttpFoundation\Request;
|
||||||
*/
|
*/
|
||||||
class Subscribers extends CircleController
|
class Subscribers extends CircleController
|
||||||
{
|
{
|
||||||
use ActorControllerTrait;
|
/**
|
||||||
public function subscribersByActorId(Request $request, int $id)
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
|
public function subscribersByActor(Request $request, Actor $actor): array
|
||||||
{
|
{
|
||||||
return $this->handleActorById(
|
return [
|
||||||
$id,
|
'_template' => 'collection/actors.html.twig',
|
||||||
fn ($actor) => [
|
'title' => _m('Subscribers'),
|
||||||
'actor' => $actor,
|
'empty_message' => _m('No subscribers.'),
|
||||||
],
|
'sort_form_fields' => [],
|
||||||
);
|
'page' => $this->int('page') ?? 1,
|
||||||
|
'actors' => $actor->getSubscribers(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function subscribersByActorNickname(Request $request, string $nickname)
|
/**
|
||||||
|
* @throws ClientException
|
||||||
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
|
public function subscribersByActorId(Request $request, int $id): array
|
||||||
{
|
{
|
||||||
return $this->handleActorByNickname(
|
$actor = Actor::getById($id);
|
||||||
$nickname,
|
if (\is_null($actor)) {
|
||||||
fn ($actor) => [
|
throw new ClientException(_m('No such actor.'), 404);
|
||||||
'_template' => 'collection/actors.html.twig',
|
}
|
||||||
'title' => _m('Subscribers'),
|
return $this->subscribersByActor($request, $actor);
|
||||||
'empty_message' => _m('No subscribers.'),
|
|
||||||
'sort_form_fields' => [],
|
|
||||||
'page' => $this->int('page') ?? 1,
|
|
||||||
'actors' => $actor->getSubscribers(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -76,6 +82,8 @@ class Subscribers extends CircleController
|
||||||
* @throws \App\Util\Exception\ServerException
|
* @throws \App\Util\Exception\ServerException
|
||||||
* @throws ClientException
|
* @throws ClientException
|
||||||
* @throws RedirectException
|
* @throws RedirectException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
*/
|
*/
|
||||||
public function subscribersAdd(Request $request, int $object_id): array
|
public function subscribersAdd(Request $request, int $object_id): array
|
||||||
{
|
{
|
||||||
|
@ -124,6 +132,8 @@ class Subscribers extends CircleController
|
||||||
* @throws \App\Util\Exception\ServerException
|
* @throws \App\Util\Exception\ServerException
|
||||||
* @throws ClientException
|
* @throws ClientException
|
||||||
* @throws RedirectException
|
* @throws RedirectException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
*/
|
*/
|
||||||
public function subscribersRemove(Request $request, int $object_id): array
|
public function subscribersRemove(Request $request, int $object_id): array
|
||||||
{
|
{
|
||||||
|
|
|
@ -24,7 +24,9 @@ declare(strict_types = 1);
|
||||||
namespace Component\Subscription\Controller;
|
namespace Component\Subscription\Controller;
|
||||||
|
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use Component\Collection\Util\ActorControllerTrait;
|
use App\Entity\Actor;
|
||||||
|
use App\Util\Exception\ClientException;
|
||||||
|
use App\Util\Exception\ServerException;
|
||||||
use Component\Collection\Util\Controller\CircleController;
|
use Component\Collection\Util\Controller\CircleController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
@ -33,29 +35,33 @@ use Symfony\Component\HttpFoundation\Request;
|
||||||
*/
|
*/
|
||||||
class Subscriptions extends CircleController
|
class Subscriptions extends CircleController
|
||||||
{
|
{
|
||||||
use ActorControllerTrait;
|
/**
|
||||||
public function subscriptionsByActorId(Request $request, int $id)
|
* @throws ClientException
|
||||||
|
* @throws ServerException
|
||||||
|
*
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
|
public function subscriptionsByActorId(Request $request, int $id): array
|
||||||
{
|
{
|
||||||
return $this->handleActorById(
|
$actor = Actor::getById($id);
|
||||||
$id,
|
if (\is_null($actor)) {
|
||||||
fn ($actor) => [
|
throw new ClientException(_m('No such actor.'), 404);
|
||||||
'actor' => $actor,
|
}
|
||||||
],
|
return $this->subscriptionsByActor($request, $actor);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function subscriptionsByActorNickname(Request $request, string $nickname)
|
/**
|
||||||
|
* @return ControllerResultType
|
||||||
|
*/
|
||||||
|
public function subscriptionsByActor(Request $request, Actor $actor): array
|
||||||
{
|
{
|
||||||
return $this->handleActorByNickname(
|
return [
|
||||||
$nickname,
|
'_template' => 'collection/actors.html.twig',
|
||||||
fn ($actor) => [
|
'title' => _m('Subscriptions'),
|
||||||
'_template' => 'collection/actors.html.twig',
|
'empty_message' => _m('Haven\'t subscribed anyone.'),
|
||||||
'title' => _m('Subscriptions'),
|
'sort_form_fields' => [],
|
||||||
'empty_message' => _m('Haven\'t subscribed anyone.'),
|
'page' => $this->int('page') ?? 1,
|
||||||
'sort_form_fields' => [],
|
'actors' => $actor->getSubscribers(),
|
||||||
'page' => $this->int('page') ?? 1,
|
];
|
||||||
'actors' => $actor->getSubscribers(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -114,27 +114,6 @@ class ActorSubscription extends Entity
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @see Entity->getNotificationTargetIds
|
|
||||||
*/
|
|
||||||
public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array
|
|
||||||
{
|
|
||||||
if (!\array_key_exists('object', $ids_already_known)) {
|
|
||||||
$target_ids = [$this->getSubscribedId()]; // The object of any subscription is the one subscribed (or unsubscribed)
|
|
||||||
} else {
|
|
||||||
$target_ids = $ids_already_known['object'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional actors that should know about this
|
|
||||||
if ($include_additional && \array_key_exists('additional', $ids_already_known)) {
|
|
||||||
array_push($target_ids, ...$ids_already_known['additional']);
|
|
||||||
} else {
|
|
||||||
return $target_ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_unique($target_ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function schemaDef(): array
|
public static function schemaDef(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
|
@ -24,12 +24,11 @@ declare(strict_types = 1);
|
||||||
namespace Component\Subscription;
|
namespace Component\Subscription;
|
||||||
|
|
||||||
use App\Core\Cache;
|
use App\Core\Cache;
|
||||||
use App\Core\DB\DB;
|
use App\Core\DB;
|
||||||
use App\Core\Event;
|
use App\Core\Event;
|
||||||
use function App\Core\I18n\_m;
|
use function App\Core\I18n\_m;
|
||||||
use App\Core\Modules\Component;
|
use App\Core\Modules\Component;
|
||||||
use App\Core\Router\RouteLoader;
|
use App\Core\Router;
|
||||||
use App\Core\Router\Router;
|
|
||||||
use App\Entity\Activity;
|
use App\Entity\Activity;
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\LocalUser;
|
use App\Entity\LocalUser;
|
||||||
|
@ -37,22 +36,20 @@ use App\Util\Common;
|
||||||
use App\Util\Exception\DuplicateFoundException;
|
use App\Util\Exception\DuplicateFoundException;
|
||||||
use App\Util\Exception\NotFoundException;
|
use App\Util\Exception\NotFoundException;
|
||||||
use App\Util\Exception\ServerException;
|
use App\Util\Exception\ServerException;
|
||||||
use App\Util\Nickname;
|
use Component\Notification\Entity\Attention;
|
||||||
use Component\Subscription\Controller\Subscribers as SubscribersController;
|
use Component\Subscription\Controller\Subscribers as SubscribersController;
|
||||||
use Component\Subscription\Controller\Subscriptions as SubscriptionsController;
|
use Component\Subscription\Controller\Subscriptions as SubscriptionsController;
|
||||||
|
use EventResult;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
class Subscription extends Component
|
class Subscription extends Component
|
||||||
{
|
{
|
||||||
public function onAddRoute(RouteLoader $r): bool
|
public function onAddRoute(Router $r): EventResult
|
||||||
{
|
{
|
||||||
$r->connect(id: 'actor_subscribe_add', uri_path: '/actor/subscribe/{object_id<\d+>}', target: [SubscribersController::class, 'subscribersAdd']);
|
$r->connect(id: 'actor_subscribe_add', uri_path: '/actor/subscribe/{object_id<\d+>}', target: [SubscribersController::class, 'subscribersAdd']);
|
||||||
$r->connect(id: 'actor_subscribe_remove', uri_path: '/actor/unsubscribe/{object_id<\d+>}', target: [SubscribersController::class, 'subscribersRemove']);
|
$r->connect(id: 'actor_subscribe_remove', uri_path: '/actor/unsubscribe/{object_id<\d+>}', target: [SubscribersController::class, 'subscribersRemove']);
|
||||||
$r->connect(id: 'actor_subscriptions_id', uri_path: '/actor/{id<\d+>}/subscriptions', target: [SubscriptionsController::class, 'subscriptionsByActorId']);
|
$r->connect(id: 'actor_subscriptions_id', uri_path: '/actor/{id<\d+>}/subscriptions', target: [SubscriptionsController::class, 'subscriptionsByActorId']);
|
||||||
$r->connect(id: 'actor_subscriptions_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/subscriptions', target: [SubscriptionsController::class, 'subscriptionsByActorNickname']);
|
|
||||||
$r->connect(id: 'actor_subscribers_id', uri_path: '/actor/{id<\d+>}/subscribers', target: [SubscribersController::class, 'subscribersByActorId']);
|
$r->connect(id: 'actor_subscribers_id', uri_path: '/actor/{id<\d+>}/subscribers', target: [SubscribersController::class, 'subscribersByActorId']);
|
||||||
$r->connect(id: 'actor_subscribers_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/subscribers', target: [SubscribersController::class, 'subscribersByActorNickname']);
|
|
||||||
return Event::next;
|
return Event::next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,6 +58,8 @@ class Subscription extends Component
|
||||||
*
|
*
|
||||||
* @param Actor|int|LocalUser $subject The Actor who subscribed or unsubscribed
|
* @param Actor|int|LocalUser $subject The Actor who subscribed or unsubscribed
|
||||||
* @param Actor|int|LocalUser $object The Actor who was subscribed or unsubscribed from
|
* @param Actor|int|LocalUser $object The Actor who was subscribed or unsubscribed from
|
||||||
|
*
|
||||||
|
* @return array{bool, bool}
|
||||||
*/
|
*/
|
||||||
public static function refreshSubscriptionCount(int|Actor|LocalUser $subject, int|Actor|LocalUser $object): array
|
public static function refreshSubscriptionCount(int|Actor|LocalUser $subject, int|Actor|LocalUser $object): array
|
||||||
{
|
{
|
||||||
|
@ -70,7 +69,7 @@ class Subscription extends Component
|
||||||
$cache_subscriber = Cache::delete(Actor::cacheKeys($subscriber_id)['subscribed']);
|
$cache_subscriber = Cache::delete(Actor::cacheKeys($subscriber_id)['subscribed']);
|
||||||
$cache_subscribed = Cache::delete(Actor::cacheKeys($subscribed_id)['subscribers']);
|
$cache_subscribed = Cache::delete(Actor::cacheKeys($subscribed_id)['subscribers']);
|
||||||
|
|
||||||
return [$cache_subscriber,$cache_subscribed];
|
return [$cache_subscriber, $cache_subscribed];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -100,22 +99,24 @@ class Subscription extends Component
|
||||||
$subscription = DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true);
|
$subscription = DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true);
|
||||||
$activity = null;
|
$activity = null;
|
||||||
if (\is_null($subscription)) {
|
if (\is_null($subscription)) {
|
||||||
DB::persist(Entity\ActorSubscription::create($opts));
|
DB::persist($subscription = Entity\ActorSubscription::create($opts));
|
||||||
$activity = Activity::create([
|
$activity = Activity::create([
|
||||||
'actor_id' => $subscriber_id,
|
'actor_id' => $subscriber_id,
|
||||||
'verb' => 'subscribe',
|
'verb' => 'subscribe',
|
||||||
'object_type' => 'actor',
|
'object_type' => Actor::schemaName(),
|
||||||
'object_id' => $subscribed_id,
|
'object_id' => $subscribed_id,
|
||||||
'source' => $source,
|
'source' => $source,
|
||||||
]);
|
]);
|
||||||
DB::persist($activity);
|
DB::persist($activity);
|
||||||
|
DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $subscribed_id]));
|
||||||
|
|
||||||
Event::handle('NewNotification', [
|
Event::handle('NewNotification', [
|
||||||
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
|
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
|
||||||
$activity,
|
$activity,
|
||||||
['object' => [$activity->getObjectId()]],
|
[$subscribed_id],
|
||||||
_m('{subject} subscribed to {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $activity->getObjectId()]),
|
$reason = _m('{subject} subscribed to {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $activity->getObjectId()]),
|
||||||
]);
|
]);
|
||||||
|
Event::handle('NewSubscriptionEnd', [$subject, $activity, $object, $reason]);
|
||||||
}
|
}
|
||||||
return $activity;
|
return $activity;
|
||||||
}
|
}
|
||||||
|
@ -149,21 +150,22 @@ class Subscription extends Component
|
||||||
if (!\is_null($subscription)) {
|
if (!\is_null($subscription)) {
|
||||||
// Remove Subscription
|
// Remove Subscription
|
||||||
DB::remove($subscription);
|
DB::remove($subscription);
|
||||||
$previous_follow_activity = DB::findBy('activity', ['verb' => 'subscribe', 'object_type' => 'actor', 'object_id' => $subscribed_id], order_by: ['created' => 'DESC'])[0];
|
$previous_follow_activity = DB::findBy(Activity::class, ['verb' => 'subscribe', 'object_type' => Actor::schemaName(), 'object_id' => $subscribed_id], order_by: ['created' => 'DESC'])[0];
|
||||||
// Store Activity
|
// Store Activity
|
||||||
$activity = Activity::create([
|
$activity = Activity::create([
|
||||||
'actor_id' => $subscriber_id,
|
'actor_id' => $subscriber_id,
|
||||||
'verb' => 'undo',
|
'verb' => 'undo',
|
||||||
'object_type' => 'activity',
|
'object_type' => Activity::schemaName(),
|
||||||
'object_id' => $previous_follow_activity->getId(),
|
'object_id' => $previous_follow_activity->getId(),
|
||||||
'source' => $source,
|
'source' => $source,
|
||||||
]);
|
]);
|
||||||
DB::persist($activity);
|
DB::persist($activity);
|
||||||
|
|
||||||
|
DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $subscribed_id]));
|
||||||
Event::handle('NewNotification', [
|
Event::handle('NewNotification', [
|
||||||
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
|
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
|
||||||
$activity,
|
$activity,
|
||||||
['object' => [$previous_follow_activity->getObjectId()]],
|
[$subscribed_id],
|
||||||
_m('{subject} unsubscribed from {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $previous_follow_activity->getObjectId()]),
|
_m('{subject} unsubscribed from {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $previous_follow_activity->getObjectId()]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
@ -177,17 +179,16 @@ class Subscription extends Component
|
||||||
* In the case of ``\App\Component\Subscription``, the action added allows a **LocalUser** to **subscribe** or
|
* In the case of ``\App\Component\Subscription``, the action added allows a **LocalUser** to **subscribe** or
|
||||||
* **unsubscribe** a given **Actor**.
|
* **unsubscribe** a given **Actor**.
|
||||||
*
|
*
|
||||||
* @param Actor $object The Actor on which the action is to be performed
|
* @param Actor $object The Actor on which the action is to be performed
|
||||||
* @param array $actions An array containing all actions added to the
|
* @param array<array{url: string, title: string, classes: string, id: string}> $actions
|
||||||
* current profile, this event adds an action to it
|
* An array containing all actions added to the
|
||||||
|
* current profile, this event adds an action to it
|
||||||
*
|
*
|
||||||
* @throws DuplicateFoundException
|
* @throws DuplicateFoundException
|
||||||
* @throws NotFoundException
|
* @throws NotFoundException
|
||||||
* @throws ServerException
|
* @throws ServerException
|
||||||
*
|
|
||||||
* @return bool EventHook
|
|
||||||
*/
|
*/
|
||||||
public function onAddProfileActions(Request $request, Actor $object, array &$actions): bool
|
public function onAddProfileActions(Request $request, Actor $object, array &$actions): EventResult
|
||||||
{
|
{
|
||||||
// Action requires a user to be logged in
|
// Action requires a user to be logged in
|
||||||
// We know it's a LocalUser, which has the same id as Actor
|
// We know it's a LocalUser, which has the same id as Actor
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% block profile_view %}
|
{% block profile_view %}
|
||||||
{% include 'cards/profile/view.html.twig' with { actor: object } %}
|
{% include 'cards/blocks/profile.html.twig' with { actor: object } %}
|
||||||
{% endblock profile_view %}
|
{% endblock profile_view %}
|
||||||
{{ form(form) }}
|
{{ form(form) }}
|
||||||
{% endblock body %}
|
{% endblock body %}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user