Compare commits

..

312 Commits
oauth1 ... v3

Author SHA1 Message Date
Eliseu Amaro
9d3ddfe916
[CSS] .section-panel height fix, better section hierarchy
.section-panel is now only allowed to have the height it's allowed on view, .section-details hierarchy is now better represented through a bigger padding for titles, and a button-like consistent padding for subtitles; buttons' padding now dependent on font-size.
2022-11-25 15:42:41 +01:00
Eliseu Amaro
87559e9a8c
[CSS] Improved reset.css, better and more granular sizes added
Search form dimensions fixed, page header alignment issues fixed, and consistent font sizes used throughout
2022-11-25 14:51:39 +01:00
Hugo Sales
9cf83db62a
[CORE][GNUSocial] Prevent multiple initializations 2022-11-20 21:54:36 +00:00
Hugo Sales
360a95c7aa
[TESTS] Use custom test bootstrap to only to only setup the database once and wrap everything in a transaction that gets rolled back at the end of each test 2022-10-29 19:56:03 +01:00
Hugo Sales
b41de11364
[TESTS] Remove environment setup from script 2022-10-29 19:54:37 +01:00
Hugo Sales
88e513c610
[TESTS] Make all services accessible in test environment 2022-10-29 19:53:26 +01:00
Hugo Sales
5829e77edd
[TESTS][TOOLS] Clarify make target names 2022-10-29 19:52:35 +01:00
Hugo Sales
334de7c739
[TESTS] Speedup container startup 2022-10-29 16:57:40 +01:00
Hugo Sales
fe21796e79
[TESTS] Tweak tests 2022-10-29 16:45:49 +01:00
Hugo Sales
789b1499c5
[TESTS] Fix deprecation warnings caused by removing xdebug 2022-10-29 16:40:28 +01:00
Hugo Sales
daec26f2d8
[TESTS] Speed up test runs 2022-10-28 19:22:30 +01:00
Hugo Sales
46fcab2e94
[TESTS] Fix deprecations and unstable tests 2022-10-28 18:49:03 +01:00
Hugo Sales
e44bef6de7
[TESTS] Use paratest and PHPUnit cache
This speeds up tests from 90s to 44s on my machine
2022-10-28 00:22:42 +01:00
Hugo Sales
857e5a9c6f
[TESTS] Update PHPUnit config to use coverage cache 2022-10-28 00:04:25 +01:00
Hugo Sales
1749cd81e4
[DEPENDENCIES] Update all dependencies 2022-10-28 00:03:42 +01:00
Hugo Sales
5521792169
[TESTS] Add paratest 2022-10-27 23:39:30 +01:00
Hugo Sales
9679e766d2
[DOCKER] Small fixes in docker startup scripts 2022-10-26 13:59:10 +01:00
Hugo Sales
adb7fbe1b0
[TOOLS] Finish build files for Debian package 2022-10-26 00:37:34 +01:00
Hugo Sales
af1779fdd8
[DEPENDENCIES] Update dependencies and use my version of XML_XRD while it get's merged upstream (fixes deprecation warnings) 2022-10-26 00:35:37 +01:00
Hugo Sales
584a0d8fed
[DOCKER][Redis][PLUGIN][OAuth2] Temporarily disable redis protected mode and fix permission of OAuth2 keys 2022-10-25 22:25:44 +01:00
Hugo Sales
a44e64ed7a
[DEPENDENCIES] Explicitly add pear/xml_xrd as a dependency 2022-10-25 22:20:37 +01:00
Hugo Sales
fb127e2d05
[PLUGIN][OAuth2] Fix OAuth2 install script 2022-10-25 22:15:42 +01:00
Hugo Sales
9703b059da
[TOOLS] Add PKGBUILD for Arch Linux 2022-10-25 19:55:51 +01:00
Hugo Sales
293c6fe078
[DEPENDENCIES] Update dependencies 2022-10-25 19:01:44 +01:00
tsmethurst
56cdc192ca [PLUGIN][ActivityPub][TEST] Add http signature tests 2022-10-25 16:36:41 +02:00
tsmethurst
a123e152d5 [PLUGIN][ActivityPub][TEST] Parse + validate GoToSocial fixtures 2022-10-23 15:19:11 +02:00
tsmethurst
f61cb2d4f6 [PLUGIN][ActivityPub] Check more thoroughly for note 'title' 2022-10-23 15:18:34 +02:00
tsmethurst
2da614e344 [PLUGIN][ActivityPub] Improve has/get checks
Fixes an issue where GtS posts were not being processed because of stripped @context
2022-10-22 17:47:14 +02:00
tsmethurst
c3477ea56b [PLUGIN][ActivityPub] Log unused core activity to help debug 2022-10-22 17:46:19 +02:00
tsmethurst
49a80a3c40 [PLUGIN][ActivityPub][TESTS] Add GoToSocial test fixtures 2022-10-21 13:01:32 +02:00
tsmethurst
97114e38e0
[PLUGIN][ActivityPub][TESTS] Replace invalid URL in fixtures 2022-10-21 11:47:27 +01:00
tsmethurst
2df30e2987
[PLUGIN][ActivityPub] Sign outgoing GET requests on behalf of relevant actor 2022-10-21 11:31:35 +01:00
tsmethurst
3b3ded5212
[PLUGIN][ActivityPub] Fix incorrect use of ActivityPubActor::create, should be ::createOrUpdate 2022-10-21 11:31:35 +01:00
tsmethurst
dc240fae49
[DOCKER] Fix incorrect script mount in worker 2022-10-21 11:31:35 +01:00
Hugo Sales
5cbb1627f2
[COMPONENT][Language] Fix collection query build event incorrectly not setting 'actor_language' join
Thanks to tsmethurst <tobi.smethurst@protonmail.com> for finding the error
2022-10-21 11:31:35 +01:00
Hugo Sales
46ff8aacd2
[UTIL][TemporaryFile] Silence warnings in critical section inside TemporaryFile 2022-10-21 11:30:47 +01:00
Hugo Sales
c4d6df4637
[TESTS] Fixup failing tests
Not a permanent solution
2022-10-21 11:30:37 +01:00
Hugo Sales
053bc38792
[TESTS] Fix tests 2022-10-19 22:39:17 +01:00
Hugo Sales
2fd46ca886
[TOOLS] Continue raising PHPStan level to 6 2022-10-19 22:39:17 +01:00
Hugo Sales
c31f3d4997
[TOOLS] Continue raising PHPStan to level 6 2022-10-19 22:39:17 +01:00
Hugo Sales
e6bb418fe6
[TOOLS] Begin raising PHPStan level to 6 2022-10-19 22:38:49 +01:00
Hugo Sales
fed2242a56
[TOOLS] Raise PHPStan level to 5 and fix associated error, fixing some bugs in the process 2022-10-19 22:38:49 +01:00
Hugo Sales
edeee49af9
[TOOLS] Fix errors pointed out by PHPStan level 4 2022-10-19 22:38:49 +01:00
Hugo Sales
4d7742e0e1
[OAuth2] Fix error in plugin install 2022-10-19 22:38:49 +01:00
Hugo Sales
76f2cdd212
[DEPENDENCIES] Update dependencies 2022-10-19 22:38:44 +01:00
Hugo Sales
a2aa45fb1f
[DOCS] Expand developer Event documentation 2022-04-03 22:05:19 +01:00
Hugo Sales
d4b7e990ce
[CORE][Event] Make all events return \EventResult, enforced at container build time 2022-04-03 21:40:32 +01:00
Hugo Sales
aef1fac536
[SECURITY] Refactor security hardening code and disable unused stream wrappers
Ensure unwanted enviorment variables are removed from the actual
global environment rather than just the `$_ENV` superglobal variable

Disable stream wrappers, as this is an unexpected feature for most
developers and can be exploited. For instance, `phar://` can be used
to override any class and thus provide code execution (through
`__wakeup` or `__costruct`, for instance). Not a complete solution, as
`php://` can also be abused, but we can't disable it as it gets used
_somewhere_ in our dependencies
2022-04-03 18:02:54 +01:00
Hugo Sales
556ac85061
[PLUGIN][Pinboard] For tag list request, respond with the most common variant and the corresponding count for each canon tag 2022-04-01 02:10:12 +01:00
Hugo Sales
539104ec33
[PLUGIN][Pinboard] Refactor and cleanup code 2022-04-01 00:17:57 +01:00
Hugo Sales
74ffd261b8
[PLUGIN][Pinboard] Implement tag handling 2022-04-01 00:16:04 +01:00
Hugo Sales
ca9945a4be
[ENTITY][Actor][COMPONENT][Tag] Add Actor->getNoteTags(?string $note_type) which gets a cached list of NoteTags for notes of type $note_type for the actor 2022-04-01 00:11:01 +01:00
Hugo Sales
08587b6942
[COMPONENT][Link][Tag] Refactor to make it easier to create links or tags from other places 2022-04-01 00:09:25 +01:00
Hugo Sales
1664293cf7
[PLUGIN][Pinboard] Change token to user user ID rather than nickname, to avoid complications with it possibly changing 2022-03-31 22:06:37 +01:00
Hugo Sales
94ab4ce8c4
[PLUGIN][Pinboard] Invalidate token and it's cache when actor information is changed via ActorForms 2022-03-31 03:47:14 +01:00
Hugo Sales
dd70de20da
[PLUGIN][Pinboard] Implement token authentication and settings page, allowing the user to enable, disable, refresh or consult their token 2022-03-31 03:29:31 +01:00
Hugo Sales
ded9c86054
[CORE][DB] Add DB::refetch, which refetches an entity from the database, so it's managed and definitely up to date (use when wanting to update entities from cache) 2022-03-31 03:29:31 +01:00
Hugo Sales
20e07c9140
[CORE][DB] Make DB::dql return an object rather than an array if limit 1 is specified 2022-03-31 03:29:31 +01:00
Hugo Sales
4e2f6545ec
[COMPONENT][Person][PLUGIN][WebHooks] Rename person settings section from 'others' to 'api' 2022-03-31 03:29:31 +01:00
Hugo Sales
f6a8f44420
[COMPONENT][Person][TEMPLATES] Move persosn settings template from core to the component 2022-03-31 03:29:31 +01:00
Diogo Peralta Cordeiro
fd71d6ee7d
[PLUGIN][UnboundGroup] Finish implementation 2022-03-29 00:57:41 +01:00
Diogo Peralta Cordeiro
dfc5918c2c
[PLUGIN][ActivityPub] Federate out Service information in Activities 2022-03-28 23:54:19 +01:00
Diogo Peralta Cordeiro
83599ef866
[CORE][Modules][Plugin] version should be static 2022-03-28 23:54:18 +01:00
Diogo Peralta Cordeiro
fa82306f6f
[COMPONENT][Posting] Blog posts should be Articles by default 2022-03-28 23:54:18 +01:00
Hugo Sales
10f71e9fed
[UI][TEMPLATES] Fix note text template. Use rendered content directly 2022-03-28 23:23:07 +01:00
Hugo Sales
e2501ee927
[PLUGIN][Pinboard] Implement remaining API endpoints, restructure, fix template 2022-03-28 23:23:07 +01:00
Diogo Peralta Cordeiro
a9665177ea
[PLUGIN][Blog] Move to plugins, mistakenly was in components 2022-03-28 20:59:16 +01:00
Diogo Peralta Cordeiro
41861d284c
[COMPONENT][Circle] Correct self tags settings text 2022-03-28 20:59:16 +01:00
Hugo Sales
bd868a2675
[PLUGINS][Pinboard] Add initial implementation of Pinboard API, lacking authentication, tags and feed endpoints 2022-03-28 20:59:16 +01:00
Hugo Sales
87e35716c1
[UTIL] Add Formatting::explode(array , string ) 2022-03-28 20:59:16 +01:00
Hugo Sales
dac94f53cd
[CORE][Entity] Rename createOrUpdate to 'checkExistingAndCreateOrUpdate', remove update feature from 'create' and add 'createOrUpdate' and fix users 2022-03-28 20:59:15 +01:00
Hugo Sales
b10c359dec
[DEPENDENCIES] Update dependencies 2022-03-28 20:59:15 +01:00
Hugo Sales
483983790a
[CORE][Router] Rename \App\Core\Router\Router to \App\Core\Router and merge \App\Core\Router\RouteLoader with \App\Core\Router 2022-03-28 20:59:15 +01:00
Hugo Sales
60af9f5e9b
[CORE][Queue] Rename App\Core\Queue\Queue to App\Core\Queue 2022-03-28 20:59:15 +01:00
Hugo Sales
abe35428da
[CORE][DB] Rename App\Core\DB\DB to App\Core\DB 2022-03-28 20:59:14 +01:00
Hugo Sales
ca5520edbf
[PLUGIN][WebHooks] Add hook for subscriptions 2022-03-28 20:59:14 +01:00
Diogo Peralta Cordeiro
e3e14c53ef
[PLUGIN][ActivityPub] Model/Note->toJson federate the url, even though it's the same as the id 2022-03-28 20:59:14 +01:00
Diogo Peralta Cordeiro
be33c20614
[PLUGIN][ActivityPub] Improve flexibility of Type layer, accomodate more elaborate understanding of Group Announces after FEP-2100 development 2022-03-28 20:58:48 +01:00
Diogo Peralta Cordeiro
7305a725cb
[PLUGIN][UnboundGroup] First steps on implementing AP FEP-2100 2022-03-28 20:57:43 +01:00
Diogo Peralta Cordeiro
fd4c3b0e68
[PLUGIN][Embed][Test] Move Test to correct location 2022-03-28 20:53:50 +01:00
Diogo Peralta Cordeiro
16f51e5143
[COMPONENT][Notification] ->getSubscribers() should not be pre-included
Notification bug fix on Subscription component
Correct docblock
2022-03-28 20:53:19 +01:00
Diogo Peralta Cordeiro
ba4230447e
[COMPONENT][Group] Add orderBy to query, as otherwise the feed order is wrong 2022-03-28 20:49:28 +01:00
Diogo Peralta Cordeiro
7463044971
[COMPONENT][Circle] Ensure strict typing on getter 2022-03-28 20:48:29 +01:00
Hugo Sales
7027633ed5
[PLUGIN][WebHooks] Make request method configurable
This way, PUT can be used, which doesn't seem to be the standard, so isn't the default, but which makes sense to me, as it doesn't have a response, which we don't care about anyway
2022-03-24 00:51:00 +00:00
Hugo Sales
48b42c539c
[PLUGINS][WebHooks] Use ActivityPub to serialize the activity, so the object is included 2022-03-24 00:51:00 +00:00
Hugo Sales
d41a67a9f9
[PLUGIN][WebHooks] Add WebHooks plugin, which allows for sending a POST request to an external resource when a notification or a follow occurs 2022-03-24 00:51:00 +00:00
Diogo Peralta Cordeiro
13f22c911c
[COMPONENT][Notification] Feed: Fix typo in query 2022-03-23 16:09:13 +00:00
Diogo Peralta Cordeiro
56b8710b26
[PLUGIN][ActivityPub][Notification] Fix some issues with targetting 2022-03-23 13:23:44 +00:00
Diogo Peralta Cordeiro
e63c310d70
[COMPONENT][Notification] Always pre-add Actor subscribers when notifying 2022-03-23 13:23:44 +00:00
Diogo Peralta Cordeiro
03f449035a
[PLUGIN][ActivityPub][Model][Activity] Sometimes we don't have a local, move on with encapsulated 2022-03-23 13:23:44 +00:00
Diogo Peralta Cordeiro
8808195a80
[PLUGIN][ActivityPub][Test] Test @language handling 2022-03-23 13:23:44 +00:00
Diogo Peralta Cordeiro
45344c80d1
[PLUGIN][ActivityPub][Model][Note] Fix @language handling 2022-03-23 13:23:43 +00:00
Diogo Peralta Cordeiro
7eddbd343d
[PLUGIN][ActivityPub][Test] Add Like{Note} fixture 2022-03-23 13:23:43 +00:00
Diogo Peralta Cordeiro
259d2da05a
[CORE][Controller] Add default handler for when using http methods 2022-03-23 13:23:43 +00:00
Diogo Peralta Cordeiro
2f7fdf6ee4
[PLUGIN][ActivityPub][Test] Activity: Create Page
Fixed a couple of bugs
2022-03-19 22:21:35 +00:00
Diogo Peralta Cordeiro
6955872e05
[PLUGIN][ActivityPub][Model][Activity] toJson: When in activity context, use object's context if available 2022-03-19 22:20:32 +00:00
Diogo Peralta Cordeiro
23e88b30a6
[COMPONENT][Blog] This is not used for replies 2022-03-19 22:18:33 +00:00
Diogo Peralta Cordeiro
60713878f0
[TESTS] Load languages prior to remaining fixtures 2022-03-19 22:18:00 +00:00
Diogo Peralta Cordeiro
06c67b31c2
[PLUGIN][ActivityPub][Model][Note] toJson: Respect source attribute and @language from context 2022-03-19 18:01:25 +00:00
Diogo Peralta Cordeiro
a08b661779
[COMPONENT][Group] Cast integer string to int when getting group from context 2022-03-19 18:01:25 +00:00
Diogo Peralta Cordeiro
0649a5154c
[PLUGIN][ActivityPub][Test][Model][Note] fromJson 2022-03-19 18:01:24 +00:00
Hugo Sales
91fecd77ba
[TOOLS][DOCKER] Use a more robust way to check for database availability 2022-03-19 17:20:12 +00:00
Hugo Sales
e22fe55bbe
[TOOLS] Add .well-known/acme-challenge/ root certbot to nginx container, to allow certbot certificate renewals 2022-03-19 07:32:01 +00:00
Diogo Peralta Cordeiro
dd62825169
[PLUGIN][ActivityPub][Model][Note] fromJson: Respect source attribute and @language from context 2022-03-15 17:49:09 +00:00
Hugo Sales
27706d63f4
[PLUGIN][OAuth] Fix login for OAuth 2022-03-14 21:41:22 +00:00
Diogo Peralta Cordeiro
20f690c532
[TESTS] Fix a couple of issues from last changes 2022-03-14 18:37:39 +00:00
Diogo Peralta Cordeiro
888c3798b7
[COMPONENT][Notification] Make logic more generic and robust
Fixed various bugs

Some important concepts to bear in mind:

* Notification: Associated with activities, won't be reconstructed
together with objects, can be thought of as transient

* Attention: Associated with objects, will be reconstructed with them, can
be thought as persistent

* Notifications and Attentions have no direct implications.

* Mentions are a specific form of attentions in notes, leads to the creation of Attentions.

Finally,

Potential PHP issue detected and reported: https://github.com/php/php-src/issues/8199
`static::method()` from a non static context (such as a class method) calls `__call`, rather than
the expected `__callStatic`. Can be fixed by using `(static fn() => static::method())()`, but the
usage of the magic method is strictly unnecessary in this case.
2022-03-14 11:37:09 +00:00
Hugo Sales
e1cceac150
[CORE][Form][TESTS] Fix FormTest::handle 2022-03-13 18:53:53 +00:00
Hugo Sales
63ef9292f3
[DEPENDENCIES] Update dependencies 2022-03-13 18:17:32 +00:00
Hugo Sales
cbae649991
[PLUGIN][ActivityPub][TESTS] Move ActivityPub test fixtures to new facility 2022-03-13 18:11:11 +00:00
Hugo Sales
1d8bba3949
[TESTS][MODULES] Move Test Fixtures to tests/fixtures folder and add support for loading fixtures from components and plugins 2022-03-13 18:00:21 +00:00
Hugo Sales
18864ca9fa
[CONTROLLER][Security] Override the _next form field in Security->register to redirect to login page 2022-03-13 16:01:51 +00:00
Diogo Peralta Cordeiro
390c532456
[PLUGIN][ActivityPub][Tests] Create Actor Tests 2022-03-13 16:00:35 +00:00
Diogo Peralta Cordeiro
636cb681d6
[PLUGIN][ActivityPub][Tests] Create a TestCase for the plugin 2022-03-13 15:54:14 +00:00
Diogo Peralta Cordeiro
7d84323df4
[PLUGIN][ActivityPub][Tests] Add some fixtures for GNU social's 2022-03-13 15:53:21 +00:00
Diogo Peralta Cordeiro
2d7850ccfb
[PLUGIN][ActivityPub][Tests] Borrow test fixtures from Lemmy 2022-03-13 15:52:48 +00:00
Diogo Peralta Cordeiro
d8108dbc32
[COMPONENT][Posting] Fix request handling issues that resulted from splitting creation and controller 2022-03-13 15:52:48 +00:00
Hugo Sales
cf05d3dbb0
[ENTITY][TESTS] Fix Note->isVisibleTo with and associated test 2022-03-13 15:03:03 +00:00
Hugo Sales
eb3c848fc8
[TOOLS][TESTS] Ensure database schema is up to date in tests 2022-03-13 14:22:18 +00:00
Hugo Sales
5c708af272
[CORE][Form] Remove unweildy return of form errors from Form::handle 2022-03-13 14:19:56 +00:00
Hugo Sales
8433771465
[TOOLING][TESTS] Allow specifying any phpunit flag when invoking make
Examples:
  make test -- --filter 'method'
  make test -- directory
2022-03-10 01:23:36 +00:00
Hugo Sales
0ce5eba355
[PLUGINS][Favourite][RepeatNote][DeleteNote][WebMonetization] Make use of 'activitypub_handler' more readable 2022-03-10 00:40:54 +00:00
Hugo Sales
9a9eed1457
[CORE][Router][Form] Add Router::sanitizeLocalURL and use it in Form::forceRedirect 2022-03-09 20:51:42 +00:00
Hugo Sales
f540711948
[CORE][GNUsocial] Remove Session parameter, as it's no longer a service. Use session from Request 2022-03-09 20:51:42 +00:00
Hugo Sales
c870fd44e3
[PLUGIN][Embed] Fix test folder name, so Symfony doesn't attempt to autowire it 2022-03-09 20:51:42 +00:00
Hugo Sales
c30fcead74
[DEPENDENCIES] Move from Symfony 5.4 to 6 and update all other packages, where applicable 2022-03-09 20:51:42 +00:00
Hugo Sales
301421ea15
[SECURITY][EVENT] Remove deprecated uses of Symfony Guard. Add LoginSucess and LoginFailure events 2022-03-09 20:51:16 +00:00
Diogo Peralta Cordeiro
4d77f3497d
[COMPONENT][Person][TESTS] Fix Controller/PersonSettingsTest 2022-03-09 14:24:50 +00:00
Diogo Peralta Cordeiro
f735e6b31c
[TESTS] Fix Util/CommonTest 2022-03-09 14:24:50 +00:00
Diogo Peralta Cordeiro
893d299e29
[UTIL][Common] Respect detect language setting
Minor bug fix
2022-03-09 14:24:50 +00:00
Diogo Peralta Cordeiro
d857baa0f1
[TESTS] Fix Twig/ExtensionTest 2022-03-09 01:43:58 +00:00
Diogo Peralta Cordeiro
0441f030ab
[COMPONENT][Group][TESTS] Fix Entity/GroupTest 2022-03-09 01:43:51 +00:00
Diogo Peralta Cordeiro
cac68a6372
[TESTS] Fix Entity/NoteTest 2022-03-09 01:42:11 +00:00
Diogo Peralta Cordeiro
28453c585f
[COMPONENT][Attachment][TESTS] Fix Entity/AttachmentThumbnailTest 2022-03-09 01:42:11 +00:00
Diogo Peralta Cordeiro
5c7b079df5
[COMPONENT][Attachment][Controller] Security fix: We were not ensuring that attachment was related to note 2022-03-09 01:42:11 +00:00
Diogo Peralta Cordeiro
47f03d4c9f
[COMPONENT][Attachment][TESTS] Fix Entity/AttachmentTest 2022-03-09 01:42:06 +00:00
Diogo Peralta Cordeiro
cc4f967186
[TESTS] Fix Circle SelfTags Setting test 2022-03-09 01:40:35 +00:00
Diogo Peralta Cordeiro
ff06a2656a
[COMPONENT][Group][Entity] Useless URI column removed
Add table to Makefile backup
2022-03-09 01:40:34 +00:00
Diogo Peralta Cordeiro
d5fd7da707
[TESTS] Fix Core/RouterTest 2022-03-09 01:40:34 +00:00
Diogo Peralta Cordeiro
1bdeac7076
[TESTS] Fix Core/DB/UpdateListenerTest 2022-03-09 01:40:34 +00:00
Diogo Peralta Cordeiro
e67ed58286
[TESTS] Temporarily Disable Controller/AdminTest: It seems we are repeating values arbitrarily - specially in plugins, and the generated file is just nonsense overall really, wrong sections and stuff 2022-03-09 01:40:34 +00:00
Diogo Peralta Cordeiro
487791d606
[TESTS] Fix Core/ControllerTest 2022-03-09 01:40:33 +00:00
Diogo Peralta Cordeiro
813e66e83e
[TESTS] Fix Core/CacheTest 2022-03-09 01:40:33 +00:00
Diogo Peralta Cordeiro
88ace68627
[TESTS] Fix Controller/FeedsTest 2022-03-09 01:40:33 +00:00
Diogo Peralta Cordeiro
416665d830
[COMPONENT][Attachment][TESTS] Fix Controller/AttachmentTest 2022-03-09 01:40:09 +00:00
Hugo Sales
808a3b219e
[TESTS] Specify non-null fields for use of creating actors in tests 2022-03-09 01:37:11 +00:00
Hugo Sales
df40dd7c66
[TESTS] Add support for loading test suites from plugins and components 2022-03-09 01:37:11 +00:00
Hugo Sales
afa8443949
[TESTS] Fix some failing tests broken by restructuring and dependency updates 2022-03-09 01:37:11 +00:00
Hugo Sales
46de2d47e9
[TOOLS] Add explicit return types to fix deprecation warnings raised by PHPUnit 2022-03-09 01:37:10 +00:00
Hugo Sales
372cf91fbc
[TOOLS][TESTS] Split tests into different test suites 2022-03-09 01:37:10 +00:00
Diogo Peralta Cordeiro
9c9e86649a
[TESTS] Fix Controller/SecurityTest 2022-03-09 01:37:10 +00:00
Diogo Peralta Cordeiro
a37ce86d05
[TESTS] Fix DataFixtures 2022-03-07 15:26:27 +00:00
Hugo Sales
9a0c74cb0c
[CORE][SECURITY] Replicate 'next' form submission feature on login form 2022-03-07 15:26:27 +00:00
Hugo Sales
46c91a4b39
[I18N] Fix use of string concatenations in translations 2022-03-07 15:26:26 +00:00
Hugo Sales
3f14ad0f69
[COMPONENT][Posting][FORM] Refactor Posting form to use a form action with a separate controller and the new Form::forceRedirect 2022-03-07 15:26:26 +00:00
Hugo Sales
6ddc176faf
[CORE][Form] Add facilities for automattically adding a _next field to all forms, which can be customized by the in Form::create and defaults to the current URL. Usage of RedirectedException should mostly be replaced with Form::forceRedirect 2022-03-07 15:26:26 +00:00
Diogo Peralta Cordeiro
d629976322
[UTIL][Notification] Remove deprecated code 2022-03-07 15:26:24 +00:00
Diogo Peralta Cordeiro
1a0c9e720f
[COMPONENT][FreeNetwork] Start using queues
[COMPONENT][Notification] Start using queues
[PLUGIN][ActivityPub] Start using queues
2022-03-05 14:23:08 +00:00
Diogo Peralta Cordeiro
6fa5ec3218
[CORE][Queue] Fix some minor issues 2022-03-05 14:22:44 +00:00
Diogo Peralta Cordeiro
626b4263f1
[PLUGIN][ActivityPub][Model][Actor] Fix internal logic for updating
Actors
2022-03-05 14:19:12 +00:00
Hugo Sales
1daa314c55
[COMPONENT][Posting][FORM] Refactor Posting form to use a form action with a separate controller and the new Form::forceRedirect 2022-03-04 15:16:19 +00:00
Hugo Sales
7814697f82
[UTIL][EXCEPTION] Forward given status code in RedirectException 2022-03-04 15:15:04 +00:00
Hugo Sales
7a8d67f1e2
[CORE][Controller] Fix bug where a JSON request could not recieve a redirect response 2022-03-04 15:14:05 +00:00
Hugo Sales
94449c9153
[CORE][Form] Add facilities for automattically adding a _next field to all forms, which can be customized by the in Form::create and defaults to the current URL. Usage of RedirectedException should mostly be replaced with Form::forceRedirect 2022-03-04 15:12:35 +00:00
Hugo Sales
7c9b01c516
[UTIL][Common] Add Common::getRequest 2022-03-04 15:09:39 +00:00
Hugo Sales
6cae6c925d
[TOOLS] Keep feed table in delete content Make rule 2022-03-01 18:12:58 +00:00
Diogo Peralta Cordeiro
12fb876a6d
[PLUGIN][ActivityPub][Model][Activity] No @context to exclude when object is not embedded. 2022-03-01 18:00:24 +00:00
Hugo Sales
7ca4330f17
[TEMPLATES] Tweak note complimentary info to not output empty <span>s 2022-03-01 17:58:53 +00:00
Hugo Sales
802a8d124a
[TOOLS] For delete content, restore local_groups and actor_subscriptions 2022-03-01 17:57:39 +00:00
Hugo Sales
87354c06bf
[TEMPLATES] For note complementary info, compare identity by the ID, rather than nickname, which is not unique 2022-03-01 17:39:14 +00:00
Hugo Sales
5600218924
[TWIG][I18N] Remove unnecessary wrappers for translation functions, use them directly 2022-03-01 17:25:51 +00:00
Hugo Sales
90f9378bca
[TEMPLATES] Use transList and trans function for note complimentary info 2022-03-01 13:46:06 +00:00
Hugo Sales
070f53c10e
[TWIG][I18N] Add transList function, which uses _m_list 2022-03-01 13:46:01 +00:00
Hugo Sales
f73e9c12ba
[CORE][I18n] Add I18n::_m_list, which formats an array of elements into a list. Limited to 5 elements, as that should be enough (tm) and ICU doesn't support this natively 2022-03-01 13:45:40 +00:00
Hugo Sales
fc203e2e38
[TWIG][TEMPLATES] Rename transchoice to trans and make it more generic 2022-03-01 13:45:11 +00:00
Hugo Sales
b3374333f3
[TEMPLATES][I18N] Fixup use of trans filter, in favour of trans tags. These are much more flexible and facilitate parameterized translations, rather than using concats. The only appropriate use of the trans filter is when a whole string in a variable needs to be translated (which should probably be avoided anyway) 2022-03-01 13:16:11 +00:00
Hugo Sales
0b864e85fd
[TEMPLATES] Fixup uses of deprecated noteView, in favour of new NoteFactory facility 2022-03-01 11:23:39 +00:00
Hugo Sales
a9a60bbd92
[COMPONENT][Posting] Clarify use of cache in note replies when posting 2022-03-01 11:19:47 +00:00
Hugo Sales
4cc4d06b11
[CORE][Cache] Fix bug where empty lists must be stored as a string in Redis (not supported natively), so we can't directly push to it, but the key still exists 2022-03-01 11:07:21 +00:00
Hugo Sales
f8c1b0f71d
[TOOLS] Add Make rule to delete content, but keep actors and sequences 2022-02-28 23:37:16 +00:00
Hugo Sales
43ae3add43
[TEMPLATE] Update uses of NoteFactory macro, to pass the values seperately, rather than inside a converstation key 2022-02-28 15:48:47 +00:00
Hugo Sales
d5f90a1206
[ENTITY][Note][CONFIG] Use getListPartialCache for getReplies. Add feeds/cached_replies config entry to control how many replies get cached 2022-02-28 15:47:38 +00:00
Hugo Sales
85ce6bfd41
[CORE][Cache] Add getListPartialCache, which allows for fetching a list and backing only a portion of it in the cache (useful for feeds and replies to notes, for instance) 2022-02-28 15:47:38 +00:00
Hugo Sales
46c4bd9099
[COMPONENT][Conversation] Sort conversation correctly 2022-02-28 15:47:38 +00:00
Hugo Sales
35f3781a32
[COMPONENT][Collection] Add mechanism for specifying the ordering of note and actor queries 2022-02-28 15:47:38 +00:00
Hugo Sales
45c7888676
[TOOLS] Run CS-Fixer on whole project 2022-02-28 15:47:37 +00:00
Hugo Sales
255c44bbf0
[ENTITY][LocalUser] Don't use FILTER_SANITIZE_EMAIL, use just want to validate. Up to the user to fix any errors. Use setter, rather than duplicate it's code 2022-02-28 15:47:37 +00:00
Hugo Sales
5188a473d0
[TOOLS] Fix errors reported by PHPStan 2022-02-28 15:47:37 +00:00
Hugo Sales
8c15d21591
[TOOLS] Add update-dependencies and update-autocode Make rules 2022-02-28 15:47:37 +00:00
Hugo Sales
df640f60d2
[DEPENDENCIES] Update dependencies 2022-02-28 15:47:37 +00:00
Hugo Sales
6e85a4adbb
[CONFIG] Change default config to make media files (attachments and their thumbnails) to a subfolder to file, so cleanup scripts can avoid files meant to be persistent (plugin files, certificates) 2022-02-28 15:47:37 +00:00
Hugo Sales
eccf21edef
[TOOLS][PLUGINS][OAuth2] Add mechanism to allow plugins to have an install script. Add script for generating keys for OAuth 2022-02-28 15:47:32 +00:00
Eliseu Amaro
9b86794cda
[CSS] Details inside another details (accordion widget) will represent their 'open/close feedback arrows' properly now 2022-02-28 13:09:12 +00:00
Eliseu Amaro
077975136e
[CARDS][Note] Both 'in conversation' and 'in reply to' link to note's conversation. The former anchors it's id, while the latter it's parent id 2022-02-28 12:43:40 +00:00
Diogo Peralta Cordeiro
5495a3c5ec
[ENTITY][Note] NoteType now becomes a varchar as predicted 2022-02-27 02:04:48 +00:00
Diogo Peralta Cordeiro
a9b34b75b6
[PLUGIN][TreeNotes] Correct cache issues and iterate functionality
- Replies ordering now correct
- Replies count added
- Posting adds new replies to cache (when concerning replies cache is not empty) and increments replies count
- Configuration to specify number of in-tree replies shown added
- TreeNotes templates was moved from core to plugin
- Button to read more replies was added
2022-02-27 01:46:25 +00:00
Diogo Peralta Cordeiro
2f539d176d
[TWIG] Implement transchoice for ICU plural translations 2022-02-27 00:44:23 +00:00
Diogo Peralta Cordeiro
d4c908c194
[CORE][Cache] Implement listPushRight 2022-02-27 00:44:23 +00:00
Diogo Peralta Cordeiro
b630d530f4
[PLUGIN][ActivityPub][Postman] JSON_UNESCAPED_SLASHES
Only record webfinger matches for acct
2022-02-25 13:52:56 +00:00
Eliseu Amaro
26a50618b0
[CARDS][Note] Notification targets are now used as target info, instead of previous reply dependant implementation [COMPONENTS][Group] Feed title is applied to GroupFeed view 2022-02-25 13:12:16 +00:00
Diogo Peralta Cordeiro
d5731e6351
[COMPONENT][Notification] Consider attention properly in notes 2022-02-25 13:12:16 +00:00
Diogo Peralta Cordeiro
f5e92de62d
[PLUGIN][ActivityPub][Util][Explorer] Simplify fetching Actor by URI 2022-02-25 13:12:14 +00:00
Eliseu Amaro
7c80277436
[CSS] Fix header position on >1080p displays 2022-02-24 19:16:41 +00:00
Diogo Peralta Cordeiro
4754593cde
[PLUGIN][ActivityPub][Model][Activity] If the object is wrapped in an activity, exclude the @context 2022-02-24 19:07:46 +00:00
Eliseu Amaro
d12038a9f8
[CSS] Complete refactor, removing all useless rules, squashing related separate files, and limiting folder depth 2022-02-24 19:05:14 +00:00
Diogo Peralta Cordeiro
af02bc7b32
[PLUGIN][ActivityPub][Model][Note] Replace our directMessage extension with LitePub's 2022-02-23 22:27:32 +00:00
Diogo Peralta Cordeiro
bc3d5245f5
[PLUGIN][ActivityPub][Model][Note] Handle Mentions properly 2022-02-23 22:27:32 +00:00
Diogo Peralta Cordeiro
f3c2e49e3f
[PLUGIN][ActivityPub] Correct @context 2022-02-23 22:27:30 +00:00
Diogo Peralta Cordeiro
05b7f2c28b
[CORE][DB] Remove doc from deprecated DB::merge and add about DB::refresh 2022-02-23 17:42:20 +00:00
Eliseu Amaro
338ea0ea58
[COMPONENTS][Group] Group view template now extends the Collection of Notes view instead of trying to reinvent the wheel [COMPONENTS][Conversation] Replaced deprecated DB::merge with DB::persist 2022-02-23 17:01:41 +00:00
Diogo Peralta Cordeiro
57a07ef74f
[COMPONENT][FreeNetwork] Add to Search the query expression 2022-02-21 04:53:12 +00:00
Diogo Peralta Cordeiro
c380cbd846
[COMPONENT][FreeNetwork] Mention and Group tags in notes are handled differently 2022-02-21 04:52:30 +00:00
Diogo Peralta Cordeiro
7678e155d9
[COMPONENT][Search] Ensure title is set when saving as feed 2022-02-21 04:49:08 +00:00
Diogo Peralta Cordeiro
59380ed2ac
[COMPONENT][Collection] If invalid term, just perform a regular search for it 2022-02-21 04:48:18 +00:00
Diogo Peralta Cordeiro
1e310aa124
[PLUGIN][ActivityPub][FreeNetwork] DB::findBy won't work if not commited first 2022-02-20 15:01:49 +00:00
José Marques
8b0e9c7890
[UTIL][Form][ActorForms] Fix Full Name validate: Tried to mb_strln on null
If the trim(string) is empty, then store null without further ado
2022-02-20 05:03:55 +00:00
Eliseu Amaro
f1caabd296
[CARDS][Note] Note factory template macro created, allows Notes to be represented with completely different macros/blocks, possible to extend types through additional events. Compact Notes have a max height, content can be scrolled by [CSS] Avatars, and Embed attachments now have a max-block-size which acts independently of image orientation 2022-02-20 05:03:54 +00:00
Eliseu Amaro
a71c16d654
[COMPONENTS][Posting] Fixed issue where an embed attachment would violate Note's conversation_id not null constraint
Conversation was only assigned after storing Note's attachments
2022-02-20 05:03:41 +00:00
Diogo Peralta Cordeiro
ecfd6b5ad2
[PLUGIN][ActivityPub][Model][Note] Sometimes content is explicitely null 2022-02-20 05:03:40 +00:00
Diogo Peralta Cordeiro
496701ce73
[PLUGIN][ActivityPub][Inbox] Add event for notifications triggered by AP Inbox 2022-02-20 05:03:40 +00:00
Diogo Peralta Cordeiro
e86dbad6d6
[COMPONENT][Notification] Don't explode with understandable duplicate notifications
This is common when a duplicate federation request is received
2022-02-20 05:03:40 +00:00
Diogo Peralta Cordeiro
6f3e760c63
[PLUGIN][ActivityPub][Inbox] Separate handler by method 2022-02-20 05:03:40 +00:00
Diogo Peralta Cordeiro
51cccd0155
[PLUGIN][ActivityPub] Simplify DB usage 2022-02-20 05:03:40 +00:00
Diogo Peralta Cordeiro
9523927b8e
[PLUGIN][ActivityPub][Model][Note] There may be no attachments, nor tags, nor to, nor cc 2022-02-19 05:46:48 +00:00
Diogo Peralta Cordeiro
ebbd8bf1e4
[PLUGIN][ActivityPub][HTTPSignatures] Fix wrong assumption that sha512 is used in hs2019 2022-02-19 04:49:50 +00:00
Diogo Peralta Cordeiro
7a59d5a002
[PLUGIN][ActivityPub][HTTPSignatures] Validate draft-cavage-http-signatures-11 2022-02-19 04:49:50 +00:00
Diogo Peralta Cordeiro
52ae5fa690
[PLUGIN][ActivityPub][Inbox] Improve logs 2022-02-19 04:49:50 +00:00
Diogo Peralta Cordeiro
99f7e7cd79
[PLUGIN][ActivityPub][Model][Note] Handle group scope properly 2022-02-19 04:49:50 +00:00
Diogo Peralta Cordeiro
27635d8ec2
[PLUGIN][ActivityPub][Model][Note] Add name property as note title 2022-02-19 04:49:49 +00:00
Diogo Peralta Cordeiro
0a741903a1
[PLUGIN][ActivityPub][Model][Note] Federate content out 2022-02-19 04:49:49 +00:00
Diogo Peralta Cordeiro
8f60fc4685
[PLUGIN][ActivityPub][Model][Note] Federate attentions out 2022-02-19 04:49:49 +00:00
Diogo Peralta Cordeiro
8cf60275e6
[PLUGIN][ActivityPub][Model][Note] Add support to Pages 2022-02-19 04:49:49 +00:00
Eliseu Amaro
75837af412
[CSS] Replacing problematic special Unicode glyphs
A browser will use Unicode glyphs from other font families if the glyph in question is not present for the current typeface. This leads to unnerving situations, whereby setting content through pseudo-selectors will cause text to misalign. And no, line-height won't make a difference in this case. This happens because fonts have different heights. Another reason may reside on CSS3 having pseudo selectors but not really having a proper spec for them to begin with.
2022-02-19 04:01:47 +00:00
Eliseu Amaro
03a475b642
[TWIG] Form layout is all new, since extending form_div_layout.html.twig was quite limiting
[COMPONENTS][Posting] It is now visible on Actor profiles [COMPONENTS][Search] Overall rework of search results template, there's also additional help text added [CSS] Header no longer translucent, font sizes yet more consistent, replies marker less pronounced, and font hierarchy is now applied in both weight and size
2022-02-19 04:01:47 +00:00
Diogo Peralta Cordeiro
b69f4a46c5
[COMPONENT][Posting] Page should flush with a different notification 2022-02-16 19:35:27 +00:00
Diogo Peralta Cordeiro
b6ed0b4c6c
[CORE][Actor] Fix generic profile route 2022-02-16 18:53:08 +00:00
Diogo Peralta Cordeiro
cee2d143c9
[COMPONENT][Posting] Add storeLocalPage
Minor refactoring and bug fixing
2022-02-16 18:53:08 +00:00
Diogo Peralta Cordeiro
2d5fac7a89
[COMPONENT][Notification] Re-introduce the concept of note attention
Minor refactoring and bug fixing
2022-02-16 18:53:08 +00:00
Eliseu Amaro
e70acd5c3b
[UTIL][HTML] HTML abstraction class was extended with a more specialised Heading class
This little abstraction layer made it a bit easier to add a different title to a Note or Actor Feed Collection template, from whichever controller that uses it. Please, bear in mind, that abstract templates such as those found in Components\Collection, may act in a very 'declarative' way upon using them. This makes it difficult to dynamically choose what type of header is used without undergoing a mining operation in the likes of a pyramid of doom. Hence, this _little_ change.
2022-02-16 18:53:08 +00:00
Diogo Peralta Cordeiro
f66e178dfc
[CORE][Actor][Settings] Fix nickname change and refactor Core Form::handle so it's harder to repeat these mistakes again
Minor improvements on actor->getLocal
2022-02-16 18:53:07 +00:00
Diogo Peralta Cordeiro
397b54a207
[PLUGIN][Bundles] Refactor BlogCollections to Bundles 2022-02-16 18:53:07 +00:00
Diogo Peralta Cordeiro
33e1d3eb20
[COMPONENT][Conversation] Use Router::url's _fragment for anchor 2022-02-16 18:53:07 +00:00
Diogo Peralta Cordeiro
54b9ec48b4
[COMPONENT][Collection][FeedController] Fix group scope, we should use the IN context actor to check the group 2022-02-16 18:53:07 +00:00
Diogo Peralta Cordeiro
40590bbd11
[COMPONENT][Group] Restore settings functionality 2022-02-16 18:53:07 +00:00
Eliseu Amaro
5b94973079
[COMPONENTS][Posting] Form is no longer added to RightPanel if not on a feed|conversation|groups route 2022-02-16 18:53:07 +00:00
Eliseu Amaro
9d9abf8afb
[CARDS][Note] Removed incorrect aria attributes, polished Note card 2022-02-16 18:53:06 +00:00
Diogo Peralta Cordeiro
be0a2d27e2
[COMPONENT][Blog] Initial support for in group blogs 2022-02-16 18:53:06 +00:00
Diogo Peralta Cordeiro
bf23ae2dcf
[ENTITY][Note] Some notes aren't exactly just a note but rather a Page, or further (like happening or poll), this is only initial support for that
It prolly will become a varchar instead of an enum, so plugins can add their own note types
2022-02-16 18:53:06 +00:00
Diogo Peralta Cordeiro
33e768c298
[COMPONENT][Group][Controller] Separate feed from other group features 2022-02-15 17:13:16 +00:00
Diogo Peralta Cordeiro
3f9c86f0df
[COMPONENT][Group] More flexible member roles than only isAdmin
Refactor terminology of canAdmin to match current roles system
2022-02-14 05:02:10 +00:00
Diogo Peralta Cordeiro
bc63c3727a
[COMPONENT][GROUP] Allow to create a group as private and prioritise group scope on Posting in that context 2022-02-14 05:02:09 +00:00
Diogo Peralta Cordeiro
090a087832
[COMPONENT][Group] Check nickname on register 2022-02-14 01:21:40 +00:00
Diogo Peralta Cordeiro
262b14a977
[COMPONENT][Collection] Organisation no longer is an actor type but rather a type of Actor Group 2022-02-14 00:41:57 +00:00
Diogo Peralta Cordeiro
10d1a7ed2a
[PLUGIN][ActivityPub] Implement Group Inbox POST 2022-02-13 23:15:00 +00:00
Diogo Peralta Cordeiro
3ae8f8213f
[GROUP] Notifity group subscribers of new activity concerning the group 2022-02-13 23:15:00 +00:00
Diogo Peralta Cordeiro
66323c5a73
[PLUGIN][ActivityPub] Fix several issues with target and notifications inserted by AP 2022-02-13 23:14:59 +00:00
Diogo Peralta Cordeiro
56c884026f
[COMPONENT][Notification] We must record remote notifications because of feeds 2022-02-13 23:14:59 +00:00
Diogo Peralta Cordeiro
62bf788b90
[CORE][Note] Implement private group scope properly 2022-02-13 23:14:59 +00:00
Diogo Peralta Cordeiro
6500e99b69
[COMPONENT][Posting] Respect context actor concerning visibility and In sorting 2022-02-13 23:14:58 +00:00
Diogo Peralta Cordeiro
cda1568db5
[TEMPLATES][Cards][Blocks] Provide both actor uri and url, as well as full mention guidance 2022-02-13 23:14:58 +00:00
Diogo Peralta Cordeiro
1f2638d15a
[ROUTES] Remove ActorCircle holdover route 2022-02-11 15:31:47 +00:00
Diogo Peralta Cordeiro
6b4fa8c303
[COMPONENT][Notification] Additional check to avoid unnecessary notifications 2022-02-11 15:31:47 +00:00
Diogo Peralta Cordeiro
17733f32d6
[PLUGIN][ActivityPub] Implement Group Outbox
Fix various minor issues
2022-02-11 10:06:01 +00:00
Diogo Peralta Cordeiro
fb3e900b28
[CORE] Add CONFIG_ prefix to environment whitelist
Fixed minor issues with Commong:config of env not being included and ported to local social yaml

Fixed some regressions introduced with [CORE] Unset sensitive information from the environment
2022-02-11 10:05:58 +00:00
Diogo Peralta Cordeiro
416451a519
[CORE][Actor] Simplify logic so more is reused between different types of actors
Minor bug fixes
2022-02-11 00:27:03 +00:00
Diogo Peralta Cordeiro
1f1524c2b3
[GROUP] Simplify logic by making Actor::Organisation a type of Actor::Group
Some minor bug fixes
2022-02-11 00:26:43 +00:00
Eliseu Amaro
35e907f7b2
[CARDS][Note] Note's 'in reply to' information added, overall polish of feeds templates and proper titles added for every single section that makes up a note 2022-02-09 18:49:34 +00:00
Eliseu Amaro
79bb258ba6
[CSS] Further dialing of sizes and media queries for a better mobile UX 2022-02-08 17:14:28 +00:00
Eliseu Amaro
80dfea6812
[CARDS][Note] Note's actions are now inside the same div as Note's complementary info, overall footprint of replies diminished 2022-02-08 17:01:58 +00:00
Eliseu Amaro
f6b19d2a0f
[CARDS][Note] Note's actions are now inside the same div as Note's complementary info, overall footprint of replies diminished 2022-02-08 16:13:46 +00:00
Eliseu Amaro
67a2387b31
[CARDS][Note] Removed note's complementary info related to the current user everywhere, which was criticized from being redundant 2022-02-08 15:19:33 +00:00
Eliseu Amaro
5d0b8930e1
[COMPONENTS][Conversation] Removed redundant complementary information from notes replied to 2022-02-08 14:43:39 +00:00
Eliseu Amaro
22741702bf
[CSS] Replaced .section-details-subtitle summary, .section-details-title summary outline to a border, since Firefox ESR doesn't apply border-radius to outline 2022-02-08 14:22:52 +00:00
Eliseu Amaro
ba131bdb16
[CSS] Background noise is back, default_theme directory hierarchy simplified
[PLUGINS][Oomox] Fixed issue where resetting colours when no entity was present would lead to an error (it expected an entity, but NULL was given)
2022-02-08 14:12:59 +00:00
Eliseu Amaro
7b0667109d
[CARDS][Note] Note actions are now displayed at the end
Due to space constraint on mobile screens, prior actions placement proved to be a problem. Additionally, note replies are now separated from their parent, allowing more horizontal space to be used if necessary/more reply depth to be presented in a reasonable fashion.
2022-02-08 01:26:25 +00:00
Eliseu Amaro
5cd3bc3206
[CSS] Touch devices are now able to scroll horizontally on note author information 2022-02-08 00:30:15 +00:00
Eliseu Amaro
79d022e850
[CSS] Fixing note attachments padding, height and allowing their wrap when limited space is available 2022-02-08 00:18:24 +00:00
Eliseu Amaro
cb393ca554
[CARDS][Note] Fix note replies from calling note macro as if it was still part of the same template 2022-02-08 00:05:51 +00:00
Eliseu Amaro
99593a19ef
[CSS] Default theme polish work, more consistent font sizes and improved dark theme colors 2022-02-07 23:54:29 +00:00
Eliseu Amaro
9a53f94b77
[TWIG] Replaced getRightPanelBlocks with addRightPanelBlock, provides more control on block placement
[COMPONENTS][RightPanel] Refactored template, improved clarity, and added Posting form related macros

[PLUGINS][NoteTypeFeedFilter] Removed icons from template, added them through CSS to further improve performance
2022-02-07 20:29:14 +00:00
Eliseu Amaro
d6666cf209
[CSS] Aligned details marker arrows 2022-02-07 02:46:08 +00:00
Eliseu Amaro
b3d582f665
[PLUGINS][AttachmentCollections] Fixed "Error: Expected Doctrine\ORM\Query\Lexer::T_IDENTIFIER, got 'Plugin\AttachmentCollections\Entity\AttachmentCollection'"
[TWIG] Cards are now divided into blocks and macros, additional macros done, attachments page no longer inside cards directory
[CARDS][Navigation] Now using macros to create section, details, and nav elements
2022-02-07 01:54:04 +00:00
Eliseu Amaro
2b9f70f89f
[PLUGINS][BlogCollections] Entities and base plugin and controller done 2022-02-07 01:52:35 +00:00
Eliseu Amaro
e0ceddc2e6
[CSS] Replaced fooobar:not([foo=bar], [foo2=bar2]) rule, as Firefox ESR 78.x doesn't support that specific syntax 2022-02-04 21:12:22 +00:00
Diogo Peralta Cordeiro
81f6d496c6
[PLUGIN][OAuth2] Fix some static issues 2022-02-04 19:56:17 +00:00
Eliseu Amaro
4dd976eb22
[ENTITY][Note] Added function getRenderedSplit, return an array of paragraphs/line breaks
[PLUGINS][Favourite] Foreign keys now properly defined on schema

[CARDS][Note] Note text is now hidden by default if too many paragraphs/line breaks are present, BlogCollection plugin will certainly need this feature
2022-02-04 16:07:24 +00:00
Bruno Aleixo
fb76775716 [TOOLS][COMPONENTS][CORE] Ran cs-fixer on all files 2022-01-30 16:41:54 +00:00
Bruno Aleixo
162b01e2c5 [CORE] Unset sensitive information from the environment 2022-01-30 16:39:43 +00:00
Eliseu Amaro
afd1211852
[CSS] Using accent-color rule to stylize checkbox 2022-01-28 23:15:01 +00:00
Eliseu Amaro
8f8070036c
[CSS] Eliminated repeated rules, improved icon alignment, and removed checkbox and radio custom styling
Browser specific quirks made it impossible to stylize checkbox and radio buttons. High DPI, custom default font sizes and/or custom GTK themes make it very difficult to keep it consistent.
2022-01-28 18:21:04 +00:00
Eliseu Amaro
2e6f91f34e
[FORM][ActorForms] Fullname length is now validated prior to being set 2022-01-27 17:53:02 +00:00
Eliseu Amaro
5036b72a71
[ENTITY][Actor] Nickname is lower case transformed when generating 'actor_view_nickname', making sure that actor pages are linked accordingly 2022-01-27 17:19:50 +00:00
Eliseu Amaro
a17a514bfd
[CONTROLLER][Security] Further sanity checks and validation done on email entry 2022-01-27 17:08:20 +00:00
Eliseu Amaro
1576d253a5
[CONTROLLER][UserPanel] Email is now sanitized and validated before calling corresponding setter 2022-01-27 16:59:43 +00:00
Eliseu Amaro
64a698d255
[COMPONENTS][Search] Polished search template for a clearer header hierarchy 2022-01-27 02:17:41 +00:00
Eliseu Amaro
ab6dabf4f7
[CSS] Fix issue where panels wouldn't scroll independantly 2022-01-27 01:53:30 +00:00
Eliseu Amaro
222e1fbb2b
[PLUGINS][AttachmentShowRelated] Replacing h2 with span, its supposed to be complementary content, not main 2022-01-27 01:13:18 +00:00
Eliseu Amaro
117549bf1e
[PLUGINS][Favourite] Remove favourite action properly removes note_favourite Entity now [COMPONENTS][Collection] Simplyfying feed-action-details template section
[COMPONENTS] Documentation work [PLUGINS] Documentation work
2022-01-27 00:54:27 +00:00
Eliseu Amaro
adf484f58a
[COMPONENTS][Posting] No error to ignore was reported on line 161, removed ignore
[PLUGINS][Directory] Further documentation work

[CORE][Controller] Separating workflows, setting proper return types

[TWIG][Security] Removing unused stylesheet calls
2022-01-26 20:54:55 +00:00
Eliseu Amaro
16e7d6cff7
[COMPONENTS] Documenting methods with high cognitive complexity, specifically in Group and Posting components
[PLUGINS][Directory] Updating docs, @params weren't set correctly
2022-01-26 20:01:37 +00:00
Eliseu Amaro
6a5312aca9
[CORE][GNUsocial] social.local.yaml is now updated with the proper node name 2022-01-26 18:46:31 +00:00
Eliseu Amaro
14bb1b2876
[COMPONENTS][Conversation] Note being replied to now appears before Posting's own form, RightPanel is also open by default on smaller screens when the current route is 'conversation_reply_to' 2022-01-25 19:18:42 +00:00
Hugo Sales
c7c5fe7979
[PLUGIN][OAuth2] Add 'me' field to token responses 2022-01-25 16:07:39 +00:00
Hugo Sales
fa0d02a9ac
[PLUGIN][OAuth2] Start adding OAuth2 support with client registration
This hardcodes the user, and has some other issues, so it is not yet
complete.

We follow mastodon's spec for automatic client registration, available
at both `/api/v1/apps` and a more reasonable `/oauth/client`. This
accepts a JSON POST with the client info and returns JSON with a
`client_id` and a `client_secret`, to be used with `/oauth/authorize`
and `/oauth/token`. It also, seemingly, requires returning an `id`
with unclear purpose.

The `/oauth/token` endpoint doesn't currently return a `me` field.
2022-01-25 13:35:44 +00:00
Hugo Sales
4736146b80
[TOOLS] Update autocode, allow for abstract entity classes, derive namespace from file rather than using 'get_declared_classes' 2022-01-25 13:35:44 +00:00
Eliseu Amaro
e3bfb1ebc5
[CSS] .note-info text will automatically crop when no space is available, on hover will show contents 2022-01-25 00:02:38 +00:00
Eliseu Amaro
ee04571f4d
[TWIG] Various fixes related to header elements hierarchy
Widgets shouldn't have a header element from here forward, since their location varies
2022-01-23 19:46:47 +00:00
Eliseu Amaro
bf07fa1ade
[COMPONENTS][Collection] Added PrependActorsCollection event [COMPONENTS][Group] Added getGroupCreateForm, used in PrependActorsCollection event to build create a new Group form view
[COMPONENTS][LeftPanel] Removed onEndShowStyles event since the corresponding CSS needed is now consolidated into the default_theme itself [COMPONENTS][RightPanel] Deleted components/RightPanel/RightPanel.php, since its only method (onEndShowStyles) wasn't needed anymore
2022-01-23 19:07:39 +00:00
Eliseu Amaro
e4a3438d55
[CORE][I18n] Fixing 'file_get_contents(): Argument #1 () must be of type string, Symfony\Component\Finder\SplFileInfo given' error by using Symfony's Finder to iterate through existing files 2022-01-23 19:07:39 +00:00
Diogo Peralta Cordeiro
6b1c6f603e
[CORE][ActorLocalRoles] Improve Roles 2022-01-22 18:47:56 +00:00
Hugo Sales
5f243f68be
[DEPENDENCIES] Add symfony/psr-http-message-bridge 2022-01-21 22:05:34 +00:00
Hugo Sales
68c3204e71
[DEPENDENCIES] Update dependencies 2022-01-21 22:05:34 +00:00
538 changed files with 22253 additions and 11283 deletions

View File

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

View File

@ -178,7 +178,7 @@ return $config
// There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis. // There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis.
'no_spaces_inside_parenthesis' => true, 'no_spaces_inside_parenthesis' => true,
// Removes `@param`, `@return` and `@var` tags that don't provide any useful information. // Removes `@param`, `@return` and `@var` tags that don't provide any useful information.
'no_superfluous_phpdoc_tags' => 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.

View File

@ -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
View File

@ -352,8 +352,8 @@ SOCIAL_DBMS=${DBMS}
SOCIAL_DB=${DB_NAME} SOCIAL_DB=${DB_NAME}
SOCIAL_USER=${DB_USER} SOCIAL_USER=${DB_USER}
SOCIAL_PASSWORD=${DB_PASSWORD} SOCIAL_PASSWORD=${DB_PASSWORD}
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
View 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

View File

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

View File

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

View File

@ -22,10 +22,10 @@ declare(strict_types = 1);
namespace Component\Attachment; namespace Component\Attachment;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\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:'])) {

View File

@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Attachment\Controller; namespace Component\Attachment\Controller;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB\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');

View File

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

View File

@ -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')]);
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Avatar\Controller; namespace Component\Avatar\Controller;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB\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;

View File

@ -24,10 +24,10 @@ declare(strict_types = 1);
namespace Component\Avatar\Entity; namespace Component\Avatar\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\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;

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@ namespace Component\Circle\Controller;
use App\Core\Cache; use App\Core\Cache;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB\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,

View File

@ -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'],

View File

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

View File

@ -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]],
]); ]);

View File

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

View File

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

View File

@ -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
{ {
} }

View File

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

View File

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

View File

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

View File

@ -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
{ {
} }

View File

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

View File

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

View File

@ -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 %}

View File

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

View File

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

View File

@ -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 %}

View File

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

View File

@ -28,11 +28,11 @@ declare(strict_types = 1);
namespace Component\Conversation\Controller; namespace Component\Conversation\Controller;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\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(),
]; ];
} }
} }

View File

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

View File

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

View File

@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Conversation\Entity; namespace Component\Conversation\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\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;

View File

@ -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'],
]; ];
} }
} }

View File

@ -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']);

View 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
}

View File

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

View File

@ -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]);
} }
} }

View File

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

View File

@ -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',

View File

@ -6,7 +6,7 @@ namespace Component\FreeNetwork\Util\WebfingerResource;
use App\Core\Event; use App\Core\Event;
use App\Core\Log; use App\Core\Log;
use App\Core\Router\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;

View File

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

View 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);
}
}

View File

@ -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'],
],
];
}
}

View File

@ -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'],
];
}
}

View File

@ -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'],
],
];
}
}

View File

@ -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' => [

View File

@ -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'],

View File

@ -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'],
], ],

View File

@ -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)) {

View File

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

View File

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

View File

@ -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 %}

View File

@ -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());
} }
} }

View File

@ -25,7 +25,7 @@ namespace Component\Language\Controller;
use App\Core\Cache; use App\Core\Cache;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB\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']);
} }
} }

View File

@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Language\Entity; namespace Component\Language\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\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(

View File

@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Language\Entity; namespace Component\Language\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\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
{ {

View File

@ -23,7 +23,7 @@ namespace Component\Language;
use App\Core\Event; use App\Core\Event;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\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;
} }
} }

View File

@ -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 %}

View File

@ -25,10 +25,10 @@ namespace Component\LeftPanel\Controller;
use App\Core\Cache; use App\Core\Cache;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB\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);

View File

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

View File

@ -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) }}

View File

@ -1,24 +1,24 @@
{% block leftpanel %} {% block leftpanel %}
<label class="panel-left-icon" for="toggle-panel-left" tabindex="-1">{{ icon('menu', 'icon icon-left') | raw }}</label> <label class="panel-left-icon" for="toggle-panel-left" tabindex="-1">{{ icon('menu', 'icon icon-left') | raw }}</label>
<a id="anchor-left-panel" class="anchor-hidden" tabindex="0" title="{{ '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 %}

View File

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

View File

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

View File

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

View File

@ -35,7 +35,7 @@ declare(strict_types = 1);
namespace Component\Notification\Controller; namespace Component\Notification\Controller;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB\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'),

View 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'],
],
];
}
}

View File

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

View File

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

View 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())),
];
}
}

View File

@ -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'],
); );

View 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;
}
}

View File

@ -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 %}

View 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);
// }
}

View 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.'));
}
}

View 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)]);
}
}

View File

@ -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':

View File

@ -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 %}

View File

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

View File

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

View File

@ -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) }}

View File

@ -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 %}

View File

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

View File

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

View File

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

View File

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

View File

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