280 Commits

Author SHA1 Message Date
b999c1bd62 yet another iteration 2022-01-22 18:49:25 +00:00
9dc6243822 fix firewalls a little 2022-01-22 18:49:25 +00:00
ce8f54dc46 support json on oauth 2022-01-22 18:49:24 +00:00
9e7db08e50 more grants 2022-01-22 18:49:24 +00:00
841d10cde0 buah 2022-01-22 18:49:24 +00:00
95c8f3bdc7 damn 2022-01-22 18:49:24 +00:00
b82818646f wip42 2022-01-22 18:49:24 +00:00
5ac764f3e5 move to plugin 2022-01-22 18:49:24 +00:00
4ad1de2616 some logic 2022-01-22 18:49:23 +00:00
29f53bb698 wip5 2022-01-22 18:49:23 +00:00
cb16b627b4 cenas 2022-01-22 18:49:23 +00:00
19dd4ba368 wip4 2022-01-22 18:49:23 +00:00
53a1a3fad1 wip3 2022-01-22 18:49:23 +00:00
737648359d wip2 2022-01-22 18:49:23 +00:00
57c09c6f8f wip 2022-01-22 18:49:22 +00:00
08e3da092b [OAuth2] Add scopes 2022-01-22 18:49:22 +00:00
7959ea497b [PLUGIN][OAuth2] Add OAuth2 support 2022-01-22 18:49:22 +00:00
559f6d650b [COMPONENT][Language] Fix template name in language sorting 2022-01-21 22:05:34 +00:00
3d9edd1db8 [COMPONENTS][LeftPanel] Edit feeds page polish, existing links are shown in a grid, saving space
[COMPONENTS][Collection] Fixing details summary class [PLUGINS][WebMonetization] Fixing widget details summary class
2022-01-21 22:05:34 +00:00
402300fe93 [COMPONENTS][Search] Fixing Search form incorrect class names 2022-01-21 22:05:34 +00:00
e2e1b0172d [COMPONENTS][Collection] Actors view template ordering section polished [PLUGINS][RepeatNote] Note to be repeated now uses full note card
[CSS] Simplyfying rules, re-ordering and removing unnecessary and costly 'display: flex' rules
[CARDS][Note] Minimal note macro has info inside the note itself now, since horizontal space is limited
2022-01-21 22:05:33 +00:00
f731850f5c [CSS] .section-widget class and derivatives replaced as .frame-section, since a widget implies a simple element with a specific function 2022-01-21 22:05:33 +00:00
7d546e8901 [CSS] Improved performance, reduced padding [COMPONENTS][LeftPanel] Consolidated CSS into base.css [COMPONENTS][RightPanel] Consolidated CSS into base.css [PLUGINS][WebMonetization] Replaced fieldset with section
Accessibility tests failed if the fieldset had no legend, since it
wasn't really neeeded, it was replaced as another element.
2022-01-21 22:05:33 +00:00
bdeb3bcff5 [PLUGIN][ActivityPub] Federate Actor of types other than Person
Fix some other minor bugs
2022-01-21 22:05:31 +00:00
25b2847201 [TOOLS][AYY1] Improve accesibility testing to save images and compare the differences against a reference (tests/screenshots/ 2022-01-21 21:03:09 +00:00
23d45ffab7 [UTIL][Formatting] Mention prefix was hardcoded, fixed. 2022-01-21 21:03:09 +00:00
b253ce5e70 [DOCS][Design] Add guidelines menu entry 2022-01-21 21:03:09 +00:00
c4f9e58e8d [COMPONENTS][Attachment] Fixed typo on attachmentShowWithNote, where the template called was somehow replaced with a child of it 2022-01-21 21:03:09 +00:00
6ab740d780 [COMPONENT][Search][UI] Fix template, which included the search builder form inside the search form, chaos ensuing 2022-01-21 21:03:09 +00:00
de795b78f9 [DOCKER][DEPENDENCIES] Restructure PHP Dockerfile to install each package in separate layers and add WikiMedia texvc 2022-01-21 21:03:09 +00:00
29d498770c [COMPONENTS][Group] Create a group route added, template polished
[COMPONENTS][Circle] Removed any Group related route from shouldAddToRightPanel event
[CARDS][Profile] Block should now allow inline long nicknames to not
break
2022-01-21 21:03:09 +00:00
d7039b1c5c [COMPONENTS][Group] Create a group route added, template polished
[COMPONENTS][Circle] Removed any Group related route from shouldAddToRightPanel event
[CARDS][Profile] Block should now allow inline long nicknames to not
break
2022-01-21 21:03:08 +00:00
1856af68b3 [PLUGIN][RepeatNote][COMPONENTS][Posting] Review and fix RepeatNote. Handle attachment lives in Posting 2022-01-21 21:03:08 +00:00
9bd1f42843 [TOOLS] Use sudo to remove files 2022-01-21 21:03:08 +00:00
145c88d43f [ENTITY][Note] Only attempt to find mentions if we have content 2022-01-21 21:03:08 +00:00
4717dde12e [TWIG][I18N] Improve base template facilitate translations of the accessibility panel text 2022-01-21 21:03:08 +00:00
c028a601a5 [COMPONENTS][Group] Create a group route added, template polished
[COMPONENTS][Circle] Removed any Group related route from shouldAddToRightPanel event
[CARDS][Profile] Block should now allow inline long nicknames to not
break
2022-01-21 21:03:08 +00:00
692ecf1c99 [TWIG] Improved templates HTML structure, removed unnecessary element nesting, and refactored content sectioning
[COMPONENTS][Search] Refactored widget event as 'PrependRightPanel' (making it able to accomodate more generic blocks)
2022-01-21 21:03:08 +00:00
242fe3fd6e [PLUGINS][PinnedNotes] Replacing arbitary size values with common variables 2022-01-21 21:03:08 +00:00
dbdf1d9b0b [CSS] Fixed footer responsiveness, since its content wouldn't wrap up from insuficient space for all of its content 2022-01-21 21:03:08 +00:00
7daa61500d [COMPONENTS][Collection] Notes collection template now has a default title
[CARDS][Note] Removed note actions from minimal note block
2022-01-21 21:03:07 +00:00
077cbcf424 [TWIG] Improved accessibility menu
[COMPONENTS][RightPanel] Content form row is now preceeded by the content type form row
2022-01-21 21:03:07 +00:00
04431885aa [PLUGIN][PinnedNotes] Fix ActivityPub config 2022-01-21 21:03:07 +00:00
b8a35f9d6d [PLUGIN][WebMonetization] Fix ActivityPub config 2022-01-21 21:03:07 +00:00
184d0246a5 [COMPONENTS][RightPanel] AppendRightPanelBlock event refactored,
replaced with src/Twig/Rintime::getRightPanelBlocks
[COMPONENTS] Re-ordered onAppendRightPanelBlock event calls arguments for improved consistency across events
2022-01-21 21:03:07 +00:00
da7ae5e1f5 [TESTS][A11Y] Login and check all user pages for accessibility 2022-01-21 21:03:07 +00:00
9e4aed84f8 [PLUGIN][LatexNotes] add LaTeX support for notes 2022-01-21 21:03:07 +00:00
db42ade2b6 [PLUGIN][MarkdownNotes] add markdown support for notes 2022-01-21 21:03:07 +00:00
06d11d8337 [PLUGINS[WebMonetization] Basic activityPub support 2022-01-21 21:03:07 +00:00
148dd6db50 [PLUGINS][PinnedNotes] Basic activityPub support 2022-01-21 21:03:06 +00:00
21c7912702 [PLUGIN][Pinned Notes] Allow user to pin his notes 2022-01-21 21:03:06 +00:00
f7cbfbff8c [COMPONENT][Collection] Add event to render html before drawing feed 2022-01-21 21:03:06 +00:00
3f0d996dc9 [COMPONENT][Tag] Fix event handling 2022-01-21 21:03:06 +00:00
9e891ed020 [TOOLS][PHPStan] Ignore errors due to lack of namespace in CodeCeption acceptance tester 2022-01-21 21:03:06 +00:00
6c6c0270c5 [TESTS][CodeCeption] Update acceptance tests to remove accesibility test kludge 2022-01-21 21:03:06 +00:00
a59997b41f [TOOLS][DOCKER][A11Y] Switch to Pa11y CI and don't run it 'integrated' with codeception, since there was no good way to share cookies 2022-01-21 21:03:06 +00:00
d542be1df4 [ACCESSIBILITY] Fix regressions in panel checkboxes and accessibility menu accesskeys
Accessibility menu accesskey regressions introduced with [ACCESSIBILITY][BASE] Accessibility menu was unreachable.
2022-01-13 19:47:41 +00:00
eff9318c1d [UTIL][Formatting] Mention title is not always defined 2022-01-13 18:07:19 +00:00
fa9df9962e [COMPONENTS][Conversation] Route 'conversation_mute' now has the
corresponding conversation view embedded, user is also redirected
properly
[PLUGINS][Favourite] Fixed typo
2022-01-13 17:47:47 +00:00
859bf0c0bf [CONTROLLER][UserPanel] Notification settings panel debug information added for future reference 2022-01-13 16:24:23 +00:00
d29e28b829 [CORE][Entity] Replaced get_called_class() calls with static::class since the former is deprecated 2022-01-13 16:24:12 +00:00
14b03c7137 [UI][UserPanel] Do not try to re-set an unchanged nickname 2022-01-12 17:46:13 +00:00
480f570238 [CORE][SECURITY][HTML] Refactor Security::sanitize to HTML::sanitize
Update composer dependencies, move more general deps from ActivityPub to Core
2022-01-12 17:12:58 +00:00
968b1751fd [CSS] Further styling optimizations, compacting common rules. Removed select dropdown images, since they are no longer required 2022-01-12 16:42:33 +00:00
c8daa82c1d [TWIG] Replaced base.css @import with HTML link imports
According to 'High Performance Web Sites' (ISBN 10: 0596529309), @import has a negative impact on web page performance. Since all imports dependant on base.css will only start downloading after that one is fully gathered.
2022-01-12 16:32:47 +00:00
600a1511cb [TWIG] Removed all instances were 'arrow-down' icon was called in twig templates, replaced it's intended feedback by using :after pseudo-selector within CSS
In user panel render time reduced dramatically, by ~70ms. Said icon was requested by twig >10 times.
2022-01-12 16:07:28 +00:00
59b8bdf99b [PLUGIN][ActivityPub] Provide ActivityStreams 2.0 responses for every Collection
Implemented ActivityPub Outbox
2022-01-11 20:30:25 +00:00
f3a7e8f04d [TOOLS] Remove CodeCeption files from composer autoload-dev, since they're not available before installing, and ignore errors in PHPStan 2022-01-10 23:14:43 +00:00
65504b72bb [TOOLS] Setup pa11y to run on pages after codeception 2022-01-10 23:09:39 +00:00
José Marques
d713429d88 [CORE][Nickname] Properly set nickname for existing accounts 2022-01-10 11:33:13 +00:00
1056bc661f [COMPONENT][FreeNetwork] Restore Galaxy feed 2022-01-10 10:29:55 +00:00
f40eb3955f [TOOLS] Update makefile to add an acceptance and accesibility testing target 2022-01-09 14:45:33 +00:00
b2b445d21e [TOOLS][DOCKER] Add pa11y and nginx container to tooling toolchain 2022-01-09 14:44:56 +00:00
528f6df240 [TOOLS][PHPStan] Ignore autogenerated Codeception classes 2022-01-09 14:44:08 +00:00
894c78bf99 [TOOLS] Keep git in docker image, since we use patched composer packages, temporarily 2022-01-09 14:43:33 +00:00
38baa192d8 [DEPENDENCIES][TOOL] Add codeception and bootstrap it for acceptance and accessibility testing 2022-01-09 14:42:16 +00:00
a697399a6f [PLUGIN][DeleteNote][Favourite][ProfileColor][RepeatNote][WebMonetization] Refactor, cleanup and cache results 2022-01-09 12:29:34 +00:00
cdf1d67d0f [CORE][Cache] Workaround to redis not allowing empty lists 2022-01-09 12:29:34 +00:00
06ece5b72e [COMPONENT][Collection] Only run queries if the criteria is not empty 2022-01-09 12:29:34 +00:00
da6d3bd351 [COMPONENT][Collection] Use current locale 2022-01-09 12:29:34 +00:00
c835fc6aca [COMPONENT][Collection][Feed][Attachment][Feed][Language][Tag] Refactor and consolidate Search and Feed query mechanisms into Collection. Remame 'onSearch' events to 'onCollectionQuery' 2022-01-09 12:29:34 +00:00
57604b3851 [PLUGIN][NoteTypeFilter] Always show filtering options 2022-01-09 12:29:34 +00:00
b1abd81aca [DEPENDENCIES] Update dependencies 2022-01-08 00:11:12 +00:00
5cfed3d536 [TWIG] Display errors in templates that display forms with form_start 2022-01-08 00:11:08 +00:00
0758d6145b [COMPONENT][Collection][CONTROLLER][Collection] Use null-safe calls to attempt to get a language 2022-01-08 00:07:32 +00:00
d17f276419 [COMPONENTS][Conversation] Added missing foreign keys to ConversationMute Entity
Fixed 'is_muted' variable check logic that impeded the Conversation from being muted
2022-01-07 21:14:51 +00:00
fc57b3290e [COMPONENTS][Search] Polished results page HTML view 2022-01-07 21:14:51 +00:00
1438433859 [PLUGINS][NoteTypeFeedFilter] Polish feed actions HTML, adding proper anchor titles and better user feedback when a filter in applied
[COMPONENTS][Collection] Notes feed template HTML polish to accomodate changes needed for NoteTypeFeedFilter
2022-01-07 21:14:51 +00:00
cb1dc4c10f [PLUGIN][WebMonetization] Adding Web Monetization plugin which allows for donations using the Web Monetizations protocol 2022-01-07 14:55:35 -03:00
9cf8970603 [TEMPLATES][Base] AppendToHead event added to base template 2022-01-07 14:53:55 -03:00
c3d58c350e [COMPONENTS][Collections] Iterating documentation 2022-01-07 09:23:37 -03:00
e056920de4 [COMPONENT][Subscription] Fix Notifications 2022-01-06 12:13:11 +00:00
0c245fcb6e [COMPONENTS][Subscription] Subscribe Actor action implemented
[TWIG] AddProfileAction event added
[CARDS][Profile] Refactor and restyling to accomodate Actor actions
2022-01-06 12:13:10 +00:00
0d1ab2c9cf [SECURITY][Register] New users should have their current browser language set as first language preference 2022-01-05 04:19:35 +00:00
3f8fab0021 [PLUGIN][Favourite] Fix routes 2022-01-05 04:19:35 +00:00
cd6ce3542e [COMPONENT][Circle] Move circles to a component, various bug fixes
Mention links are now correct
2022-01-05 04:19:22 +00:00
627d92b290 [COMPONENT][Tag] Improve Note Tag Handling and start extracting Circles logic out of the plugin, various bug fixes 2022-01-05 01:30:02 +00:00
ee007befa4 [COMPONENT][Posting] DB::Flush after Notification and fix minor issues with In targets 2022-01-05 01:30:01 +00:00
9df9c6a19c [COMPONENT][Collection] Make MetaCollectionPlugin a trait and abstract collection delete and name update 2022-01-05 01:30:00 +00:00
754135743e [COMPONENT][Subscription] Move respective routes to component 2022-01-05 01:29:27 +00:00
5a0bbfc795 [UTIL][Common][I18N] Use actor's preferred language for _m and utility to retrieve current language even when no actor is logged in 2022-01-05 01:29:26 +00:00
6247dd4c1a [COMPONENT][RightPanel] Display form errors 2022-01-04 18:58:32 +00:00
de8eab2cf8 [CORE][FORM][FormTypeNonceExtension] Add a nonce to all forms with a CSRF token 2022-01-04 18:58:32 +00:00
b7e4f79ccc [CORE][Cache] Add Cache::incr which increments a value at , atomically, in the case of Redis 2022-01-04 18:58:32 +00:00
a5b5362be2 [DOCS][Designer] General guidelines for styling initiated
Added wireframes of default page, dividing page into 4 distinct general
areas.

Added CSS classes reference table.
2022-01-04 00:02:21 +00:00
d444ea7963 [COMPONENT][Conversation] Refactor and fix Conversation component 2022-01-03 21:20:27 +00:00
a729a8eddb [COMPONENT][Collection] Pass current actor and their top language to query, if not otherwise specified 2022-01-03 21:20:26 +00:00
a8a8cc4046 [COMPONENT][Posting] Plumb in reply_to and redirecto to GET from 2022-01-03 21:20:26 +00:00
7d38c927e1 [ENTITY][Note][CACHE] Consolidate cache keys to helper function and add ensureCanInteract 2022-01-03 21:20:26 +00:00
135bf8bc68 [COMPONENTS][Conversation] Documented respective Controller
[PLUGINS][TreeNotes] Documentation added, feedFormatTree explained in detail
2022-01-03 19:26:17 +00:00
5a31258190 [COMPONENTS][Conversation] Further documentation work, expected arguments explained in more detail 2022-01-03 19:26:17 +00:00
f5fc7b6cd1 [CORE][Controller] Add facility for either returning null or throwing, from Controller->{int,string,bool} 2022-01-03 18:02:33 +00:00
141c5f6785 [COMPONENT][Collection][CONTROLLER][Collection] Add utility method to call Feed::query in Collection, which handles getting and passing the page 2022-01-03 18:02:33 +00:00
07b65584ff [COMPONENTS][Posting] Replaced is_int() with is_numeric(), casting target to int when using Actor getter
From php-stan: is int with string evaluates to false

Thus, the change was made in order to behave as intended
2022-01-03 17:58:48 +00:00
4ae160b0f8 [PLUGINS][AttachmentShowRelated] Fix onEndShowStyles condition to add proper stylesheet to array 2022-01-03 17:51:19 +00:00
a622b175bc [DEPENDENCIES] Update dependencies 2022-01-03 16:38:51 +00:00
9ea230d12b [COMPONENT][Subscription] Implement subscription handlers 2022-01-03 02:23:06 +00:00
fe087b2217 [PLUGIN][ActivityPub] Accept Undo Follow 2022-01-03 02:23:06 +00:00
a9ea49d34c [TOOLS][DOC] Add documentation to functions flagged by doc-check 2022-01-03 02:23:06 +00:00
9e0a2dd4a0 [TOOLS] Fix errors found by PHPStan 2022-01-03 02:23:06 +00:00
8fa04bb47d [EVENT][AddFeedActions] Add bool param which denotes whether the feed is empty. [PLUGIN][NoteTypeFeedFilter] Don't show filters if the feed is empty 2022-01-03 02:23:06 +00:00
d5a6fa924b [COMPONENT][Conversation][ENTITY][ConversationMute] Rename Conversation{Block,Mute} 2022-01-03 02:23:05 +00:00
ba0b0629b7 [TOOLS] Fix deprecations in php-doc-check by providing my own implementation 2022-01-03 02:18:44 +00:00
27276ba379 [CONFIG] Rename streams/notes_per_page to feeds/entries_per_page 2022-01-03 02:18:44 +00:00
ea5a4df1a4 [UI][PLUGIN][Directory] Add UI for Directory listing ordering. This uses a GET parameter, which subits the field in order_by and the operator in order_op. Using order_by=filed^ is still supported 2022-01-03 02:18:44 +00:00
6cfb69d64b [COMPONENT][Subscription] Start component 2022-01-03 02:18:44 +00:00
5fa8056899 [COMPONENT][Collection] Refactoring: Further work in abstracting collections 2022-01-03 02:18:43 +00:00
def5f36c25 [PLUGIN][ActivityPub][Inbox] Accept Follow Activity
Improve how Core Activity is handled in general
2022-01-02 23:50:16 +00:00
afb7ae0f75 [CORE][Util][Exception] Add log context to BugFoundException 2022-01-02 23:50:15 +00:00
064288e33b [CSS] Applying correct border-radius to note-info and adding a greater margin on note-complementary, in case its the last of type 2022-01-02 23:50:15 +00:00
c7ea56d571 [CSS] Fixing checkboxes from displaying background cropped 2022-01-02 23:50:15 +00:00
17b46b9aeb [CSS] Fixed radio button, it didnt change its own background upon being checked, making it invisible 2022-01-02 23:50:15 +00:00
28424402ec [CONTROLLER][CollectionController] Refactored methods names and form
titles
[PLUGINS][AttachmentCollections] Renamed respective Controller to
differentiate it between The Controller and itself, renamed templates
and removed unnecessary HTML from templates
2022-01-02 23:50:15 +00:00
7ad39fdc83 [PLUGINS][Repeat] Added onNoteDeleteRelated event
Using DB::merge to increment attachment lives when repeating a note,
since it's getting deprecated in the future, an alternative needs to
replace it here
2022-01-02 23:50:15 +00:00
d5080890ac [PLUGINS][Favourite] Added onNoteDeleteRelated event
All favourite entities are now removed from note_favourite table when the respective note
is deleted. Documented the favourNote and unfavourNote methods
2022-01-02 23:50:15 +00:00
f42e91d2bc [CORE][Controller] Allow plugins to override redirect responses 2022-01-02 23:50:14 +00:00
362fc6c7dd [CORE][Controller] Set some safe default headers for every response 2022-01-02 23:50:14 +00:00
046731a05a [COMPONENT][Avatar] Save title if possible, delete correctly, no early flushes
[PLUGIN][ActivityPub] Minor bug fixes in Actor translation
2022-01-02 23:50:14 +00:00
d27e8610d6 [CORE][DB] DB:removeBy Accept class name instead of table. 2022-01-02 23:50:14 +00:00
b7574500f8 [COMPONENT][FreeNetwork] Set discovery cors enabled by default 2022-01-01 23:57:28 +00:00
6ea45df3b8 [COMPONENT][FreeNetwork] Set JRD as the default mimetype for .well-know/host-meta 2022-01-01 23:57:28 +00:00
d6cd33019d [UI][PLUGIN][Directory] Add sort options to UI 2022-01-01 22:18:18 +00:00
5662210a2d [UI][PLUGIN][Directory] Use a single template for all results 2022-01-01 22:18:18 +00:00
b1fbf7d6ef [PLUGIN][Directory] Add option to order by subscribers 2022-01-01 22:18:18 +00:00
9f11d270f4 [PLUGIN][Directory] Fix opposite sort order 2022-01-01 22:18:18 +00:00
e7940a21ee [PLUGINS][TreeNotes] Feed only shows each note and its respective direct
replies, conversation shows whole tree

[COMPONENTS][Feed] Added request to FormatNoteList event

Every single Note that was provided to FeedController::postProcess is
shown. This means, that even though the Feed is formatted to show only a
Note and its respective direct replies, those same replies are shown
individually again (and they get the chance to show their own direct
replies).

The Note list provided to FormatNoteList is reversed, and for every
index, the respective Note replies are filtered out of the original list.
The replies are then added as leafs of the current Note and added to the tree.
2022-01-01 21:42:47 +00:00
f6311debbf [PLUGIN][Directory] Refactor directory controller, so it's hopefully clearer what's happening 2022-01-01 20:50:04 +00:00
175c98b043 [PLUGIN][Directory] Add options to sort by nickname, created, modified and activity, ascending or descending 2022-01-01 20:49:17 +00:00
acc84d757c [CORE][Controller] Make Controller->{int,bool,string} functions return null if the GET parameter doesn't exist 2022-01-01 20:03:40 +00:00
fc76a00908 [PLUGIN][Directory] Rename actor to people, as it's what's actually interacted with 2022-01-01 20:02:32 +00:00
1f01923aa1 [COMPONENT][Conversation] Implement Conversation muting 2022-01-01 10:32:07 +00:00
1a99762699 [COMPONENT][Posting][Notification] Move group inbox message creation to Notification component 2022-01-01 10:32:07 +00:00
f346cd8167 [COMPONENT][Language][Tag] Update usage of Functional::cartesian_product 2021-12-31 21:03:25 +00:00
7aa90954eb [UTIL][Functional] Update Functional::cartesian_product to pass an array of collections first and a separator after 2021-12-31 21:03:25 +00:00
0050371de7 [PLUGIN][NoteTypeFeedFilter][MediaFeed][COMPONENT][Feed] Rename MediaFeed to NoteTypeFeedFilter and add support for filtering by more types, moving functionality from Feed component 2021-12-31 21:03:25 +00:00
b7872ba4ee [CORE][Controller][FeedController] Use controller instance for filtering, making FeedControler->postProcess function non-static 2021-12-31 21:03:17 +00:00
ba078b7b76 [ENTITY][Note] Caching note replies query
[COMPONENTS][Conversation] Reply route will now present a more suitable title
2021-12-31 17:54:06 +00:00
d7b46735ac [CARDS][Note] Removing unnecessary elements on replies block, and fixing gaps between note-info and the note border itself 2021-12-31 17:02:18 +00:00
6dd31926ad [COMPONENTS][Conversation] reply_add route nows shows the given Note entire conversation, the Note itself is highlighted in the conversation 2021-12-30 18:34:47 +00:00
34cc010136 [CARDS][Note] Separating complementary info as its own block 2021-12-30 18:34:47 +00:00
9a6bdf74dc [COMPONENT][Feed] Add way of filtering the notes on an arbitrary feed with a types GET parameter, that accepts {,!}<[media,text,link,tags]...> 2021-12-30 18:28:57 +00:00
0ae24f6088 [CORE][Controller][FeedController] Use controller instance for filtering, making FeedControler->postProcess function non-static 2021-12-30 18:28:57 +00:00
5f4968ac05 [ENTITY][Note][CACHE] Store lists as lists in the cache 2021-12-30 18:28:57 +00:00
2e0bfc0bcd [TOOLS] Run CS-Fixer on all files 2021-12-30 18:28:57 +00:00
2dbc35fcc3 [DEPENDENCIES] Update dependencies 2021-12-30 18:28:57 +00:00
8831276489 [TOOLS] Fix errors reported by PHPStan 2021-12-30 18:28:57 +00:00
5229d4cd8c [COMPONENT][Notification] Remove nickname GET parameter 2021-12-30 18:28:56 +00:00
cbb70a5054 [COMPONENT][Feed] Refactor Feed component 2021-12-30 18:28:56 +00:00
f16df759a9 [Components][Posting] Posting section title set accordingly if in 'reply_add' route 2021-12-30 16:18:16 +00:00
2c31f2e440 [PLUGIN][Actor Circles] Actor Circles plugin, allow user to create a custom feed of actors 2021-12-30 12:18:16 -03:00
85e31c684d [MODULES][Collection] Fixing mistakes 2021-12-30 12:16:29 -03:00
bdd8cbf36d [COMPONENT][Right Panel] Send request to AppendRightPanelBlock event 2021-12-30 12:14:41 -03:00
d7f70d288d [MODULES][Collection] Abstracting Collections 2021-12-29 21:56:45 -03:00
49d247aec2 [COMPONENTS][Feed] Styling for the empty feed page added 2021-12-29 19:31:28 +00:00
f28ed5e359 [CSS] .note-complementary-info now smaller in general 2021-12-29 19:02:06 +00:00
6b82708968 [CARDS][Note] AppendCardNote event overhaul
[PLUGINS][Favourite] Added complementary information on user action

[COMPONENTS][Conversation] Fixed AppendCardNote issue where cached query
would only act on Notes with depth > 1
2021-12-29 18:47:12 +00:00
836560f55f [CARDS][Note] AppendCardNote event overhaul
[PLUGINS][Favourite] Added complementary information on user action

[COMPONENTS][Conversation] Fixed AppendCardNote issue where cached query
would only act on Notes with depth > 1
2021-12-29 18:35:12 +00:00
0caec6ab9e [COMPONENT][ACTOR CIRCLE] fixing template text 2021-12-29 14:13:25 -03:00
01d5e84a08 [COMPONENT][ACTOR CIRCLE] mention self tag circle with @#self_tag 2021-12-29 14:13:25 -03:00
f9bc1c790f [PLUGIN][AttachmentCollections] Dark mode 2021-12-29 10:52:04 -03:00
25120c6630 [PLUGIN][AttachmentCollections] Responsive pages 2021-12-29 10:52:04 -03:00
137723e59a [PLUGIN][ActivityPub][Favourite][Repeat][Delete] Document event handlers 2021-12-28 22:57:49 +00:00
8274e93ed5 [PLUGINS][DeleteNote] Added documentation, stating the scope of what deleting a Note means 2021-12-28 22:43:04 +00:00
ce3c6a7f23 [PLUGINS][RepeatNote] Added documentation (not for ActivityPub related functions) 2021-12-28 21:51:27 +00:00
846ec37cd9 [COMPONENT][Notification] Add event to decide whether local actors should be bothered 2021-12-28 18:45:18 +00:00
4d8e39bf69 [PLUGIN][RepeatNote] Do not notify about clone note
Fix redirecion after action
Fix typo in activitypub handler
2021-12-28 18:30:27 +00:00
182c6265a3 [PLUGINS][RepeatNote] Add onFilterNoteList event
Filters repeats out of Conversations, and replaces a repeat with the original Note on Actor feed

Added isNoteRepeat to plugin's entity, which returns true if a given
Note is a repeat of another Note
2021-12-28 17:37:01 +00:00
1d1d169a5c [PLUGIN][ActivityPub] Support federation of Tombstones 2021-12-28 17:10:20 +00:00
9cda64f275 [COMPONENT][Notification] Use _m() in reason 2021-12-28 16:36:52 +00:00
3e83387e98 [PLUGIN][DeleteNote] Support ActivityPub 2021-12-28 16:22:38 +00:00
9585472679 [ENTITY][Actor] Basic check if can admin for remote actors 2021-12-28 15:38:41 +00:00
b7c82b9dcb [PLUGIN][DeleteNote] Ensure permissions properly 2021-12-28 15:38:39 +00:00
5c2b46a71d [COMPONENT][Link] Wrap delete operations in transactions 2021-12-28 06:56:05 +00:00
46d121ef7b [COMPONENT][Attachment] Wrap delete operations in transactions and correct sequence of deletation 2021-12-28 06:56:05 +00:00
bf4a0008ef [ENTITY][Note] GNU social uses Tombstones for deleted notes instead of fully removing them.
Various corrections.
2021-12-28 06:56:05 +00:00
bb4149e092 [PLUGIN][AttachmentCollections] Restore functionality
Some minor corrections
2021-12-28 04:43:13 +00:00
a03429ba03 [PLUGIN][DeleteNote] Delete Note action implemented
Replaces Note content with a tombstone, removes any attachment relations and decrements their lives (possibly even removing the attachment), and creates the respective activity
2021-12-27 22:33:36 +00:00
f5b06e2c7e [UTIL][Formatting] Fix group mentions 2021-12-27 21:38:20 +00:00
c40e38c5ba [TOOLS] Fix errors found by PHPStan 2021-12-27 20:37:16 +00:00
d74a9ad373 [ENTITY][Subscription] Add Subscription::cacheKeys 2021-12-27 20:37:16 +00:00
76440961ca [CORE][DB] Add option to findOneBy to return null rather than throw 2021-12-27 20:37:16 +00:00
8796885fa0 [COMPONENT][Tag] Remove '.' from tag regex 2021-12-27 20:37:16 +00:00
5c10448080 [COMPONENT][Group] Add group subscribe button 2021-12-27 20:37:16 +00:00
559ec3df39 [TWIG] Fix login template 2021-12-27 19:09:32 +00:00
20d89f0f24 [COMPONENTS][Avatar] Make sure dimension values are integers
[CARDS][Note] Fix assumed avatar dimension values
2021-12-27 19:08:55 +00:00
19975b8d8d [COMPONENTS][Avatar] Make sure dimension values are integers
[CARDS][Note] Fix assumed avatar dimension values
2021-12-27 19:08:51 +00:00
65a3d738ca [PLUGIN][AttachmentCollections] Make it look good 2021-12-27 15:38:47 -03:00
7ddfe92773 fix: redirect 2021-12-27 15:35:09 -03:00
e932ff43d0 [PLUGIN][AttachmentCollections] changes path name to be the same as the one introduced in c4dacd7626 2021-12-27 15:31:19 -03:00
672df5165c [PLUGIN][AttachmentCollections] Fixing forms submission 2021-12-27 15:25:20 -03:00
72a19d7eac [COMPONENT][Posting] Fix error around mentions of actors that don't exist 2021-12-27 17:35:33 +00:00
b84315c95b [TOOLS] Fix errors reported by PHPStan at level 4 2021-12-27 17:35:33 +00:00
edd996d281 [DEPENDENCIES] Update dependencies, including PHPStan to dev version 2021-12-27 17:35:33 +00:00
cf2f87fc1d [AUTOGENERATED] Update autogenerated code 2021-12-27 17:35:33 +00:00
c9d05d71f5 [COMPONENT][Group] Fix group creation, Refactor related entities to inside the component
Other minor bug fixes and improvements
2021-12-27 17:28:03 +00:00
d03572e366 [PLUGIN][Directory] Make it list groups 2021-12-27 17:10:58 +00:00
de148c1f78 [COMPONENT][Avatar][Controller] Implement multiple dimensions 2021-12-27 05:08:29 +00:00
2a902d6a7e [ASSETS][css][sections] rename profile-avatar to avatar 2021-12-27 05:08:29 +00:00
195618801b [TEMPLATES][Cards][Note] Fix some issues with note minimal 2021-12-27 05:08:29 +00:00
80afc0fa6c [TEMPLATES][Cards][Profile] Provide both actor uri and url, as well as full mention guidance 2021-12-27 05:08:27 +00:00
eb761609aa [ENTITY][Note] If note is a reply to, notify reply's actor 2021-12-27 04:56:00 +00:00
c4dacd7626 [COMPONENT][Attachment] Vinculate note information with attachment controllers
Various minor bug fixes
2021-12-27 04:56:00 +00:00
fd44bc3ac5 [CARDS][Note] Minimize calls between different tables
For instance, the actor_url was set using note.getActor().getUrl() instead of using the actor from the start (since actor was needed in other setters as well).
2021-12-27 03:06:35 +00:00
65676d3980 [CARDS][Note] Fix attachment page from retrieving image dimensions
Attachments may use only a specific block, not the full note macro itself. Since this is the case, the actor needs to be retrieved for the minimal macro note.
2021-12-27 03:06:35 +00:00
ea42ba9f26 [CARDS][Profile] Fix bio
Was using 'hasBio()' instead of 'getBio()'
2021-12-27 03:06:35 +00:00
a1a6f5f4fd [TOOLS] Add warning to update code in bin/generate_entity_fields 2021-12-27 03:06:35 +00:00
93276ce8d0 [AUTOGENERATED] Update autogenerated code 2021-12-27 03:06:30 +00:00
0df423e84b [TOOLS] Update bin/generate_entity_fields so it defaults nullable variables to null and handles null in varchars 2021-12-27 03:03:57 +00:00
7eff22d548 [TOOLS] Fix errors reported by updated PHPStan 2021-12-27 03:03:57 +00:00
52e2231661 [DEPENDENCIES] Update PHPStan and other dependencies 2021-12-27 03:03:57 +00:00
9d3c01312f [CARDS][Note] Fix assumed avatar dimension values 2021-12-27 03:03:53 +00:00
ce23660dba [PLUGIN][ImageEncoder] Only show thumbnails if they exist 2021-12-27 03:02:21 +00:00
58715f1733 [PLUGIN][ImageEncoder] If vips doesn't support, don't throw exception, just let other plugin try 2021-12-27 03:02:20 +00:00
838510ced2 [CARDS][Navigation] Replaced footer to nav
Since the footer is inside a section, it couldn't be a footer element
2021-12-27 03:02:20 +00:00
b30198413c [PLUGINS][Oomox] Add rel to response headers 2021-12-26 21:26:04 +00:00
7402e749cb [COMPONENTS][Feed] Removed unnecessary ARIA 2021-12-26 21:19:33 +00:00
18cfcc0796 [ICONS] Removed XML processing instructions in svg files 2021-12-26 21:19:06 +00:00
045ff6fb68 [PLUGINS][XMPPNotifications] Fix typo 2021-12-26 21:19:06 +00:00
fec1861b80 [CONTROLLER][Note] Respect note scope 2021-12-26 21:19:06 +00:00
d891089945 [PLUGIN][StoreRemoteMedia] Let the user decide the max file size to download 2021-12-26 21:19:04 +00:00
0c421116a6 [ENTITY][Note] Relive isVisibleTo method 2021-12-26 19:50:21 +00:00
78cc9c4659 [PLUGINS][Repeat] Repeat now added has a reply and conversation of original note 2021-12-26 19:16:57 +00:00
e10a38a3e2 [CSS] Align page header on ultrawide screens 2021-12-26 19:16:57 +00:00
feb2631f00 [CARDS][Note] Add permalink to extra note actions 2021-12-26 19:16:57 +00:00
a1d9909379 [CORE][VisibilityScope] Use enum type instead of Bitmap 2021-12-26 19:16:56 +00:00
6f0d9add08 [DEPENDENCIES] Run composer after php version bump from 8.0 to 8.1 2021-12-26 19:16:17 +00:00
6883e51fc8 [DOCKER] Force docker php to use proper PHP executable 2021-12-26 19:16:16 +00:00
3d9141f4ce [COMPONENT][Tag] Allow searching for actor circles with {actor,people}-{circle,list}:#tag 2021-12-26 19:16:16 +00:00
4df80be095 [ENTITY][Actor] Set default null values 2021-12-26 19:16:16 +00:00
d37f38a1ea [ENTITY][Note] A note by default isn't a reply 2021-12-26 19:16:16 +00:00
dd268ba8db [CARDS][Profile] Use Actor::hasBio() instead of getter 2021-12-26 19:16:16 +00:00
8e7c94fe1d [COMPONENT][Attachment] Entity should have default refCount value, every attachment starts with 1 life 2021-12-26 19:16:16 +00:00
94e216a943 [COMPONENT][Conversation] remove early flush in utility function 2021-12-26 19:16:16 +00:00
fdf506b9f9 [CARDS][Note] Fix structure to break content in a controlled manner
[CSS] Responsive feed styling work

Note info content will now break as expected, useless space trimmed down to accomodate smaller screens.
2021-12-26 19:16:16 +00:00
726613cd96 [ENTITY][ActorCircle][COMPONENT][Tag] Add fields to ActorCircle and add or remove target to actor circle when they add or remove a selftag 2021-12-26 19:16:15 +00:00
000ec680e6 [CORE][TOOL] Minor fixes and run cs-fixer 2021-12-26 19:16:15 +00:00
97243151fa [TOOL] CS-fixer does not accept a list of files, so we need to run it on each file individually 2021-12-26 19:16:15 +00:00
c79b1e4c94 [AUTOGENERATED] Update auto generated code 2021-12-26 19:16:15 +00:00
68076d73dd [TOOLS] Update bin/generate_entity_fields so it automatically truncates values in setters 2021-12-26 19:16:15 +00:00
29bb11e8bc [TOOLS] Don't fail commit when checking tools fail 2021-12-26 19:16:15 +00:00
ec28f23025 [TOOLS] Run CS-fixer on all files 2021-12-26 19:16:15 +00:00
5e42723624 [ENTITY][Note] Include reply_to's targets in child's 2021-12-26 19:16:14 +00:00
f5f7fc6056 [PLUGIN][Favourite][Repeat] Add notification target getter 2021-12-26 19:16:14 +00:00
625618b4e0 [PLUGIN][Favourite][Repeat] Fix incorrect use of RedirectException 2021-12-26 19:16:14 +00:00
91f8c86efa [PLUGIN][ActivityPub] Support and federate scopes 2021-12-26 19:16:12 +00:00
21f585ef7e [COMPONENT][Language] Do not exclude notes without language from the feeds 2021-12-26 16:48:36 +00:00
9d5e149dec [COMPONENT][Feed] Correct ordering 2021-12-26 16:48:35 +00:00
19502050e0 [TOOLS] Add remove-file and minor corrections to nuke everything 2021-12-26 16:48:35 +00:00
3e13765f62 [CORE][SCOPE] Implement basic visibility in feeds 2021-12-26 16:48:34 +00:00
d4bc1d097d [ENTITY][NoteTag][COMPONENT][Language] Add language to NoteTag and minor corrections 2021-12-26 06:22:30 +00:00
78fddaf86a [PLUGIN][ActivityPub] Notify mentions in tags 2021-12-26 06:22:28 +00:00
9d0b39e680 [PLUGIN][ActivityPub] Support tags in notes 2021-12-25 18:04:31 +00:00
36483a6ecd [COMPONENT][Link] Ignore html anchors that include mention class 2021-12-25 18:04:30 +00:00
0d5e545a6e [TWIG] Replaced unused markup classes 2021-12-25 17:59:49 +00:00
3275a989db [CSS] Style paging info 2021-12-25 17:48:07 +00:00
ab640b110b [COMPONENTS][Feed] Remove 'feed' from header
When not logged in, the word would be repeated
2021-12-25 17:47:12 +00:00
7891461d36 [PLUGINS][AttachmentCollections] Fix template
[PLUGINS][AttachmentShowRelated] Fix template

[CSS] Fix section-subtitle-details children padding issues

[COMPONENTS][Posting] Remove unnecessary colon

Overall polish of existing plugins templates
2021-12-25 17:31:16 +00:00
ce3b677833 [CONFIG][Twig] Replaced form theme to a custom one
[CARDS][Forms] Added custom social form theme

[CSS] Replaced/added new classes to be used with forms

Base form theme created, can be extended to create complex blocks to be
called by twig when rendering a specific form.
2021-12-25 16:19:46 +00:00
8651bd44c2 [PLUGINS][ProfileColor] Fix callable argument when retrieving cache data 2021-12-25 15:31:24 +00:00
6ada5e60d2 [TOOLS] Make PHP-CS-Fixer and PHP-Doc-check run inside the tooling container 2021-12-25 15:20:21 +00:00
365 changed files with 12793 additions and 5997 deletions

View File

@@ -37,16 +37,22 @@ database-force-schema-update:
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c "/var/www/social/bin/console doctrine:schema:update --dump-sql --force"
tooling-docker: .PHONY
@cd docker/tooling && docker-compose up -d > /dev/null 2>&1
@cd docker/tooling && docker-compose up -d --build > /dev/null 2>&1
accessibility: .PHONY
@cd docker/accessibility && docker-compose up
stop-tooling: .PHONY
cd docker/tooling && docker-compose down
tooling-php-shell: tooling-docker
docker exec -it $(call translate-container-name,tooling_php_1) sh
test-accesibility: tooling-docker
cd docker/tooling && docker-compose run pa11y /accessibility.sh
test: tooling-docker
docker exec $(call translate-container-name,tooling_php_1) /var/tooling/coverage.sh $(call args,'')
cs-fixer: tooling-docker
bin/php-cs-fixer
@bin/php-cs-fixer $${CS_FIXER_FILE}
doc-check: tooling-docker
bin/php-doc-check
@@ -54,13 +60,13 @@ doc-check: tooling-docker
phpstan: tooling-docker
bin/phpstan
stop-tooling: .PHONY
cd docker/tooling && docker-compose down
remove-var:
rm -rf var/*
remove-file:
sudo rm -rf file/*
flush-redis-cache:
docker exec -it $(call translate-container-name,$(strip $(DIR))_redis_1) sh -c 'redis-cli flushall'
force-nuke-everything: remove-var flush-redis-cache database-force-nuke
force-nuke-everything: down up flush-redis-cache database-force-nuke remove-var remove-file

View File

@@ -1,4 +1,4 @@
#!/usr/local/bin/php
#!/usr/bin/env php
<?php
use Symfony\Component\Yaml\Yaml;
@@ -27,6 +27,7 @@ $files = array_merge(glob(ROOT . '/src/Entity/*.php'),
glob(ROOT . '/plugins/*/Entity/*.php')));
$classes = [];
$nullable_no_defaults_warning = [];
foreach ($files as $file) {
@@ -47,16 +48,32 @@ foreach ($files as $file) {
$fields_code = [];
$methods_code = [];
foreach ($fields as $field) {
$nullable = !@$schema['fields'][$field]['not null'] ? '?' : '';
$type = types[$schema['fields'][$field]['type']];
$field_schema = $schema['fields'][$field];
$nullable = ($field_schema['not null'] ?? false) ? '' : '?';
$type = types[$field_schema['type']];
$type = $type !== '' ? $nullable . $type : $type;
$method_name = str_replace([' ', 'actor'], ['', 'Actor'], ucwords(str_replace('_', ' ', $field)));
$default = @$schema['fields'][$field]['default'];
$length = $field_schema['length'] ?? null;
if (isset($default) && $nullable != '?' && $type != '\DateTimeInterface') {
if (is_string($default)) {
$field_setter = "\${$field}";
if (\is_int($length)) {
if ($nullable === '?') {
$field_setter = "\is_null(\${$field}) ? null : \mb_substr(\${$field}, 0, $length)";
} else {
$field_setter = "\mb_substr(\${$field}, 0, $length)";
}
}
if (($nullable === '?' || \array_key_exists('default', $field_schema)) && $type != '\DateTimeInterface') {
if (!\array_key_exists('default', $field_schema)) {
$nullable_no_defaults_warning[] = "{$class}::{$field}";
}
$default = $field_schema['default'] ?? null;
if (\is_string($default)) {
$default = "'{$default}'";
} elseif ($type == 'bool') {
} elseif (\is_null($default)) {
$default = "null";
} elseif ($type === 'bool' || $type === '?bool') {
$default = $default ? 'true' : 'false';
}
@@ -66,13 +83,13 @@ foreach ($files as $file) {
}
$methods_code[] = " public function set{$method_name}({$type} \${$field}): self" .
"\n {\n \$this->{$field} = \${$field};\n return \$this;\n }" . "\n\n" .
"\n {\n \$this->{$field} = {$field_setter};\n return \$this;\n }" . "\n\n" .
" public function get{$method_name}()" . ($type !== '' ? ": {$type}" : '') .
"\n {\n return \$this->{$field};\n }" . "\n";
}
$fields_code = implode("\n", $fields_code);
$methods_code = implode("\n", $methods_code) . "\n";
$methods_code = implode("\n", $methods_code);
$begin = '// {{{ Autocode';
$end = '// }}} Autocode';
@@ -91,7 +108,13 @@ foreach ($files as $file) {
}
$in_file = file_get_contents($file);
$out_file = preg_replace("/\\s*{$begin}[^\\/]*{$end}/m", $code, $in_file);
$out_file = preg_replace("%\\s*{$begin}.*{$end}%smu", $code, $in_file);
file_put_contents($file, $out_file);
}
if (!empty($nullable_no_defaults_warning)) {
echo "Warning: The following don't have a default value, but we're assigning it `null`. Doctrine might not like this, so update it\n";
foreach ($nullable_no_defaults_warning as $n) {
echo " {$n}\n";
}
}

View File

@@ -2,8 +2,8 @@
. bin/translate_container_name.sh
if [ $# -eq 1 ]; then
docker exec -it "$(translate_container_name tooling_php_1)" /var/www/social/vendor/bin/php-cs-fixer --ansi -n --config=".php-cs-fixer.php" fix $1
if [ "$#" -eq 0 ] || [ -z "$*" ]; then
docker exec "$(translate_container_name tooling_php_1)" /var/www/social/vendor/bin/php-cs-fixer -n --config=".php-cs-fixer.php" fix
else
docker exec -it "$(translate_container_name tooling_php_1)" /var/www/social/vendor/bin/php-cs-fixer -n --ansi --config=".php-cs-fixer.php" fix
docker exec "$(translate_container_name tooling_php_1)" sh -c "/var/www/social/vendor/bin/php-cs-fixer -q -n --config=\".php-cs-fixer.php\" fix --path-mode=intersection -- $*"
fi

View File

@@ -2,32 +2,25 @@
root="$(git rev-parse --show-toplevel)"
# get the list of changed files
# get the list of changed files that didn't get only partially added
staged_files="$(git status --porcelain | sed -rn "s/^[^ ][ ] (.*)/\1/p")"
if (! (: "${SKIP_ALL?}") 2>/dev/null) && (! (: "${SKIP_CS_FIX?}") 2>/dev/null); then
echo "Running php-cs-fixer on edited files"
files=""
for staged in ${staged_files}; do
# work only with existing files
if [ -f "${staged}" ] && expr "${staged}" : '^.*\.php$' > /dev/null; then
# use php-cs-fixer and get flag of correction
files="${staged} ${files}"
CS_FIXER_FILE="${staged}" make cs-fixer
git add "${staged}"
fi
done
if [ -n "${files}" ]; then
prev="${PWD}"
cd "${root}" && make cs-fixer "${files}" || exit 1
cd "${prev}" || exit 1
fi
fi
if (! (: "${SKIP_ALL?}") 2>/dev/null) && (! (: "${SKIP_DOC_CHECK?}") 2>/dev/null); then
echo "Running php-doc-checker"
if echo "${staged_files}" | grep -F ".php"; then
prev="${PWD}"
cd "${root}" && make doc-check || exit 1
cd "${prev}" || exit 1
if echo "${staged_files}" | grep -F ".php" > /dev/null 2>&1; then
echo "Running php-doc-checker"
make doc-check < /dev/tty
fi
fi

10
codeception.yml Normal file
View File

@@ -0,0 +1,10 @@
paths:
tests: tests/CodeCeption
output: tests/CodeCeption/_output
data: tests/CodeCeption/_data
support: tests/CodeCeption/_support
envs: tests/CodeCeption/_envs
actor_suffix: Tester
extensions:
enabled:
- Codeception\Extension\RunFailed

View File

@@ -22,6 +22,7 @@ declare(strict_types = 1);
namespace Component\Attachment;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
@@ -38,10 +39,10 @@ class Attachment extends Component
{
public function onAddRoute(RouteLoader $r): bool
{
$r->connect('attachment_show', '/object/attachment/{id<\d+>}', [C\Attachment::class, 'attachment_show']);
$r->connect('attachment_view', '/object/attachment/{id<\d+>}/view', [C\Attachment::class, 'attachment_view']);
$r->connect('attachment_download', '/object/attachment/{id<\d+>}/download', [C\Attachment::class, 'attachment_download']);
$r->connect('attachment_thumbnail', '/object/attachment/{id<\d+>}/thumbnail/{size<big|medium|small>}', [C\Attachment::class, 'attachment_thumbnail']);
$r->connect('note_attachment_show', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}', [C\Attachment::class, 'attachmentShowWithNote']);
$r->connect('note_attachment_view', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/view', [C\Attachment::class, 'attachmentViewWithNote']);
$r->connect('note_attachment_download', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/download', [C\Attachment::class, 'attachmentDownloadWithNote']);
$r->connect('note_attachment_thumbnail', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/thumbnail/{size<big|medium|small>}', [C\Attachment::class, 'attachmentThumbnailWithNote']);
return Event::next;
}
@@ -56,17 +57,18 @@ class Attachment extends Component
return Event::stop;
}
public function onNoteDeleteRelated(Note &$note): bool
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
{
Cache::delete("note-attachments-{$note->getId()}");
E\AttachmentToNote::removeWhereNoteId($note->getId());
foreach ($note->getAttachments() as $attachment) {
$attachment->kill();
}
DB::wrapInTransaction(fn () => E\AttachmentToNote::removeWhereNoteId($note->getId()));
Cache::delete("note-attachments-{$note->getId()}");
return Event::next;
}
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{
$note_qb->leftJoin(E\AttachmentToNote::class, 'attachment_to_note', Expr\Join::WITH, 'note.id = attachment_to_note.note_id');
return Event::next;
@@ -75,7 +77,7 @@ class Attachment extends Component
/**
* Populate $note_expr with the criteria for looking for notes with attachments
*/
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr)
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
{
$include_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) {

View File

@@ -28,14 +28,13 @@ use App\Core\DB\DB;
use App\Core\Event;
use App\Core\GSFile;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use Component\Attachment\Entity\AttachmentThumbnail;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NoSuchFileException;
use App\Util\Exception\NotFoundException;
use App\Util\Exception\ServerException;
use Component\Attachment\Entity\AttachmentThumbnail;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -46,22 +45,23 @@ class Attachment extends Controller
/**
* Generic function that handles getting a representation for an attachment
*/
private function attachment(int $id, callable $handle)
private function attachment(int $attachment_id, Note|int $note, callable $handle)
{
if ($id <= 0) { // This should never happen coming from the router, but let's bail if it does
// @codeCoverageIgnoreStart
Log::critical("Attachment controller called with {$id}, which should not be possible");
throw new ClientException(_m('No such attachment.'), 404);
// @codeCoverageIgnoreEnd
} else {
$res = null;
if (Event::handle('AttachmentFileInfo', [$id, &$res]) != Event::stop) {
// If no one else claims this attachment, use the default representation
try {
$res = GSFile::getAttachmentFileInfo($id);
} catch (NoSuchFileException $e) {
// Continue below
}
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
$note = \is_int($note) ? Note::getById($note) : $note;
// Before anything, ensure proper scope
if (!$note->isVisibleTo(Common::actor())) {
throw new ClientException(_m('You don\'t have permissions to view this attachment.'), 401);
}
$res = null;
if (Event::handle('AttachmentFileInfo', [$attachment, $note, &$res]) !== Event::stop) {
// If no one else claims this attachment, use the default representation
try {
$res = GSFile::getAttachmentFileInfo($attachment_id);
} catch (NoSuchFileException $e) {
// Continue below
}
}
@@ -73,6 +73,9 @@ class Attachment extends Controller
throw new ServerException('This attachment is not stored locally.');
// @codeCoverageIgnoreEnd
} else {
$res['attachment'] = $attachment;
$res['note'] = $note;
$res['title'] = $attachment->getBestTitle($note);
return $handle($res);
}
}
@@ -81,16 +84,17 @@ class Attachment extends Controller
/**
* The page where the attachment and it's info is shown
*/
public function attachment_show(Request $request, int $id)
public function attachmentShowWithNote(Request $request, int $note_id, int $attachment_id)
{
try {
$attachment = DB::findOneBy('attachment', ['id' => $id]);
return $this->attachment($id, function ($res) use ($id, $attachment) {
return $this->attachment($attachment_id, $note_id, function ($res) use ($note_id, $attachment_id) {
return [
'_template' => '/cards/attachments/show.html.twig',
'download' => Router::url('attachment_download', ['id' => $id]),
'attachment' => $attachment,
'right_panel_vars' => ['attachment_id' => $id],
'download' => $res['attachment']->getDownloadUrl(note: $note_id),
'title' => $res['title'],
'attachment' => $res['attachment'],
'note' => $res['note'],
'right_panel_vars' => ['attachment_id' => $attachment_id, 'note_id' => $note_id],
];
});
} catch (NotFoundException) {
@@ -101,27 +105,29 @@ class Attachment extends Controller
/**
* Display the attachment inline
*/
public function attachment_view(Request $request, int $id)
public function attachmentViewWithNote(Request $request, int $note_id, int $attachment_id)
{
return $this->attachment(
$id,
$attachment_id,
$note_id,
fn (array $res) => GSFile::sendFile(
$res['filepath'],
$res['mimetype'],
GSFile::ensureFilenameWithProperExtension($res['filename'], $res['mimetype']) ?? $res['filename'],
GSFile::ensureFilenameWithProperExtension($res['title'], $res['mimetype']) ?? $res['filename'],
HeaderUtils::DISPOSITION_INLINE,
),
);
}
public function attachment_download(Request $request, int $id)
public function attachmentDownloadWithNote(Request $request, int $note_id, int $attachment_id)
{
return $this->attachment(
$id,
$attachment_id,
$note_id,
fn (array $res) => GSFile::sendFile(
$res['filepath'],
$res['mimetype'],
GSFile::ensureFilenameWithProperExtension($res['filename'], $res['mimetype']) ?? $res['filename'],
GSFile::ensureFilenameWithProperExtension($res['title'], $res['mimetype']) ?? $res['filename'],
HeaderUtils::DISPOSITION_ATTACHMENT,
),
);
@@ -130,16 +136,21 @@ class Attachment extends Controller
/**
* Controller to produce a thumbnail for a given attachment id
*
* @param int $id Attachment ID
* @param int $attachment_id Attachment ID
*
* @throws \App\Util\Exception\DuplicateFoundException
* @throws ClientException
* @throws NotFoundException
* @throws ServerException
*/
public function attachment_thumbnail(Request $request, int $id, string $size = 'small'): Response
public function attachmentThumbnailWithNote(Request $request, int $note_id, int $attachment_id, string $size = 'small'): Response
{
$attachment = DB::findOneBy('attachment', ['id' => $id]);
// Before anything, ensure proper scope
if (!Note::getById($note_id)->isVisibleTo(Common::actor())) {
throw new ClientException(_m('You don\'t have permissions to view this thumbnail.'), 401);
}
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
$crop = Common::config('thumbnail', 'smart_crop');

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@@ -39,7 +41,7 @@ class ActorToAttachment extends Entity
// @codeCoverageIgnoreStart
private int $attachment_id;
private int $actor_id;
private \DateTimeInterface $modified;
private DateTimeInterface $modified;
public function setAttachmentId(int $attachment_id): self
{
@@ -77,10 +79,6 @@ class ActorToAttachment extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
/**
* @param int $attachment_id
* @return mixed
*/
public static function removeWhereAttachmentId(int $attachment_id): mixed
{
return DB::dql(
@@ -92,11 +90,6 @@ class ActorToAttachment extends Entity
);
}
/**
* @param int $actor_id
* @param int $attachment_id
* @return mixed
*/
public static function removeWhere(int $attachment_id, int $actor_id): mixed
{
return DB::dql(
@@ -109,10 +102,6 @@ class ActorToAttachment extends Entity
);
}
/**
* @param int $actor_id
* @return mixed
*/
public static function removeWhereActorId(int $actor_id): mixed
{
return DB::dql(

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
@@ -26,10 +28,10 @@ use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Event;
use App\Core\GSFile;
use App\Entity\Note;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
@@ -57,13 +59,13 @@ class Attachment extends Entity
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $id;
private int $lives = 1;
private ?string $filehash;
private ?string $mimetype;
private ?string $filename;
private ?int $size;
private ?int $width;
private ?int $height;
private int $lives = 1;
private ?string $filehash = null;
private ?string $mimetype = null;
private ?string $filename = null;
private ?int $size = null;
private ?int $width = null;
private ?int $height = null;
private DateTimeInterface $modified;
public function setId(int $id): self
@@ -77,25 +79,20 @@ class Attachment extends Entity
return $this->id;
}
/**
* @return int
*/
public function setLives(int $lives): self
{
$this->lives = $lives;
return $this;
}
public function getLives(): int
{
return $this->lives;
}
/**
* @param int $lives
*/
public function setLives(int $lives): void
{
$this->lives = $lives;
}
public function setFilehash(?string $filehash): self
{
$this->filehash = $filehash;
$this->filehash = \is_null($filehash) ? null : mb_substr($filehash, 0, 64);
return $this;
}
@@ -106,7 +103,7 @@ class Attachment extends Entity
public function setMimetype(?string $mimetype): self
{
$this->mimetype = $mimetype;
$this->mimetype = \is_null($mimetype) ? null : mb_substr($mimetype, 0, 255);
return $this;
}
@@ -117,7 +114,7 @@ class Attachment extends Entity
public function setFilename(?string $filename): self
{
$this->filename = $filename;
$this->filename = \is_null($filename) ? null : mb_substr($filename, 0, 191);
return $this;
}
@@ -176,44 +173,36 @@ class Attachment extends Entity
public function getMimetypeMajor(): ?string
{
$mime = $this->getMimetype();
return is_null($mime) ? $mime : GSFile::mimetypeMajor($mime);
return \is_null($mime) ? $mime : GSFile::mimetypeMajor($mime);
}
public function getMimetypeMinor(): ?string
{
$mime = $this->getMimetype();
return is_null($mime) ? $mime : GSFile::mimetypeMinor($mime);
return \is_null($mime) ? $mime : GSFile::mimetypeMinor($mime);
}
/**
* @return int
*/
public function livesIncrementAndGet(): int
{
++$this->lives;
return $this->lives;
}
/**
* @return int
*/
public function livesDecrementAndGet(): int
{
--$this->lives;
return $this->lives;
}
const FILEHASH_ALGO = 'sha256';
public const FILEHASH_ALGO = 'sha256';
/**
* Delete a file if safe, removes dependencies, cleanups and flushes
*
* @return bool
*/
public function kill(): bool
{
if ($this->livesDecrementAndGet() <= 0) {
return $this->delete();
return DB::wrapInTransaction(fn () => $this->delete());
}
return true;
}
@@ -223,7 +212,7 @@ class Attachment extends Entity
*/
public function deleteStorage(): bool
{
if (!is_null($filepath = $this->getPath())) {
if (!\is_null($filepath = $this->getPath())) {
if (file_exists($filepath)) {
if (@unlink($filepath) === false) {
// @codeCoverageIgnoreStart
@@ -248,6 +237,8 @@ class Attachment extends Entity
/**
* Attachment delete always removes dependencies, cleanups and flushes
* WARNING: Wrap this function in a transaction!
*
* @see kill() It's more likely that you want to use that rather than call delete directly
*/
protected function delete(): bool
@@ -261,7 +252,7 @@ class Attachment extends Entity
// Collect files starting with the one associated with this attachment
$files = [];
if (!is_null($filepath = $this->getPath())) {
if (!\is_null($filepath = $this->getPath())) {
$files[] = $filepath;
}
@@ -306,25 +297,21 @@ class Attachment extends Entity
/**
* TODO: Maybe this isn't the best way of handling titles
*
* @param null|Note $note
*
* @throws DuplicateFoundException
* @throws NotFoundException
* @throws ServerException
*
* @return string
*/
public function getBestTitle(?Note $note = null): string
{
// If we have a note, then the best title is the title itself
if (!is_null(($note))) {
if (!\is_null(($note))) {
$title = Cache::get('attachment-title-' . $this->getId() . '-' . $note->getId(), function () use ($note) {
try {
$attachment_to_note = DB::findOneBy('attachment_to_note', [
'attachment_id' => $this->getId(),
'note_id' => $note->getId(),
]);
if (!is_null($attachment_to_note->getTitle())) {
if (!\is_null($attachment_to_note->getTitle())) {
return $attachment_to_note->getTitle();
}
} catch (NotFoundException) {
@@ -332,14 +319,13 @@ class Attachment extends Entity
Event::handle('AttachmentGetBestTitle', [$this, $note, &$title]);
return $title;
}
return null;
});
if ($title != null) {
return $title;
}
}
// Else
if (!is_null($filename = $this->getFilename())) {
if (!\is_null($filename = $this->getFilename())) {
// A filename would do just as well
return $filename;
} else {
@@ -359,18 +345,25 @@ class Attachment extends Entity
public function getPath()
{
$filename = $this->getFilename();
return is_null($filename) ? null : Common::config('attachments', 'dir') . DIRECTORY_SEPARATOR . $filename;
return \is_null($filename) ? null : Common::config('attachments', 'dir') . \DIRECTORY_SEPARATOR . $filename;
}
public function getUrl(int $type = Router::ABSOLUTE_URL): string
public function getUrl(Note|int $note, int $type = Router::ABSOLUTE_URL): string
{
return Router::url(id: 'attachment_view', args: ['id' => $this->getId()], type: $type);
return Router::url(id: 'note_attachment_view', args: ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId()], type: $type);
}
public function getShowUrl(Note|int $note, int $type = Router::ABSOLUTE_URL): string
{
return Router::url(id: 'note_attachment_show', args: ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId()], type: $type);
}
public function getDownloadUrl(Note|int $note, int $type = Router::ABSOLUTE_URL): string
{
return Router::url(id: 'note_attachment_download', args: ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId()], type: $type);
}
/**
* @param null|string $size
* @param bool $crop
*
* @throws ClientException
* @throws NotFoundException
* @throws ServerException
@@ -382,9 +375,9 @@ class Attachment extends Entity
return AttachmentThumbnail::getOrCreate(attachment: $this, size: $size, crop: $crop);
}
public function getThumbnailUrl(?string $size = null)
public function getThumbnailUrl(Note|int $note, ?string $size = null)
{
return Router::url('attachment_thumbnail', ['id' => $this->getId(), 'size' => $size ?? Common::config('thumbnail', 'default_size')]);
return Router::url('note_attachment_thumbnail', ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId(), 'size' => $size ?? Common::config('thumbnail', 'default_size')]);
}
public static function schemaDef(): array
@@ -393,7 +386,7 @@ class Attachment extends Entity
'name' => 'attachment',
'fields' => [
'id' => ['type' => 'serial', 'not null' => true],
'lives' => ['type' => 'int', 'not null' => true, 'description' => 'RefCount'],
'lives' => ['type' => 'int', 'default' => 1, 'not null' => true, 'description' => 'RefCount, starts with 1'],
'filehash' => ['type' => 'varchar', 'length' => 64, 'description' => 'sha256 of the file contents, if the file is stored locally'],
'mimetype' => ['type' => 'varchar', 'length' => 255, 'description' => 'resource mime type 127+1+127 as per rfc6838#section-4.2'],
'filename' => ['type' => 'varchar', 'length' => 191, 'description' => 'file name of resource when available'],

View File

@@ -66,12 +66,11 @@ class AttachmentThumbnail extends Entity
'medium' => self::SIZE_MEDIUM,
'big' => self::SIZE_BIG,
];
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $attachment_id;
private ?string $mimetype;
private int $size = self::SIZE_SMALL;
private ?string $mimetype = null;
private int $size = 0;
private string $filename;
private int $width;
private int $height;
@@ -90,7 +89,7 @@ class AttachmentThumbnail extends Entity
public function setMimetype(?string $mimetype): self
{
$this->mimetype = $mimetype;
$this->mimetype = \is_null($mimetype) ? null : mb_substr($mimetype, 0, 129);
return $this;
}
@@ -99,20 +98,20 @@ class AttachmentThumbnail extends Entity
return $this->mimetype;
}
public function getSize(): int
{
return $this->size;
}
public function setSize(int $size): self
{
$this->size = $size;
return $this;
}
public function getSize(): int
{
return $this->size;
}
public function setFilename(string $filename): self
{
$this->filename = $filename;
$this->filename = mb_substr($filename, 0, 191);
return $this;
}
@@ -121,6 +120,28 @@ class AttachmentThumbnail extends Entity
return $this->filename;
}
public function setWidth(int $width): self
{
$this->width = $width;
return $this;
}
public function getWidth(): int
{
return $this->width;
}
public function setHeight(int $height): self
{
$this->height = $height;
return $this;
}
public function getHeight(): int
{
return $this->height;
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
@@ -132,28 +153,6 @@ class AttachmentThumbnail extends Entity
return $this->modified;
}
public function getWidth(): int
{
return $this->width;
}
public function setWidth(int $width): self
{
$this->width = $width;
return $this;
}
public function getHeight(): int
{
return $this->height;
}
public function setHeight(int $height): self
{
$this->height = $height;
return $this;
}
// @codeCoverageIgnoreEnd
// }}} Autocode

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@@ -37,20 +39,9 @@ class AttachmentToLink extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $attachment_id;
private int $link_id;
private \DateTimeInterface $modified;
public function setAttachmentId(int $attachment_id): self
{
$this->attachment_id = $attachment_id;
return $this;
}
public function getAttachmentId(): int
{
return $this->attachment_id;
}
private int $attachment_id;
private DateTimeInterface $modified;
public function setLinkId(int $link_id): self
{
@@ -63,6 +54,17 @@ class AttachmentToLink extends Entity
return $this->link_id;
}
public function setAttachmentId(int $attachment_id): self
{
$this->attachment_id = $attachment_id;
return $this;
}
public function getAttachmentId(): int
{
return $this->attachment_id;
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
@@ -77,10 +79,6 @@ class AttachmentToLink extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
/**
* @param int $attachment_id
* @return mixed
*/
public static function removeWhereAttachmentId(int $attachment_id): mixed
{
return DB::dql(
@@ -92,11 +90,6 @@ class AttachmentToLink extends Entity
);
}
/**
* @param int $link_id
* @param int $attachment_id
* @return mixed
*/
public static function removeWhere(int $link_id, int $attachment_id): mixed
{
return DB::dql(
@@ -109,10 +102,6 @@ class AttachmentToLink extends Entity
);
}
/**
* @param int $link_id
* @return mixed
*/
public static function removeWhereLinkId(int $link_id): mixed
{
return DB::dql(

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@@ -19,7 +21,6 @@
namespace Component\Attachment\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use DateTimeInterface;
@@ -44,8 +45,8 @@ class AttachmentToNote extends Entity
// @codeCoverageIgnoreStart
private int $attachment_id;
private int $note_id;
private ?string $title;
private \DateTimeInterface $modified;
private ?string $title = null;
private DateTimeInterface $modified;
public function setAttachmentId(int $attachment_id): self
{
@@ -94,11 +95,6 @@ class AttachmentToNote extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
/**
* @param int $note_id
* @param int $attachment_id
* @return mixed
*/
public static function removeWhere(int $note_id, int $attachment_id): mixed
{
return DB::dql(
@@ -111,10 +107,6 @@ class AttachmentToNote extends Entity
);
}
/**
* @param int $note_id
* @return mixed
*/
public static function removeWhereNoteId(int $note_id): mixed
{
return DB::dql(
@@ -126,10 +118,6 @@ class AttachmentToNote extends Entity
);
}
/**
* @param int $attachment_id
* @return mixed
*/
public static function removeWhereAttachmentId(int $attachment_id): mixed
{
return DB::dql(

View File

@@ -29,6 +29,8 @@ use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Util\Common;
use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentThumbnail;
use Component\Avatar\Controller as C;
use Component\Avatar\Exception\NoAvatarException;
use Symfony\Component\HttpFoundation\Request;
@@ -41,8 +43,8 @@ class Avatar extends Component
public function onAddRoute(RouteLoader $r): bool
{
$r->connect('avatar_actor', '/actor/{actor_id<\d+>}/avatar/{size<full|big|medium|small>?full}', [Controller\Avatar::class, 'avatar_view']);
$r->connect('avatar_default', '/avatar/default/{size<full|big|medium|small>?full}', [Controller\Avatar::class, 'default_avatar_view']);
$r->connect('avatar_actor', '/actor/{actor_id<\d+>}/avatar/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'avatar_view']);
$r->connect('avatar_default', '/avatar/default/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'default_avatar_view']);
$r->connect('avatar_settings', '/settings/avatar', [Controller\Avatar::class, 'settings_avatar']);
return Event::next;
}
@@ -102,7 +104,7 @@ class Avatar extends Component
/**
* Get the cached avatar associated with the given Actor id, or the current user if not given
*/
public static function getUrl(int $actor_id, string $size = 'full', int $type = Router::ABSOLUTE_PATH): string
public static function getUrl(int $actor_id, string $size = 'medium', int $type = Router::ABSOLUTE_PATH): string
{
try {
return self::getAvatar($actor_id)->getUrl($size, $type);
@@ -111,13 +113,13 @@ class Avatar extends Component
}
}
public static function getDimensions(int $actor_id, string $size = 'full')
public static function getDimensions(int $actor_id, string $size = 'medium')
{
try {
$attachment = self::getAvatar($actor_id)->getAttachment();
return ['width' => $attachment->getWidth(), 'height' => $attachment->getHeight()];
return ['width' => (int) $attachment->getWidth(), 'height' => (int) $attachment->getHeight()];
} catch (NoAvatarException) {
return ['width' => Common::config('thumbnail', 'small'), 'height' => Common::config('thumbnail', 'small')];
return ['width' => (int) (Common::config('thumbnail', 'small')), 'height' => (int) (Common::config('thumbnail', 'small'))];
}
}
@@ -127,7 +129,7 @@ class Avatar extends Component
* Returns the avatar file's hash, mimetype, title and path.
* Ensures exactly one cached value exists
*/
public static function getAvatarFileInfo(int $actor_id, string $size = 'full'): array
public static function getAvatarFileInfo(int $actor_id, string $size = 'medium'): array
{
$res = Cache::get(
"avatar-file-info-{$actor_id}-{$size}",
@@ -151,8 +153,12 @@ class Avatar extends Component
'title' => 'default_avatar.svg',
];
} else {
$res = $res[0]; // A user must always only have one avatar.
$res['filepath'] = DB::findOneBy('attachment', ['id' => $res['id']])->getPath();
$res = $res[0]; // A user must always only have one avatar.
if ($size === 'full') {
$res['filepath'] = Attachment::getByPK(['id' => $res['id']])->getPath();
} else {
$res['filepath'] = AttachmentThumbnail::getOrCreate(Attachment::getByPK(['id' => $res['id']]), $size)->getPath();
}
return $res;
}
}

View File

@@ -33,7 +33,6 @@ use function App\Core\I18n\_m;
use App\Core\Log;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NotFoundException;
use App\Util\TemporaryFile;
use Component\Avatar\Entity\Avatar as AvatarEntity;
use Exception;
@@ -57,13 +56,8 @@ class Avatar extends Controller
*/
public function avatar_view(Request $request, int $actor_id, string $size): Response
{
switch ($size) {
case 'full':
$res = \Component\Avatar\Avatar::getAvatarFileInfo($actor_id);
return M::sendFile($res['filepath'], $res['mimetype'], $res['title']);
default:
throw new Exception('Not implemented');
}
$res = \Component\Avatar\Avatar::getAvatarFileInfo($actor_id, $size);
return M::sendFile($res['filepath'], $res['mimetype'], $res['title']);
}
/**
@@ -85,15 +79,14 @@ class Avatar extends Controller
$user = Common::user();
$actor_id = $user->getId();
if ($data['remove'] == true) {
try {
$avatar = DB::findOneBy('avatar', ['actor_id' => $actor_id]);
if (\is_null($avatar = DB::findOneBy(AvatarEntity::class, ['actor_id' => $actor_id], return_null: true))) {
$form->addError(new FormError(_m('No avatar set, so cannot delete.')));
} else {
$avatar->delete();
Event::handle('AvatarUpdate', [$user->getId()]);
} catch (NotFoundException) {
$form->addError(new FormError(_m('No avatar set, so cannot delete')));
}
} else {
$attachment = null;
$title = $data['avatar']?->getClientOriginalName() ?? null;
if (isset($data['hidden'])) {
// Cropped client side
$matches = [];
@@ -113,15 +106,18 @@ class Avatar extends Controller
$file = $data['avatar'];
$attachment = GSFile::storeFileAsAttachment($file);
} else {
throw new ClientException('Invalid form');
throw new ClientException(_m('Invalid form.'));
}
// Delete current avatar if there's one
$avatar = DB::find('avatar', ['actor_id' => $actor_id]);
$avatar?->delete();
if (!\is_null($avatar = DB::findOneBy(AvatarEntity::class, ['actor_id' => $actor_id], return_null: true))) {
$avatar->delete();
}
DB::persist($attachment);
// Can only get new id after inserting
DB::flush();
DB::persist(AvatarEntity::create(['actor_id' => $actor_id, 'attachment_id' => $attachment->getId()]));
DB::persist(AvatarEntity::create([
'actor_id' => $actor_id,
'attachment_id' => $attachment->getId(),
'title' => $title,
]));
DB::flush();
Event::handle('AvatarUpdate', [$user->getId()]);
}

View File

@@ -26,9 +26,11 @@ namespace Component\Avatar\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Event;
use App\Core\Router\Router;
use Component\Attachment\Entity\Attachment;
use App\Util\Common;
use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentThumbnail;
use DateTimeInterface;
/**
@@ -51,7 +53,7 @@ class Avatar extends Entity
// @codeCoverageIgnoreStart
private int $actor_id;
private int $attachment_id;
private ?string $title;
private ?string $title = null;
private DateTimeInterface $created;
private DateTimeInterface $modified;
@@ -77,16 +79,17 @@ class Avatar extends Entity
return $this->attachment_id;
}
public function setTitle(?string $title): self
{
$this->title = \is_null($title) ? null : mb_substr($title, 0, 191);
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): void
{
$this->title = $title;
}
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
@@ -114,7 +117,7 @@ class Avatar extends Entity
private ?Attachment $attachment = null;
public function getUrl(string $size = 'full', int $type = Router::ABSOLUTE_PATH): string
public function getUrl(string $size = 'medium', int $type = Router::ABSOLUTE_PATH): string
{
$actor_id = $this->getActorId();
return Cache::get("avatar-url-{$actor_id}-{$size}-{$type}", fn () => Router::url('avatar_actor', ['actor_id' => $actor_id, 'size' => $size], $type));
@@ -122,10 +125,15 @@ class Avatar extends Entity
public function getAttachment(): Attachment
{
$this->attachment ??= DB::findOneBy('attachment', ['id' => $this->attachment_id]);
$this->attachment ??= DB::findOneBy('attachment', ['id' => $this->getAttachmentId()]);
return $this->attachment;
}
public function getAttachmentThumbnail(string $size): ?AttachmentThumbnail
{
return AttachmentThumbnail::getOrCreate($this->getAttachment(), $size);
}
public static function getFilePathStatic(string $filename): string
{
return Common::config('avatar', 'dir') . $filename;
@@ -141,10 +149,11 @@ class Avatar extends Entity
*/
public function delete(): bool
{
DB::remove($this);
$actor_id = $this->getActorId();
$attachment = $this->getAttachment();
DB::wrapInTransaction(fn () => DB::removeBy(static::class, ['actor_id' => $actor_id]));
$attachment->kill();
DB::flush();
Event::handle('AvatarUpdate', [$actor_id]);
return true;
}

View File

@@ -44,12 +44,12 @@ class ForeignLink
private int $user_id;
private int $foreign_id;
private int $service;
private ?string $credentials;
private int $noticesync = 1;
private int $friendsync = 2;
private int $profilesync = 1;
private ?DateTimeInterface $last_noticesync;
private ?DateTimeInterface $last_friendsync;
private ?string $credentials = null;
private int $noticesync = 1;
private int $friendsync = 2;
private int $profilesync = 1;
private ?DateTimeInterface $last_noticesync = null;
private ?DateTimeInterface $last_friendsync = null;
private DateTimeInterface $created;
private DateTimeInterface $modified;
@@ -88,7 +88,7 @@ class ForeignLink
public function setCredentials(?string $credentials): self
{
$this->credentials = $credentials;
$this->credentials = \is_null($credentials) ? null : mb_substr($credentials, 0, 191);
return $this;
}

View File

@@ -43,7 +43,7 @@ class ForeignService
// @codeCoverageIgnoreStart
private int $id;
private string $name;
private ?string $description;
private ?string $description = null;
private DateTimeInterface $created;
private DateTimeInterface $modified;
@@ -60,7 +60,7 @@ class ForeignService
public function setName(string $name): self
{
$this->name = $name;
$this->name = mb_substr($name, 0, 32);
return $this;
}
@@ -71,7 +71,7 @@ class ForeignService
public function setDescription(?string $description): self
{
$this->description = $description;
$this->description = \is_null($description) ? null : mb_substr($description, 0, 191);
return $this;
}

View File

@@ -44,7 +44,7 @@ class ForeignUser
private int $id;
private int $service;
private string $uri;
private ?string $nickname;
private ?string $nickname = null;
private DateTimeInterface $created;
private DateTimeInterface $modified;
@@ -72,7 +72,7 @@ class ForeignUser
public function setUri(string $uri): self
{
$this->uri = $uri;
$this->uri = mb_substr($uri, 0, 191);
return $this;
}
@@ -83,7 +83,7 @@ class ForeignUser
public function setNickname(?string $nickname): self
{
$this->nickname = $nickname;
$this->nickname = \is_null($nickname) ? null : mb_substr($nickname, 0, 191);
return $this;
}

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace Component\Circle;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\Feed;
use App\Entity\LocalUser;
use App\Util\Common;
use App\Util\Nickname;
use Component\Circle\Controller as CircleController;
use Component\Circle\Entity\ActorCircle;
use Component\Circle\Entity\ActorCircleSubscription;
use Component\Circle\Entity\ActorTag;
use Component\Collection\Util\MetaCollectionTrait;
use Component\Tag\Tag;
use Functional as F;
use Symfony\Component\HttpFoundation\Request;
/**
* Component responsible for handling and representing ActorCircles and ActorTags
*
* @author Hugo Sales <hugo@hsal.es>
* @author Phablulo <phablulo@gmail.com>
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class Circle extends Component
{
use MetaCollectionTrait;
public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/';
protected string $slug = 'circle';
protected string $plural_slug = 'circles';
public function onAddRoute(RouteLoader $r): bool
{
$r->connect('actor_circle_view_by_circle_id', '/circle/{circle_id<\d+>}', [CircleController\Circle::class, 'circleById']);
// View circle members by (tagger id or nickname) and tag
$r->connect('actor_circle_view_by_circle_tagger_tag', '/circle/actor/{tagger_id<\d+>/{tag<' . Tag::TAG_SLUG_REGEX . '>}}', [CircleController\Circle::class, 'circleByTaggerIdAndTag']);
$r->connect('actor_circle_view_by_circle_tagger_tag', '/circle/@{nickname<' . Nickname::DISPLAY_FMT . '>}/{tag<' . Tag::TAG_SLUG_REGEX . '>}', [CircleController\Circle::class, 'circleByTaggerNicknameAndTag']);
// View all circles by actor id or nickname
$r->connect(
id: 'actor_circles_view_by_actor_id',
uri_path: '/actor/{tag<' . Tag::TAG_SLUG_REGEX . '>}/circles',
target: [CircleController\Circles::class, 'collectionsViewByActorId'],
);
$r->connect(
id: 'actor_circles_view_by_nickname',
uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/circles',
target: [CircleController\Circles::class, 'collectionsViewByActorNickname'],
);
$r->connect('actor_circle_view_feed_by_circle_id', '/circle/{circle_id<\d+>}/feed', [CircleController\Circles::class, 'feedByCircleId']);
// View circle feed by (tagger id or nickname) and tag
$r->connect('actor_circle_view_feed_by_circle_tagger_tag', '/circle/actor/{tagger_id<\d+>/{tag<' . Tag::TAG_SLUG_REGEX . '>}}/feed', [CircleController\Circles::class, 'feedByTaggerIdAndTag']);
$r->connect('actor_circle_view_feed_by_circle_tagger_tag', '/circle/@{nickname<' . Nickname::DISPLAY_FMT . '>}/{tag<' . Tag::TAG_SLUG_REGEX . '>}/feed', [CircleController\Circles::class, 'feedByTaggerNicknameAndTag']);
return Event::next;
}
public static function cacheKeys(string $tag_single_or_multi): array
{
return [
'actor_single' => "actor-tag-feed-{$tag_single_or_multi}",
'actor_multi' => "actor-tags-feed-{$tag_single_or_multi}",
];
}
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool
{
if ($section === 'profile' && $request->get('_route') === 'settings') {
$tabs[] = [
'title' => 'Self tags',
'desc' => 'Add or remove tags on yourself',
'id' => 'settings-self-tags',
'controller' => CircleController\SelfTagsSettings::settingsSelfTags($request, Common::actor(), 'settings-self-tags-details'),
];
}
return Event::next;
}
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): bool
{
$circles = $actor->getCircles();
foreach ($circles as $circle) {
$tag = $circle->getTag();
$targets["#{$tag}"] = $tag;
}
return Event::next;
}
// Meta Collection -------------------------------------------------------------------
private function getActorIdFromVars(array $vars): int
{
$id = $vars['request']->get('id', null);
if ($id) {
return (int) $id;
}
$nick = $vars['request']->get('nickname');
$user = LocalUser::getByNickname($nick);
return $user->getId();
}
public static function createCircle(Actor|int $tagger_id, string $tag): int
{
$tagger_id = \is_int($tagger_id) ? $tagger_id : $tagger_id->getId();
$circle = ActorCircle::create([
'tagger' => $tagger_id,
'tag' => $tag,
'description' => null, // TODO
'private' => false, // TODO
]);
DB::persist($circle);
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
return $circle->getId();
}
protected function createCollection(Actor $owner, array $vars, string $name)
{
$this->createCircle($owner, $name);
DB::persist(ActorTag::create([
'tagger' => $owner->getId(),
'tagged' => self::getActorIdFromVars($vars),
'tag' => $name,
]));
}
protected function removeItem(Actor $owner, array $vars, $items, array $collections)
{
$tagger_id = $owner->getId();
$tagged_id = $this->getActorIdFromVars($vars);
$circles_to_remove_tagged_from = DB::findBy(ActorCircle::class, ['id' => $items]);
foreach ($circles_to_remove_tagged_from as $circle) {
DB::removeBy(ActorCircleSubscription::class, ['actor_id' => $tagged_id, 'circle_id' => $circle->getId()]);
}
$tags = F\map($circles_to_remove_tagged_from, fn ($x) => $x->getTag());
foreach ($tags as $tag) {
DB::removeBy(ActorTag::class, ['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]);
}
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
}
protected function addItem(Actor $owner, array $vars, $items, array $collections)
{
$tagger_id = $owner->getId();
$tagged_id = $this->getActorIdFromVars($vars);
$circles_to_add_tagged_to = DB::findBy(ActorCircle::class, ['id' => $items]);
foreach ($circles_to_add_tagged_to as $circle) {
DB::persist(ActorCircleSubscription::create(['actor_id' => $tagged_id, 'circle_id' => $circle->getId()]));
}
$tags = F\map($circles_to_add_tagged_to, fn ($x) => $x->getTag());
foreach ($tags as $tag) {
DB::persist(ActorTag::create(['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]));
}
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
}
/**
* @see MetaCollectionPlugin->shouldAddToRightPanel
*/
protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool
{
return \in_array($vars['path'], ['actor_view_nickname', 'actor_view_id']);
}
protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array
{
$tagged_id = !\is_null($vars) ? $this->getActorIdFromVars($vars) : null;
$circles = \is_null($tagged_id) ? $owner->getCircles() : F\select($owner->getCircles(), function ($x) use ($tagged_id) {
foreach ($x->getActorTags() as $at) {
if ($at->getTagged() === $tagged_id) {
return true;
}
}
return false;
});
return $ids_only ? array_map(fn ($x) => $x->getId(), $circles) : $circles;
}
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
{
DB::persist(Feed::create([
'actor_id' => $actor_id,
'url' => Router::url($route = 'actor_circles_view_by_nickname', ['nickname' => $user->getNickname()]),
'route' => $route,
'title' => _m('Circles'),
'ordering' => $ordering++,
]));
return Event::next;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace Component\Circle\Controller;
use function App\Core\I18n\_m;
use App\Entity\LocalUser;
use App\Util\Exception\ClientException;
use Component\Circle\Entity\ActorCircle;
use Component\Collection\Util\Controller\CircleController;
class Circle extends CircleController
{
public function circleById(int|ActorCircle $circle_id): array
{
$circle = \is_int($circle_id) ? ActorCircle::getByPK(['id' => $circle_id]) : $circle_id;
unset($circle_id);
if (\is_null($circle)) {
throw new ClientException(_m('No such circle.'), 404);
} else {
return [
'_template' => 'collection/actors.html.twig',
'title' => _m('Circle'),
'empty_message' => _m('No members.'),
'sort_form_fields' => [],
'page' => $this->int('page') ?? 1,
'actors' => $circle->getTaggedActors(),
];
}
}
public function circleByTaggerIdAndTag(int $tagger_id, string $tag): array
{
return $this->circleById(ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag]));
}
public function circleByTaggerNicknameAndTag(string $tagger_nickname, string $tag): array
{
return $this->circleById(ActorCircle::getByPK(['tagger' => LocalUser::getByNickname($tagger_nickname)->getId(), 'tag' => $tag]));
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace Component\Circle\Controller;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\LocalUser;
use Component\Circle\Entity\ActorCircle;
use Component\Collection\Util\Controller\MetaCollectionController;
class Circles extends MetaCollectionController
{
protected string $slug = 'circle';
protected string $plural_slug = 'circles';
protected string $page_title = 'Actor circles';
public function createCollection(int $owner_id, string $name)
{
return \Component\Circle\Circle::createCircle($owner_id, $name);
}
public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string
{
return Router::url(
'actor_circle_view_by_circle_id',
['circle_id' => $collection_id],
);
}
public function getCollectionItems(int $owner_id, $collection_id): array
{
$notes = []; // TODO: Use Feed::query
return [
'_template' => 'collection/notes.html.twig',
'notes' => $notes,
];
}
public function feedByCircleId(int $circle_id)
{
// Owner id isn't used
return $this->getCollectionItems(0, $circle_id);
}
public function feedByTaggerIdAndTag(int $tagger_id, string $tag)
{
// Owner id isn't used
$circle_id = ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])->getId();
return $this->getCollectionItems($tagger_id, $circle_id);
}
public function feedByTaggerNicknameAndTag(string $tagger_nickname, string $tag)
{
$tagger_id = LocalUser::getByNickname($tagger_nickname)->getId();
$circle_id = ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])->getId();
return $this->getCollectionItems($tagger_id, $circle_id);
}
public function getCollectionsByActorId(int $owner_id): array
{
return DB::findBy(ActorCircle::class, ['tagger' => $owner_id], order_by: ['id' => 'desc']);
}
public function getCollectionBy(int $owner_id, int $collection_id): ActorCircle
{
return DB::findOneBy(ActorCircle::class, ['id' => $collection_id, 'actor_id' => $owner_id]);
}
public function setCollectionName(int $actor_id, string $actor_nickname, ActorCircle $collection, string $name)
{
foreach ($collection->getActorTags(db_reference: true) as $at) {
$at->setTag($name);
}
$collection->setTag($name);
Cache::delete(Actor::cacheKeys($actor_id)['circles']);
}
public function removeCollection(int $actor_id, string $actor_nickname, ActorCircle $collection)
{
foreach ($collection->getActorTags(db_reference: true) as $at) {
DB::remove($at);
}
DB::remove($collection);
Cache::delete(Actor::cacheKeys($actor_id)['circles']);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types = 1);
namespace Component\Circle\Controller;
use App\Core\Cache;
use App\Core\Controller;
use App\Core\DB\DB;
use function App\Core\I18n\_m;
use App\Entity as E;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use Component\Circle\Entity\ActorCircle;
use Component\Circle\Entity\ActorTag;
use Component\Circle\Form\SelfTagsForm;
use Component\Tag\Tag as CompTag;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
class SelfTagsSettings extends Controller
{
/**
* Generic settings page for an Actor's self tags
*/
public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id)
{
$actor = Common::actor();
if (!$actor->canAdmin($target)) {
throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()]));
}
$actor_self_tags = $target->getSelfTags();
[$add_form, $existing_form] = SelfTagsForm::handleTags(
$request,
$actor_self_tags,
handle_new: /**
* Handle adding tags
*/
function ($form) use ($request, $target, $details_id) {
$data = $form->getData();
$tags = $data['new-tags'];
foreach ($tags as $tag) {
$tag = CompTag::sanitize($tag);
[$actor_tag, $actor_tag_existed] = ActorTag::createOrUpdate([
'tagger' => $target->getId(), // self tag means tagger = tagger in ActorTag
'tagged' => $target->getId(),
'tag' => $tag,
]);
if (!$actor_tag_existed) {
DB::persist($actor_tag);
// Try to find the self-tag circle
$actor_circle = DB::findOneBy(
ActorCircle::class,
[
'tagger' => null, // Self-tag circle
'tag' => $tag,
],
return_null: true,
);
// It is the first time someone uses this self-tag!
if (\is_null($actor_circle)) {
DB::persist(ActorCircle::create([
'tagger' => null, // Self-tag circle
'tag' => $tag,
'private' => false, // by definition
'description' => null, // The controller can show this in every language as appropriate
]));
}
}
}
DB::flush();
Cache::delete(E\Actor::cacheKeys($target->getId())['self-tags']);
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id, '_fragment' => $details_id]);
},
handle_existing: /**
* Handle changes to the existing tags
*/
function ($form, array $form_definition) use ($request, $target, $details_id) {
$changed = false;
foreach (array_chunk($form_definition, 2) as $entry) {
$tag = CompTag::sanitize($entry[0][2]['data']);
/** @var SubmitButton $remove */
$remove = $form->get($entry[1][0]);
if ($remove->isClicked()) {
$changed = true;
DB::removeBy(
'actor_tag',
[
'tagger' => $target->getId(),
'tagged' => $target->getId(),
'tag' => $tag,
],
);
// We intentionally leave the self-tag actor circle, even if it is now empty
}
}
if ($changed) {
DB::flush();
Cache::delete(E\Actor::cacheKeys($target->getId())['self-tags']);
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id, '_fragment' => $details_id]);
}
},
remove_label: _m('Remove self tag'),
add_label: _m('Add self tag'),
);
return [
'_template' => 'self_tags_settings.fragment.html.twig',
'add_self_tags_form' => $add_form->createView(),
'existing_self_tags_form' => $existing_form?->createView(),
];
}
}

View File

@@ -19,11 +19,12 @@ declare(strict_types = 1);
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Circle\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Router\Router;
use DateTimeInterface;
/**
@@ -48,10 +49,10 @@ class ActorCircle extends Entity
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $id;
private int $tagger;
private ?int $tagger = null; // If null, is the special global self-tag circle
private string $tag;
private ?string $description;
private ?bool $private;
private ?string $description = null;
private ?bool $private = false;
private DateTimeInterface $created;
private DateTimeInterface $modified;
@@ -66,20 +67,20 @@ class ActorCircle extends Entity
return $this->id;
}
public function setTagger(int $tagger): self
public function setTagger(?int $tagger): self
{
$this->tagger = $tagger;
return $this;
}
public function getTagger(): int
public function getTagger(): ?int
{
return $this->tagger;
}
public function setTag(string $tag): self
{
$this->tag = $tag;
$this->tag = mb_substr($tag, 0, 64);
return $this;
}
@@ -135,60 +136,86 @@ class ActorCircle extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
public function getActorTag()
/**
* For use with MetaCollection trait only
*/
public function getName(): string
{
return $this->tag;
}
public function getActorTags(bool $db_reference = false): array
{
$handle = fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]);
if ($db_reference) {
return $handle();
}
return Cache::get(
"circle-{$this->getId()}-tagged",
$handle,
);
}
public function getTaggedActors()
{
return Cache::get(
"actor-tag-{$this->getTag()}",
fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'canonical' => $this->getTag()], limit: 1)[0], // TODO jank
"circle-{$this->getId()}-tagged-actors",
function () {
if ($this->getTagger()) {
return DB::dql('SELECT a FROM actor AS a JOIN actor_tag AS at WITH at.tagged = a.id WHERE at.tag = :tag AND at.tagger = :tagger', ['tag' => $this->getTag(), 'tagger' => $this->getTagger()]);
} else { // Self-tag
return DB::dql('SELECT a FROM actor AS a JOIN actor_tag AS at WITH at.tagged = a.id WHERE at.tag = :tag AND at.tagger = at.tagged', ['tag' => $this->getTag()]);
}
},
);
}
public function getSubscribedActors(?int $offset = null, ?int $limit = null): array
{
return Cache::get(
"circle-{$this->getId()}",
"circle-{$this->getId()}-subscribers",
fn () => DB::dql(
<<< 'EOQ'
SELECT a
FROM App\Entity\Actor a
JOIN App\Entity\ActorCircleSubscription s
FROM actor a
JOIN actor_circle_subscription s
WITH a.id = s.actor_id
ORDER BY s.created DESC, a.id DESC
EOQ,
options: ['offset' => $offset,
'limit' => $limit, ],
options: [
'offset' => $offset,
'limit' => $limit,
],
),
);
}
public function getUrl(int $type = Router::ABSOLUTE_PATH): string
{
return Router::url('actor_circle_view_by_circle_id', ['circle_id' => $this->getId()], type: $type);
}
public static function schemaDef(): array
{
return [
'name' => 'actor_circle',
'description' => 'a actor can have lists of actors, to separate their feed',
'description' => 'An actor can have lists of actors, to separate their feed or quickly mention his friend',
'fields' => [
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'],
// An actor can be tagged by many actors
'tagger' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'actor_list_tagger_fkey', 'not null' => true, 'description' => 'user making the tag'],
// Many Actor Circles can reference (and probably will) an Actor Tag
'tag' => ['type' => 'varchar', 'length' => 64, 'foreign key' => true, 'target' => 'ActorTag.tag', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'actor tag'], // Join with ActorTag // // so, Doctrine doesn't like that the target is not unique, even though the pair is
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], // An actor can be tagged by many actors
'tagger' => ['type' => 'int', 'default' => null, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'actor_list_tagger_fkey', 'description' => 'user making the tag, null if self-tag'],
'tag' => ['type' => 'varchar', 'length' => 64, 'foreign key' => true, 'target' => 'ActorTag.tag', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'actor tag'], // Join with ActorTag // // so, Doctrine doesn't like that the target is not unique, even though the pair is // Many Actor Circles can reference (and probably will) an Actor Tag
'description' => ['type' => 'text', 'description' => 'description of the people tag'],
'private' => ['type' => 'bool', 'default' => false, 'description' => 'is this tag private'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['id'],
'primary key' => ['id'], // But we will mostly refer to them with `tagger` and `tag`
'indexes' => [
'actor_list_modified_idx' => ['modified'],
'actor_list_tagger_tag_idx' => ['tagger', 'tag'], // The actual identifier we will use the most
'actor_list_tag_idx' => ['tag'],
'actor_list_tagger_tag_idx' => ['tagger', 'tag'],
'actor_list_tagger_idx' => ['tagger'],
],
];
}
public function __toString()
{
return $this->getTag();
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@@ -17,7 +19,7 @@
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Circle\Entity;
use App\Core\Entity;
use DateTimeInterface;
@@ -59,13 +61,13 @@ class ActorCircleSubscription extends Entity
return $this->actor_id;
}
public function setCircleid(int $circle_id): self
public function setCircleId(int $circle_id): self
{
$this->circle_id = $circle_id;
return $this;
}
public function getCircleid(): int
public function getCircleId(): int
{
return $this->circle_id;
}
@@ -98,18 +100,18 @@ class ActorCircleSubscription extends Entity
public static function schemaDef(): array
{
return [
'name' => 'actor_circle_subscription',
'name' => 'actor_circle_subscription',
'fields' => [
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_circle_subscription_actor_id_fkey', 'not null' => true, 'description' => 'foreign key to actor table'],
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_circle_subscription_actor_id_fkey', 'not null' => true, 'description' => 'foreign key to actor table'],
// An actor subscribes many circles; A Circle is subscribed by many actors.
'circle_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'ActorCircle.id', 'multiplicity' => 'one to many', 'name' => 'actor_circle_subscription_actor_circle_fkey', 'not null' => true, 'description' => 'foreign key to actor_circle'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['circle_id', 'actor_id'],
'indexes' => [
'indexes' => [
'actor_circle_subscription_actor_id_idx' => ['actor_id'],
'actor_circle_subscription_created_idx' => ['created'],
'actor_circle_subscription_created_idx' => ['created'],
],
];
}

View File

@@ -19,12 +19,12 @@ declare(strict_types = 1);
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Circle\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Router\Router;
use App\Entity\Actor;
use Component\Tag\Tag;
use DateTimeInterface;
@@ -54,8 +54,6 @@ class ActorTag extends Entity
private int $tagger;
private int $tagged;
private string $tag;
private string $canonical;
private bool $use_canonical;
private DateTimeInterface $modified;
public function setTagger(int $tagger): self
@@ -82,7 +80,7 @@ class ActorTag extends Entity
public function setTag(string $tag): self
{
$this->tag = $tag;
$this->tag = mb_substr($tag, 0, 64);
return $this;
}
@@ -91,28 +89,6 @@ class ActorTag extends Entity
return $this->tag;
}
public function setCanonical(string $canonical): self
{
$this->canonical = $canonical;
return $this;
}
public function getCanonical(): string
{
return $this->canonical;
}
public function setUseCanonical(bool $use_canonical): self
{
$this->use_canonical = $use_canonical;
return $this;
}
public function getUseCanonical(): bool
{
return $this->use_canonical;
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
@@ -127,18 +103,22 @@ class ActorTag extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function getByActorId(int $actor_id): array
public function getUrl(?Actor $actor = null, int $type = Router::ABSOLUTE_PATH): string
{
return Cache::getList(Actor::cacheKeys($actor_id)['tags'], fn () => DB::dql('select at from actor_tag at join actor a with a.id = at.tagger where a.id = :id', ['id' => $actor_id]));
$params = ['tag' => $this->getTag()];
if (!\is_null($actor)) {
$params['locale'] = $actor->getTopLanguage()->getLocale();
}
return Router::url('single_actor_tag', $params, type: $type);
}
public function getUrl(?Actor $actor = null): string
public function getCircle(): ActorCircle
{
$params = ['canon' => $this->getCanonical(), 'tag' => $this->getTag()];
if (!\is_null($actor)) {
$params['lang'] = $actor->getTopLanguage()->getLocale();
if ($this->getTagger() === $this->getTagged()) { // Self-tag
return DB::findOneBy(ActorCircle::class, ['tagger' => null, 'tag' => $this->getTag()]);
} else {
return DB::findOneBy(ActorCircle::class, ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]);
}
return Router::url('single_actor_tag', $params);
}
public static function schemaDef(): array
@@ -146,24 +126,16 @@ class ActorTag extends Entity
return [
'name' => 'actor_tag',
'fields' => [
'tagger' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagger_fkey', 'not null' => true, 'description' => 'actor making the tag'],
'tagged' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagged_fkey', 'not null' => true, 'description' => 'actor tagged'],
'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hash tag associated with this actor'],
'canonical' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'ascii slug of tag'],
'use_canonical' => ['type' => 'bool', 'not null' => true, 'description' => 'whether the user wanted to block canonical tags'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['tagger', 'tagged', 'tag', 'use_canonical'],
'tagger' => ['type' => 'int', 'not null' => true, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagger_fkey', 'description' => 'actor making the tag'],
'tagged' => ['type' => 'int', 'not null' => true, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagged_fkey', 'description' => 'actor tagged'],
'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hashtag associated with this note'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
], // We will always assume the tagger's preferred language for tags and circles
'primary key' => ['tagger', 'tagged', 'tag'],
'indexes' => [
'actor_tag_modified_idx' => ['modified'],
'actor_tag_tagger_tag_idx' => ['tagger', 'tag'], // For Circles
'actor_tag_tagged_idx' => ['tagged'],
],
];
}
public function __toString(): string
{
return $this->getTag();
}
}

View File

@@ -2,13 +2,11 @@
declare(strict_types = 1);
namespace Component\Tag\Form;
namespace Component\Circle\Form;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Entity as E;
use App\Util\Form\ArrayTransformer;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
@@ -16,31 +14,27 @@ use Symfony\Component\HttpFoundation\Request;
abstract class SelfTagsForm
{
/**
* @param E\ActorTag[]|E\ActorTagBlock[]|E\NoteTagBlock[] $tags
*
* @return array [Form (add), ?Form (existing)]
*/
public static function handleTags(
Request $request,
array $tags,
array $actor_self_tags,
callable $handle_new,
callable $handle_existing,
string $remove_label,
string $add_label,
): array {
$form_definition = [];
foreach ($tags as $tag) {
$canon = $tag->getCanonical();
$form_definition[] = ["{$canon}:old-tag", TextType::class, ['data' => '#' . $tag->getTag(), 'label' => ' ', 'disabled' => true]];
$form_definition[] = ["{$canon}:toggle-canon", SubmitType::class, ['attr' => ['data' => $tag->getUseCanonical()], 'label' => $tag->getUseCanonical() ? _m('Set non-canonical') : _m('Set canonical')]];
$form_definition[] = [$existing_form_name = "{$canon}:remove", SubmitType::class, ['label' => $remove_label]];
foreach ($actor_self_tags as $tag) {
$tag = $tag->getTag();
$form_definition[] = ["{$tag}:old-tag", TextType::class, ['data' => $tag, 'label' => ' ', 'disabled' => true]];
$form_definition[] = [$existing_form_name = "{$tag}:remove", SubmitType::class, ['label' => $remove_label]];
}
$existing_form = !empty($form_definition) ? Form::create($form_definition) : null;
$add_form = Form::create([
['new-tags', TextType::class, ['label' => ' ', 'data' => [], 'required' => false, 'help' => _m('Tags for yourself (letters, numbers, -, ., and _), comma- or space-separated.'), 'transformer' => ArrayTransformer::class]],
['new-tags-use-canon', CheckboxType::class, ['label' => _m('Use canonical'), 'help' => _m('Assume this tag is the same as similar tags'), 'required' => false, 'data' => true]],
[$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]],
]);

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types = 1);
namespace Component\Collection;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Entity\Actor;
use App\Util\Formatting;
use Component\Collection\Util\Parser;
use Component\Subscription\Entity\ActorSubscription;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
class Collection extends Component
{
/**
* Perform a high level query on notes or actors
*
* Supports a variety of query terms and is used both in feeds and
* in search. Uses query builders to allow for extension
*/
public static function query(string $query, int $page, ?string $locale = null, ?Actor $actor = null): array
{
$note_criteria = null;
$actor_criteria = null;
if (!empty($query = trim($query))) {
[$note_criteria, $actor_criteria] = Parser::parse($query, $locale, $actor);
}
$note_qb = DB::createQueryBuilder();
$actor_qb = DB::createQueryBuilder();
// TODO consider selecting note related stuff, to avoid separate queries (though they're cached, so maybe it's okay)
$note_qb->select('note')->from('App\Entity\Note', 'note')->orderBy('note.created', 'DESC')->addOrderBy('note.id', 'DESC');
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor')->orderBy('actor.created', 'DESC')->addOrderBy('actor.id', 'DESC');
Event::handle('CollectionQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
$notes = [];
$actors = [];
if (!\is_null($note_criteria)) {
$note_qb->addCriteria($note_criteria);
$notes = $note_qb->getQuery()->execute();
}
if (!\is_null($actor_criteria)) {
$actor_qb->addCriteria($actor_criteria);
$actors = $actor_qb->getQuery()->execute();
}
// N.B.: Scope is only enforced at FeedController level
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
}
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{
$note_qb->leftJoin(ActorSubscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed_id')
->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
return Event::next;
}
/**
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text
* notes, for different types of actors and for the content of text notes
*/
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr)
{
if (str_contains($term, ':')) {
$term = explode(':', $term);
if (Formatting::startsWith($term[0], 'note')) {
switch ($term[0]) {
case 'notes-all':
$note_expr = $eb->neq('note.created', null);
break;
case 'note-local':
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
break;
case 'note-types':
case 'notes-include':
case 'note-filter':
if (\is_null($note_expr)) {
$note_expr = [];
}
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
$note_expr[] = $eb->neq('note.content', null);
} else {
$note_expr[] = $eb->eq('note.content', null);
}
break;
case 'note-conversation':
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
break;
case 'note-from':
case 'notes-from':
$subscribed_expr = $eb->eq('subscription.subscriber_id', $actor->getId());
$type_consts = [];
if ($term[1] === 'subscribed') {
$type_consts = null;
}
foreach (explode(',', $term[1]) as $from) {
if (str_starts_with($from, 'subscribed-')) {
[, $type] = explode('-', $from);
if (\in_array($type, ['actor', 'actors'])) {
$type_consts = null;
} else {
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type));
}
}
}
if (\is_null($type_consts)) {
$note_expr = $subscribed_expr;
} elseif (!empty($type_consts)) {
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
}
break;
}
} elseif (Formatting::startsWith($term, 'actor-')) {
switch ($term[0]) {
case 'actor-types':
case 'actors-include':
case 'actor-filter':
case 'actor-local':
if (\is_null($actor_expr)) {
$actor_expr = [];
}
foreach (
[
Actor::PERSON => ['person', 'people'],
Actor::GROUP => ['group', 'groups'],
Actor::ORGANISATION => ['org', 'orgs', 'organization', 'organizations', 'organisation', 'organisations'],
Actor::BOT => ['bot', 'bots'],
] as $type => $match) {
if (array_intersect(explode(',', $term[1]), $match) !== []) {
$actor_expr[] = $eb->eq('actor.type', $type);
} else {
$actor_expr[] = $eb->neq('actor.type', $type);
}
}
break;
}
}
} else {
$note_expr = $eb->contains('note.content', $term);
}
return Event::next;
}
}

View File

@@ -30,16 +30,14 @@ declare(strict_types = 1);
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace App\Core\Controller;
namespace Component\Collection\Util;
use App\Core\DB\DB;
use function App\Core\I18n\_m;
use App\Core\Router\Router;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use Component\Feed\Util\FeedController;
abstract class ActorController extends FeedController
trait ActorControllerTrait
{
/**
* Generic function that handles getting a representation for an actor from id
@@ -48,7 +46,7 @@ abstract class ActorController extends FeedController
{
$actor = DB::findOneBy('actor', ['id' => $id]);
if ($actor->getIsLocal()) {
throw new RedirectException(url: $actor->getUrl(Router::ABSOLUTE_PATH));
return ['_redirect' => $actor->getUrl(Router::ABSOLUTE_PATH), 'actor' => $actor];
}
if (empty($actor)) {
throw new ClientException(_m('No such actor.'), 404);

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types = 1);
namespace Component\Collection\Util\Controller;
class CircleController extends OrderedCollection
{
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types = 1);
namespace Component\Collection\Util\Controller;
use App\Core\Controller;
use App\Entity\Actor;
use App\Util\Common;
use Component\Collection\Collection as CollectionModule;
class Collection extends Controller
{
public function query(string $query, ?string $locale = null, ?Actor $actor = null): array
{
$actor ??= Common::actor();
$locale ??= Common::currentLanguage()->getLocale();
return CollectionModule::query($query, $this->int('page') ?? 1, $locale, $actor);
}
}

View File

@@ -30,29 +30,36 @@ declare(strict_types = 1);
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Component\Feed\Util;
namespace Component\Collection\Util\Controller;
use App\Core\Controller;
use App\Core\Event;
use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Common;
use Functional as F;
abstract class FeedController extends Controller
abstract class FeedController extends OrderedCollection
{
/**
* Post process the result of a feed controller, to remove any
* Post-processing of the result of a feed controller, to remove any
* notes or actors the user specified, as well as format the raw
* list of notes into a usable format
*/
public static function post_process(array $result): array
protected function postProcess(array $result): array
{
$actor = Common::actor();
if (\array_key_exists('notes', $result)) {
$notes = $result['notes'];
self::enforceScope($notes, $actor);
Event::handle('FilterNoteList', [$actor, &$notes, $result['request']]);
Event::handle('FormatNoteList', [$notes, &$result['notes']]);
Event::handle('FormatNoteList', [$notes, &$result['notes'], &$result['request']]);
}
return $result;
}
private static function enforceScope(array &$notes, ?Actor $actor): void
{
$notes = F\select($notes, fn (Note $n) => $n->isVisibleTo($actor));
}
}

View File

@@ -3,7 +3,6 @@
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
@@ -18,109 +17,123 @@ declare(strict_types = 1);
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
/**
* Collections Controller for GNU social
*
* @package GNUsocial
* @category Plugin
*
* @author Phablulo <phablulo@gmail.com>
* @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Plugin\AttachmentCollections\Controller;
namespace Component\Collection\Util\Controller;
use App\Core\Form;
use App\Core\DB\DB;
use App\Util\Common;
use App\Core\Router\Router;
use App\Core\Form;
use function App\Core\I18n\_m;
use Component\Feed\Util\FeedController;
use Symfony\Component\HttpFoundation\Request;
use Plugin\AttachmentCollections\Entity\Collection;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use App\Entity\LocalUser;
use App\Util\Common;
use App\Util\Exception\RedirectException;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
class Controller extends FeedController
abstract class MetaCollectionController extends FeedController
{
public function collectionsByActorNickname(Request $request, string $nickname): array
protected string $slug = 'collectionsEntry';
protected string $plural_slug = 'collectionsList';
protected string $page_title = 'Collections';
abstract public function getCollectionUrl(int $owner_id, string $owner_nickname, int $collection_id): string;
abstract public function getCollectionItems(int $owner_id, $collection_id): array;
abstract public function getCollectionsByActorId(int $owner_id): array;
abstract public function getCollectionBy(int $owner_id, int $collection_id);
abstract public function createCollection(int $owner_id, string $name);
public function collectionsViewByActorNickname(Request $request, string $nickname): array
{
$user = DB::findOneBy('local_user', ['nickname' => $nickname]);
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
return self::collectionsView($request, $user->getId(), $nickname);
}
public function collectionsViewByActorId(Request $request, int $id): array
{
return self::collectionsView($request, $id, null);
}
/**
* Generate Collections page
*
* @param int $id actor id
* @param ?string $nickname actor nickname
* @return array twig template options
*
* @return array twig template options
*/
public function collectionsView(Request $request, int $id, ?string $nickname): array
{
$collections = DB::dql(
'select collection from Plugin\AttachmentCollections\Entity\Collection collection '
. 'where collection.actor_id = :id',
['id' => $id]
);
$collections = $this->getCollectionsByActorId($id);
$create_title = _m('Create a ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->slug)));
$collections_title = _m('The ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->plural_slug)));
// create collection form
$create = null;
if (Common::user()?->getId() === $id) {
$create = Form::create([
['name', TextType::class, [
'label' => _m('Create collection'),
'attr' => [
'label' => $create_title,
'attr' => [
'placeholder' => _m('Name'),
'required' => 'required'
'required' => 'required',
],
'data' => '',
]],
['add_collection', SubmitType::class, [
'label' => _m('Create collection'),
'label' => $create_title,
'attr' => [
'title' => _m('Create collection'),
'title' => $create_title,
],
]],
]);
$create->handleRequest($request);
if ($create->isSubmitted() && $create->isValid()) {
DB::persist(Collection::create([
'name' => $create->getData()['name'],
'actor_id' => $id,
]));
$this->createCollection($id, $create->getData()['name']);
DB::flush();
throw new RedirectException();
}
}
// We need to inject some functions in twig,
// but i don't want to create an enviroment for this
// but I don't want to create an environment for this
// as twig docs suggest in https://twig.symfony.com/doc/2.x/advanced.html#functions.
//
// Instead, I'm using an anonymous class to encapsulate
// the functions and passing how the class to the template.
// It's suggested at https://stackoverflow.com/a/50364502.
$fn = new class ($id, $nickname, $request)
{
// the functions and passing that class to the template.
// This is suggested at https://stackoverflow.com/a/50364502.
$fn = new class($id, $nickname, $request, $this, $this->slug) {
private $id;
private $nick;
private $request;
public function __construct($id, $nickname, $request)
private $parent;
private $slug;
public function __construct($id, $nickname, $request, $parent, $slug)
{
$this->id = $id;
$this->nick = $nickname;
$this->id = $id;
$this->nick = $nickname;
$this->request = $request;
$this->parent = $parent;
$this->slug = $slug;
}
// there's already a injected function called path,
// there's already an injected function called path,
// that maps to Router::url(name, args), but since
// I want to preserve nicknames, I think it's better
// to use that getUrl function
public function getUrl($cid)
{
if (\is_null($this->nick)) {
return Router::url(
'collection_notes_view_by_actor_id',
['id' => $this->id, 'cid' => $cid]
);
}
return Router::url(
'collection_notes_view_by_nickname',
['nickname' => $this->nick, 'cid' => $cid]
);
return $this->parent->getCollectionUrl($this->id, $this->nick, $cid);
}
// There are many collections in this page and we need two
// forms for each one of them: one form to edit the collection's
@@ -133,11 +146,11 @@ class Controller extends FeedController
['name', TextType::class, [
'attr' => [
'placeholder' => 'New name',
'required' => 'required'
'required' => 'required',
],
'data' => '',
]],
['update_'.$collection->getId(), SubmitType::class, [
['update_' . $collection->getId(), SubmitType::class, [
'label' => _m('Save'),
'attr' => [
'title' => _m('Save'),
@@ -146,61 +159,58 @@ class Controller extends FeedController
]);
$edit->handleRequest($this->request);
if ($edit->isSubmitted() && $edit->isValid()) {
$collection->setName($edit->getData()['name']);
DB::persist($collection);
$this->parent->setCollectionName($this->id, $this->nick, $collection, $edit->getData()['name']);
DB::flush();
throw new RedirectException();
}
return $edit->createView();
}
// creating the remove form
public function rmForm($collection)
{
$rm = Form::create([
['remove_'.$collection->getId(), SubmitType::class, [
'label' => _m('Delete collection'),
['remove_' . $collection->getId(), SubmitType::class, [
'label' => _m('Delete ' . $this->slug),
'attr' => [
'title' => _m('Delete collection'),
'title' => _m('Delete ' . $this->slug),
'class' => 'danger',
],
]],
]);
$rm->handleRequest($this->request);
if ($rm->isSubmitted()) {
DB::remove($collection);
$this->parent->removeCollection($this->id, $this->nick, $collection);
DB::flush();
throw new RedirectException();
}
return $rm->createView();
}
};
return [
'_template' => 'AttachmentCollections/collections.html.twig',
'page_title' => 'Attachment Collections list',
'_template' => 'collection/meta_collections.html.twig',
'page_title' => $this->page_title,
'list_title' => $collections_title,
'add_collection' => $create?->createView(),
'fn' => $fn,
'collections' => $collections,
];
}
public function collectionNotesByNickname(Request $request, string $nickname, int $cid): array
public function collectionsEntryViewNotesByNickname(Request $request, string $nickname, int $cid): array
{
$user = DB::findOneBy('local_user', ['nickname' => $nickname]);
return self::collectionNotesByActorId($request, $user->getId(), $cid);
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
return self::collectionsEntryViewNotesByActorId($request, $user->getId(), $cid);
}
public function collectionNotesByActorId(Request $request, int $id, int $cid): array
public function collectionsEntryViewNotesByActorId(Request $request, int $id, int $cid): array
{
$collection = DB::findOneBy('attachment_collection', ['id' => $cid]);
$attchs = DB::dql(
'select attch from attachment_album_entry entry '
. 'left join Component\Attachment\Entity\Attachment attch '
. 'with entry.attachment_id = attch.id '
. 'where entry.collection_id = :cid',
['cid' => $cid]
);
return [
'_template' => 'AttachmentCollections/collection.html.twig',
'page_title' => $collection->getName(),
'attachments' => $attchs,
];
$collection = $this->getCollectionBy($id, $cid);
$vars = $this->getCollectionItems($id, $cid);
return array_merge([
'_template' => 'collections/collection_entry_view.html.twig',
'page_title' => $collection->getName(),
], $vars);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types = 1);
namespace Component\Collection\Util\Controller;
class OrderedCollection extends Collection
{
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
/**
* Collections for GNU social
*
* @package GNUsocial
* @category Plugin
*
* @author Phablulo <phablulo@gmail.com>
* @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Component\Collection\Util;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Exception\RedirectException;
use App\Util\Formatting;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
trait MetaCollectionTrait
{
//protected string $slug = 'collection';
//protected string $plural_slug = 'collections';
/**
* create a collection owned by Actor $owner.
*
* @param Actor $owner The collection's owner
* @param array $vars Page vars sent by AppendRightPanelBlock event
* @param string $name Collection's name
*/
abstract protected function createCollection(Actor $owner, array $vars, string $name);
/**
* remove item from collections.
*
* @param Actor $owner Current user
* @param array $vars Page vars sent by AppendRightPanelBlock event
* @param array $items Array of collections's ids to remove the current item from
* @param array $collections List of ids of collections owned by $owner
*/
abstract protected function removeItem(Actor $owner, array $vars, array $items, array $collections);
/**
* add item to collections.
*
* @param Actor $owner Current user
* @param array $vars Page vars sent by AppendRightPanelBlock event
* @param array $items Array of collections's ids to add the current item to
* @param array $collections List of ids of collections owned by $owner
*/
abstract protected function addItem(Actor $owner, array $vars, array $items, array $collections);
/**
* Check the route to determine whether the widget should be added
*/
abstract protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool;
/**
* Get array of collections's owned by $actor
*
* @param Actor $owner Collection's owner
* @param ?array $vars Page vars sent by AppendRightPanelBlock event
* @param bool $ids_only if true, the function must return only the primary key or each collections
*/
abstract protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array;
/**
* Append Collections widget to the right panel.
* It's compose of two forms: one to select collections to add
* the current item to, and another to create a new collection.
*/
public function onAppendRightPanelBlock(Request $request, $vars, &$res): bool
{
$user = Common::actor();
if (\is_null($user)) {
return Event::next;
}
if (!$this->shouldAddToRightPanel($user, $vars, $request)) {
return Event::next;
}
$collections = $this->getCollectionsBy($user);
// form: add to collection
$choices = [];
foreach ($collections as $col) {
$choices[$col->getName()] = $col->getId();
}
$collections = array_map(fn ($x) => $x->getId(), $collections);
$already_selected = $this->getCollectionsBy($user, $vars, true);
$add_form = Form::create([
['collections', ChoiceType::class, [
'choices' => $choices,
'multiple' => true,
'required' => false,
'choice_attr' => function ($id) use ($already_selected) {
if (\in_array($id, $already_selected)) {
return ['selected' => 'selected'];
}
return [];
},
]],
['add', SubmitType::class, [
'label' => _m('Add to ' . $this->plural_slug),
'attr' => [
'title' => _m('Add to ' . $this->plural_slug),
],
]],
]);
$add_form->handleRequest($request);
if ($add_form->isSubmitted() && $add_form->isValid()) {
$selected = $add_form->getData()['collections'];
$removed = array_filter($already_selected, fn ($x) => !\in_array($x, $selected));
$added = array_filter($selected, fn ($x) => !\in_array($x, $already_selected));
if (\count($removed) > 0) {
$this->removeItem($user, $vars, $removed, $collections);
}
if (\count($added) > 0) {
$this->addItem($user, $vars, $added, $collections);
}
DB::flush();
throw new RedirectException();
}
// form: add to new collection
$create_form = Form::create([
['name', TextType::class, [
'label' => _m('Add to a new ' . $this->slug),
'attr' => [
'placeholder' => _m('New ' . $this->slug . ' name'),
'required' => 'required',
],
'data' => '',
]],
['create', SubmitType::class, [
'label' => _m('Create a new ' . $this->slug),
'attr' => [
'title' => _m('Create a new ' . $this->slug),
],
]],
]);
$create_form->handleRequest($request);
if ($create_form->isSubmitted() && $create_form->isValid()) {
$name = $create_form->getData()['name'];
$this->createCollection($user, $vars, $name);
DB::flush();
throw new RedirectException();
}
$res[] = Formatting::twigRenderFile(
'collection/widget_add_to.html.twig',
[
'ctitle' => _m('Add to ' . $this->plural_slug),
'user' => $user,
'has_collections' => \count($collections) > 0,
'add_form' => $add_form->createView(),
'create_form' => $create_form->createView(),
],
);
return Event::next;
}
public function onEndShowStyles(array &$styles, string $route): bool
{
$styles[] = 'components/Collection/assets/css/widget.css';
$styles[] = 'components/Collection/assets/css/pages.css';
return Event::next;
}
}

View File

@@ -21,7 +21,7 @@ declare(strict_types = 1);
// }}}
namespace Component\Search\Util;
namespace Component\Collection\Util;
use App\Core\Event;
use App\Entity\Actor;
@@ -53,7 +53,7 @@ abstract class Parser
*
* @return Criteria[]
*/
public static function parse(string $input, ?string $language = null, ?Actor $actor = null, int $level = 0): array
public static function parse(string $input, ?string $locale = null, ?Actor $actor = null, int $level = 0): array
{
if ($level === 0) {
$input = trim(preg_replace(['/\s+/', '/\s+AND\s+/', '/\s+OR\s+/'], [' ', '&', '|'], $input), ' |&');
@@ -78,17 +78,17 @@ abstract class Parser
$term = mb_substr($input, $left, $end ? null : $right - $left);
$note_res = null;
$actor_res = null;
Event::handle('SearchCreateExpression', [$eb, $term, $language, $actor, &$note_res, &$actor_res]);
if (\is_null($note_res) && \is_null($actor_res)) {
Event::handle('CollectionQueryCreateExpression', [$eb, $term, $locale, $actor, &$note_res, &$actor_res]);
if (\is_null($note_res) && \is_null($actor_res)) { // @phpstan-ignore-line
throw new ServerException("No one claimed responsibility for a match term: {$term}");
}
if (!\is_null($note_res) && !empty($note_res)) {
if (!empty($note_res)) { // @phpstan-ignore-line
if (\is_array($note_res)) {
$note_res = $eb->orX(...$note_res);
}
$note_parts[] = $note_res;
}
if (!\is_null($actor_res) && !empty($note_res)) {
if (!empty($actor_res)) {
if (\is_array($actor_res)) {
$actor_res = $eb->orX(...$actor_res);
}
@@ -103,21 +103,22 @@ abstract class Parser
$last_op = $delimiter;
}
$match = true;
continue 2;
break;
}
}
if (!$match) {
// TODO
if (!$match) { // @phpstan-ignore-line
++$right;
}
}
$note_criteria = null;
$actor_criteria = null;
if (!empty($note_parts)) {
if (!empty($note_parts)) { // @phpstan-ignore-line
self::connectParts($note_parts, $note_criteria_arr, $last_op, $eb, force: true);
$note_criteria = new Criteria($eb->orX(...$note_criteria_arr));
}
if (!empty($actor_parts)) {
if (!empty($actor_parts)) { // @phpstan-ignore-line
self::connectParts($actor_parts, $actor_criteria_arr, $last_op, $eb, force: true);
$actor_criteria = new Criteria($eb->orX(...$actor_criteria_arr));
}

View File

@@ -0,0 +1,60 @@
{% extends 'stdgrid.html.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block body %}
<div class="frame-section frame-section-padding">
<h1 class="frame-section-title">{{ title }}</h1>
<details class="frame-section section-details-title">
<summary class="details-summary-title">
<strong>
{% trans %}Ordering rules{% endtrans %}
</strong>
</summary>
<form method="GET" class="section-form">
<div class="container-grid">
<section class="frame-section frame-section-padding">
<strong>{% trans %}Sort by{% endtrans %}</strong>
<hr>
<div class="container-block">
{% for field in sort_form_fields %}
<span class="container-block">
<label for="order_by_{{ field.value }}">{{ field.label }}</label>
<input id="order_by_{{ field.value }}" type="radio" name="order_by" value="{{ field.value }}" {% if field.checked %}checked="checked"{% endif %}>
</span>
{% endfor %}
</div>
</section>
<section class="frame-section frame-section-padding">
<strong class="section-title">{% trans %}Order{% endtrans %}</strong>
<hr>
<section class="container-block">
<span class="container-block">
<label for="order_op_asc">{% trans %}Ascending{% endtrans %}</label>
<input id="order_op_asc" type="radio" name="order_op" value="ASC">
</span>
<span class="container-block">
<label for="order_op_desc">{% trans %}Descending{% endtrans %}</label>
<input id="order_op_desc" type="radio" name="order_op" value="DESC" checked="checked">
</span>
</section>
</section>
</div>
<button type="submit">{% trans %}Order{% endtrans %}</button>
</form>
</details>
<section class="frame-section-padding">
{% if actors is defined and actors is not empty %}
{% for actor in actors %}
{% block profile_view %}{% include 'cards/profile/view.html.twig' %}{% endblock profile_view %}
<hr>
{% endfor %}
<p>{% trans %}Page: %page%{% endtrans %}</p>
{% else %}
<h2>{{ empty_message }}</h2>
{% endif %}
</section>
</div>
{% endblock body %}

View File

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

View File

@@ -0,0 +1,34 @@
{% extends 'stdgrid.html.twig' %}
{% block title %}{{ page_title | trans }}{% endblock %}
{% block body %}
<div class="frame-section frame-section-padding">
<h2 class="frame-section-title">{{ page_title | trans }}</h2>
{% if add_collection %}
<div class="frame-section section-form">
{{ form(add_collection) }}
</div>
{% endif %}
<div class="frame-section collections-list">
<h3>{{ list_title | trans }}</h3>
{% for col in collections %}
<div class="collection-item">
<a class="name" href="{{ fn.getUrl(col.id) }}">{{ col.name }}</a>
<details title="Expand if you want to edit the collection's name">
<summary>
<span class="collection-action">{{ icon('edit') | raw }}</span>
</summary>
{{ form(fn.editForm(col)) }}
</details>
<details title="Expand if you want to delete the collection">
<summary>
<span class="collection-action">{{ icon('delete') | raw }}</span>
</summary>
{{ form(fn.rmForm(col)) }}
</details>
</div>
{% endfor %}
</div>
</div>
{% endblock body %}

View File

@@ -0,0 +1,55 @@
{% extends 'stdgrid.html.twig' %}
{% import '/cards/note/view.html.twig' as noteView %}
{% block title %}{% if page_title is defined %}{{ page_title | trans }}{% endif %}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
{% endblock stylesheets %}
{% block body %}
{% for block in handle_event('BeforeFeed', app.request) %}
{{ block | raw }}
{% endfor %}
{% if notes is defined %}
<header class="feed-header">
{% if page_title is defined %}
<h1 class="heading-no-margin">{{ page_title | trans }}</h1>
{% else %}
<h3 class="heading-no-margin">{{ 'Notes' | trans }}</h3>
{% endif %}
<nav class="feed-actions">
<details class="feed-actions-details">
<summary>
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
</summary>
<div class="feed-actions-details-dropdown">
<menu>
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
{{ block | raw }}
{% endfor %}
</menu>
</div>
</details>
</nav>
</header>
{% if notes is not empty %}
{# Backwards compatibility with hAtom 0.1 #}
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
{% for conversation in notes %}
{% block current_note %}
{% if conversation is instanceof('array') %}
{{ noteView.macro_note(conversation['note'], conversation['replies']) }}
{% else %}
{{ noteView.macro_note(conversation) }}
{% endif %}
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
{% endblock current_note %}
{% endfor %}
</section>
{% endif %}
{% endif %}
{% endblock body %}

View File

@@ -0,0 +1,27 @@
<section class="frame-section collections">
<details class="section-details-title" title="Expand if you want to access more options.">
<summary class="details-summary-title">
<h2>{{ctitle}}</h2>
</summary>
{% if has_collections %}
<section class="section-form">
{{ form(add_form) }}
</section>
<details class="frame-section-padding section-details-subtitle"
title="Expand if you want to access more options.">
<summary class="details-summary-subtitle">
<strong>{% trans %}Other options{% endtrans %}</strong>
</summary>
<section class="section-form">
{{ form(create_form) }}
</section>
</details>
{% else %}
<section class="section-form">
{{ form(create_form) }}
</section>
{% endif %}
</details>
</section>

View File

@@ -20,42 +20,123 @@ declare(strict_types = 1);
/**
* @author Hugo Sales <hugo@hsal.es>
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @author Eliseu Amaro <mail@eliseuama.ro>
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Component\Conversation\Controller;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use Component\Feed\Feed;
use Component\Feed\Util\FeedController;
use App\Core\Log;
use App\Core\Router\Router;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\NoSuchNoteException;
use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException;
use Component\Collection\Util\Controller\FeedController;
use Component\Conversation\Entity\ConversationMute;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
class Conversation extends FeedController
{
/**
* Render conversation page
* Render conversation page.
*
* @return array
* @param int $conversation_id To identify what Conversation is to be rendered
*
* @throws \App\Util\Exception\ServerException
*
* @return array Array containing keys: 'notes' (all known notes in the given Conversation), 'should_format' (boolean, stating if onFormatNoteList events may or not format given notes), 'page_title' (used as the title header)
*/
public function showConversation(Request $request, int $conversation_id)
public function showConversation(Request $request, int $conversation_id): array
{
// TODO:
// if note is root -> just link
// if note is a reply -> link from above plus anchor
/* $notes = DB::findBy(
table: 'note',
criteria: ['conversation_id' => $conversation_id],
order_by: ['created' => 'DESC', 'id' => 'DESC']
);*/
$data = Feed::query(query: "note-conversation:{$conversation_id}", page: $this->int('p') ?? 1);
$notes = $data['notes'];
return [
'_template' => 'feed/feed.html.twig',
'notes' => $notes,
'_template' => 'collection/notes.html.twig',
'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [],
'should_format' => false,
'page_title' => _m('Conversation'),
];
}
/**
* Controller for the note reply non-JS page
*
* Leverages the `PostingModifyData` event to add the `reply_to_id` field from the GET variable 'reply_to_id'
*
* @throws ClientException
* @throws NoLoggedInUser
* @throws NoSuchNoteException
* @throws ServerException
*
* @return array
*/
public function addReply(Request $request)
{
$user = Common::ensureLoggedIn();
$note_id = $this->int('reply_to_id', new ClientException(_m('Malformed query.')));
$note = Note::ensureCanInteract(Note::getByPK($note_id), $user);
$conversation_id = $note->getConversationId();
return $this->showConversation($request, $conversation_id);
}
/**
* Creates form view for Muting Conversation extra action.
*
* @param int $conversation_id The Conversation id that this action targets
*
* @throws \App\Util\Exception\NoLoggedInUser
* @throws \App\Util\Exception\RedirectException
* @throws \App\Util\Exception\ServerException
*
* @return array Array containing templating where the form is to be rendered, and the form itself
*/
public function muteConversation(Request $request, int $conversation_id)
{
$user = Common::ensureLoggedIn();
$is_muted = ConversationMute::isMuted($conversation_id, $user);
$form = Form::create([
['mute_conversation', SubmitType::class, ['label' => $is_muted ? _m('Unmute') : _m('Mute'), 'attr' => ['class' => '']]],
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (!$is_muted) {
DB::persist(ConversationMute::create(['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]));
} else {
DB::removeBy('conversation_mute', ['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]);
}
DB::flush();
Cache::delete(ConversationMute::cacheKeys($conversation_id, $user->getId())['mute']);
// Redirect user to where they came from
// Prevent open redirect
if (!\is_null($from = $this->string('from'))) {
if (Router::isAbsolute($from)) {
Log::warning("Actor {$user->getId()} attempted to mute conversation {$conversation_id} and then get redirected to another host, or the URL was invalid ({$from})");
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
} else {
// TODO anchor on element id
throw new RedirectException(url: $from);
}
} else {
// If we don't have a URL to return to, go to the instance root
throw new RedirectException('root');
}
}
return [
'_template' => 'conversation/mute.html.twig',
'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [],
'is_muted' => $is_muted,
'form' => $form->createView(),
];
}
}

View File

@@ -1,64 +0,0 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
/**
* @author Hugo Sales <hugo@hsal.es>
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Component\Conversation\Controller;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\NoSuchNoteException;
use App\Util\Exception\ServerException;
use Component\Feed\Util\FeedController;
use Symfony\Component\HttpFoundation\Request;
class Reply extends FeedController
{
/**
* Controller for the note reply non-JS page
*
* @throws ClientException
* @throws NoLoggedInUser
* @throws NoSuchNoteException
* @throws ServerException
*
* @return array
*/
public function addReply(Request $request, int $note_id, int $actor_id)
{
$user = Common::ensureLoggedIn();
$note = Note::getByPK($note_id);
if (\is_null($note) || !$note->isVisibleTo($user)) {
throw new NoSuchNoteException();
}
return [
'_template' => 'reply/add_reply.html.twig',
'note' => $note,
];
}
}

View File

@@ -23,24 +23,35 @@ declare(strict_types = 1);
namespace Component\Conversation;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Formatting;
use Component\Conversation\Controller\Reply as ReplyController;
use Component\Conversation\Entity\Conversation as ConversationEntity;
use Component\Conversation\Entity\ConversationMute;
use Functional as F;
use Symfony\Component\HttpFoundation\Request;
class Conversation extends Component
{
public function onAddRoute(RouteLoader $r): bool
{
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
$r->connect('conversation_mute', '/conversation/{conversation_id<\d+>}/mute', [Controller\Conversation::class, 'muteConversation']);
$r->connect('conversation_reply_to', '/conversation/reply', [Controller\Conversation::class, 'addReply']);
return Event::next;
}
/**
* **Assigns** the given local Note it's corresponding **Conversation**
* **Assigns** the given local Note it's corresponding **Conversation**.
*
* **If a _$parent_id_ is not given**, then the Actor is not attempting a reply,
* therefore, we can assume (for now) that we need to create a new Conversation and assign it
@@ -48,6 +59,9 @@ class Conversation extends Component
*
* **On the other hand**, given a _$parent_id_, the Actor is attempting to post a reply. Meaning that,
* this Note conversation_id should be same as the parent Note
*
* @param \App\Entity\Note $current_note Local Note currently being assigned a Conversation
* @param null|int $parent_id If present, it's a reply
*/
public static function assignLocalConversation(Note $current_note, ?int $parent_id): void
{
@@ -72,12 +86,19 @@ class Conversation extends Component
}
DB::merge($current_note);
DB::flush();
}
/**
* HTML rendering event that adds a reply link as a note
* action, if a user is logged in
* action, if a user is logged in.
*
* @param \App\Entity\Note $note The Note being rendered
* @param array $actions Contains keys 'url' (linking 'conversation_reply_to'
* route), 'title' (used as title for aforementioned url),
* 'classes' (CSS styling classes used to visually inform the user of action context),
* 'id' (HTML markup id used to redirect user to this anchor upon performing the action)
*
* @throws \App\Util\Exception\ServerException
*/
public function onAddNoteActions(Request $request, Note $note, array &$actions): bool
{
@@ -85,15 +106,18 @@ class Conversation extends Component
return Event::next;
}
// Generating URL for reply action route
$args = ['note_id' => $note->getId(), 'actor_id' => $note->getActor()->getId()];
$type = Router::ABSOLUTE_PATH;
$reply_action_url = Router::url('reply_add', $args, $type);
$from = $request->query->has('from')
? $request->query->get('from')
: $request->getPathInfo();
$query_string = $request->getQueryString();
// Concatenating get parameter to redirect the user to where he came from
$reply_action_url .= !\is_null($query_string) ? '?from=' : '&from=';
$reply_action_url .= mb_substr($query_string, 2);
$reply_action_url = Router::url(
'conversation_reply_to',
[
'reply_to_id' => $note->getId(),
'from' => $from . '#note-anchor-' . $note->getId(),
],
Router::ABSOLUTE_PATH,
);
$reply_action = [
'url' => $reply_action_url,
@@ -103,79 +127,130 @@ class Conversation extends Component
];
$actions[] = $reply_action;
return Event::next;
}
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): bool
{
// If Actor is adding a reply, get parent's Note id
// Else it's null
$extra_args['reply_to'] = $request->get('_route') === 'reply_add' ? (int) $request->get('note_id') : null;
return Event::next;
}
/**
* Append on note information about user actions
* Posting event to add extra info to a note
*/
public function onPostingModifyData(Request $request, Actor $actor, array &$data): bool
{
$data['reply_to_id'] = $request->get('_route') === 'conversation_reply_to' && $request->query->has('reply_to_id')
? $request->query->getInt('reply_to_id')
: null;
if (!\is_null($data['reply_to_id'])) {
Note::ensureCanInteract(Note::getById($data['reply_to_id']), $actor);
}
return Event::next;
}
/**
* Append on note information about user actions.
*
* @param array $vars Contains information related to Note currently being rendered
* @param array $result Contains keys 'actors', and 'action'. Needed to construct a string, stating who ($result['actors']), has already performed a reply ($result['action']), in the given Note (vars['note'])
*/
public function onAppendCardNote(array $vars, array &$result): bool
{
// if note is the original, append on end "user replied to this"
// if note is the reply itself: append on end "in response to user in conversation"
$check_user = !\is_null(Common::user());
$note = $vars['note'];
// The current Note being rendered
$note = $vars['note'];
$complementary_info = '';
$reply_actor = [];
$note_replies = $note->getReplies();
// Will have actors array, and action string
// Actors are the subjects, action is the verb (in the final phrase)
$reply_actors = F\map(
$note->getReplies(),
fn (Note $reply) => Actor::getByPK($reply->getActorId()),
);
// Get actors who replied
foreach ($note_replies as $reply) {
$reply_actor[] = Actor::getByPK($reply->getActorId());
}
if (\count($reply_actor) < 1) {
if (empty($reply_actors)) {
return Event::next;
}
// Filter out multiple replies from the same actor
$reply_actor = array_unique($reply_actor, \SORT_REGULAR);
$reply_actors = array_unique($reply_actors, \SORT_REGULAR);
$result[] = ['actors' => $reply_actors, 'action' => 'replied to'];
// Add to complementary info
foreach ($reply_actor as $actor) {
$reply_actor_url = $actor->getUrl();
$reply_actor_nickname = $actor->getNickname();
return Event::next;
}
if ($check_user && $actor->getId() === (Common::actor())->getId()) {
// If the reply is yours
$you_translation = _m('You');
$prepend = "<a href={$reply_actor_url}>{$you_translation}</a>, " . ($prepend = &$complementary_info);
$complementary_info = $prepend;
} else {
// If the repeat is from someone else
$complementary_info .= "<a href={$reply_actor_url}>{$reply_actor_nickname}</a>, ";
}
/**
* Informs **\App\Component\Posting::onAppendRightPostingBlock**, of the **current page context** in which the given
* Actor is in. This is valuable when posting within a group route, allowing \App\Component\Posting to create a
* Note **targeting** that specific Group.
*
* @param \App\Entity\Actor $actor The Actor currently attempting to post a Note
* @param null|\App\Entity\Actor $context_actor The 'owner' of the current route (e.g. Group or Actor), used to target it
*/
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor)
{
$to_note_id = $request->query->get('reply_to_id');
if (!\is_null($to_note_id)) {
// Getting the actor itself
$context_actor = Actor::getById(Note::getById((int) $to_note_id)->getActorId());
return Event::stop;
}
return Event::next;
}
/**
* Event launched when deleting given Note, it's deletion implies further changes to object related to this Note.
* Please note, **replies are NOT deleted**, their reply_to is only set to null since this Note no longer exists.
*
* @param \App\Entity\Note $note Note being deleted
* @param \App\Entity\Actor $actor Actor that performed the delete action
*/
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
{
// Ensure we have the most up to date replies
Cache::delete(Note::cacheKeys($note->getId())['replies']);
DB::wrapInTransaction(fn () => F\each($note->getReplies(), fn (Note $note) => $note->setReplyTo(null)));
Cache::delete(Note::cacheKeys($note->getId())['replies']);
return Event::next;
}
/**
* Adds extra actions related to Conversation Component, that act upon/from the given Note.
*
* @param \App\Entity\Note $note Current Note being rendered
* @param array $actions Containing 'url' (Controller connected route), 'title' (used in anchor link containing the url), ?'classes' (CSS classes required for styling, if needed)
*
* @throws \App\Util\Exception\ServerException
*
* @return bool EventHook
*/
public function onAddExtraNoteActions(Request $request, Note $note, array &$actions)
{
if (\is_null($user = Common::user())) {
return Event::next;
}
$complementary_info = rtrim(trim($complementary_info), ',');
$complementary_info .= _m(' replied to this note.');
$result[] = Formatting::twigRenderString($complementary_info, []);
$from = $request->query->has('from')
? $request->query->get('from')
: $request->getPathInfo();
$mute_extra_action_url = Router::url(
'conversation_mute',
[
'conversation_id' => $note->getConversationId(),
'from' => $from . '#note-anchor-' . $note->getId(),
],
Router::ABSOLUTE_PATH,
);
$actions[] = [
'title' => ConversationMute::isMuted($note, $user) ? _m('Unmute conversation') : _m('Mute conversation'),
'classes' => '',
'url' => $mute_extra_action_url,
];
return Event::next;
}
public function onAddRoute(RouteLoader $r): bool
public function onNewNotificationShould(Activity $activity, Actor $actor)
{
$r->connect('reply_add', '/object/note/new?to={actor_id<\d+>}&reply_to={note_id<\d+>}', [ReplyController::class, 'addReply']);
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
return Event::next;
}
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor $context_actor)
{
$to_query = $request->get('actor_id');
if (!\is_null($to_query)) {
// Getting the actor itself
$context_actor = Actor::getById((int) $to_query);
if ($activity->getObjectType() === 'note' && ConversationMute::isMuted($activity, $actor)) {
return Event::stop;
}
return Event::next;

View File

@@ -23,8 +23,10 @@ declare(strict_types = 1);
namespace Component\Conversation\Entity;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Router\Router;
use App\Entity\Note;
/**
* Entity class for Conversations
@@ -46,7 +48,6 @@ class Conversation extends Entity
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $id;
private string $uri;
private int $initial_note_id;
public function setId(int $id): self

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace Component\Conversation\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Entity\Note;
use DateTimeInterface;
/**
* Entity class for Conversations Mutes
*
* @category DB
* @package GNUsocial
*
* @author Hugo Sales <hugo@hsal.es>
* @copyright 2022 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class ConversationMute extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $conversation_id;
private int $actor_id;
private DateTimeInterface $created;
public function setConversationId(int $conversation_id): self
{
$this->conversation_id = $conversation_id;
return $this;
}
public function getConversationId(): int
{
return $this->conversation_id;
}
public function setActorId(int $actor_id): self
{
$this->actor_id = $actor_id;
return $this;
}
public function getActorId(): int
{
return $this->actor_id;
}
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getCreated(): DateTimeInterface
{
return $this->created;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function cacheKeys(int $conversation_id, int $actor_id): array
{
return [
'mute' => "conversation-mute-{$conversation_id}-{$actor_id}",
];
}
/**
* Check if a conversation referenced by $object is muted form $actor
*/
public static function isMuted(Activity|Note|int $object, Actor|LocalUser $actor): bool
{
$conversation_id = null;
if (\is_int($object)) {
$conversation_id = $object;
} elseif ($object instanceof Note) {
$conversation_id = $object->getConversationId();
} elseif ($object instanceof Activity) {
$conversation_id = Note::getById($object->getObjectId())->getConversationId();
}
return Cache::get(
self::cacheKeys($conversation_id, $actor->getId())['mute'],
fn () => (bool) DB::count('conversation_mute', ['conversation_id' => $conversation_id, 'actor_id' => $actor->getId()]),
);
}
public static function schemaDef(): array
{
return [
'name' => 'conversation_mute',
'fields' => [
'conversation_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Conversation.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'The conversation being blocked'],
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Who blocked the conversation'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
],
'primary key' => ['conversation_id', 'actor_id'],
'foreign keys' => [
'conversation_id_to_id_fkey' => ['conversation', ['conversation_id' => 'id']],
'actor_id_to_id_fkey' => ['actor', ['actor_id' => 'id']],
],
];
}
}

View File

@@ -0,0 +1,21 @@
{% extends 'collection/notes.html.twig' %}
{% block body %}
<div class="frame-section frame-section-padding">
{% if is_muted %}
<span class="frame-section-padding alert">
<label>Do you wish to <b>unmute</b> this conversation?</label>
{{ form(form) }}
</span>
{% else %}
<span class="frame-section-padding alert">
<label>Do you wish to <b>mute</b> this conversation?</label>
{{ form(form) }}
</span>
{% endif %}
<hr>
{{ parent() }}
</div>
{% endblock body %}

View File

@@ -33,38 +33,25 @@ declare(strict_types = 1);
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace App\Controller;
namespace Component\Feed\Controller;
use function App\Core\I18n\_m;
use App\Core\VisibilityScope;
use App\Util\Common;
use Component\Feed\Feed;
use Component\Feed\Util\FeedController;
use Component\Collection\Util\Controller\FeedController;
use Symfony\Component\HttpFoundation\Request;
class Feeds extends FeedController
{
// Can't have constants inside herestring
private $public_scope = VisibilityScope::PUBLIC;
private $instance_scope = VisibilityScope::PUBLIC | VisibilityScope::SITE;
private $message_scope = VisibilityScope::MESSAGE;
private $subscriber_scope = VisibilityScope::PUBLIC | VisibilityScope::SUBSCRIBER;
/**
* The Planet feed represents every local post. Which is what this instance has to share with the universe.
*/
public function public(Request $request): array
{
$data = Feed::query(
query: 'note-local:true',
page: $this->int('p'),
language: Common::actor()?->getTopLanguage()?->getLocale(),
);
$data = $this->query('note-local:true');
return [
'_template' => 'feed/feed.html.twig',
'page_title' => _m(\is_null(Common::user()) ? 'Feed' : 'Planet'),
'should_format' => true,
'notes' => $data['notes'],
'_template' => 'collection/notes.html.twig',
'page_title' => _m(\is_null(Common::user()) ? 'Feed' : 'Planet'),
'notes' => $data['notes'],
];
}
@@ -74,17 +61,11 @@ class Feeds extends FeedController
public function home(Request $request): array
{
Common::ensureLoggedIn();
$data = Feed::query(
query: 'note-from:subscribed-person,subscribed-group,subscribed-organization,subscribed-business',
page: $this->int('p'),
language: Common::actor()?->getTopLanguage()?->getLocale(),
actor: Common::actor(),
);
$data = $this->query('note-from:subscribed-person,subscribed-group,subscribed-organisation');
return [
'_template' => 'feed/feed.html.twig',
'page_title' => _m('Home'),
'should_format' => true,
'notes' => $data['notes'],
'_template' => 'collection/notes.html.twig',
'page_title' => _m('Home'),
'notes' => $data['notes'],
];
}
}

View File

@@ -23,142 +23,17 @@ declare(strict_types = 1);
namespace Component\Feed;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Entity\Actor;
use App\Entity\Subscription;
use App\Util\Formatting;
use Component\Search\Util\Parser;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use App\Core\Router\RouteLoader;
use Component\Feed\Controller as C;
class Feed extends Component
{
/**
* Perform a high level query on notes or actors
*
* Supports a variety of query terms and is used both in feeds and
* in search. Uses query builders to allow for extension
*/
public static function query(string $query, int $page, ?string $language = null, ?Actor $actor = null): array
public function onAddRoute(RouteLoader $r): bool
{
$note_criteria = null;
$actor_criteria = null;
if (!empty($query = trim($query))) {
[$note_criteria, $actor_criteria] = Parser::parse($query, $language, $actor);
}
$note_qb = DB::createQueryBuilder();
$actor_qb = DB::createQueryBuilder();
$note_qb->select('note')->from('App\Entity\Note', 'note')->orderBy('note.created', 'DESC')->orderBy('note.id', 'DESC');
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor')->orderBy('actor.created', 'DESC')->orderBy('actor.id', 'DESC');
Event::handle('SearchQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
$notes = [];
$actors = [];
if (!\is_null($note_criteria)) {
$note_qb->addCriteria($note_criteria);
}
$notes = $note_qb->getQuery()->execute();
if (!\is_null($actor_criteria)) {
$actor_qb->addCriteria($actor_criteria);
}
$actors = $actor_qb->getQuery()->execute();
// TODO: Enforce scoping on the notes before returning
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
}
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb)
{
$note_qb->leftJoin(Subscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed')
->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
return Event::next;
}
/**
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text
* notes, for different types of actors and for the content of text notes
*/
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr)
{
if (str_contains($term, ':')) {
$term = explode(':', $term);
if (Formatting::startsWith($term[0], 'note-')) {
switch ($term[0]) {
case 'note-local':
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
break;
case 'note-types':
case 'notes-include':
case 'note-filter':
if (\is_null($note_expr)) {
$note_expr = [];
}
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
$note_expr[] = $eb->neq('note.content', null);
} else {
$note_expr[] = $eb->eq('note.content', null);
}
break;
case 'note-conversation':
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
break;
case 'note-from':
case 'notes-from':
$subscribed_expr = $eb->eq('subscription.subscriber', $actor->getId());
$type_consts = [];
if ($term[1] === 'subscribed') {
$type_consts = null;
}
foreach (explode(',', $term[1]) as $from) {
if (str_starts_with($from, 'subscribed-')) {
[, $type] = explode('-', $from);
if (\in_array($type, ['actor', 'actors'])) {
$type_consts = null;
} else {
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type));
}
}
}
if (\is_null($type_consts)) {
$note_expr = $subscribed_expr;
} elseif (!empty($type_consts)) {
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
}
break;
}
} elseif (Formatting::startsWith($term, 'actor-')) {
switch ($term[0]) {
case 'actor-types':
case 'actors-include':
case 'actor-filter':
case 'actor-local':
if (\is_null($actor_expr)) {
$actor_expr = [];
}
foreach (
[
Actor::PERSON => ['person', 'people'],
Actor::GROUP => ['group', 'groups'],
Actor::ORGANIZATION => ['org', 'orgs', 'organization', 'organizations', 'organisation', 'organisations'],
Actor::BUSINESS => ['business', 'businesses'],
Actor::BOT => ['bot', 'bots'],
] as $type => $match) {
if (array_intersect(explode(',', $term[1]), $match) !== []) {
$actor_expr[] = $eb->eq('actor.type', $type);
} else {
$actor_expr[] = $eb->neq('actor.type', $type);
}
}
break;
}
}
} else {
$note_expr = $eb->contains('note.content', $term);
}
$r->connect('feed_public', '/feed/public', [C\Feeds::class, 'public']);
$r->connect('feed_home', '/feed/home', [C\Feeds::class, 'home']);
return Event::next;
}
}

View File

@@ -1,42 +0,0 @@
{% extends 'stdgrid.html.twig' %}
{% import '/cards/note/view.html.twig' as noteView %}
{% block title %}{% if page_title is defined %}{{ page_title | trans }}{% endif %}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
{% endblock stylesheets %}
{% block body %}
<header class="feed-header">
{% if page_title is defined %}
<h1>{{ page_title | trans }} {{ 'feed' | trans }}</h1>
{% endif %}
<nav class="feed-actions" role=navigation>
{% for block in handle_event('AddFeedActions', app.request) %}
{{ block | raw }}
{% endfor %}
</nav>
</header>
{# Backwards compatibility with hAtom 0.1 #}
<main class="feed" tabindex="0" role="feed">
<div class="h-feed hfeed notes">
{% if notes is defined and notes is not empty %}
{% for conversation in notes %}
{% block current_note %}
{% if conversation is instanceof('array') %}
{{ noteView.macro_note(conversation['note'], conversation['replies']) }}
{% else %}
{{ noteView.macro_note(conversation) }}
{% endif %}
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
{% endblock current_note %}
{% endfor %}
{% else %}
<div id="empty-notes"><h1>{% trans %}No notes here.{% endtrans %}</h1></div>
{% endif %}
</div>
</main>
{% endblock body %}

View File

@@ -37,8 +37,7 @@ namespace Component\FreeNetwork\Controller;
use App\Core\DB\DB;
use function App\Core\I18n\_m;
use App\Util\Common;
use Component\Feed\Feed;
use Component\Feed\Util\FeedController;
use Component\Collection\Util\Controller\FeedController;
use Symfony\Component\HttpFoundation\Request;
class Feeds extends FeedController
@@ -52,13 +51,9 @@ class Feeds extends FeedController
public function network(Request $request): array
{
Common::ensureLoggedIn();
$data = Feed::query(
query: 'note-local:false',
page: $this->int('p'),
language: Common::actor()?->getTopLanguage()?->getLocale(),
);
$data = $this->query('note-local:false');
return [
'_template' => 'feed/feed.html.twig',
'_template' => 'collection/notes.html.twig',
'page_title' => _m('Meteorites'),
'should_format' => true,
'notes' => $data['notes'],
@@ -73,6 +68,7 @@ class Feeds extends FeedController
public function clique(Request $request): array
{
Common::ensureLoggedIn();
// TODO: maybe make this a Collection::query
$notes = DB::dql(
<<<'EOF'
SELECT n FROM \App\Entity\Note AS n
@@ -86,7 +82,7 @@ class Feeds extends FeedController
EOF,
);
return [
'_template' => 'feed/feed.html.twig',
'_template' => 'collection/notes.html.twig',
'page_title' => _m('Planetary System'),
'should_format' => true,
'notes' => $notes,
@@ -101,13 +97,9 @@ class Feeds extends FeedController
public function federated(Request $request): array
{
Common::ensureLoggedIn();
$data = Feed::query(
query: '',
page: $this->int('p'),
language: Common::actor()?->getTopLanguage()?->getLocale(),
);
$data = $this->query('notes-all:yeah');
return [
'_template' => 'feed/feed.html.twig',
'_template' => 'collection/notes.html.twig',
'page_title' => _m('Galaxy'),
'should_format' => true,
'notes' => $data['notes'],

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
/**
* @author James Walker <james@status.net>
* @author Craig Andrews <candrews@integralblue.com>
@@ -8,21 +10,17 @@
namespace Component\FreeNetwork\Controller;
use App\Core\Controller;
use App\Core\Event;
use Component\FreeNetwork\Util\Discovery;
use Component\FreeNetwork\Util\XrdController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use XML_XRD;
class HostMeta extends XrdController
{
protected string $default_mimetype = Discovery::XRD_MIMETYPE;
protected string $default_mimetype = Discovery::JRD_MIMETYPE;
public function setXRD()
{
if (Event::handle('StartHostMetaLinks', [&$this->xrd->links])) {
if (Event::handle('StartHostMetaLinks', [&$this->xrd->links]) !== Event::stop) {
Event::handle('EndHostMetaLinks', [&$this->xrd->links]);
}
}

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types = 1);
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
@@ -30,7 +32,6 @@ use App\Util\Common;
use Component\FreeNetwork\Util\Discovery;
use Component\FreeNetwork\Util\XrdController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class OwnerXrd extends XrdController
{

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types = 1);
/*
* StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2010, StatusNet, Inc.
@@ -69,7 +71,7 @@ class Webfinger extends XrdController
$this->xrd->subject = $this->resource;
foreach ($this->target->getAliases() as $alias) {
if ($alias != $this->xrd->subject && !in_array($alias, $this->xrd->aliases)) {
if ($alias != $this->xrd->subject && !\in_array($alias, $this->xrd->aliases)) {
$this->xrd->aliases[] = $alias;
}
}

View File

@@ -32,17 +32,11 @@ declare(strict_types = 1);
namespace Component\FreeNetwork\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Entity\Actor;
use Component\FreeNetwork\Util\Discovery;
use DateTimeInterface;
use Exception;
use Plugin\ActivityPub\Util\DiscoveryHints;
use Plugin\ActivityPub\Util\Explorer;
/**
* Table Definition for free_network_actor_protocol
@@ -53,27 +47,27 @@ use Plugin\ActivityPub\Util\Explorer;
class FreeNetworkActorProtocol extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart;
// @codeCoverageIgnoreStart
private int $actor_id;
private ?string $protocol;
private ?string $addr;
private ?string $protocol = null;
private string $addr;
private DateTimeInterface $created;
private DateTimeInterface $modified;
public function getActorId(): int
{
return $this->actor_id;
}
public function setActorId(int $actor_id): self
{
$this->actor_id = $actor_id;
return $this;
}
public function getActorId(): int
{
return $this->actor_id;
}
public function setProtocol(?string $protocol): self
{
$this->protocol = $protocol;
$this->protocol = \is_null($protocol) ? null : mb_substr($protocol, 0, 32);
return $this;
}
@@ -93,14 +87,20 @@ class FreeNetworkActorProtocol extends Entity
return $this->addr;
}
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getCreated(): DateTimeInterface
{
return $this->created;
}
public function setCreated(DateTimeInterface $created): self
public function setModified(DateTimeInterface $modified): self
{
$this->created = $created;
$this->modified = $modified;
return $this;
}
@@ -109,19 +109,14 @@ class FreeNetworkActorProtocol extends Entity
return $this->modified;
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
return $this;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function protocolSucceeded(string $protocol, int|Actor $actor_id, string $addr): void
{
$actor_id = is_int($actor_id) ? $actor_id : $actor_id->getId();
$actor_id = \is_int($actor_id) ? $actor_id : $actor_id->getId();
$attributed_protocol = self::getByPK(['actor_id' => $actor_id]);
if (is_null($attributed_protocol)) {
if (\is_null($attributed_protocol)) {
$attributed_protocol = self::create([
'actor_id' => $actor_id,
'protocol' => $protocol,
@@ -130,14 +125,14 @@ class FreeNetworkActorProtocol extends Entity
} else {
$attributed_protocol->setProtocol($protocol);
}
DB::wrapInTransaction(fn() => DB::persist($attributed_protocol));
DB::wrapInTransaction(fn () => DB::persist($attributed_protocol));
}
public static function canIActor(string $protocol, int|Actor $actor_id): bool
{
$actor_id = is_int($actor_id) ? $actor_id : $actor_id->getId();
$actor_id = \is_int($actor_id) ? $actor_id : $actor_id->getId();
$attributed_protocol = self::getByPK(['actor_id' => $actor_id])?->getProtocol();
if (is_null($attributed_protocol)) {
if (\is_null($attributed_protocol)) {
// If it is not attributed, you can go ahead.
return true;
} else {
@@ -149,9 +144,9 @@ class FreeNetworkActorProtocol extends Entity
public static function canIAddr(string $protocol, string $target): bool
{
// Normalize $addr, i.e. add 'acct:' if missing
$addr = Discovery::normalize($target);
$addr = Discovery::normalize($target);
$attributed_protocol = self::getByPK(['addr' => $addr])?->getProtocol();
if (is_null($attributed_protocol)) {
if (\is_null($attributed_protocol)) {
// If it is not attributed, you can go ahead.
return true;
} else {
@@ -167,7 +162,7 @@ class FreeNetworkActorProtocol extends Entity
'fields' => [
'actor_id' => ['type' => 'int', 'not null' => true],
'protocol' => ['type' => 'varchar', 'length' => 32, 'description' => 'the protocol plugin that should handle federation of this actor'],
'addr' => ['type' => 'text', 'not null' => true, 'description' => 'webfinger acct'],
'addr' => ['type' => 'text', 'not null' => true, 'description' => 'webfinger acct'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types = 1);
/**
* StatusNet, the distributed open-source microblogging tool
*
@@ -50,7 +52,7 @@ use Throwable;
*/
class WebfingerReconstructionException extends ServerException
{
public function __construct(string $message = '', int $code = 500, Throwable $previous = null)
public function __construct(string $message = '', int $code = 500, ?Throwable $previous = null)
{
// We could log an entry here with the search parameters
parent::__construct(_m('WebFinger URI generation failed.'));

View File

@@ -54,8 +54,6 @@ use Component\FreeNetwork\Util\WebfingerResource;
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceActor;
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceNote;
use Exception;
use Plugin\ActivityPub\Entity\ActivitypubActivity;
use Plugin\ActivityPub\Util\TypeResponse;
use const PREG_SET_ORDER;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
@@ -209,9 +207,8 @@ class FreeNetwork extends Component
return Event::stop; // We got our target, stop handler execution
}
$APNote = ActivitypubActivity::getByPK(['object_uri' => $resource]);
if ($APNote instanceof ActivitypubActivity) {
$target = new WebfingerResourceNote(Note::getByPK(['id' => $APNote->getObjectId()]));
if (!\is_null($note = DB::findOneBy(Note::class, ['url' => $resource], return_null: true))) {
$target = new WebfingerResourceNote($note);
return Event::stop; // We got our target, stop handler execution
}
@@ -270,7 +267,7 @@ class FreeNetwork extends Component
* @throws ClientException
* @throws ServerException
*/
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?TypeResponse &$response = null): bool
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?Response &$response = null): bool
{
if (!\in_array($route, ['freenetwork_hostmeta', 'freenetwork_hostmeta_format', 'freenetwork_webfinger', 'freenetwork_webfinger_format', 'freenetwork_ownerxrd'])) {
return Event::next;
@@ -300,6 +297,9 @@ class FreeNetwork extends Component
Discovery::XRD_MIMETYPE => new Response(content: $vars['xrd']->to('xml'), headers: $headers),
Discovery::JRD_MIMETYPE, Discovery::JRD_MIMETYPE_OLD => new JsonResponse(data: $vars['xrd']->to('json'), headers: $headers, json: true),
};
$response->headers->set('cache-control', 'no-store, no-cache, must-revalidate');
return Event::stop;
}
@@ -500,6 +500,11 @@ class FreeNetwork extends Component
return false;
}
public static function mentionToName(string $nickname, string $uri): string
{
return '@' . $nickname . '@' . parse_url($uri, \PHP_URL_HOST);
}
public function onPluginVersion(array &$versions): bool
{
$versions[] = [

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types = 1);
/**
* StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2010, StatusNet, Inc.
@@ -69,7 +71,7 @@ class LinkHeader
$this->type = null;
// remove uri-reference from header
$str = substr($str, strlen($uri_reference[0]));
$str = mb_substr($str, mb_strlen($uri_reference[0]));
// parse link-params
$params = explode(';', $str);
@@ -78,7 +80,7 @@ class LinkHeader
if (empty($param)) {
continue;
}
list($param_name, $param_value) = explode('=', $param, 2);
[$param_name, $param_value] = explode('=', $param, 2);
$param_name = trim($param_name);
$param_value = preg_replace('(^"|"$)', '', trim($param_value));
@@ -110,18 +112,17 @@ class LinkHeader
$headers = $response->getHeader('Link');
if ($headers) {
// Can get an array or string, so try to simplify the path
if (!is_array($headers)) {
if (!\is_array($headers)) {
$headers = [$headers];
}
foreach ($headers as $header) {
$lh = new self($header);
if ((is_null($rel) || $lh->rel == $rel) && (is_null($type) || $lh->type == $type)) {
if ((\is_null($rel) || $lh->rel == $rel) && (\is_null($type) || $lh->type == $type)) {
return $lh->href;
}
}
}
return null;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
namespace Component\FreeNetwork\Util\LrddMethod;
// This file is part of GNU social - https://www.gnu.org/software/social
@@ -39,29 +41,27 @@ class LrddMethodHostMeta extends LRDDMethod
/**
* For RFC6415 and HTTP URIs, fetch the host-meta file
* and look for LRDD templates
*
* @param mixed $uri
*/
public function discover($uri)
{
// This is allowed for RFC6415 but not the 'WebFinger' RFC7033.
$try_schemes = ['https', 'http'];
$scheme = mb_strtolower(parse_url($uri, PHP_URL_SCHEME));
$scheme = mb_strtolower(parse_url($uri, \PHP_URL_SCHEME));
switch ($scheme) {
case 'acct':
// We can't use parse_url data for this, since the 'host'
// entry is only set if the scheme has '://' after it.
$parts = explode('@', parse_url($uri, PHP_URL_PATH), 2);
$parts = explode('@', parse_url($uri, \PHP_URL_PATH), 2);
if (!Discovery::isAcct($uri) || count($parts) != 2) {
if (!Discovery::isAcct($uri) || \count($parts) != 2) {
throw new Exception('Bad resource URI: ' . $uri);
}
[, $domain] = $parts;
break;
case 'http':
case 'https':
$domain = mb_strtolower(parse_url($uri, PHP_URL_HOST));
$domain = mb_strtolower(parse_url($uri, \PHP_URL_HOST));
$try_schemes = [$scheme];
break;
default:

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
namespace Component\FreeNetwork\Util\LrddMethod;
// This file is part of GNU social - https://www.gnu.org/software/social
@@ -38,10 +40,7 @@ class LrddMethodLinkHtml extends LRDDMethod
* For HTTP IDs, fetch the URL and look for <link> elements
* in the HTML response.
*
* @param mixed $uri
*
* @todo fail out of WebFinger URIs faster
*
*/
public function discover($uri)
{
@@ -65,7 +64,7 @@ class LrddMethodLinkHtml extends LRDDMethod
preg_match('/<head(\s[^>]*)?>(.*?)<\/head>/is', $html, $head_matches);
if (count($head_matches) != 3) {
if (\count($head_matches) != 3) {
return [];
}
[, , $head_html] = $head_matches;
@@ -78,23 +77,23 @@ class LrddMethodLinkHtml extends LRDDMethod
$link_type = null;
preg_match('/\srel=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $rel_matches);
if (count($rel_matches) > 3) {
if (\count($rel_matches) > 3) {
$link_rel = $rel_matches[3];
} elseif (count($rel_matches) > 1) {
} elseif (\count($rel_matches) > 1) {
$link_rel = $rel_matches[1];
}
preg_match('/\shref=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $href_matches);
if (count($href_matches) > 3) {
if (\count($href_matches) > 3) {
$link_uri = $href_matches[3];
} elseif (count($href_matches) > 1) {
} elseif (\count($href_matches) > 1) {
$link_uri = $href_matches[1];
}
preg_match('/\stype=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $type_matches);
if (count($type_matches) > 3) {
if (\count($type_matches) > 3) {
$link_type = $type_matches[3];
} elseif (count($type_matches) > 1) {
} elseif (\count($type_matches) > 1) {
$link_type = $type_matches[1];
}

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types = 1);
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
@@ -36,19 +38,17 @@ class LrddMethodWebfinger extends LRDDMethod
/**
* Simply returns the WebFinger URL over HTTPS at the uri's domain:
* https://{domain}/.well-known/webfinger?resource={uri}
*
* @param mixed $uri
*/
public function discover($uri)
{
$parts = explode('@', parse_url($uri, PHP_URL_PATH), 2);
$parts = explode('@', parse_url($uri, \PHP_URL_PATH), 2);
if (!Discovery::isAcct($uri) || count($parts) != 2) {
if (!Discovery::isAcct($uri) || \count($parts) != 2) {
throw new Exception('Bad resource URI: ' . $uri);
}
[, $domain] = $parts;
if (!filter_var($domain, FILTER_VALIDATE_IP)
&& !filter_var(gethostbyname($domain), FILTER_VALIDATE_IP)) {
if (!filter_var($domain, \FILTER_VALIDATE_IP)
&& !filter_var(gethostbyname($domain), \FILTER_VALIDATE_IP)) {
throw new Exception('Bad resource host.');
}
@@ -56,7 +56,7 @@ class LrddMethodWebfinger extends LRDDMethod
Discovery::LRDD_REL,
'https://' . $domain . '/.well-known/webfinger?resource={uri}',
Discovery::JRD_MIMETYPE,
true // isTemplate
true, // isTemplate
);
return [$link];

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
namespace Component\FreeNetwork\Util;
use App\Core\Entity;
@@ -40,8 +42,6 @@ abstract class WebfingerResource
/**
* List of alternative IDs of a certain Actor
*
* @return array
*/
public function getAliases(): array
{
@@ -53,7 +53,7 @@ abstract class WebfingerResource
// you've run HTTPS all the time!
if (Common::config('fix', 'legacy_http')) {
foreach ($aliases as $alias => $id) {
if (!strtolower(parse_url($alias, PHP_URL_SCHEME)) === 'https') {
if (!mb_strtolower(parse_url($alias, \PHP_URL_SCHEME)) === 'https') {
continue;
}
$aliases[preg_replace('/^https:/i', 'http:', $alias, 1)] = $id;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
namespace Component\FreeNetwork\Util\WebfingerResource;
use App\Core\Event;
@@ -24,9 +26,9 @@ use XML_XRD_Element_Link;
*/
class WebfingerResourceActor extends WebFingerResource
{
const PROFILEPAGE = 'http://webfinger.net/rel/profile-page';
public const PROFILEPAGE = 'http://webfinger.net/rel/profile-page';
public function __construct(Actor $object = null)
public function __construct(?Actor $object = null)
{
// The type argument above verifies that it's our class
parent::__construct($object);
@@ -49,8 +51,9 @@ class WebfingerResourceActor extends WebFingerResource
/**
* Reconstruct WebFinger acct: from object
*
* @return array|false|mixed|string|string[]|null
* @throws WebfingerReconstructionException
*
* @return null|array|false|mixed|string|string[]
*/
public function reconstructAcct()
{
@@ -58,7 +61,7 @@ class WebfingerResourceActor extends WebFingerResource
if (Event::handle('StartWebFingerReconstruction', [$this->object, &$acct])) {
// TODO: getUri may not always give us the correct host on remote users?
$host = parse_url($this->object->getUri(Router::ABSOLUTE_URL), PHP_URL_HOST);
$host = parse_url($this->object->getUri(Router::ABSOLUTE_URL), \PHP_URL_HOST);
if (empty($this->object->getNickname()) || empty($host)) {
throw new WebFingerReconstructionException(print_r($this->object, true));
}
@@ -75,8 +78,11 @@ class WebfingerResourceActor extends WebFingerResource
if (Event::handle('StartWebFingerProfileLinks', [$xrd, $this->object])) {
// Profile page, can give more metadata from Link header or HTML parsing
$xrd->links[] = new XML_XRD_Element_Link(self::PROFILEPAGE,
$this->object->getUrl(Router::ABSOLUTE_URL), 'text/html');
$xrd->links[] = new XML_XRD_Element_Link(
self::PROFILEPAGE,
$this->object->getUrl(Router::ABSOLUTE_URL),
'text/html',
);
// // XFN
// $xrd->links[] = new XML_XRD_Element_Link('http://gmpg.org/xfn/11',

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
namespace Component\FreeNetwork\Util\WebfingerResource;
use App\Core\Event;
@@ -22,7 +24,7 @@ use XML_XRD_Element_Link;
*/
class WebfingerResourceNote extends WebfingerResource
{
public function __construct(Note $object = null)
public function __construct(?Note $object = null)
{
// The type argument above verifies that it's our class
parent::__construct($object);
@@ -30,29 +32,37 @@ class WebfingerResourceNote extends WebfingerResource
/**
* Update given XRD with self's data
*
* @param XML_XRD $xrd
*/
public function updateXRD(XML_XRD $xrd)
{
if (Event::handle('StartWebFingerNoticeLinks', [$xrd, $this->object])) {
if ($this->object->isLocal()) {
$xrd->links[] = new XML_XRD_Element_Link('alternate',
common_local_url('ApiStatusesShow',
$xrd->links[] = new XML_XRD_Element_Link(
'alternate',
common_local_url(
'ApiStatusesShow',
['id' => $this->object->id,
'format' => 'atom', ]),
'application/atom+xml');
'format' => 'atom', ],
),
'application/atom+xml',
);
$xrd->links[] = new XML_XRD_Element_Link('alternate',
common_local_url('ApiStatusesShow',
$xrd->links[] = new XML_XRD_Element_Link(
'alternate',
common_local_url(
'ApiStatusesShow',
['id' => $this->object->id,
'format' => 'json', ]),
'application/json');
'format' => 'json', ],
),
'application/json',
);
} else {
try {
$xrd->links[] = new XML_XRD_Element_Link('alternate',
$xrd->links[] = new XML_XRD_Element_Link(
'alternate',
$this->object->getUrl(),
'text/html');
'text/html',
);
} catch (InvalidUrlException $e) {
// don't do a fallback in webfinger
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
namespace Component\FreeNetwork\Util;
use App\Core\Controller;

View File

@@ -24,87 +24,75 @@ declare(strict_types = 1);
namespace Component\Group\Controller;
use App\Core\Cache;
use App\Core\Controller\ActorController;
use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Entity\Actor;
use App\Core\UserRoles;
use App\Entity as E;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NicknameEmptyException;
use App\Util\Exception\NicknameInvalidException;
use App\Util\Exception\NicknameNotAllowedException;
use App\Util\Exception\NicknameTakenException;
use App\Util\Exception\NicknameTooLongException;
use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException;
use App\Util\Form\ActorForms;
use App\Util\Nickname;
use Component\Collection\Util\Controller\FeedController;
use Component\Group\Entity\GroupMember;
use Component\Group\Entity\LocalGroup;
use Component\Subscription\Entity\ActorSubscription;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
class Group extends ActorController
class Group extends FeedController
{
public function groupViewId(Request $request, int $id)
{
return $this->handleActorById(
$id,
fn ($actor) => [
'_template' => 'group/view.html.twig',
'actor' => $actor,
],
);
}
/**
* View a group feed and give the option of creating it if it doesn't exist
* View a group feed by its nickname
*
* @param string $nickname The group's nickname to be shown
*
* @throws NicknameEmptyException
* @throws NicknameNotAllowedException
* @throws NicknameTakenException
* @throws NicknameTooLongException
* @throws ServerException
*
* @return array
*/
public function groupViewNickname(Request $request, string $nickname)
{
Nickname::validate($nickname, which: Nickname::CHECK_LOCAL_GROUP); // throws
$group = Actor::getByNickname($nickname, type: Actor::GROUP);
if (\is_null($group)) {
$actor = Common::actor();
if (!\is_null($actor)) {
$form = Form::create([
['create', SubmitType::class, ['label' => _m('Create this group')]],
]);
$group = LocalGroup::getActorByNickname($nickname);
$actor = Common::actor();
$subscribe_form = null;
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
Log::info(
_m(
'Actor id:{actor_id} nick:{actor_nick} created the group {nickname}',
['{actor_id}' => $actor->getId(), 'actor_nick' => $actor->getNickname(), 'nickname' => $nickname],
),
);
$group = Actor::create([
'nickname' => $nickname,
'type' => Actor::GROUP,
'is_local' => true,
]);
DB::persist($group);
DB::persist(E\Subscription::create([
'subscriber' => $group->getId(),
'subscribed' => $group->getId(),
]));
DB::persist(E\Subscription::create([
'subscriber' => $actor->getId(),
'subscribed' => $group->getId(),
]));
DB::persist(E\GroupMember::create([
'group_id' => $group->getId(),
'actor_id' => $actor->getId(),
'is_admin' => true,
]));
DB::flush();
Cache::delete(Actor::cacheKeys($actor->getId())['subscriber']);
Cache::delete(Actor::cacheKeys($actor->getId())['subscribed']);
throw new RedirectException;
}
return [
'_template' => 'group/view.html.twig',
'nickname' => $nickname,
'create_form' => $form->createView(),
];
if (!\is_null($group)
&& !\is_null($actor)
&& \is_null(Cache::get(
ActorSubscription::cacheKeys($actor, $group)['subscribed'],
fn () => DB::findOneBy('actor_subscription', [
'subscriber_id' => $actor->getId(),
'subscribed_id' => $group->getId(),
], return_null: true),
))
) {
$subscribe_form = Form::create([['subscribe', SubmitType::class, ['label' => _m('Subscribe to this group')]]]);
$subscribe_form->handleRequest($request);
if ($subscribe_form->isSubmitted() && $subscribe_form->isValid()) {
DB::persist(ActorSubscription::create([
'subscriber_id' => $actor->getId(),
'subscribed_id' => $group->getId(),
]));
DB::flush();
Cache::delete(E\Actor::cacheKeys($group->getId())['subscribers']);
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
Cache::delete(ActorSubscription::cacheKeys($actor, $group)['subscribed']);
}
}
@@ -114,27 +102,107 @@ class Group extends ActorController
join activity a with n.id = a.object_id
join group_inbox gi with a.id = gi.activity_id
where a.object_type = 'note' and gi.group_id = :group_id
order by a.created desc, a.id desc
EOF,
['group_id' => $group->getId()],
) : [];
return [
'_template' => 'group/view.html.twig',
'actor' => $group,
'nickname' => $group?->getNickname() ?? $nickname,
'notes' => $notes,
'_template' => 'group/view.html.twig',
'actor' => $group,
'nickname' => $group?->getNickname() ?? $nickname,
'notes' => $notes,
'subscribe_form' => $subscribe_form?->createView(),
];
}
/**
* Page that allows an actor to create a new group
*
* @throws RedirectException
* @throws ServerException
*
* @return array
*/
public function groupCreate(Request $request)
{
if (\is_null($actor = Common::actor())) {
throw new RedirectException('security_login');
}
$create_form = Form::create([
['group_nickname', TextType::class, ['label' => _m('Group nickname')]],
['group_create', SubmitType::class, ['label' => _m('Create this group!')]],
]);
$create_form->handleRequest($request);
if ($create_form->isSubmitted() && $create_form->isValid()) {
$data = $create_form->getData();
$nickname = $data['group_nickname'];
Log::info(
_m(
'Actor id:{actor_id} nick:{actor_nick} created the group {nickname}',
['{actor_id}' => $actor->getId(), 'actor_nick' => $actor->getNickname(), 'nickname' => $nickname],
),
);
DB::persist($group = E\Actor::create([
'nickname' => $nickname,
'type' => E\Actor::GROUP,
'is_local' => true,
'roles' => UserRoles::BOT,
]));
DB::persist(LocalGroup::create([
'group_id' => $group->getId(),
'nickname' => $nickname,
]));
DB::persist(ActorSubscription::create([
'subscriber_id' => $group->getId(),
'subscribed_id' => $group->getId(),
]));
DB::persist(GroupMember::create([
'group_id' => $group->getId(),
'actor_id' => $actor->getId(),
'is_admin' => true,
]));
DB::flush();
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribers']);
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
throw new RedirectException();
}
return [
'_template' => 'group/create.html.twig',
'create_form' => $create_form->createView(),
];
}
/**
* Settings page for the group with the provided nickname, checks if the current actor can administrate given group
*
* @throws ClientException
* @throws NicknameEmptyException
* @throws NicknameInvalidException
* @throws NicknameNotAllowedException
* @throws NicknameTakenException
* @throws NicknameTooLongException
* @throws NoLoggedInUser
* @throws ServerException
*
* @return array
*/
public function groupSettings(Request $request, string $nickname)
{
$group = Actor::getByNickname($nickname, type: Actor::GROUP);
$actor = Common::actor();
if (!\is_null($group) && $actor->canAdmin($group)) {
$local_group = LocalGroup::getByNickname($nickname);
$group_actor = $local_group->getActor();
$actor = Common::actor();
if (!\is_null($group_actor) && $actor->canAdmin($group_actor)) {
return [
'_template' => 'group/settings.html.twig',
'group' => $group,
'personal_info_form' => ActorForms::personalInfo($request, $group)->createView(),
'group' => $group_actor,
'personal_info_form' => ActorForms::personalInfo($request, $actor, $local_group)->createView(),
'open_details_query' => $this->string('open'),
];
} else {

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@@ -17,7 +19,7 @@
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Group\Entity;
use App\Core\Entity;
use DateTimeInterface;
@@ -42,11 +44,11 @@ class GroupAlias extends Entity
// @codeCoverageIgnoreStart
private string $alias;
private int $group_id;
private \DateTimeInterface $modified;
private DateTimeInterface $modified;
public function setAlias(string $alias): self
{
$this->alias = $alias;
$this->alias = mb_substr($alias, 0, 64);
return $this;
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@@ -17,7 +19,7 @@
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Group\Entity;
use App\Core\Entity;
use DateTimeInterface;
@@ -43,7 +45,7 @@ class GroupBlock extends Entity
private int $group_id;
private int $blocked_actor;
private int $blocker_user;
private \DateTimeInterface $modified;
private DateTimeInterface $modified;
public function setGroupId(int $group_id): self
{

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@@ -17,7 +19,7 @@
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Group\Entity;
use App\Core\Entity;
use DateTimeInterface;
@@ -42,7 +44,7 @@ class GroupInbox extends Entity
// @codeCoverageIgnoreStart
private int $group_id;
private int $activity_id;
private \DateTimeInterface $created;
private DateTimeInterface $created;
public function setGroupId(int $group_id): self
{

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@@ -17,7 +19,7 @@
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Group\Entity;
use App\Core\Entity;

View File

@@ -19,7 +19,7 @@ declare(strict_types = 1);
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Group\Entity;
use App\Core\Entity;
use DateTimeInterface;
@@ -44,8 +44,8 @@ class GroupMember extends Entity
// @codeCoverageIgnoreStart
private int $group_id;
private int $actor_id;
private ?bool $is_admin;
private ?string $uri;
private ?bool $is_admin = false;
private ?string $uri = null;
private DateTimeInterface $created;
private DateTimeInterface $modified;
@@ -84,7 +84,7 @@ class GroupMember extends Entity
public function setUri(?string $uri): self
{
$this->uri = $uri;
$this->uri = \is_null($uri) ? null : mb_substr($uri, 0, 191);
return $this;
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/soci
//
@@ -17,11 +19,20 @@
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Group\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Entity\Actor;
use App\Util\Exception\NicknameEmptyException;
use App\Util\Exception\NicknameException;
use App\Util\Exception\NicknameInvalidException;
use App\Util\Exception\NicknameNotAllowedException;
use App\Util\Exception\NicknameTakenException;
use App\Util\Exception\NicknameTooLongException;
use App\Util\Nickname;
use DateTimeInterface;
/**
@@ -43,9 +54,9 @@ class LocalGroup extends Entity
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $group_id;
private ?string $nickname;
private \DateTimeInterface $created;
private \DateTimeInterface $modified;
private ?string $nickname = null;
private DateTimeInterface $created;
private DateTimeInterface $modified;
public function setGroupId(int $group_id): self
{
@@ -60,7 +71,7 @@ class LocalGroup extends Entity
public function setNickname(?string $nickname): self
{
$this->nickname = $nickname;
$this->nickname = \is_null($nickname) ? null : mb_substr($nickname, 0, 64);
return $this;
}
@@ -99,6 +110,41 @@ class LocalGroup extends Entity
return DB::find('actor', ['id' => $this->group_id]);
}
public static function getByNickname(string $nickname): ?self
{
$res = DB::findBy(self::class, ['nickname' => $nickname]);
return $res === [] ? null : $res[0];
}
public static function getActorByNickname(string $nickname): ?Actor
{
$res = DB::findBy(Actor::class, ['nickname' => $nickname, 'type' => Actor::GROUP]);
return $res === [] ? null : $res[0];
}
/**
* Checks if desired nickname is allowed, and in case it is, it sets Actor's nickname cache to newly set nickname
*
* @param string $nickname Desired NEW nickname (do not use in local user creation)
*
* @throws NicknameEmptyException
* @throws NicknameException
* @throws NicknameInvalidException
* @throws NicknameNotAllowedException
* @throws NicknameTakenException
* @throws NicknameTooLongException
*
* @return $this
*/
public function setNicknameSanitizedAndCached(string $nickname): self
{
$nickname = Nickname::normalize($nickname, check_already_used: true, which: Nickname::CHECK_LOCAL_GROUP, check_is_allowed: true);
$this->setNickname($nickname);
$this->getActor()->setNickname($nickname);
/// XXX: cache?
return $this;
}
public static function schemaDef(): array
{
return [

View File

@@ -30,15 +30,16 @@ use App\Entity\Actor;
use App\Util\Common;
use App\Util\HTML;
use App\Util\Nickname;
use Component\Circle\Controller\SelfTagsSettings;
use Component\Group\Controller as C;
use Component\Tag\Controller\Tag as TagController;
use Component\Group\Entity\LocalGroup;
use Symfony\Component\HttpFoundation\Request;
class Group extends Component
{
public function onAddRoute(RouteLoader $r): bool
{
$r->connect(id: 'group_actor_view_id', uri_path: '/group/{id<\d+>}', target: [C\Group::class, 'groupViewId']);
$r->connect(id: 'group_create', uri_path: '/group/new', target: [C\Group::class, 'groupCreate']);
$r->connect(id: 'group_actor_view_nickname', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\Group::class, 'groupViewNickname'], options: ['is_system_path' => false]);
$r->connect(id: 'group_settings', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}/settings', target: [C\Group::class, 'groupSettings'], options: ['is_system_path' => false]);
return Event::next;
@@ -53,7 +54,7 @@ class Group extends Component
$group = $vars['actor'];
if (!\is_null($actor) && $group->isGroup() && $actor->canAdmin($group)) {
$url = Router::url('group_settings', ['nickname' => $group->getNickname()]);
$res[] = HTML::html(['hr' => '', 'a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings')], 'p' => _m('Group settings')]]);
$res[] = HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings'), 'class' => 'profile-extra-actions'], _m('Group settings')]]);
}
return Event::next;
}
@@ -62,12 +63,12 @@ class Group extends Component
{
if ($section === 'profile' && $request->get('_route') === 'group_settings') {
$nickname = $request->get('nickname');
$group = Actor::getByNickname($nickname, type: Actor::GROUP);
$group = LocalGroup::getActorByNickname($nickname);
$tabs[] = [
'title' => 'Self tags',
'desc' => 'Add or remove tags on this group',
'id' => 'settings-self-tags',
'controller' => TagController::settingsSelfTags($request, $group, 'settings-self-tags-details'),
'controller' => SelfTagsSettings::settingsSelfTags($request, $group, 'settings-self-tags-details'),
];
}
return Event::next;
@@ -82,7 +83,7 @@ class Group extends Component
if (!\is_null($id = $request->get('id'))) {
return Actor::getById((int) $id);
} elseif (!\is_null($nickname = $request->get('nickname'))) {
return Actor::getByNickname($nickname, type: Actor::GROUP);
return LocalGroup::getActorByNickname($nickname);
}
}
return null;
@@ -93,7 +94,7 @@ class Group extends Component
$group = $this->getGroupFromContext($request);
if (!\is_null($group)) {
$nick = '!' . $group->getNickname();
$targets[$nick] = $nick;
$targets[$nick] = $group->getId();
}
return Event::next;
}

View File

@@ -0,0 +1,5 @@
{% extends 'stdgrid.html.twig' %}
{% block body %}
{{ form(create_form) }}
{% endblock body %}

View File

@@ -16,32 +16,50 @@
{% include 'cards/profile/view.html.twig' with { 'actor': actor } only %}
{% endblock profile_view %}
<main class="feed" tabindex="0" role="feed">
<div class="h-feed hfeed notes">
{% if notes is defined and notes is not empty %}
{% for conversation in notes %}
{% block current_note %}
{% if conversation is instanceof('array') %}
{{ noteView.macro_note(conversation['note'], conversation['replies']) }}
{% else %}
{{ noteView.macro_note(conversation) }}
{% endif %}
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
{% endblock current_note %}
{% endfor %}
{% else %}
<div id="empty-notes"><h1>{% trans %}No notes here.{% endtrans %}</h1></div>
{% endif %}
</div>
</main>
{% if notes is defined %}
<article>
<header class="feed-header">
{% if page_title is defined %}
<h1 class="heading-no-margin">{{ page_title | trans }}</h1>
{% else %}
<h1 class="heading-no-margin">{{ 'Notes' | trans }}</h1>
{% endif %}
<nav class="feed-actions">
<details class="feed-actions-details">
<summary>
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
</summary>
<div class="feed-actions-details-dropdown">
<menu>
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
{{ block | raw }}
{% endfor %}
</menu>
</div>
</details>
</nav>
</header>
{% else %}
<div class="section-padding section-widget">
<p>{% trans with { '%group%': nickname } %}The group <em>%group%</em> doesn't exist.{% endtrans %}</p>
{% if create_form is defined and create_form is not null %}
<p>{% trans %}Would you like to create it?{% endtrans %}</p>
{{ form(create_form) }}
{% endif %}
</div>
{% if notes is not empty %}
{# Backwards compatibility with hAtom 0.1 #}
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
{% for conversation in notes %}
{% block current_note %}
{% if conversation is instanceof('array') %}
{{ noteView.macro_note(conversation['note'], conversation['replies']) }}
{% else %}
{{ noteView.macro_note(conversation) }}
{% endif %}
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
{% endblock current_note %}
{% endfor %}
</section>
{% else %}
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
<strong>{% trans %}No notes yet...{% endtrans %}</strong>
</section>
{% endif %}
</article>
{% endif %}
{% endif %}
{% endblock body %}

View File

@@ -28,13 +28,13 @@ use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Entity\ActorLanguage;
use App\Entity\Language as LangEntity;
use App\Util\Common;
use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException;
use App\Util\Form\FormFields;
use Component\Language\Entity\ActorLanguage;
use Component\Language\Entity\Language as LangEntity;
use Functional as F;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@@ -139,7 +139,7 @@ class Language extends Controller
}
return [
'_template' => 'settings/sort_languages.html.twig',
'_template' => 'language/sort.html.twig',
'form' => $form->createView(),
];
}

View File

@@ -21,11 +21,13 @@ declare(strict_types = 1);
// }}}
namespace App\Entity;
namespace Component\Language\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Util\Common;
use Functional as F;
@@ -69,16 +71,17 @@ class ActorLanguage extends Entity
return $this->language_id;
}
public function getOrdering(): int
{
return $this->ordering;
}
public function setOrdering(int $ordering): self
{
$this->ordering = $ordering;
return $this;
}
public function getOrdering(): int
{
return $this->ordering;
}
// @codeCoverageIgnoreEnd
// }}} Autocode

View File

@@ -21,12 +21,14 @@ declare(strict_types = 1);
// }}}
namespace App\Entity;
namespace Component\Language\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use function App\Core\I18n\_m;
use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Common;
use DateTimeInterface;
use Functional as F;
@@ -46,9 +48,9 @@ class Language extends Entity
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $id;
private string $locale;
private string $long_display;
private string $short_display;
private ?string $locale = null;
private ?string $long_display = null;
private ?string $short_display = null;
private DateTimeInterface $created;
public function setId(int $id): self
@@ -62,35 +64,35 @@ class Language extends Entity
return $this->id;
}
public function setLocale(string $locale): self
public function setLocale(?string $locale): self
{
$this->locale = $locale;
$this->locale = \is_null($locale) ? null : mb_substr($locale, 0, 64);
return $this;
}
public function getLocale(): string
public function getLocale(): ?string
{
return $this->locale;
}
public function setLongDisplay(string $long_display): self
public function setLongDisplay(?string $long_display): self
{
$this->long_display = $long_display;
$this->long_display = \is_null($long_display) ? null : mb_substr($long_display, 0, 64);
return $this;
}
public function getLongDisplay(): string
public function getLongDisplay(): ?string
{
return $this->long_display;
}
public function setShortDisplay(string $short_display): self
public function setShortDisplay(?string $short_display): self
{
$this->short_display = $short_display;
$this->short_display = \is_null($short_display) ? null : mb_substr($short_display, 0, 12);
return $this;
}
public function getShortDisplay(): string
public function getShortDisplay(): ?string
{
return $this->short_display;
}
@@ -105,6 +107,7 @@ class Language extends Entity
{
return $this->created;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
@@ -159,12 +162,12 @@ class Language extends Entity
$preferred_language_choices = $actor->getPreferredLanguageChoices($context_actor);
ksort($language_choices);
if ($use_short_display ?? Common::config('posting', 'use_short_language_display')) {
$key = array_key_first($preferred_language_choices);
$locale = $preferred_language_choices[$key];
$key = array_key_first($preferred_language_choices);
$language = $preferred_language_choices[$key];
unset($preferred_language_choices[$key], $language_choices[$key]);
$short_display = self::getByLocale($locale)->getShortDisplay();
$preferred_language_choices[$short_display] = trim($locale);
$language_choices[$short_display] = trim($locale);
$short_display = $language->getShortDisplay();
$preferred_language_choices[$short_display] = ($locale = $language->getLocale());
$language_choices[$short_display] = $locale;
}
return [$language_choices, $preferred_language_choices];
}
@@ -175,10 +178,10 @@ class Language extends Entity
'name' => 'language',
'description' => 'all known languages',
'fields' => [
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'],
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'],
'locale' => ['type' => 'varchar', 'length' => 64, 'description' => 'The locale identifier for the language of a note. 2-leter-iso-language-code_4-leter-script-code_2-leter-iso-country-code, but kept longer in case we get a different format'],
'long_display' => ['type' => 'varchar', 'length' => 64, 'description' => 'The long display string for the language, in english (translated later)'],
'short_display' => ['type' => 'varchar', 'length' => 12, 'description' => 'The short display string for the language (used for the first option)'],
'short_display' => ['type' => 'varchar', 'length' => 12, 'description' => 'The short display string for the language (used for the first option)'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
],
'primary key' => ['id'],

View File

@@ -25,11 +25,11 @@ use App\Core\Event;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Entity\Actor;
use App\Entity\ActorLanguage;
use App\Entity\Note;
use App\Util\Formatting;
use App\Util\Functional as GSF;
use Component\Language\Controller as C;
use Component\Language\Entity\ActorLanguage;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
@@ -38,20 +38,20 @@ use Symfony\Component\HttpFoundation\Request;
class Language extends Component
{
public function onAddRoute(RouteLoader $r)
public function onAddRoute(RouteLoader $r): bool
{
$r->connect('settings_sort_languages', '/settings/sort_languages', [C\Language::class, 'sortLanguages']);
return Event::next;
}
public function onFilterNoteList(?Actor $actor, array &$notes, Request $request)
public function onFilterNoteList(?Actor $actor, array &$notes, Request $request): bool
{
if (\is_null($actor)) {
return Event::next;
}
$notes = F\select(
$notes,
fn (Note $n) => \in_array($n->getLanguageId(), ActorLanguage::getActorRelatedLanguagesIds($actor)),
fn (Note $n) => \is_null($n->getLanguageId()) || \in_array($n->getLanguageId(), ActorLanguage::getActorRelatedLanguagesIds($actor)),
);
return Event::next;
@@ -60,7 +60,7 @@ class Language extends Component
/**
* Populate $note_expr or $actor_expr with an expression to match a language
*/
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr)
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
{
$search_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
@@ -90,26 +90,26 @@ class Language extends Component
$note_expr = $temp_note_expr;
$actor_expr = $temp_actor_expr;
return Event::stop;
} elseif (Formatting::startsWith($term, GSF::cartesianProduct(['-', '_'], ['note', 'post'], ['lang', 'language'], [':']))) {
} elseif (Formatting::startsWith($term, GSF::cartesianProduct([['note', 'post'], ['lang', 'language'], [':']], separator: ['-', '_']))) {
$note_expr = $temp_note_expr;
return Event::stop;
} elseif (Formatting::startsWith($term, GSF::cartesianProduct(['-', '_'], ['note', 'post'], ['author', 'actor', 'people', 'person'], ['lang', 'language'], [':']))) {
} elseif (Formatting::startsWith($term, GSF::cartesianProduct([['note', 'post'], ['author', 'actor', 'people', 'person'], ['lang', 'language'], [':']], separator: ['-', '_']))) {
$note_expr = $temp_note_actor_expr;
return Event::stop;
} elseif (Formatting::startsWith($term, GSF::cartesianProduct(['-', '_'], ['actor', 'people', 'person'], ['lang', 'language'], [':']))) {
} elseif (Formatting::startsWith($term, GSF::cartesianProduct([['actor', 'people', 'person'], ['lang', 'language'], [':']], separator: ['-', '_']))) {
$actor_expr = $temp_actor_expr;
return Event::stop;
}
return Event::next;
}
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{
$note_qb->leftJoin('App\Entity\Language', 'note_language', Expr\Join::WITH, 'note.language_id = note_language.id')
->leftJoin('App\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'note.actor_id = actor_language.actor_id')
->leftJoin('App\Entity\Language', 'note_actor_language', Expr\Join::WITH, 'note_actor_language.id = actor_language.language_id');
$actor_qb->leftJoin('App\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'actor.id = actor_language.actor_id')
->leftJoin('App\Entity\Language', 'language', Expr\Join::WITH, 'actor_language.language_id = language.id');
$note_qb->leftJoin('Component\Language\Entity\Language', 'note_language', Expr\Join::WITH, 'note.language_id = note_language.id')
->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'note.actor_id = actor_language.actor_id')
->leftJoin('Component\Language\Entity\Language', 'note_actor_language', Expr\Join::WITH, 'note_actor_language.id = actor_language.language_id');
$actor_qb->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'actor.id = actor_language.actor_id')
->leftJoin('Component\Language\Entity\Language', 'language', Expr\Join::WITH, 'actor_language.language_id = language.id');
return Event::next;
}
}

View File

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

View File

@@ -33,7 +33,7 @@ use App\Entity\Feed;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use Component\Feed\Util\FeedController;
use Component\Collection\Util\Controller\FeedController;
use Functional as F;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;

View File

@@ -77,7 +77,7 @@ class LeftPanel extends Component
*/
public function onEndShowStyles(array &$styles, string $route): bool
{
$styles[] = 'components/Left/assets/css/view.css';
$styles[] = 'components/LeftPanel/assets/css/view.css';
return Event::next;
}
}

View File

@@ -5,35 +5,49 @@
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
{% endblock stylesheets %}
{% macro edit_feeds_form_row(child) %}
<div class="form-row">
{{ form_label(child) }}
{{ form_widget(child) }}
</div>
{% endmacro %}
{% block body %}
<div class="section-widget">
<div class="frame-section">
<form class="section-form" action="{{ path('edit_feeds') }}" method="post">
<fieldset>
<legend class="section-form-legend">{{ "Edit feed navigation links" | trans }}</legend>
<h1 class="frame-section-title">{{ "Edit feed navigation links" | trans }}</h1>
{# Since the form is not separated into individual groups, this happened #}
{{ form_start(edit_feeds) }}
{% for child in edit_feeds.children %}
{% if 'row_url' in child.vars.block_prefixes %}
<div class="section-widget section-padding">
{{ form_label(child) }}
{{ form_widget(child) }}
{% elseif 'row_title' in child.vars.block_prefixes %}
{{ form_label(child) }}
{{ form_widget(child) }}
{% elseif 'row_order' in child.vars.block_prefixes %}
{{ form_label(child) }}
{{ form_widget(child) }}
{% elseif 'row_remove' in child.vars.block_prefixes %}
{{ form_label(child) }}
{{ form_widget(child) }}
</div>
{% endif %}
{% endfor %}
{{ form_errors(edit_feeds) }}
<section class="container-grid">
{% for child in edit_feeds.children %}
{% if 'row_url' in child.vars.block_prefixes %}
<div class="frame-section frame-section-padding">
{{ _self.edit_feeds_form_row(child) }}
{% elseif 'row_title' in child.vars.block_prefixes %}
{{ _self.edit_feeds_form_row(child) }}
{% elseif 'row_order' in child.vars.block_prefixes %}
{{ _self.edit_feeds_form_row(child) }}
{% elseif 'row_remove' in child.vars.block_prefixes %}
{{ _self.edit_feeds_form_row(child) }}
</div>
{% endif %}
{% endfor %}
</section>
<div class="form-row">
{{ form_row(edit_feeds.update_exisiting) }}
{{ form_row(edit_feeds.reset) }}
</div>
<hr>
<section class="frame-section frame-section-padding">
<h2>{% trans %}Add a new feed{% endtrans %}</h2>
{{ form_rest(edit_feeds) }}
</section>
{{ form_end(edit_feeds) }}
</fieldset>
</form>
</div>

View File

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

View File

@@ -49,9 +49,9 @@ class Link extends Entity
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $id;
private ?string $url;
private ?string $url_hash;
private ?string $mimetype;
private ?string $url = null;
private ?string $url_hash = null;
private ?string $mimetype = null;
private DateTimeInterface $modified;
public function setId(int $id): self
@@ -65,20 +65,20 @@ class Link extends Entity
return $this->id;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(?string $url): self
{
$this->url = $url;
return $this;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrlHash(?string $url_hash): self
{
$this->url_hash = $url_hash;
$this->url_hash = \is_null($url_hash) ? null : mb_substr($url_hash, 0, 64);
return $this;
}
@@ -89,7 +89,7 @@ class Link extends Entity
public function setMimetype(?string $mimetype): self
{
$this->mimetype = $mimetype;
$this->mimetype = \is_null($mimetype) ? null : mb_substr($mimetype, 0, 50);
return $this;
}
@@ -98,18 +98,6 @@ class Link extends Entity
return $this->mimetype;
}
public function getMimetypeMajor(): ?string
{
$mime = $this->getMimetype();
return \is_null($mime) ? $mime : GSFile::mimetypeMajor($mime);
}
public function getMimetypeMinor(): ?string
{
$mime = $this->getMimetype();
return \is_null($mime) ? $mime : GSFile::mimetypeMinor($mime);
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
@@ -172,6 +160,18 @@ class Link extends Entity
}
}
public function getMimetypeMajor(): ?string
{
$mime = $this->getMimetype();
return \is_null($mime) ? $mime : GSFile::mimetypeMajor($mime);
}
public function getMimetypeMinor(): ?string
{
$mime = $this->getMimetype();
return \is_null($mime) ? $mime : GSFile::mimetypeMinor($mime);
}
public static function schemaDef(): array
{
return [

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@@ -40,7 +42,7 @@ class NoteToLink extends Entity
// @codeCoverageIgnoreStart
private int $link_id;
private int $note_id;
private \DateTimeInterface $modified;
private DateTimeInterface $modified;
public function setLinkId(int $link_id): self
{
@@ -74,6 +76,7 @@ class NoteToLink extends Entity
{
return $this->modified;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
@@ -93,10 +96,6 @@ class NoteToLink extends Entity
return parent::create($args, $obj);
}
/**
* @param int $note_id
* @return mixed
*/
public static function removeWhereNoteId(int $note_id): mixed
{
return DB::dql(
@@ -108,11 +107,6 @@ class NoteToLink extends Entity
);
}
/**
* @param int $link_id
* @param int $note_id
* @return mixed
*/
public static function removeWhere(int $link_id, int $note_id): mixed
{
return DB::dql(
@@ -125,10 +119,6 @@ class NoteToLink extends Entity
);
}
/**
* @param int $link_id
* @return mixed
*/
public static function removeWhereLinkId(int $link_id): mixed
{
return DB::dql(

View File

@@ -26,6 +26,7 @@ namespace Component\Link;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Common;
use App\Util\HTML;
@@ -41,7 +42,8 @@ class Link extends Component
{
if (Common::config('attachments', 'process_links')) {
$matched_urls = [];
preg_match_all($this->getURLRegex(), $content, $matched_urls);
// TODO: This solution to ignore mentions when content is in html is far from ideal
preg_match_all($this->getURLRegex(), preg_replace('#<a href="(.*?)" class="u-url mention">#', '', $content), $matched_urls);
$matched_urls = array_unique($matched_urls[1]);
foreach ($matched_urls as $match) {
try {
@@ -259,9 +261,9 @@ class Link extends Component
return HTML::html(['a' => ['attrs' => $attrs, $url]], options: ['indent' => false]);
}
public function onNoteDeleteRelated(Note &$note): bool
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
{
NoteToLink::removeWhereNoteId($note->getId());
DB::wrapInTransaction(fn () => NoteToLink::removeWhereNoteId($note->getId()));
return Event::next;
}
}

View File

@@ -57,7 +57,7 @@ class Feed extends Controller
)
EOF, ['id' => $user->getId()]);
return [
'_template' => 'feed/feed.html.twig',
'_template' => 'collection/notes.html.twig',
'page_title' => _m('Notifications'),
'should_format' => true,
'notes' => $notes,

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@@ -45,9 +47,9 @@ class Notification extends Entity
// @codeCoverageIgnoreStart
private int $activity_id;
private int $target_id;
private ?string $reason;
private \DateTimeInterface $created;
private \DateTimeInterface $modified;
private ?string $reason = null;
private DateTimeInterface $created;
private DateTimeInterface $modified;
public function setActivityId(int $activity_id): self
{
@@ -73,7 +75,7 @@ class Notification extends Entity
public function setReason(?string $reason): self
{
$this->reason = $reason;
$this->reason = \is_null($reason) ? null : mb_substr($reason, 0, 191);
return $this;
}
@@ -107,9 +109,6 @@ class Notification extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
/**
* @return Actor
*/
public function getTarget(): Actor
{
return Actor::getById($this->getTargetId());
@@ -122,18 +121,14 @@ class Notification extends Entity
*/
public static function getNotificationTargetIdsByActivity(int|Activity $activity_id): array
{
$notifications = DB::findBy('notification', ['activity_id' => is_int($activity_id) ? $activity_id : $activity_id->getId()]);
$targets = [];
$notifications = DB::findBy('notification', ['activity_id' => \is_int($activity_id) ? $activity_id : $activity_id->getId()]);
$targets = [];
foreach ($notifications as $notification) {
$targets[] = $notification->getTargetId();
}
return $targets;
}
/**
* @param int|Activity $activity_id
* @return array
*/
public function getNotificationTargetsByActivity(int|Activity $activity_id): array
{
return DB::findBy('actor', ['id' => $this->getNotificationTargetIdsByActivity($activity_id)]);
@@ -154,7 +149,7 @@ class Notification extends Entity
'primary key' => ['activity_id', 'target_id'],
'indexes' => [
'attention_activity_id_idx' => ['activity_id'],
'attention_target_id_idx' => ['target_id'],
'attention_target_id_idx' => ['target_id'],
],
];
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@@ -38,7 +40,7 @@ class UserNotificationPrefs extends Entity
// @codeCoverageIgnoreStart
private int $user_id;
private string $transport;
private ?int $target_actor_id;
private ?int $target_actor_id = null;
private bool $activity_by_subscribed = true;
private bool $mention = true;
private bool $reply = true;
@@ -47,9 +49,9 @@ class UserNotificationPrefs extends Entity
private bool $nudge = false;
private bool $dm = true;
private bool $post_on_status_change = false;
private ?bool $enable_posting;
private \DateTimeInterface $created;
private \DateTimeInterface $modified;
private ?bool $enable_posting = true;
private DateTimeInterface $created;
private DateTimeInterface $modified;
public function setUserId(int $user_id): self
{
@@ -64,7 +66,7 @@ class UserNotificationPrefs extends Entity
public function setTransport(string $transport): self
{
$this->transport = $transport;
$this->transport = mb_substr($transport, 0, 191);
return $this;
}

View File

@@ -32,6 +32,7 @@ use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use Component\FreeNetwork\FreeNetwork;
use Component\Group\Entity\GroupInbox;
use Component\Notification\Controller\Feed;
class Notification extends Component
@@ -46,7 +47,7 @@ class Notification extends Component
{
DB::persist(\App\Entity\Feed::create([
'actor_id' => $actor_id,
'url' => Router::url($route = 'feed_notifications', ['nickname' => $user->getNickname()]),
'url' => Router::url($route = 'feed_notifications'),
'route' => $route,
'title' => _m('Notifications'),
'ordering' => $ordering++,
@@ -60,7 +61,7 @@ class Notification extends Component
*/
public function onNewNotification(Actor $sender, Activity $activity, array $ids_already_known = [], ?string $reason = null): bool
{
$targets = $activity->getNotificationTargets($ids_already_known, sender_id: $sender->getId());
$targets = $activity->getNotificationTargets(ids_already_known: $ids_already_known, sender_id: $sender->getId());
$this->notify($sender, $activity, $targets, $reason);
return Event::next;
@@ -76,15 +77,32 @@ class Notification extends Component
if ($target->getIsLocal()) {
if ($target->isGroup()) {
// FIXME: Make sure we check (for both local and remote) users are in the groups they send to!
DB::persist(GroupInbox::create([
'group_id' => $target->getId(),
'activity_id' => $activity->getId(),
]));
} else {
if ($target->hasBlocked($activity->getActor())) {
Log::info("Not saving reply to actor {$target->getId()} from sender {$sender->getId()} because of a block.");
continue;
}
}
// TODO: use https://symfony.com/doc/current/notifier.html
if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) {
// TODO: use https://symfony.com/doc/current/notifier.html
// XXX: Unideal as in failures the rollback will leave behind a false notification,
// but most notifications (all) require flushing the objects first
// Should be okay as long as implementors bear this in mind
DB::wrapInTransaction(fn() => DB::persist(Entity\Notification::create([
'activity_id' => $activity->getId(),
'target_id' => $target->getId(),
'reason' => $reason,
])));
}
} else {
$remote_targets[] = $target;
// We have no authority nor responsibility of notifying remote actors of a remote actor's doing
if ($sender->getIsLocal()) {
$remote_targets[] = $target;
}
}
}

View File

@@ -24,30 +24,29 @@ declare(strict_types = 1);
namespace Component\Posting;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Event;
use App\Core\Form;
use App\Core\GSFile;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\Router;
use App\Core\Security;
use App\Core\VisibilityScope;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\GroupInbox;
use App\Entity\Language;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\BugFoundException;
use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException;
use App\Util\Form\FormFields;
use App\Util\Formatting;
use App\Util\HTML;
use Component\Attachment\Entity\ActorToAttachment;
use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentToNote;
use Component\Conversation\Conversation;
use Component\Language\Entity\Language;
use Functional as F;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
@@ -56,6 +55,7 @@ use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Validator\Constraints\Length;
class Posting extends Component
@@ -74,8 +74,7 @@ class Posting extends Component
return Event::next;
}
$actor = $user->getActor();
$actor_id = $user->getId();
$actor = $user->getActor();
$placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
@@ -96,14 +95,21 @@ class Posting extends Component
Event::handle('PostingGetContextActor', [$request, $actor, &$context_actor]);
$form_params = [];
if (!empty($in_targets)) {
if (!empty($in_targets)) { // @phpstan-ignore-line
// Add "none" option to the top of choices
$in_targets = array_merge([_m('Public') => 'public'], $in_targets);
$form_params[] = ['in', ChoiceType::class, ['label' => _m('In:'), 'multiple' => false, 'expanded' => false, 'choices' => $in_targets]];
}
$form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [_m('Public') => 'public', _m('Instance') => 'instance', _m('Private') => 'private']]];
// TODO: if in group page, add GROUP visibility to the choices.
$form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [
_m('Public') => VisibilityScope::EVERYWHERE->value,
_m('Local') => VisibilityScope::LOCAL->value,
_m('Addressee') => VisibilityScope::ADDRESSEE->value,
]]];
$form_params[] = ['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]];
$form_params[] = ['attachments', FileType::class, ['label' => _m('Attachments:'), 'multiple' => true, 'required' => false, 'invalid_message' => _m('Attachment not valid.')]];
$form_params[] = FormFields::language($actor, $context_actor, label: _m('Note language:'), help: _m('The selected language will be federated and added as a lang attribute, preferred language can be set up in settings'));
$form_params[] = FormFields::language($actor, $context_actor, label: _m('Note language'), help: _m('The selected language will be federated and added as a lang attribute, preferred language can be set up in settings'));
if (\count($available_content_types) > 1) {
$form_params[] = ['content_type', ChoiceType::class,
@@ -125,29 +131,53 @@ class Posting extends Component
try {
if ($form->isValid()) {
$data = $form->getData();
Event::handle('PostingModifyData', [$request, $actor, &$data, $form_params, $form]);
if (empty($data['content']) && empty($data['attachments'])) {
// TODO Display error: At least one of `content` and `attachments` must be provided
throw new ClientException(_m('You must enter content or provide at least one attachment to post a note'));
throw new ClientException(_m('You must enter content or provide at least one attachment to post a note.'));
}
if (\is_null(VisibilityScope::tryFrom($data['visibility']))) {
throw new ClientException(_m('You have selected an impossible visibility.'));
}
$content_type = $data['content_type'] ?? $available_content_types[array_key_first($available_content_types)];
$extra_args = [];
Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form_params, $form]);
if (\array_key_exists('in', $data) && $data['in'] !== 'public') {
$target = $data['in'];
}
self::storeLocalNote(
$user->getActor(),
$data['content'],
$content_type,
$data['language'],
$data['attachments'],
target: $data['in'] ?? null,
actor: $user->getActor(),
content: $data['content'],
content_type: $content_type,
locale: $data['language'],
scope: VisibilityScope::from($data['visibility']),
target: $target ?? null, // @phpstan-ignore-line
reply_to_id: $data['reply_to_id'],
attachments: $data['attachments'],
process_note_content_extra_args: $extra_args,
);
try {
if ($request->query->has('from')) {
$from = $request->query->get('from');
if (str_contains($from, '#')) {
[$from, $fragment] = explode('#', $from);
}
Router::match($from);
throw new RedirectException(url: $from . (isset($fragment) ? '#' . $fragment : ''));
}
} catch (ResourceNotFoundException $e) {
// continue
}
throw new RedirectException();
}
} catch (FormSizeFileException $sizeFileException) {
throw new FormSizeFileException();
} catch (FormSizeFileException $e) {
throw new ClientException(_m('Invalid file size given'), previous: $e);
}
}
@@ -165,26 +195,30 @@ class Posting extends Component
* @param array $processed_attachments Array of [Attachment, Attachment's name] to be associated to this $actor and Note
* @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
*
* @throws BugFoundException
* @throws ClientException
* @throws DuplicateFoundException
* @throws ServerException
*
* @return Entity|mixed
*/
public static function storeLocalNote(
Actor $actor,
?string $content,
string $content_type,
?string $language = null,
?string $locale = null,
?VisibilityScope $scope = null,
null|Actor|int $target = null,
?int $reply_to_id = null,
array $attachments = [],
array $processed_attachments = [],
?string $target = null,
array $process_note_content_extra_args = [],
) {
$rendered = null;
bool $notify = true,
?string $rendered = null,
string $source = 'web',
): Note {
$scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL
$mentions = [];
if (!empty($content)) {
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $language, &$mentions]);
if (\is_null($rendered) && !empty($content)) {
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]);
}
$note = Note::create([
@@ -192,8 +226,11 @@ class Posting extends Component
'content' => $content,
'content_type' => $content_type,
'rendered' => $rendered,
'language_id' => !\is_null($language) ? Language::getByLocale($language)->getId() : null,
'language_id' => !\is_null($locale) ? Language::getByLocale($locale)->getId() : null,
'is_local' => true,
'scope' => $scope,
'reply_to' => $reply_to_id,
'source' => $source,
]);
/** @var UploadedFile[] $attachments */
@@ -210,11 +247,6 @@ class Posting extends Component
DB::persist($note);
// Assign conversation to this note
// AddExtraArgsToNoteContent already added the info we need
$reply_to = $process_note_content_extra_args['reply_to'];
Conversation::assignLocalConversation($note, $reply_to);
// Need file and note ids for the next step
$note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL));
if (!empty($content)) {
@@ -227,48 +259,42 @@ class Posting extends Component
DB::persist(ActorToAttachment::create($args));
}
DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname]));
$a->livesIncrementAndGet();
}
}
Conversation::assignLocalConversation($note, $reply_to_id);
$activity = Activity::create([
'actor_id' => $actor->getId(),
'verb' => 'create',
'object_type' => 'note',
'object_id' => $note->getId(),
'source' => 'web',
'source' => $source,
]);
DB::persist($activity);
if (!\is_null($target)) {
switch ($target[0]) {
case '!':
$mentions[] = [
'mentioned' => [Actor::getByNickname(mb_substr($target, 1), type: Actor::GROUP)],
'type' => 'group',
'text' => mb_substr($target, 1),
];
break;
default:
throw new ClientException(_m('Unkown target type give in \'in\' field: ' . $target));
}
$target = \is_int($target) ? Actor::getById($target) : $target;
$mentions[] = [
'mentioned' => [$target],
'type' => match ($target->getType()) {
Actor::PERSON => 'mention',
Actor::GROUP => 'group',
default => throw new ClientException(_m('Unknown target type give in \'In\' field: {target}', ['{target}' => $target?->getNickname() ?? '<null>'])),
},
'text' => $target->getNickname(),
];
}
$mentioned = [];
foreach (F\unique(F\flat_map($mentions, fn (array $m) => $m['mentioned'] ?? []), fn (Actor $a) => $a->getId()) as $m) {
if (!\is_null($m)) {
$mentioned[] = $m->getId();
if ($m->isGroup()) {
DB::persist(GroupInbox::create([
'group_id' => $m->getId(),
'activity_id' => $activity->getId(),
]));
}
}
}
$mention_ids = F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId())));
// Flush before notification
DB::flush();
Event::handle('NewNotification', [$actor, $activity, ['object' => $mentioned], "{$actor->getNickname()} created note {$note->getUrl()}"]);
if ($notify) {
Event::handle('NewNotification', [$actor, $activity, ['object' => $mention_ids], _m('{nickname} created a note {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $activity->getObjectId()])]);
}
return $note;
}
@@ -282,7 +308,7 @@ class Posting extends Component
return Event::stop;
case 'text/html':
// TODO: It has to linkify and stuff as well
$rendered = Security::sanitize($content);
$rendered = HTML::sanitize($content);
return Event::stop;
default:
return Event::next;

View File

@@ -35,7 +35,7 @@ class RightPanel extends Component
*/
public function onEndShowStyles(array &$styles, string $route): bool
{
$styles[] = 'components/Right/assets/css/view.css';
$styles[] = 'components/RightPanel/assets/css/view.css';
return Event::next;
}
}

View File

@@ -1,57 +1,65 @@
{% block rightpanel %}
<label class="panel-right-icon" for="panel-right-toggle" aria-hidden="true"
tabindex="-1">{{ icon('chevron-left', 'icon icon-right') | raw }}</label>
<input type="checkbox" id="panel-right-toggle" aria-hidden="true" tabindex="-1">
<label class="panel-right-icon" for="toggle-panel-right" tabindex="-1">{{ icon('chevron-left', 'icon icon-right') | raw }}</label>
<a id="anchor-right-panel" class="anchor-hidden" tabindex="0" title="{{ 'Press tab followed by a space to access right panel' | trans }}"></a>
<input type="checkbox" id="toggle-panel-right" tabindex="0" title="{{ 'Open right panel' | trans }}">
<div class="header-panel section-panel-right">
<a id="anchor-right-panel" class="anchor-hidden" title="{{ 'Press tab to access selected region!' | trans }}"></a>
<aside class="panel-content accessibility-target">
<aside class="section-panel section-panel-right">
<section class="panel-content accessibility-target">
{% set prepend_right_panel = handle_event('PrependRightPanel', request) %}
{% for widget in prepend_right_panel %}
{{ widget | raw }}
{% endfor %}
{% set blocks = handle_event('AppendRightPostingBlock', request) %}
{% if blocks['post_form'] is defined %}
<section class="section-widget" title="{{ 'Create a new note.' | trans }}">
<details class="section-widget-title-details" open="open"
title="{{ 'Expand if you want to access more options.' | trans }}">
<summary class="section-title-summary">
<h2>
{% set current_path = app.request.get('_route') %}
{% set blocks = handle_event('AppendRightPostingBlock', request) %}
{% if blocks['post_form'] is defined %}
<section class="frame-section" title="{{ 'Create a new note.' | trans }}">
<details class="section-details-title" open="open"
title="{{ 'Expand if you want to access more options.' | trans }}">
<summary class="details-summary-title">
<h2>
{% set current_path = app.request.get('_route') %}
{% if current_path == 'conversation_reply_to' %}
{{ "Reply to note" | trans }}
{% else %}
{{ "Create a note" | trans }}
</h2>
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
</summary>
{% endif %}
</h2>
</summary>
<div class="section-form">
<fieldset>
{{ form_start(blocks['post_form']) }}
{% if blocks['post_form'].in is defined %}
{{ form_row(blocks['post_form'].in) }}
{% endif %}
{{ form_row(blocks['post_form'].visibility) }}
{{ form_row(blocks['post_form'].content) }}
{{ form_row(blocks['post_form'].attachments) }}
<section class="section-form">
{{ form_start(blocks['post_form']) }}
{{ form_errors(blocks['post_form']) }}
{% if blocks['post_form'].in is defined %}
{{ form_row(blocks['post_form'].in) }}
{% endif %}
{{ form_row(blocks['post_form'].visibility) }}
{{ form_row(blocks['post_form'].content_type) }}
{{ form_row(blocks['post_form'].content) }}
{{ form_row(blocks['post_form'].attachments) }}
<details class="section-widget-subtitle-details">
<summary class="section-subtitle-summary">
<strong>
{{ "Additional options" | trans }}
</strong>
{{ icon('arrow-down', 'icon icon-details-close') | raw }}
</summary>
{{ form_row(blocks['post_form'].language) }}
{{ form_row(blocks['post_form'].tag_use_canonical) }}
</details>
{{ form_rest(blocks['post_form']) }}
{{ form_end(blocks['post_form']) }}
</fieldset>
</div>
</details>
</section>
{% endif %}
<details class="section-details-subtitle">
<summary class="details-summary-subtitle">
<strong>
{{ "Additional options" | trans }}
</strong>
</summary>
<section class="section-form">
{{ form_row(blocks['post_form'].language) }}
{{ form_row(blocks['post_form'].tag_use_canonical) }}
</section>
</details>
{{ form_rest(blocks['post_form']) }}
{{ form_end(blocks['post_form']) }}
</section>
</details>
</section>
{% endif %}
{% set current_path = app.request.get('_route') %}
{% for block in handle_event('AppendRightPanelBlock', {'path': current_path, 'vars': right_panel_vars | default }, request) %}
{{ block | raw }}
{% endfor %}
</aside>
</div>
{% set extra_blocks = get_right_panel_blocks({'path': current_path, 'request': app.request, 'vars': (right_panel_vars | default)}) %}
{% for block in extra_blocks %}
{{ block | raw }}
{% endfor %}
</section>
</aside>
{% endblock rightpanel %}

View File

@@ -30,8 +30,7 @@ use App\Util\Exception\BugFoundException;
use App\Util\Exception\RedirectException;
use App\Util\Form\FormFields;
use App\Util\Formatting;
use Component\Feed\Feed;
use Component\Feed\Util\FeedController;
use Component\Collection\Util\Controller\FeedController;
use Component\Search as Comp;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@@ -49,7 +48,7 @@ class Search extends FeedController
$language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null;
$q = $this->string('q');
$data = Feed::query(query: $q, page: $this->int('p'), language: $language);
$data = $this->query(query: $q, locale: $language);
$notes = $data['notes'];
$actors = $data['actors'];
@@ -131,7 +130,7 @@ class Search extends FeedController
}
return [
'_template' => 'search/show.html.twig',
'_template' => 'search/view.html.twig',
'actor' => $actor,
'search_form' => Comp\Search::searchForm($request, query: $q, add_subscribe: !\is_null($actor)),
'search_builder_form' => $search_builder_form->createView(),

View File

@@ -29,6 +29,7 @@ use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Util\Common;
use App\Util\Exception\RedirectException;
use App\Util\Formatting;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormView;
@@ -83,7 +84,7 @@ class Search extends Component
'label' => _m('Search'),
'attr' => [
//'class' => 'button-container search-button-container',
'title' => _m('Query notes for specific tags.'),
'title' => _m('Perform search'),
],
],
];
@@ -119,9 +120,9 @@ class Search extends Component
*
* @throws RedirectException
*/
public function onAddExtraHeaderForms(Request $request, array &$elements)
public function onPrependRightPanel(Request $request, array &$elements)
{
$elements[] = self::searchForm($request);
$elements[] = Formatting::twigRenderFile('cards/search/view.html.twig', ['search' => self::searchForm($request)]);
return Event::next;
}

View File

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

View File

@@ -1,91 +0,0 @@
{% extends 'feed/feed.html.twig' %}
{% block body %}
{% if error is defined %}
<label class="alert alert-danger">
{{ error.getMessage() }}
</label>
{% endif %}
<div class="section-padding">
{{ form_start(search_form) }}
<div>
{{ form_row(search_form.search_query) }}
{{ form_row(search_form.submit_search) }}
</div>
{% if actor is not null %}
<details class="section-widget">
<summary>
<h3>
{% trans %}Save this search as a feed{% endtrans %}
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
</h3>
</summary>
{{ form_row(search_form.title) }}
{{ form_row(search_form.subscribe_to_search) }}
</details>
{% endif %}
{{ form_end(search_form)}}
<hr>
<details class="section-widget">
<summary>
<h3>
{% trans %}Build a search query{% endtrans %}
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
</h3>
</summary>
{{ form_start(search_builder_form) }}
{# actor options, display if first checked, with checkbox trick #}
<details class="section-widget">
<summary>
<h3>
{% trans %}People search options{% endtrans %}
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
</h3>
</summary>
{{ form_row(search_builder_form.include_actors) }}
{{ form_row(search_builder_form.include_actors_people) }}
{{ form_row(search_builder_form.include_actors_groups) }}
{{ form_row(search_builder_form.include_actors_lists) }}
{{ form_row(search_builder_form.include_actors_businesses) }}
{{ form_row(search_builder_form.include_actors_organizations) }}
{{ form_row(search_builder_form.include_actors_bots) }}
{{ form_row(search_builder_form.actor_langs) }}
{{ form_row(search_builder_form.actor_tags) }}
</details>
<details class="section-widget">
<summary>
<h3>
{% trans %}Note search options{% endtrans %}
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
</h3>
</summary>
{{ form_row(search_builder_form.include_notes) }}
{{ form_row(search_builder_form.include_notes_text) }}
{{ form_row(search_builder_form.include_notes_media) }}
{{ form_row(search_builder_form.include_notes_polls) }}
{{ form_row(search_builder_form.include_notes_bookmarks) }}
{{ form_row(search_builder_form.note_langs) }}
{{ form_row(search_builder_form.note_tags) }}
{{ form_row(search_builder_form.note_actor_langs) }}
{{ form_row(search_builder_form.note_actor_tags) }}
</details>
{{ form_end(search_builder_form) }}
</details>
<hr>
</div>
{# Backwards compatibility with hAtom 0.1 #}
{{ parent() }}
{% for actor in actors %}
{% include 'cards/profile/view.html.twig' with {'actor': actor} %}
{% endfor %}
{{ "Page: " ~ page }}
{% endblock body %}

View File

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

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace Component\Subscription\Controller;
use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use Component\Collection\Util\ActorControllerTrait;
use Component\Collection\Util\Controller\CircleController;
use Component\Subscription\Subscription as SubscriptionComponent;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
/**
* Collection of an actor's subscribers
*/
class Subscribers extends CircleController
{
use ActorControllerTrait;
public function subscribersByActorId(Request $request, int $id)
{
return $this->handleActorById(
$id,
fn ($actor) => [
'actor' => $actor,
],
);
}
public function subscribersByActorNickname(Request $request, string $nickname)
{
return $this->handleActorByNickname(
$nickname,
fn ($actor) => [
'_template' => 'collection/actors.html.twig',
'title' => _m('Subscribers'),
'empty_message' => _m('No subscribers.'),
'sort_form_fields' => [],
'page' => $this->int('page') ?? 1,
'actors' => $actor->getSubscribers(),
],
);
}
/**
* @throws \App\Util\Exception\DuplicateFoundException
* @throws \App\Util\Exception\NoLoggedInUser
* @throws \App\Util\Exception\NotFoundException
* @throws \App\Util\Exception\ServerException
* @throws ClientException
* @throws RedirectException
*/
public function subscribersAdd(Request $request, int $object_id): array
{
$subject = Common::ensureLoggedIn();
$object = Actor::getById($object_id);
$form = Form::create(
[
['subscriber_add', SubmitType::class, ['label' => _m('Subscribe!')]],
],
);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (!\is_null(SubscriptionComponent::subscribe($subject, $object))) {
DB::flush();
SubscriptionComponent::refreshSubscriptionCount($subject, $object);
}
// Redirect user to where they came from
// Prevent open redirect
if (!\is_null($from = $this->string('from'))) {
if (Router::isAbsolute($from)) {
Log::warning("Actor {$object_id} attempted to reply to a note and then get redirected to another host, or the URL was invalid ({$from})");
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
}
// TODO anchor on element id
throw new RedirectException(url: $from);
}
// If we don't have a URL to return to, go to the instance root
throw new RedirectException('root');
}
return [
'_template' => 'subscription/add_subscriber.html.twig',
'form' => $form->createView(),
'object' => $object,
];
}
/**
* @throws \App\Util\Exception\DuplicateFoundException
* @throws \App\Util\Exception\NoLoggedInUser
* @throws \App\Util\Exception\NotFoundException
* @throws \App\Util\Exception\ServerException
* @throws ClientException
* @throws RedirectException
*/
public function subscribersRemove(Request $request, int $object_id): array
{
$subject = Common::ensureLoggedIn();
$object = Actor::getById($object_id);
$form = Form::create(
[
['subscriber_remove', SubmitType::class, ['label' => _m('Unsubscribe')]],
],
);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (!\is_null(SubscriptionComponent::unsubscribe($subject, $object))) {
DB::flush();
SubscriptionComponent::refreshSubscriptionCount($subject, $object);
}
// Redirect user to where they came from
// Prevent open redirect
if (!\is_null($from = $this->string('from'))) {
if (Router::isAbsolute($from)) {
Log::warning("Actor {$object_id} attempted to subscribe an actor and then get redirected to another host, or the URL was invalid ({$from})");
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
}
// TODO anchor on element id
throw new RedirectException(url: $from);
}
// If we don't have a URL to return to, go to the instance root
throw new RedirectException('root');
}
return [
'_template' => 'subscription/remove_subscriber.html.twig',
'form' => $form->createView(),
'object' => $object,
];
}
}

View File

@@ -21,23 +21,25 @@ declare(strict_types = 1);
// }}}
namespace App\Controller;
namespace Component\Subscription\Controller;
use App\Core\Controller\ActorController;
use function App\Core\I18n\_m;
use Component\Collection\Util\ActorControllerTrait;
use Component\Collection\Util\Controller\CircleController;
use Symfony\Component\HttpFoundation\Request;
/**
* Collection of an actor's subscriptions
*/
class Subscriptions extends ActorController
class Subscriptions extends CircleController
{
use ActorControllerTrait;
public function subscriptionsByActorId(Request $request, int $id)
{
return $this->handleActorById(
$id,
fn ($actor) => [
'_template' => 'subscriptions/view.html.twig',
'actor' => $actor,
'actor' => $actor,
],
);
}
@@ -47,8 +49,12 @@ class Subscriptions extends ActorController
return $this->handleActorByNickname(
$nickname,
fn ($actor) => [
'_template' => 'subscriptions/view.html.twig',
'actor' => $actor,
'_template' => 'collection/actors.html.twig',
'title' => _m('Subscriptions'),
'empty_message' => _m('Haven\'t subscribed anyone.'),
'sort_form_fields' => [],
'page' => $this->int('page') ?? 1,
'actors' => $actor->getSubscribers(),
],
);
}

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