Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,4 @@
* `config => feature-hints => hidden` (local) - Number of the last hint the administration has hidden via the app config
* `config => conversations => sort-order` (local) - User selected sort order for conversations (`activity` or `alphabetical`)
* `config => conversations => group-mode` (local) - User selected grouping mode for conversations (`none`, `group-first` or `private-first`)
* `private-reply` - Whether clients can link the original message to a private reply in one-to-one conversations
2 changes: 2 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use OCA\Talk\Chat\Changelog\Listener as ChangelogListener;
use OCA\Talk\Chat\Listener as ChatListener;
use OCA\Talk\Chat\Parser\Changelog;
use OCA\Talk\Chat\Parser\PrivateReply;
use OCA\Talk\Chat\Parser\ReactionParser;
use OCA\Talk\Chat\Parser\SystemMessage;
use OCA\Talk\Chat\Parser\UserMention;
Expand Down Expand Up @@ -235,6 +236,7 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(MessageParseEvent::class, Changelog::class, -75);
$context->registerEventListener(MessageParseEvent::class, ReactionParser::class);
$context->registerEventListener(MessageParseEvent::class, SystemMessage::class);
$context->registerEventListener(MessageParseEvent::class, PrivateReply::class);
$context->registerEventListener(MessageParseEvent::class, SystemMessage::class, 9999);
$context->registerEventListener(MessageParseEvent::class, UserMention::class, -100);

Expand Down
1 change: 1 addition & 0 deletions lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ class Capabilities implements IPublicCapability {
'federated-shared-items',
'scheduled-messages',
'conversation-presets',
'private-reply',
Comment thread
existentialcoder marked this conversation as resolved.
];

public const CONDITIONAL_FEATURES = [
Expand Down
7 changes: 5 additions & 2 deletions lib/Chat/ChatManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class ChatManager {
public const VERB_VOICE_MESSAGE = 'voice-message';
public const VERB_RECORD_AUDIO = 'record-audio';
public const VERB_RECORD_VIDEO = 'record-video';
public const VERB_PRIVATE_REPLY = 'private_reply';

/**
* Last read message ID of -1 is set on the attendee table as default.
Expand Down Expand Up @@ -393,6 +394,8 @@ public function sendMessage(
int $threadId = 0,
string $threadTitle = '',
bool $fromScheduledMessage = false,
string $verb = self::VERB_MESSAGE,
array $extraMetaData = [],
): IComment {
if ($chat->isFederatedConversation()) {
$e = new MessagingNotAllowedException();
Expand All @@ -405,7 +408,7 @@ public function sendMessage(
$comment->setCreationDateTime($creationDateTime);
// A verb ('comment', 'like'...) must be provided to be able to save a
// comment
$comment->setVerb(self::VERB_MESSAGE);
$comment->setVerb($verb);

if ($replyTo instanceof IComment) {
$comment->setParentId($replyTo->getId());
Expand Down Expand Up @@ -443,7 +446,7 @@ public function sendMessage(
if ($threadId !== Thread::THREAD_NONE) {
$metadata[Message::METADATA_THREAD_ID] = $threadId;
}
$comment->setMetaData($metadata);
$comment->setMetaData(array_merge($metadata, $extraMetaData));

$event = new BeforeChatMessageSentEvent($chat, $comment, $participant, $silent, $replyTo);
$this->dispatcher->dispatchTyped($event);
Expand Down
39 changes: 39 additions & 0 deletions lib/Chat/Parser/PrivateReply.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Talk\Chat\Parser;

use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Events\MessageParseEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;

/**
* @template-implements IEventListener<Event>
*/
class PrivateReply implements IEventListener {

#[\Override]
public function handle(Event $event): void {
if (!$event instanceof MessageParseEvent) {
return;
}

$message = $event->getMessage();

if ($message->getMessageType() !== ChatManager::VERB_PRIVATE_REPLY) {
return;
}

if ($message->getComment()->getParentId() === '0') {
$message->setVisibility(false);
$event->stopPropagation();
return;
}
}
}
148 changes: 125 additions & 23 deletions lib/Controller/ChatController.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,86 @@ public function __construct(
parent::__construct($appName, $request);
}

/**
* @return array{parent: IComment, parentMessage: Message}
* @throws \InvalidArgumentException When a 400 should be thrown
* @throws \DomainException When a 403 should be thrown
*/
private function resolveReplyTo(int $replyTo, string $replyToToken, string $actorType, string $actorId, \DateTime $creationDateTime): array {
$isPrivateReplyFromAnotherConvo = $replyToToken != '';

try {
$targetParentRoom = $isPrivateReplyFromAnotherConvo ? $this->manager->getRoomByToken($replyToToken) : $this->room;
$parent = $this->chatManager->getParentComment($targetParentRoom, (string)$replyTo);
} catch (NotFoundException $e) {
throw new \InvalidArgumentException('reply-to', Http::STATUS_BAD_REQUEST);
}

if ($isPrivateReplyFromAnotherConvo) {
$isOneToOneRoom = $this->room->getType() === Room::TYPE_ONE_TO_ONE;
if (!$isOneToOneRoom) {
throw new \InvalidArgumentException('reply-to', Http::STATUS_BAD_REQUEST);
}

$parentActorId = $parent->getActorId();
$parentActorType = $parent->getActorType();
// Validate if the members are part of the convo
try {
$this->participantService->getParticipantByActor($targetParentRoom, $actorType, $actorId);
$this->participantService->getParticipantByActor($targetParentRoom, $parentActorType, $parentActorId);
} catch (ParticipantNotFoundException $e) {
throw new \DomainException('reply-to', Http::STATUS_FORBIDDEN);
}

$originalParentMessage = $this->messageParser->createMessage($targetParentRoom, $this->participant, $parent, $this->l);
$this->messageParser->parseMessage($originalParentMessage);

Comment thread
nickvergessen marked this conversation as resolved.
if (!$originalParentMessage->isReplyable()) {
throw new \InvalidArgumentException('reply-to', Http::STATUS_BAD_REQUEST);
}

$originalMessageData = $originalParentMessage->toArray('json', null);
$originalMessageData['threadId'] = Thread::THREAD_NONE;
$originalMessageData['reactions'] = new \stdClass();
$originalMessageData['messageType'] = $originalParentMessage->getMessageType();

$extraMetaData = [
'replyToMessageId' => $replyTo,
'replyToConversationToken' => $replyToToken,
'replyToConversationName' => $targetParentRoom->getName(),
'replyToActorDisplayName' => $originalParentMessage->getActorDisplayName(),
];
$originalMessageData['metaData'] = array_merge($originalMessageData['metaData'] ?? [], $extraMetaData);
// Create a link message for the private reply
$copiedParent = $this->chatManager->sendMessage(
$this->room,
$this->participant,
$parentActorType,
$parentActorId,
ChatManager::VERB_PRIVATE_REPLY,
$creationDateTime,
null,
'',
true,
verb: ChatManager::VERB_PRIVATE_REPLY,
extraMetaData: [
'originalMessage' => $originalMessageData,
],
);

$parentMessage = $this->messageParser->createMessage($this->room, $this->participant, $copiedParent, $this->l);
$this->messageParser->parseMessage($parentMessage);
} else {
$parentMessage = $this->messageParser->createMessage($targetParentRoom, $this->participant, $parent, $this->l);
$this->messageParser->parseMessage($parentMessage);

if (!$parentMessage->isReplyable()) {
throw new \InvalidArgumentException('reply-to', Http::STATUS_BAD_REQUEST);
}
}

return ['parent' => $isPrivateReplyFromAnotherConvo ? $copiedParent : $parent, 'parentMessage' => $parentMessage];
}
/**
* @return list{0: Attendee::ACTOR_*, 1: string}
*/
Expand Down Expand Up @@ -219,7 +299,12 @@ protected function parseCommentToResponse(IComment $comment, ?Message $parentMes
}
$data = $chatMessage->toArray($this->getResponseFormat(), $thread);
if ($parentMessage instanceof Message) {
$data['parent'] = $parentMessage->toArray($this->getResponseFormat(), $thread);
$parentComment = $parentMessage->getComment();
if ($parentComment->getVerb() === ChatManager::VERB_PRIVATE_REPLY && $parentComment->getParentId() === '0') {
Comment thread
Antreesy marked this conversation as resolved.
$data['parent'] = $this->buildPrivateReplyParentSnapshot($parentComment);
} else {
$data['parent'] = $parentMessage->toArray($this->getResponseFormat(), $thread);
}
}

$headers = [];
Expand All @@ -240,13 +325,15 @@ protected function parseCommentToResponse(IComment $comment, ?Message $parentMes
* @param string $referenceId for the message to be able to later identify it again
* @param int $replyTo Parent id which this message is a reply to
* @psalm-param non-negative-int $replyTo
* @param string $replyToToken Parent token to which reply is initiated
* @param bool $silent If sent silent the chat message will not create any notifications
* @param string $threadTitle Only supported when not replying, when given will create a thread (requires `threads` capability)
* @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)
* @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{}>
* @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{}>
*
* 201: Message sent successfully
* 400: Sending message is not possible
* 403: When trying to cross reference wrongly on a reply-private
* 404: Actor not found
* 413: Message too long
* 429: Mention rate limit exceeded (guests only)
Expand All @@ -262,7 +349,7 @@ protected function parseCommentToResponse(IComment $comment, ?Message $parentMes
'apiVersion' => '(v1)',
'token' => '[a-z0-9]{4,30}',
])]
public function sendMessage(string $message, string $actorDisplayName = '', string $referenceId = '', int $replyTo = 0, bool $silent = false, string $threadTitle = '', int $threadId = 0): DataResponse {
public function sendMessage(string $message, string $actorDisplayName = '', string $referenceId = '', int $replyTo = 0, string $replyToToken = '', bool $silent = false, string $threadTitle = '', int $threadId = 0): DataResponse {
Comment thread
nickvergessen marked this conversation as resolved.
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
Expand All @@ -279,27 +366,25 @@ public function sendMessage(string $message, string $actorDisplayName = '', stri
}

$parent = $parentMessage = null;
$creationDateTime = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'));

if ($replyTo !== 0) {
try {
$parent = $this->chatManager->getParentComment($this->room, (string)$replyTo);
} catch (NotFoundException $e) {
// Someone is trying to reply cross-rooms or to a non-existing message
return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST);
}

$parentMessage = $this->messageParser->createMessage($this->room, $this->participant, $parent, $this->l);
$this->messageParser->parseMessage($parentMessage);
if (!$parentMessage->isReplyable()) {
$resolvedReplyTo = $this->resolveReplyTo($replyTo, $replyToToken, $actorType, $actorId, $creationDateTime);
} catch (\InvalidArgumentException) {
return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST);
} catch (\DomainException) {
return new DataResponse(['error' => 'reply-to'], Http::STATUS_FORBIDDEN);
}
$parent = $resolvedReplyTo['parent'];
$parentMessage = $resolvedReplyTo['parentMessage'];
} elseif ($threadId !== 0) {
if (!$this->threadService->validateThread($this->room->getId(), $threadId)) {
return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST);
}
}

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

try {
$createThread = $replyTo === 0 && $threadId === Thread::THREAD_NONE && $threadTitle !== '';
Expand Down Expand Up @@ -398,10 +483,11 @@ public function getScheduledMessages(): DataResponse {
* @param bool $silent If sent silent the scheduled message will not create any notifications when sent
* @param string $threadTitle Only supported when not replying, when given will create a thread (requires `threads` capability)
* @param int $threadId Thread id without quoting a specific message (requires `threads` capability)
* @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{}>
* @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{}>
*
* 201: Message scheduled successfully
* 400: Scheduling the message is not possible
* 403: When trying to cross reference wrongly on a reply-private
* 404: Actor not found
* 413: Message too long
*/
Expand Down Expand Up @@ -435,19 +521,19 @@ public function scheduleMessage(
}

$parent = $parentMessage = null;
$creationDateTime = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'));

if ($replyTo !== 0) {
[$actorType, $actorId] = $this->getActorInfo();
try {
$parent = $this->chatManager->getParentComment($this->room, (string)$replyTo);
} catch (NotFoundException $e) {
// Someone is trying to reply cross-rooms or to a non-existing message
return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST);
}

$parentMessage = $this->messageParser->createMessage($this->room, $this->participant, $parent, $this->l);
$this->messageParser->parseMessage($parentMessage);
if (!$parentMessage->isReplyable()) {
$resolvedReplyTo = $this->resolveReplyTo($replyTo, '', $actorType, $actorId, $creationDateTime);
} catch (\InvalidArgumentException) {
return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST);
} catch (\DomainException) {
return new DataResponse(['error' => 'reply-to'], Http::STATUS_FORBIDDEN);
}
$parent = $resolvedReplyTo['parent'];
$parentMessage = $resolvedReplyTo['parentMessage'];
}

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

if ($comment->getVerb() === ChatManager::VERB_PRIVATE_REPLY && $comment->getParentId() === '0') {
$loadedParents[$parentId] = $this->buildPrivateReplyParentSnapshot($comment);
$messages[$commentKey]['parent'] = $loadedParents[$parentId];
continue;
}

$expireDate = $message->getComment()->getExpireDate();
if ($expireDate instanceof \DateTime && $expireDate < $now) {
$commentIdToIndex[$id] = null;
Expand Down Expand Up @@ -1671,6 +1763,16 @@ public function getUpcomingReminders(): DataResponse {
return new DataResponse($resultData, Http::STATUS_OK);
}

private function buildPrivateReplyParentSnapshot(IComment $comment): array {
$metaData = $comment->getMetaData() ?? [];
$message = $metaData['originalMessage'];
$message['id'] = (int)$comment->getId();
unset($metaData['originalMessage']);
$message['metaData'] = array_merge($metaData, $message['metaData'] ?? []);

return $message;
}

/**
* @throws DoesNotExistException
* @throws CannotReachRemoteException
Expand Down
1 change: 1 addition & 0 deletions lib/Model/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ public function isReplyable(): bool {
&& $this->getMessageType() !== ChatManager::VERB_COMMAND
&& $this->getMessageType() !== ChatManager::VERB_MESSAGE_DELETED
&& $this->getMessageType() !== ChatManager::VERB_REACTION
&& $this->getMessageType() !== ChatManager::VERB_PRIVATE_REPLY
&& $this->getMessageType() !== ChatManager::VERB_REACTION_DELETED
&& \in_array($this->getActorType(), [
Attendee::ACTOR_USERS,
Expand Down
8 changes: 8 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@
* threadId?: int,
* // Set when a thread is created with this message. If missing, no thread creation is associated with this message
* threadTitle?: string,
* // Set only when a message in a convo is private replied on a 1-1 room. Represents the parent message id
* replyToMessageId?: int,
* // Set only when a message in a convo is private replied on a 1-1 room. Represents the parent message's group conversation token.
* replyToConversationToken?: string,
* // Set only when a message in a convo is private replied on a 1-1 room. Represents the parent message's group conversation name
* replyToConversationName?: string,
* // Set only when a message in a convo is private replied on a 1-1 room. Represents the parent message's actor display name
* replyToActorDisplayName?: string,
* }
*
* @psalm-type TalkChatMessage = TalkBaseMessage&array{
Expand Down
11 changes: 10 additions & 1 deletion lib/Signaling/Listener.php
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,16 @@ protected function notifyMessageSent(AMessageSentEvent $event): void {
if ($parent !== null) {
$parentMessage = $this->messageParser->createMessage($event->getRoom(), null, $parent, $l10n);
$this->messageParser->parseMessage($parentMessage);
$data['chat']['comment']['parent'] = $parentMessage->toArray('json', $thread);
if ($parent->getVerb() === ChatManager::VERB_PRIVATE_REPLY && $parent->getParentId() === '0') {
$metaData = $parent->getMetaData() ?? [];
$parentSnapshot = $metaData['originalMessage'];
$parentSnapshot['id'] = (int)$parent->getId();
unset($metaData['originalMessage']);
$parentSnapshot['metaData'] = array_merge($metaData, $parentSnapshot['metaData'] ?? []);
$data['chat']['comment']['parent'] = $parentSnapshot;
} else {
$data['chat']['comment']['parent'] = $parentMessage->toArray('json', $thread);
}
}

$this->externalSignaling->sendRoomMessage($room, $data);
Expand Down
Loading
Loading