diff --git a/appinfo/info.xml b/appinfo/info.xml index c5034914a5c..ecc39e70d72 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.24 agpl Anna Larch @@ -62,7 +62,7 @@ https://raw.githubusercontent.com/nextcloud/spreed/main/docs/video-verfication.png - + diff --git a/docs/capabilities.md b/docs/capabilities.md index c0b4c7815a7..a715397d87f 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -210,6 +210,9 @@ * `config => call => live-transcription-target-language-id` (local) - User defined string value with the id of the target language to use for live translations ## 24 +* `conversation-categories` (local) - Whether the user can create custom categories to organize conversations in the sidebar +* `config => conversations => sort-order` (local) - User selected sort order for conversations (`activity` or `alphabetical`) +* `config => conversations => group-mode` (local) - User selected grouping mode for conversations (`none` or `type-first`) * `react-permission` - When permission 256 is required to add reactions (previously handled by the chat permission) * `config => permissions => max-default` - Maximum value for default permissions (510 with react-permission, 254 without) * `config => permissions => max-custom` - Maximum value for custom permissions (511 with react-permission, 255 without) diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 3281d9e2d7c..581c24d15eb 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -131,6 +131,7 @@ class Capabilities implements IPublicCapability { 'federated-shared-items', 'scheduled-messages', 'conversation-presets', + 'conversation-categories', ]; public const CONDITIONAL_FEATURES = [ @@ -163,6 +164,7 @@ class Capabilities implements IPublicCapability { 'sensitive-conversations', 'scheduled-messages', 'conversation-presets', + 'conversation-categories', ]; public const LOCAL_CONFIGS = [ @@ -193,6 +195,8 @@ class Capabilities implements IPublicCapability { 'conversations' => [ 'can-create', 'list-style', + 'sort-order', + 'group-mode', 'description-length', ], 'federation' => [ @@ -294,6 +298,8 @@ public function getCapabilities(): array { 'can-create' => $user instanceof IUser && !$this->talkConfig->isNotAllowedToCreateConversations($user), 'force-passwords' => $this->talkConfig->isPasswordEnforced(), 'list-style' => $this->talkConfig->getConversationsListStyle($user?->getUID()), + 'sort-order' => $this->talkConfig->getConversationsSortOrder($user?->getUID()), + 'group-mode' => $this->talkConfig->getConversationsGroupMode($user?->getUID()), 'description-length' => Room::DESCRIPTION_MAXIMUM_LENGTH, 'retention-event' => max(0, $this->appConfig->getAppValueInt('retention_event_rooms', 28)), 'retention-phone' => max(0, $this->appConfig->getAppValueInt('retention_phone_rooms', 7)), diff --git a/lib/Config.php b/lib/Config.php index 5153ea00dc1..f5025c1ac15 100644 --- a/lib/Config.php +++ b/lib/Config.php @@ -813,6 +813,50 @@ public function getChatStyle(?string $userId): string { return UserPreference::CHAT_STYLE_SPLIT; } + /** + * User setting for conversations sort order + * + * @param ?string $userId + * @return UserPreference::CONVERSATIONS_SORT_ORDER_* + */ + public function getConversationsSortOrder(?string $userId): string { + if ($userId !== null) { + $userSetting = $this->config->getUserValue( + $userId, + 'spreed', + UserPreference::CONVERSATIONS_SORT_ORDER, + ); + + if (in_array($userSetting, [UserPreference::CONVERSATIONS_SORT_ORDER_ACTIVITY, UserPreference::CONVERSATIONS_SORT_ORDER_ALPHABETICAL], true)) { + return $userSetting; + } + } + + return UserPreference::CONVERSATIONS_SORT_ORDER_ACTIVITY; + } + + /** + * User setting for conversations group mode + * + * @param ?string $userId + * @return UserPreference::CONVERSATIONS_GROUP_MODE_* + */ + public function getConversationsGroupMode(?string $userId): string { + if ($userId !== null) { + $userSetting = $this->config->getUserValue( + $userId, + 'spreed', + UserPreference::CONVERSATIONS_GROUP_MODE, + ); + + if (in_array($userSetting, [UserPreference::CONVERSATIONS_GROUP_MODE_NONE, UserPreference::CONVERSATIONS_GROUP_MODE_TYPE_FIRST], true)) { + return $userSetting; + } + } + + return UserPreference::CONVERSATIONS_GROUP_MODE_NONE; + } + /** * User setting falling back to admin defined app config */ diff --git a/lib/Controller/ConversationCategoryController.php b/lib/Controller/ConversationCategoryController.php new file mode 100644 index 00000000000..ca64ec9f51b --- /dev/null +++ b/lib/Controller/ConversationCategoryController.php @@ -0,0 +1,154 @@ +, array{}> + * + * 200: Categories returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/categories', requirements: [ + 'apiVersion' => '(v4)', + ])] + public function getCategories(): DataResponse { + $categories = $this->categoryService->getCategories($this->userId); + return new DataResponse(array_values(array_map([$this, 'formatCategory'], $categories))); + } + + /** + * Create a new conversation category + * + * Required capability: `conversation-categories` + * + * @param string $name Name of the category + * @return DataResponse + * + * 201: Category created + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/categories', requirements: [ + 'apiVersion' => '(v4)', + ])] + public function createCategory(string $name): DataResponse { + $category = $this->categoryService->createCategory($this->userId, $name); + return new DataResponse($this->formatCategory($category), Http::STATUS_CREATED); + } + + /** + * Update a conversation category + * + * Required capability: `conversation-categories` + * + * @param int $categoryId ID of the category + * @param string $name New name for the category + * @return DataResponse|DataResponse + * + * 200: Category updated + * 404: Category not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/categories/{categoryId}', requirements: [ + 'apiVersion' => '(v4)', + 'categoryId' => '\d+', + ])] + public function updateCategory(int $categoryId, string $name): DataResponse { + try { + $category = $this->categoryService->updateCategory($categoryId, $this->userId, $name); + return new DataResponse($this->formatCategory($category)); + } catch (DoesNotExistException) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + } + + /** + * Delete a conversation category + * + * Required capability: `conversation-categories` + * + * @param int $categoryId ID of the category + * @return DataResponse|DataResponse + * + * 200: Category deleted + * 404: Category not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/categories/{categoryId}', requirements: [ + 'apiVersion' => '(v4)', + 'categoryId' => '\d+', + ])] + public function deleteCategory(int $categoryId): DataResponse { + try { + $this->categoryService->deleteCategory($categoryId, $this->userId); + return new DataResponse(null); + } catch (DoesNotExistException) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + } + + /** + * Reorder conversation categories + * + * Required capability: `conversation-categories` + * + * @param list $orderedIds Ordered list of category IDs + * @return DataResponse, array{}> + * + * 200: Categories reordered + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/categories/reorder', requirements: [ + 'apiVersion' => '(v4)', + ])] + public function reorderCategories(array $orderedIds): DataResponse { + $this->categoryService->reorderCategories($this->userId, $orderedIds); + $categories = $this->categoryService->getCategories($this->userId); + return new DataResponse(array_values(array_map([$this, 'formatCategory'], $categories))); + } + + /** + * @return TalkConversationCategory + */ + protected function formatCategory(ConversationCategory $category): array { + return [ + 'id' => (string)$category->getId(), + 'name' => $category->getName(), + 'sortOrder' => $category->getSortOrder(), + ]; + } +} diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index a4762f35fe6..435012757ec 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -1888,6 +1888,28 @@ public function unarchiveConversation(): DataResponse { return new DataResponse($this->formatRoom($this->room, $this->participant)); } + /** + * Assign a conversation category + * + * Required capability: `conversation-categories` + * + * @param list $categoryIds IDs of categories to assign (empty array to unassign all) + * @return DataResponse + * + * 200: Conversation categories updated + */ + #[NoAdminRequired] + #[FederationSupported] + #[RequireLoggedInParticipant] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/category', requirements: [ + 'apiVersion' => '(v4)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function assignToCategory(array $categoryIds = []): DataResponse { + $this->participantService->assignConversationToCategories($this->participant, $categoryIds); + return new DataResponse($this->formatRoom($this->room, $this->participant)); + } + /** * Mark a conversation as important (still sending notifications while on DND) * diff --git a/lib/Migration/Version24000Date20260313120000.php b/lib/Migration/Version24000Date20260313120000.php new file mode 100644 index 00000000000..9139efab3ea --- /dev/null +++ b/lib/Migration/Version24000Date20260313120000.php @@ -0,0 +1,68 @@ +hasTable('talk_conversation_categories')) { + $table = $schema->createTable('talk_conversation_categories'); + $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->setPrimaryKey(['id']); + $table->addIndex(['user_id'], 'tcs_user_id'); + } + + $attendeesTable = $schema->getTable('talk_attendees'); + if (!$attendeesTable->hasColumn('category_ids')) { + $attendeesTable->addColumn('category_ids', Types::TEXT, [ + 'notnull' => false, + 'default' => null, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/Attendee.php b/lib/Model/Attendee.php index 75cb0013f72..386646db148 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 setCategoryIds(?string $categoryIds) + * @method ?string getCategoryIds() * @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 $categoryIds = 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('categoryIds', 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..3233c9118c5 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'], + 'category_ids' => $row['category_ids'] ?? null, '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/ConversationCategory.php b/lib/Model/ConversationCategory.php new file mode 100644 index 00000000000..11e9cc5b5f8 --- /dev/null +++ b/lib/Model/ConversationCategory.php @@ -0,0 +1,37 @@ +addType('userId', Types::STRING); + $this->addType('name', Types::STRING); + $this->addType('sortOrder', Types::INTEGER); + $this->addType('collapsed', Types::BOOLEAN); + } +} diff --git a/lib/Model/ConversationCategoryMapper.php b/lib/Model/ConversationCategoryMapper.php new file mode 100644 index 00000000000..141a0a42b6d --- /dev/null +++ b/lib/Model/ConversationCategoryMapper.php @@ -0,0 +1,91 @@ + + */ +class ConversationCategoryMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'talk_conversation_categories', ConversationCategory::class); + } + + /** + * @return ConversationCategory[] + */ + 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(int $id, string $userId): ConversationCategory { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))); + + return $this->findEntity($qb); + } + + public function getMaxSortOrder(string $userId): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->max('sort_order')) + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))); + + $result = $qb->executeQuery(); + $max = $result->fetchOne(); + $result->closeCursor(); + + return (int)$max; + } + + /** + * Clear a category from all attendees' category_ids JSON arrays when a category is deleted + */ + public function clearCategoryFromAttendees(int|string $categoryId, string $userId): void { + $categoryIdStr = (string)$categoryId; + $qb = $this->db->getQueryBuilder(); + // Find attendees that have this category in their JSON array + // Use quoted string match to avoid false positives (e.g. "1" matching "12") + $qb->select('a.id', 'a.category_ids') + ->from('talk_attendees', 'a') + ->where($qb->expr()->like('a.category_ids', $qb->createNamedParameter('%"' . $categoryIdStr . '"%'))) + ->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 $categoryIds */ + $categoryIds = json_decode($row['category_ids'], true) ?? []; + $categoryIds = array_values(array_filter($categoryIds, fn ($id) => (string)$id !== $categoryIdStr)); + + $updateQb = $this->db->getQueryBuilder(); + $updateQb->update('talk_attendees') + ->set('category_ids', $updateQb->createNamedParameter( + empty($categoryIds) ? null : json_encode($categoryIds), + empty($categoryIds) ? 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 ceb4fcd5fdf..0eea95bd21b 100644 --- a/lib/Model/SelectHelper.php +++ b/lib/Model/SelectHelper.php @@ -110,6 +110,7 @@ public function selectAttendeesTable(IQueryBuilder $query, string $alias = 'a'): $alias . 'archived', $alias . 'important', $alias . 'sensitive', + $alias . 'category_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 4a8bca9a1f8..c8dca83bd9e 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -12,6 +12,13 @@ /** * @psalm-type TalkActorTypes = 'users'|'groups'|'guests'|'emails'|'circles'|'bridged'|'bots'|'federated_users'|'phones' * + * @psalm-type TalkConversationCategory = array{ + * // SnowflakeID + * id: numeric-string, + * name: string, + * sortOrder: int, + * } + * * @psalm-type TalkBan = array{ * // Identifier of the ban * id: int, @@ -554,6 +561,8 @@ * isImportant: bool, * // Required capability: `sensitive-conversations` * isSensitive: bool, + * // IDs of the custom categories this conversation belongs to (only available with `conversation-categories` capability) + * categoryIds: list, * // Required capability: `pinned-messages` * lastPinnedId: int, * // Required capability: `pinned-messages` @@ -815,6 +824,10 @@ * retention-phone: non-negative-int, * // Retention period for instant meetings in seconds, `0` means no retention * retention-instant-meetings: non-negative-int, + * // User selected sort order for conversations + * sort-order: string, + * // User selected grouping mode for conversations + * group-mode: string, * }, * federation: array{ * // Whether federation is enabled diff --git a/lib/Service/ConversationCategoryService.php b/lib/Service/ConversationCategoryService.php new file mode 100644 index 00000000000..cb2873ff690 --- /dev/null +++ b/lib/Service/ConversationCategoryService.php @@ -0,0 +1,94 @@ +mapper->findByUserId($userId); + } + + public function getCategory(int $categoryId, string $userId): ConversationCategory { + return $this->mapper->findById($categoryId, $userId); + } + + public function createCategory(string $userId, string $name): ConversationCategory { + $maxOrder = $this->mapper->getMaxSortOrder($userId); + + $category = new ConversationCategory(); + $category->setUserId($userId); + $category->setName($name); + $category->setSortOrder($maxOrder + 1); + $category->setCollapsed(false); + + return $this->mapper->insert($category); + } + + /** + * @throws DoesNotExistException + */ + public function updateCategory(int $categoryId, string $userId, string $name): ConversationCategory { + $category = $this->mapper->findById($categoryId, $userId); + $category->setName($name); + return $this->mapper->update($category); + } + + /** + * @throws DoesNotExistException + */ + public function deleteCategory(int $categoryId, string $userId): void { + $category = $this->mapper->findById($categoryId, $userId); + $this->mapper->clearCategoryFromAttendees($categoryId, $userId); + $this->mapper->delete($category); + } + + /** + * @param int[] $orderedIds + * @throws DoesNotExistException + */ + public function reorderCategories(string $userId, array $orderedIds): void { + $categories = $this->mapper->findByUserId($userId); + $categoryMap = []; + foreach ($categories as $category) { + $categoryMap[$category->getId()] = $category; + } + + $order = 0; + foreach ($orderedIds as $id) { + if (!isset($categoryMap[$id])) { + continue; + } + $category = $categoryMap[$id]; + $category->setSortOrder($order); + $this->mapper->update($category); + $order++; + } + } + + /** + * @throws DoesNotExistException + */ + public function toggleCollapsed(int $categoryId, string $userId): ConversationCategory { + $category = $this->mapper->findById($categoryId, $userId); + $category->setCollapsed(!$category->isCollapsed()); + return $this->mapper->update($category); + } +} diff --git a/lib/Service/ParticipantService.php b/lib/Service/ParticipantService.php index b0dc6c87f10..0b83f6e9bdc 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 $categoryIds + */ + public function assignConversationToCategories(Participant $participant, array $categoryIds): void { + $attendee = $participant->getAttendee(); + + if (empty($categoryIds)) { + $attendee->setCategoryIds(null); + } else { + $attendee->setCategoryIds(json_encode($categoryIds)); + } + + $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 3ed35898cb4..aa25d610758 100644 --- a/lib/Service/RoomFormatter.php +++ b/lib/Service/RoomFormatter.php @@ -158,6 +158,7 @@ public function formatRoomV4( 'isArchived' => false, 'isImportant' => false, 'isSensitive' => false, + 'categoryIds' => [], 'hasScheduledMessages' => 0, 'attributes' => 0, ]; @@ -248,6 +249,7 @@ public function formatRoomV4( 'isArchived' => $attendee->isArchived(), 'isImportant' => $attendee->isImportant(), 'isSensitive' => $attendee->isSensitive(), + 'categoryIds' => array_values(array_map('strval', json_decode($attendee->getCategoryIds() ?? '[]', true))), 'lastPinnedId' => $room->getLastPinnedId(), 'hiddenPinnedId' => $attendee->getHiddenPinnedId(), 'attributes' => $room->getAttributes(), diff --git a/lib/Settings/BeforePreferenceSetEventListener.php b/lib/Settings/BeforePreferenceSetEventListener.php index bacd965650e..f611736b604 100644 --- a/lib/Settings/BeforePreferenceSetEventListener.php +++ b/lib/Settings/BeforePreferenceSetEventListener.php @@ -84,6 +84,12 @@ public function validatePreference(string $userId, string $key, string|int|null if ($key === UserPreference::CHAT_STYLE) { return $value === UserPreference::CHAT_STYLE_SPLIT || $value === UserPreference::CHAT_STYLE_UNIFIED; } + if ($key === UserPreference::CONVERSATIONS_SORT_ORDER) { + return $value === UserPreference::CONVERSATIONS_SORT_ORDER_ACTIVITY || $value === UserPreference::CONVERSATIONS_SORT_ORDER_ALPHABETICAL; + } + if ($key === UserPreference::CONVERSATIONS_GROUP_MODE) { + return $value === UserPreference::CONVERSATIONS_GROUP_MODE_NONE || $value === UserPreference::CONVERSATIONS_GROUP_MODE_TYPE_FIRST; + } if ($key === UserPreference::LIVE_TRANSCRIPTION_TARGET_LANGUAGE_ID) { // Accept any value, as it will be used for both local and federated diff --git a/lib/Settings/UserPreference.php b/lib/Settings/UserPreference.php index 8c7df0ab972..17e9b29775f 100644 --- a/lib/Settings/UserPreference.php +++ b/lib/Settings/UserPreference.php @@ -25,5 +25,13 @@ class UserPreference { public const CHAT_STYLE_SPLIT = 'split'; public const CHAT_STYLE_UNIFIED = 'unified'; + public const CONVERSATIONS_SORT_ORDER = 'conversations_sort_order'; + public const CONVERSATIONS_SORT_ORDER_ACTIVITY = 'activity'; + public const CONVERSATIONS_SORT_ORDER_ALPHABETICAL = 'alphabetical'; + + public const CONVERSATIONS_GROUP_MODE = 'conversations_group_mode'; + public const CONVERSATIONS_GROUP_MODE_NONE = 'none'; + public const CONVERSATIONS_GROUP_MODE_TYPE_FIRST = 'type-first'; + public const LIVE_TRANSCRIPTION_TARGET_LANGUAGE_ID = 'live_transcription_target_language_id'; } diff --git a/openapi-administration.json b/openapi-administration.json index 0fd889f2c27..2b3b4e3028c 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -348,7 +348,9 @@ "description-length", "retention-event", "retention-phone", - "retention-instant-meetings" + "retention-instant-meetings", + "sort-order", + "group-mode" ], "properties": { "can-create": { @@ -390,6 +392,14 @@ "format": "int64", "description": "Retention period for instant meetings in seconds, `0` means no retention", "minimum": 0 + }, + "sort-order": { + "type": "string", + "description": "User selected sort order for conversations" + }, + "group-mode": { + "type": "string", + "description": "User selected grouping mode for conversations" } } }, diff --git a/openapi-backend-recording.json b/openapi-backend-recording.json index a1f221d70fb..d538a013d28 100644 --- a/openapi-backend-recording.json +++ b/openapi-backend-recording.json @@ -271,7 +271,9 @@ "description-length", "retention-event", "retention-phone", - "retention-instant-meetings" + "retention-instant-meetings", + "sort-order", + "group-mode" ], "properties": { "can-create": { @@ -313,6 +315,14 @@ "format": "int64", "description": "Retention period for instant meetings in seconds, `0` means no retention", "minimum": 0 + }, + "sort-order": { + "type": "string", + "description": "User selected sort order for conversations" + }, + "group-mode": { + "type": "string", + "description": "User selected grouping mode for conversations" } } }, diff --git a/openapi-backend-signaling.json b/openapi-backend-signaling.json index 899f9be2b9a..acb546dcecb 100644 --- a/openapi-backend-signaling.json +++ b/openapi-backend-signaling.json @@ -271,7 +271,9 @@ "description-length", "retention-event", "retention-phone", - "retention-instant-meetings" + "retention-instant-meetings", + "sort-order", + "group-mode" ], "properties": { "can-create": { @@ -313,6 +315,14 @@ "format": "int64", "description": "Retention period for instant meetings in seconds, `0` means no retention", "minimum": 0 + }, + "sort-order": { + "type": "string", + "description": "User selected sort order for conversations" + }, + "group-mode": { + "type": "string", + "description": "User selected grouping mode for conversations" } } }, diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index 2f7b09835d4..e9cd87356ae 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -322,7 +322,9 @@ "description-length", "retention-event", "retention-phone", - "retention-instant-meetings" + "retention-instant-meetings", + "sort-order", + "group-mode" ], "properties": { "can-create": { @@ -364,6 +366,14 @@ "format": "int64", "description": "Retention period for instant meetings in seconds, `0` means no retention", "minimum": 0 + }, + "sort-order": { + "type": "string", + "description": "User selected sort order for conversations" + }, + "group-mode": { + "type": "string", + "description": "User selected grouping mode for conversations" } } }, @@ -897,6 +907,7 @@ "isArchived", "isImportant", "isSensitive", + "categoryIds", "lastPinnedId", "hiddenPinnedId", "hasScheduledMessages", @@ -1195,6 +1206,13 @@ "type": "boolean", "description": "Required capability: `sensitive-conversations`" }, + "categoryIds": { + "type": "array", + "description": "IDs of the custom categories this conversation belongs to (only available with `conversation-categories` capability)", + "items": { + "type": "string" + } + }, "lastPinnedId": { "type": "integer", "format": "int64", diff --git a/openapi-bots.json b/openapi-bots.json index cc4a33c2a91..fd46947b4e9 100644 --- a/openapi-bots.json +++ b/openapi-bots.json @@ -271,7 +271,9 @@ "description-length", "retention-event", "retention-phone", - "retention-instant-meetings" + "retention-instant-meetings", + "sort-order", + "group-mode" ], "properties": { "can-create": { @@ -313,6 +315,14 @@ "format": "int64", "description": "Retention period for instant meetings in seconds, `0` means no retention", "minimum": 0 + }, + "sort-order": { + "type": "string", + "description": "User selected sort order for conversations" + }, + "group-mode": { + "type": "string", + "description": "User selected grouping mode for conversations" } } }, diff --git a/openapi-federation.json b/openapi-federation.json index 8b62f32dd3d..813a58c35ef 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -322,7 +322,9 @@ "description-length", "retention-event", "retention-phone", - "retention-instant-meetings" + "retention-instant-meetings", + "sort-order", + "group-mode" ], "properties": { "can-create": { @@ -364,6 +366,14 @@ "format": "int64", "description": "Retention period for instant meetings in seconds, `0` means no retention", "minimum": 0 + }, + "sort-order": { + "type": "string", + "description": "User selected sort order for conversations" + }, + "group-mode": { + "type": "string", + "description": "User selected grouping mode for conversations" } } }, @@ -962,6 +972,7 @@ "isArchived", "isImportant", "isSensitive", + "categoryIds", "lastPinnedId", "hiddenPinnedId", "hasScheduledMessages", @@ -1260,6 +1271,13 @@ "type": "boolean", "description": "Required capability: `sensitive-conversations`" }, + "categoryIds": { + "type": "array", + "description": "IDs of the custom categories this conversation belongs to (only available with `conversation-categories` capability)", + "items": { + "type": "string" + } + }, "lastPinnedId": { "type": "integer", "format": "int64", diff --git a/openapi-full.json b/openapi-full.json index 2db353b447e..5e745a5c742 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -505,7 +505,9 @@ "description-length", "retention-event", "retention-phone", - "retention-instant-meetings" + "retention-instant-meetings", + "sort-order", + "group-mode" ], "properties": { "can-create": { @@ -547,6 +549,14 @@ "format": "int64", "description": "Retention period for instant meetings in seconds, `0` means no retention", "minimum": 0 + }, + "sort-order": { + "type": "string", + "description": "User selected sort order for conversations" + }, + "group-mode": { + "type": "string", + "description": "User selected grouping mode for conversations" } } }, @@ -998,6 +1008,27 @@ } } }, + "ConversationCategory": { + "type": "object", + "required": [ + "id", + "name", + "sortOrder" + ], + "properties": { + "id": { + "type": "string", + "description": "SnowflakeID" + }, + "name": { + "type": "string" + }, + "sortOrder": { + "type": "integer", + "format": "int64" + } + } + }, "ConversationPreset": { "type": "object", "required": [ @@ -1946,6 +1977,7 @@ "isArchived", "isImportant", "isSensitive", + "categoryIds", "lastPinnedId", "hiddenPinnedId", "hasScheduledMessages", @@ -2244,6 +2276,13 @@ "type": "boolean", "description": "Required capability: `sensitive-conversations`" }, + "categoryIds": { + "type": "array", + "description": "IDs of the custom categories this conversation belongs to (only available with `conversation-categories` capability)", + "items": { + "type": "string" + } + }, "lastPinnedId": { "type": "integer", "format": "int64", @@ -12041,13 +12080,13 @@ } } }, - "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { + "/ocs/v2.php/apps/spreed/api/{apiVersion}/categories": { "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_category-get-categories", + "summary": "Get all conversation categories for the current user", + "description": "Required capability: `conversation-categories`", "tags": [ - "files_integration" + "conversation_category" ], "security": [ { @@ -12065,19 +12104,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" } }, { @@ -12093,7 +12122,7 @@ ], "responses": { "200": { - "description": "Room token returned", + "description": "Categories returned", "content": { "application/json": { "schema": { @@ -12113,14 +12142,9 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "token" - ], - "properties": { - "token": { - "type": "string" - } + "type": "array", + "items": { + "$ref": "#/components/schemas/ConversationCategory" } } } @@ -12130,8 +12154,8 @@ } } }, - "400": { - "description": "Rooms not allowed for shares", + "401": { + "description": "Current user is not logged in", "content": { "application/json": { "schema": { @@ -12150,18 +12174,77 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "nullable": true - } + "data": {} } } } } } } + } + } + }, + "post": { + "operationId": "conversation_category-create-category", + "summary": "Create a new conversation category", + "description": "Required capability: `conversation-categories`", + "tags": [ + "conversation_category" + ], + "security": [ + { + "bearer_auth": [] }, - "404": { - "description": "Share not found", + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the category" + } + } + } + } + } + }, + "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": { + "201": { + "description": "Category created", "content": { "application/json": { "schema": { @@ -12180,7 +12263,9 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "$ref": "#/components/schemas/ConversationCategory" + } } } } @@ -12219,16 +12304,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.", + "/ocs/v2.php/apps/spreed/api/{apiVersion}/categories/{categoryId}": { + "put": { + "operationId": "conversation_category-update-category", + "summary": "Update a conversation category", + "description": "Required capability: `conversation-categories`", "tags": [ - "files_integration" + "conversation_category" ], "security": [ - {}, { "bearer_auth": [] }, @@ -12236,6 +12320,25 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "New name for the category" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -12244,19 +12347,19 @@ "schema": { "type": "string", "enum": [ - "v1" + "v4" ], - "default": "v1" + "default": "v4" } }, { - "name": "shareToken", + "name": "categoryId", "in": "path", - "description": "Token of the file share", + "description": "ID of the category", "required": true, "schema": { - "type": "string", - "pattern": "^.+$" + "type": "integer", + "format": "int64" } }, { @@ -12272,7 +12375,7 @@ ], "responses": { "200": { - "description": "Room token and user info returned", + "description": "Category updated", "content": { "application/json": { "schema": { @@ -12292,23 +12395,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/ConversationCategory" } } } @@ -12317,8 +12404,8 @@ } } }, - "400": { - "description": "Rooms not allowed for shares", + "404": { + "description": "Category not found", "content": { "application/json": { "schema": { @@ -12347,8 +12434,8 @@ } } }, - "404": { - "description": "Share not found", + "401": { + "description": "Current user is not logged in", "content": { "application/json": { "schema": { @@ -12367,9 +12454,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "nullable": true - } + "data": {} } } } @@ -12378,17 +12463,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", + }, + "delete": { + "operationId": "conversation_category-delete-category", + "summary": "Delete a conversation category", + "description": "Required capability: `conversation-categories`", "tags": [ - "guest" + "conversation_category" ], "security": [ - {}, { "bearer_auth": [] }, @@ -12396,25 +12479,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "displayName" - ], - "properties": { - "displayName": { - "type": "string", - "description": "New display name" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -12423,18 +12487,19 @@ "schema": { "type": "string", "enum": [ - "v1" + "v4" ], - "default": "v1" + "default": "v4" } }, { - "name": "token", + "name": "categoryId", "in": "path", + "description": "ID of the category", "required": true, "schema": { - "type": "string", - "pattern": "^[a-z0-9]{4,30}$" + "type": "integer", + "format": "int64" } }, { @@ -12450,7 +12515,7 @@ ], "responses": { "200": { - "description": "Display name updated successfully", + "description": "Category deleted", "content": { "application/json": { "schema": { @@ -12479,8 +12544,8 @@ } } }, - "403": { - "description": "Not a guest", + "404": { + "description": "Category not found", "content": { "application/json": { "schema": { @@ -12509,8 +12574,8 @@ } } }, - "404": { - "description": "Not a participant", + "401": { + "description": "Current user is not logged in", "content": { "application/json": { "schema": { @@ -12529,9 +12594,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "nullable": true - } + "data": {} } } } @@ -12542,15 +12605,15 @@ } } }, - "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}": { - "post": { - "operationId": "live_transcription-enable", - "summary": "Enable the live transcription", + "/ocs/v2.php/apps/spreed/api/{apiVersion}/categories/reorder": { + "put": { + "operationId": "conversation_category-reorder-categories", + "summary": "Reorder conversation categories", + "description": "Required capability: `conversation-categories`", "tags": [ - "live_transcription" + "conversation_category" ], "security": [ - {}, { "bearer_auth": [] }, @@ -12558,16 +12621,645 @@ "basic_auth": [] } ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "orderedIds" + ], + "properties": { + "orderedIds": { + "type": "array", + "description": "Ordered list of category IDs", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + }, + "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": "Categories 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/ConversationCategory" + } + } + } + } + } + } + } + } + }, + "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", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], "default": "v1" } }, @@ -21216,6 +21908,137 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/category": { + "post": { + "operationId": "room-assign-to-category", + "summary": "Assign a conversation category", + "description": "Required capability: `conversation-categories`", + "tags": [ + "room" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "categoryIds": { + "type": "array", + "default": [], + "description": "IDs of categories 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 categories 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 f612b57f35d..4c990db6bb5 100644 --- a/openapi.json +++ b/openapi.json @@ -458,7 +458,9 @@ "description-length", "retention-event", "retention-phone", - "retention-instant-meetings" + "retention-instant-meetings", + "sort-order", + "group-mode" ], "properties": { "can-create": { @@ -500,6 +502,14 @@ "format": "int64", "description": "Retention period for instant meetings in seconds, `0` means no retention", "minimum": 0 + }, + "sort-order": { + "type": "string", + "description": "User selected sort order for conversations" + }, + "group-mode": { + "type": "string", + "description": "User selected grouping mode for conversations" } } }, @@ -951,6 +961,27 @@ } } }, + "ConversationCategory": { + "type": "object", + "required": [ + "id", + "name", + "sortOrder" + ], + "properties": { + "id": { + "type": "string", + "description": "SnowflakeID" + }, + "name": { + "type": "string" + }, + "sortOrder": { + "type": "integer", + "format": "int64" + } + } + }, "ConversationPreset": { "type": "object", "required": [ @@ -1834,6 +1865,7 @@ "isArchived", "isImportant", "isSensitive", + "categoryIds", "lastPinnedId", "hiddenPinnedId", "hasScheduledMessages", @@ -2132,6 +2164,13 @@ "type": "boolean", "description": "Required capability: `sensitive-conversations`" }, + "categoryIds": { + "type": "array", + "description": "IDs of the custom categories this conversation belongs to (only available with `conversation-categories` capability)", + "items": { + "type": "string" + } + }, "lastPinnedId": { "type": "integer", "format": "int64", @@ -11929,13 +11968,13 @@ } } }, - "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { + "/ocs/v2.php/apps/spreed/api/{apiVersion}/categories": { "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_category-get-categories", + "summary": "Get all conversation categories for the current user", + "description": "Required capability: `conversation-categories`", "tags": [ - "files_integration" + "conversation_category" ], "security": [ { @@ -11953,19 +11992,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" } }, { @@ -11981,7 +12010,7 @@ ], "responses": { "200": { - "description": "Room token returned", + "description": "Categories returned", "content": { "application/json": { "schema": { @@ -12001,14 +12030,9 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "token" - ], - "properties": { - "token": { - "type": "string" - } + "type": "array", + "items": { + "$ref": "#/components/schemas/ConversationCategory" } } } @@ -12018,8 +12042,8 @@ } } }, - "400": { - "description": "Rooms not allowed for shares", + "401": { + "description": "Current user is not logged in", "content": { "application/json": { "schema": { @@ -12038,18 +12062,77 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "nullable": true - } + "data": {} } } } } } } + } + } + }, + "post": { + "operationId": "conversation_category-create-category", + "summary": "Create a new conversation category", + "description": "Required capability: `conversation-categories`", + "tags": [ + "conversation_category" + ], + "security": [ + { + "bearer_auth": [] }, - "404": { - "description": "Share not found", + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the category" + } + } + } + } + } + }, + "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": { + "201": { + "description": "Category created", "content": { "application/json": { "schema": { @@ -12068,7 +12151,9 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "$ref": "#/components/schemas/ConversationCategory" + } } } } @@ -12107,16 +12192,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.", + "/ocs/v2.php/apps/spreed/api/{apiVersion}/categories/{categoryId}": { + "put": { + "operationId": "conversation_category-update-category", + "summary": "Update a conversation category", + "description": "Required capability: `conversation-categories`", "tags": [ - "files_integration" + "conversation_category" ], "security": [ - {}, { "bearer_auth": [] }, @@ -12124,6 +12208,25 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "New name for the category" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -12132,19 +12235,19 @@ "schema": { "type": "string", "enum": [ - "v1" + "v4" ], - "default": "v1" + "default": "v4" } }, { - "name": "shareToken", + "name": "categoryId", "in": "path", - "description": "Token of the file share", + "description": "ID of the category", "required": true, "schema": { - "type": "string", - "pattern": "^.+$" + "type": "integer", + "format": "int64" } }, { @@ -12160,7 +12263,7 @@ ], "responses": { "200": { - "description": "Room token and user info returned", + "description": "Category updated", "content": { "application/json": { "schema": { @@ -12180,23 +12283,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/ConversationCategory" } } } @@ -12205,8 +12292,8 @@ } } }, - "400": { - "description": "Rooms not allowed for shares", + "404": { + "description": "Category not found", "content": { "application/json": { "schema": { @@ -12235,8 +12322,8 @@ } } }, - "404": { - "description": "Share not found", + "401": { + "description": "Current user is not logged in", "content": { "application/json": { "schema": { @@ -12255,9 +12342,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "nullable": true - } + "data": {} } } } @@ -12266,17 +12351,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", + }, + "delete": { + "operationId": "conversation_category-delete-category", + "summary": "Delete a conversation category", + "description": "Required capability: `conversation-categories`", "tags": [ - "guest" + "conversation_category" ], "security": [ - {}, { "bearer_auth": [] }, @@ -12284,25 +12367,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "displayName" - ], - "properties": { - "displayName": { - "type": "string", - "description": "New display name" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -12311,18 +12375,19 @@ "schema": { "type": "string", "enum": [ - "v1" + "v4" ], - "default": "v1" + "default": "v4" } }, { - "name": "token", + "name": "categoryId", "in": "path", + "description": "ID of the category", "required": true, "schema": { - "type": "string", - "pattern": "^[a-z0-9]{4,30}$" + "type": "integer", + "format": "int64" } }, { @@ -12338,7 +12403,7 @@ ], "responses": { "200": { - "description": "Display name updated successfully", + "description": "Category deleted", "content": { "application/json": { "schema": { @@ -12367,8 +12432,8 @@ } } }, - "403": { - "description": "Not a guest", + "404": { + "description": "Category not found", "content": { "application/json": { "schema": { @@ -12397,8 +12462,8 @@ } } }, - "404": { - "description": "Not a participant", + "401": { + "description": "Current user is not logged in", "content": { "application/json": { "schema": { @@ -12417,9 +12482,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "nullable": true - } + "data": {} } } } @@ -12430,15 +12493,15 @@ } } }, - "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}": { - "post": { - "operationId": "live_transcription-enable", - "summary": "Enable the live transcription", + "/ocs/v2.php/apps/spreed/api/{apiVersion}/categories/reorder": { + "put": { + "operationId": "conversation_category-reorder-categories", + "summary": "Reorder conversation categories", + "description": "Required capability: `conversation-categories`", "tags": [ - "live_transcription" + "conversation_category" ], "security": [ - {}, { "bearer_auth": [] }, @@ -12446,18 +12509,647 @@ "basic_auth": [] } ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "orderedIds" + ], + "properties": { + "orderedIds": { + "type": "array", + "description": "Ordered list of category IDs", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + }, + "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": "Categories 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/ConversationCategory" + } + } + } + } + } + } + } + } + }, + "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", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } }, { "name": "token", @@ -21104,6 +21796,137 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/category": { + "post": { + "operationId": "room-assign-to-category", + "summary": "Assign a conversation category", + "description": "Required capability: `conversation-categories`", + "tags": [ + "room" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "categoryIds": { + "type": "array", + "default": [], + "description": "IDs of categories 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 categories 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 d2690b92f96..6cc0b89e3b5 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -177,6 +177,8 @@ export const mockedCapabilities: Capabilities = { 'retention-event': 28, 'retention-phone': 7, 'retention-instant-meetings': 1, + 'sort-order': 'activity', + 'group-mode': 'none', }, federation: { enabled: false, diff --git a/src/components/LeftSidebar/ConversationsList/ConversationCategoryHeader.vue b/src/components/LeftSidebar/ConversationsList/ConversationCategoryHeader.vue new file mode 100644 index 00000000000..83a67dd5422 --- /dev/null +++ b/src/components/LeftSidebar/ConversationsList/ConversationCategoryHeader.vue @@ -0,0 +1,148 @@ + + + + + + + diff --git a/src/components/LeftSidebar/ConversationsList/ConversationItem.vue b/src/components/LeftSidebar/ConversationsList/ConversationItem.vue index 0132dbeeef4..9805e44f7b0 100644 --- a/src/components/LeftSidebar/ConversationsList/ConversationItem.vue +++ b/src/components/LeftSidebar/ConversationsList/ConversationItem.vue @@ -115,6 +115,17 @@ {{ labelArchive }} + + + {{ t('spreed', 'Categories') }} + + + diff --git a/src/components/LeftSidebar/LeftSidebar.spec.js b/src/components/LeftSidebar/LeftSidebar.spec.js index 3018c7f919e..c764a73725c 100644 --- a/src/components/LeftSidebar/LeftSidebar.spec.js +++ b/src/components/LeftSidebar/LeftSidebar.spec.js @@ -17,12 +17,20 @@ import { autocompleteQuery } from '../../services/coreService.ts' import { EventBus } from '../../services/EventBus.ts' import storeConfig from '../../store/storeConfig.js' import { useActorStore } from '../../stores/actor.ts' -import { findNcActionButton, findNcButton } from '../../test-helpers.js' +import { findNcActionButton, findNcButton, generateOCSResponse } from '../../test-helpers.js' import { requestTabLeadership } from '../../utils/requestTabLeadership.js' vi.mock('../../services/conversationsService', () => ({ searchListedConversations: vi.fn(), })) +vi.mock('../../services/conversationCategoriesService', () => ({ + fetchCategories: vi.fn(() => generateOCSResponse({ payload: [] })), + createCategory: vi.fn(), + updateCategory: vi.fn(), + deleteCategory: vi.fn(), + reorderCategories: vi.fn(), + assignConversationToCategories: vi.fn(), +})) vi.mock('../../services/coreService', () => ({ autocompleteQuery: vi.fn(), })) @@ -160,8 +168,15 @@ describe('LeftSidebar.vue', () => { const normalConversationsList = conversationsList.filter((conversation) => !conversation.isArchived) const conversationListItems = wrapper.findAll('.conversation') expect(conversationListItems).toHaveLength(normalConversationsList.length) - expect(conversationListItems.at(0).text()).toContain(normalConversationsList[0].displayName) - expect(conversationListItems.at(1).text()).toContain(normalConversationsList[1].displayName) + // Favorites are sorted first, then by lastActivity descending + const sorted = [...normalConversationsList].sort((a, b) => { + if (a.isFavorite !== b.isFavorite) { + return a.isFavorite ? -1 : 1 + } + return b.lastActivity - a.lastActivity + }) + expect(conversationListItems.at(0).text()).toContain(sorted[0].displayName) + expect(conversationListItems.at(1).text()).toContain(sorted[1].displayName) expect(conversationsReceivedEvent).toHaveBeenCalled() }) diff --git a/src/components/LeftSidebar/LeftSidebar.vue b/src/components/LeftSidebar/LeftSidebar.vue index 17a7d2d8c7a..d10cdfe1c21 100644 --- a/src/components/LeftSidebar/LeftSidebar.vue +++ b/src/components/LeftSidebar/LeftSidebar.vue @@ -20,14 +20,15 @@ - + :class="{ 'hidden-visually': isSearching }" + @close="isCreatingCategory = false"> @@ -74,6 +75,77 @@ {{ t('spreed', 'Clear filters') }} + + + + + + + + {{ t('spreed', 'Recent activity') }} + + + + + {{ t('spreed', 'Alphabetical') }} + + + + + + + + + {{ t('spreed', 'Groups first') }} + + + + + {{ t('spreed', 'Private first') }} + + + + + + + + + + {{ t('spreed', 'New category') }} + @@ -309,7 +381,9 @@ import { ref } from 'vue' import { START_LOCATION } from 'vue-router' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import NcActionCaption from '@nextcloud/vue/components/NcActionCaption' +import NcActionInput from '@nextcloud/vue/components/NcActionInput' import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation' import NcAppNavigationCaption from '@nextcloud/vue/components/NcAppNavigationCaption' import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' @@ -317,14 +391,17 @@ import NcButton from '@nextcloud/vue/components/NcButton' import NcChip from '@nextcloud/vue/components/NcChip' import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import IconAccountGroupOutline from 'vue-material-design-icons/AccountGroupOutline.vue' import IconAccountMultiplePlusOutline from 'vue-material-design-icons/AccountMultiplePlusOutline.vue' +import IconAccountOutline from 'vue-material-design-icons/AccountOutline.vue' import IconArchiveOutline from 'vue-material-design-icons/ArchiveOutline.vue' import IconArrowLeft from 'vue-material-design-icons/ArrowLeft.vue' import IconAt from 'vue-material-design-icons/At.vue' import IconCalendarBlankOutline from 'vue-material-design-icons/CalendarBlankOutline.vue' import IconChatPlusOutline from 'vue-material-design-icons/ChatPlusOutline.vue' +import IconClockOutline from 'vue-material-design-icons/ClockOutline.vue' import IconCogOutline from 'vue-material-design-icons/CogOutline.vue' -import IconFilterOutline from 'vue-material-design-icons/FilterOutline.vue' +import IconFilterCogOutline from 'vue-material-design-icons/FilterCogOutline.vue' import IconFilterRemoveOutline from 'vue-material-design-icons/FilterRemoveOutline.vue' import IconFormatListBulleted from 'vue-material-design-icons/FormatListBulleted.vue' import IconForumOutline from 'vue-material-design-icons/ForumOutline.vue' @@ -334,6 +411,8 @@ import IconMessageOutline from 'vue-material-design-icons/MessageOutline.vue' import IconNoteEditOutline from 'vue-material-design-icons/NoteEditOutline.vue' import IconPhoneOutline from 'vue-material-design-icons/PhoneOutline.vue' import IconPlus from 'vue-material-design-icons/Plus.vue' +import IconSortAlphabeticalAscending from 'vue-material-design-icons/SortAlphabeticalAscending.vue' +import IconTagMultipleOutline from 'vue-material-design-icons/TagMultipleOutline.vue' import NewConversationDialog from '../NewConversationDialog/NewConversationDialog.vue' import ThreadItem from '../RightSidebar/Threads/ThreadItem.vue' import LoadingPlaceholder from '../UIShared/LoadingPlaceholder.vue' @@ -359,6 +438,7 @@ import { EventBus } from '../../services/EventBus.ts' import { talkBroadcastChannel } from '../../services/talkBroadcastChannel.js' import { useActorStore } from '../../stores/actor.ts' import { useChatExtrasStore } from '../../stores/chatExtras.ts' +import { useConversationCategoriesStore } from '../../stores/conversationCategories.ts' import { useFederationStore } from '../../stores/federation.ts' import { useSettingsStore } from '../../stores/settings.ts' import { useTalkHashStore } from '../../stores/talkHash.js' @@ -407,6 +487,8 @@ export default { NcActions, NcActionButton, NcActionCaption, + NcActionInput, + NcActionSeparator, TransitionWrapper, ConversationsListVirtual, SearchConversationsResults, @@ -415,7 +497,7 @@ export default { IconAt, IconMessageBadgeOutline, IconMessageOutline, - IconFilterOutline, + IconFilterCogOutline, IconFilterRemoveOutline, IconArchiveOutline, IconArrowLeft, @@ -428,6 +510,11 @@ export default { IconCogOutline, IconFormatListBulleted, IconNoteEditOutline, + IconSortAlphabeticalAscending, + IconTagMultipleOutline, + IconClockOutline, + IconAccountGroupOutline, + IconAccountOutline, NcEmptyContent, }, @@ -443,6 +530,7 @@ export default { const federationStore = useFederationStore() const talkHashStore = useTalkHashStore() const settingsStore = useSettingsStore() + const categoriesStore = useConversationCategoriesStore() const { initializeNavigation, resetNavigation } = useArrowNavigation(leftSidebar, searchBox) const isMobile = useIsMobile() @@ -469,6 +557,7 @@ export default { actorStore: useActorStore(), chatExtrasStore: useChatExtrasStore(), tokenStore: useTokenStore(), + categoriesStore, } }, @@ -498,12 +587,52 @@ export default { isFocused: false, isNavigating: false, fallbackConversationToken: null, + isCreatingCategory: false, } }, computed: { + sortOrder() { + return this.settingsStore.sortOrder + }, + + groupMode() { + return this.settingsStore.groupMode + }, + conversationsList() { - return this.$store.getters.conversationsList + return [...this.$store.getters.conversationsList].sort((conversation1, conversation2) => { + // Favorites always first + if (conversation1.isFavorite !== conversation2.isFavorite) { + return conversation1.isFavorite ? -1 : 1 + } + + // Group mode: groups first + if (this.groupMode === CONVERSATION.GROUP_MODE.GROUPS_FIRST) { + const isGroup1 = conversation1.type === CONVERSATION.TYPE.GROUP || conversation1.type === CONVERSATION.TYPE.PUBLIC + const isGroup2 = conversation2.type === CONVERSATION.TYPE.GROUP || conversation2.type === CONVERSATION.TYPE.PUBLIC + if (isGroup1 !== isGroup2) { + return isGroup1 ? -1 : 1 + } + } + + // Group mode: private first + if (this.groupMode === CONVERSATION.GROUP_MODE.PRIVATE_FIRST) { + const isPrivate1 = conversation1.type === CONVERSATION.TYPE.ONE_TO_ONE || conversation1.type === CONVERSATION.TYPE.ONE_TO_ONE_FORMER + const isPrivate2 = conversation2.type === CONVERSATION.TYPE.ONE_TO_ONE || conversation2.type === CONVERSATION.TYPE.ONE_TO_ONE_FORMER + if (isPrivate1 !== isPrivate2) { + return isPrivate1 ? -1 : 1 + } + } + + // Sort order + if (this.sortOrder === CONVERSATION.SORT_ORDER.ALPHABETICAL) { + return (conversation1.displayName || '').localeCompare(conversation2.displayName || '') + } + + // Default: activity (most recent first) + return conversation2.lastActivity - conversation1.lastActivity + }) }, emptyContentLabel() { @@ -541,21 +670,94 @@ export default { }, filteredConversationsList() { + let conversations if (this.isFocused) { - return this.conversationsList.filter((conversation) => shouldIncludeArchived(conversation, this.showArchived)) + conversations = this.conversationsList.filter((conversation) => shouldIncludeArchived(conversation, this.showArchived)) + } else { + let validConversationsCount = 0 + const filteredConversations = this.conversationsList.filter((conversation) => { + const conversationIsValid = filterConversation(conversation, this.filters) + if (conversationIsValid) { + validConversationsCount++ + } + return shouldIncludeArchived(conversation, this.showArchived) + && (conversationIsValid || hasCall(conversation) || conversation.token === this.token) + }) + // return empty if it only includes the current conversation without any flags + conversations = validConversationsCount === 0 && !this.isNavigating ? [] : filteredConversations } - let validConversationsCount = 0 - const filteredConversations = this.conversationsList.filter((conversation) => { - const conversationIsValid = filterConversation(conversation, this.filters) - if (conversationIsValid) { - validConversationsCount++ + // If no categories or showing archived, return plain list + const categories = this.categoriesStore.sortedCategories + if (categories.length === 0 || this.showArchived) { + return conversations + } + + // Group conversations by category + const favoriteConversations = conversations.filter((c) => c.isFavorite) + const categorizedConversations = conversations.filter((c) => !c.isFavorite && c.categoryIds?.length > 0) + const uncategorizedConversations = conversations.filter((c) => !c.isFavorite && (!c.categoryIds || c.categoryIds.length === 0)) + + const result = [] + + // Favorites category (built-in) + const favoritesCollapsed = this.categoriesStore.isCollapsed('favorites') + if (favoriteConversations.length > 0) { + result.push({ + _type: 'category-header', + id: 'category-favorites', + name: t('spreed', 'Favorites'), + categoryId: 'favorites', + collapsed: favoritesCollapsed, + unreadCount: favoriteConversations.reduce((sum, c) => sum + (c.unreadMessages || 0), 0), + }) + if (!favoritesCollapsed) { + result.push(...favoriteConversations) } - return shouldIncludeArchived(conversation, this.showArchived) - && (conversationIsValid || hasCall(conversation) || conversation.token === this.token) - }) - // return empty if it only includes the current conversation without any flags - return validConversationsCount === 0 && !this.isNavigating ? [] : filteredConversations + } + + // Custom categories + for (let i = 0; i < categories.length; i++) { + const category = categories[i] + const categoryConvs = categorizedConversations.filter((c) => c.categoryIds.includes(String(category.id))) + if (categoryConvs.length === 0 && this.isFiltered) { + continue // Hide empty categories when filtering + } + + const unreadCount = categoryConvs.reduce((sum, c) => sum + (c.unreadMessages || 0), 0) + result.push({ + _type: 'category-header', + id: `category-${category.id}`, + name: category.name, + categoryId: category.id, + collapsed: category.collapsed, + unreadCount, + isFirst: i === 0, + isLast: i === categories.length - 1, + }) + + if (!category.collapsed) { + result.push(...categoryConvs) + } + } + + // Uncategorized conversations + const otherCollapsed = this.categoriesStore.isCollapsed('other') + if (uncategorizedConversations.length > 0) { + result.push({ + _type: 'category-header', + id: 'category-other', + name: t('spreed', 'Other'), + categoryId: 'other', + collapsed: otherCollapsed, + unreadCount: uncategorizedConversations.reduce((sum, c) => sum + (c.unreadMessages || 0), 0), + }) + if (!otherCollapsed) { + result.push(...uncategorizedConversations) + } + } + + return result }, followedThreads() { @@ -664,6 +866,9 @@ export default { this.debounceFetchConversations = debounce(this.fetchConversations, 3000) this.debounceHandleScroll = debounce(this.handleScroll, 50) + // Fetch conversation categories + this.categoriesStore.fetchCategories() + EventBus.on('should-refresh-conversations', this.handleShouldRefreshConversations) EventBus.once('conversations-received', this.handleConversationsReceived) EventBus.on('route-change', this.onRouteChange) @@ -718,6 +923,28 @@ export default { this.$refs.invitationHandler.showModal() }, + handleSortOrder(sortOrder) { + this.settingsStore.updateSortOrder(sortOrder) + }, + + handleGroupMode(groupMode) { + this.settingsStore.updateGroupMode(groupMode) + }, + + async handleCreateCategory(event) { + const name = event.target?.[0]?.value?.trim() || '' + if (!name) { + return + } + try { + await this.categoriesStore.createCategory(name) + } catch (error) { + console.error('Failed to create category:', error) + } + this.isCreatingCategory = false + this.$refs.filtersAndSort?.closeMenu?.() + }, + handleFilter(filter) { // Store the active filter if (filter === null) { @@ -1157,7 +1384,7 @@ export default { transition: all 0.15s ease; z-index: 1; // TODO replace with NcAppNavigationSearch - width: calc(100% - (var(--default-grid-baseline) + var(--default-clickable-area)) * 2); + width: calc(100% - (var(--default-grid-baseline) + var(--default-clickable-area)) * 3); display: flex; &--expanded { diff --git a/src/constants.ts b/src/constants.ts index f07f54d28d8..c793695a833 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -146,6 +146,17 @@ export const CONVERSATION = { COMPACT: 'compact', }, + SORT_ORDER: { + ACTIVITY: 'activity', + ALPHABETICAL: 'alphabetical', + }, + + GROUP_MODE: { + NONE: 'none', + GROUPS_FIRST: 'group-first', + PRIVATE_FIRST: 'private-first', + }, + MAX_NAME_LENGTH: 255, } as const diff --git a/src/services/conversationCategoriesService.ts b/src/services/conversationCategoriesService.ts new file mode 100644 index 00000000000..e2265ce9b09 --- /dev/null +++ b/src/services/conversationCategoriesService.ts @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { components } from '../types/openapi/openapi.ts' + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +export type ConversationCategory = components['schemas']['ConversationCategory'] + +/** + * Fetch all conversation categories for the current user + */ +async function fetchCategories() { + return axios.get(generateOcsUrl('apps/spreed/api/v4/categories')) +} + +/** + * Create a new conversation category + * + * @param name Name of the category + */ +async function createCategory(name: string) { + return axios.post(generateOcsUrl('apps/spreed/api/v4/categories'), { name }) +} + +/** + * Update a conversation category name + * + * @param categoryId ID of the category + * @param name New name for the category + */ +async function updateCategory(categoryId: string, name: string) { + return axios.put(generateOcsUrl('apps/spreed/api/v4/categories/{categoryId}', { categoryId }), { name }) +} + +/** + * Delete a conversation category + * + * @param categoryId ID of the category to delete + */ +async function deleteCategory(categoryId: string) { + return axios.delete(generateOcsUrl('apps/spreed/api/v4/categories/{categoryId}', { categoryId })) +} + +/** + * Reorder conversation categories + * + * @param orderedIds Ordered list of category IDs + */ +async function reorderCategories(orderedIds: string[]) { + return axios.put(generateOcsUrl('apps/spreed/api/v4/categories/reorder'), { orderedIds }) +} + +/** + * Assign conversation categories + * + * @param token Conversation token + * @param categoryIds Category IDs to assign (empty array to unassign all) + */ +async function assignConversationToCategories(token: string, categoryIds: string[]) { + return axios.post(generateOcsUrl('apps/spreed/api/v4/room/{token}/category', { token }), { categoryIds }) +} + +export { + assignConversationToCategories, + createCategory, + deleteCategory, + fetchCategories, + reorderCategories, + updateCategory, +} diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 779c41e80ec..08804dbf7f0 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -116,6 +116,22 @@ async function setChatStyle(value: string) { return setUserConfig('spreed', 'chat_style', value) } +/** + * + * @param value + */ +async function setConversationsSortOrder(value: string) { + return setUserConfig('spreed', 'conversations_sort_order', value) +} + +/** + * + * @param value + */ +async function setConversationsGroupMode(value: string) { + return setUserConfig('spreed', 'conversations_group_mode', value) +} + /** * @param hasUserAccount * @param value @@ -151,7 +167,9 @@ export { setAttachmentFolder, setBlurVirtualBackground, setChatStyle, + setConversationsGroupMode, setConversationsListStyle, + setConversationsSortOrder, setLiveTranscriptionTargetLanguageId, setPlaySounds, setReadStatusPrivacy, diff --git a/src/store/conversationsStore.js b/src/store/conversationsStore.js index 5d847d7741c..9c896e918a9 100644 --- a/src/store/conversationsStore.js +++ b/src/store/conversationsStore.js @@ -21,6 +21,7 @@ import { } from '../services/avatarService.ts' import BrowserStorage from '../services/BrowserStorage.js' import { getTalkConfig, hasTalkFeature } from '../services/CapabilitiesManager.ts' +import { assignConversationToCategories } from '../services/conversationCategoriesService.ts' import { addToFavorites, archiveConversation, @@ -137,20 +138,16 @@ function state() { const getters = { conversations: (state) => state.conversations, /** - * List of all conversations sorted by isFavorite and lastActivity without breakout rooms + * List of all conversations without breakout rooms, sorted by most recent activity * * @param {object} state state - * @return {object[]} sorted conversations list + * @return {object[]} conversations list sorted by activity */ conversationsList: (state) => { return Object.values(state.conversations) // Filter out breakout rooms .filter((conversation) => conversation.objectType !== CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM) - // Sort by isFavorite and lastActivity .sort((conversation1, conversation2) => { - if (conversation1.isFavorite !== conversation2.isFavorite) { - return conversation1.isFavorite ? -1 : 1 - } return conversation2.lastActivity - conversation1.lastActivity }) }, @@ -611,6 +608,19 @@ const actions = { } }, + async assignToCategories(context, { token, categoryIds }) { + if (!context.getters.conversations[token]) { + return + } + + try { + const response = await assignConversationToCategories(token, categoryIds) + context.commit('addConversation', response.data.ocs.data) + } catch (error) { + console.error('Error while assigning conversation to categories: ', error) + } + }, + async toggleImportant(context, { token, isImportant }) { if (!context.getters.conversations[token]) { return diff --git a/src/stores/conversationCategories.ts b/src/stores/conversationCategories.ts new file mode 100644 index 00000000000..80ad389ecd9 --- /dev/null +++ b/src/stores/conversationCategories.ts @@ -0,0 +1,231 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ConversationCategory } from '../services/conversationCategoriesService.ts' + +import { showError } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import { defineStore } from 'pinia' +import { computed, reactive } from 'vue' +import BrowserStorage from '../services/BrowserStorage.js' +import { + createCategory as createCategoryApi, + deleteCategory as deleteCategoryApi, + fetchCategories as fetchCategoriesApi, + reorderCategories as reorderCategoriesApi, + updateCategory as updateCategoryApi, +} from '../services/conversationCategoriesService.ts' + +type CategoryWithCollapsed = ConversationCategory & { collapsed: boolean } + +const STORAGE_KEY = 'conversation-categories' + +/** + * Read cached categories from BrowserStorage + */ +function readCachedCategories(): Record { + return JSON.parse(BrowserStorage.getItem(STORAGE_KEY) ?? '{}') as Record +} + +/** + * Persist categories to BrowserStorage + * + * @param categories Categories record to persist + */ +function persistCategories(categories: Record) { + BrowserStorage.setItem(STORAGE_KEY, JSON.stringify(categories)) +} + +export const useConversationCategoriesStore = defineStore('conversationCategories', () => { + const categories = reactive>(readCachedCategories()) + + const sortedCategories = computed(() => { + return Object.values(categories).sort((a, b) => a.sortOrder - b.sortOrder) + }) + + /** + * Get a category by its ID + * + * @param id Category ID + */ + function categoryById(id: number | string): CategoryWithCollapsed | undefined { + return categories[String(id)] + } + + /** + * Fetch all conversation categories from the server + */ + async function fetchCategories() { + try { + const response = await fetchCategoriesApi() + const storedCategories = readCachedCategories() + const newCategories = response.data.ocs.data + .reduce((acc: Record, category: ConversationCategory) => { + acc[category.id] = { ...category, collapsed: storedCategories[category.id]?.collapsed ?? false } + return acc + }, {}) + // Clear and repopulate the reactive object + for (const key of Object.keys(categories)) { + delete categories[key] + } + Object.assign(categories, newCategories) + persistCategories(categories) + } catch (error) { + console.error('Failed to fetch conversation categories:', error) + } + } + + /** + * Create a new conversation category + * + * @param name Name of the category + */ + async function createCategory(name: string) { + try { + const response = await createCategoryApi(name) + const category = response.data.ocs.data + categories[category.id] = { ...category, collapsed: false } + persistCategories(categories) + return category + } catch (error) { + console.error('Failed to create category:', error) + throw error + } + } + + /** + * Update the name of a conversation category + * + * @param categoryId ID of the category + * @param name New name for the category + */ + async function updateCategoryName(categoryId: string, name: string) { + try { + const response = await updateCategoryApi(categoryId, name) + const category = response.data.ocs.data + const collapsed = categories[category.id]?.collapsed ?? false + categories[category.id] = { ...category, collapsed } + persistCategories(categories) + return category + } catch (error) { + showError(t('spreed', 'Error renaming category')) + throw error + } + } + + /** + * Remove a conversation category + * + * @param categoryId ID of the category to remove + */ + async function removeCategory(categoryId: string) { + try { + await deleteCategoryApi(categoryId) + delete categories[categoryId] + persistCategories(categories) + } catch (error) { + showError(t('spreed', 'Error deleting category')) + throw error + } + } + + /** + * Reorder conversation categories + * + * @param orderedIds Ordered list of category IDs + */ + async function reorderCategories(orderedIds: string[]) { + try { + const response = await reorderCategoriesApi(orderedIds) + const data = response.data.ocs.data + const newCategories: Record = {} + for (const category of data) { + const collapsed = categories[category.id]?.collapsed ?? false + newCategories[category.id] = { ...category, collapsed } + } + for (const key of Object.keys(categories)) { + delete categories[key] + } + Object.assign(categories, newCategories) + persistCategories(categories) + } catch (error) { + showError(t('spreed', 'Error reordering categories')) + throw error + } + } + + /** + * Toggle the collapsed state of a category (including built-in favorites/other) + * + * @param categoryId ID of the category to toggle + */ + function toggleCollapsed(categoryId: string) { + const key = String(categoryId) + const category = categories[key] + if (category) { + category.collapsed = !category.collapsed + } else { + // Built-in categories (favorites, other) - create a synthetic entry + categories[key] = { id: key, name: key, sortOrder: 0, collapsed: true } + } + persistCategories(categories) + } + + /** + * Check if a category is collapsed (works for both custom and built-in categories) + * + * @param categoryId ID of the category to check + */ + function isCollapsed(categoryId: string): boolean { + return categories[String(categoryId)]?.collapsed ?? false + } + + /** + * Move a category up in the sort order + * + * @param categoryId ID of the category to move + */ + async function moveCategoryUp(categoryId: string) { + const sorted = sortedCategories.value + const index = sorted.findIndex((c) => c.id === categoryId) + if (index <= 0) { + return + } + const orderedIds = sorted.map((c) => c.id) + ;[orderedIds[index - 1], orderedIds[index]] = [orderedIds[index], orderedIds[index - 1]] + await reorderCategories(orderedIds) + } + + /** + * Move a category down in the sort order + * + * @param categoryId ID of the category to move + */ + async function moveCategoryDown(categoryId: string) { + const sorted = sortedCategories.value + const index = sorted.findIndex((c) => c.id === categoryId) + if (index === -1 || index >= sorted.length - 1) { + return + } + const orderedIds = sorted.map((c) => c.id) + ;[orderedIds[index], orderedIds[index + 1]] = [orderedIds[index + 1], orderedIds[index]] + await reorderCategories(orderedIds) + } + + return { + categories, + sortedCategories, + categoryById, + fetchCategories, + createCategory, + updateCategoryName, + removeCategory, + reorderCategories, + toggleCollapsed, + isCollapsed, + moveCategoryUp, + moveCategoryDown, + } +}) diff --git a/src/stores/settings.ts b/src/stores/settings.ts index d67b4a93841..f06e61f5899 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -7,14 +7,16 @@ import { getCurrentUser } from '@nextcloud/auth' import { loadState } from '@nextcloud/initial-state' import { defineStore } from 'pinia' import { ref } from 'vue' -import { PRIVACY } from '../constants.ts' +import { CONVERSATION, PRIVACY } from '../constants.ts' import BrowserStorage from '../services/BrowserStorage.js' import { getTalkConfig } from '../services/CapabilitiesManager.ts' import { setAttachmentFolder, setBlurVirtualBackground, setChatStyle, + setConversationsGroupMode, setConversationsListStyle, + setConversationsSortOrder, setLiveTranscriptionTargetLanguageId, setReadStatusPrivacy, setStartWithoutMedia, @@ -52,6 +54,9 @@ export const useSettingsStore = defineStore('settings', () => { liveTranscriptionTargetLanguageId.value = BrowserStorage.getItem('liveTranscriptionTargetLanguageId') as string } + const sortOrder = ref(getTalkConfig('local', 'conversations', 'sort-order') ?? CONVERSATION.SORT_ORDER.ACTIVITY) + const groupMode = ref(getTalkConfig('local', 'conversations', 'group-mode') ?? CONVERSATION.GROUP_MODE.NONE) + const attachmentFolder = ref(getTalkConfig('local', 'attachments', 'folder') ?? '') /** @@ -184,6 +189,26 @@ export const useSettingsStore = defineStore('settings', () => { liveTranscriptionTargetLanguageId.value = value } + /** + * Update the sort order for the conversation list + * + * @param value - the sort order ('activity', 'alphabetical') + */ + async function updateSortOrder(value: string) { + await setConversationsSortOrder(value) + sortOrder.value = value + } + + /** + * Update the group mode for the conversation list + * + * @param value - the group mode ('none', 'group-first', 'private-first') + */ + async function updateGroupMode(value: string) { + await setConversationsGroupMode(value) + groupMode.value = value + } + return { readStatusPrivacy, typingStatusPrivacy, @@ -197,8 +222,12 @@ export const useSettingsStore = defineStore('settings', () => { conversationsListStyle, attachmentFolder, chatStyle, + sortOrder, + groupMode, liveTranscriptionTargetLanguageId, + updateSortOrder, + updateGroupMode, updateReadStatusPrivacy, updateTypingStatusPrivacy, setShowMediaSettings, diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 7f9d1f707ba..8565445b858 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -358,6 +358,10 @@ export type components = { * @description Retention period for instant meetings in seconds, `0` means no retention */ "retention-instant-meetings": number; + /** @description User selected sort order for conversations */ + "sort-order": string; + /** @description User selected grouping mode for conversations */ + "group-mode": string; }; federation: { /** @description Whether federation is enabled */ diff --git a/src/types/openapi/openapi-backend-recording.ts b/src/types/openapi/openapi-backend-recording.ts index a0de3a01a1f..f0cfe2c6ea1 100644 --- a/src/types/openapi/openapi-backend-recording.ts +++ b/src/types/openapi/openapi-backend-recording.ts @@ -172,6 +172,10 @@ export type components = { * @description Retention period for instant meetings in seconds, `0` means no retention */ "retention-instant-meetings": number; + /** @description User selected sort order for conversations */ + "sort-order": string; + /** @description User selected grouping mode for conversations */ + "group-mode": string; }; federation: { /** @description Whether federation is enabled */ diff --git a/src/types/openapi/openapi-backend-signaling.ts b/src/types/openapi/openapi-backend-signaling.ts index b29ca9d8d3f..eb8efe3840d 100644 --- a/src/types/openapi/openapi-backend-signaling.ts +++ b/src/types/openapi/openapi-backend-signaling.ts @@ -158,6 +158,10 @@ export type components = { * @description Retention period for instant meetings in seconds, `0` means no retention */ "retention-instant-meetings": number; + /** @description User selected sort order for conversations */ + "sort-order": string; + /** @description User selected grouping mode for conversations */ + "group-mode": string; }; federation: { /** @description Whether federation is enabled */ diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index 3fa8e81b172..e8d2a538eb3 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -283,6 +283,10 @@ export type components = { * @description Retention period for instant meetings in seconds, `0` means no retention */ "retention-instant-meetings": number; + /** @description User selected sort order for conversations */ + "sort-order": string; + /** @description User selected grouping mode for conversations */ + "group-mode": string; }; federation: { /** @description Whether federation is enabled */ @@ -739,6 +743,8 @@ export type components = { isImportant: boolean; /** @description Required capability: `sensitive-conversations` */ isSensitive: boolean; + /** @description IDs of the custom categories this conversation belongs to (only available with `conversation-categories` capability) */ + categoryIds: string[]; /** * Format: int64 * @description Required capability: `pinned-messages` diff --git a/src/types/openapi/openapi-bots.ts b/src/types/openapi/openapi-bots.ts index 4591277a2ec..e79b6236947 100644 --- a/src/types/openapi/openapi-bots.ts +++ b/src/types/openapi/openapi-bots.ts @@ -176,6 +176,10 @@ export type components = { * @description Retention period for instant meetings in seconds, `0` means no retention */ "retention-instant-meetings": number; + /** @description User selected sort order for conversations */ + "sort-order": string; + /** @description User selected grouping mode for conversations */ + "group-mode": string; }; federation: { /** @description Whether federation is enabled */ diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index 81e1c3b7ba3..255cd682c77 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -294,6 +294,10 @@ export type components = { * @description Retention period for instant meetings in seconds, `0` means no retention */ "retention-instant-meetings": number; + /** @description User selected sort order for conversations */ + "sort-order": string; + /** @description User selected grouping mode for conversations */ + "group-mode": string; }; federation: { /** @description Whether federation is enabled */ @@ -783,6 +787,8 @@ export type components = { isImportant: boolean; /** @description Required capability: `sensitive-conversations` */ isSensitive: boolean; + /** @description IDs of the custom categories this conversation belongs to (only available with `conversation-categories` capability) */ + categoryIds: 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 10c22a32eba..37fd79ddaa7 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -688,6 +688,74 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/categories": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all conversation categories for the current user + * @description Required capability: `conversation-categories` + */ + get: operations["conversation_category-get-categories"]; + put?: never; + /** + * Create a new conversation category + * @description Required capability: `conversation-categories` + */ + post: operations["conversation_category-create-category"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/categories/{categoryId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update a conversation category + * @description Required capability: `conversation-categories` + */ + put: operations["conversation_category-update-category"]; + post?: never; + /** + * Delete a conversation category + * @description Required capability: `conversation-categories` + */ + delete: operations["conversation_category-delete-category"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/categories/reorder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Reorder conversation categories + * @description Required capability: `conversation-categories` + */ + put: operations["conversation_category-reorder-categories"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { parameters: { query?: never; @@ -1439,6 +1507,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/category": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Assign a conversation category + * @description Required capability: `conversation-categories` + */ + post: operations["room-assign-to-category"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/important": { parameters: { query?: never; @@ -2646,6 +2734,10 @@ export type components = { * @description Retention period for instant meetings in seconds, `0` means no retention */ "retention-instant-meetings": number; + /** @description User selected sort order for conversations */ + "sort-order": string; + /** @description User selected grouping mode for conversations */ + "group-mode": string; }; federation: { /** @description Whether federation is enabled */ @@ -2870,6 +2962,13 @@ export type components = { /** @description Conversation token */ roomToken: string; }; + ConversationCategory: { + /** @description SnowflakeID */ + id: string; + name: string; + /** Format: int64 */ + sortOrder: number; + }; ConversationPreset: { /** @description Identifier of the preset, currently known: default, forced, webinar, presentation, hallway */ identifier: string; @@ -3496,6 +3595,8 @@ export type components = { isImportant: boolean; /** @description Required capability: `sensitive-conversations` */ isSensitive: boolean; + /** @description IDs of the custom categories this conversation belongs to (only available with `conversation-categories` capability) */ + categoryIds: string[]; /** * Format: int64 * @description Required capability: `pinned-messages` @@ -7429,6 +7530,279 @@ export interface operations { }; }; }; + "conversation_category-get-categories": { + 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 Categories returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationCategory"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_category-create-category": { + 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 category */ + name: string; + }; + }; + }; + responses: { + /** @description Category created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationCategory"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_category-update-category": { + 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 category */ + categoryId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description New name for the category */ + name: string; + }; + }; + }; + responses: { + /** @description Category updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationCategory"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Category not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_category-delete-category": { + 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 category */ + categoryId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Category deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Category not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_category-reorder-categories": { + 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 category IDs */ + orderedIds: number[]; + }; + }; + }; + responses: { + /** @description Categories reordered */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationCategory"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "files_integration-get-room-by-file-id": { parameters: { query?: never; @@ -11126,6 +11500,61 @@ export interface operations { }; }; }; + "room-assign-to-category": { + 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 categories to assign (empty array to unassign all) + * @default [] + */ + categoryIds?: string[]; + }; + }; + }; + responses: { + /** @description Conversation categories 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 e1fa42d20f8..3b49566638b 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -688,6 +688,74 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/categories": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all conversation categories for the current user + * @description Required capability: `conversation-categories` + */ + get: operations["conversation_category-get-categories"]; + put?: never; + /** + * Create a new conversation category + * @description Required capability: `conversation-categories` + */ + post: operations["conversation_category-create-category"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/categories/{categoryId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update a conversation category + * @description Required capability: `conversation-categories` + */ + put: operations["conversation_category-update-category"]; + post?: never; + /** + * Delete a conversation category + * @description Required capability: `conversation-categories` + */ + delete: operations["conversation_category-delete-category"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/categories/reorder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Reorder conversation categories + * @description Required capability: `conversation-categories` + */ + put: operations["conversation_category-reorder-categories"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { parameters: { query?: never; @@ -1439,6 +1507,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/category": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Assign a conversation category + * @description Required capability: `conversation-categories` + */ + post: operations["room-assign-to-category"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/important": { parameters: { query?: never; @@ -2112,6 +2200,10 @@ export type components = { * @description Retention period for instant meetings in seconds, `0` means no retention */ "retention-instant-meetings": number; + /** @description User selected sort order for conversations */ + "sort-order": string; + /** @description User selected grouping mode for conversations */ + "group-mode": string; }; federation: { /** @description Whether federation is enabled */ @@ -2336,6 +2428,13 @@ export type components = { /** @description Conversation token */ roomToken: string; }; + ConversationCategory: { + /** @description SnowflakeID */ + id: string; + name: string; + /** Format: int64 */ + sortOrder: number; + }; ConversationPreset: { /** @description Identifier of the preset, currently known: default, forced, webinar, presentation, hallway */ identifier: string; @@ -2929,6 +3028,8 @@ export type components = { isImportant: boolean; /** @description Required capability: `sensitive-conversations` */ isSensitive: boolean; + /** @description IDs of the custom categories this conversation belongs to (only available with `conversation-categories` capability) */ + categoryIds: string[]; /** * Format: int64 * @description Required capability: `pinned-messages` @@ -6862,6 +6963,279 @@ export interface operations { }; }; }; + "conversation_category-get-categories": { + 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 Categories returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationCategory"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_category-create-category": { + 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 category */ + name: string; + }; + }; + }; + responses: { + /** @description Category created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationCategory"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_category-update-category": { + 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 category */ + categoryId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description New name for the category */ + name: string; + }; + }; + }; + responses: { + /** @description Category updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationCategory"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Category not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_category-delete-category": { + 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 category */ + categoryId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Category deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Category not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "conversation_category-reorder-categories": { + 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 category IDs */ + orderedIds: number[]; + }; + }; + }; + responses: { + /** @description Categories reordered */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ConversationCategory"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "files_integration-get-room-by-file-id": { parameters: { query?: never; @@ -10559,6 +10933,61 @@ export interface operations { }; }; }; + "room-assign-to-category": { + 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 categories to assign (empty array to unassign all) + * @default [] + */ + categoryIds?: string[]; + }; + }; + }; + responses: { + /** @description Conversation categories 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/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index 5370b7f7ca8..63a9f979164 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -100,6 +100,12 @@ public function testGetCapabilitiesGuest(): void { $this->talkConfig->method('getConversationsListStyle') ->willReturn('two-lines'); + $this->talkConfig->method('getConversationsSortOrder') + ->willReturn('activity'); + + $this->talkConfig->method('getConversationsGroupMode') + ->willReturn('none'); + $this->talkConfig->expects($this->once()) ->method('isBreakoutRoomsEnabled') ->willReturn(false); @@ -212,6 +218,8 @@ public function testGetCapabilitiesGuest(): void { 'can-create' => false, 'force-passwords' => false, 'list-style' => 'two-lines', + 'sort-order' => 'activity', + 'group-mode' => 'none', 'description-length' => 2000, 'retention-event' => 28, 'retention-phone' => 7, @@ -306,6 +314,12 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea $this->talkConfig->method('getConversationsListStyle') ->willReturn('two-lines'); + $this->talkConfig->method('getConversationsSortOrder') + ->willReturn('activity'); + + $this->talkConfig->method('getConversationsGroupMode') + ->willReturn('none'); + $this->talkConfig->expects($this->any()) ->method('getSignalingMode') ->willReturn('internal'); @@ -426,6 +440,8 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea 'can-create' => $canCreate, 'force-passwords' => false, 'list-style' => 'two-lines', + 'sort-order' => 'activity', + 'group-mode' => 'none', 'description-length' => 2000, 'retention-event' => 28, 'retention-phone' => 7, diff --git a/tests/php/Chat/ChatManagerTest.php b/tests/php/Chat/ChatManagerTest.php index f931b8c27b2..92f935bf893 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, + 'section_id' => 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, + 'section_id' => 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, + 'section_id' => null, 'has_unread_threads' => false, 'has_unread_thread_mentions' => false, 'has_unread_thread_directs' => false,