diff --git a/appinfo/info.xml b/appinfo/info.xml index cb3af711408..72bdcbd1c20 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -18,7 +18,7 @@ * 🌉 **Sync with other chat solutions** With [Matterbridge](https://github.com/42wim/matterbridge/) being integrated in Talk, you can easily sync a lot of other chat solutions to Nextcloud Talk and vice-versa. ]]> - 23.0.0-dev.1 + 23.0.0-dev.2 agpl Anna Larch diff --git a/docs/capabilities.md b/docs/capabilities.md index d4ceabbcfb5..5c0294a46a6 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -197,3 +197,6 @@ ## 22 * `threads` - Whether the chat supports threads * `config => call => live-transcription` - Whether live transcription is supported in calls + +## 23 +* `pinned-messages` - Whether messages can be pinned diff --git a/lib/BackgroundJob/UnpinMessage.php b/lib/BackgroundJob/UnpinMessage.php new file mode 100644 index 00000000000..f866b8ca1f6 --- /dev/null +++ b/lib/BackgroundJob/UnpinMessage.php @@ -0,0 +1,64 @@ +manager->getRoomById($roomId); + } catch (RoomNotFoundException) { + return; + } + + $messageId = (int)$argument['messageId']; + try { + $comment = $this->chatManager->getComment($room, (string)$messageId); + } catch (NotFoundException) { + // Message most likely expired, reset the last_pinned_id if matching + if ($room->getLastPinnedId() === $messageId) { + $newLastPinned = 0; + $attachments = $this->attachmentService->getAttachmentsByType($room, Attachment::TYPE_PINNED, 0, 1); + if (isset($attachments[0])) { + $newLastPinned = $attachments[0]->getMessageId(); + } + $this->roomService->setLastPinnedId($room, $newLastPinned); + } + return; + } + + $this->chatManager->unpinMessage($room, $comment, null); + } +} diff --git a/lib/Capabilities.php b/lib/Capabilities.php index a82e8e8a78f..8b2317024dd 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -124,6 +124,7 @@ class Capabilities implements IPublicCapability { 'upcoming-reminders', 'sensitive-conversations', 'threads', + 'pinned-messages', ]; public const CONDITIONAL_FEATURES = [ diff --git a/lib/Chat/ChatManager.php b/lib/Chat/ChatManager.php index f2fa760feaa..ee127238c8a 100644 --- a/lib/Chat/ChatManager.php +++ b/lib/Chat/ChatManager.php @@ -11,6 +11,7 @@ use DateInterval; use OC\Memcache\ArrayCache; use OC\Memcache\NullCache; +use OCA\Talk\BackgroundJob\UnpinMessage; use OCA\Talk\CachePrefix; use OCA\Talk\Events\BeforeChatMessageSentEvent; use OCA\Talk\Events\BeforeSystemMessageSentEvent; @@ -19,6 +20,7 @@ use OCA\Talk\Exceptions\InvalidRoomException; use OCA\Talk\Exceptions\MessagingNotAllowedException; use OCA\Talk\Exceptions\ParticipantNotFoundException; +use OCA\Talk\Model\Attachment; use OCA\Talk\Model\Attendee; use OCA\Talk\Model\Message; use OCA\Talk\Model\Poll; @@ -34,6 +36,7 @@ use OCA\Talk\Share\RoomShareProvider; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; use OCP\Collaboration\Reference\IReferenceManager; use OCP\Comments\IComment; use OCP\Comments\MessageTooLongException; @@ -122,6 +125,7 @@ public function __construct( protected IReferenceManager $referenceManager, protected ILimiter $rateLimiter, protected IRequest $request, + protected IJobList $jobList, protected IL10N $l, protected LoggerInterface $logger, ) { @@ -751,6 +755,129 @@ public function editMessage(Room $chat, IComment $comment, Participant $particip ); } + public function pinMessage(Room $chat, IComment $comment, Participant $participant, int $pinUntil): ?IComment { + $metaData = $comment->getMetaData() ?? []; + + if (!empty($metaData[Message::METADATA_PINNED_MESSAGE_ID])) { + // Message is already pinned + return null; + } + + $message = $this->addSystemMessage( + $chat, + $participant, + $participant->getAttendee()->getActorType(), + $participant->getAttendee()->getActorId(), + json_encode(['message' => 'message_pinned', 'parameters' => ['message' => $comment->getId()]]), + $this->timeFactory->getDateTime(), + false, + null, + $comment, + ); + + if ($pinUntil === 0) { + // Pinned without expiration, pin until message expiration, if applicable + $pinUntil = $message->getExpireDate()?->getTimestamp() ?? 0; + } elseif ($message->getExpireDate()) { + // When the message expires, expire the pin latest at that time + $pinUntil = min($pinUntil, $message->getExpireDate()->getTimestamp()); + } + + $metaData[Message::METADATA_PINNED_AT] = $this->timeFactory->getTime(); + $metaData[Message::METADATA_PINNED_MESSAGE_ID] = (int)$message->getId(); + $metaData[Message::METADATA_PINNED_BY_TYPE] = $participant->getAttendee()->getActorType(); + $metaData[Message::METADATA_PINNED_BY_ID] = $participant->getAttendee()->getActorId(); + if ($pinUntil) { + $metaData[Message::METADATA_PINNED_UNTIL] = $pinUntil; + } + $comment->setMetaData($metaData); + $this->commentsManager->save($comment); + + $this->participantService->resetHiddenPinnedId($chat, (int)$comment->getId()); + + $this->roomService->setLastPinnedId($chat, (int)$comment->getId()); + + $this->attachmentService->createAttachmentEntryGeneric( + $chat, + $comment, + Attachment::TYPE_PINNED, + ); + + if ($pinUntil) { + $this->jobList->scheduleAfter( + UnpinMessage::class, + $pinUntil, + [ + 'roomId' => $chat->getId(), + 'messageId' => (int)$comment->getId(), + ], + ); + } + + return $message; + } + + public function unpinMessage(Room $chat, IComment $comment, ?Participant $participant): ?IComment { + $metaData = $comment->getMetaData() ?? []; + + if (empty($metaData[Message::METADATA_PINNED_MESSAGE_ID])) { + // Message is not pinned + return null; + } + + $pinnedId = (int)$comment->getId(); + unset( + $metaData[Message::METADATA_PINNED_AT], + $metaData[Message::METADATA_PINNED_MESSAGE_ID], + $metaData[Message::METADATA_PINNED_BY_TYPE], + $metaData[Message::METADATA_PINNED_BY_ID], + $metaData[Message::METADATA_PINNED_UNTIL], + ); + $comment->setMetaData($metaData); + $this->commentsManager->save($comment); + + $this->attachmentService->deleteAttachmentByMessageId($pinnedId); + + $this->jobList->remove( + UnpinMessage::class, + [ + 'roomId' => $chat->getId(), + 'messageId' => $pinnedId, + ], + ); + + if ($chat->getLastPinnedId() === $pinnedId) { + $newLastPinned = 0; + $attachments = $this->attachmentService->getAttachmentsByType($chat, Attachment::TYPE_PINNED, 0, 1); + if (isset($attachments[0])) { + $newLastPinned = $attachments[0]->getMessageId(); + } + $this->roomService->setLastPinnedId($chat, $newLastPinned); + } + + if ($participant instanceof Participant) { + $actorType = $participant->getAttendee()->getActorType(); + $actorId = $participant->getAttendee()->getActorId(); + } else { + $actorType = Attendee::ACTOR_GUESTS; + $actorId = Attendee::ACTOR_ID_SYSTEM; + } + + return $this->addSystemMessage( + $chat, + $participant, + $actorType, + $actorId, + json_encode(['message' => 'message_unpinned', 'parameters' => ['message' => $comment->getId()]]), + $this->timeFactory->getDateTime(), + false, + null, + $comment, + true, + true, + ); + } + protected static function compareMention(array $mention1, array $mention2): int { if ($mention1['type'] === $mention2['type']) { return $mention1['id'] <=> $mention2['id']; diff --git a/lib/Chat/MessageParser.php b/lib/Chat/MessageParser.php index 3794350c8c1..4bff008b5ce 100644 --- a/lib/Chat/MessageParser.php +++ b/lib/Chat/MessageParser.php @@ -44,7 +44,10 @@ public function __construct( } public function createMessage(Room $room, ?Participant $participant, IComment $comment, IL10N $l): Message { - return new Message($room, $participant, $comment, $l); + $message = new Message($room, $participant, $comment, $l); + $metaData = $this->addPinnedActorDisplayNameInfo($message, $comment->getMetaData() ?? []); + $message->setMetaData($metaData); + return $message; } public function createMessageFromProxyCache(Room $room, ?Participant $participant, ProxyCacheMessage $proxy, IL10N $l): Message { @@ -63,6 +66,15 @@ public function createMessageFromProxyCache(Room $room, ?Participant $participan $proxy->getParsedMessageParameters() ); + try { + $metaData = json_decode($proxy->getMetaData(), true, flags: JSON_THROW_ON_ERROR); + if (is_array($metaData)) { + $metaData = $this->addPinnedActorDisplayNameInfo($message, $metaData); + $message->setMetaData($metaData); + } + } catch (\JsonException) { + } + return $message; } @@ -121,6 +133,21 @@ protected function setLastEditInfo(Message $message): void { } } + protected function addPinnedActorDisplayNameInfo(Message $message, array $metaData): array { + if (isset($metaData[Message::METADATA_PINNED_BY_TYPE], $metaData[Message::METADATA_PINNED_BY_ID])) { + [$actorType, $actorId, $displayName] = $this->getActorInformation( + $message, + $metaData[Message::METADATA_PINNED_BY_TYPE], + $metaData[Message::METADATA_PINNED_BY_ID], + ); + + $metaData[Message::METADATA_PINNED_BY_TYPE] = $actorType; + $metaData[Message::METADATA_PINNED_BY_ID] = $actorId; + $metaData[Message::METADATA_PINNED_BY_NAME] = $displayName; + } + return $metaData; + } + protected function getActorInformation(Message $message, string $actorType, string $actorId, string $displayName = ''): array { if ($actorType === Attendee::ACTOR_USERS) { $tempDisplayName = $this->userManager->getDisplayName($actorId); diff --git a/lib/Chat/Parser/SystemMessage.php b/lib/Chat/Parser/SystemMessage.php index afde3b767fe..4026ea172f1 100644 --- a/lib/Chat/Parser/SystemMessage.php +++ b/lib/Chat/Parser/SystemMessage.php @@ -622,6 +622,21 @@ protected function parseMessage(Message $chatMessage, $allowInaccurate): void { if ($currentUserIsActor) { $parsedMessage = $this->l->t('You edited a message'); } + } elseif ($message === 'message_pinned') { + $parsedMessage = $this->l->t('{actor} pinned a message'); + if ($currentUserIsActor) { + $parsedMessage = $this->l->t('You pinned a message'); + } + } elseif ($message === 'message_unpinned') { + $systemIsActor = $parsedParameters['actor']['type'] === Attendee::ACTOR_GUESTS + && $parsedParameters['actor']['id'] === Attendee::ACTOR_ID_SYSTEM; + + $parsedMessage = $this->l->t('{actor} unpinned a message'); + if ($systemIsActor) { + $parsedMessage = $this->l->t('Message was automatically unpinned'); + } elseif ($currentUserIsActor) { + $parsedMessage = $this->l->t('You unpinned a message'); + } } elseif ($message === 'reaction_revoked') { $parsedMessage = $this->l->t('{actor} deleted a reaction'); if ($currentUserIsActor) { diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php index 9013f4181cb..d3c00bd974b 100644 --- a/lib/Controller/ChatController.php +++ b/lib/Controller/ChatController.php @@ -168,9 +168,34 @@ protected function getActorInfo(string $actorDisplayName = ''): array { } /** - * @return DataResponse + * @template S of Http::STATUS_* + * @param S $statusCode HTTP status code + * @return DataResponse */ - protected function parseCommentToResponse(IComment $comment, ?Message $parentMessage = null): DataResponse { + protected function parseCommentAndParentToResponse(?IComment $comment, ?IComment $parentComment = null, int $statusCode = Http::STATUS_CREATED): DataResponse { + if ($comment === null) { + $headers = []; + if ($this->participant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) { + $headers = ['X-Chat-Last-Common-Read' => (string)$this->chatManager->getLastCommonReadMessage($this->room)]; + } + return new DataResponse(null, $statusCode, $headers); + } + + $parsedParentMessage = null; + if ($parentComment !== null) { + $parsedParentMessage = $this->messageParser->createMessage($this->room, $this->participant, $parentComment, $this->l); + $this->messageParser->parseMessage($parsedParentMessage); + } + + return $this->parseCommentToResponse($comment, $parsedParentMessage, $statusCode); + } + + /** + * @template S of Http::STATUS_* + * @param S $statusCode HTTP status code + * @return DataResponse + */ + protected function parseCommentToResponse(IComment $comment, ?Message $parentMessage = null, int $statusCode = Http::STATUS_CREATED): DataResponse { $chatMessage = $this->messageParser->createMessage($this->room, $this->participant, $comment, $this->l); $this->messageParser->parseMessage($chatMessage); @@ -179,7 +204,7 @@ protected function parseCommentToResponse(IComment $comment, ?Message $parentMes if ($this->participant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) { $headers = ['X-Chat-Last-Common-Read' => (string)$this->chatManager->getLastCommonReadMessage($this->room)]; } - return new DataResponse(null, Http::STATUS_CREATED, $headers); + return new DataResponse(null, $statusCode, $headers); } try { @@ -197,7 +222,7 @@ protected function parseCommentToResponse(IComment $comment, ?Message $parentMes if ($this->participant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) { $headers = ['X-Chat-Last-Common-Read' => (string)$this->chatManager->getLastCommonReadMessage($this->room)]; } - return new DataResponse($data, Http::STATUS_CREATED, $headers); + return new DataResponse($data, $statusCode, $headers); } /** @@ -1571,6 +1596,7 @@ public function getObjectsSharedInRoomOverview(int $limit = 7): DataResponse { Attachment::TYPE_LOCATION, Attachment::TYPE_MEDIA, Attachment::TYPE_OTHER, + Attachment::TYPE_PINNED, Attachment::TYPE_POLL, Attachment::TYPE_RECORDING, Attachment::TYPE_VOICE, @@ -1595,6 +1621,11 @@ public function getObjectsSharedInRoomOverview(int $limit = 7): DataResponse { $messagesByType[$objectType][] = $messages[$messageId]; } } + + if ($objectType === Attachment::TYPE_PINNED) { + // Enforce sort order of pinned messages again after loading them from the comments table instead of attachments + uasort($messagesByType[$objectType], static fn (array $m1, array $m2): int => ($m1['metaData'][Message::METADATA_PINNED_AT] ?? 0) <=> ($m2['metaData'][Message::METADATA_PINNED_AT] ?? 0)); + } } return new DataResponse($messagesByType, Http::STATUS_OK); @@ -1626,6 +1657,10 @@ public function getObjectsSharedInRoom(string $objectType, int $lastKnownMessage $messageIds = array_map(static fn (Attachment $attachment): int => $attachment->getMessageId(), $attachments); $messages = $this->getMessagesForRoom($messageIds); + if ($objectType === Attachment::TYPE_PINNED) { + // Enforce sort order of pinned messages again after loading them from the comments table instead of attachments + uasort($messages, static fn (array $m1, array $m2): int => ($m1['metaData'][Message::METADATA_PINNED_AT] ?? 0) <=> ($m2['metaData'][Message::METADATA_PINNED_AT] ?? 0)); + } $headers = []; if (!empty($messages)) { @@ -1636,6 +1671,113 @@ public function getObjectsSharedInRoom(string $objectType, int $lastKnownMessage return new DataResponse($messages, Http::STATUS_OK, $headers); } + /** + * Pin a message in a chat as a moderator + * + * Required capability: `pinned-messages` + * + * @param int $messageId ID of the message + * @psalm-param non-negative-int $messageId + * @param int $pinUntil Unix timestamp when to unpin the message + * @psalm-param non-negative-int $pinUntil + * @return DataResponse|DataResponse + * + * 200: Message was pinned successfully + * 400: Message could not be pinned + * 404: Message was not found + */ + #[PublicPage] + #[RequireModeratorParticipant] + #[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/chat/{token}/{messageId}/pin', requirements: [ + 'apiVersion' => '(v1)', + 'token' => '[a-z0-9]{4,30}', + 'messageId' => '[0-9]+', + ])] + public function pinMessage(int $messageId, int $pinUntil = 0): DataResponse { + // FIXME add federation + + try { + $comment = $this->chatManager->getComment($this->room, (string)$messageId); + } catch (NotFoundException) { + return new DataResponse(['error' => 'message'], Http::STATUS_NOT_FOUND); + } + + if ($comment->getVerb() !== ChatManager::VERB_MESSAGE && $comment->getVerb() !== ChatManager::VERB_OBJECT_SHARED) { + // System message (since the message is not parsed, it has type "system") + return new DataResponse(['error' => 'message'], Http::STATUS_BAD_REQUEST); + } + + if ($pinUntil !== 0 && $pinUntil < $this->timeFactory->getTime()) { + // System message (since the message is not parsed, it has type "system") + return new DataResponse(['error' => 'until'], Http::STATUS_BAD_REQUEST); + } + + $systemMessageComment = $this->chatManager->pinMessage($this->room, $comment, $this->participant, $pinUntil); + + return $this->parseCommentAndParentToResponse($systemMessageComment, $comment, Http::STATUS_OK); + } + + /** + * Unpin a message in a chat as a moderator + * + * Required capability: `pinned-messages` + * + * @param int $messageId ID of the message + * @psalm-param non-negative-int $messageId + * @return DataResponse|DataResponse + * + * 200: Message is not pinned now + * 404: Message was not found + */ + #[PublicPage] + #[RequireModeratorParticipant] + #[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/chat/{token}/{messageId}/pin', requirements: [ + 'apiVersion' => '(v1)', + 'token' => '[a-z0-9]{4,30}', + 'messageId' => '[0-9]+', + ])] + public function unpinMessage(int $messageId): DataResponse { + // FIXME add federation + + try { + $comment = $this->chatManager->getComment($this->room, (string)$messageId); + } catch (NotFoundException) { + return new DataResponse(['error' => 'message'], Http::STATUS_NOT_FOUND); + } + + $systemMessageComment = $this->chatManager->unpinMessage($this->room, $comment, $this->participant); + + return $this->parseCommentAndParentToResponse($systemMessageComment, $comment, Http::STATUS_OK); + } + + /** + * Hide a message in a chat as a user + * + * Required capability: `pinned-messages` + * + * @param int $messageId ID of the message + * @psalm-param non-negative-int $messageId + * @return DataResponse|DataResponse + * + * 200: Pinned message is now hidden + * 404: Message was not found + */ + #[PublicPage] + #[RequireModeratorOrNoLobby] + #[RequireParticipant] + #[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/chat/{token}/{messageId}/pin/self', requirements: [ + 'apiVersion' => '(v1)', + 'token' => '[a-z0-9]{4,30}', + 'messageId' => '[0-9]+', + ])] + public function hidePinnedMessage(int $messageId): DataResponse { + $this->participantService->hidePinnedMessage($this->participant, $messageId); + return new DataResponse(null, Http::STATUS_OK); + } + /** * @return array */ diff --git a/lib/Federation/CloudFederationProviderTalk.php b/lib/Federation/CloudFederationProviderTalk.php index 83c5054defd..111bc274b59 100644 --- a/lib/Federation/CloudFederationProviderTalk.php +++ b/lib/Federation/CloudFederationProviderTalk.php @@ -463,7 +463,21 @@ private function messagePosted(int $remoteAttendeeId, array $notification): arra // Note: `messageParameters` (array during parsing) vs `messageParameter` (string during sending) $notification['messageData']['messageParameters'] = json_decode($notification['messageData']['messageParameter'], true, flags: JSON_THROW_ON_ERROR); unset($notification['messageData']['messageParameter']); + + if (isset($notification['messageData']['metaData'])) { + // Decode metaData to array and after converting back to string + $notification['messageData']['metaData'] = json_decode($notification['messageData']['metaData'], true, flags: JSON_THROW_ON_ERROR); + } else { + $notification['messageData']['metaData'] = []; + } + $converted = $this->userConverter->convertMessage($room, $notification['messageData']); + + if (!empty($converted['metaData'])) { + $converted['metaData'] = json_encode($converted['metaData'], JSON_THROW_ON_ERROR); + } else { + unset($converted['metaData']); + } $converted['messageParameter'] = json_encode($converted['messageParameters'], JSON_THROW_ON_ERROR); unset($converted['messageParameters']); diff --git a/lib/Federation/Proxy/TalkV1/UserConverter.php b/lib/Federation/Proxy/TalkV1/UserConverter.php index 4d59c78ec82..0364ed3f44e 100644 --- a/lib/Federation/Proxy/TalkV1/UserConverter.php +++ b/lib/Federation/Proxy/TalkV1/UserConverter.php @@ -139,6 +139,9 @@ public function convertMessage(Room $room, array $message): array { $message['token'] = $room->getToken(); $message = $this->convertAttendee($room, $message, 'actorType', 'actorId', 'actorDisplayName'); $message = $this->convertAttendee($room, $message, 'lastEditActorType', 'lastEditActorId', 'lastEditActorDisplayName'); + if (!empty($message['metaData']['pinnedActorType'])) { + $message['metaData'] = $this->convertAttendee($room, $message['metaData'], 'pinnedActorType', 'pinnedActorId', 'pinnedActorDisplayName'); + } $message = $this->convertMessageParameters($room, $message); if (isset($message['parent'])) { diff --git a/lib/Manager.php b/lib/Manager.php index e330169a73a..e8511ac6100 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -124,6 +124,7 @@ public function createRoomObjectFromData(array $data): Room { 'has_federation' => 0, 'mention_permissions' => 0, 'transcription_language' => '', + 'last_pinned_id' => 0, ], $data)); } @@ -194,6 +195,7 @@ public function createRoomObject(array $row): Room { (int)$row['has_federation'], (int)$row['mention_permissions'], (string)$row['transcription_language'], + (int)$row['last_pinned_id'], ); } diff --git a/lib/Migration/Version23000Date20251030090219.php b/lib/Migration/Version23000Date20251030090219.php new file mode 100644 index 00000000000..840edb7cb3f --- /dev/null +++ b/lib/Migration/Version23000Date20251030090219.php @@ -0,0 +1,49 @@ +getTable('talk_rooms'); + if (!$table->hasColumn('last_pinned_id')) { + $table->addColumn('last_pinned_id', Types::BIGINT, [ + 'notnull' => false, + 'default' => 0, + ]); + } + + $table = $schema->getTable('talk_attendees'); + if (!$table->hasColumn('hidden_pinned_id')) { + $table->addColumn('hidden_pinned_id', Types::BIGINT, [ + 'notnull' => false, + 'default' => 0, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/Attachment.php b/lib/Model/Attachment.php index 37588f020a2..3232cc83541 100644 --- a/lib/Model/Attachment.php +++ b/lib/Model/Attachment.php @@ -32,6 +32,7 @@ class Attachment extends Entity { public const TYPE_LOCATION = 'location'; public const TYPE_MEDIA = 'media'; public const TYPE_OTHER = 'other'; + public const TYPE_PINNED = 'pinned'; public const TYPE_POLL = 'poll'; public const TYPE_RECORDING = 'recording'; public const TYPE_VOICE = 'voice'; diff --git a/lib/Model/Attendee.php b/lib/Model/Attendee.php index 430473067d6..caf34e5e574 100644 --- a/lib/Model/Attendee.php +++ b/lib/Model/Attendee.php @@ -70,6 +70,8 @@ * @method bool getHasUnreadThreadMentions() * @method void setHasUnreadThreadDirects(bool $hasUnreadThreadDirects) * @method bool getHasUnreadThreadDirects() + * @method void setHiddenPinnedId(int $hiddenPinnedId) + * @method int getHiddenPinnedId() */ class Attendee extends Entity { public const ACTOR_USERS = 'users'; @@ -142,6 +144,7 @@ class Attendee extends Entity { protected bool $hasUnreadThreads = false; protected bool $hasUnreadThreadMentions = false; protected bool $hasUnreadThreadDirects = false; + protected int $hiddenPinnedId = 0; public function __construct() { $this->addType('roomId', Types::BIGINT); @@ -173,6 +176,7 @@ public function __construct() { $this->addType('hasUnreadThreads', Types::BOOLEAN); $this->addType('hasUnreadThreadMentions', Types::BOOLEAN); $this->addType('hasUnreadThreadDirects', Types::BOOLEAN); + $this->addType('hiddenPinnedId', Types::BIGINT); } public function getDisplayName(): string { diff --git a/lib/Model/AttendeeMapper.php b/lib/Model/AttendeeMapper.php index 4577019d2a7..73bff825be3 100644 --- a/lib/Model/AttendeeMapper.php +++ b/lib/Model/AttendeeMapper.php @@ -314,6 +314,7 @@ public function createAttendeeFromRow(array $row): Attendee { 'has_unread_threads' => (bool)$row['has_unread_threads'], 'has_unread_thread_mentions' => (bool)$row['has_unread_thread_mentions'], 'has_unread_thread_directs' => (bool)$row['has_unread_thread_directs'], + 'hidden_pinned_id' => (int)$row['hidden_pinned_id'], ]); } } diff --git a/lib/Model/Message.php b/lib/Model/Message.php index 75e86de4e7a..a27bade533a 100644 --- a/lib/Model/Message.php +++ b/lib/Model/Message.php @@ -25,42 +25,33 @@ class Message { public const METADATA_SILENT = 'silent'; public const METADATA_CAN_MENTION_ALL = 'can_mention_all'; public const METADATA_THREAD_ID = 'thread_id'; - - /** @var bool */ - protected $visible = true; - - /** @var string */ - protected $type = ''; - - /** @var string */ - protected $message = ''; - - /** @var string */ - protected $rawMessage = ''; - - /** @var array */ - protected $parameters = []; - - /** @var string */ - protected $actorType = ''; - - /** @var string */ - protected $actorId = ''; - - /** @var string */ - protected $actorDisplayName = ''; - - /** @var string */ - protected $lastEditActorType = ''; - - /** @var string */ - protected $lastEditActorId = ''; - - /** @var string */ - protected $lastEditActorDisplayName = ''; - - /** @var int */ - protected $lastEditTimestamp = 0; + public const METADATA_PINNED_BY_TYPE = 'pinned_by_type'; + public const METADATA_PINNED_BY_ID = 'pinned_by_id'; + public const METADATA_PINNED_BY_NAME = 'pinned_by_name'; + public const METADATA_PINNED_MESSAGE_ID = 'pinned_id'; + public const METADATA_PINNED_AT = 'pinned_at'; + public const METADATA_PINNED_UNTIL = 'pinned_until'; + public const EXPOSED_METADATA_KEYS = [ + self::METADATA_PINNED_BY_TYPE => 'pinnedActorType', + self::METADATA_PINNED_BY_ID => 'pinnedActorId', + self::METADATA_PINNED_BY_NAME => 'pinnedActorDisplayName', + self::METADATA_PINNED_AT => 'pinnedAt', + self::METADATA_PINNED_UNTIL => 'pinnedUntil', + ]; + + protected bool $visible = true; + protected string $type = ''; + protected string $message = ''; + protected string $rawMessage = ''; + protected array $parameters = []; + protected string $actorType = ''; + protected string $actorId = ''; + protected string $actorDisplayName = ''; + protected string $lastEditActorType = ''; + protected string $lastEditActorId = ''; + protected string $lastEditActorDisplayName = ''; + protected int $lastEditTimestamp = 0; + protected array $metaData = []; public function __construct( protected Room $room, @@ -150,6 +141,10 @@ public function setLastEdit(string $type, string $id, string $displayName, int $ $this->lastEditTimestamp = $timestamp; } + public function setMetaData(array $metaData): void { + $this->metaData = $metaData; + } + public function getActorType(): string { return $this->actorType; } @@ -238,6 +233,17 @@ public function toArray(string $format, ?Thread $thread): array { $data[self::METADATA_SILENT] = true; } + $data['metaData'] = []; + foreach (self::EXPOSED_METADATA_KEYS as $exposedKey => $exposedAs) { + if (isset($this->metaData[$exposedKey])) { + $data['metaData'][$exposedAs] = $this->metaData[$exposedKey]; + } + } + + if (empty($data['metaData'])) { + unset($data['metaData']); + } + return $data; } } diff --git a/lib/Model/SelectHelper.php b/lib/Model/SelectHelper.php index 304c614b949..4d41a4edfe2 100644 --- a/lib/Model/SelectHelper.php +++ b/lib/Model/SelectHelper.php @@ -46,6 +46,7 @@ public function selectRoomsTable(IQueryBuilder $query, string $alias = 'r'): voi ->addSelect($alias . 'has_federation') ->addSelect($alias . 'mention_permissions') ->addSelect($alias . 'transcription_language') + ->addSelect($alias . 'last_pinned_id') ->selectAlias($alias . 'id', 'r_id'); } @@ -83,6 +84,7 @@ public function selectAttendeesTable(IQueryBuilder $query, string $alias = 'a'): ->addSelect($alias . 'has_unread_threads') ->addSelect($alias . 'has_unread_thread_mentions') ->addSelect($alias . 'has_unread_thread_directs') + ->addSelect($alias . 'hidden_pinned_id') ->selectAlias($alias . 'id', 'a_id'); } diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index e05fb4aba30..b55aaabd516 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -104,6 +104,19 @@ * systemMessage: string, * } * + * @psalm-type TalkChatMessageMetaData = array{ + * // Actor type of the attendee that pinned the message - Required capability: `pinned-messages` + * pinnedActorType?: string, + * // Actor ID of the attendee that pinned the message - Required capability: `pinned-messages` + * pinnedActorId?: string, + * // Display name of the attendee that pinned the message - Required capability: `pinned-messages` + * pinnedActorDisplayName?: string, + * // Timestamp when the message was pinned - Required capability: `pinned-messages` + * pinnedAt?: int, + * // Timestamp until when the message is pinned. If missing the message is pinned infinitely - Required capability: `pinned-messages` + * pinnedUntil?: int, + * } + * * @psalm-type TalkChatMessage = TalkBaseMessage&array{ * deleted?: true, * id: int, @@ -123,6 +136,7 @@ * isThread?: bool, * threadTitle?: string, * threadReplies?: int, + * metaData?: TalkChatMessageMetaData, * } * * @psalm-type TalkChatProxyMessage = TalkBaseMessage @@ -369,6 +383,10 @@ * isImportant: bool, * // Required capability: `sensitive-conversations` * isSensitive: bool, + * // Required capability: `pinned-messages` + * lastPinnedId: int, + * // Required capability: `pinned-messages` + * hiddenPinnedId: int, * } * * @psalm-type TalkDashboardEventAttachment = array{ diff --git a/lib/Room.php b/lib/Room.php index 4795f4f4094..27c6379b5bb 100644 --- a/lib/Room.php +++ b/lib/Room.php @@ -143,6 +143,7 @@ public function __construct( private int $hasFederation, private int $mentionPermissions, private string $liveTranscriptionLanguageId, + private int $lastPinnedId, ) { } @@ -617,4 +618,12 @@ public function getLiveTranscriptionLanguageId(): string { public function setLiveTranscriptionLanguageId(string $liveTranscriptionLanguageId): void { $this->liveTranscriptionLanguageId = $liveTranscriptionLanguageId; } + + public function getLastPinnedId(): int { + return $this->lastPinnedId; + } + + public function setLastPinnedId(int $lastPinnedId): void { + $this->lastPinnedId = $lastPinnedId; + } } diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index 1f4e98c5763..32a93cc178a 100644 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -22,6 +22,17 @@ public function __construct( ) { } + public function createAttachmentEntryGeneric(Room $room, IComment $comment, string $attachmentType): void { + $attachment = new Attachment(); + $attachment->setRoomId($room->getId()); + $attachment->setActorType($comment->getActorType()); + $attachment->setActorId($comment->getActorId()); + $attachment->setMessageId((int)$comment->getId()); + $attachment->setMessageTime($comment->getCreationDateTime()->getTimestamp()); + $attachment->setObjectType($attachmentType); + $this->attachmentMapper->insert($attachment); + } + public function createAttachmentEntry(Room $room, IComment $comment, string $messageType, array $parameters): void { $attachment = new Attachment(); $attachment->setRoomId($room->getId()); diff --git a/lib/Service/ParticipantService.php b/lib/Service/ParticipantService.php index 71a29073d77..50df51fa034 100644 --- a/lib/Service/ParticipantService.php +++ b/lib/Service/ParticipantService.php @@ -364,6 +364,22 @@ public function markConversationAsInsensitive(Participant $participant): void { $this->attendeeMapper->update($attendee); } + public function resetHiddenPinnedId(Room $room, int $messagesId): void { + $query = $this->connection->getQueryBuilder(); + $query->update('talk_attendees') + ->set('hidden_pinned_id', $query->createNamedParameter(0)) + ->where($query->expr()->eq('room_id', $query->createNamedParameter($room->getId()))) + ->andWhere($query->expr()->eq('hidden_pinned_id', $query->createNamedParameter($messagesId))); + $query->executeStatement(); + } + + public function hidePinnedMessage(Participant $participant, int $messagesId): void { + $attendee = $participant->getAttendee(); + $attendee->setHiddenPinnedId($messagesId); + $attendee->setLastAttendeeActivity($this->timeFactory->getTime()); + $this->attendeeMapper->update($attendee); + } + /** * @param RoomService $roomService * @param Room $room diff --git a/lib/Service/RoomFormatter.php b/lib/Service/RoomFormatter.php index 15068ef041c..ebfb32e5436 100644 --- a/lib/Service/RoomFormatter.php +++ b/lib/Service/RoomFormatter.php @@ -153,6 +153,8 @@ public function formatRoomV4( 'recordingConsent' => $this->talkConfig->recordingConsentRequired() === RecordingService::CONSENT_REQUIRED_OPTIONAL ? $room->getRecordingConsent() : $this->talkConfig->recordingConsentRequired(), 'mentionPermissions' => Room::MENTION_PERMISSIONS_EVERYONE, 'liveTranscriptionLanguageId' => '', + 'lastPinnedId' => 0, + 'hiddenPinnedId' => 0, 'isArchived' => false, 'isImportant' => false, 'isSensitive' => false, @@ -243,6 +245,8 @@ public function formatRoomV4( 'isArchived' => $attendee->isArchived(), 'isImportant' => $attendee->isImportant(), 'isSensitive' => $attendee->isSensitive(), + 'lastPinnedId' => $room->getLastPinnedId(), + 'hiddenPinnedId' => $attendee->getHiddenPinnedId(), ]); if ($room->isFederatedConversation()) { diff --git a/lib/Service/RoomService.php b/lib/Service/RoomService.php index f488c28e542..61f406f09e7 100644 --- a/lib/Service/RoomService.php +++ b/lib/Service/RoomService.php @@ -882,6 +882,16 @@ public function setLiveTranscriptionLanguageId(Room $room, string $newState): vo $this->dispatcher->dispatchTyped($event); } + public function setLastPinnedId(Room $room, int $lastPinnedId): void { + $update = $this->db->getQueryBuilder(); + $update->update('talk_rooms') + ->set('last_pinned_id', $update->createNamedParameter($lastPinnedId)) + ->where($update->expr()->eq('id', $update->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT))); + $update->executeStatement(); + + $room->setLastPinnedId($lastPinnedId); + } + public function setAssignedSignalingServer(Room $room, ?int $signalingServer): bool { $update = $this->db->getQueryBuilder(); $update->update('talk_rooms') diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index fe8c8593a87..99c622f2416 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -446,11 +446,41 @@ "threadReplies": { "type": "integer", "format": "int64" + }, + "metaData": { + "$ref": "#/components/schemas/ChatMessageMetaData" } } } ] }, + "ChatMessageMetaData": { + "type": "object", + "properties": { + "pinnedActorType": { + "type": "string", + "description": "Actor type of the attendee that pinned the message - Required capability: `pinned-messages`" + }, + "pinnedActorId": { + "type": "string", + "description": "Actor ID of the attendee that pinned the message - Required capability: `pinned-messages`" + }, + "pinnedActorDisplayName": { + "type": "string", + "description": "Display name of the attendee that pinned the message - Required capability: `pinned-messages`" + }, + "pinnedAt": { + "type": "integer", + "format": "int64", + "description": "Timestamp when the message was pinned - Required capability: `pinned-messages`" + }, + "pinnedUntil": { + "type": "integer", + "format": "int64", + "description": "Timestamp until when the message is pinned. If missing the message is pinned infinitely - Required capability: `pinned-messages`" + } + } + }, "ChatProxyMessage": { "$ref": "#/components/schemas/BaseMessage" }, @@ -660,7 +690,9 @@ "unreadMessages", "isArchived", "isImportant", - "isSensitive" + "isSensitive", + "lastPinnedId", + "hiddenPinnedId" ], "properties": { "actorId": { @@ -953,6 +985,16 @@ "isSensitive": { "type": "boolean", "description": "Required capability: `sensitive-conversations`" + }, + "lastPinnedId": { + "type": "integer", + "format": "int64", + "description": "Required capability: `pinned-messages`" + }, + "hiddenPinnedId": { + "type": "integer", + "format": "int64", + "description": "Required capability: `pinned-messages`" } } }, diff --git a/openapi-federation.json b/openapi-federation.json index 95219efcfdf..4081cd3f1f2 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -446,11 +446,41 @@ "threadReplies": { "type": "integer", "format": "int64" + }, + "metaData": { + "$ref": "#/components/schemas/ChatMessageMetaData" } } } ] }, + "ChatMessageMetaData": { + "type": "object", + "properties": { + "pinnedActorType": { + "type": "string", + "description": "Actor type of the attendee that pinned the message - Required capability: `pinned-messages`" + }, + "pinnedActorId": { + "type": "string", + "description": "Actor ID of the attendee that pinned the message - Required capability: `pinned-messages`" + }, + "pinnedActorDisplayName": { + "type": "string", + "description": "Display name of the attendee that pinned the message - Required capability: `pinned-messages`" + }, + "pinnedAt": { + "type": "integer", + "format": "int64", + "description": "Timestamp when the message was pinned - Required capability: `pinned-messages`" + }, + "pinnedUntil": { + "type": "integer", + "format": "int64", + "description": "Timestamp until when the message is pinned. If missing the message is pinned infinitely - Required capability: `pinned-messages`" + } + } + }, "ChatProxyMessage": { "$ref": "#/components/schemas/BaseMessage" }, @@ -714,7 +744,9 @@ "unreadMessages", "isArchived", "isImportant", - "isSensitive" + "isSensitive", + "lastPinnedId", + "hiddenPinnedId" ], "properties": { "actorId": { @@ -1007,6 +1039,16 @@ "isSensitive": { "type": "boolean", "description": "Required capability: `sensitive-conversations`" + }, + "lastPinnedId": { + "type": "integer", + "format": "int64", + "description": "Required capability: `pinned-messages`" + }, + "hiddenPinnedId": { + "type": "integer", + "format": "int64", + "description": "Required capability: `pinned-messages`" } } }, diff --git a/openapi-full.json b/openapi-full.json index c5ec17f8307..a64bfe64786 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -646,11 +646,41 @@ "threadReplies": { "type": "integer", "format": "int64" + }, + "metaData": { + "$ref": "#/components/schemas/ChatMessageMetaData" } } } ] }, + "ChatMessageMetaData": { + "type": "object", + "properties": { + "pinnedActorType": { + "type": "string", + "description": "Actor type of the attendee that pinned the message - Required capability: `pinned-messages`" + }, + "pinnedActorId": { + "type": "string", + "description": "Actor ID of the attendee that pinned the message - Required capability: `pinned-messages`" + }, + "pinnedActorDisplayName": { + "type": "string", + "description": "Display name of the attendee that pinned the message - Required capability: `pinned-messages`" + }, + "pinnedAt": { + "type": "integer", + "format": "int64", + "description": "Timestamp when the message was pinned - Required capability: `pinned-messages`" + }, + "pinnedUntil": { + "type": "integer", + "format": "int64", + "description": "Timestamp until when the message is pinned. If missing the message is pinned infinitely - Required capability: `pinned-messages`" + } + } + }, "ChatMessageWithParent": { "allOf": [ { @@ -1538,7 +1568,9 @@ "unreadMessages", "isArchived", "isImportant", - "isSensitive" + "isSensitive", + "lastPinnedId", + "hiddenPinnedId" ], "properties": { "actorId": { @@ -1831,6 +1863,16 @@ "isSensitive": { "type": "boolean", "description": "Required capability: `sensitive-conversations`" + }, + "lastPinnedId": { + "type": "integer", + "format": "int64", + "description": "Required capability: `pinned-messages`" + }, + "hiddenPinnedId": { + "type": "integer", + "format": "int64", + "description": "Required capability: `pinned-messages`" } } }, @@ -22460,6 +22502,519 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/{messageId}/pin": { + "post": { + "operationId": "chat-pin-message", + "summary": "Pin a message in a chat as a moderator", + "description": "Required capability: `pinned-messages`", + "tags": [ + "chat" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pinUntil": { + "type": "integer", + "format": "int64", + "default": 0, + "description": "Unix timestamp when to unpin the message", + "minimum": 0 + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "messageId", + "in": "path", + "description": "ID of the message", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "x-nextcloud-federation", + "in": "header", + "description": "Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Message was pinned successfully", + "headers": { + "X-Chat-Last-Common-Read": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ChatMessageWithParent" + } + ] + } + } + } + } + } + } + } + }, + "400": { + "description": "Message could not be pinned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "message", + "until" + ] + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Message was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "message", + "until" + ] + } + } + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "chat-unpin-message", + "summary": "Unpin a message in a chat as a moderator", + "description": "Required capability: `pinned-messages`", + "tags": [ + "chat" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "messageId", + "in": "path", + "description": "ID of the message", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "x-nextcloud-federation", + "in": "header", + "description": "Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Message is not pinned now", + "headers": { + "X-Chat-Last-Common-Read": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ChatMessageWithParent" + } + ] + } + } + } + } + } + } + } + }, + "404": { + "description": "Message was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "message" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/{messageId}/pin/self": { + "delete": { + "operationId": "chat-hide-pinned-message", + "summary": "Hide a message in a chat as a user", + "description": "Required capability: `pinned-messages`", + "tags": [ + "chat" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "messageId", + "in": "path", + "description": "ID of the message", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "x-nextcloud-federation", + "in": "header", + "description": "Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Pinned message is now hidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "404": { + "description": "Message was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/mentions": { "get": { "operationId": "chat-mentions", diff --git a/openapi.json b/openapi.json index 86f7d275316..b1ed70631c5 100644 --- a/openapi.json +++ b/openapi.json @@ -605,11 +605,41 @@ "threadReplies": { "type": "integer", "format": "int64" + }, + "metaData": { + "$ref": "#/components/schemas/ChatMessageMetaData" } } } ] }, + "ChatMessageMetaData": { + "type": "object", + "properties": { + "pinnedActorType": { + "type": "string", + "description": "Actor type of the attendee that pinned the message - Required capability: `pinned-messages`" + }, + "pinnedActorId": { + "type": "string", + "description": "Actor ID of the attendee that pinned the message - Required capability: `pinned-messages`" + }, + "pinnedActorDisplayName": { + "type": "string", + "description": "Display name of the attendee that pinned the message - Required capability: `pinned-messages`" + }, + "pinnedAt": { + "type": "integer", + "format": "int64", + "description": "Timestamp when the message was pinned - Required capability: `pinned-messages`" + }, + "pinnedUntil": { + "type": "integer", + "format": "int64", + "description": "Timestamp until when the message is pinned. If missing the message is pinned infinitely - Required capability: `pinned-messages`" + } + } + }, "ChatMessageWithParent": { "allOf": [ { @@ -1443,7 +1473,9 @@ "unreadMessages", "isArchived", "isImportant", - "isSensitive" + "isSensitive", + "lastPinnedId", + "hiddenPinnedId" ], "properties": { "actorId": { @@ -1736,6 +1768,16 @@ "isSensitive": { "type": "boolean", "description": "Required capability: `sensitive-conversations`" + }, + "lastPinnedId": { + "type": "integer", + "format": "int64", + "description": "Required capability: `pinned-messages`" + }, + "hiddenPinnedId": { + "type": "integer", + "format": "int64", + "description": "Required capability: `pinned-messages`" } } }, @@ -22365,6 +22407,519 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/{messageId}/pin": { + "post": { + "operationId": "chat-pin-message", + "summary": "Pin a message in a chat as a moderator", + "description": "Required capability: `pinned-messages`", + "tags": [ + "chat" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pinUntil": { + "type": "integer", + "format": "int64", + "default": 0, + "description": "Unix timestamp when to unpin the message", + "minimum": 0 + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "messageId", + "in": "path", + "description": "ID of the message", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "x-nextcloud-federation", + "in": "header", + "description": "Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Message was pinned successfully", + "headers": { + "X-Chat-Last-Common-Read": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ChatMessageWithParent" + } + ] + } + } + } + } + } + } + } + }, + "400": { + "description": "Message could not be pinned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "message", + "until" + ] + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Message was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "message", + "until" + ] + } + } + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "chat-unpin-message", + "summary": "Unpin a message in a chat as a moderator", + "description": "Required capability: `pinned-messages`", + "tags": [ + "chat" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "messageId", + "in": "path", + "description": "ID of the message", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "x-nextcloud-federation", + "in": "header", + "description": "Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Message is not pinned now", + "headers": { + "X-Chat-Last-Common-Read": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ChatMessageWithParent" + } + ] + } + } + } + } + } + } + } + }, + "404": { + "description": "Message was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "message" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/{messageId}/pin/self": { + "delete": { + "operationId": "chat-hide-pinned-message", + "summary": "Hide a message in a chat as a user", + "description": "Required capability: `pinned-messages`", + "tags": [ + "chat" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "messageId", + "in": "path", + "description": "ID of the message", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "x-nextcloud-federation", + "in": "header", + "description": "Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Pinned message is now hidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "404": { + "description": "Message was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/mentions": { "get": { "operationId": "chat-mentions", diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index a5ce6392875..2c42f836fe3 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -251,6 +251,25 @@ export type components = { threadTitle?: string; /** Format: int64 */ threadReplies?: number; + metaData?: components["schemas"]["ChatMessageMetaData"]; + }; + ChatMessageMetaData: { + /** @description Actor type of the attendee that pinned the message - Required capability: `pinned-messages` */ + pinnedActorType?: string; + /** @description Actor ID of the attendee that pinned the message - Required capability: `pinned-messages` */ + pinnedActorId?: string; + /** @description Display name of the attendee that pinned the message - Required capability: `pinned-messages` */ + pinnedActorDisplayName?: string; + /** + * Format: int64 + * @description Timestamp when the message was pinned - Required capability: `pinned-messages` + */ + pinnedAt?: number; + /** + * Format: int64 + * @description Timestamp until when the message is pinned. If missing the message is pinned infinitely - Required capability: `pinned-messages` + */ + pinnedUntil?: number; }; ChatProxyMessage: components["schemas"]["BaseMessage"]; OCSMeta: { @@ -511,6 +530,16 @@ export type components = { isImportant: boolean; /** @description Required capability: `sensitive-conversations` */ isSensitive: boolean; + /** + * Format: int64 + * @description Required capability: `pinned-messages` + */ + lastPinnedId: number; + /** + * Format: int64 + * @description Required capability: `pinned-messages` + */ + hiddenPinnedId: number; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; }; diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index a307576043b..1d6414ee9fb 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -262,6 +262,25 @@ export type components = { threadTitle?: string; /** Format: int64 */ threadReplies?: number; + metaData?: components["schemas"]["ChatMessageMetaData"]; + }; + ChatMessageMetaData: { + /** @description Actor type of the attendee that pinned the message - Required capability: `pinned-messages` */ + pinnedActorType?: string; + /** @description Actor ID of the attendee that pinned the message - Required capability: `pinned-messages` */ + pinnedActorId?: string; + /** @description Display name of the attendee that pinned the message - Required capability: `pinned-messages` */ + pinnedActorDisplayName?: string; + /** + * Format: int64 + * @description Timestamp when the message was pinned - Required capability: `pinned-messages` + */ + pinnedAt?: number; + /** + * Format: int64 + * @description Timestamp until when the message is pinned. If missing the message is pinned infinitely - Required capability: `pinned-messages` + */ + pinnedUntil?: number; }; ChatProxyMessage: components["schemas"]["BaseMessage"]; FederationInvite: { @@ -538,6 +557,16 @@ export type components = { isImportant: boolean; /** @description Required capability: `sensitive-conversations` */ isSensitive: boolean; + /** + * Format: int64 + * @description Required capability: `pinned-messages` + */ + lastPinnedId: number; + /** + * Format: int64 + * @description Required capability: `pinned-messages` + */ + hiddenPinnedId: number; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; }; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 6a8c00e8500..ad00c29bee2 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1576,6 +1576,50 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/{messageId}/pin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Pin a message in a chat as a moderator + * @description Required capability: `pinned-messages` + */ + post: operations["chat-pin-message"]; + /** + * Unpin a message in a chat as a moderator + * @description Required capability: `pinned-messages` + */ + delete: operations["chat-unpin-message"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/{messageId}/pin/self": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Hide a message in a chat as a user + * @description Required capability: `pinned-messages` + */ + delete: operations["chat-hide-pinned-message"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/mentions": { parameters: { query?: never; @@ -2420,6 +2464,25 @@ export type components = { threadTitle?: string; /** Format: int64 */ threadReplies?: number; + metaData?: components["schemas"]["ChatMessageMetaData"]; + }; + ChatMessageMetaData: { + /** @description Actor type of the attendee that pinned the message - Required capability: `pinned-messages` */ + pinnedActorType?: string; + /** @description Actor ID of the attendee that pinned the message - Required capability: `pinned-messages` */ + pinnedActorId?: string; + /** @description Display name of the attendee that pinned the message - Required capability: `pinned-messages` */ + pinnedActorDisplayName?: string; + /** + * Format: int64 + * @description Timestamp when the message was pinned - Required capability: `pinned-messages` + */ + pinnedAt?: number; + /** + * Format: int64 + * @description Timestamp until when the message is pinned. If missing the message is pinned infinitely - Required capability: `pinned-messages` + */ + pinnedUntil?: number; }; ChatMessageWithParent: components["schemas"]["ChatMessage"] & { parent?: components["schemas"]["ChatMessage"] | components["schemas"]["DeletedChatMessage"]; @@ -2871,6 +2934,16 @@ export type components = { isImportant: boolean; /** @description Required capability: `sensitive-conversations` */ isSensitive: boolean; + /** + * Format: int64 + * @description Required capability: `pinned-messages` + */ + lastPinnedId: number; + /** + * Format: int64 + * @description Required capability: `pinned-messages` + */ + hiddenPinnedId: number; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; RoomWithInvalidInvitations: components["schemas"]["Room"] & { @@ -11134,6 +11207,191 @@ export interface operations { }; }; }; + "chat-pin-message": { + parameters: { + query?: never; + header: { + /** @description Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request */ + "x-nextcloud-federation"?: string; + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + /** @description ID of the message */ + messageId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * Format: int64 + * @description Unix timestamp when to unpin the message + * @default 0 + */ + pinUntil?: number; + }; + }; + }; + responses: { + /** @description Message was pinned successfully */ + 200: { + headers: { + "X-Chat-Last-Common-Read"?: string; + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ChatMessageWithParent"] | null; + }; + }; + }; + }; + /** @description Message could not be pinned */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message" | "until"; + }; + }; + }; + }; + }; + /** @description Message was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message" | "until"; + }; + }; + }; + }; + }; + }; + }; + "chat-unpin-message": { + parameters: { + query?: never; + header: { + /** @description Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request */ + "x-nextcloud-federation"?: string; + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + /** @description ID of the message */ + messageId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Message is not pinned now */ + 200: { + headers: { + "X-Chat-Last-Common-Read"?: string; + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ChatMessageWithParent"] | null; + }; + }; + }; + }; + /** @description Message was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message"; + }; + }; + }; + }; + }; + }; + }; + "chat-hide-pinned-message": { + parameters: { + query?: never; + header: { + /** @description Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request */ + "x-nextcloud-federation"?: string; + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + /** @description ID of the message */ + messageId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Pinned message is now hidden */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Message was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + }; + }; "chat-mentions": { parameters: { query: { diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 1cc7c1a0a52..d024c201716 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1576,6 +1576,50 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/{messageId}/pin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Pin a message in a chat as a moderator + * @description Required capability: `pinned-messages` + */ + post: operations["chat-pin-message"]; + /** + * Unpin a message in a chat as a moderator + * @description Required capability: `pinned-messages` + */ + delete: operations["chat-unpin-message"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/{messageId}/pin/self": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Hide a message in a chat as a user + * @description Required capability: `pinned-messages` + */ + delete: operations["chat-hide-pinned-message"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/mentions": { parameters: { query?: never; @@ -1898,6 +1942,25 @@ export type components = { threadTitle?: string; /** Format: int64 */ threadReplies?: number; + metaData?: components["schemas"]["ChatMessageMetaData"]; + }; + ChatMessageMetaData: { + /** @description Actor type of the attendee that pinned the message - Required capability: `pinned-messages` */ + pinnedActorType?: string; + /** @description Actor ID of the attendee that pinned the message - Required capability: `pinned-messages` */ + pinnedActorId?: string; + /** @description Display name of the attendee that pinned the message - Required capability: `pinned-messages` */ + pinnedActorDisplayName?: string; + /** + * Format: int64 + * @description Timestamp when the message was pinned - Required capability: `pinned-messages` + */ + pinnedAt?: number; + /** + * Format: int64 + * @description Timestamp until when the message is pinned. If missing the message is pinned infinitely - Required capability: `pinned-messages` + */ + pinnedUntil?: number; }; ChatMessageWithParent: components["schemas"]["ChatMessage"] & { parent?: components["schemas"]["ChatMessage"] | components["schemas"]["DeletedChatMessage"]; @@ -2333,6 +2396,16 @@ export type components = { isImportant: boolean; /** @description Required capability: `sensitive-conversations` */ isSensitive: boolean; + /** + * Format: int64 + * @description Required capability: `pinned-messages` + */ + lastPinnedId: number; + /** + * Format: int64 + * @description Required capability: `pinned-messages` + */ + hiddenPinnedId: number; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; RoomWithInvalidInvitations: components["schemas"]["Room"] & { @@ -10596,6 +10669,191 @@ export interface operations { }; }; }; + "chat-pin-message": { + parameters: { + query?: never; + header: { + /** @description Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request */ + "x-nextcloud-federation"?: string; + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + /** @description ID of the message */ + messageId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * Format: int64 + * @description Unix timestamp when to unpin the message + * @default 0 + */ + pinUntil?: number; + }; + }; + }; + responses: { + /** @description Message was pinned successfully */ + 200: { + headers: { + "X-Chat-Last-Common-Read"?: string; + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ChatMessageWithParent"] | null; + }; + }; + }; + }; + /** @description Message could not be pinned */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message" | "until"; + }; + }; + }; + }; + }; + /** @description Message was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message" | "until"; + }; + }; + }; + }; + }; + }; + }; + "chat-unpin-message": { + parameters: { + query?: never; + header: { + /** @description Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request */ + "x-nextcloud-federation"?: string; + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + /** @description ID of the message */ + messageId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Message is not pinned now */ + 200: { + headers: { + "X-Chat-Last-Common-Read"?: string; + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ChatMessageWithParent"] | null; + }; + }; + }; + }; + /** @description Message was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message"; + }; + }; + }; + }; + }; + }; + }; + "chat-hide-pinned-message": { + parameters: { + query?: never; + header: { + /** @description Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request */ + "x-nextcloud-federation"?: string; + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + /** @description ID of the message */ + messageId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Pinned message is now hidden */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Message was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + }; + }; "chat-mentions": { parameters: { query: { diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index ad1a6db9f57..ce9505cf905 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -514,6 +514,20 @@ private function assertRooms(array $rooms, TableNode $formData, bool $shouldOrde if (!empty($expectedRoom['lobbyTimer'])) { $data['lobbyTimer'] = (int)$room['lobbyTimer']; } + if (isset($expectedRoom['hiddenPinnedId'])) { + if ($room['hiddenPinnedId'] === 0) { + $data['hiddenPinnedId'] = 'EMPTY'; + } else { + $data['hiddenPinnedId'] = self::$messageIdToText[(int)$room['hiddenPinnedId']] ?? 'UNKNOWN_MESSAGE'; + } + } + if (isset($expectedRoom['lastPinnedId'])) { + if ($room['lastPinnedId'] === 0) { + $data['lastPinnedId'] = 'EMPTY'; + } else { + $data['lastPinnedId'] = self::$messageIdToText[(int)$room['lastPinnedId']] ?? 'UNKNOWN_MESSAGE'; + } + } if (isset($expectedRoom['lobbyTimer'])) { $data['lobbyTimer'] = (int)$room['lobbyTimer']; if ($expectedRoom['lobbyTimer'] === 'GREATER_THAN_ZERO' && $room['lobbyTimer'] > 0) { @@ -2036,6 +2050,30 @@ public function userDeletesReminder(string $user, string $message, string $ident $this->assertStatusCode($this->response, $statusCode); } + #[Then('/^user "([^"]*)" (unpins|pins|hides pinned) message "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] + public function userPinActionMessage(string $user, string $action, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { + $this->userPinActionWithTimeMessage($user, $action, $message, 0, $identifier, $statusCode, $apiVersion); + } + + #[Then('/^user "([^"]*)" (unpins|pins|hides pinned) message "([^"]*)" for (\d+) seconds in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] + public function userPinActionWithTimeMessage(string $user, string $action, string $message, int $duration, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { + $this->setCurrentUser($user); + + $body = []; + if ($action === 'pins' && $duration !== 0) { + $body['pinUntil'] = time() + $duration; + } + + $routeSuffix = $action === 'hides pinned' ? '/self' : ''; + $this->sendRequest( + $action === 'pins' ? 'POST' : 'DELETE', + '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/' . self::$textToMessageId[$message] . '/pin' . $routeSuffix, + $body + ); + + $this->assertStatusCode($this->response, $statusCode); + } + #[Then('/^user "([^"]*)" gets upcoming reminders \((v1)\)$/')] public function userGetsUpcomingReminders(string $user, string $apiVersion, ?TableNode $table = null): void { @@ -2585,7 +2623,7 @@ public function userSearchesInRoom(string $user, string $searchProvider, string $this->compareSearchResponse($formData); } - #[Then('/^user "([^"]*)" sees the following shared (media|audio|voice|file|deckcard|location|other) in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] + #[Then('/^user "([^"]*)" sees the following shared (media|audio|voice|file|deckcard|location|pinned|other) in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSeesTheFollowingSharedMediaInRoom(string $user, string $objectType, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/share?objectType=' . $objectType); @@ -2674,6 +2712,13 @@ protected function compareDataResponse(?TableNode $formData = null): void { $includeMessageType = in_array('messageType', $formData->getRow(0), true); $includeThreadTitle = in_array('threadTitle', $formData->getRow(0), true); $includeThreadReplies = in_array('threadReplies', $formData->getRow(0), true); + $includeMetaDataKeys = array_map( + static fn (string $field): string => substr($field, strlen('metaData.')), + array_filter( + $formData->getRow(0), + static fn (string $field): bool => str_starts_with($field, 'metaData.') + ) + ); $expected = $formData->getHash(); $count = count($expected); @@ -2748,7 +2793,7 @@ protected function compareDataResponse(?TableNode $formData = null): void { } } - Assert::assertEquals($expected, array_map(function ($message, $expected) use ($includeParents, $includeReferenceId, $includeReactions, $includeReactionsSelf, $includeLastEdit, $includeMessageType, $includeThreadTitle, $includeThreadReplies) { + Assert::assertEquals($expected, array_map(function ($message, $expected) use ($includeParents, $includeReferenceId, $includeReactions, $includeReactionsSelf, $includeLastEdit, $includeMessageType, $includeThreadTitle, $includeThreadReplies, $includeMetaDataKeys) { $data = [ 'room' => self::$tokenToIdentifier[$message['token']], 'actorType' => $message['actorType'], @@ -2799,6 +2844,17 @@ protected function compareDataResponse(?TableNode $formData = null): void { $data['threadReplies'] = $message['threadReplies'] ?? null; } + if (!empty($includeMetaDataKeys)) { + $metaData = $message['metaData'] ?? []; + foreach ($includeMetaDataKeys as $key) { + $data['metaData.' . $key] = $metaData[$key] ?? 'UNSET'; + $expectedValue = $expected['metaData.' . $key]; + if ($expectedValue === 'NUMERIC' && is_numeric($data['metaData.' . $key])) { + $data['metaData.' . $key] = $expectedValue; + } + } + } + return $data; }, $messages, $expected)); } diff --git a/tests/integration/features/chat-1/pinned-messages.feature b/tests/integration/features/chat-1/pinned-messages.feature new file mode 100644 index 00000000000..fc634a974fd --- /dev/null +++ b/tests/integration/features/chat-1/pinned-messages.feature @@ -0,0 +1,91 @@ +Feature: chat-1/pinned-messages + Background: + Given user "participant1" exists + Given user "participant2" exists + + Scenario: Moderators can pin and unpin messages + Given user "participant1" creates room "room" (v4) + | roomType | 3 | + | roomName | room | + And user "participant1" adds user "participant2" to room "room" with 200 (v4) + When user "participant1" sends message "Message 1" to room "room" with 201 + When user "participant1" sends message "Message 2" to room "room" with 201 + + # Pinned messages are sorted by moment of pinning + When user "participant2" pins message "Message 2" in room "room" with 403 + When user "participant1" pins message "Message 2" in room "room" with 200 + Then user "participant2" is participant of the following rooms (v4) + | id | type | lastPinnedId | hiddenPinnedId | + | room | 3 | Message 2 | EMPTY | + # Ensure the order by timestamp + When wait for 1 second + When user "participant1" pins message "Message 1" in room "room" with 200 + Then user "participant2" is participant of the following rooms (v4) + | id | type | lastPinnedId | hiddenPinnedId | + | room | 3 | Message 1 | EMPTY | + # Ensure the order by timestamp + When wait for 1 second + When user "participant1" pins message "Message 2" in room "room" with 200 + Then user "participant2" is participant of the following rooms (v4) + | id | type | lastPinnedId | hiddenPinnedId | + | room | 3 | Message 1 | EMPTY | + When user "participant2" unpins message "Message 1" in room "room" with 403 + Then user "participant2" is participant of the following rooms (v4) + | id | type | lastPinnedId | hiddenPinnedId | + | room | 3 | Message 1 | EMPTY | + Then user "participant1" sees the following messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | room | users | participant1 | participant1-displayname | Message 2 | [] | + | room | users | participant1 | participant1-displayname | Message 1 | [] | + Then user "participant1" sees the following system messages in room "room" with 200 + | room | actorType | actorId | systemMessage | message | messageParameters | + | room | users | participant1 | message_pinned | You pinned a message | "IGNORE" | + | room | users | participant1 | message_pinned | You pinned a message | "IGNORE" | + | room | users | participant1 | user_added | You added {user} | "IGNORE" | + | room | users | participant1 | conversation_created | You created the conversation | "IGNORE" | + Then user "participant1" sees the following shared pinned in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | metaData.pinnedActorDisplayName | metaData.pinnedUntil | + | room | users | participant1 | participant1-displayname | Message 1 | [] | participant1-displayname | UNSET | + | room | users | participant1 | participant1-displayname | Message 2 | [] | participant1-displayname | UNSET | + + # Unpinning resets lastPinnedId + When user "participant1" unpins message "Message 1" in room "room" with 200 + Then user "participant1" sees the following system messages in room "room" with 200 + | room | actorType | actorId | systemMessage | message | messageParameters | + | room | users | participant1 | message_unpinned | You unpinned a message | "IGNORE" | + | room | users | participant1 | message_pinned | You pinned a message | "IGNORE" | + | room | users | participant1 | message_pinned | You pinned a message | "IGNORE" | + | room | users | participant1 | user_added | You added {user} | "IGNORE" | + | room | users | participant1 | conversation_created | You created the conversation | "IGNORE" | + Then user "participant1" sees the following shared pinned in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | room | users | participant1 | participant1-displayname | Message 2 | [] | + Then user "participant2" is participant of the following rooms (v4) + | id | type | lastPinnedId | hiddenPinnedId | + | room | 3 | Message 2 | EMPTY | + + # Hide as user + When user "participant2" hides pinned message "Message 2" in room "room" with 200 + Then user "participant2" is participant of the following rooms (v4) + | id | type | lastPinnedId | hiddenPinnedId | + | room | 3 | Message 2 | Message 2 | + When user "participant1" unpins message "Message 2" in room "room" with 200 + Then user "participant2" is participant of the following rooms (v4) + | id | type | lastPinnedId | hiddenPinnedId | + | room | 3 | EMPTY | Message 2 | + + # Pin temporarily + When user "participant1" pins message "Message 2" for 3 seconds in room "room" with 200 + Then user "participant1" sees the following shared pinned in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | metaData.pinnedActorDisplayName | metaData.pinnedUntil | + | room | users | participant1 | participant1-displayname | Message 2 | [] | participant1-displayname | NUMERIC | + Then user "participant2" is participant of the following rooms (v4) + | id | type | lastPinnedId | hiddenPinnedId | + | room | 3 | Message 2 | EMPTY | + When wait for 4 seconds + And run "OCA\Talk\BackgroundJob\UnpinMessage" background jobs + Then user "participant1" sees the following shared pinned in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | metaData.pinnedActorDisplayName | metaData.pinnedUntil | + Then user "participant2" is participant of the following rooms (v4) + | id | type | lastPinnedId | hiddenPinnedId | + | room | 3 | EMPTY | EMPTY | diff --git a/tests/integration/features/sharing-1/delete.feature b/tests/integration/features/sharing-1/delete.feature index e56d0574ff1..9a4dadf7d65 100644 --- a/tests/integration/features/sharing-1/delete.feature +++ b/tests/integration/features/sharing-1/delete.feature @@ -373,6 +373,7 @@ Feature: delete | poll | 0 | | voice | 0 | | recording | 0 | + | pinned | 0 | When user "participant1" deletes file "welcome.txt" Then user "participant1" sees the following shared file in room "public room" with 200 And user "participant1" sees the following shared summarized overview in room "public room" with 200 @@ -385,6 +386,7 @@ Feature: delete | poll | 0 | | voice | 0 | | recording | 0 | + | pinned | 0 | And user "participant1" sees the following messages in room "public room" with 200 | room | actorType | actorId | actorDisplayName | message | messageParameters | | public room | users | participant1 | participant1-displayname | *You shared a file which is no longer available* | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname","mention-id":"participant1"}} | diff --git a/tests/php/Chat/ChatManagerTest.php b/tests/php/Chat/ChatManagerTest.php index 2a133e9a78f..47f644fc0ef 100644 --- a/tests/php/Chat/ChatManagerTest.php +++ b/tests/php/Chat/ChatManagerTest.php @@ -23,6 +23,7 @@ use OCA\Talk\Service\ThreadService; use OCA\Talk\Share\RoomShareProvider; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; use OCP\Collaboration\Reference\IReferenceManager; use OCP\Comments\IComment; use OCP\Comments\ICommentsManager; @@ -60,6 +61,7 @@ class ChatManagerTest extends TestCase { protected IReferenceManager&MockObject $referenceManager; protected ILimiter&MockObject $rateLimiter; protected IRequest&MockObject $request; + protected IJobList&MockObject $jobList; protected LoggerInterface&MockObject $logger; protected IL10N&MockObject $l; protected ?ChatManager $chatManager = null; @@ -81,6 +83,7 @@ public function setUp(): void { $this->attachmentService = $this->createMock(AttachmentService::class); $this->referenceManager = $this->createMock(IReferenceManager::class); $this->rateLimiter = $this->createMock(ILimiter::class); + $this->jobList = $this->createMock(IJobList::class); $this->request = $this->createMock(IRequest::class); $this->l = $this->createMock(IL10N::class); $this->logger = $this->createMock(LoggerInterface::class); @@ -121,6 +124,7 @@ protected function getManager(array $methods = []): ChatManager { $this->referenceManager, $this->rateLimiter, $this->request, + $this->jobList, $this->l, $this->logger, ]) @@ -146,6 +150,7 @@ protected function getManager(array $methods = []): ChatManager { $this->referenceManager, $this->rateLimiter, $this->request, + $this->jobList, $this->l, $this->logger, ); @@ -437,6 +442,7 @@ public function testDeleteMessage(): void { 'has_unread_threads' => false, 'has_unread_thread_mentions' => false, 'has_unread_thread_directs' => false, + 'hidden_pinned_id' => 0, ]); $chat = $this->createMock(Room::class); $chat->expects($this->any()) @@ -505,6 +511,7 @@ public function testDeleteMessageFileShare(): void { 'has_unread_threads' => false, 'has_unread_thread_mentions' => false, 'has_unread_thread_directs' => false, + 'hidden_pinned_id' => 0, ]); $chat = $this->createMock(Room::class); $chat->expects($this->any()) @@ -595,6 +602,7 @@ public function testDeleteMessageFileShareNotFound(): void { 'has_unread_threads' => false, 'has_unread_thread_mentions' => false, 'has_unread_thread_directs' => false, + 'hidden_pinned_id' => 0, ]); $chat = $this->createMock(Room::class); $chat->expects($this->any()) diff --git a/tests/php/Service/RoomServiceTest.php b/tests/php/Service/RoomServiceTest.php index 2939adb119e..4372296be5f 100644 --- a/tests/php/Service/RoomServiceTest.php +++ b/tests/php/Service/RoomServiceTest.php @@ -379,6 +379,7 @@ public function testVerifyPassword(): void { Room::HAS_FEDERATION_NONE, Room::MENTION_PERMISSIONS_EVERYONE, '', + 0, ); $verificationResult = $service->verifyPassword($room, '1234');