From ba9c0b14175700f550f6b4f25c1eb59d76d25c58 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Thu, 26 Mar 2026 17:33:13 +0100 Subject: [PATCH 1/6] feat(api): implement conversation tags - allow users to organize conversations with custom tags - support reordering Co-Authored-By: Claude Opus 4.6 Signed-off-by: Rikdekker Signed-off-by: Joas Schilling Signed-off-by: Maksim Sukharev --- appinfo/info.xml | 2 +- docs/capabilities.md | 1 + lib/Capabilities.php | 2 + lib/Controller/ConversationTagController.php | 201 +++++++++++++ lib/Controller/RoomController.php | 25 ++ lib/Exceptions/InvalidTagNameException.php | 13 + lib/Exceptions/TagLimitExceededException.php | 13 + .../TagNameAlreadyInUseException.php | 13 + lib/Exceptions/TagNotCustomException.php | 13 + .../Version24000Date20260313120000.php | 78 +++++ lib/Model/Attendee.php | 4 + lib/Model/AttendeeMapper.php | 1 + lib/Model/ConversationTag.php | 55 ++++ lib/Model/ConversationTagMapper.php | 77 +++++ lib/Model/SelectHelper.php | 1 + lib/ResponseDefinitions.php | 15 + lib/Service/ConversationTagService.php | 271 ++++++++++++++++++ lib/Service/ParticipantService.php | 17 ++ lib/Service/RoomFormatter.php | 2 + tests/php/Chat/ChatManagerTest.php | 3 + 20 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 lib/Controller/ConversationTagController.php create mode 100644 lib/Exceptions/InvalidTagNameException.php create mode 100644 lib/Exceptions/TagLimitExceededException.php create mode 100644 lib/Exceptions/TagNameAlreadyInUseException.php create mode 100644 lib/Exceptions/TagNotCustomException.php create mode 100644 lib/Migration/Version24000Date20260313120000.php create mode 100644 lib/Model/ConversationTag.php create mode 100644 lib/Model/ConversationTagMapper.php create mode 100644 lib/Service/ConversationTagService.php diff --git a/appinfo/info.xml b/appinfo/info.xml index c5034914a5c..ce22dac5020 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. ]]> - 24.0.0-dev.2 + 24.0.0-dev.3 agpl Anna Larch diff --git a/docs/capabilities.md b/docs/capabilities.md index 58f2ea9d908..cae4f691aa3 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -226,3 +226,4 @@ * `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 * `config => attachments => conversation-subfolders` (local) - Whether per-conversation subfolders are used for Talk attachments; when `true` files must be uploaded to `Talk/-/-/` before calling the attachment endpoint +* `conversation-tags` (local) - Whether the user can create custom tags to organize conversations in the sidebar diff --git a/lib/Capabilities.php b/lib/Capabilities.php index db47841106c..2279d2948f6 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -132,6 +132,7 @@ class Capabilities implements IPublicCapability { 'scheduled-messages', 'conversation-presets', 'private-reply', + 'conversation-tags', ]; public const CONDITIONAL_FEATURES = [ @@ -164,6 +165,7 @@ class Capabilities implements IPublicCapability { 'sensitive-conversations', 'scheduled-messages', 'conversation-presets', + 'conversation-tags', ]; public const LOCAL_CONFIGS = [ diff --git a/lib/Controller/ConversationTagController.php b/lib/Controller/ConversationTagController.php new file mode 100644 index 00000000000..4fa55d3b930 --- /dev/null +++ b/lib/Controller/ConversationTagController.php @@ -0,0 +1,201 @@ +, array{}> + * + * 200: Tags returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/tags', requirements: [ + 'apiVersion' => '(v4)', + ])] + public function getTags(): DataResponse { + $tags = $this->tagService->getTags($this->userId); + return new DataResponse(array_map([$this, 'formatTag'], $tags)); + } + + /** + * Create a new conversation tag + * + * Required capability: `conversation-tags` + * + * @param string $name Name of the tag + * @return DataResponse|DataResponse + * + * 201: Tag created + * 400: Invalid or duplicate name, or the user has reached the tag limit + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/tags', requirements: [ + 'apiVersion' => '(v4)', + ])] + public function createTag(string $name): DataResponse { + try { + $tag = $this->tagService->createTag($this->userId, $name); + } catch (InvalidTagNameException|TagNameAlreadyInUseException) { + return new DataResponse(['error' => 'name'], Http::STATUS_BAD_REQUEST); + } catch (TagLimitExceededException) { + return new DataResponse(['error' => 'limit'], Http::STATUS_BAD_REQUEST); + } + return new DataResponse($this->formatTag($tag), Http::STATUS_CREATED); + } + + /** + * Update a conversation tag + * + * Required capability: `conversation-tags` + * + * @param string $tagId ID of the tag + * @param string $name New name for the tag + * @return DataResponse|DataResponse|DataResponse + * + * 200: Tag updated + * 400: Invalid or duplicate name, or the tag is a built-in and cannot be renamed + * 404: Tag not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/tags/{tagId}', requirements: [ + 'apiVersion' => '(v4)', + 'tagId' => '\d+', + ])] + public function updateTag(string $tagId, string $name): DataResponse { + try { + $tag = $this->tagService->updateTag($tagId, $this->userId, $name); + return new DataResponse($this->formatTag($tag)); + } catch (DoesNotExistException) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } catch (InvalidTagNameException|TagNameAlreadyInUseException) { + return new DataResponse(['error' => 'name'], Http::STATUS_BAD_REQUEST); + } catch (TagNotCustomException) { + return new DataResponse(['error' => 'type'], Http::STATUS_BAD_REQUEST); + } + } + + /** + * Delete a conversation tag + * + * Required capability: `conversation-tags` + * + * @param string $tagId ID of the tag + * @return DataResponse|DataResponse|DataResponse + * + * 200: Tag deleted + * 400: The tag is a built-in and cannot be deleted + * 404: Tag not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/tags/{tagId}', requirements: [ + 'apiVersion' => '(v4)', + 'tagId' => '\d+', + ])] + public function deleteTag(string $tagId): DataResponse { + try { + $this->tagService->deleteTag($tagId, $this->userId); + return new DataResponse(null); + } catch (DoesNotExistException) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } catch (TagNotCustomException) { + return new DataResponse(['error' => 'type'], Http::STATUS_BAD_REQUEST); + } + } + + /** + * Reorder conversation tags + * + * Required capability: `conversation-tags` + * + * @param list $orderedIds Ordered list of tag IDs + * @return DataResponse, array{}> + * + * 200: Tags reordered + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/tags/reorder', requirements: [ + 'apiVersion' => '(v4)', + ])] + public function reorderTags(array $orderedIds): DataResponse { + $this->tagService->reorderTags($this->userId, $orderedIds); + $tags = $this->tagService->getTags($this->userId); + return new DataResponse(array_map([$this, 'formatTag'], $tags)); + } + + /** + * Set the collapsed state of a conversation tag + * + * Required capability: `conversation-tags` + * + * @param string $tagId ID of the tag + * @param bool $collapsed Whether the tag should be collapsed + * @return DataResponse|DataResponse + * + * 200: Collapsed state updated + * 404: Tag not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/tags/{tagId}/collapsed', requirements: [ + 'apiVersion' => '(v4)', + 'tagId' => '\d+', + ])] + public function updateTagCollapsed(string $tagId, bool $collapsed): DataResponse { + try { + $tag = $this->tagService->setCollapsed($tagId, $this->userId, $collapsed); + return new DataResponse($this->formatTag($tag)); + } catch (DoesNotExistException) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + } + + /** + * @return TalkConversationTag + */ + protected function formatTag(ConversationTag $tag): array { + return [ + 'id' => (string)$tag->getId(), + 'name' => $tag->getName(), + 'sortOrder' => $tag->getSortOrder(), + 'collapsed' => $tag->isCollapsed(), + 'type' => $tag->getType(), + ]; + } +} diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index 1e1e9ca838d..780bc446084 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -63,6 +63,7 @@ use OCA\Talk\Service\BanService; use OCA\Talk\Service\BreakoutRoomService; use OCA\Talk\Service\ChecksumVerificationService; +use OCA\Talk\Service\ConversationTagService; use OCA\Talk\Service\InvitationService; use OCA\Talk\Service\NoteToSelfService; use OCA\Talk\Service\ParticipantService; @@ -159,6 +160,7 @@ public function __construct( protected IURLGenerator $url, protected IL10N $l, protected ThreadService $threadService, + protected ConversationTagService $conversationTagService, protected Forced $forcedParameters, ) { parent::__construct($appName, $request); @@ -1888,6 +1890,29 @@ public function unarchiveConversation(): DataResponse { return new DataResponse($this->formatRoom($this->room, $this->participant)); } + /** + * Assign conversation tags + * + * Required capability: `conversation-tags` + * + * @param list $tagIds IDs of tags to assign (empty array to unassign all) + * @return DataResponse + * + * 200: Conversation tags updated + */ + #[NoAdminRequired] + #[FederationSupported] + #[RequireLoggedInParticipant] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/tags', requirements: [ + 'apiVersion' => '(v4)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function assignTags(array $tagIds = []): DataResponse { + $tagIds = $this->conversationTagService->validateTagIdsForUser($this->participant->getAttendee()->getActorId(), $tagIds); + $this->participantService->assignConversationToTags($this->participant, $tagIds); + return new DataResponse($this->formatRoom($this->room, $this->participant)); + } + /** * Mark a conversation as important (still sending notifications while on DND) * diff --git a/lib/Exceptions/InvalidTagNameException.php b/lib/Exceptions/InvalidTagNameException.php new file mode 100644 index 00000000000..268d337e245 --- /dev/null +++ b/lib/Exceptions/InvalidTagNameException.php @@ -0,0 +1,13 @@ +hasTable('talk_conversation_tags')) { + $table = $schema->createTable('talk_conversation_tags'); + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + 'length' => 20, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('name', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('sort_order', Types::INTEGER, [ + 'notnull' => true, + 'default' => 0, + ]); + $table->addColumn('collapsed', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => 0, + ]); + $table->addColumn('type', Types::STRING, [ + 'notnull' => true, + 'length' => 16, + 'default' => 'custom', + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['user_id'], 'tct_user_id'); + // Uniqueness guards: + // - For built-ins (type in favorites/other), name equals the type → a second + // insert of the same built-in for the same user collides here. + // - For custom tags, prevents a user from creating two tags with the same name. + $table->addUniqueIndex(['user_id', 'type', 'name'], 'tct_user_type_name'); + } + + $attendeesTable = $schema->getTable('talk_attendees'); + if (!$attendeesTable->hasColumn('tag_ids')) { + $attendeesTable->addColumn('tag_ids', Types::TEXT, [ + 'notnull' => false, + 'default' => null, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/Attendee.php b/lib/Model/Attendee.php index 75cb0013f72..11d325e6d54 100644 --- a/lib/Model/Attendee.php +++ b/lib/Model/Attendee.php @@ -46,6 +46,8 @@ * @method bool isImportant() * @method void setSensitive(bool $sensitive) * @method bool isSensitive() + * @method void setTagIds(?string $tagIds) + * @method ?string getTagIds() * @internal * @method int getPermissions() * @method void setAccessToken(string $accessToken) @@ -148,6 +150,7 @@ class Attendee extends Entity { protected bool $hasUnreadThreads = false; protected bool $hasUnreadThreadMentions = false; protected bool $hasUnreadThreadDirects = false; + protected ?string $tagIds = null; protected int $hiddenPinnedId = 0; protected int $hasScheduledMessages = 0; @@ -181,6 +184,7 @@ public function __construct() { $this->addType('hasUnreadThreads', Types::BOOLEAN); $this->addType('hasUnreadThreadMentions', Types::BOOLEAN); $this->addType('hasUnreadThreadDirects', Types::BOOLEAN); + $this->addType('tagIds', Types::STRING); $this->addType('hiddenPinnedId', Types::BIGINT); $this->addType('hasScheduledMessages', Types::INTEGER); diff --git a/lib/Model/AttendeeMapper.php b/lib/Model/AttendeeMapper.php index f1715b06d1e..08f59905d15 100644 --- a/lib/Model/AttendeeMapper.php +++ b/lib/Model/AttendeeMapper.php @@ -311,6 +311,7 @@ public function createAttendeeFromRow(array $row): Attendee { 'archived' => (bool)$row['archived'], 'important' => (bool)$row['important'], 'sensitive' => (bool)$row['sensitive'], + 'tag_ids' => $row['tag_ids'], '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'], diff --git a/lib/Model/ConversationTag.php b/lib/Model/ConversationTag.php new file mode 100644 index 00000000000..bd04bb0c2b8 --- /dev/null +++ b/lib/Model/ConversationTag.php @@ -0,0 +1,55 @@ +addType('userId', Types::STRING); + $this->addType('name', Types::STRING); + $this->addType('sortOrder', Types::INTEGER); + $this->addType('collapsed', Types::BOOLEAN); + $this->addType('type', Types::STRING); + } + + /** + * Overrides the magic `@method getType()` to narrow the return type for static analysis. + * The DB can only hold one of these three values (see ensureBuiltInTags / createTag). + * + * @return self::TYPE_* + */ + public function getType(): string { + return $this->type; + } +} diff --git a/lib/Model/ConversationTagMapper.php b/lib/Model/ConversationTagMapper.php new file mode 100644 index 00000000000..ba7feae846d --- /dev/null +++ b/lib/Model/ConversationTagMapper.php @@ -0,0 +1,77 @@ + + */ +class ConversationTagMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'talk_conversation_tags', ConversationTag::class); + } + + /** + * @return list + */ + public function findByUserId(string $userId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->orderBy('sort_order', 'ASC'); + + return $this->findEntities($qb); + } + + public function findById(string $id, string $userId): ConversationTag { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))); + + return $this->findEntity($qb); + } + + /** + * Clear a tag from all attendees' tag_ids JSON arrays when a tag is deleted + */ + public function clearTagFromAttendees(string $tagId, string $userId): void { + $qb = $this->db->getQueryBuilder(); + // Find attendees that have this tag in their JSON array + // Use quoted string match to avoid false positives (e.g. "1" matching "12") + $qb->select('a.id', 'a.tag_ids') + ->from('talk_attendees', 'a') + ->where($qb->expr()->like('a.tag_ids', $qb->createNamedParameter('%"' . $tagId . '"%'))) + ->andWhere($qb->expr()->eq('a.actor_type', $qb->createNamedParameter('users'))) + ->andWhere($qb->expr()->eq('a.actor_id', $qb->createNamedParameter($userId))); + + $result = $qb->executeQuery(); + while ($row = $result->fetch()) { + /** @var list $tagIds */ + $tagIds = json_decode($row['tag_ids'], true) ?? []; + $tagIds = array_values(array_filter($tagIds, fn ($id) => (string)$id !== $tagId)); + + $updateQb = $this->db->getQueryBuilder(); + $updateQb->update('talk_attendees') + ->set('tag_ids', $updateQb->createNamedParameter( + empty($tagIds) ? null : json_encode($tagIds), + empty($tagIds) ? IQueryBuilder::PARAM_NULL : IQueryBuilder::PARAM_STR + )) + ->where($updateQb->expr()->eq('id', $updateQb->createNamedParameter((int)$row['id'], IQueryBuilder::PARAM_INT))); + $updateQb->executeStatement(); + } + $result->closeCursor(); + } +} diff --git a/lib/Model/SelectHelper.php b/lib/Model/SelectHelper.php index 6ccc084f84f..f248e43d5eb 100644 --- a/lib/Model/SelectHelper.php +++ b/lib/Model/SelectHelper.php @@ -109,6 +109,7 @@ public function selectAttendeesTable(IQueryBuilder $query, string $alias = 'a'): $alias . 'archived', $alias . 'important', $alias . 'sensitive', + $alias . 'tag_ids', $alias . 'has_unread_threads', $alias . 'has_unread_thread_mentions', $alias . 'has_unread_thread_directs', diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 6f1a8e00aec..5c8ed5e407b 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -12,6 +12,19 @@ /** * @psalm-type TalkActorTypes = 'users'|'groups'|'guests'|'emails'|'circles'|'bridged'|'bots'|'federated_users'|'phones' * + * @psalm-type TalkConversationTag = array{ + * // SnowflakeID + * id: numeric-string, + * // Display name + * name: string, + * // Sort order from 0 (top) to higher (bottom) + * sortOrder: int, + * // Whether the tag list should be open or collapsed + * collapsed: bool, + * // favorites and other are special tags and have a fixed sorting position + * type: 'custom'|'favorites'|'other', + * } + * * @psalm-type TalkBan = array{ * // Identifier of the ban * id: int, @@ -562,6 +575,8 @@ * isImportant: bool, * // Required capability: `sensitive-conversations` * isSensitive: bool, + * // IDs of the custom tags this conversation is marked with (only available with `conversation-tags` capability) + * tagIds: list, * // Required capability: `pinned-messages` * lastPinnedId: int, * // Required capability: `pinned-messages` diff --git a/lib/Service/ConversationTagService.php b/lib/Service/ConversationTagService.php new file mode 100644 index 00000000000..0ef88d10de5 --- /dev/null +++ b/lib/Service/ConversationTagService.php @@ -0,0 +1,271 @@ + + */ + private function fetchAndEnsureBuiltInTags(string $userId): array { + $tags = $this->mapper->findByUserId($userId); + + $existingBuiltInTypes = []; + foreach ($tags as $tag) { + if ($tag->getType() !== ConversationTag::TYPE_CUSTOM) { + $existingBuiltInTypes[$tag->getType()] = true; + } + } + + foreach ([ConversationTag::TYPE_FAVORITES, ConversationTag::TYPE_OTHER] as $type) { + if (isset($existingBuiltInTypes[$type])) { + continue; + } + $newTag = new ConversationTag(); + $newTag->setUserId($userId); + $newTag->setName($type === ConversationTag::TYPE_FAVORITES ? $this->l->t('Favorites') : $this->l->t('Other')); + $newTag->setType($type); + $newTag->setCollapsed(false); + $newTag->setSortOrder($type === ConversationTag::TYPE_FAVORITES ? 0 : 1); + try { + $tags[] = $this->mapper->insert($newTag); + } catch (DBException $e) { + // Concurrent request inserted the same built-in already. The row exists, + // but we don't have a reference to it in $tags; worst case the caller sees + // a sort_order collision that gets resolved on the next reorder. Safe to skip. + if ($e->getReason() !== DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } + } + } + + return $tags; + } + + /** + * @return list + */ + public function getTags(string $userId): array { + return $this->fetchAndEnsureBuiltInTags($userId); + } + + public function getTag(string $tagId, string $userId): ConversationTag { + return $this->mapper->findById($tagId, $userId); + } + + /** + * @throws InvalidTagNameException when the name is empty or exceeds MAX_TAG_NAME_LENGTH + * @throws TagLimitExceededException when the user already owns MAX_CUSTOM_TAGS_PER_USER custom tags + * @throws TagNameAlreadyInUseException when the user already owns a custom tag with the same name + */ + public function createTag(string $userId, string $name): ConversationTag { + $name = $this->normalizeTagName($name); + + // Single read: we derive the custom count, the max custom sort_order, and the + // current 'other' built-in from the one list we just fetched. + $tags = $this->fetchAndEnsureBuiltInTags($userId); + + $customCount = 0; + $maxCustomSortOrder = 0; + $otherBuiltIn = null; + foreach ($tags as $tag) { + if ($tag->getType() === ConversationTag::TYPE_CUSTOM) { + $customCount++; + if ($tag->getSortOrder() > $maxCustomSortOrder) { + $maxCustomSortOrder = $tag->getSortOrder(); + } + } elseif ($tag->getType() === ConversationTag::TYPE_OTHER) { + $otherBuiltIn = $tag; + } + } + + if ($customCount >= self::MAX_CUSTOM_TAGS_PER_USER) { + throw new TagLimitExceededException(); + } + + $newSortOrder = $maxCustomSortOrder + 1; + + // If 'other' sits exactly at the new sort_order, push it up by one to keep sort_orders unique. + if ($otherBuiltIn !== null && $otherBuiltIn->getSortOrder() === $newSortOrder) { + $otherBuiltIn->setSortOrder($newSortOrder + 1); + $this->mapper->update($otherBuiltIn); + } + + $newTag = new ConversationTag(); + $newTag->setUserId($userId); + $newTag->setName($name); + $newTag->setType(ConversationTag::TYPE_CUSTOM); + $newTag->setSortOrder($newSortOrder); + $newTag->setCollapsed(false); + + try { + return $this->mapper->insert($newTag); + } catch (DBException $e) { + if ($e->getReason() === DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw new TagNameAlreadyInUseException(); + } + throw $e; + } + } + + /** + * @throws DoesNotExistException when no tag with that id exists for the user + * @throws InvalidTagNameException when the name is empty or exceeds MAX_TAG_NAME_LENGTH + * @throws TagNotCustomException when the tag is a built-in (favorites/other) and therefore immutable + * @throws TagNameAlreadyInUseException when the user already owns another custom tag with the same name + */ + public function updateTag(string $tagId, string $userId, string $name): ConversationTag { + $name = $this->normalizeTagName($name); + $tag = $this->mapper->findById($tagId, $userId); + if ($tag->getType() !== ConversationTag::TYPE_CUSTOM) { + throw new TagNotCustomException(); + } + $tag->setName($name); + try { + return $this->mapper->update($tag); + } catch (DBException $e) { + if ($e->getReason() === DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw new TagNameAlreadyInUseException(); + } + throw $e; + } + } + + /** + * @throws DoesNotExistException when no tag with that id exists for the user + * @throws TagNotCustomException when the tag is a built-in (favorites/other) and cannot be deleted + */ + public function deleteTag(string $tagId, string $userId): void { + $tag = $this->mapper->findById($tagId, $userId); + if ($tag->getType() !== ConversationTag::TYPE_CUSTOM) { + throw new TagNotCustomException(); + } + $this->mapper->clearTagFromAttendees($tagId, $userId); + $this->mapper->delete($tag); + } + + /** + * Trim the caller-supplied name, enforce non-empty and the MAX_TAG_NAME_LENGTH cap. + * + * @throws InvalidTagNameException + */ + private function normalizeTagName(string $name): string { + $name = trim($name); + if ($name === '' || mb_strlen($name) > self::MAX_TAG_NAME_LENGTH) { + throw new InvalidTagNameException(); + } + return $name; + } + + /** + * Take a caller-supplied list of tag IDs and return the subset that is: + * - a numeric string (matches `^\d+$`), + * - owned by $userId in the tag table, + * - not a duplicate, + * - within the MAX_TAG_IDS_PER_CONVERSATION cap. + * + * Everything else is silently dropped. The return value can safely be persisted as-is. + * + * @param list $tagIds Unchecked input from the API layer + * @return list + */ + public function validateTagIdsForUser(string $userId, array $tagIds): array { + if ($tagIds === []) { + return []; + } + + $ownedIds = array_map( + static fn (ConversationTag $tag): string => (string)$tag->getId(), + $this->mapper->findByUserId($userId), + ); + + $valid = []; + foreach ($tagIds as $tagId) { + if (count($valid) >= self::MAX_TAG_IDS_PER_CONVERSATION) { + break; + } + if (!is_string($tagId) && !is_int($tagId)) { + continue; + } + $tagIdStr = (string)$tagId; + if (!preg_match('/^\d+$/', $tagIdStr)) { + continue; + } + if (!in_array($tagIdStr, $ownedIds, true)) { + continue; + } + $valid[] = $tagIdStr; + } + return array_values(array_unique($valid)); + } + + /** + * @param string[] $orderedIds + */ + public function reorderTags(string $userId, array $orderedIds): void { + $tags = $this->mapper->findByUserId($userId); + + $order = 0; + $seen = []; + foreach ($orderedIds as $id) { + if (!is_string($id) || isset($seen[$id])) { + continue; + } + foreach ($tags as $tag) { + if ($tag->getId() !== $id) { + continue; + } + $tag->setSortOrder($order); + $this->mapper->update($tag); + $order++; + $seen[$id] = true; + break; + } + } + } + + /** + * Set the collapsed state of a tag + * + * @throws DoesNotExistException + */ + public function setCollapsed(string $tagId, string $userId, bool $collapsed): ConversationTag { + $tag = $this->mapper->findById($tagId, $userId); + $tag->setCollapsed($collapsed); + return $this->mapper->update($tag); + } +} diff --git a/lib/Service/ParticipantService.php b/lib/Service/ParticipantService.php index b0dc6c87f10..5c94913f4ba 100644 --- a/lib/Service/ParticipantService.php +++ b/lib/Service/ParticipantService.php @@ -326,6 +326,23 @@ public function unarchiveConversation(Participant $participant): void { $this->attendeeMapper->update($attendee); } + /** + * @param Participant $participant + * @param list $tagIds + */ + public function assignConversationToTags(Participant $participant, array $tagIds): void { + $attendee = $participant->getAttendee(); + + if (empty($tagIds)) { + $attendee->setTagIds(null); + } else { + $attendee->setTagIds(json_encode($tagIds)); + } + + $attendee->setLastAttendeeActivity($this->timeFactory->getTime()); + $this->attendeeMapper->update($attendee); + } + /** * @param Participant $participant */ diff --git a/lib/Service/RoomFormatter.php b/lib/Service/RoomFormatter.php index 2af9f20aa0e..bf3b9a9a696 100644 --- a/lib/Service/RoomFormatter.php +++ b/lib/Service/RoomFormatter.php @@ -158,6 +158,7 @@ public function formatRoomV4( 'isArchived' => false, 'isImportant' => false, 'isSensitive' => false, + 'tagIds' => [], 'hasScheduledMessages' => 0, 'attributes' => 0, ]; @@ -248,6 +249,7 @@ public function formatRoomV4( 'isArchived' => $attendee->isArchived(), 'isImportant' => $attendee->isImportant(), 'isSensitive' => $attendee->isSensitive(), + 'tagIds' => array_values(array_map('strval', json_decode($attendee->getTagIds() ?? '[]', true))), 'lastPinnedId' => $room->getLastPinnedId(), 'hiddenPinnedId' => $attendee->getHiddenPinnedId(), 'attributes' => $room->getAttributes(), diff --git a/tests/php/Chat/ChatManagerTest.php b/tests/php/Chat/ChatManagerTest.php index f931b8c27b2..7fcf3494586 100644 --- a/tests/php/Chat/ChatManagerTest.php +++ b/tests/php/Chat/ChatManagerTest.php @@ -439,6 +439,7 @@ public function testDeleteMessage(): void { 'archived' => 0, 'important' => 0, 'sensitive' => 0, + 'tag_ids' => null, 'has_unread_threads' => false, 'has_unread_thread_mentions' => false, 'has_unread_thread_directs' => false, @@ -509,6 +510,7 @@ public function testDeleteMessageFileShare(): void { 'archived' => 0, 'important' => 0, 'sensitive' => 0, + 'tag_ids' => null, 'has_unread_threads' => false, 'has_unread_thread_mentions' => false, 'has_unread_thread_directs' => false, @@ -601,6 +603,7 @@ public function testDeleteMessageFileShareNotFound(): void { 'archived' => 0, 'important' => 0, 'sensitive' => 0, + 'tag_ids' => null, 'has_unread_threads' => false, 'has_unread_thread_mentions' => false, 'has_unread_thread_directs' => false, From d0a8a40dccd4aa7daeac6cd97fa5f1a2885bd39a Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Thu, 23 Apr 2026 11:07:34 +0200 Subject: [PATCH 2/6] test(integration): Add integration tests for tags Co-Authored-By: Claude Opus 4.7 Signed-off-by: Joas Schilling --- .../features/bootstrap/FeatureContext.php | 215 ++++++++++++++++++ .../features/conversation-3/tags.feature | 158 +++++++++++++ .../lib/Controller/ApiController.php | 3 + 3 files changed, 376 insertions(+) create mode 100644 tests/integration/features/conversation-3/tags.feature diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index f8e1f212d97..5553832ef2e 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -83,6 +83,8 @@ class FeatureContext implements Context, SnippetAcceptingContext { protected static array $renamedTeams = []; /** @var array */ protected static array $userToBanId; + /** @var array> */ + protected static array $tagNameToId = []; protected static ?string $queryLogFile = null; protected static ?string $currentScenario = null; @@ -235,6 +237,7 @@ public function setUp(BeforeScenarioScope $scope): void { self::$userToSessionId = []; self::$userToAttendeeId = []; self::$userToBanId = []; + self::$tagNameToId = []; self::$textToMessageId = []; self::$messageIdToText = []; self::$titleToThreadId = []; @@ -4975,6 +4978,218 @@ public function userMarksConversationSensitive(string $user, string $identifier, $this->assertStatusCode($this->response, $statusCode); } + private function resolveTagId(string $user, string $name): string { + if (isset(self::$tagNameToId[$user][$name])) { + return self::$tagNameToId[$user][$name]; + } + // Built-in tags are addressed by their stable type keywords in the feature files, + // even though the API now returns localized display names. + if ($name === 'favorites' || $name === 'other') { + $this->fetchTagsIntoMap($user); + if (isset(self::$tagNameToId[$user][$name])) { + return self::$tagNameToId[$user][$name]; + } + } + // Allow scenarios to exercise tags owned by other users (to test access checks) + // and literal ids (e.g. "999999999" for not-found cases) + foreach (self::$tagNameToId as $otherUserTags) { + if (isset($otherUserTags[$name])) { + return $otherUserTags[$name]; + } + } + if (preg_match('/^\d+$/', $name)) { + return $name; + } + throw new \RuntimeException('Tag "' . $name . '" has not been created for user "' . $user . '"'); + } + + private function fetchTagsIntoMap(string $user): void { + $previousUser = $this->currentUser; + $this->setCurrentUser($user); + $this->sendRequest('GET', '/apps/spreed/api/v4/tags'); + if ($this->response->getStatusCode() === 200) { + foreach ($this->getDataFromResponse($this->response) as $tag) { + $key = $tag['type'] === 'custom' ? $tag['name'] : $tag['type']; + self::$tagNameToId[$user][$key] = (string)$tag['id']; + } + } + $this->setCurrentUser($previousUser); + } + + #[When('/^user "([^"]*)" creates tag "([^"]*)" with (\d+) \((v4)\)$/')] + public function userCreatesTag(string $user, string $name, int $statusCode, string $apiVersion = 'v4', ?TableNode $formData = null): void { + $this->setCurrentUser($user); + $this->sendRequest( + 'POST', '/apps/spreed/api/' . $apiVersion . '/tags', + ['name' => $name], + ); + $this->assertStatusCode($this->response, $statusCode); + + $body = $this->getDataFromResponse($this->response); + if ($statusCode === 201) { + Assert::assertIsArray($body); + Assert::assertSame($name, $body['name']); + Assert::assertSame('custom', $body['type']); + self::$tagNameToId[$user][$name] = (string)$body['id']; + } elseif ($formData instanceof TableNode) { + Assert::assertSame($formData->getRowsHash(), $body); + } + } + + #[When('/^user "([^"]*)" renames tag "([^"]*)" to "([^"]*)" with (\d+) \((v4)\)$/')] + public function userRenamesTag(string $user, string $oldName, string $newName, int $statusCode, string $apiVersion = 'v4', ?TableNode $formData = null): void { + $tagId = $this->resolveTagId($user, $oldName); + $this->setCurrentUser($user); + $this->sendRequest( + 'PUT', '/apps/spreed/api/' . $apiVersion . '/tags/' . $tagId, + ['name' => $newName], + ); + $this->assertStatusCode($this->response, $statusCode); + + if ($statusCode === 200) { + if (isset(self::$tagNameToId[$user][$oldName])) { + self::$tagNameToId[$user][$newName] = self::$tagNameToId[$user][$oldName]; + unset(self::$tagNameToId[$user][$oldName]); + } + } elseif ($formData instanceof TableNode) { + Assert::assertSame($formData->getRowsHash(), $this->getDataFromResponse($this->response)); + } + } + + #[When('/^user "([^"]*)" deletes tag "([^"]*)" with (\d+) \((v4)\)$/')] + public function userDeletesTag(string $user, string $name, int $statusCode, string $apiVersion = 'v4', ?TableNode $formData = null): void { + $tagId = $this->resolveTagId($user, $name); + $this->setCurrentUser($user); + $this->sendRequest( + 'DELETE', '/apps/spreed/api/' . $apiVersion . '/tags/' . $tagId, + ); + $this->assertStatusCode($this->response, $statusCode); + + if ($statusCode === 200) { + unset(self::$tagNameToId[$user][$name]); + } elseif ($formData instanceof TableNode) { + Assert::assertSame($formData->getRowsHash(), $this->getDataFromResponse($this->response)); + } + } + + #[When('/^user "([^"]*)" (collapses|expands) tag "([^"]*)" with (\d+) \((v4)\)$/')] + public function userCollapsesTag(string $user, string $action, string $name, int $statusCode, string $apiVersion): void { + $tagId = $this->resolveTagId($user, $name); + $this->setCurrentUser($user); + $this->sendRequest( + 'PUT', '/apps/spreed/api/' . $apiVersion . '/tags/' . $tagId . '/collapsed', + ['collapsed' => $action === 'collapses' ? 1 : 0], + ); + $this->assertStatusCode($this->response, $statusCode); + } + + #[When('/^user "([^"]*)" reorders tags to "([^"]*)" with (\d+) \((v4)\)$/')] + public function userReordersTags(string $user, string $names, int $statusCode, string $apiVersion): void { + $orderedIds = array_map( + fn (string $name): string => $this->resolveTagId($user, trim($name)), + explode(',', $names), + ); + + $this->setCurrentUser($user); + $this->sendRequest( + 'PUT', '/apps/spreed/api/' . $apiVersion . '/tags/reorder', + ['orderedIds' => $orderedIds], + ); + $this->assertStatusCode($this->response, $statusCode); + } + + #[Then('/^user "([^"]*)" sees the following tags with (\d+) \((v4)\)$/')] + public function userSeesTheFollowingTags(string $user, int $statusCode, string $apiVersion, ?TableNode $formData = null): void { + $this->setCurrentUser($user); + $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/tags'); + $this->assertStatusCode($this->response, $statusCode); + + if ($statusCode !== 200) { + return; + } + + $tags = $this->getDataFromResponse($this->response); + foreach ($tags as $tag) { + if ($tag['type'] === 'custom') { + self::$tagNameToId[$user][$tag['name']] = (string)$tag['id']; + } else { + // Built-in tags are addressed by their type keyword in the feature files, + // while their display name is localized in API responses. + self::$tagNameToId[$user][$tag['type']] = (string)$tag['id']; + } + } + + if ($formData === null) { + Assert::assertEmpty($tags); + return; + } + + $expected = $formData->getColumnsHash(); + Assert::assertCount(count($expected), $tags, 'Tag count does not match'); + $actual = array_map(static function (array $tag, array $expectedTag): array { + $data = []; + if (isset($expectedTag['name'])) { + $data['name'] = $tag['name']; + } + if (isset($expectedTag['type'])) { + $data['type'] = $tag['type']; + } + if (isset($expectedTag['sortOrder'])) { + $data['sortOrder'] = (string)$tag['sortOrder']; + } + if (isset($expectedTag['collapsed'])) { + $data['collapsed'] = $tag['collapsed'] ? '1' : '0'; + } + return $data; + }, $tags, $expected); + + Assert::assertSame($expected, $actual); + } + + #[When('/^user "([^"]*)" assigns tags "([^"]*)" to room "([^"]*)" with (\d+) \((v4)\)$/')] + public function userAssignsTagsToRoom(string $user, string $names, string $identifier, int $statusCode, string $apiVersion): void { + $tagIds = []; + if ($names !== '') { + $tagIds = array_map( + fn (string $name): string => $this->resolveTagId($user, trim($name)), + explode(',', $names), + ); + } + + $this->setCurrentUser($user); + $this->sendRequest( + 'POST', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/tags', + ['tagIds' => $tagIds], + ); + $this->assertStatusCode($this->response, $statusCode); + } + + #[Then('/^user "([^"]*)" sees tags "([^"]*)" on room "([^"]*)" with (\d+) \((v4)\)$/')] + public function userSeesTagsOnRoom(string $user, string $names, string $identifier, int $statusCode, string $apiVersion): void { + $this->setCurrentUser($user); + $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier]); + $this->assertStatusCode($this->response, $statusCode); + + if ($statusCode !== 200) { + return; + } + + $expectedIds = []; + if ($names !== '') { + $expectedIds = array_map( + fn (string $name): string => $this->resolveTagId($user, trim($name)), + explode(',', $names), + ); + } + + $room = $this->getDataFromResponse($this->response); + $actual = array_values(array_map('strval', $room['tagIds'] ?? [])); + + sort($expectedIds); + sort($actual); + Assert::assertSame($expectedIds, $actual); + } + public function sendRequestFullUrl(string $verb, string $fullUrl, TableNode|array|string|null $body = null, array $headers = [], array $options = []): void { $client = new Client(); $options = array_merge($options, ['cookies' => $this->getUserCookieJar($this->currentUser)]); diff --git a/tests/integration/features/conversation-3/tags.feature b/tests/integration/features/conversation-3/tags.feature new file mode 100644 index 00000000000..544c1dba7f3 --- /dev/null +++ b/tests/integration/features/conversation-3/tags.feature @@ -0,0 +1,158 @@ +Feature: conversation-3/tags + Background: + Given user "participant1" exists + Given user "participant2" exists + + Scenario: Listing tags auto-provisions the two built-in tags + When user "participant1" sees the following tags with 200 (v4) + | name | type | sortOrder | collapsed | + | Favorites | favorites | 0 | 0 | + | Other | other | 1 | 0 | + + Scenario: Creating, renaming and deleting a custom tag + When user "participant1" creates tag "Work" with 201 (v4) + And user "participant1" creates tag "Family" with 201 (v4) + Then user "participant1" sees the following tags with 200 (v4) + | name | type | sortOrder | collapsed | + | Favorites | favorites | 0 | 0 | + | Work | custom | 1 | 0 | + | Family | custom | 2 | 0 | + | Other | other | 3 | 0 | + When user "participant1" renames tag "Work" to "Projects" with 200 (v4) + And user "participant1" deletes tag "Family" with 200 (v4) + Then user "participant1" sees the following tags with 200 (v4) + | name | type | sortOrder | collapsed | + | Favorites | favorites | 0 | 0 | + | Projects | custom | 1 | 0 | + | Other | other | 3 | 0 | + + Scenario: Tags are scoped per user + Given user "participant1" creates tag "Work" with 201 (v4) + When user "participant2" sees the following tags with 200 (v4) + | name | type | sortOrder | collapsed | + | Favorites | favorites | 0 | 0 | + | Other | other | 1 | 0 | + And user "participant1" sees the following tags with 200 (v4) + | name | type | sortOrder | collapsed | + | Favorites | favorites | 0 | 0 | + | Work | custom | 1 | 0 | + | Other | other | 2 | 0 | + + Scenario: Rejects empty or duplicate tag names + Given user "participant1" creates tag "Work" with 201 (v4) + When user "participant1" creates tag "" with 400 (v4) + | error | name | + And user "participant1" creates tag " " with 400 (v4) + | error | name | + And user "participant1" creates tag "Work" with 400 (v4) + | error | name | + + Scenario: Built-in tags cannot be renamed or deleted + Given user "participant1" sees the following tags with 200 (v4) + | name | type | sortOrder | collapsed | + | Favorites | favorites | 0 | 0 | + | Other | other | 1 | 0 | + When user "participant1" renames tag "favorites" to "Starred" with 400 (v4) + | error | type | + And user "participant1" deletes tag "other" with 400 (v4) + | error | type | + + Scenario: Updating a non-existent tag returns 404 + When user "participant1" renames tag "999999999" to "Nope" with 404 (v4) + And user "participant1" deletes tag "999999999" with 404 (v4) + + Scenario: Rename rejects empty name and duplicate of another custom tag + Given user "participant1" creates tag "Work" with 201 (v4) + And user "participant1" creates tag "Family" with 201 (v4) + When user "participant1" renames tag "Work" to "" with 400 (v4) + | error | name | + And user "participant1" renames tag "Work" to "Family" with 400 (v4) + | error | name | + + Scenario: Collapsing and expanding a tag + Given user "participant1" creates tag "Work" with 201 (v4) + When user "participant1" collapses tag "Work" with 200 (v4) + And user "participant1" collapses tag "favorites" with 200 (v4) + Then user "participant1" sees the following tags with 200 (v4) + | name | type | sortOrder | collapsed | + | Favorites | favorites | 0 | 1 | + | Work | custom | 1 | 1 | + | Other | other | 2 | 0 | + When user "participant1" expands tag "favorites" with 200 (v4) + Then user "participant1" sees the following tags with 200 (v4) + | name | type | sortOrder | collapsed | + | Favorites | favorites | 0 | 0 | + | Work | custom | 1 | 1 | + | Other | other | 2 | 0 | + + Scenario: Reordering tags + Given user "participant1" creates tag "Work" with 201 (v4) + And user "participant1" creates tag "Family" with 201 (v4) + And user "participant1" creates tag "Hobbies" with 201 (v4) + And user "participant1" sees the following tags with 200 (v4) + | name | type | sortOrder | + | Favorites | favorites | 0 | + | Work | custom | 1 | + | Family | custom | 2 | + | Hobbies | custom | 3 | + | Other | other | 4 | + When user "participant1" reorders tags to "favorites, Hobbies, Work, Family, other" with 200 (v4) + Then user "participant1" sees the following tags with 200 (v4) + | name | type | sortOrder | + | Favorites | favorites | 0 | + | Hobbies | custom | 1 | + | Work | custom | 2 | + | Family | custom | 3 | + | Other | other | 4 | + + Scenario: Assigning and unassigning tags on a conversation + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" creates tag "Work" with 201 (v4) + And user "participant1" creates tag "Family" with 201 (v4) + When user "participant1" assigns tags "Work, Family" to room "group room" with 200 (v4) + Then user "participant1" sees tags "Work, Family" on room "group room" with 200 (v4) + When user "participant1" assigns tags "Work" to room "group room" with 200 (v4) + Then user "participant1" sees tags "Work" on room "group room" with 200 (v4) + When user "participant1" assigns tags "" to room "group room" with 200 (v4) + Then user "participant1" sees tags "" on room "group room" with 200 (v4) + + Scenario: Tag assignments are scoped per participant + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "group room" with 200 (v4) + And user "participant1" creates tag "Work" with 201 (v4) + And user "participant2" creates tag "Personal" with 201 (v4) + When user "participant1" assigns tags "Work" to room "group room" with 200 (v4) + And user "participant2" assigns tags "Personal" to room "group room" with 200 (v4) + Then user "participant1" sees tags "Work" on room "group room" with 200 (v4) + And user "participant2" sees tags "Personal" on room "group room" with 200 (v4) + + Scenario: Assigning a tag owned by another user is silently dropped + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant2" creates tag "Foreign" with 201 (v4) + And user "participant1" creates tag "Own" with 201 (v4) + When user "participant1" assigns tags "Foreign, Own" to room "group room" with 200 (v4) + Then user "participant1" sees tags "Own" on room "group room" with 200 (v4) + + Scenario: Non-existent tag ids are silently dropped + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" creates tag "Work" with 201 (v4) + When user "participant1" assigns tags "999999999, Work" to room "group room" with 200 (v4) + Then user "participant1" sees tags "Work" on room "group room" with 200 (v4) + + Scenario: Deleting a tag also removes it from assigned conversations + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" creates tag "Work" with 201 (v4) + And user "participant1" creates tag "Family" with 201 (v4) + And user "participant1" assigns tags "Work, Family" to room "group room" with 200 (v4) + When user "participant1" deletes tag "Work" with 200 (v4) + Then user "participant1" sees tags "Family" on room "group room" with 200 (v4) diff --git a/tests/integration/spreedcheats/lib/Controller/ApiController.php b/tests/integration/spreedcheats/lib/Controller/ApiController.php index eb227d3090c..d10a9ad8914 100644 --- a/tests/integration/spreedcheats/lib/Controller/ApiController.php +++ b/tests/integration/spreedcheats/lib/Controller/ApiController.php @@ -61,6 +61,9 @@ public function resetSpreed(): DataResponse { $delete = $this->db->getQueryBuilder(); $delete->delete('talk_consent')->executeStatement(); + $delete = $this->db->getQueryBuilder(); + $delete->delete('talk_conversation_tags')->executeStatement(); + $delete = $this->db->getQueryBuilder(); $delete->delete('talk_internalsignaling')->executeStatement(); From 349a266cd3b618966e44006213d857e78d05ebec Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Sun, 26 Apr 2026 13:17:29 +0200 Subject: [PATCH 3/6] chore: regenerate openapi types Signed-off-by: Maksim Sukharev --- openapi-backend-sipbridge.json | 8 + openapi-federation.json | 8 + openapi-full.json | 1449 ++++++++++++++-- openapi.json | 1453 +++++++++++++++-- .../openapi/openapi-backend-sipbridge.ts | 2 + src/types/openapi/openapi-federation.ts | 2 + src/types/openapi/openapi-full.ts | 574 +++++++ src/types/openapi/openapi.ts | 574 +++++++ 8 files changed, 3734 insertions(+), 336 deletions(-) diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index c23432e87eb..2c319bbf544 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -938,6 +938,7 @@ "isArchived", "isImportant", "isSensitive", + "tagIds", "lastPinnedId", "hiddenPinnedId", "hasScheduledMessages", @@ -1236,6 +1237,13 @@ "type": "boolean", "description": "Required capability: `sensitive-conversations`" }, + "tagIds": { + "type": "array", + "description": "IDs of the custom tags this conversation is marked with (only available with `conversation-tags` capability)", + "items": { + "type": "string" + } + }, "lastPinnedId": { "type": "integer", "format": "int64", diff --git a/openapi-federation.json b/openapi-federation.json index 1f99fd6b5da..7b254a49b81 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -1003,6 +1003,7 @@ "isArchived", "isImportant", "isSensitive", + "tagIds", "lastPinnedId", "hiddenPinnedId", "hasScheduledMessages", @@ -1301,6 +1302,13 @@ "type": "boolean", "description": "Required capability: `sensitive-conversations`" }, + "tagIds": { + "type": "array", + "description": "IDs of the custom tags this conversation is marked with (only available with `conversation-tags` capability)", + "items": { + "type": "string" + } + }, "lastPinnedId": { "type": "integer", "format": "int64", diff --git a/openapi-full.json b/openapi-full.json index cd620ec092c..739d31a0f5b 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1070,6 +1070,44 @@ } } }, + "ConversationTag": { + "type": "object", + "required": [ + "id", + "name", + "sortOrder", + "collapsed", + "type" + ], + "properties": { + "id": { + "type": "string", + "description": "SnowflakeID" + }, + "name": { + "type": "string", + "description": "Display name" + }, + "sortOrder": { + "type": "integer", + "format": "int64", + "description": "Sort order from 0 (top) to higher (bottom)" + }, + "collapsed": { + "type": "boolean", + "description": "Whether the tag list should be open or collapsed" + }, + "type": { + "type": "string", + "enum": [ + "custom", + "favorites", + "other" + ], + "description": "favorites and other are special tags and have a fixed sorting position" + } + } + }, "DashboardEvent": { "type": "object", "required": [ @@ -1987,6 +2025,7 @@ "isArchived", "isImportant", "isSensitive", + "tagIds", "lastPinnedId", "hiddenPinnedId", "hasScheduledMessages", @@ -2285,6 +2324,13 @@ "type": "boolean", "description": "Required capability: `sensitive-conversations`" }, + "tagIds": { + "type": "array", + "description": "IDs of the custom tags this conversation is marked with (only available with `conversation-tags` capability)", + "items": { + "type": "string" + } + }, "lastPinnedId": { "type": "integer", "format": "int64", @@ -12814,13 +12860,13 @@ } } }, - "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags": { "get": { - "operationId": "files_integration-get-room-by-file-id", - "summary": "Get the token of the room associated to the given file id", - "description": "This is the counterpart of self::getRoomByShareToken() for file ids instead of share tokens, although both return the same room token if the given file id and share token refer to the same file.\nIf there is no room associated to the given file id a new room is created; the new room is a public room associated with a \"file\" object with the given file id. Unlike normal rooms in which the owner is the user that created the room these are special rooms without owner (although self joined users with direct access to the file become persistent participants automatically when they join until they explicitly leave or no longer have access to the file).\nIn any case, to create or even get the token of the room, the file must be shared and the user must be the owner of a public share of the file (like a link share, for example) or have direct access to that file; an error is returned otherwise. A user has direct access to a file if they have access to it (or to an ancestor) through a user, group, circle or room share (but not through a link share, for example), or if they are the owner of such a file.", + "operationId": "conversation_tag-get-tags", + "summary": "Get all conversation tags for the current user", + "description": "Required capability: `conversation-tags`", "tags": [ - "files_integration" + "conversation_tag" ], "security": [ { @@ -12838,19 +12884,9 @@ "schema": { "type": "string", "enum": [ - "v1" + "v4" ], - "default": "v1" - } - }, - { - "name": "fileId", - "in": "path", - "description": "ID of the file", - "required": true, - "schema": { - "type": "string", - "pattern": "^.+$" + "default": "v4" } }, { @@ -12866,7 +12902,7 @@ ], "responses": { "200": { - "description": "Room token returned", + "description": "Tags returned", "content": { "application/json": { "schema": { @@ -12886,14 +12922,9 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "token" - ], - "properties": { - "token": { - "type": "string" - } + "type": "array", + "items": { + "$ref": "#/components/schemas/ConversationTag" } } } @@ -12903,64 +12934,6 @@ } } }, - "400": { - "description": "Rooms not allowed for shares", - "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": "Share not found", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": {} - } - } - } - } - } - } - }, "401": { "description": "Current user is not logged in", "content": { @@ -12990,18 +12963,15 @@ } } } - } - }, - "/ocs/v2.php/apps/spreed/api/{apiVersion}/publicshare/{shareToken}": { - "get": { - "operationId": "files_integration-get-room-by-share-token", - "summary": "Returns the token of the room associated to the file of the given share token", - "description": "This is the counterpart of self::getRoomByFileId() for share tokens instead of file ids, although both return the same room token if the given file id and share token refer to the same file.\nIf there is no room associated to the file id of the given share token a new room is created; the new room is a public room associated with a \"file\" object with the file id of the given share token. Unlike normal rooms in which the owner is the user that created the room these are special rooms without owner (although self joined users with direct access to the file become persistent participants automatically when they join until they explicitly leave or no longer have access to the file).\nIn any case, to create or even get the token of the room, the file must be publicly shared (like a link share, for example); an error is returned otherwise.\nBesides the token of the room this also returns the current user ID and display name, if any; this is needed by the Talk sidebar to know the actual current user, as the public share page uses the incognito mode and thus logged-in users as seen as guests.", + }, + "post": { + "operationId": "conversation_tag-create-tag", + "summary": "Create a new conversation tag", + "description": "Required capability: `conversation-tags`", "tags": [ - "files_integration" + "conversation_tag" ], "security": [ - {}, { "bearer_auth": [] }, @@ -13009,6 +12979,25 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the tag" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -13017,19 +13006,9 @@ "schema": { "type": "string", "enum": [ - "v1" + "v4" ], - "default": "v1" - } - }, - { - "name": "shareToken", - "in": "path", - "description": "Token of the file share", - "required": true, - "schema": { - "type": "string", - "pattern": "^.+$" + "default": "v4" } }, { @@ -13044,8 +13023,8 @@ } ], "responses": { - "200": { - "description": "Room token and user info returned", + "201": { + "description": "Tag created", "content": { "application/json": { "schema": { @@ -13065,23 +13044,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "token", - "userId", - "userDisplayName" - ], - "properties": { - "token": { - "type": "string" - }, - "userId": { - "type": "string" - }, - "userDisplayName": { - "type": "string" - } - } + "$ref": "#/components/schemas/ConversationTag" } } } @@ -13091,7 +13054,7 @@ } }, "400": { - "description": "Rooms not allowed for shares", + "description": "Invalid or duplicate name, or the user has reached the tag limit", "content": { "application/json": { "schema": { @@ -13111,7 +13074,19 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "nullable": true + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "name", + "limit" + ] + } + } } } } @@ -13120,8 +13095,8 @@ } } }, - "404": { - "description": "Share not found", + "401": { + "description": "Current user is not logged in", "content": { "application/json": { "schema": { @@ -13140,9 +13115,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "nullable": true - } + "data": {} } } } @@ -13153,15 +13126,15 @@ } } }, - "/ocs/v2.php/apps/spreed/api/{apiVersion}/guest/{token}/name": { - "post": { - "operationId": "guest-set-display-name", - "summary": "Set the display name as a guest", + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags/{tagId}": { + "put": { + "operationId": "conversation_tag-update-tag", + "summary": "Update a conversation tag", + "description": "Required capability: `conversation-tags`", "tags": [ - "guest" + "conversation_tag" ], "security": [ - {}, { "bearer_auth": [] }, @@ -13176,12 +13149,12 @@ "schema": { "type": "object", "required": [ - "displayName" + "name" ], "properties": { - "displayName": { + "name": { "type": "string", - "description": "New display name" + "description": "New name for the tag" } } } @@ -13196,18 +13169,19 @@ "schema": { "type": "string", "enum": [ - "v1" + "v4" ], - "default": "v1" + "default": "v4" } }, { - "name": "token", + "name": "tagId", "in": "path", + "description": "ID of the tag", "required": true, "schema": { "type": "string", - "pattern": "^[a-z0-9]{4,30}$" + "pattern": "^\\d+$" } }, { @@ -13223,7 +13197,7 @@ ], "responses": { "200": { - "description": "Display name updated successfully", + "description": "Tag updated", "content": { "application/json": { "schema": { @@ -13243,7 +13217,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "nullable": true + "$ref": "#/components/schemas/ConversationTag" } } } @@ -13252,8 +13226,8 @@ } } }, - "403": { - "description": "Not a guest", + "400": { + "description": "Invalid or duplicate name, or the tag is a built-in and cannot be renamed", "content": { "application/json": { "schema": { @@ -13273,7 +13247,19 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "nullable": true + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "name", + "type" + ] + } + } } } } @@ -13283,7 +13269,7 @@ } }, "404": { - "description": "Not a participant", + "description": "Tag not found", "content": { "application/json": { "schema": { @@ -13311,24 +13297,1022 @@ } } } - } - } - } - }, - "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}": { - "post": { - "operationId": "live_transcription-enable", - "summary": "Enable the live transcription", - "tags": [ - "live_transcription" - ], - "security": [ - {}, - { - "bearer_auth": [] }, - { - "basic_auth": [] + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "conversation_tag-delete-tag", + "summary": "Delete a conversation tag", + "description": "Required capability: `conversation-tags`", + "tags": [ + "conversation_tag" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "tagId", + "in": "path", + "description": "ID of the tag", + "required": true, + "schema": { + "type": "string", + "pattern": "^\\d+$" + } + }, + { + "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": "Tag deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "400": { + "description": "The tag is a built-in and cannot be deleted", + "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": [ + "type" + ] + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Tag not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags/reorder": { + "put": { + "operationId": "conversation_tag-reorder-tags", + "summary": "Reorder conversation tags", + "description": "Required capability: `conversation-tags`", + "tags": [ + "conversation_tag" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "orderedIds" + ], + "properties": { + "orderedIds": { + "type": "array", + "description": "Ordered list of tag IDs", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "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": "Tags reordered", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConversationTag" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags/{tagId}/collapsed": { + "put": { + "operationId": "conversation_tag-update-tag-collapsed", + "summary": "Set the collapsed state of a conversation tag", + "description": "Required capability: `conversation-tags`", + "tags": [ + "conversation_tag" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "collapsed" + ], + "properties": { + "collapsed": { + "type": "boolean", + "description": "Whether the tag should be collapsed" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "tagId", + "in": "path", + "description": "ID of the tag", + "required": true, + "schema": { + "type": "string", + "pattern": "^\\d+$" + } + }, + { + "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": "Collapsed state updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ConversationTag" + } + } + } + } + } + } + } + }, + "404": { + "description": "Tag not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { + "get": { + "operationId": "files_integration-get-room-by-file-id", + "summary": "Get the token of the room associated to the given file id", + "description": "This is the counterpart of self::getRoomByShareToken() for file ids instead of share tokens, although both return the same room token if the given file id and share token refer to the same file.\nIf there is no room associated to the given file id a new room is created; the new room is a public room associated with a \"file\" object with the given file id. Unlike normal rooms in which the owner is the user that created the room these are special rooms without owner (although self joined users with direct access to the file become persistent participants automatically when they join until they explicitly leave or no longer have access to the file).\nIn any case, to create or even get the token of the room, the file must be shared and the user must be the owner of a public share of the file (like a link share, for example) or have direct access to that file; an error is returned otherwise. A user has direct access to a file if they have access to it (or to an ancestor) through a user, group, circle or room share (but not through a link share, for example), or if they are the owner of such a file.", + "tags": [ + "files_integration" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "fileId", + "in": "path", + "description": "ID of the file", + "required": true, + "schema": { + "type": "string", + "pattern": "^.+$" + } + }, + { + "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": "Room token returned", + "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": [ + "token" + ], + "properties": { + "token": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Rooms not allowed for shares", + "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": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/publicshare/{shareToken}": { + "get": { + "operationId": "files_integration-get-room-by-share-token", + "summary": "Returns the token of the room associated to the file of the given share token", + "description": "This is the counterpart of self::getRoomByFileId() for share tokens instead of file ids, although both return the same room token if the given file id and share token refer to the same file.\nIf there is no room associated to the file id of the given share token a new room is created; the new room is a public room associated with a \"file\" object with the file id of the given share token. Unlike normal rooms in which the owner is the user that created the room these are special rooms without owner (although self joined users with direct access to the file become persistent participants automatically when they join until they explicitly leave or no longer have access to the file).\nIn any case, to create or even get the token of the room, the file must be publicly shared (like a link share, for example); an error is returned otherwise.\nBesides the token of the room this also returns the current user ID and display name, if any; this is needed by the Talk sidebar to know the actual current user, as the public share page uses the incognito mode and thus logged-in users as seen as guests.", + "tags": [ + "files_integration" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "shareToken", + "in": "path", + "description": "Token of the file share", + "required": true, + "schema": { + "type": "string", + "pattern": "^.+$" + } + }, + { + "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": "Room token and user info returned", + "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": [ + "token", + "userId", + "userDisplayName" + ], + "properties": { + "token": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "userDisplayName": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Rooms not allowed for shares", + "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": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/guest/{token}/name": { + "post": { + "operationId": "guest-set-display-name", + "summary": "Set the display name as a guest", + "tags": [ + "guest" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "displayName" + ], + "properties": { + "displayName": { + "type": "string", + "description": "New display name" + } + } + } + } + } + }, + "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": "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": "Display name updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "403": { + "description": "Not a guest", + "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": "Not a participant", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}": { + "post": { + "operationId": "live_transcription-enable", + "summary": "Enable the live transcription", + "tags": [ + "live_transcription" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] } ], "parameters": [ @@ -21989,6 +22973,137 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/tags": { + "post": { + "operationId": "room-assign-tags", + "summary": "Assign conversation tags", + "description": "Required capability: `conversation-tags`", + "tags": [ + "room" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tagIds": { + "type": "array", + "default": [], + "description": "IDs of tags to assign (empty array to unassign all)", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "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": "Conversation tags updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Room" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/important": { "post": { "operationId": "room-mark-conversation-as-important", diff --git a/openapi.json b/openapi.json index d80abd78355..016a102eeb4 100644 --- a/openapi.json +++ b/openapi.json @@ -1023,6 +1023,44 @@ } } }, + "ConversationTag": { + "type": "object", + "required": [ + "id", + "name", + "sortOrder", + "collapsed", + "type" + ], + "properties": { + "id": { + "type": "string", + "description": "SnowflakeID" + }, + "name": { + "type": "string", + "description": "Display name" + }, + "sortOrder": { + "type": "integer", + "format": "int64", + "description": "Sort order from 0 (top) to higher (bottom)" + }, + "collapsed": { + "type": "boolean", + "description": "Whether the tag list should be open or collapsed" + }, + "type": { + "type": "string", + "enum": [ + "custom", + "favorites", + "other" + ], + "description": "favorites and other are special tags and have a fixed sorting position" + } + } + }, "DashboardEvent": { "type": "object", "required": [ @@ -1875,6 +1913,7 @@ "isArchived", "isImportant", "isSensitive", + "tagIds", "lastPinnedId", "hiddenPinnedId", "hasScheduledMessages", @@ -2173,6 +2212,13 @@ "type": "boolean", "description": "Required capability: `sensitive-conversations`" }, + "tagIds": { + "type": "array", + "description": "IDs of the custom tags this conversation is marked with (only available with `conversation-tags` capability)", + "items": { + "type": "string" + } + }, "lastPinnedId": { "type": "integer", "format": "int64", @@ -12702,13 +12748,13 @@ } } }, - "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags": { "get": { - "operationId": "files_integration-get-room-by-file-id", - "summary": "Get the token of the room associated to the given file id", - "description": "This is the counterpart of self::getRoomByShareToken() for file ids instead of share tokens, although both return the same room token if the given file id and share token refer to the same file.\nIf there is no room associated to the given file id a new room is created; the new room is a public room associated with a \"file\" object with the given file id. Unlike normal rooms in which the owner is the user that created the room these are special rooms without owner (although self joined users with direct access to the file become persistent participants automatically when they join until they explicitly leave or no longer have access to the file).\nIn any case, to create or even get the token of the room, the file must be shared and the user must be the owner of a public share of the file (like a link share, for example) or have direct access to that file; an error is returned otherwise. A user has direct access to a file if they have access to it (or to an ancestor) through a user, group, circle or room share (but not through a link share, for example), or if they are the owner of such a file.", + "operationId": "conversation_tag-get-tags", + "summary": "Get all conversation tags for the current user", + "description": "Required capability: `conversation-tags`", "tags": [ - "files_integration" + "conversation_tag" ], "security": [ { @@ -12726,19 +12772,9 @@ "schema": { "type": "string", "enum": [ - "v1" + "v4" ], - "default": "v1" - } - }, - { - "name": "fileId", - "in": "path", - "description": "ID of the file", - "required": true, - "schema": { - "type": "string", - "pattern": "^.+$" + "default": "v4" } }, { @@ -12754,7 +12790,7 @@ ], "responses": { "200": { - "description": "Room token returned", + "description": "Tags returned", "content": { "application/json": { "schema": { @@ -12774,14 +12810,9 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "token" - ], - "properties": { - "token": { - "type": "string" - } + "type": "array", + "items": { + "$ref": "#/components/schemas/ConversationTag" } } } @@ -12791,64 +12822,6 @@ } } }, - "400": { - "description": "Rooms not allowed for shares", - "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": "Share not found", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": {} - } - } - } - } - } - } - }, "401": { "description": "Current user is not logged in", "content": { @@ -12878,18 +12851,15 @@ } } } - } - }, - "/ocs/v2.php/apps/spreed/api/{apiVersion}/publicshare/{shareToken}": { - "get": { - "operationId": "files_integration-get-room-by-share-token", - "summary": "Returns the token of the room associated to the file of the given share token", - "description": "This is the counterpart of self::getRoomByFileId() for share tokens instead of file ids, although both return the same room token if the given file id and share token refer to the same file.\nIf there is no room associated to the file id of the given share token a new room is created; the new room is a public room associated with a \"file\" object with the file id of the given share token. Unlike normal rooms in which the owner is the user that created the room these are special rooms without owner (although self joined users with direct access to the file become persistent participants automatically when they join until they explicitly leave or no longer have access to the file).\nIn any case, to create or even get the token of the room, the file must be publicly shared (like a link share, for example); an error is returned otherwise.\nBesides the token of the room this also returns the current user ID and display name, if any; this is needed by the Talk sidebar to know the actual current user, as the public share page uses the incognito mode and thus logged-in users as seen as guests.", + }, + "post": { + "operationId": "conversation_tag-create-tag", + "summary": "Create a new conversation tag", + "description": "Required capability: `conversation-tags`", "tags": [ - "files_integration" + "conversation_tag" ], "security": [ - {}, { "bearer_auth": [] }, @@ -12897,6 +12867,25 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the tag" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -12905,19 +12894,9 @@ "schema": { "type": "string", "enum": [ - "v1" + "v4" ], - "default": "v1" - } - }, - { - "name": "shareToken", - "in": "path", - "description": "Token of the file share", - "required": true, - "schema": { - "type": "string", - "pattern": "^.+$" + "default": "v4" } }, { @@ -12932,8 +12911,8 @@ } ], "responses": { - "200": { - "description": "Room token and user info returned", + "201": { + "description": "Tag created", "content": { "application/json": { "schema": { @@ -12953,23 +12932,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "token", - "userId", - "userDisplayName" - ], - "properties": { - "token": { - "type": "string" - }, - "userId": { - "type": "string" - }, - "userDisplayName": { - "type": "string" - } - } + "$ref": "#/components/schemas/ConversationTag" } } } @@ -12979,7 +12942,7 @@ } }, "400": { - "description": "Rooms not allowed for shares", + "description": "Invalid or duplicate name, or the user has reached the tag limit", "content": { "application/json": { "schema": { @@ -12999,7 +12962,19 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "nullable": true + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "name", + "limit" + ] + } + } } } } @@ -13008,8 +12983,8 @@ } } }, - "404": { - "description": "Share not found", + "401": { + "description": "Current user is not logged in", "content": { "application/json": { "schema": { @@ -13028,9 +13003,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "nullable": true - } + "data": {} } } } @@ -13041,15 +13014,15 @@ } } }, - "/ocs/v2.php/apps/spreed/api/{apiVersion}/guest/{token}/name": { - "post": { - "operationId": "guest-set-display-name", - "summary": "Set the display name as a guest", + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags/{tagId}": { + "put": { + "operationId": "conversation_tag-update-tag", + "summary": "Update a conversation tag", + "description": "Required capability: `conversation-tags`", "tags": [ - "guest" + "conversation_tag" ], "security": [ - {}, { "bearer_auth": [] }, @@ -13064,12 +13037,12 @@ "schema": { "type": "object", "required": [ - "displayName" + "name" ], "properties": { - "displayName": { + "name": { "type": "string", - "description": "New display name" + "description": "New name for the tag" } } } @@ -13084,18 +13057,19 @@ "schema": { "type": "string", "enum": [ - "v1" + "v4" ], - "default": "v1" + "default": "v4" } }, { - "name": "token", + "name": "tagId", "in": "path", + "description": "ID of the tag", "required": true, "schema": { "type": "string", - "pattern": "^[a-z0-9]{4,30}$" + "pattern": "^\\d+$" } }, { @@ -13111,7 +13085,7 @@ ], "responses": { "200": { - "description": "Display name updated successfully", + "description": "Tag updated", "content": { "application/json": { "schema": { @@ -13131,7 +13105,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "nullable": true + "$ref": "#/components/schemas/ConversationTag" } } } @@ -13140,8 +13114,8 @@ } } }, - "403": { - "description": "Not a guest", + "400": { + "description": "Invalid or duplicate name, or the tag is a built-in and cannot be renamed", "content": { "application/json": { "schema": { @@ -13161,7 +13135,19 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "nullable": true + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "name", + "type" + ] + } + } } } } @@ -13171,7 +13157,7 @@ } }, "404": { - "description": "Not a participant", + "description": "Tag not found", "content": { "application/json": { "schema": { @@ -13199,26 +13185,1024 @@ } } } - } - } - } - }, - "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}": { - "post": { - "operationId": "live_transcription-enable", - "summary": "Enable the live transcription", - "tags": [ - "live_transcription" - ], - "security": [ - {}, - { - "bearer_auth": [] }, - { - "basic_auth": [] - } - ], + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "conversation_tag-delete-tag", + "summary": "Delete a conversation tag", + "description": "Required capability: `conversation-tags`", + "tags": [ + "conversation_tag" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "tagId", + "in": "path", + "description": "ID of the tag", + "required": true, + "schema": { + "type": "string", + "pattern": "^\\d+$" + } + }, + { + "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": "Tag deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "400": { + "description": "The tag is a built-in and cannot be deleted", + "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": [ + "type" + ] + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Tag not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags/reorder": { + "put": { + "operationId": "conversation_tag-reorder-tags", + "summary": "Reorder conversation tags", + "description": "Required capability: `conversation-tags`", + "tags": [ + "conversation_tag" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "orderedIds" + ], + "properties": { + "orderedIds": { + "type": "array", + "description": "Ordered list of tag IDs", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "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": "Tags reordered", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConversationTag" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags/{tagId}/collapsed": { + "put": { + "operationId": "conversation_tag-update-tag-collapsed", + "summary": "Set the collapsed state of a conversation tag", + "description": "Required capability: `conversation-tags`", + "tags": [ + "conversation_tag" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "collapsed" + ], + "properties": { + "collapsed": { + "type": "boolean", + "description": "Whether the tag should be collapsed" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "tagId", + "in": "path", + "description": "ID of the tag", + "required": true, + "schema": { + "type": "string", + "pattern": "^\\d+$" + } + }, + { + "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": "Collapsed state updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ConversationTag" + } + } + } + } + } + } + } + }, + "404": { + "description": "Tag not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { + "get": { + "operationId": "files_integration-get-room-by-file-id", + "summary": "Get the token of the room associated to the given file id", + "description": "This is the counterpart of self::getRoomByShareToken() for file ids instead of share tokens, although both return the same room token if the given file id and share token refer to the same file.\nIf there is no room associated to the given file id a new room is created; the new room is a public room associated with a \"file\" object with the given file id. Unlike normal rooms in which the owner is the user that created the room these are special rooms without owner (although self joined users with direct access to the file become persistent participants automatically when they join until they explicitly leave or no longer have access to the file).\nIn any case, to create or even get the token of the room, the file must be shared and the user must be the owner of a public share of the file (like a link share, for example) or have direct access to that file; an error is returned otherwise. A user has direct access to a file if they have access to it (or to an ancestor) through a user, group, circle or room share (but not through a link share, for example), or if they are the owner of such a file.", + "tags": [ + "files_integration" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "fileId", + "in": "path", + "description": "ID of the file", + "required": true, + "schema": { + "type": "string", + "pattern": "^.+$" + } + }, + { + "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": "Room token returned", + "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": [ + "token" + ], + "properties": { + "token": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Rooms not allowed for shares", + "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": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/publicshare/{shareToken}": { + "get": { + "operationId": "files_integration-get-room-by-share-token", + "summary": "Returns the token of the room associated to the file of the given share token", + "description": "This is the counterpart of self::getRoomByFileId() for share tokens instead of file ids, although both return the same room token if the given file id and share token refer to the same file.\nIf there is no room associated to the file id of the given share token a new room is created; the new room is a public room associated with a \"file\" object with the file id of the given share token. Unlike normal rooms in which the owner is the user that created the room these are special rooms without owner (although self joined users with direct access to the file become persistent participants automatically when they join until they explicitly leave or no longer have access to the file).\nIn any case, to create or even get the token of the room, the file must be publicly shared (like a link share, for example); an error is returned otherwise.\nBesides the token of the room this also returns the current user ID and display name, if any; this is needed by the Talk sidebar to know the actual current user, as the public share page uses the incognito mode and thus logged-in users as seen as guests.", + "tags": [ + "files_integration" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "shareToken", + "in": "path", + "description": "Token of the file share", + "required": true, + "schema": { + "type": "string", + "pattern": "^.+$" + } + }, + { + "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": "Room token and user info returned", + "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": [ + "token", + "userId", + "userDisplayName" + ], + "properties": { + "token": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "userDisplayName": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Rooms not allowed for shares", + "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": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/guest/{token}/name": { + "post": { + "operationId": "guest-set-display-name", + "summary": "Set the display name as a guest", + "tags": [ + "guest" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "displayName" + ], + "properties": { + "displayName": { + "type": "string", + "description": "New display name" + } + } + } + } + } + }, + "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": "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": "Display name updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "403": { + "description": "Not a guest", + "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": "Not a participant", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}": { + "post": { + "operationId": "live_transcription-enable", + "summary": "Enable the live transcription", + "tags": [ + "live_transcription" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], "parameters": [ { "name": "apiVersion", @@ -21877,6 +22861,137 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/tags": { + "post": { + "operationId": "room-assign-tags", + "summary": "Assign conversation tags", + "description": "Required capability: `conversation-tags`", + "tags": [ + "room" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tagIds": { + "type": "array", + "default": [], + "description": "IDs of tags to assign (empty array to unassign all)", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "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": "Conversation tags updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Room" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/important": { "post": { "operationId": "room-mark-conversation-as-important", diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index 4fa17d36ce1..f8905e43fab 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -762,6 +762,8 @@ export type components = { isImportant: boolean; /** @description Required capability: `sensitive-conversations` */ isSensitive: boolean; + /** @description IDs of the custom tags this conversation is marked with (only available with `conversation-tags` capability) */ + tagIds: string[]; /** * Format: int64 * @description Required capability: `pinned-messages` diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index 7160990adda..b2efabea4f6 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -806,6 +806,8 @@ export type components = { isImportant: boolean; /** @description Required capability: `sensitive-conversations` */ isSensitive: boolean; + /** @description IDs of the custom tags this conversation is marked with (only available with `conversation-tags` capability) */ + tagIds: string[]; /** * Format: int64 * @description Required capability: `pinned-messages` diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index d2ba011e53d..957a99dd626 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -730,6 +730,94 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all conversation tags for the current user + * @description Required capability: `conversation-tags` + */ + get: operations["conversation_tag-get-tags"]; + put?: never; + /** + * Create a new conversation tag + * @description Required capability: `conversation-tags` + */ + post: operations["conversation_tag-create-tag"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags/{tagId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update a conversation tag + * @description Required capability: `conversation-tags` + */ + put: operations["conversation_tag-update-tag"]; + post?: never; + /** + * Delete a conversation tag + * @description Required capability: `conversation-tags` + */ + delete: operations["conversation_tag-delete-tag"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags/reorder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Reorder conversation tags + * @description Required capability: `conversation-tags` + */ + put: operations["conversation_tag-reorder-tags"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags/{tagId}/collapsed": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Set the collapsed state of a conversation tag + * @description Required capability: `conversation-tags` + */ + put: operations["conversation_tag-update-tag-collapsed"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { parameters: { query?: never; @@ -1481,6 +1569,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Assign conversation tags + * @description Required capability: `conversation-tags` + */ + post: operations["room-assign-tags"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/important": { parameters: { query?: never; @@ -2947,6 +3055,24 @@ export type components = { [key: string]: number; }; }; + ConversationTag: { + /** @description SnowflakeID */ + id: string; + /** @description Display name */ + name: string; + /** + * Format: int64 + * @description Sort order from 0 (top) to higher (bottom) + */ + sortOrder: number; + /** @description Whether the tag list should be open or collapsed */ + collapsed: boolean; + /** + * @description favorites and other are special tags and have a fixed sorting position + * @enum {string} + */ + type: "custom" | "favorites" | "other"; + }; DashboardEvent: { /** @description List of calendars this event belongs to */ calendars: components["schemas"]["DashboardEventCalendar"][]; @@ -3561,6 +3687,8 @@ export type components = { isImportant: boolean; /** @description Required capability: `sensitive-conversations` */ isSensitive: boolean; + /** @description IDs of the custom tags this conversation is marked with (only available with `conversation-tags` capability) */ + tagIds: string[]; /** * Format: int64 * @description Required capability: `pinned-messages` @@ -7804,6 +7932,397 @@ export interface operations { }; }; }; + "conversation_tag-get-tags": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Tags returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationTag"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_tag-create-tag": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Name of the tag */ + name: string; + }; + }; + }; + responses: { + /** @description Tag created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationTag"]; + }; + }; + }; + }; + /** @description Invalid or duplicate name, or the user has reached the tag limit */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "name" | "limit"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_tag-update-tag": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + /** @description ID of the tag */ + tagId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description New name for the tag */ + name: string; + }; + }; + }; + responses: { + /** @description Tag updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationTag"]; + }; + }; + }; + }; + /** @description Invalid or duplicate name, or the tag is a built-in and cannot be renamed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "name" | "type"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Tag not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_tag-delete-tag": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + /** @description ID of the tag */ + tagId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Tag deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description The tag is a built-in and cannot be deleted */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "type"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Tag not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_tag-reorder-tags": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Ordered list of tag IDs */ + orderedIds: string[]; + }; + }; + }; + responses: { + /** @description Tags reordered */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationTag"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_tag-update-tag-collapsed": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + /** @description ID of the tag */ + tagId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Whether the tag should be collapsed */ + collapsed: boolean; + }; + }; + }; + responses: { + /** @description Collapsed state updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationTag"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Tag not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "files_integration-get-room-by-file-id": { parameters: { query?: never; @@ -11501,6 +12020,61 @@ export interface operations { }; }; }; + "room-assign-tags": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description IDs of tags to assign (empty array to unassign all) + * @default [] + */ + tagIds?: string[]; + }; + }; + }; + responses: { + /** @description Conversation tags updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Room"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "room-mark-conversation-as-important": { parameters: { query?: never; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 38444c0a210..9db37779059 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -730,6 +730,94 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all conversation tags for the current user + * @description Required capability: `conversation-tags` + */ + get: operations["conversation_tag-get-tags"]; + put?: never; + /** + * Create a new conversation tag + * @description Required capability: `conversation-tags` + */ + post: operations["conversation_tag-create-tag"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags/{tagId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update a conversation tag + * @description Required capability: `conversation-tags` + */ + put: operations["conversation_tag-update-tag"]; + post?: never; + /** + * Delete a conversation tag + * @description Required capability: `conversation-tags` + */ + delete: operations["conversation_tag-delete-tag"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags/reorder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Reorder conversation tags + * @description Required capability: `conversation-tags` + */ + put: operations["conversation_tag-reorder-tags"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/tags/{tagId}/collapsed": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Set the collapsed state of a conversation tag + * @description Required capability: `conversation-tags` + */ + put: operations["conversation_tag-update-tag-collapsed"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { parameters: { query?: never; @@ -1481,6 +1569,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Assign conversation tags + * @description Required capability: `conversation-tags` + */ + post: operations["room-assign-tags"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/important": { parameters: { query?: never; @@ -2413,6 +2521,24 @@ export type components = { [key: string]: number; }; }; + ConversationTag: { + /** @description SnowflakeID */ + id: string; + /** @description Display name */ + name: string; + /** + * Format: int64 + * @description Sort order from 0 (top) to higher (bottom) + */ + sortOrder: number; + /** @description Whether the tag list should be open or collapsed */ + collapsed: boolean; + /** + * @description favorites and other are special tags and have a fixed sorting position + * @enum {string} + */ + type: "custom" | "favorites" | "other"; + }; DashboardEvent: { /** @description List of calendars this event belongs to */ calendars: components["schemas"]["DashboardEventCalendar"][]; @@ -2994,6 +3120,8 @@ export type components = { isImportant: boolean; /** @description Required capability: `sensitive-conversations` */ isSensitive: boolean; + /** @description IDs of the custom tags this conversation is marked with (only available with `conversation-tags` capability) */ + tagIds: string[]; /** * Format: int64 * @description Required capability: `pinned-messages` @@ -7237,6 +7365,397 @@ export interface operations { }; }; }; + "conversation_tag-get-tags": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Tags returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationTag"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_tag-create-tag": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Name of the tag */ + name: string; + }; + }; + }; + responses: { + /** @description Tag created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationTag"]; + }; + }; + }; + }; + /** @description Invalid or duplicate name, or the user has reached the tag limit */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "name" | "limit"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_tag-update-tag": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + /** @description ID of the tag */ + tagId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description New name for the tag */ + name: string; + }; + }; + }; + responses: { + /** @description Tag updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationTag"]; + }; + }; + }; + }; + /** @description Invalid or duplicate name, or the tag is a built-in and cannot be renamed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "name" | "type"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Tag not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_tag-delete-tag": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + /** @description ID of the tag */ + tagId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Tag deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description The tag is a built-in and cannot be deleted */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "type"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Tag not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_tag-reorder-tags": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Ordered list of tag IDs */ + orderedIds: string[]; + }; + }; + }; + responses: { + /** @description Tags reordered */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationTag"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_tag-update-tag-collapsed": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + /** @description ID of the tag */ + tagId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Whether the tag should be collapsed */ + collapsed: boolean; + }; + }; + }; + responses: { + /** @description Collapsed state updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationTag"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Tag not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "files_integration-get-room-by-file-id": { parameters: { query?: never; @@ -10934,6 +11453,61 @@ export interface operations { }; }; }; + "room-assign-tags": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description IDs of tags to assign (empty array to unassign all) + * @default [] + */ + tagIds?: string[]; + }; + }; + }; + responses: { + /** @description Conversation tags updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Room"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "room-mark-conversation-as-important": { parameters: { query?: never; From 9dba8d4f2daee7cc8200223b2d041253d69252c9 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Thu, 26 Mar 2026 17:34:57 +0100 Subject: [PATCH 4/6] feat: implement conversation tags services and stores Co-Authored-By: Claude Opus 4.6 Signed-off-by: Rikdekker Signed-off-by: Maksim Sukharev --- src/__mocks__/capabilities.ts | 9 + src/services/conversationTagsService.ts | 96 +++++++ src/store/conversationsStore.js | 14 + src/stores/__tests__/conversationTags.spec.js | 251 ++++++++++++++++++ src/stores/conversationTags.ts | 184 +++++++++++++ src/types/index.ts | 16 ++ 6 files changed, 570 insertions(+) create mode 100644 src/services/conversationTagsService.ts create mode 100644 src/stores/__tests__/conversationTags.spec.js create mode 100644 src/stores/conversationTags.ts diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index 2074cd27066..2d57772521f 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -102,6 +102,12 @@ export const mockedCapabilities: Capabilities = { 'upcoming-reminders', 'sensitive-conversations', 'threads', + 'pinned-messages', + 'federated-shared-items', + 'scheduled-messages', + 'conversation-presets', + 'private-reply', + 'conversation-tags', // Conditional features 'message-expiration', 'reactions', @@ -129,6 +135,9 @@ export const mockedCapabilities: Capabilities = { 'mutual-calendar-events', 'upcoming-reminders', 'sensitive-conversations', + 'scheduled-messages', + 'conversation-presets', + 'conversation-tags', ], config: { attachments: { diff --git a/src/services/conversationTagsService.ts b/src/services/conversationTagsService.ts new file mode 100644 index 00000000000..91f46ef8f99 --- /dev/null +++ b/src/services/conversationTagsService.ts @@ -0,0 +1,96 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { + assignConversationToTagsParams, + assignConversationToTagsResponse, + createTagParams, + createTagResponse, + deleteTagResponse, + fetchTagsResponse, + reorderTagsParams, + reorderTagsResponse, + updateTagCollapsedParams, + updateTagCollapsedResponse, + updateTagParams, + updateTagResponse, +} from '../types/index.ts' + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +/** + * Fetch all conversation tags for the current user + */ +async function fetchTags(): fetchTagsResponse { + return axios.get(generateOcsUrl('apps/spreed/api/v4/tags')) +} + +/** + * Create a new conversation tag + * + * @param name Name of the tag + */ +async function createTag(name: createTagParams['name']): createTagResponse { + return axios.post(generateOcsUrl('apps/spreed/api/v4/tags'), { name }) +} + +/** + * Update a conversation tag name + * + * @param tagId ID of the tag + * @param name New name for the tag + */ +async function updateTag(tagId: string, name: updateTagParams['name']): updateTagResponse { + return axios.put(generateOcsUrl('apps/spreed/api/v4/tags/{tagId}', { tagId }), { name }) +} + +/** + * Delete a conversation tag + * + * @param tagId ID of the tag to delete + */ +async function deleteTag(tagId: string): deleteTagResponse { + return axios.delete(generateOcsUrl('apps/spreed/api/v4/tags/{tagId}', { tagId })) +} + +/** + * Reorder conversation tags + * + * @param orderedIds Ordered list of tag IDs + */ +async function reorderTags(orderedIds: reorderTagsParams['orderedIds']): reorderTagsResponse { + return axios.put(generateOcsUrl('apps/spreed/api/v4/tags/reorder'), { orderedIds }) +} + +/** + * Update the collapsed state of a conversation tag + * + * @param tagId ID of the tag + * @param collapsed Whether the tag should be collapsed + */ +async function updateTagCollapsed(tagId: string, collapsed: updateTagCollapsedParams['collapsed']): updateTagCollapsedResponse { + return axios.put(generateOcsUrl('apps/spreed/api/v4/tags/{tagId}/collapsed', { tagId }), { collapsed }) +} + +/** + * Assign conversation tags + * + * @param token Conversation token + * @param tagIds Tag IDs to assign (empty array to unassign all) + */ +async function assignConversationToTags(token: string, tagIds: assignConversationToTagsParams['tagIds']): assignConversationToTagsResponse { + return axios.post(generateOcsUrl('apps/spreed/api/v4/room/{token}/tags', { token }), { tagIds }) +} + +export { + assignConversationToTags, + createTag, + deleteTag, + fetchTags, + reorderTags, + updateTag, + updateTagCollapsed, +} diff --git a/src/store/conversationsStore.js b/src/store/conversationsStore.js index 6e895a9d67a..0f1fd1abd60 100644 --- a/src/store/conversationsStore.js +++ b/src/store/conversationsStore.js @@ -54,6 +54,7 @@ import { unarchiveConversation, unbindConversationFromObject, } from '../services/conversationsService.ts' +import { assignConversationToTags } from '../services/conversationTagsService.ts' import { setLiveTranscriptionLanguage } from '../services/liveTranscriptionService.ts' import { clearConversationHistory, @@ -626,6 +627,19 @@ const actions = { } }, + async assignConversationToTags(context, { token, tagIds }) { + if (!context.getters.conversations[token]) { + return + } + + try { + const response = await assignConversationToTags(token, tagIds) + context.commit('addConversation', response.data.ocs.data) + } catch (error) { + console.error('Error while assigning conversation to tags: ', error) + } + }, + async toggleImportant(context, { token, isImportant }) { if (!context.getters.conversations[token]) { return diff --git a/src/stores/__tests__/conversationTags.spec.js b/src/stores/__tests__/conversationTags.spec.js new file mode 100644 index 00000000000..7a04a96cca6 --- /dev/null +++ b/src/stores/__tests__/conversationTags.spec.js @@ -0,0 +1,251 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { showError } from '@nextcloud/dialogs' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import BrowserStorage from '../../services/BrowserStorage.js' +import { + createTag as createTagApi, + deleteTag as deleteTagApi, + fetchTags as fetchTagsApi, + reorderTags as reorderTagsApi, + updateTag as updateTagApi, + updateTagCollapsed as updateTagCollapsedApi, +} from '../../services/conversationTagsService.ts' +import { generateOCSResponse } from '../../test-helpers.js' +import { useConversationTagsStore } from '../conversationTags.ts' + +vi.mock('../../services/BrowserStorage.js', () => ({ + default: { + getItem: vi.fn().mockReturnValue(null), + setItem: vi.fn(), + }, +})) + +vi.mock('../../services/conversationTagsService.ts', () => ({ + createTag: vi.fn(), + deleteTag: vi.fn(), + fetchTags: vi.fn(), + reorderTags: vi.fn(), + updateTag: vi.fn(), + updateTagCollapsed: vi.fn(), +})) + +const favoritesTag = { + id: 'favorites', + name: 'Favorites', + type: 'favorites', + sortOrder: 1, + collapsed: false, +} + +const customTagOne = { + id: 'tag-1', + name: 'Alpha', + type: 'custom', + sortOrder: 2, + collapsed: false, +} + +const customTagTwo = { + id: 'tag-2', + name: 'Beta', + type: 'custom', + sortOrder: 3, + collapsed: true, +} + +/** + * @return {Array} + */ +function getPersistedTags() { + return JSON.parse(BrowserStorage.setItem.mock.calls.at(-1)[1]) +} + +describe('conversationTagsStore', () => { + let conversationTagsStore + + beforeEach(() => { + setActivePinia(createPinia()) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + it('loads cached tags and exposes sorted custom tags', () => { + BrowserStorage.getItem.mockReturnValueOnce(JSON.stringify([customTagTwo, favoritesTag, customTagOne])) + + conversationTagsStore = useConversationTagsStore() + + expect(BrowserStorage.getItem).toHaveBeenCalledWith('conversationTags') + expect(conversationTagsStore.sortedTags.map((tag) => tag.id)).toEqual(['favorites', 'tag-1', 'tag-2']) + expect(conversationTagsStore.customTags.map((tag) => tag.id)).toEqual(['tag-1', 'tag-2']) + expect(conversationTagsStore.hasCustomTags).toBe(true) + }) + + it('fetches tags and replaces the stored tag list', async () => { + BrowserStorage.getItem.mockReturnValueOnce(JSON.stringify([customTagOne])) + conversationTagsStore = useConversationTagsStore() + fetchTagsApi.mockResolvedValue(generateOCSResponse({ payload: [favoritesTag, customTagTwo] })) + + await conversationTagsStore.fetchTags() + await nextTick() + + expect(fetchTagsApi).toHaveBeenCalled() + expect(conversationTagsStore.sortedTags.map((tag) => tag.id)).toEqual(['favorites', 'tag-2']) + expect(conversationTagsStore.tags['tag-1']).toBeUndefined() + expect(getPersistedTags()).toEqual([favoritesTag, customTagTwo]) + }) + + it('logs an error when fetching tags fails', async () => { + const error = new Error('fetch failed') + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + conversationTagsStore = useConversationTagsStore() + fetchTagsApi.mockRejectedValue(error) + + await conversationTagsStore.fetchTags() + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch conversation tags:', error) + }) + + it('creates a tag and persists it', async () => { + conversationTagsStore = useConversationTagsStore() + createTagApi.mockResolvedValue(generateOCSResponse({ payload: customTagOne })) + + const tag = await conversationTagsStore.createTag(customTagOne.name) + await nextTick() + + expect(createTagApi).toHaveBeenCalledWith(customTagOne.name) + expect(tag).toEqual(customTagOne) + expect(conversationTagsStore.tags[customTagOne.id]).toEqual(customTagOne) + expect(getPersistedTags()).toEqual([customTagOne]) + }) + + it('updates a tag name', async () => { + BrowserStorage.getItem.mockReturnValueOnce(JSON.stringify([customTagOne])) + conversationTagsStore = useConversationTagsStore() + const updatedTag = { ...customTagOne, name: 'Renamed' } + updateTagApi.mockResolvedValue(generateOCSResponse({ payload: updatedTag })) + + const tag = await conversationTagsStore.updateTagName(customTagOne.id, updatedTag.name) + await nextTick() + + expect(updateTagApi).toHaveBeenCalledWith(customTagOne.id, updatedTag.name) + expect(tag).toEqual(updatedTag) + expect(conversationTagsStore.tags[customTagOne.id]).toEqual(updatedTag) + }) + + it('shows an error when renaming a tag fails', async () => { + BrowserStorage.getItem.mockReturnValueOnce(JSON.stringify([customTagOne])) + conversationTagsStore = useConversationTagsStore() + const error = new Error('rename failed') + updateTagApi.mockRejectedValue(error) + + await expect(conversationTagsStore.updateTagName(customTagOne.id, 'Renamed')).rejects.toThrow('rename failed') + + expect(showError).toHaveBeenCalledWith('Error renaming tag') + expect(conversationTagsStore.tags[customTagOne.id]).toEqual(customTagOne) + }) + + it('removes a tag', async () => { + BrowserStorage.getItem.mockReturnValueOnce(JSON.stringify([favoritesTag, customTagOne])) + conversationTagsStore = useConversationTagsStore() + deleteTagApi.mockResolvedValue(generateOCSResponse({ payload: [] })) + + await conversationTagsStore.removeTag(customTagOne.id) + await nextTick() + + expect(deleteTagApi).toHaveBeenCalledWith(customTagOne.id) + expect(conversationTagsStore.tags[customTagOne.id]).toBeUndefined() + expect(getPersistedTags()).toEqual([favoritesTag]) + }) + + it('shows an error when deleting a tag fails', async () => { + BrowserStorage.getItem.mockReturnValueOnce(JSON.stringify([customTagOne])) + conversationTagsStore = useConversationTagsStore() + const error = new Error('delete failed') + deleteTagApi.mockRejectedValue(error) + + await expect(conversationTagsStore.removeTag(customTagOne.id)).rejects.toThrow('delete failed') + + expect(showError).toHaveBeenCalledWith('Error deleting tag') + expect(conversationTagsStore.tags[customTagOne.id]).toEqual(customTagOne) + }) + + it('moves a tag by reordering with the server response', async () => { + BrowserStorage.getItem.mockReturnValueOnce(JSON.stringify([customTagOne, favoritesTag, customTagTwo])) + conversationTagsStore = useConversationTagsStore() + const reorderedTags = [ + { ...favoritesTag, sortOrder: 1 }, + { ...customTagTwo, sortOrder: 2 }, + { ...customTagOne, sortOrder: 3 }, + ] + reorderTagsApi.mockResolvedValue(generateOCSResponse({ payload: reorderedTags })) + + await conversationTagsStore.moveTag(customTagOne.id, 1) + await nextTick() + + expect(reorderTagsApi).toHaveBeenCalledWith(['favorites', 'tag-2', 'tag-1']) + expect(conversationTagsStore.sortedTags.map((tag) => tag.id)).toEqual(['favorites', 'tag-2', 'tag-1']) + expect(getPersistedTags()).toEqual(reorderedTags) + }) + + it('shows an error when moving a tag fails during reordering', async () => { + BrowserStorage.getItem.mockReturnValueOnce(JSON.stringify([favoritesTag, customTagOne])) + conversationTagsStore = useConversationTagsStore() + const error = new Error('reorder failed') + reorderTagsApi.mockRejectedValue(error) + + await expect(conversationTagsStore.moveTag(customTagOne.id, -1)).rejects.toThrow('reorder failed') + + expect(showError).toHaveBeenCalledWith('Error reordering tags') + expect(conversationTagsStore.sortedTags.map((tag) => tag.id)).toEqual(['favorites', 'tag-1']) + }) + + it('toggles collapsed state and keeps the server version', async () => { + BrowserStorage.getItem.mockReturnValueOnce(JSON.stringify([customTagOne])) + conversationTagsStore = useConversationTagsStore() + const collapsedTag = { ...customTagOne, collapsed: true } + updateTagCollapsedApi.mockResolvedValue(generateOCSResponse({ payload: collapsedTag })) + + const togglePromise = conversationTagsStore.toggleCollapsed(customTagOne.id) + + expect(conversationTagsStore.tags[customTagOne.id].collapsed).toBe(true) + + await togglePromise + await nextTick() + + expect(updateTagCollapsedApi).toHaveBeenCalledWith(customTagOne.id, true) + expect(conversationTagsStore.tags[customTagOne.id]).toEqual(collapsedTag) + }) + + it('reverts collapsed state when syncing it fails', async () => { + BrowserStorage.getItem.mockReturnValueOnce(JSON.stringify([customTagOne])) + conversationTagsStore = useConversationTagsStore() + const error = new Error('collapse failed') + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + updateTagCollapsedApi.mockRejectedValue(error) + + await conversationTagsStore.toggleCollapsed(customTagOne.id) + + expect(updateTagCollapsedApi).toHaveBeenCalledWith(customTagOne.id, true) + expect(conversationTagsStore.tags[customTagOne.id].collapsed).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to update collapsed state:', error) + }) + + it('ignores invalid moves', async () => { + BrowserStorage.getItem.mockReturnValueOnce(JSON.stringify([favoritesTag, customTagOne, customTagTwo])) + conversationTagsStore = useConversationTagsStore() + + await conversationTagsStore.moveTag(favoritesTag.id, -1) + await conversationTagsStore.moveTag(customTagTwo.id, 1) + + expect(reorderTagsApi).not.toHaveBeenCalled() + }) +}) diff --git a/src/stores/conversationTags.ts b/src/stores/conversationTags.ts new file mode 100644 index 00000000000..9fcaa4eb6c2 --- /dev/null +++ b/src/stores/conversationTags.ts @@ -0,0 +1,184 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ConversationTag } from '../types/index.ts' + +import { showError } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import { defineStore } from 'pinia' +import { computed, reactive, watch } from 'vue' +import BrowserStorage from '../services/BrowserStorage.js' +import { + createTag as createTagApi, + deleteTag as deleteTagApi, + fetchTags as fetchTagsApi, + reorderTags as reorderTagsApi, + updateTag as updateTagApi, + updateTagCollapsed as updateTagCollapsedApi, +} from '../services/conversationTagsService.ts' + +export const useConversationTagsStore = defineStore('conversationTags', () => { + const tags = reactive>({}) + + // Populate from cache immediately so the UI is snappy on page load + const conversationTags = BrowserStorage.getItem('conversationTags') + if (conversationTags) { + const parsedTags = JSON.parse(conversationTags) as ConversationTag[] + for (const tag of parsedTags) { + tags[tag.id] = tag + } + } + // Persist every change to BrowserStorage + watch(tags, (newTags) => { + BrowserStorage.setItem('conversationTags', JSON.stringify(Object.values(tags))) + }, { deep: true }) + + /** + * Fully replace the current reactive tag map with a new tag list. + * + * @param newTags Tags to store + */ + function replaceTags(newTags: ConversationTag[]) { + for (const key of Object.keys(tags)) { + delete tags[key] + } + for (const tag of newTags) { + tags[tag.id] = tag + } + } + + const sortedTags = computed(() => Object.values(tags).sort((a, b) => a.sortOrder - b.sortOrder)) + + const customTags = computed(() => sortedTags.value.filter((c) => c.type === 'custom')) + + const hasCustomTags = computed(() => customTags.value.length > 0) + + /** + * Fetch all conversation tags from the server + */ + async function fetchTags() { + try { + const response = await fetchTagsApi() + replaceTags(response.data.ocs.data) + } catch (error) { + console.error('Failed to fetch conversation tags:', error) + } + } + + /** + * Create a new conversation tag + * + * @param name Name of the tag + */ + async function createTag(name: string) { + const response = await createTagApi(name) + const tag = response.data.ocs.data + tags[tag.id] = tag + return tag + } + + /** + * Update the name of a conversation tag + * + * @param tagId ID of the tag + * @param name New name for the tag + */ + async function updateTagName(tagId: string, name: string) { + try { + const response = await updateTagApi(tagId, name) + const tag = response.data.ocs.data + tags[tag.id] = tag + return tag + } catch (error) { + showError(t('spreed', 'Error renaming tag')) + throw error + } + } + + /** + * Remove a conversation tag + * + * @param tagId ID of the tag to remove + */ + async function removeTag(tagId: string) { + try { + await deleteTagApi(tagId) + delete tags[tagId] + } catch (error) { + showError(t('spreed', 'Error deleting tag')) + throw error + } + } + + /** + * Reorder conversation tags + * + * @param orderedIds Ordered list of tag IDs + */ + async function reorderTags(orderedIds: string[]) { + try { + const response = await reorderTagsApi(orderedIds) + replaceTags(response.data.ocs.data) + } catch (error) { + showError(t('spreed', 'Error reordering tags')) + throw error + } + } + + /** + * Toggle the collapsed state of a tag (including built-in favorites/other). + * Syncs the new state with the server. + * + * @param tagId DB ID string, or built-in type name ('favorites' | 'other') + */ + async function toggleCollapsed(tagId: string) { + const tag = tags[tagId]! + const newCollapsed = !tag.collapsed + // Optimistic update + tag.collapsed = newCollapsed + try { + const response = await updateTagCollapsedApi(tag.id, newCollapsed) + const updated = response.data.ocs.data + tags[updated.id] = updated + } catch (error) { + // Revert on failure + tag.collapsed = !newCollapsed + console.error('Failed to update collapsed state:', error) + } + } + + /** + * Move a tag by the given offset in the sort order. + * + * @param tagId ID of the tag to move + * @param offset Relative position change + */ + async function moveTag(tagId: string, offset: -1 | 1) { + const orderedIds = sortedTags.value.map((tag) => tag.id) + const index = orderedIds.indexOf(tagId) + const nextIndex = index + offset + + if (index === -1 || nextIndex < 0 || nextIndex >= orderedIds.length) { + return + } + + const [movedTagId] = orderedIds.splice(index, 1) + orderedIds.splice(nextIndex, 0, movedTagId) + await reorderTags(orderedIds) + } + + return { + tags, + sortedTags, + customTags, + hasCustomTags, + fetchTags, + createTag, + updateTagName, + removeTag, + toggleCollapsed, + moveTag, + } +}) diff --git a/src/types/index.ts b/src/types/index.ts index 96813695e73..1b47f7bd179 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -666,3 +666,19 @@ export type PostAttachmentFolderResponse = ApiResponse['requestBody']['content']['application/json'] export type ProbeAttachmentFolderResponse = ApiResponse + +// Conversation tags +export type ConversationTag = components['schemas']['ConversationTag'] + +export type fetchTagsResponse = ApiResponse +export type createTagParams = Required['requestBody']['content']['application/json'] +export type createTagResponse = ApiResponse +export type updateTagParams = Required['requestBody']['content']['application/json'] +export type updateTagResponse = ApiResponse +export type deleteTagResponse = ApiResponse +export type reorderTagsParams = Required['requestBody']['content']['application/json'] +export type reorderTagsResponse = ApiResponse +export type updateTagCollapsedParams = Required['requestBody']['content']['application/json'] +export type updateTagCollapsedResponse = ApiResponse +export type assignConversationToTagsParams = Required['requestBody']['content']['application/json'] +export type assignConversationToTagsResponse = ApiResponse From 7f9fbe76cd4eb8d42dc2155be3b00f7c26a700d2 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 27 Apr 2026 14:30:20 +0200 Subject: [PATCH 5/6] feat: implement conversation tags UI Co-Authored-By: Claude Opus 4.6 Signed-off-by: Rikdekker Signed-off-by: Maksim Sukharev --- .../ConversationsList/ConversationItem.vue | 115 +++++++++++++ .../ConversationTagHeader.vue | 158 ++++++++++++++++++ .../ConversationsListVirtual.vue | 145 +++++++++++++++- .../LeftSidebar/LeftSidebar.spec.js | 5 +- src/components/LeftSidebar/LeftSidebar.vue | 7 + 5 files changed, 421 insertions(+), 9 deletions(-) create mode 100644 src/components/LeftSidebar/ConversationsList/ConversationTagHeader.vue diff --git a/src/components/LeftSidebar/ConversationsList/ConversationItem.vue b/src/components/LeftSidebar/ConversationsList/ConversationItem.vue index d572954072a..174b2644a02 100644 --- a/src/components/LeftSidebar/ConversationsList/ConversationItem.vue +++ b/src/components/LeftSidebar/ConversationsList/ConversationItem.vue @@ -115,6 +115,17 @@ {{ labelArchive }} + + + {{ t('spreed', 'Tags') }} + + +