From f6c58ee4f964ad20dfca5b87da1e3a387faefd50 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Mon, 2 Mar 2026 21:12:47 +0000 Subject: [PATCH 1/7] add ability for user to delete a DM thread --- config/mbin_routes/message.yaml | 9 ++- config/mbin_routes/message_api.yaml | 9 ++- config/packages/league_oauth2_server.yaml | 1 + config/packages/nelmio_api_doc.yaml | 2 + config/packages/security.yaml | 2 +- docs/04-app_developers/README.md | 2 + .../Api/Message/MessageRemoveApi.php | 78 ++++++++++++++++++ .../Message/MessageThreadController.php | 13 ++- src/DTO/OAuth2ClientDto.php | 1 + src/Entity/MessageThread.php | 2 +- src/Entity/OAuth2UserConsent.php | 1 + src/Service/MessageManager.php | 13 +++ templates/messages/front.html.twig | 8 ++ .../Api/Message/MessageRemoveApiTest.php | 81 +++++++++++++++++++ 14 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 src/Controller/Api/Message/MessageRemoveApi.php create mode 100644 tests/Functional/Controller/Api/Message/MessageRemoveApiTest.php diff --git a/config/mbin_routes/message.yaml b/config/mbin_routes/message.yaml index cc6bb1e367..7045ae64c0 100644 --- a/config/mbin_routes/message.yaml +++ b/config/mbin_routes/message.yaml @@ -4,12 +4,19 @@ messages_front: methods: [ GET ] messages_single: - controller: App\Controller\Message\MessageThreadController + controller: App\Controller\Message\MessageThreadController::show path: /profile/messages/{id} methods: [ GET, POST ] requirements: id: \d+ +messages_remove_thread: + controller: App\Controller\Message\MessageThreadController::remove + path: /profile/messages/{id}/delete + methods: [ POST ] + requirements: + id: \d+ + messages_create: controller: App\Controller\Message\MessageCreateThreadController path: /u/{username}/message diff --git a/config/mbin_routes/message_api.yaml b/config/mbin_routes/message_api.yaml index 746686e2c4..9d6a295d88 100644 --- a/config/mbin_routes/message_api.yaml +++ b/config/mbin_routes/message_api.yaml @@ -41,9 +41,16 @@ api_message_retrieve_thread: methods: [ GET ] format: json +# Delete a thread with a user +api_message_remove_thread: + controller: App\Controller\Api\Message\MessageRemoveApi::removeThread + path: /api/messages/thread/{thread_id} + methods: [ DELETE ] + format: json + # Create a thread with a user api_message_create_thread: controller: App\Controller\Api\Message\MessageThreadCreateApi path: /api/users/{user_id}/message methods: [ POST ] - format: json \ No newline at end of file + format: json diff --git a/config/packages/league_oauth2_server.yaml b/config/packages/league_oauth2_server.yaml index f7388c121c..c456fb25fe 100644 --- a/config/packages/league_oauth2_server.yaml +++ b/config/packages/league_oauth2_server.yaml @@ -69,6 +69,7 @@ league_oauth2_server: "user:message", "user:message:read", "user:message:create", + "user:message:delete", "user:notification", "user:notification:read", "user:notification:delete", diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index f728838bc8..85744059b6 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -148,6 +148,7 @@ nelmio_api_doc: user:message: Read your messages and send messages to other users. user:message:read: Read your messages. user:message:create: Send messages to other users. + user:message:delete: Delete your messages. user:notification: Read and clear your notifications. user:notification:read: Read your notifications, including message notifications. user:notification:delete: Clear notifications. @@ -265,6 +266,7 @@ nelmio_api_doc: user:message: Read your messages and send messages to other users. user:message:read: Read your messages. user:message:create: Send messages to other users. + user:message:delete: Delete your messages. user:notification: Read and clear your notifications. user:notification:read: Read your notifications, including message notifications. user:notification:delete: Clear notifications. diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 37d5f8858e..234473b386 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -253,7 +253,7 @@ security: 'ROLE_OAUTH2_USER:PROFILE': ['ROLE_OAUTH2_USER:PROFILE:READ', 'ROLE_OAUTH2_USER:PROFILE:EDIT'] 'ROLE_OAUTH2_USER:MESSAGE': - ['ROLE_OAUTH2_USER:MESSAGE:READ', 'ROLE_OAUTH2_USER:MESSAGE:CREATE'] + ['ROLE_OAUTH2_USER:MESSAGE:READ', 'ROLE_OAUTH2_USER:MESSAGE:CREATE', 'ROLE_OAUTH2_USER:MESSAGE:DELETE'] 'ROLE_OAUTH2_USER:NOTIFICATION': [ 'ROLE_OAUTH2_USER:NOTIFICATION:READ', diff --git a/docs/04-app_developers/README.md b/docs/04-app_developers/README.md index 73547ae411..ab8f46f611 100644 --- a/docs/04-app_developers/README.md +++ b/docs/04-app_developers/README.md @@ -188,6 +188,8 @@ POST /api/client - Also allows the client to mark unread messages as read or read messages as unread - `user:message:create` - Allows the client to create new messages to other users or reply to existing messages + - `user:message:delete` + - Allows the client to delete message-threads of the current user - `user:notification` - `user:notification:read` - Allows the client to read notifications about threads, posts, or comments being replied to, as well as moderation notifications. diff --git a/src/Controller/Api/Message/MessageRemoveApi.php b/src/Controller/Api/Message/MessageRemoveApi.php new file mode 100644 index 0000000000..a5bad5755d --- /dev/null +++ b/src/Controller/Api/Message/MessageRemoveApi.php @@ -0,0 +1,78 @@ +rateLimit($apiReadLimiter); + + $manager->removeUserFromThread($thread, $this->getUserOrThrow()); + + return new Response(status: 204, headers: $headers); + } +} diff --git a/src/Controller/Message/MessageThreadController.php b/src/Controller/Message/MessageThreadController.php index 8c813f7ccf..96d6979e1f 100644 --- a/src/Controller/Message/MessageThreadController.php +++ b/src/Controller/Message/MessageThreadController.php @@ -21,7 +21,7 @@ public function __construct(private readonly MessageManager $manager) #[IsGranted('ROLE_USER')] #[IsGranted('show', subject: 'thread', statusCode: 403)] - public function __invoke(#[MapEntity(id: 'id')] MessageThread $thread, Request $request): Response + public function show(#[MapEntity(id: 'id')] MessageThread $thread, Request $request): Response { $form = $this->createForm(MessageType::class); $form->handleRequest($request); @@ -43,4 +43,15 @@ public function __invoke(#[MapEntity(id: 'id')] MessageThread $thread, Request $ ] ); } + + #[IsGranted('ROLE_USER')] + #[IsGranted('show', subject: 'thread', statusCode: 403)] + public function remove(#[MapEntity(id: 'id')] MessageThread $thread): Response + { + $this->manager->removeUserFromThread($thread, $this->getUserOrThrow()); + + return $this->redirectToRoute( + 'messages_front' + ); + } } diff --git a/src/DTO/OAuth2ClientDto.php b/src/DTO/OAuth2ClientDto.php index d4f80d8a9c..ec82efc3f4 100644 --- a/src/DTO/OAuth2ClientDto.php +++ b/src/DTO/OAuth2ClientDto.php @@ -72,6 +72,7 @@ class OAuth2ClientDto extends ImageUploadDto implements \JsonSerializable 'user:message', 'user:message:read', 'user:message:create', + 'user:message:delete', 'user:notification', 'user:notification:read', 'user:notification:delete', diff --git a/src/Entity/MessageThread.php b/src/Entity/MessageThread.php index 4dc20629ed..8fe3409b3b 100644 --- a/src/Entity/MessageThread.php +++ b/src/Entity/MessageThread.php @@ -34,7 +34,7 @@ class MessageThread new JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE'), ] )] - #[ManyToMany(targetEntity: User::class, cascade: ['persist'], orphanRemoval: true)] + #[ManyToMany(targetEntity: User::class, cascade: ['persist'], orphanRemoval: false)] public Collection $participants; #[OneToMany(mappedBy: 'thread', targetEntity: Message::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'ASC'])] diff --git a/src/Entity/OAuth2UserConsent.php b/src/Entity/OAuth2UserConsent.php index f1ebe4ad04..bcd0364ac3 100644 --- a/src/Entity/OAuth2UserConsent.php +++ b/src/Entity/OAuth2UserConsent.php @@ -86,6 +86,7 @@ class OAuth2UserConsent 'user:message' => 'oauth2.grant.user.message.all', 'user:message:read' => 'oauth2.grant.user.message.read', 'user:message:create' => 'oauth2.grant.user.message.create', + 'user:message:delete' => 'oauth2.grant.user.message.delete', 'user:notification' => 'oauth2.grant.user.notification.all', 'user:notification:read' => 'oauth2.grant.user.notification.read', 'user:notification:delete' => 'oauth2.grant.user.notification.delete', diff --git a/src/Service/MessageManager.php b/src/Service/MessageManager.php index 89560dc7c6..2c45b6a477 100644 --- a/src/Service/MessageManager.php +++ b/src/Service/MessageManager.php @@ -164,6 +164,19 @@ public function editMessage(Message $message, array $object): void } } + public function removeUserFromThread(MessageThread $thread, User $user): void + { + if (!$thread->userIsParticipant($user)) { + throw new \InvalidArgumentException('user is not a participant of this message thread'); + } + if ($thread->participants->count() > 1) { + $thread->participants->removeElement($user); + } else { + $this->entityManager->remove($thread); + } + $this->entityManager->flush(); + } + /** @return string[] */ public function findAudience(MessageThread $thread): array { diff --git a/templates/messages/front.html.twig b/templates/messages/front.html.twig index ff2c1ef0f9..4220b851a1 100644 --- a/templates/messages/front.html.twig +++ b/templates/messages/front.html.twig @@ -41,7 +41,15 @@ + {{ component('date', {date: thread.updatedAt}) }} + +
+ +
{% endif %} {% endfor %} diff --git a/tests/Functional/Controller/Api/Message/MessageRemoveApiTest.php b/tests/Functional/Controller/Api/Message/MessageRemoveApiTest.php new file mode 100644 index 0000000000..be662dcd35 --- /dev/null +++ b/tests/Functional/Controller/Api/Message/MessageRemoveApiTest.php @@ -0,0 +1,81 @@ +createMessage($this->getUserByUsername('JohnDoe'), $this->getUserByUsername('JaneDoe'), 'test message'); + $this->client->request('DELETE', "/api/messages/thread/{$message->thread->getId()}"); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotRemoveMessagesWithoutScope(): void + { + self::createOAuth2AuthCodeClient(); + $user = $this->getUserByUsername('JohnDoe'); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $from = $this->getUserByUsername('JaneDoe'); + $user = $this->entityManager->getRepository(User::class)->find($user->getId()); + $message = $this->createMessage($user, $from, 'test message'); + + $this->client->request('DELETE', "/api/messages/thread/{$message->thread->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + public function testApiCanRemoveThread(): void + { + self::createOAuth2AuthCodeClient(); + $user = $this->getUserByUsername('JohnDoe'); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'user:message:delete user:message:read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $from = $this->getUserByUsername('JaneDoe'); + $user = $this->entityManager->getRepository(User::class)->find($user->getId()); + $message = $this->createMessage($user, $from, 'test message'); + + $this->client->request('DELETE', "/api/messages/thread/{$message->thread->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(204); + + $this->client->request('GET', "/api/messages/thread/{$message->thread->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + public function testRemovedThreadIgnoresNewMessages(): void + { + self::createOAuth2AuthCodeClient(); + $user = $this->getUserByUsername('JohnDoe'); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'user:message:delete user:message:read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $from = $this->getUserByUsername('JaneDoe'); + $user = $this->entityManager->getRepository(User::class)->find($user->getId()); + $message = $this->createMessage($user, $from, 'test message'); + + $this->client->request('DELETE', "/api/messages/thread/{$message->thread->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(204); + + $messageDto = new MessageDto(); + $messageDto->body = 'test message'; + $message2 = $this->messageManager->toMessage($messageDto, $message->thread, $from); + self::assertSame($message->thread->getId(), $message2->thread->getId()); + + $this->client->request('GET', "/api/messages/thread/{$message->thread->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } +} From ab38d1141b8d99cbd31369e5b26b4dd303c4d294 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Tue, 3 Mar 2026 08:53:42 +0000 Subject: [PATCH 2/7] allow DMs to remote users via API --- src/Controller/Api/Message/MessageThreadCreateApi.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Controller/Api/Message/MessageThreadCreateApi.php b/src/Controller/Api/Message/MessageThreadCreateApi.php index f4ea432112..0029721680 100644 --- a/src/Controller/Api/Message/MessageThreadCreateApi.php +++ b/src/Controller/Api/Message/MessageThreadCreateApi.php @@ -90,10 +90,6 @@ public function __invoke( ): JsonResponse { $headers = $this->rateLimit($apiMessageLimiter); - if ($receiver->apId) { - throw new AccessDeniedHttpException(); - } - $dto = $this->deserializeMessage(); $errors = $validator->validate($dto); From 2f0b96edca6d429afc30b2d0841be9e756d36dc5 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Thu, 12 Mar 2026 18:34:32 +0000 Subject: [PATCH 3/7] reportable messages and bigger refactor --- config/mbin_routes/message_reports.yaml | 22 +++ config/services.yaml | 9 + migrations/Version20260311182316.php | 38 +++++ .../ActivityPub/ReportController.php | 2 +- .../Admin/AdminReportController.php | 1 + .../Moderate/MagazineReportsAcceptApi.php | 10 +- .../Moderate/MagazineReportsRejectApi.php | 2 +- .../Moderate/MagazineReportsRetrieveApi.php | 2 +- .../Api/Message/MessageReportApi.php | 83 ++++++++++ .../Api/Notification/NotificationBaseApi.php | 22 +-- .../Message/MessageReportController.php | 155 ++++++++++++++++++ .../Message/MessageThreadController.php | 9 +- src/DTO/ReportDto.php | 17 +- src/DTO/ReportResponseDto.php | 25 ++- src/Entity/Contracts/HashtagableInterface.php | 8 + src/Entity/Contracts/ReportInterface.php | 8 + src/Entity/Entry.php | 10 +- src/Entity/EntryComment.php | 10 +- .../EntryCommentCreatedNotification.php | 21 ++- .../EntryCommentDeletedNotification.php | 21 ++- src/Entity/EntryCommentEditedNotification.php | 21 ++- .../EntryCommentMentionedNotification.php | 21 ++- src/Entity/EntryCommentReplyNotification.php | 22 ++- src/Entity/EntryCreatedNotification.php | 20 ++- src/Entity/EntryDeletedNotification.php | 20 ++- src/Entity/EntryEditedNotification.php | 20 ++- src/Entity/EntryMentionedNotification.php | 21 ++- src/Entity/MagazineBanNotification.php | 12 +- src/Entity/MagazineUnBanNotification.php | 12 +- src/Entity/Message.php | 31 +++- src/Entity/MessageNotification.php | 13 +- src/Entity/MessageReport.php | 41 +++++ src/Entity/NewSignupNotification.php | 13 +- src/Entity/Notification.php | 5 +- src/Entity/Post.php | 10 +- src/Entity/PostComment.php | 10 +- src/Entity/PostCommentCreatedNotification.php | 20 ++- src/Entity/PostCommentDeletedNotification.php | 26 ++- src/Entity/PostCommentEditedNotification.php | 20 ++- .../PostCommentMentionedNotification.php | 20 ++- src/Entity/PostCommentReplyNotification.php | 21 ++- src/Entity/PostCreatedNotification.php | 20 ++- src/Entity/PostDeletedNotification.php | 20 ++- src/Entity/PostEditedNotification.php | 20 ++- src/Entity/PostMentionedNotification.php | 20 ++- src/Entity/Report.php | 7 +- src/Entity/ReportApprovedNotification.php | 31 ++-- src/Entity/ReportCreatedNotification.php | 22 ++- src/Entity/ReportRejectedNotification.php | 33 ++-- .../ContentNotificationPurgeListener.php | 33 ++-- .../SubjectReportedSubscriber.php | 26 ++- src/Factory/ActivityPub/ActivityFactory.php | 20 +-- .../ActivityPub/EntryCommentNoteFactory.php | 78 ++++----- src/Factory/ActivityPub/EntryPageFactory.php | 83 +++++----- src/Factory/ActivityPub/MessageFactory.php | 19 ++- .../ActivityPub/PostCommentNoteFactory.php | 78 ++++----- src/Factory/ActivityPub/PostNoteFactory.php | 71 ++++---- src/Factory/ContentManagerFactory.php | 41 ----- .../Contract/ActivityFactoryInterface.php | 19 +++ src/Factory/Contract/ActorUrlFactory.php | 30 ++++ src/Factory/Contract/ContentDtoFactory.php | 21 +++ src/Factory/Contract/ContentUrlFactory.php | 24 +++ src/Factory/Contract/ReportUrlFactory.php | 17 ++ src/Factory/Entry/EntryCommentDtoFactory.php | 34 ++++ src/Factory/Entry/EntryCommentUrlFactory.php | 48 ++++++ src/Factory/Entry/EntryDtoFactory.php | 31 ++++ src/Factory/Entry/EntryUrlFactory.php | 47 ++++++ src/Factory/InMagazineReportUrlFactory.php | 32 ++++ src/Factory/Magazine/MagazineUrlFactory.php | 44 +++++ src/Factory/Message/MessageDtoFactory.php | 43 +++++ src/Factory/Message/MessageUrlFactory.php | 43 +++++ src/Factory/Post/PostCommentDtoFactory.php | 37 +++++ src/Factory/Post/PostCommentUrlFactory.php | 44 +++++ src/Factory/Post/PostDtoFactory.php | 40 +++++ src/Factory/Post/PostUrlFactory.php | 48 ++++++ src/Factory/ReportFactory.php | 39 ++--- src/Factory/User/UserUrlFactory.php | 46 ++++++ .../ActivityPub/Inbox/FlagHandler.php | 25 ++- .../ActivityPub/Outbox/FlagHandler.php | 14 +- src/Repository/MagazineRepository.php | 2 +- src/Repository/MessageRepository.php | 37 +++++ src/Repository/MessageThreadRepository.php | 10 ++ src/Repository/NotificationRepository.php | 53 ++++++ src/Repository/ReportRepository.php | 23 +++ src/Repository/TagLinkRepository.php | 4 +- src/Security/Voter/MessageThreadVoter.php | 20 ++- .../ActivityPub/ActivityJsonBuilder.php | 52 +++--- .../Contracts/ContentManagerInterface.php | 8 + .../ContentNotificationManagerInterface.php | 4 + src/Service/Contracts/SwitchableService.php | 14 ++ src/Service/EntryCommentManager.php | 26 ++- src/Service/EntryManager.php | 26 ++- src/Service/FeedManager.php | 16 +- src/Service/MessageManager.php | 31 +++- .../EntryCommentNotificationManager.php | 24 ++- .../Notification/EntryNotificationManager.php | 24 ++- .../MessageNotificationManager.php | 49 +++++- .../PostCommentNotificationManager.php | 24 ++- .../Notification/PostNotificationManager.php | 24 ++- .../ReportNotificationManager.php | 10 +- .../UserPushSubscriptionManager.php | 12 +- src/Service/NotificationManager.php | 9 +- .../NotificationManagerTypeResolver.php | 38 ----- src/Service/PostCommentManager.php | 24 ++- src/Service/PostManager.php | 26 ++- src/Service/ReportManager.php | 22 ++- src/Service/SwitchingServiceRegistry.php | 71 ++++++++ src/Twig/Extension/SubjectExtension.php | 6 + src/Twig/Extension/UrlExtension.php | 1 + src/Twig/Runtime/UrlExtensionRuntime.php | 27 ++- templates/components/report_list.html.twig | 12 +- templates/layout/_subject_link.html.twig | 2 + templates/messages/reports.html.twig | 18 ++ templates/messages/single.html.twig | 7 + templates/notifications/_blocks.html.twig | 27 ++- 115 files changed, 2317 insertions(+), 688 deletions(-) create mode 100644 config/mbin_routes/message_reports.yaml create mode 100644 migrations/Version20260311182316.php create mode 100644 src/Controller/Api/Message/MessageReportApi.php create mode 100644 src/Controller/Message/MessageReportController.php create mode 100644 src/Entity/Contracts/HashtagableInterface.php create mode 100644 src/Entity/MessageReport.php delete mode 100644 src/Factory/ContentManagerFactory.php create mode 100644 src/Factory/Contract/ActivityFactoryInterface.php create mode 100644 src/Factory/Contract/ActorUrlFactory.php create mode 100644 src/Factory/Contract/ContentDtoFactory.php create mode 100644 src/Factory/Contract/ContentUrlFactory.php create mode 100644 src/Factory/Contract/ReportUrlFactory.php create mode 100644 src/Factory/Entry/EntryCommentDtoFactory.php create mode 100644 src/Factory/Entry/EntryCommentUrlFactory.php create mode 100644 src/Factory/Entry/EntryDtoFactory.php create mode 100644 src/Factory/Entry/EntryUrlFactory.php create mode 100644 src/Factory/InMagazineReportUrlFactory.php create mode 100644 src/Factory/Magazine/MagazineUrlFactory.php create mode 100644 src/Factory/Message/MessageDtoFactory.php create mode 100644 src/Factory/Message/MessageUrlFactory.php create mode 100644 src/Factory/Post/PostCommentDtoFactory.php create mode 100644 src/Factory/Post/PostCommentUrlFactory.php create mode 100644 src/Factory/Post/PostDtoFactory.php create mode 100644 src/Factory/Post/PostUrlFactory.php create mode 100644 src/Factory/User/UserUrlFactory.php create mode 100644 src/Service/Contracts/SwitchableService.php delete mode 100644 src/Service/NotificationManagerTypeResolver.php create mode 100644 src/Service/SwitchingServiceRegistry.php create mode 100644 templates/messages/reports.html.twig diff --git a/config/mbin_routes/message_reports.yaml b/config/mbin_routes/message_reports.yaml new file mode 100644 index 0000000000..513557a229 --- /dev/null +++ b/config/mbin_routes/message_reports.yaml @@ -0,0 +1,22 @@ +message_reports: + controller: App\Controller\Message\MessageReportController::reports + path: /messages/reports/{status} + defaults: { status: !php/const \App\Entity\Report::STATUS_ANY } + methods: [ GET ] + +message_report_approve: + controller: App\Controller\Message\MessageReportController::reportApprove + path: /messages/reports/{report_id}/approve + methods: [ POST ] + +message_report_reject: + controller: App\Controller\Message\MessageReportController::reportReject + path: /messages/reports/{report_id}/reject + methods: [ POST ] + +message_report: + controller: App\Controller\Message\MessageReportController::reportMessage + path: /mr/{id} + methods: [ GET, POST ] + requirements: + id: \d+ diff --git a/config/services.yaml b/config/services.yaml index ee1b428cb9..f3f0f97ca7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -125,6 +125,11 @@ parameters: mbin_use_federation_allow_list: '%env(bool:default::MBIN_USE_FEDERATION_ALLOW_LIST)%' services: + _instanceof: + App\Service\Contracts\SwitchableService: + tags: ['switchable_service'] + lazy: true + # default configuration for services in *this* file _defaults: autowire: true # Automatically injects dependencies in your services. @@ -259,3 +264,7 @@ services: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler: arguments: - '%env(DATABASE_URL)%' + + App\Service\SwitchingServiceRegistry: + arguments: + - !tagged 'switchable_service' diff --git a/migrations/Version20260311182316.php b/migrations/Version20260311182316.php new file mode 100644 index 0000000000..318d3f56de --- /dev/null +++ b/migrations/Version20260311182316.php @@ -0,0 +1,38 @@ +addSql('ALTER TABLE message ALTER uuid DROP DEFAULT'); + $this->addSql('ALTER TABLE message_thread ALTER updated_at DROP NOT NULL'); + + $this->addSql('ALTER TABLE report ADD message_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE report ALTER magazine_id DROP NOT NULL'); + $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784537A1329 FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_C42F7784537A1329 ON report (message_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE message_thread ALTER updated_at SET NOT NULL'); + $this->addSql('ALTER TABLE message ALTER uuid SET DEFAULT \'gen_random_uuid()\''); + + $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784537A1329'); + $this->addSql('DROP INDEX IDX_C42F7784537A1329'); + $this->addSql('ALTER TABLE report DROP message_id'); + $this->addSql('ALTER TABLE report ALTER magazine_id SET NOT NULL'); + } +} diff --git a/src/Controller/ActivityPub/ReportController.php b/src/Controller/ActivityPub/ReportController.php index caac4252e3..44a6ffa726 100644 --- a/src/Controller/ActivityPub/ReportController.php +++ b/src/Controller/ActivityPub/ReportController.php @@ -29,7 +29,7 @@ public function __invoke( throw new ArgumentException('there is no such report'); } - $json = $this->factory->build($report, $this->factory->getPublicUrl($report->getSubject())); + $json = $this->factory->build($report); $response = new JsonResponse($json); $response->headers->set('Content-Type', 'application/activity+json'); diff --git a/src/Controller/Admin/AdminReportController.php b/src/Controller/Admin/AdminReportController.php index 6eb0f3d8be..e4f246b389 100644 --- a/src/Controller/Admin/AdminReportController.php +++ b/src/Controller/Admin/AdminReportController.php @@ -25,6 +25,7 @@ public function __invoke(Request $request, string $status): Response { $page = (int) $request->get('p', 1); + //TODO rest api for this $reports = $this->repository->findAllPaginated($page, $status); $this->notificationRepository->markReportNotificationsAsRead($this->getUserOrThrow()); diff --git a/src/Controller/Api/Magazine/Moderate/MagazineReportsAcceptApi.php b/src/Controller/Api/Magazine/Moderate/MagazineReportsAcceptApi.php index 05052110ab..0b9a2aebbe 100644 --- a/src/Controller/Api/Magazine/Moderate/MagazineReportsAcceptApi.php +++ b/src/Controller/Api/Magazine/Moderate/MagazineReportsAcceptApi.php @@ -10,7 +10,9 @@ use App\Entity\Magazine; use App\Entity\Report; use App\Factory\ContentManagerFactory; +use App\Service\Contracts\ContentManagerInterface; use App\Service\ReportManager; +use App\Service\SwitchingServiceRegistry; use Nelmio\ApiDocBundle\Attribute\Model; use Nelmio\ApiDocBundle\Attribute\Security; use OpenApi\Attributes as OA; @@ -84,17 +86,17 @@ public function __invoke( #[MapEntity(id: 'report_id')] Report $report, ReportManager $reportManager, - ContentManagerFactory $managerFactory, + SwitchingServiceRegistry $serviceRegistry, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); - if ($magazine->getId() !== $report->magazine->getId()) { + //TODO create api endpoints for reports without magazine (or maybe not) + if ($magazine->getId() !== $report->magazine?->getId()) { throw new NotFoundHttpException('Report not found in magazine'); } - $manager = $managerFactory->createManager($report->getSubject()); - + $manager = $serviceRegistry->getService($report->getSubject(), ContentManagerInterface::class); $manager->delete($this->getUserOrThrow(), $report->getSubject()); return new JsonResponse( diff --git a/src/Controller/Api/Magazine/Moderate/MagazineReportsRejectApi.php b/src/Controller/Api/Magazine/Moderate/MagazineReportsRejectApi.php index 6c5c5f0992..13662aeac4 100644 --- a/src/Controller/Api/Magazine/Moderate/MagazineReportsRejectApi.php +++ b/src/Controller/Api/Magazine/Moderate/MagazineReportsRejectApi.php @@ -87,7 +87,7 @@ public function __invoke( ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); - if ($magazine->getId() !== $report->magazine->getId()) { + if ($magazine->getId() !== $report->magazine?->getId()) { throw new NotFoundHttpException('Report not found in magazine'); } diff --git a/src/Controller/Api/Magazine/Moderate/MagazineReportsRetrieveApi.php b/src/Controller/Api/Magazine/Moderate/MagazineReportsRetrieveApi.php index 1f8c2ccc56..14cde30ce7 100644 --- a/src/Controller/Api/Magazine/Moderate/MagazineReportsRetrieveApi.php +++ b/src/Controller/Api/Magazine/Moderate/MagazineReportsRetrieveApi.php @@ -85,7 +85,7 @@ public function __invoke( ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); - if ($magazine->getId() !== $report->magazine->getId()) { + if ($magazine->getId() !== $report->magazine?->getId()) { throw new NotFoundHttpException('The report was not found in the magazine'); } diff --git a/src/Controller/Api/Message/MessageReportApi.php b/src/Controller/Api/Message/MessageReportApi.php new file mode 100644 index 0000000000..fc2d676da5 --- /dev/null +++ b/src/Controller/Api/Message/MessageReportApi.php @@ -0,0 +1,83 @@ +rateLimit($apiReportLimiter); + + $this->reportContent($message); + + return new JsonResponse( + status: 204, + headers: $headers + ); + } +} diff --git a/src/Controller/Api/Notification/NotificationBaseApi.php b/src/Controller/Api/Notification/NotificationBaseApi.php index 1dd4a21aaa..9198d04687 100644 --- a/src/Controller/Api/Notification/NotificationBaseApi.php +++ b/src/Controller/Api/Notification/NotificationBaseApi.php @@ -9,6 +9,8 @@ use App\DTO\EntryResponseDto; use App\DTO\PostCommentResponseDto; use App\DTO\PostResponseDto; +use App\Entity\Contracts\ContentInterface; +use App\Entity\Contracts\HashtagableInterface; use App\Entity\Contracts\ReportInterface; use App\Entity\Entry; use App\Entity\EntryComment; @@ -19,12 +21,15 @@ use App\Entity\ReportApprovedNotification; use App\Entity\ReportCreatedNotification; use App\Entity\ReportRejectedNotification; +use App\Factory\Contract\ContentDtoFactory; use App\Factory\MessageFactory; +use App\Service\SwitchingServiceRegistry; use Symfony\Contracts\Service\Attribute\Required; class NotificationBaseApi extends BaseApi { private MessageFactory $messageFactory; + private SwitchingServiceRegistry $serviceRegistry; #[Required] public function setMessageFactory(MessageFactory $messageFactory) @@ -32,6 +37,11 @@ public function setMessageFactory(MessageFactory $messageFactory) $this->messageFactory = $messageFactory; } + #[Required] + public function setServiceRegistry(SwitchingServiceRegistry $serviceRegistry) { + $this->serviceRegistry = $serviceRegistry; + } + /** * Serialize a single message to JSON. * @@ -139,15 +149,7 @@ protected function serializeNotification(Notification $dto) private function createResponseDtoForReport(ReportInterface $subject): EntryCommentResponseDto|EntryResponseDto|PostCommentResponseDto|PostResponseDto { - if ($subject instanceof Entry) { - return $this->entryFactory->createResponseDto($subject, $this->tagLinkRepository->getTagsOfContent($subject)); - } elseif ($subject instanceof EntryComment) { - return $this->entryCommentFactory->createResponseDto($subject, $this->tagLinkRepository->getTagsOfContent($subject)); - } elseif ($subject instanceof Post) { - return $this->postFactory->createResponseDto($subject, $this->tagLinkRepository->getTagsOfContent($subject)); - } elseif ($subject instanceof PostComment) { - return $this->postCommentFactory->createResponseDto($subject, $this->tagLinkRepository->getTagsOfContent($subject)); - } - throw new \InvalidArgumentException("cannot work with: '".\get_class($subject)."'"); + $tags = $subject instanceof HashtagableInterface ? $this->tagLinkRepository->getTagsOfContent($subject) : []; + return $this->serviceRegistry->getService($subject, ContentDtoFactory::class)->createResponseDto($subject, $tags); } } diff --git a/src/Controller/Message/MessageReportController.php b/src/Controller/Message/MessageReportController.php new file mode 100644 index 0000000000..66b77d7f44 --- /dev/null +++ b/src/Controller/Message/MessageReportController.php @@ -0,0 +1,155 @@ +security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_MODERATOR')) { + throw new AccessDeniedException(); + } + + $reports = $this->repository->findReports($this->getPageNb($request), status: $status); + + $reportIds = array_map(function (Report $report) { return $report->getId(); }, [...$reports->getCurrentPageResults()]); + $this->notificationRepository->markReportNotificationsOfMessagesAsRead($this->getUserOrThrow(), $reportIds); + + return $this->render( + 'messages/reports.html.twig', + [ + 'reports' => $reports, + ] + ); + } + + public function reportApprove( + #[MapEntity(id: 'report_id')] + Report $report, + Request $request, + ): Response { + if (!$this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_MODERATOR')) { + throw new AccessDeniedException(); + } + + $this->validateCsrf('report_approve', $request->getPayload()->get('token')); + + $this->reportManager->accept($report, $this->getUserOrThrow()); + + return $this->redirectToRefererOrHome($request); + } + + public function reportReject( + #[MapEntity(id: 'report_id')] + Report $report, + Request $request, + ): Response { + if (!$this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_MODERATOR')) { + throw new AccessDeniedException(); + } + + $this->validateCsrf('report_decline', $request->getPayload()->get('token')); + + $this->reportManager->reject($report, $this->getUserOrThrow()); + + return $this->redirectToRefererOrHome($request); + } + + #[IsGranted('ROLE_USER')] + public function reportMessage( + #[MapEntity] + Message $subject, + Request $request, + ): Response { + $user = $this->getUserOrThrow(); + $thread = $subject->thread; + if(!$thread->userIsParticipant($user)) { + throw new AccessDeniedException(); + } + + $dto = ReportDto::create($subject); + + $form = $this->createForm( + ReportType::class, + $dto, + ['action' => $this->generateUrl($dto->getRouteName(), ['id' => $subject->getId()])] + ); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + return $this->handleReportRequest($dto, $request); + } + + if ($request->isXmlHttpRequest()) { + return $this->getJsonFormResponse($form, 'report/_form_report.html.twig'); + } + + return $this->render( + 'report/create.html.twig', + [ + 'form' => $form->createView(), + 'magazine' => null, + 'subject' => $subject, + ] + ); + } + + private function handleReportRequest(ReportDto $dto, Request $request): Response + { + $reportError = false; + try { + $this->reportManager->report($dto, $this->getUserOrThrow()); + $responseMessage = $this->translator->trans('subject_reported'); + + //TODO should the message be deleted directly or at report-accept? + } catch (SubjectHasBeenReportedException $exception) { + $reportError = true; + $responseMessage = $this->translator->trans('subject_reported_exists'); + } finally { + if ($request->isXmlHttpRequest()) { + return new JsonResponse( + [ + 'success' => true, + 'html' => \sprintf("
%s
", ($reportError) ? 'alert__danger' : 'alert__info', $responseMessage), + ] + ); + } + + $this->addFlash($reportError ? 'error' : 'info', $responseMessage); + + return $this->redirectToRefererOrHome($request); + } + } +} diff --git a/src/Controller/Message/MessageThreadController.php b/src/Controller/Message/MessageThreadController.php index 96d6979e1f..311b54d116 100644 --- a/src/Controller/Message/MessageThreadController.php +++ b/src/Controller/Message/MessageThreadController.php @@ -7,6 +7,7 @@ use App\Controller\AbstractController; use App\Entity\MessageThread; use App\Form\MessageType; +use App\Repository\NotificationRepository; use App\Service\MessageManager; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\Request; @@ -15,7 +16,10 @@ class MessageThreadController extends AbstractController { - public function __construct(private readonly MessageManager $manager) + public function __construct( + private readonly MessageManager $manager, + private readonly NotificationRepository $notificationRepo, + ) { } @@ -32,7 +36,8 @@ public function show(#[MapEntity(id: 'id')] MessageThread $thread, Request $requ return $this->redirectToRoute('messages_single', ['id' => $thread->getId()]); } - $this->manager->readMessages($thread, $this->getUserOrThrow()); + //$this->manager->readMessages($thread, $this->getUserOrThrow()); + $this->notificationRepo->markMessageNotificationsAsRead($this->getUserOrThrow(), $thread); return $this->render( 'messages/single.html.twig', diff --git a/src/DTO/ReportDto.php b/src/DTO/ReportDto.php index f0a7136c53..7e9ada02e7 100644 --- a/src/DTO/ReportDto.php +++ b/src/DTO/ReportDto.php @@ -27,8 +27,8 @@ public static function create(ReportInterface $subject, ?string $reason = null, $dto->subject = $subject; $dto->reason = $reason; - $dto->magazine = $subject->magazine; - $dto->reported = $subject->user; + $dto->magazine = $subject->magazine ?? null; + $dto->reported = $subject->getUser(); return $dto; } @@ -40,18 +40,7 @@ public function getId(): ?int public function getRouteName(): string { - switch (\get_class($this->getSubject())) { - case Entry::class: - return 'entry_report'; - case EntryComment::class: - return 'entry_comment_report'; - case Post::class: - return 'post_report'; - case PostComment::class: - return 'post_comment_report'; - } - - throw new \LogicException(); + return $this->getSubject()->getReportType(); } public function getSubject(): ReportInterface diff --git a/src/DTO/ReportResponseDto.php b/src/DTO/ReportResponseDto.php index 6d6dc25522..675610047b 100644 --- a/src/DTO/ReportResponseDto.php +++ b/src/DTO/ReportResponseDto.php @@ -5,6 +5,12 @@ namespace App\DTO; use App\Entity\Contracts\VisibilityInterface; +use App\Entity\Entry; +use App\Entity\EntryComment; +use App\Entity\EntryReport; +use App\Entity\Message; +use App\Entity\Post; +use App\Entity\PostComment; use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; @@ -59,10 +65,11 @@ public static function create( #[OA\Property( 'type', enum: [ - 'entry_report', - 'entry_comment_report', - 'post_report', - 'post_comment_report', + Entry::REPORT_TYPE, + EntryComment::REPORT_TYPE, + Post::REPORT_TYPE, + PostComment::REPORT_TYPE, + Message::REPORT_TYPE, 'null_report', ] )] @@ -75,13 +82,15 @@ public function getType(): string switch (\get_class($this->subject)) { case EntryResponseDto::class: - return 'entry_report'; + return Entry::REPORT_TYPE; case EntryCommentResponseDto::class: - return 'entry_comment_report'; + return EntryComment::REPORT_TYPE; case PostResponseDto::class: - return 'post_report'; + return Post::REPORT_TYPE; case PostCommentResponseDto::class: - return 'post_comment_report'; + return PostComment::REPORT_TYPE; + case MessageResponseDto::class: + return Message::REPORT_TYPE; } throw new \LogicException(); diff --git a/src/Entity/Contracts/HashtagableInterface.php b/src/Entity/Contracts/HashtagableInterface.php new file mode 100644 index 0000000000..1b400baff8 --- /dev/null +++ b/src/Entity/Contracts/HashtagableInterface.php @@ -0,0 +1,8 @@ +get(EntryCommentUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s: %s', $this->entryComment->user->username, $trans->trans('added_new_comment'), $this->entryComment->getShortTitle()); - $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null; - $url = $urlGenerator->generate('entry_comment_view', [ - 'entry_id' => $this->entryComment->entry->getId(), - 'magazine_name' => $this->entryComment->magazine->name, - 'slug' => $this->entryComment->entry->slug ?? '-', - 'comment_id' => $this->entryComment->getId(), - ]); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->entryComment->user); + $url = $commentUrlFactory->getLocalUrl($this->entryComment); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/EntryCommentDeletedNotification.php b/src/Entity/EntryCommentDeletedNotification.php index e8a90cc16b..c4337b42cb 100644 --- a/src/Entity/EntryCommentDeletedNotification.php +++ b/src/Entity/EntryCommentDeletedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Entry\EntryCommentUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -40,17 +44,16 @@ public function getType(): string return 'entry_comment_deleted_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var EntryCommentUrlFactory $commentUrlFactory */ + $commentUrlFactory = $serviceContainer->get(EntryCommentUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $trans->trans('comment'), $this->entryComment->getShortTitle(), $this->entryComment->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted')); - $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null; - $url = $urlGenerator->generate('entry_comment_view', [ - 'entry_id' => $this->entryComment->entry->getId(), - 'magazine_name' => $this->entryComment->magazine->name, - 'slug' => $this->entryComment->entry->slug ?? '-', - 'comment_id' => $this->entryComment->getId(), - ]); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->entryComment->user); + $url = $commentUrlFactory->getLocalUrl($this->entryComment); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_removed_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/EntryCommentEditedNotification.php b/src/Entity/EntryCommentEditedNotification.php index 899fbf73bc..74e9ecfd3d 100644 --- a/src/Entity/EntryCommentEditedNotification.php +++ b/src/Entity/EntryCommentEditedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Entry\EntryCommentUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -40,17 +44,16 @@ public function getType(): string return 'entry_comment_edited_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var EntryCommentUrlFactory $commentUrlFactory */ + $commentUrlFactory = $serviceContainer->get(EntryCommentUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->entryComment->user->username, $trans->trans('edited_comment'), $this->entryComment->getShortTitle()); - $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null; - $url = $urlGenerator->generate('entry_comment_view', [ - 'entry_id' => $this->entryComment->entry->getId(), - 'magazine_name' => $this->entryComment->magazine->name, - 'slug' => $this->entryComment->entry->slug ?? '-', - 'comment_id' => $this->entryComment->getId(), - ]); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->entryComment->user); + $url = $commentUrlFactory->getLocalUrl($this->entryComment); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_edited_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/EntryCommentMentionedNotification.php b/src/Entity/EntryCommentMentionedNotification.php index 4b7eff4cb8..ad7b9c9f57 100644 --- a/src/Entity/EntryCommentMentionedNotification.php +++ b/src/Entity/EntryCommentMentionedNotification.php @@ -4,9 +4,13 @@ namespace App\Entity; +use App\Factory\Entry\EntryCommentUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -38,17 +42,16 @@ public function getType(): string return 'entry_comment_mentioned_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var EntryCommentUrlFactory $commentUrlFactory */ + $commentUrlFactory = $serviceContainer->get(EntryCommentUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->entryComment->user->username, $trans->trans('mentioned_you'), $this->entryComment->getShortTitle()); - $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null; - $url = $urlGenerator->generate('entry_comment_view', [ - 'entry_id' => $this->entryComment->entry->getId(), - 'magazine_name' => $this->entryComment->magazine->name, - 'slug' => $this->entryComment->entry->slug ?? '-', - 'comment_id' => $this->entryComment->getId(), - ]); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->entryComment->user); + $url = $commentUrlFactory->getLocalUrl($this->entryComment); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_mention', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/EntryCommentReplyNotification.php b/src/Entity/EntryCommentReplyNotification.php index 08ba77b4ca..d0fb81f9cb 100644 --- a/src/Entity/EntryCommentReplyNotification.php +++ b/src/Entity/EntryCommentReplyNotification.php @@ -4,10 +4,15 @@ namespace App\Entity; +use App\Factory\Entry\EntryCommentUrlFactory; +use App\Factory\Entry\EntryUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -40,17 +45,16 @@ public function getType(): string return 'entry_comment_reply_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var EntryCommentUrlFactory $commentUrlFactory */ + $commentUrlFactory = $serviceContainer->get(EntryCommentUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->entryComment->user->username, $trans->trans('replied_to_your_comment'), $this->entryComment->getShortTitle()); - $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null; - $url = $urlGenerator->generate('entry_comment_view', [ - 'entry_id' => $this->entryComment->entry->getId(), - 'magazine_name' => $this->entryComment->magazine->name, - 'slug' => $this->entryComment->entry->slug ?? '-', - 'comment_id' => $this->entryComment->getId(), - ]); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->entryComment->user); + $url = $commentUrlFactory->getLocalUrl($this->entryComment); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_reply', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/EntryCreatedNotification.php b/src/Entity/EntryCreatedNotification.php index fbb3854dbb..c9301d361f 100644 --- a/src/Entity/EntryCreatedNotification.php +++ b/src/Entity/EntryCreatedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Entry\EntryUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -35,16 +39,16 @@ public function getType(): string return 'entry_created_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var EntryUrlFactory $entryUrlFactory */ + $entryUrlFactory = $serviceContainer->get(EntryUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->entry->user->username, $trans->trans('added_new_thread'), $this->entry->getShortTitle()); - $slash = $this->entry->user->avatar && !str_starts_with('/', $this->entry->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->entry->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entry->user->avatar->filePath : null; - $url = $urlGenerator->generate('entry_single', [ - 'entry_id' => $this->entry->getId(), - 'magazine_name' => $this->entry->magazine->name, - 'slug' => $this->entry->slug ?? '-', - ]); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->entry->user); + $url = $entryUrlFactory->getLocalUrl($this->entry); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_thread', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/EntryDeletedNotification.php b/src/Entity/EntryDeletedNotification.php index 7ac7dd47b7..f6e433c6ab 100644 --- a/src/Entity/EntryDeletedNotification.php +++ b/src/Entity/EntryDeletedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Entry\EntryUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -35,16 +39,16 @@ public function getType(): string return 'entry_deleted_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var EntryUrlFactory $entryUrlFactory */ + $entryUrlFactory = $serviceContainer->get(EntryUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s', $this->entry->getShortTitle(), $this->entry->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted')); - $slash = $this->entry->user->avatar && !str_starts_with('/', $this->entry->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->entry->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entry->user->avatar->filePath : null; - $url = $urlGenerator->generate('entry_single', [ - 'entry_id' => $this->entry->getId(), - 'magazine_name' => $this->entry->magazine->name, - 'slug' => $this->entry->slug ?? '-', - ]); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->entry->user); + $url = $entryUrlFactory->getLocalUrl($this->entry); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_removed_thread', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/EntryEditedNotification.php b/src/Entity/EntryEditedNotification.php index 0d76734d4e..5a472cdbe6 100644 --- a/src/Entity/EntryEditedNotification.php +++ b/src/Entity/EntryEditedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Entry\EntryUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -35,16 +39,16 @@ public function getType(): string return 'entry_edited_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var EntryUrlFactory $entryUrlFactory */ + $entryUrlFactory = $serviceContainer->get(EntryUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->entry->user->username, $trans->trans('edited_thread'), $this->entry->getShortTitle()); - $slash = $this->entry->user->avatar && !str_starts_with('/', $this->entry->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->entry->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entry->user->avatar->filePath : null; - $url = $urlGenerator->generate('entry_single', [ - 'entry_id' => $this->entry->getId(), - 'magazine_name' => $this->entry->magazine->name, - 'slug' => $this->entry->slug ?? '-', - ]); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->entry->user); + $url = $entryUrlFactory->getLocalUrl($this->entry); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_edited_thread', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/EntryMentionedNotification.php b/src/Entity/EntryMentionedNotification.php index 9db1590d90..8e9cbfc091 100644 --- a/src/Entity/EntryMentionedNotification.php +++ b/src/Entity/EntryMentionedNotification.php @@ -4,9 +4,14 @@ namespace App\Entity; +use App\Factory\Entry\EntryUrlFactory; +use App\Factory\Magazine\MagazineUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -33,16 +38,16 @@ public function getType(): string return 'entry_mentioned_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var EntryUrlFactory $entryUrlFactory */ + $entryUrlFactory = $serviceContainer->get(EntryUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->entry->user->username, $trans->trans('mentioned_you'), $this->entry->getShortTitle()); - $slash = $this->entry->user->avatar && !str_starts_with('/', $this->entry->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->entry->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entry->user->avatar->filePath : null; - $url = $urlGenerator->generate('entry_single', [ - 'entry_id' => $this->entry->getId(), - 'magazine_name' => $this->entry->magazine->name, - 'slug' => $this->entry->slug ?? '-', - ]); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->entry->user); + $url = $entryUrlFactory->getLocalUrl($this->entry); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_mention', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/MagazineBanNotification.php b/src/Entity/MagazineBanNotification.php index 1b5cd1fec4..f43f45bc18 100644 --- a/src/Entity/MagazineBanNotification.php +++ b/src/Entity/MagazineBanNotification.php @@ -4,10 +4,13 @@ namespace App\Entity; +use App\Factory\Magazine\MagazineUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -35,8 +38,11 @@ public function getType(): string return 'magazine_ban_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var MagazineUrlFactory $magazineUrlFactory */ + $magazineUrlFactory = $serviceContainer->get(MagazineUrlFactory::class); + $intl = new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT, calendar: \IntlDateFormatter::GREGORIAN); if ($this->ban->expiredAt) { @@ -54,8 +60,8 @@ public function getMessage(TranslatorInterface $trans, string $locale, UrlGenera $this->ban->reason ); } - $slash = $this->ban->magazine->icon && !str_starts_with('/', $this->ban->magazine->icon->filePath) ? '/' : ''; - $avatarUrl = $this->ban->magazine->icon ? '/media/cache/resolve/avatar_thumb'.$slash.$this->ban->magazine->icon->filePath : null; + + $avatarUrl = $magazineUrlFactory->getAvatarUrl($this->ban->magazine); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_ban', locale: $locale), avatarUrl: $avatarUrl); } diff --git a/src/Entity/MagazineUnBanNotification.php b/src/Entity/MagazineUnBanNotification.php index 205521181b..ac915c2125 100644 --- a/src/Entity/MagazineUnBanNotification.php +++ b/src/Entity/MagazineUnBanNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Magazine\MagazineUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -35,11 +39,13 @@ public function getType(): string return 'magazine_unban_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var MagazineUrlFactory $magazineUrlFactory */ + $magazineUrlFactory = $serviceContainer->get(MagazineUrlFactory::class); + $message = $trans->trans('you_are_no_longer_banned_from_magazine', ['%m' => $this->ban->magazine->name], locale: $locale); - $slash = $this->ban->magazine->icon && !str_starts_with('/', $this->ban->magazine->icon->filePath) ? '/' : ''; - $avatarUrl = $this->ban->magazine->icon ? '/media/cache/resolve/avatar_thumb'.$slash.$this->ban->magazine->icon->filePath : null; + $avatarUrl = $magazineUrlFactory->getAvatarUrl($this->ban->magazine); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_ban', locale: $locale), avatarUrl: $avatarUrl); } diff --git a/src/Entity/Message.php b/src/Entity/Message.php index 1ea030890d..cbc2539c0b 100644 --- a/src/Entity/Message.php +++ b/src/Entity/Message.php @@ -5,6 +5,7 @@ namespace App\Entity; use App\Entity\Contracts\ActivityPubActivityInterface; +use App\Entity\Contracts\ReportInterface; use App\Entity\Traits\ActivityPubActivityTrait; use App\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\EditedAtTrait; @@ -20,13 +21,14 @@ use Symfony\Component\Uid\Uuid; #[Entity] -class Message implements ActivityPubActivityInterface +class Message implements ActivityPubActivityInterface, ReportInterface { use ActivityPubActivityTrait; use CreatedAtTrait { CreatedAtTrait::__construct as createdAtTraitConstruct; } use EditedAtTrait; + public const STATUS_NEW = 'new'; public const STATUS_READ = 'read'; public const STATUS_OPTIONS = [ @@ -34,6 +36,8 @@ class Message implements ActivityPubActivityInterface self::STATUS_READ, ]; + public const string REPORT_TYPE = 'message_report'; + #[ManyToOne(targetEntity: MessageThread::class, inversedBy: 'messages')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public MessageThread $thread; @@ -46,6 +50,8 @@ class Message implements ActivityPubActivityInterface public string $status = self::STATUS_NEW; #[Column(type: 'uuid', unique: true, nullable: false)] public string $uuid; + #[OneToMany(mappedBy: 'message', targetEntity: MessageReport::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] + public Collection $reports; #[Id] #[GeneratedValue] #[Column(type: 'integer')] @@ -61,6 +67,7 @@ public function __construct(MessageThread $thread, User $sender, string $body, ? $this->notifications = new ArrayCollection(); $this->uuid = Uuid::v4()->toRfc4122(); $this->apId = $apId; + $this->reports = new ArrayCollection(); $thread->addMessage($this); @@ -87,4 +94,26 @@ public function getUser(): User { return $this->sender; } + + + public function getApId(): ?string + { + return $this->apId; + } + + public function getMagazine(): ?Magazine + { + return null; + } + + public function getShortTitle(): string { + $body = wordwrap($this->body, 60); + $body = explode("\n", $body); + return trim($body[0]).(isset($body[1]) ? '...' : ''); + } + + public function getReportType(): string + { + return self::REPORT_TYPE; + } } diff --git a/src/Entity/MessageNotification.php b/src/Entity/MessageNotification.php index aec3c4af15..f09c61ccf7 100644 --- a/src/Entity/MessageNotification.php +++ b/src/Entity/MessageNotification.php @@ -5,10 +5,13 @@ namespace App\Entity; use App\Enums\EPushNotificationType; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -38,11 +41,15 @@ public function getType(): string return 'message_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + /** @var UrlGeneratorInterface $urlGenerator */ + $urlGenerator = $serviceContainer->get(UrlGeneratorInterface::class); + $message = \sprintf('%s %s: %s', $this->message->sender->username, $trans->trans('wrote_message'), $this->message->body); - $slash = $this->message->sender->avatar && !str_starts_with('/', $this->message->sender->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->message->sender->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->message->sender->avatar->filePath : null; + $avatarUrl = $userUrlFactory->getAvatarUrl($this->message->sender); $url = $urlGenerator->generate('messages_single', ['id' => $this->message->thread->getId()]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_message', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl, category: EPushNotificationType::Message); diff --git a/src/Entity/MessageReport.php b/src/Entity/MessageReport.php new file mode 100644 index 0000000000..aeccd65d7e --- /dev/null +++ b/src/Entity/MessageReport.php @@ -0,0 +1,41 @@ +getUser(), null, $reason); + + $this->message = $message; + } + + public function getSubject(): Message + { + return $this->message; + } + + public function clearSubject(): Report + { + $this->message = null; + + return $this; + } + + public function getType(): string + { + return 'message'; + } +} diff --git a/src/Entity/NewSignupNotification.php b/src/Entity/NewSignupNotification.php index 66cfc0d3bc..9adf83c241 100644 --- a/src/Entity/NewSignupNotification.php +++ b/src/Entity/NewSignupNotification.php @@ -4,10 +4,13 @@ namespace App\Entity; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -28,13 +31,15 @@ public function getSubject(): ?User return $this->newUser; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = str_replace('%u%', $this->newUser->username, $trans->trans('notification_body_new_signup', locale: $locale)); $title = $trans->trans('notification_title_new_signup', locale: $locale); - $url = $urlGenerator->generate('user_overview', ['username' => $this->newUser->username]); - $slash = $this->newUser->avatar && !str_starts_with('/', $this->newUser->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->newUser->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->newUser->avatar->filePath : null; + $url = $userUrlFactory->getLocalUrl($this->newUser); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->newUser); return new PushNotification($this->getId(), $message, $title, actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/Notification.php b/src/Entity/Notification.php index 71836fb63a..ab732dbd08 100644 --- a/src/Entity/Notification.php +++ b/src/Entity/Notification.php @@ -6,6 +6,7 @@ use App\Entity\Traits\CreatedAtTrait; use App\Payloads\PushNotification; +use App\Service\SwitchingServiceRegistry; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\DiscriminatorColumn; use Doctrine\ORM\Mapping\DiscriminatorMap; @@ -15,6 +16,8 @@ use Doctrine\ORM\Mapping\InheritanceType; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -80,5 +83,5 @@ public function getId(): int abstract public function getType(): string; - abstract public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification; + abstract public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification; } diff --git a/src/Entity/Post.php b/src/Entity/Post.php index 19d1400218..de432eb42e 100644 --- a/src/Entity/Post.php +++ b/src/Entity/Post.php @@ -7,6 +7,7 @@ use App\Entity\Contracts\ActivityPubActivityInterface; use App\Entity\Contracts\CommentInterface; use App\Entity\Contracts\FavouriteInterface; +use App\Entity\Contracts\HashtagableInterface; use App\Entity\Contracts\RankingInterface; use App\Entity\Contracts\ReportInterface; use App\Entity\Contracts\VisibilityInterface; @@ -40,7 +41,7 @@ #[Index(columns: ['created_at'], name: 'post_created_at_idx')] #[Index(columns: ['last_active'], name: 'post_last_active_at_idx')] #[Index(columns: ['body_ts'], name: 'post_body_ts_idx')] -class Post implements VotableInterface, CommentInterface, VisibilityInterface, RankingInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface +class Post implements VotableInterface, CommentInterface, VisibilityInterface, RankingInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface, HashtagableInterface { use VotableTrait; use RankingTrait; @@ -51,6 +52,8 @@ class Post implements VotableInterface, CommentInterface, VisibilityInterface, R CreatedAtTrait::__construct as createdAtTraitConstruct; } + public const string REPORT_TYPE = 'post_report'; + #[ManyToOne(targetEntity: User::class, inversedBy: 'posts')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public User $user; @@ -352,6 +355,11 @@ public function isAdult(): bool return $this->isAdult || $this->magazine->isAdult; } + public function getReportType(): string + { + return self::REPORT_TYPE; + } + public function __sleep() { return []; diff --git a/src/Entity/PostComment.php b/src/Entity/PostComment.php index 3f6cd88e0c..0eafdaa733 100644 --- a/src/Entity/PostComment.php +++ b/src/Entity/PostComment.php @@ -7,6 +7,7 @@ use App\Entity\Contracts\ActivityPubActivityInterface; use App\Entity\Contracts\ContentInterface; use App\Entity\Contracts\FavouriteInterface; +use App\Entity\Contracts\HashtagableInterface; use App\Entity\Contracts\ReportInterface; use App\Entity\Contracts\VisibilityInterface; use App\Entity\Contracts\VotableInterface; @@ -37,7 +38,7 @@ #[Index(columns: ['last_active'], name: 'post_comment_last_active_at_idx')] #[Index(columns: ['created_at'], name: 'post_comment_created_at_idx')] #[Index(columns: ['body_ts'], name: 'post_comment_body_ts_idx')] -class PostComment implements VotableInterface, VisibilityInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface +class PostComment implements VotableInterface, VisibilityInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface, HashtagableInterface { use VotableTrait; use VisibilityTrait; @@ -47,6 +48,8 @@ class PostComment implements VotableInterface, VisibilityInterface, ReportInterf CreatedAtTrait::__construct as createdAtTraitConstruct; } + public const string REPORT_TYPE = 'post_comment_report'; + #[ManyToOne(targetEntity: User::class, inversedBy: 'postComments')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public User $user; @@ -312,4 +315,9 @@ public function getChildrenByCriteria(MbinCriteria $postCommentCriteria): array return $children; } + + public function getReportType(): string + { + return self::REPORT_TYPE; + } } diff --git a/src/Entity/PostCommentCreatedNotification.php b/src/Entity/PostCommentCreatedNotification.php index bde7838126..a79da4b9a6 100644 --- a/src/Entity/PostCommentCreatedNotification.php +++ b/src/Entity/PostCommentCreatedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Post\PostCommentUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -40,16 +44,16 @@ public function getType(): string return 'post_comment_created_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var PostCommentUrlFactory $commentUrlFactory */ + $commentUrlFactory = $serviceContainer->get(PostCommentUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->postComment->user->username, $trans->trans('added_new_comment'), $this->postComment->getShortTitle()); - $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->postComment->user->avatar->filePath : null; - $url = $urlGenerator->generate('post_single', [ - 'magazine_name' => $this->postComment->post->magazine->name, - 'post_id' => $this->postComment->post->getId(), - 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, - ]).'#post-comment-'.$this->postComment->getId(); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->postComment->user); + $url = $commentUrlFactory->getLocalUrl($this->postComment); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/PostCommentDeletedNotification.php b/src/Entity/PostCommentDeletedNotification.php index f2e9bfc6f4..b2617701c2 100644 --- a/src/Entity/PostCommentDeletedNotification.php +++ b/src/Entity/PostCommentDeletedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Post\PostCommentUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -40,16 +44,20 @@ public function getType(): string return 'post_comment_deleted_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { - $message = \sprintf('%s %s - %s', $trans->trans('comment'), $this->postComment->getShortTitle(), $this->postComment->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted')); - $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$this->postComment->user->avatar->filePath : null; - $url = $urlGenerator->generate('post_single', [ - 'magazine_name' => $this->postComment->post->magazine->name, - 'post_id' => $this->postComment->post->getId(), - 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, - ]).'#post-comment-'.$this->postComment->getId(); + /** @var PostCommentUrlFactory $commentUrlFactory */ + $commentUrlFactory = $serviceContainer->get(PostCommentUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + + $message = \sprintf('%s %s - %s', + $trans->trans('comment'), + $this->postComment->getShortTitle(), + $this->postComment->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted') + ); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->postComment->user); + $url = $commentUrlFactory->getLocalUrl($this->postComment); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_removed_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/PostCommentEditedNotification.php b/src/Entity/PostCommentEditedNotification.php index 185c4c67c5..fbe5c81fd6 100644 --- a/src/Entity/PostCommentEditedNotification.php +++ b/src/Entity/PostCommentEditedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Post\PostCommentUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -40,16 +44,16 @@ public function getType(): string return 'post_comment_edited_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var PostCommentUrlFactory $commentUrlFactory */ + $commentUrlFactory = $serviceContainer->get(PostCommentUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->postComment->user->username, $trans->trans('edited_comment'), $this->postComment->getShortTitle()); - $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->postComment->user->avatar->filePath : null; - $url = $urlGenerator->generate('post_single', [ - 'magazine_name' => $this->postComment->post->magazine->name, - 'post_id' => $this->postComment->post->getId(), - 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, - ]).'#post-comment-'.$this->postComment->getId(); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->postComment->user); + $url = $commentUrlFactory->getLocalUrl($this->postComment); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_edited_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/PostCommentMentionedNotification.php b/src/Entity/PostCommentMentionedNotification.php index 33124996d3..a8fff81fdd 100644 --- a/src/Entity/PostCommentMentionedNotification.php +++ b/src/Entity/PostCommentMentionedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Post\PostCommentUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -40,16 +44,16 @@ public function getType(): string return 'post_comment_mentioned_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var PostCommentUrlFactory $commentUrlFactory */ + $commentUrlFactory = $serviceContainer->get(PostCommentUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->postComment->user->username, $trans->trans('mentioned_you'), $this->postComment->getShortTitle()); - $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->postComment->user->avatar->filePath : null; - $url = $urlGenerator->generate('post_single', [ - 'magazine_name' => $this->postComment->post->magazine->name, - 'post_id' => $this->postComment->post->getId(), - 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, - ]).'#post-comment-'.$this->postComment->getId(); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->postComment->user); + $url = $commentUrlFactory->getLocalUrl($this->postComment); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_mention', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/PostCommentReplyNotification.php b/src/Entity/PostCommentReplyNotification.php index 81ebf5733d..850e2b80bd 100644 --- a/src/Entity/PostCommentReplyNotification.php +++ b/src/Entity/PostCommentReplyNotification.php @@ -4,10 +4,15 @@ namespace App\Entity; +use App\Factory\Post\PostCommentUrlFactory; +use App\Factory\Post\PostUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -40,16 +45,16 @@ public function getType(): string return 'post_comment_reply_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var PostCommentUrlFactory $commentUrlFactory */ + $commentUrlFactory = $serviceContainer->get(PostCommentUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->postComment->user->username, $trans->trans('replied_to_your_comment'), $this->postComment->getShortTitle()); - $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->postComment->user->avatar->filePath : null; - $url = $urlGenerator->generate('post_single', [ - 'magazine_name' => $this->postComment->post->magazine->name, - 'post_id' => $this->postComment->post->getId(), - 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, - ]).'#post-comment-'.$this->postComment->getId(); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->postComment->user); + $url = $commentUrlFactory->getLocalUrl($this->postComment); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_reply', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/PostCreatedNotification.php b/src/Entity/PostCreatedNotification.php index bbed49278c..370c16551a 100644 --- a/src/Entity/PostCreatedNotification.php +++ b/src/Entity/PostCreatedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Post\PostUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -35,16 +39,16 @@ public function getType(): string return 'post_created_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var PostUrlFactory $postUrlFactory */ + $postUrlFactory = $serviceContainer->get(PostUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->post->user->username, $trans->trans('added_new_post'), $this->post->getShortTitle()); - $slash = $this->post->user->avatar && !str_starts_with('/', $this->post->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->post->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->post->user->avatar->filePath : null; - $url = $urlGenerator->generate('post_single', [ - 'magazine_name' => $this->post->magazine->name, - 'post_id' => $this->post->getId(), - 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, - ]); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->post->user); + $url = $postUrlFactory->getLocalUrl($this->post); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_post', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/PostDeletedNotification.php b/src/Entity/PostDeletedNotification.php index 6c0341c9ac..af5de8d4ba 100644 --- a/src/Entity/PostDeletedNotification.php +++ b/src/Entity/PostDeletedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Post\PostUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -35,16 +39,16 @@ public function getType(): string return 'post_deleted_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var PostUrlFactory $postUrlFactory */ + $postUrlFactory = $serviceContainer->get(PostUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $trans->trans('post'), $this->post->getShortTitle(), $this->post->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted')); - $slash = $this->post->user->avatar && !str_starts_with('/', $this->post->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->post->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->post->user->avatar->filePath : null; - $url = $urlGenerator->generate('post_single', [ - 'magazine_name' => $this->post->magazine->name, - 'post_id' => $this->post->getId(), - 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, - ]); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->post->user); + $url = $postUrlFactory->getLocalUrl($this->post); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_removed_post', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/PostEditedNotification.php b/src/Entity/PostEditedNotification.php index 4a43b686c9..1f6bbc4d7b 100644 --- a/src/Entity/PostEditedNotification.php +++ b/src/Entity/PostEditedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Post\PostUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -35,16 +39,16 @@ public function getType(): string return 'post_edited_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var PostUrlFactory $postUrlFactory */ + $postUrlFactory = $serviceContainer->get(PostUrlFactory::class); + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->post->user->username, $trans->trans('edited_post'), $this->post->getShortTitle()); - $slash = $this->post->user->avatar && !str_starts_with('/', $this->post->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->post->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->post->user->avatar->filePath : null; - $url = $urlGenerator->generate('post_single', [ - 'magazine_name' => $this->post->magazine->name, - 'post_id' => $this->post->getId(), - 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, - ]); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->post->user); + $url = $postUrlFactory->getLocalUrl($this->post); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_edited_post', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/PostMentionedNotification.php b/src/Entity/PostMentionedNotification.php index e12251f800..24377d83ec 100644 --- a/src/Entity/PostMentionedNotification.php +++ b/src/Entity/PostMentionedNotification.php @@ -4,10 +4,14 @@ namespace App\Entity; +use App\Factory\Post\PostUrlFactory; +use App\Factory\User\UserUrlFactory; use App\Payloads\PushNotification; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -35,16 +39,16 @@ public function getType(): string return 'post_mentioned_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { + /** @var UserUrlFactory $userUrlFactory */ + $userUrlFactory = $serviceContainer->get(UserUrlFactory::class); + /** @var PostUrlFactory $userUrlFactory */ + $postUrlFactory = $serviceContainer->get(PostUrlFactory::class); + $message = \sprintf('%s %s - %s', $this->post->user->username, $trans->trans('mentioned_you'), $this->post->getShortTitle()); - $slash = $this->post->user->avatar && !str_starts_with('/', $this->post->user->avatar->filePath) ? '/' : ''; - $avatarUrl = $this->post->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->post->user->avatar->filePath : null; - $url = $urlGenerator->generate('post_single', [ - 'magazine_name' => $this->post->magazine->name, - 'post_id' => $this->post->getId(), - 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, - ]); + $url = $postUrlFactory->getLocalUrl($this->post); + $avatarUrl = $userUrlFactory->getAvatarUrl($this->post->user); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_mention', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } diff --git a/src/Entity/Report.php b/src/Entity/Report.php index 17df33426f..7d2247458d 100644 --- a/src/Entity/Report.php +++ b/src/Entity/Report.php @@ -28,6 +28,7 @@ 'entry_comment' => 'EntryCommentReport', 'post' => 'PostReport', 'post_comment' => 'PostCommentReport', + 'message' => 'MessageReport', ])] #[UniqueConstraint(name: 'report_uuid_idx', columns: ['uuid'])] abstract class Report @@ -54,8 +55,8 @@ abstract class Report ]; #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'reports')] - #[JoinColumn(nullable: false, onDelete: 'CASCADE')] - public Magazine $magazine; + #[JoinColumn(nullable: true, onDelete: 'CASCADE')] + public ?Magazine $magazine; #[ManyToOne(targetEntity: User::class, inversedBy: 'reports')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public User $reporting; @@ -80,7 +81,7 @@ abstract class Report #[Column(type: 'integer')] private int $id; - public function __construct(User $reporting, User $reported, Magazine $magazine, ?string $reason = null) + public function __construct(User $reporting, User $reported, ?Magazine $magazine, ?string $reason = null) { $this->reporting = $reporting; $this->reported = $reported; diff --git a/src/Entity/ReportApprovedNotification.php b/src/Entity/ReportApprovedNotification.php index 95fbe4c418..f57a9aec22 100644 --- a/src/Entity/ReportApprovedNotification.php +++ b/src/Entity/ReportApprovedNotification.php @@ -5,10 +5,15 @@ namespace App\Entity; use App\Entity\Contracts\ReportInterface; +use App\Factory\Contract\ContentUrlFactory; +use App\Factory\Contract\ReportUrlFactory; use App\Payloads\PushNotification; +use App\Service\SwitchingServiceRegistry; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -31,12 +36,14 @@ public function getType(): string return 'report_approved_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { - /** @var Entry|EntryComment|Post|PostComment $subject */ + /** @var SwitchingServiceRegistry $serviceRegistry */ + $serviceRegistry = $serviceContainer->get(SwitchingServiceRegistry::class); + $subject = $this->report->getSubject(); - $linkToSubject = $this->getSubjectLink($this->report->getSubject(), $urlGenerator); - $linkToReport = $urlGenerator->generate('magazine_panel_reports', ['name' => $this->report->magazine->name, 'status' => Report::STATUS_APPROVED]); + $linkToSubject = $this->getSubjectLink($this->report->getSubject(), $serviceRegistry); + $linkToReport = $serviceRegistry->getService($this->report, ReportUrlFactory::class)->getReportUrl($this->report, Report::STATUS_APPROVED); if ($this->report->reporting->getId() === $this->user->getId()) { $title = $trans->trans('own_report_accepted', locale: $locale); $message = \sprintf('%s: %s', $trans->trans('report_subject', locale: $locale), $subject->getShortTitle()); @@ -58,18 +65,12 @@ public function getMessage(TranslatorInterface $trans, string $locale, UrlGenera return new PushNotification($this->getId(), $message, $title, actionUrl: $actionUrl); } - private function getSubjectLink(ReportInterface $subject, UrlGeneratorInterface $urlGenerator): string + private function getSubjectLink(ReportInterface $subject, SwitchingServiceRegistry $serviceRegistry): string { - if ($subject instanceof Entry) { - return $urlGenerator->generate('entry_single', ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->getId(), 'slug' => $subject->slug]); - } elseif ($subject instanceof EntryComment) { - return $urlGenerator->generate('entry_comment_view', ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->entry->getId(), 'slug' => $subject->entry->slug, 'comment_id' => $subject->getId()]); - } elseif ($subject instanceof Post) { - return $urlGenerator->generate('post_single', ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->getId(), 'slug' => $subject->slug]); - } elseif ($subject instanceof PostComment) { - return $urlGenerator->generate('post_single', ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->post->getId(), 'slug' => $subject->post->slug]).'#post-comment-'.$subject->getId(); + try { + return $serviceRegistry->getService($subject, ContentUrlFactory::class)->getLocalUrl($subject); + } catch (\Exception) { + return ''; } - - return ''; } } diff --git a/src/Entity/ReportCreatedNotification.php b/src/Entity/ReportCreatedNotification.php index f805df3d56..b60bfa5f06 100644 --- a/src/Entity/ReportCreatedNotification.php +++ b/src/Entity/ReportCreatedNotification.php @@ -4,10 +4,15 @@ namespace App\Entity; +use App\Entity\Contracts\ReportInterface; +use App\Factory\Contract\ReportUrlFactory; use App\Payloads\PushNotification; +use App\Service\SwitchingServiceRegistry; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -30,13 +35,20 @@ public function getType(): string return 'report_created_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { - /** @var Entry|EntryComment|Post|PostComment $subject */ + /** @var SwitchingServiceRegistry $serviceRegistry */ + $serviceRegistry = $serviceContainer->get(SwitchingServiceRegistry::class); + $subject = $this->report->getSubject(); - $reportLink = $urlGenerator->generate('magazine_panel_reports', ['name' => $this->report->magazine->name, 'status' => Report::STATUS_PENDING]).'#report-id-'.$this->report->getId(); - $message = \sprintf('%s %s %s\n%s: %s', $this->report->reporting->username, $trans->trans('reported', locale: $locale), $this->report->reported->username, - $trans->trans('report_subject', locale: $locale), $subject->getShortTitle()); + $reportLink = $serviceRegistry->getService($this->report, ReportUrlFactory::class)->getReportUrl($this->report, Report::STATUS_PENDING); + $message = \sprintf('%s %s %s\n%s: %s', + $this->report->reporting->username, + $trans->trans('reported', locale: $locale), + $this->report->reported->username, + $trans->trans('report_subject', locale: $locale), + $subject->getShortTitle() + ); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_report'), actionUrl: $reportLink); } diff --git a/src/Entity/ReportRejectedNotification.php b/src/Entity/ReportRejectedNotification.php index 23147b1aaf..3ad28e6e31 100644 --- a/src/Entity/ReportRejectedNotification.php +++ b/src/Entity/ReportRejectedNotification.php @@ -5,10 +5,14 @@ namespace App\Entity; use App\Entity\Contracts\ReportInterface; +use App\Factory\Contract\ContentUrlFactory; use App\Payloads\PushNotification; +use App\Service\SwitchingServiceRegistry; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -31,30 +35,31 @@ public function getType(): string return 'report_rejected_notification'; } - public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + public function getMessage(TranslatorInterface $trans, string $locale, ContainerInterface $serviceContainer): PushNotification { - /** @var Entry|EntryComment|Post|PostComment $subject */ + /** @var SwitchingServiceRegistry $serviceRegistry */ + $serviceRegistry = $serviceContainer->get(SwitchingServiceRegistry::class); + $subject = $this->report->getSubject(); $message = \sprintf('%s: %s\n%s: %s', $trans->trans('reported_user', locale: $locale), $this->report->reported->username, $trans->trans('report_subject', locale: $locale), $subject->getShortTitle() ); - return new PushNotification($this->getId(), $message, $trans->trans('own_report_rejected', locale: $locale), actionUrl: $this->getSubjectLink($subject, $urlGenerator)); + return new PushNotification( + $this->getId(), + $message, + $trans->trans('own_report_rejected', locale: $locale), + actionUrl: $this->getSubjectLink($subject, $serviceRegistry) + ); } - private function getSubjectLink(ReportInterface $subject, UrlGeneratorInterface $urlGenerator): string + private function getSubjectLink(ReportInterface $subject, SwitchingServiceRegistry $serviceRegistry): string { - if ($subject instanceof Entry) { - return $urlGenerator->generate('entry_single', ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->getId(), 'slug' => $subject->slug]); - } elseif ($subject instanceof EntryComment) { - return $urlGenerator->generate('entry_comment_view', ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->entry->getId(), 'slug' => $subject->entry->slug, 'comment_id' => $subject->getId()]); - } elseif ($subject instanceof Post) { - return $urlGenerator->generate('post_single', ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->getId(), 'slug' => $subject->slug]); - } elseif ($subject instanceof PostComment) { - return $urlGenerator->generate('post_single', ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->post->getId(), 'slug' => $subject->post->slug]).'#post-comment-'.$subject->getId(); + try { + return $serviceRegistry->getService($subject, ContentUrlFactory::class)->getLocalUrl($subject); + } catch (\Exception) { + return ''; } - - return ''; } } diff --git a/src/EventListener/ContentNotificationPurgeListener.php b/src/EventListener/ContentNotificationPurgeListener.php index 7533a46579..626033fb11 100644 --- a/src/EventListener/ContentNotificationPurgeListener.php +++ b/src/EventListener/ContentNotificationPurgeListener.php @@ -4,47 +4,36 @@ namespace App\EventListener; +use App\Entity\Contracts\ContentInterface; use App\Entity\Entry; use App\Entity\EntryComment; use App\Entity\Post; use App\Entity\PostComment; +use App\Entity\Report; +use App\Service\Contracts\ContentNotificationManagerInterface; use App\Service\Notification\EntryCommentNotificationManager; use App\Service\Notification\EntryNotificationManager; use App\Service\Notification\PostCommentNotificationManager; use App\Service\Notification\PostNotificationManager; +use App\Service\SwitchingServiceRegistry; use Doctrine\Persistence\Event\LifecycleEventArgs; readonly class ContentNotificationPurgeListener { public function __construct( - private EntryNotificationManager $entryManager, - private EntryCommentNotificationManager $entryCommentManager, - private PostNotificationManager $postManager, - private PostCommentNotificationManager $postCommentManager, + private SwitchingServiceRegistry $serviceRegistry, ) { } public function preRemove(LifecycleEventArgs $args): void { $object = $args->getObject(); - - switch ($object) { - case $object instanceof Entry: - $this->entryManager->purgeNotifications($object); - $this->entryManager->purgeMagazineLog($object); - break; - case $object instanceof EntryComment: - $this->entryCommentManager->purgeNotifications($object); - $this->entryCommentManager->purgeMagazineLog($object); - break; - case $object instanceof Post: - $this->postManager->purgeNotifications($object); - $this->postManager->purgeMagazineLog($object); - break; - case $object instanceof PostComment: - $this->postCommentManager->purgeNotifications($object); - $this->postCommentManager->purgeMagazineLog($object); - break; + if (!($object instanceof ContentInterface)) { + return; } + + $manager = $this->serviceRegistry->getService($object, ContentNotificationManagerInterface::class); + $manager->purgeNotifications($object); + $manager->purgeMagazineLog($object); } } diff --git a/src/EventSubscriber/SubjectReportedSubscriber.php b/src/EventSubscriber/SubjectReportedSubscriber.php index bf53080196..b8aebbd5cf 100644 --- a/src/EventSubscriber/SubjectReportedSubscriber.php +++ b/src/EventSubscriber/SubjectReportedSubscriber.php @@ -4,9 +4,11 @@ namespace App\EventSubscriber; +use App\Entity\Message; use App\Event\Report\SubjectReportedEvent; use App\Message\ActivityPub\Outbox\FlagMessage; use App\Service\Notification\ReportNotificationManager; +use App\Service\SettingsManager; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Messenger\MessageBusInterface; @@ -17,6 +19,7 @@ public function __construct( private readonly MessageBusInterface $bus, private readonly LoggerInterface $logger, private readonly ReportNotificationManager $notificationManager, + private readonly SettingsManager $settingsManager, ) { } @@ -24,16 +27,29 @@ public function onSubjectReported(SubjectReportedEvent $reportedEvent): void { $this->logger->debug($reportedEvent->report->reported->username.' was reported for '.$reportedEvent->report->reason); $this->notificationManager->sendReportCreatedNotification($reportedEvent->report); - if (!$reportedEvent->report->magazine->apId and 'random' !== $reportedEvent->report->magazine->name) { - return; - } - if ($reportedEvent->report->magazine->apId) { + $sendFlag = false; + if($reportedEvent->report->magazine === null) { + // is a message -> check if remote + $message = $reportedEvent->report->getSubject(); + if($message instanceof Message) { + if($message->getApId() !== null && !$this->settingsManager->isLocalUrl($message->getApId())) { + $this->logger->debug('was a message from a remote instance, dispatching a new FlagMessage'); + $sendFlag = true; + } + } else { + $this->logger->error('got a report with magazine === null but it was not a MessageReport'); + } + } elseif ($reportedEvent->report->magazine->apId) { $this->logger->debug('was on a remote magazine, dispatching a new FlagMessage'); + $sendFlag = true; } elseif ('random' === $reportedEvent->report->magazine->name) { $this->logger->debug('was on the random magazine, dispatching a new FlagMessage'); + $sendFlag = true; + } + if ($sendFlag) { + $this->bus->dispatch(new FlagMessage($reportedEvent->report->getId())); } - $this->bus->dispatch(new FlagMessage($reportedEvent->report->getId())); } public static function getSubscribedEvents(): array diff --git a/src/Factory/ActivityPub/ActivityFactory.php b/src/Factory/ActivityPub/ActivityFactory.php index 764e3c870a..2a38ef6234 100644 --- a/src/Factory/ActivityPub/ActivityFactory.php +++ b/src/Factory/ActivityPub/ActivityFactory.php @@ -5,34 +5,28 @@ namespace App\Factory\ActivityPub; use App\Entity\Contracts\ActivityPubActivityInterface; +use App\Entity\Contracts\HashtagableInterface; use App\Entity\Entry; use App\Entity\EntryComment; use App\Entity\Message; use App\Entity\Post; use App\Entity\PostComment; +use App\Factory\Contract\ActivityFactoryInterface; use App\Repository\TagLinkRepository; +use App\Service\SwitchingServiceRegistry; class ActivityFactory { public function __construct( private readonly TagLinkRepository $tagLinkRepository, - private readonly EntryPageFactory $pageFactory, - private readonly EntryCommentNoteFactory $entryNoteFactory, - private readonly PostNoteFactory $postNoteFactory, - private readonly PostCommentNoteFactory $postCommentNoteFactory, - private readonly MessageFactory $messageFactory, + private readonly SwitchingServiceRegistry $serviceRegistry, ) { } public function create(ActivityPubActivityInterface $activity, bool $context = false): array { - return match (true) { - $activity instanceof Entry => $this->pageFactory->create($activity, $this->tagLinkRepository->getTagsOfContent($activity), $context), - $activity instanceof EntryComment => $this->entryNoteFactory->create($activity, $this->tagLinkRepository->getTagsOfContent($activity), $context), - $activity instanceof Post => $this->postNoteFactory->create($activity, $this->tagLinkRepository->getTagsOfContent($activity), $context), - $activity instanceof PostComment => $this->postCommentNoteFactory->create($activity, $this->tagLinkRepository->getTagsOfContent($activity), $context), - $activity instanceof Message => $this->messageFactory->build($activity, $context), - default => throw new \LogicException('Cannot handle activity of type '.\get_class($activity)), - }; + $hashtags = $activity instanceof HashtagableInterface ? $this->tagLinkRepository->getTagsOfContent($activity) : []; + $factory = $this->serviceRegistry->getService($activity, ActivityFactoryInterface::class); + return $factory->create($activity, $hashtags, $context); } } diff --git a/src/Factory/ActivityPub/EntryCommentNoteFactory.php b/src/Factory/ActivityPub/EntryCommentNoteFactory.php index a6174024db..70d7d48e73 100644 --- a/src/Factory/ActivityPub/EntryCommentNoteFactory.php +++ b/src/Factory/ActivityPub/EntryCommentNoteFactory.php @@ -6,6 +6,8 @@ use App\Entity\Contracts\ActivityPubActivityInterface; use App\Entity\EntryComment; +use App\Factory\Contract\ActivityFactoryInterface; +use App\Factory\Entry\EntryCommentUrlFactory; use App\Markdown\MarkdownConverter; use App\Markdown\RenderTarget; use App\Service\ActivityPub\ApHttpClientInterface; @@ -14,10 +16,15 @@ use App\Service\ActivityPub\Wrapper\MentionsWrapper; use App\Service\ActivityPub\Wrapper\TagsWrapper; use App\Service\ActivityPubManager; +use App\Service\Contracts\SwitchableService; use App\Service\MentionManager; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -class EntryCommentNoteFactory +/** + * @implements SwitchableService + * @implements ActivityPubActivityInterface + */ +class EntryCommentNoteFactory implements SwitchableService, ActivityFactoryInterface { public function __construct( private readonly UrlGeneratorInterface $urlGenerator, @@ -26,67 +33,76 @@ public function __construct( private readonly ImageWrapper $imageWrapper, private readonly TagsWrapper $tagsWrapper, private readonly MentionsWrapper $mentionsWrapper, - private readonly MentionManager $mentionManager, private readonly EntryPageFactory $pageFactory, private readonly ApHttpClientInterface $client, private readonly ActivityPubManager $activityPubManager, private readonly MarkdownConverter $markdownConverter, + private readonly EntryCommentUrlFactory $entryCommentUrlFactory, ) { } - public function create(EntryComment $comment, array $tags, bool $context = false): array + public function getSupportedTypes(): array + { + return [EntryComment::class]; + } + + /** + * @inheritDoc + * @param EntryComment $subject + */ + public function create($subject, array $tags, bool $context = false): array { if ($context) { $note['@context'] = $this->contextProvider->referencedContexts(); } - if ('random' !== $comment->magazine->name && !$comment->magazine->apId) { // @todo - $tags[] = $comment->magazine->name; + if ('random' !== $subject->magazine->name && !$subject->magazine->apId) { // @todo + $tags[] = $subject->magazine->name; } - $cc = [$this->groupFactory->getActivityPubId($comment->magazine)]; - if ($followersUrl = $comment->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $comment->apId)) { + $cc = [$this->groupFactory->getActivityPubId($subject->magazine)]; + if ($followersUrl = $subject->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $subject->apId)) { $cc[] = $followersUrl; } $note = array_merge($note ?? [], [ - 'id' => $this->getActivityPubId($comment), + 'id' => $this->getActivityPubId($subject), 'type' => 'Note', - 'attributedTo' => $this->activityPubManager->getActorProfileId($comment->user), - 'inReplyTo' => $this->getReplyTo($comment), + 'attributedTo' => $this->activityPubManager->getActorProfileId($subject->user), + 'inReplyTo' => $this->getReplyTo($subject), 'to' => [ ActivityPubActivityInterface::PUBLIC_URL, ], 'cc' => $cc, - 'audience' => $this->groupFactory->getActivityPubId($comment->magazine), - 'sensitive' => $comment->entry->isAdult(), + 'audience' => $this->groupFactory->getActivityPubId($subject->magazine), + 'sensitive' => $subject->entry->isAdult(), 'content' => $this->markdownConverter->convertToHtml( - $comment->body, + $subject->body, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub] ), 'mediaType' => 'text/html', - 'source' => $comment->body ? [ - 'content' => $comment->body, + 'source' => $subject->body ? [ + 'content' => $subject->body, 'mediaType' => 'text/markdown', ] : null, - 'url' => $this->getActivityPubId($comment), + 'url' => $this->getActivityPubId($subject), 'tag' => array_merge( $this->tagsWrapper->build($tags), - $this->mentionsWrapper->build($comment->mentions ?? [], $comment->body) + $this->mentionsWrapper->build($subject->mentions ?? [], $subject->body) ), - 'published' => $comment->createdAt->format(DATE_ATOM), + 'published' => $subject->createdAt->format(DATE_ATOM), ]); $note['contentMap'] = [ - $comment->lang => $note['content'], + $subject->lang => $note['content'], ]; - if ($comment->image) { - $note = $this->imageWrapper->build($note, $comment->image, $comment->getShortTitle()); + if ($subject->image) { + $note = $this->imageWrapper->build($note, $subject->image, $subject->getShortTitle()); } $mentions = []; - foreach ($comment->mentions ?? [] as $mention) { + foreach ($subject->mentions ?? [] as $mention) { try { $profileId = $this->activityPubManager->findActorOrCreate($mention)?->apProfileId; if ($profileId) { @@ -102,8 +118,8 @@ public function create(EntryComment $comment, array $tags, bool $context = false array_merge( $note['to'], $mentions, - $this->activityPubManager->createCcFromBody($comment->body), - [$this->getReplyToAuthor($comment)], + $this->activityPubManager->createCcFromBody($subject->body), + [$this->getReplyToAuthor($subject)], ) ) ); @@ -113,19 +129,7 @@ public function create(EntryComment $comment, array $tags, bool $context = false public function getActivityPubId(EntryComment $comment): string { - if ($comment->apId) { - return $comment->apId; - } - - return $this->urlGenerator->generate( - 'ap_entry_comment', - [ - 'magazine_name' => $comment->magazine->name, - 'entry_id' => $comment->entry->getId(), - 'comment_id' => $comment->getId(), - ], - UrlGeneratorInterface::ABSOLUTE_URL - ); + return $this->entryCommentUrlFactory->getActivityPubId($comment); } private function getReplyTo(EntryComment $comment): string diff --git a/src/Factory/ActivityPub/EntryPageFactory.php b/src/Factory/ActivityPub/EntryPageFactory.php index ab1153e6b1..11a23ab50e 100644 --- a/src/Factory/ActivityPub/EntryPageFactory.php +++ b/src/Factory/ActivityPub/EntryPageFactory.php @@ -6,6 +6,8 @@ use App\Entity\Contracts\ActivityPubActivityInterface; use App\Entity\Entry; +use App\Factory\Contract\ActivityFactoryInterface; +use App\Factory\Entry\EntryUrlFactory; use App\Markdown\MarkdownConverter; use App\Markdown\RenderTarget; use App\Service\ActivityPub\ApHttpClientInterface; @@ -14,9 +16,14 @@ use App\Service\ActivityPub\Wrapper\MentionsWrapper; use App\Service\ActivityPub\Wrapper\TagsWrapper; use App\Service\ActivityPubManager; +use App\Service\Contracts\SwitchableService; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -class EntryPageFactory +/** + * @implements SwitchableService + * @implements ActivityPubActivityInterface + */ +class EntryPageFactory implements SwitchableService, ActivityFactoryInterface { public function __construct( private readonly UrlGeneratorInterface $urlGenerator, @@ -28,80 +35,90 @@ public function __construct( private readonly ApHttpClientInterface $client, private readonly ActivityPubManager $activityPubManager, private readonly MarkdownConverter $markdownConverter, + private readonly EntryUrlFactory $entryUrlFactory, ) { } - public function create(Entry $entry, array $tags, bool $context = false): array + public function getSupportedTypes(): array + { + return [Entry::class]; + } + + /** + * @inheritDoc + * @param Entry $subject + */ + public function create($subject, array $tags, bool $context = false): array { if ($context) { $page['@context'] = $this->contextProvider->referencedContexts(); } - if ('random' !== $entry->magazine->name && !$entry->magazine->apId) { // @todo - $tags[] = $entry->magazine->name; + if ('random' !== $subject->magazine->name && !$subject->magazine->apId) { // @todo + $tags[] = $subject->magazine->name; } $cc = []; - if ($followersUrl = $entry->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $entry->apId)) { + if ($followersUrl = $subject->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $subject->apId)) { $cc[] = $followersUrl; } $page = array_merge($page ?? [], [ - 'id' => $this->getActivityPubId($entry), + 'id' => $this->getActivityPubId($subject), 'type' => 'Page', - 'attributedTo' => $this->activityPubManager->getActorProfileId($entry->user), + 'attributedTo' => $this->activityPubManager->getActorProfileId($subject->user), 'inReplyTo' => null, 'to' => [ - $this->groupFactory->getActivityPubId($entry->magazine), + $this->groupFactory->getActivityPubId($subject->magazine), ActivityPubActivityInterface::PUBLIC_URL, ], 'cc' => $cc, - 'name' => $entry->title, - 'audience' => $this->groupFactory->getActivityPubId($entry->magazine), - 'content' => $entry->body ? $this->markdownConverter->convertToHtml( - $entry->body, + 'name' => $subject->title, + 'audience' => $this->groupFactory->getActivityPubId($subject->magazine), + 'content' => $subject->body ? $this->markdownConverter->convertToHtml( + $subject->body, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub] ) : null, - 'summary' => $entry->getShortTitle().' '.implode( + 'summary' => $subject->getShortTitle().' '.implode( ' ', array_map(fn ($val) => '#'.$val, $tags) ), 'mediaType' => 'text/html', - 'source' => $entry->body ? [ - 'content' => $entry->body, + 'source' => $subject->body ? [ + 'content' => $subject->body, 'mediaType' => 'text/markdown', ] : null, 'tag' => array_merge( $this->tagsWrapper->build($tags), - $this->mentionsWrapper->build($entry->mentions ?? [], $entry->body) + $this->mentionsWrapper->build($subject->mentions ?? [], $subject->body) ), - 'commentsEnabled' => !$entry->isLocked, - 'sensitive' => $entry->isAdult(), - 'stickied' => $entry->sticky, - 'published' => $entry->createdAt->format(DATE_ATOM), + 'commentsEnabled' => !$subject->isLocked, + 'sensitive' => $subject->isAdult(), + 'stickied' => $subject->sticky, + 'published' => $subject->createdAt->format(DATE_ATOM), ]); $page['contentMap'] = [ - $entry->lang => $page['content'], + $subject->lang => $page['content'], ]; - if ($entry->url) { - $page['source'] = $entry->url; + if ($subject->url) { + $page['source'] = $subject->url; $page['attachment'][] = [ - 'href' => $entry->url, + 'href' => $subject->url, 'type' => 'Link', ]; } - if ($entry->image) { + if ($subject->image) { // We do not know whether the image comes from an embed. // Even if $entry->hasEmbed is true that does not mean that the image is from the embed - $page = $this->imageWrapper->build($page, $entry->image, $entry->title); + $page = $this->imageWrapper->build($page, $subject->image, $subject->title); } - if ($entry->body) { + if ($subject->body) { $page['to'] = array_unique( - array_merge($page['to'], $this->activityPubManager->createCcFromBody($entry->body)) + array_merge($page['to'], $this->activityPubManager->createCcFromBody($subject->body)) ); } @@ -110,14 +127,6 @@ public function create(Entry $entry, array $tags, bool $context = false): array public function getActivityPubId(Entry $entry): string { - if ($entry->apId) { - return $entry->apId; - } - - return $this->urlGenerator->generate( - 'ap_entry', - ['magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId()], - UrlGeneratorInterface::ABSOLUTE_URL - ); + return $this->entryUrlFactory->getActivityPubId($entry); } } diff --git a/src/Factory/ActivityPub/MessageFactory.php b/src/Factory/ActivityPub/MessageFactory.php index 34a63b99ae..cd6f5507f9 100644 --- a/src/Factory/ActivityPub/MessageFactory.php +++ b/src/Factory/ActivityPub/MessageFactory.php @@ -4,14 +4,21 @@ namespace App\Factory\ActivityPub; +use App\Entity\Contracts\ActivityPubActivityInterface; use App\Entity\Message; use App\Entity\User; +use App\Factory\Contract\ActivityFactoryInterface; use App\Markdown\MarkdownConverter; use App\Markdown\RenderTarget; use App\Service\ActivityPub\ContextsProvider; +use App\Service\Contracts\SwitchableService; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -class MessageFactory +/** + * @implements SwitchableService + * @implements ActivityPubActivityInterface + */ +class MessageFactory implements SwitchableService, ActivityFactoryInterface { public function __construct( private readonly UrlGeneratorInterface $urlGenerator, @@ -20,6 +27,16 @@ public function __construct( ) { } + public function getSupportedTypes(): array + { + return [Message::class]; + } + + public function create($subject, array $tags, bool $context = false): array + { + return $this->build($subject, $context); + } + public function build(Message $message, bool $includeContext = true): array { $actorUrl = null === $message->sender->apId ? $this->urlGenerator->generate('ap_user', ['username' => $message->sender->username], UrlGeneratorInterface::ABSOLUTE_URL) : $message->sender->apPublicUrl; diff --git a/src/Factory/ActivityPub/PostCommentNoteFactory.php b/src/Factory/ActivityPub/PostCommentNoteFactory.php index 96ffabe4f6..2ee3bd95a6 100644 --- a/src/Factory/ActivityPub/PostCommentNoteFactory.php +++ b/src/Factory/ActivityPub/PostCommentNoteFactory.php @@ -6,6 +6,8 @@ use App\Entity\Contracts\ActivityPubActivityInterface; use App\Entity\PostComment; +use App\Factory\Contract\ActivityFactoryInterface; +use App\Factory\Post\PostCommentUrlFactory; use App\Markdown\MarkdownConverter; use App\Markdown\RenderTarget; use App\Service\ActivityPub\ApHttpClientInterface; @@ -14,10 +16,15 @@ use App\Service\ActivityPub\Wrapper\MentionsWrapper; use App\Service\ActivityPub\Wrapper\TagsWrapper; use App\Service\ActivityPubManager; +use App\Service\Contracts\SwitchableService; use App\Service\MentionManager; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -class PostCommentNoteFactory +/** + * @implements SwitchableService + * @implements ActivityPubActivityInterface + */ +class PostCommentNoteFactory implements SwitchableService, ActivityFactoryInterface { public function __construct( private readonly UrlGeneratorInterface $urlGenerator, @@ -27,66 +34,75 @@ public function __construct( private readonly GroupFactory $groupFactory, private readonly TagsWrapper $tagsWrapper, private readonly MentionsWrapper $mentionsWrapper, - private readonly MentionManager $mentionManager, private readonly ApHttpClientInterface $client, private readonly ActivityPubManager $activityPubManager, private readonly MarkdownConverter $markdownConverter, + private readonly PostCommentUrlFactory $postCommentUrlFactory, ) { } - public function create(PostComment $comment, array $tags, bool $context = false): array + public function getSupportedTypes(): array + { + return [PostComment::class]; + } + + /** + * @inheritDoc + * @param PostComment $subject + */ + public function create($subject, array $tags, bool $context = false): array { if ($context) { $note['@context'] = $this->contextProvider->referencedContexts(); } - if ('random' !== $comment->magazine->name && !$comment->magazine->apId) { // @todo - $tags[] = $comment->magazine->name; + if ('random' !== $subject->magazine->name && !$subject->magazine->apId) { // @todo + $tags[] = $subject->magazine->name; } - $cc = [$this->groupFactory->getActivityPubId($comment->magazine)]; - if ($followersUrl = $comment->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $comment->apId)) { + $cc = [$this->groupFactory->getActivityPubId($subject->magazine)]; + if ($followersUrl = $subject->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $subject->apId)) { $cc[] = $followersUrl; } $note = array_merge($note ?? [], [ - 'id' => $this->getActivityPubId($comment), + 'id' => $this->getActivityPubId($subject), 'type' => 'Note', - 'attributedTo' => $this->activityPubManager->getActorProfileId($comment->user), - 'inReplyTo' => $this->getReplyTo($comment), + 'attributedTo' => $this->activityPubManager->getActorProfileId($subject->user), + 'inReplyTo' => $this->getReplyTo($subject), 'to' => [ ActivityPubActivityInterface::PUBLIC_URL, ], 'cc' => $cc, - 'audience' => $this->groupFactory->getActivityPubId($comment->magazine), - 'sensitive' => $comment->post->isAdult(), + 'audience' => $this->groupFactory->getActivityPubId($subject->magazine), + 'sensitive' => $subject->post->isAdult(), 'content' => $this->markdownConverter->convertToHtml( - $comment->body, + $subject->body, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub], ), 'mediaType' => 'text/html', - 'source' => $comment->body ? [ - 'content' => $comment->body, + 'source' => $subject->body ? [ + 'content' => $subject->body, 'mediaType' => 'text/markdown', ] : null, - 'url' => $this->getActivityPubId($comment), + 'url' => $this->getActivityPubId($subject), 'tag' => array_merge( $this->tagsWrapper->build($tags), - $this->mentionsWrapper->build($comment->mentions ?? [], $comment->body) + $this->mentionsWrapper->build($subject->mentions ?? [], $subject->body) ), - 'published' => $comment->createdAt->format(DATE_ATOM), + 'published' => $subject->createdAt->format(DATE_ATOM), ]); $note['contentMap'] = [ - $comment->lang => $note['content'], + $subject->lang => $note['content'], ]; - if ($comment->image) { - $note = $this->imageWrapper->build($note, $comment->image, $comment->getShortTitle()); + if ($subject->image) { + $note = $this->imageWrapper->build($note, $subject->image, $subject->getShortTitle()); } $mentions = []; - foreach ($comment->mentions ?? [] as $mention) { + foreach ($subject->mentions ?? [] as $mention) { try { $profileId = $this->activityPubManager->findActorOrCreate($mention)?->apProfileId; if ($profileId) { @@ -102,8 +118,8 @@ public function create(PostComment $comment, array $tags, bool $context = false) array_merge( $note['to'], $mentions, - $this->activityPubManager->createCcFromBody($comment->body), - [$this->getReplyToAuthor($comment)], + $this->activityPubManager->createCcFromBody($subject->body), + [$this->getReplyToAuthor($subject)], ) ) ); @@ -113,19 +129,7 @@ public function create(PostComment $comment, array $tags, bool $context = false) public function getActivityPubId(PostComment $comment): string { - if ($comment->apId) { - return $comment->apId; - } - - return $this->urlGenerator->generate( - 'ap_post_comment', - [ - 'magazine_name' => $comment->magazine->name, - 'post_id' => $comment->post->getId(), - 'comment_id' => $comment->getId(), - ], - UrlGeneratorInterface::ABSOLUTE_URL - ); + return $this->postCommentUrlFactory->getActivityPubId($comment); } private function getReplyTo(PostComment $comment): string diff --git a/src/Factory/ActivityPub/PostNoteFactory.php b/src/Factory/ActivityPub/PostNoteFactory.php index c71abf1c6c..79241d268f 100644 --- a/src/Factory/ActivityPub/PostNoteFactory.php +++ b/src/Factory/ActivityPub/PostNoteFactory.php @@ -6,6 +6,9 @@ use App\Entity\Contracts\ActivityPubActivityInterface; use App\Entity\Post; +use App\Entity\PostComment; +use App\Factory\Contract\ActivityFactoryInterface; +use App\Factory\Post\PostUrlFactory; use App\Markdown\MarkdownConverter; use App\Markdown\RenderTarget; use App\Service\ActivityPub\ApHttpClientInterface; @@ -14,11 +17,16 @@ use App\Service\ActivityPub\Wrapper\MentionsWrapper; use App\Service\ActivityPub\Wrapper\TagsWrapper; use App\Service\ActivityPubManager; +use App\Service\Contracts\SwitchableService; use App\Service\MentionManager; use App\Service\TagExtractor; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -class PostNoteFactory +/** + * @implements SwitchableService + * @implements ActivityPubActivityInterface + */ +class PostNoteFactory implements SwitchableService, ActivityFactoryInterface { public function __construct( private readonly UrlGeneratorInterface $urlGenerator, @@ -29,86 +37,87 @@ public function __construct( private readonly MentionsWrapper $mentionsWrapper, private readonly ApHttpClientInterface $client, private readonly ActivityPubManager $activityPubManager, - private readonly MentionManager $mentionManager, private readonly TagExtractor $tagExtractor, private readonly MarkdownConverter $markdownConverter, + private readonly PostUrlFactory $urlFactory, ) { } - public function create(Post $post, array $tags, bool $context = false): array + public function getSupportedTypes(): array + { + return [Post::class]; + } + + /** + * @inheritDoc + * @param Post $subject + */ + public function create($subject, array $tags, bool $context = false): array { if ($context) { $note['@context'] = $this->contextProvider->referencedContexts(); } - if ('random' !== $post->magazine->name && !$post->magazine->apId) { // @todo - $tags[] = $post->magazine->name; + if ('random' !== $subject->magazine->name && !$subject->magazine->apId) { // @todo + $tags[] = $subject->magazine->name; } $body = $this->tagExtractor->joinTagsToBody( - $post->body, + $subject->body, $tags ); $cc = []; - if ($followersUrl = $post->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $post->apId)) { + if ($followersUrl = $subject->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $subject->apId)) { $cc[] = $followersUrl; } $note = array_merge($note ?? [], [ - 'id' => $this->getActivityPubId($post), + 'id' => $this->getActivityPubId($subject), 'type' => 'Note', - 'attributedTo' => $this->activityPubManager->getActorProfileId($post->user), + 'attributedTo' => $this->activityPubManager->getActorProfileId($subject->user), 'inReplyTo' => null, 'to' => [ - $this->groupFactory->getActivityPubId($post->magazine), + $this->groupFactory->getActivityPubId($subject->magazine), ActivityPubActivityInterface::PUBLIC_URL, ], 'cc' => $cc, - 'audience' => $this->groupFactory->getActivityPubId($post->magazine), - 'sensitive' => $post->isAdult(), - 'stickied' => $post->sticky, + 'audience' => $this->groupFactory->getActivityPubId($subject->magazine), + 'sensitive' => $subject->isAdult(), + 'stickied' => $subject->sticky, 'content' => $this->markdownConverter->convertToHtml( $body, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub], ), 'mediaType' => 'text/html', - 'source' => $post->body ? [ + 'source' => $subject->body ? [ 'content' => $body, 'mediaType' => 'text/markdown', ] : null, - 'url' => $this->getActivityPubId($post), + 'url' => $this->getActivityPubId($subject), 'tag' => array_merge( $this->tagsWrapper->build($tags), - $this->mentionsWrapper->build($post->mentions ?? [], $post->body) + $this->mentionsWrapper->build($subject->mentions ?? [], $subject->body) ), - 'commentsEnabled' => !$post->isLocked, - 'published' => $post->createdAt->format(DATE_ATOM), + 'commentsEnabled' => !$subject->isLocked, + 'published' => $subject->createdAt->format(DATE_ATOM), ]); $note['contentMap'] = [ - $post->lang => $note['content'], + $subject->lang => $note['content'], ]; - if ($post->image) { - $note = $this->imageWrapper->build($note, $post->image, $post->getShortTitle()); + if ($subject->image) { + $note = $this->imageWrapper->build($note, $subject->image, $subject->getShortTitle()); } - $note['to'] = array_unique(array_merge($note['to'], $this->activityPubManager->createCcFromBody($post->body))); + $note['to'] = array_unique(array_merge($note['to'], $this->activityPubManager->createCcFromBody($subject->body))); return $note; } public function getActivityPubId(Post $post): string { - if ($post->apId) { - return $post->apId; - } - - return $this->urlGenerator->generate( - 'ap_post', - ['magazine_name' => $post->magazine->name, 'post_id' => $post->getId()], - UrlGeneratorInterface::ABSOLUTE_URL - ); + return $this->urlFactory->getActivityPubId($post); } } diff --git a/src/Factory/ContentManagerFactory.php b/src/Factory/ContentManagerFactory.php deleted file mode 100644 index 0165cf3fcf..0000000000 --- a/src/Factory/ContentManagerFactory.php +++ /dev/null @@ -1,41 +0,0 @@ -entryManager; - } elseif ($subject instanceof EntryComment) { - return $this->entryCommentManager; - } elseif ($subject instanceof Post) { - return $this->postManager; - } elseif ($subject instanceof PostComment) { - return $this->postCommentManager; - } - throw new \LogicException("Unsupported subject type: '".\get_class($subject)."'"); - } -} diff --git a/src/Factory/Contract/ActivityFactoryInterface.php b/src/Factory/Contract/ActivityFactoryInterface.php new file mode 100644 index 0000000000..932a86ae16 --- /dev/null +++ b/src/Factory/Contract/ActivityFactoryInterface.php @@ -0,0 +1,19 @@ + + */ +readonly class EntryCommentDtoFactory implements SwitchableService, ContentDtoFactory +{ + + public function __construct( + private EntryCommentFactory $commentFactory, + ){} + + public function getSupportedTypes(): array + { + return [EntryComment::class]; + } + + public function createResponseDto($subject, array $hashtags): EntryCommentResponseDto + { + return $this->commentFactory->createResponseDto($subject, $hashtags); + } +} diff --git a/src/Factory/Entry/EntryCommentUrlFactory.php b/src/Factory/Entry/EntryCommentUrlFactory.php new file mode 100644 index 0000000000..f788015049 --- /dev/null +++ b/src/Factory/Entry/EntryCommentUrlFactory.php @@ -0,0 +1,48 @@ + + */ +readonly class EntryCommentUrlFactory implements SwitchableService, ContentUrlFactory +{ + + public function __construct( + private UrlGeneratorInterface $urlGenerator, + ){} + + public function getSupportedTypes(): array + { + return [EntryComment::class]; + } + + function getActivityPubId($subject): string + { + if ($subject->apId) { + return $subject->apId; + } + + return $this->urlGenerator->generate('ap_entry_comment', [ + 'magazine_name' => $subject->magazine->name, + 'entry_id' => $subject->entry->getId(), + 'comment_id' => $subject->getId(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + } + + function getLocalUrl($subject): string + { + return $this->urlGenerator->generate('entry_comment_view', [ + 'magazine_name' => $subject->magazine->name, + 'entry_id' => $subject->entry->getId(), + 'slug' => $subject->entry->slug ?? '-', + 'comment_id' => $subject->getId(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + } +} diff --git a/src/Factory/Entry/EntryDtoFactory.php b/src/Factory/Entry/EntryDtoFactory.php new file mode 100644 index 0000000000..1933a8bc38 --- /dev/null +++ b/src/Factory/Entry/EntryDtoFactory.php @@ -0,0 +1,31 @@ + + */ +readonly class EntryDtoFactory implements SwitchableService, ContentDtoFactory +{ + + public function __construct( + private EntryFactory $entryFactory, + ){} + + public function getSupportedTypes(): array + { + return [Entry::class]; + } + + public function createResponseDto($subject, array $hashtags): EntryResponseDto + { + return $this->entryFactory->createResponseDto($subject, $hashtags); + } +} diff --git a/src/Factory/Entry/EntryUrlFactory.php b/src/Factory/Entry/EntryUrlFactory.php new file mode 100644 index 0000000000..ba32b5e5fb --- /dev/null +++ b/src/Factory/Entry/EntryUrlFactory.php @@ -0,0 +1,47 @@ + + */ +readonly class EntryUrlFactory implements SwitchableService, ContentUrlFactory +{ + + public function __construct( + private UrlGeneratorInterface $urlGenerator, + ){} + + public function getSupportedTypes(): array + { + return [Entry::class]; + } + + function getActivityPubId($subject): string + { + if ($subject->apId) { + return $subject->apId; + } + + return $this->urlGenerator->generate( + 'ap_entry', + ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->getId()], + UrlGeneratorInterface::ABSOLUTE_URL + ); + } + + function getLocalUrl($subject): string + { + return $this->urlGenerator->generate('entry_single', [ + 'magazine_name' => $subject->magazine->name, + 'entry_id' => $subject->getId(), + 'slug' => $subject->slug ?? '-' + ], UrlGeneratorInterface::ABSOLUTE_URL); + } +} diff --git a/src/Factory/InMagazineReportUrlFactory.php b/src/Factory/InMagazineReportUrlFactory.php new file mode 100644 index 0000000000..99af700e2c --- /dev/null +++ b/src/Factory/InMagazineReportUrlFactory.php @@ -0,0 +1,32 @@ + + */ +class InMagazineReportUrlFactory implements SwitchableService, ReportUrlFactory +{ + + public function __construct( + private readonly UrlGeneratorInterface $urlGenerator, + ){} + + public function getSupportedTypes(): array + { + return [EntryReport::class, EntryCommentReport::class, PostReport::class, PostCommentReport::class]; + } + + public function getReportUrl($report, string $status): string { + return $this->urlGenerator->generate('magazine_panel_reports', ['name' => $report->magazine->name, 'status' => $status]).'#report-id-'.$report->getId(); + } +} diff --git a/src/Factory/Magazine/MagazineUrlFactory.php b/src/Factory/Magazine/MagazineUrlFactory.php new file mode 100644 index 0000000000..b45a95c5df --- /dev/null +++ b/src/Factory/Magazine/MagazineUrlFactory.php @@ -0,0 +1,44 @@ + + */ +readonly class MagazineUrlFactory implements SwitchableService, ActorUrlFactory +{ + + public function __construct( + private GroupFactory $groupFactory, + private UrlGeneratorInterface $urlGenerator, + ){} + + public function getSupportedTypes(): array + { + return [Magazine::class]; + } + + public function getActivityPubId($actor): string + { + return $this->groupFactory->getActivityPubId($actor); + } + + public function getLocalUrl($actor): string + { + return $this->urlGenerator->generate('front_magazine', ['name' => $actor->name], UrlGeneratorInterface::ABSOLUTE_URL); + } + + public function getAvatarUrl($actor): ?string + { + $slash = $actor->icon && !str_starts_with('/', $actor->icon->filePath) ? '/' : ''; + return $actor->icon ? '/media/cache/resolve/avatar_thumb'.$slash.$actor->icon->filePath : null; + } +} diff --git a/src/Factory/Message/MessageDtoFactory.php b/src/Factory/Message/MessageDtoFactory.php new file mode 100644 index 0000000000..5e6ea3356a --- /dev/null +++ b/src/Factory/Message/MessageDtoFactory.php @@ -0,0 +1,43 @@ + + */ +readonly class MessageDtoFactory implements SwitchableService, ContentDtoFactory +{ + + public function __construct( + private MessageFactory $messageFactory, + ){} + + public function getSupportedTypes(): array + { + return [Message::class]; + } + + public function createResponseDto($subject, array $hashtags): MessageResponseDto + { + return $this->messageFactory->createResponseDto($subject); + } +} diff --git a/src/Factory/Message/MessageUrlFactory.php b/src/Factory/Message/MessageUrlFactory.php new file mode 100644 index 0000000000..1d58c3bbde --- /dev/null +++ b/src/Factory/Message/MessageUrlFactory.php @@ -0,0 +1,43 @@ + + * @implements ReportUrlFactory + */ +readonly class MessageUrlFactory implements SwitchableService, ContentUrlFactory, ReportUrlFactory +{ + + public function __construct( + private UrlGeneratorInterface $urlGenerator, + ){} + + public function getSupportedTypes(): array + { + return [Message::class]; + } + + public function getActivityPubId($subject): string + { + return $this->urlGenerator->generate('ap_message', ['uuid' => $subject->uuid], UrlGeneratorInterface::ABSOLUTE_URL); + } + + public function getLocalUrl($subject): string + { + return $this->urlGenerator->generate('messages_single', ['id' => $subject->thread->getId()], UrlGeneratorInterface::ABSOLUTE_URL); + } + + public function getReportUrl($report, string $status): string + { + return $this->urlGenerator->generate('message_reports', ['status' => $status]).'#report-id-'.$report->getId(); + } +} diff --git a/src/Factory/Post/PostCommentDtoFactory.php b/src/Factory/Post/PostCommentDtoFactory.php new file mode 100644 index 0000000000..aa08300141 --- /dev/null +++ b/src/Factory/Post/PostCommentDtoFactory.php @@ -0,0 +1,37 @@ + + */ +readonly class PostCommentDtoFactory implements SwitchableService, ContentDtoFactory +{ + + public function __construct( + private PostCommentFactory $commentFactory, + ){} + + public function getSupportedTypes(): array + { + return [PostComment::class]; + } + + public function createResponseDto($subject, array $hashtags): PostCommentResponseDto + { + return $this->commentFactory->createResponseDto($subject, $hashtags); + } +} diff --git a/src/Factory/Post/PostCommentUrlFactory.php b/src/Factory/Post/PostCommentUrlFactory.php new file mode 100644 index 0000000000..e8a41ba3b6 --- /dev/null +++ b/src/Factory/Post/PostCommentUrlFactory.php @@ -0,0 +1,44 @@ + + */ +readonly class PostCommentUrlFactory implements SwitchableService, ContentUrlFactory +{ + + public function __construct( + private UrlGeneratorInterface $urlGenerator, + private PostUrlFactory $postUrlFactory, + ){} + + public function getSupportedTypes(): array + { + return [PostComment::class]; + } + + function getActivityPubId($subject): string + { + if ($subject->apId) { + return $subject->apId; + } + + return $this->urlGenerator->generate('ap_post_comment', [ + 'magazine_name' => $subject->magazine->name, + 'post_id' => $subject->post->getId(), + 'comment_id' => $subject->getId(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + } + + function getLocalUrl($subject): string + { + return $this->postUrlFactory->getLocalUrl($subject).'#post-comment-'.$subject->getId(); + } +} diff --git a/src/Factory/Post/PostDtoFactory.php b/src/Factory/Post/PostDtoFactory.php new file mode 100644 index 0000000000..aef1ce2fd5 --- /dev/null +++ b/src/Factory/Post/PostDtoFactory.php @@ -0,0 +1,40 @@ + + */ +readonly class PostDtoFactory implements SwitchableService, ContentDtoFactory +{ + + public function __construct( + private PostFactory $postFactory, + ){} + + public function getSupportedTypes(): array + { + return [Post::class]; + } + + public function createResponseDto($subject, array $hashtags): PostResponseDto + { + return $this->postFactory->createResponseDto($subject, $hashtags); + } +} diff --git a/src/Factory/Post/PostUrlFactory.php b/src/Factory/Post/PostUrlFactory.php new file mode 100644 index 0000000000..9c041f825a --- /dev/null +++ b/src/Factory/Post/PostUrlFactory.php @@ -0,0 +1,48 @@ + + */ +readonly class PostUrlFactory implements SwitchableService, ContentUrlFactory +{ + + public function __construct( + private UrlGeneratorInterface $urlGenerator, + ){} + + public function getSupportedTypes(): array + { + return [Post::class]; + } + + function getActivityPubId($subject): string + { + if ($subject->apId) { + return $subject->apId; + } + + return $this->urlGenerator->generate( + 'ap_post', + ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->getId()], + UrlGeneratorInterface::ABSOLUTE_URL + ); + } + + function getLocalUrl($subject): string + { + return $this->urlGenerator->generate('post_single', [ + 'magazine_name' => $subject->magazine->name, + 'post_id' => $subject->getId(), + 'slug' => empty($subject->slug) ? '-' : $subject->slug, + ], UrlGeneratorInterface::ABSOLUTE_URL); + } +} diff --git a/src/Factory/ReportFactory.php b/src/Factory/ReportFactory.php index 24d96f25f4..f2d6289283 100644 --- a/src/Factory/ReportFactory.php +++ b/src/Factory/ReportFactory.php @@ -6,16 +6,22 @@ use App\DTO\ReportDto; use App\DTO\ReportResponseDto; +use App\Entity\Contracts\HashtagableInterface; use App\Entity\Entry; use App\Entity\EntryComment; use App\Entity\EntryCommentReport; use App\Entity\EntryReport; +use App\Entity\Message; +use App\Entity\MessageReport; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\PostCommentReport; use App\Entity\PostReport; use App\Entity\Report; +use App\Factory\Contract\ContentDtoFactory; use App\Repository\TagLinkRepository; +use App\Service\SwitchingServiceRegistry; +use App\Utils\SqlHelpers; use Doctrine\ORM\EntityManagerInterface; class ReportFactory @@ -24,11 +30,8 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly UserFactory $userFactory, private readonly MagazineFactory $magazineFactory, - private readonly EntryFactory $entryFactory, - private readonly PostFactory $postFactory, - private readonly EntryCommentFactory $entryCommentFactory, - private readonly PostCommentFactory $postCommentFactory, private readonly TagLinkRepository $tagLinkRepository, + private readonly SwitchingServiceRegistry $serviceRegistry, ) { } @@ -36,14 +39,15 @@ public function createFromDto(ReportDto $dto): Report { $className = $this->entityManager->getClassMetadata(\get_class($dto->getSubject()))->name.'Report'; - return new $className($dto->getSubject()->user, $dto->getSubject(), $dto->reason); + return new $className($dto->getSubject()->getUser(), $dto->getSubject(), $dto->reason); } public function createResponseDto(Report $report): ReportResponseDto { + $magazine = $report->magazine !== null ? $this->magazineFactory->createSmallDto($report->magazine) : null; $toReturn = ReportResponseDto::create( $report->getId(), - $this->magazineFactory->createSmallDto($report->magazine), + $magazine, $this->userFactory->createSmallDto($report->reported), $this->userFactory->createSmallDto($report->reporting), $report->reason, @@ -55,26 +59,9 @@ public function createResponseDto(Report $report): ReportResponseDto ); $subject = $report->getSubject(); - switch (\get_class($report)) { - case EntryReport::class: - \assert($subject instanceof Entry); - $toReturn->subject = $this->entryFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfContent($subject)); - break; - case EntryCommentReport::class: - \assert($subject instanceof EntryComment); - $toReturn->subject = $this->entryCommentFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfContent($subject)); - break; - case PostReport::class: - \assert($subject instanceof Post); - $toReturn->subject = $this->postFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfContent($subject)); - break; - case PostCommentReport::class: - \assert($subject instanceof PostComment); - $toReturn->subject = $this->postCommentFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfContent($subject)); - break; - default: - throw new \LogicException(); - } + $hashtags = $subject instanceof HashtagableInterface ? $this->tagLinkRepository->getTagsOfContent($subject) : []; + $factory = $this->serviceRegistry->getService($subject, ContentDtoFactory::class); + $toReturn->subject = $factory->createResponseDto($subject, $hashtags); return $toReturn; } diff --git a/src/Factory/User/UserUrlFactory.php b/src/Factory/User/UserUrlFactory.php new file mode 100644 index 0000000000..220211b1f9 --- /dev/null +++ b/src/Factory/User/UserUrlFactory.php @@ -0,0 +1,46 @@ + + */ +readonly class UserUrlFactory implements SwitchableService, ActorUrlFactory +{ + + public function __construct( + private PersonFactory $personFactory, + private UrlGeneratorInterface $urlGenerator, + ){} + + public function getSupportedTypes(): array + { + return [User::class]; + } + + public function getActivityPubId($actor): string + { + return $this->personFactory->getActivityPubId($actor); + } + + public function getLocalUrl($actor): string + { + return $this->urlGenerator->generate('user_overview', ['username' => $actor->username], UrlGeneratorInterface::ABSOLUTE_URL); + } + + public function getAvatarUrl($actor): ?string + { + $slash = $actor->avatar && !str_starts_with('/', $actor->avatar->filePath) ? '/' : ''; + return $actor->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$actor->avatar->filePath : null; + } +} diff --git a/src/MessageHandler/ActivityPub/Inbox/FlagHandler.php b/src/MessageHandler/ActivityPub/Inbox/FlagHandler.php index 73213af630..59ffb08a65 100644 --- a/src/MessageHandler/ActivityPub/Inbox/FlagHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/FlagHandler.php @@ -13,6 +13,7 @@ use App\MessageHandler\MbinMessageHandler; use App\Repository\EntryCommentRepository; use App\Repository\EntryRepository; +use App\Repository\MessageRepository; use App\Repository\PostCommentRepository; use App\Repository\PostRepository; use App\Service\ActivityPubManager; @@ -35,6 +36,7 @@ public function __construct( private readonly EntryCommentRepository $entryCommentRepository, private readonly PostRepository $postRepository, private readonly PostCommentRepository $postCommentRepository, + private readonly MessageRepository $messageRepository, private readonly SettingsManager $settingsManager, private readonly LoggerInterface $logger, ) { @@ -85,21 +87,11 @@ public function doWork(MessageInterface $message): void private function findRemoteSubject(string $apUrl): ?ReportInterface { - $entry = $this->entryRepository->findOneBy(['apId' => $apUrl]); - $entryComment = null; - $post = null; - $postComment = null; - if (!$entry) { - $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $apUrl]); - } - if (!$entry and !$entryComment) { - $post = $this->postRepository->findOneBy(['apId' => $apUrl]); - } - if (!$entry and !$entryComment and !$post) { - $postComment = $this->postCommentRepository->findOneBy(['apId' => $apUrl]); - } - - return $entry ?? $entryComment ?? $post ?? $postComment; + return $this->entryRepository->findOneBy(['apId' => $apUrl]) + ?? $this->entryCommentRepository->findOneBy(['apId' => $apUrl]) + ?? $this->postRepository->findOneBy(['apId' => $apUrl]) + ?? $this->postCommentRepository->findOneBy(['apId' => $apUrl]) + ?? $this->messageRepository->findOneBy(['apId' => $apUrl]); } private function findLocalSubject(string $apUrl): ?ReportInterface @@ -117,6 +109,9 @@ private function findLocalSubject(string $apUrl): ?ReportInterface if (preg_match_all("/\/m\/([a-zA-Z0-9\-_:@.]+)\/p\/([1-9][0-9]*)/", $apUrl, $matches)) { return $this->postRepository->findOneBy(['id' => $matches[2][0]]); } + if (preg_match_all("/\/message\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/", $apUrl, $matches)) { + return $this->messageRepository->findOneBy(['uuid' => $matches[1][0]]); + } return null; } diff --git a/src/MessageHandler/ActivityPub/Outbox/FlagHandler.php b/src/MessageHandler/ActivityPub/Outbox/FlagHandler.php index 517fff892a..4a3850f537 100644 --- a/src/MessageHandler/ActivityPub/Outbox/FlagHandler.php +++ b/src/MessageHandler/ActivityPub/Outbox/FlagHandler.php @@ -75,14 +75,16 @@ private function getInboxUrls(Report $report): array { $urls = []; - if (null === $report->magazine->apId) { - foreach ($report->magazine->moderators as /* @var Moderator $moderator */ $moderator) { - if ($moderator->user->apId and !\in_array($moderator->user->apInboxUrl, $urls)) { - $urls[] = $moderator->user->apInboxUrl; + if (null !== $report->magazine) { + if (null === $report->magazine->apId) { + foreach ($report->magazine->moderators as /* @var Moderator $moderator */ $moderator) { + if ($moderator->user->apId and !\in_array($moderator->user->apInboxUrl, $urls)) { + $urls[] = $moderator->user->apInboxUrl; + } } + } else { + $urls[] = $report->magazine->apInboxUrl; } - } else { - $urls[] = $report->magazine->apInboxUrl; } if ($report->reported->apId and !\in_array($report->reported->apInboxUrl, $urls)) { diff --git a/src/Repository/MagazineRepository.php b/src/Repository/MagazineRepository.php index 4109411284..279b130a09 100644 --- a/src/Repository/MagazineRepository.php +++ b/src/Repository/MagazineRepository.php @@ -279,7 +279,7 @@ public function findReports( ); try { - $pagerfanta->setMaxPerPage(self::PER_PAGE); + $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); diff --git a/src/Repository/MessageRepository.php b/src/Repository/MessageRepository.php index e21b4896e5..174e01aef2 100644 --- a/src/Repository/MessageRepository.php +++ b/src/Repository/MessageRepository.php @@ -5,9 +5,13 @@ namespace App\Repository; use App\Entity\Message; +use App\Entity\MessageReport; +use App\Entity\Report; use App\PageView\MessageThreadPageView; +use App\Pagination\NativeQueryAdapter; use App\Service\SettingsManager; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\DBAL\ParameterType; use Doctrine\Persistence\ManagerRegistry; use Pagerfanta\Doctrine\ORM\QueryAdapter; use Pagerfanta\Exception\NotValidCurrentPageException; @@ -97,4 +101,37 @@ public function findByApId(string $apId): ?Message return null; } + + public function findReports( + ?int $page = 1, + int $perPage = self::PER_PAGE, + string $status = Report::STATUS_PENDING, + ): PagerfantaInterface { + $dql = 'SELECT r FROM '.MessageReport::class.' r'; + + if (Report::STATUS_ANY !== $status) { + $dql .= ' WHERE r.status = :status'; + } + + $dql .= " ORDER BY CASE WHEN r.status = 'pending' THEN 1 ELSE 2 END, r.weight DESC, r.createdAt DESC"; + + $query = $this->getEntityManager()->createQuery($dql); + + if (Report::STATUS_ANY !== $status) { + $query->setParameter('status', $status); + } + + $pagerfanta = new Pagerfanta( + new QueryAdapter($query) + ); + + try { + $pagerfanta->setMaxPerPage($perPage); + $pagerfanta->setCurrentPage($page); + } catch (NotValidCurrentPageException $e) { + throw new NotFoundHttpException(); + } + + return $pagerfanta; + } } diff --git a/src/Repository/MessageThreadRepository.php b/src/Repository/MessageThreadRepository.php index 8d76fe69e2..27a06f9419 100644 --- a/src/Repository/MessageThreadRepository.php +++ b/src/Repository/MessageThreadRepository.php @@ -6,6 +6,7 @@ use App\Entity\Message; use App\Entity\MessageThread; +use App\Entity\Report; use App\Entity\User; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\Exception; @@ -92,4 +93,13 @@ public function findByParticipants(array $participants): array return []; } + + public function threadContainsReportedMessage(MessageThread $thread): bool { + $sql = 'SELECT EXISTS( SELECT 1 FROM message m INNER JOIN report r ON m.id = r.message_id WHERE m.thread_id = :tId AND (r.status = :statusPending OR r.status = :statusAppeal) );'; + $query = $this->getEntityManager()->getConnection()->prepare($sql); + $query->bindValue('tId', $thread->getId(), ParameterType::INTEGER); + $query->bindValue('statusPending', Report::STATUS_PENDING); + $query->bindValue('statusAppeal', Report::STATUS_APPEAL); + return $query->executeQuery()->fetchFirstColumn()[0]; + } } diff --git a/src/Repository/NotificationRepository.php b/src/Repository/NotificationRepository.php index 0b471b2b78..c5aa472d4f 100644 --- a/src/Repository/NotificationRepository.php +++ b/src/Repository/NotificationRepository.php @@ -7,16 +7,21 @@ use App\Entity\Entry; use App\Entity\EntryComment; use App\Entity\Magazine; +use App\Entity\Message; +use App\Entity\MessageThread; use App\Entity\Notification; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\User; +use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\DBAL\ParameterType; use Doctrine\Persistence\ManagerRegistry; use Pagerfanta\Doctrine\ORM\QueryAdapter; use Pagerfanta\Exception\NotValidCurrentPageException; use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** @@ -158,6 +163,34 @@ public function removePostCommentNotifications(PostComment $comment): void $stmt->executeQuery(); } + public function removeMessageNotifications(Message $message): void + { + $conn = $this->getEntityManager()->getConnection(); + $sql = 'DELETE FROM notification AS n WHERE n.message_id = :messageId'; + + $stmt = $conn->prepare($sql); + $stmt->bindValue('messageId', $message->getId()); + + $stmt->executeQuery(); + } + + public function markMessageNotificationsAsRead(User $user, MessageThread $thread): void { + $conn = $this->getEntityManager()->getConnection(); + $sql = 'UPDATE notification n SET status = :s + WHERE n.user_id = :uId + AND EXISTS( + SELECT 1 FROM report r + INNER JOIN message m ON r.message_id = m.id + INNER JOIN message_thread t ON m.thread_id = t.id + WHERE t.id = :tId AND n.report_id = r.id + )'; + $stmt = $conn->prepare($sql); + $stmt->bindValue('s', Notification::STATUS_READ); + $stmt->bindValue('uId', $user->getId(), ParameterType::INTEGER); + $stmt->bindValue('tId', $thread->getId(), ParameterType::INTEGER); + $stmt->executeQuery(); + } + public function markReportNotificationsAsRead(User $user): void { $conn = $this->getEntityManager()->getConnection(); @@ -184,6 +217,26 @@ public function markReportNotificationsInMagazineAsRead(User $user, Magazine $ma $stmt->executeQuery(); } + public function markReportNotificationsOfMessagesAsRead(User $user, array $reportIds): void + { + $sql = 'UPDATE notification n SET status = :s + WHERE n.user_id = :uId + AND n.report_id IN (:reports)'; + $params = [ + 'uId' => $user->getId(), + 'reports' => $reportIds, + 's' => Notification::STATUS_READ, + ]; + $rewritten = SqlHelpers::rewriteArrayParameters($params, $sql); + $conn = $this->getEntityManager()->getConnection(); + $stmt = $conn->prepare($rewritten['sql']); + + foreach ($rewritten['parameters'] as $param => $value) { + $stmt->bindValue($param, $value, SqlHelpers::getSqlType($value)); + } + $stmt->executeQuery(); + } + public function markOwnReportNotificationsAsRead(User $user): void { $conn = $this->getEntityManager()->getConnection(); diff --git a/src/Repository/ReportRepository.php b/src/Repository/ReportRepository.php index 74c7e297d9..e05845f2cd 100644 --- a/src/Repository/ReportRepository.php +++ b/src/Repository/ReportRepository.php @@ -9,6 +9,8 @@ use App\Entity\EntryComment; use App\Entity\EntryCommentReport; use App\Entity\EntryReport; +use App\Entity\Message; +use App\Entity\MessageReport; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\PostCommentReport; @@ -45,6 +47,7 @@ public function findBySubject(ReportInterface $subject): ?Report $subject instanceof EntryComment => $this->findByEntryComment($subject), $subject instanceof Post => $this->findByPost($subject), $subject instanceof PostComment => $this->findByPostComment($subject), + $subject instanceof Message => $this->findByMessage($subject), default => throw new \LogicException(), }; } @@ -85,6 +88,15 @@ private function findByPostComment(PostComment $comment): ?PostCommentReport ->getOneOrNullResult(); } + private function findByMessage(Message $message): ?MessageReport + { + $dql = 'SELECT r FROM '.MessageReport::class.' r WHERE r.message = :message'; + + return $this->getEntityManager()->createQuery($dql) + ->setParameter('message', $message) + ->getOneOrNullResult(); + } + public function findPendingBySubject(ReportInterface $subject): ?Report { return match (true) { @@ -92,6 +104,7 @@ public function findPendingBySubject(ReportInterface $subject): ?Report $subject instanceof EntryComment => $this->findPendingByEntryComment($subject), $subject instanceof Post => $this->findPendingByPost($subject), $subject instanceof PostComment => $this->findPendingByPostComment($subject), + $subject instanceof Message => $this->findPendingByMessage($subject), default => throw new \LogicException(), }; } @@ -136,6 +149,16 @@ private function findPendingByPostComment(PostComment $comment): ?PostCommentRep ->getOneOrNullResult(); } + private function findPendingByMessage(Message $message): ?PostCommentReport + { + $dql = 'SELECT r FROM '.MessageReport::class.' r WHERE r.message = :comment AND r.status = :status'; + + return $this->getEntityManager()->createQuery($dql) + ->setParameter('message', $message) + ->setParameter('status', Report::STATUS_PENDING) + ->getOneOrNullResult(); + } + public function findAllPaginated(int $page = 1, string $status = Report::STATUS_PENDING): PagerfantaInterface { $dql = 'SELECT r FROM '.Report::class.' r'; diff --git a/src/Repository/TagLinkRepository.php b/src/Repository/TagLinkRepository.php index cc9e0035e1..7416404e33 100644 --- a/src/Repository/TagLinkRepository.php +++ b/src/Repository/TagLinkRepository.php @@ -4,6 +4,7 @@ namespace App\Repository; +use App\Entity\Contracts\HashtagableInterface; use App\Entity\Entry; use App\Entity\EntryComment; use App\Entity\Hashtag; @@ -32,7 +33,7 @@ public function __construct( /** * @return string[] */ - public function getTagsOfContent(Entry|EntryComment|Post|PostComment $content): array + public function getTagsOfContent(HashtagableInterface $content): array { if ($content instanceof Entry) { return $this->getTagsOfEntry($content); @@ -43,7 +44,6 @@ public function getTagsOfContent(Entry|EntryComment|Post|PostComment $content): } elseif ($content instanceof PostComment) { return $this->getTagsOfPostComment($content); } else { - // this is unreachable because of the strict types throw new \LogicException('Cannot handle content of type '.\get_class($content)); } } diff --git a/src/Security/Voter/MessageThreadVoter.php b/src/Security/Voter/MessageThreadVoter.php index f7ae4bbe72..f562a7f9c6 100644 --- a/src/Security/Voter/MessageThreadVoter.php +++ b/src/Security/Voter/MessageThreadVoter.php @@ -6,6 +6,7 @@ use App\Entity\MessageThread; use App\Entity\User; +use App\Repository\MessageThreadRepository; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; @@ -14,6 +15,13 @@ class MessageThreadVoter extends Voter public const SHOW = 'show'; public const REPLY = 'reply'; + public function __construct( + private readonly MessageThreadRepository $messageThreadRepository, + ) + { + + } + protected function supports(string $attribute, $subject): bool { return $subject instanceof MessageThread @@ -45,11 +53,17 @@ private function canShow(MessageThread $thread, User $user): bool return false; } - if (!$thread->userIsParticipant($user)) { - return false; + if ($thread->userIsParticipant($user)) { + return true; } - return true; + if ($user->isModerator() || $user->isAdmin()) { + if($this->messageThreadRepository->threadContainsReportedMessage($thread)) { + return true; + } + } + + return false; } private function canReply(MessageThread $thread, User $user): bool diff --git a/src/Service/ActivityPub/ActivityJsonBuilder.php b/src/Service/ActivityPub/ActivityJsonBuilder.php index 71b7ec682b..d2a18cc5dd 100644 --- a/src/Service/ActivityPub/ActivityJsonBuilder.php +++ b/src/Service/ActivityPub/ActivityJsonBuilder.php @@ -7,6 +7,7 @@ use App\Entity\Activity; use App\Entity\Contracts\ActivityPubActivityInterface; use App\Entity\Contracts\ActivityPubActorInterface; +use App\Entity\Contracts\ContentInterface; use App\Entity\Contracts\ReportInterface; use App\Entity\Entry; use App\Entity\EntryComment; @@ -24,6 +25,8 @@ use App\Factory\ActivityPub\PersonFactory; use App\Factory\ActivityPub\PostCommentNoteFactory; use App\Factory\ActivityPub\PostNoteFactory; +use App\Factory\Contract\ContentUrlFactory; +use App\Service\SwitchingServiceRegistry; use Psr\Log\LoggerInterface; use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -31,19 +34,18 @@ class ActivityJsonBuilder { public function __construct( - private readonly UrlGeneratorInterface $urlGenerator, - private readonly InstanceFactory $instanceFactory, - private readonly PersonFactory $personFactory, - private readonly GroupFactory $groupFactory, - private readonly ActivityFactory $activityFactory, - private readonly ContextsProvider $contextsProvider, - private readonly EntryPageFactory $entryPageFactory, - private readonly EntryCommentNoteFactory $entryCommentNoteFactory, - private readonly PostNoteFactory $postNoteFactory, - private readonly PostCommentNoteFactory $postCommentNoteFactory, - private readonly LoggerInterface $logger, - private readonly ApHttpClientInterface $apHttpClient, - private readonly KernelInterface $kernel, + private readonly UrlGeneratorInterface $urlGenerator, + private readonly InstanceFactory $instanceFactory, + private readonly PersonFactory $personFactory, + private readonly GroupFactory $groupFactory, + private readonly ActivityFactory $activityFactory, + private readonly ContextsProvider $contextsProvider, + private readonly EntryPageFactory $entryPageFactory, + private readonly PostNoteFactory $postNoteFactory, + private readonly SwitchingServiceRegistry $serviceRegistry, + private readonly LoggerInterface $logger, + private readonly ApHttpClientInterface $apHttpClient, + private readonly KernelInterface $kernel, ) { } @@ -304,7 +306,11 @@ public function buildFlagFromActivity(Activity $activity): array // I created an issue for it: https://github.com/LemmyNet/lemmy/issues/4217 $lemmyObject = $this->getPublicUrl($activity->getObject()); - if ('random' !== $activity->audience || $activity->audience->apId) { + if (null === $activity->audience) { + // likely a message and so can either be from Lemmy or Mastodon or something else; defaulting to Lemmy + $audience = $this->personFactory->getActivityPubId($activity->objectUser); + $object = $lemmyObject; + } elseif ('random' !== $activity->audience || $activity->audience->apId) { // apAttributedToUrl is not a standardized field, // so it is not implemented by every software that supports groups. // Some don't have moderation at all, so it will probably remain optional in the future. @@ -326,7 +332,7 @@ public function buildFlagFromActivity(Activity $activity): array 'content' => $activity->contentString, ]; - if ('random' !== $activity->audience->name || $activity->audience->apId) { + if ($activity->audience !== null && ('random' !== $activity->audience->name || $activity->audience->apId)) { $result['to'] = [$this->groupFactory->getActivityPubId($activity->audience)]; } @@ -517,20 +523,8 @@ private function buildLockFromActivity(Activity $activity): array ]; } - public function getPublicUrl(ReportInterface|ActivityPubActivityInterface $subject): string + public function getPublicUrl(ContentInterface $subject): string { - if ($subject instanceof Entry) { - return $this->entryPageFactory->getActivityPubId($subject); - } elseif ($subject instanceof EntryComment) { - return $this->entryCommentNoteFactory->getActivityPubId($subject); - } elseif ($subject instanceof Post) { - return $this->postNoteFactory->getActivityPubId($subject); - } elseif ($subject instanceof PostComment) { - return $this->postCommentNoteFactory->getActivityPubId($subject); - } elseif ($subject instanceof Message) { - return $this->urlGenerator->generate('ap_message', ['uuid' => $subject->uuid], UrlGeneratorInterface::ABSOLUTE_URL); - } - - throw new \LogicException("can't handle ".\get_class($subject)); + return $this->serviceRegistry->getService($subject, ContentUrlFactory::class)->getActivityPubId($subject); } } diff --git a/src/Service/Contracts/ContentManagerInterface.php b/src/Service/Contracts/ContentManagerInterface.php index f3e9539f70..0e0d725331 100644 --- a/src/Service/Contracts/ContentManagerInterface.php +++ b/src/Service/Contracts/ContentManagerInterface.php @@ -4,6 +4,14 @@ namespace App\Service\Contracts; +use App\Entity\Contracts\ContentInterface; +use App\Entity\Contracts\ContentVisibilityInterface; +use App\Entity\User; + interface ContentManagerInterface extends ManagerInterface { + + function restore(User $moderator, ContentVisibilityInterface $content): void; + + function delete(User $moderator, ContentInterface $subject): void; } diff --git a/src/Service/Contracts/ContentNotificationManagerInterface.php b/src/Service/Contracts/ContentNotificationManagerInterface.php index 9cc9ac5fb0..22e4563bae 100644 --- a/src/Service/Contracts/ContentNotificationManagerInterface.php +++ b/src/Service/Contracts/ContentNotificationManagerInterface.php @@ -13,4 +13,8 @@ public function sendCreated(ContentInterface $subject): void; public function sendEdited(ContentInterface $subject): void; public function sendDeleted(ContentInterface $subject): void; + + public function purgeNotifications(ContentInterface $subject): void; + + public function purgeMagazineLog(ContentInterface $subject): void; } diff --git a/src/Service/Contracts/SwitchableService.php b/src/Service/Contracts/SwitchableService.php new file mode 100644 index 0000000000..dcfd95ca82 --- /dev/null +++ b/src/Service/Contracts/SwitchableService.php @@ -0,0 +1,14 @@ +apDomain && $user->apDomain !== parse_url($comment->apId ?? '', PHP_URL_HOST) && !$comment->magazine->userIsModerator($user)) { - $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m]', ['u' => $user->apId, 'm' => $comment->magazine->apId ?? $comment->magazine->name]); + $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m}', ['u' => $user->apId, 'm' => $comment->magazine->apId ?? $comment->magazine->name]); return; } @@ -225,7 +238,12 @@ private function isTrashed(User $user, EntryComment $comment): bool return !$comment->isAuthor($user); } - public function restore(User $user, EntryComment $comment): void + /** + * @param User $user + * @param EntryComment $comment + * @return void + */ + public function restore(User $user, ContentVisibilityInterface $comment): void { if (VisibilityInterface::VISIBILITY_TRASHED !== $comment->visibility) { throw new \Exception('Invalid visibility'); diff --git a/src/Service/EntryManager.php b/src/Service/EntryManager.php index 1f0dc731c8..d73d321c7c 100644 --- a/src/Service/EntryManager.php +++ b/src/Service/EntryManager.php @@ -5,6 +5,8 @@ namespace App\Service; use App\DTO\EntryDto; +use App\Entity\Contracts\ContentInterface; +use App\Entity\Contracts\ContentVisibilityInterface; use App\Entity\Contracts\VisibilityInterface; use App\Entity\Entry; use App\Entity\Magazine; @@ -32,6 +34,7 @@ use App\Repository\ImageRepository; use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\Contracts\ContentManagerInterface; +use App\Service\Contracts\SwitchableService; use App\Utils\Slugger; use App\Utils\UrlCleaner; use Doctrine\Common\Collections\Criteria; @@ -46,7 +49,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; use Webmozart\Assert\Assert; -class EntryManager implements ContentManagerInterface +class EntryManager implements SwitchableService, ContentManagerInterface { public function __construct( private readonly LoggerInterface $logger, @@ -71,6 +74,11 @@ public function __construct( ) { } + public function getSupportedTypes(): array + { + return [Entry::class]; + } + /** * @throws TagBannedException * @throws UserBannedException @@ -242,10 +250,15 @@ public function edit(Entry $entry, EntryDto $dto, User $editedBy): Entry return $entry; } - public function delete(User $user, Entry $entry): void + /** + * @param User $user + * @param Entry $entry + * @return void + */ + public function delete(User $user, ContentInterface $entry): void { if ($user->apDomain && $user->apDomain !== parse_url($entry->apId ?? '', PHP_URL_HOST) && !$entry->magazine->userIsModerator($user)) { - $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m]', ['u' => $user->apId, 'm' => $entry->magazine->apId ?? $entry->magazine->name]); + $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m}', ['u' => $user->apId, 'm' => $entry->magazine->apId ?? $entry->magazine->name]); return; } @@ -295,7 +308,12 @@ public function purge(User $user, Entry $entry): void } } - public function restore(User $user, Entry $entry): void + /** + * @param User $user + * @param Entry $entry + * @return void + */ + public function restore(User $user, ContentVisibilityInterface $entry): void { if (VisibilityInterface::VISIBILITY_TRASHED !== $entry->visibility) { throw new \Exception('Invalid visibility'); diff --git a/src/Service/FeedManager.php b/src/Service/FeedManager.php index c7bd9b2446..f53637407a 100644 --- a/src/Service/FeedManager.php +++ b/src/Service/FeedManager.php @@ -6,6 +6,8 @@ use App\Entity\Entry; use App\Entity\Post; +use App\Factory\Entry\EntryUrlFactory; +use App\Factory\Post\PostUrlFactory; use App\Markdown\MarkdownConverter; use App\PageView\ContentPageView; use App\Repository\ContentRepository; @@ -32,6 +34,8 @@ public function __construct( private readonly MagazineRepository $magazineRepository, private readonly UserRepository $userRepository, private readonly TagLinkRepository $tagLinkRepository, + private readonly EntryUrlFactory $entryUrlFactory, + private readonly PostUrlFactory $postUrlFactory, private readonly UrlGeneratorInterface $urlGenerator, private readonly Security $security, private readonly MediaExtensionRuntime $mediaExtensionRuntime, @@ -98,11 +102,7 @@ public function getItems(iterable $content): \Generator } if ($subject instanceof Entry) { - $link = $this->urlGenerator->generate('entry_single', [ - 'magazine_name' => $subject->magazine->name, - 'entry_id' => $subject->getId(), - 'slug' => $subject->slug, - ], UrlGeneratorInterface::ABSOLUTE_URL); + $link = $this->entryUrlFactory->getLocalUrl($subject); $item->setContent($this->markdownConverter->convertToHtml($subject->body ?? '', 'entry')); $item->setSummary($subject->getShortDesc()); @@ -110,11 +110,7 @@ public function getItems(iterable $content): \Generator $item->setLink($link); $item->set('comments', $link.'#comments'); } elseif ($subject instanceof Post) { - $link = $this->urlGenerator->generate('post_single', [ - 'magazine_name' => $subject->magazine->name, - 'post_id' => $subject->getId(), - 'slug' => $subject->slug, - ], UrlGeneratorInterface::ABSOLUTE_URL); + $link = $this->postUrlFactory->getLocalUrl($subject); $item->setContent($this->markdownConverter->convertToHtml($subject->body ?? '', 'post')); $item->setSummary($subject->getShortTitle()); diff --git a/src/Service/MessageManager.php b/src/Service/MessageManager.php index 2c45b6a477..af9e075f27 100644 --- a/src/Service/MessageManager.php +++ b/src/Service/MessageManager.php @@ -5,6 +5,8 @@ namespace App\Service; use App\DTO\MessageDto; +use App\Entity\Contracts\ContentInterface; +use App\Entity\Contracts\ContentVisibilityInterface; use App\Entity\Message; use App\Entity\MessageThread; use App\Entity\User; @@ -15,13 +17,15 @@ use App\Exception\UserDeletedException; use App\Message\ActivityPub\Outbox\CreateMessage; use App\Repository\MessageThreadRepository; +use App\Service\Contracts\ContentManagerInterface; +use App\Service\Contracts\SwitchableService; use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\MessageBusInterface; -class MessageManager +class MessageManager implements SwitchableService, ContentManagerInterface { public function __construct( private readonly MessageThreadRepository $messageThreadRepository, @@ -33,6 +37,31 @@ public function __construct( ) { } + public function getSupportedTypes(): array + { + return [Message::class]; + } + + public function delete(User $moderator, ContentInterface $subject): void + { + \assert($subject instanceof Message); + + if ($moderator->apDomain && $moderator->apDomain !== parse_url($subject->apId ?? '', PHP_URL_HOST)) { + $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted message', ['u' => $moderator->apId]); + + return; + } + + // we currently have no way to soft-delete messages, so just replace the content + $subject->body = '[deleted]'; + $this->entityManager->flush(); + } + + public function restore(User $moderator, ContentVisibilityInterface $content): void + { + // not implemented, as we currently have no way to soft-delete messages + } + public function toThread(MessageDto $dto, User $sender, User ...$receivers): MessageThread { $thread = new MessageThread($sender, ...$receivers); diff --git a/src/Service/Notification/EntryCommentNotificationManager.php b/src/Service/Notification/EntryCommentNotificationManager.php index 0381ec8672..f242713593 100644 --- a/src/Service/Notification/EntryCommentNotificationManager.php +++ b/src/Service/Notification/EntryCommentNotificationManager.php @@ -21,6 +21,7 @@ use App\Repository\NotificationSettingsRepository; use App\Repository\UserRepository; use App\Service\Contracts\ContentNotificationManagerInterface; +use App\Service\Contracts\SwitchableService; use App\Service\GenerateHtmlClassService; use App\Service\ImageManager; use App\Service\ImageManagerInterface; @@ -34,7 +35,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Environment; -class EntryCommentNotificationManager implements ContentNotificationManagerInterface +class EntryCommentNotificationManager implements SwitchableService, ContentNotificationManagerInterface { use NotificationTrait; @@ -58,6 +59,11 @@ public function __construct( ) { } + public function getSupportedTypes(): array + { + return [EntryComment::class]; + } + public function sendCreated(ContentInterface $subject): void { if ($subject->user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) { @@ -198,16 +204,22 @@ public function sendDeleted(ContentInterface $subject): void if (!$subject instanceof EntryComment) { throw new \LogicException(); } - $this->notifyMagazine($notification = new EntryCommentDeletedNotification($subject->user, $subject)); + $this->notifyMagazine(new EntryCommentDeletedNotification($subject->user, $subject)); } - public function purgeNotifications(EntryComment $comment): void + public function purgeNotifications(ContentInterface $subject): void { - $this->notificationRepository->removeEntryCommentNotifications($comment); + if (!$subject instanceof EntryComment) { + throw new \LogicException(); + } + $this->notificationRepository->removeEntryCommentNotifications($subject); } - public function purgeMagazineLog(EntryComment $comment): void + public function purgeMagazineLog(ContentInterface $subject): void { - $this->magazineLogRepository->removeEntryCommentLogs($comment); + if (!$subject instanceof EntryComment) { + throw new \LogicException(); + } + $this->magazineLogRepository->removeEntryCommentLogs($subject); } } diff --git a/src/Service/Notification/EntryNotificationManager.php b/src/Service/Notification/EntryNotificationManager.php index 044914dcb7..51182cfeff 100644 --- a/src/Service/Notification/EntryNotificationManager.php +++ b/src/Service/Notification/EntryNotificationManager.php @@ -20,6 +20,7 @@ use App\Repository\NotificationSettingsRepository; use App\Repository\UserRepository; use App\Service\Contracts\ContentNotificationManagerInterface; +use App\Service\Contracts\SwitchableService; use App\Service\GenerateHtmlClassService; use App\Service\ImageManager; use App\Service\ImageManagerInterface; @@ -34,7 +35,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Environment; -class EntryNotificationManager implements ContentNotificationManagerInterface +class EntryNotificationManager implements SwitchableService, ContentNotificationManagerInterface { use NotificationTrait; @@ -58,6 +59,11 @@ public function __construct( ) { } + public function getSupportedTypes(): array + { + return [Entry::class]; + } + public function sendCreated(ContentInterface $subject): void { if ($subject->user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) { @@ -165,16 +171,22 @@ public function sendDeleted(ContentInterface $subject): void if (!$subject instanceof Entry) { throw new \LogicException(); } - $this->notifyMagazine($notification = new EntryDeletedNotification($subject->user, $subject)); + $this->notifyMagazine(new EntryDeletedNotification($subject->user, $subject)); } - public function purgeNotifications(Entry $entry): void + public function purgeNotifications(ContentInterface $subject): void { - $this->notificationRepository->removeEntryNotifications($entry); + if (!$subject instanceof Entry) { + throw new \LogicException(); + } + $this->notificationRepository->removeEntryNotifications($subject); } - public function purgeMagazineLog(Entry $entry): void + public function purgeMagazineLog(ContentInterface $subject): void { - $this->magazineLogRepository->removeEntryLogs($entry); + if (!$subject instanceof Entry) { + throw new \LogicException(); + } + $this->magazineLogRepository->removeEntryLogs($subject); } } diff --git a/src/Service/Notification/MessageNotificationManager.php b/src/Service/Notification/MessageNotificationManager.php index d8d00d27d5..f1652fca38 100644 --- a/src/Service/Notification/MessageNotificationManager.php +++ b/src/Service/Notification/MessageNotificationManager.php @@ -4,29 +4,37 @@ namespace App\Service\Notification; +use App\Entity\Contracts\ContentInterface; use App\Entity\Message; use App\Entity\MessageNotification; +use App\Entity\Notification; use App\Entity\User; use App\Event\NotificationCreatedEvent; use App\Factory\MagazineFactory; use App\Repository\MagazineSubscriptionRepository; +use App\Repository\NotificationRepository; +use App\Service\Contracts\ContentNotificationManagerInterface; +use App\Service\Contracts\SwitchableService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Mercure\HubInterface; -class MessageNotificationManager +class MessageNotificationManager implements SwitchableService, ContentNotificationManagerInterface { use NotificationTrait; public function __construct( private readonly EventDispatcherInterface $eventDispatcher, - private readonly MagazineSubscriptionRepository $repository, - private readonly MagazineFactory $magazineFactory, - private readonly HubInterface $publisher, + private readonly NotificationRepository $notificationRepository, private readonly EntityManagerInterface $entityManager, ) { } + public function getSupportedTypes(): array + { + return [Message::class]; + } + public function send(Message $message, User $sender): void { $thread = $message->thread; @@ -40,4 +48,37 @@ public function send(Message $message, User $sender): void $this->entityManager->flush(); } + + public function sendCreated(ContentInterface $subject): void + { + // not supported + } + + private function notifyMagazine(Notification $notification): void + { + // not supported + } + + public function sendEdited(ContentInterface $subject): void + { + // not supported + } + + public function sendDeleted(ContentInterface $subject): void + { + // not supported + } + + public function purgeNotifications(ContentInterface $subject): void + { + if (!$subject instanceof Message) { + throw new \LogicException(); + } + $this->notificationRepository->removeMessageNotifications($subject); + } + + public function purgeMagazineLog(ContentInterface $subject): void + { + // not supported + } } diff --git a/src/Service/Notification/PostCommentNotificationManager.php b/src/Service/Notification/PostCommentNotificationManager.php index be18e2578a..b6ae8c3414 100644 --- a/src/Service/Notification/PostCommentNotificationManager.php +++ b/src/Service/Notification/PostCommentNotificationManager.php @@ -21,6 +21,7 @@ use App\Repository\NotificationSettingsRepository; use App\Repository\UserRepository; use App\Service\Contracts\ContentNotificationManagerInterface; +use App\Service\Contracts\SwitchableService; use App\Service\GenerateHtmlClassService; use App\Service\ImageManager; use App\Service\ImageManagerInterface; @@ -34,7 +35,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Environment; -class PostCommentNotificationManager implements ContentNotificationManagerInterface +class PostCommentNotificationManager implements SwitchableService, ContentNotificationManagerInterface { use NotificationTrait; @@ -58,6 +59,11 @@ public function __construct( ) { } + public function getSupportedTypes(): array + { + return [PostComment::class]; + } + public function sendCreated(ContentInterface $subject): void { if ($subject->user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) { @@ -196,16 +202,22 @@ public function sendDeleted(ContentInterface $subject): void if (!$subject instanceof PostComment) { throw new \LogicException(); } - $this->notifyMagazine($notification = new PostCommentDeletedNotification($subject->user, $subject)); + $this->notifyMagazine(new PostCommentDeletedNotification($subject->user, $subject)); } - public function purgeNotifications(PostComment $comment): void + public function purgeNotifications(ContentInterface $subject): void { - $this->notificationRepository->removePostCommentNotifications($comment); + if (!$subject instanceof PostComment) { + throw new \LogicException(); + } + $this->notificationRepository->removePostCommentNotifications($subject); } - public function purgeMagazineLog(PostComment $comment): void + public function purgeMagazineLog(ContentInterface $subject): void { - $this->magazineLogRepository->removePostCommentLogs($comment); + if (!$subject instanceof PostComment) { + throw new \LogicException(); + } + $this->magazineLogRepository->removePostCommentLogs($subject); } } diff --git a/src/Service/Notification/PostNotificationManager.php b/src/Service/Notification/PostNotificationManager.php index 7f71daed13..d2fa355969 100644 --- a/src/Service/Notification/PostNotificationManager.php +++ b/src/Service/Notification/PostNotificationManager.php @@ -19,6 +19,7 @@ use App\Repository\NotificationSettingsRepository; use App\Repository\UserRepository; use App\Service\Contracts\ContentNotificationManagerInterface; +use App\Service\Contracts\SwitchableService; use App\Service\GenerateHtmlClassService; use App\Service\ImageManager; use App\Service\ImageManagerInterface; @@ -32,7 +33,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Environment; -class PostNotificationManager implements ContentNotificationManagerInterface +class PostNotificationManager implements SwitchableService, ContentNotificationManagerInterface { use NotificationTrait; @@ -55,6 +56,11 @@ public function __construct( ) { } + public function getSupportedTypes(): array + { + return [Post::class]; + } + public function sendCreated(ContentInterface $subject): void { if ($subject->user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) { @@ -156,16 +162,22 @@ public function sendDeleted(ContentInterface $subject): void if (!$subject instanceof Post) { throw new \LogicException(); } - $this->notifyMagazine($notification = new PostDeletedNotification($subject->user, $subject)); + $this->notifyMagazine(new PostDeletedNotification($subject->user, $subject)); } - public function purgeNotifications(Post $post): void + public function purgeNotifications(ContentInterface $subject): void { - $this->notificationRepository->removePostNotifications($post); + if (!$subject instanceof Post) { + throw new \LogicException(); + } + $this->notificationRepository->removePostNotifications($subject); } - public function purgeMagazineLog(Post $post): void + public function purgeMagazineLog(ContentInterface $subject): void { - $this->magazineLogRepository->removePostLogs($post); + if (!$subject instanceof Post) { + throw new \LogicException(); + } + $this->magazineLogRepository->removePostLogs($subject); } } diff --git a/src/Service/Notification/ReportNotificationManager.php b/src/Service/Notification/ReportNotificationManager.php index 6a0ec25524..28f23d852f 100644 --- a/src/Service/Notification/ReportNotificationManager.php +++ b/src/Service/Notification/ReportNotificationManager.php @@ -11,6 +11,7 @@ use App\Event\NotificationCreatedEvent; use App\Repository\UserRepository; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; class ReportNotificationManager @@ -25,9 +26,12 @@ public function __construct( public function sendReportCreatedNotification(Report $report): void { $receivers = []; - foreach ($report->magazine->moderators as /* @var Moderator $moderator */ $moderator) { - if (null === $moderator->user->apId) { - $receivers[] = $moderator->user; + + if($report->magazine !== null) { + foreach ($report->magazine->moderators as /* @var Moderator $moderator */ $moderator) { + if (null === $moderator->user->apId) { + $receivers[] = $moderator->user; + } } } diff --git a/src/Service/Notification/UserPushSubscriptionManager.php b/src/Service/Notification/UserPushSubscriptionManager.php index 7db160b7fa..877ce3db09 100644 --- a/src/Service/Notification/UserPushSubscriptionManager.php +++ b/src/Service/Notification/UserPushSubscriptionManager.php @@ -6,6 +6,7 @@ use App\Entity\Notification; use App\Entity\User; +use App\Kernel; use App\Payloads\PushNotification; use App\Repository\SiteRepository; use App\Repository\UserPushSubscriptionRepository; @@ -16,20 +17,27 @@ use Minishlink\WebPush\Subscription; use Minishlink\WebPush\WebPush; use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Container; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; class UserPushSubscriptionManager { + + private readonly ContainerInterface $serviceContainer; + public function __construct( private readonly SettingsManager $settingsManager, private readonly SiteRepository $siteRepository, private readonly UserPushSubscriptionRepository $pushSubscriptionRepository, private readonly TranslatorInterface $translator, - private readonly UrlGeneratorInterface $urlGenerator, private readonly LoggerInterface $logger, private readonly EntityManagerInterface $entityManager, + Kernel $kernel, ) { + $this->serviceContainer = $kernel->getContainer(); } /** @@ -48,7 +56,7 @@ public function sendTextToUser(User $user, PushNotification|Notification $pushNo $subs = $this->pushSubscriptionRepository->findBy($criteria); foreach ($subs as $sub) { if ($pushNotification instanceof Notification) { - $toSend = $pushNotification->getMessage($this->translator, $sub->locale ?? $this->settingsManager->get('KBIN_DEFAULT_LANG'), $this->urlGenerator); + $toSend = $pushNotification->getMessage($this->translator, $sub->locale ?? $this->settingsManager->get('KBIN_DEFAULT_LANG'), $this->serviceContainer); } elseif ($pushNotification instanceof PushNotification) { $toSend = $pushNotification; } else { diff --git a/src/Service/NotificationManager.php b/src/Service/NotificationManager.php index 4901b6e8b9..6cf931b672 100644 --- a/src/Service/NotificationManager.php +++ b/src/Service/NotificationManager.php @@ -10,6 +10,7 @@ use App\Entity\MessageNotification; use App\Entity\Notification; use App\Entity\User; +use App\Service\Contracts\ContentNotificationManagerInterface; use App\Service\Notification\MagazineBanNotificationManager; use App\Service\Notification\MessageNotificationManager; use Doctrine\ORM\EntityManagerInterface; @@ -17,7 +18,7 @@ class NotificationManager { public function __construct( - private readonly NotificationManagerTypeResolver $resolver, + private readonly SwitchingServiceRegistry $serviceRegistry, private readonly MessageNotificationManager $messageNotificationManager, private readonly EntityManagerInterface $entityManager, private readonly MagazineBanNotificationManager $magazineBanNotificationManager, @@ -26,17 +27,17 @@ public function __construct( public function sendCreated(ContentInterface $subject): void { - $this->resolver->resolve($subject)->sendCreated($subject); + $this->serviceRegistry->getService($subject, ContentNotificationManagerInterface::class)->sendCreated($subject); } public function sendEdited(ContentInterface $subject): void { - $this->resolver->resolve($subject)->sendEdited($subject); + $this->serviceRegistry->getService($subject, ContentNotificationManagerInterface::class)->sendEdited($subject); } public function sendDeleted(ContentInterface $subject): void { - $this->resolver->resolve($subject)->sendDeleted($subject); + $this->serviceRegistry->getService($subject, ContentNotificationManagerInterface::class)->sendDeleted($subject); } public function sendMessageNotification(Message $message, User $sender): void diff --git a/src/Service/NotificationManagerTypeResolver.php b/src/Service/NotificationManagerTypeResolver.php deleted file mode 100644 index 951544255d..0000000000 --- a/src/Service/NotificationManagerTypeResolver.php +++ /dev/null @@ -1,38 +0,0 @@ - $this->entryNotificationManager, - $subject instanceof EntryComment => $this->entryCommentNotificationManager, - $subject instanceof Post => $this->postNotificationManager, - $subject instanceof PostComment => $this->postCommentNotificationManager, - default => throw new \LogicException(), - }; - } -} diff --git a/src/Service/PostCommentManager.php b/src/Service/PostCommentManager.php index 8e567dec70..4503bc4238 100644 --- a/src/Service/PostCommentManager.php +++ b/src/Service/PostCommentManager.php @@ -5,6 +5,8 @@ namespace App\Service; use App\DTO\PostCommentDto; +use App\Entity\Contracts\ContentInterface; +use App\Entity\Contracts\ContentVisibilityInterface; use App\Entity\Contracts\VisibilityInterface; use App\Entity\PostComment; use App\Entity\User; @@ -23,6 +25,7 @@ use App\Message\DeleteImageMessage; use App\Repository\ImageRepository; use App\Service\Contracts\ContentManagerInterface; +use App\Service\Contracts\SwitchableService; use Doctrine\ORM\EntityManagerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -31,7 +34,7 @@ use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use Webmozart\Assert\Assert; -class PostCommentManager implements ContentManagerInterface +class PostCommentManager implements SwitchableService, ContentManagerInterface { public function __construct( private readonly LoggerInterface $logger, @@ -48,6 +51,11 @@ public function __construct( ) { } + public function getSupportedTypes(): array + { + return [PostComment::class]; + } + /** * @throws TagBannedException * @throws UserBannedException @@ -170,10 +178,15 @@ public function edit(PostComment $comment, PostCommentDto $dto, ?User $editedBy return $comment; } - public function delete(User $user, PostComment $comment): void + /** + * @param User $user + * @param PostComment $comment + * @return void + */ + public function delete(User $user, ContentInterface $comment): void { if ($user->apDomain && $user->apDomain !== parse_url($comment->apId ?? '', PHP_URL_HOST) && !$comment->magazine->userIsModerator($user)) { - $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m]', ['u' => $user->apId, 'm' => $comment->magazine->apId ?? $comment->magazine->name]); + $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m}', ['u' => $user->apId, 'm' => $comment->magazine->apId ?? $comment->magazine->name]); return; } @@ -227,9 +240,12 @@ private function isTrashed(User $user, PostComment $comment): bool } /** + * @param User $user + * @param PostComment $comment + * @return void * @throws \Exception */ - public function restore(User $user, PostComment $comment): void + public function restore(User $user, ContentVisibilityInterface $comment): void { if (VisibilityInterface::VISIBILITY_TRASHED !== $comment->visibility) { throw new \Exception('Invalid visibility'); diff --git a/src/Service/PostManager.php b/src/Service/PostManager.php index e40cb99449..3e6618df80 100644 --- a/src/Service/PostManager.php +++ b/src/Service/PostManager.php @@ -5,6 +5,8 @@ namespace App\Service; use App\DTO\PostDto; +use App\Entity\Contracts\ContentInterface; +use App\Entity\Contracts\ContentVisibilityInterface; use App\Entity\Contracts\VisibilityInterface; use App\Entity\Magazine; use App\Entity\MagazineLogPostLocked; @@ -27,6 +29,7 @@ use App\Repository\PostRepository; use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\Contracts\ContentManagerInterface; +use App\Service\Contracts\SwitchableService; use App\Utils\Slugger; use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Order; @@ -40,7 +43,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; use Webmozart\Assert\Assert; -class PostManager implements ContentManagerInterface +class PostManager implements SwitchableService, ContentManagerInterface { public function __construct( private readonly LoggerInterface $logger, @@ -63,6 +66,11 @@ public function __construct( ) { } + public function getSupportedTypes(): array + { + return [Post::class]; + } + /** * @throws TagBannedException * @throws UserBannedException @@ -179,10 +187,15 @@ public function edit(Post $post, PostDto $dto, ?User $editedBy = null): Post return $post; } - public function delete(User $user, Post $post): void + /** + * @param User $user + * @param Post $post + * @return void + */ + public function delete(User $user, ContentInterface $post): void { if ($user->apDomain && $user->apDomain !== parse_url($post->apId ?? '', PHP_URL_HOST) && !$post->magazine->userIsModerator($user)) { - $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m]', ['u' => $user->apId, 'm' => $post->magazine->apId ?? $post->magazine->name]); + $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m}', ['u' => $user->apId, 'm' => $post->magazine->apId ?? $post->magazine->name]); return; } @@ -237,7 +250,12 @@ private function isTrashed(User $user, Post $post): bool return !$post->isAuthor($user); } - public function restore(User $user, Post $post): void + /** + * @param User $user + * @param Post $post + * @return void + */ + public function restore(User $user, ContentVisibilityInterface $post): void { if (VisibilityInterface::VISIBILITY_TRASHED !== $post->visibility) { throw new \Exception('Invalid visibility'); diff --git a/src/Service/ReportManager.php b/src/Service/ReportManager.php index 499ea5436d..23d623acec 100644 --- a/src/Service/ReportManager.php +++ b/src/Service/ReportManager.php @@ -5,15 +5,17 @@ namespace App\Service; use App\DTO\ReportDto; +use App\Entity\Contracts\ContentInterface; +use App\Entity\Contracts\ContentVisibilityInterface; use App\Entity\Report; use App\Entity\User; use App\Event\Report\ReportApprovedEvent; use App\Event\Report\ReportRejectedEvent; use App\Event\Report\SubjectReportedEvent; use App\Exception\SubjectHasBeenReportedException; -use App\Factory\ContentManagerFactory; use App\Factory\ReportFactory; use App\Repository\ReportRepository; +use App\Service\Contracts\ContentManagerInterface; use Doctrine\ORM\EntityManagerInterface; use Psr\EventDispatcher\EventDispatcherInterface; @@ -24,7 +26,7 @@ public function __construct( private readonly ReportRepository $repository, private readonly EventDispatcherInterface $dispatcher, private readonly EntityManagerInterface $entityManager, - private readonly ContentManagerFactory $managerFactory, + private readonly SwitchingServiceRegistry $serviceRegistry, ) { } @@ -51,14 +53,14 @@ public function report(ReportDto $dto, User $reporting): Report public function reject(Report $report, User $moderator): void { - $manager = $this->managerFactory->createManager($report->getSubject()); - $report->status = Report::STATUS_REJECTED; $report->consideredBy = $moderator; $report->consideredAt = new \DateTimeImmutable(); - if ($report->getSubject()->isTrashed()) { - $manager->restore($moderator, $report->getSubject()); + $subject = $report->getSubject(); + if ($subject instanceof ContentVisibilityInterface && $subject->isTrashed()) { + $manager = $this->serviceRegistry->getService($subject, ContentManagerInterface::class); + $manager->restore($moderator, $subject); } $this->entityManager->flush(); @@ -68,13 +70,15 @@ public function reject(Report $report, User $moderator): void public function accept(Report $report, User $moderator): void { - $manager = $this->managerFactory->createManager($report->getSubject()); - $report->status = Report::STATUS_APPROVED; $report->consideredBy = $moderator; $report->consideredAt = new \DateTimeImmutable(); - $manager->delete($moderator, $report->getSubject()); + $subject = $report->getSubject(); + if($subject instanceof ContentInterface) { + $manager = $this->serviceRegistry->getService($subject, ContentManagerInterface::class); + $manager->delete($moderator, $subject); + } $this->entityManager->flush(); diff --git a/src/Service/SwitchingServiceRegistry.php b/src/Service/SwitchingServiceRegistry.php new file mode 100644 index 0000000000..00722d5189 --- /dev/null +++ b/src/Service/SwitchingServiceRegistry.php @@ -0,0 +1,71 @@ +services = [...$services]; + } + + /** + * @template S of SwitchableService + * @template O of object + * @param O $object + * @param class-string $interface + * @return S + */ + public function getService(object $object, string $interface): object { + $objectClass = \get_class($object); + if(isset($this->cache[$objectClass][$interface])) { + return $this->cache[$objectClass][$interface]; + } + + $closestImpl = null; + foreach ($this->services as $impl) { + if(!\is_subclass_of($impl, $interface)) continue; + foreach ($impl->getSupportedTypes() as $supportedClass) { + if ($objectClass === $supportedClass || \is_subclass_of($object, $supportedClass)) { + $dist = $this->parentDistance($objectClass, $supportedClass); + if ($closestImpl === null || $closestImpl[0] > $dist) { + $closestImpl = [$dist, $impl]; + } + } + } + } + + if($closestImpl === null){ + throw new \LogicException('service '.$interface.' was requested for '.\get_class($object).' but no implementation is available'); + } + + $this->cache[$objectClass][$interface] = $closestImpl[1]; + return $closestImpl[1]; + } + + private function parentDistance(string $class, string $target): int { + $cur = $class; + $distance = 0; + while($cur !== $target){ + $cur = \get_parent_class($cur); + if($cur === false) { + throw new \LogicException($class.' is not a subclass of '.$target); + } + } + return $distance; + } +} diff --git a/src/Twig/Extension/SubjectExtension.php b/src/Twig/Extension/SubjectExtension.php index d3ddf165b5..dd0a85ac2e 100644 --- a/src/Twig/Extension/SubjectExtension.php +++ b/src/Twig/Extension/SubjectExtension.php @@ -7,6 +7,7 @@ use App\Entity\Entry; use App\Entity\EntryComment; use App\Entity\Magazine; +use App\Entity\Message; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\User; @@ -48,6 +49,11 @@ public function getTests(): array return $subject instanceof User; } ), + new TwigTest( + 'message', function ($subject) { + return $subject instanceof Message; + } + ), ]; } } diff --git a/src/Twig/Extension/UrlExtension.php b/src/Twig/Extension/UrlExtension.php index db38bc44e6..cac17c2dc0 100644 --- a/src/Twig/Extension/UrlExtension.php +++ b/src/Twig/Extension/UrlExtension.php @@ -38,6 +38,7 @@ public function getFunctions(): array new TwigFunction('post_comment_voters_url', [UrlExtensionRuntime::class, 'postCommentVotersUrl']), new TwigFunction('post_comment_favourites_url', [UrlExtensionRuntime::class, 'postCommentFavouritesUrl']), new TwigFunction('post_comment_delete_url', [UrlExtensionRuntime::class, 'postCommentDeleteUrl']), + new TwigFunction('message_url', [UrlExtensionRuntime::class, 'messageUrl']), new TwigFunction('options_url', [UrlExtensionRuntime::class, 'optionsUrl']), new TwigFunction('mention_url', [UrlExtensionRuntime::class, 'mentionUrl']), ]; diff --git a/src/Twig/Runtime/UrlExtensionRuntime.php b/src/Twig/Runtime/UrlExtensionRuntime.php index d8ee916ee3..7f49329b04 100644 --- a/src/Twig/Runtime/UrlExtensionRuntime.php +++ b/src/Twig/Runtime/UrlExtensionRuntime.php @@ -6,9 +6,12 @@ use App\Entity\Entry; use App\Entity\EntryComment; +use App\Entity\Message; use App\Entity\Post; use App\Entity\PostComment; +use App\Factory\Contract\ContentUrlFactory; use App\Service\MentionManager; +use App\Service\SwitchingServiceRegistry; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -17,6 +20,7 @@ class UrlExtensionRuntime implements RuntimeExtensionInterface { public function __construct( private readonly UrlGeneratorInterface $urlGenerator, + private readonly SwitchingServiceRegistry $serviceRegistry, private readonly RequestStack $requestStack, private readonly MentionManager $mentionManager, ) { @@ -24,11 +28,7 @@ public function __construct( public function entryUrl(Entry $entry): string { - return $this->urlGenerator->generate('entry_single', [ - 'magazine_name' => $entry->magazine->name, - 'entry_id' => $entry->getId(), - 'slug' => empty($entry->slug) ? '-' : $entry->slug, - ]); + return $this->serviceRegistry->getService($entry, ContentUrlFactory::class)->getLocalUrl($entry); } public function entryFavouritesUrl(Entry $entry): string @@ -89,12 +89,7 @@ public function entryCommentCreateUrl(EntryComment $comment): string public function entryCommentViewUrl(EntryComment $comment): string { - return $this->urlGenerator->generate('entry_comment_view', [ - 'magazine_name' => $comment->magazine->name, - 'entry_id' => $comment->entry->getId(), - 'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug, - 'comment_id' => $comment->getId(), - ]); + return $this->serviceRegistry->getService($comment, ContentUrlFactory::class)->getLocalUrl($comment); } public function entryCommentEditUrl(EntryComment $comment): string @@ -150,11 +145,7 @@ public function entryCommentModerateUrl(EntryComment $comment): string public function postUrl(Post $post): string { - return $this->urlGenerator->generate('post_single', [ - 'magazine_name' => $post->magazine->name, - 'post_id' => $post->getId(), - 'slug' => empty($post->slug) ? '-' : $post->slug, - ]); + return $this->serviceRegistry->getService($post, ContentUrlFactory::class)->getLocalUrl($post); } public function postEditUrl(Post $post): string @@ -263,6 +254,10 @@ public function postCommentDeleteUrl(PostComment $comment): string ]); } + public function messageUrl(Message $message): string { + return $this->serviceRegistry->getService($message, ContentUrlFactory::class)->getLocalUrl($message); + } + // $additionalParams indicates extra parameters to set in addition to [$name] = $value // Set $value to null to indicate deleting a parameter // TODO: It'd be better to have just a single $params which is an associative array diff --git a/templates/components/report_list.html.twig b/templates/components/report_list.html.twig index 77874f958b..58d0324d38 100644 --- a/templates/components/report_list.html.twig +++ b/templates/components/report_list.html.twig @@ -34,6 +34,12 @@ {% for report in reports %} + {% if report.subject is message %} + {% set routePrefix = 'message_report' %} + {% else %} + {% set routePrefix = 'magazine_panel_report' %} + {% endif %} +
{{ component('user_inline', {user: report.reporting, showNewIcon: true}) }}, @@ -52,7 +58,7 @@ {% if report.status is not same as REPORT_CLOSED %} {% if report.status is not same as REPORT_REJECTED %}
@@ -60,15 +66,17 @@ {% endif %} {% if report.status is not same as REPORT_APPROVED %}
{% endif %} {% endif %} + {% if report.subject.magazine is not same as null %} {{ 'ban'|trans }} ({{ report.reported.username|username(true) }}) + {% endif %}
{% endfor %} diff --git a/templates/layout/_subject_link.html.twig b/templates/layout/_subject_link.html.twig index 5ca9781994..5dc1690aac 100644 --- a/templates/layout/_subject_link.html.twig +++ b/templates/layout/_subject_link.html.twig @@ -6,4 +6,6 @@ {{ subject.shortTitle }} {%- elseif subject is post_comment -%} {{ subject.shortTitle }} +{%- elseif subject is message -%} + {{ subject.shortTitle }} {%- endif -%} diff --git a/templates/messages/reports.html.twig b/templates/messages/reports.html.twig new file mode 100644 index 0000000000..50e04eb3e7 --- /dev/null +++ b/templates/messages/reports.html.twig @@ -0,0 +1,18 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'reports'|trans }} - {{ parent() -}} +{%- endblock -%} + +{% block mainClass %}page-magazine-reports{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} +

{{ 'reports'|trans }}

+ {{ component('report_list', {reports: reports, routeName: 'message_reports'}) }} +{% endblock %} diff --git a/templates/messages/single.html.twig b/templates/messages/single.html.twig index 354ad13fc3..7e8908e729 100644 --- a/templates/messages/single.html.twig +++ b/templates/messages/single.html.twig @@ -57,6 +57,13 @@ ({{ 'edited'|trans }} {{ component('date', {date: message.editedAt}) }}) {% endif %}
+ +
+ +
{% endfor %} diff --git a/templates/notifications/_blocks.html.twig b/templates/notifications/_blocks.html.twig index 907ac64bf7..c2c2c53773 100644 --- a/templates/notifications/_blocks.html.twig +++ b/templates/notifications/_blocks.html.twig @@ -140,14 +140,21 @@ {{ postComment.getShortTitle() }} + {% elseif notification.report.message is defined and notification.report.message is not same as null %} + {% set message = notification.report.message %} + {{ message.shortTitle }} {% endif %} {% endblock %} {% block report_created_notification %} {{ component('user_inline', {user: notification.report.reporting, showNewIcon: true}) }} {{ 'reported'|trans|lower }} {{ component('user_inline', {user: notification.report.reported, showNewIcon: true}) }}
{{ 'report_subject'|trans }}: {{ block('reportlink') }}
- {% if app.user.admin or app.user.moderator or notification.report.magazine.userIsModerator(app.user) %} - {{ 'open_report'|trans }} + {% if app.user.admin or app.user.moderator or (notification.report.magazine is not same as null and notification.report.magazine.userIsModerator(app.user)) %} + {% if notification.report.subject is message %} + {{ 'open_report'|trans }} + {% else %} + {{ 'open_report'|trans }} + {% endif %} {% endif %} {% endblock report_created_notification %} @@ -155,8 +162,12 @@ {{ 'own_report_rejected'|trans }}
{{ 'reported_user'|trans }}: {{ component('user_inline', {user: notification.report.reported, showNewIcon: true}) }}
{{ 'report_subject'|trans }}: {{ block('reportlink') }}
- {% if app.user.admin or app.user.moderator or notification.report.magazine.userIsModerator(app.user) %} - {{ 'open_report'|trans }} + {% if app.user.admin or app.user.moderator or (notification.report.magazine is not same as null and notification.report.magazine.userIsModerator(app.user)) %} + {% if notification.report.subject is message %} + {{ 'open_report'|trans }} + {% else %} + {{ 'open_report'|trans }} + {% endif %} {% endif %} {% endblock report_rejected_notification %} @@ -171,8 +182,12 @@ {{ 'reporting_user'|trans }}: {{ component('user_inline', {user: notification.report.reported, showNewIcon: true}) }}
{% endif %} {{ 'report_subject'|trans }}: {{ block('reportlink') }}
- {% if app.user.admin or app.user.moderator or notification.report.magazine.userIsModerator(app.user) %} - {{ 'open_report'|trans }} + {% if app.user.admin or app.user.moderator or (notification.report.magazine is not same as null and notification.report.magazine.userIsModerator(app.user)) %} + {% if notification.report.subject is message %} + {{ 'open_report'|trans }} + {% else %} + {{ 'open_report'|trans }} + {% endif %} {% endif %} {% endblock report_approved_notification %} From 153f6d6b5806b0358d645fc8537f49f9fb1eaab4 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Thu, 12 Mar 2026 21:05:19 +0000 Subject: [PATCH 4/7] add icon what a user account has its "birthday" --- src/Entity/Traits/CreatedAtTrait.php | 7 +++++++ templates/components/user_box.html.twig | 6 ++++++ templates/components/user_inline.html.twig | 3 +++ templates/components/user_inline_box.html.twig | 3 +++ templates/user/_user_popover.html.twig | 3 +++ 5 files changed, 22 insertions(+) diff --git a/src/Entity/Traits/CreatedAtTrait.php b/src/Entity/Traits/CreatedAtTrait.php index 38ac38e4eb..45d68e86df 100644 --- a/src/Entity/Traits/CreatedAtTrait.php +++ b/src/Entity/Traits/CreatedAtTrait.php @@ -29,4 +29,11 @@ public function isNew(): bool return $this->getCreatedAt() >= new \DateTime("now -$days days"); } + + public function isCakeDay(): bool + { + $now = new \DateTime(); + return $this->getCreatedAt()->format('d') === $now->format('d') + && $this->getCreatedAt()->format('m') === $now->format('m'); + } } diff --git a/templates/components/user_box.html.twig b/templates/components/user_box.html.twig index 177e47dc3e..3fa71ae78c 100644 --- a/templates/components/user_box.html.twig +++ b/templates/components/user_box.html.twig @@ -42,6 +42,9 @@ {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} + {% if user.isCakeDay() %} + + {% endif %} {% if user.admin() %} {{ 'user_badge_admin'|trans }} @@ -60,6 +63,9 @@ {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} + {% if user.isCakeDay() %} + + {% endif %} {% if user.admin() %} {{ 'user_badge_admin'|trans }} diff --git a/templates/components/user_inline.html.twig b/templates/components/user_inline.html.twig index 4ff918828f..96aa709996 100644 --- a/templates/components/user_inline.html.twig +++ b/templates/components/user_inline.html.twig @@ -18,4 +18,7 @@ {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} + {% if user.isCakeDay() %} + + {% endif %} diff --git a/templates/components/user_inline_box.html.twig b/templates/components/user_inline_box.html.twig index 77eaa70093..e82e7fe7f7 100644 --- a/templates/components/user_inline_box.html.twig +++ b/templates/components/user_inline_box.html.twig @@ -35,6 +35,9 @@ {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} + {% if user.isCakeDay() %} + + {% endif %} {% if user.admin() %} {{ 'user_badge_admin'|trans }} diff --git a/templates/user/_user_popover.html.twig b/templates/user/_user_popover.html.twig index 21a53f1267..438718537d 100644 --- a/templates/user/_user_popover.html.twig +++ b/templates/user/_user_popover.html.twig @@ -15,6 +15,9 @@ {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} + {% if user.isCakeDay() %} + + {% endif %}

From f8febf6bb4c74af041c6f01759a0301e5df56f54 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Thu, 12 Mar 2026 21:12:28 +0000 Subject: [PATCH 5/7] add tests --- .../User/UserFrontControllerTest.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/Functional/Controller/User/UserFrontControllerTest.php b/tests/Functional/Controller/User/UserFrontControllerTest.php index e99cf874e1..67cc8d38da 100644 --- a/tests/Functional/Controller/User/UserFrontControllerTest.php +++ b/tests/Functional/Controller/User/UserFrontControllerTest.php @@ -119,6 +119,30 @@ public function testFollowingPage(): void $this->assertEquals(1, $crawler->filter('#main .users ul li')->count()); } + public function testNewIndicator(): void { + $user = $this->getUserByUsername('JohnDoe'); + + $this->client->request('GET', '/u/JohnDoe'); + $this->assertSelectorExists('#content.user-main h1 i.fa-solid.fa-leaf.new-user-icon'); + + $user->createdAt = new \DateTimeImmutable('now - 31days'); + $this->entityManager->flush(); + $this->client->request('GET', '/u/JohnDoe'); + $this->assertSelectorNotExists('#content.user-main h1 i.fa-solid.fa-leaf.new-user-icon'); + } + + public function testCakeDayIndicator(): void { + $user = $this->getUserByUsername('JohnDoe'); + + $this->client->request('GET', '/u/JohnDoe'); + $this->assertSelectorExists('#content.user-main h1 i.fa-solid.fa-cake-candles'); + + $user->createdAt = new \DateTimeImmutable('now - 1days'); + $this->entityManager->flush(); + $this->client->request('GET', '/u/JohnDoe'); + $this->assertSelectorNotExists('#content.user-main h1 i.fa-solid.fa-cake-candles'); + } + private function prepareEntries(): KernelBrowser { $entry1 = $this->getEntryByTitle( From 3b079b41b085eb1b50f2fe8b08e719935c3648db Mon Sep 17 00:00:00 2001 From: blued_gear Date: Thu, 12 Mar 2026 21:14:00 +0000 Subject: [PATCH 6/7] Revert "add tests" This reverts commit f8febf6bb4c74af041c6f01759a0301e5df56f54. --- .../User/UserFrontControllerTest.php | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/tests/Functional/Controller/User/UserFrontControllerTest.php b/tests/Functional/Controller/User/UserFrontControllerTest.php index 67cc8d38da..e99cf874e1 100644 --- a/tests/Functional/Controller/User/UserFrontControllerTest.php +++ b/tests/Functional/Controller/User/UserFrontControllerTest.php @@ -119,30 +119,6 @@ public function testFollowingPage(): void $this->assertEquals(1, $crawler->filter('#main .users ul li')->count()); } - public function testNewIndicator(): void { - $user = $this->getUserByUsername('JohnDoe'); - - $this->client->request('GET', '/u/JohnDoe'); - $this->assertSelectorExists('#content.user-main h1 i.fa-solid.fa-leaf.new-user-icon'); - - $user->createdAt = new \DateTimeImmutable('now - 31days'); - $this->entityManager->flush(); - $this->client->request('GET', '/u/JohnDoe'); - $this->assertSelectorNotExists('#content.user-main h1 i.fa-solid.fa-leaf.new-user-icon'); - } - - public function testCakeDayIndicator(): void { - $user = $this->getUserByUsername('JohnDoe'); - - $this->client->request('GET', '/u/JohnDoe'); - $this->assertSelectorExists('#content.user-main h1 i.fa-solid.fa-cake-candles'); - - $user->createdAt = new \DateTimeImmutable('now - 1days'); - $this->entityManager->flush(); - $this->client->request('GET', '/u/JohnDoe'); - $this->assertSelectorNotExists('#content.user-main h1 i.fa-solid.fa-cake-candles'); - } - private function prepareEntries(): KernelBrowser { $entry1 = $this->getEntryByTitle( From 0d6f3b1e628e9da005994d004667b61d9be04882 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Thu, 12 Mar 2026 21:14:00 +0000 Subject: [PATCH 7/7] Revert "add icon what a user account has its "birthday"" This reverts commit 153f6d6b5806b0358d645fc8537f49f9fb1eaab4. --- src/Entity/Traits/CreatedAtTrait.php | 7 ------- templates/components/user_box.html.twig | 6 ------ templates/components/user_inline.html.twig | 3 --- templates/components/user_inline_box.html.twig | 3 --- templates/user/_user_popover.html.twig | 3 --- 5 files changed, 22 deletions(-) diff --git a/src/Entity/Traits/CreatedAtTrait.php b/src/Entity/Traits/CreatedAtTrait.php index 45d68e86df..38ac38e4eb 100644 --- a/src/Entity/Traits/CreatedAtTrait.php +++ b/src/Entity/Traits/CreatedAtTrait.php @@ -29,11 +29,4 @@ public function isNew(): bool return $this->getCreatedAt() >= new \DateTime("now -$days days"); } - - public function isCakeDay(): bool - { - $now = new \DateTime(); - return $this->getCreatedAt()->format('d') === $now->format('d') - && $this->getCreatedAt()->format('m') === $now->format('m'); - } } diff --git a/templates/components/user_box.html.twig b/templates/components/user_box.html.twig index 3fa71ae78c..177e47dc3e 100644 --- a/templates/components/user_box.html.twig +++ b/templates/components/user_box.html.twig @@ -42,9 +42,6 @@ {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} - {% if user.isCakeDay() %} - - {% endif %} {% if user.admin() %} {{ 'user_badge_admin'|trans }} @@ -63,9 +60,6 @@ {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} - {% if user.isCakeDay() %} - - {% endif %} {% if user.admin() %} {{ 'user_badge_admin'|trans }} diff --git a/templates/components/user_inline.html.twig b/templates/components/user_inline.html.twig index 96aa709996..4ff918828f 100644 --- a/templates/components/user_inline.html.twig +++ b/templates/components/user_inline.html.twig @@ -18,7 +18,4 @@ {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} - {% if user.isCakeDay() %} - - {% endif %} diff --git a/templates/components/user_inline_box.html.twig b/templates/components/user_inline_box.html.twig index e82e7fe7f7..77eaa70093 100644 --- a/templates/components/user_inline_box.html.twig +++ b/templates/components/user_inline_box.html.twig @@ -35,9 +35,6 @@ {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} - {% if user.isCakeDay() %} - - {% endif %} {% if user.admin() %} {{ 'user_badge_admin'|trans }} diff --git a/templates/user/_user_popover.html.twig b/templates/user/_user_popover.html.twig index 438718537d..21a53f1267 100644 --- a/templates/user/_user_popover.html.twig +++ b/templates/user/_user_popover.html.twig @@ -15,9 +15,6 @@ {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} - {% if user.isCakeDay() %} - - {% endif %}