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')