diff --git a/appinfo/info.xml b/appinfo/info.xml index c5034914a5c..ce22dac5020 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -18,7 +18,7 @@ * 🌉 **Sync with other chat solutions** With [Matterbridge](https://github.com/42wim/matterbridge/) being integrated in Talk, you can easily sync a lot of other chat solutions to Nextcloud Talk and vice-versa. ]]> - 24.0.0-dev.2 + 24.0.0-dev.3 agpl Anna Larch diff --git a/docs/capabilities.md b/docs/capabilities.md index 58f2ea9d908..6d1a1f0d3e8 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -226,3 +226,4 @@ * `config => conversations => group-mode` (local) - User selected grouping mode for conversations (`none`, `group-first` or `private-first`) * `private-reply` - Whether clients can link the original message to a private reply in one-to-one conversations * `config => attachments => conversation-subfolders` (local) - Whether per-conversation subfolders are used for Talk attachments; when `true` files must be uploaded to `Talk/-/-/` before calling the attachment endpoint +* `mute-conversations` - Whether conversations can be muted for a given time diff --git a/lib/Capabilities.php b/lib/Capabilities.php index db47841106c..8856d7f2274 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -132,6 +132,7 @@ class Capabilities implements IPublicCapability { 'scheduled-messages', 'conversation-presets', 'private-reply', + 'mute-conversations', ]; public const CONDITIONAL_FEATURES = [ diff --git a/lib/Chat/Notifier.php b/lib/Chat/Notifier.php index 30f35f3b93c..a1fb24952a8 100644 --- a/lib/Chat/Notifier.php +++ b/lib/Chat/Notifier.php @@ -692,6 +692,10 @@ protected function shouldMentionedUserBeNotified(string $userId, IComment $comme return self::PRIORITY_NONE; } + if ($attendee->getMuteUntil() >= $this->timeFactory->getTime()) { + return self::PRIORITY_NONE; + } + $notificationLevel = $attendee->getNotificationLevel(); $threadId = (int)$comment->getTopmostParentId(); if ($threadId !== 0) { @@ -768,6 +772,10 @@ protected function shouldParticipantBeNotified(Participant $participant, ICommen return self::PRIORITY_NONE; } + if ($participant->getAttendee()->getMuteUntil() >= $this->timeFactory->getTime()) { + return self::PRIORITY_NONE; + } + if ($participant->getAttendee()->isImportant()) { return self::PRIORITY_IMPORTANT; } diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index 1e1e9ca838d..3c32552239f 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -3238,4 +3238,54 @@ public function scheduleMeeting(string $calendarUri, int $start, ?array $attende return new DataResponse(null, Http::STATUS_OK); } + + /** + * Mute all notifications in a conversation until a specific time. + * Does not alter notification settings for the attendee. + * + * Required capability: `mute-conversations` + * + * @param int $muteUntil Unix timestamp until notifications are muted + * @return DataResponse|DataResponse + * + * 200: Conversation muted + * 400: Timestamp is in the past + */ + #[NoAdminRequired] + #[FederationSupported] + #[RequireLoggedInParticipant] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/mute', requirements: [ + 'apiVersion' => '(v4)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function muteConversation(int $muteUntil): DataResponse { + if ($muteUntil <= $this->timeFactory->getTime()) { + return new DataResponse(['error' => 'mute-until'], Http::STATUS_BAD_REQUEST); + } + + $this->participantService->setMuteUntil($this->participant, $muteUntil); + return new DataResponse($this->formatRoom($this->room, $this->participant)); + } + + /** + * Unmute all notifications in a conversation, when they were muted before. + * Does not alter notification settings for the attendee. + * + * Required capability: `mute-conversations` + * + * @return DataResponse + * + * 200: Conversation unmuted + */ + #[NoAdminRequired] + #[FederationSupported] + #[RequireLoggedInParticipant] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/mute', requirements: [ + 'apiVersion' => '(v4)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function unmuteConversation(): DataResponse { + $this->participantService->setMuteUntil($this->participant, 0); + return new DataResponse($this->formatRoom($this->room, $this->participant)); + } } diff --git a/lib/Migration/Version24000Date20260419190830.php b/lib/Migration/Version24000Date20260419190830.php new file mode 100644 index 00000000000..2feae38211a --- /dev/null +++ b/lib/Migration/Version24000Date20260419190830.php @@ -0,0 +1,42 @@ +getTable('talk_attendees'); + if (!$table->hasColumn('mute_until')) { + $table->addColumn('mute_until', Types::BIGINT, [ + 'notnull' => true, + 'default' => 0, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/Attendee.php b/lib/Model/Attendee.php index 75cb0013f72..c56c8185efb 100644 --- a/lib/Model/Attendee.php +++ b/lib/Model/Attendee.php @@ -74,6 +74,8 @@ * @method int getHiddenPinnedId() * @method void setHasScheduledMessages(int $scheduledMessages) * @method int getHasScheduledMessages() + * @method void setMuteUntil(int $muteUntil) + * @method int getMuteUntil() */ class Attendee extends Entity { public const ACTOR_USERS = 'users'; @@ -150,6 +152,7 @@ class Attendee extends Entity { protected bool $hasUnreadThreadDirects = false; protected int $hiddenPinnedId = 0; protected int $hasScheduledMessages = 0; + protected int $muteUntil = 0; public function __construct() { $this->addType('roomId', Types::BIGINT); @@ -183,7 +186,7 @@ public function __construct() { $this->addType('hasUnreadThreadDirects', Types::BOOLEAN); $this->addType('hiddenPinnedId', Types::BIGINT); $this->addType('hasScheduledMessages', Types::INTEGER); - + $this->addType('muteUntil', Types::BIGINT); } public function getDisplayName(): string { diff --git a/lib/Model/AttendeeMapper.php b/lib/Model/AttendeeMapper.php index f1715b06d1e..fd6aebaa1c0 100644 --- a/lib/Model/AttendeeMapper.php +++ b/lib/Model/AttendeeMapper.php @@ -316,6 +316,7 @@ public function createAttendeeFromRow(array $row): Attendee { 'has_unread_thread_directs' => (bool)$row['has_unread_thread_directs'], 'hidden_pinned_id' => (int)$row['hidden_pinned_id'], 'has_scheduled_messages' => (int)$row['has_scheduled_messages'], + 'mute_until' => (int)$row['mute_until'], ]); } } diff --git a/lib/Model/SelectHelper.php b/lib/Model/SelectHelper.php index 6ccc084f84f..9be81cc9a4e 100644 --- a/lib/Model/SelectHelper.php +++ b/lib/Model/SelectHelper.php @@ -114,6 +114,7 @@ public function selectAttendeesTable(IQueryBuilder $query, string $alias = 'a'): $alias . 'has_unread_thread_directs', $alias . 'hidden_pinned_id', $alias . 'has_scheduled_messages', + $alias . 'mute_until', ])->selectAlias($alias . 'id', 'a_id'); } diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 6f1a8e00aec..041f380071c 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -570,6 +570,8 @@ * hasScheduledMessages: int, * // Bit-flag of enabled attributes of this conversation (only available with capability: `conversation-attributes`). See [attributes list](https://nextcloud-talk.readthedocs.io/en/latest/constants/#conversation-attributes) for details * attributes: int, + * // Required capability: `mute-conversations`. Timestamp until the conversation is muted, i.e. not receiving notifications + * muteUntil: int, * } * * @psalm-type TalkDashboardEventAttachment = array{ diff --git a/lib/Service/ParticipantService.php b/lib/Service/ParticipantService.php index b0dc6c87f10..dfd03443b39 100644 --- a/lib/Service/ParticipantService.php +++ b/lib/Service/ParticipantService.php @@ -382,6 +382,13 @@ public function hidePinnedMessage(Participant $participant, int $messagesId): vo $this->attendeeMapper->update($attendee); } + public function setMuteUntil(Participant $participant, int $muteUntil): void { + $attendee = $participant->getAttendee(); + $attendee->setMuteUntil($muteUntil); + $attendee->setLastAttendeeActivity($this->timeFactory->getTime()); + $this->attendeeMapper->update($attendee); + } + /** * @param RoomService $roomService * @param Room $room @@ -2058,6 +2065,7 @@ public function getParticipantUsersForCallNotifications(Room $room): array { ->where($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT))) ->andWhere($query->expr()->eq('a.actor_type', $query->createNamedParameter(Attendee::ACTOR_USERS))) ->andWhere($query->expr()->eq('a.notification_calls', $query->createNamedParameter(Participant::NOTIFY_CALLS_ON))) + ->andWhere($query->expr()->lte('a.mute_until', $query->createNamedParameter($this->timeFactory->getTime(), IQueryBuilder::PARAM_INT))) ->andWhere($query->expr()->isNull('s.in_call')); if ($room->getLobbyState() !== Webinary::LOBBY_NONE) { diff --git a/lib/Service/RoomFormatter.php b/lib/Service/RoomFormatter.php index 2af9f20aa0e..92f875d7260 100644 --- a/lib/Service/RoomFormatter.php +++ b/lib/Service/RoomFormatter.php @@ -160,6 +160,7 @@ public function formatRoomV4( 'isSensitive' => false, 'hasScheduledMessages' => 0, 'attributes' => 0, + 'muteUntil' => 0, ]; if ($room->isFederatedConversation()) { @@ -251,6 +252,7 @@ public function formatRoomV4( 'lastPinnedId' => $room->getLastPinnedId(), 'hiddenPinnedId' => $attendee->getHiddenPinnedId(), 'attributes' => $room->getAttributes(), + 'muteUntil' => $attendee->getMuteUntil(), ]); if ($room->isFederatedConversation()) { diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index c23432e87eb..50628184c0c 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -941,7 +941,8 @@ "lastPinnedId", "hiddenPinnedId", "hasScheduledMessages", - "attributes" + "attributes", + "muteUntil" ], "properties": { "actorId": { @@ -1255,6 +1256,11 @@ "type": "integer", "format": "int64", "description": "Bit-flag of enabled attributes of this conversation (only available with capability: `conversation-attributes`). See [attributes list](https://nextcloud-talk.readthedocs.io/en/latest/constants/#conversation-attributes) for details" + }, + "muteUntil": { + "type": "integer", + "format": "int64", + "description": "Required capability: `mute-conversations`. Timestamp until the conversation is muted, i.e. not receiving notifications" } } }, diff --git a/openapi-federation.json b/openapi-federation.json index 1f99fd6b5da..377569a4136 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -1006,7 +1006,8 @@ "lastPinnedId", "hiddenPinnedId", "hasScheduledMessages", - "attributes" + "attributes", + "muteUntil" ], "properties": { "actorId": { @@ -1320,6 +1321,11 @@ "type": "integer", "format": "int64", "description": "Bit-flag of enabled attributes of this conversation (only available with capability: `conversation-attributes`). See [attributes list](https://nextcloud-talk.readthedocs.io/en/latest/constants/#conversation-attributes) for details" + }, + "muteUntil": { + "type": "integer", + "format": "int64", + "description": "Required capability: `mute-conversations`. Timestamp until the conversation is muted, i.e. not receiving notifications" } } }, diff --git a/openapi-full.json b/openapi-full.json index cd620ec092c..4f6e233b8f6 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1990,7 +1990,8 @@ "lastPinnedId", "hiddenPinnedId", "hasScheduledMessages", - "attributes" + "attributes", + "muteUntil" ], "properties": { "actorId": { @@ -2304,6 +2305,11 @@ "type": "integer", "format": "int64", "description": "Bit-flag of enabled attributes of this conversation (only available with capability: `conversation-attributes`). See [attributes list](https://nextcloud-talk.readthedocs.io/en/latest/constants/#conversation-attributes) for details" + }, + "muteUntil": { + "type": "integer", + "format": "int64", + "description": "Required capability: `mute-conversations`. Timestamp until the conversation is muted, i.e. not receiving notifications" } } }, @@ -25587,6 +25593,287 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/mute": { + "post": { + "operationId": "room-mute-conversation", + "summary": "Mute all notifications in a conversation until a specific time. Does not alter notification settings for the attendee.", + "description": "Required capability: `mute-conversations`", + "tags": [ + "room" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "muteUntil" + ], + "properties": { + "muteUntil": { + "type": "integer", + "format": "int64", + "description": "Unix timestamp until notifications are muted" + } + } + } + } + } + }, + "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 muted", + "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" + } + } + } + } + } + } + } + }, + "400": { + "description": "Timestamp is in the past", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "mute-until" + ] + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "room-unmute-conversation", + "summary": "Unmute all notifications in a conversation, when they were muted before. Does not alter notification settings for the attendee.", + "description": "Required capability: `mute-conversations`", + "tags": [ + "room" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "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 unmuted", + "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}/settings/user": { "post": { "operationId": "settings-set-user-setting", diff --git a/openapi.json b/openapi.json index d80abd78355..e8898b71b0f 100644 --- a/openapi.json +++ b/openapi.json @@ -1878,7 +1878,8 @@ "lastPinnedId", "hiddenPinnedId", "hasScheduledMessages", - "attributes" + "attributes", + "muteUntil" ], "properties": { "actorId": { @@ -2192,6 +2193,11 @@ "type": "integer", "format": "int64", "description": "Bit-flag of enabled attributes of this conversation (only available with capability: `conversation-attributes`). See [attributes list](https://nextcloud-talk.readthedocs.io/en/latest/constants/#conversation-attributes) for details" + }, + "muteUntil": { + "type": "integer", + "format": "int64", + "description": "Required capability: `mute-conversations`. Timestamp until the conversation is muted, i.e. not receiving notifications" } } }, @@ -25475,6 +25481,287 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/mute": { + "post": { + "operationId": "room-mute-conversation", + "summary": "Mute all notifications in a conversation until a specific time. Does not alter notification settings for the attendee.", + "description": "Required capability: `mute-conversations`", + "tags": [ + "room" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "muteUntil" + ], + "properties": { + "muteUntil": { + "type": "integer", + "format": "int64", + "description": "Unix timestamp until notifications are muted" + } + } + } + } + } + }, + "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 muted", + "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" + } + } + } + } + } + } + } + }, + "400": { + "description": "Timestamp is in the past", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "mute-until" + ] + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "room-unmute-conversation", + "summary": "Unmute all notifications in a conversation, when they were muted before. Does not alter notification settings for the attendee.", + "description": "Required capability: `mute-conversations`", + "tags": [ + "room" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "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 unmuted", + "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}/settings/user": { "post": { "operationId": "settings-set-user-setting", diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index 4fa17d36ce1..6f203a17002 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -782,6 +782,11 @@ export type components = { * @description Bit-flag of enabled attributes of this conversation (only available with capability: `conversation-attributes`). See [attributes list](https://nextcloud-talk.readthedocs.io/en/latest/constants/#conversation-attributes) for details */ attributes: number; + /** + * Format: int64 + * @description Required capability: `mute-conversations`. Timestamp until the conversation is muted, i.e. not receiving notifications + */ + muteUntil: number; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; }; diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index 7160990adda..ed6246bae83 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -826,6 +826,11 @@ export type components = { * @description Bit-flag of enabled attributes of this conversation (only available with capability: `conversation-attributes`). See [attributes list](https://nextcloud-talk.readthedocs.io/en/latest/constants/#conversation-attributes) for details */ attributes: number; + /** + * Format: int64 + * @description Required capability: `mute-conversations`. Timestamp until the conversation is muted, i.e. not receiving notifications + */ + muteUntil: number; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; }; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index d2ba011e53d..359f4fd8106 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1782,6 +1782,30 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/mute": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Mute all notifications in a conversation until a specific time. Does not alter notification settings for the attendee. + * @description Required capability: `mute-conversations` + */ + post: operations["room-mute-conversation"]; + /** + * Unmute all notifications in a conversation, when they were muted before. Does not alter notification settings for the attendee. + * @description Required capability: `mute-conversations` + */ + delete: operations["room-unmute-conversation"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/settings/user": { parameters: { query?: never; @@ -3581,6 +3605,11 @@ export type components = { * @description Bit-flag of enabled attributes of this conversation (only available with capability: `conversation-attributes`). See [attributes list](https://nextcloud-talk.readthedocs.io/en/latest/constants/#conversation-attributes) for details */ attributes: number; + /** + * Format: int64 + * @description Required capability: `mute-conversations`. Timestamp until the conversation is muted, i.e. not receiving notifications + */ + muteUntil: number; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; RoomWithInvalidInvitations: components["schemas"]["Room"] & { @@ -12933,6 +12962,123 @@ export interface operations { }; }; }; + "room-mute-conversation": { + 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": { + /** + * Format: int64 + * @description Unix timestamp until notifications are muted + */ + muteUntil: number; + }; + }; + }; + responses: { + /** @description Conversation muted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Room"]; + }; + }; + }; + }; + /** @description Timestamp is in the past */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "mute-until"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "room-unmute-conversation": { + 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?: never; + responses: { + /** @description Conversation unmuted */ + 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; + }; + }; + }; + }; + }; + }; "settings-set-user-setting": { parameters: { query?: never; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 38444c0a210..16324f78962 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1782,6 +1782,30 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/mute": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Mute all notifications in a conversation until a specific time. Does not alter notification settings for the attendee. + * @description Required capability: `mute-conversations` + */ + post: operations["room-mute-conversation"]; + /** + * Unmute all notifications in a conversation, when they were muted before. Does not alter notification settings for the attendee. + * @description Required capability: `mute-conversations` + */ + delete: operations["room-unmute-conversation"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/settings/user": { parameters: { query?: never; @@ -3014,6 +3038,11 @@ export type components = { * @description Bit-flag of enabled attributes of this conversation (only available with capability: `conversation-attributes`). See [attributes list](https://nextcloud-talk.readthedocs.io/en/latest/constants/#conversation-attributes) for details */ attributes: number; + /** + * Format: int64 + * @description Required capability: `mute-conversations`. Timestamp until the conversation is muted, i.e. not receiving notifications + */ + muteUntil: number; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; RoomWithInvalidInvitations: components["schemas"]["Room"] & { @@ -12366,6 +12395,123 @@ export interface operations { }; }; }; + "room-mute-conversation": { + 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": { + /** + * Format: int64 + * @description Unix timestamp until notifications are muted + */ + muteUntil: number; + }; + }; + }; + responses: { + /** @description Conversation muted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Room"]; + }; + }; + }; + }; + /** @description Timestamp is in the past */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "mute-until"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; + "room-unmute-conversation": { + 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?: never; + responses: { + /** @description Conversation unmuted */ + 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; + }; + }; + }; + }; + }; + }; "settings-set-user-setting": { parameters: { query?: never; diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index f8e1f212d97..4f2eb79c29d 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -548,6 +548,12 @@ private function assertRooms(array $rooms, TableNode $formData, bool $shouldOrde $data['lobbyTimer'] = 'GREATER_THAN_ZERO'; } } + if (isset($expectedRoom['muteUntil'])) { + $data['muteUntil'] = (int)$room['muteUntil']; + if ($expectedRoom['muteUntil'] === 'GREATER_THAN_ZERO' && $room['muteUntil'] > 0) { + $data['muteUntil'] = 'GREATER_THAN_ZERO'; + } + } if (isset($expectedRoom['breakoutRoomMode'])) { $data['breakoutRoomMode'] = (int)$room['breakoutRoomMode']; } @@ -4975,6 +4981,26 @@ public function userMarksConversationSensitive(string $user, string $identifier, $this->assertStatusCode($this->response, $statusCode); } + #[When('/^user "([^"]*)" mutes room "([^"]*)" until OFFSET\((\d+)\) with (\d+) \((v4)\)$/')] + public function userMutesConversationUntil(string $user, string $identifier, int $muteUntil, int $statusCode, string $apiVersion): void { + $this->setCurrentUser($user); + $this->sendRequest( + 'POST', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/mute', [ + 'muteUntil' => time() + $muteUntil, + ], + ); + $this->assertStatusCode($this->response, $statusCode); + } + + #[When('/^user "([^"]*)" unmutes room "([^"]*)" with (\d+) \((v4)\)$/')] + public function userUnmutesConversationUntil(string $user, string $identifier, int $statusCode, string $apiVersion): void { + $this->setCurrentUser($user); + $this->sendRequest( + 'DELETE', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/mute', + ); + $this->assertStatusCode($this->response, $statusCode); + } + public function sendRequestFullUrl(string $verb, string $fullUrl, TableNode|array|string|null $body = null, array $headers = [], array $options = []): void { $client = new Client(); $options = array_merge($options, ['cookies' => $this->getUserCookieJar($this->currentUser)]); diff --git a/tests/integration/features/conversation-5/mute.feature b/tests/integration/features/conversation-5/mute.feature new file mode 100644 index 00000000000..1bffbf0da2c --- /dev/null +++ b/tests/integration/features/conversation-5/mute.feature @@ -0,0 +1,50 @@ +Feature: conversation-5/mute + Background: + Given user "participant1" exists + Given user "participant2" exists + + Scenario: Mark as (un-)mute + Given user "participant1" creates room "group room" (v4) + | roomType | 3 | + | roomName | room | + And user "participant1" is participant of the following unordered rooms (v4) + | id | name | muteUntil | + | group room | room | 0 | + And user "participant1" mutes room "group room" until OFFSET(3600) with 200 (v4) + And user "participant1" is participant of the following unordered rooms (v4) + | id | name | muteUntil | + | group room | room | GREATER_THAN_ZERO | + And user "participant1" unmutes room "group room" with 200 (v4) + And user "participant1" is participant of the following unordered rooms (v4) + | id | name | muteUntil | + | group room | room | 0 | + + Scenario: No notifications for muted rooms + When user "participant1" creates room "one-to-one room" (v4) + | roomType | 1 | + | invite | participant2 | + And user "participant2" creates room "one-to-one room" with 200 (v4) + | roomType | 1 | + | invite | participant1 | + And user "participant1" sends message "Message1" to room "one-to-one room" with 201 + And user "participant2" mutes room "one-to-one room" until OFFSET(3600) with 200 (v4) + And user "participant1" sends message "Message with mention for @participant2" to room "one-to-one room" with 201 + Then user "participant2" has the following notifications + | app | object_type | object_id | subject | message | + | spreed | chat | one-to-one room/Message1 | participant1-displayname sent you a private message | Message1 | + And user "participant2" unmutes room "one-to-one room" with 200 (v4) + Then user "participant2" has the following notifications + | app | object_type | object_id | subject | message | + | spreed | chat | one-to-one room/Message1 | participant1-displayname sent you a private message | Message1 | + + Scenario: Participant1 calls while participant2 has muted the conversation + When user "participant1" creates room "room" (v4) + | roomType | 3 | + | roomName | room | + And user "participant1" adds user "participant2" to room "room" with 200 (v4) + Then user "participant1" is participant of room "room" (v4) + And user "participant2" is participant of room "room" (v4) + And user "participant2" mutes room "room" until OFFSET(3600) with 200 (v4) + Then user "participant1" joins room "room" with 200 (v4) + Then user "participant1" joins call "room" with 200 (v4) + Then user "participant2" has the following notifications diff --git a/tests/php/Chat/ChatManagerTest.php b/tests/php/Chat/ChatManagerTest.php index f931b8c27b2..2a9ef1680c3 100644 --- a/tests/php/Chat/ChatManagerTest.php +++ b/tests/php/Chat/ChatManagerTest.php @@ -444,6 +444,7 @@ public function testDeleteMessage(): void { 'has_unread_thread_directs' => false, 'hidden_pinned_id' => 0, 'has_scheduled_messages' => 0, + 'mute_until' => 0, ]); $chat = $this->createMock(Room::class); $chat->expects($this->any()) @@ -514,6 +515,7 @@ public function testDeleteMessageFileShare(): void { 'has_unread_thread_directs' => false, 'hidden_pinned_id' => 0, 'has_scheduled_messages' => 0, + 'mute_until' => 0, ]); $chat = $this->createMock(Room::class); $chat->expects($this->any()) @@ -606,6 +608,7 @@ public function testDeleteMessageFileShareNotFound(): void { 'has_unread_thread_directs' => false, 'hidden_pinned_id' => 0, 'has_scheduled_messages' => 0, + 'mute_until' => 0, ]); $chat = $this->createMock(Room::class); $chat->expects($this->any()) diff --git a/tests/php/Chat/NotifierTest.php b/tests/php/Chat/NotifierTest.php index eb0987342e8..6d7e079ddaf 100644 --- a/tests/php/Chat/NotifierTest.php +++ b/tests/php/Chat/NotifierTest.php @@ -177,6 +177,10 @@ public function testNotifyMentionedUsers(string $message, array $alreadyNotified ->method('notify'); } + $current = 1234567; + $this->timeFactory->method('getTime') + ->willReturn($current); + $room = $this->getRoom(); $comment = $this->newComment('108', 'users', 'testUser', new \DateTime('@' . 1000000016), $message); $notifier = $this->getNotifier([]); @@ -188,24 +192,30 @@ public function testNotifyMentionedUsers(string $message, array $alreadyNotified public static function dataShouldParticipantBeNotified(): array { return [ - [Attendee::ACTOR_GROUPS, 'test1', null, Attendee::ACTOR_USERS, 'test1', [], false, Notifier::PRIORITY_NONE], - [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test1', [], false, Notifier::PRIORITY_NONE], - [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test2', [], false, Notifier::PRIORITY_NORMAL], - [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test2', [['id' => 'test1', 'type' => Attendee::ACTOR_USERS]], false, Notifier::PRIORITY_NONE], - [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test2', [['id' => 'test1', 'type' => Attendee::ACTOR_FEDERATED_USERS]], false, Notifier::PRIORITY_NORMAL], - [Attendee::ACTOR_USERS, 'test1', Session::SESSION_TIMEOUT - 5, Attendee::ACTOR_USERS, 'test2', [], false, Notifier::PRIORITY_NONE], - [Attendee::ACTOR_USERS, 'test1', Session::SESSION_TIMEOUT + 5, Attendee::ACTOR_USERS, 'test2', [], false, Notifier::PRIORITY_NORMAL], + [Attendee::ACTOR_GROUPS, 'test1', null, Attendee::ACTOR_USERS, 'test1', [], false, 0, Notifier::PRIORITY_NONE], + [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test1', [], false, 0, Notifier::PRIORITY_NONE], + [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test2', [], false, 0, Notifier::PRIORITY_NORMAL], + [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test2', [['id' => 'test1', 'type' => Attendee::ACTOR_USERS]], false, 0, Notifier::PRIORITY_NONE], + [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test2', [['id' => 'test1', 'type' => Attendee::ACTOR_FEDERATED_USERS]], false, 0, Notifier::PRIORITY_NORMAL], + [Attendee::ACTOR_USERS, 'test1', Session::SESSION_TIMEOUT - 5, Attendee::ACTOR_USERS, 'test2', [], false, 0, Notifier::PRIORITY_NONE], + [Attendee::ACTOR_USERS, 'test1', Session::SESSION_TIMEOUT + 5, Attendee::ACTOR_USERS, 'test2', [], false, 0, Notifier::PRIORITY_NORMAL], // Marked as important, still blocked by session and being the author, but otherwise with PRIORITY_IMPORTANT - [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test1', [], true, Notifier::PRIORITY_NONE], - [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test2', [], true, Notifier::PRIORITY_IMPORTANT], - [Attendee::ACTOR_USERS, 'test1', Session::SESSION_TIMEOUT - 5, Attendee::ACTOR_USERS, 'test2', [], true, Notifier::PRIORITY_NONE], - [Attendee::ACTOR_USERS, 'test1', Session::SESSION_TIMEOUT + 5, Attendee::ACTOR_USERS, 'test2', [], true, Notifier::PRIORITY_IMPORTANT], + [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test1', [], true, 0, Notifier::PRIORITY_NONE], + [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test2', [], true, 0, Notifier::PRIORITY_IMPORTANT], + [Attendee::ACTOR_USERS, 'test1', Session::SESSION_TIMEOUT - 5, Attendee::ACTOR_USERS, 'test2', [], true, 0, Notifier::PRIORITY_NONE], + [Attendee::ACTOR_USERS, 'test1', Session::SESSION_TIMEOUT + 5, Attendee::ACTOR_USERS, 'test2', [], true, 0, Notifier::PRIORITY_IMPORTANT], + + // Marked as muted, no notification even with PRIORITY_IMPORTANT + [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test1', [], true, 9999999, Notifier::PRIORITY_NONE], + [Attendee::ACTOR_USERS, 'test1', null, Attendee::ACTOR_USERS, 'test2', [], true, 9999999, Notifier::PRIORITY_NONE], + [Attendee::ACTOR_USERS, 'test1', Session::SESSION_TIMEOUT - 5, Attendee::ACTOR_USERS, 'test2', [], true, 9999999, Notifier::PRIORITY_NONE], + [Attendee::ACTOR_USERS, 'test1', Session::SESSION_TIMEOUT + 5, Attendee::ACTOR_USERS, 'test2', [], true, 9999999, Notifier::PRIORITY_NONE], ]; } #[DataProvider('dataShouldParticipantBeNotified')] - public function testShouldParticipantBeNotified(string $actorType, string $actorId, ?int $sessionAge, string $commentActorType, string $commentActorId, array $alreadyNotifiedUsers, bool $isImportant, int $expected): void { + public function testShouldParticipantBeNotified(string $actorType, string $actorId, ?int $sessionAge, string $commentActorType, string $commentActorId, array $alreadyNotifiedUsers, bool $isImportant, int $muteUntil, int $expected): void { $comment = $this->createMock(IComment::class); $comment->method('getActorType') ->willReturn($commentActorType); @@ -217,13 +227,14 @@ public function testShouldParticipantBeNotified(string $actorType, string $actor 'actor_type' => $actorType, 'actor_id' => $actorId, 'important' => $isImportant, + 'mute_until' => $muteUntil, ]); + $current = 1234567; + $this->timeFactory->method('getTime') + ->willReturn($current); + $session = null; if ($sessionAge !== null) { - $current = 1234567; - $this->timeFactory->method('getTime') - ->willReturn($current); - $session = Session::fromRow([ 'last_ping' => $current - $sessionAge, ]);