Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
39 changes: 39 additions & 0 deletions docs/sticker.md
Original file line number Diff line number Diff line change
@@ -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 |
4 changes: 4 additions & 0 deletions lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ class Capabilities implements IPublicCapability {
'announcement-preset',
'promote-demote-owner',
'matrix-rooms',
'stickers',
];

public const CONDITIONAL_FEATURES = [
Expand Down Expand Up @@ -179,6 +180,7 @@ class Capabilities implements IPublicCapability {
'classified-conversations',
'announcement-preset',
'matrix-rooms',
'stickers',
];

public const LOCAL_CONFIGS = [
Expand Down Expand Up @@ -207,6 +209,7 @@ class Capabilities implements IPublicCapability {
'summary-threshold',
'style',
'matterbridge-enabled',
'stickers-folder',
],
'conversations' => [
'can-create',
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 9 additions & 0 deletions lib/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions lib/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
56 changes: 56 additions & 0 deletions lib/Controller/StickerController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Talk\Controller;

use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Service\StickerService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;

/**
* @psalm-import-type TalkStickerPack from ResponseDefinitions
*/
class StickerController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private readonly IUserSession $userSession,
private readonly StickerService $stickerService,
) {
parent::__construct($appName, $request);
}

/**
* Get the sticker packs of the user
*
* 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.
*
* @return DataResponse<Http::STATUS_OK, list<TalkStickerPack>, 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);
}
}
24 changes: 24 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<TalkSticker>,
* }
*
* @psalm-type TalkThread = array{
* // Identifier of the thread
* id: positive-int,
Expand Down Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions lib/Service/StickerService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Talk\Service;

use OCA\Talk\Config;
use OCA\Talk\ResponseDefinitions;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;

/**
* @psalm-import-type TalkSticker from ResponseDefinitions
* @psalm-import-type TalkStickerPack from ResponseDefinitions
*/
class StickerService {
/**
* Images clients can show inline. No SVG, as it can carry scripts.
*/
public const array MIME_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];

public function __construct(
private readonly Config $talkConfig,
private readonly IRootFolder $rootFolder,
) {
}

/**
* The sticker packs the user can send: each subfolder of the stickers
* folder is a pack, images directly in the stickers folder form a pack of
* its own. Only images the user may share are listed, as sending a sticker
* shares its file with the conversation.
*
* @return list<TalkStickerPack>
*/
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<TalkSticker>
*/
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;
}
}
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 6 additions & 1 deletion openapi-administration.json
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,8 @@
"typing-privacy",
"summary-threshold",
"style",
"matterbridge-enabled"
"matterbridge-enabled",
"stickers-folder"
],
"properties": {
"max-length": {
Expand Down Expand Up @@ -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"
}
}
},
Expand Down
7 changes: 6 additions & 1 deletion openapi-backend-recording.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@
"typing-privacy",
"summary-threshold",
"style",
"matterbridge-enabled"
"matterbridge-enabled",
"stickers-folder"
],
"properties": {
"max-length": {
Expand Down Expand Up @@ -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"
}
}
},
Expand Down
7 changes: 6 additions & 1 deletion openapi-backend-signaling.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@
"typing-privacy",
"summary-threshold",
"style",
"matterbridge-enabled"
"matterbridge-enabled",
"stickers-folder"
],
"properties": {
"max-length": {
Expand Down Expand Up @@ -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"
}
}
},
Expand Down
Loading