Browse Source

Merge pull request #6730 from nextcloud/feature/1920/api-reactions-to-message

Api reactions to message
pull/6912/head
Joas Schilling 4 years ago
committed by GitHub
parent
commit
4080f6d090
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 130
      .drone.yml
  2. 1
      appinfo/routes.php
  3. 41
      appinfo/routes/routesReactionController.php
  4. 1
      docs/capabilities.md
  5. 2
      docs/chat.md
  6. 1
      docs/index.md
  7. 66
      docs/reaction.md
  8. 9
      lib/Capabilities.php
  9. 44
      lib/Chat/Notifier.php
  10. 12
      lib/Chat/Parser/Listener.php
  11. 52
      lib/Chat/Parser/ReactionParser.php
  12. 153
      lib/Chat/ReactionManager.php
  13. 130
      lib/Controller/ReactionController.php
  14. 29
      lib/Exceptions/ReactionAlreadyExistsException.php
  15. 29
      lib/Exceptions/ReactionNotSupportedException.php
  16. 29
      lib/Exceptions/ReactionOutOfContextException.php
  17. 2
      lib/Manager.php
  18. 2
      lib/Model/Message.php
  19. 23
      lib/Notification/Notifier.php
  20. 1
      mkdocs.yml
  21. 63
      tests/integration/features/bootstrap/FeatureContext.php
  22. 68
      tests/integration/features/reaction/react.feature
  23. 11
      tests/php/CapabilitiesTest.php
  24. 50
      tests/php/Chat/NotifierTest.php

130
.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

1
appinfo/routes.php

@ -36,6 +36,7 @@ return array_merge_recursive(
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'),

41
appinfo/routes/routesReactionController.php

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021 Vitor Mattos <vitor@php.rio>
*
* @author Vitor Mattos <vitor@php.rio>
*
* @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/>.
*
*/
return [
'ocs' => [
['name' => 'Reaction#react', 'url' => '/api/{apiVersion}/reaction/{token}/{messageId}', 'verb' => 'POST', 'requirements' => [
'apiVersion' => 'v1',
'token' => '^[a-z0-9]{4,30}$',
]],
['name' => 'Reaction#delete', 'url' => '/api/{apiVersion}/reaction/{token}/{messageId}', 'verb' => 'DELETE', 'requirements' => [
'apiVersion' => 'v1',
'token' => '^[a-z0-9]{4,30}$',
]],
['name' => 'Reaction#getReactions', 'url' => '/api/{apiVersion}/reaction/{token}/{messageId}', 'verb' => 'GET', 'requirements' => [
'apiVersion' => 'v1',
'token' => '^[a-z0-9]{4,30}$',
]],
],
];

1
docs/capabilities.md

@ -89,3 +89,4 @@ title: Capabilities
## 14
* `chat-unread` - Whether the API to mark a conversation as unread is available
* `reactions` - Api reactions to chat message

2
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 count 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

1
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/)

66
docs/reaction.md

@ -0,0 +1,66 @@
# Reaction API
Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1`
## React to a message
* Required capability: `reactions`
* Method: `POST`
* Endpoint: `/reaction/{token}/{messageId}`
* Data:
field | type | Description
---|---|---
`reaction` | string | the reaction emoji
* Response:
- Status code:
+ `200 OK` Reaction already exists
+ `201 Created`
+ `400 Bad Request` In case of no reaction support, message out of reactions context or 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
## Delete a reaction
* Required capability: `reactions`
* Method: `DELETE`
* Endpoint: `/reaction/{token}/{messageId}`
* Data:
field | type | Description
---|---|---
`reaction` | string | the reaction emoji
* Response:
- Status code:
+ `201 Created`
+ `400 Bad Request` In case of no reaction support, message out of reactions context or any other error
+ `404 Not Found` When the conversation or message to react or reaction could not be found for the participant
## Retrieve reactions of a message by type
* Required capability: `reactions`
* Method: `GET`
* Endpoint: `/reaction/{token}/{messageId}`
* Data:
field | type | Description
---|---|---
`reaction` | string | **Optional:** the reaction emoji
* Response:
- Status code:
+ `200 OK`
+ `400 Bad Request` In case of no reaction support, message out of reactions context or any other error
+ `404 Not Found` When the conversation or message to react could not be found for the participant
- Data:
Array with data of reactions:
field | type | Description
---|---|---
`actorType` | string | `guests` or `users`
`actorId` | string | Actor id of the reacting participant
`actorDisplayName` | string | Display name of the reaction author
`timestamp` | int | Timestamp in seconds and UTC time zone

9
lib/Capabilities.php

@ -27,6 +27,7 @@ namespace OCA\Talk;
use OCA\Talk\Chat\ChatManager;
use OCP\Capabilities\IPublicCapability;
use OCP\Comments\ICommentsManager;
use OCP\IConfig;
use OCP\IUser;
use OCP\IUserSession;
@ -37,14 +38,18 @@ class Capabilities implements IPublicCapability {
protected $serverConfig;
/** @var Config */
protected $talkConfig;
/** @var ICommentsManager */
protected $commentsManager;
/** @var IUserSession */
protected $userSession;
public function __construct(IConfig $serverConfig,
Config $talkConfig,
ICommentsManager $commentsManager,
IUserSession $userSession) {
$this->serverConfig = $serverConfig;
$this->talkConfig = $talkConfig;
$this->commentsManager = $commentsManager;
$this->userSession = $userSession;
}
@ -115,6 +120,10 @@ class Capabilities implements IPublicCapability {
],
];
if ($this->commentsManager->supportReactions()) {
$capabilities['features'][] = 'reactions';
}
if ($user instanceof IUser) {
$capabilities['config']['attachments']['folder'] = $this->talkConfig->getAttachmentFolder($user->getUID());
$capabilities['config']['chat']['read-privacy'] = $this->talkConfig->getUserReadPrivacy($user->getUID());

44
lib/Chat/Notifier.php

@ -200,10 +200,11 @@ class Notifier {
* @param Room $chat
* @param IComment $comment
* @param IComment $replyTo
* @param string $subject
* @return array[] Actor that was replied to
* @psalm-return array<int, array{id: string, type: string}>
*/
public function notifyReplyToAuthor(Room $chat, IComment $comment, IComment $replyTo): array {
public function notifyReplyToAuthor(Room $chat, IComment $comment, IComment $replyTo, string $subject = 'reply'): array {
if ($replyTo->getActorType() !== Attendee::ACTOR_USERS) {
// No reply notification when the replyTo-author was not a user
return [];
@ -213,7 +214,7 @@ class Notifier {
return [];
}
$notification = $this->createNotification($chat, $comment, 'reply');
$notification = $this->createNotification($chat, $comment, $subject);
$notification->setUser($replyTo->getActorId());
$this->notificationManager->notify($notification);
@ -266,6 +267,34 @@ class Notifier {
}
}
public function notifyReacted(Room $chat, IComment $comment, IComment $reaction): void {
if ($comment->getActorType() !== Attendee::ACTOR_USERS) {
return;
}
if ($comment->getActorType() === $reaction->getActorType() && $comment->getActorId() === $reaction->getActorId()) {
return;
}
$participant = $chat->getParticipant($comment->getActorId(), false);
$notificationLevel = $participant->getAttendee()->getNotificationLevel();
if ($notificationLevel === Participant::NOTIFY_DEFAULT) {
if ($chat->getType() === Room::TYPE_ONE_TO_ONE) {
$notificationLevel = Participant::NOTIFY_ALWAYS;
} else {
$notificationLevel = $this->getDefaultGroupNotification();
}
}
if ($notificationLevel === Participant::NOTIFY_ALWAYS) {
$notification = $this->createNotification($chat, $comment, 'reaction', [
'reaction' => $reaction->getMessage(),
]);
$notification->setUser($comment->getActorId());
$this->notificationManager->notify($notification);
}
}
/**
* Removes all the pending notifications for the room with the given ID.
*
@ -363,17 +392,18 @@ class Notifier {
* @param Room $chat
* @param IComment $comment
* @param string $subject
* @param array $subjectData
* @return INotification
*/
private function createNotification(Room $chat, IComment $comment, string $subject): INotification {
private function createNotification(Room $chat, IComment $comment, string $subject, array $subjectData = []): INotification {
$subjectData['userType'] = $comment->getActorType();
$subjectData['userId'] = $comment->getActorId();
$notification = $this->notificationManager->createNotification();
$notification
->setApp('spreed')
->setObject('chat', $chat->getToken())
->setSubject($subject, [
'userType' => $comment->getActorType(),
'userId' => $comment->getActorId(),
])
->setSubject($subject, $subjectData)
->setMessage($comment->getVerb(), [
'commentId' => $comment->getId(),
])

12
lib/Chat/Parser/Listener.php

@ -97,6 +97,18 @@ class Listener {
}
});
$dispatcher->addListener(MessageParser::EVENT_MESSAGE_PARSE, static function (ChatMessageEvent $event) {
$chatMessage = $event->getMessage();
if ($chatMessage->getMessageType() !== 'reaction' && $chatMessage->getMessageType() !== 'reaction_deleted') {
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();

52
lib/Chat/Parser/ReactionParser.php

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021 Vitor Mattos <vitor@php.rio>
*
* @author Vitor Mattos <vitor@php.rio>
*
* @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\Chat\Parser;
use OCA\Talk\Model\Message;
use OCP\IL10N;
class ReactionParser {
/** @var IL10N|null */
private $l;
/**
* @param Message $message
* @throws \OutOfBoundsException
*/
public function parseMessage(Message $message): void {
$comment = $message->getComment();
if (!in_array($comment->getVerb(), ['reaction', 'reaction_deleted'])) {
throw new \OutOfBoundsException('Not a reaction');
}
$this->l = $message->getL10n();
$message->setMessageType('system');
if ($comment->getVerb() === 'reaction_deleted') {
// This message is necessary to make compatible with old clients
$message->setMessage($this->l->t('Message deleted by author'), [], $comment->getVerb());
} else {
$message->setMessage($message->getMessage(), [], $comment->getVerb());
}
}
}

153
lib/Chat/ReactionManager.php

@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021 Vitor Mattos <vitor@php.rio>
*
* @author Vitor Mattos <vitor@php.rio>
*
* @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\Chat;
use OCA\Talk\Exceptions\ReactionAlreadyExistsException;
use OCA\Talk\Exceptions\ReactionNotSupportedException;
use OCA\Talk\Exceptions\ReactionOutOfContextException;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Comments\IComment;
use OCP\Comments\ICommentsManager;
use OCP\Comments\NotFoundException;
use OCP\IL10N;
class ReactionManager {
/** @var ICommentsManager|CommentsManager */
private $commentsManager;
/** @var IL10N */
private $l;
/** @var MessageParser */
private $messageParser;
/** @var Notifier */
private $notifier;
/** @var ITimeFactory */
protected $timeFactory;
public function __construct(CommentsManager $commentsManager,
IL10N $l,
MessageParser $messageParser,
Notifier $notifier,
ITimeFactory $timeFactory) {
$this->commentsManager = $commentsManager;
$this->l = $l;
$this->messageParser = $messageParser;
$this->notifier = $notifier;
$this->timeFactory = $timeFactory;
}
public function addReactionMessage(Room $chat, Participant $participant, IComment $parentMessage, string $reaction): IComment {
try {
// Check if the user already reacted with the same reaction
$comment = $this->commentsManager->getReactionComment(
(int) $parentMessage->getId(),
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId(),
$reaction
);
throw new ReactionAlreadyExistsException();
} catch (NotFoundException $e) {
}
$comment = $this->commentsManager->create(
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId(),
'chat',
(string) $chat->getId()
);
$comment->setParentId((string) $parentMessage->getId());
$comment->setMessage($reaction);
$comment->setVerb('reaction');
$this->commentsManager->save($comment);
$this->notifier->notifyReacted($chat, $parentMessage, $comment);
return $comment;
}
public function deleteReactionMessage(Participant $participant, int $messageId, string $reaction): IComment {
$comment = $this->commentsManager->getReactionComment(
$messageId,
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId(),
$reaction
);
$comment->setMessage(
json_encode([
'deleted_by_type' => $participant->getAttendee()->getActorType(),
'deleted_by_id' => $participant->getAttendee()->getActorId(),
'deleted_on' => $this->timeFactory->getDateTime()->getTimestamp(),
])
);
$comment->setVerb('reaction_deleted');
$this->commentsManager->save($comment);
return $comment;
}
public function retrieveReactionMessages(Room $chat, Participant $participant, int $messageId, ?string $reaction): array {
if ($reaction) {
$comments = $this->commentsManager->retrieveAllReactionsWithSpecificReaction($messageId, $reaction);
} else {
$comments = $this->commentsManager->retrieveAllReactions($messageId);
}
foreach ($comments as $comment) {
$message = $this->messageParser->createMessage($chat, $participant, $comment, $this->l);
$this->messageParser->parseMessage($message);
$reactions[$comment->getMessage()][] = [
'actorType' => $comment->getActorType(),
'actorId' => $comment->getActorId(),
'actorDisplayName' => $message->getActorDisplayName(),
'timestamp' => $comment->getCreationDateTime()->getTimestamp(),
];
}
return $reactions;
}
/**
* @param Room $chat
* @param string $messageId
* @return IComment
* @throws NotFoundException
* @throws ReactionNotSupportedException
* @throws ReactionOutOfContextException
*/
public function getCommentToReact(Room $chat, string $messageId): IComment {
if (!$this->commentsManager->supportReactions()) {
throw new ReactionNotSupportedException();
}
$comment = $this->commentsManager->get($messageId);
if ($comment->getObjectType() !== 'chat'
|| $comment->getObjectId() !== (string) $chat->getId()
|| $comment->getVerb() !== 'comment') {
throw new ReactionOutOfContextException();
}
return $comment;
}
}

130
lib/Controller/ReactionController.php

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021 Vitor Mattos <vitor@php.rio>
*
* @author Vitor Mattos <vitor@php.rio>
*
* @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\Controller;
use OCA\Talk\Chat\ReactionManager;
use OCA\Talk\Exceptions\ReactionAlreadyExistsException;
use OCA\Talk\Exceptions\ReactionNotSupportedException;
use OCA\Talk\Exceptions\ReactionOutOfContextException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\Comments\NotFoundException;
use OCP\IRequest;
class ReactionController extends AEnvironmentAwareController {
/** @var ReactionManager */
private $reactionManager;
public function __construct(string $appName,
IRequest $request,
ReactionManager $reactionManager) {
parent::__construct($appName, $request);
$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 {
try {
$chat = $this->getRoom();
$participant = $this->getParticipant();
$parentMessage = $this->reactionManager->getCommentToReact($chat, (string) $messageId);
$this->reactionManager->addReactionMessage($chat, $participant, $parentMessage, $reaction);
} catch (NotFoundException $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
} catch (ReactionAlreadyExistsException $e) {
return new DataResponse([], Http::STATUS_OK);
} catch (ReactionNotSupportedException | ReactionOutOfContextException | \Exception $e) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
return new DataResponse([], Http::STATUS_CREATED);
}
/**
* @NoAdminRequired
* @RequireParticipant
* @RequireReadWriteConversation
* @RequireModeratorOrNoLobby
*
* @param int $messageId for reaction
* @param string $reaction the reaction emoji
* @return DataResponse
*/
public function delete(int $messageId, string $reaction): DataResponse {
$participant = $this->getParticipant();
try {
// Verify that messageId is part of the room
$this->reactionManager->getCommentToReact($this->getRoom(), (string) $messageId);
} catch (ReactionNotSupportedException | ReactionOutOfContextException | NotFoundException $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
try {
$this->reactionManager->deleteReactionMessage(
$participant,
$messageId,
$reaction
);
} catch (NotFoundException $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
return new DataResponse([], Http::STATUS_OK);
}
/**
* @NoAdminRequired
* @RequireParticipant
* @RequireReadWriteConversation
* @RequireModeratorOrNoLobby
*
* @param int $messageId for reaction
* @param string|null $reaction the reaction emoji
* @return DataResponse
*/
public function getReactions(int $messageId, ?string $reaction): DataResponse {
try {
// Verify that messageId is part of the room
$this->reactionManager->getCommentToReact($this->getRoom(), (string) $messageId);
} catch (ReactionNotSupportedException | ReactionOutOfContextException | NotFoundException $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
$reactions = $this->reactionManager->retrieveReactionMessages($this->getRoom(), $this->getParticipant(), $messageId, $reaction);
return new DataResponse($reactions, Http::STATUS_OK);
}
}

29
lib/Exceptions/ReactionAlreadyExistsException.php

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021 Vitor Mattos <vitor@php.rio>
*
* @author Vitor Mattos <vitor@php.rio>
*
* @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\Exceptions;
class ReactionAlreadyExistsException extends \OutOfBoundsException {
}

29
lib/Exceptions/ReactionNotSupportedException.php

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022, Vitor Mattos <vitor@php.rio>
*
* @author Vitor Mattos <vitor@php.rio>
*
* @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\Exceptions;
class ReactionNotSupportedException extends \Exception {
}

29
lib/Exceptions/ReactionOutOfContextException.php

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022, Vitor Mattos <vitor@php.rio>
*
* @author Vitor Mattos <vitor@php.rio>
*
* @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\Exceptions;
class ReactionOutOfContextException extends \Exception {
}

2
lib/Manager.php

@ -233,6 +233,7 @@ class Manager {
'reference_id' => $row['comment_reference_id'] ?? null,
'creation_timestamp' => $row['comment_creation_timestamp'],
'latest_child_timestamp' => $row['comment_latest_child_timestamp'],
'reactions' => $row['comment_reactions'],
]);
}
@ -1185,5 +1186,6 @@ class Manager {
}
$query->selectAlias('c.creation_timestamp', 'comment_creation_timestamp');
$query->selectAlias('c.latest_child_timestamp', 'comment_latest_child_timestamp');
$query->selectAlias('c.reactions', 'comment_reactions');
}
}

2
lib/Model/Message.php

@ -163,6 +163,7 @@ class Message {
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 @@ class Message {
'messageType' => $this->getMessageType(),
'isReplyable' => $this->isReplyable(),
'referenceId' => (string) $this->getComment()->getReferenceId(),
'reactions' => $this->getComment()->getReactions(),
];
if ($this->getMessageType() === 'comment_deleted') {

23
lib/Notification/Notifier.php

@ -260,7 +260,7 @@ class Notifier implements INotifier {
}
return $this->parseCall($notification, $room, $l);
}
if ($subject === 'reply' || $subject === 'mention' || $subject === 'chat') {
if ($subject === 'reply' || $subject === 'mention' || $subject === 'chat' || $subject === 'reaction') {
return $this->parseChatMessage($notification, $room, $participant, $l);
}
@ -456,6 +456,27 @@ class Notifier implements INotifier {
$subject = $l->t('A guest replied to your message in conversation {call}');
}
}
} elseif ($notification->getSubject() === 'reaction') {
$richSubjectParameters['reaction'] = [
'type' => 'highlight',
'id' => $subjectParameters['reaction'],
'name' => $subjectParameters['reaction'],
];
if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
$subject = $l->t('{user} reacted with {reaction} to your private message');
} elseif ($richSubjectUser) {
$subject = $l->t('{user} reacted with {reaction} to your message in conversation {call}');
} elseif (!$isGuest) {
$subject = $l->t('A deleted user reacted with {reaction} to your message in conversation {call}');
} else {
try {
$richSubjectParameters['guest'] = $this->getGuestParameter($room, $comment->getActorId());
$subject = $l->t('{guest} (guest) reacted with {reaction} to your message in conversation {call}');
} catch (ParticipantNotFoundException $e) {
$subject = $l->t('A guest reacted with {reaction} to your message in conversation {call}');
}
}
} elseif ($room->getType() === Room::TYPE_ONE_TO_ONE) {
$subject = $l->t('{user} mentioned you in a private conversation');
} elseif ($richSubjectUser) {

1
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'

63
tests/integration/features/bootstrap/FeatureContext.php

@ -447,8 +447,8 @@ class FeatureContext implements Context, SnippetAcceptingContext {
return $attendee;
}, $result);
usort($expected, [$this, 'sortAttendees']);
usort($result, [$this, 'sortAttendees']);
usort($expected, [self::class, 'sortAttendees']);
usort($result, [self::class, 'sortAttendees']);
Assert::assertEquals($expected, $result);
} else {
@ -477,7 +477,7 @@ class FeatureContext implements Context, SnippetAcceptingContext {
}
}
protected function sortAttendees(array $a1, array $a2): int {
protected static function sortAttendees(array $a1, array $a2): int {
if (array_key_exists('participantType', $a1) && array_key_exists('participantType', $a2) && $a1['participantType'] !== $a2['participantType']) {
return $a1['participantType'] <=> $a2['participantType'];
}
@ -1559,6 +1559,7 @@ class FeatureContext implements Context, SnippetAcceptingContext {
}
$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 @@ class FeatureContext implements Context, SnippetAcceptingContext {
$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 @@ class FeatureContext implements Context, SnippetAcceptingContext {
if ($includeReferenceId) {
$data['referenceId'] = $message['referenceId'];
}
if ($includeReactions) {
$data['reactions'] = json_encode($message['reactions'], JSON_UNESCAPED_UNICODE);
}
return $data;
}, $messages));
}
@ -2079,6 +2083,57 @@ class FeatureContext implements Context, SnippetAcceptingContext {
$this->setCurrentUser($currentUser);
}
/**
* @Given /^user "([^"]*)" (delete react|react) with "([^"]*)" on message "([^"]*)" to room "([^"]*)" with (\d+)(?: \((v1)\))?$/
*/
public function userReactWithOnMessageToRoomWith(string $user, string $action, string $reaction, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1'): void {
$token = self::$identifierToToken[$identifier];
$messageId = self::$messages[$message];
$this->setCurrentUser($user);
$verb = $action === 'react' ? 'POST' : 'DELETE';
$this->sendRequest($verb, '/apps/spreed/api/' . $apiVersion . '/reaction/' . $token . '/' . $messageId, [
'reaction' => $reaction
]);
$this->assertStatusCode($this->response, $statusCode);
}
/**
* @Given /^user "([^"]*)" retrieve reactions "([^"]*)" of message "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/
*/
public function userRetrieveReactionsOfMessageInRoomWith(string $user, string $reaction, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1', TableNode $formData): void {
$token = self::$identifierToToken[$identifier];
$messageId = self::$messages[$message];
$this->setCurrentUser($user);
$reaction = $reaction !== 'all' ? '?reaction=' . $reaction : '';
$this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/reaction/' . $token . '/' . $messageId . $reaction);
$this->assertStatusCode($this->response, $statusCode);
$this->assertReactionList($formData);
}
private function assertReactionList(TableNode $formData): void {
$expected = [];
foreach ($formData->getHash() as $row) {
$reaction = $row['reaction'];
unset($row['reaction']);
$expected[$reaction][] = $row;
}
$result = $this->getDataFromResponse($this->response);
$result = array_map(static function ($reaction, $list) use ($expected): array {
$list = array_map(function ($reaction) {
unset($reaction['timestamp']);
return $reaction;
}, $list);
Assert::assertCount(count($list), $expected[$reaction], 'Reaction count by type does not match');
usort($expected[$reaction], [self::class, 'sortAttendees']);
usort($list, [self::class, 'sortAttendees']);
Assert::assertEquals($expected[$reaction], $list, 'Reaction list by type does not match');
return $list;
}, array_keys($result), array_values($result));
Assert::assertCount(count($expected), $result, 'Reaction count does not match');
}
/*
* Requests
*/

68
tests/integration/features/reaction/react.feature

@ -0,0 +1,68 @@
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 200
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} |
Scenario: Delete reaction 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 "participant2" delete react with "👍" on message "Message 1" to room "room" with 200
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 | [] | [] |
Scenario: Retrieve reactions of a message
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 "participant1" react with "👍" on 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" retrieve reactions "👍" of message "Message 1" in room "room" with 200
| actorType | actorId | actorDisplayName | reaction |
| users | participant1 | participant1-displayname | 👍 |
| users | participant2 | participant2-displayname | 👍 |
And user "participant2" react with "👎" on message "Message 1" to room "room" with 201
And user "participant1" retrieve reactions "👎" of message "Message 1" in room "room" with 200
| actorType | actorId | actorDisplayName | reaction |
| users | participant2 | participant2-displayname | 👎 |
And user "participant1" retrieve reactions "all" of message "Message 1" in room "room" with 200
| actorType | actorId | actorDisplayName | reaction |
| users | participant1 | participant1-displayname | 👍 |
| users | participant2 | participant2-displayname | 👎 |
| users | participant2 | participant2-displayname | 👍 |

11
tests/php/CapabilitiesTest.php

@ -26,6 +26,7 @@ declare(strict_types=1);
namespace OCA\Talk\Tests\Unit;
use OCA\Talk\Capabilities;
use OCA\Talk\Chat\CommentsManager;
use OCA\Talk\Config;
use OCA\Talk\Participant;
use OCP\Capabilities\IPublicCapability;
@ -41,6 +42,8 @@ class CapabilitiesTest extends TestCase {
protected $serverConfig;
/** @var Config|MockObject */
protected $talkConfig;
/** @var CommentsManager|MockObject */
protected $commentsManager;
/** @var IUserSession|MockObject */
protected $userSession;
/** @var array */
@ -50,7 +53,11 @@ class CapabilitiesTest extends TestCase {
parent::setUp();
$this->serverConfig = $this->createMock(IConfig::class);
$this->talkConfig = $this->createMock(Config::class);
$this->commentsManager = $this->createMock(CommentsManager::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->commentsManager->expects($this->any())
->method('supportReactions')
->willReturn(true);
$this->baseFeatures = [
'audio',
@ -96,6 +103,7 @@ class CapabilitiesTest extends TestCase {
'direct-mention-flag',
'notification-calls',
'conversation-permissions',
'reactions',
];
}
@ -103,6 +111,7 @@ class CapabilitiesTest extends TestCase {
$capabilities = new Capabilities(
$this->serverConfig,
$this->talkConfig,
$this->commentsManager,
$this->userSession
);
@ -160,6 +169,7 @@ class CapabilitiesTest extends TestCase {
$capabilities = new Capabilities(
$this->serverConfig,
$this->talkConfig,
$this->commentsManager,
$this->userSession
);
@ -246,6 +256,7 @@ class CapabilitiesTest extends TestCase {
$capabilities = new Capabilities(
$this->serverConfig,
$this->talkConfig,
$this->commentsManager,
$this->userSession
);

50
tests/php/Chat/NotifierTest.php

@ -129,20 +129,24 @@ class NotifierTest extends TestCase {
/**
* @return Room|MockObject
*/
private function getRoom() {
private function getRoom($settings = []) {
/** @var Room|MockObject */
$room = $this->createMock(Room::class);
$room->expects($this->any())
->method('getParticipant')
->willReturnCallback(function (string $actorId) use ($room): Participant {
->willReturnCallback(function (string $actorId) use ($room, $settings): Participant {
if ($actorId === 'userNotInOneToOneChat') {
throw new ParticipantNotFoundException();
}
$attendee = Attendee::fromRow([
$attendeeRow = [
'actor_type' => 'user',
'actor_id' => $actorId,
]);
];
if (isset($settings['attendee'][$actorId])) {
$attendeeRow = array_merge($attendeeRow, $settings['attendee'][$actorId]);
}
$attendee = Attendee::fromRow($attendeeRow);
return new Participant($room, $attendee, null);
});
@ -373,6 +377,44 @@ class NotifierTest extends TestCase {
];
}
/**
* @dataProvider dataNotifyReacted
*/
public function testNotifyReacted(int $notify, int $notifyType, int $roomType, string $authorId): void {
$this->notificationManager->expects($this->exactly($notify))
->method('notify');
$room = $this->getRoom([
'attendee' => [
'testUser' => [
'notificationLevel' => $notifyType,
]
]
]);
$room->method('getType')
->willReturn($roomType);
$comment = $this->newComment('108', 'users', 'testUser', new \DateTime('@' . 1000000016), 'message');
$reaction = $this->newComment('108', 'users', $authorId, new \DateTime('@' . 1000000016), 'message');
$notifier = $this->getNotifier([]);
$notifier->notifyReacted($room, $comment, $reaction);
}
public function dataNotifyReacted(): array {
return [
'author react to own message' =>
[0, Participant::NOTIFY_MENTION, Room::TYPE_GROUP, 'testUser'],
'notify never' =>
[0, Participant::NOTIFY_NEVER, Room::TYPE_GROUP, 'testUser2'],
'notify default, not one to one' =>
[0, Participant::NOTIFY_DEFAULT, Room::TYPE_GROUP, 'testUser2'],
'notify default, one to one' =>
[1, Participant::NOTIFY_DEFAULT, Room::TYPE_ONE_TO_ONE, 'testUser2'],
'notify always' =>
[1, Participant::NOTIFY_ALWAYS, Room::TYPE_GROUP, 'testUser2'],
];
}
/**
* @dataProvider dataGetMentionedUsers
*/

Loading…
Cancel
Save