mirror of
https://github.com/nextcloud/spreed.git
synced 2025-12-18 05:20:50 +01:00
feat(conversations): Add "Important conversations" which still notify during DND
Signed-off-by: Joas Schilling <coding@schilljs.com>
This commit is contained in:
parent
9e2450da69
commit
7c8e3bc5aa
15 changed files with 135 additions and 12 deletions
|
|
@ -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.
|
||||
]]></description>
|
||||
|
||||
<version>22.0.0-dev.2</version>
|
||||
<version>22.0.0-dev.3</version>
|
||||
<licence>agpl</licence>
|
||||
|
||||
<author>Anna Larch</author>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -1681,6 +1681,40 @@ class RoomController extends AEnvironmentAwareOCSController {
|
|||
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<Http::STATUS_OK, TalkRoom, array{}>
|
||||
*
|
||||
* 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<Http::STATUS_OK, TalkRoom, array{}>
|
||||
*
|
||||
* 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
|
||||
*
|
||||
|
|
|
|||
41
lib/Migration/Version21001Date20250328123156.php
Normal file
41
lib/Migration/Version21001Date20250328123156.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 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\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
class Version21001Date20250328123156 extends SimpleMigrationStep {
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
* @return null|ISchemaWrapper
|
||||
*/
|
||||
#[\Override]
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
$table = $schema->getTable('talk_attendees');
|
||||
if (!$table->hasColumn('important')) {
|
||||
$table->addColumn('important', Types::BOOLEAN, [
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,8 @@ use OCP\DB\Types;
|
|||
* @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 @@ class Attendee extends Entity {
|
|||
$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);
|
||||
|
|
|
|||
|
|
@ -309,6 +309,7 @@ class AttendeeMapper extends QBMapper {
|
|||
'unread_messages' => (int)$row['unread_messages'],
|
||||
'last_attendee_activity' => (int)$row['last_attendee_activity'],
|
||||
'archived' => (bool)$row['archived'],
|
||||
'important' => (bool)$row['important'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ class SelectHelper {
|
|||
->addSelect($alias . 'unread_messages')
|
||||
->addSelect($alias . 'last_attendee_activity')
|
||||
->addSelect($alias . 'archived')
|
||||
->addSelect($alias . 'important')
|
||||
->selectAlias($alias . 'id', 'a_id');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -285,11 +285,11 @@ class Listener implements IEventListener {
|
|||
}
|
||||
|
||||
$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 @@ class Listener implements IEventListener {
|
|||
} else {
|
||||
$fallbackLang = $this->serverConfig->getSystemValueString('default_language', 'en');
|
||||
/** @psalm-var array<string, string> $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 @@ class Listener implements IEventListener {
|
|||
|
||||
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 @@ class Listener implements IEventListener {
|
|||
$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]);
|
||||
|
|
|
|||
|
|
@ -252,6 +252,10 @@ class Notifier implements INotifier {
|
|||
->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);
|
||||
|
|
|
|||
|
|
@ -291,6 +291,8 @@ namespace OCA\Talk;
|
|||
* unreadMentionDirect: bool,
|
||||
* unreadMessages: int,
|
||||
* isArchived: bool,
|
||||
* // Required capability: `important-conversations`
|
||||
* isImportant: bool,
|
||||
* }
|
||||
*
|
||||
* @psalm-type TalkRoomWithInvalidInvitations = TalkRoom&array{
|
||||
|
|
|
|||
|
|
@ -324,6 +324,26 @@ class ParticipantService {
|
|||
$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 @@ class ParticipantService {
|
|||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @return string[]
|
||||
* @return array<string, bool> (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 @@ class ParticipantService {
|
|||
);
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ class RoomFormatter {
|
|||
'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 @@ class RoomFormatter {
|
|||
'breakoutRoomStatus' => $room->getBreakoutRoomStatus(),
|
||||
'mentionPermissions' => $room->getMentionPermissions(),
|
||||
'isArchived' => $attendee->isArchived(),
|
||||
'isImportant' => $attendee->isImportant(),
|
||||
]);
|
||||
|
||||
if ($room->isFederatedConversation()) {
|
||||
|
|
|
|||
|
|
@ -429,6 +429,7 @@ class ChatManagerTest extends TestCase {
|
|||
'unread_messages' => 0,
|
||||
'last_attendee_activity' => 0,
|
||||
'archived' => 0,
|
||||
'important' => 0,
|
||||
]);
|
||||
$chat = $this->createMock(Room::class);
|
||||
$chat->expects($this->any())
|
||||
|
|
@ -492,6 +493,7 @@ class ChatManagerTest extends TestCase {
|
|||
'unread_messages' => 0,
|
||||
'last_attendee_activity' => 0,
|
||||
'archived' => 0,
|
||||
'important' => 0,
|
||||
]);
|
||||
$chat = $this->createMock(Room::class);
|
||||
$chat->expects($this->any())
|
||||
|
|
@ -577,6 +579,7 @@ class ChatManagerTest extends TestCase {
|
|||
'unread_messages' => 0,
|
||||
'last_attendee_activity' => 0,
|
||||
'archived' => 0,
|
||||
'important' => 0,
|
||||
]);
|
||||
$chat = $this->createMock(Room::class);
|
||||
$chat->expects($this->any())
|
||||
|
|
|
|||
|
|
@ -824,7 +824,12 @@ class NotifierTest extends TestCase {
|
|||
->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')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue