diff --git a/appinfo/info.xml b/appinfo/info.xml index fe5a6ae88d0..27a6c4b6fd9 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. ]]> - 22.0.0-dev.2 + 22.0.0-dev.3 agpl Anna Larch diff --git a/docs/capabilities.md b/docs/capabilities.md index f5c19beea36..ebc8ad69964 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -181,3 +181,4 @@ ## 21.1 * `conversation-creation-all` - Whether the conversation creation endpoint allows to specify all attributes of a conversation +* `important-conversations` (local) - Whether important conversations are supported diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 5f9fed13b0d..10f5aef4398 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -115,6 +115,7 @@ class Capabilities implements IPublicCapability { 'schedule-meeting', 'edit-draft-poll', 'conversation-creation-all', + 'important-conversations', ]; public const CONDITIONAL_FEATURES = [ @@ -138,6 +139,7 @@ class Capabilities implements IPublicCapability { 'chat-summary-api', 'call-notification-state-api', 'schedule-meeting', + 'important-conversations', ]; public const LOCAL_CONFIGS = [ diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index 30a39bd9aec..414efc7815c 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -1681,6 +1681,40 @@ public function unarchiveConversation(): DataResponse { return new DataResponse($this->formatRoom($this->room, $this->participant)); } + /** + * Mark a conversation as important (still sending notifications while on DND) + * + * Required capability: `important-conversations` + * + * @return DataResponse + * + * 200: Conversation was marked as important + */ + #[NoAdminRequired] + #[FederationSupported] + #[RequireLoggedInParticipant] + public function markConversationAsImportant(): DataResponse { + $this->participantService->markConversationAsImportant($this->participant); + return new DataResponse($this->formatRoom($this->room, $this->participant)); + } + + /** + * Mark a conversation as unimportant (no longer sending notifications while on DND) + * + * Required capability: `important-conversations` + * + * @return DataResponse + * + * 200: Conversation was marked as unimportant + */ + #[NoAdminRequired] + #[FederationSupported] + #[RequireLoggedInParticipant] + public function markConversationAsUnimportant(): DataResponse { + $this->participantService->markConversationAsUnimportant($this->participant); + return new DataResponse($this->formatRoom($this->room, $this->participant)); + } + /** * Join a room * diff --git a/lib/Migration/Version21001Date20250328123156.php b/lib/Migration/Version21001Date20250328123156.php new file mode 100644 index 00000000000..8f15e466b33 --- /dev/null +++ b/lib/Migration/Version21001Date20250328123156.php @@ -0,0 +1,41 @@ +getTable('talk_attendees'); + if (!$table->hasColumn('important')) { + $table->addColumn('important', Types::BOOLEAN, [ + 'default' => 0, + 'notnull' => false, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/Attendee.php b/lib/Model/Attendee.php index 33d1ade0290..2f95b8d8dd5 100644 --- a/lib/Model/Attendee.php +++ b/lib/Model/Attendee.php @@ -42,6 +42,8 @@ * @method void setPermissions(int $permissions) * @method void setArchived(bool $archived) * @method bool isArchived() + * @method void setImportant(bool $important) + * @method bool isImportant() * @internal * @method int getPermissions() * @method void setAccessToken(string $accessToken) @@ -113,6 +115,7 @@ class Attendee extends Entity { protected int $notificationLevel = 0; protected int $notificationCalls = 0; protected bool $archived = false; + protected bool $important = false; protected int $lastJoinedCall = 0; protected int $lastReadMessage = 0; protected int $lastMentionMessage = 0; @@ -137,6 +140,7 @@ public function __construct() { $this->addType('participantType', Types::SMALLINT); $this->addType('favorite', Types::BOOLEAN); $this->addType('archived', Types::BOOLEAN); + $this->addType('important', Types::BOOLEAN); $this->addType('notificationLevel', Types::INTEGER); $this->addType('notificationCalls', Types::INTEGER); $this->addType('lastJoinedCall', Types::INTEGER); diff --git a/lib/Model/AttendeeMapper.php b/lib/Model/AttendeeMapper.php index 55fb2298cad..d2e1b5f1a9c 100644 --- a/lib/Model/AttendeeMapper.php +++ b/lib/Model/AttendeeMapper.php @@ -309,6 +309,7 @@ public function createAttendeeFromRow(array $row): Attendee { 'unread_messages' => (int)$row['unread_messages'], 'last_attendee_activity' => (int)$row['last_attendee_activity'], 'archived' => (bool)$row['archived'], + 'important' => (bool)$row['important'], ]); } } diff --git a/lib/Model/SelectHelper.php b/lib/Model/SelectHelper.php index 14f5dc36f96..9326fd8776f 100644 --- a/lib/Model/SelectHelper.php +++ b/lib/Model/SelectHelper.php @@ -77,6 +77,7 @@ public function selectAttendeesTable(IQueryBuilder $query, string $alias = 'a'): ->addSelect($alias . 'unread_messages') ->addSelect($alias . 'last_attendee_activity') ->addSelect($alias . 'archived') + ->addSelect($alias . 'important') ->selectAlias($alias . 'id', 'a_id'); } diff --git a/lib/Notification/Listener.php b/lib/Notification/Listener.php index 7fa87742570..edc8643c2b6 100644 --- a/lib/Notification/Listener.php +++ b/lib/Notification/Listener.php @@ -285,11 +285,11 @@ protected function sendCallNotifications(Room $room): void { } $this->preparedCallNotifications = []; - $userIds = $this->participantsService->getParticipantUserIdsForCallNotifications($room); + $users = $this->participantsService->getParticipantUsersForCallNotifications($room); // Room name depends on the notification user for one-to-one, // so we avoid pre-parsing it there. Also, it comes with some base load, // so we only do it for "big enough" calls. - $preparseNotificationForPush = count($userIds) > 10; + $preparseNotificationForPush = count($users) > 10; if ($preparseNotificationForPush) { $fallbackLang = $this->serverConfig->getSystemValue('force_language', null); if (is_string($fallbackLang)) { @@ -298,13 +298,14 @@ protected function sendCallNotifications(Room $room): void { } else { $fallbackLang = $this->serverConfig->getSystemValueString('default_language', 'en'); /** @psalm-var array $userLanguages */ - $userLanguages = $this->serverConfig->getUserValueForUsers('core', 'lang', $userIds); + $userLanguages = $this->serverConfig->getUserValueForUsers('core', 'lang', array_map('strval', array_keys($users))); } } $this->connection->beginTransaction(); try { - foreach ($userIds as $userId) { + foreach ($users as $userId => $isImportant) { + $userId = (string)$userId; if ($actorId === $userId) { continue; } @@ -327,6 +328,7 @@ protected function sendCallNotifications(Room $room): void { try { $userNotification->setUser($userId); + $userNotification->setPriorityNotification($isImportant); $this->notificationManager->notify($userNotification); } catch (\InvalidArgumentException $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); @@ -359,7 +361,8 @@ protected function sendCallNotification(Room $room, ?Attendee $actor, Attendee $ $notification->setSubject('call', [ 'callee' => $actor?->getActorId(), ]) - ->setDateTime($dateTime); + ->setDateTime($dateTime) + ->setPriorityNotification($target->isImportant()); $this->notificationManager->notify($notification); } catch (\InvalidArgumentException $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index de8fb5fc50f..9ad4ea3f589 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -252,6 +252,10 @@ public function prepare(INotification $notification, string $languageCode): INot ->setIcon($this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg'))) ->setLink($this->url->linkToRouteAbsolute('spreed.Page.showCall', ['token' => $room->getToken()])); + if ($participant instanceof Participant && $this->notificationManager->isPreparingPushNotification()) { + $notification->setPriorityNotification($participant->getAttendee()->isImportant()); + } + $subject = $notification->getSubject(); if ($subject === 'record_file_stored' || $subject === 'transcript_file_stored' || $subject === 'transcript_failed' || $subject === 'summary_file_stored' || $subject === 'summary_failed') { return $this->parseStoredRecording($notification, $room, $participant, $l); diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index d89feb782e8..626a7eb3f0f 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -291,6 +291,8 @@ * unreadMentionDirect: bool, * unreadMessages: int, * isArchived: bool, + * // Required capability: `important-conversations` + * isImportant: bool, * } * * @psalm-type TalkRoomWithInvalidInvitations = TalkRoom&array{ diff --git a/lib/Service/ParticipantService.php b/lib/Service/ParticipantService.php index e8096dc2c0e..d0c4fea926b 100644 --- a/lib/Service/ParticipantService.php +++ b/lib/Service/ParticipantService.php @@ -324,6 +324,26 @@ public function unarchiveConversation(Participant $participant): void { $this->attendeeMapper->update($attendee); } + /** + * @param Participant $participant + */ + public function markConversationAsImportant(Participant $participant): void { + $attendee = $participant->getAttendee(); + $attendee->setImportant(true); + $attendee->setLastAttendeeActivity($this->timeFactory->getTime()); + $this->attendeeMapper->update($attendee); + } + + /** + * @param Participant $participant + */ + public function markConversationAsUnimportant(Participant $participant): void { + $attendee = $participant->getAttendee(); + $attendee->setImportant(false); + $attendee->setLastAttendeeActivity($this->timeFactory->getTime()); + $this->attendeeMapper->update($attendee); + } + /** * @param RoomService $roomService * @param Room $room @@ -1918,12 +1938,12 @@ public function getActorsCountByType(Room $room, string $actorType, int $maxLast /** * @param Room $room - * @return string[] + * @return array (userId => isImportant) */ - public function getParticipantUserIdsForCallNotifications(Room $room): array { + public function getParticipantUsersForCallNotifications(Room $room): array { $query = $this->connection->getQueryBuilder(); - $query->select('a.actor_id') + $query->select('a.actor_id', 'a.important') ->from('talk_attendees', 'a') ->leftJoin( 'a', 'talk_sessions', 's', @@ -1960,14 +1980,14 @@ public function getParticipantUserIdsForCallNotifications(Room $room): array { ); } - $userIds = []; + $users = []; $result = $query->executeQuery(); while ($row = $result->fetch()) { - $userIds[] = $row['actor_id']; + $users[$row['actor_id']] = (bool)$row['important']; } $result->closeCursor(); - return $userIds; + return $users; } /** diff --git a/lib/Service/RoomFormatter.php b/lib/Service/RoomFormatter.php index 32418bf8874..a6fb7ecd238 100644 --- a/lib/Service/RoomFormatter.php +++ b/lib/Service/RoomFormatter.php @@ -145,6 +145,7 @@ public function formatRoomV4( 'recordingConsent' => $this->talkConfig->recordingConsentRequired() === RecordingService::CONSENT_REQUIRED_OPTIONAL ? $room->getRecordingConsent() : $this->talkConfig->recordingConsentRequired(), 'mentionPermissions' => Room::MENTION_PERMISSIONS_EVERYONE, 'isArchived' => false, + 'isImportant' => false, ]; if ($room->isFederatedConversation()) { @@ -229,6 +230,7 @@ public function formatRoomV4( 'breakoutRoomStatus' => $room->getBreakoutRoomStatus(), 'mentionPermissions' => $room->getMentionPermissions(), 'isArchived' => $attendee->isArchived(), + 'isImportant' => $attendee->isImportant(), ]); if ($room->isFederatedConversation()) { diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index 0fd59bed2ee..ea3ec52b320 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -588,7 +588,8 @@ "unreadMention", "unreadMentionDirect", "unreadMessages", - "isArchived" + "isArchived", + "isImportant" ], "properties": { "actorId": { @@ -813,6 +814,10 @@ }, "isArchived": { "type": "boolean" + }, + "isImportant": { + "type": "boolean", + "description": "Required capability: `important-conversations`" } } }, diff --git a/openapi-federation.json b/openapi-federation.json index b50e642c861..24dde8322e3 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -642,7 +642,8 @@ "unreadMention", "unreadMentionDirect", "unreadMessages", - "isArchived" + "isArchived", + "isImportant" ], "properties": { "actorId": { @@ -867,6 +868,10 @@ }, "isArchived": { "type": "boolean" + }, + "isImportant": { + "type": "boolean", + "description": "Required capability: `important-conversations`" } } }, diff --git a/openapi-full.json b/openapi-full.json index 0cf92a3a88e..8e3124cf856 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1242,7 +1242,8 @@ "unreadMention", "unreadMentionDirect", "unreadMessages", - "isArchived" + "isArchived", + "isImportant" ], "properties": { "actorId": { @@ -1467,6 +1468,10 @@ }, "isArchived": { "type": "boolean" + }, + "isImportant": { + "type": "boolean", + "description": "Required capability: `important-conversations`" } } }, diff --git a/openapi.json b/openapi.json index 697cd3c1e9c..bad384c39ac 100644 --- a/openapi.json +++ b/openapi.json @@ -1147,7 +1147,8 @@ "unreadMention", "unreadMentionDirect", "unreadMessages", - "isArchived" + "isArchived", + "isImportant" ], "properties": { "actorId": { @@ -1372,6 +1373,10 @@ }, "isArchived": { "type": "boolean" + }, + "isImportant": { + "type": "boolean", + "description": "Required capability: `important-conversations`" } } }, diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index 30131ce6a9a..773de5f537c 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -353,6 +353,8 @@ export type components = { /** Format: int64 */ unreadMessages: number; isArchived: boolean; + /** @description Required capability: `important-conversations` */ + isImportant: boolean; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; }; diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index 4778900fc99..ee786db1cf3 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -400,6 +400,8 @@ export type components = { /** Format: int64 */ unreadMessages: number; isArchived: boolean; + /** @description Required capability: `important-conversations` */ + isImportant: boolean; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; }; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 47172780a0e..cea96cd48ce 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -2385,6 +2385,8 @@ export type components = { /** Format: int64 */ unreadMessages: number; isArchived: boolean; + /** @description Required capability: `important-conversations` */ + isImportant: boolean; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; RoomWithInvalidInvitations: components["schemas"]["Room"] & { diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index adffbaa1329..b451518c4c7 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1867,6 +1867,8 @@ export type components = { /** Format: int64 */ unreadMessages: number; isArchived: boolean; + /** @description Required capability: `important-conversations` */ + isImportant: boolean; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; RoomWithInvalidInvitations: components["schemas"]["Room"] & { diff --git a/tests/php/Chat/ChatManagerTest.php b/tests/php/Chat/ChatManagerTest.php index 72d844199dd..061a3f2cbb4 100644 --- a/tests/php/Chat/ChatManagerTest.php +++ b/tests/php/Chat/ChatManagerTest.php @@ -429,6 +429,7 @@ public function testDeleteMessage(): void { 'unread_messages' => 0, 'last_attendee_activity' => 0, 'archived' => 0, + 'important' => 0, ]); $chat = $this->createMock(Room::class); $chat->expects($this->any()) @@ -492,6 +493,7 @@ public function testDeleteMessageFileShare(): void { 'unread_messages' => 0, 'last_attendee_activity' => 0, 'archived' => 0, + 'important' => 0, ]); $chat = $this->createMock(Room::class); $chat->expects($this->any()) @@ -577,6 +579,7 @@ public function testDeleteMessageFileShareNotFound(): void { 'unread_messages' => 0, 'last_attendee_activity' => 0, 'archived' => 0, + 'important' => 0, ]); $chat = $this->createMock(Room::class); $chat->expects($this->any()) diff --git a/tests/php/Notification/NotifierTest.php b/tests/php/Notification/NotifierTest.php index 00b1623472e..7f4870b7cde 100644 --- a/tests/php/Notification/NotifierTest.php +++ b/tests/php/Notification/NotifierTest.php @@ -824,7 +824,12 @@ public function testPrepareChatMessage(string $subject, int $roomType, array $su ->with($room) ->willReturn('getAvatarUrl'); + $attendee = Attendee::fromRow([ + 'important' => false, + ]); $participant = $this->createMock(Participant::class); + $participant->method('getAttendee') + ->willReturn($attendee); $this->participantService->expects($this->once()) ->method('getParticipant') ->with($room, 'recipient')