diff --git a/appinfo/routes.php b/appinfo/routes.php index fd51ff82c1a..db32cbd72c1 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -153,6 +153,16 @@ 'token' => '^[a-z0-9]{4,30}$', ], ], + [ + 'name' => 'Chat#deleteMessage', + 'url' => '/api/{apiVersion}/chat/{token}/{messageId}', + 'verb' => 'DELETE', + 'requirements' => [ + 'apiVersion' => 'v1', + 'token' => '^[a-z0-9]{4,30}$', + 'messageId' => '^[0-9]+$', + ], + ], [ 'name' => 'Chat#setReadMarker', 'url' => '/api/{apiVersion}/chat/{token}/read', diff --git a/docs/capabilities.md b/docs/capabilities.md index faf3b18d962..2383394cb8c 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -64,3 +64,6 @@ title: Capabilities * `room-description` - A description can be get and set for conversations. * `config => chat => read-privacy` - See `chat-read-status` * `config => previews => max-gif-size` - Maximum size in bytes below which a GIF can be embedded directly in the page at render time. Bigger files will be rendered statically using the preview endpoint instead. Can be set with `occ config:app:set spreed max-gif-size --value=X` where X is the new value in bytes. Defaults to 3 MB. + +## 12.0 +* `delete-messages` - Allows to delete chat messages up to 6 hours for your own messages or when being a moderator. On deleting the message text will be replaced and a follow up system message will make sure clients and users update it in their cache and storage. diff --git a/docs/chat.md b/docs/chat.md index f9b336ff6b6..3d03bf5e12e 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -44,7 +44,7 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` `actorDisplayName` | string | Display name of the message author `timestamp` | int | Timestamp in seconds and UTC time zone `systemMessage` | string | empty for normal chat message or the type of the system message (untranslated) - `messageType` | string | Currently known types are `comment`, `system` and `command` + `messageType` | string | Currently known types are `comment`, `comment_deleted`, `system` and `command` `isReplyable` | bool | True if the user can post a reply to this message (only available with `chat-replies` capability) `referenceId` | string | A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability) `message` | string | Message string with placeholders (see [Rich Object String](https://github.com/nextcloud/server/issues/1706)) @@ -96,6 +96,35 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` - Data: The full message array of the new message, as defined in [Receive chat messages of a conversation](#receive-chat-messages-of-a-conversation) + +## Deleting a chat message + +* Method: `DELETE` +* Endpoint: `/chat/{token}/{messageId}` + +* Response: + - Status code: + + `200 OK` - When deleting was successful + + `202 Accepted` - When deleting was successful but Matterbridge is enabled so the message was leaked to other services + + `400 Bad Request` The message is already older than 6 hours + + `403 Forbidden` When the message is not from the current user and the user not a moderator + + `403 Forbidden` When the conversation is read-only + + `404 Not Found` When the conversation or chat message could not be found for the participant + + `405 Method Not Allowed` When the message is not a normal chat message + + `412 Precondition Failed` When the lobby is active and the user is not a moderator + + - Header: + + field | type | Description + ------|------|------------ + `X-Chat-Last-Common-Read` | int | ID of the last message read by every user that has read privacy set to public. When the user themself has it set to private the value the header is not set (only available with `chat-read-status` capability) + + - Data: + The full message array of the new system message "You deleted a message", as defined in [Receive chat messages of a conversation](#receive-chat-messages-of-a-conversation) + The parent message is the object of the deleted message with the replaced text "Message deleted by you". + This message should not be displayed to the user but instead be used to remove the original message from any cache/storage of the device. + + ## Mark chat as read * Method: `POST` @@ -119,7 +148,6 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` `X-Chat-Last-Common-Read` | int | ID of the last message read by every user that has read privacy set to public. When the user themself has it set to private the value the header is not set (only available with `chat-read-status` capability) - ## Get mention autocomplete suggestions * Method: `GET` @@ -177,3 +205,4 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` * `moderator_demoted` - {actor} demoted {user} from moderator * `guest_moderator_promoted` - {actor} promoted {user} to moderator * `guest_moderator_demoted` - {actor} demoted {user} from moderator +* `message_deleted` - Message deleted by {actor} (Should not be shown to the user) diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 1f3a5472080..668574f0db8 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -68,6 +68,7 @@ public function getCapabilities(): array { 'last-room-activity', 'no-ping', 'system-messages', + 'delete-messages', 'mention-flag', 'in-call-flags', 'notification-levels', diff --git a/lib/Chat/ChatManager.php b/lib/Chat/ChatManager.php index b21d90b4a23..4202a3a462a 100644 --- a/lib/Chat/ChatManager.php +++ b/lib/Chat/ChatManager.php @@ -106,15 +106,28 @@ public function __construct(CommentsManager $commentsManager, * @param \DateTime $creationDateTime * @param bool $sendNotifications * @param string|null $referenceId + * @param int|null $parentId * @return IComment */ - public function addSystemMessage(Room $chat, string $actorType, string $actorId, string $message, \DateTime $creationDateTime, bool $sendNotifications, ?string $referenceId = null): IComment { + public function addSystemMessage( + Room $chat, + string $actorType, + string $actorId, + string $message, + \DateTime $creationDateTime, + bool $sendNotifications, + ?string $referenceId = null, + ?int $parentId = null + ): IComment { $comment = $this->commentsManager->create($actorType, $actorId, 'chat', (string) $chat->getId()); $comment->setMessage($message, self::MAX_CHAT_LENGTH); $comment->setCreationDateTime($creationDateTime); if ($referenceId !== null) { $comment->setReferenceId($referenceId); } + if ($parentId !== null) { + $comment->setParentId((string) $parentId); + } $comment->setVerb('system'); $event = new ChatEvent($chat, $comment); @@ -231,6 +244,30 @@ public function sendMessage(Room $chat, Participant $participant, string $actorT return $comment; } + public function deleteMessage(Room $chat, int $messageId, string $actorType, string $actorId, \DateTime $deletionTime): IComment { + $comment = $this->getComment($chat, (string) $messageId); + $comment->setMessage( + json_encode([ + 'deleted_by_type' => $actorType, + 'deleted_by_id' => $actorId, + 'deleted_on' => $deletionTime->getTimestamp(), + ]) + ); + $comment->setVerb('comment_deleted'); + $this->commentsManager->save($comment); + + return $this->addSystemMessage( + $chat, + $actorType, + $actorId, + json_encode(['message' => 'message_deleted', 'parameters' => ['message' => $messageId]]), + $this->timeFactory->getDateTime(), + false, + null, + $messageId + ); + } + /** * @param Room $chat * @param string $parentId @@ -247,6 +284,22 @@ public function getParentComment(Room $chat, string $parentId): IComment { return $comment; } + /** + * @param Room $chat + * @param string $messageId + * @return IComment + * @throws NotFoundException + */ + public function getComment(Room $chat, string $messageId): IComment { + $comment = $this->commentsManager->get($messageId); + + if ($comment->getObjectType() !== 'chat' || $comment->getObjectId() !== (string) $chat->getId()) { + throw new NotFoundException('Message not found in the right context'); + } + + return $comment; + } + public function getLastReadMessageFromLegacy(Room $chat, IUser $user): int { $marker = $this->commentsManager->getReadMark('chat', $chat->getId(), $user); if ($marker === null) { diff --git a/lib/Chat/Parser/Listener.php b/lib/Chat/Parser/Listener.php index 2cb2fac3a68..75e068b7c3a 100644 --- a/lib/Chat/Parser/Listener.php +++ b/lib/Chat/Parser/Listener.php @@ -96,5 +96,25 @@ public static function register(IEventDispatcher $dispatcher): void { $event->stopPropagation(); } }); + + $dispatcher->addListener(MessageParser::EVENT_MESSAGE_PARSE, static function (ChatMessageEvent $event) { + $chatMessage = $event->getMessage(); + + if ($chatMessage->getMessageType() !== 'comment_deleted') { + return; + } + + /** @var SystemMessage $parser */ + $parser = \OC::$server->query(SystemMessage::class); + + try { + $parser->parseDeletedMessage($chatMessage); + $event->stopPropagation(); + } catch (\OutOfBoundsException $e) { + // Unknown message, ignore + } catch (\RuntimeException $e) { + $event->stopPropagation(); + } + }, 9999);// First things first } } diff --git a/lib/Chat/Parser/SystemMessage.php b/lib/Chat/Parser/SystemMessage.php index 74acd068872..ab153448086 100644 --- a/lib/Chat/Parser/SystemMessage.php +++ b/lib/Chat/Parser/SystemMessage.php @@ -89,7 +89,7 @@ public function parseMessage(Message $chatMessage): void { $message = $data['message']; $parameters = $data['parameters']; - $parsedParameters = ['actor' => $this->getActor($comment)]; + $parsedParameters = ['actor' => $this->getActorFromComment($comment)]; $participant = $chatMessage->getParticipant(); if (!$participant->isGuest()) { @@ -354,6 +354,57 @@ public function parseMessage(Message $chatMessage): void { if ($currentUserIsActor) { $parsedMessage = $this->l->t('You stopped Matterbridge.'); } + } elseif ($message === 'message_deleted') { + $parsedMessage = $this->l->t('{actor} deleted a message'); + if ($currentUserIsActor) { + $parsedMessage = $this->l->t('You deleted a message'); + } + } else { + throw new \OutOfBoundsException('Unknown subject'); + } + + $chatMessage->setMessage($parsedMessage, $parsedParameters, $message); + } + + /** + * @param Message $chatMessage + * @throws \OutOfBoundsException + */ + public function parseDeletedMessage(Message $chatMessage): void { + $this->l = $chatMessage->getL10n(); + $data = json_decode($chatMessage->getMessage(), true); + if (!\is_array($data)) { + throw new \OutOfBoundsException('Invalid message'); + } + + $parsedParameters = ['actor' => $this->getActor($data['deleted_by_type'], $data['deleted_by_id'])]; + + $participant = $chatMessage->getParticipant(); + $currentActorId = $participant->getAttendee()->getActorId(); + + $authorIsActor = $data['deleted_by_type'] === $chatMessage->getComment()->getActorType() + && $data['deleted_by_id'] === $chatMessage->getComment()->getActorId(); + + if (!$participant->isGuest()) { + $currentUserIsActor = $parsedParameters['actor']['type'] === 'user' && + $participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS && + $currentActorId === $parsedParameters['actor']['id']; + } else { + $currentUserIsActor = $parsedParameters['actor']['type'] === 'guest' && + $participant->getAttendee()->getActorType() === 'guest' && + $currentActorId === $parsedParameters['actor']['id']; + } + + if ($chatMessage->getMessageType() === 'comment_deleted') { + $message = 'message_deleted'; + $parsedMessage = $this->l->t('Message deleted by author'); + + if (!$authorIsActor) { + $parsedMessage = $this->l->t('Message deleted by {actor}'); + } + if ($currentUserIsActor) { + $parsedMessage = $this->l->t('Message deleted by you'); + } } else { throw new \OutOfBoundsException('Unknown subject'); } @@ -430,12 +481,16 @@ protected function getFileFromShare(Participant $participant, string $shareId): ]; } - protected function getActor(IComment $comment): array { - if ($comment->getActorType() === Attendee::ACTOR_GUESTS) { - return $this->getGuest($comment->getActorId()); + protected function getActorFromComment(IComment $comment): array { + return $this->getActor($comment->getActorType(), $comment->getActorId()); + } + + protected function getActor(string $actorType, string $actorId): array { + if ($actorType === Attendee::ACTOR_GUESTS) { + return $this->getGuest($actorId); } - return $this->getUser($comment->getActorId()); + return $this->getUser($actorId); } protected function getUser(string $uid): array { diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php index 9aecf0fbe37..4c5706086af 100644 --- a/lib/Controller/ChatController.php +++ b/lib/Controller/ChatController.php @@ -29,6 +29,7 @@ use OCA\Talk\Chat\ChatManager; use OCA\Talk\Chat\MessageParser; use OCA\Talk\GuestManager; +use OCA\Talk\MatterbridgeManager; use OCA\Talk\Model\Attendee; use OCA\Talk\Model\Message; use OCA\Talk\Model\Session; @@ -92,6 +93,9 @@ class ChatController extends AEnvironmentAwareController { /** @var IUserStatusManager */ private $statusManager; + /** @var MatterbridgeManager */ + protected $matterbridgeManager; + /** @var SearchPlugin */ private $searchPlugin; @@ -120,6 +124,7 @@ public function __construct(string $appName, MessageParser $messageParser, IManager $autoCompleteManager, IUserStatusManager $statusManager, + MatterbridgeManager $matterbridgeManager, SearchPlugin $searchPlugin, ISearchResult $searchResult, ITimeFactory $timeFactory, @@ -138,6 +143,7 @@ public function __construct(string $appName, $this->messageParser = $messageParser; $this->autoCompleteManager = $autoCompleteManager; $this->statusManager = $statusManager; + $this->matterbridgeManager = $matterbridgeManager; $this->searchPlugin = $searchPlugin; $this->searchResult = $searchResult; $this->timeFactory = $timeFactory; @@ -462,6 +468,69 @@ public function receiveMessages(int $lookIntoFuture, return $response; } + /** + * @NoAdminRequired + * @RequireParticipant + * @RequireReadWriteConversation + * @RequireModeratorOrNoLobby + * + * @param int $messageId + * @return DataResponse + */ + public function deleteMessage(int $messageId): DataResponse { + try { + $message = $this->chatManager->getComment($this->room, (string) $messageId); + } catch (NotFoundException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + $attendee = $this->participant->getAttendee(); + if (!$this->participant->hasModeratorPermissions(false) + && ($message->getActorType() !== $attendee->getActorType() + || $message->getActorId() !== $attendee->getActorId())) { + // Actor is not a moderator or not the owner of the message + return new DataResponse([], Http::STATUS_FORBIDDEN); + } + + if ($message->getVerb() !== 'comment') { + // System message or file share (since the message is not parsed, it has type "system") + return new DataResponse([], Http::STATUS_METHOD_NOT_ALLOWED); + } + + $maxDeleteAge = $this->timeFactory->getDateTime(); + $maxDeleteAge->sub(new \DateInterval('PT6H')); + if ($message->getCreationDateTime() < $maxDeleteAge) { + // Message is too old + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + $systemMessageComment = $this->chatManager->deleteMessage( + $this->room, + $messageId, + $attendee->getActorType(), + $attendee->getActorId(), + $this->timeFactory->getDateTime() + ); + + $systemMessage = $this->messageParser->createMessage($this->room, $this->participant, $systemMessageComment, $this->l); + $this->messageParser->parseMessage($systemMessage); + + $comment = $this->chatManager->getComment($this->room, (string) $messageId); + $message = $this->messageParser->createMessage($this->room, $this->participant, $comment, $this->l); + $this->messageParser->parseMessage($message); + + $data = $systemMessage->toArray(); + $data['parent'] = $message->toArray(); + + $bridge = $this->matterbridgeManager->getBridgeOfRoom($this->room); + + $response = new DataResponse($data, $bridge['enabled'] ? Http::STATUS_ACCEPTED: Http::STATUS_OK); + if ($this->participant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) { + $response->addHeader('X-Chat-Last-Common-Read', $this->chatManager->getLastCommonReadMessage($this->room)); + } + return $response; + } + /** * @NoAdminRequired * @RequireParticipant diff --git a/lib/Model/Message.php b/lib/Model/Message.php index 97d578c184e..49ed61e98c8 100644 --- a/lib/Model/Message.php +++ b/lib/Model/Message.php @@ -158,11 +158,12 @@ public function getActorDisplayName(): string { public function isReplyable(): bool { return $this->getMessageType() !== 'system' && $this->getMessageType() !== 'command' && + $this->getMessageType() !== 'comment_deleted' && \in_array($this->getActorType(), [Attendee::ACTOR_USERS, Attendee::ACTOR_GUESTS]); } public function toArray(): array { - return [ + $data = [ 'id' => (int) $this->getComment()->getId(), 'token' => $this->getRoom()->getToken(), 'actorType' => $this->getActorType(), @@ -176,5 +177,11 @@ public function toArray(): array { 'isReplyable' => $this->isReplyable(), 'referenceId' => (string) $this->getComment()->getReferenceId(), ]; + + if ($this->getMessageType() === 'comment_deleted') { + $data['deleted'] = true; + } + + return $data; } } diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.vue b/src/components/MessagesList/MessagesGroup/Message/Message.vue index 9b1032ff0b0..c4a04bc1a29 100644 --- a/src/components/MessagesList/MessagesGroup/Message/Message.vue +++ b/src/components/MessagesList/MessagesGroup/Message/Message.vue @@ -52,6 +52,9 @@ the main body of the message as well as a quote. +
+ +
@@ -87,7 +90,7 @@ the main body of the message as well as a quote. title="" :size="16" />
-
@@ -129,6 +132,13 @@ the main body of the message as well as a quote. {{ action.label }} + + {{ t('spreed', 'Delete') }} +
@@ -148,13 +158,19 @@ import RichText from '@juliushaertl/vue-richtext' import AlertCircle from 'vue-material-design-icons/AlertCircle' import Check from 'vue-material-design-icons/Check' import CheckAll from 'vue-material-design-icons/CheckAll' +import Reload from 'vue-material-design-icons/Reload' import Quote from '../../../Quote' import participant from '../../../../mixins/participant' import { EventBus } from '../../../../services/EventBus' import emojiRegex from 'emoji-regex' import { PARTICIPANT, CONVERSATION } from '../../../../constants' import moment from '@nextcloud/moment' -import Reload from 'vue-material-design-icons/Reload' +import { + showError, + showSuccess, + showWarning, + TOAST_DEFAULT_TIMEOUT +} from '@nextcloud/dialogs' export default { name: 'Message', @@ -274,6 +290,13 @@ export default { type: String, required: true, }, + /** + * The type of the message. + */ + messageType: { + type: String, + required: true, + }, /** * The parent message's id. */ @@ -293,6 +316,7 @@ export default { // Is tall enough for both actions and date upon hovering isTallEnough: false, showReloadButton: false, + isDeleting: false, } }, @@ -302,7 +326,7 @@ export default { }, hasActions() { - return this.isReplyable && !this.isConversationReadOnly + return (this.isReplyable || this.isDeleteable) && !this.isConversationReadOnly }, isConversationReadOnly() { @@ -313,6 +337,10 @@ export default { return this.systemMessage !== '' }, + isDeletedMessage() { + return this.messageType === 'comment_deleted' + }, + messageTime() { return moment(this.timestamp * 1000).format('LT') }, @@ -337,6 +365,7 @@ export default { showSentIcon() { return !this.isSystemMessage && !this.isTemporary + && !this.isDeleting && this.actorType === this.participant.actorType && this.actorId === this.participant.actorId }, @@ -408,7 +437,7 @@ export default { // Determines whether the date has to be displayed or not hasDate() { - if (this.isTemporary || this.sendingFailure) { + if (this.isTemporary || this.isDeleting || this.sendingFailure) { // Never on temporary or failed messages return false } @@ -446,6 +475,24 @@ export default { return t('spreed', 'You can not send messages to this conversation at the moment') }, + isMyMsg() { + return this.actorId === this.$store.getters.getActorId() + && this.actorType === this.$store.getters.getActorType() + }, + + isDeleteable() { + const isFileShare = this.message === '{file}' + && this.messageParameters?.file + + return (moment(this.timestamp * 1000).add(6, 'h')) > moment() + && this.messageType === 'comment' + && !this.isDeleting + && !isFileShare + && (this.participant.participantType === PARTICIPANT.TYPE.OWNER + || this.participant.participantType === PARTICIPANT.TYPE.MODERATOR + || this.isMyMsg) + }, + messageActions() { return this.$store.getters.messageActions }, @@ -457,7 +504,6 @@ export default { apiVersion: 'v3', } }, - }, watch: { @@ -515,8 +561,38 @@ export default { }) EventBus.$emit('focusChatInput') }, - handleDelete() { - this.$store.dispatch('deleteMessage', this.message) + async handleDelete() { + this.isDeleting = true + try { + const statusCode = await this.$store.dispatch('deleteMessage', { + message: { + token: this.token, + id: this.id, + }, + placeholder: t('spreed', 'Deleting message'), + }) + + if (statusCode === 202) { + showWarning(t('spreed', 'Message deleted successfully, but Matterbridge is configured and the message might already be distributed to other services'), { + timeout: TOAST_DEFAULT_TIMEOUT * 2, + }) + } else if (statusCode === 200) { + showSuccess(t('spreed', 'Message deleted successfully')) + } + } catch (e) { + if (e?.response?.status === 400) { + showError(t('spreed', 'Message could not be deleted because it is too old')) + } else if (e?.response?.status === 405) { + showError(t('spreed', 'Only normal chat messages can be deleted')) + } else { + showError(t('spreed', 'An error occurred while deleting the message')) + console.error(e) + } + this.isDeleting = false + return + } + + this.isDeleting = false }, }, } @@ -559,6 +635,13 @@ export default { width: 100%; } + &.deleted-message { + background-color: var(--color-background-dark); + color: var(--color-text-lighter); + padding: var(--border-radius) var(--border-radius-large); + border-radius: var(--border-radius-large); + } + ::v-deep .rich-text--wrapper { white-space: pre-wrap; word-break: break-word; diff --git a/src/components/MessagesList/MessagesList.vue b/src/components/MessagesList/MessagesList.vue index a7ea914e12c..433b855ddac 100644 --- a/src/components/MessagesList/MessagesList.vue +++ b/src/components/MessagesList/MessagesList.vue @@ -162,6 +162,10 @@ export default { const groups = [] let lastMessage = null for (const message of this.messagesList) { + if (message.systemMessage === 'message_deleted') { + continue + } + if (!this.messagesShouldBeGrouped(message, lastMessage)) { // Add the date separator for different days if (this.messagesHaveDifferentDate(message, lastMessage)) { diff --git a/src/services/messagesService.js b/src/services/messagesService.js index c17966be60e..02d4af8fcd0 100644 --- a/src/services/messagesService.js +++ b/src/services/messagesService.js @@ -103,8 +103,20 @@ const postNewMessage = async function({ token, message, actorDisplayName, refere return response } +/** + * Deletes a message from the server. + * + * @param {object} param0 The message object that is destructured + * @param {string} token The conversation token + * @param {string} id The id of the message to be deleted + */ +const deleteMessage = async function({ token, id }) { + return axios.delete(generateOcsUrl('apps/spreed/api/v1/chat', 2) + token + '/' + id) +} + export { fetchMessages, lookForNewMessages, postNewMessage, + deleteMessage, } diff --git a/src/store/messagesStore.js b/src/store/messagesStore.js index 65d17e237f3..dd7ed356ab8 100644 --- a/src/store/messagesStore.js +++ b/src/store/messagesStore.js @@ -20,6 +20,7 @@ * */ import Vue from 'vue' +import { deleteMessage } from '../services/messagesService' const state = { /** @@ -129,6 +130,16 @@ const mutations = { } }, + /** + * Deletes a message from the store. + * @param {object} state current store state; + * @param {object} message the message; + * @param {string} placeholder Placeholder message until deleting finished + */ + markMessageAsDeleting(state, { message, placeholder }) { + Vue.set(state.messages[message.token][message.id], 'messageType', 'comment_deleted') + Vue.set(state.messages[message.token][message.id], 'message', placeholder) + }, /** * Adds a temporary message to the store. * @param {object} state current store state; @@ -221,10 +232,31 @@ const actions = { * Delete a message * * @param {object} context default store context; - * @param {string} message the message to be deleted; + * @param {object} message the message to be deleted; + * @param {string} placeholder Placeholder message until deleting finished */ - deleteMessage(context, message) { - context.commit('deleteMessage', message) + async deleteMessage(context, { message, placeholder }) { + const messageObject = Object.assign({}, context.getters.message(message.token, message.id)) + context.commit('markMessageAsDeleting', { message, placeholder }) + + let response + try { + response = await deleteMessage(message) + } catch (e) { + // Restore the previous message state + context.commit('addMessage', messageObject) + throw e + } + + const systemMessage = response.data.ocs.data + if (systemMessage.parent) { + context.commit('addMessage', systemMessage.parent) + systemMessage.parent = systemMessage.parent.id + } + + context.commit('addMessage', systemMessage) + + return response.status }, /** diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 8f64ba0e691..feb7af04d5f 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -976,7 +976,6 @@ public function userAddUserToRoom($user, $newUser, $identifier, $statusCode, $ap * @param string $apiVersion */ public function userAddAttendeeToRoom($user, $newType, $newId, $identifier, $statusCode, $apiVersion = 'v1') { - var_dump($newType); $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/participants', @@ -1103,6 +1102,24 @@ public function userSendsMessageToRoom($user, $message, $identifier, $statusCode } } + /** + * @Then /^user "([^"]*)" deletes message "([^"]*)" from room "([^"]*)" with (\d+)(?: \((v(1|2|3))\))?$/ + * + * @param string $user + * @param string $message + * @param string $identifier + * @param string $statusCode + * @param string $apiVersion + */ + public function userDeletesMessageFromRoom($user, $message, $identifier, $statusCode, $apiVersion = 'v1') { + $this->setCurrentUser($user); + $this->sendRequest( + 'DELETE', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/' . self::$messages[$message], + new TableNode([['message', $message]]) + ); + $this->assertStatusCode($this->response, $statusCode); + } + /** * @Then /^user "([^"]*)" reads message "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v(1|2|3))\))?$/ * @@ -1191,19 +1208,44 @@ public function userSeesTheFollowingMessagesInRoom($user, $identifier, $statusCo $this->compareDataResponse($formData); } + /** + * @Then /^user "([^"]*)" received a system messages in room "([^"]*)" to delete "([^"]*)"(?: \((v(1|2|3))\))?$/ + * + * @param string $user + * @param string $identifier + * @param string $message + * @param string $apiVersion + */ + public function userReceivedDeleteMessage($user, $identifier, $message, $apiVersion = 'v1') { + $this->setCurrentUser($user); + $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '?lookIntoFuture=0'); + $this->assertStatusCode($this->response, 200); + + $actual = $this->getDataFromResponse($this->response); + + foreach ($actual as $m) { + if ($m['systemMessage'] === 'message_deleted') { + if (isset($m['parent']['id']) && $m['parent']['id'] === self::$messages[$message]) { + return; + } + } + } + Assert::fail('Missing message_deleted system message for "' . $message . '"'); + } + /** * @Then /^user "([^"]*)" sees the following messages in room "([^"]*)" starting with "([^"]*)" with (\d+)(?: \((v(1|2|3))\))?$/ * * @param string $user * @param string $identifier - * @param string $knwonMessage + * @param string $knownMessage * @param string $statusCode * @param string $apiVersion * @param TableNode|null $formData */ - public function userAwaitsTheFollowingMessagesInRoom($user, $identifier, $knwonMessage, $statusCode, $apiVersion = 'v1', TableNode $formData = null) { + public function userAwaitsTheFollowingMessagesInRoom($user, $identifier, $knownMessage, $statusCode, $apiVersion = 'v1', TableNode $formData = null) { $this->setCurrentUser($user); - $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '?lookIntoFuture=1&includeLastKnown=1&lastKnownMessageId=' . self::$messages[$knwonMessage]); + $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '?lookIntoFuture=1&includeLastKnown=1&lastKnownMessageId=' . self::$messages[$knownMessage]); $this->assertStatusCode($this->response, $statusCode); $this->compareDataResponse($formData); diff --git a/tests/integration/features/chat/delete.feature b/tests/integration/features/chat/delete.feature new file mode 100644 index 00000000000..388ceee9140 --- /dev/null +++ b/tests/integration/features/chat/delete.feature @@ -0,0 +1,148 @@ +Feature: chat/reply + Background: + Given user "participant1" exists + Given user "participant2" exists + + Scenario: moderator deletes their own message + Given user "participant1" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "group room" with 200 + And user "participant1" sends message "Message 1" to room "group room" with 201 + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1 | [] | | + Then user "participant2" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1 | [] | | + And user "participant1" deletes message "Message 1" from room "group room" with 200 + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message deleted by you | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | | + Then user "participant2" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message deleted by author | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | | + Then user "participant1" received a system messages in room "group room" to delete "Message 1" + Then user "participant2" received a system messages in room "group room" to delete "Message 1" + + Scenario: user deletes their own message + Given user "participant1" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "group room" with 200 + And user "participant2" sends message "Message 1" to room "group room" with 201 + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant2 | participant2-displayname | Message 1 | [] | | + Then user "participant2" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant2 | participant2-displayname | Message 1 | [] | | + And user "participant2" deletes message "Message 1" from room "group room" with 200 + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant2 | participant2-displayname | Message deleted by author | {"actor":{"type":"user","id":"participant2","name":"participant2-displayname"}} | | + Then user "participant2" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant2 | participant2-displayname | Message deleted by you | {"actor":{"type":"user","id":"participant2","name":"participant2-displayname"}} | | + Then user "participant1" received a system messages in room "group room" to delete "Message 1" + Then user "participant2" received a system messages in room "group room" to delete "Message 1" + + Scenario: moderator deletes other user message + Given user "participant1" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "group room" with 200 + And user "participant2" sends message "Message 1" to room "group room" with 201 + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant2 | participant2-displayname | Message 1 | [] | | + And user "participant1" deletes message "Message 1" from room "group room" with 200 + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant2 | participant2-displayname | Message deleted by you | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | | + Then user "participant2" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant2 | participant2-displayname | Message deleted by {actor} | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | | + Then user "participant1" received a system messages in room "group room" to delete "Message 1" + Then user "participant2" received a system messages in room "group room" to delete "Message 1" + + Scenario: moderator deletes their own message which got replies + Given user "participant1" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "group room" with 200 + And user "participant2" sends message "Message 1" to room "group room" with 201 + When user "participant1" sends reply "Message 1-1" on message "Message 1" to room "group room" with 201 + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1-1 | [] | Message 1 | + | group room | users | participant2 | participant2-displayname | Message 1 | [] | | + Then user "participant2" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1-1 | [] | Message 1 | + | group room | users | participant2 | participant2-displayname | Message 1 | [] | | + And user "participant1" deletes message "Message 1" from room "group room" with 200 + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1-1 | [] | Message deleted by you | + | group room | users | participant2 | participant2-displayname | Message deleted by you | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | | + Then user "participant2" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1-1 | [] | Message deleted by {actor} | + | group room | users | participant2 | participant2-displayname | Message deleted by {actor} | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | | + Then user "participant1" received a system messages in room "group room" to delete "Message 1" + Then user "participant2" received a system messages in room "group room" to delete "Message 1" + + Scenario: user deletes their own message which got replies + Given user "participant1" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "group room" with 200 + And user "participant2" sends message "Message 1" to room "group room" with 201 + When user "participant1" sends reply "Message 1-1" on message "Message 1" to room "group room" with 201 + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1-1 | [] | Message 1 | + | group room | users | participant2 | participant2-displayname | Message 1 | [] | | + Then user "participant2" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1-1 | [] | Message 1 | + | group room | users | participant2 | participant2-displayname | Message 1 | [] | | + And user "participant2" deletes message "Message 1" from room "group room" with 200 + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1-1 | [] | Message deleted by author | + | group room | users | participant2 | participant2-displayname | Message deleted by author | {"actor":{"type":"user","id":"participant2","name":"participant2-displayname"}} | | + Then user "participant2" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1-1 | [] | Message deleted by you | + | group room | users | participant2 | participant2-displayname | Message deleted by you | {"actor":{"type":"user","id":"participant2","name":"participant2-displayname"}} | | + Then user "participant1" received a system messages in room "group room" to delete "Message 1" + Then user "participant2" received a system messages in room "group room" to delete "Message 1" + + Scenario: moderator deletes other user message + Given user "participant1" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "group room" with 200 + And user "participant2" sends message "Message 1" to room "group room" with 201 + When user "participant1" sends reply "Message 1-1" on message "Message 1" to room "group room" with 201 + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1-1 | [] | Message 1 | + | group room | users | participant2 | participant2-displayname | Message 1 | [] | | + Then user "participant2" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1-1 | [] | Message 1 | + | group room | users | participant2 | participant2-displayname | Message 1 | [] | | + And user "participant1" deletes message "Message 1" from room "group room" with 200 + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1-1 | [] | Message deleted by you | + | group room | users | participant2 | participant2-displayname | Message deleted by you | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | | + Then user "participant2" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Message 1-1 | [] | Message deleted by {actor} | + | group room | users | participant2 | participant2-displayname | Message deleted by {actor} | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | | + Then user "participant1" received a system messages in room "group room" to delete "Message 1" + Then user "participant2" received a system messages in room "group room" to delete "Message 1" diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index b53bf418e51..690e74c712b 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -65,6 +65,7 @@ public function setUp(): void { 'last-room-activity', 'no-ping', 'system-messages', + 'delete-messages', 'mention-flag', 'in-call-flags', 'notification-levels', diff --git a/tests/php/Chat/Parser/SystemMessageTest.php b/tests/php/Chat/Parser/SystemMessageTest.php index b857a000077..30c5044064b 100644 --- a/tests/php/Chat/Parser/SystemMessageTest.php +++ b/tests/php/Chat/Parser/SystemMessageTest.php @@ -399,10 +399,21 @@ public function testParseMessage(string $message, array $parameters, $recipientI /** @var IComment|MockObject $comment */ $comment = $this->createMock(IComment::class); + if ($recipientId && strpos($recipientId, 'guest::') !== false) { + $comment->method('getActorType') + ->willReturn('guests'); + $comment->method('getActorId') + ->willReturn(substr($recipientId, strlen('guest::'))); + } else { + $comment->method('getActorType') + ->willReturn('users'); + $comment->method('getActorId') + ->willReturn($recipientId); + } - $parser = $this->getParser(['getActor', 'getUser', 'getGuest', 'parseCall', 'getFileFromShare']); + $parser = $this->getParser(['getActorFromComment', 'getUser', 'getGuest', 'parseCall', 'getFileFromShare']); $parser->expects($this->once()) - ->method('getActor') + ->method('getActorFromComment') ->with($comment) ->willReturn(['id' => 'actor', 'type' => 'user']); $parser->expects($this->any()) @@ -475,9 +486,9 @@ public function testParseMessageThrows($return) { /** @var IComment|MockObject $comment */ $comment = $this->createMock(IComment::class); - $parser = $this->getParser(['getActor']); + $parser = $this->getParser(['getActorFromComment']); $parser->expects($this->any()) - ->method('getActor') + ->method('getActorFromComment') ->with($comment) ->willReturn(['id' => 'actor', 'type' => 'user']); @@ -808,7 +819,7 @@ public function testGetActor(string $actorType, array $guestData, array $userDat ->willReturn($userData); } - $this->assertSame($expected, self::invokePrivate($parser, 'getActor', [$chatMessage])); + $this->assertSame($expected, self::invokePrivate($parser, 'getActorFromComment', [$chatMessage])); } public function dataGetUser(): array { diff --git a/tests/php/Controller/ChatControllerTest.php b/tests/php/Controller/ChatControllerTest.php index 1291d4b6b75..30086496c90 100644 --- a/tests/php/Controller/ChatControllerTest.php +++ b/tests/php/Controller/ChatControllerTest.php @@ -28,6 +28,7 @@ use OCA\Talk\Chat\MessageParser; use OCA\Talk\Controller\ChatController; use OCA\Talk\GuestManager; +use OCA\Talk\MatterbridgeManager; use OCA\Talk\Model\Message; use OCA\Talk\Participant; use OCA\Talk\Room; @@ -75,6 +76,8 @@ class ChatControllerTest extends TestCase { protected $autoCompleteManager; /** @var IUserStatusManager|MockObject */ protected $statusManager; + /** @var MatterbridgeManager|MockObject */ + protected $matterbridgeManager; /** @var SearchPlugin|MockObject */ protected $searchPlugin; /** @var ISearchResult|MockObject */ @@ -109,6 +112,7 @@ public function setUp(): void { $this->messageParser = $this->createMock(MessageParser::class); $this->autoCompleteManager = $this->createMock(IManager::class); $this->statusManager = $this->createMock(IUserStatusManager::class); + $this->matterbridgeManager = $this->createMock(MatterbridgeManager::class); $this->searchPlugin = $this->createMock(SearchPlugin::class); $this->searchResult = $this->createMock(ISearchResult::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); @@ -142,6 +146,7 @@ private function recreateChatController() { $this->messageParser, $this->autoCompleteManager, $this->statusManager, + $this->matterbridgeManager, $this->searchPlugin, $this->searchResult, $this->timeFactory,