Browse Source

feat(conversations): Add "Important conversations" which still notify during DND

Signed-off-by: Joas Schilling <coding@schilljs.com>
pull/14879/head
Joas Schilling 7 months ago
committed by backportbot[bot]
parent
commit
5196effdf0
  1. 1
      docs/capabilities.md
  2. 2
      lib/Capabilities.php
  3. 34
      lib/Controller/RoomController.php
  4. 41
      lib/Migration/Version21001Date20250328123156.php
  5. 4
      lib/Model/Attendee.php
  6. 1
      lib/Model/AttendeeMapper.php
  7. 1
      lib/Model/SelectHelper.php
  8. 13
      lib/Notification/Listener.php
  9. 4
      lib/Notification/Notifier.php
  10. 2
      lib/ResponseDefinitions.php
  11. 32
      lib/Service/ParticipantService.php
  12. 2
      lib/Service/RoomFormatter.php
  13. 3
      tests/php/Chat/ChatManagerTest.php
  14. 5
      tests/php/Notification/NotifierTest.php

1
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

2
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 = [

34
lib/Controller/RoomController.php

@ -1673,6 +1673,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

@ -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;
}
}

4
lib/Model/Attendee.php

@ -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);

1
lib/Model/AttendeeMapper.php

@ -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'],
]);
}
}

1
lib/Model/SelectHelper.php

@ -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');
}

13
lib/Notification/Listener.php

@ -284,11 +284,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)) {
@ -297,13 +297,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;
}
@ -326,6 +327,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]);
@ -358,7 +360,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]);

4
lib/Notification/Notifier.php

@ -249,6 +249,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);

2
lib/ResponseDefinitions.php

@ -291,6 +291,8 @@ namespace OCA\Talk;
* unreadMentionDirect: bool,
* unreadMessages: int,
* isArchived: bool,
* // Required capability: `important-conversations`
* isImportant: bool,
* }
*
* @psalm-type TalkRoomWithInvalidInvitations = TalkRoom&array{

32
lib/Service/ParticipantService.php

@ -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;
}
/**

2
lib/Service/RoomFormatter.php

@ -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()) {

3
tests/php/Chat/ChatManagerTest.php

@ -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())

5
tests/php/Notification/NotifierTest.php

@ -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…
Cancel
Save