diff options
author | Joas Schilling <213943+nickvergessen@users.noreply.github.com> | 2024-03-14 16:48:56 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-03-14 16:48:56 +0100 |
commit | 14d44eefdcd4bb875514bb4798e8ba82ef425aeb (patch) | |
tree | 24d05163f3b979a00de820e8b23219e90795fec6 | |
parent | 380f456c146f0b312f728b7b43e41650f4a8e1e1 (diff) | |
parent | 13a5f6790e902aa47c0d6d789a9a6fdbb3908536 (diff) |
Merge pull request #11653 from nextcloud/feat/11272/polls
feat(federation): Implement polls
-rw-r--r-- | docs/poll.md | 4 | ||||
-rw-r--r-- | lib/Controller/PollController.php | 29 | ||||
-rw-r--r-- | lib/Federation/Proxy/TalkV1/Controller/PollController.php | 181 | ||||
-rw-r--r-- | lib/Federation/Proxy/TalkV1/UserConverter.php | 17 | ||||
-rw-r--r-- | src/components/NewMessage/NewMessage.vue | 3 | ||||
-rw-r--r-- | src/components/NewMessage/NewMessageAttachments.vue | 1 | ||||
-rw-r--r-- | tests/integration/features/bootstrap/FeatureContext.php | 16 | ||||
-rw-r--r-- | tests/integration/features/federation/poll.feature | 109 |
8 files changed, 358 insertions, 2 deletions
diff --git a/docs/poll.md b/docs/poll.md index 8d41e105f..7ea130a3a 100644 --- a/docs/poll.md +++ b/docs/poll.md @@ -4,6 +4,7 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` ## Create a poll in a conversation +* Federation capability: `federation-v1` * Method: `POST` * Endpoint: `/poll/{token}` * Data: @@ -31,6 +32,7 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` ## Get state or result of a poll +* Federation capability: `federation-v1` * Method: `GET` * Endpoint: `/poll/{token}/{pollId}` @@ -48,6 +50,7 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` ## Vote on a poll +* Federation capability: `federation-v1` * Method: `POST` * Endpoint: `/poll/{token}/{pollId}` * Data: @@ -72,6 +75,7 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` ## Close a poll +* Federation capability: `federation-v1` * Method: `DELETE` * Endpoint: `/poll/{token}/{pollId}` diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index 762529e08..ae94b66ae 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -30,6 +30,7 @@ namespace OCA\Talk\Controller; use JsonException; use OCA\Talk\Chat\ChatManager; use OCA\Talk\Exceptions\WrongPermissionsException; +use OCA\Talk\Middleware\Attribute\FederationSupported; use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby; use OCA\Talk\Middleware\Attribute\RequireParticipant; use OCA\Talk\Middleware\Attribute\RequirePermission; @@ -80,12 +81,19 @@ class PollController extends AEnvironmentAwareController { * 201: Poll created successfully * 400: Creating poll is not possible */ + #[FederationSupported] #[PublicPage] #[RequireModeratorOrNoLobby] #[RequireParticipant] #[RequirePermission(permission: RequirePermission::CHAT)] #[RequireReadWriteConversation] public function createPoll(string $question, array $options, int $resultMode, int $maxVotes): DataResponse { + if ($this->room->getRemoteServer() !== '') { + /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */ + $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class); + return $proxy->createPoll($this->room, $this->participant, $question, $options, $resultMode, $maxVotes); + } + if ($this->room->getType() !== Room::TYPE_GROUP && $this->room->getType() !== Room::TYPE_PUBLIC) { return new DataResponse([], Http::STATUS_BAD_REQUEST); @@ -140,10 +148,17 @@ class PollController extends AEnvironmentAwareController { * 200: Poll returned * 404: Poll not found */ + #[FederationSupported] #[PublicPage] #[RequireModeratorOrNoLobby] #[RequireParticipant] public function showPoll(int $pollId): DataResponse { + if ($this->room->getRemoteServer() !== '') { + /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */ + $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class); + return $proxy->showPoll($this->room, $this->participant, $pollId); + } + try { $poll = $this->pollService->getPoll($this->room->getId(), $pollId); } catch (DoesNotExistException $e) { @@ -171,10 +186,17 @@ class PollController extends AEnvironmentAwareController { * 400: Voting is not possible * 404: Poll not found */ + #[FederationSupported] #[PublicPage] #[RequireModeratorOrNoLobby] #[RequireParticipant] public function votePoll(int $pollId, array $optionIds = []): DataResponse { + if ($this->room->getRemoteServer() !== '') { + /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */ + $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class); + return $proxy->votePoll($this->room, $this->participant, $pollId, $optionIds); + } + try { $poll = $this->pollService->getPoll($this->room->getId(), $pollId); } catch (\Exception $e) { @@ -225,10 +247,17 @@ class PollController extends AEnvironmentAwareController { * 403: Missing permissions to close poll * 404: Poll not found */ + #[FederationSupported] #[PublicPage] #[RequireModeratorOrNoLobby] #[RequireParticipant] public function closePoll(int $pollId): DataResponse { + if ($this->room->getRemoteServer() !== '') { + /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */ + $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class); + return $proxy->closePoll($this->room, $this->participant, $pollId); + } + try { $poll = $this->pollService->getPoll($this->room->getId(), $pollId); } catch (\Exception $e) { diff --git a/lib/Federation/Proxy/TalkV1/Controller/PollController.php b/lib/Federation/Proxy/TalkV1/Controller/PollController.php new file mode 100644 index 000000000..442b12677 --- /dev/null +++ b/lib/Federation/Proxy/TalkV1/Controller/PollController.php @@ -0,0 +1,181 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2024 Joas Schilling <coding@schilljs.com> + * + * @author Joas Schilling <coding@schilljs.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Talk\Federation\Proxy\TalkV1\Controller; + +use OCA\Talk\Exceptions\CannotReachRemoteException; +use OCA\Talk\Federation\Proxy\TalkV1\ProxyRequest; +use OCA\Talk\Federation\Proxy\TalkV1\UserConverter; +use OCA\Talk\Participant; +use OCA\Talk\ResponseDefinitions; +use OCA\Talk\Room; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; + +/** + * @psalm-import-type TalkPoll from ResponseDefinitions + */ +class PollController { + public function __construct( + protected ProxyRequest $proxy, + protected UserConverter $userConverter, + ) { + } + + /** + * @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array<empty>, array{}> + * @throws CannotReachRemoteException + * + * 200: Poll returned + * 404: Poll not found + * + * @see \OCA\Talk\Controller\PollController::showPoll() + */ + public function showPoll(Room $room, Participant $participant, int $pollId): DataResponse { + $proxy = $this->proxy->get( + $participant->getAttendee()->getInvitedCloudId(), + $participant->getAttendee()->getAccessToken(), + $room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken() . '/' . $pollId, + ); + + if ($proxy->getStatusCode() === Http::STATUS_NOT_FOUND) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + /** @var TalkPoll $data */ + $data = $this->proxy->getOCSData($proxy); + $data = $this->userConverter->convertPoll($room, $data); + + return new DataResponse($data); + } + + /** + * @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array<empty>, array{}> + * @throws CannotReachRemoteException + * + * 200: Voted successfully + * 400: Voting is not possible + * 404: Poll not found + * + * @see \OCA\Talk\Controller\PollController::votePoll() + */ + public function votePoll(Room $room, Participant $participant, int $pollId, array $optionIds): DataResponse { + $proxy = $this->proxy->post( + $participant->getAttendee()->getInvitedCloudId(), + $participant->getAttendee()->getAccessToken(), + $room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken() . '/' . $pollId, + ['optionIds' => $optionIds], + ); + + $statusCode = $proxy->getStatusCode(); + if ($statusCode !== Http::STATUS_OK) { + if (!in_array($statusCode, [ + Http::STATUS_BAD_REQUEST, + Http::STATUS_NOT_FOUND, + ], true)) { + $statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode); + } + return new DataResponse([], $statusCode); + } + + /** @var TalkPoll $data */ + $data = $this->proxy->getOCSData($proxy); + $data = $this->userConverter->convertPoll($room, $data); + + return new DataResponse($data); + } + + + /** + * @return DataResponse<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array<empty>, array{}> + * @throws CannotReachRemoteException + * + * 201: Poll created successfully + * 400: Creating poll is not possible + * + * @see \OCA\Talk\Controller\PollController::createPoll() + */ + public function createPoll(Room $room, Participant $participant, string $question, array $options, int $resultMode, int $maxVotes): DataResponse { + $proxy = $this->proxy->post( + $participant->getAttendee()->getInvitedCloudId(), + $participant->getAttendee()->getAccessToken(), + $room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken(), + [ + 'question' => $question, + 'options' => $options, + 'resultMode' => $resultMode, + 'maxVotes' => $maxVotes, + ], + ); + + if ($proxy->getStatusCode() === Http::STATUS_BAD_REQUEST) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + /** @var TalkPoll $data */ + $data = $this->proxy->getOCSData($proxy, [Http::STATUS_CREATED]); + $data = $this->userConverter->convertPoll($room, $data); + + return new DataResponse($data, Http::STATUS_CREATED); + } + + /** + * @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array<empty>, array{}> + * @throws CannotReachRemoteException + * + * 200: Poll closed successfully + * 400: Poll already closed + * 403: Missing permissions to close poll + * 404: Poll not found + * + * @see \OCA\Talk\Controller\PollController::closePoll() + */ + public function closePoll(Room $room, Participant $participant, int $pollId): DataResponse { + $proxy = $this->proxy->delete( + $participant->getAttendee()->getInvitedCloudId(), + $participant->getAttendee()->getAccessToken(), + $room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken() . '/' . $pollId, + ); + + $statusCode = $proxy->getStatusCode(); + if ($statusCode !== Http::STATUS_OK) { + if (!in_array($statusCode, [ + Http::STATUS_BAD_REQUEST, + Http::STATUS_FORBIDDEN, + Http::STATUS_NOT_FOUND, + ], true)) { + $statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode); + } + return new DataResponse([], $statusCode); + } + + /** @var TalkPoll $data */ + $data = $this->proxy->getOCSData($proxy); + $data = $this->userConverter->convertPoll($room, $data); + + return new DataResponse($data); + } +} diff --git a/lib/Federation/Proxy/TalkV1/UserConverter.php b/lib/Federation/Proxy/TalkV1/UserConverter.php index 44ad8dc24..a1af19fdb 100644 --- a/lib/Federation/Proxy/TalkV1/UserConverter.php +++ b/lib/Federation/Proxy/TalkV1/UserConverter.php @@ -34,6 +34,7 @@ use OCA\Talk\Service\ParticipantService; /** * @psalm-import-type TalkChatMessageWithParent from ResponseDefinitions + * @psalm-import-type TalkPoll from ResponseDefinitions * @psalm-import-type TalkReaction from ResponseDefinitions */ class UserConverter { @@ -154,6 +155,22 @@ class UserConverter { /** * @param Room $room + * @param TalkPoll $poll + * @return TalkPoll + */ + public function convertPoll(Room $room, array $poll): array { + $poll = $this->convertAttendee($room, $poll, 'actorType', 'actorId', 'actorDisplayName'); + if (isset($poll['details'])) { + $poll['details'] = array_map( + fn (array $vote): array => $this->convertAttendee($room, $vote, 'actorType', 'actorId', 'actorDisplayName'), + $poll['details'] + ); + } + return $poll; + } + + /** + * @param Room $room * @param TalkReaction[] $reactions * @return TalkReaction[] */ diff --git a/src/components/NewMessage/NewMessage.vue b/src/components/NewMessage/NewMessage.vue index 181cda13b..7c15dfcea 100644 --- a/src/components/NewMessage/NewMessage.vue +++ b/src/components/NewMessage/NewMessage.vue @@ -420,7 +420,6 @@ export default { canCreatePoll() { return !this.isOneToOne && !this.noChatPermission && this.conversation.type !== CONVERSATION.TYPE.NOTE_TO_SELF - && (!supportFederationV1 || !this.conversation.remoteServer) }, currentConversationIsJoined() { @@ -459,7 +458,7 @@ export default { }, showAttachmentsMenu() { - return this.canShareFiles && !this.broadcast && !this.upload && !this.messageToEdit + return (this.canUploadFiles || this.canShareFiles || this.canCreatePoll) && !this.broadcast && !this.upload && !this.messageToEdit }, showAudioRecorder() { diff --git a/src/components/NewMessage/NewMessageAttachments.vue b/src/components/NewMessage/NewMessageAttachments.vue index 778e25fbf..654774b46 100644 --- a/src/components/NewMessage/NewMessageAttachments.vue +++ b/src/components/NewMessage/NewMessageAttachments.vue @@ -26,6 +26,7 @@ :container="container" :boundaries-element="boundariesElement" :disabled="disabled" + :force-menu="true" :aria-label="t('spreed', 'Share files to the conversation')" :aria-haspopup="true"> <template #icon> diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 5efd6bf8a..afc431ed9 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -2216,6 +2216,22 @@ class FeatureContext implements Context, SnippetAcceptingContext { $expected['status'] = 1; } + if (str_ends_with($expected['actorId'], '@{$BASE_URL}')) { + $expected['actorId'] = str_replace('{$BASE_URL}', rtrim($this->baseUrl, '/'), $expected['actorId']); + } + if (str_ends_with($expected['actorId'], '@{$REMOTE_URL}')) { + $expected['actorId'] = str_replace('{$REMOTE_URL}', rtrim($this->baseRemoteUrl, '/'), $expected['actorId']); + } + + if (isset($expected['details'])) { + if (str_contains($expected['details'], '@{$BASE_URL}')) { + $expected['details'] = str_replace('{$BASE_URL}', rtrim($this->baseUrl, '/'), $expected['details']); + } + if (str_contains($expected['details'], '@{$REMOTE_URL}')) { + $expected['details'] = str_replace('{$REMOTE_URL}', rtrim($this->baseRemoteUrl, '/'), $expected['details']); + } + } + if ($expected['votedSelf'] === 'not voted') { $expected['votedSelf'] = []; } else { diff --git a/tests/integration/features/federation/poll.feature b/tests/integration/features/federation/poll.feature new file mode 100644 index 000000000..fe2efdbef --- /dev/null +++ b/tests/integration/features/federation/poll.feature @@ -0,0 +1,109 @@ +Feature: chat-2/poll + Background: + Given user "participant1" exists + Given user "participant2" exists + + Scenario: Create a public poll without max votes limit + Given the following "spreed" app config is set + | federation_enabled | yes | + Given user "participant1" creates room "room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" adds federated_user "participant2" to room "room" with 200 (v4) + And user "participant2" has the following invitations (v1) + | remoteServerUrl | remoteToken | state | inviterCloudId | inviterDisplayName | + | LOCAL | room | 0 | participant1@http://localhost:8080 | participant1-displayname | + And user "participant2" accepts invite to room "room" of server "LOCAL" with 200 (v1) + | id | name | type | remoteServer | remoteToken | + | room | room | 2 | LOCAL | room | + Then user "participant2" is participant of the following rooms (v4) + | id | type | + | room | 2 | + When user "participant2" creates a poll in room "LOCAL::room" with 201 + | question | What is the question? | + | options | ["Where are you?","How much is the fish?"] | + | resultMode | public | + | maxVotes | unlimited | + Then user "participant1" sees the following messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | room | federated_users | participant2@{$REMOTE_URL} | participant2-displayname | {object} | {"actor":{"type":"user","id":"participant2","name":"participant2-displayname","server":"http:\/\/localhost:8180"},"object":{"type":"talk-poll","id":POLL_ID(What is the question?),"name":"What is the question?"}} | + Then user "participant2" sees poll "What is the question?" in room "LOCAL::room" with 200 + | id | POLL_ID(What is the question?) | + | question | What is the question? | + | options | ["Where are you?","How much is the fish?"] | + | votes | [] | + | numVoters | 0 | + | resultMode | public | + | maxVotes | unlimited | + | actorType | users | + | actorId | participant2 | + | actorDisplayName | participant2-displayname | + | status | open | + | votedSelf | not voted | + Then user "participant1" votes for options "[1]" on poll "What is the question?" in room "room" with 200 + | id | POLL_ID(What is the question?) | + | question | What is the question? | + | options | ["Where are you?","How much is the fish?"] | + | votes | {"option-1":1} | + | numVoters | 1 | + | resultMode | public | + | maxVotes | unlimited | + | actorType | federated_users | + | actorId | participant2@{$REMOTE_URL} | + | actorDisplayName | participant2-displayname | + | status | open | + | votedSelf | [1] | + Then user "participant1" sees poll "What is the question?" in room "room" with 200 + | id | POLL_ID(What is the question?) | + | question | What is the question? | + | options | ["Where are you?","How much is the fish?"] | + | votes | {"option-1":1} |