diff --git a/docs/chat.md b/docs/chat.md index 3c9fe3bbe14..b9ad01e7213 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -195,6 +195,7 @@ See [OCP\RichObjectStrings\Definitions](https://github.com/nextcloud/server/blob |---------------|--------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `messageType` | string | A message type to show the message in different styles. Currently known: `voice-message` and `comment` | | `caption` | string | A caption message that should be shown together with the shared file (only available with `media-caption` capability) | +| `replyTo` | int | The message ID this caption message is a reply to (only allowed for messages from the same conversation and when the message type is not `system` or `command`) | | `silent` | bool | If sent silent the message will not create chat notifications even for mentions (only available with `media-caption` capability, yes `media-caption` not `silent-send`) | * Response: [See official OCS Share API docs](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-share-api.html?highlight=sharing#create-a-new-share) diff --git a/lib/Chat/ChatManager.php b/lib/Chat/ChatManager.php index 6ef082b4e46..eef6972066d 100644 --- a/lib/Chat/ChatManager.php +++ b/lib/Chat/ChatManager.php @@ -140,7 +140,7 @@ public function addSystemMessage( \DateTime $creationDateTime, bool $sendNotifications, ?string $referenceId = null, - ?int $parentId = null, + ?IComment $replyTo = null, bool $shouldSkipLastMessageUpdate = false, bool $silent = false, ): IComment { @@ -153,8 +153,8 @@ public function addSystemMessage( $comment->setReferenceId($referenceId); } } - if ($parentId !== null) { - $comment->setParentId((string) $parentId); + if ($replyTo !== null) { + $comment->setParentId($replyTo->getId()); } $messageDecoded = json_decode($message, true); @@ -168,6 +168,8 @@ public function addSystemMessage( $this->setMessageExpiration($chat, $comment); + $shouldFlush = $this->notificationManager->defer(); + $event = new BeforeSystemMessageSentEvent($chat, $comment, silent: $silent, skipLastActivityUpdate: $shouldSkipLastMessageUpdate); $this->dispatcher->dispatchTyped($event); $event = new ChatEvent($chat, $comment, $shouldSkipLastMessageUpdate, $silent); @@ -182,6 +184,29 @@ public function addSystemMessage( } if ($sendNotifications) { + /** @var ?IComment $captionComment */ + $captionComment = null; + $alreadyNotifiedUsers = $usersDirectlyMentioned = []; + if ($messageType === 'file_shared') { + if (isset($messageDecoded['parameters']['metaData']['caption'])) { + $captionComment = clone $comment; + $captionComment->setMessage($messageDecoded['parameters']['metaData']['caption'], self::MAX_CHAT_LENGTH); + $usersDirectlyMentioned = $this->notifier->getMentionedUserIds($captionComment); + } + if ($replyTo instanceof IComment) { + $alreadyNotifiedUsers = $this->notifier->notifyReplyToAuthor($chat, $comment, $replyTo, $silent); + if ($replyTo->getActorType() === Attendee::ACTOR_USERS) { + $usersDirectlyMentioned[] = $replyTo->getActorId(); + } + } + } + + $alreadyNotifiedUsers = $this->notifier->notifyMentionedUsers($chat, $captionComment ?? $comment, $alreadyNotifiedUsers, $silent); + if (!empty($alreadyNotifiedUsers)) { + $userIds = array_column($alreadyNotifiedUsers, 'id'); + $this->participantService->markUsersAsMentioned($chat, $userIds, (int) $comment->getId(), $usersDirectlyMentioned); + } + $this->notifier->notifyOtherParticipant($chat, $comment, [], $silent); } @@ -203,6 +228,10 @@ public function addSystemMessage( } $this->cache->remove($chat->getToken()); + if ($shouldFlush) { + $this->notificationManager->flush(); + } + if ($messageType === 'object_shared' || $messageType === 'file_shared') { $this->attachmentService->createAttachmentEntry($chat, $comment, $messageType, $messageDecoded['parameters'] ?? []); } @@ -466,7 +495,7 @@ public function deleteMessage(Room $chat, IComment $comment, Participant $partic $this->timeFactory->getDateTime(), false, null, - (int) $comment->getId(), + $comment, true ); } diff --git a/lib/Chat/ReactionManager.php b/lib/Chat/ReactionManager.php index e825d7ec6ab..b1f174b6e8e 100644 --- a/lib/Chat/ReactionManager.php +++ b/lib/Chat/ReactionManager.php @@ -110,7 +110,7 @@ public function addReactionMessage(Room $chat, string $actorType, string $actorI */ public function deleteReactionMessage(Room $chat, string $actorType, string $actorId, int $messageId, string $reaction): IComment { // Just to verify that messageId is part of the room and throw error if not. - $this->getCommentToReact($chat, (string) $messageId); + $parentComment = $this->getCommentToReact($chat, (string) $messageId); $comment = $this->commentsManager->getReactionComment( $messageId, @@ -136,7 +136,7 @@ public function deleteReactionMessage(Room $chat, string $actorType, string $act $this->timeFactory->getDateTime(), false, null, - $messageId, + $parentComment, true ); diff --git a/lib/Chat/SystemMessage/Listener.php b/lib/Chat/SystemMessage/Listener.php index f671dff8a72..80af7f69b64 100644 --- a/lib/Chat/SystemMessage/Listener.php +++ b/lib/Chat/SystemMessage/Listener.php @@ -27,6 +27,7 @@ use DateInterval; use OCA\Talk\Chat\ChatManager; +use OCA\Talk\Chat\MessageParser; use OCA\Talk\Events\AAttendeeRemovedEvent; use OCA\Talk\Events\AParticipantModifiedEvent; use OCA\Talk\Events\ARoomModifiedEvent; @@ -51,8 +52,10 @@ use OCA\Talk\Webinary; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Comments\IComment; +use OCP\Comments\NotFoundException; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; +use OCP\IL10N; use OCP\IRequest; use OCP\ISession; use OCP\IUser; @@ -75,6 +78,8 @@ public function __construct( protected ITimeFactory $timeFactory, protected Manager $manager, protected ParticipantService $participantService, + protected MessageParser $messageParser, + protected IL10N $l, ) { } @@ -455,12 +460,28 @@ protected function sendSystemMessage(Room $room, string $message, array $paramet $referenceId = (string) $referenceId; } + $parent = null; + $replyTo = $parameters['metaData']['replyTo'] ?? null; + if ($replyTo !== null) { + try { + $parentComment = $this->chatManager->getParentComment($room, (string) $replyTo); + $parentMessage = $this->messageParser->createMessage($room, $participant, $parentComment, $this->l); + $this->messageParser->parseMessage($parentMessage); + if ($parentMessage->isReplyable()) { + $parent = $parentComment; + } + } catch (NotFoundException) { + } + + } + return $this->chatManager->addSystemMessage( $room, $actorType, $actorId, json_encode(['message' => $message, 'parameters' => $parameters]), - $this->timeFactory->getDateTime(), $message === 'file_shared', + $this->timeFactory->getDateTime(), + $message === 'file_shared', $referenceId, - null, + $parent, $shouldSkipLastMessageUpdate, $silent, ); diff --git a/src/components/NewMessage/NewMessage.vue b/src/components/NewMessage/NewMessage.vue index eeead3bcd09..79f1801e55d 100644 --- a/src/components/NewMessage/NewMessage.vue +++ b/src/components/NewMessage/NewMessage.vue @@ -54,7 +54,7 @@
- @@ -517,6 +517,7 @@ export default { if (this.upload) { // Clear input content from store this.$store.dispatch('setCurrentMessageInput', { token: this.token, text: '' }) + this.$store.dispatch('removeMessageToBeReplied', this.token) if (this.$store.getters.getInitialisedUploads(this.$store.getters.currentUploadId).length) { // If dialog contains files to upload, delegate sending diff --git a/src/store/fileUploadStore.js b/src/store/fileUploadStore.js index 5478be2f588..11fc99477e4 100644 --- a/src/store/fileUploadStore.js +++ b/src/store/fileUploadStore.js @@ -375,6 +375,9 @@ const actions = { if (options?.silent) { Object.assign(rawMetadata, { silent: options.silent }) } + if (temporaryMessage.parent) { + Object.assign(rawMetadata, { replyTo: temporaryMessage.parent.id }) + } const metadata = JSON.stringify(rawMetadata) try { diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 126e152587f..1a4bc71f26b 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -125,6 +125,10 @@ public static function getTokenForIdentifier(string $identifier) { return self::$identifierToToken[$identifier]; } + public static function getMessageIdForText(string $text): int { + return self::$textToMessageId[$text]; + } + public static function getActorIdForPhoneNumber(string $phoneNumber): string { return self::$phoneNumberToActorId[$phoneNumber]; } diff --git a/tests/integration/features/bootstrap/SharingContext.php b/tests/integration/features/bootstrap/SharingContext.php index 151c25b6296..70b47abd69e 100644 --- a/tests/integration/features/bootstrap/SharingContext.php +++ b/tests/integration/features/bootstrap/SharingContext.php @@ -775,15 +775,28 @@ private function userSharesWith(string $user, string $path, string $shareType, s $parameters[] = 'shareType=' . $shareType; $parameters[] = 'shareWith=' . $shareWith; + $talkMetaData = []; + if ($body instanceof TableNode) { foreach ($body->getRowsHash() as $key => $value) { if ($key === 'expireDate' && $value !== 'invalid date') { $value = date('Y-m-d', strtotime($value)); } - $parameters[] = $key . '=' . $value; + if ($key === 'talkMetaData.replyTo') { + $value = FeatureContext::getMessageIdForText($value); + } + if (str_starts_with($key, 'talkMetaData.')) { + $talkMetaData[substr($key, 13)] = $value; + } else { + $parameters[] = $key . '=' . $value; + } } } + if (!empty($talkMetaData)) { + $parameters[] = 'talkMetaData=' . json_encode($talkMetaData); + } + $url .= '?' . implode('&', $parameters); $this->sendingTo('POST', $url); diff --git a/tests/integration/features/chat-1/file-share.feature b/tests/integration/features/chat-1/file-share.feature index 96f4237ee57..0b8b01d001e 100644 --- a/tests/integration/features/chat-1/file-share.feature +++ b/tests/integration/features/chat-1/file-share.feature @@ -42,6 +42,45 @@ Feature: chat/file-share | room | actorType | actorId | actorDisplayName | message | messageParameters | | public room | users | participant1 | participant1-displayname | {mention-user1} | "IGNORE" | + Scenario: Captioned message as a reply + Given user "participant1" creates room "public room" (v4) + | roomType | 3 | + | roomName | room | + And user "participant1" adds user "participant2" to room "public room" with 200 (v4) + And user "participant2" sends message "Message 1" to room "public room" with 201 + Then user "participant1" sees the following messages in room "public room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | public room | users | participant2 | participant2-displayname | Message 1 | [] | | + When user "participant1" shares "welcome.txt" with room "public room" + | talkMetaData.caption | @participant2 | + | talkMetaData.replyTo | Message 1 | + Then user "participant1" sees the following messages in room "public room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | public room | users | participant1 | participant1-displayname | {mention-user1} | "IGNORE" | Message 1 | + | public room | users | participant2 | participant2-displayname | Message 1 | [] | | + + Scenario: Captioned message can not reply cross chats + Given user "participant1" creates room "room1" (v4) + | roomType | 3 | + | roomName | room | + Given user "participant1" creates room "room2" (v4) + | roomType | 3 | + | roomName | room | + And user "participant1" adds user "participant2" to room "room1" with 200 (v4) + And user "participant2" sends message "Message 1" to room "room1" with 201 + Then user "participant1" sees the following messages in room "room1" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | room1 | users | participant2 | participant2-displayname | Message 1 | [] | | + When user "participant1" shares "welcome.txt" with room "room2" + | talkMetaData.caption | @participant2 | + | talkMetaData.replyTo | Message 1 | + Then user "participant1" sees the following messages in room "room1" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | room1 | users | participant2 | participant2-displayname | Message 1 | [] | | + Then user "participant1" sees the following messages in room "room2" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | room2 | users | participant1 | participant1-displayname | {mention-user1} | "IGNORE" | | + Scenario: Can not share a file without chat permission Given user "participant1" creates room "public room" (v4) | roomType | 3 | diff --git a/tests/integration/features/chat-1/notifications.feature b/tests/integration/features/chat-1/notifications.feature index 0549f2b4df9..8d66570e29a 100644 --- a/tests/integration/features/chat-1/notifications.feature +++ b/tests/integration/features/chat-1/notifications.feature @@ -373,6 +373,48 @@ Feature: chat/notifications | app | object_type | object_id | subject | | spreed | chat | room/Hi @all @participant2 @"group/attendees1" bye | participant1-displayname replied to your message in conversation room | + Scenario: Replying with a captioned file gives a reply notification + When user "participant1" creates room "room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "room" with 200 (v4) + And user "participant1" adds group "attendees1" to room "room" with 200 (v4) + # Join and leave to clear the invite notification + Given user "participant2" joins room "room" with 200 (v4) + Given user "participant2" leaves room "room" with 200 (v4) + When user "participant2" sends 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 | parentMessage | + | room | users | participant2 | participant2-displayname | Message 1 | [] | | + When user "participant1" shares "welcome.txt" with room "room" + | talkMetaData.caption | Caption 1-1 | + | talkMetaData.replyTo | Message 1 | + Then user "participant1" sees the following messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | room | users | participant1 | participant1-displayname | Caption 1-1 | "IGNORE" | Message 1 | + | room | users | participant2 | participant2-displayname | Message 1 | [] | | + Then user "participant2" has the following notifications + | app | object_type | object_id | subject | + | spreed | chat | room/Caption 1-1 | participant1-displayname replied to your message in conversation room | + + Scenario: Mentions in captions trigger normal mention notifications + When user "participant1" creates room "room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "room" with 200 (v4) + And user "participant1" adds group "attendees1" to room "room" with 200 (v4) + # Join and leave to clear the invite notification + Given user "participant2" joins room "room" with 200 (v4) + Given user "participant2" leaves room "room" with 200 (v4) + When user "participant1" shares "welcome.txt" with room "room" + | talkMetaData.caption | @participant2 | + Then user "participant1" sees the following messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | room | users | participant1 | participant1-displayname | {mention-user1} | "IGNORE" | | + Then user "participant2" has the following notifications + | app | object_type | object_id | subject | + | spreed | chat | room/{mention-user1} | participant1-displayname mentioned you in conversation room | + Scenario: Delete notification when the message is deleted When user "participant1" creates room "one-to-one room" (v4) | roomType | 1 | diff --git a/tests/php/Chat/ChatManagerTest.php b/tests/php/Chat/ChatManagerTest.php index 99fd88e2bb0..cd05ddaaa48 100644 --- a/tests/php/Chat/ChatManagerTest.php +++ b/tests/php/Chat/ChatManagerTest.php @@ -473,7 +473,7 @@ public function testDeleteMessage(): void { $chatManager = $this->getManager(['addSystemMessage']); $chatManager->expects($this->once()) ->method('addSystemMessage') - ->with($chat, Attendee::ACTOR_USERS, 'user', $this->anything(), $this->anything(), false, null, 123456) + ->with($chat, Attendee::ACTOR_USERS, 'user', $this->anything(), $this->anything(), false, null, $comment) ->willReturn($systemMessage); $this->assertSame($systemMessage, $chatManager->deleteMessage($chat, $comment, $participant, $date)); @@ -553,7 +553,7 @@ public function testDeleteMessageFileShare(): void { $chatManager = $this->getManager(['addSystemMessage']); $chatManager->expects($this->once()) ->method('addSystemMessage') - ->with($chat, Attendee::ACTOR_USERS, 'user', $this->anything(), $this->anything(), false, null, 123456) + ->with($chat, Attendee::ACTOR_USERS, 'user', $this->anything(), $this->anything(), false, null, $comment) ->willReturn($systemMessage); $this->assertSame($systemMessage, $chatManager->deleteMessage($chat, $comment, $participant, $date)); diff --git a/tests/php/Chat/SystemMessage/ListenerTest.php b/tests/php/Chat/SystemMessage/ListenerTest.php index e859ca24297..8667ce3e6bf 100644 --- a/tests/php/Chat/SystemMessage/ListenerTest.php +++ b/tests/php/Chat/SystemMessage/ListenerTest.php @@ -22,6 +22,7 @@ namespace OCA\Talk\Tests\php\Chat\SystemMessage; use OCA\Talk\Chat\ChatManager; +use OCA\Talk\Chat\MessageParser; use OCA\Talk\Chat\SystemMessage\Listener; use OCA\Talk\Events\AParticipantModifiedEvent; use OCA\Talk\Events\ARoomModifiedEvent; @@ -37,6 +38,7 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\Comments\IComment; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IL10N; use OCP\IRequest; use OCP\ISession; use OCP\IUser; @@ -71,6 +73,8 @@ class ListenerTest extends TestCase { protected $manager; /** @var ParticipantService|MockObject */ protected $participantService; + /** @var MessageParser|MockObject */ + protected $messageParser; protected ?array $handlers = null; protected ?\DateTime $dummyTime = null; @@ -94,6 +98,13 @@ protected function setUp(): void { $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->manager = $this->createMock(Manager::class); $this->participantService = $this->createMock(ParticipantService::class); + $this->messageParser = $this->createMock(MessageParser::class); + $l = $this->createMock(IL10N::class); + $l->expects($this->any()) + ->method('t') + ->willReturnCallback(function ($string, $args) { + return vsprintf($string, $args); + }); $this->handlers = []; @@ -112,6 +123,8 @@ protected function setUp(): void { $this->timeFactory, $this->manager, $this->participantService, + $this->messageParser, + $l, ); }