Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
21dd869
feat(permissions): Split chat and reaction permissions
luflow Jan 23, 2026
9dd89cf
docs: Update API documentation for REACT permission
luflow Jan 23, 2026
b97da99
docs: Apply suggestion from @nickvergessen
luflow Jan 26, 2026
0e93210
feat(capabilities): Add react-permission capability
luflow Jan 26, 2026
9e86644
feat(frontend): Add federation fallback for react permission
luflow Jan 26, 2026
cac5bef
test(integration): Update expected participantPermissions to 510
luflow Jan 26, 2026
7281e29
docs: Add react-permission integration guide for clients
luflow Jan 26, 2026
bf54ce9
feat(capabilities): Expose permissions max values via API
luflow Jan 28, 2026
ea9dcfd
feat(frontend): Add capability-aware permissions UI with API fallback
luflow Jan 28, 2026
6ed5f02
docs: Document permissions config and improve variable naming
luflow Jan 28, 2026
2e1cf39
fix(openapi): Update permissions max value to 511
luflow Jan 28, 2026
e13ec1c
fix: correct handling of disabled states if canPostMessages is not al…
luflow Jan 28, 2026
6603740
fix: Correct disabled behavior for audio recorder button
luflow Jan 28, 2026
11cc212
fix: Address PR review feedback for permissions feature
luflow Jan 29, 2026
6f5f5d2
test: Skip federation permissions test for older server versions
luflow Jan 29, 2026
1e4d959
fix(permissions): Regenerate OpenAPI specs and update CapabilitiesTest
luflow Jan 29, 2026
cf3e25a
fix(permissions): Add permissions to LOCAL_CONFIGS array
luflow Jan 29, 2026
bbbc22d
fix(permissions): Allow empty list in config-local type definition
luflow Jan 29, 2026
b6790b1
fix(permissions): Regenerate OpenAPI specs after type change
luflow Jan 29, 2026
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
5 changes: 5 additions & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,8 @@
* `scheduled-messages` (local) - Whether a user can schedule messages
* `config => call => live-translation` - Whether live translation is supported in calls
* `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
* `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)
3 changes: 2 additions & 1 deletion docs/constants.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@
* `16` Can publish audio stream
* `32` Can publish video stream
* `64` Can publish screen sharing stream
* `128` Can post chat message, share items and do reactions
* `128` Can post chat message and share items
* `256` Can add reactions (New in Nextcloud 34, previously handled by the chat permission)

### Attendee permission modifications
* `set` - Setting this permission set.
Expand Down
56 changes: 56 additions & 0 deletions docs/react-permission-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# React Permission Integration Guide

This document describes the changes needed for frontend clients to support the new separate reaction permission introduced in Talk 24 (Nextcloud 34).

## Background

Previously, the CHAT permission (128) controlled both posting messages and adding reactions. This has been split into two separate permissions:

- **CHAT (128)**: Post messages and share items
- **REACT (256)**: Add/remove reactions

## New Capability

A new capability `react-permission` indicates server support for the separate reaction permission.

```
features: [..., "react-permission", ...]
```

## Permission Constants

```
PERMISSIONS_REACT = 256
PERMISSIONS_MAX_DEFAULT = 510 // was 254
PERMISSIONS_MAX_CUSTOM = 511 // was 255
```

## Implementation

### Checking if user can react

```javascript
const hasReactPermissionCapability = capabilities.features.includes('react-permission')

const permissionToCheck = hasReactPermissionCapability
? PERMISSIONS_REACT // 256
: PERMISSIONS_CHAT // 128 (fallback for older servers)

const canReact = (participant.permissions & permissionToCheck) !== 0
```

### Why the fallback is needed

When federating with older Nextcloud servers or when the desktop client connects to an older server, the `react-permission` capability won't be present. In this case, clients should fall back to checking the CHAT permission, as that's what controlled reactions before this change.

## Migration

The backend migration automatically grants the REACT permission to all users who previously had the CHAT permission, ensuring backward compatibility.

## UI Changes

The permissions editor should show two separate checkboxes:
- "Can post messages" (CHAT - 128)
- "Can add reactions" (REACT - 256)

Instead of the previous combined "Can post messages and reactions" checkbox.
4 changes: 3 additions & 1 deletion docs/reaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1`: since Nextcloud 24
+ `200 OK` Reaction already exists
+ `201 Created` User reacted with a new reaction
+ `400 Bad Request` In case of no reaction support, message out of reactions context or any other error
+ `403 Forbidden` When the participant does not have the required permission to react (see attendee permission `256`)
+ `404 Not Found` When the conversation or message to react could not be found for the participant

- Data:
Expand All @@ -45,8 +46,9 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1`: since Nextcloud 24

* Response:
- Status code:
+ `201 Created`
+ `200 OK`
+ `400 Bad Request` In case of no reaction support, message out of reactions context or any other error
+ `403 Forbidden` When the participant does not have the required permission to react (see attendee permission `256`)
+ `404 Not Found` When the conversation or message to react or reaction could not be found for the participant

- Data:
Expand Down
8 changes: 8 additions & 0 deletions lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
namespace OCA\Talk;

use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Service\LiveTranscriptionService;
use OCP\App\IAppManager;
use OCP\AppFramework\Services\IAppConfig;
Expand Down Expand Up @@ -78,6 +79,7 @@ class Capabilities implements IPublicCapability {
'rich-object-delete',
'unified-search',
'chat-permission',
'react-permission',
'silent-send',
'silent-call',
'send-call-notification',
Expand Down Expand Up @@ -202,6 +204,8 @@ class Capabilities implements IPublicCapability {
'experiments' => [
'enabled',
],
'permissions' => [
],
];

protected ICache $talkCache;
Expand Down Expand Up @@ -294,6 +298,10 @@ public function getCapabilities(): array {
'experiments' => [
'enabled' => max(0, $this->appConfig->getAppValueInt($user instanceof IUser ? 'experiments_users' : 'experiments_guests')),
],
'permissions' => [
'max-default' => Attendee::PERMISSIONS_MAX_DEFAULT,
'max-custom' => Attendee::PERMISSIONS_MAX_CUSTOM,
],
],
'config-local' => self::LOCAL_CONFIGS,
'version' => $this->appManager->getAppVersion('spreed'),
Expand Down
2 changes: 1 addition & 1 deletion lib/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ public function isNotAllowedToCreateConversations(IUser $user): bool {
}

/**
* @return int<0, 255>
* @return int<0, 511>
* @psalm-return int-mask-of<Attendee::PERMISSIONS_*>
*/
public function getDefaultPermissions(): int {
Expand Down
4 changes: 2 additions & 2 deletions lib/Controller/ReactionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function __construct(
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequirePermission(permission: RequirePermission::CHAT)]
#[RequirePermission(permission: RequirePermission::REACT)]
#[RequireReadWriteConversation]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/reaction/{token}/{messageId}', requirements: [
Expand Down Expand Up @@ -108,7 +108,7 @@ public function react(int $messageId, string $reaction): DataResponse {
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequirePermission(permission: RequirePermission::CHAT)]
#[RequirePermission(permission: RequirePermission::REACT)]
#[RequireReadWriteConversation]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/reaction/{token}/{messageId}', requirements: [
Expand Down
8 changes: 4 additions & 4 deletions lib/Controller/RoomController.php
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ protected function formatRoom(
* @psalm-param non-negative-int|null $lobbyTimer
* @param 0|1|2 $sipEnabled Whether SIP dial-in shall be enabled (only available with `conversation-creation-all` capability)
* @psalm-param Webinary::SIP_* $sipEnabled
* @param int<0, 255> $permissions Default permissions for participants (only available with `conversation-creation-all` capability)
* @param int<0, 511> $permissions Default permissions for participants (only available with `conversation-creation-all` capability)
* @psalm-param int-mask-of<Attendee::PERMISSIONS_*> $permissions
* @param 0|1 $recordingConsent Whether participants need to agree to a recording before joining a call (only available with `conversation-creation-all` capability)
* @psalm-param RecordingService::CONSENT_REQUIRED_NO|RecordingService::CONSENT_REQUIRED_YES $recordingConsent
Expand Down Expand Up @@ -2665,7 +2665,7 @@ protected function changeParticipantType(int $attendeeId, bool $promote): DataRe
* Update the permissions of a room
*
* @param 'call'|'default' $mode Level of the permissions ('call' (removed in Talk 20), 'default')
* @param int<0, 255> $permissions New permissions
* @param int<0, 511> $permissions New permissions
* @psalm-param int-mask-of<Attendee::PERMISSIONS_*> $permissions
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'breakout-room'|'mode'|'type'|'value'}, array{}>
*
Expand Down Expand Up @@ -2699,7 +2699,7 @@ public function setPermissions(string $mode, int $permissions): DataResponse {
* @param int $attendeeId ID of the attendee
* @psalm-param non-negative-int $attendeeId
* @param 'set'|'remove'|'add' $method Method of updating permissions ('set', 'remove', 'add')
* @param int<0, 255> $permissions New permissions
* @param int<0, 511> $permissions New permissions
* @psalm-param int-mask-of<Attendee::PERMISSIONS_*> $permissions
* @return DataResponse<Http::STATUS_OK, list<TalkParticipant>, array{X-Nextcloud-Has-User-Statuses?: true}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'participant'|'method'|'moderator'|'room-type'|'type'|'value'}, array{}>
*
Expand Down Expand Up @@ -2738,7 +2738,7 @@ public function setAttendeePermissions(int $attendeeId, string $method, int $per
*
* @param 'set'|'remove'|'add' $method Method of updating permissions ('set', 'remove', 'add')
* @psalm-param Attendee::PERMISSIONS_MODIFY_* $method
* @param int<0, 255> $permissions New permissions
* @param int<0, 511> $permissions New permissions
* @psalm-param int-mask-of<Attendee::PERMISSIONS_*> $permissions
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, null, array{}>
* @deprecated Call permissions have been removed
Expand Down
1 change: 1 addition & 0 deletions lib/Middleware/Attribute/RequirePermission.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
class RequirePermission {

public const CHAT = 'chat';
public const REACT = 'react';
public const START_CALL = 'call-start';

public function __construct(
Expand Down
3 changes: 3 additions & 0 deletions lib/Middleware/InjectionMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,9 @@ protected function checkPermission(AEnvironmentAwareOCSController $controller, s
if ($permission === RequirePermission::CHAT && !($participant->getPermissions() & Attendee::PERMISSIONS_CHAT)) {
throw new PermissionsException();
}
if ($permission === RequirePermission::REACT && !($participant->getPermissions() & Attendee::PERMISSIONS_REACT)) {
throw new PermissionsException();
}
if ($permission === RequirePermission::START_CALL && !($participant->getPermissions() & Attendee::PERMISSIONS_CALL_START)) {
throw new PermissionsException();
}
Expand Down
106 changes: 106 additions & 0 deletions lib/Migration/Version23000Date20260123100000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Talk\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

/**
* Add PERMISSIONS_REACT (256) to all records that have PERMISSIONS_CHAT (128) set.
* This migration splits the combined "chat" permission into separate "chat" (post messages)
* and "react" (add reactions) permissions for backward compatibility.
*/
class Version23000Date20260123100000 extends SimpleMigrationStep {

public function __construct(
protected IDBConnection $connection,
) {
}

/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
#[\Override]
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
// Add REACT permission (256) to all room default_permissions that have CHAT (128) set
$update = $this->connection->getQueryBuilder();
$update->update('talk_rooms')
->set('default_permissions', $update->func()->add(
'default_permissions',
$update->createNamedParameter(256, IQueryBuilder::PARAM_INT) // Attendee::PERMISSIONS_REACT
))
->where($update->expr()->neq('default_permissions', $update->createNamedParameter(0, IQueryBuilder::PARAM_INT))) // Attendee::PERMISSIONS_DEFAULT
->andWhere(
$update->expr()->eq(
$update->expr()->castColumn(
$update->expr()->bitwiseAnd(
'default_permissions',
128 // Attendee::PERMISSIONS_CHAT
),
IQueryBuilder::PARAM_INT
),
$update->createNamedParameter(128, IQueryBuilder::PARAM_INT) // Attendee::PERMISSIONS_CHAT
)
)
->andWhere(
$update->expr()->neq(
$update->expr()->castColumn(
$update->expr()->bitwiseAnd(
'default_permissions',
256 // Attendee::PERMISSIONS_REACT
),
IQueryBuilder::PARAM_INT
),
$update->createNamedParameter(256, IQueryBuilder::PARAM_INT) // Attendee::PERMISSIONS_REACT - don't add if already set
)
);
$update->executeStatement();

// Add REACT permission (256) to all attendee permissions that have CHAT (128) set
$update = $this->connection->getQueryBuilder();
$update->update('talk_attendees')
->set('permissions', $update->func()->add(
'permissions',
$update->createNamedParameter(256, IQueryBuilder::PARAM_INT) // Attendee::PERMISSIONS_REACT
))
->where($update->expr()->neq('permissions', $update->createNamedParameter(0, IQueryBuilder::PARAM_INT))) // Attendee::PERMISSIONS_DEFAULT
->andWhere(
$update->expr()->eq(
$update->expr()->castColumn(
$update->expr()->bitwiseAnd(
'permissions',
128 // Attendee::PERMISSIONS_CHAT
),
IQueryBuilder::PARAM_INT
),
$update->createNamedParameter(128, IQueryBuilder::PARAM_INT) // Attendee::PERMISSIONS_CHAT
)
)
->andWhere(
$update->expr()->neq(
$update->expr()->castColumn(
$update->expr()->bitwiseAnd(
'permissions',
256 // Attendee::PERMISSIONS_REACT
),
IQueryBuilder::PARAM_INT
),
$update->createNamedParameter(256, IQueryBuilder::PARAM_INT) // Attendee::PERMISSIONS_REACT - don't add if already set
)
);
$update->executeStatement();
}
}
2 changes: 2 additions & 0 deletions lib/Model/Attendee.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class Attendee extends Entity {
public const PERMISSIONS_PUBLISH_VIDEO = 32;
public const PERMISSIONS_PUBLISH_SCREEN = 64;
public const PERMISSIONS_CHAT = 128;
public const PERMISSIONS_REACT = 256;
public const PERMISSIONS_MAX_DEFAULT // Max int (when all permissions are granted as default)
= self::PERMISSIONS_CALL_START
| self::PERMISSIONS_CALL_JOIN
Expand All @@ -110,6 +111,7 @@ class Attendee extends Entity {
| self::PERMISSIONS_PUBLISH_VIDEO
| self::PERMISSIONS_PUBLISH_SCREEN
| self::PERMISSIONS_CHAT
| self::PERMISSIONS_REACT
;
public const PERMISSIONS_MAX_CUSTOM = self::PERMISSIONS_MAX_DEFAULT | self::PERMISSIONS_CUSTOM; // Max int (when all permissions are granted as custom)

Expand Down
6 changes: 5 additions & 1 deletion lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -555,8 +555,12 @@
* experiments: array{
* enabled: non-negative-int,
* },
* permissions: array{
* max-default: int,
* max-custom: int,
* },
* },
* config-local: array<string, non-empty-list<string>>,
* config-local: array<string, list<string>>,
* version: string,
* }
*
Expand Down
3 changes: 1 addition & 2 deletions lib/Service/RoomService.php
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,7 @@ public function setDefaultPermissions(Room $room, int $permissions): void {
throw new DefaultPermissionsException(DefaultPermissionsException::REASON_BREAKOUT_ROOM);
}

if ($permissions < 0 || $permissions > 255) {
// Do not allow manual changing the permissions in breakout rooms
if ($permissions < Attendee::PERMISSIONS_DEFAULT || $permissions > Attendee::PERMISSIONS_MAX_CUSTOM) {
throw new DefaultPermissionsException(DefaultPermissionsException::REASON_VALUE);
}

Expand Down
Loading
Loading