From 1164ab04334096623c1c88916575bd0725be7575 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 16 Oct 2026 16:05:40 +0200 Subject: [PATCH 1/3] fix(reaction): Mark one-to-one unread on reactions Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Joas Schilling --- lib/Chat/ChatManager.php | 6 +++- lib/Chat/ReactionManager.php | 6 ++++ lib/Controller/ReactionController.php | 7 +++- .../ConversationsList/Conversation.spec.js | 17 +++++++++ src/composables/useConversationInfo.ts | 15 ++++++++ .../features/chat-3/reaction.feature | 35 +++++++++++++++++++ tests/php/Chat/ChatManagerTest.php | 15 ++++++-- 7 files changed, 97 insertions(+), 4 deletions(-) diff --git a/lib/Chat/ChatManager.php b/lib/Chat/ChatManager.php index 15f3e5686e1..866916fac88 100644 --- a/lib/Chat/ChatManager.php +++ b/lib/Chat/ChatManager.php @@ -1032,7 +1032,11 @@ public function getUnreadCount(Room $chat, int $lastReadMessage): int { $key = $chat->getId() . '-' . $chat->getLastMessageId() . '-' . $lastReadMessage; $unreadCount = $this->unreadCountCache->get($key); if ($unreadCount === null) { - $unreadCount = $this->commentsManager->getNumberOfCommentsWithVerbsForObjectSinceComment('chat', (string)$chat->getId(), $lastReadMessage, [self::VERB_MESSAGE, self::VERB_OBJECT_SHARED]); + $verbs = [self::VERB_MESSAGE, self::VERB_OBJECT_SHARED]; + if ($chat->getType() === Room::TYPE_ONE_TO_ONE) { + $verbs[] = self::VERB_REACTION; + } + $unreadCount = $this->commentsManager->getNumberOfCommentsWithVerbsForObjectSinceComment('chat', (string)$chat->getId(), $lastReadMessage, $verbs); $this->unreadCountCache->set($key, $unreadCount, 1800); } return $unreadCount; diff --git a/lib/Chat/ReactionManager.php b/lib/Chat/ReactionManager.php index ce865a127ef..dc80d1da4de 100644 --- a/lib/Chat/ReactionManager.php +++ b/lib/Chat/ReactionManager.php @@ -18,6 +18,7 @@ use OCA\Talk\Participant; use OCA\Talk\ResponseDefinitions; use OCA\Talk\Room; +use OCA\Talk\Service\RoomService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Comments\IComment; use OCP\Comments\NotFoundException; @@ -38,6 +39,7 @@ public function __construct( private readonly Notifier $notifier, private readonly IEventDispatcher $dispatcher, private readonly ITimeFactory $timeFactory, + private readonly RoomService $roomService, ) { } @@ -80,6 +82,10 @@ public function addReactionMessage(Room $chat, string $actorType, string $actorI $this->commentsManager->save($comment); + if ($chat->getType() === Room::TYPE_ONE_TO_ONE) { + $this->roomService->setLastMessage($chat, $comment); + } + $event = new ReactionAddedEvent($chat, $parentMessage, $actorType, $actorId, $actorDisplayName, $reaction, $comment); $this->dispatcher->dispatchTyped($event); diff --git a/lib/Controller/ReactionController.php b/lib/Controller/ReactionController.php index 2af41f4b63f..84befb07e4f 100644 --- a/lib/Controller/ReactionController.php +++ b/lib/Controller/ReactionController.php @@ -21,6 +21,7 @@ use OCA\Talk\Middleware\Attribute\RequireReadWriteConversation; use OCA\Talk\ResponseDefinitions; use OCA\Talk\Room; +use OCA\Talk\Service\ParticipantService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\PublicPage; @@ -39,6 +40,7 @@ public function __construct( IRequest $request, private readonly ReactionManager $reactionManager, private readonly SendService $matrixSendService, + private readonly ParticipantService $participantService, ) { parent::__construct($appName, $request); } @@ -81,7 +83,7 @@ public function react(int $messageId, string $reaction): DataResponse { if ($this->room->getObjectType() === Room::OBJECT_TYPE_MATRIX) { $this->matrixSendService->addReaction($this->getRoom(), $this->getParticipant(), $messageId, $reaction); } else { - $this->reactionManager->addReactionMessage( + $comment = $this->reactionManager->addReactionMessage( $this->getRoom(), $this->getParticipant()->getAttendee()->getActorType(), $this->getParticipant()->getAttendee()->getActorId(), @@ -89,6 +91,9 @@ public function react(int $messageId, string $reaction): DataResponse { $messageId, $reaction ); + if ($this->getRoom()->getType() === Room::TYPE_ONE_TO_ONE) { + $this->participantService->updateLastReadMessage($this->getParticipant(), (int)$comment->getId()); + } } $status = Http::STATUS_CREATED; } catch (NotFoundException) { diff --git a/src/components/LeftSidebar/ConversationsList/Conversation.spec.js b/src/components/LeftSidebar/ConversationsList/Conversation.spec.js index 6ed7a722f5f..9dbb7b35946 100644 --- a/src/components/LeftSidebar/ConversationsList/Conversation.spec.js +++ b/src/components/LeftSidebar/ConversationsList/Conversation.spec.js @@ -163,6 +163,23 @@ describe('ConversationItem.vue', () => { await testConversationLabel(item, 'Alice has joined the call') }) + test('displays own last reaction in one to one conversations', async () => { + item.type = CONVERSATION.TYPE.ONE_TO_ONE + item.lastMessage.actorId = 'actor-id-1' + item.lastMessage.message = '👍' + item.lastMessage.systemMessage = MESSAGE.SYSTEM_TYPE.REACTION + + await testConversationLabel(item, 'You reacted with 👍') + }) + + test('displays other participant last reaction in one to one conversations', async () => { + item.type = CONVERSATION.TYPE.ONE_TO_ONE + item.lastMessage.message = '👍' + item.lastMessage.systemMessage = MESSAGE.SYSTEM_TYPE.REACTION + + await testConversationLabel(item, 'Reacted with 👍') + }) + test('displays last message without author in one to one conversations', async () => { item.type = CONVERSATION.TYPE.ONE_TO_ONE await testConversationLabel(item, 'hello') diff --git a/src/composables/useConversationInfo.ts b/src/composables/useConversationInfo.ts index c9b236a1955..3ede9a00c32 100644 --- a/src/composables/useConversationInfo.ts +++ b/src/composables/useConversationInfo.ts @@ -165,6 +165,21 @@ export function useConversationInfo({ } } + if (lastMessage.value.systemMessage === MESSAGE.SYSTEM_TYPE.REACTION) { + const reaction = lastMessage.value.actorId === item.value.actorId && lastMessage.value.actorType === item.value.actorType + ? t('spreed', 'You reacted with {reaction}', { reaction: lastMessage.value.message }, { escape: false, sanitize: false }) + // TRANSLATORS The second person in one-to-one conversation has reacted with emoji '{reaction}' + : t('spreed', 'Reacted with {reaction}', { + reaction: lastMessage.value.message, + }, { escape: false, sanitize: false }) + return { + actor: null, + icon: null, + message: reaction, + title: reaction, + } + } + if (shortLastChatMessageAuthor.value === '') { return { actor: null, diff --git a/tests/integration/features/chat-3/reaction.feature b/tests/integration/features/chat-3/reaction.feature index dcff1222fdd..40dab69afa2 100644 --- a/tests/integration/features/chat-3/reaction.feature +++ b/tests/integration/features/chat-3/reaction.feature @@ -203,3 +203,38 @@ Feature: chat-3/reaction | actorType | actorId | actorDisplayName | reaction | | deleted_users | deleted_users | | 👎 | | deleted_users | deleted_users | | 👍 | + + Scenario: Reaction marks one-to-one conversation unread + Given user "participant1" creates room "room" (v4) + | roomType | 1 | + | invite | participant2 | + And user "participant1" sends message "Message 1" to room "room" with 201 + And user "participant2" reads message "Message 1" in room "room" with 200 + Then user "participant1" is participant of the following rooms (v4) + | id | type | lastMessage | unreadMessages | + | room | 1 | Message 1 | 0 | + And user "participant2" is participant of the following rooms (v4) + | id | type | lastMessage | unreadMessages | + | room | 1 | Message 1 | 0 | + When user "participant2" react with "👍" on message "Message 1" to room "room" with 201 + | actorType | actorId | actorDisplayName | reaction | + | users | participant2 | participant2-displayname | 👍 | + Then user "participant1" is participant of the following rooms (v4) + | id | type | lastMessage | unreadMessages | + | room | 1 | 👍 | 1 | + And user "participant2" is participant of the following rooms (v4) + | id | type | lastMessage | unreadMessages | + | room | 1 | 👍 | 0 | + + Scenario: Reaction does not mark group conversation unread + 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 + When user "participant2" react with "👍" on message "Message 1" to room "room" with 201 + | actorType | actorId | actorDisplayName | reaction | + | users | participant2 | participant2-displayname | 👍 | + Then user "participant1" is participant of the following rooms (v4) + | id | type | lastMessage | unreadMessages | + | room | 3 | Message 1 | 0 | diff --git a/tests/php/Chat/ChatManagerTest.php b/tests/php/Chat/ChatManagerTest.php index b4108fbdd53..9dd4039ed75 100644 --- a/tests/php/Chat/ChatManagerTest.php +++ b/tests/php/Chat/ChatManagerTest.php @@ -389,16 +389,27 @@ public function testWaitForNewMessagesWithWaiting(): void { $this->assertEquals($expected, $comments); } - public function testGetUnreadCount(): void { + public static function dataGetUnreadCount(): array { + return [ + [Room::TYPE_ONE_TO_ONE, ['comment', 'object_shared', 'reaction']], + [Room::TYPE_GROUP, ['comment', 'object_shared']], + [Room::TYPE_PUBLIC, ['comment', 'object_shared']], + ]; + } + + #[DataProvider('dataGetUnreadCount')] + public function testGetUnreadCount(int $roomType, array $verbs): void { /** @var Room&MockObject $chat */ $chat = $this->createMock(Room::class); $chat->expects($this->atLeastOnce()) ->method('getId') ->willReturn(23); + $chat->method('getType') + ->willReturn($roomType); $this->commentsManager->expects($this->once()) ->method('getNumberOfCommentsWithVerbsForObjectSinceComment') - ->with('chat', 23, 42, ['comment', 'object_shared']); + ->with('chat', 23, 42, $verbs); $this->chatManager->getUnreadCount($chat, 42); } From 59315d3feae2d4b7aafe8842b9d8ed0ad32e86b3 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Thu, 29 Oct 2026 17:15:11 +0100 Subject: [PATCH 2/3] fix(reaction): Restore last message when reaction is removed in one-to-one Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Maksim Sukharev --- lib/Chat/ReactionManager.php | 14 ++++++++++++++ tests/integration/features/chat-3/reaction.feature | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/Chat/ReactionManager.php b/lib/Chat/ReactionManager.php index dc80d1da4de..73e6e74e1e7 100644 --- a/lib/Chat/ReactionManager.php +++ b/lib/Chat/ReactionManager.php @@ -129,6 +129,20 @@ public function deleteReactionMessage(Room $chat, string $actorType, string $act $comment->setVerb(ChatManager::VERB_REACTION_DELETED); $this->commentsManager->save($comment); + if ($chat->getType() === Room::TYPE_ONE_TO_ONE && $chat->getLastMessageId() === (int)$comment->getId()) { + try { + $previousMessage = $this->chatManager->getPreviousMessageWithVerb( + $chat, + (int)$comment->getId(), + [ChatManager::VERB_MESSAGE, ChatManager::VERB_OBJECT_SHARED], + true, + ); + $this->roomService->setLastMessage($chat, $previousMessage); + } catch (NotFoundException) { + // No previous chat message, keep the deleted reaction as last message + } + } + $revokedComment = $this->chatManager->addSystemMessage( $chat, null, diff --git a/tests/integration/features/chat-3/reaction.feature b/tests/integration/features/chat-3/reaction.feature index 40dab69afa2..5721bdcae03 100644 --- a/tests/integration/features/chat-3/reaction.feature +++ b/tests/integration/features/chat-3/reaction.feature @@ -225,6 +225,14 @@ Feature: chat-3/reaction And user "participant2" is participant of the following rooms (v4) | id | type | lastMessage | unreadMessages | | room | 1 | 👍 | 0 | + When user "participant2" delete react with "👍" on message "Message 1" to room "room" with 200 + | actorType | actorId | actorDisplayName | reaction | + Then user "participant1" is participant of the following rooms (v4) + | id | type | lastMessage | unreadMessages | + | room | 1 | Message 1 | 0 | + And user "participant2" is participant of the following rooms (v4) + | id | type | lastMessage | unreadMessages | + | room | 1 | Message 1 | 0 | Scenario: Reaction does not mark group conversation unread Given user "participant1" creates room "room" (v4) From b75d32cacc881ae81901dbd6b3b524fdeb20fecf Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Thu, 29 Oct 2026 17:17:18 +0100 Subject: [PATCH 3/3] fix(reaction): Update one-to-one last message from realtime reactions Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Maksim Sukharev --- src/store/messagesStore.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/store/messagesStore.js b/src/store/messagesStore.js index 9c9133386ed..c7f1bf8d709 100644 --- a/src/store/messagesStore.js +++ b/src/store/messagesStore.js @@ -538,6 +538,18 @@ const actions = { reactionsStore.processReaction(token, message) } + // One-to-one conversations show reactions as last message + if (fromRealtime && conversation?.type === CONVERSATION.TYPE.ONE_TO_ONE) { + if (message.systemMessage === MESSAGE.SYSTEM_TYPE.REACTION + && message.id > conversation.lastMessage?.id) { + context.commit('updateConversationLastMessage', { token, lastMessage: message }) + } else if (message.systemMessage === MESSAGE.SYSTEM_TYPE.REACTION_REVOKED + && conversation.lastMessage?.systemMessage === MESSAGE.SYSTEM_TYPE.REACTION) { + // Server already rolled back lastMessage, store may not hold that message + context.dispatch('fetchConversation', { token }) + } + } + if ([MESSAGE.SYSTEM_TYPE.MESSAGE_DELETED, MESSAGE.SYSTEM_TYPE.MESSAGE_EDITED].includes(message.systemMessage)) { // update conversation lastMessage, if it was edited or deleted if (message.parent.id === conversation.lastMessage?.id) {