diff --git a/.drone.yml b/.drone.yml index d25ce08230f..78fc3fea94f 100644 --- a/.drone.yml +++ b/.drone.yml @@ -176,6 +176,43 @@ trigger: - pull_request - push +--- +kind: pipeline +name: int-sqlite-reaction + +steps: + - name: integration-reaction + image: ghcr.io/nextcloud/continuous-integration-php8.0:latest + environment: + APP_NAME: spreed + CORE_BRANCH: master + GUESTS_BRANCH: master + DATABASEHOST: sqlite + commands: + - bash tests/drone-run-integration-tests.sh || exit 0 + - wget https://raw.githubusercontent.com/nextcloud/travis_ci/master/before_install.sh + - bash ./before_install.sh $APP_NAME $CORE_BRANCH $DATABASEHOST + - cd ../server + - git clone --depth 1 -b "$GUESTS_BRANCH" https://github.com/nextcloud/guests apps/guests + - ./occ app:enable $APP_NAME + - cd apps/$APP_NAME + + # Run integration tests + - cd tests/integration/ + - bash run.sh features/reaction + +services: + - name: cache + image: ghcr.io/nextcloud/continuous-integration-redis:latest + +trigger: + branch: + - master + - stable* + event: + - pull_request + - push + --- kind: pipeline name: int-sqlite-sharing @@ -475,6 +512,53 @@ trigger: # - pull_request - push +--- +kind: pipeline +name: int-mysql-reaction + +steps: + - name: integration-reaction + image: ghcr.io/nextcloud/continuous-integration-php8.0:latest + environment: + APP_NAME: spreed + CORE_BRANCH: master + GUESTS_BRANCH: master + DATABASEHOST: mysql + commands: + - bash tests/drone-run-integration-tests.sh || exit 0 + - wget https://raw.githubusercontent.com/nextcloud/travis_ci/master/before_install.sh + - bash ./before_install.sh $APP_NAME $CORE_BRANCH $DATABASEHOST + - cd ../server + - git clone --depth 1 -b "$GUESTS_BRANCH" https://github.com/nextcloud/guests apps/guests + - ./occ app:enable $APP_NAME + - cd apps/$APP_NAME + + # Run integration tests + - cd tests/integration/ + - bash run.sh features/reaction + +services: + - name: cache + image: ghcr.io/nextcloud/continuous-integration-redis:latest + - name: mysql + image: ghcr.io/nextcloud/continuous-integration-mariadb-10.4:10.4 + environment: + MYSQL_ROOT_PASSWORD: owncloud + MYSQL_USER: oc_autotest + MYSQL_PASSWORD: owncloud + MYSQL_DATABASE: oc_autotest + command: [ "--innodb_large_prefix=true", "--innodb_file_format=barracuda", "--innodb_file_per_table=true" ] + tmpfs: + - /var/lib/mysql + +trigger: + branch: + - master + - stable* + event: +# - pull_request + - push + --- kind: pipeline name: int-mysql-sharing @@ -789,6 +873,52 @@ trigger: # - pull_request - push +--- +kind: pipeline +name: int-pgsql-reaction + +steps: + - name: integration-reaction + image: ghcr.io/nextcloud/continuous-integration-php8.0:latest + environment: + APP_NAME: spreed + CORE_BRANCH: master + GUESTS_BRANCH: master + DATABASEHOST: pgsql + commands: + - bash tests/drone-run-integration-tests.sh || exit 0 + - wget https://raw.githubusercontent.com/nextcloud/travis_ci/master/before_install.sh + - bash ./before_install.sh $APP_NAME $CORE_BRANCH $DATABASEHOST + - cd ../server + - git clone --depth 1 -b "$GUESTS_BRANCH" https://github.com/nextcloud/guests apps/guests + - ./occ app:enable $APP_NAME + - cd apps/$APP_NAME + + # Run integration tests + - cd tests/integration/ + - bash run.sh features/reaction + +services: + - name: cache + image: ghcr.io/nextcloud/continuous-integration-redis:latest + - name: pgsql + image: ghcr.io/nextcloud/continuous-integration-postgres-13:postgres-13 + environment: + POSTGRES_USER: oc_autotest + POSTGRES_DB: oc_autotest_dummy + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_PASSWORD: + tmpfs: + - /var/lib/postgresql/data + +trigger: + branch: + - master + - stable* + event: +# - pull_request + - push + --- kind: pipeline name: int-pgsql-sharing diff --git a/appinfo/routes.php b/appinfo/routes.php index e888b7006b5..fde29617ec7 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -36,6 +36,7 @@ include(__DIR__ . '/routes/routesMatterbridgeSettingsController.php'), include(__DIR__ . '/routes/routesPageController.php'), include(__DIR__ . '/routes/routesPublicShareAuthController.php'), + include(__DIR__ . '/routes/routesReactionController.php'), include(__DIR__ . '/routes/routesRoomController.php'), include(__DIR__ . '/routes/routesSettingsController.php'), include(__DIR__ . '/routes/routesSignalingController.php'), diff --git a/appinfo/routes/routesReactionController.php b/appinfo/routes/routesReactionController.php new file mode 100644 index 00000000000..bed62fb1515 --- /dev/null +++ b/appinfo/routes/routesReactionController.php @@ -0,0 +1,33 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +return [ + 'ocs' => [ + ['name' => 'Reaction#react', 'url' => '/api/{apiVersion}/reaction/{token}/{messageId}', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v1', + 'token' => '^[a-z0-9]{4,30}$', + ]], + ], +]; diff --git a/docs/chat.md b/docs/chat.md index ff03979efdd..dd496a7b4b0 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -50,6 +50,7 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` `message` | string | Message string with placeholders (see [Rich Object String](https://github.com/nextcloud/server/issues/1706)) `messageParameters` | array | Message parameters for `message` (see [Rich Object String](https://github.com/nextcloud/server/issues/1706)) `parent` | array | **Optional:** See `Parent data` below + `reactions` | array | **Optional:** An array map with relation between reaction emoji and total of reactions with this emoji #### Parent data @@ -324,3 +325,4 @@ See [OCP\RichObjectStrings\Definitions](https://github.com/nextcloud/server/blob * `matterbridge_config_removed` - {actor} removed the Matterbridge configuration * `matterbridge_config_enabled` - {actor} started Matterbridge * `matterbridge_config_disabled` - {actor} stopped Matterbridge + diff --git a/docs/index.md b/docs/index.md index 0e19b823f8a..3e1dec1dc83 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,6 +16,7 @@ * [Participant API](participant.md) * [Call API](call.md) * [Chat API](chat.md) +* [Reaction API](reaction.md) * [Webinar API](webinar.md) * [Internal Signaling API](internal-signaling.md) * [Standalone Signaling API](https://nextcloud-spreed-signaling.readthedocs.io/en/latest/) diff --git a/docs/reaction.md b/docs/reaction.md new file mode 100644 index 00000000000..54a58b75a0e --- /dev/null +++ b/docs/reaction.md @@ -0,0 +1,20 @@ +# Reaction API + +Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` + +## React to a message + +* Method: `POST` +* Endpoint: `/chat/{token}/{messageId}` +* Data: + + field | type | Description + ---|---|--- + `reaction` | string | the reaction emoji + +* Response: + - Status code: + + `201 Created` + + `400 Bad Request` In case of any other error + + `404 Not Found` When the conversation or message to react could not be found for the participant + + `409 Conflict` User already did this reaction to this message diff --git a/lib/Chat/Parser/Listener.php b/lib/Chat/Parser/Listener.php index 83e26f673ee..593d9bd6e96 100644 --- a/lib/Chat/Parser/Listener.php +++ b/lib/Chat/Parser/Listener.php @@ -97,6 +97,18 @@ public static function register(IEventDispatcher $dispatcher): void { } }); + $dispatcher->addListener(MessageParser::EVENT_MESSAGE_PARSE, static function (ChatMessageEvent $event) { + $chatMessage = $event->getMessage(); + + if ($chatMessage->getMessageType() !== 'reaction') { + return; + } + + /** @var ReactionParser $parser */ + $parser = \OC::$server->get(ReactionParser::class); + $parser->parseMessage($chatMessage); + }); + $dispatcher->addListener(MessageParser::EVENT_MESSAGE_PARSE, static function (ChatMessageEvent $event) { $chatMessage = $event->getMessage(); diff --git a/lib/Chat/Parser/ReactionParser.php b/lib/Chat/Parser/ReactionParser.php new file mode 100644 index 00000000000..3ec9d88017e --- /dev/null +++ b/lib/Chat/Parser/ReactionParser.php @@ -0,0 +1,43 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +namespace OCA\Talk\Chat\Parser; + +use OCA\Talk\Model\Message; + +class ReactionParser { + /** + * @param Message $message + * @throws \OutOfBoundsException + */ + public function parseMessage(Message $message): void { + $comment = $message->getComment(); + if (!in_array($comment->getVerb(), ['reaction'])) { + throw new \OutOfBoundsException('Not a reaction'); + } + $message->setMessageType('system'); + $message->setMessage($message->getMessage(), [], $comment->getVerb()); + } +} diff --git a/lib/Chat/ReactionManager.php b/lib/Chat/ReactionManager.php new file mode 100644 index 00000000000..4ea721a7d51 --- /dev/null +++ b/lib/Chat/ReactionManager.php @@ -0,0 +1,54 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +namespace OCA\Talk\Chat; + +use OCA\Talk\Participant; +use OCA\Talk\Room; +use OCP\Comments\IComment; +use OCP\Comments\ICommentsManager; + +class ReactionManager { + /** @var ICommentsManager|CommentsManager */ + private $commentsManager; + + public function __construct(CommentsManager $commentsManager) { + $this->commentsManager = $commentsManager; + } + + public function addReactionMessage(Room $chat, Participant $participant, int $messageId, string $reaction): IComment { + $comment = $this->commentsManager->create( + $participant->getAttendee()->getActorType(), + $participant->getAttendee()->getActorId(), + 'chat', + (string) $chat->getId() + ); + $comment->setParentId((string) $messageId); + $comment->setMessage($reaction); + $comment->setVerb('reaction'); + $this->commentsManager->save($comment); + return $comment; + } +} diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php index 312965dc3bf..e97bc4fc6c5 100644 --- a/lib/Controller/ChatController.php +++ b/lib/Controller/ChatController.php @@ -27,6 +27,7 @@ use OCA\Talk\Chat\AutoComplete\SearchPlugin; use OCA\Talk\Chat\AutoComplete\Sorter; use OCA\Talk\Chat\ChatManager; +use OCA\Talk\Chat\CommentsManager; use OCA\Talk\Chat\MessageParser; use OCA\Talk\GuestManager; use OCA\Talk\MatterbridgeManager; @@ -68,6 +69,9 @@ class ChatController extends AEnvironmentAwareController { /** @var IAppManager */ private $appManager; + /** @var CommentsManager */ + private $commentsManager; + /** @var ChatManager */ private $chatManager; @@ -121,6 +125,7 @@ public function __construct(string $appName, IRequest $request, IUserManager $userManager, IAppManager $appManager, + CommentsManager $commentsManager, ChatManager $chatManager, ParticipantService $participantService, SessionService $sessionService, @@ -141,6 +146,7 @@ public function __construct(string $appName, $this->userId = $UserId; $this->userManager = $userManager; $this->appManager = $appManager; + $this->commentsManager = $commentsManager; $this->chatManager = $chatManager; $this->participantService = $participantService; $this->sessionService = $sessionService; diff --git a/lib/Controller/ReactionController.php b/lib/Controller/ReactionController.php new file mode 100644 index 00000000000..0343ff061dc --- /dev/null +++ b/lib/Controller/ReactionController.php @@ -0,0 +1,95 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +namespace OCA\Talk\Controller; + +use OCA\Talk\Chat\ChatManager; +use OCA\Talk\Chat\CommentsManager; +use OCA\Talk\Chat\ReactionManager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\Comments\NotFoundException; +use OCP\IRequest; + +class ReactionController extends AEnvironmentAwareController { + /** @var CommentsManager */ + private $commentsManager; + /** @var ChatManager */ + private $chatManager; + /** @var ReactionManager */ + private $reactionManager; + + public function __construct(string $appName, + IRequest $request, + CommentsManager $commentsManager, + ChatManager $chatManager, + ReactionManager $reactionManager) { + parent::__construct($appName, $request); + + $this->commentsManager = $commentsManager; + $this->chatManager = $chatManager; + $this->reactionManager = $reactionManager; + } + + /** + * @NoAdminRequired + * @RequireParticipant + * @RequireReadWriteConversation + * @RequireModeratorOrNoLobby + * + * @param int $messageId for reaction + * @param string $reaction the reaction emoji + * @return DataResponse + */ + public function react(int $messageId, string $reaction): DataResponse { + $participant = $this->getParticipant(); + try { + // Verify that messageId is part of the room + $this->chatManager->getComment($this->getRoom(), (string) $messageId); + } catch (NotFoundException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + try { + // Verify already reacted whith the same reaction + $this->commentsManager->getReactionComment( + $messageId, + $participant->getAttendee()->getActorType(), + $participant->getAttendee()->getActorId(), + $reaction + ); + return new DataResponse([], Http::STATUS_CONFLICT); + } catch (NotFoundException $e) { + } + + try { + $this->reactionManager->addReactionMessage($this->getRoom(), $participant, $messageId, $reaction); + } catch (\Exception $e) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse([], Http::STATUS_CREATED); + } +} diff --git a/lib/Model/Message.php b/lib/Model/Message.php index 30f1fa6d935..118adad728d 100644 --- a/lib/Model/Message.php +++ b/lib/Model/Message.php @@ -163,6 +163,7 @@ public function isReplyable(): bool { return $this->getMessageType() !== 'system' && $this->getMessageType() !== 'command' && $this->getMessageType() !== 'comment_deleted' && + $this->getMessageType() !== 'reaction' && \in_array($this->getActorType(), [Attendee::ACTOR_USERS, Attendee::ACTOR_GUESTS]); } @@ -180,6 +181,7 @@ public function toArray(): array { 'messageType' => $this->getMessageType(), 'isReplyable' => $this->isReplyable(), 'referenceId' => (string) $this->getComment()->getReferenceId(), + 'reactions' => $this->getComment()->getReactions(), ]; if ($this->getMessageType() === 'comment_deleted') { diff --git a/mkdocs.yml b/mkdocs.yml index 28860dae356..d83858d6430 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,6 +26,7 @@ nav: - 'Participants management': 'participant.md' - 'Call management': 'call.md' - 'Chat management': 'chat.md' + - 'Reaction management': 'reaction.md' - 'Webinar management': 'webinar.md' - 'Settings': 'settings.md' - 'Integration by other apps': 'integration.md' diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 58814dd1519..aaf63c134c2 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -1559,6 +1559,7 @@ protected function compareDataResponse(TableNode $formData = null) { } $includeParents = in_array('parentMessage', $formData->getRow(0), true); $includeReferenceId = in_array('referenceId', $formData->getRow(0), true); + $includeReactions = in_array('reactions', $formData->getRow(0), true); $count = count($formData->getHash()); Assert::assertCount($count, $messages, 'Message count does not match'); @@ -1567,7 +1568,7 @@ protected function compareDataResponse(TableNode $formData = null) { $messages[$i]['messageParameters'] = 'IGNORE'; } } - Assert::assertEquals($formData->getHash(), array_map(function ($message) use ($includeParents, $includeReferenceId) { + Assert::assertEquals($formData->getHash(), array_map(function ($message) use ($includeParents, $includeReferenceId, $includeReactions) { $data = [ 'room' => self::$tokenToIdentifier[$message['token']], 'actorType' => $message['actorType'], @@ -1584,6 +1585,9 @@ protected function compareDataResponse(TableNode $formData = null) { if ($includeReferenceId) { $data['referenceId'] = $message['referenceId']; } + if ($includeReactions) { + $data['reactions'] = json_encode($message['reactions'], JSON_UNESCAPED_UNICODE); + } return $data; }, $messages)); } @@ -2079,6 +2083,19 @@ public function removeUserFromGroup($user, $group) { $this->setCurrentUser($currentUser); } + /** + * @Given /^user "([^"]*)" react with "([^"]*)" on message "([^"]*)" to room "([^"]*)" with (\d+)(?: \((v1)\))?$/ + */ + public function userReactWithOnMessageToRoomWith(string $user, string $reaction, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { + $token = self::$identifierToToken[$identifier]; + $messageId = self::$messages[$message]; + $this->setCurrentUser($user); + $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/reaction/' . $token . '/' . $messageId, [ + 'reaction' => $reaction + ]); + $this->assertStatusCode($this->response, $statusCode); + } + /* * Requests */ diff --git a/tests/integration/features/reaction/react.feature b/tests/integration/features/reaction/react.feature new file mode 100644 index 00000000000..a03412d082b --- /dev/null +++ b/tests/integration/features/reaction/react.feature @@ -0,0 +1,31 @@ +Feature: reaction/react + Background: + Given user "participant1" exists + Given user "participant2" exists + + Scenario: React to message with success + Given user "participant1" creates room "room" (v4) + | roomType | 3 | + | roomName | room | + And user "participant1" adds user "participant2" to room "room" with 200 (v4) + And user "participant1" sends message "Message 1" to room "room" with 201 + And user "participant2" react with "👍" on message "Message 1" to room "room" with 201 + Then user "participant1" sees the following messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | reactions | + | room | users | participant1 | participant1-displayname | Message 1 | [] | {"👍":1} | + And user "participant1" react with "👍" on message "Message 1" to room "room" with 201 + Then user "participant1" sees the following messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | reactions | + | room | users | participant1 | participant1-displayname | Message 1 | [] | {"👍":2} | + + Scenario: React two times to same message with the same reaction + Given user "participant1" creates room "room" (v4) + | roomType | 3 | + | roomName | room | + And user "participant1" adds user "participant2" to room "room" with 200 (v4) + And user "participant1" sends message "Message 1" to room "room" with 201 + And user "participant2" react with "👍" on message "Message 1" to room "room" with 201 + And user "participant2" react with "👍" on message "Message 1" to room "room" with 409 + Then user "participant1" sees the following messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | reactions | + | room | users | participant1 | participant1-displayname | Message 1 | [] | {"👍":1} | diff --git a/tests/php/Controller/ChatControllerTest.php b/tests/php/Controller/ChatControllerTest.php index 92473c7fd70..b2343610bf0 100644 --- a/tests/php/Controller/ChatControllerTest.php +++ b/tests/php/Controller/ChatControllerTest.php @@ -25,6 +25,7 @@ use OCA\Talk\Chat\AutoComplete\SearchPlugin; use OCA\Talk\Chat\ChatManager; +use OCA\Talk\Chat\CommentsManager; use OCA\Talk\Chat\MessageParser; use OCA\Talk\Controller\ChatController; use OCA\Talk\GuestManager; @@ -62,6 +63,8 @@ class ChatControllerTest extends TestCase { protected $userManager; /** @var IAppManager|MockObject */ private $appManager; + /** @var CommentsManager|MockObject */ + protected $commentsManager; /** @var ChatManager|MockObject */ protected $chatManager; /** @var ParticipantService|MockObject */ @@ -108,6 +111,7 @@ public function setUp(): void { $this->userId = 'testUser'; $this->userManager = $this->createMock(IUserManager::class); $this->appManager = $this->createMock(IAppManager::class); + $this->commentsManager = $this->createMock(CommentsManager::class); $this->chatManager = $this->createMock(ChatManager::class); $this->participantService = $this->createMock(ParticipantService::class); $this->sessionService = $this->createMock(SessionService::class); @@ -143,6 +147,7 @@ private function recreateChatController() { $this->createMock(IRequest::class), $this->userManager, $this->appManager, + $this->commentsManager, $this->chatManager, $this->participantService, $this->sessionService,