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/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/__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/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') }}
+
+
+
+
+
+
+
+ {{ t('spreed', 'Back') }}
+
+
+
+
+
+
+ {{ t('spreed', 'Remove all tags') }}
+
+
+
+
+
+
+ {{ tag.name }}
+
+
+
+
+
+
+
+
+ {{ t('spreed', 'New tag') }}
+
+
@@ -255,9 +311,13 @@ import IconContentCopy from 'vue-material-design-icons/ContentCopy.vue'
import IconExitToApp from 'vue-material-design-icons/ExitToApp.vue'
import IconMessageAlertOutline from 'vue-material-design-icons/MessageAlertOutline.vue'
import IconMessageBadgeOutline from 'vue-material-design-icons/MessageBadgeOutline.vue'
+import IconMinusCircleOutline from 'vue-material-design-icons/MinusCircleOutline.vue'
import IconPhoneRingOutline from 'vue-material-design-icons/PhoneRingOutline.vue'
+import IconPlus from 'vue-material-design-icons/Plus.vue'
import IconShieldLockOutline from 'vue-material-design-icons/ShieldLockOutline.vue'
import IconStar from 'vue-material-design-icons/Star.vue' // Filled for better indication
+import IconTagMultipleOutline from 'vue-material-design-icons/TagMultipleOutline.vue'
+import IconTagOutline from 'vue-material-design-icons/TagOutline.vue'
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import IconVideo from 'vue-material-design-icons/Video.vue' // Filled for better indication
import ConfirmDialog from '../../UIShared/ConfirmDialog.vue'
@@ -266,11 +326,13 @@ import IconMarkChatRead from '../../../../img/material-icons/mark-chat-read.svg?
import { useConversationInfo } from '../../../composables/useConversationInfo.ts'
import { AVATAR, CONVERSATION, PARTICIPANT } from '../../../constants.ts'
import { getTalkConfig, hasTalkFeature } from '../../../services/CapabilitiesManager.ts'
+import { useConversationTagsStore } from '../../../stores/conversationTags.ts'
import { copyConversationLinkToClipboard } from '../../../utils/handleUrl.ts'
const supportsArchive = hasTalkFeature('local', 'archived-conversations-v2')
const supportImportantConversations = hasTalkFeature('local', 'important-conversations')
const supportSensitiveConversations = hasTalkFeature('local', 'sensitive-conversations')
+const supportTags = hasTalkFeature('local', 'conversation-tags')
const notificationLevels = [
{ value: PARTICIPANT.NOTIFY.ALWAYS, label: t('spreed', 'All messages'), icon: IconBellRingOutline },
@@ -295,8 +357,12 @@ export default {
IconMessageAlertOutline,
IconMessageBadgeOutline,
IconPhoneRingOutline,
+ IconPlus,
IconShieldLockOutline,
IconStar,
+ IconMinusCircleOutline,
+ IconTagMultipleOutline,
+ IconTagOutline,
IconVideo,
NcActionButton,
NcActionSeparator,
@@ -350,12 +416,16 @@ export default {
const { item, isSearchResult } = toRefs(props)
const { counterType, conversationInformation } = useConversationInfo({ item, isSearchResult })
+ const tagsStore = useConversationTagsStore()
+
return {
AVATAR,
IconMarkChatRead,
supportsArchive,
supportImportantConversations,
supportSensitiveConversations,
+ supportTags,
+ tagsStore,
submenu,
isDarkTheme,
counterType,
@@ -402,6 +472,10 @@ export default {
return this.item.notificationLevel.toString()
},
+ currentTagIds() {
+ return this.item.tagIds ?? []
+ },
+
notificationCalls() {
return this.item.notificationCalls === PARTICIPANT.NOTIFY_CALLS.ON
},
@@ -539,6 +613,47 @@ export default {
this.$store.dispatch('toggleArchive', this.item)
},
+ isTagAssigned(tagId) {
+ return this.currentTagIds.includes(tagId)
+ },
+
+ async toggleTag(tagId) {
+ const newIds = this.isTagAssigned(tagId)
+ ? this.currentTagIds.filter((id) => id !== tagId)
+ : [...this.currentTagIds, tagId]
+ this.assignToTags(newIds)
+ },
+
+ async assignToTags(tagIds) {
+ this.$store.dispatch('assignConversationToTags', {
+ token: this.item.token,
+ tagIds,
+ })
+ },
+
+ async handleCreateTag() {
+ const name = await spawnDialog(ConfirmDialog, {
+ name: t('spreed', 'New tag'),
+ isForm: true,
+ inputProps: { label: t('spreed', 'Tag name') },
+ buttons: [
+ { label: t('spreed', 'Cancel'), variant: 'tertiary', callback: () => false },
+ { label: t('spreed', 'Save'), variant: 'primary', type: 'submit', callback: () => true },
+ ],
+ })
+ if (!name) {
+ return
+ }
+ try {
+ const tag = await this.tagsStore.createTag(name)
+ if (tag) {
+ await this.assignToTags([...this.currentTagIds, tag.id])
+ }
+ } catch (error) {
+ console.error('Failed to create tag:', error)
+ }
+ },
+
/**
* Set the notification level for the conversation
*
diff --git a/src/components/LeftSidebar/ConversationsList/ConversationTagHeader.vue b/src/components/LeftSidebar/ConversationsList/ConversationTagHeader.vue
new file mode 100644
index 00000000000..dc8247adfa9
--- /dev/null
+++ b/src/components/LeftSidebar/ConversationsList/ConversationTagHeader.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/LeftSidebar/ConversationsList/ConversationsListVirtual.vue b/src/components/LeftSidebar/ConversationsList/ConversationsListVirtual.vue
index f3b6df4e572..5fec8ff1a05 100644
--- a/src/components/LeftSidebar/ConversationsList/ConversationsListVirtual.vue
+++ b/src/components/LeftSidebar/ConversationsList/ConversationsListVirtual.vue
@@ -4,20 +4,145 @@
-->