diff --git a/docs/capabilities.md b/docs/capabilities.md index 07ba7c0b81d..ef08ab8edd5 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -243,3 +243,5 @@ ## 26 * `config => matrix => enabled` (local) - Whether the Matrix integration is enabled, so users can manage their linked Matrix account * `matrix-rooms` (local) - Whether Matrix accounts can be linked, see [Matrix integration](matrix.md) +* `stickers` (local) - Whether the sticker packs of the user can be listed, see [Sticker API](sticker.md) +* `config => chat => stickers-folder` (local) - Path of the folder with sticker packs, relative to the user's files; stickers are disabled when empty diff --git a/docs/settings.md b/docs/settings.md index dddff1f6dbe..25dc0d65efe 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -99,6 +99,7 @@ Legend: | `start_calls` | int | `0` | Yes | 🖌️ | Who can start a call, see [constants list](constants.md#start-call) | | `max_call_duration` | int | `0` | No | | Maximum duration of a call in seconds, 0 for unlimited. Federated calls will be terminated based on the setting of the host server. Calls are ended via a background job, so system cron should be used and calls will last a bit longer (until the next execution of the cron). | | `max_gif_size` | int | `3145728` | No | | Maximum file size for clients to render gifs previews with animation | +| `stickers_folder` | string | | No | | Path of a folder, relative to each user's files (e.g. a team folder), whose subfolders are sticker packs, see [Sticker API](sticker.md). Empty disables stickers | | `session_ping_limit` | int | `200` | No | | Number of sessions the HPB can ping in a single request | | `token_entropy` | int | `8` | No | | Length of conversation tokens, can be increased to make tokens harder to guess but reduces readability and dial-in comfort | | `default_group_notification` | int | `1` | No | 🖌️ | Default notification level for group conversations [constants list](constants.md#participant-notification-levels) (Default changed from 2 (mentions) to 1 (always) in Nextcloud 33 for new installations) | diff --git a/docs/sticker.md b/docs/sticker.md new file mode 100644 index 00000000000..8f6cd59a7fb --- /dev/null +++ b/docs/sticker.md @@ -0,0 +1,39 @@ +# Sticker API + +Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1`: since Nextcloud 36 + +Stickers are images in the subfolders of a folder the administration configures (app config +`stickers_folder`, e.g. a team folder `Stickers`): every subfolder is a sticker pack, images +directly in the folder form a pack of their own. A user sees the packs of that folder as it is in +their own files. A sticker is sent by sharing its file with the conversation, like any other file +(see [Share a file to the chat](chat.md#share-a-file-to-the-chat)). + +## Get the sticker packs + +* Required capability: `stickers` +* Method: `GET` +* Endpoint: `/stickers` + +* Response: + - Status code: + + `200 OK` + + - Data: + Array of sticker packs, empty when stickers are disabled or the user has no access to the + folder. Only PNG, JPEG, GIF and WebP images the user may share are listed. + +| field | type | Description | +|------------|--------|-------------------------------------| +| `id` | int | File id of the pack's folder | +| `name` | string | Name of the pack's folder | +| `stickers` | array | The pack's stickers, sorted by name | + +Each sticker: + +| field | type | Description | +|------------|--------|------------------------------------------------------------| +| `id` | int | File id of the image | +| `name` | string | File name without extension | +| `path` | string | Path of the file relative to the user's files, to share it | +| `mimetype` | string | Mime type of the image | +| `etag` | string | ETag of the file, to refresh cached previews | diff --git a/lib/Capabilities.php b/lib/Capabilities.php index d1b2118ec74..b4c798103ae 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -140,6 +140,7 @@ class Capabilities implements IPublicCapability { 'announcement-preset', 'promote-demote-owner', 'matrix-rooms', + 'stickers', ]; public const CONDITIONAL_FEATURES = [ @@ -179,6 +180,7 @@ class Capabilities implements IPublicCapability { 'classified-conversations', 'announcement-preset', 'matrix-rooms', + 'stickers', ]; public const LOCAL_CONFIGS = [ @@ -207,6 +209,7 @@ class Capabilities implements IPublicCapability { 'summary-threshold', 'style', 'matterbridge-enabled', + 'stickers-folder', ], 'conversations' => [ 'can-create', @@ -316,6 +319,7 @@ public function getCapabilities(): array { 'summary-threshold' => max(1, $this->appConfig->getAppValueInt(Config::SUMMARY_THRESHOLD)), 'style' => $this->talkConfig->getChatStyle($user?->getUID()), 'matterbridge-enabled' => $user instanceof IUser && $this->serverConfig->getAppValue('spreed', 'enable_matterbridge', '0') === '1', + 'stickers-folder' => $user instanceof IUser ? $this->talkConfig->getStickersFolder() : '', ], 'conversations' => [ 'can-create' => $user instanceof IUser && !$this->talkConfig->isNotAllowedToCreateConversations($user), diff --git a/lib/Config.php b/lib/Config.php index fa2ca1e1026..17729f2e219 100644 --- a/lib/Config.php +++ b/lib/Config.php @@ -85,6 +85,7 @@ class Config { public const string MATRIX_ALLOWED_GROUPS = 'matrix_allowed_groups'; public const string DELETE_ONE_TO_ONE_CONVERSATIONS = 'delete_one_to_one_conversations'; public const string MAX_GIF_SIZE = 'max_gif_size'; + public const string STICKERS_FOLDER = 'stickers_folder'; public const string CERTIFICATE_EXPIRATION_DAYS = 'certificate_expiration_days'; public const string TOKEN_ENTROPY = 'token_entropy'; public const string SUMMARY_THRESHOLD = 'summary_threshold'; @@ -380,6 +381,14 @@ public function getDefaultPermissions(): int { return Attendee::PERMISSIONS_MAX_DEFAULT & ~Attendee::PERMISSIONS_LOBBY_IGNORE; } + /** + * Path of the folder with sticker packs, relative to each user's files, + * without leading or trailing slashes. Empty when stickers are disabled. + */ + public function getStickersFolder(): string { + return trim($this->appConfig->getAppValueString(self::STICKERS_FOLDER), '/'); + } + public function getAttachmentFolder(string $userId): string { $defaultAttachmentFolder = $this->appConfig->getAppValueString(self::DEFAULT_ATTACHMENT_FOLDER); return $this->config->getUserValue($userId, 'spreed', UserPreference::ATTACHMENT_FOLDER, $defaultAttachmentFolder); diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index 9b0094d151d..62a9b1e99c9 100644 --- a/lib/ConfigLexicon.php +++ b/lib/ConfigLexicon.php @@ -193,6 +193,7 @@ public function getAppConfigs(): array { new Entry(Config::MATRIX_ALLOWED_GROUPS, ValueType::ARRAY, [], definition: 'List of group ids that are allowed to link a Matrix account (empty means everyone)'), new Entry(Config::DELETE_ONE_TO_ONE_CONVERSATIONS, ValueType::BOOL, false, definition: 'Whether one-to-one conversations can be left by either participant or should be deleted when one participant leaves'), new Entry(Config::MAX_GIF_SIZE, ValueType::INT, 3145728, definition: 'Maximum file size for clients to render gifs previews with animation', rename: 'max-gif-size'), + new Entry(Config::STICKERS_FOLDER, ValueType::STRING, '', definition: 'Path of a folder, relative to each user\'s files (e.g. a team folder), whose subfolders are sticker packs. Empty disables stickers'), new Entry(Config::CERTIFICATE_EXPIRATION_DAYS, ValueType::INT, 10, definition: 'Minimum days a certificate needs to be valid for, before an expiration notification will be shown. (default 10, minimum 0 and maximum 365)'), new Entry(Config::TOKEN_ENTROPY, ValueType::INT, 8, definition: 'Length of conversation tokens, can be increased to make tokens harder to guess but reduces readability and dial-in comfort'), new Entry(Config::SUMMARY_THRESHOLD, ValueType::INT, 100, definition: 'Amount of unread messages a user needs before they see the option to summarize with AI'), diff --git a/lib/Controller/StickerController.php b/lib/Controller/StickerController.php new file mode 100644 index 00000000000..191eaa20acb --- /dev/null +++ b/lib/Controller/StickerController.php @@ -0,0 +1,56 @@ + chat => stickers-folder` + * is empty) or the user has no access to the folder. + * + * @return DataResponse, array{}> + * + * 200: Sticker packs returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/stickers', requirements: [ + 'apiVersion' => '(v1)', + ])] + public function getStickerPacks(): DataResponse { + $user = $this->userSession->getUser(); + $packs = $user === null ? [] : $this->stickerService->getPacks($user->getUID()); + return new DataResponse($packs, Http::STATUS_OK); + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 405f52fc575..4f16001e250 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -761,6 +761,28 @@ * userId: ?string, * } * + * @psalm-type TalkSticker = array{ + * // File id of the sticker's image + * id: int, + * // File name without extension + * name: string, + * // Path of the file relative to the user's files, to share it with a conversation + * path: string, + * // Mime type of the image + * mimetype: string, + * // ETag of the file, to refresh cached previews + * etag: string, + * } + * + * @psalm-type TalkStickerPack = array{ + * // File id of the pack's folder + * id: int, + * // Name of the pack's folder + * name: string, + * // Stickers of the pack, sorted by name + * stickers: non-empty-list, + * } + * * @psalm-type TalkThread = array{ * // Identifier of the thread * id: positive-int, @@ -871,6 +893,8 @@ * style: 'split'|'unified', * // Whether Matterbridge is enabled * matterbridge-enabled: bool, + * // Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled + * stickers-folder: string, * }, * conversations: array{ * // Whether the user can create conversations diff --git a/lib/Service/StickerService.php b/lib/Service/StickerService.php new file mode 100644 index 00000000000..b0398551efa --- /dev/null +++ b/lib/Service/StickerService.php @@ -0,0 +1,101 @@ + + */ + public function getPacks(string $userId): array { + $path = $this->talkConfig->getStickersFolder(); + if ($path === '') { + return []; + } + + try { + $userFolder = $this->rootFolder->getUserFolder($userId); + $root = $userFolder->get($path); + } catch (NotFoundException|NotPermittedException) { + return []; + } + if (!$root instanceof Folder) { + return []; + } + + $folders = [$root]; + $subfolders = array_filter($root->getDirectoryListing(), static fn (Node $node): bool => $node instanceof Folder); + usort($subfolders, static fn (Node $a, Node $b): int => strnatcasecmp($a->getName(), $b->getName())); + array_push($folders, ...$subfolders); + + $packs = []; + foreach ($folders as $folder) { + $stickers = $this->getStickers($userFolder, $folder); + if ($stickers !== []) { + $packs[] = [ + 'id' => $folder->getId(), + 'name' => $folder->getName(), + 'stickers' => $stickers, + ]; + } + } + return $packs; + } + + /** + * @return list + */ + protected function getStickers(Folder $userFolder, Folder $folder): array { + $stickers = []; + foreach ($folder->getDirectoryListing() as $node) { + if (!$node instanceof File + || !in_array($node->getMimetype(), self::MIME_TYPES, true) + || !$node->isShareable()) { + continue; + } + $stickers[] = [ + 'id' => $node->getId(), + 'name' => pathinfo($node->getName(), PATHINFO_FILENAME), + 'path' => $userFolder->getRelativePath($node->getPath()) ?? '', + 'mimetype' => $node->getMimetype(), + 'etag' => $node->getEtag(), + ]; + } + usort($stickers, static fn (array $a, array $b): int => strnatcasecmp($a['name'], $b['name'])); + return $stickers; + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 83b990e00cc..c8351cd3701 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ nav: - 'Chat management': 'chat.md' - 'Reaction management': 'reaction.md' - 'Poll management': 'poll.md' + - 'Sticker management': 'sticker.md' - 'Breakout rooms management': 'breakout-rooms.md' - 'Bots management': 'bot-management.md' - 'Integration by other apps': 'integration.md' diff --git a/openapi-administration.json b/openapi-administration.json index dad5b92f4f6..472b5d2b027 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -303,7 +303,8 @@ "typing-privacy", "summary-threshold", "style", - "matterbridge-enabled" + "matterbridge-enabled", + "stickers-folder" ], "properties": { "max-length": { @@ -346,6 +347,10 @@ "matterbridge-enabled": { "type": "boolean", "description": "Whether Matterbridge is enabled" + }, + "stickers-folder": { + "type": "string", + "description": "Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled" } } }, diff --git a/openapi-backend-recording.json b/openapi-backend-recording.json index dd4533338d3..d428549d155 100644 --- a/openapi-backend-recording.json +++ b/openapi-backend-recording.json @@ -226,7 +226,8 @@ "typing-privacy", "summary-threshold", "style", - "matterbridge-enabled" + "matterbridge-enabled", + "stickers-folder" ], "properties": { "max-length": { @@ -269,6 +270,10 @@ "matterbridge-enabled": { "type": "boolean", "description": "Whether Matterbridge is enabled" + }, + "stickers-folder": { + "type": "string", + "description": "Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled" } } }, diff --git a/openapi-backend-signaling.json b/openapi-backend-signaling.json index 8137be6355d..6505c10d272 100644 --- a/openapi-backend-signaling.json +++ b/openapi-backend-signaling.json @@ -226,7 +226,8 @@ "typing-privacy", "summary-threshold", "style", - "matterbridge-enabled" + "matterbridge-enabled", + "stickers-folder" ], "properties": { "max-length": { @@ -269,6 +270,10 @@ "matterbridge-enabled": { "type": "boolean", "description": "Whether Matterbridge is enabled" + }, + "stickers-folder": { + "type": "string", + "description": "Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled" } } }, diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index c4add839151..e0681d4ddec 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -277,7 +277,8 @@ "typing-privacy", "summary-threshold", "style", - "matterbridge-enabled" + "matterbridge-enabled", + "stickers-folder" ], "properties": { "max-length": { @@ -320,6 +321,10 @@ "matterbridge-enabled": { "type": "boolean", "description": "Whether Matterbridge is enabled" + }, + "stickers-folder": { + "type": "string", + "description": "Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled" } } }, diff --git a/openapi-bots.json b/openapi-bots.json index 87beff7d3b0..5e79d632c62 100644 --- a/openapi-bots.json +++ b/openapi-bots.json @@ -239,7 +239,8 @@ "typing-privacy", "summary-threshold", "style", - "matterbridge-enabled" + "matterbridge-enabled", + "stickers-folder" ], "properties": { "max-length": { @@ -282,6 +283,10 @@ "matterbridge-enabled": { "type": "boolean", "description": "Whether Matterbridge is enabled" + }, + "stickers-folder": { + "type": "string", + "description": "Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled" } } }, diff --git a/openapi-federation.json b/openapi-federation.json index cbac7202e4c..2e83e583483 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -277,7 +277,8 @@ "typing-privacy", "summary-threshold", "style", - "matterbridge-enabled" + "matterbridge-enabled", + "stickers-folder" ], "properties": { "max-length": { @@ -320,6 +321,10 @@ "matterbridge-enabled": { "type": "boolean", "description": "Whether Matterbridge is enabled" + }, + "stickers-folder": { + "type": "string", + "description": "Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled" } } }, diff --git a/openapi-full.json b/openapi-full.json index 5f3efc89ba4..7cb8934bd22 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -473,7 +473,8 @@ "typing-privacy", "summary-threshold", "style", - "matterbridge-enabled" + "matterbridge-enabled", + "stickers-folder" ], "properties": { "max-length": { @@ -516,6 +517,10 @@ "matterbridge-enabled": { "type": "boolean", "description": "Whether Matterbridge is enabled" + }, + "stickers-folder": { + "type": "string", + "description": "Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled" } } }, @@ -2854,6 +2859,66 @@ } } }, + "Sticker": { + "type": "object", + "required": [ + "id", + "name", + "path", + "mimetype", + "etag" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "File id of the sticker's image" + }, + "name": { + "type": "string", + "description": "File name without extension" + }, + "path": { + "type": "string", + "description": "Path of the file relative to the user's files, to share it with a conversation" + }, + "mimetype": { + "type": "string", + "description": "Mime type of the image" + }, + "etag": { + "type": "string", + "description": "ETag of the file, to refresh cached previews" + } + } + }, + "StickerPack": { + "type": "object", + "required": [ + "id", + "name", + "stickers" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "File id of the pack's folder" + }, + "name": { + "type": "string", + "description": "Name of the pack's folder" + }, + "stickers": { + "type": "array", + "description": "Stickers of the pack, sorted by name", + "items": { + "$ref": "#/components/schemas/Sticker" + }, + "minItems": 1 + } + } + }, "Thread": { "type": "object", "required": [ @@ -33137,6 +33202,111 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/stickers": { + "get": { + "operationId": "sticker-get-sticker-packs", + "summary": "Get the sticker packs of the user", + "description": "Required capability: `stickers`\nA sticker is sent by sharing its file with the conversation. The list is empty when stickers are disabled (`config => chat => stickers-folder` is empty) or the user has no access to the folder.", + "tags": [ + "sticker" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "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": "Sticker packs returned", + "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/StickerPack" + } + } + } + } + } + } + } + } + }, + "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/temp-user-avatar": { "post": { "operationId": "temp_avatar-post-avatar", diff --git a/openapi.json b/openapi.json index bd7c412f757..e850ef057af 100644 --- a/openapi.json +++ b/openapi.json @@ -413,7 +413,8 @@ "typing-privacy", "summary-threshold", "style", - "matterbridge-enabled" + "matterbridge-enabled", + "stickers-folder" ], "properties": { "max-length": { @@ -456,6 +457,10 @@ "matterbridge-enabled": { "type": "boolean", "description": "Whether Matterbridge is enabled" + }, + "stickers-folder": { + "type": "string", + "description": "Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled" } } }, @@ -2729,6 +2734,66 @@ } } }, + "Sticker": { + "type": "object", + "required": [ + "id", + "name", + "path", + "mimetype", + "etag" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "File id of the sticker's image" + }, + "name": { + "type": "string", + "description": "File name without extension" + }, + "path": { + "type": "string", + "description": "Path of the file relative to the user's files, to share it with a conversation" + }, + "mimetype": { + "type": "string", + "description": "Mime type of the image" + }, + "etag": { + "type": "string", + "description": "ETag of the file, to refresh cached previews" + } + } + }, + "StickerPack": { + "type": "object", + "required": [ + "id", + "name", + "stickers" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "File id of the pack's folder" + }, + "name": { + "type": "string", + "description": "Name of the pack's folder" + }, + "stickers": { + "type": "array", + "description": "Stickers of the pack, sorted by name", + "items": { + "$ref": "#/components/schemas/Sticker" + }, + "minItems": 1 + } + } + }, "Thread": { "type": "object", "required": [ @@ -33012,6 +33077,111 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/stickers": { + "get": { + "operationId": "sticker-get-sticker-packs", + "summary": "Get the sticker packs of the user", + "description": "Required capability: `stickers`\nA sticker is sent by sharing its file with the conversation. The list is empty when stickers are disabled (`config => chat => stickers-folder` is empty) or the user has no access to the folder.", + "tags": [ + "sticker" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "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": "Sticker packs returned", + "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/StickerPack" + } + } + } + } + } + } + } + } + }, + "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/temp-user-avatar": { "post": { "operationId": "temp_avatar-post-avatar", diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index ba840d695be..6bf3072a053 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -181,6 +181,7 @@ export const mockedCapabilities: Capabilities = { 'summary-threshold': 100, style: CHAT_STYLE.SPLIT, 'matterbridge-enabled': false, + 'stickers-folder': '', }, conversations: { 'can-create': true, diff --git a/src/components/NewMessage/NewMessage.vue b/src/components/NewMessage/NewMessage.vue index 9b6e449b545..2c94611f9cc 100644 --- a/src/components/NewMessage/NewMessage.vue +++ b/src/components/NewMessage/NewMessage.vue @@ -153,6 +153,12 @@ @submit="handleSubmit" /> + + ', +} + +describe('NewMessageStickerPicker.vue', () => { + const packs = [ + { + id: 10, + name: 'Cats', + stickers: [ + { id: 11, name: 'angry', path: '/Stickers/Cats/angry.png', mimetype: 'image/png', etag: 'e11' }, + { id: 12, name: 'sleepy', path: '/Stickers/Cats/sleepy.gif', mimetype: 'image/gif', etag: 'e12' }, + ], + }, + { + id: 20, + name: 'Dogs', + stickers: [{ id: 21, name: 'wag', path: '/Stickers/Dogs/wag.webp', mimetype: 'image/webp', etag: 'e21' }], + }, + ] + let stickersStore + + beforeEach(() => { + setActivePinia(createPinia()) + stickersStore = useStickersStore() + stickersStore.fetchPacks = vi.fn() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + /** + * Mount the picker as opened + */ + async function mountOpened() { + const wrapper = mount(NewMessageStickerPicker, { + props: { folder: 'Stickers' }, + global: { stubs: { NcPopover } }, + }) + await wrapper.findComponent(NcPopover).vm.$emit('update:shown', true) + return wrapper + } + + it('fetches the packs when opened', async () => { + await mountOpened() + + expect(stickersStore.fetchPacks).toHaveBeenCalledTimes(1) + }) + + it('names the folder when there are no stickers', async () => { + stickersStore.loaded = true + + const wrapper = await mountOpened() + + expect(wrapper.text()).toContain('No stickers') + expect(wrapper.text()).toContain('Stickers') + }) + + it('shows the first pack and switches to another one', async () => { + stickersStore.packs = packs + stickersStore.loaded = true + + const wrapper = await mountOpened() + + expect(wrapper.findAll('.sticker-picker__sticker').map((button) => button.attributes('aria-label'))).toEqual(['angry', 'sleepy']) + expect(wrapper.find('img').attributes('src')).toContain('fileId=11') + + await wrapper.findAll('[role="tab"]')[1].trigger('click') + + expect(wrapper.findAll('.sticker-picker__sticker').map((button) => button.attributes('aria-label'))).toEqual(['wag']) + }) + + it('emits the selected sticker and closes', async () => { + stickersStore.packs = packs + stickersStore.loaded = true + + const wrapper = await mountOpened() + await wrapper.findAll('.sticker-picker__sticker')[1].trigger('click') + + expect(wrapper.emitted('select')).toEqual([[packs[0].stickers[1]]]) + expect(wrapper.findComponent(NcPopover).props('shown')).toBe(false) + }) +}) diff --git a/src/components/NewMessage/NewMessageStickerPicker.vue b/src/components/NewMessage/NewMessageStickerPicker.vue new file mode 100644 index 00000000000..4bee1ee5235 --- /dev/null +++ b/src/components/NewMessage/NewMessageStickerPicker.vue @@ -0,0 +1,170 @@ + + + + + + + diff --git a/src/services/stickersService.ts b/src/services/stickersService.ts new file mode 100644 index 00000000000..965506d11d9 --- /dev/null +++ b/src/services/stickersService.ts @@ -0,0 +1,20 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { getStickerPacksResponse } from '../types/index.ts' + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +/** + * Fetch the sticker packs of the current user + */ +async function getStickerPacks(): getStickerPacksResponse { + return axios.get(generateOcsUrl('apps/spreed/api/v1/stickers')) +} + +export { + getStickerPacks, +} diff --git a/src/stores/__tests__/stickers.spec.js b/src/stores/__tests__/stickers.spec.js new file mode 100644 index 00000000000..f3a922d8ad5 --- /dev/null +++ b/src/stores/__tests__/stickers.spec.js @@ -0,0 +1,66 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { showError } from '@nextcloud/dialogs' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getStickerPacks } from '../../services/stickersService.ts' +import { generateOCSResponse } from '../../test-helpers.js' +import { useStickersStore } from '../stickers.ts' + +vi.mock('../../services/stickersService.ts', () => ({ + getStickerPacks: vi.fn(), +})) + +describe('stickersStore', () => { + const packs = [ + { + id: 10, + name: 'Cats', + stickers: [{ id: 11, name: 'sleepy', path: '/Stickers/Cats/sleepy.gif', mimetype: 'image/gif', etag: 'abc' }], + }, + ] + let stickersStore + + beforeEach(() => { + setActivePinia(createPinia()) + stickersStore = useStickersStore() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('stores the fetched packs', async () => { + getStickerPacks.mockResolvedValueOnce(generateOCSResponse({ payload: packs })) + + await stickersStore.fetchPacks() + + expect(stickersStore.packs).toEqual(packs) + expect(stickersStore.loaded).toBe(true) + expect(stickersStore.loading).toBe(false) + }) + + it('fetches only once at a time', async () => { + getStickerPacks.mockResolvedValueOnce(generateOCSResponse({ payload: packs })) + + await Promise.all([stickersStore.fetchPacks(), stickersStore.fetchPacks()]) + + expect(getStickerPacks).toHaveBeenCalledTimes(1) + }) + + it('keeps the packs it has and shows an error when fetching fails', async () => { + console.error = vi.fn() + getStickerPacks.mockResolvedValueOnce(generateOCSResponse({ payload: packs })) + await stickersStore.fetchPacks() + getStickerPacks.mockRejectedValueOnce(new Error('offline')) + + await stickersStore.fetchPacks() + + expect(showError).toHaveBeenCalled() + expect(stickersStore.packs).toEqual(packs) + expect(stickersStore.loading).toBe(false) + }) +}) diff --git a/src/stores/stickers.ts b/src/stores/stickers.ts new file mode 100644 index 00000000000..431f6804a6a --- /dev/null +++ b/src/stores/stickers.ts @@ -0,0 +1,46 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { StickerPack } from '../types/index.ts' + +import { showError } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { getStickerPacks } from '../services/stickersService.ts' + +export const useStickersStore = defineStore('stickers', () => { + const packs = ref([]) + const loaded = ref(false) + const loading = ref(false) + + /** + * Fetch the sticker packs of the current user. Packs fetched before stay + * shown meanwhile, so the picker opens without waiting. + */ + async function fetchPacks() { + if (loading.value) { + return + } + loading.value = true + try { + const response = await getStickerPacks() + packs.value = response.data.ocs.data + loaded.value = true + } catch (error) { + console.error('Failed to fetch sticker packs:', error) + showError(t('spreed', 'Failed to load stickers')) + } finally { + loading.value = false + } + } + + return { + packs, + loaded, + loading, + fetchPacks, + } +}) diff --git a/src/types/index.ts b/src/types/index.ts index 6f76a4215c1..50319c49923 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -654,6 +654,11 @@ export type { AppConfigSetValueResponse, } from './core.ts' +// Stickers +export type Sticker = components['schemas']['Sticker'] +export type StickerPack = components['schemas']['StickerPack'] +export type getStickerPacksResponse = ApiResponse + // Presets export type ConversationPreset = components['schemas']['ConversationPreset'] export type getPresetsResponse = ApiResponse diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index c6dfb72bfc2..3b350695b7d 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -399,6 +399,8 @@ export type components = { style: "split" | "unified"; /** @description Whether Matterbridge is enabled */ "matterbridge-enabled": boolean; + /** @description Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled */ + "stickers-folder": string; }; conversations: { /** @description Whether the user can create conversations */ diff --git a/src/types/openapi/openapi-backend-recording.ts b/src/types/openapi/openapi-backend-recording.ts index 61b57221dfa..c18240289cb 100644 --- a/src/types/openapi/openapi-backend-recording.ts +++ b/src/types/openapi/openapi-backend-recording.ts @@ -173,6 +173,8 @@ export type components = { style: "split" | "unified"; /** @description Whether Matterbridge is enabled */ "matterbridge-enabled": boolean; + /** @description Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled */ + "stickers-folder": string; }; conversations: { /** @description Whether the user can create conversations */ diff --git a/src/types/openapi/openapi-backend-signaling.ts b/src/types/openapi/openapi-backend-signaling.ts index dd5610daa34..28347afd3f2 100644 --- a/src/types/openapi/openapi-backend-signaling.ts +++ b/src/types/openapi/openapi-backend-signaling.ts @@ -131,6 +131,8 @@ export type components = { style: "split" | "unified"; /** @description Whether Matterbridge is enabled */ "matterbridge-enabled": boolean; + /** @description Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled */ + "stickers-folder": string; }; conversations: { /** @description Whether the user can create conversations */ diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index c2b8ce93c60..bb35f3f14c6 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -256,6 +256,8 @@ export type components = { style: "split" | "unified"; /** @description Whether Matterbridge is enabled */ "matterbridge-enabled": boolean; + /** @description Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled */ + "stickers-folder": string; }; conversations: { /** @description Whether the user can create conversations */ diff --git a/src/types/openapi/openapi-bots.ts b/src/types/openapi/openapi-bots.ts index c8c0024f48c..d07bcaa8e1c 100644 --- a/src/types/openapi/openapi-bots.ts +++ b/src/types/openapi/openapi-bots.ts @@ -177,6 +177,8 @@ export type components = { style: "split" | "unified"; /** @description Whether Matterbridge is enabled */ "matterbridge-enabled": boolean; + /** @description Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled */ + "stickers-folder": string; }; conversations: { /** @description Whether the user can create conversations */ diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index 0e67ea911c7..3d0e4acd265 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -267,6 +267,8 @@ export type components = { style: "split" | "unified"; /** @description Whether Matterbridge is enabled */ "matterbridge-enabled": boolean; + /** @description Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled */ + "stickers-folder": string; }; conversations: { /** @description Whether the user can create conversations */ diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 29ae1fa1124..b3b637f18a3 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -2149,6 +2149,27 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/stickers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the sticker packs of the user + * @description Required capability: `stickers` + * A sticker is sent by sharing its file with the conversation. The list is empty when stickers are disabled (`config => chat => stickers-folder` is empty) or the user has no access to the folder. + */ + get: operations["sticker-get-sticker-packs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/temp-user-avatar": { parameters: { query?: never; @@ -3100,6 +3121,8 @@ export type components = { style: "split" | "unified"; /** @description Whether Matterbridge is enabled */ "matterbridge-enabled": boolean; + /** @description Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled */ + "stickers-folder": string; }; conversations: { /** @description Whether the user can create conversations */ @@ -4230,6 +4253,32 @@ export type components = { /** @description User id of the current user */ userId: string | null; }; + Sticker: { + /** + * Format: int64 + * @description File id of the sticker's image + */ + id: number; + /** @description File name without extension */ + name: string; + /** @description Path of the file relative to the user's files, to share it with a conversation */ + path: string; + /** @description Mime type of the image */ + mimetype: string; + /** @description ETag of the file, to refresh cached previews */ + etag: string; + }; + StickerPack: { + /** + * Format: int64 + * @description File id of the pack's folder + */ + id: number; + /** @description Name of the pack's folder */ + name: string; + /** @description Stickers of the pack, sorted by name */ + stickers: components["schemas"]["Sticker"][]; + }; Thread: { /** * Format: int64 @@ -16259,6 +16308,50 @@ export interface operations { }; }; }; + "sticker-get-sticker-packs": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sticker packs returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["StickerPack"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "temp_avatar-post-avatar": { parameters: { query?: never; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 0f52ae3fae1..428f6cdb0c5 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -2149,6 +2149,27 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/stickers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the sticker packs of the user + * @description Required capability: `stickers` + * A sticker is sent by sharing its file with the conversation. The list is empty when stickers are disabled (`config => chat => stickers-folder` is empty) or the user has no access to the folder. + */ + get: operations["sticker-get-sticker-packs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/temp-user-avatar": { parameters: { query?: never; @@ -2442,6 +2463,8 @@ export type components = { style: "split" | "unified"; /** @description Whether Matterbridge is enabled */ "matterbridge-enabled": boolean; + /** @description Path of the folder with sticker packs relative to the user's files, empty when stickers are disabled */ + "stickers-folder": string; }; conversations: { /** @description Whether the user can create conversations */ @@ -3539,6 +3562,32 @@ export type components = { /** @description User id of the current user */ userId: string | null; }; + Sticker: { + /** + * Format: int64 + * @description File id of the sticker's image + */ + id: number; + /** @description File name without extension */ + name: string; + /** @description Path of the file relative to the user's files, to share it with a conversation */ + path: string; + /** @description Mime type of the image */ + mimetype: string; + /** @description ETag of the file, to refresh cached previews */ + etag: string; + }; + StickerPack: { + /** + * Format: int64 + * @description File id of the pack's folder + */ + id: number; + /** @description Name of the pack's folder */ + name: string; + /** @description Stickers of the pack, sorted by name */ + stickers: components["schemas"]["Sticker"][]; + }; Thread: { /** * Format: int64 @@ -15568,6 +15617,50 @@ export interface operations { }; }; }; + "sticker-get-sticker-packs": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sticker packs returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["StickerPack"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "temp_avatar-post-avatar": { parameters: { query?: never; diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index 768025f9d58..212d00b5e4a 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -230,6 +230,7 @@ public function testGetCapabilitiesGuest(): void { 'summary-threshold' => 100, 'style' => 'split', 'matterbridge-enabled' => false, + 'stickers-folder' => '', ], 'conversations' => [ 'can-create' => false, @@ -473,6 +474,7 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea 'summary-threshold' => 100, 'style' => 'split', 'matterbridge-enabled' => false, + 'stickers-folder' => '', ], 'conversations' => [ 'can-create' => $canCreate, diff --git a/tests/php/Service/StickerServiceTest.php b/tests/php/Service/StickerServiceTest.php new file mode 100644 index 00000000000..178c18ae985 --- /dev/null +++ b/tests/php/Service/StickerServiceTest.php @@ -0,0 +1,130 @@ +talkConfig = $this->createMock(Config::class); + $this->rootFolder = $this->createMock(IRootFolder::class); + $this->userFolder = $this->createMock(IUserFolder::class); + $this->userFolder->method('getRelativePath') + ->willReturnCallback(static fn (string $path): string => substr($path, strlen('/alice/files'))); + $this->rootFolder->method('getUserFolder') + ->with('alice') + ->willReturn($this->userFolder); + + $this->service = new StickerService($this->talkConfig, $this->rootFolder); + } + + protected function file(int $id, string $path, string $mimetype, bool $shareable = true): File&MockObject { + $file = $this->createMock(File::class); + $file->method('getId')->willReturn($id); + $file->method('getName')->willReturn(basename($path)); + $file->method('getPath')->willReturn('/alice/files' . $path); + $file->method('getMimetype')->willReturn($mimetype); + $file->method('isShareable')->willReturn($shareable); + $file->method('getEtag')->willReturn('etag' . $id); + return $file; + } + + /** + * @param list $children + */ + protected function folder(int $id, string $name, array $children): Folder&MockObject { + $folder = $this->createMock(Folder::class); + $folder->method('getId')->willReturn($id); + $folder->method('getName')->willReturn($name); + $folder->method('getDirectoryListing')->willReturn($children); + return $folder; + } + + public function testDisabledWithoutFolder(): void { + $this->talkConfig->method('getStickersFolder')->willReturn(''); + $this->rootFolder->expects($this->never())->method('getUserFolder'); + + $this->assertSame([], $this->service->getPacks('alice')); + } + + public function testNoPacksWithoutAccessToTheFolder(): void { + $this->talkConfig->method('getStickersFolder')->willReturn('Stickers'); + $this->userFolder->method('get') + ->with('Stickers') + ->willThrowException(new NotFoundException()); + + $this->assertSame([], $this->service->getPacks('alice')); + } + + public function testNoPacksWhenThePathIsAFile(): void { + $this->talkConfig->method('getStickersFolder')->willReturn('Stickers'); + $this->userFolder->method('get') + ->willReturn($this->file(1, '/Stickers', 'image/png')); + + $this->assertSame([], $this->service->getPacks('alice')); + } + + public function testPacksFromSubfolders(): void { + $this->talkConfig->method('getStickersFolder')->willReturn('Stickers'); + $cats = $this->folder(20, 'cats', [ + $this->file(22, '/Stickers/cats/sleepy.gif', 'image/gif'), + $this->file(21, '/Stickers/cats/Angry.webp', 'image/webp'), + ]); + $dogs = $this->folder(30, 'Dogs', [ + // Not an image clients show inline + $this->file(31, '/Stickers/Dogs/bark.svg', 'image/svg+xml'), + // Not shareable by the user + $this->file(32, '/Stickers/Dogs/wag.png', 'image/png', false), + // Packs do not nest + $this->folder(33, 'puppies', [$this->file(34, '/Stickers/Dogs/puppies/a.png', 'image/png')]), + ]); + $root = $this->folder(10, 'Stickers', [ + $dogs, + $this->file(12, '/Stickers/wave.jpg', 'image/jpeg'), + $cats, + $this->file(11, '/Stickers/notes.txt', 'text/plain'), + ]); + $this->userFolder->method('get') + ->with('Stickers') + ->willReturn($root); + + $this->assertSame([ + [ + 'id' => 10, + 'name' => 'Stickers', + 'stickers' => [ + ['id' => 12, 'name' => 'wave', 'path' => '/Stickers/wave.jpg', 'mimetype' => 'image/jpeg', 'etag' => 'etag12'], + ], + ], + [ + 'id' => 20, + 'name' => 'cats', + 'stickers' => [ + ['id' => 21, 'name' => 'Angry', 'path' => '/Stickers/cats/Angry.webp', 'mimetype' => 'image/webp', 'etag' => 'etag21'], + ['id' => 22, 'name' => 'sleepy', 'path' => '/Stickers/cats/sleepy.gif', 'mimetype' => 'image/gif', 'etag' => 'etag22'], + ], + ], + ], $this->service->getPacks('alice')); + } +}