diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 2ff55834c2..6a45d10b20 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -51,6 +51,7 @@ @use 'components/inline_md'; @use 'components/emoji_picker'; @use 'components/filter_list'; +@use 'components/poll'; @use 'pages/post_single'; @use 'pages/post_front'; @use 'pages/page_bookmarks'; diff --git a/assets/styles/components/_poll.scss b/assets/styles/components/_poll.scss new file mode 100644 index 0000000000..e8ca2f6139 --- /dev/null +++ b/assets/styles/components/_poll.scss @@ -0,0 +1,24 @@ +.poll { + margin: auto; + + form { + margin: auto; + max-width: 400px; + } + + .poll-result { + margin: .5rem 0; + } + + .choice-result { + margin-bottom: .5rem; + + .choice-bar { + background-color: var(--kbin-button-primary-bg); + color: var(--kbin-button-primary-text-color); + height: .5rem; + border: var(--kbin-button-primary-border); + border-radius: var(--kbin-rounded-edges-radius); + } + } +} diff --git a/assets/styles/components/_post.scss b/assets/styles/components/_post.scss index eefad7c2bd..3adbd3fd27 100644 --- a/assets/styles/components/_post.scss +++ b/assets/styles/components/_post.scss @@ -23,6 +23,10 @@ div { margin-bottom: 0; } + + .poll-area div { + margin-bottom: 1rem; + } } .post-container { diff --git a/assets/styles/layout/_forms.scss b/assets/styles/layout/_forms.scss index a3ad80154e..b634494eef 100644 --- a/assets/styles/layout/_forms.scss +++ b/assets/styles/layout/_forms.scss @@ -258,7 +258,8 @@ form { flex-direction: row-reverse; justify-content: flex-end; - input[type=checkbox] { + input[type=checkbox], + input[type=radio] { margin-right: .5rem; } } @@ -574,3 +575,18 @@ div.input-box { width: 100%; } + +.poll-area { + .sub-form { + display: none; + } + &:has(input:checked) .sub-form { + display: block; + } +} + +form { + .collection-container.hide-index .existing-collection-items > div > label { + display: none; + } +} diff --git a/config/mbin_routes/activity_pub.yaml b/config/mbin_routes/activity_pub.yaml index e56f56f156..6ffc2f4a5c 100644 --- a/config/mbin_routes/activity_pub.yaml +++ b/config/mbin_routes/activity_pub.yaml @@ -162,3 +162,9 @@ ap_contexts: path: /contexts.{_format} methods: [GET] format: jsonld + +ap_poll_vote: + controller: App\Controller\ActivityPub\PollVoteController + path: /u/{username}/votes/{uuid} + methods: [GET] + condition: '%kbin_ap_route_condition%' diff --git a/config/mbin_routes/poll.yaml b/config/mbin_routes/poll.yaml new file mode 100644 index 0000000000..8724ea2f5d --- /dev/null +++ b/config/mbin_routes/poll.yaml @@ -0,0 +1,9 @@ +poll_vote: + controller: App\Controller\PollVoteController::vote + path: /poll/{id}/vote + methods: [GET] + +poll_refresh: + controller: App\Controller\PollVoteController::refreshVoteCounts + path: /poll/{id}/refresh + methods: [GET] diff --git a/config/mbin_routes/poll_api.yaml b/config/mbin_routes/poll_api.yaml new file mode 100644 index 0000000000..fa4eec47e9 --- /dev/null +++ b/config/mbin_routes/poll_api.yaml @@ -0,0 +1,19 @@ +api_poll_vote_entry: + controller: App\Controller\Api\Poll\PollVoteController::voteOnEntry + path: /api/entry/{entryId}/poll/vote + methods: [ PUT ] + +api_poll_vote_entry_comment: + controller: App\Controller\Api\Poll\PollVoteController::voteOnEntryComment + path: /api/entry/{entryId}/comments/{commentId}/poll/vote + methods: [ PUT ] + +api_poll_vote_post: + controller: App\Controller\Api\Poll\PollVoteController::voteOnPost + path: /api/post/{postId}/poll/vote + methods: [ PUT ] + +api_poll_vote_post_comment: + controller: App\Controller\Api\Poll\PollVoteController::voteOnPostComment + path: /api/post/{postId}/comments/{commentId}/poll/vote + methods: [ PUT ] diff --git a/docs/05-fediverse_developers/README.md b/docs/05-fediverse_developers/README.md index 5c1b9c7197..768c94549a 100644 --- a/docs/05-fediverse_developers/README.md +++ b/docs/05-fediverse_developers/README.md @@ -84,6 +84,30 @@ Each magazine has an AP actor at `https://instance.tld/m/name`: %object_message% ``` +### Polls + +Polls can be part of all of the above, except private messages. If that is the case, their `type` becomes `Question`. +The deciding factor if an object is a thread or microblog in that case is the existence of the `title` property. + +Mbin's representation of polls are mostly the same as Mastodons (see [Mastodon's documentation](https://docs.joinmastodon.org/spec/activitypub/#Question)): + +- Poll options are part of the `oneOf` (for single choice polls) or the `anyOf` property (for multiple choice polls) +- Each option has a `name` property, which is both the value and the label of this option +- Each option has a `replies` property containing the amount of votes in their `totalItems` property + +```json +%object_poll% +``` + +#### Poll Votes + +A vote in a poll is represented as a `Note` object with a `name` property containing the exact value of the choice that is voted for. +For multiple-choice-polls, multiple activities will be sent, each containing one vote for one choice. + +```json +%object_poll_vote% +``` + ## Collections ### User Outbox diff --git a/migrations/Version20260408134939.php b/migrations/Version20260408134939.php new file mode 100644 index 0000000000..07424d5ed1 --- /dev/null +++ b/migrations/Version20260408134939.php @@ -0,0 +1,83 @@ +addSql('CREATE SEQUENCE poll_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE poll_choice_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE poll (id INT NOT NULL, multiple_choice BOOLEAN NOT NULL, voter_count INT DEFAULT 0 NOT NULL, end_date TIMESTAMP(0) WITH TIME ZONE NOT NULL, is_remote BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, sent_notifications BOOLEAN NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE TABLE poll_choice (id INT NOT NULL, name VARCHAR(255) NOT NULL, vote_count INT NOT NULL, poll_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_2DAE19C93C947C0F ON poll_choice (poll_id)'); + $this->addSql('CREATE TABLE poll_vote (uuid UUID NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, ap_id VARCHAR(255) DEFAULT NULL, voter_id INT NOT NULL, choice_id INT NOT NULL, poll_id INT NOT NULL, PRIMARY KEY (uuid))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_ED568EBE904F155E ON poll_vote (ap_id)'); + $this->addSql('CREATE INDEX IDX_ED568EBEEBB4B8AD ON poll_vote (voter_id)'); + $this->addSql('CREATE INDEX IDX_ED568EBE998666D1 ON poll_vote (choice_id)'); + $this->addSql('CREATE INDEX IDX_ED568EBE3C947C0F ON poll_vote (poll_id)'); + $this->addSql('ALTER TABLE poll_choice ADD CONSTRAINT FK_2DAE19C93C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE poll_vote ADD CONSTRAINT FK_ED568EBEEBB4B8AD FOREIGN KEY (voter_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE poll_vote ADD CONSTRAINT FK_ED568EBE998666D1 FOREIGN KEY (choice_id) REFERENCES poll_choice (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE poll_vote ADD CONSTRAINT FK_ED568EBE3C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE activity ADD object_poll_vote_id UUID DEFAULT NULL'); + $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A69F3DEA4 FOREIGN KEY (object_poll_vote_id) REFERENCES poll_vote (uuid) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('CREATE INDEX IDX_AC74095A69F3DEA4 ON activity (object_poll_vote_id)'); + $this->addSql('ALTER TABLE entry ADD poll_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE entry ADD CONSTRAINT FK_2B219D703C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_2B219D703C947C0F ON entry (poll_id)'); + $this->addSql('ALTER TABLE entry_comment ADD poll_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFB3C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_B892FDFB3C947C0F ON entry_comment (poll_id)'); + $this->addSql('ALTER TABLE post ADD poll_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE post ADD CONSTRAINT FK_5A8A6C8D3C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_5A8A6C8D3C947C0F ON post (poll_id)'); + $this->addSql('ALTER TABLE post_comment ADD poll_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55F3C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_A99CE55F3C947C0F ON post_comment (poll_id)'); + $this->addSql('ALTER TABLE notification ADD poll_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA3C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('CREATE INDEX IDX_BF5476CA3C947C0F ON notification (poll_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE poll_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE poll_choice_id_seq CASCADE'); + $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A69F3DEA4'); + $this->addSql('DROP INDEX IDX_AC74095A69F3DEA4'); + $this->addSql('ALTER TABLE activity DROP object_poll_vote_id'); + $this->addSql('ALTER TABLE entry DROP CONSTRAINT FK_2B219D703C947C0F'); + $this->addSql('DROP INDEX UNIQ_2B219D703C947C0F'); + $this->addSql('ALTER TABLE entry DROP poll_id'); + $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFB3C947C0F'); + $this->addSql('DROP INDEX UNIQ_B892FDFB3C947C0F'); + $this->addSql('ALTER TABLE entry_comment DROP poll_id'); + $this->addSql('ALTER TABLE post DROP CONSTRAINT FK_5A8A6C8D3C947C0F'); + $this->addSql('DROP INDEX UNIQ_5A8A6C8D3C947C0F'); + $this->addSql('ALTER TABLE post DROP poll_id'); + $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55F3C947C0F'); + $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA3C947C0F'); + $this->addSql('DROP INDEX IDX_BF5476CA3C947C0F'); + $this->addSql('ALTER TABLE notification DROP poll_id'); + $this->addSql('DROP INDEX UNIQ_A99CE55F3C947C0F'); + $this->addSql('ALTER TABLE post_comment DROP poll_id'); + $this->addSql('ALTER TABLE poll_choice DROP CONSTRAINT FK_2DAE19C93C947C0F'); + $this->addSql('ALTER TABLE poll_vote DROP CONSTRAINT FK_ED568EBEEBB4B8AD'); + $this->addSql('ALTER TABLE poll_vote DROP CONSTRAINT FK_ED568EBE998666D1'); + $this->addSql('ALTER TABLE poll_vote DROP CONSTRAINT FK_ED568EBE3C947C0F'); + $this->addSql('DROP TABLE poll'); + $this->addSql('DROP TABLE poll_choice'); + $this->addSql('DROP TABLE poll_vote'); + } +} diff --git a/src/Command/DebugCommand.php b/src/Command/DebugCommand.php new file mode 100644 index 0000000000..c010a51be3 --- /dev/null +++ b/src/Command/DebugCommand.php @@ -0,0 +1,33 @@ +pollRepository->getAllEndedPollsToSentNotifications() as $poll) { + $output->writeln("poll {$poll->getId()}"); + foreach ($this->pollRepository->getAllLocalVotersOfPoll($poll) as $voter) { + $output->writeln("Voter $voter->username in poll {$poll->getId()}"); + } + } + + return 0; + } +} diff --git a/src/Command/DocumentationGenerateFederationCommand.php b/src/Command/DocumentationGenerateFederationCommand.php index 8fedcb855a..0d5adae532 100644 --- a/src/Command/DocumentationGenerateFederationCommand.php +++ b/src/Command/DocumentationGenerateFederationCommand.php @@ -25,6 +25,7 @@ use App\Factory\ActivityPub\LockFactory; use App\Factory\ActivityPub\MessageFactory; use App\Factory\ActivityPub\PersonFactory; +use App\Factory\ActivityPub\PollVoteFactory; use App\Factory\ActivityPub\PostCommentNoteFactory; use App\Factory\ActivityPub\PostNoteFactory; use App\Factory\ImageFactory; @@ -44,6 +45,7 @@ use App\Service\EntryManager; use App\Service\MagazineManager; use App\Service\MessageManager; +use App\Service\PollManager; use App\Service\PostCommentManager; use App\Service\PostManager; use App\Service\ReportManager; @@ -103,6 +105,8 @@ public function __construct( private readonly CollectionInfoWrapper $collectionInfoWrapper, private readonly BlockFactory $blockFactory, private readonly LockFactory $lockFactory, + private readonly PollVoteFactory $pollVoteFactory, + private readonly PollManager $pollManager, ) { parent::__construct(); } @@ -224,6 +228,18 @@ private function generateMarkdown(string $content): string $thread = $this->messageManager->toThread($dto, $user, $user2); $message = $thread->getLastMessage(); + $dto = new EntryDto(); + $dto->user = $user; + $dto->magazine = $magazine; + $dto->title = 'Do you like FOSS?'; + $dto->lang = 'en'; + $dto->addPoll = true; + $dto->choices = ['Yes', 'No']; + $dto->isMultipleChoicePoll = false; + $entryWithPoll = $this->entryManager->create($dto, $user, rateLimit: false); + $this->pollManager->vote($entryWithPoll->poll, $entryWithPoll, $user2, ['Yes']); + $pollVote = $entryWithPoll->poll->getUserVotes($user2)[0]; + $userOutboxCollectionInfo = $this->collectionFactory->getUserOutboxCollection($user, false); $userOutboxCollectionItems = $this->collectionFactory->getUserOutboxCollectionItems($user, 1, false); $userFollowerCollection = $this->collectionInfoWrapper->build('ap_user_followers', ['username' => $user->username], $this->userRepository->findFollowers(1, $user)->getNbResults()); @@ -279,6 +295,8 @@ private function generateMarkdown(string $content): string '%object_post%' => json_encode($this->postNoteFactory->create($post, []), $jsonFlags), '%object_post_comment%' => json_encode($this->postCommentNoteFactory->create($postComment, []), $jsonFlags), '%object_message%' => json_encode($this->messageFactory->build($message, false), $jsonFlags), + '%object_poll%' => json_encode($this->entryPageFactory->create($entryWithPoll, []), $jsonFlags), + '%object_poll_vote%' => json_encode($this->pollVoteFactory->build($pollVote), $jsonFlags), '%collection_user_outbox%' => json_encode($userOutboxCollectionInfo, $jsonFlags), '%collection_items_user_outbox%' => json_encode($userOutboxCollectionItems, $jsonFlags), '%collection_user_followers%' => json_encode($userFollowerCollection, $jsonFlags), diff --git a/src/Controller/ActivityPub/PollVoteController.php b/src/Controller/ActivityPub/PollVoteController.php new file mode 100644 index 0000000000..f235e48eb7 --- /dev/null +++ b/src/Controller/ActivityPub/PollVoteController.php @@ -0,0 +1,33 @@ + 'username'])] User $user, + #[MapEntity(mapping: ['uuid' => 'uuid'])] PollVote $pollVote, + ): JsonResponse { + if ($pollVote->getUser()->getId() !== $user->getId()) { + throw new NotFoundHttpException(); + } + + return new JsonResponse( + $pollVoteFactory->build($pollVote), + headers: ['Content-Type' => 'application/activity+json'], + ); + } +} diff --git a/src/Controller/Api/BaseApi.php b/src/Controller/Api/BaseApi.php index 8514e8513b..b734d7185c 100644 --- a/src/Controller/Api/BaseApi.php +++ b/src/Controller/Api/BaseApi.php @@ -5,15 +5,12 @@ namespace App\Controller\Api; use App\Controller\AbstractController; -use App\DTO\EntryCommentDto; use App\DTO\EntryCommentResponseDto; -use App\DTO\EntryDto; use App\DTO\EntryResponseDto; use App\DTO\MagazineDto; use App\DTO\MagazineResponseDto; -use App\DTO\PostCommentDto; +use App\DTO\PollResponseDto; use App\DTO\PostCommentResponseDto; -use App\DTO\PostDto; use App\DTO\PostResponseDto; use App\DTO\ReportDto; use App\DTO\ReportRequestDto; @@ -30,6 +27,7 @@ use App\Entity\Image; use App\Entity\MagazineLog; use App\Entity\OAuth2ClientAccess; +use App\Entity\Poll; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\UserFilterList; @@ -461,22 +459,22 @@ protected function reportContent(ReportInterface $reportable): void * * @param Entry[]|null $crosspostedEntries */ - protected function serializeEntry(EntryDto|Entry $dto, array $tags, ?array $crosspostedEntries = null): EntryResponseDto + protected function serializeEntry(Entry $entry, array $tags, ?array $crosspostedEntries = null): EntryResponseDto { $crosspostedEntryDtos = null; if (null !== $crosspostedEntries) { $crosspostedEntryDtos = array_map(fn (Entry $item) => $this->entryFactory->createResponseDto($item, []), $crosspostedEntries); } - $response = $this->entryFactory->createResponseDto($dto, $tags, $crosspostedEntryDtos); + $response = $this->entryFactory->createResponseDto($entry, $tags, $crosspostedEntryDtos); if ($this->isGranted('ROLE_OAUTH2_ENTRY:VOTE')) { - $response->isFavourited = $dto instanceof EntryDto ? $dto->isFavourited : $dto->isFavored($this->getUserOrThrow()); - $response->userVote = $dto instanceof EntryDto ? $dto->userVote : $dto->getUserChoice($this->getUserOrThrow()); + $response->isFavourited = $entry->isFavored($this->getUserOrThrow()); + $response->userVote = $entry->getUserChoice($this->getUserOrThrow()); } if ($user = $this->getUser()) { - $response->canAuthUserModerate = $dto->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin(); - $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto)?->getStatus() ?? ENotificationStatus::Default; + $response->canAuthUserModerate = $entry->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin(); + $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $entry)?->getStatus() ?? ENotificationStatus::Default; } return $response; @@ -485,13 +483,13 @@ protected function serializeEntry(EntryDto|Entry $dto, array $tags, ?array $cros /** * Serialize a single entry comment to JSON. */ - protected function serializeEntryComment(EntryCommentDto $comment, array $tags): EntryCommentResponseDto + protected function serializeEntryComment(EntryComment $comment, array $tags): EntryCommentResponseDto { $response = $this->entryCommentFactory->createResponseDto($comment, $tags); if ($this->isGranted('ROLE_OAUTH2_ENTRY_COMMENT:VOTE')) { - $response->isFavourited = $comment->isFavourited; - $response->userVote = $comment->userVote; + $response->isFavourited = $comment->isFavored($this->getUserOrThrow()); + $response->userVote = $comment->getUserChoice($this->getUserOrThrow()); } if ($user = $this->getUser()) { @@ -504,21 +502,18 @@ protected function serializeEntryComment(EntryCommentDto $comment, array $tags): /** * Serialize a single post to JSON. */ - protected function serializePost(Post|PostDto $dto, array $tags): PostResponseDto + protected function serializePost(Post $post, array $tags): PostResponseDto { - if (null === $dto) { - return []; - } - $response = $this->postFactory->createResponseDto($dto, $tags); + $response = $this->postFactory->createResponseDto($post, $tags); if ($this->isGranted('ROLE_OAUTH2_POST:VOTE')) { - $response->isFavourited = $dto instanceof PostDto ? $dto->isFavourited : $dto->isFavored($this->getUserOrThrow()); - $response->userVote = $dto instanceof PostDto ? $dto->userVote : $dto->getUserChoice($this->getUserOrThrow()); + $response->isFavourited = $post->isFavored($this->getUserOrThrow()); + $response->userVote = $post->getUserChoice($this->getUserOrThrow()); } if ($user = $this->getUser()) { - $response->canAuthUserModerate = $dto->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin(); - $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto)?->getStatus() ?? ENotificationStatus::Default; + $response->canAuthUserModerate = $post->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin(); + $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $post)?->getStatus() ?? ENotificationStatus::Default; } return $response; @@ -527,13 +522,13 @@ protected function serializePost(Post|PostDto $dto, array $tags): PostResponseDt /** * Serialize a single comment to JSON. */ - protected function serializePostComment(PostCommentDto $comment, array $tags): PostCommentResponseDto + protected function serializePostComment(PostComment $comment, array $tags): PostCommentResponseDto { $response = $this->postCommentFactory->createResponseDto($comment, $tags); if ($this->isGranted('ROLE_OAUTH2_POST_COMMENT:VOTE')) { - $response->isFavourited = $comment instanceof PostCommentDto ? $comment->isFavourited : $comment->isFavored($this->getUserOrThrow()); - $response->userVote = $comment instanceof PostCommentDto ? $comment->userVote : $comment->getUserChoice($this->getUserOrThrow()); + $response->isFavourited = $comment->isFavored($this->getUserOrThrow()); + $response->userVote = $comment->getUserChoice($this->getUserOrThrow()); } if ($user = $this->getUser()) { @@ -542,4 +537,9 @@ protected function serializePostComment(PostCommentDto $comment, array $tags): P return $response; } + + protected function serializePoll(Poll $poll): PollResponseDto + { + return PollResponseDto::createFromPoll($poll, $this->getUser()); + } } diff --git a/src/Controller/Api/Combined/CombinedRetrieveApi.php b/src/Controller/Api/Combined/CombinedRetrieveApi.php index 67a004d296..49038f645d 100644 --- a/src/Controller/Api/Combined/CombinedRetrieveApi.php +++ b/src/Controller/Api/Combined/CombinedRetrieveApi.php @@ -813,16 +813,16 @@ private function serializeContent(PagerfantaInterface $content, array $headers): foreach ($content as $item) { if ($item instanceof Entry) { $this->handlePrivateContent($item); - $result[] = new ContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + $result[] = new ContentResponseDto(entry: $this->serializeEntry($item, $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof Post) { $this->handlePrivateContent($item); - $result[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + $result[] = new ContentResponseDto(post: $this->serializePost($item, $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof EntryComment) { $this->handlePrivateContent($item); - $result[] = new ContentResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + $result[] = new ContentResponseDto(entryComment: $this->serializeEntryComment($item, $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof PostComment) { $this->handlePrivateContent($item); - $result[] = new ContentResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + $result[] = new ContentResponseDto(postComment: $this->serializePostComment($item, $this->tagLinkRepository->getTagsOfContent($item))); } } @@ -835,10 +835,10 @@ private function serializeContentCursored(CursorPaginationInterface $content, ar foreach ($content as $item) { if ($item instanceof Entry) { $this->handlePrivateContent($item); - $result[] = new ContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + $result[] = new ContentResponseDto(entry: $this->serializeEntry($item, $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof Post) { $this->handlePrivateContent($item); - $result[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + $result[] = new ContentResponseDto(post: $this->serializePost($item, $this->tagLinkRepository->getTagsOfContent($item))); } } diff --git a/src/Controller/Api/Entry/Admin/EntriesChangeMagazineApi.php b/src/Controller/Api/Entry/Admin/EntriesChangeMagazineApi.php index 9e3b14f203..0e114983fe 100644 --- a/src/Controller/Api/Entry/Admin/EntriesChangeMagazineApi.php +++ b/src/Controller/Api/Entry/Admin/EntriesChangeMagazineApi.php @@ -86,7 +86,7 @@ public function __invoke( $manager->changeMagazine($entry, $target); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Comments/EntryCommentsCreateApi.php b/src/Controller/Api/Entry/Comments/EntryCommentsCreateApi.php index e98779ea63..e6fd1b3709 100644 --- a/src/Controller/Api/Entry/Comments/EntryCommentsCreateApi.php +++ b/src/Controller/Api/Entry/Comments/EntryCommentsCreateApi.php @@ -118,11 +118,10 @@ public function __invoke( // Rate limiting already taken care of $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); - $dto = $factory->createDto($comment); $dto->parent = $parent; return new JsonResponse( - $this->serializeEntryComment($dto, $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializeEntryComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), status: 201, headers: $headers ); @@ -227,11 +226,10 @@ public function uploadImage( // Rate limiting already taken care of $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); - $dto = $factory->createDto($comment); $dto->parent = $parent; return new JsonResponse( - $this->serializeEntryComment($dto, $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializeEntryComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), status: 201, headers: $headers ); diff --git a/src/Controller/Api/Entry/Comments/EntryCommentsFavouriteApi.php b/src/Controller/Api/Entry/Comments/EntryCommentsFavouriteApi.php index 2fcd48fd56..ab3e3c8118 100644 --- a/src/Controller/Api/Entry/Comments/EntryCommentsFavouriteApi.php +++ b/src/Controller/Api/Entry/Comments/EntryCommentsFavouriteApi.php @@ -78,7 +78,7 @@ public function __invoke( $manager->toggle($this->getUserOrThrow(), $comment); return new JsonResponse( - $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializeEntryComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Comments/EntryCommentsVoteApi.php b/src/Controller/Api/Entry/Comments/EntryCommentsVoteApi.php index 062868cb13..a6918ea08e 100644 --- a/src/Controller/Api/Entry/Comments/EntryCommentsVoteApi.php +++ b/src/Controller/Api/Entry/Comments/EntryCommentsVoteApi.php @@ -98,7 +98,7 @@ public function __invoke( $manager->vote($choice, $comment, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializeEntryComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetAdultApi.php b/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetAdultApi.php index 44016e8a83..43b559cc72 100644 --- a/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetAdultApi.php +++ b/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetAdultApi.php @@ -86,7 +86,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializeEntryComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetLanguageApi.php b/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetLanguageApi.php index 09b42ac472..89b1a2f672 100644 --- a/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetLanguageApi.php +++ b/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetLanguageApi.php @@ -100,7 +100,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializeEntryComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsTrashApi.php b/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsTrashApi.php index 12f2056fcb..1dc8cba552 100644 --- a/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsTrashApi.php +++ b/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsTrashApi.php @@ -82,7 +82,7 @@ public function trash( // Force response to have all fields visible $visibility = $comment->visibility; $comment->visibility = VisibilityInterface::VISIBILITY_VISIBLE; - $response = $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment))->jsonSerialize(); + $response = $this->serializeEntryComment($comment, $this->tagLinkRepository->getTagsOfContent($comment))->jsonSerialize(); $response['visibility'] = $visibility; return new JsonResponse( @@ -159,7 +159,7 @@ public function restore( } return new JsonResponse( - $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializeEntryComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/DomainEntriesRetrieveApi.php b/src/Controller/Api/Entry/DomainEntriesRetrieveApi.php index 734499c4ee..ae50c23de0 100644 --- a/src/Controller/Api/Entry/DomainEntriesRetrieveApi.php +++ b/src/Controller/Api/Entry/DomainEntriesRetrieveApi.php @@ -164,7 +164,7 @@ public function __invoke( try { \assert($value instanceof Entry); $this->handlePrivateContent($value); - $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); + $dtos[] = $this->serializeEntry($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } diff --git a/src/Controller/Api/Entry/EntriesFavouriteApi.php b/src/Controller/Api/Entry/EntriesFavouriteApi.php index 179d525415..f92f92aeab 100644 --- a/src/Controller/Api/Entry/EntriesFavouriteApi.php +++ b/src/Controller/Api/Entry/EntriesFavouriteApi.php @@ -69,7 +69,7 @@ public function __invoke( $manager->toggle($this->getUserOrThrow(), $entry); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/EntriesRetrieveApi.php b/src/Controller/Api/Entry/EntriesRetrieveApi.php index ad6ac2fc60..43df940f56 100644 --- a/src/Controller/Api/Entry/EntriesRetrieveApi.php +++ b/src/Controller/Api/Entry/EntriesRetrieveApi.php @@ -83,10 +83,8 @@ public function __invoke( $dispatcher->dispatch(new EntryHasBeenSeenEvent($entry)); - $dto = $factory->createDto($entry); - return new JsonResponse( - $this->serializeEntry($dto, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } @@ -216,7 +214,7 @@ public function collection( try { \assert($value instanceof Entry); $this->handlePrivateContent($value); - $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); + $dtos[] = $this->serializeEntry($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (AccessDeniedException $e) { continue; } @@ -338,7 +336,7 @@ public function subscribed( try { \assert($value instanceof Entry); $this->handlePrivateContent($value); - $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); + $dtos[] = $this->serializeEntry($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } @@ -460,7 +458,7 @@ public function moderated( try { \assert($value instanceof Entry); $this->handlePrivateContent($value); - $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); + $dtos[] = $this->serializeEntry($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } @@ -582,7 +580,7 @@ public function favourited( try { \assert($value instanceof Entry); $this->handlePrivateContent($value); - $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); + $dtos[] = $this->serializeEntry($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } diff --git a/src/Controller/Api/Entry/EntriesVoteApi.php b/src/Controller/Api/Entry/EntriesVoteApi.php index 1e5fde75dd..4ea979b790 100644 --- a/src/Controller/Api/Entry/EntriesVoteApi.php +++ b/src/Controller/Api/Entry/EntriesVoteApi.php @@ -97,7 +97,7 @@ public function __invoke( $manager->vote($choice, $entry, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/MagazineEntriesRetrieveApi.php b/src/Controller/Api/Entry/MagazineEntriesRetrieveApi.php index d5f0040320..3ca2b16caa 100644 --- a/src/Controller/Api/Entry/MagazineEntriesRetrieveApi.php +++ b/src/Controller/Api/Entry/MagazineEntriesRetrieveApi.php @@ -166,7 +166,7 @@ public function __invoke( foreach ($entries->getCurrentPageResults() as $value) { try { \assert($value instanceof Entry); - $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); + $dtos[] = $this->serializeEntry($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } diff --git a/src/Controller/Api/Entry/MagazineEntryCreateApi.php b/src/Controller/Api/Entry/MagazineEntryCreateApi.php index 251dd44ebc..2e45fbdb3b 100644 --- a/src/Controller/Api/Entry/MagazineEntryCreateApi.php +++ b/src/Controller/Api/Entry/MagazineEntryCreateApi.php @@ -101,7 +101,7 @@ public function article( ]); return new JsonResponse( - $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), status: 201, headers: $headers ); @@ -180,7 +180,7 @@ public function link( ]); return new JsonResponse( - $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), status: 201, headers: $headers ); @@ -254,7 +254,7 @@ public function video( ]); return new JsonResponse( - $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), status: 201, headers: $headers ); @@ -361,7 +361,7 @@ public function uploadImage( $entry = $manager->create($dto, $this->getUserOrThrow()); return new JsonResponse( - $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), status: 201, headers: $headers ); @@ -470,12 +470,11 @@ public function entry( $entry = $manager->create($dto, $this->getUserOrThrow()); - $retDto = $manager->createDto($entry); $tags = $this->tagLinkRepository->getTagsOfContent($entry); $crossposts = $this->entryRepository->findCross($entry); return new JsonResponse( - $this->serializeEntry($retDto, $tags, $crossposts), + $this->serializeEntry($entry, $tags, $crossposts), status: 201, headers: $headers ); diff --git a/src/Controller/Api/Entry/Moderate/EntriesLockApi.php b/src/Controller/Api/Entry/Moderate/EntriesLockApi.php index cbd453b73b..e653b3ec1e 100644 --- a/src/Controller/Api/Entry/Moderate/EntriesLockApi.php +++ b/src/Controller/Api/Entry/Moderate/EntriesLockApi.php @@ -80,7 +80,7 @@ public function __invoke( $manager->toggleLock($entry, $this->getUserOrThrow()); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Moderate/EntriesPinApi.php b/src/Controller/Api/Entry/Moderate/EntriesPinApi.php index cf824daf58..7e728fa8b5 100644 --- a/src/Controller/Api/Entry/Moderate/EntriesPinApi.php +++ b/src/Controller/Api/Entry/Moderate/EntriesPinApi.php @@ -76,7 +76,7 @@ public function __invoke( $manager->pin($entry, $this->getUserOrThrow()); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Moderate/EntriesSetAdultApi.php b/src/Controller/Api/Entry/Moderate/EntriesSetAdultApi.php index 686c130cff..b98d45ebc3 100644 --- a/src/Controller/Api/Entry/Moderate/EntriesSetAdultApi.php +++ b/src/Controller/Api/Entry/Moderate/EntriesSetAdultApi.php @@ -86,7 +86,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Moderate/EntriesSetLanguageApi.php b/src/Controller/Api/Entry/Moderate/EntriesSetLanguageApi.php index 803d84e8d9..9fc5ab75e3 100644 --- a/src/Controller/Api/Entry/Moderate/EntriesSetLanguageApi.php +++ b/src/Controller/Api/Entry/Moderate/EntriesSetLanguageApi.php @@ -100,7 +100,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Moderate/EntriesTrashApi.php b/src/Controller/Api/Entry/Moderate/EntriesTrashApi.php index 466eb6efa0..885ebec284 100644 --- a/src/Controller/Api/Entry/Moderate/EntriesTrashApi.php +++ b/src/Controller/Api/Entry/Moderate/EntriesTrashApi.php @@ -79,7 +79,7 @@ public function trash( $manager->trash($moderator, $entry); - $response = $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)); + $response = $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)); // Force response to have all fields visible $visibility = $response->visibility; @@ -161,7 +161,7 @@ public function restore( } return new JsonResponse( - $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/UserEntriesRetrieveApi.php b/src/Controller/Api/Entry/UserEntriesRetrieveApi.php index 0a33780ca4..8cdd528ff2 100644 --- a/src/Controller/Api/Entry/UserEntriesRetrieveApi.php +++ b/src/Controller/Api/Entry/UserEntriesRetrieveApi.php @@ -153,7 +153,7 @@ public function __invoke( foreach ($entries->getCurrentPageResults() as $value) { try { \assert($value instanceof Entry); - $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); + $dtos[] = $this->serializeEntry($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } diff --git a/src/Controller/Api/Poll/PollVoteController.php b/src/Controller/Api/Poll/PollVoteController.php new file mode 100644 index 0000000000..b0e77c3efb --- /dev/null +++ b/src/Controller/Api/Poll/PollVoteController.php @@ -0,0 +1,302 @@ + 'id'])] Entry $entry, + Request $request, + PollManager $pollManager, + RateLimiterFactoryInterface $apiVoteLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiVoteLimiter); + $poll = $entry->poll; + if (null === $poll) { + throw new NotFoundHttpException(); + } + + return $this->voteOnPoll($request, $pollManager, $this->getUserOrThrow(), $entry, $poll, $headers); + } + + #[OA\Response( + response: 200, + description: 'voted on poll', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new Model(type: PollResponseDto::class) + )] + #[OA\Response( + response: 400, + description: 'Poll was not valid. Possibly: poll already ended, choices do not exist, user already voted', + content: new OA\JsonContent(ref: new Model(type: BadRequestErrorSchema::class)) + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'Poll not found', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'choices', + description: 'The user\'s voting choices', + in: 'query', + schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string')), + )] + #[OA\Tag(name: 'entry_comment')] + #[OA\Tag(name: 'poll')] + #[Security(name: 'oauth2', scopes: ['entry_comment:vote'])] + #[IsGranted('ROLE_OAUTH2_ENTRY_COMMENT:VOTE')] + public function voteOnEntryComment( + #[MapEntity(mapping: ['entryId' => 'id'])] Entry $entry, + #[MapEntity(mapping: ['commentId' => 'id'])] EntryComment $comment, + Request $request, + PollManager $pollManager, + RateLimiterFactoryInterface $apiVoteLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiVoteLimiter); + $poll = $comment->poll; + if (null === $poll) { + throw new NotFoundHttpException(); + } + + if ($comment->entry->getId() !== $entry->getId()) { + throw new BadRequestHttpException(); + } + + return $this->voteOnPoll($request, $pollManager, $this->getUserOrThrow(), $comment, $poll, $headers); + } + + #[OA\Response( + response: 200, + description: 'voted on poll', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new Model(type: PollResponseDto::class) + )] + #[OA\Response( + response: 400, + description: 'Poll was not valid. Possibly: poll already ended, choices do not exist, user already voted', + content: new OA\JsonContent(ref: new Model(type: BadRequestErrorSchema::class)) + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'Poll not found', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'choices', + description: 'The user\'s voting choices', + in: 'query', + schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string')), + )] + #[OA\Tag(name: 'post')] + #[OA\Tag(name: 'poll')] + #[Security(name: 'oauth2', scopes: ['post:vote'])] + #[IsGranted('ROLE_OAUTH2_POST:VOTE')] + public function voteOnPost( + #[MapEntity(mapping: ['postId' => 'id'])] Post $post, + Request $request, + PollManager $pollManager, + RateLimiterFactoryInterface $apiVoteLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiVoteLimiter); + $poll = $post->poll; + if (null === $poll) { + throw new NotFoundHttpException(); + } + + return $this->voteOnPoll($request, $pollManager, $this->getUserOrThrow(), $post, $poll, $headers); + } + + #[OA\Response( + response: 200, + description: 'voted on poll', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new Model(type: PollResponseDto::class) + )] + #[OA\Response( + response: 400, + description: 'Poll was not valid. Possibly: poll already ended, choices do not exist, user already voted', + content: new OA\JsonContent(ref: new Model(type: BadRequestErrorSchema::class)) + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'Poll not found', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'choices', + description: 'The user\'s voting choices', + in: 'query', + schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string')), + )] + #[OA\Tag(name: 'post_comment')] + #[OA\Tag(name: 'poll')] + #[Security(name: 'oauth2', scopes: ['post_comment:vote'])] + #[IsGranted('ROLE_OAUTH2_POST_COMMENT:VOTE')] + public function voteOnPostComment( + #[MapEntity(mapping: ['postId' => 'id'])] Post $post, + #[MapEntity(mapping: ['commentId' => 'id'])] PostComment $comment, + Request $request, + PollManager $pollManager, + RateLimiterFactoryInterface $apiVoteLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiVoteLimiter); + $poll = $comment->poll; + if (null === $poll) { + throw new NotFoundHttpException(); + } + + if ($comment->post->getId() !== $post->getId()) { + throw new BadRequestHttpException(); + } + + return $this->voteOnPoll($request, $pollManager, $this->getUserOrThrow(), $comment, $poll, $headers); + } + + public function voteOnPoll(Request $request, PollManager $pollManager, User $user, Entry|EntryComment|Post|PostComment $content, Poll $poll, array $headers): JsonResponse + { + if ($content->poll->getId() !== $poll->getId() || $content->poll->hasEnded() || $content->poll->hasUserVoted($user)) { + throw new BadRequestHttpException(); + } + + $choices = $request->query->all('choices'); + + foreach ($choices as $choice) { + if (!$content->poll->findChoice($choice)) { + throw new BadRequestHttpException(); + } + } + + $pollManager->vote($poll, $content, $user, $choices); + $this->entityManager->refresh($poll); + + return new JsonResponse($this->serializePoll($poll), headers: $headers); + } +} diff --git a/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetAdultApi.php b/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetAdultApi.php index 811781be60..03d1b0e6c7 100644 --- a/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetAdultApi.php +++ b/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetAdultApi.php @@ -86,7 +86,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializePostComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetLanguageApi.php b/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetLanguageApi.php index 00b414f6cd..8401738916 100644 --- a/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetLanguageApi.php +++ b/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetLanguageApi.php @@ -100,7 +100,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializePostComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Comments/Moderate/PostCommentsTrashApi.php b/src/Controller/Api/Post/Comments/Moderate/PostCommentsTrashApi.php index 089cde4883..511a837c77 100644 --- a/src/Controller/Api/Post/Comments/Moderate/PostCommentsTrashApi.php +++ b/src/Controller/Api/Post/Comments/Moderate/PostCommentsTrashApi.php @@ -82,7 +82,7 @@ public function trash( // Force response to have all fields visible $visibility = $comment->visibility; $comment->visibility = VisibilityInterface::VISIBILITY_VISIBLE; - $response = $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment))->jsonSerialize(); + $response = $this->serializePostComment($comment, $this->tagLinkRepository->getTagsOfContent($comment))->jsonSerialize(); $response['visibility'] = $visibility; return new JsonResponse( @@ -159,7 +159,7 @@ public function restore( } return new JsonResponse( - $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializePostComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Comments/PostCommentsCreateApi.php b/src/Controller/Api/Post/Comments/PostCommentsCreateApi.php index ad99598ca5..bac0b52c8a 100644 --- a/src/Controller/Api/Post/Comments/PostCommentsCreateApi.php +++ b/src/Controller/Api/Post/Comments/PostCommentsCreateApi.php @@ -120,7 +120,7 @@ public function __invoke( $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializePostComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), status: 201, headers: $headers ); @@ -226,7 +226,7 @@ public function uploadImage( $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializePostComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), status: 201, headers: $headers ); diff --git a/src/Controller/Api/Post/Comments/PostCommentsFavouriteApi.php b/src/Controller/Api/Post/Comments/PostCommentsFavouriteApi.php index 206b4bb9ab..41fa43eec3 100644 --- a/src/Controller/Api/Post/Comments/PostCommentsFavouriteApi.php +++ b/src/Controller/Api/Post/Comments/PostCommentsFavouriteApi.php @@ -78,7 +78,7 @@ public function __invoke( $manager->toggle($this->getUserOrThrow(), $comment); return new JsonResponse( - $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializePostComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Comments/PostCommentsVoteApi.php b/src/Controller/Api/Post/Comments/PostCommentsVoteApi.php index f120376737..3929f2608f 100644 --- a/src/Controller/Api/Post/Comments/PostCommentsVoteApi.php +++ b/src/Controller/Api/Post/Comments/PostCommentsVoteApi.php @@ -97,7 +97,7 @@ public function __invoke( $manager->vote($choice, $comment, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), + $this->serializePostComment($comment, $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Moderate/PostsLockApi.php b/src/Controller/Api/Post/Moderate/PostsLockApi.php index b654d74ae6..dd1d28d151 100644 --- a/src/Controller/Api/Post/Moderate/PostsLockApi.php +++ b/src/Controller/Api/Post/Moderate/PostsLockApi.php @@ -80,7 +80,7 @@ public function __invoke( $manager->toggleLock($post, $this->getUserOrThrow()); return new JsonResponse( - $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), + $this->serializePost($post, $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Moderate/PostsPinApi.php b/src/Controller/Api/Post/Moderate/PostsPinApi.php index c0de3eb403..48a9fb7ded 100644 --- a/src/Controller/Api/Post/Moderate/PostsPinApi.php +++ b/src/Controller/Api/Post/Moderate/PostsPinApi.php @@ -76,7 +76,7 @@ public function __invoke( $manager->pin($post); return new JsonResponse( - $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), + $this->serializePost($post, $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Moderate/PostsSetAdultApi.php b/src/Controller/Api/Post/Moderate/PostsSetAdultApi.php index 479cb64a86..0fa6327739 100644 --- a/src/Controller/Api/Post/Moderate/PostsSetAdultApi.php +++ b/src/Controller/Api/Post/Moderate/PostsSetAdultApi.php @@ -86,7 +86,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), + $this->serializePost($post, $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Moderate/PostsSetLanguageApi.php b/src/Controller/Api/Post/Moderate/PostsSetLanguageApi.php index 94fd8f8ebc..87c02f0957 100644 --- a/src/Controller/Api/Post/Moderate/PostsSetLanguageApi.php +++ b/src/Controller/Api/Post/Moderate/PostsSetLanguageApi.php @@ -100,7 +100,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), + $this->serializePost($post, $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Moderate/PostsTrashApi.php b/src/Controller/Api/Post/Moderate/PostsTrashApi.php index 26e3f42326..9df337d139 100644 --- a/src/Controller/Api/Post/Moderate/PostsTrashApi.php +++ b/src/Controller/Api/Post/Moderate/PostsTrashApi.php @@ -82,7 +82,7 @@ public function trash( // Force response to have all fields visible $visibility = $post->visibility; $post->visibility = VisibilityInterface::VISIBILITY_VISIBLE; - $response = $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post))->jsonSerialize(); + $response = $this->serializePost($post, $this->tagLinkRepository->getTagsOfContent($post))->jsonSerialize(); $response['visibility'] = $visibility; return new JsonResponse( @@ -159,7 +159,7 @@ public function restore( } return new JsonResponse( - $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), + $this->serializePost($post, $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/PostsBaseApi.php b/src/Controller/Api/Post/PostsBaseApi.php index 69f0033ed7..f4ddcc9cdb 100644 --- a/src/Controller/Api/Post/PostsBaseApi.php +++ b/src/Controller/Api/Post/PostsBaseApi.php @@ -106,10 +106,6 @@ protected function deserializePostCommentFromForm(?PostCommentDto $dto = null): */ protected function serializePostCommentTree(?PostComment $comment, PostCommentPageView $commentPageView, ?int $depth = null): array { - if (null === $comment) { - return []; - } - if (null === $depth) { $depth = self::constrainDepth($this->request->getCurrentRequest()->get('d', self::DEPTH)); } diff --git a/src/Controller/Api/Post/PostsCreateApi.php b/src/Controller/Api/Post/PostsCreateApi.php index e436c63040..b33d24a7c0 100644 --- a/src/Controller/Api/Post/PostsCreateApi.php +++ b/src/Controller/Api/Post/PostsCreateApi.php @@ -110,7 +110,7 @@ public function __invoke( $post = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializePost($manager->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), + $this->serializePost($post, $this->tagLinkRepository->getTagsOfContent($post)), status: 201, headers: $headers ); @@ -207,7 +207,7 @@ public function uploadImage( $post = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializePost($manager->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), + $this->serializePost($post, $this->tagLinkRepository->getTagsOfContent($post)), status: 201, headers: $headers ); diff --git a/src/Controller/Api/Post/PostsFavouriteApi.php b/src/Controller/Api/Post/PostsFavouriteApi.php index 5d49789d79..9c68fa429e 100644 --- a/src/Controller/Api/Post/PostsFavouriteApi.php +++ b/src/Controller/Api/Post/PostsFavouriteApi.php @@ -69,7 +69,7 @@ public function __invoke( $manager->toggle($this->getUserOrThrow(), $post); return new JsonResponse( - $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), + $this->serializePost($post, $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/PostsRetrieveApi.php b/src/Controller/Api/Post/PostsRetrieveApi.php index 277974de7b..53bc68edcc 100644 --- a/src/Controller/Api/Post/PostsRetrieveApi.php +++ b/src/Controller/Api/Post/PostsRetrieveApi.php @@ -86,10 +86,8 @@ public function __invoke( $dispatcher->dispatch(new PostHasBeenSeenEvent($post)); - $dto = $factory->createDto($post); - return new JsonResponse( - $this->serializePost($dto, $this->tagLinkRepository->getTagsOfContent($post)), + $this->serializePost($post, $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } @@ -208,7 +206,7 @@ public function collection( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); + $dtos[] = $this->serializePost($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } @@ -345,7 +343,7 @@ public function subscribed( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - $dtos[] = $this->serializePost($this->postFactory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); + $dtos[] = $this->serializePost($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } @@ -481,10 +479,10 @@ public function subscribedWithBoosts( try { if ($value instanceof Post) { $this->handlePrivateContent($value); - $dtos[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); + $dtos[] = new ContentResponseDto(post: $this->serializePost($value, $this->tagLinkRepository->getTagsOfContent($value))); } elseif ($value instanceof PostComment) { $this->handlePrivateContent($value); - $dtos[] = new ContentResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); + $dtos[] = new ContentResponseDto(postComment: $this->serializePostComment($value, $this->tagLinkRepository->getTagsOfContent($value))); } else { throw new \AssertionError('got unexpected type '.\get_class($value)); } @@ -611,7 +609,7 @@ public function moderated( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); + $dtos[] = $this->serializePost($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } @@ -732,7 +730,7 @@ public function favourited( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); + $dtos[] = $this->serializePost($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } @@ -882,7 +880,7 @@ public function byMagazine( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); + $dtos[] = $this->serializePost($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } diff --git a/src/Controller/Api/Post/PostsUpdateApi.php b/src/Controller/Api/Post/PostsUpdateApi.php index e1a962f593..d97739cf58 100644 --- a/src/Controller/Api/Post/PostsUpdateApi.php +++ b/src/Controller/Api/Post/PostsUpdateApi.php @@ -100,7 +100,7 @@ public function __invoke( $post = $manager->edit($post, $dto, $user); return new JsonResponse( - $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), + $this->serializePost($post, $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/PostsVoteApi.php b/src/Controller/Api/Post/PostsVoteApi.php index 605ae2205b..e15da03721 100644 --- a/src/Controller/Api/Post/PostsVoteApi.php +++ b/src/Controller/Api/Post/PostsVoteApi.php @@ -100,7 +100,7 @@ public function __invoke( $manager->vote($choice, $post, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), + $this->serializePost($post, $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/UserPostsRetrieveApi.php b/src/Controller/Api/Post/UserPostsRetrieveApi.php index 964062347a..4f9c0912d8 100644 --- a/src/Controller/Api/Post/UserPostsRetrieveApi.php +++ b/src/Controller/Api/Post/UserPostsRetrieveApi.php @@ -146,7 +146,7 @@ public function __invoke( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); + $dtos[] = $this->serializePost($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } diff --git a/src/Controller/Api/Search/SearchRetrieveApi.php b/src/Controller/Api/Search/SearchRetrieveApi.php index bc5ee697a6..f9a8d441c1 100644 --- a/src/Controller/Api/Search/SearchRetrieveApi.php +++ b/src/Controller/Api/Search/SearchRetrieveApi.php @@ -384,19 +384,19 @@ private function serializeItem(object $item): ?SearchResponseDto if ($item instanceof Entry) { $this->handlePrivateContent($item); - return new SearchResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + return new SearchResponseDto(entry: $this->serializeEntry($item, $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof Post) { $this->handlePrivateContent($item); - return new SearchResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + return new SearchResponseDto(post: $this->serializePost($item, $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof EntryComment) { $this->handlePrivateContent($item); - return new SearchResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + return new SearchResponseDto(entryComment: $this->serializeEntryComment($item, $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof PostComment) { $this->handlePrivateContent($item); - return new SearchResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + return new SearchResponseDto(postComment: $this->serializePostComment($item, $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof Magazine) { return new SearchResponseDto(magazine: $this->serializeMagazine($this->magazineFactory->createDto($item))); } elseif ($item instanceof User) { diff --git a/src/Controller/Api/Tag/TagContentRetrieveApiController.php b/src/Controller/Api/Tag/TagContentRetrieveApiController.php index 2915db7c92..13020057c4 100644 --- a/src/Controller/Api/Tag/TagContentRetrieveApiController.php +++ b/src/Controller/Api/Tag/TagContentRetrieveApiController.php @@ -198,7 +198,7 @@ public function entries( try { \assert($value instanceof Entry); $this->handlePrivateContent($value); - $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); + $dtos[] = $this->serializeEntry($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (AccessDeniedException $e) { continue; } @@ -353,7 +353,7 @@ public function entryComments( try { \assert($value instanceof EntryComment); $this->handlePrivateContent($value); - $dtos[] = $this->serializeEntryComment($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); + $dtos[] = $this->serializeEntryComment($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (AccessDeniedException $e) { continue; } @@ -509,7 +509,7 @@ public function posts( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - $dtos[] = $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); + $dtos[] = $this->serializePost($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (AccessDeniedException $e) { continue; } @@ -665,7 +665,7 @@ public function postComments( try { \assert($value instanceof PostComment); $this->handlePrivateContent($value); - $dtos[] = $this->serializePostComment($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); + $dtos[] = $this->serializePostComment($value, $this->tagLinkRepository->getTagsOfContent($value)); } catch (AccessDeniedException $e) { continue; } diff --git a/src/Controller/Api/User/UserContentApi.php b/src/Controller/Api/User/UserContentApi.php index f759b86821..868cca8e07 100644 --- a/src/Controller/Api/User/UserContentApi.php +++ b/src/Controller/Api/User/UserContentApi.php @@ -194,16 +194,16 @@ private function serializeResults(array $results): array try { if ($item instanceof Entry) { $this->handlePrivateContent($item); - $result[] = new ExtendedContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + $result[] = new ExtendedContentResponseDto(entry: $this->serializeEntry($item, $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof Post) { $this->handlePrivateContent($item); - $result[] = new ExtendedContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + $result[] = new ExtendedContentResponseDto(post: $this->serializePost($item, $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof EntryComment) { $this->handlePrivateContent($item); - $result[] = new ExtendedContentResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + $result[] = new ExtendedContentResponseDto(entryComment: $this->serializeEntryComment($item, $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof PostComment) { $this->handlePrivateContent($item); - $result[] = new ExtendedContentResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + $result[] = new ExtendedContentResponseDto(postComment: $this->serializePostComment($item, $this->tagLinkRepository->getTagsOfContent($item))); } } catch (\Exception) { } diff --git a/src/Controller/Entry/Comment/EntryCommentCreateController.php b/src/Controller/Entry/Comment/EntryCommentCreateController.php index 7dae05b2f8..45ec65bb2d 100644 --- a/src/Controller/Entry/Comment/EntryCommentCreateController.php +++ b/src/Controller/Entry/Comment/EntryCommentCreateController.php @@ -59,7 +59,6 @@ public function __invoke( $dto->entry = $entry; $dto->parent = $parent; $dto->ip = $this->ipResolver->resolve(); - if (!$this->isGranted('create_content', $dto->magazine)) { throw new AccessDeniedHttpException(); } @@ -98,6 +97,7 @@ public function __invoke( private function getForm(Entry $entry, ?EntryComment $parent = null): FormInterface { $dto = new EntryCommentDto(); + $dto->addEmptyChoices(); if ($parent && $this->getUser()->addMentionsEntries) { $handle = $this->mentionManager->addHandle([$parent->user->username])[0]; diff --git a/src/Controller/Entry/EntryCreateController.php b/src/Controller/Entry/EntryCreateController.php index 87dc8c3de6..e4db503fee 100644 --- a/src/Controller/Entry/EntryCreateController.php +++ b/src/Controller/Entry/EntryCreateController.php @@ -21,7 +21,9 @@ use App\Service\EntryCommentManager; use App\Service\EntryManager; use App\Service\IpResolver; +use App\Service\PollManager; use App\Service\SettingsManager; +use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Form\FormInterface; @@ -50,6 +52,8 @@ public function __construct( private readonly ValidatorInterface $validator, private readonly IpResolver $ipResolver, private readonly Security $security, + private readonly PollManager $pollManager, + private readonly EntityManagerInterface $entityManager, ) { } @@ -89,6 +93,7 @@ public function __invoke( $dto->isAdult = '1' === $isNsfw; $dto->isOc = '1' === $isOc; $dto->tags = $tags; + $dto->addEmptyChoices(); if (null !== $imageHash) { $img = $this->imageRepository->findOneBySha256(hex2bin($imageHash)); diff --git a/src/Controller/Entry/EntryEditController.php b/src/Controller/Entry/EntryEditController.php index 7fe32529ae..abebb7d10c 100644 --- a/src/Controller/Entry/EntryEditController.php +++ b/src/Controller/Entry/EntryEditController.php @@ -10,6 +10,7 @@ use App\Entity\Magazine; use App\Form\EntryEditType; use App\Service\EntryManager; +use App\Service\PollManager; use App\Service\SettingsManager; use Psr\Log\LoggerInterface; use Symfony\Bridge\Doctrine\Attribute\MapEntity; @@ -26,6 +27,7 @@ public function __construct( private readonly EntryManager $manager, private readonly LoggerInterface $logger, private readonly SettingsManager $settingsManager, + private readonly PollManager $pollManager, ) { } diff --git a/src/Controller/Entry/EntryFrontController.php b/src/Controller/Entry/EntryFrontController.php index 90f756e3d6..e927293e93 100644 --- a/src/Controller/Entry/EntryFrontController.php +++ b/src/Controller/Entry/EntryFrontController.php @@ -229,6 +229,7 @@ private function renderResponse(Request $request, Criteria $criteria, array $dat if ('microblog' === $criteria->content) { $dto = new PostDto(); + $dto->addEmptyChoices(); if (isset($data['magazine'])) { $dto->magazine = $data['magazine']; diff --git a/src/Controller/Entry/EntrySingleController.php b/src/Controller/Entry/EntrySingleController.php index ee27b95dc2..24c92a6f2b 100644 --- a/src/Controller/Entry/EntrySingleController.php +++ b/src/Controller/Entry/EntrySingleController.php @@ -100,6 +100,7 @@ public function __invoke( } $dto = new EntryCommentDto(); + $dto->addEmptyChoices(); if ($user && $user->addMentionsEntries && $entry->user !== $user) { $dto->body = $this->mentionManager->addHandle([$entry->user->username])[0]; } diff --git a/src/Controller/PollVoteController.php b/src/Controller/PollVoteController.php new file mode 100644 index 0000000000..0e4c64b6ae --- /dev/null +++ b/src/Controller/PollVoteController.php @@ -0,0 +1,88 @@ +getUserOrThrow(); + $choices = $request->query->all('choice'); + + $content = $this->pollManager->getContentOfPoll($poll); + + if (null === $content) { + throw new NotFoundHttpException(); + } + + try { + $this->pollManager->vote($poll, $content, $user, $choices); + } catch (\Throwable $e) { + $this->logger->error('There was an error voting on poll {id}: {class} - {m}', [ + 'id' => $poll->getId(), + 'class' => \get_class($e), + 'm' => $e->getMessage(), + ]); + throw new BadRequestHttpException(previous: $e); + } + + return $this->redirectToPollContent($poll); + } + + #[IsGranted('ROLE_USER')] + public function refreshVoteCounts(#[MapEntity] Poll $poll): Response + { + if (null === $poll->getSubject()->apId) { + throw new BadRequestHttpException('Cannot refresh the vote counts of a local poll'); + } + + $this->apHttpClient->invalidateActivityObjectCache($poll->getSubject()->apId); + $object = $this->apHttpClient->getActivityObject($poll->getSubject()->apId); + if ($this->pollManager->hasPollProperties($object)) { + $this->pollManager->updatePollCounts($poll, $object); + } + + return $this->redirectToPollContent($poll); + } + + protected function redirectToPollContent(Poll $poll): Response + { + $content = $poll->getSubject(); + if ($content instanceof Entry) { + return $this->redirectToRoute('entry_single', ['entry_id' => $content->getId(), 'magazine_name' => $content->magazine->name]); + } elseif ($content instanceof EntryComment) { + return $this->redirectToRoute('entry_comment_view', ['entry_id' => $content->entry->getId(), 'comment_id' => $content->getId(), 'slug' => '-', 'magazine_name' => $content->magazine->name]); + } elseif ($content instanceof Post) { + return $this->redirectToRoute('post_single', ['post_id' => $content->getId(), 'magazine_name' => $content->magazine->name]); + } elseif ($content instanceof PostComment) { + return $this->redirectToRoute('post_single', ['post_id' => $content->post->getId(), 'magazine_name' => $content->magazine->name]); + } else { + throw new BadRequestHttpException(); + } + } +} diff --git a/src/Controller/Post/Comment/PostCommentCreateController.php b/src/Controller/Post/Comment/PostCommentCreateController.php index 9509032f77..93e2587479 100644 --- a/src/Controller/Post/Comment/PostCommentCreateController.php +++ b/src/Controller/Post/Comment/PostCommentCreateController.php @@ -106,6 +106,7 @@ public function __invoke( private function getForm(Post $post, ?PostComment $parent): FormInterface { $dto = new PostCommentDto(); + $dto->addEmptyChoices(); if ($parent && $this->getUser()->addMentionsPosts) { $handle = $this->mentionManager->addHandle([$parent->user->username])[0]; diff --git a/src/Controller/Post/PostCreateController.php b/src/Controller/Post/PostCreateController.php index 2e8410587b..8de2a11312 100644 --- a/src/Controller/Post/PostCreateController.php +++ b/src/Controller/Post/PostCreateController.php @@ -32,6 +32,7 @@ public function __construct( public function __invoke(Request $request): Response { $dto = new PostDto(); + $dto->addEmptyChoices(); // check if the "random" magazine exists and if so, use it $randomMagazine = $this->magazineRepository->findOneByName('random'); if (null !== $randomMagazine) { @@ -45,6 +46,7 @@ public function __invoke(Request $request): Response $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + /** @var PostDto $dto */ $dto = $form->getData(); $dto->ip = $this->ipResolver->resolve(); diff --git a/src/DTO/ContentWithPollDto.php b/src/DTO/ContentWithPollDto.php new file mode 100644 index 0000000000..764ce47208 --- /dev/null +++ b/src/DTO/ContentWithPollDto.php @@ -0,0 +1,29 @@ +pollEndsAt = new \DateTimeImmutable('now + 7 days'); + } + + public function addEmptyChoices(): void + { + $choicesToAdd = 5 - \sizeof($this->choices); + if ($choicesToAdd <= 0) { + $choicesToAdd = 1; + } + + for ($i = 0; $i < $choicesToAdd; ++$i) { + $this->choices[] = ''; + } + } +} diff --git a/src/DTO/Contracts/PollDtoTrait.php b/src/DTO/Contracts/PollDtoTrait.php new file mode 100644 index 0000000000..4facb9f40d --- /dev/null +++ b/src/DTO/Contracts/PollDtoTrait.php @@ -0,0 +1,16 @@ +commentId = $id; @@ -146,6 +148,7 @@ public static function create( $dto->canAuthUserModerate = $canAuthUserModerate; $dto->bookmarks = $bookmarks; $dto->isAuthorModeratorInMagazine = $isAuthorModeratorInMagazine; + $dto->poll = $poll; return $dto; } @@ -168,6 +171,7 @@ public function jsonSerialize(): mixed 'isFavourited', 'userVote', 'mentions', + 'poll', ]; } @@ -199,6 +203,7 @@ public function jsonSerialize(): mixed 'canAuthUserModerate' => $this->canAuthUserModerate, 'bookmarks' => $this->bookmarks, 'isAuthorModeratorInMagazine' => $this->isAuthorModeratorInMagazine, + 'poll' => $this->poll?->jsonSerialize(), ]); } } diff --git a/src/DTO/EntryDto.php b/src/DTO/EntryDto.php index e06835cec8..11d069f01a 100644 --- a/src/DTO/EntryDto.php +++ b/src/DTO/EntryDto.php @@ -14,7 +14,7 @@ use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; -class EntryDto implements ContentVisibilityInterface +class EntryDto extends ContentWithPollDto implements ContentVisibilityInterface { #[Assert\NotBlank] public Magazine|MagazineDto|null $magazine = null; diff --git a/src/DTO/EntryRequestDto.php b/src/DTO/EntryRequestDto.php index bd1e7a336c..c8b2e8780e 100644 --- a/src/DTO/EntryRequestDto.php +++ b/src/DTO/EntryRequestDto.php @@ -4,6 +4,7 @@ namespace App\DTO; +use App\DTO\Contracts\PollDtoTrait; use App\Entity\Entry; use App\Service\SettingsManager; use OpenApi\Attributes as OA; @@ -12,6 +13,8 @@ #[OA\Schema(required: ['title'])] class EntryRequestDto extends ContentRequestDto { + use PollDtoTrait; + #[Groups([ Entry::ENTRY_TYPE_ARTICLE, Entry::ENTRY_TYPE_LINK, @@ -73,6 +76,11 @@ public function mergeIntoDto(EntryDto $dto, SettingsManager $settingsManager): E $dto->url = $this->url ?? $dto->url; $dto->tags = $this->tags ?? $dto->tags; + $dto->addPoll = $this->addPoll ?? $dto->addPoll; + $dto->choices = $this->choices ?? $dto->choices; + $dto->isMultipleChoicePoll = $this->isMultipleChoicePoll ?? $dto->isMultipleChoicePoll; + $dto->pollEndsAt = $this->pollEndsAt ?? $dto->pollEndsAt; + return $dto; } } diff --git a/src/DTO/EntryResponseDto.php b/src/DTO/EntryResponseDto.php index 8e8c9e918d..62f38bf20b 100644 --- a/src/DTO/EntryResponseDto.php +++ b/src/DTO/EntryResponseDto.php @@ -61,6 +61,7 @@ class EntryResponseDto implements \JsonSerializable */ #[OA\Property(type: 'array', items: new OA\Items(ref: new Model(type: EntryResponseDto::class)))] public ?array $crosspostedEntries; + public ?PollResponseDto $poll = null; /** * @param string[]|null $bookmarks @@ -96,6 +97,7 @@ public static function create( ?array $bookmarks = null, ?array $crosspostedEntries = null, ?bool $isAuthorModeratorInMagazine = null, + ?PollResponseDto $poll = null, ): self { $dto = new EntryResponseDto(); $dto->entryId = $id; @@ -128,6 +130,7 @@ public static function create( $dto->bookmarks = $bookmarks; $dto->crosspostedEntries = $crosspostedEntries; $dto->isAuthorModeratorInMagazine = $isAuthorModeratorInMagazine; + $dto->poll = $poll; return $dto; } @@ -149,6 +152,7 @@ public function jsonSerialize(): mixed 'isFavourited', 'userVote', 'slug', + 'poll', ]; } @@ -186,6 +190,7 @@ public function jsonSerialize(): mixed 'bookmarks' => $this->bookmarks, 'crosspostedEntries' => $this->crosspostedEntries, 'isAuthorModeratorInMagazine' => $this->isAuthorModeratorInMagazine, + 'poll' => $this->poll?->jsonSerialize(), ]); } } diff --git a/src/DTO/PollChoiceResponseDto.php b/src/DTO/PollChoiceResponseDto.php new file mode 100644 index 0000000000..ae7f046535 --- /dev/null +++ b/src/DTO/PollChoiceResponseDto.php @@ -0,0 +1,39 @@ +name = $choice->name; + $dto->voteCount = $choice->voteCount; + $dto->currentUserHasVoted = $user instanceof User ? $choice->hasUserVoted($user) : null; + + return $dto; + } + + public function jsonSerialize(): array + { + return [ + 'name' => $this->name, + 'voteCount' => $this->voteCount, + 'currentUserHasVoted' => $this->currentUserHasVoted, + ]; + } +} diff --git a/src/DTO/PollResponseDto.php b/src/DTO/PollResponseDto.php new file mode 100644 index 0000000000..f09d8ae4e1 --- /dev/null +++ b/src/DTO/PollResponseDto.php @@ -0,0 +1,42 @@ +voterCount = $poll->voterCount; + $dto->endDate = $poll->endDate; + $dto->currentUserHasVoted = $user instanceof User ? $poll->hasUserVoted($user) : null; + $dto->choices = array_map(fn (PollChoice $choice) => PollChoiceResponseDto::createFromPollChoice($choice, $user), $poll->choices->toArray()); + + return $dto; + } + + public function jsonSerialize(): array + { + return [ + 'voterCount' => $this->voterCount, + 'endDate' => $this->endDate, + 'currentUserHasVoted' => $this->currentUserHasVoted, + 'choices' => $this->choices ? array_map(fn (PollChoiceResponseDto $dto) => $dto->jsonSerialize(), $this->choices) : null, + ]; + } +} diff --git a/src/DTO/PostCommentDto.php b/src/DTO/PostCommentDto.php index 538e1faec8..60c889bf8e 100644 --- a/src/DTO/PostCommentDto.php +++ b/src/DTO/PostCommentDto.php @@ -14,7 +14,7 @@ use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; -class PostCommentDto implements ContentVisibilityInterface +class PostCommentDto extends ContentWithPollDto implements ContentVisibilityInterface { public const MAX_BODY_LENGTH = 5000; diff --git a/src/DTO/PostCommentResponseDto.php b/src/DTO/PostCommentResponseDto.php index dac06a0c1b..99bd6d2bcc 100644 --- a/src/DTO/PostCommentResponseDto.php +++ b/src/DTO/PostCommentResponseDto.php @@ -87,6 +87,8 @@ class PostCommentResponseDto implements \JsonSerializable #[OA\Property(type: 'array', items: new OA\Items(type: 'string'))] public ?array $bookmarks = null; + public ?PollResponseDto $poll = null; + /** * @param string[] $bookmarks */ @@ -114,6 +116,7 @@ public static function create( ?bool $canAuthUserModerate = null, ?array $bookmarks = null, ?bool $isAuthorModeratorInMagazine = null, + ?PollResponseDto $poll = null, ): self { $dto = new PostCommentResponseDto(); $dto->commentId = $id; @@ -140,6 +143,7 @@ public static function create( $dto->canAuthUserModerate = $canAuthUserModerate; $dto->bookmarks = $bookmarks; $dto->isAuthorModeratorInMagazine = $isAuthorModeratorInMagazine; + $dto->poll = $poll; return $dto; } @@ -158,6 +162,7 @@ public function jsonSerialize(): mixed 'userVote', 'slug', 'mentions', + 'poll', ]; } @@ -189,6 +194,7 @@ public function jsonSerialize(): mixed 'canAuthUserModerate' => $this->canAuthUserModerate, 'bookmarks' => $this->bookmarks, 'isAuthorModeratorInMagazine' => $this->isAuthorModeratorInMagazine, + 'poll' => $this->poll?->jsonSerialize(), ]); } diff --git a/src/DTO/PostDto.php b/src/DTO/PostDto.php index ee875b342c..149fdcc0da 100644 --- a/src/DTO/PostDto.php +++ b/src/DTO/PostDto.php @@ -13,7 +13,7 @@ use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; -class PostDto implements ContentVisibilityInterface +class PostDto extends ContentWithPollDto implements ContentVisibilityInterface { public const MAX_BODY_LENGTH = 5000; diff --git a/src/DTO/PostResponseDto.php b/src/DTO/PostResponseDto.php index e959205660..0019d747d9 100644 --- a/src/DTO/PostResponseDto.php +++ b/src/DTO/PostResponseDto.php @@ -45,6 +45,7 @@ class PostResponseDto implements \JsonSerializable #[OA\Property(type: 'array', items: new OA\Items(type: 'string'))] public ?array $bookmarks = null; public ?bool $isAuthorModeratorInMagazine = null; + public ?PollResponseDto $poll = null; /** * @param string[] $bookmarks @@ -74,6 +75,7 @@ public static function create( ?bool $canAuthUserModerate = null, ?array $bookmarks = null, ?bool $isAuthorModeratorInMagazine = null, + ?PollResponseDto $poll = null, ): self { $dto = new PostResponseDto(); $dto->postId = $id; @@ -100,6 +102,7 @@ public static function create( $dto->canAuthUserModerate = $canAuthUserModerate; $dto->bookmarks = $bookmarks; $dto->isAuthorModeratorInMagazine = $isAuthorModeratorInMagazine; + $dto->poll = $poll; return $dto; } @@ -118,6 +121,7 @@ public function jsonSerialize(): mixed 'userVote', 'slug', 'mentions', + 'poll', ]; } @@ -149,6 +153,7 @@ public function jsonSerialize(): mixed 'notificationStatus' => $this->notificationStatus, 'bookmarks' => $this->bookmarks, 'isAuthorModeratorInMagazine' => $this->isAuthorModeratorInMagazine, + 'poll' => $this->poll?->jsonSerialize(), ]); } } diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index 991e42fe97..ae4bc106d8 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -64,6 +64,9 @@ class Activity #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?PostComment $objectPostComment = null; + #[ManyToOne(targetEntity: PollVote::class), JoinColumn(referencedColumnName: 'uuid', nullable: true, onDelete: 'CASCADE')] + public ?PollVote $objectPollVote = null; + #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?Message $objectMessage = null; @@ -97,7 +100,7 @@ public function __construct(string $type) $this->createdAtTraitConstruct(); } - public function setObject(ActivityPubActivityInterface|Entry|EntryComment|Post|PostComment|ActivityPubActorInterface|User|Magazine|MagazineBan|Activity|array|string $object): void + public function setObject(ActivityPubActivityInterface|Entry|EntryComment|Post|PostComment|PollVote|ActivityPubActorInterface|User|Magazine|MagazineBan|Activity|array|string $object): void { if ($object instanceof Entry) { $this->objectEntry = $object; @@ -107,6 +110,8 @@ public function setObject(ActivityPubActivityInterface|Entry|EntryComment|Post|P $this->objectPost = $object; } elseif ($object instanceof PostComment) { $this->objectPostComment = $object; + } elseif ($object instanceof PollVote) { + $this->objectPollVote = $object; } elseif ($object instanceof Message) { $this->objectMessage = $object; } elseif ($object instanceof User) { @@ -129,9 +134,9 @@ public function setObject(ActivityPubActivityInterface|Entry|EntryComment|Post|P } } - public function getObject(): Post|EntryComment|PostComment|Entry|Message|User|Magazine|MagazineBan|array|string|null + public function getObject(): Entry|Post|EntryComment|PostComment|PollVote|Message|User|Magazine|MagazineBan|array|string|null { - $o = $this->objectEntry ?? $this->objectEntryComment ?? $this->objectPost ?? $this->objectPostComment ?? $this->objectMessage ?? $this->objectUser ?? $this->objectMagazine ?? $this->objectMagazineBan; + $o = $this->objectEntry ?? $this->objectEntryComment ?? $this->objectPost ?? $this->objectPostComment ?? $this->objectPollVote ?? $this->objectMessage ?? $this->objectUser ?? $this->objectMagazine ?? $this->objectMagazineBan; if (null !== $o) { return $o; } diff --git a/src/Entity/Entry.php b/src/Entity/Entry.php index accef09f89..96e8e27950 100644 --- a/src/Entity/Entry.php +++ b/src/Entity/Entry.php @@ -32,6 +32,7 @@ use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; +use Doctrine\ORM\Mapping\OneToOne; use Doctrine\ORM\Mapping\OrderBy; use Webmozart\Assert\Assert; @@ -115,6 +116,8 @@ class Entry implements VotableInterface, CommentInterface, DomainInterface, Visi public ?string $ip = null; #[Column(type: Types::JSONB, nullable: true)] public ?array $mentions = null; + #[OneToOne(targetEntity: Poll::class, inversedBy: 'entry')] + public ?Poll $poll = null; #[OneToMany(mappedBy: 'entry', targetEntity: EntryComment::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $comments; #[OneToMany(mappedBy: 'entry', targetEntity: EntryVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] diff --git a/src/Entity/EntryComment.php b/src/Entity/EntryComment.php index c642b1137b..2788da963c 100644 --- a/src/Entity/EntryComment.php +++ b/src/Entity/EntryComment.php @@ -30,6 +30,7 @@ use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; +use Doctrine\ORM\Mapping\OneToOne; use Doctrine\ORM\Mapping\OrderBy; use Webmozart\Assert\Assert; @@ -80,6 +81,8 @@ class EntryComment implements VotableInterface, VisibilityInterface, ReportInter public ?string $ip = null; #[Column(type: 'json', nullable: true)] public ?array $mentions = null; + #[OneToOne(targetEntity: Poll::class)] + public ?Poll $poll = null; #[OneToMany(mappedBy: 'parent', targetEntity: EntryComment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'ASC'])] public Collection $children; diff --git a/src/Entity/Notification.php b/src/Entity/Notification.php index 71836fb63a..6f040e5064 100644 --- a/src/Entity/Notification.php +++ b/src/Entity/Notification.php @@ -31,6 +31,8 @@ 'entry_comment_reply' => 'EntryCommentReplyNotification', 'entry_comment_deleted' => 'EntryCommentDeletedNotification', 'entry_comment_mentioned' => 'EntryCommentMentionedNotification', + 'poll_ended' => 'PollEndedNotification', + 'poll_edited' => 'PollEditedNotification', 'post_created' => 'PostCreatedNotification', 'post_edited' => 'PostEditedNotification', 'post_deleted' => 'PostDeletedNotification', diff --git a/src/Entity/Poll.php b/src/Entity/Poll.php new file mode 100644 index 0000000000..0ddc1760f3 --- /dev/null +++ b/src/Entity/Poll.php @@ -0,0 +1,145 @@ + 0])] + public int $voterCount = 0; + + #[Column(type: Types::DATETIMETZ_IMMUTABLE)] + public \DateTimeImmutable $endDate; + + #[Column] + public bool $isRemote = false; + + #[Column] + public bool $sentNotifications = false; + + /** + * @var Collection + */ + #[OneToMany(targetEntity: PollVote::class, mappedBy: 'poll')] + public Collection $votes; + + /** @var Collection */ + #[OneToMany(targetEntity: PollChoice::class, mappedBy: 'poll')] + public Collection $choices; + + #[OneToOne(targetEntity: Entry::class, mappedBy: 'poll')] + public ?Entry $entry; + + #[OneToOne(targetEntity: EntryComment::class, mappedBy: 'poll')] + public ?EntryComment $entryComment; + + #[OneToOne(targetEntity: Post::class, mappedBy: 'poll')] + public ?Post $post; + + #[OneToOne(targetEntity: PostComment::class, mappedBy: 'poll')] + public ?PostComment $postComment; + + public function __construct(?\DateTimeImmutable $endDate = null) + { + $this->createdAtTraitConstruct(); + $this->endDate = $endDate ?? new \DateTimeImmutable('now + 7days'); + $this->votes = new ArrayCollection(); + $this->choices = new ArrayCollection(); + } + + public function getId(): int + { + return $this->id; + } + + public function hasUserVoted(User $user): bool + { + return \count($this->getUserVotes($user)) > 0; + } + + /** + * @return PollVote[] + */ + public function getUserVotes(User $user): array + { + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('voter', $user)); + + return $this->votes->matching($criteria)->toArray(); + } + + public function findChoice(string $name): ?PollChoice + { + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('name', $name)); + + return $this->choices->matching($criteria)->first() ?: null; + } + + public function hasEnded(): bool + { + return new \DateTimeImmutable() > $this->endDate; + } + + /** + * @return array + */ + public function getResultData(?User $user): array + { + $result = []; + foreach ($this->choices as $choice) { + $result[] = [ + 'name' => $choice->name, + 'userVoted' => $user ? $choice->hasUserVoted($user) : false, + 'percentage' => $this->voterCount ? round(100 / $this->voterCount * $choice->voteCount, 2) : 0, + ]; + } + + return $result; + } + + public function updateVoterCount(): void + { + if ($this->isRemote) { + ++$this->voterCount; + } else { + $voteUsers = []; + foreach ($this->votes as $vote) { + $voteUsers[$vote->voter->getId()] = $vote->voter; + } + $this->voterCount = \count($voteUsers); + } + } + + public function getSubject(): Entry|EntryComment|Post|PostComment|null + { + return $this->entry ?? $this->entryComment ?? $this->post ?? $this->postComment; + } +} diff --git a/src/Entity/PollChoice.php b/src/Entity/PollChoice.php new file mode 100644 index 0000000000..3c6b5a5ce5 --- /dev/null +++ b/src/Entity/PollChoice.php @@ -0,0 +1,69 @@ + + */ + #[OneToMany(targetEntity: PollVote::class, mappedBy: 'choice')] + public Collection $votes; + + #[JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[ManyToOne(targetEntity: Poll::class)] + public Poll $poll; + + public function __construct() + { + $this->votes = new ArrayCollection(); + } + + /** + * @return Collection + */ + public function getUserVotes(User $user): Collection + { + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('voter', $user)); + + return $this->votes->matching($criteria); + } + + public function hasUserVoted(User $user): bool + { + return !$this->getUserVotes($user)->isEmpty(); + } + + public function updateVoteCount(): void + { + if ($this->poll->isRemote) { + ++$this->voteCount; + } else { + $this->voteCount = $this->votes->count(); + } + } +} diff --git a/src/Entity/PollEditedNotification.php b/src/Entity/PollEditedNotification.php new file mode 100644 index 0000000000..6d5ed0d12c --- /dev/null +++ b/src/Entity/PollEditedNotification.php @@ -0,0 +1,42 @@ +poll = $poll; + } + + public function getType(): string + { + return 'poll_edited'; + } + + public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + { + $content = $this->poll->entry ?? $this->poll->entryComment ?? $this->poll->post ?? $this->poll->postComment; + $message = ''; + $title = $trans->trans('notification_title_poll_edited', locale: $locale); + $action = ApActivityRepository::s_getLocalUrlOfEntity($urlGenerator, $content, true); + + return new PushNotification($this->getId(), $message, $title, $action); + } +} diff --git a/src/Entity/PollEndedNotification.php b/src/Entity/PollEndedNotification.php new file mode 100644 index 0000000000..808ad9db13 --- /dev/null +++ b/src/Entity/PollEndedNotification.php @@ -0,0 +1,42 @@ +poll = $poll; + } + + public function getType(): string + { + return 'poll_ended'; + } + + public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + { + $content = $this->poll->entry ?? $this->poll->entryComment ?? $this->poll->post ?? $this->poll->postComment; + $message = ''; + $title = $trans->trans('notification_title_poll_ended', locale: $locale); + $action = ApActivityRepository::s_getLocalUrlOfEntity($urlGenerator, $content, true); + + return new PushNotification($this->getId(), $message, $title, $action); + } +} diff --git a/src/Entity/PollVote.php b/src/Entity/PollVote.php new file mode 100644 index 0000000000..9fd7aafe04 --- /dev/null +++ b/src/Entity/PollVote.php @@ -0,0 +1,52 @@ +createdAtTraitConstruct(); + } + + public function getUser(): ?User + { + return $this->voter; + } +} diff --git a/src/Entity/Post.php b/src/Entity/Post.php index c7e1f00693..c69c7506bc 100644 --- a/src/Entity/Post.php +++ b/src/Entity/Post.php @@ -30,6 +30,7 @@ use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; +use Doctrine\ORM\Mapping\OneToOne; use Webmozart\Assert\Assert; #[Entity(repositoryClass: PostRepository::class)] @@ -85,6 +86,8 @@ class Post implements VotableInterface, CommentInterface, VisibilityInterface, R public ?string $ip = null; #[Column(type: Types::JSONB, nullable: true)] public ?array $mentions = null; + #[OneToOne(targetEntity: Poll::class)] + public ?Poll $poll = null; #[OneToMany(mappedBy: 'post', targetEntity: PostComment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $comments; #[OneToMany(mappedBy: 'post', targetEntity: PostVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] diff --git a/src/Entity/PostComment.php b/src/Entity/PostComment.php index 2283beef7d..fd9bd16319 100644 --- a/src/Entity/PostComment.php +++ b/src/Entity/PostComment.php @@ -30,6 +30,7 @@ use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; +use Doctrine\ORM\Mapping\OneToOne; use Doctrine\ORM\Mapping\OrderBy; use Webmozart\Assert\Assert; @@ -82,6 +83,8 @@ class PostComment implements VotableInterface, VisibilityInterface, ReportInterf public bool $isAdult = false; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public ?bool $updateMark = false; + #[OneToOne(targetEntity: Poll::class)] + public ?Poll $poll = null; #[OneToMany(mappedBy: 'parent', targetEntity: PostComment::class, orphanRemoval: true)] #[OrderBy(['createdAt' => 'ASC'])] public Collection $children; diff --git a/src/Event/Poll/PollEditedEvent.php b/src/Event/Poll/PollEditedEvent.php new file mode 100644 index 0000000000..ca2b5491c8 --- /dev/null +++ b/src/Event/Poll/PollEditedEvent.php @@ -0,0 +1,22 @@ + 'onPollVote', + PollEditedEvent::class => 'onPollEdited', + PollPreEditedEvent::class => 'onPollPreEdited', + ]; + } + + public function onPollVote(PollVoteEvent $event): void + { + if ($event->poll->isRemote && null === $event->voter->apId) { + // remote poll, local user -> send the vote + foreach ($event->poll->votes as $vote) { + $this->bus->dispatch(new PollVoteMessage($vote->uuid->toString())); + } + } elseif (!$event->poll->isRemote) { + // remote poll -> send update with new vote numbers + $this->bus->dispatch(new UpdateMessage($event->content->getId(), \get_class($event->content))); + $event->content->editedAt = new \DateTimeImmutable(); + } + } + + public function onPollEdited(PollEditedEvent $event): void + { + } + + public function onPollPreEdited(PollPreEditedEvent $event): void + { + try { + $this->notificationManager->sendPollEditedNotification($event->poll); + } catch (\Throwable $exception) { + $this->logger->error('Something went wrong while sending the poll edited notifications for poll {p}: {e} - {m}', [ + 'p' => $event->poll->getId(), + 'e' => \get_class($exception), + 'm' => $exception->getMessage(), + ]); + } + } +} diff --git a/src/Exception/PollHasEndedException.php b/src/Exception/PollHasEndedException.php new file mode 100644 index 0000000000..a8eae528b1 --- /dev/null +++ b/src/Exception/PollHasEndedException.php @@ -0,0 +1,9 @@ + $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), + $activity instanceof PollVote => $this->pollVoteFactory->build($activity, $context), default => throw new \LogicException('Cannot handle activity of type '.\get_class($activity)), }; } diff --git a/src/Factory/ActivityPub/EntryCommentNoteFactory.php b/src/Factory/ActivityPub/EntryCommentNoteFactory.php index a6174024db..5eee5ea7cc 100644 --- a/src/Factory/ActivityPub/EntryCommentNoteFactory.php +++ b/src/Factory/ActivityPub/EntryCommentNoteFactory.php @@ -31,6 +31,7 @@ public function __construct( private readonly ApHttpClientInterface $client, private readonly ActivityPubManager $activityPubManager, private readonly MarkdownConverter $markdownConverter, + private readonly PollFactory $pollFactory, ) { } @@ -108,6 +109,10 @@ public function create(EntryComment $comment, array $tags, bool $context = false ) ); + if ($comment->poll) { + $this->pollFactory->addToNote($note, $comment->poll); + } + return $note; } diff --git a/src/Factory/ActivityPub/EntryPageFactory.php b/src/Factory/ActivityPub/EntryPageFactory.php index ab1153e6b1..ada34d124c 100644 --- a/src/Factory/ActivityPub/EntryPageFactory.php +++ b/src/Factory/ActivityPub/EntryPageFactory.php @@ -28,6 +28,7 @@ public function __construct( private readonly ApHttpClientInterface $client, private readonly ActivityPubManager $activityPubManager, private readonly MarkdownConverter $markdownConverter, + private readonly PollFactory $pollFactory, ) { } @@ -48,7 +49,7 @@ public function create(Entry $entry, array $tags, bool $context = false): array $page = array_merge($page ?? [], [ 'id' => $this->getActivityPubId($entry), - 'type' => 'Page', + 'type' => $entry->poll ? 'Question' : 'Page', 'attributedTo' => $this->activityPubManager->getActorProfileId($entry->user), 'inReplyTo' => null, 'to' => [ @@ -105,6 +106,10 @@ public function create(Entry $entry, array $tags, bool $context = false): array ); } + if ($entry->poll) { + $this->pollFactory->addToNote($page, $entry->poll); + } + return $page; } diff --git a/src/Factory/ActivityPub/PollChoiceNoteFactory.php b/src/Factory/ActivityPub/PollChoiceNoteFactory.php new file mode 100644 index 0000000000..d25fc5bb7e --- /dev/null +++ b/src/Factory/ActivityPub/PollChoiceNoteFactory.php @@ -0,0 +1,36 @@ + 'Note', + 'name' => $pollChoice->name, + 'replies' => [ + 'type' => 'Collection', + 'totalItems' => $pollChoice->voteCount, + ], + ]; + + if ($includeContext) { + $note['@context'] = [ + $this->contextsProvider->referencedContexts(), + ]; + } + + return $note; + } +} diff --git a/src/Factory/ActivityPub/PollFactory.php b/src/Factory/ActivityPub/PollFactory.php new file mode 100644 index 0000000000..9a93d314e7 --- /dev/null +++ b/src/Factory/ActivityPub/PollFactory.php @@ -0,0 +1,30 @@ +voterCount; + $note['endTime'] = $poll->endDate->format(DATE_ATOM); + $options = []; + foreach ($poll->choices as $choice) { + $options[] = $this->pollChoiceNoteFactory->create($choice); + } + if ($poll->multipleChoice) { + $note['anyOf'] = $options; + } else { + $note['oneOf'] = $options; + } + } +} diff --git a/src/Factory/ActivityPub/PollVoteFactory.php b/src/Factory/ActivityPub/PollVoteFactory.php new file mode 100644 index 0000000000..767a3c801d --- /dev/null +++ b/src/Factory/ActivityPub/PollVoteFactory.php @@ -0,0 +1,57 @@ +personFactory->getActivityPubId($vote->getUser()); + $content = $this->entryRepository->findOneBy(['poll' => $vote->poll]) + ?? $this->entryCommentRepository->findOneBy(['poll' => $vote->poll]) + ?? $this->postRepository->findOneBy(['poll' => $vote->poll]) + ?? $this->postCommentRepository->findOneBy(['poll' => $vote->poll]) + ?? throw new \LogicException(); + + $result = [ + '@context' => $this->contextsProvider->referencedContexts(), + 'id' => $this->urlGenerator->generate('ap_poll_vote', ['username' => $vote->getUser()->username, 'uuid' => $vote->uuid], UrlGeneratorInterface::ABSOLUTE_URL), + 'attributedTo' => $actorUrl, + 'to' => [$this->personFactory->getActivityPubId($content->user)], + 'cc' => [], + 'type' => 'Note', + 'published' => $vote->createdAt->format(DATE_ATOM), + 'inReplyTo' => $content->apId ?? $this->apActivityRepository->getLocalUrlOfEntity($content, true), + 'name' => $vote->choice->name, + ]; + + if (!$includeContext) { + unset($result['@context']); + } + + return $result; + } +} diff --git a/src/Factory/ActivityPub/PostCommentNoteFactory.php b/src/Factory/ActivityPub/PostCommentNoteFactory.php index 96ffabe4f6..2349afd7a9 100644 --- a/src/Factory/ActivityPub/PostCommentNoteFactory.php +++ b/src/Factory/ActivityPub/PostCommentNoteFactory.php @@ -31,6 +31,7 @@ public function __construct( private readonly ApHttpClientInterface $client, private readonly ActivityPubManager $activityPubManager, private readonly MarkdownConverter $markdownConverter, + private readonly PollFactory $pollFactory, ) { } @@ -108,6 +109,10 @@ public function create(PostComment $comment, array $tags, bool $context = false) ) ); + if ($comment->poll) { + $this->pollFactory->addToNote($note, $comment->poll); + } + return $note; } diff --git a/src/Factory/ActivityPub/PostNoteFactory.php b/src/Factory/ActivityPub/PostNoteFactory.php index c71abf1c6c..f75bdcc764 100644 --- a/src/Factory/ActivityPub/PostNoteFactory.php +++ b/src/Factory/ActivityPub/PostNoteFactory.php @@ -32,6 +32,7 @@ public function __construct( private readonly MentionManager $mentionManager, private readonly TagExtractor $tagExtractor, private readonly MarkdownConverter $markdownConverter, + private readonly PollFactory $pollFactory, ) { } @@ -94,6 +95,10 @@ public function create(Post $post, array $tags, bool $context = false): array $note = $this->imageWrapper->build($note, $post->image, $post->getShortTitle()); } + if ($post->poll) { + $this->pollFactory->addToNote($note, $post->poll); + } + $note['to'] = array_unique(array_merge($note['to'], $this->activityPubManager->createCcFromBody($post->body))); return $note; diff --git a/src/Factory/EntryCommentFactory.php b/src/Factory/EntryCommentFactory.php index 741a8566ca..6657fb65cf 100644 --- a/src/Factory/EntryCommentFactory.php +++ b/src/Factory/EntryCommentFactory.php @@ -6,7 +6,10 @@ use App\DTO\EntryCommentDto; use App\DTO\EntryCommentResponseDto; +use App\DTO\PollChoiceResponseDto; +use App\DTO\PollResponseDto; use App\Entity\EntryComment; +use App\Entity\PollChoice; use App\Entity\User; use App\PageView\EntryCommentPageView; use App\Repository\BookmarkListRepository; @@ -38,9 +41,18 @@ public function createFromDto(EntryCommentDto $dto, User $user): EntryComment ); } - public function createResponseDto(EntryCommentDto|EntryComment $comment, array $tags, int $childCount = 0): EntryCommentResponseDto + public function createResponseDto(EntryComment $comment, array $tags, int $childCount = 0): EntryCommentResponseDto { - $dto = $comment instanceof EntryComment ? $this->createDto($comment) : $comment; + $dto = $this->createDto($comment); + $pollDto = null; + if ($dto->addPoll) { + $pollDto = new PollResponseDto(); + $pollDto->endDate = $dto->pollEndsAt; + $pollDto->voterCount = $comment->poll->voterCount; + $user = $this->security->getUser(); + $pollDto->currentUserHasVoted = $user instanceof User ? $comment->poll->hasUserVoted($user) : null; + $pollDto->choices = $comment->poll->choices ? array_map(fn (PollChoice $choice) => PollChoiceResponseDto::createFromPollChoice($choice, $user), $comment->poll->choices->toArray()) : null; + } return EntryCommentResponseDto::create( $dto->getId(), @@ -66,13 +78,14 @@ public function createResponseDto(EntryCommentDto|EntryComment $comment, array $ $childCount, bookmarks: $this->bookmarkListRepository->getBookmarksOfContentInterface($comment), isAuthorModeratorInMagazine: $dto->magazine->userIsModerator($dto->user), + poll: $pollDto, ); } public function createResponseTree(EntryComment $comment, EntryCommentPageView $commentPageView, int $depth = -1, ?bool $canModerate = null): EntryCommentResponseDto { $commentDto = $this->createDto($comment); - $toReturn = $this->createResponseDto($commentDto, $this->tagLinkRepository->getTagsOfContent($comment), array_reduce($comment->children->toArray(), EntryCommentResponseDto::class.'::recursiveChildCount', 0)); + $toReturn = $this->createResponseDto($comment, $this->tagLinkRepository->getTagsOfContent($comment), array_reduce($comment->children->toArray(), EntryCommentResponseDto::class.'::recursiveChildCount', 0)); $toReturn->isFavourited = $commentDto->isFavourited; $toReturn->userVote = $commentDto->userVote; $toReturn->canAuthUserModerate = $canModerate; diff --git a/src/Factory/EntryFactory.php b/src/Factory/EntryFactory.php index 21fe9aa12f..88fe1bb459 100644 --- a/src/Factory/EntryFactory.php +++ b/src/Factory/EntryFactory.php @@ -6,8 +6,11 @@ use App\DTO\EntryDto; use App\DTO\EntryResponseDto; +use App\DTO\PollChoiceResponseDto; +use App\DTO\PollResponseDto; use App\Entity\Badge; use App\Entity\Entry; +use App\Entity\PollChoice; use App\Entity\User; use App\Repository\BookmarkListRepository; use App\Repository\TagLinkRepository; @@ -42,10 +45,18 @@ public function createFromDto(EntryDto $dto, User $user): Entry ); } - public function createResponseDto(EntryDto|Entry $entry, array $tags, ?array $crosspostedEntries = null): EntryResponseDto + public function createResponseDto(Entry $entry, array $tags, ?array $crosspostedEntries = null): EntryResponseDto { - $dto = $entry instanceof Entry ? $this->createDto($entry) : $entry; + $dto = $this->createDto($entry); $badges = $dto->badges ? array_map(fn (Badge $badge) => $this->badgeFactory->createDto($badge), $dto->badges->toArray()) : null; + $pollDto = null; + if ($dto->addPoll) { + $user = $this->security->getUser(); + $pollDto = new PollResponseDto(); + $pollDto->voterCount = $entry->poll->voterCount; + $pollDto->currentUserHasVoted = $user instanceof User ? $entry->poll->hasUserVoted($user) : null; + $pollDto->choices = $entry->poll->choices ? array_map(fn (PollChoice $choice) => PollChoiceResponseDto::createFromPollChoice($choice, $user), $entry->poll->choices->toArray()) : null; + } return EntryResponseDto::create( $dto->getId(), @@ -77,6 +88,7 @@ public function createResponseDto(EntryDto|Entry $entry, array $tags, ?array $cr bookmarks: $this->bookmarkListRepository->getBookmarksOfContentInterface($entry), crosspostedEntries: $crosspostedEntries, isAuthorModeratorInMagazine: $dto->magazine->userIsModerator($dto->user), + poll: $pollDto ); } @@ -116,6 +128,15 @@ public function createDto(Entry $entry): EntryDto $dto->apShareCount = $entry->apShareCount; $dto->tags = $this->tagLinkRepository->getTagsOfContent($entry); + if ($entry->poll) { + $dto->addPoll = true; + $dto->pollEndsAt = $entry->poll->endDate; + $dto->isMultipleChoicePoll = $entry->poll->multipleChoice; + foreach ($entry->poll->choices as $choice) { + $dto->choices[] = $choice->name; + } + } + $currentUser = $this->security->getUser(); // Only return the user's vote if permission to control voting has been given $dto->isFavourited = $this->security->isGranted('ROLE_OAUTH2_ENTRY:VOTE') ? $entry->isFavored($currentUser) : null; diff --git a/src/Factory/PostCommentFactory.php b/src/Factory/PostCommentFactory.php index d1f48b11de..07c070f7d6 100644 --- a/src/Factory/PostCommentFactory.php +++ b/src/Factory/PostCommentFactory.php @@ -4,8 +4,11 @@ namespace App\Factory; +use App\DTO\PollChoiceResponseDto; +use App\DTO\PollResponseDto; use App\DTO\PostCommentDto; use App\DTO\PostCommentResponseDto; +use App\Entity\PollChoice; use App\Entity\PostComment; use App\Entity\User; use App\PageView\PostCommentPageView; @@ -38,9 +41,19 @@ public function createFromDto(PostCommentDto $dto, User $user): PostComment ); } - public function createResponseDto(PostCommentDto|PostComment $comment, array $tags, int $childCount = 0): PostCommentResponseDto + public function createResponseDto(PostComment $comment, array $tags, int $childCount = 0): PostCommentResponseDto { - $dto = $comment instanceof PostComment ? $this->createDto($comment) : $comment; + $dto = $this->createDto($comment); + + $pollDto = null; + if ($dto->addPoll) { + $pollDto = new PollResponseDto(); + $pollDto->endDate = $dto->pollEndsAt; + $pollDto->voterCount = $comment->poll->voterCount; + $user = $this->security->getUser(); + $pollDto->currentUserHasVoted = $user instanceof User ? $comment->poll->hasUserVoted($user) : null; + $pollDto->choices = $comment->poll->choices ? array_map(fn (PollChoice $choice) => PollChoiceResponseDto::createFromPollChoice($choice, $user), $comment->poll->choices->toArray()) : null; + } return PostCommentResponseDto::create( $dto->getId(), @@ -65,13 +78,14 @@ public function createResponseDto(PostCommentDto|PostComment $comment, array $ta $dto->lastActive, bookmarks: $this->bookmarkListRepository->getBookmarksOfContentInterface($comment), isAuthorModeratorInMagazine: $dto->magazine->userIsModerator($dto->user), + poll: $pollDto, ); } public function createResponseTree(PostComment $comment, PostCommentPageView $criteria, int $depth, ?bool $canModerate = null): PostCommentResponseDto { $commentDto = $this->createDto($comment); - $toReturn = $this->createResponseDto($commentDto, $this->tagLinkRepository->getTagsOfContent($comment), array_reduce($comment->children->toArray(), PostCommentResponseDto::class.'::recursiveChildCount', 0)); + $toReturn = $this->createResponseDto($comment, $this->tagLinkRepository->getTagsOfContent($comment), array_reduce($comment->children->toArray(), PostCommentResponseDto::class.'::recursiveChildCount', 0)); $toReturn->isFavourited = $commentDto->isFavourited; $toReturn->userVote = $commentDto->userVote; $toReturn->canAuthUserModerate = $canModerate; @@ -122,8 +136,8 @@ public function createDto(PostComment $comment): PostCommentDto $currentUser = $this->security->getUser(); // Only return the user's vote if permission to control voting has been given - $dto->isFavourited = $this->security->isGranted('ROLE_OAUTH2_POST_COMMENT:VOTE') ? $comment->isFavored($currentUser) : null; - $dto->userVote = $this->security->isGranted('ROLE_OAUTH2_POST_COMMENT:VOTE') ? $comment->getUserChoice($currentUser) : null; + $dto->isFavourited = $this->security->isGranted('ROLE_OAUTH2_POST_COMMENT:VOTE') && $currentUser instanceof User ? $comment->isFavored($currentUser) : null; + $dto->userVote = $this->security->isGranted('ROLE_OAUTH2_POST_COMMENT:VOTE') && $currentUser instanceof User ? $comment->getUserChoice($currentUser) : null; return $dto; } diff --git a/src/Factory/PostFactory.php b/src/Factory/PostFactory.php index 204b33a381..16be5a75e6 100644 --- a/src/Factory/PostFactory.php +++ b/src/Factory/PostFactory.php @@ -4,8 +4,11 @@ namespace App\Factory; +use App\DTO\PollChoiceResponseDto; +use App\DTO\PollResponseDto; use App\DTO\PostDto; use App\DTO\PostResponseDto; +use App\Entity\PollChoice; use App\Entity\Post; use App\Entity\User; use App\Repository\BookmarkListRepository; @@ -33,9 +36,19 @@ public function createFromDto(PostDto $dto, User $user): Post ); } - public function createResponseDto(PostDto|Post $post, array $tags): PostResponseDto + public function createResponseDto(Post $post, array $tags): PostResponseDto { - $dto = $post instanceof Post ? $this->createDto($post) : $post; + $dto = $this->createDto($post); + + $pollDto = null; + if ($dto->addPoll) { + $pollDto = new PollResponseDto(); + $pollDto->endDate = $dto->pollEndsAt; + $pollDto->voterCount = $post->poll->voterCount; + $user = $this->security->getUser(); + $pollDto->currentUserHasVoted = $user instanceof User ? $post->poll->hasUserVoted($user) : null; + $pollDto->choices = $post->poll->choices ? array_map(fn (PollChoice $choice) => PollChoiceResponseDto::createFromPollChoice($choice, $user), $post->poll->choices->toArray()) : null; + } return PostResponseDto::create( $dto->getId(), @@ -61,6 +74,7 @@ public function createResponseDto(PostDto|Post $post, array $tags): PostResponse $dto->slug, bookmarks: $this->bookmarkListRepository->getBookmarksOfContentInterface($post), isAuthorModeratorInMagazine: $dto->magazine->userIsModerator($dto->user), + poll: $pollDto, ); } diff --git a/src/Form/EntryCommentType.php b/src/Form/EntryCommentType.php index 5a43e2254d..59fc17978c 100644 --- a/src/Form/EntryCommentType.php +++ b/src/Form/EntryCommentType.php @@ -11,9 +11,13 @@ use App\Form\Type\LanguageType; use App\Service\SettingsManager; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\UrlType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; @@ -45,6 +49,30 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ) ->add('imageUrl', UrlType::class, ['required' => false, 'default_protocol' => 'https']) ->add('imageAlt', TextareaType::class, ['required' => false]) + ->add('addPoll', CheckboxType::class, [ + 'required' => false, + ]) + ->add('isMultipleChoicePoll', CheckboxType::class, [ + 'required' => false, + 'label' => 'poll_is_multiple_choice', + ]) + ->add('pollEndsAt', DateTimeType::class, [ + 'attr' => [ + 'class' => 'form-control', + ], + 'required' => false, + 'label' => 'poll_ends_at', + ]) + ->add('choices', CollectionType::class, [ + 'entry_type' => TextType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'required' => false, + 'attr' => [ + 'class' => 'existing-collection-items', + ], + 'label' => 'poll_choices', + ]) ->add('submit', SubmitType::class); $builder->addEventSubscriber($this->defaultLanguage); diff --git a/src/Form/EntryEditType.php b/src/Form/EntryEditType.php index d8d1a89318..88c16592af 100644 --- a/src/Form/EntryEditType.php +++ b/src/Form/EntryEditType.php @@ -11,10 +11,13 @@ use App\Form\EventListener\DisableFieldsOnEntryEdit; use App\Form\EventListener\ImageListener; // use App\Form\Type\BadgesType; +use App\Form\EventListener\RemovePollFieldsOnEntryEdit; use App\Form\Type\LanguageType; use App\Form\Type\MagazineAutocompleteType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; @@ -29,6 +32,7 @@ public function __construct( private readonly ImageListener $imageListener, private readonly DefaultLanguage $defaultLanguage, private readonly DisableFieldsOnEntryEdit $disableFieldsOnEntryEdit, + private readonly RemovePollFieldsOnEntryEdit $removePollFieldsOnEntryEdit, ) { } @@ -85,6 +89,27 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('isOc', CheckboxType::class, [ 'required' => false, ]) + ->add('isMultipleChoicePoll', CheckboxType::class, [ + 'required' => false, + 'label' => 'poll_is_multiple_choice', + ]) + ->add('pollEndsAt', DateTimeType::class, [ + 'attr' => [ + 'class' => 'form-control', + ], + 'required' => false, + 'label' => 'poll_ends_at', + ]) + ->add('choices', CollectionType::class, [ + 'entry_type' => TextType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'required' => false, + 'attr' => [ + 'class' => 'existing-collection-items', + ], + 'label' => 'poll_choices', + ]) ->add('submit', SubmitType::class); $builder->get('tags')->addModelTransformer( @@ -94,6 +119,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder->addEventSubscriber($this->defaultLanguage); $builder->addEventSubscriber($this->disableFieldsOnEntryEdit); $builder->addEventSubscriber($this->imageListener); + $builder->addEventSubscriber($this->removePollFieldsOnEntryEdit); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Form/EntryType.php b/src/Form/EntryType.php index 32828704bc..007d2d4668 100644 --- a/src/Form/EntryType.php +++ b/src/Form/EntryType.php @@ -10,11 +10,12 @@ use App\Form\EventListener\DefaultLanguage; use App\Form\EventListener\DisableFieldsOnEntryEdit; use App\Form\EventListener\ImageListener; -// use App\Form\Type\BadgesType; use App\Form\Type\LanguageType; use App\Form\Type\MagazineAutocompleteType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; @@ -85,6 +86,30 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('isOc', CheckboxType::class, [ 'required' => false, ]) + ->add('addPoll', CheckboxType::class, [ + 'required' => false, + ]) + ->add('isMultipleChoicePoll', CheckboxType::class, [ + 'required' => false, + 'label' => 'poll_is_multiple_choice', + ]) + ->add('pollEndsAt', DateTimeType::class, [ + 'attr' => [ + 'class' => 'form-control', + ], + 'required' => false, + 'label' => 'poll_ends_at', + ]) + ->add('choices', CollectionType::class, [ + 'entry_type' => TextType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'required' => false, + 'attr' => [ + 'class' => 'existing-collection-items', + ], + 'label' => 'poll_choices', + ]) ->add('submit', SubmitType::class); $builder->get('tags')->addModelTransformer( diff --git a/src/Form/EventListener/RemovePollFieldsOnEntryEdit.php b/src/Form/EventListener/RemovePollFieldsOnEntryEdit.php new file mode 100644 index 0000000000..2321710b58 --- /dev/null +++ b/src/Form/EventListener/RemovePollFieldsOnEntryEdit.php @@ -0,0 +1,33 @@ + 'preSetData']; + } + + public function preSetData(FormEvent $event): void + { + $dto = $event->getData(); + $form = $event->getForm(); + + if (!$dto || null === $dto->getId()) { + return; + } + + if (!$dto->addPoll) { + $form->remove('isMultipleChoicePoll'); + $form->remove('pollEndsAt'); + $form->remove('choices'); + } + } +} diff --git a/src/Form/PostCommentType.php b/src/Form/PostCommentType.php index 4913fc8008..5936ac37d7 100644 --- a/src/Form/PostCommentType.php +++ b/src/Form/PostCommentType.php @@ -11,9 +11,13 @@ use App\Form\Type\LanguageType; use App\Service\SettingsManager; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\UrlType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; @@ -45,6 +49,30 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ) ->add('imageUrl', UrlType::class, ['required' => false, 'default_protocol' => 'https']) ->add('imageAlt', TextareaType::class, ['required' => false]) + ->add('addPoll', CheckboxType::class, [ + 'required' => false, + ]) + ->add('isMultipleChoicePoll', CheckboxType::class, [ + 'required' => false, + 'label' => 'poll_is_multiple_choice', + ]) + ->add('pollEndsAt', DateTimeType::class, [ + 'attr' => [ + 'class' => 'form-control', + ], + 'required' => false, + 'label' => 'poll_ends_at', + ]) + ->add('choices', CollectionType::class, [ + 'entry_type' => TextType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'required' => false, + 'attr' => [ + 'class' => 'existing-collection-items', + ], + 'label' => 'poll_choices', + ]) ->add('submit', SubmitType::class); $builder->addEventSubscriber($this->defaultLanguage); diff --git a/src/Form/PostType.php b/src/Form/PostType.php index 17c893a18c..ecb761d3c6 100644 --- a/src/Form/PostType.php +++ b/src/Form/PostType.php @@ -10,12 +10,14 @@ use App\Form\EventListener\ImageListener; use App\Form\Type\LanguageType; use App\Form\Type\MagazineAutocompleteType; -use App\Service\SettingsManager; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\UrlType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -25,7 +27,6 @@ class PostType extends AbstractType public function __construct( private readonly ImageListener $imageListener, private readonly DefaultLanguage $defaultLanguage, - private readonly SettingsManager $settingsManager, ) { } @@ -47,6 +48,36 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('imageUrl', UrlType::class, ['required' => false, 'default_protocol' => 'https']) ->add('imageAlt', TextareaType::class, ['required' => false]) ->add('isAdult', CheckboxType::class, ['required' => false]) + ->add('addPoll', CheckboxType::class, [ + 'required' => false, + 'row_attr' => [ + 'class' => 'checkbox', + ], + ]) + ->add('isMultipleChoicePoll', CheckboxType::class, [ + 'required' => false, + 'label' => 'poll_is_multiple_choice', + 'row_attr' => [ + 'class' => 'checkbox', + ], + ]) + ->add('pollEndsAt', DateTimeType::class, [ + 'attr' => [ + 'class' => 'form-control', + ], + 'required' => false, + 'label' => 'poll_ends_at', + ]) + ->add('choices', CollectionType::class, [ + 'entry_type' => TextType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'required' => false, + 'attr' => [ + 'class' => 'existing-collection-items', + ], + 'label' => 'poll_choices', + ]) ->add('submit', SubmitType::class); $builder->addEventSubscriber($this->defaultLanguage); diff --git a/src/Message/ActivityPub/Outbox/PollVoteMessage.php b/src/Message/ActivityPub/Outbox/PollVoteMessage.php new file mode 100644 index 0000000000..ce60ccdc98 --- /dev/null +++ b/src/Message/ActivityPub/Outbox/PollVoteMessage.php @@ -0,0 +1,15 @@ +page->create($object); + } else { + return $this->note->create($object); + } + break; case 'Note': $this->logger->debug('[ChainActivityHandler::retrieveObject] Creating note {o}', ['o' => $object]); diff --git a/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php b/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php index 7d20358163..3f26d3341e 100644 --- a/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php @@ -13,6 +13,7 @@ use App\Exception\InstanceBannedException; use App\Exception\InvalidApPostException; use App\Exception\InvalidWebfingerException; +use App\Exception\PollHasEndedException; use App\Exception\PostingRestrictedException; use App\Exception\PostLockedException; use App\Exception\TagBannedException; @@ -79,22 +80,19 @@ public function doWork(MessageInterface $message): void try { if ('ChatMessage' === $object['type']) { $this->handlePrivateMessage($object); + } elseif ('Question' === $object['type']) { + if (isset($object['name'])) { + $this->handlePage($object, $stickyIt, $message->fullCreatePayload); + } else { + $this->handleChain($object, $stickyIt, $message->fullCreatePayload); + } + $this->invalidateTagsOfId($object['id']); } elseif (\in_array($object['type'], $postTypes)) { $this->handleChain($object, $stickyIt, $message->fullCreatePayload); - if (method_exists($this->cache, 'invalidateTags')) { - // clear markdown renders that are tagged with the id of the post - $tag = UrlUtils::getCacheKeyForMarkdownUrl($object['id']); - $this->cache->invalidateTags([$tag]); - $this->logger->debug('cleared cached items with tag {t}', ['t' => $tag]); - } + $this->invalidateTagsOfId($object['id']); } elseif (\in_array($object['type'], $entryTypes)) { $this->handlePage($object, $stickyIt, $message->fullCreatePayload); - if (method_exists($this->cache, 'invalidateTags')) { - // clear markdown renders that are tagged with the id of the entry - $tag = UrlUtils::getCacheKeyForMarkdownUrl($object['id']); - $this->cache->invalidateTags([$tag]); - $this->logger->debug('cleared cached items with tag {t}', ['t' => $tag]); - } + $this->invalidateTagsOfId($object['id']); } else { $this->logger->warning('received Create activity for unknown type {t} of object {o}; ignoring', [ 't' => $object['type'], @@ -120,6 +118,8 @@ public function doWork(MessageInterface $message): void $this->logger->info('[CreateHandler::doWork] Did not create the message, because the user is blocked by one of the receivers'); } catch (EntryLockedException|PostLockedException) { $this->logger->info('[CreateHandler::doWork] Did not create the comment, because the entry/post is locked'); + } catch (PollHasEndedException) { + $this->logger->info('[CreateHandler::doWork] Did not create the vote ({vId}), because the poll ({pId}) has already ended', ['vId' => $object['id'], 'pId' => $object['inReplyTo']]); } } @@ -130,6 +130,7 @@ public function doWork(MessageInterface $message): void * @throws InstanceBannedException * @throws EntryLockedException * @throws PostLockedException + * @throws PollHasEndedException */ private function handleChain(array $object, bool $stickyIt, ?array $fullCreatePayload): void { @@ -202,4 +203,14 @@ private function handlePrivateMentions(): void { // TODO implement private mentions } + + private function invalidateTagsOfId(string $id): void + { + if (method_exists($this->cache, 'invalidateTags')) { + // clear markdown renders that are tagged with the id + $tag = UrlUtils::getCacheKeyForMarkdownUrl($id); + $this->cache->invalidateTags([$tag]); + $this->logger->debug('cleared cached items with tag {t}', ['t' => $tag]); + } + } } diff --git a/src/MessageHandler/ActivityPub/Inbox/UpdateHandler.php b/src/MessageHandler/ActivityPub/Inbox/UpdateHandler.php index 91e8be0a23..f703a51ba8 100644 --- a/src/MessageHandler/ActivityPub/Inbox/UpdateHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/UpdateHandler.php @@ -31,6 +31,7 @@ use App\Service\EntryCommentManager; use App\Service\EntryManager; use App\Service\MessageManager; +use App\Service\PollManager; use App\Service\PostCommentManager; use App\Service\PostManager; use Doctrine\ORM\EntityManagerInterface; @@ -60,6 +61,7 @@ public function __construct( private readonly LoggerInterface $logger, private readonly MessageBusInterface $bus, private readonly ImageFactory $imageFactory, + private readonly PollManager $pollManager, ) { parent::__construct($this->entityManager, $this->kernel); } @@ -147,8 +149,9 @@ private function editEntry(Entry $entry, User $user, array $payload): void $dto->title = $payload['object']['name']; - $this->extractChanges($dto, $payload); - $this->entryManager->edit($entry, $dto, $user); + $contentChanged = $this->extractChanges($dto, $payload); + $this->entryManager->edit($entry, $dto, $user, $contentChanged); + $this->updatePollCounts($entry, $payload['object']); } private function editEntryComment(EntryComment $comment, User $user, array $payload): void @@ -160,9 +163,10 @@ private function editEntryComment(EntryComment $comment, User $user, array $payl } $dto = $this->entryCommentFactory->createDto($comment); - $this->extractChanges($dto, $payload); + $contentChanged = $this->extractChanges($dto, $payload); - $this->entryCommentManager->edit($comment, $dto, $user); + $this->entryCommentManager->edit($comment, $dto, $user, $contentChanged); + $this->updatePollCounts($comment, $payload['object']); } private function editPost(Post $post, User $user, array $payload): void @@ -174,9 +178,10 @@ private function editPost(Post $post, User $user, array $payload): void } $dto = $this->postFactory->createDto($post); - $this->extractChanges($dto, $payload); + $contentChanged = $this->extractChanges($dto, $payload); - $this->postManager->edit($post, $dto, $user); + $this->postManager->edit($post, $dto, $user, $contentChanged); + $this->updatePollCounts($post, $payload['object']); } private function editPostComment(PostComment $comment, User $user, array $payload): void @@ -188,27 +193,54 @@ private function editPostComment(PostComment $comment, User $user, array $payloa } $dto = $this->postCommentFactory->createDto($comment); - $this->extractChanges($dto, $payload); + $contentChanged = $this->extractChanges($dto, $payload); - $this->postCommentManager->edit($comment, $dto, $user); + $this->postCommentManager->edit($comment, $dto, $user, $contentChanged); + $this->updatePollCounts($comment, $payload['object']); } - private function extractChanges(EntryDto|EntryCommentDto|PostDto|PostCommentDto $dto, array $payload): void + private function updatePollCounts(Entry|EntryComment|Post|PostComment $content, array $payload): void { + $poll = $content->poll; + if (null !== $poll && $this->pollManager->hasPollProperties($payload)) { + $this->pollManager->updatePollCounts($poll, $payload); + } + } + + /** + * @return bool true if the content of the post has changed (title, body, url, image), otherwise false + */ + private function extractChanges(EntryDto|EntryCommentDto|PostDto|PostCommentDto $dto, array $payload): bool + { + $isContentSame = true; $this->logger->debug('[UpdateHandler::extractChanges] extracting changes from {c}', ['c' => \get_class($dto)]); if (!empty($payload['object']['content'])) { + $previousBody = $dto->body; $dto->body = $this->objectExtractor->getMarkdownBody($payload['object']); + if ($previousBody !== $dto->body) { + $isContentSame = false; + } } else { + if (null !== $dto->body) { + $isContentSame = false; + } $dto->body = null; } if (!empty($payload['object']['attachment'])) { $this->logger->debug('[UpdateHandler::extractChanges] was not empty :)'); $image = $this->activityPubManager->handleImages($payload['object']['attachment']); if (null !== $image) { + $previousImage = $dto->image; $dto->image = $this->imageFactory->createDto($image); + if ($previousImage->id !== $dto->image->id) { + $isContentSame = false; + } } if ($dto instanceof EntryDto) { $url = ActivityPubManager::extractUrlFromAttachment($payload['object']['attachment']); + if ($dto->url !== $url) { + $isContentSame = false; + } $dto->url = $url; $this->logger->debug('[UpdateHandler::extractChanges] setting url to {u} which was extracted from the attachment array', ['u' => $url]); } @@ -220,6 +252,17 @@ private function extractChanges(EntryDto|EntryCommentDto|PostDto|PostCommentDto if (isset($payload['object']['commentsEnabled']) && \is_bool($payload['object']['commentsEnabled']) && ($dto instanceof EntryDto || $dto instanceof PostDto)) { $dto->isLocked = !$payload['object']['commentsEnabled']; } + $this->logger->debug('[UpdateHandler::extractChanges] content was the same: {x}', ['x' => $isContentSame ? 'true' : 'false']); + + if ($this->pollManager->hasPollProperties($payload['object'])) { + $pollIsSame = $this->pollManager->extractPollChanges($payload['object'], $dto); + $this->logger->debug('[UpdateHandler::extractChanges] poll was the same: {x}', ['x' => $pollIsSame ? 'true' : 'false']); + if (!$pollIsSame) { + $isContentSame = false; + } + } + + return !$isContentSame; } private function editMessage(Message $message, User $user, array $payload): void diff --git a/src/MessageHandler/ActivityPub/Outbox/PollVoteHandler.php b/src/MessageHandler/ActivityPub/Outbox/PollVoteHandler.php new file mode 100644 index 0000000000..63f3d63af9 --- /dev/null +++ b/src/MessageHandler/ActivityPub/Outbox/PollVoteHandler.php @@ -0,0 +1,75 @@ +workWrapper($message); + } + + public function doWork(MessageInterface $message): void + { + if (!$message instanceof PollVoteMessage) { + throw new \LogicException(); + } + + $vote = $this->entityManager->getRepository(PollVote::class)->find(Uuid::fromString($message->voteUuid)); + if (!$vote->poll->isRemote) { + $this->logger->info('The poll {p} is not remote, so we do not have to send anything', ['p' => $message->voteUuid]); + + return; + } elseif (null !== $vote->voter->apId) { + $this->logger->info('The voter of poll {p} is not a local user, so we do not have to send anything', ['p' => $message->voteUuid]); + + return; + } + + $content = $this->pollManager->getContentOfPoll($vote->poll); + if (null === $content) { + $this->logger->warning('Could not find the content the poll {p} belongs to', ['p' => $vote->poll]); + + return; + } + + if (null === $content->user->apId) { + $this->logger->info('The content of poll {p} is not a local, so we do not have to send anything', ['p' => $message->voteUuid]); + + return; + } + + $activity = $this->createWrapper->build($vote); + $json = $this->activityJsonBuilder->buildActivityJson($activity); + $this->deliverManager->deliver([$content->user->apInboxUrl], $json); + } +} diff --git a/src/MessageHandler/CheckPollEndedMessageHandler.php b/src/MessageHandler/CheckPollEndedMessageHandler.php new file mode 100644 index 0000000000..7a0bf1b1c0 --- /dev/null +++ b/src/MessageHandler/CheckPollEndedMessageHandler.php @@ -0,0 +1,55 @@ +workWrapper($message); + } + + public function doWork(MessageInterface $message): void + { + if (!$message instanceof CheckPollEndedMessage) { + throw new \LogicException(); + } + + foreach ($this->pollRepository->getAllEndedPollsToSentNotifications() as $poll) { + try { + $this->logger->debug('Sending notifications for poll {p}', ['p' => $poll->getId()]); + $this->notificationManager->sendPollEndedNotification($poll); + $poll->sentNotifications = true; + $this->entityManager->flush(); + } catch (\Throwable $exception) { + $this->logger->error('An error occurred while sending the notifications for the ended poll {p}: {e} - {m}', [ + 'p' => $poll->getId(), + 'e' => \get_class($exception), + 'm' => $exception->getMessage(), + ]); + } + } + } +} diff --git a/src/Repository/ApActivityRepository.php b/src/Repository/ApActivityRepository.php index 81a39e59e7..a29c982843 100644 --- a/src/Repository/ApActivityRepository.php +++ b/src/Repository/ApActivityRepository.php @@ -168,16 +168,22 @@ public function getLocalUrlOfActivity(string $type, int $id): ?string return $this->getLocalUrlOfEntity($entity); } - public function getLocalUrlOfEntity(Entry|EntryComment|Post|PostComment $entity): ?string + public function getLocalUrlOfEntity(Entry|EntryComment|Post|PostComment $entity, bool $absoluteUrl = false): ?string { + return self::s_getLocalUrlOfEntity($this->urlGenerator, $entity, $absoluteUrl); + } + + public static function s_getLocalUrlOfEntity(UrlGeneratorInterface $urlGenerator, Entry|EntryComment|Post|PostComment $entity, bool $absoluteUrl = false): ?string + { + $reference = $absoluteUrl ? UrlGeneratorInterface::ABSOLUTE_URL : UrlGeneratorInterface::ABSOLUTE_PATH; if ($entity instanceof Entry) { - return $this->urlGenerator->generate('entry_single', ['entry_id' => $entity->getId(), 'magazine_name' => $entity->magazine->name]); + return $urlGenerator->generate('entry_single', ['entry_id' => $entity->getId(), 'magazine_name' => $entity->magazine->name], $reference); } elseif ($entity instanceof EntryComment) { - return $this->urlGenerator->generate('entry_comment_view', ['comment_id' => $entity->getId(), 'entry_id' => $entity->entry->getId(), 'magazine_name' => $entity->magazine->name]); + return $urlGenerator->generate('entry_comment_view', ['comment_id' => $entity->getId(), 'entry_id' => $entity->entry->getId(), 'magazine_name' => $entity->magazine->name], $reference); } elseif ($entity instanceof Post) { - return $this->urlGenerator->generate('post_single', ['post_id' => $entity->getId(), 'magazine_name' => $entity->magazine->name]); + return $urlGenerator->generate('post_single', ['post_id' => $entity->getId(), 'magazine_name' => $entity->magazine->name], $reference); } elseif ($entity instanceof PostComment) { - return $this->urlGenerator->generate('post_single', ['post_id' => $entity->post->getId(), 'magazine_name' => $entity->magazine->name])."#post-comment-{$entity->getId()}"; + return $urlGenerator->generate('post_single', ['post_id' => $entity->post->getId(), 'magazine_name' => $entity->magazine->name], $reference)."#post-comment-{$entity->getId()}"; } return null; diff --git a/src/Repository/NotificationRepository.php b/src/Repository/NotificationRepository.php index 0b471b2b78..f4d24b77ef 100644 --- a/src/Repository/NotificationRepository.php +++ b/src/Repository/NotificationRepository.php @@ -8,6 +8,7 @@ use App\Entity\EntryComment; use App\Entity\Magazine; use App\Entity\Notification; +use App\Entity\Poll; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\User; @@ -80,6 +81,7 @@ public function findUnreadEntryNotifications(User $user, Entry $entry): iterable $result, fn ($notification) => (isset($notification->entry) && $notification->entry === $entry) || (isset($notification->entryComment) && $notification->entryComment->entry === $entry) + || (isset($notification->poll) && $notification->poll instanceof Poll && ($notification->poll->entry === $entry || $notification->poll->entryComment?->entry === $entry)) ); } @@ -111,6 +113,7 @@ public function findUnreadPostNotifications(User $user, Post $post): iterable $result, fn ($notification) => (isset($notification->post) && $notification->post === $post) || (isset($notification->postComment) && $notification->postComment->post === $post) + || (isset($notification->poll) && $notification->poll instanceof Poll && ($notification->poll->post === $post || $notification->poll->postComment?->post === $post)) ); } diff --git a/src/Repository/PollRepository.php b/src/Repository/PollRepository.php new file mode 100644 index 0000000000..3f97c00e01 --- /dev/null +++ b/src/Repository/PollRepository.php @@ -0,0 +1,54 @@ + + * + * @method Poll|null find($id, $lockMode = null, $lockVersion = null) + * @method Poll|null findOneBy(array $criteria, array $orderBy = null) + * @method Poll|null findOneByUrl(string $url) + * @method Poll[] findAll() + * @method Poll[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class PollRepository extends ServiceEntityRepository +{ + public const PER_PAGE = 25; + + public function __construct( + ManagerRegistry $registry, + ) { + parent::__construct($registry, Poll::class); + } + + /** + * @return User[] + */ + public function getAllLocalVotersOfPoll(Poll $poll): array + { + $localVotes = array_filter($poll->votes->toArray(), fn (PollVote $vote) => null === $vote->voter->apId); + + return array_map(fn (PollVote $vote) => $vote->voter, $localVotes); + } + + /** + * @return Poll[] + */ + public function getAllEndedPollsToSentNotifications(): array + { + return $this->createQueryBuilder('p') + ->andWhere('p.endDate <= :now') + ->andWhere('p.sentNotifications = false') + ->setParameter('now', new \DateTime()) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Scheduler/MbinTaskProvider.php b/src/Scheduler/MbinTaskProvider.php index 51416eb292..09e486587c 100644 --- a/src/Scheduler/MbinTaskProvider.php +++ b/src/Scheduler/MbinTaskProvider.php @@ -4,6 +4,7 @@ namespace App\Scheduler; +use App\Message\CheckPollEndedMessage; use App\Message\ClearDeadMessagesMessage; use App\Message\ClearDeletedUserMessage; use Symfony\Component\Scheduler\Attribute\AsSchedule; @@ -29,6 +30,7 @@ public function getSchedule(): Schedule ->add( RecurringMessage::every('1 day', new ClearDeletedUserMessage()), RecurringMessage::every('1 day', new ClearDeadMessagesMessage()), + RecurringMessage::every('5 minutes', new CheckPollEndedMessage()) ) ->stateful($this->cache); } diff --git a/src/Service/ActivityPub/ActivityJsonBuilder.php b/src/Service/ActivityPub/ActivityJsonBuilder.php index 71b7ec682b..3e50c1751b 100644 --- a/src/Service/ActivityPub/ActivityJsonBuilder.php +++ b/src/Service/ActivityPub/ActivityJsonBuilder.php @@ -83,7 +83,7 @@ public function buildActivityJson(Activity $activity, bool $includeContext = tru public function buildCreateFromActivity(Activity $activity): array { - $o = $activity->objectEntry ?? $activity->objectEntryComment ?? $activity->objectPost ?? $activity->objectPostComment ?? $activity->objectMessage; + $o = $activity->objectEntry ?? $activity->objectEntryComment ?? $activity->objectPost ?? $activity->objectPostComment ?? $activity->objectMessage ?? $activity->objectPollVote; $item = $this->activityFactory->create($o, true); unset($item['@context']); diff --git a/src/Service/ActivityPub/ApHttpClient.php b/src/Service/ActivityPub/ApHttpClient.php index 0e60228f12..db8bde3128 100644 --- a/src/Service/ActivityPub/ApHttpClient.php +++ b/src/Service/ActivityPub/ApHttpClient.php @@ -147,6 +147,11 @@ public function getActivityObjectCacheKey(string $url): string return 'ap_object_'.hash('sha256', $url); } + public function invalidateActivityObjectCache(string $url): void + { + $this->cache->delete($this->getActivityObjectCacheKey($url)); + } + /** * Retrieve AP actor object (could be a user or magazine). * diff --git a/src/Service/ActivityPub/ApHttpClientInterface.php b/src/Service/ActivityPub/ApHttpClientInterface.php index 4b556c7387..66d533b795 100644 --- a/src/Service/ActivityPub/ApHttpClientInterface.php +++ b/src/Service/ActivityPub/ApHttpClientInterface.php @@ -53,6 +53,13 @@ public function getWebfingerObject(string $url): ?array; */ public function getActorObject(string $apProfileId): ?array; + /** + * Remove activity object from cache. + * + * @param string $url URL to remove from the activity cache + */ + public function invalidateActivityObjectCache(string $url): void; + /** * Remove actor object from cache. * diff --git a/src/Service/ActivityPub/Note.php b/src/Service/ActivityPub/Note.php index 63c573ab4e..81866aee64 100644 --- a/src/Service/ActivityPub/Note.php +++ b/src/Service/ActivityPub/Note.php @@ -11,11 +11,16 @@ use App\Entity\Entry; use App\Entity\EntryComment; use App\Entity\Magazine; +use App\Entity\Poll; +use App\Entity\PollVote; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\User; use App\Exception\EntryLockedException; use App\Exception\InstanceBannedException; +use App\Exception\InvalidApPostException; +use App\Exception\InvalidWebfingerException; +use App\Exception\PollHasEndedException; use App\Exception\PostLockedException; use App\Exception\TagBannedException; use App\Exception\UserBannedException; @@ -24,10 +29,13 @@ use App\Repository\ApActivityRepository; use App\Service\ActivityPubManager; use App\Service\EntryCommentManager; +use App\Service\PollManager; use App\Service\PostCommentManager; use App\Service\PostManager; use App\Service\SettingsManager; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Exception\ORMException; +use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; @@ -44,6 +52,7 @@ public function __construct( private readonly SettingsManager $settingsManager, private readonly ImageFactory $imageFactory, private readonly ApObjectExtractor $objectExtractor, + private readonly PollManager $pollManager, ) { } @@ -54,9 +63,10 @@ public function __construct( * @throws InstanceBannedException * @throws EntryLockedException * @throws PostLockedException + * @throws PollHasEndedException * @throws \Exception */ - public function create(array $object, ?array $root = null, bool $stickyIt = false): EntryComment|PostComment|Post + public function create(array $object, ?array $root = null, bool $stickyIt = false): EntryComment|PostComment|Post|PollVote { // First try to find the activity object in the database $current = $this->repository->findByObjectId($object['id']); @@ -82,6 +92,14 @@ public function create(array $object, ?array $root = null, bool $stickyIt = fals $parentObjectId = $this->repository->findByObjectId($replyTo); $parent = $this->entityManager->getRepository($parentObjectId['type'])->find((int) $parentObjectId['id']); + if (isset($object['name'])) { + if ($parent instanceof Entry || $parent instanceof EntryComment || $parent instanceof Post || $parent instanceof PostComment) { + if ($parent->poll && $parent->poll->findChoice($object['name'])) { + return $this->voteOnPoll($parent->poll, $parent, $object); + } + } + } + if ($parent instanceof Entry) { $root = $parent; @@ -221,7 +239,15 @@ private function createPost(array $object, bool $stickyIt = false): Post $dto->isLocked = !$object['commentsEnabled']; } - return $this->postManager->create($dto, $actor, false, $stickyIt); + $post = $this->postManager->create($dto, $actor, false, $stickyIt); + + if ($this->pollManager->hasPollProperties($object)) { + $poll = $this->pollManager->createFromApObject($object); + $post->poll = $poll; + $this->entityManager->flush(); + } + + return $post; } elseif ($actor instanceof Magazine) { throw new UnrecoverableMessageHandlingException('Actor "'.$object['attributedTo'].'" is not a user, but a magazine for post "'.$dto->apId.'".'); } else { @@ -289,4 +315,48 @@ private function createPostComment(array $object, ActivityPubActivityInterface $ throw new UnrecoverableMessageHandlingException('Actor "'.$object['attributedTo'].'"could not be found for post "'.$dto->apId.'".'); } } + + /** + * @throws InvalidArgumentException + * @throws ORMException + * @throws InvalidApPostException + * @throws PollHasEndedException + * @throws UserDeletedException + * @throws InvalidWebfingerException + * @throws UserBannedException + */ + private function voteOnPoll(?Poll $poll, Entry|EntryComment|Post|PostComment $content, array $object): PollVote + { + $apId = $object['id']; + $actor = $this->activityPubManager->findActorOrCreate($object['attributedTo']); + if ($actor instanceof User) { + if ($actor->isBanned) { + throw new UserBannedException(); + } + if ($actor->isDeleted || $actor->isSoftDeleted() || $actor->isTrashed()) { + throw new UserDeletedException(); + } + + $choice = $object['name']; + $existingVotes = $poll->getUserVotes($actor); + $existingSameVote = array_filter($existingVotes, fn (PollVote $vote) => $vote->choice->name === $choice); + if (\sizeof($existingSameVote) > 0) { + throw new \LogicException("User $actor->username has already voted on the poll {$poll->getId()} with choice '$choice'."); + } + + $this->pollManager->vote($poll, $content, $actor, [$choice], true); + $this->entityManager->refresh($poll); + $newVotes = $poll->getUserVotes($actor); + $newVote = array_filter($newVotes, fn (PollVote $vote) => $vote->choice->name === $choice); + if (1 !== \sizeof($newVote)) { + throw new \LogicException('Created the vote, but it could not be found!'); + } + + return $newVote[array_key_first($newVote)]; + } elseif ($actor instanceof Magazine) { + throw new UnrecoverableMessageHandlingException('Actor "'.$object['attributedTo'].'" is not a user, but a magazine for voting on poll "'.$apId.'".'); + } else { + throw new UnrecoverableMessageHandlingException('Actor "'.$object['attributedTo'].'"could not be found for post "'.$apId.'".'); + } + } } diff --git a/src/Service/ActivityPub/Page.php b/src/Service/ActivityPub/Page.php index f5e581f172..6832a982f5 100644 --- a/src/Service/ActivityPub/Page.php +++ b/src/Service/ActivityPub/Page.php @@ -18,6 +18,7 @@ use App\Repository\InstanceRepository; use App\Service\ActivityPubManager; use App\Service\EntryManager; +use App\Service\PollManager; use App\Service\SettingsManager; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -34,6 +35,7 @@ public function __construct( private readonly ApObjectExtractor $objectExtractor, private readonly LoggerInterface $logger, private readonly InstanceRepository $instanceRepository, + private readonly PollManager $pollManager, ) { } @@ -125,7 +127,15 @@ public function create(array $object, bool $stickyIt = false): Entry $this->logger->debug('creating page'); - return $this->entryManager->create($dto, $actor, false, $stickyIt); + $entry = $this->entryManager->create($dto, $actor, false, $stickyIt); + + if ($this->pollManager->hasPollProperties($object)) { + $poll = $this->pollManager->createFromApObject($object); + $entry->poll = $poll; + $this->entityManager->flush(); + } + + return $entry; } else { throw new EntityNotFoundException('Actor could not be found for entry.'); } diff --git a/src/Service/ActivityPub/Wrapper/CreateWrapper.php b/src/Service/ActivityPub/Wrapper/CreateWrapper.php index 985e221686..be4ccaf452 100644 --- a/src/Service/ActivityPub/Wrapper/CreateWrapper.php +++ b/src/Service/ActivityPub/Wrapper/CreateWrapper.php @@ -28,6 +28,8 @@ public function build(ActivityPubActivityInterface $item): Activity $activity->userActor = $item->getUser(); } elseif ($item instanceof Message) { $activity->userActor = $item->sender; + } else { + $activity->userActor = $item->getUser(); } $this->entityManager->persist($activity); $this->entityManager->flush(); diff --git a/src/Service/EntryCommentManager.php b/src/Service/EntryCommentManager.php index 19bf4adc6b..ef8010cb7f 100644 --- a/src/Service/EntryCommentManager.php +++ b/src/Service/EntryCommentManager.php @@ -45,6 +45,7 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ImageRepository $imageRepository, private readonly SettingsManager $settingsManager, + private readonly PollManager $pollManager, ) { } @@ -114,6 +115,10 @@ public function create(EntryCommentDto $dto, User $user, $rateLimit = true): Ent $this->entityManager->persist($comment); $this->entityManager->flush(); + if ($dto->addPoll) { + $this->pollManager->createPoll($dto, $comment); + } + $this->tagManager->updateEntryCommentTags($comment, $this->tagExtractor->extract($comment->body) ?? []); $this->dispatcher->dispatch(new EntryCommentCreatedEvent($comment)); @@ -130,7 +135,7 @@ public function canUserEditComment(EntryComment $comment, User $user): bool return $entryCommentHost === $userHost || $userHost === $magazineHost || $comment->magazine->userIsModerator($user); } - public function edit(EntryComment $comment, EntryCommentDto $dto, ?User $editedByUser = null): EntryComment + public function edit(EntryComment $comment, EntryCommentDto $dto, ?User $editedByUser = null, bool $contentChanged = true): EntryComment { Assert::same($comment->entry->getId(), $dto->entry->getId()); @@ -151,6 +156,10 @@ public function edit(EntryComment $comment, EntryCommentDto $dto, ?User $editedB throw new \Exception('Comment body and image cannot be empty'); } + if ($comment->poll && $contentChanged) { + $this->pollManager->edit($comment->poll, $dto, $editedByUser); + } + $comment->apLikeCount = $dto->apLikeCount; $comment->apDislikeCount = $dto->apDislikeCount; $comment->apShareCount = $dto->apShareCount; diff --git a/src/Service/EntryManager.php b/src/Service/EntryManager.php index 1f0dc731c8..c34472b706 100644 --- a/src/Service/EntryManager.php +++ b/src/Service/EntryManager.php @@ -68,6 +68,7 @@ public function __construct( private readonly ImageRepository $imageRepository, private readonly ApHttpClientInterface $apHttpClient, private readonly CacheInterface $cache, + private readonly PollManager $pollManager, ) { } @@ -144,6 +145,10 @@ public function create(EntryDto $dto, User $user, bool $rateLimit = true, bool $ $this->entityManager->persist($entry); $this->entityManager->flush(); + if ($dto->addPoll) { + $this->pollManager->createPoll($dto, $entry); + } + $tags = array_unique(array_merge($this->tagExtractor->extract($entry->body) ?? [], $dto->tags ?? [])); $this->tagManager->updateEntryTags($entry, $tags); @@ -189,7 +194,7 @@ public function canUserEditEntry(Entry $entry, User $user): bool return $entryHost === $userHost || $userHost === $magazineHost || $entry->magazine->userIsModerator($user); } - public function edit(Entry $entry, EntryDto $dto, User $editedBy): Entry + public function edit(Entry $entry, EntryDto $dto, User $editedBy, bool $contentChanged = true): Entry { Assert::same($entry->magazine->getId(), $dto->magazine->getId()); @@ -221,6 +226,10 @@ public function edit(Entry $entry, EntryDto $dto, User $editedBy): Entry throw new \Exception('Entry body, name, url and image cannot all be empty'); } + if ($entry->poll && $contentChanged) { + $this->pollManager->edit($entry->poll, $dto, $editedBy); + } + $entry->apLikeCount = $dto->apLikeCount; $entry->apDislikeCount = $dto->apDislikeCount; $entry->apShareCount = $dto->apShareCount; @@ -242,6 +251,19 @@ public function edit(Entry $entry, EntryDto $dto, User $editedBy): Entry return $entry; } + public function refreshFromRemote(Entry $entry, EntryDto $dto): void + { + if ($entry->poll) { + $this->pollManager->refreshFromRemote($entry->poll, $dto); + } + + $entry->apLikeCount = $dto->apLikeCount; + $entry->apDislikeCount = $dto->apDislikeCount; + $entry->apShareCount = $dto->apShareCount; + $entry->updateScore(); + $entry->updateRanking(); + } + public function delete(User $user, Entry $entry): void { if ($user->apDomain && $user->apDomain !== parse_url($entry->apId ?? '', PHP_URL_HOST) && !$entry->magazine->userIsModerator($user)) { diff --git a/src/Service/Notification/PollNotificationManager.php b/src/Service/Notification/PollNotificationManager.php new file mode 100644 index 0000000000..d07477b20c --- /dev/null +++ b/src/Service/Notification/PollNotificationManager.php @@ -0,0 +1,38 @@ +pollRepository->getAllLocalVotersOfPoll($poll) as $user) { + $notification = new PollEditedNotification($user, $poll); + $this->entityManager->persist($notification); + } + $this->entityManager->flush(); + } + + public function sendPollEndedNotification(Poll $poll): void + { + foreach ($this->pollRepository->getAllLocalVotersOfPoll($poll) as $user) { + $notification = new PollEndedNotification($user, $poll); + $this->entityManager->persist($notification); + } + $this->entityManager->flush(); + } +} diff --git a/src/Service/NotificationManager.php b/src/Service/NotificationManager.php index 4901b6e8b9..314cba8a10 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\Repository\PollRepository; use App\Service\Notification\MagazineBanNotificationManager; use App\Service\Notification\MessageNotificationManager; use Doctrine\ORM\EntityManagerInterface; @@ -21,6 +22,7 @@ public function __construct( private readonly MessageNotificationManager $messageNotificationManager, private readonly EntityManagerInterface $entityManager, private readonly MagazineBanNotificationManager $magazineBanNotificationManager, + private readonly PollRepository $pollRepository, ) { } diff --git a/src/Service/PollManager.php b/src/Service/PollManager.php new file mode 100644 index 0000000000..edb2b9bc8f --- /dev/null +++ b/src/Service/PollManager.php @@ -0,0 +1,264 @@ +multipleChoice = $dto->isMultipleChoicePoll; + $poll->endDate = $dto->pollEndsAt; + $this->entityManager->persist($poll); + + $this->createChoices($dto, $poll); + $object->poll = $poll; + $this->entityManager->flush(); + + return $poll; + } + + public function edit(?Poll $poll, ContentWithPollDto $dto, User $editor): void + { + $this->logger->info('[PollManager] the poll {p} was changed, resetting all votes', ['p' => $poll->getId()]); + $this->eventDispatcher->dispatch(new PollPreEditedEvent($poll, $this->getContentOfPoll($poll), $editor)); + $poll->endDate = $dto->pollEndsAt; + $poll->multipleChoice = $dto->isMultipleChoicePoll; + foreach ($poll->choices as $choice) { + // remove all choices, which also removes all votes + $this->entityManager->remove($choice); + } + $this->createChoices($dto, $poll); + $this->entityManager->flush(); + $this->eventDispatcher->dispatch(new PollEditedEvent($poll, $this->getContentOfPoll($poll), $editor)); + } + + private function createChoices(ContentWithPollDto $dto, Poll $poll): void + { + foreach ($dto->choices as $choice) { + if (!trim($choice ?? '')) { + continue; + } + $pollChoice = new PollChoice(); + $pollChoice->poll = $poll; + $pollChoice->name = trim($choice); + $poll->choices[] = $pollChoice; + $this->entityManager->persist($pollChoice); + } + } + + /** + * @param Entry|EntryComment|Post|PostComment $content where the poll belongs to + * @param string[] $choices the choices a user votes for + * @param bool $allowMultipleChoices if the context allows for the user to vote again, that should only be allowed from activity pub + * + * @throws ORMException + * @throws PollHasEndedException + * @throws \LogicException + */ + public function vote(Poll $poll, Entry|EntryComment|Post|PostComment $content, User $user, array $choices, bool $allowMultipleChoices = false): void + { + if (!$poll->multipleChoice && \sizeof($choices) > 1) { + throw new \LogicException('Poll does not allow multiple choices.'); + } + if (0 === \sizeof($choices)) { + throw new \LogicException('No choice found'); + } + if ($poll->hasUserVoted($user) && !$allowMultipleChoices) { + throw new \LogicException('Already voted'); + } + if ($poll->hasEnded()) { + throw new PollHasEndedException('Poll has already ended'); + } + + $voteEntities = []; + foreach (array_unique($choices) as $choice) { + $choiceEntity = $poll->findChoice($choice); + $vote = new PollVote(); + $vote->voter = $user; + $vote->poll = $poll; + $vote->choice = $choiceEntity; + $this->entityManager->persist($vote); + $voteEntities[] = $vote; + $this->entityManager->flush(); + $this->entityManager->refresh($choiceEntity); + $choiceEntity->updateVoteCount(); + } + $this->entityManager->flush(); + $this->entityManager->refresh($poll); + $poll->updateVoterCount(); + $this->entityManager->flush(); + + $this->eventDispatcher->dispatch(new PollVoteEvent($poll, $content, $user, $voteEntities)); + } + + public function getContentOfPoll(Poll $poll): Entry|EntryComment|Post|PostComment|null + { + return $this->entryRepository->findOneBy(['poll' => $poll]) + ?? $this->entryCommentRepository->findOneBy(['poll' => $poll]) + ?? $this->postRepository->findOneBy(['poll' => $poll]) + ?? $this->postCommentRepository->findOneBy(['poll' => $poll]) + ; + } + + public function hasPollProperties(array $object): bool + { + if ('Question' === $object['type'] && isset($object['votersCount']) && (isset($object['endTime']) || isset($object['closed'])) && (isset($object['anyOf']) || isset($object['oneOf']))) { + $choices = $object['anyOf'] ?? $object['oneOf']; + if (\is_array($choices)) { + foreach ($choices as $choice) { + if (!\is_array($choice)) { + return false; + } + if (isset($choice['type']) && 'Note' === $choice['type'] && isset($choice['name']) && \is_string($choice['name'])) { + if (isset($choice['replies']['type']) && 'Collection' === $choice['replies']['type'] && isset($choice['replies']['totalItems'])) { + // this is the positive case + } else { + return false; + } + } else { + return false; + } + } + + return true; + } + } + + return false; + } + + /** + * Call PollManager::canCreateFromApObject() first, this that $object contains all the necessary information. + * + * @throws \DateMalformedStringException + */ + public function createFromApObject(array $object): Poll + { + $poll = new Poll(); + $poll->endDate = new \DateTimeImmutable($object['endTime'] ?? $object['closed']); + $poll->createdAt = new \DateTimeImmutable($object['published']); + $poll->isRemote = true; + $poll->voterCount = $object['votersCount']; + $poll->multipleChoice = isset($object['anyOf']); + + $choices = $object['anyOf'] ?? $object['oneOf']; + $choiceNames = []; + foreach ($choices as $choice) { + if (array_find($choiceNames, fn (string $choiceName) => $choiceName === $choice['name'])) { + // do not create duplicate choices + continue; + } + $pollChoice = new PollChoice(); + $pollChoice->poll = $poll; + $pollChoice->name = $choice['name']; + $pollChoice->voteCount = $choice['replies']['totalItems']; + $this->entityManager->persist($pollChoice); + } + $this->entityManager->persist($poll); + $this->entityManager->flush(); + + return $poll; + } + + /** + * Call PollManager::canCreateFromApObject() first, this that $payload contains all the necessary information. + */ + public function updatePollCounts(Poll $poll, array $payload): void + { + $this->logger->info('[PollManager] Updating vote counts for poll {p}', ['p' => $poll->getId()]); + $poll->voterCount = $payload['votersCount']; + + $choices = $payload['anyOf'] ?? $payload['oneOf']; + foreach ($choices as $choice) { + $pollChoice = $poll->findChoice($choice['name']); + $pollChoice->voteCount = $choice['replies']['totalItems']; + } + $this->entityManager->flush(); + } + + public function extractPollChanges(array $object, ContentWithPollDto $dto): bool + { + $isContentSame = true; + $dto->addPoll = true; + $isMultipleChoice = isset($object['anyOf']); + if ($dto->isMultipleChoicePoll !== $isMultipleChoice) { + $isContentSame = false; + $this->logger->debug('[PollManager::extractPollChanges] multiple choice setting is not the same: {p} -> {n}', [ + 'p' => $dto->isMultipleChoicePoll, + 'n' => $isMultipleChoice, + ]); + } + $dto->isMultipleChoicePoll = $isMultipleChoice; + $endTime = new \DateTimeImmutable($object['endTime'] ?? $object['closed']); + if ($dto->pollEndsAt->getTimestamp() !== $endTime->getTimestamp()) { + $isContentSame = false; + $this->logger->debug('[PollManager::extractPollChanges] endTime is not the same: {p} -> {n}', [ + 'p' => $dto->pollEndsAt, + 'n' => $endTime, + ]); + } + $dto->pollEndsAt = $endTime; + + $choicesObject = $object['anyOf'] ?? $object['oneOf']; + $choices = []; + foreach ($choicesObject as $choice) { + $choiceName = $choice['name']; + $choices[] = $choiceName; + if (!\in_array($choiceName, $dto->choices)) { + $isContentSame = false; + $this->logger->debug('[PollManager::extractPollChanges] choice {c} did not exist previously', [ + 'c' => $choiceName, + ]); + } + } + + foreach ($dto->choices as $choice) { + if (!\in_array($choice, $choices)) { + $isContentSame = false; + $this->logger->debug('[PollManager::extractPollChanges] choice {c} did exist previously, but doesn\'t anymore', [ + 'c' => $choice, + ]); + } + } + + $dto->choices = $choices; + + return $isContentSame; + } +} diff --git a/src/Service/PostCommentManager.php b/src/Service/PostCommentManager.php index 8e567dec70..4584ba80a1 100644 --- a/src/Service/PostCommentManager.php +++ b/src/Service/PostCommentManager.php @@ -45,6 +45,7 @@ public function __construct( private readonly MessageBusInterface $bus, private readonly SettingsManager $settingsManager, private readonly EntityManagerInterface $entityManager, + private readonly PollManager $pollManager, ) { } @@ -113,6 +114,10 @@ public function create(PostCommentDto $dto, User $user, $rateLimit = true): Post $this->entityManager->persist($comment); $this->entityManager->flush(); + if ($dto->addPoll) { + $this->pollManager->createPoll($dto, $comment); + } + $this->tagManager->updatePostCommentTags($comment, $this->tagExtractor->extract($comment->body) ?? []); $this->dispatcher->dispatch(new PostCommentCreatedEvent($comment)); @@ -132,7 +137,7 @@ public function canUserEditPostComment(PostComment $postComment, User $user): bo /** * @throws \Exception */ - public function edit(PostComment $comment, PostCommentDto $dto, ?User $editedBy = null): PostComment + public function edit(PostComment $comment, PostCommentDto $dto, ?User $editedBy = null, bool $contentChanged = true): PostComment { Assert::same($comment->post->getId(), $dto->post->getId()); @@ -153,6 +158,10 @@ public function edit(PostComment $comment, PostCommentDto $dto, ?User $editedBy throw new \Exception('Comment body and image cannot be empty'); } + if ($comment->poll && $contentChanged) { + $this->pollManager->edit($comment->poll, $dto, $editedBy); + } + $comment->apLikeCount = $dto->apLikeCount; $comment->apDislikeCount = $dto->apDislikeCount; $comment->apShareCount = $dto->apShareCount; diff --git a/src/Service/PostManager.php b/src/Service/PostManager.php index e40cb99449..a819ec9b99 100644 --- a/src/Service/PostManager.php +++ b/src/Service/PostManager.php @@ -60,6 +60,7 @@ public function __construct( private readonly ApHttpClientInterface $apHttpClient, private readonly SettingsManager $settingsManager, private readonly CacheInterface $cache, + private readonly PollManager $pollManager, ) { } @@ -121,6 +122,10 @@ public function create(PostDto $dto, User $user, $rateLimit = true, bool $sticky $this->entityManager->persist($post); $this->entityManager->flush(); + if ($dto->addPoll) { + $this->pollManager->createPoll($dto, $post); + } + $this->tagManager->updatePostTags($post, $this->tagExtractor->extract($post->body) ?? []); $this->dispatcher->dispatch(new PostCreatedEvent($post)); @@ -141,7 +146,7 @@ public function canUserEditPost(Post $post, User $user): bool return $postHost === $userHost || $userHost === $magazineHost || $post->magazine->userIsModerator($user); } - public function edit(Post $post, PostDto $dto, ?User $editedBy = null): Post + public function edit(Post $post, PostDto $dto, ?User $editedBy = null, bool $contentChanged = true): Post { Assert::same($post->magazine->getId(), $dto->magazine->getId()); @@ -162,6 +167,10 @@ public function edit(Post $post, PostDto $dto, ?User $editedBy = null): Post throw new \Exception('Post body and image cannot be empty'); } + if ($post->poll && $contentChanged) { + $this->pollManager->edit($post->poll, $dto, $editedBy); + } + $post->apLikeCount = $dto->apLikeCount; $post->apDislikeCount = $dto->apDislikeCount; $post->apShareCount = $dto->apShareCount; diff --git a/src/Twig/Components/PollComponent.php b/src/Twig/Components/PollComponent.php new file mode 100644 index 0000000000..b9f84487b8 --- /dev/null +++ b/src/Twig/Components/PollComponent.php @@ -0,0 +1,16 @@ +entryUrl($content); + } elseif ($content instanceof EntryComment) { + return $this->entryCommentViewUrl($content); + } elseif ($content instanceof Post) { + return $this->postUrl($content); + } elseif ($content instanceof PostComment) { + return $this->postUrl($content->post).'#post-comment-'.$content->getId(); + } + + return '#'; + } } diff --git a/templates/components/entry.html.twig b/templates/components/entry.html.twig index c50691291c..6b8d1884cd 100644 --- a/templates/components/entry.html.twig +++ b/templates/components/entry.html.twig @@ -88,6 +88,9 @@
{{ entry.body|markdown("entry")|raw }}
+ {% if entry.poll is not same as null %} + {{ component('poll', {poll: entry.poll, isExternal: entry.apId is not same as null}) }} + {% endif %}
{% endif %} diff --git a/templates/components/entry_comment.html.twig b/templates/components/entry_comment.html.twig index a1e65484ad..c5384973f9 100644 --- a/templates/components/entry_comment.html.twig +++ b/templates/components/entry_comment.html.twig @@ -76,6 +76,9 @@ {% elseif(comment.visibility is same as 'soft_deleted') %}

[{{ 'deleted_by_author'|trans }}]

{% endif %} + {% if comment.poll is not same as null %} + {{ component('poll', {poll: comment.poll, isExternal: comment.apId is not same as null}) }} + {% endif %}
diff --git a/templates/components/poll.html.twig b/templates/components/poll.html.twig new file mode 100644 index 0000000000..c76b089255 --- /dev/null +++ b/templates/components/poll.html.twig @@ -0,0 +1,61 @@ +
+ {% set forceResults = app.request.query.get('showResults')|bool %} + {% if app.user and poll.hasUserVoted(app.user) is not same as true and poll.hasEnded() is not same as true and forceResults is not same as true %} +
+ {% if poll.multipleChoice %} + {% for choice in poll.choices %} +
+ + +
+ {% endfor %} + {% else %} + {% for choice in poll.choices %} +
+ + +
+ {% endfor %} + {% endif %} +
+ {{ 'poll_show_results'|trans }} + +
+
+ {% else %} +
+ {% for choice in poll.getResultData(app.user) %} +
+
+ {{ choice['percentage'] }}% + + {{ choice['name'] }} + {% if choice['userVoted'] %} + + {% endif %} + +
+
+
+ {% endfor %} +
+
+
+ {{ poll.voterCount }} {{ 'poll_voters'|trans({'%count%': poll.voterCount}) }} + {% if poll.hasEnded %} + {{ 'poll_ended'|trans }} {{ component('date', {date: poll.endDate}) }} + {% else %} + {{ 'poll_ends'|trans }} {{ component('date', {date: poll.endDate}) }} + {% endif %} +
+
+ {% if forceResults %} + {{ 'poll_hide_results'|trans }} + {% endif %} + {% if isExternal %} + {{ 'poll_update_results'|trans }} + {% endif %} +
+
+ {% endif %} +
diff --git a/templates/components/post.html.twig b/templates/components/post.html.twig index 73013ef1ed..215d0a7a0b 100644 --- a/templates/components/post.html.twig +++ b/templates/components/post.html.twig @@ -64,6 +64,10 @@
{% if post.visibility in ['visible', 'private'] or (post.visibility is same as 'trashed' and this.canSeeTrashed) %} {{ post.body|markdown("post")|raw }} + + {% if post.poll is not same as null %} + {{ component('poll', {poll: post.poll, isExternal: post.apId is not same as null}) }} + {% endif %} {% elseif(post.visibility is same as 'trashed') %}

[{{ 'deleted_by_moderator'|trans }}]

{% elseif(post.visibility is same as 'soft_deleted') %} diff --git a/templates/components/post_comment.html.twig b/templates/components/post_comment.html.twig index e13183a28d..f6e660fd44 100644 --- a/templates/components/post_comment.html.twig +++ b/templates/components/post_comment.html.twig @@ -73,6 +73,9 @@ {% elseif(comment.visibility is same as 'soft_deleted') %}

[{{ 'deleted_by_author'|trans }}]

{% endif %} + {% if comment.poll is not same as null %} + {{ component('poll', {poll: comment.poll, isExternal: comment.apId is not same as null}) }} + {% endif %}
diff --git a/templates/entry/_form_edit.html.twig b/templates/entry/_form_edit.html.twig index a0b9dca9b3..05e2c55fbc 100644 --- a/templates/entry/_form_edit.html.twig +++ b/templates/entry/_form_edit.html.twig @@ -33,6 +33,31 @@ {{ form_row(form.magazine, {label: false}) }} {{ form_row(form.tags, {label: 'tags'}) }} {# form_row(form.badges, {label: 'badges'}) #} + + {% if entry.poll is not same as null %} +
+
{{ 'poll'|trans }}
+

{{ 'poll_edit_voters_reset'|trans }}

+
+ {{ form_row(form.isMultipleChoicePoll, {row_attr: {class: 'checkbox'}}) }} + {{ form_row(form.pollEndsAt, {attr: {style: 'padding: 1rem .5rem;'}}) }} + + {{ form_label(form.choices) }} +
+ {{ form_widget(form.choices) }} +
+
+
+ +
+
+
+ {% endif %}
{{ form_row(form.isAdult, {label: 'is_adult', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.isOc, {label: 'oc', row_attr: {class: 'checkbox'}}) }} diff --git a/templates/entry/_form_entry.html.twig b/templates/entry/_form_entry.html.twig index 69695662bd..06ca5d9b74 100644 --- a/templates/entry/_form_entry.html.twig +++ b/templates/entry/_form_entry.html.twig @@ -44,6 +44,7 @@ {{ form_row(form.magazine, {label: false}) }} {{ form_row(form.tags, {label: 'tags'}) }} {# form_row(form.badges, {label: 'badges'}) #} + {{ include('form/_poll_form.html.twig') }}
{{ form_row(form.isAdult, {label: 'is_adult', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.isOc, {label: 'oc', row_attr: {class: 'checkbox'}}) }} diff --git a/templates/entry/comment/_form_comment.html.twig b/templates/entry/comment/_form_comment.html.twig index cf96a5dcf3..67a6f1b767 100644 --- a/templates/entry/comment/_form_comment.html.twig +++ b/templates/entry/comment/_form_comment.html.twig @@ -22,7 +22,11 @@ 'data-controller': 'input-length rich-textarea autogrow', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value': constant('App\\DTO\\EntryCommentDto::MAX_BODY_LENGTH') -}}) }}
+}}) }} + +{{ include('form/_poll_form.html.twig') }} + +
    {% if hasImage %} + {{ form_row(form.addPoll, {row_attr: {class: 'checkbox', style: 'float: left; margin-right: 1rem'}}) }} +
    + {{ form_row(form.isMultipleChoicePoll, {row_attr: {class: 'checkbox'}}) }} + {{ form_row(form.pollEndsAt, {attr: {style: 'padding: 1rem .5rem;'}}) }} + + {{ form_label(form.choices) }} +
    + {{ form_widget(form.choices) }} +
    +
    +
    + +
    +
    +
    +
diff --git a/templates/notifications/_blocks.html.twig b/templates/notifications/_blocks.html.twig index 907ac64bf7..3a88fa8e36 100644 --- a/templates/notifications/_blocks.html.twig +++ b/templates/notifications/_blocks.html.twig @@ -187,3 +187,13 @@
{% endif %} {% endblock %} + +{% block poll_ended %} + {{ 'notification_title_poll_ended'|trans }}: + {{ notification.poll.getSubject().shortTitle }} +{% endblock %} + +{% block poll_edited %} + {{ 'notification_title_poll_edited'|trans }}: + {{ notification.poll.getSubject().shortTitle }} +{% endblock %} diff --git a/templates/post/_form_post.html.twig b/templates/post/_form_post.html.twig index 3b8abe6313..b214161820 100644 --- a/templates/post/_form_post.html.twig +++ b/templates/post/_form_post.html.twig @@ -29,6 +29,7 @@ 'data-input-length-max-value': constant('App\\DTO\\PostDto::MAX_BODY_LENGTH') }}) }}
+{{ include('form/_poll_form.html.twig') }}
{{ form_row(form.isAdult, {label:'is_adult'}) }} {{ form_row(form.magazine, {label: false, attr: {placeholder: false}}) }} diff --git a/templates/post/comment/_form_comment.html.twig b/templates/post/comment/_form_comment.html.twig index 67a5322e40..3f26c0456c 100644 --- a/templates/post/comment/_form_comment.html.twig +++ b/templates/post/comment/_form_comment.html.twig @@ -25,6 +25,9 @@ 'data-input-length-max-value': constant('App\\DTO\\PostCommentDto::MAX_BODY_LENGTH') }}) }}
+ +{{ include('form/_poll_form.html.twig') }} +
    {% if hasImage %} diff --git a/tests/ActivityPubJsonDriver.php b/tests/ActivityPubJsonDriver.php index 414662f152..42b6e0eba3 100644 --- a/tests/ActivityPubJsonDriver.php +++ b/tests/ActivityPubJsonDriver.php @@ -70,6 +70,10 @@ protected function scrubArray(array $data): array $data['updated'] = 'SCRUBBED_DATE'; } + if (isset($data['endTime'])) { + $data['endTime'] = 'SCRUBBED_DATE'; + } + if (isset($data['publicKey'])) { $data['publicKey'] = 'SCRUBBED_KEY'; } @@ -115,6 +119,10 @@ protected function scrubObject(object $data): object $data->updated = 'SCRUBBED_DATE'; } + if (isset($data->endTime)) { + $data->endTime = 'SCRUBBED_DATE'; + } + if (isset($data->publicKey)) { $data->publicKey = 'SCRUBBED_KEY'; } diff --git a/tests/FactoryTrait.php b/tests/FactoryTrait.php index cac582ac2d..fe949c2985 100644 --- a/tests/FactoryTrait.php +++ b/tests/FactoryTrait.php @@ -23,6 +23,8 @@ use App\Entity\Message; use App\Entity\MessageThread; use App\Entity\Notification; +use App\Entity\Poll; +use App\Entity\PollChoice; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\Site; @@ -639,4 +641,22 @@ private function getKibbyImageVariantDto(string $suffix): ImageDto return $dto; } + + protected function createSimplePoll(bool $isMultipleChoice, bool $isRemote): Poll + { + $poll = new Poll(); + $poll->multipleChoice = $isMultipleChoice; + $poll->isRemote = $isRemote; + $this->entityManager->persist($poll); + foreach (['A', 'B', 'C'] as $choiceName) { + $choice = new PollChoice(); + $choice->name = $choiceName; + $choice->poll = $poll; + $poll->choices->add($choice); + $this->entityManager->persist($choice); + } + $this->entityManager->refresh($poll); + + return $poll; + } } diff --git a/tests/Functional/ActivityPub/ActivityPubFunctionalTestCase.php b/tests/Functional/ActivityPub/ActivityPubFunctionalTestCase.php index 286d06cb8b..339aea4f11 100644 --- a/tests/Functional/ActivityPub/ActivityPubFunctionalTestCase.php +++ b/tests/Functional/ActivityPub/ActivityPubFunctionalTestCase.php @@ -12,6 +12,8 @@ use App\Entity\EntryComment; use App\Entity\Magazine; use App\Entity\Message; +use App\Entity\Poll; +use App\Entity\PollVote; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\User; @@ -193,9 +195,13 @@ protected function switchToLocalDomain(): void /** * @param callable(Entry $entry):void|null $entryCreateCallback */ - protected function createRemoteEntryInRemoteMagazine(Magazine $magazine, User $user, ?callable $entryCreateCallback = null): array + protected function createRemoteEntryInRemoteMagazine(Magazine $magazine, User $user, ?callable $entryCreateCallback = null, bool $addPoll = false): array { - $entry = $this->getEntryByTitle('remote entry', magazine: $magazine, user: $user); + $entry = $this->getEntryByTitle('remote entry'.($addPoll ? ' with poll' : ''), magazine: $magazine, user: $user); + if ($addPoll) { + $entry->poll = $this->createSimplePoll(false, true); + } + $json = $this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; @@ -213,6 +219,9 @@ protected function createRemoteEntryInRemoteMagazine(Magazine $magazine, User $u $this->entitiesToRemoveAfterSetup[] = $announceActivity; $this->entitiesToRemoveAfterSetup[] = $createActivity; + if ($addPoll) { + $this->entitiesToRemoveAfterSetup[] = $entry->poll; + } $this->entitiesToRemoveAfterSetup[] = $entry; return $announce; @@ -221,11 +230,14 @@ protected function createRemoteEntryInRemoteMagazine(Magazine $magazine, User $u /** * @param callable(EntryComment $entry):void|null $entryCommentCreateCallback */ - protected function createRemoteEntryCommentInRemoteMagazine(Magazine $magazine, User $user, ?callable $entryCommentCreateCallback = null): array + protected function createRemoteEntryCommentInRemoteMagazine(Magazine $magazine, User $user, ?callable $entryCommentCreateCallback = null, bool $addPoll = false): array { $entries = array_filter($this->entitiesToRemoveAfterSetup, fn ($item) => $item instanceof Entry); $entry = $entries[array_key_first($entries)]; - $comment = $this->createEntryComment('remote entry comment', $entry, $user); + $comment = $this->createEntryComment('remote entry comment'.($addPoll ? ' with poll' : ''), $entry, $user); + if ($addPoll) { + $comment->poll = $this->createSimplePoll(false, true); + } $json = $this->entryCommentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; @@ -243,6 +255,9 @@ protected function createRemoteEntryCommentInRemoteMagazine(Magazine $magazine, $this->entitiesToRemoveAfterSetup[] = $announceActivity; $this->entitiesToRemoveAfterSetup[] = $createActivity; + if ($addPoll) { + $this->entitiesToRemoveAfterSetup[] = $comment->poll; + } $this->entitiesToRemoveAfterSetup[] = $comment; return $announce; @@ -251,9 +266,12 @@ protected function createRemoteEntryCommentInRemoteMagazine(Magazine $magazine, /** * @param callable(Post $entry):void|null $postCreateCallback */ - protected function createRemotePostInRemoteMagazine(Magazine $magazine, User $user, ?callable $postCreateCallback = null): array + protected function createRemotePostInRemoteMagazine(Magazine $magazine, User $user, ?callable $postCreateCallback = null, bool $addPoll = false): array { - $post = $this->createPost('remote post', magazine: $magazine, user: $user); + $post = $this->createPost('remote post'.($addPoll ? ' with poll' : ''), magazine: $magazine, user: $user); + if ($addPoll) { + $post->poll = $this->createSimplePoll(false, true); + } $json = $this->postNoteFactory->create($post, $this->tagLinkRepository->getTagsOfContent($post)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; @@ -271,6 +289,9 @@ protected function createRemotePostInRemoteMagazine(Magazine $magazine, User $us $this->entitiesToRemoveAfterSetup[] = $announceActivity; $this->entitiesToRemoveAfterSetup[] = $createActivity; + if ($addPoll) { + $this->entitiesToRemoveAfterSetup[] = $post->poll; + } $this->entitiesToRemoveAfterSetup[] = $post; return $announce; @@ -279,11 +300,14 @@ protected function createRemotePostInRemoteMagazine(Magazine $magazine, User $us /** * @param callable(PostComment $entry):void|null $postCommentCreateCallback */ - protected function createRemotePostCommentInRemoteMagazine(Magazine $magazine, User $user, ?callable $postCommentCreateCallback = null): array + protected function createRemotePostCommentInRemoteMagazine(Magazine $magazine, User $user, ?callable $postCommentCreateCallback = null, bool $addPoll = false): array { $posts = array_filter($this->entitiesToRemoveAfterSetup, fn ($item) => $item instanceof Post); $post = $posts[array_key_first($posts)]; - $comment = $this->createPostComment('remote post comment', $post, $user); + $comment = $this->createPostComment('remote post comment'.($addPoll ? ' with poll' : ''), $post, $user); + if ($addPoll) { + $comment->poll = $this->createSimplePoll(false, true); + } $json = $this->postCommentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; @@ -301,6 +325,9 @@ protected function createRemotePostCommentInRemoteMagazine(Magazine $magazine, U $this->entitiesToRemoveAfterSetup[] = $announceActivity; $this->entitiesToRemoveAfterSetup[] = $createActivity; + if ($addPoll) { + $this->entitiesToRemoveAfterSetup[] = $comment->poll; + } $this->entitiesToRemoveAfterSetup[] = $comment; return $announce; @@ -309,9 +336,12 @@ protected function createRemotePostCommentInRemoteMagazine(Magazine $magazine, U /** * @param callable(Entry $entry):void|null $entryCreateCallback */ - protected function createRemoteEntryInLocalMagazine(Magazine $magazine, User $user, ?callable $entryCreateCallback = null): array + protected function createRemoteEntryInLocalMagazine(Magazine $magazine, User $user, ?callable $entryCreateCallback = null, bool $addPoll = false, $pollMultipleChoice = false): array { - $entry = $this->getEntryByTitle('remote entry in local', magazine: $magazine, user: $user); + $entry = $this->getEntryByTitle('remote entry in local'.($addPoll ? ' with poll' : ''), magazine: $magazine, user: $user); + if ($addPoll) { + $entry->poll = $this->createSimplePoll($pollMultipleChoice, true); + } $json = $this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; @@ -326,6 +356,9 @@ protected function createRemoteEntryInLocalMagazine(Magazine $magazine, User $us } $this->entitiesToRemoveAfterSetup[] = $createActivity; + if ($addPoll) { + $this->entitiesToRemoveAfterSetup[] = $entry->poll; + } $this->entitiesToRemoveAfterSetup[] = $entry; return $create; @@ -334,11 +367,14 @@ protected function createRemoteEntryInLocalMagazine(Magazine $magazine, User $us /** * @param callable(EntryComment $entry):void|null $entryCommentCreateCallback */ - protected function createRemoteEntryCommentInLocalMagazine(Magazine $magazine, User $user, ?callable $entryCommentCreateCallback = null): array + protected function createRemoteEntryCommentInLocalMagazine(Magazine $magazine, User $user, ?callable $entryCommentCreateCallback = null, bool $addPoll = false): array { $entries = array_filter($this->entitiesToRemoveAfterSetup, fn ($item) => $item instanceof Entry && 'remote entry in local' === $item->title); $entry = $entries[array_key_first($entries)]; - $comment = $this->createEntryComment('remote entry comment', $entry, $user); + $comment = $this->createEntryComment('remote entry comment'.($addPoll ? ' with poll' : ''), $entry, $user); + if ($addPoll) { + $comment->poll = $this->createSimplePoll(false, true); + } $json = $this->entryCommentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; @@ -353,6 +389,9 @@ protected function createRemoteEntryCommentInLocalMagazine(Magazine $magazine, U } $this->entitiesToRemoveAfterSetup[] = $createActivity; + if ($addPoll) { + $this->entitiesToRemoveAfterSetup[] = $comment->poll; + } $this->entitiesToRemoveAfterSetup[] = $comment; return $create; @@ -361,9 +400,12 @@ protected function createRemoteEntryCommentInLocalMagazine(Magazine $magazine, U /** * @param callable(Post $entry):void|null $postCreateCallback */ - protected function createRemotePostInLocalMagazine(Magazine $magazine, User $user, ?callable $postCreateCallback = null): array + protected function createRemotePostInLocalMagazine(Magazine $magazine, User $user, ?callable $postCreateCallback = null, bool $addPoll = false): array { - $post = $this->createPost('remote post in local', magazine: $magazine, user: $user); + $post = $this->createPost('remote post in local'.($addPoll ? ' with poll' : ''), magazine: $magazine, user: $user); + if ($addPoll) { + $post->poll = $this->createSimplePoll(false, true); + } $json = $this->postNoteFactory->create($post, $this->tagLinkRepository->getTagsOfContent($post)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; @@ -378,6 +420,9 @@ protected function createRemotePostInLocalMagazine(Magazine $magazine, User $use } $this->entitiesToRemoveAfterSetup[] = $createActivity; + if ($addPoll) { + $this->entitiesToRemoveAfterSetup[] = $post->poll; + } $this->entitiesToRemoveAfterSetup[] = $post; return $create; @@ -386,11 +431,14 @@ protected function createRemotePostInLocalMagazine(Magazine $magazine, User $use /** * @param callable(PostComment $entry):void|null $postCommentCreateCallback */ - protected function createRemotePostCommentInLocalMagazine(Magazine $magazine, User $user, ?callable $postCommentCreateCallback = null): array + protected function createRemotePostCommentInLocalMagazine(Magazine $magazine, User $user, ?callable $postCommentCreateCallback = null, bool $addPoll = false): array { $posts = array_filter($this->entitiesToRemoveAfterSetup, fn ($item) => $item instanceof Post && 'remote post in local' === $item->body); $post = $posts[array_key_first($posts)]; - $comment = $this->createPostComment('remote post comment in local', $post, $user); + $comment = $this->createPostComment('remote post comment in local'.($addPoll ? ' with poll' : ''), $post, $user); + if ($addPoll) { + $comment->poll = $this->createSimplePoll(false, true); + } $json = $this->postCommentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; @@ -405,6 +453,9 @@ protected function createRemotePostCommentInLocalMagazine(Magazine $magazine, Us } $this->entitiesToRemoveAfterSetup[] = $createActivity; + if ($addPoll) { + $this->entitiesToRemoveAfterSetup[] = $comment->poll; + } $this->entitiesToRemoveAfterSetup[] = $comment; return $create; @@ -439,6 +490,29 @@ protected function createRemoteMessage(User $fromRemoteUser, User $toLocalUser, return $create; } + public function createRemoteVoteOnLocalPoll(Poll $localPoll, User $remoteUser, string $choice): array + { + $vote = new PollVote(); + $vote->poll = $localPoll; + $vote->voter = $remoteUser; + $vote->choice = $localPoll->findChoice($choice); + $this->entityManager->persist($vote); + + $createActivity = $this->createWrapper->build($vote); + $create = $this->activityJsonBuilder->buildActivityJson($createActivity); + // replace current domain with previous one, because we are creating a remote object with the remote domain + // responding to a local object with a local domain and the local domain is the previous one + $create['object']['inReplyTo'] = str_replace($this->settingsManager->get('KBIN_DOMAIN'), $this->prev, $create['object']['inReplyTo']); + $create['to'][0] = str_replace($this->settingsManager->get('KBIN_DOMAIN'), $this->prev, $create['to'][0]); + $create['object']['to'][0] = str_replace($this->settingsManager->get('KBIN_DOMAIN'), $this->prev, $create['object']['to'][0]); + $this->testingApHttpClient->activityObjects[$create['id']] = $create; + + $this->entitiesToRemoveAfterSetup[] = $createActivity; + $this->entitiesToRemoveAfterSetup[] = $vote; + + return $create; + } + /** * This rewrites the target fields `to` and `audience` to the @see self::$prev domain. * This is useful when remote actors create activities on local magazines. diff --git a/tests/Functional/ActivityPub/Inbox/CreateHandlerTest.php b/tests/Functional/ActivityPub/Inbox/CreateHandlerTest.php index cc095785c3..d18de81f5a 100644 --- a/tests/Functional/ActivityPub/Inbox/CreateHandlerTest.php +++ b/tests/Functional/ActivityPub/Inbox/CreateHandlerTest.php @@ -6,6 +6,7 @@ use App\Entity\Contracts\ActivityPubActivityInterface; use App\Entity\Contracts\VisibilityInterface; +use App\Entity\Entry; use App\Entity\Magazine; use App\Entity\User; use App\Enums\EDirectMessageSettings; @@ -24,6 +25,8 @@ class CreateHandlerTest extends ActivityPubFunctionalTestCase private array $announcePost; private array $announcePostComment; private array $createEntry; + private array $createEntryWithPoll; + private array $createEntryWithMultipleChoicePoll; private array $createEntryWithUrlAndImage; private array $createEntryComment; private array $createPost; @@ -33,6 +36,10 @@ class CreateHandlerTest extends ActivityPubFunctionalTestCase private array $createMastodonPostWithMentionWithoutTagArray; private array $createPostWithPublicNS; private array $createPostWithPublicShortURL; + private Entry $localEntryWithPoll; + private array $remoteVoteOnLocalPoll; + private Entry $localEntryWithMultipleChoicePoll; + private array $remoteVoteOnLocalMultipleChoicePoll; public function setUpRemoteEntities(): void { @@ -41,6 +48,8 @@ public function setUpRemoteEntities(): void $this->announcePost = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remoteUser); $this->announcePostComment = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser); $this->createEntry = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser); + $this->createEntryWithPoll = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, addPoll: true); + $this->createEntryWithMultipleChoicePoll = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, addPoll: true, pollMultipleChoice: true); $this->createEntryWithUrlAndImage = $this->createRemoteEntryWithUrlAndImageInLocalMagazine($this->localMagazine, $this->remoteUser); $this->createEntryComment = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remoteUser); $this->createPost = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser); @@ -54,6 +63,16 @@ public function setUpRemoteEntities(): void public function setUpLocalEntities(): void { $this->setupRemoteActor(); + $this->localEntryWithPoll = $this->getEntryByTitle('A local entry with a poll'); + $this->localEntryWithPoll->poll = $this->createSimplePoll(false, false); + $this->localEntryWithMultipleChoicePoll = $this->getEntryByTitle('A local entry with a multiple choice poll'); + $this->localEntryWithMultipleChoicePoll->poll = $this->createSimplePoll(true, false); + } + + public function setUpLateRemoteEntities(): void + { + $this->remoteVoteOnLocalPoll = $this->createRemoteVoteOnLocalPoll($this->localEntryWithPoll->poll, $this->remoteUser, 'B'); + $this->remoteVoteOnLocalMultipleChoicePoll = $this->createRemoteVoteOnLocalPoll($this->localEntryWithMultipleChoicePoll->poll, $this->remoteUser, 'A'); } public function testCreateAnnouncedEntry(): void @@ -132,6 +151,48 @@ public function testCreateEntry(): void self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[0]['inboxUrl']); } + public function testCreateEntryWithPoll(): void + { + $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryWithPoll))); + $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntryWithPoll['object']['id']]); + self::assertNotNull($entry); + self::assertNotNull($entry->poll); + self::assertFalse($entry->poll->multipleChoice); + self::assertTrue($entry->poll->isRemote); + $this->entityManager->refresh($entry->poll); + self::assertNotNull($entry->poll->findChoice('A')); + self::assertNotNull($entry->poll->findChoice('B')); + self::assertNotNull($entry->poll->findChoice('C')); + self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber)); + $postedObjects = $this->testingApHttpClient->getPostedObjects(); + self::assertNotEmpty($postedObjects); + // the id of the 'Create' activity should be wrapped in a 'Announce' activity + self::assertEquals($this->createEntryWithPoll['id'], $postedObjects[0]['payload']['object']['id']); + self::assertEquals($this->createEntryWithPoll['object']['id'], $postedObjects[0]['payload']['object']['object']['id']); + self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[0]['inboxUrl']); + } + + public function testCreateEntryWithMultipleChoicePoll(): void + { + $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryWithMultipleChoicePoll))); + $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntryWithMultipleChoicePoll['object']['id']]); + self::assertNotNull($entry); + self::assertNotNull($entry->poll); + self::assertTrue($entry->poll->multipleChoice); + self::assertTrue($entry->poll->isRemote); + $this->entityManager->refresh($entry->poll); + self::assertNotNull($entry->poll->findChoice('A')); + self::assertNotNull($entry->poll->findChoice('B')); + self::assertNotNull($entry->poll->findChoice('C')); + self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber)); + $postedObjects = $this->testingApHttpClient->getPostedObjects(); + self::assertNotEmpty($postedObjects); + // the id of the 'Create' activity should be wrapped in a 'Announce' activity + self::assertEquals($this->createEntryWithMultipleChoicePoll['id'], $postedObjects[0]['payload']['object']['id']); + self::assertEquals($this->createEntryWithMultipleChoicePoll['object']['id'], $postedObjects[0]['payload']['object']['object']['id']); + self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[0]['inboxUrl']); + } + public function testCreateEntryWithUrlAndImage(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryWithUrlAndImage))); @@ -266,6 +327,39 @@ public function testPostWithPublicShortURL(): void self::assertNotNull($post); } + public function testRemoteVoteOnLocalEntryWithPoll(): void + { + $this->bus->dispatch(new ActivityMessage(json_encode($this->remoteVoteOnLocalPoll))); + $entry = $this->entryRepository->find($this->localEntryWithPoll->getId()); + self::assertNotNull($entry); + self::assertNotNull($entry->poll); + self::assertEquals(1, $entry->poll->voterCount); + self::assertNotNull($entry->poll->findChoice('B')); + self::assertEquals(1, $entry->poll->findChoice('B')->voteCount); + } + + public function testRemoteVotesOnLocalEntryWithMultipleChoicePoll(): void + { + $choiceA = $this->remoteVoteOnLocalMultipleChoicePoll; + $choiceB = $this->remoteVoteOnLocalMultipleChoicePoll; + $choiceB['object']['name'] = 'B'; + $choiceC = $this->remoteVoteOnLocalMultipleChoicePoll; + $choiceC['object']['name'] = 'C'; + $this->bus->dispatch(new ActivityMessage(json_encode($choiceA))); + $this->bus->dispatch(new ActivityMessage(json_encode($choiceB))); + $this->bus->dispatch(new ActivityMessage(json_encode($choiceC))); + $entry = $this->entryRepository->find($this->localEntryWithMultipleChoicePoll->getId()); + self::assertNotNull($entry); + self::assertNotNull($entry->poll); + self::assertEquals(1, $entry->poll->voterCount); + self::assertNotNull($entry->poll->findChoice('A')); + self::assertEquals(1, $entry->poll->findChoice('A')->voteCount); + self::assertNotNull($entry->poll->findChoice('B')); + self::assertEquals(1, $entry->poll->findChoice('B')->voteCount); + self::assertNotNull($entry->poll->findChoice('C')); + self::assertEquals(1, $entry->poll->findChoice('C')->voteCount); + } + private function setupRemoteActor(): void { $domain = 'some.instance.tld'; diff --git a/tests/Functional/Controller/Api/Magazine/Moderate/MagazineRetrieveBansApiTest.php b/tests/Functional/Controller/Api/Magazine/Moderate/MagazineRetrieveBansApiTest.php index 0a62bea786..f51ef3c6e2 100644 --- a/tests/Functional/Controller/Api/Magazine/Moderate/MagazineRetrieveBansApiTest.php +++ b/tests/Functional/Controller/Api/Magazine/Moderate/MagazineRetrieveBansApiTest.php @@ -5,13 +5,10 @@ namespace App\Tests\Functional\Controller\Api\Magazine\Moderate; use App\DTO\MagazineBanDto; -use App\Tests\Functional\Controller\Api\Magazine\MagazineRetrieveApiTest; use App\Tests\WebTestCase; class MagazineRetrieveBansApiTest extends WebTestCase { - public const BAN_RESPONSE_KEYS = ['banId', 'reason', 'expired', 'expiredAt', 'bannedUser', 'bannedBy', 'magazine']; - public function testApiCannotRetrieveMagazineBansAnonymous(): void { $magazine = $this->getMagazineByName('test'); @@ -77,7 +74,7 @@ public function testApiCanRetrieveMagazineBans(): void self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(self::BAN_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals($ban->reason, $jsonData['items'][0]['reason']); - self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); + self::assertArrayKeysMatch(WebTestCase::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['bannedUser']); self::assertSame($bannedUser->getId(), $jsonData['items'][0]['bannedUser']['userId']); diff --git a/tests/Functional/Controller/Api/Poll/PollVoteControllerTest.php b/tests/Functional/Controller/Api/Poll/PollVoteControllerTest.php new file mode 100644 index 0000000000..fede12b3a4 --- /dev/null +++ b/tests/Functional/Controller/Api/Poll/PollVoteControllerTest.php @@ -0,0 +1,270 @@ +getEntryByTitle('Entry with poll'); + $entry->poll = $this->createSimplePoll(false, false); + $this->entityManager->flush(); + + $this->client->request('PUT', "/api/entry/{$entry->getId()}/poll/vote?choices[]=B"); + self::assertResponseStatusCodeSame(401); + } + + public function testCannotVoteWithRead(): void + { + $user = $this->getUserByUsername('user'); + $entry = $this->getEntryByTitle('Entry with poll'); + $entry->poll = $this->createSimplePoll(false, false); + $this->entityManager->flush(); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/entry/{$entry->getId()}/poll/vote?choices[]=B", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + public function testCannotVoteWithWrongChoice(): void + { + $user = $this->getUserByUsername('user'); + $entry = $this->getEntryByTitle('Entry with poll'); + $entry->poll = $this->createSimplePoll(false, false); + $this->entityManager->flush(); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'entry:vote'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/entry/{$entry->getId()}/poll/vote?choices[]=D", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(400); + } + + public function testCannotVoteOnExpiredPoll(): void + { + $user = $this->getUserByUsername('user'); + $entry = $this->getEntryByTitle('Entry with poll'); + $entry->poll = $this->createSimplePoll(false, false); + $entry->poll->endDate = new \DateTimeImmutable('now - 1 day'); + $this->entityManager->flush(); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'entry:vote'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/entry/{$entry->getId()}/poll/vote?choices[]=A", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(400); + } + + public function testVoteOnEntryPoll(): void + { + $user = $this->getUserByUsername('user'); + $entry = $this->getEntryByTitle('Entry with poll'); + $entry->poll = $this->createSimplePoll(false, false); + $this->entityManager->flush(); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'entry:vote'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/entry/{$entry->getId()}/poll/vote?choices[]=B", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->verifyPollResponse(['B']); + } + + #[Depends('testVoteOnEntryPoll')] + public function testCannotVoteTwice(): void + { + $user = $this->getUserByUsername('user'); + $entry = $this->getEntryByTitle('Entry with poll'); + $entry->poll = $this->createSimplePoll(false, false); + $this->entityManager->flush(); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'entry:vote'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/entry/{$entry->getId()}/poll/vote?choices[]=B", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->verifyPollResponse(['B']); + + $this->client->request('PUT', "/api/entry/{$entry->getId()}/poll/vote?choices[]=A", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(400); + } + + public function testVoteOnEntryMultipleChoicePoll(): void + { + $user = $this->getUserByUsername('user'); + $entry = $this->getEntryByTitle('Entry with multiple choice poll'); + $entry->poll = $this->createSimplePoll(true, false); + $this->entityManager->flush(); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'entry:vote'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/entry/{$entry->getId()}/poll/vote?choices[]=B&choices[]=C", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->verifyPollResponse(['B', 'C']); + } + + public function testVoteOnEntryCommentPoll(): void + { + $user = $this->getUserByUsername('user'); + $entry = $this->getEntryByTitle('Entry with comment'); + $entryComment = $this->createEntryComment('comment with poll', $entry); + $entryComment->poll = $this->createSimplePoll(false, false); + $this->entityManager->flush(); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'entry_comment:vote'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/poll/vote?choices[]=B", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->verifyPollResponse(['B']); + } + + public function testVoteOnEntryCommentMultipleChoicePoll(): void + { + $user = $this->getUserByUsername('user'); + $entry = $this->getEntryByTitle('Entry with comment'); + $entryComment = $this->createEntryComment('comment with poll', $entry); + $entryComment->poll = $this->createSimplePoll(true, false); + $this->entityManager->flush(); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'entry_comment:vote'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/poll/vote?choices[]=B&choices[]=A", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->verifyPollResponse(['B', 'A']); + } + + public function testVoteOnPostPoll(): void + { + $user = $this->getUserByUsername('user'); + $post = $this->createPost('Post with poll'); + $post->poll = $this->createSimplePoll(false, false); + $this->entityManager->flush(); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'post:vote'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/post/{$post->getId()}/poll/vote?choices[]=B", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->verifyPollResponse(['B']); + } + + public function testVoteOnPostMultipleChoicePoll(): void + { + $user = $this->getUserByUsername('user'); + $post = $this->createPost('Post with poll'); + $post->poll = $this->createSimplePoll(true, false); + $this->entityManager->flush(); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'post:vote'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/post/{$post->getId()}/poll/vote?choices[]=B&choices[]=A&choices[]=C", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->verifyPollResponse(['B', 'A', 'C']); + } + + public function testVoteOnPostCommentPoll(): void + { + $user = $this->getUserByUsername('user'); + $post = $this->createPost('Post with comment'); + $postComment = $this->createPostComment('comment with poll', $post); + $postComment->poll = $this->createSimplePoll(false, false); + $this->entityManager->flush(); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'post_comment:vote'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/post/{$post->getId()}/comments/{$postComment->getId()}/poll/vote?choices[]=B", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->verifyPollResponse(['B']); + } + + public function testVoteOnPostCommentMultipleChoicePoll(): void + { + $user = $this->getUserByUsername('user'); + $post = $this->createPost('Post with comment'); + $postComment = $this->createPostComment('comment with poll', $post); + $postComment->poll = $this->createSimplePoll(true, false); + $this->entityManager->flush(); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'post_comment:vote'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/post/{$post->getId()}/comments/{$postComment->getId()}/poll/vote?choices[]=B", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->verifyPollResponse(['B']); + } + + private function verifyPollResponse(array $choices): void + { + $data = self::getJsonResponse($this->client); + self::assertArrayKeysMatch(WebTestCase::POLL_KEYS, $data); + self::assertIsArray($data['choices']); + self::assertCount(3, $data['choices']); + foreach ($data['choices'] as $choice) { + self::assertArrayKeysMatch(WebTestCase::POLL_CHOICE_KEYS, $choice); + if (\in_array($choice['name'], $choices)) { + self::assertEquals(1, $choice['voteCount']); + self::assertTrue($choice['currentUserHasVoted']); + } else { + self::assertEquals(0, $choice['voteCount']); + self::assertFalse($choice['currentUserHasVoted']); + } + } + } +} diff --git a/tests/Service/TestingApHttpClient.php b/tests/Service/TestingApHttpClient.php index 9180f50ebc..c16966a36b 100644 --- a/tests/Service/TestingApHttpClient.php +++ b/tests/Service/TestingApHttpClient.php @@ -103,6 +103,10 @@ public function getActivityObjectCacheKey(string $url): string return 'SOME_TESTING_CACHE_KEY'; } + public function invalidateActivityObjectCache(string $url): void + { + } + public function getInboxUrl(string $apProfileId): string { $actor = $this->getActorObject($apProfileId); diff --git a/tests/Unit/ActivityPub/Outbox/CreateTest.php b/tests/Unit/ActivityPub/Outbox/CreateTest.php index b94c76f826..837347c635 100644 --- a/tests/Unit/ActivityPub/Outbox/CreateTest.php +++ b/tests/Unit/ActivityPub/Outbox/CreateTest.php @@ -26,6 +26,20 @@ public function testCreateEntryWithUrlAndImage(): void $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); } + public function testCreateEntryWithPoll(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateEntryWithPollActivity()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } + + public function testCreateEntryWithMultipleChoicePoll(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateEntryWithMultipleChoicePollActivity()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } + public function testCreateEntryComment(): void { $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateEntryCommentActivity()); @@ -33,6 +47,20 @@ public function testCreateEntryComment(): void $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); } + public function testCreateEntryCommentWithPoll(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateEntryCommentWithPollActivity()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } + + public function testCreateEntryCommentWithMultipleChoicePoll(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateEntryCommentWithMultipleChoicePollActivity()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } + public function testCreateNestedEntryComment(): void { $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateNestedEntryCommentActivity()); @@ -40,6 +68,20 @@ public function testCreateNestedEntryComment(): void $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); } + public function testCreateNestedEntryCommentWithPoll(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateNestedEntryCommentWithPollActivity()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } + + public function testCreateNestedEntryCommentWithMultipleChoicePoll(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateNestedEntryCommentWithMultipleChoicePollActivity()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } + public function testCreatePost(): void { $json = $this->activityJsonBuilder->buildActivityJson($this->getCreatePostActivity()); @@ -47,6 +89,20 @@ public function testCreatePost(): void $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); } + public function testCreatePostWithPoll(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreatePostActivityWithPoll()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } + + public function testCreatePostWithMultipleChoicePoll(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreatePostActivityWithMultipleChoicePoll()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } + public function testCreatePostComment(): void { $json = $this->activityJsonBuilder->buildActivityJson($this->getCreatePostCommentActivity()); @@ -54,6 +110,20 @@ public function testCreatePostComment(): void $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); } + public function testCreatePostCommentWithPoll(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreatePostCommentActivityWithPoll()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } + + public function testCreatePostCommentWithMultipleChoicePoll(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreatePostCommentActivityWithMultipleChoicePoll()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } + public function testCreateNestedPostComment(): void { $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateNestedPostCommentActivity()); @@ -61,10 +131,31 @@ public function testCreateNestedPostComment(): void $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); } + public function testCreateNestedPostCommentWithPoll(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateNestedPostCommentWithPollActivity()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } + + public function testCreateNestedPostCommentWithMultipleChoicePoll(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateNestedPostCommentWithMultipleChoicePollActivity()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } + public function testCreateMessage(): void { $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateMessageActivity()); $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); } + + public function testCreatePollVote(): void + { + $json = $this->activityJsonBuilder->buildActivityJson($this->getCreatePollVoteActivity()); + + $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver()); + } } diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryCommentWithMultipleChoicePoll__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryCommentWithMultipleChoicePoll__1.json new file mode 100644 index 0000000000..262f0f6169 --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryCommentWithMultipleChoicePoll__1.json @@ -0,0 +1,82 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user", + "published": "SCRUBBED_DATE", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "object": { + "id": "SCRUBBED_ID", + "type": "Note", + "attributedTo": "https://kbin.test/u/user", + "inReplyTo": "SCRUBBED_ID", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "audience": "https://kbin.test/m/test", + "sensitive": false, + "content": "

    test

    \n", + "mediaType": "text/html", + "source": { + "content": "test", + "mediaType": "text/markdown" + }, + "url": "SCRUBBED_ID", + "tag": [ + { + "type": "Hashtag", + "href": "https://kbin.test/tag/test", + "name": "#test" + } + ], + "published": "SCRUBBED_DATE", + "contentMap": { + "en": "

    test

    \n" + }, + "votersCount": 0, + "endTime": "SCRUBBED_DATE", + "oneOf": [ + { + "type": "Note", + "name": "A", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "B", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "C", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ] + }, + "audience": "https://kbin.test/m/test" +} diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryCommentWithPoll__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryCommentWithPoll__1.json new file mode 100644 index 0000000000..262f0f6169 --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryCommentWithPoll__1.json @@ -0,0 +1,82 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user", + "published": "SCRUBBED_DATE", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "object": { + "id": "SCRUBBED_ID", + "type": "Note", + "attributedTo": "https://kbin.test/u/user", + "inReplyTo": "SCRUBBED_ID", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "audience": "https://kbin.test/m/test", + "sensitive": false, + "content": "

    test

    \n", + "mediaType": "text/html", + "source": { + "content": "test", + "mediaType": "text/markdown" + }, + "url": "SCRUBBED_ID", + "tag": [ + { + "type": "Hashtag", + "href": "https://kbin.test/tag/test", + "name": "#test" + } + ], + "published": "SCRUBBED_DATE", + "contentMap": { + "en": "

    test

    \n" + }, + "votersCount": 0, + "endTime": "SCRUBBED_DATE", + "oneOf": [ + { + "type": "Note", + "name": "A", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "B", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "C", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ] + }, + "audience": "https://kbin.test/m/test" +} diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryWithMultipleChoicePoll__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryWithMultipleChoicePoll__1.json new file mode 100644 index 0000000000..9c9c957e46 --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryWithMultipleChoicePoll__1.json @@ -0,0 +1,80 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user", + "published": "SCRUBBED_DATE", + "to": [ + "https://kbin.test/m/test", + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://kbin.test/u/user/followers" + ], + "object": { + "id": "SCRUBBED_ID", + "type": "Question", + "attributedTo": "https://kbin.test/u/user", + "inReplyTo": null, + "to": [ + "https://kbin.test/m/test", + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://kbin.test/u/user/followers" + ], + "name": "test", + "audience": "https://kbin.test/m/test", + "content": null, + "summary": "test #test", + "mediaType": "text/html", + "source": null, + "tag": [ + { + "type": "Hashtag", + "href": "https://kbin.test/tag/test", + "name": "#test" + } + ], + "commentsEnabled": true, + "sensitive": false, + "stickied": false, + "published": "SCRUBBED_DATE", + "contentMap": { + "en": null + }, + "votersCount": 0, + "endTime": "SCRUBBED_DATE", + "anyOf": [ + { + "type": "Note", + "name": "A", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "B", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "C", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ] + }, + "audience": "https://kbin.test/m/test" +} diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryWithPoll__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryWithPoll__1.json new file mode 100644 index 0000000000..4cf12f2b45 --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryWithPoll__1.json @@ -0,0 +1,80 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user", + "published": "SCRUBBED_DATE", + "to": [ + "https://kbin.test/m/test", + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://kbin.test/u/user/followers" + ], + "object": { + "id": "SCRUBBED_ID", + "type": "Question", + "attributedTo": "https://kbin.test/u/user", + "inReplyTo": null, + "to": [ + "https://kbin.test/m/test", + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://kbin.test/u/user/followers" + ], + "name": "test", + "audience": "https://kbin.test/m/test", + "content": null, + "summary": "test #test", + "mediaType": "text/html", + "source": null, + "tag": [ + { + "type": "Hashtag", + "href": "https://kbin.test/tag/test", + "name": "#test" + } + ], + "commentsEnabled": true, + "sensitive": false, + "stickied": false, + "published": "SCRUBBED_DATE", + "contentMap": { + "en": null + }, + "votersCount": 0, + "endTime": "SCRUBBED_DATE", + "oneOf": [ + { + "type": "Note", + "name": "A", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "B", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "C", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ] + }, + "audience": "https://kbin.test/m/test" +} diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedEntryCommentWithMultipleChoicePoll__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedEntryCommentWithMultipleChoicePoll__1.json new file mode 100644 index 0000000000..c5520bf038 --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedEntryCommentWithMultipleChoicePoll__1.json @@ -0,0 +1,82 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user", + "published": "SCRUBBED_DATE", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "object": { + "id": "SCRUBBED_ID", + "type": "Note", + "attributedTo": "https://kbin.test/u/user", + "inReplyTo": "SCRUBBED_ID", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "audience": "https://kbin.test/m/test", + "sensitive": false, + "content": "

    test

    \n", + "mediaType": "text/html", + "source": { + "content": "test", + "mediaType": "text/markdown" + }, + "url": "SCRUBBED_ID", + "tag": [ + { + "type": "Hashtag", + "href": "https://kbin.test/tag/test", + "name": "#test" + } + ], + "published": "SCRUBBED_DATE", + "contentMap": { + "en": "

    test

    \n" + }, + "votersCount": 0, + "endTime": "SCRUBBED_DATE", + "anyOf": [ + { + "type": "Note", + "name": "A", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "B", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "C", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ] + }, + "audience": "https://kbin.test/m/test" +} diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedEntryCommentWithPoll__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedEntryCommentWithPoll__1.json new file mode 100644 index 0000000000..262f0f6169 --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedEntryCommentWithPoll__1.json @@ -0,0 +1,82 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user", + "published": "SCRUBBED_DATE", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "object": { + "id": "SCRUBBED_ID", + "type": "Note", + "attributedTo": "https://kbin.test/u/user", + "inReplyTo": "SCRUBBED_ID", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "audience": "https://kbin.test/m/test", + "sensitive": false, + "content": "

    test

    \n", + "mediaType": "text/html", + "source": { + "content": "test", + "mediaType": "text/markdown" + }, + "url": "SCRUBBED_ID", + "tag": [ + { + "type": "Hashtag", + "href": "https://kbin.test/tag/test", + "name": "#test" + } + ], + "published": "SCRUBBED_DATE", + "contentMap": { + "en": "

    test

    \n" + }, + "votersCount": 0, + "endTime": "SCRUBBED_DATE", + "oneOf": [ + { + "type": "Note", + "name": "A", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "B", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "C", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ] + }, + "audience": "https://kbin.test/m/test" +} diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedPostCommentWithMultipleChoicePoll__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedPostCommentWithMultipleChoicePoll__1.json new file mode 100644 index 0000000000..c5520bf038 --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedPostCommentWithMultipleChoicePoll__1.json @@ -0,0 +1,82 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user", + "published": "SCRUBBED_DATE", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "object": { + "id": "SCRUBBED_ID", + "type": "Note", + "attributedTo": "https://kbin.test/u/user", + "inReplyTo": "SCRUBBED_ID", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "audience": "https://kbin.test/m/test", + "sensitive": false, + "content": "

    test

    \n", + "mediaType": "text/html", + "source": { + "content": "test", + "mediaType": "text/markdown" + }, + "url": "SCRUBBED_ID", + "tag": [ + { + "type": "Hashtag", + "href": "https://kbin.test/tag/test", + "name": "#test" + } + ], + "published": "SCRUBBED_DATE", + "contentMap": { + "en": "

    test

    \n" + }, + "votersCount": 0, + "endTime": "SCRUBBED_DATE", + "anyOf": [ + { + "type": "Note", + "name": "A", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "B", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "C", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ] + }, + "audience": "https://kbin.test/m/test" +} diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedPostCommentWithPoll__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedPostCommentWithPoll__1.json new file mode 100644 index 0000000000..262f0f6169 --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedPostCommentWithPoll__1.json @@ -0,0 +1,82 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user", + "published": "SCRUBBED_DATE", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "object": { + "id": "SCRUBBED_ID", + "type": "Note", + "attributedTo": "https://kbin.test/u/user", + "inReplyTo": "SCRUBBED_ID", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "audience": "https://kbin.test/m/test", + "sensitive": false, + "content": "

    test

    \n", + "mediaType": "text/html", + "source": { + "content": "test", + "mediaType": "text/markdown" + }, + "url": "SCRUBBED_ID", + "tag": [ + { + "type": "Hashtag", + "href": "https://kbin.test/tag/test", + "name": "#test" + } + ], + "published": "SCRUBBED_DATE", + "contentMap": { + "en": "

    test

    \n" + }, + "votersCount": 0, + "endTime": "SCRUBBED_DATE", + "oneOf": [ + { + "type": "Note", + "name": "A", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "B", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "C", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ] + }, + "audience": "https://kbin.test/m/test" +} diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePollVote__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePollVote__1.json new file mode 100644 index 0000000000..2c010f7705 --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePollVote__1.json @@ -0,0 +1,27 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user2", + "published": "SCRUBBED_DATE", + "to": [ + "https://kbin.test/u/user" + ], + "cc": [], + "object": { + "id": "SCRUBBED_ID", + "attributedTo": "https://kbin.test/u/user2", + "to": [ + "https://kbin.test/u/user" + ], + "cc": [], + "type": "Note", + "published": "SCRUBBED_DATE", + "inReplyTo": "SCRUBBED_ID", + "name": "A" + } +} diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostCommentWithMultipleChoicePoll__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostCommentWithMultipleChoicePoll__1.json new file mode 100644 index 0000000000..c5520bf038 --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostCommentWithMultipleChoicePoll__1.json @@ -0,0 +1,82 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user", + "published": "SCRUBBED_DATE", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "object": { + "id": "SCRUBBED_ID", + "type": "Note", + "attributedTo": "https://kbin.test/u/user", + "inReplyTo": "SCRUBBED_ID", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "audience": "https://kbin.test/m/test", + "sensitive": false, + "content": "

    test

    \n", + "mediaType": "text/html", + "source": { + "content": "test", + "mediaType": "text/markdown" + }, + "url": "SCRUBBED_ID", + "tag": [ + { + "type": "Hashtag", + "href": "https://kbin.test/tag/test", + "name": "#test" + } + ], + "published": "SCRUBBED_DATE", + "contentMap": { + "en": "

    test

    \n" + }, + "votersCount": 0, + "endTime": "SCRUBBED_DATE", + "anyOf": [ + { + "type": "Note", + "name": "A", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "B", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "C", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ] + }, + "audience": "https://kbin.test/m/test" +} diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostCommentWithPoll__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostCommentWithPoll__1.json new file mode 100644 index 0000000000..262f0f6169 --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostCommentWithPoll__1.json @@ -0,0 +1,82 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user", + "published": "SCRUBBED_DATE", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "object": { + "id": "SCRUBBED_ID", + "type": "Note", + "attributedTo": "https://kbin.test/u/user", + "inReplyTo": "SCRUBBED_ID", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://kbin.test/u/user" + ], + "cc": [ + "https://kbin.test/m/test", + "https://kbin.test/u/user/followers" + ], + "audience": "https://kbin.test/m/test", + "sensitive": false, + "content": "

    test

    \n", + "mediaType": "text/html", + "source": { + "content": "test", + "mediaType": "text/markdown" + }, + "url": "SCRUBBED_ID", + "tag": [ + { + "type": "Hashtag", + "href": "https://kbin.test/tag/test", + "name": "#test" + } + ], + "published": "SCRUBBED_DATE", + "contentMap": { + "en": "

    test

    \n" + }, + "votersCount": 0, + "endTime": "SCRUBBED_DATE", + "oneOf": [ + { + "type": "Note", + "name": "A", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "B", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "C", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ] + }, + "audience": "https://kbin.test/m/test" +} diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostWithMultipleChoicePoll__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostWithMultipleChoicePoll__1.json new file mode 100644 index 0000000000..03ac59daea --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostWithMultipleChoicePoll__1.json @@ -0,0 +1,82 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user", + "published": "SCRUBBED_DATE", + "to": [ + "https://kbin.test/m/test", + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://kbin.test/u/user/followers" + ], + "object": { + "id": "SCRUBBED_ID", + "type": "Note", + "attributedTo": "https://kbin.test/u/user", + "inReplyTo": null, + "to": [ + "https://kbin.test/m/test", + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://kbin.test/u/user/followers" + ], + "audience": "https://kbin.test/m/test", + "sensitive": false, + "stickied": false, + "content": "

    test

    \n

    #test

    \n", + "mediaType": "text/html", + "source": { + "content": "test\n\n #test", + "mediaType": "text/markdown" + }, + "url": "SCRUBBED_ID", + "tag": [ + { + "type": "Hashtag", + "href": "https://kbin.test/tag/test", + "name": "#test" + } + ], + "commentsEnabled": true, + "published": "SCRUBBED_DATE", + "contentMap": { + "en": "

    test

    \n

    #test

    \n" + }, + "votersCount": 0, + "endTime": "SCRUBBED_DATE", + "anyOf": [ + { + "type": "Note", + "name": "A", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "B", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "C", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ] + }, + "audience": "https://kbin.test/m/test" +} diff --git a/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostWithPoll__1.json b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostWithPoll__1.json new file mode 100644 index 0000000000..9cbf05fc90 --- /dev/null +++ b/tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostWithPoll__1.json @@ -0,0 +1,82 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://kbin.test/contexts" + ], + "id": "SCRUBBED_ID", + "type": "Create", + "actor": "https://kbin.test/u/user", + "published": "SCRUBBED_DATE", + "to": [ + "https://kbin.test/m/test", + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://kbin.test/u/user/followers" + ], + "object": { + "id": "SCRUBBED_ID", + "type": "Note", + "attributedTo": "https://kbin.test/u/user", + "inReplyTo": null, + "to": [ + "https://kbin.test/m/test", + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://kbin.test/u/user/followers" + ], + "audience": "https://kbin.test/m/test", + "sensitive": false, + "stickied": false, + "content": "

    test

    \n

    #test

    \n", + "mediaType": "text/html", + "source": { + "content": "test\n\n #test", + "mediaType": "text/markdown" + }, + "url": "SCRUBBED_ID", + "tag": [ + { + "type": "Hashtag", + "href": "https://kbin.test/tag/test", + "name": "#test" + } + ], + "commentsEnabled": true, + "published": "SCRUBBED_DATE", + "contentMap": { + "en": "

    test

    \n

    #test

    \n" + }, + "votersCount": 0, + "endTime": "SCRUBBED_DATE", + "oneOf": [ + { + "type": "Note", + "name": "A", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "B", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "C", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ] + }, + "audience": "https://kbin.test/m/test" +} diff --git a/tests/Unit/ActivityPub/Traits/CreateActivityGeneratorTrait.php b/tests/Unit/ActivityPub/Traits/CreateActivityGeneratorTrait.php index e51bb08379..85beb1d04c 100644 --- a/tests/Unit/ActivityPub/Traits/CreateActivityGeneratorTrait.php +++ b/tests/Unit/ActivityPub/Traits/CreateActivityGeneratorTrait.php @@ -5,6 +5,7 @@ namespace App\Tests\Unit\ActivityPub\Traits; use App\Entity\Activity; +use App\Entity\PollVote; trait CreateActivityGeneratorTrait { @@ -15,6 +16,22 @@ public function getCreateEntryActivity(): Activity return $this->createWrapper->build($entry); } + public function getCreateEntryWithPollActivity(): Activity + { + $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user); + $entry->poll = $this->createSimplePoll(false, true); + + return $this->createWrapper->build($entry); + } + + public function getCreateEntryWithMultipleChoicePollActivity(): Activity + { + $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user); + $entry->poll = $this->createSimplePoll(true, true); + + return $this->createWrapper->build($entry); + } + public function getCreateEntryActivityWithImageAndUrl(): Activity { $entry = $this->getEntryByTitle('test', url: 'https://joinmbin.org', magazine: $this->magazine, user: $this->user, image: $this->getKibbyImageDto()); @@ -30,6 +47,24 @@ public function getCreateEntryCommentActivity(): Activity return $this->createWrapper->build($entryComment); } + public function getCreateEntryCommentWithPollActivity(): Activity + { + $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user); + $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user); + $entryComment->poll = $this->createSimplePoll(false, true); + + return $this->createWrapper->build($entryComment); + } + + public function getCreateEntryCommentWithMultipleChoicePollActivity(): Activity + { + $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user); + $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user); + $entryComment->poll = $this->createSimplePoll(false, true); + + return $this->createWrapper->build($entryComment); + } + public function getCreateNestedEntryCommentActivity(): Activity { $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user); @@ -39,6 +74,26 @@ public function getCreateNestedEntryCommentActivity(): Activity return $this->createWrapper->build($entryComment2); } + public function getCreateNestedEntryCommentWithPollActivity(): Activity + { + $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user); + $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user); + $entryComment2 = $this->createEntryComment('test', entry: $entry, user: $this->user, parent: $entryComment); + $entryComment2->poll = $this->createSimplePoll(false, true); + + return $this->createWrapper->build($entryComment2); + } + + public function getCreateNestedEntryCommentWithMultipleChoicePollActivity(): Activity + { + $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user); + $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user); + $entryComment2 = $this->createEntryComment('test', entry: $entry, user: $this->user, parent: $entryComment); + $entryComment2->poll = $this->createSimplePoll(true, true); + + return $this->createWrapper->build($entryComment2); + } + public function getCreatePostActivity(): Activity { $post = $this->createPost('test', magazine: $this->magazine, user: $this->user); @@ -46,6 +101,22 @@ public function getCreatePostActivity(): Activity return $this->createWrapper->build($post); } + public function getCreatePostActivityWithPoll(): Activity + { + $post = $this->createPost('test', magazine: $this->magazine, user: $this->user); + $post->poll = $this->createSimplePoll(false, true); + + return $this->createWrapper->build($post); + } + + public function getCreatePostActivityWithMultipleChoicePoll(): Activity + { + $post = $this->createPost('test', magazine: $this->magazine, user: $this->user); + $post->poll = $this->createSimplePoll(true, true); + + return $this->createWrapper->build($post); + } + public function getCreatePostCommentActivity(): Activity { $post = $this->createPost('test', magazine: $this->magazine, user: $this->user); @@ -54,6 +125,24 @@ public function getCreatePostCommentActivity(): Activity return $this->createWrapper->build($postComment); } + public function getCreatePostCommentActivityWithPoll(): Activity + { + $post = $this->createPost('test', magazine: $this->magazine, user: $this->user); + $postComment = $this->createPostComment('test', post: $post, user: $this->user); + $postComment->poll = $this->createSimplePoll(false, true); + + return $this->createWrapper->build($postComment); + } + + public function getCreatePostCommentActivityWithMultipleChoicePoll(): Activity + { + $post = $this->createPost('test', magazine: $this->magazine, user: $this->user); + $postComment = $this->createPostComment('test', post: $post, user: $this->user); + $postComment->poll = $this->createSimplePoll(true, true); + + return $this->createWrapper->build($postComment); + } + public function getCreateNestedPostCommentActivity(): Activity { $post = $this->createPost('test', magazine: $this->magazine, user: $this->user); @@ -63,6 +152,26 @@ public function getCreateNestedPostCommentActivity(): Activity return $this->createWrapper->build($postComment2); } + public function getCreateNestedPostCommentWithPollActivity(): Activity + { + $post = $this->createPost('test', magazine: $this->magazine, user: $this->user); + $postComment = $this->createPostComment('test', post: $post, user: $this->user); + $postComment2 = $this->createPostComment('test', post: $post, user: $this->user, parent: $postComment); + $postComment2->poll = $this->createSimplePoll(false, true); + + return $this->createWrapper->build($postComment2); + } + + public function getCreateNestedPostCommentWithMultipleChoicePollActivity(): Activity + { + $post = $this->createPost('test', magazine: $this->magazine, user: $this->user); + $postComment = $this->createPostComment('test', post: $post, user: $this->user); + $postComment2 = $this->createPostComment('test', post: $post, user: $this->user, parent: $postComment); + $postComment2->poll = $this->createSimplePoll(true, true); + + return $this->createWrapper->build($postComment2); + } + public function getCreateMessageActivity(): Activity { $user2 = $this->getUserByUsername('user2'); @@ -70,4 +179,19 @@ public function getCreateMessageActivity(): Activity return $this->createWrapper->build($message); } + + public function getCreatePollVoteActivity(): Activity + { + $user2 = $this->getUserByUsername('user2'); + $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user); + $entry->poll = $this->createSimplePoll(false, false); + + $vote = new PollVote(); + $vote->poll = $entry->poll; + $vote->choice = $entry->poll->findChoice('A'); + $vote->voter = $user2; + $this->entityManager->persist($vote); + + return $this->createWrapper->build($vote); + } } diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index 667bfd3a41..f39f9661eb 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -50,6 +50,7 @@ use App\Service\MentionManager; use App\Service\MessageManager; use App\Service\NotificationManager; +use App\Service\PollManager; use App\Service\PostCommentManager; use App\Service\PostManager; use App\Service\ProjectInfoService; @@ -86,24 +87,26 @@ abstract class WebTestCase extends BaseWebTestCase use OAuth2FlowTrait; use ValidationTrait; - protected const PAGINATED_KEYS = ['items', 'pagination']; - protected const PAGINATION_KEYS = ['count', 'currentPage', 'maxPage', 'perPage']; - protected const CURSOR_PAGINATION_KEYS = ['currentCursor', 'currentCursor2', 'nextCursor', 'nextCursor2', 'previousCursor', 'previousCursor2', 'perPage']; - protected const IMAGE_KEYS = ['filePath', 'sourceUrl', 'storageUrl', 'altText', 'width', 'height', 'blurHash']; - protected const MESSAGE_RESPONSE_KEYS = ['messageId', 'threadId', 'sender', 'body', 'status', 'createdAt']; - protected const USER_RESPONSE_KEYS = ['userId', 'username', 'about', 'avatar', 'cover', 'createdAt', 'followersCount', 'apId', 'apProfileId', 'isBot', 'isFollowedByUser', 'isFollowerOfUser', 'isBlockedByUser', 'isAdmin', 'isGlobalModerator', 'serverSoftware', 'serverSoftwareVersion', 'notificationStatus', 'reputationPoints', 'discoverable', 'indexable', 'title']; - protected const USER_SMALL_RESPONSE_KEYS = ['userId', 'username', 'isBot', 'isFollowedByUser', 'isFollowerOfUser', 'isBlockedByUser', 'avatar', 'apId', 'apProfileId', 'createdAt', 'isAdmin', 'isGlobalModerator', 'discoverable', 'indexable', 'title']; - protected const ENTRY_RESPONSE_KEYS = ['entryId', 'magazine', 'user', 'domain', 'title', 'url', 'image', 'body', 'lang', 'tags', 'badges', 'numComments', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'isOc', 'isAdult', 'isPinned', 'isLocked', 'createdAt', 'editedAt', 'lastActive', 'visibility', 'type', 'slug', 'apId', 'canAuthUserModerate', 'notificationStatus', 'bookmarks', 'crosspostedEntries', 'isAuthorModeratorInMagazine']; - protected const ENTRY_COMMENT_RESPONSE_KEYS = ['commentId', 'magazine', 'user', 'entryId', 'parentId', 'rootId', 'image', 'body', 'lang', 'isAdult', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'mentions', 'tags', 'createdAt', 'editedAt', 'lastActive', 'childCount', 'children', 'canAuthUserModerate', 'bookmarks', 'isAuthorModeratorInMagazine']; - protected const POST_RESPONSE_KEYS = ['postId', 'user', 'magazine', 'image', 'body', 'lang', 'isAdult', 'isPinned', 'isLocked', 'comments', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'tags', 'mentions', 'createdAt', 'editedAt', 'lastActive', 'slug', 'canAuthUserModerate', 'notificationStatus', 'bookmarks', 'isAuthorModeratorInMagazine']; - protected const POST_COMMENT_RESPONSE_KEYS = ['commentId', 'user', 'magazine', 'postId', 'parentId', 'rootId', 'image', 'body', 'lang', 'isAdult', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'mentions', 'tags', 'createdAt', 'editedAt', 'lastActive', 'childCount', 'children', 'canAuthUserModerate', 'bookmarks', 'isAuthorModeratorInMagazine']; - protected const BAN_RESPONSE_KEYS = ['banId', 'reason', 'expired', 'expiredAt', 'bannedUser', 'bannedBy', 'magazine']; - protected const LOG_ENTRY_KEYS = ['type', 'createdAt', 'magazine', 'moderator', 'subject']; - protected const MAGAZINE_RESPONSE_KEYS = ['magazineId', 'owner', 'icon', 'banner', 'name', 'title', 'description', 'rules', 'subscriptionsCount', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isAdult', 'isUserSubscribed', 'isBlockedByUser', 'tags', 'badges', 'moderators', 'apId', 'apProfileId', 'serverSoftware', 'serverSoftwareVersion', 'isPostingRestrictedToMods', 'localSubscribers', 'notificationStatus', 'discoverable', 'indexable']; - protected const MAGAZINE_SMALL_RESPONSE_KEYS = ['magazineId', 'name', 'icon', 'banner', 'isUserSubscribed', 'isBlockedByUser', 'apId', 'apProfileId', 'discoverable', 'indexable']; - protected const DOMAIN_RESPONSE_KEYS = ['domainId', 'name', 'entryCount', 'subscriptionsCount', 'isUserSubscribed', 'isBlockedByUser']; - - protected const KIBBY_PNG_URL_RESULT = 'a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png'; + protected const array PAGINATED_KEYS = ['items', 'pagination']; + protected const array PAGINATION_KEYS = ['count', 'currentPage', 'maxPage', 'perPage']; + protected const array CURSOR_PAGINATION_KEYS = ['currentCursor', 'currentCursor2', 'nextCursor', 'nextCursor2', 'previousCursor', 'previousCursor2', 'perPage']; + protected const array IMAGE_KEYS = ['filePath', 'sourceUrl', 'storageUrl', 'altText', 'width', 'height', 'blurHash']; + protected const array MESSAGE_RESPONSE_KEYS = ['messageId', 'threadId', 'sender', 'body', 'status', 'createdAt']; + protected const array USER_RESPONSE_KEYS = ['userId', 'username', 'about', 'avatar', 'cover', 'createdAt', 'followersCount', 'apId', 'apProfileId', 'isBot', 'isFollowedByUser', 'isFollowerOfUser', 'isBlockedByUser', 'isAdmin', 'isGlobalModerator', 'serverSoftware', 'serverSoftwareVersion', 'notificationStatus', 'reputationPoints', 'discoverable', 'indexable', 'title']; + protected const array USER_SMALL_RESPONSE_KEYS = ['userId', 'username', 'isBot', 'isFollowedByUser', 'isFollowerOfUser', 'isBlockedByUser', 'avatar', 'apId', 'apProfileId', 'createdAt', 'isAdmin', 'isGlobalModerator', 'discoverable', 'indexable', 'title']; + protected const array ENTRY_RESPONSE_KEYS = ['entryId', 'magazine', 'user', 'domain', 'title', 'url', 'image', 'body', 'lang', 'tags', 'badges', 'numComments', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'isOc', 'isAdult', 'isPinned', 'isLocked', 'createdAt', 'editedAt', 'lastActive', 'visibility', 'type', 'slug', 'apId', 'canAuthUserModerate', 'notificationStatus', 'bookmarks', 'crosspostedEntries', 'isAuthorModeratorInMagazine', 'poll']; + protected const array ENTRY_COMMENT_RESPONSE_KEYS = ['commentId', 'magazine', 'user', 'entryId', 'parentId', 'rootId', 'image', 'body', 'lang', 'isAdult', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'mentions', 'tags', 'createdAt', 'editedAt', 'lastActive', 'childCount', 'children', 'canAuthUserModerate', 'bookmarks', 'isAuthorModeratorInMagazine', 'poll']; + protected const array POST_RESPONSE_KEYS = ['postId', 'user', 'magazine', 'image', 'body', 'lang', 'isAdult', 'isPinned', 'isLocked', 'comments', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'tags', 'mentions', 'createdAt', 'editedAt', 'lastActive', 'slug', 'canAuthUserModerate', 'notificationStatus', 'bookmarks', 'isAuthorModeratorInMagazine', 'poll']; + protected const array POST_COMMENT_RESPONSE_KEYS = ['commentId', 'user', 'magazine', 'postId', 'parentId', 'rootId', 'image', 'body', 'lang', 'isAdult', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'mentions', 'tags', 'createdAt', 'editedAt', 'lastActive', 'childCount', 'children', 'canAuthUserModerate', 'bookmarks', 'isAuthorModeratorInMagazine', 'poll']; + protected const array BAN_RESPONSE_KEYS = ['banId', 'reason', 'expired', 'expiredAt', 'bannedUser', 'bannedBy', 'magazine']; + protected const array LOG_ENTRY_KEYS = ['type', 'createdAt', 'magazine', 'moderator', 'subject']; + protected const array MAGAZINE_RESPONSE_KEYS = ['magazineId', 'owner', 'icon', 'banner', 'name', 'title', 'description', 'rules', 'subscriptionsCount', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isAdult', 'isUserSubscribed', 'isBlockedByUser', 'tags', 'badges', 'moderators', 'apId', 'apProfileId', 'serverSoftware', 'serverSoftwareVersion', 'isPostingRestrictedToMods', 'localSubscribers', 'notificationStatus', 'discoverable', 'indexable']; + protected const array MAGAZINE_SMALL_RESPONSE_KEYS = ['magazineId', 'name', 'icon', 'banner', 'isUserSubscribed', 'isBlockedByUser', 'apId', 'apProfileId', 'discoverable', 'indexable']; + protected const array DOMAIN_RESPONSE_KEYS = ['domainId', 'name', 'entryCount', 'subscriptionsCount', 'isUserSubscribed', 'isBlockedByUser']; + protected const array POLL_KEYS = ['voterCount', 'endDate', 'currentUserHasVoted', 'choices']; + protected const array POLL_CHOICE_KEYS = ['name', 'voteCount', 'currentUserHasVoted']; + + protected const string KIBBY_PNG_URL_RESULT = 'a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png'; protected ArrayCollection $users; protected ArrayCollection $magazines; @@ -162,6 +165,7 @@ abstract class WebTestCase extends BaseWebTestCase protected EntryPageFactory $pageFactory; protected TestingApHttpClient $testingApHttpClient; protected TestingImageManager $imageManager; + protected PollManager $pollManager; protected CreateWrapper $createWrapper; protected LikeWrapper $likeWrapper; @@ -229,6 +233,7 @@ public function setUp(): void $this->instanceManager = $this->getService(InstanceManager::class); $this->activityJsonBuilder = $this->getService(ActivityJsonBuilder::class); $this->mentionManager = $this->getService(MentionManager::class); + $this->pollManager = $this->getService(PollManager::class); $this->security = $this->getService(Security::class); $this->magazineRepository = $this->getService(MagazineRepository::class); diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index ef0b84fa86..b218854d24 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -1226,3 +1226,17 @@ filter_lists_feeds_help: Filter words in threads, microblogs and comments in fee filter_lists_comments_help: Filter words while viewing a thread or microblog in the comment tree. filter_lists_profile_help: Filter words while viewing a users' profile in their content. expired: Expired +poll: Poll +poll_edit_voters_reset: All votes will be reset after editing! +poll_ends_at: Poll ends at +poll_choices: Choices +poll_is_multiple_choice: Is multiple choice +poll_vote: Vote +poll_show_results: Show results +poll_hide_results: Hide results +poll_ends: Poll ends +poll_ended: Poll ended +poll_voters: '{0}Persons|{1}Person|]1,Inf[ Persons' +poll_update_results: Update results +notification_title_poll_ended: A poll you have voted in has ended +notification_title_poll_edited: A poll you have voted in has been edited