Skip to content

Commit 593dd11

Browse files
Merge pull request #17069 from existentialcoder/iss-6301
feat(reply_private): Enhance reply private quote
2 parents cd17adb + a302d29 commit 593dd11

26 files changed

Lines changed: 711 additions & 38 deletions

docs/capabilities.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,4 @@
224224
* `config => feature-hints => hidden` (local) - Number of the last hint the administration has hidden via the app config
225225
* `config => conversations => sort-order` (local) - User selected sort order for conversations (`activity` or `alphabetical`)
226226
* `config => conversations => group-mode` (local) - User selected grouping mode for conversations (`none`, `group-first` or `private-first`)
227+
* `private-reply` - Whether clients can link the original message to a private reply in one-to-one conversations

lib/AppInfo/Application.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use OCA\Talk\Chat\Changelog\Listener as ChangelogListener;
2121
use OCA\Talk\Chat\Listener as ChatListener;
2222
use OCA\Talk\Chat\Parser\Changelog;
23+
use OCA\Talk\Chat\Parser\PrivateReply;
2324
use OCA\Talk\Chat\Parser\ReactionParser;
2425
use OCA\Talk\Chat\Parser\SystemMessage;
2526
use OCA\Talk\Chat\Parser\UserMention;
@@ -235,6 +236,7 @@ public function register(IRegistrationContext $context): void {
235236
$context->registerEventListener(MessageParseEvent::class, Changelog::class, -75);
236237
$context->registerEventListener(MessageParseEvent::class, ReactionParser::class);
237238
$context->registerEventListener(MessageParseEvent::class, SystemMessage::class);
239+
$context->registerEventListener(MessageParseEvent::class, PrivateReply::class);
238240
$context->registerEventListener(MessageParseEvent::class, SystemMessage::class, 9999);
239241
$context->registerEventListener(MessageParseEvent::class, UserMention::class, -100);
240242

lib/Capabilities.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ class Capabilities implements IPublicCapability {
131131
'federated-shared-items',
132132
'scheduled-messages',
133133
'conversation-presets',
134+
'private-reply',
134135
];
135136

136137
public const CONDITIONAL_FEATURES = [

lib/Chat/ChatManager.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class ChatManager {
8585
public const VERB_VOICE_MESSAGE = 'voice-message';
8686
public const VERB_RECORD_AUDIO = 'record-audio';
8787
public const VERB_RECORD_VIDEO = 'record-video';
88+
public const VERB_PRIVATE_REPLY = 'private_reply';
8889

8990
/**
9091
* Last read message ID of -1 is set on the attendee table as default.
@@ -393,6 +394,8 @@ public function sendMessage(
393394
int $threadId = 0,
394395
string $threadTitle = '',
395396
bool $fromScheduledMessage = false,
397+
string $verb = self::VERB_MESSAGE,
398+
array $extraMetaData = [],
396399
): IComment {
397400
if ($chat->isFederatedConversation()) {
398401
$e = new MessagingNotAllowedException();
@@ -405,7 +408,7 @@ public function sendMessage(
405408
$comment->setCreationDateTime($creationDateTime);
406409
// A verb ('comment', 'like'...) must be provided to be able to save a
407410
// comment
408-
$comment->setVerb(self::VERB_MESSAGE);
411+
$comment->setVerb($verb);
409412

410413
if ($replyTo instanceof IComment) {
411414
$comment->setParentId($replyTo->getId());
@@ -443,7 +446,7 @@ public function sendMessage(
443446
if ($threadId !== Thread::THREAD_NONE) {
444447
$metadata[Message::METADATA_THREAD_ID] = $threadId;
445448
}
446-
$comment->setMetaData($metadata);
449+
$comment->setMetaData(array_merge($metadata, $extraMetaData));
447450

448451
$event = new BeforeChatMessageSentEvent($chat, $comment, $participant, $silent, $replyTo);
449452
$this->dispatcher->dispatchTyped($event);

lib/Chat/Parser/PrivateReply.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Talk\Chat\Parser;
10+
11+
use OCA\Talk\Chat\ChatManager;
12+
use OCA\Talk\Events\MessageParseEvent;
13+
use OCP\EventDispatcher\Event;
14+
use OCP\EventDispatcher\IEventListener;
15+
16+
/**
17+
* @template-implements IEventListener<Event>
18+
*/
19+
class PrivateReply implements IEventListener {
20+
21+
#[\Override]
22+
public function handle(Event $event): void {
23+
if (!$event instanceof MessageParseEvent) {
24+
return;
25+
}
26+
27+
$message = $event->getMessage();
28+
29+
if ($message->getMessageType() !== ChatManager::VERB_PRIVATE_REPLY) {
30+
return;
31+
}
32+
33+
if ($message->getComment()->getParentId() === '0') {
34+
$message->setVisibility(false);
35+
$event->stopPropagation();
36+
return;
37+
}
38+
}
39+
}

lib/Controller/ChatController.php

Lines changed: 125 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,86 @@ public function __construct(
143143
parent::__construct($appName, $request);
144144
}
145145

146+
/**
147+
* @return array{parent: IComment, parentMessage: Message}
148+
* @throws \InvalidArgumentException When a 400 should be thrown
149+
* @throws \DomainException When a 403 should be thrown
150+
*/
151+
private function resolveReplyTo(int $replyTo, string $replyToToken, string $actorType, string $actorId, \DateTime $creationDateTime): array {
152+
$isPrivateReplyFromAnotherConvo = $replyToToken != '';
153+
154+
try {
155+
$targetParentRoom = $isPrivateReplyFromAnotherConvo ? $this->manager->getRoomByToken($replyToToken) : $this->room;
156+
$parent = $this->chatManager->getParentComment($targetParentRoom, (string)$replyTo);
157+
} catch (NotFoundException $e) {
158+
throw new \InvalidArgumentException('reply-to', Http::STATUS_BAD_REQUEST);
159+
}
160+
161+
if ($isPrivateReplyFromAnotherConvo) {
162+
$isOneToOneRoom = $this->room->getType() === Room::TYPE_ONE_TO_ONE;
163+
if (!$isOneToOneRoom) {
164+
throw new \InvalidArgumentException('reply-to', Http::STATUS_BAD_REQUEST);
165+
}
166+
167+
$parentActorId = $parent->getActorId();
168+
$parentActorType = $parent->getActorType();
169+
// Validate if the members are part of the convo
170+
try {
171+
$this->participantService->getParticipantByActor($targetParentRoom, $actorType, $actorId);
172+
$this->participantService->getParticipantByActor($targetParentRoom, $parentActorType, $parentActorId);
173+
} catch (ParticipantNotFoundException $e) {
174+
throw new \DomainException('reply-to', Http::STATUS_FORBIDDEN);
175+
}
176+
177+
$originalParentMessage = $this->messageParser->createMessage($targetParentRoom, $this->participant, $parent, $this->l);
178+
$this->messageParser->parseMessage($originalParentMessage);
179+
180+
if (!$originalParentMessage->isReplyable()) {
181+
throw new \InvalidArgumentException('reply-to', Http::STATUS_BAD_REQUEST);
182+
}
183+
184+
$originalMessageData = $originalParentMessage->toArray('json', null);
185+
$originalMessageData['threadId'] = Thread::THREAD_NONE;
186+
$originalMessageData['reactions'] = new \stdClass();
187+
$originalMessageData['messageType'] = $originalParentMessage->getMessageType();
188+
189+
$extraMetaData = [
190+
'replyToMessageId' => $replyTo,
191+
'replyToConversationToken' => $replyToToken,
192+
'replyToConversationName' => $targetParentRoom->getName(),
193+
'replyToActorDisplayName' => $originalParentMessage->getActorDisplayName(),
194+
];
195+
$originalMessageData['metaData'] = array_merge($originalMessageData['metaData'] ?? [], $extraMetaData);
196+
// Create a link message for the private reply
197+
$copiedParent = $this->chatManager->sendMessage(
198+
$this->room,
199+
$this->participant,
200+
$parentActorType,
201+
$parentActorId,
202+
ChatManager::VERB_PRIVATE_REPLY,
203+
$creationDateTime,
204+
null,
205+
'',
206+
true,
207+
verb: ChatManager::VERB_PRIVATE_REPLY,
208+
extraMetaData: [
209+
'originalMessage' => $originalMessageData,
210+
],
211+
);
212+
213+
$parentMessage = $this->messageParser->createMessage($this->room, $this->participant, $copiedParent, $this->l);
214+
$this->messageParser->parseMessage($parentMessage);
215+
} else {
216+
$parentMessage = $this->messageParser->createMessage($targetParentRoom, $this->participant, $parent, $this->l);
217+
$this->messageParser->parseMessage($parentMessage);
218+
219+
if (!$parentMessage->isReplyable()) {
220+
throw new \InvalidArgumentException('reply-to', Http::STATUS_BAD_REQUEST);
221+
}
222+
}
223+
224+
return ['parent' => $isPrivateReplyFromAnotherConvo ? $copiedParent : $parent, 'parentMessage' => $parentMessage];
225+
}
146226
/**
147227
* @return list{0: Attendee::ACTOR_*, 1: string}
148228
*/
@@ -219,7 +299,12 @@ protected function parseCommentToResponse(IComment $comment, ?Message $parentMes
219299
}
220300
$data = $chatMessage->toArray($this->getResponseFormat(), $thread);
221301
if ($parentMessage instanceof Message) {
222-
$data['parent'] = $parentMessage->toArray($this->getResponseFormat(), $thread);
302+
$parentComment = $parentMessage->getComment();
303+
if ($parentComment->getVerb() === ChatManager::VERB_PRIVATE_REPLY && $parentComment->getParentId() === '0') {
304+
$data['parent'] = $this->buildPrivateReplyParentSnapshot($parentComment);
305+
} else {
306+
$data['parent'] = $parentMessage->toArray($this->getResponseFormat(), $thread);
307+
}
223308
}
224309

225310
$headers = [];
@@ -240,13 +325,15 @@ protected function parseCommentToResponse(IComment $comment, ?Message $parentMes
240325
* @param string $referenceId for the message to be able to later identify it again
241326
* @param int $replyTo Parent id which this message is a reply to
242327
* @psalm-param non-negative-int $replyTo
328+
* @param string $replyToToken Parent token to which reply is initiated
243329
* @param bool $silent If sent silent the chat message will not create any notifications
244330
* @param string $threadTitle Only supported when not replying, when given will create a thread (requires `threads` capability)
245331
* @param int $threadId Thread id which this message is a reply to without quoting a specific message (ignored when $replyTo is given, also requires `threads` capability)
246-
* @return DataResponse<Http::STATUS_CREATED, ?TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_REQUEST_ENTITY_TOO_LARGE|Http::STATUS_TOO_MANY_REQUESTS, array{error: string}, array{}>
332+
* @return DataResponse<Http::STATUS_CREATED, ?TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_REQUEST_ENTITY_TOO_LARGE|Http::STATUS_TOO_MANY_REQUESTS, array{error: string}, array{}>
247333
*
248334
* 201: Message sent successfully
249335
* 400: Sending message is not possible
336+
* 403: When trying to cross reference wrongly on a reply-private
250337
* 404: Actor not found
251338
* 413: Message too long
252339
* 429: Mention rate limit exceeded (guests only)
@@ -262,7 +349,7 @@ protected function parseCommentToResponse(IComment $comment, ?Message $parentMes
262349
'apiVersion' => '(v1)',
263350
'token' => '[a-z0-9]{4,30}',
264351
])]
265-
public function sendMessage(string $message, string $actorDisplayName = '', string $referenceId = '', int $replyTo = 0, bool $silent = false, string $threadTitle = '', int $threadId = 0): DataResponse {
352+
public function sendMessage(string $message, string $actorDisplayName = '', string $referenceId = '', int $replyTo = 0, string $replyToToken = '', bool $silent = false, string $threadTitle = '', int $threadId = 0): DataResponse {
266353
if ($this->room->isFederatedConversation()) {
267354
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
268355
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
@@ -279,27 +366,25 @@ public function sendMessage(string $message, string $actorDisplayName = '', stri
279366
}
280367

281368
$parent = $parentMessage = null;
369+
$creationDateTime = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'));
370+
282371
if ($replyTo !== 0) {
283372
try {
284-
$parent = $this->chatManager->getParentComment($this->room, (string)$replyTo);
285-
} catch (NotFoundException $e) {
286-
// Someone is trying to reply cross-rooms or to a non-existing message
287-
return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST);
288-
}
289-
290-
$parentMessage = $this->messageParser->createMessage($this->room, $this->participant, $parent, $this->l);
291-
$this->messageParser->parseMessage($parentMessage);
292-
if (!$parentMessage->isReplyable()) {
373+
$resolvedReplyTo = $this->resolveReplyTo($replyTo, $replyToToken, $actorType, $actorId, $creationDateTime);
374+
} catch (\InvalidArgumentException) {
293375
return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST);
376+
} catch (\DomainException) {
377+
return new DataResponse(['error' => 'reply-to'], Http::STATUS_FORBIDDEN);
294378
}
379+
$parent = $resolvedReplyTo['parent'];
380+
$parentMessage = $resolvedReplyTo['parentMessage'];
295381
} elseif ($threadId !== 0) {
296382
if (!$this->threadService->validateThread($this->room->getId(), $threadId)) {
297383
return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST);
298384
}
299385
}
300386

301387
$this->participantService->ensureOneToOneRoomIsFilled($this->room);
302-
$creationDateTime = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'));
303388

304389
try {
305390
$createThread = $replyTo === 0 && $threadId === Thread::THREAD_NONE && $threadTitle !== '';
@@ -398,10 +483,11 @@ public function getScheduledMessages(): DataResponse {
398483
* @param bool $silent If sent silent the scheduled message will not create any notifications when sent
399484
* @param string $threadTitle Only supported when not replying, when given will create a thread (requires `threads` capability)
400485
* @param int $threadId Thread id without quoting a specific message (requires `threads` capability)
401-
* @return DataResponse<Http::STATUS_CREATED, TalkScheduledMessage, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'message'|'reply-to'|'send-at'}, array{}>|DataResponse<Http::STATUS_REQUEST_ENTITY_TOO_LARGE, array{error: 'message'}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'actor'}, array{}>
486+
* @return DataResponse<Http::STATUS_CREATED, TalkScheduledMessage, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'message'|'reply-to'|'send-at'}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{error: 'reply-to'}, array{}>|DataResponse<Http::STATUS_REQUEST_ENTITY_TOO_LARGE, array{error: 'message'}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'actor'}, array{}>
402487
*
403488
* 201: Message scheduled successfully
404489
* 400: Scheduling the message is not possible
490+
* 403: When trying to cross reference wrongly on a reply-private
405491
* 404: Actor not found
406492
* 413: Message too long
407493
*/
@@ -435,19 +521,19 @@ public function scheduleMessage(
435521
}
436522

437523
$parent = $parentMessage = null;
524+
$creationDateTime = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'));
525+
438526
if ($replyTo !== 0) {
527+
[$actorType, $actorId] = $this->getActorInfo();
439528
try {
440-
$parent = $this->chatManager->getParentComment($this->room, (string)$replyTo);
441-
} catch (NotFoundException $e) {
442-
// Someone is trying to reply cross-rooms or to a non-existing message
443-
return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST);
444-
}
445-
446-
$parentMessage = $this->messageParser->createMessage($this->room, $this->participant, $parent, $this->l);
447-
$this->messageParser->parseMessage($parentMessage);
448-
if (!$parentMessage->isReplyable()) {
529+
$resolvedReplyTo = $this->resolveReplyTo($replyTo, '', $actorType, $actorId, $creationDateTime);
530+
} catch (\InvalidArgumentException) {
449531
return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST);
532+
} catch (\DomainException) {
533+
return new DataResponse(['error' => 'reply-to'], Http::STATUS_FORBIDDEN);
450534
}
535+
$parent = $resolvedReplyTo['parent'];
536+
$parentMessage = $resolvedReplyTo['parentMessage'];
451537
}
452538

453539
if ($threadId !== 0 && !$this->threadService->validateThread($this->room->getId(), $threadId)) {
@@ -1069,6 +1155,12 @@ protected function prepareCommentsAsDataResponse(array $comments, int $lastCommo
10691155
continue;
10701156
}
10711157

1158+
if ($comment->getVerb() === ChatManager::VERB_PRIVATE_REPLY && $comment->getParentId() === '0') {
1159+
$loadedParents[$parentId] = $this->buildPrivateReplyParentSnapshot($comment);
1160+
$messages[$commentKey]['parent'] = $loadedParents[$parentId];
1161+
continue;
1162+
}
1163+
10721164
$expireDate = $message->getComment()->getExpireDate();
10731165
if ($expireDate instanceof \DateTime && $expireDate < $now) {
10741166
$commentIdToIndex[$id] = null;
@@ -1671,6 +1763,16 @@ public function getUpcomingReminders(): DataResponse {
16711763
return new DataResponse($resultData, Http::STATUS_OK);
16721764
}
16731765

1766+
private function buildPrivateReplyParentSnapshot(IComment $comment): array {
1767+
$metaData = $comment->getMetaData() ?? [];
1768+
$message = $metaData['originalMessage'];
1769+
$message['id'] = (int)$comment->getId();
1770+
unset($metaData['originalMessage']);
1771+
$message['metaData'] = array_merge($metaData, $message['metaData'] ?? []);
1772+
1773+
return $message;
1774+
}
1775+
16741776
/**
16751777
* @throws DoesNotExistException
16761778
* @throws CannotReachRemoteException

lib/Model/Message.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ public function isReplyable(): bool {
168168
&& $this->getMessageType() !== ChatManager::VERB_COMMAND
169169
&& $this->getMessageType() !== ChatManager::VERB_MESSAGE_DELETED
170170
&& $this->getMessageType() !== ChatManager::VERB_REACTION
171+
&& $this->getMessageType() !== ChatManager::VERB_PRIVATE_REPLY
171172
&& $this->getMessageType() !== ChatManager::VERB_REACTION_DELETED
172173
&& \in_array($this->getActorType(), [
173174
Attendee::ACTOR_USERS,

lib/ResponseDefinitions.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,14 @@
190190
* threadId?: int,
191191
* // Set when a thread is created with this message. If missing, no thread creation is associated with this message
192192
* threadTitle?: string,
193+
* // Set only when a message in a convo is private replied on a 1-1 room. Represents the parent message id
194+
* replyToMessageId?: int,
195+
* // Set only when a message in a convo is private replied on a 1-1 room. Represents the parent message's group conversation token.
196+
* replyToConversationToken?: string,
197+
* // Set only when a message in a convo is private replied on a 1-1 room. Represents the parent message's group conversation name
198+
* replyToConversationName?: string,
199+
* // Set only when a message in a convo is private replied on a 1-1 room. Represents the parent message's actor display name
200+
* replyToActorDisplayName?: string,
193201
* }
194202
*
195203
* @psalm-type TalkChatMessage = TalkBaseMessage&array{

lib/Signaling/Listener.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,16 @@ protected function notifyMessageSent(AMessageSentEvent $event): void {
535535
if ($parent !== null) {
536536
$parentMessage = $this->messageParser->createMessage($event->getRoom(), null, $parent, $l10n);
537537
$this->messageParser->parseMessage($parentMessage);
538-
$data['chat']['comment']['parent'] = $parentMessage->toArray('json', $thread);
538+
if ($parent->getVerb() === ChatManager::VERB_PRIVATE_REPLY && $parent->getParentId() === '0') {
539+
$metaData = $parent->getMetaData() ?? [];
540+
$parentSnapshot = $metaData['originalMessage'];
541+
$parentSnapshot['id'] = (int)$parent->getId();
542+
unset($metaData['originalMessage']);
543+
$parentSnapshot['metaData'] = array_merge($metaData, $parentSnapshot['metaData'] ?? []);
544+
$data['chat']['comment']['parent'] = $parentSnapshot;
545+
} else {
546+
$data['chat']['comment']['parent'] = $parentMessage->toArray('json', $thread);
547+
}
539548
}
540549

541550
$this->externalSignaling->sendRoomMessage($room, $data);

0 commit comments

Comments
 (0)