UPSTREAM BASELINE: nextcloud/spreed v22.0.12 (без изменений)
Type checking / changes (push) Has been cancelled
Type checking / test (push) Has been cancelled
Type checking / typescript-summary (push) Has been cancelled
Node tests / changes (push) Has been cancelled
Node tests / test (push) Has been cancelled
Node tests / test-summary (push) Has been cancelled

Источник: https://github.com/nextcloud/spreed/archive/refs/tags/v22.0.12.tar.gz
С этого коммита ветка официального Nextcloud Talk отрезана (решение владельца 2026-07-06).
Все дальнейшие изменения — только наши; версии релизов: 22.0.12-f7.N.
This commit is contained in:
2026-07-06 14:07:50 +00:00
commit 01acfa3b40
1716 changed files with 613013 additions and 0 deletions
+451
View File
@@ -0,0 +1,451 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat\AutoComplete;
use OCA\Talk\Federation\Authenticator;
use OCA\Talk\Files\Util;
use OCA\Talk\GuestManager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Room;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\TalkSession;
use OCP\Collaboration\Collaborators\ISearchPlugin;
use OCP\Collaboration\Collaborators\ISearchResult;
use OCP\Collaboration\Collaborators\SearchResultType;
use OCP\IL10N;
use OCP\IUserManager;
class SearchPlugin implements ISearchPlugin {
protected ?Room $room = null;
public function __construct(
protected IUserManager $userManager,
protected GuestManager $guestManager,
protected TalkSession $talkSession,
protected ParticipantService $participantService,
protected Util $util,
protected ?string $userId,
protected IL10N $l,
protected Authenticator $federationAuthenticator,
) {
}
public function setContext(array $context): void {
$this->room = $context['room'];
}
/**
* @param string $search
* @param int $limit
* @param int $offset
* @param ISearchResult $searchResult
* @return bool whether the plugin has more results
* @since 13.0.0
*/
#[\Override]
public function search($search, $limit, $offset, ISearchResult $searchResult): bool {
if ($this->room->getObjectType() === 'file') {
$usersWithFileAccess = $this->util->getUsersWithAccessFile($this->room->getObjectId());
if (!empty($usersWithFileAccess)) {
$users = [];
foreach ($usersWithFileAccess as $userId) {
$users[$userId] = $this->userManager->getDisplayName($userId) ?? $userId;
}
$this->searchUsers($search, $users, $searchResult);
}
}
/** @var array<string, string> $userIds */
$userIds = [];
/** @var array<string, string> $groupIds */
$groupIds = [];
/** @var array<string, string> $cloudIds */
$cloudIds = [];
/** @var array<string, Attendee> $emailAttendees */
$emailAttendees = [];
/** @var list<Attendee> $guestAttendees */
$guestAttendees = [];
/** @var array<string, string> $teamIds */
$teamIds = [];
if ($this->room->getType() === Room::TYPE_ONE_TO_ONE) {
// Add potential leavers of one-to-one rooms again.
$participants = json_decode($this->room->getName(), true);
foreach ($participants as $userId) {
$userIds[$userId] = $this->userManager->getDisplayName($userId) ?? $userId;
}
} else {
$participants = $this->participantService->getParticipantsForRoom($this->room);
foreach ($participants as $participant) {
$attendee = $participant->getAttendee();
if ($attendee->getActorType() === Attendee::ACTOR_GUESTS) {
$guestAttendees[] = $attendee;
} elseif ($attendee->getActorType() === Attendee::ACTOR_EMAILS) {
$emailAttendees[$attendee->getActorId()] = $attendee;
} elseif ($attendee->getActorType() === Attendee::ACTOR_USERS) {
$userIds[$attendee->getActorId()] = $attendee->getDisplayName();
} elseif ($attendee->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
$cloudIds[$attendee->getActorId()] = $attendee->getDisplayName();
} elseif ($attendee->getActorType() === Attendee::ACTOR_GROUPS) {
$groupIds[$attendee->getActorId()] = $attendee->getDisplayName();
} elseif ($attendee->getActorType() === Attendee::ACTOR_CIRCLES) {
$teamIds[$attendee->getActorId()] = $attendee->getDisplayName();
}
}
}
$this->searchUsers($search, $userIds, $searchResult);
$this->searchGroups($search, $groupIds, $searchResult);
$this->searchGuests($search, $guestAttendees, $searchResult);
$this->searchEmails($search, $emailAttendees, $searchResult);
$this->searchFederatedUsers($search, $cloudIds, $searchResult);
$this->searchTeams($search, $teamIds, $searchResult);
return false;
}
/**
* @param array<string|int, string> $users
*/
protected function searchUsers(string $search, array $users, ISearchResult $searchResult): void {
$search = mb_strtolower($search);
$type = new SearchResultType('users');
$matches = $exactMatches = [];
foreach ($users as $userId => $displayName) {
$userId = (string)$userId;
if ($searchResult->hasResult($type, $userId)) {
continue;
}
if ($search === '') {
$matches[] = $this->createResult('user', $userId, $displayName);
continue;
}
if (strtolower($userId) === $search) {
$exactMatches[] = $this->createResult('user', $userId, $displayName);
continue;
}
if (stripos($userId, $search) !== false) {
$matches[] = $this->createResult('user', $userId, $displayName);
continue;
}
if ($displayName === '') {
continue;
}
if (mb_strtolower($displayName) === $search) {
$exactMatches[] = $this->createResult('user', $userId, $displayName);
continue;
}
if (mb_stripos($displayName, $search) !== false) {
$matches[] = $this->createResult('user', $userId, $displayName);
continue;
}
}
$searchResult->addResultSet($type, $matches, $exactMatches);
}
/**
* @param array<string, string> $cloudIds
*/
protected function searchFederatedUsers(string $search, array $cloudIds, ISearchResult $searchResult): void {
$search = mb_strtolower($search);
$type = new SearchResultType('federated_users');
$matches = $exactMatches = [];
foreach ($cloudIds as $cloudId => $displayName) {
if ($searchResult->hasResult($type, $cloudId)) {
continue;
}
if ($search === '') {
$matches[] = $this->createResult('federated_user', $cloudId, $displayName);
continue;
}
if (mb_strtolower($cloudId) === $search) {
$exactMatches[] = $this->createResult('federated_user', $cloudId, $displayName);
continue;
}
if (stripos($cloudId, $search) !== false) {
$matches[] = $this->createResult('federated_user', $cloudId, $displayName);
continue;
}
if ($displayName === '') {
continue;
}
if (mb_strtolower($displayName) === $search) {
$exactMatches[] = $this->createResult('federated_user', $cloudId, $displayName);
continue;
}
if (mb_stripos($displayName, $search) !== false) {
$matches[] = $this->createResult('federated_user', $cloudId, $displayName);
continue;
}
}
$searchResult->addResultSet($type, $matches, $exactMatches);
}
/**
* @param array<string|int, string> $groups
*/
protected function searchGroups(string $search, array $groups, ISearchResult $searchResult): void {
$search = mb_strtolower($search);
$type = new SearchResultType('groups');
$matches = $exactMatches = [];
foreach ($groups as $groupId => $displayName) {
if ($displayName === '') {
continue;
}
$groupId = (string)$groupId;
if ($searchResult->hasResult($type, $groupId)) {
continue;
}
if ($search === '') {
$matches[] = $this->createGroupResult($groupId, $displayName);
continue;
}
if (mb_strtolower($groupId) === $search) {
$exactMatches[] = $this->createGroupResult($groupId, $displayName);
continue;
}
if (mb_stripos($groupId, $search) !== false) {
$matches[] = $this->createGroupResult($groupId, $displayName);
continue;
}
if (mb_strtolower($displayName) === $search) {
$exactMatches[] = $this->createGroupResult($groupId, $displayName);
continue;
}
if (mb_stripos($displayName, $search) !== false) {
$matches[] = $this->createGroupResult($groupId, $displayName);
continue;
}
}
$searchResult->addResultSet($type, $matches, $exactMatches);
}
/**
* @param string $search
* @param list<Attendee> $attendees
* @param ISearchResult $searchResult
*/
protected function searchGuests(string $search, array $attendees, ISearchResult $searchResult): void {
if (empty($attendees)) {
$type = new SearchResultType('guests');
$searchResult->addResultSet($type, [], []);
return;
}
$search = mb_strtolower($search);
$matches = $exactMatches = [];
foreach ($attendees as $attendee) {
$name = $attendee->getDisplayName() ?: $this->l->t('Guest');
if ($search === '') {
$matches[] = $this->createGuestResult($attendee->getActorId(), $name);
continue;
}
if (mb_strtolower($name) === $search) {
$exactMatches[] = $this->createGuestResult($attendee->getActorId(), $name);
continue;
}
if (mb_stripos($name, $search) !== false) {
$matches[] = $this->createGuestResult($attendee->getActorId(), $name);
continue;
}
}
$type = new SearchResultType('guests');
$searchResult->addResultSet($type, $matches, $exactMatches);
}
/**
* @param string $search
* @param array<string, Attendee> $attendees
* @param ISearchResult $searchResult
*/
protected function searchEmails(string $search, array $attendees, ISearchResult $searchResult): void {
if (empty($attendees)) {
$type = new SearchResultType('emails');
$searchResult->addResultSet($type, [], []);
return;
}
$search = mb_strtolower($search);
$currentSessionHash = null;
if (!$this->userId) {
// Best effort: Might not work on guests that reloaded but not worth too much performance impact atm.
$currentSessionHash = false; // FIXME sha1($this->talkSession->getSessionForRoom($this->room->getToken()));
}
$matches = $exactMatches = [];
foreach ($attendees as $actorId => $attendee) {
if ($currentSessionHash === $actorId) {
// Do not suggest the current guest
continue;
}
$displayName = $attendee->getDisplayName() ?: $this->l->t('Guest');
if ($search === '') {
$matches[] = $this->createEmailResult($actorId, $displayName, $attendee->getInvitedCloudId());
continue;
}
if (mb_strtolower($displayName) === $search) {
$exactMatches[] = $this->createEmailResult($actorId, $displayName, $attendee->getInvitedCloudId());
continue;
}
if (mb_stripos($displayName, $search) !== false) {
$matches[] = $this->createEmailResult($actorId, $displayName, $attendee->getInvitedCloudId());
continue;
}
}
$type = new SearchResultType('emails');
$searchResult->addResultSet($type, $matches, $exactMatches);
}
/**
* @param string $search
* @param array<string, Attendee> $attendees
* @param ISearchResult $searchResult
*/
/**
* @param array<string|int, string> $teams
*/
protected function searchTeams(string $search, array $teams, ISearchResult $searchResult): void {
$search = mb_strtolower($search);
$type = new SearchResultType('teams');
$matches = $exactMatches = [];
foreach ($teams as $teamId => $displayName) {
if ($displayName === '') {
continue;
}
$teamId = (string)$teamId;
if ($searchResult->hasResult($type, $teamId)) {
continue;
}
if ($search === '') {
$matches[] = $this->createTeamResult($teamId, $displayName);
continue;
}
if (strtolower($teamId) === $search) {
$exactMatches[] = $this->createTeamResult($teamId, $displayName);
continue;
}
if (stripos($teamId, $search) !== false) {
$matches[] = $this->createTeamResult($teamId, $displayName);
continue;
}
if (mb_strtolower($displayName) === $search) {
$exactMatches[] = $this->createTeamResult($teamId, $displayName);
continue;
}
if (mb_stripos($displayName, $search) !== false) {
$matches[] = $this->createTeamResult($teamId, $displayName);
}
}
$searchResult->addResultSet($type, $matches, $exactMatches);
}
protected function createResult(string $type, string $uid, string $name): array {
if ($type === 'user' && $name === '') {
$name = $this->userManager->getDisplayName($uid) ?? $uid;
}
return [
'label' => $name,
'value' => [
'shareType' => $type,
'shareWith' => $uid,
],
];
}
protected function createGroupResult(string $groupId, string $name): array {
return [
'label' => $name,
'value' => [
'shareType' => 'group',
'shareWith' => 'group/' . $groupId,
],
];
}
protected function createGuestResult(string $actorId, string $name): array {
return [
'label' => $name,
'value' => [
'shareType' => 'guest',
'shareWith' => 'guest/' . $actorId,
],
];
}
protected function createEmailResult(string $actorId, string $name, ?string $email): array {
$data = [
'label' => $name,
'value' => [
'shareType' => 'email',
'shareWith' => 'email/' . $actorId,
],
];
if ($email) {
$data['details'] = ['email' => $email];
}
return $data;
}
protected function createTeamResult(string $actorId, string $name): array {
return [
'label' => $name,
'value' => [
'shareType' => 'team',
'shareWith' => 'team/' . $actorId,
],
];
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat\AutoComplete;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Chat\CommentsManager;
use OCP\Collaboration\AutoComplete\ISorter;
class Sorter implements ISorter {
public function __construct(
protected CommentsManager $commentsManager,
) {
}
/**
* @return string The ID of the sorter, e.g. commenters
* @since 13.0.0
*/
#[\Override]
public function getId(): string {
return 'talk_chat_participants';
}
/**
* executes the sort action
*
* @param array $sortArray the array to be sorted, provided as reference
* @param array{itemType: string, itemId: string, search?: string, selfUserId?: ?string, selfCloudId?: ?string} $context carries key 'itemType' and 'itemId' of the source object (e.g. a file)
* @since 13.0.0
*/
#[\Override]
public function sort(array &$sortArray, array $context): void {
foreach ($sortArray as $type => &$byType) {
if ($type !== 'users') {
continue;
}
/** @var \DateTime[] $lastComments */
$lastComments = $this->commentsManager->getLastCommentDateByActor(
$context['itemType'],
$context['itemId'],
ChatManager::VERB_MESSAGE,
$type,
array_map(function (array $suggestion) {
return $suggestion['value']['shareWith'];
}, $byType));
$search = $context['search'];
$selfUserId = $context['selfUserId'] ?? null;
usort($byType, static function (array $a, array $b) use ($lastComments, $search, $selfUserId) {
if ($selfUserId === $a['value']['shareWith']) {
return 1;
}
if ($selfUserId === $b['value']['shareWith']) {
return -1;
}
if ($search) {
// If the user searched for "Dani" we make sure "Daniel" comes before "Madani"
if (stripos($a['label'], $search) === 0) {
if (stripos($b['label'], $search) !== 0) {
return -1;
}
} elseif (stripos($b['label'], $search) === 0) {
return 1;
}
}
if (!isset($lastComments[$b['value']['shareWith']])) {
return -1;
}
if (!isset($lastComments[$a['value']['shareWith']])) {
return 1;
}
return $lastComments[$b['value']['shareWith']]->getTimestamp() - $lastComments[$a['value']['shareWith']]->getTimestamp();
});
}
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat\Changelog;
use OCA\Talk\Events\BeforeRoomsFetchEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IConfig;
/**
* @template-implements IEventListener<Event>
*/
class Listener implements IEventListener {
public function __construct(
protected Manager $manager,
protected IConfig $serverConfig,
) {
}
#[\Override]
public function handle(Event $event): void {
if (!$event instanceof BeforeRoomsFetchEvent) {
return;
}
if ($this->serverConfig->getAppValue('spreed', 'changelog', 'yes') !== 'yes') {
return;
}
$this->manager->updateChangelog($event->getUserId());
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat\Changelog;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Manager as RoomManager;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IL10N;
use OCP\PreConditionNotMetException;
class Manager {
public function __construct(
protected IConfig $config,
protected IDBConnection $connection,
protected RoomManager $roomManager,
protected ChatManager $chatManager,
protected ITimeFactory $timeFactory,
protected IL10N $l,
) {
}
public function getChangelogForUser(string $userId): int {
return (int)$this->config->getUserValue($userId, 'spreed', 'changelog', '0');
}
public function updateChangelog(string $userId): void {
$logs = $this->getChangelogs();
$hasReceivedLog = $this->getChangelogForUser($userId);
$shouldHaveReceived = count($logs);
if ($hasReceivedLog === $shouldHaveReceived) {
return;
}
try {
$this->config->setUserValue($userId, 'spreed', 'changelog', (string)$shouldHaveReceived, (string)$hasReceivedLog);
} catch (PreConditionNotMetException) {
// Parallel request won the race
return;
}
$room = $this->roomManager->getChangelogRoom($userId);
foreach ($logs as $key => $changelog) {
if ($key < $hasReceivedLog || $changelog === '') {
continue;
}
$this->chatManager->addChangelogMessage($room, $changelog);
}
}
public function getChangelogs(): array {
return [
$this->l->t(
"## Welcome to Nextcloud Talk!\n"
. 'In this conversation you will be informed about new features available in Nextcloud Talk.'
),
$this->l->t('## New in Talk %s', ['6']),
$this->l->t('- Microsoft Edge and Safari can now be used to participate in audio and video calls'),
$this->l->t('- One-to-one conversations are now persistent and cannot be turned into group conversations by accident anymore. Also when one of the participants leaves the conversation, the conversation is not automatically deleted anymore. Only if both participants leave, the conversation is deleted from the server'),
$this->l->t('- You can now notify all participants by posting "@all" into the chat'),
$this->l->t('- With the "arrow-up" key you can repost your last message'),
$this->l->t('- Talk can now have commands, send "/help" as a chat message to see if your administrator configured some'),
$this->l->t('- With projects you can create quick links between conversations, files and other items'),
$this->l->t('## New in Talk %s', ['7']),
$this->l->t('- You can now mention guests in the chat'),
$this->l->t('- Conversations can now have a lobby. This will allow moderators to join the chat and call already to prepare the meeting, while users and guests have to wait'),
$this->l->t('## New in Talk %s', ['8']),
$this->l->t('- You can now directly reply to messages giving the other users more context what your message is about'),
$this->l->t('- Searching for conversations and participants will now also filter your existing conversations, making it much easier to find previous conversations'),
$this->l->t('- You can now add custom user groups to conversations when the circles app is installed'),
$this->l->t('## New in Talk %s', ['9']),
$this->l->t('- Check out the new grid and call view'),
$this->l->t('- You can now upload and drag\'n\'drop files directly from your device into the chat'),
$this->l->t('- Shared files are now opened directly inside the chat view with the viewer apps'),
$this->l->t('## New in Talk %s', ['10']),
$this->l->t('- You can now search for chats and messages in the unified search in the top bar'),
$this->l->t('- Spice up your messages with emojis from the emoji picker'),
$this->l->t('- You can now change your camera and microphone while being in a call'),
$this->l->t('## New in Talk %s', ['11']),
$this->l->t('- Give your conversations some context with a description and open it up so logged in users can find it and join themselves'),
$this->l->t('- See a read status and send failed messages again'),
$this->l->t('- Raise your hand in a call with the R key'),
$this->l->t('## New in Talk %s', ['12']),
$this->l->t('- Join the same conversation and call from multiple devices'),
$this->l->t('- Send voice messages, share your location or contact details'),
$this->l->t('- Add groups to a conversation and new group members will automatically be added as participants'),
$this->l->t('## New in Talk %s', ['13']),
$this->l->t('- A preview of your audio and video is shown before joining a call'),
$this->l->t('- You can now blur your background in the newly designed call view'),
$this->l->t('- Moderators can now assign general and individual permissions to participants'),
$this->l->t('## New in Talk %s', ['14']),
$this->l->t('- You can now react to chat messages'),
$this->l->t('- In the sidebar you can now find an overview of the latest shared items'),
$this->l->t('## New in Talk %s', ['15']),
$this->l->t('- Use a poll to collect the opinions of others or settle on a date'),
$this->l->t('- Configure an expiration time for chat messages'),
$this->l->t('- Start calls without notifying others in big conversations. You can send individual call notifications once the call has started.'),
$this->l->t('- Send chat messages without notifying the recipients in case it is not urgent'),
$this->l->t('## New in Talk %s', ['16']),
$this->l->t('- Emojis can now be autocompleted by typing a ":"'),
$this->l->t('- Link various items using the new smart-picker by typing a "/"'),
$this->l->t('- Moderators can now create breakout rooms (requires the High-performance backend)'),
$this->l->t('- Calls can now be recorded (requires the High-performance backend)'),
$this->l->t('## New in Talk %s', ['17']) . "\n"
. $this->l->t('- Conversations can now have an avatar or emoji as icon') . "\n"
. $this->l->t('- Virtual backgrounds are now available in addition to the blurred background in video calls') . "\n"
. $this->l->t('- Reactions are now available during calls') . "\n"
. $this->l->t('- Typing indicators show which users are currently typing a message') . "\n"
. $this->l->t('- Groups can now be mentioned in chats') . "\n"
. $this->l->t('- Call recordings are automatically transcribed if a transcription provider app is registered') . "\n"
. $this->l->t('- Chat messages can be translated if a translation provider app is registered'),
$this->l->t('## New in Talk %s', ['17.1']) . "\n"
. $this->l->t('- **Markdown** can now be used in _chat_ messages') . "\n"
. $this->l->t('- Webhooks are now available to implement bots. See the documentation for more information https://nextcloud-talk.readthedocs.io/en/latest/bot-list/') . "\n"
. $this->l->t('- Set a reminder on a chat message to be notified later again'),
$this->l->t('## New in Talk %s', ['18']) . "\n"
. $this->l->t('- Use the **Note to self** conversation to take notes and share information between your devices') . "\n"
. $this->l->t('- Captions allow to send a message with a file at the same time') . "\n"
. $this->l->t('- Video of the speaker is now visible while sharing the screen and call reactions are animated'),
$this->l->t('## New in Talk %s', ['19']) . "\n"
. $this->l->t('- Messages can now be edited by logged-in authors and moderators for 6 hours') . "\n"
. $this->l->t('- Unsent message drafts are now saved in your browser') . "\n"
. $this->l->t('- Text chatting can now be done in a federated way with other Talk servers'),
$this->l->t('## New in Talk %s', ['20']) . "\n"
. $this->l->t('- Moderators can now ban accounts and guests to prevent them from rejoining a conversation') . "\n"
. $this->l->t('- Upcoming calls from linked calendar events and out-of-office replacements are now shown in conversations') . "\n"
. $this->l->t('- Calls can now be done in a federated way with other Talk servers (requires the High-performance backend)'),
$this->l->t('## New in Talk %s', ['20.1']) . "\n"
. $this->l->t('- Introducing the Nextcloud Talk desktop client for Windows, macOS and Linux: %s', ['https://nextcloud.com/talk-desktop-install']) . "\n"
. $this->l->t('- Summarize call recordings and unread messages in chats with the Nextcloud Assistant') . "\n"
. $this->l->t('- Improved meetings with recognizing guests invited via their email address, import of participant lists, drafts for polls and downloading call participant lists') . "\n"
. $this->l->t('- Archive conversations to stay focused'),
$this->l->t('## New in Talk %s', ['21']) . "\n"
. $this->l->t('- Schedule a meeting into your calendar from within a conversation') . "\n"
. $this->l->t('- Search for messages of the current conversation directly in the right sidebar') . "\n"
. $this->l->t('- See more conversations on a first glance with the new compact list (enable in the Talk settings)'),
$this->l->t('## New in Talk %s', ['21.1']) . "\n"
. $this->l->t('- Meeting conversations now sync the title and description from the calendar and are hidden with a search filter until they are close to the start') . "\n"
. $this->l->t('- Mark conversations as sensitive in the notification settings, to hide the message content from the conversation list and notifications') . "\n"
. $this->l->t('- To receive push notifications during "Do not disturb", mark conversations as important') . "\n"
. $this->l->t('- Add other participants to a one-to-one call to create a new group call on the fly') . "\n",
$this->l->t('## New in Talk %s', ['22']) . "\n"
. $this->l->t('- Use threads to keep your chat and discussions organized') . "\n"
. $this->l->t('- Live transcriptions now available during the call (requires the live-transcription ExApp and the High-performance backend)') . "\n",
];
}
}
File diff suppressed because it is too large Load Diff
+309
View File
@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat;
use OC\Comments\Comment;
use OC\Comments\Manager;
use OCP\Comments\IComment;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
class CommentsManager extends Manager {
/**
* @param array $data
* @return IComment
*/
public function getCommentFromData(array $data): IComment {
$message = $data['message'];
unset($data['message']);
$comment = new Comment($this->normalizeDatabaseData($data));
$comment->setMessage($message, ChatManager::MAX_CHAT_LENGTH);
return $comment;
}
/**
* @param string[] $ids
* @return IComment[]
* @throws Exception
*/
public function getCommentsById(array $ids): array {
$commentIds = array_map('intval', $ids);
$query = $this->dbConn->getQueryBuilder();
$query->select('*')
->from('comments')
->where($query->expr()->in('id', $query->createNamedParameter($commentIds, IQueryBuilder::PARAM_INT_ARRAY)));
$comments = [];
$result = $query->execute();
while ($row = $result->fetch()) {
$comments[(int)$row['id']] = $this->getCommentFromData($row);
}
$result->closeCursor();
return $comments;
}
/**
* FIXME: TEMPORARY method until https://github.com/nextcloud/server/pull/53896 is merged
*
* @param string $objectType the object type, e.g. 'files'
* @param string $objectId the id of the object
* @param int $lastKnownCommentId the last known comment (will be used as offset)
* @param string $sortDirection direction of the comments (`asc` or `desc`)
* @param int $limit optional, number of maximum comments to be returned. if
* set to 0, all comments are returned.
* @param bool $includeLastKnown
* @return list<IComment>
*/
#[\Override]
public function getForObjectSince(
string $objectType,
string $objectId,
int $lastKnownCommentId,
string $sortDirection = 'asc',
int $limit = 30,
bool $includeLastKnown = false,
string $topmostParentId = '',
): array {
return $this->getCommentsWithVerbForObjectSinceComment(
$objectType,
$objectId,
[],
$lastKnownCommentId,
$sortDirection,
$limit,
$includeLastKnown,
$topmostParentId,
);
}
/**
* FIXME: TEMPORARY method until https://github.com/nextcloud/server/pull/53896 is merged
*
* @param string $objectType the object type, e.g. 'files'
* @param string $objectId the id of the object
* @param string[] $verbs List of verbs to filter by
* @param int $lastKnownCommentId the last known comment (will be used as offset)
* @param string $sortDirection direction of the comments (`asc` or `desc`)
* @param int $limit optional, number of maximum comments to be returned. if
* set to 0, all comments are returned.
* @param bool $includeLastKnown
* @return list<IComment>
*/
#[\Override]
public function getCommentsWithVerbForObjectSinceComment(
string $objectType,
string $objectId,
array $verbs,
int $lastKnownCommentId,
string $sortDirection = 'asc',
int $limit = 30,
bool $includeLastKnown = false,
string $topmostParentId = '',
): array {
$comments = [];
$query = $this->dbConn->getQueryBuilder();
$query->select('*')
->from('comments')
->where($query->expr()->eq('object_type', $query->createNamedParameter($objectType)))
->andWhere($query->expr()->eq('object_id', $query->createNamedParameter($objectId)))
->orderBy('creation_timestamp', $sortDirection === 'desc' ? 'DESC' : 'ASC')
->addOrderBy('id', $sortDirection === 'desc' ? 'DESC' : 'ASC');
if ($limit > 0) {
$query->setMaxResults($limit);
}
if (!empty($verbs)) {
$query->andWhere($query->expr()->in('verb', $query->createNamedParameter($verbs, IQueryBuilder::PARAM_STR_ARRAY)));
}
if ($topmostParentId !== '') {
$query->andWhere($query->expr()->orX(
$query->expr()->eq('id', $query->createNamedParameter($topmostParentId)),
$query->expr()->eq('topmost_parent_id', $query->createNamedParameter($topmostParentId)),
));
}
$lastKnownComment = $lastKnownCommentId > 0 ? $this->getLastKnownComment(
$objectType,
$objectId,
$lastKnownCommentId
) : null;
if ($lastKnownComment instanceof IComment) {
$lastKnownCommentDateTime = $lastKnownComment->getCreationDateTime();
if ($sortDirection === 'desc') {
if ($includeLastKnown) {
$idComparison = $query->expr()->lte('id', $query->createNamedParameter($lastKnownCommentId));
} else {
$idComparison = $query->expr()->lt('id', $query->createNamedParameter($lastKnownCommentId));
}
$query->andWhere(
$query->expr()->orX(
$query->expr()->lt(
'creation_timestamp',
$query->createNamedParameter($lastKnownCommentDateTime, IQueryBuilder::PARAM_DATETIME_MUTABLE),
IQueryBuilder::PARAM_DATETIME_MUTABLE
),
$query->expr()->andX(
$query->expr()->eq(
'creation_timestamp',
$query->createNamedParameter($lastKnownCommentDateTime, IQueryBuilder::PARAM_DATETIME_MUTABLE),
IQueryBuilder::PARAM_DATETIME_MUTABLE
),
$idComparison
)
)
);
} else {
if ($includeLastKnown) {
$idComparison = $query->expr()->gte('id', $query->createNamedParameter($lastKnownCommentId));
} else {
$idComparison = $query->expr()->gt('id', $query->createNamedParameter($lastKnownCommentId));
}
$query->andWhere(
$query->expr()->orX(
$query->expr()->gt(
'creation_timestamp',
$query->createNamedParameter($lastKnownCommentDateTime, IQueryBuilder::PARAM_DATETIME_MUTABLE),
IQueryBuilder::PARAM_DATETIME_MUTABLE
),
$query->expr()->andX(
$query->expr()->eq(
'creation_timestamp',
$query->createNamedParameter($lastKnownCommentDateTime, IQueryBuilder::PARAM_DATETIME_MUTABLE),
IQueryBuilder::PARAM_DATETIME_MUTABLE
),
$idComparison
)
)
);
}
} elseif ($lastKnownCommentId > 0) {
// We didn't find the "$lastKnownComment" but we still use the ID as an offset.
// This is required as a fall-back for expired messages in talk and deleted comments in other apps.
if ($sortDirection === 'desc') {
if ($includeLastKnown) {
$query->andWhere($query->expr()->lte('id', $query->createNamedParameter($lastKnownCommentId)));
} else {
$query->andWhere($query->expr()->lt('id', $query->createNamedParameter($lastKnownCommentId)));
}
} else {
if ($includeLastKnown) {
$query->andWhere($query->expr()->gte('id', $query->createNamedParameter($lastKnownCommentId)));
} else {
$query->andWhere($query->expr()->gt('id', $query->createNamedParameter($lastKnownCommentId)));
}
}
}
$resultStatement = $query->execute();
while ($data = $resultStatement->fetch()) {
$comment = $this->getCommentFromData($data);
$this->cache($comment);
$comments[] = $comment;
}
$resultStatement->closeCursor();
return $comments;
}
/**
* @param string $actorType
* @param string $actorId
* @param string[] $messageIds
* @return array
* @psalm-return array<int, string[]>
*/
public function retrieveReactionsByActor(string $actorType, string $actorId, array $messageIds): array {
$commentIds = array_map('intval', $messageIds);
$query = $this->dbConn->getQueryBuilder();
$query->select('*')
->from('reactions')
->where($query->expr()->eq('actor_type', $query->createNamedParameter($actorType)))
->andWhere($query->expr()->eq('actor_id', $query->createNamedParameter($actorId)))
->andWhere($query->expr()->in('parent_id', $query->createNamedParameter($commentIds, IQueryBuilder::PARAM_INT_ARRAY)));
$reactions = [];
$result = $query->executeQuery();
while ($row = $result->fetch()) {
$reactions[(int)$row['parent_id']] ??= [];
$reactions[(int)$row['parent_id']][] = $row['reaction'];
}
$result->closeCursor();
return $reactions;
}
/**
* Search for comments on one or more objects with a given content
*
* @param string $search content to search for
* @param string $objectType Limit the search by object type
* @param string[] $objectIds Limit the search by object ids
* @param string[] $verbs Limit the verb of the comment
* @return list<IComment>
*/
public function searchForObjectsWithFilters(string $search, string $objectType, array $objectIds, array $verbs, ?\DateTimeImmutable $since, ?\DateTimeImmutable $until, ?string $actorType, ?string $actorId, int $offset, int $limit = 50): array {
$query = $this->dbConn->getQueryBuilder();
$query->select('*')
->from('comments')
->orderBy('creation_timestamp', 'DESC')
->addOrderBy('id', 'DESC')
->setMaxResults($limit);
if ($search !== '') {
$query->where($query->expr()->iLike('message', $query->createNamedParameter(
'%' . $this->dbConn->escapeLikeParameter($search) . '%'
)));
}
if ($since !== null) {
$query->andWhere($query->expr()->gte('creation_timestamp', $query->createNamedParameter($since, IQueryBuilder::PARAM_DATE), IQueryBuilder::PARAM_DATE));
}
if ($until !== null) {
$query->andWhere($query->expr()->lte('creation_timestamp', $query->createNamedParameter($until, IQueryBuilder::PARAM_DATE), IQueryBuilder::PARAM_DATE));
}
if ($actorType !== null && $actorId !== null) {
$query->andWhere($query->expr()->lte('actor_type', $query->createNamedParameter($actorType)))
->andWhere($query->expr()->lte('actor_id', $query->createNamedParameter($actorId)));
}
if ($objectType !== '') {
$query->andWhere($query->expr()->eq('object_type', $query->createNamedParameter($objectType)));
}
if (!empty($objectIds)) {
$query->andWhere($query->expr()->in('object_id', $query->createNamedParameter($objectIds, IQueryBuilder::PARAM_STR_ARRAY)));
}
if (!empty($verbs)) {
$query->andWhere($query->expr()->in('verb', $query->createNamedParameter($verbs, IQueryBuilder::PARAM_STR_ARRAY)));
}
if ($offset !== 0) {
$query->setFirstResult($offset);
}
$comments = [];
$result = $query->executeQuery();
while ($data = $result->fetch()) {
$comment = $this->getCommentFromData($data);
$this->cache($comment);
$comments[] = $comment;
}
$result->closeCursor();
return $comments;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat;
use OCA\Talk\Events\RoomDeletedEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/**
* @template-implements IEventListener<Event>
*/
class Listener implements IEventListener {
public function __construct(
protected ChatManager $chatManager,
) {
}
#[\Override]
public function handle(Event $event): void {
if ($event instanceof RoomDeletedEvent) {
$this->chatManager->deleteMessages($event->getRoom());
}
}
}
+201
View File
@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat;
use OCA\Talk\Events\MessageParseEvent;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\MatterbridgeManager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\Message;
use OCA\Talk\Model\ProxyCacheMessage;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCA\Talk\Service\BotService;
use OCA\Talk\Service\ParticipantService;
use OCP\Comments\IComment;
use OCP\Comments\ICommentsManager;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
use OCP\IUser;
use OCP\IUserManager;
/**
* Helper class to get a rich message from a plain text message.
*/
class MessageParser {
protected array $guestNames = [];
protected array $federatedUsersNames = [];
protected array $bots = [];
protected array $botNames = [];
public function __construct(
protected IEventDispatcher $dispatcher,
protected IUserManager $userManager,
protected ParticipantService $participantService,
protected BotService $botService,
) {
}
public function createMessage(Room $room, ?Participant $participant, IComment $comment, IL10N $l): Message {
return new Message($room, $participant, $comment, $l);
}
public function createMessageFromProxyCache(Room $room, ?Participant $participant, ProxyCacheMessage $proxy, IL10N $l): Message {
$message = new Message($room, $participant, null, $l, $proxy);
$message->setActor(
$proxy->getActorType(),
$proxy->getActorId(),
$proxy->getActorDisplayName() ?? '',
);
$message->setMessageType($proxy->getMessageType());
$message->setMessage(
$proxy->getMessage(),
$proxy->getParsedMessageParameters()
);
return $message;
}
/**
* @param bool $allowInaccurate File share messages will not have fully correct data for the file object
* E.g. path is only the file name and preview generation is estimated by
* mimetype only. This is done to prevent a filesystem setup.
*/
public function parseMessage(Message $message, bool $allowInaccurate = false): void {
$message->setMessage($message->getComment()->getMessage(), []);
$verb = $message->getComment()->getVerb();
if ($verb === ChatManager::VERB_OBJECT_SHARED) {
$verb = ChatManager::VERB_SYSTEM;
}
$message->setMessageType($verb);
$this->setMessageActor($message);
$this->setLastEditInfo($message);
$event = new MessageParseEvent($message->getRoom(), $message, $allowInaccurate);
$this->dispatcher->dispatchTyped($event);
}
protected function setMessageActor(Message $message): void {
[$actorType, $actorId, $displayName] = $this->getActorInformation(
$message,
$message->getComment()->getActorType(),
$message->getComment()->getActorId()
);
$message->setActor(
$actorType,
$actorId,
$displayName
);
}
protected function setLastEditInfo(Message $message): void {
$metaData = $message->getComment()->getMetaData();
if (!empty($metaData)) {
if (isset($metaData['last_edited_by_type'], $metaData['last_edited_by_id'], $metaData['last_edited_time'])) {
[$actorType, $actorId, $displayName] = $this->getActorInformation(
$message,
$metaData['last_edited_by_type'],
$metaData['last_edited_by_id'],
$metaData['last_edited_by_displayname'] ?? '',
);
$message->setLastEdit(
$actorType,
$actorId,
$displayName,
$metaData['last_edited_time']
);
}
}
}
protected function getActorInformation(Message $message, string $actorType, string $actorId, string $displayName = ''): array {
if ($actorType === Attendee::ACTOR_USERS) {
$tempDisplayName = $this->userManager->getDisplayName($actorId);
if ($tempDisplayName === null) {
$user = $this->userManager->get($actorId);
if (!$user instanceof IUser) {
// Deleted user
return [
ICommentsManager::DELETED_USER,
ICommentsManager::DELETED_USER,
'',
];
}
$displayName = $user->getDisplayName();
} else {
$displayName = $tempDisplayName;
}
} elseif ($actorType === Attendee::ACTOR_BRIDGED) {
$displayName = $actorId;
$actorId = MatterbridgeManager::BRIDGE_BOT_USERID;
} elseif (($actorType === Attendee::ACTOR_GUESTS || $actorType === Attendee::ACTOR_EMAILS)
&& !in_array($actorId, [Attendee::ACTOR_ID_CLI, Attendee::ACTOR_ID_SYSTEM, Attendee::ACTOR_ID_CHANGELOG, Attendee::ACTOR_ID_SAMPLE], true)) {
$cacheKey = $actorType . '/' . $actorId;
if (isset($this->guestNames[$cacheKey])) {
$displayName = $this->guestNames[$cacheKey];
} else {
try {
$participant = $this->participantService->getParticipantByActor($message->getRoom(), $actorType, $actorId);
$displayName = $participant->getAttendee()->getDisplayName();
} catch (ParticipantNotFoundException) {
}
$this->guestNames[$cacheKey] = $displayName;
}
} elseif ($actorType === Attendee::ACTOR_BOTS) {
$displayName = $actorId . '-bot';
$token = $message->getRoom()->getToken();
if (str_starts_with($actorId, Attendee::ACTOR_BOT_PREFIX)) {
$urlHash = substr($actorId, strlen(Attendee::ACTOR_BOT_PREFIX));
$botName = $this->getBotNameByUrlHashForConversation($token, $urlHash);
if ($botName) {
$displayName = $botName . ' (Bot)';
}
}
} elseif ($actorType === Attendee::ACTOR_FEDERATED_USERS) {
if (isset($this->federatedUsersNames[$actorId])) {
$displayName = $this->federatedUsersNames[$actorId];
} else {
$displayName = $actorId;
try {
$participant = $this->participantService->getParticipantByActor($message->getRoom(), Attendee::ACTOR_FEDERATED_USERS, $actorId);
$displayName = $participant->getAttendee()->getDisplayName();
} catch (ParticipantNotFoundException) {
// FIXME Read from some addressbooks?
}
$this->federatedUsersNames[$actorId] = $displayName;
}
}
return [
$actorType,
$actorId,
$displayName
];
}
protected function getBotNameByUrlHashForConversation(string $token, string $urlHash): ?string {
if (!isset($this->botNames[$token])) {
$this->botNames[$token] = [];
$bots = $this->botService->getBotsForToken($token, null);
foreach ($bots as $bot) {
$botServer = $bot->getBotServer();
$this->botNames[$token][$botServer->getUrlHash()] = $botServer->getName();
}
}
return $this->botNames[$token][$urlHash] ?? null;
}
}
+777
View File
@@ -0,0 +1,777 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Files\Util;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\Session;
use OCA\Talk\Model\ThreadAttendee;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\ThreadService;
use OCA\Talk\Webinary;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Comments\IComment;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IUserManager;
use OCP\Notification\IManager as INotificationManager;
use OCP\Notification\INotification;
/**
* Helper class for notifications related to user mentions in chat messages.
*
* This class uses the NotificationManager to create and remove the
* notifications as needed; OCA\Talk\Notification\Notifier is the one that
* prepares the notifications for display.
*/
class Notifier {
public const PRIORITY_NONE = 0;
public const PRIORITY_NORMAL = 1;
public const PRIORITY_IMPORTANT = 2;
public function __construct(
private INotificationManager $notificationManager,
private IUserManager $userManager,
private IGroupManager $groupManager,
private ParticipantService $participantService,
private ThreadService $threadService,
private IConfig $config,
private ITimeFactory $timeFactory,
private Util $util,
) {
}
/**
* Notifies the user mentioned in the comment.
*
* The comment must be a chat message comment. That is, its "objectId" must
* be the room ID.
*
* Not every user mentioned in the message is notified, but only those that
* are able to participate in the room.
*
* @param Room $chat
* @param IComment $comment
* @param array[] $alreadyNotifiedUsers
* @psalm-param array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}> $alreadyNotifiedUsers
* @param bool $silent
* @param Participant|null $participant
* @return string[] Users that were mentioned
* @psalm-return array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}>
*/
public function notifyMentionedUsers(Room $chat, IComment $comment, array $alreadyNotifiedUsers, bool $silent, ?Participant $participant = null, ?int $threadId = null): array {
$usersToNotify = $this->getUsersToNotify($chat, $comment, $alreadyNotifiedUsers, $participant);
if (!$usersToNotify) {
return $alreadyNotifiedUsers;
}
$shouldFlush = false;
if (!$silent) {
$notification = $this->createNotification($chat, $comment, 'mention', threadId: $threadId);
$parameters = $notification->getSubjectParameters();
$shouldFlush = $this->notificationManager->defer();
}
foreach ($usersToNotify as $mentionedUser) {
$shouldMentionedUserBeNotified = $this->shouldMentionedUserBeNotified($mentionedUser['id'], $comment, $chat, $mentionedUser['attendee'] ?? null);
if ($shouldMentionedUserBeNotified !== self::PRIORITY_NONE) {
if (!$silent) {
$notification->setUser($mentionedUser['id']);
if (isset($mentionedUser['reason'])) {
$notification->setSubject('mention_' . $mentionedUser['reason'], array_merge($parameters, [
'sourceId' => $mentionedUser['sourceId'] ?? null,
]));
} else {
$notification->setSubject('mention', $parameters);
}
$notification->setPriorityNotification($shouldMentionedUserBeNotified === self::PRIORITY_IMPORTANT);
$this->notificationManager->notify($notification);
}
$alreadyNotifiedUsers[] = $mentionedUser;
}
}
if ($shouldFlush) {
$this->notificationManager->flush();
}
return $alreadyNotifiedUsers;
}
/**
* @param Room $chat
* @param IComment $comment
* @param array $alreadyNotifiedUsers
* @psalm-param array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}> $alreadyNotifiedUsers
* @param Participant|null $participant
* @return array
* @psalm-return array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}>
*/
public function getUsersToNotify(Room $chat, IComment $comment, array $alreadyNotifiedUsers, ?Participant $participant = null): array {
$usersToNotify = $this->getMentionedUsers($comment);
$usersToNotify = $this->getMentionedGroupMembers($chat, $comment, $usersToNotify);
$usersToNotify = $this->getMentionedTeamMembers($chat, $comment, $usersToNotify);
$usersToNotify = $this->addMentionAllToList($chat, $usersToNotify, $participant);
$usersToNotify = $this->removeAlreadyNotifiedUsers($usersToNotify, $alreadyNotifiedUsers);
return $usersToNotify;
}
/**
* @param array $usersToNotify
* @psalm-param array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}> $usersToNotify
* @param array $alreadyNotifiedUsers
* @psalm-param array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}> $alreadyNotifiedUsers
* @return array
* @psalm-return array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}>
*/
private function removeAlreadyNotifiedUsers(array $usersToNotify, array $alreadyNotifiedUsers): array {
return array_filter($usersToNotify, static function (array $userToNotify) use ($alreadyNotifiedUsers): bool {
foreach ($alreadyNotifiedUsers as $alreadyNotified) {
if ($alreadyNotified['id'] === $userToNotify['id'] && $alreadyNotified['type'] === $userToNotify['type']) {
return false;
}
}
return true;
});
}
/**
* @param Room $chat
* @param array $list
* @psalm-param array<int, array{id: string, type: string, reason: string, sourceId?: string}> $list
* @param Participant|null $participant
* @return array
* @psalm-return array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}>
*/
private function addMentionAllToList(Room $chat, array $list, ?Participant $participant = null): array {
$usersToNotify = array_filter($list, static function (array $entry): bool {
return $entry['type'] !== Attendee::ACTOR_USERS || $entry['id'] !== 'all';
});
if (count($list) === count($usersToNotify)) {
return $usersToNotify;
}
if ($chat->getMentionPermissions() === Room::MENTION_PERMISSIONS_MODERATORS && (!$participant instanceof Participant || !$participant->hasModeratorPermissions())) {
return $usersToNotify;
}
$attendees = $this->participantService->getActorsByType($chat, Attendee::ACTOR_USERS);
foreach ($attendees as $attendee) {
$alreadyAddedToNotify = array_filter($list, static function ($user) use ($attendee): bool {
return $user['id'] === $attendee->getActorId();
});
if (!empty($alreadyAddedToNotify)) {
continue;
}
$usersToNotify[] = [
'id' => $attendee->getActorId(),
'type' => $attendee->getActorType(),
'attendee' => $attendee,
'reason' => 'all',
];
}
return $usersToNotify;
}
/**
* Notifies the author that wrote the comment which was replied to
*
* The comment must be a chat message comment. That is, its "objectId" must
* be the room ID.
*
* The author of the message is notified only if they are still able to participate in the room
*
* @param Room $chat
* @param IComment $comment
* @param IComment $replyTo
* @param bool $silent
* @return array[] Actor that was replied to
* @psalm-return array<int, array{id: string, type: string, reason: string}>
*/
public function notifyReplyToAuthor(Room $chat, IComment $comment, IComment $replyTo, bool $silent, ?int $threadId = null): array {
if ($replyTo->getActorType() !== Attendee::ACTOR_USERS && $replyTo->getActorType() !== Attendee::ACTOR_FEDERATED_USERS) {
// No reply notification when the replyTo-author was not a user or federated user
return [];
}
if ($replyTo->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
return [
[
'id' => $replyTo->getActorId(),
'type' => $replyTo->getActorType(),
'reason' => 'reply',
],
];
}
$shouldMentionedUserBeNotified = $this->shouldMentionedUserBeNotified($replyTo->getActorId(), $comment, $chat);
if ($shouldMentionedUserBeNotified === self::PRIORITY_NONE) {
return [];
}
if (!$silent) {
$notification = $this->createNotification($chat, $comment, 'reply', threadId: $threadId);
$notification->setUser($replyTo->getActorId());
$notification->setPriorityNotification($shouldMentionedUserBeNotified === self::PRIORITY_IMPORTANT);
$this->notificationManager->notify($notification);
}
return [
[
'id' => $replyTo->getActorId(),
'type' => $replyTo->getActorType(),
'reason' => 'reply',
],
];
}
/**
* Notifies the user mentioned in the comment.
*
* The comment must be a chat message comment. That is, its "objectId" must
* be the room ID.
*
* Not every user mentioned in the message is notified, but only those that
* are able to participate in the room.
*
* @param Room $chat
* @param IComment $comment
* @param array[] $alreadyNotifiedUsers
* @param bool $silent
* @psalm-param array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}> $alreadyNotifiedUsers
*/
public function notifyOtherParticipant(Room $chat, IComment $comment, array $alreadyNotifiedUsers, bool $silent): void {
if ($silent) {
return;
}
$participants = $this->participantService->getParticipantsByNotificationLevel($chat, Participant::NOTIFY_ALWAYS);
$threadId = (int)$comment->getTopmostParentId();
/** @var array<int, ThreadAttendee> $threadAttendees */
$threadAttendees = [];
if ($threadId !== 0) {
$threadAttendees = $this->threadService->findAttendeesForNotificationByThreadId($chat->getId(), $threadId);
}
// Handle participants that only subscribed with Participant::NOTIFY_ALWAYS to the thread, but not the conversation
$threadAttendeeIds = array_map(static fn (ThreadAttendee $threadAttendee): int => $threadAttendee->getAttendeeId(),
array_filter($threadAttendees, static fn (ThreadAttendee $threadAttendee): bool => $threadAttendee->getNotificationLevel() === Participant::NOTIFY_ALWAYS)
);
if (!empty($threadAttendeeIds)) {
$participantIds = array_map(static fn (Participant $participant): int => $participant->getAttendee()->getId(), $participants);
$missingParticipantIds = array_diff($threadAttendeeIds, $participantIds);
if (!empty($missingParticipantIds)) {
$missingParticipants = $this->participantService->getParticipantsByAttendeeId($chat, $missingParticipantIds);
if (!empty($missingParticipants)) {
$participants = array_merge($participants, $missingParticipants);
}
}
}
$notification = $this->createNotification($chat, $comment, 'chat', threadId: $threadId);
foreach ($participants as $participant) {
$attendeeId = $participant->getAttendee()->getId();
$shouldParticipantBeNotified = $this->shouldParticipantBeNotified($participant, $comment, $alreadyNotifiedUsers);
if (isset($threadAttendees[$attendeeId])) {
$threadAttendee = $threadAttendees[$attendeeId];
if ($threadAttendee->getNotificationLevel() !== Participant::NOTIFY_ALWAYS) {
// User unsubscribed from this thread
continue;
}
}
if ($shouldParticipantBeNotified === self::PRIORITY_NONE) {
continue;
}
$notification->setUser($participant->getAttendee()->getActorId());
$notification->setPriorityNotification($shouldParticipantBeNotified === self::PRIORITY_IMPORTANT);
$this->notificationManager->notify($notification);
}
// Also notify default participants in one-to-one chats or when the admin default is "always"
if ($this->getDefaultGroupNotification() === Participant::NOTIFY_ALWAYS || $chat->getType() === Room::TYPE_ONE_TO_ONE) {
$participants = $this->participantService->getParticipantsByNotificationLevel($chat, Participant::NOTIFY_DEFAULT);
foreach ($participants as $participant) {
$shouldParticipantBeNotified = $this->shouldParticipantBeNotified($participant, $comment, $alreadyNotifiedUsers);
if ($shouldParticipantBeNotified === self::PRIORITY_NONE) {
continue;
}
$notification->setUser($participant->getAttendee()->getActorId());
$notification->setPriorityNotification($shouldParticipantBeNotified === self::PRIORITY_IMPORTANT);
$this->notificationManager->notify($notification);
}
}
}
public function notifyReacted(Room $chat, IComment $comment, IComment $reaction): void {
if ($comment->getActorType() !== Attendee::ACTOR_USERS) {
return;
}
if ($comment->getActorType() === $reaction->getActorType() && $comment->getActorId() === $reaction->getActorId()) {
return;
}
try {
$participant = $this->participantService->getParticipant($chat, $comment->getActorId(), false);
} catch (ParticipantNotFoundException $e) {
return;
}
$notificationLevel = $participant->getAttendee()->getNotificationLevel();
if ($notificationLevel === Participant::NOTIFY_DEFAULT) {
if ($chat->getType() === Room::TYPE_ONE_TO_ONE) {
$notificationLevel = Participant::NOTIFY_ALWAYS;
} else {
$notificationLevel = $this->getDefaultGroupNotification();
}
}
if ($notificationLevel === Participant::NOTIFY_ALWAYS) {
$notification = $this->createNotification($chat, $comment, 'reaction', [
'reaction' => $reaction->getMessage(),
], $reaction);
$notification->setUser($comment->getActorId());
$this->notificationManager->notify($notification);
}
}
/**
* Removes all the pending notifications for the room with the given ID.
*/
public function removePendingNotificationsForRoom(Room $chat, bool $chatOnly = false): void {
$notification = $this->notificationManager->createNotification();
$shouldFlush = $this->notificationManager->defer();
// @todo this should be in the Notifications\Hooks
$notification->setApp('spreed');
$objectTypes = [
'chat',
'reminder',
];
if (!$chatOnly) {
$objectTypes = [
'call',
'chat',
'room',
'recording',
'recording_information',
'remote_talk_share',
];
}
foreach ($objectTypes as $type) {
$notification->setObject($type, $chat->getToken());
$this->notificationManager->markProcessed($notification);
}
if ($shouldFlush) {
$this->notificationManager->flush();
}
}
/**
* Removes all the pending mention notifications for the room
*
* @param Room $chat
* @param ?string $userId
*/
public function markMentionNotificationsRead(Room $chat, ?string $userId): void {
if ($userId === null || $userId === '') {
return;
}
$shouldFlush = $this->notificationManager->defer();
$notification = $this->notificationManager->createNotification();
$notification
->setApp('spreed')
->setObject('chat', $chat->getToken())
->setUser($userId);
$this->notificationManager->markProcessed($notification);
if ($shouldFlush) {
$this->notificationManager->flush();
}
}
/**
* Remove all mention notifications of users that got their mention removed
*
* @param list<string> $userIds
*/
public function removeMentionNotificationAfterEdit(Room $chat, IComment $comment, array $userIds): void {
$shouldFlush = $this->notificationManager->defer();
$notification = $this->notificationManager->createNotification();
$notification
->setApp('spreed')
->setObject('chat', $chat->getToken())
// FIXME message_parameters are not handled by notification app, so this removes all notifications :(
->setMessage('comment', [
'commentId' => $comment->getId(),
]);
foreach (['mention_all', 'mention_direct'] as $subject) {
$notification->setSubject($subject);
foreach ($userIds as $userId) {
$notification->setUser($userId);
$this->notificationManager->markProcessed($notification);
}
}
if ($shouldFlush) {
$this->notificationManager->flush();
}
}
/**
* Returns the IDs of the users mentioned in the given comment.
*
* @param IComment $comment
* @return string[] the mentioned user IDs
*/
public function getMentionedUserIds(IComment $comment): array {
$mentionedUsers = $this->getMentionedUsers($comment);
return array_map(static function ($mentionedUser) {
return $mentionedUser['id'];
}, $mentionedUsers);
}
/**
* Returns the cloud IDs of the federated users mentioned in the given comment.
*
* @param IComment $comment
* @return string[] the mentioned cloud IDs
*/
public function getMentionedCloudIds(IComment $comment): array {
$mentionedFederatedUsers = $this->getMentionedFederatedUsers($comment);
return array_map(static function ($mentionedUser) {
return $mentionedUser['id'];
}, $mentionedFederatedUsers);
}
/**
* @param IComment $comment
* @return array[]
* @psalm-return array<int, array{type: string, id: string, reason: string}>
*/
private function getMentionedUsers(IComment $comment): array {
$mentions = $comment->getMentions();
if (empty($mentions)) {
return [];
}
$mentionedUsers = [];
foreach ($mentions as $mention) {
if ($mention['type'] !== 'user') {
continue;
}
$mentionedUsers[] = [
'id' => $mention['id'],
'type' => Attendee::ACTOR_USERS,
'reason' => 'direct',
];
}
return $mentionedUsers;
}
/**
* @param IComment $comment
* @return array[]
* @psalm-return array<int, array{type: string, id: string, reason: string}>
*/
private function getMentionedFederatedUsers(IComment $comment): array {
$mentions = $comment->getMentions();
if (empty($mentions)) {
return [];
}
$mentionedUsers = [];
foreach ($mentions as $mention) {
if ($mention['type'] !== 'federated_user') {
continue;
}
$mentionedUsers[] = [
'id' => $mention['id'],
'type' => Attendee::ACTOR_FEDERATED_USERS,
'reason' => 'direct',
];
}
return $mentionedUsers;
}
/**
* @param Room $chat
* @param IComment $comment
* @param array $list
* @psalm-param array<int, array{id: string, type: string, reason: string}> $list
* @return array[]
* @psalm-return array<int, array{type: string, id: string, reason: string, sourceId?: string}>
*/
private function getMentionedGroupMembers(Room $chat, IComment $comment, array $list): array {
$mentions = $comment->getMentions();
if (empty($mentions)) {
return [];
}
$alreadyMentionedUserIds = array_filter(
array_map(static fn (array $entry) => $entry['type'] === Attendee::ACTOR_USERS ? $entry['id'] : null, $list),
static fn ($userId) => $userId !== null
);
$alreadyMentionedUserIds = array_flip($alreadyMentionedUserIds);
foreach ($mentions as $mention) {
if ($mention['type'] !== 'group') {
continue;
}
$group = $this->groupManager->get($mention['id']);
if (!$group instanceof IGroup) {
continue;
}
try {
$this->participantService->getParticipantByActor($chat, Attendee::ACTOR_GROUPS, $group->getGID());
} catch (ParticipantNotFoundException $e) {
continue;
}
$members = $group->getUsers();
foreach ($members as $member) {
if (isset($alreadyMentionedUserIds[$member->getUID()])) {
continue;
}
$list[] = [
'id' => $member->getUID(),
'type' => Attendee::ACTOR_USERS,
'reason' => 'group',
'sourceId' => $group->getGID(),
];
$alreadyMentionedUserIds[$member->getUID()] = true;
}
}
return $list;
}
/**
* @param Room $chat
* @param IComment $comment
* @param array $list
* @psalm-param array<int, array{type: string, id: string, reason: string, sourceId?: string}> $list
* @return array[]
* @psalm-return array<int, array{type: string, id: string, reason: string, sourceId?: string}>
*/
private function getMentionedTeamMembers(Room $chat, IComment $comment, array $list): array {
$mentions = $comment->getMentions();
if (empty($mentions)) {
return [];
}
$alreadyMentionedUserIds = array_filter(
array_map(static fn (array $entry) => $entry['type'] === Attendee::ACTOR_USERS ? $entry['id'] : null, $list),
static fn ($userId) => $userId !== null
);
$alreadyMentionedUserIds = array_flip($alreadyMentionedUserIds);
foreach ($mentions as $mention) {
if ($mention['type'] !== 'team') {
continue;
}
try {
$this->participantService->getParticipantByActor($chat, Attendee::ACTOR_CIRCLES, $mention['id']);
} catch (ParticipantNotFoundException) {
continue;
}
$members = $this->participantService->getCircleMembers($mention['id']);
if (empty($members)) {
continue;
}
foreach ($members as $member) {
$list[] = [
'id' => $member->getUserId(),
'type' => Attendee::ACTOR_USERS,
'reason' => 'team',
'sourceId' => $mention['id'],
];
$alreadyMentionedUserIds[$member->getUserId()] = true;
}
}
return $list;
}
/**
* Creates a notification for the given chat message comment and mentioned
* user ID.
*/
private function createNotification(Room $chat, IComment $comment, string $subject, array $subjectData = [], ?IComment $reaction = null, ?int $threadId = null): INotification {
$subjectData['userType'] = $reaction ? $reaction->getActorType() : $comment->getActorType();
$subjectData['userId'] = $reaction ? $reaction->getActorId() : $comment->getActorId();
$messageData = [
'commentId' => $comment->getId(),
];
if ($threadId !== null && $threadId !== 0) {
$messageData['threadId'] = $threadId;
}
$notification = $this->notificationManager->createNotification();
$notification
->setApp('spreed')
->setObject('chat', $chat->getToken())
->setSubject($subject, $subjectData)
->setMessage($comment->getVerb(), $messageData)
->setDateTime($reaction ? $reaction->getCreationDateTime() : $comment->getCreationDateTime());
return $notification;
}
protected function getDefaultGroupNotification(): int {
return (int)$this->config->getAppValue('spreed', 'default_group_notification', (string)Participant::NOTIFY_MENTION);
}
/**
* Determines whether a user should be notified about the mention:
*
* 1. The user did not mention themself
* 2. The user must exist
* 3. The user must be a participant of the room
* 4. The user must not be active in the room
*/
protected function shouldMentionedUserBeNotified(string $userId, IComment $comment, Room $room, ?Attendee $attendee = null): int {
if ($comment->getActorType() === Attendee::ACTOR_USERS && $userId === $comment->getActorId()) {
// Do not notify the user if they mentioned themselves
return self::PRIORITY_NONE;
}
try {
if (!$attendee instanceof Attendee) {
if (!$this->userManager->userExists($userId)) {
return self::PRIORITY_NONE;
}
$participant = $this->participantService->getParticipant($room, $userId, false);
$attendee = $participant->getAttendee();
} else {
$participant = new Participant($room, $attendee, null);
}
if ($room->getLobbyState() !== Webinary::LOBBY_NONE
&& !($participant->getPermissions() & Attendee::PERMISSIONS_LOBBY_IGNORE)) {
return self::PRIORITY_NONE;
}
$notificationLevel = $attendee->getNotificationLevel();
$threadId = (int)$comment->getTopmostParentId();
if ($threadId !== 0) {
$threadAttendees = $this->threadService->findAttendeeByThreadIds($attendee, [$threadId]);
$threadAttendee = array_shift($threadAttendees);
if ($threadAttendee !== null && $threadAttendee->getNotificationLevel() !== Participant::NOTIFY_DEFAULT) {
$notificationLevel = $threadAttendee->getNotificationLevel();
}
}
if ($notificationLevel === Participant::NOTIFY_DEFAULT) {
if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
$notificationLevel = Participant::NOTIFY_ALWAYS;
} else {
$notificationLevel = $this->getDefaultGroupNotification();
}
}
if ($notificationLevel === Participant::NOTIFY_NEVER) {
return self::PRIORITY_NONE;
}
if ($attendee->isImportant()) {
return self::PRIORITY_IMPORTANT;
}
return self::PRIORITY_NORMAL;
} catch (ParticipantNotFoundException $e) {
if ($room->getObjectType() === 'file' && $this->util->canUserAccessFile($room->getObjectId(), $userId)) {
// Users are added on mentions in file-rooms,
// so they can see the room in their room list and
// the notification can be parsed and links to an existing room,
// where they are a participant of.
$userDisplayName = $this->userManager->getDisplayName($userId);
$this->participantService->addUsers($room, [[
'actorType' => Attendee::ACTOR_USERS,
'actorId' => $userId,
'displayName' => $userDisplayName ?? $userId,
]]);
return self::PRIORITY_NORMAL;
}
return self::PRIORITY_NONE;
}
}
/**
* Determines whether a participant should be notified about the message:
*
* 1. The participant is not a guest
* 2. The participant is not the writing user
* 3. The participant was not mentioned already
* 4. The participant must not be active in the room
*
* @psalm-param array<int, array{type: string, id: string, reason: string, sourceId?: string, attendee?: Attendee}> $alreadyNotifiedUsers
*/
protected function shouldParticipantBeNotified(Participant $participant, IComment $comment, array $alreadyNotifiedUsers): int {
if ($participant->getAttendee()->getActorType() !== Attendee::ACTOR_USERS) {
return self::PRIORITY_NONE;
}
$userId = $participant->getAttendee()->getActorId();
if ($comment->getActorType() === Attendee::ACTOR_USERS && $userId === $comment->getActorId()) {
// Do not notify the author
return self::PRIORITY_NONE;
}
$actorType = $participant->getAttendee()->getActorType();
foreach ($alreadyNotifiedUsers as $user) {
if ($user['id'] === $userId && $user['type'] === $actorType) {
return self::PRIORITY_NONE;
}
}
if ($participant->getSession()?->getLastPing() >= $this->timeFactory->getTime() - Session::SESSION_TIMEOUT) {
// User is online
return self::PRIORITY_NONE;
}
if ($participant->getAttendee()->isImportant()) {
return self::PRIORITY_IMPORTANT;
}
return self::PRIORITY_NORMAL;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat\Parser;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Events\MessageParseEvent;
use OCA\Talk\Model\Attendee;
use OCP\Defaults;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Server;
/**
* @template-implements IEventListener<Event>
*/
class Changelog implements IEventListener {
#[\Override]
public function handle(Event $event): void {
if (!$event instanceof MessageParseEvent) {
return;
}
$chatMessage = $event->getMessage();
if ($chatMessage->getMessageType() !== ChatManager::VERB_MESSAGE) {
return;
}
if ($chatMessage->getActorType() !== Attendee::ACTOR_GUESTS) {
return;
}
if ($chatMessage->getActorId() === Attendee::ACTOR_ID_CHANGELOG) {
$l = $chatMessage->getL10n();
$chatMessage->setActor(Attendee::ACTOR_BOTS, Attendee::ACTOR_ID_CHANGELOG, $l->t('Talk updates ✅'));
$event->stopPropagation();
}
if ($chatMessage->getActorId() === Attendee::ACTOR_ID_SAMPLE) {
$theme = Server::get(Defaults::class);
$chatMessage->setActor(Attendee::ACTOR_BOTS, Attendee::ACTOR_ID_SAMPLE, $theme->getName());
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat\Parser;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Events\MessageParseEvent;
use OCA\Talk\Model\Attendee;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/**
* @template-implements IEventListener<Event>
*/
class Command implements IEventListener {
public const RESPONSE_NONE = 0;
public const RESPONSE_USER = 1;
public const RESPONSE_ALL = 2;
#[\Override]
public function handle(Event $event): void {
if (!$event instanceof MessageParseEvent) {
return;
}
$message = $event->getMessage();
if ($message->getMessageType() !== ChatManager::VERB_COMMAND) {
return;
}
$message->setVisibility(false);
$comment = $message->getComment();
$data = json_decode($comment->getMessage(), true);
if (!\is_array($data)) {
return;
}
$event->stopPropagation();
if ($data['visibility'] === self::RESPONSE_NONE) {
$message->setVisibility(false);
return;
}
$participant = $message->getParticipant();
if ($data['visibility'] !== self::RESPONSE_ALL
&& $participant !== null
&& ($participant->getAttendee()->getActorType() !== Attendee::ACTOR_USERS
|| $data['user'] !== $participant->getAttendee()->getActorId())) {
$message->setVisibility(false);
return;
}
$message->setMessage($data['output'], []);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat\Parser;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Events\MessageParseEvent;
use OCA\Talk\Model\Message;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/**
* @template-implements IEventListener<Event>
*/
class ReactionParser implements IEventListener {
#[\Override]
public function handle(Event $event): void {
if (!$event instanceof MessageParseEvent) {
return;
}
$message = $event->getMessage();
if ($message->getMessageType() !== ChatManager::VERB_REACTION && $message->getMessageType() !== ChatManager::VERB_REACTION_DELETED) {
return;
}
$comment = $message->getComment();
if (!in_array($comment->getVerb(), [ChatManager::VERB_REACTION, ChatManager::VERB_REACTION_DELETED], true)) {
return;
}
$message->setMessageType(ChatManager::VERB_SYSTEM);
if ($comment->getVerb() === ChatManager::VERB_REACTION_DELETED) {
// This message is necessary to make compatible with old clients
$message->setMessage($message->getL10n()->t('Reaction deleted by author'), [], $comment->getVerb());
} else {
$message->setMessage($message->getMessage(), [], $comment->getVerb());
}
}
}
File diff suppressed because it is too large Load Diff
+320
View File
@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat\Parser;
use OCA\Circles\CirclesManager;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Events\MessageParseEvent;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\GuestManager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\Message;
use OCA\Talk\Room;
use OCA\Talk\Service\AvatarService;
use OCA\Talk\Service\ParticipantService;
use OCP\App\IAppManager;
use OCP\Comments\ICommentsManager;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Federation\ICloudIdManager;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IUserManager;
use OCP\Server;
/**
* Helper class to get a rich message from a plain text message.
* @template-implements IEventListener<Event>
*/
class UserMention implements IEventListener {
/** @var array<string, string> */
protected array $circleNames = [];
/** @var array<string, string> */
protected array $circleLinks = [];
public function __construct(
protected IAppManager $appManager,
protected ICommentsManager $commentsManager,
protected IUserManager $userManager,
protected IGroupManager $groupManager,
protected GuestManager $guestManager,
protected AvatarService $avatarService,
protected ICloudIdManager $cloudIdManager,
protected ParticipantService $participantService,
protected IL10N $l,
) {
}
#[\Override]
public function handle(Event $event): void {
if (!$event instanceof MessageParseEvent) {
return;
}
$message = $event->getMessage();
if ($message->getMessageType() !== ChatManager::VERB_MESSAGE) {
return;
}
$this->parseMessage($message);
}
/**
* Returns the equivalent rich message to the given comment.
*
* The mentions in the comment are replaced by "{mention-$type$index}" in
* the returned rich message; each "mention-$type$index" parameter contains
* the following attributes:
* -type: the type of the mention ("user")
* -id: the ID of the user
* -name: the display name of the user, or an empty string if it could
* not be resolved.
*
* @param Message $chatMessage
*/
protected function parseMessage(Message $chatMessage): void {
$comment = $chatMessage->getComment();
$message = $chatMessage->getMessage();
$messageParameters = $chatMessage->getMessageParameters();
$mentionTypeCount = [];
// Set the current message as comment content, so that the message finds
// mentions which are now part of the message, but were not on the original
// comment, e.g. mentions at the beginning of captions
$originalCommentMessage = $comment->getMessage();
$comment->setMessage($message, ChatManager::MAX_CHAT_LENGTH + 10000);
$mentions = $comment->getMentions();
$comment->setMessage($originalCommentMessage, ChatManager::MAX_CHAT_LENGTH);
// TODO This can be removed once getMentions() returns sorted results (Nextcloud 21+)
usort($mentions, static function (array $m1, array $m2) {
return mb_strlen($m2['id']) <=> mb_strlen($m1['id']);
});
$metadata = $comment->getMetaData() ?? [];
foreach ($mentions as $mention) {
if ($mention['type'] === 'user' && $mention['id'] === 'all') {
if (!isset($metadata[Message::METADATA_CAN_MENTION_ALL])) {
continue;
}
$mention['type'] = 'call';
}
if ($mention['type'] === 'user') {
$userDisplayName = $this->userManager->getDisplayName($mention['id']);
if ($userDisplayName === null) {
continue;
}
}
if (!array_key_exists($mention['type'], $mentionTypeCount)) {
$mentionTypeCount[$mention['type']] = 0;
}
$mentionTypeCount[$mention['type']]++;
$search = $mention['id'];
if (
$mention['type'] === 'email'
|| $mention['type'] === 'group'
// || $mention['type'] === 'federated_group'
|| $mention['type'] === 'team'
// || $mention['type'] === 'federated_team'
|| $mention['type'] === 'federated_user') {
$search = $mention['type'] . '/' . $mention['id'];
}
// To keep a limited character set in parameter IDs ([a-zA-Z0-9-])
// the mention parameter ID does not include the mention ID (which
// could contain characters like '@' for user IDs) but a one-based
// index of the mentions of that type.
$mentionParameterId = 'mention-' . str_replace('_', '-', $mention['type']) . $mentionTypeCount[$mention['type']];
$message = str_replace('@"' . $search . '"', '{' . $mentionParameterId . '}', $message);
if (!str_contains($search, ' ')
&& !str_starts_with($search, 'guest/')
&& !str_starts_with($search, 'email/')
&& !str_starts_with($search, 'group/')
// && !str_starts_with($search, 'federated_group/')
&& !str_starts_with($search, 'team/')
// && !str_starts_with($search, 'federated_team/')
&& !str_starts_with($search, 'federated_user/')) {
$message = str_replace('@' . $search, '{' . $mentionParameterId . '}', $message);
}
if ($mention['type'] === 'call') {
$userId = '';
if ($chatMessage->getParticipant()?->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
$userId = $chatMessage->getParticipant()->getAttendee()->getActorId();
}
$messageParameters[$mentionParameterId] = [
'type' => $mention['type'],
'id' => $chatMessage->getRoom()->getToken(),
'name' => $chatMessage->getRoom()->getDisplayName($userId, true),
'call-type' => $this->getRoomType($chatMessage->getRoom()),
'icon-url' => $this->avatarService->getAvatarUrl($chatMessage->getRoom()),
'mention-id' => $search,
];
} elseif ($mention['type'] === 'guest') {
try {
$participant = $this->participantService->getParticipantByActor($chatMessage->getRoom(), Attendee::ACTOR_GUESTS, substr($mention['id'], strlen('guest/')));
$displayName = $participant->getAttendee()->getDisplayName() ?: $this->l->t('Guest');
} catch (ParticipantNotFoundException $e) {
$displayName = $this->l->t('Guest');
}
$messageParameters[$mentionParameterId] = [
'type' => $mention['type'],
'id' => $mention['id'],
'name' => $displayName,
'mention-id' => $search,
];
} elseif ($mention['type'] === 'email') {
try {
$participant = $this->participantService->getParticipantByActor($chatMessage->getRoom(), Attendee::ACTOR_EMAILS, $mention['id']);
$displayName = $participant->getAttendee()->getDisplayName() ?: $this->l->t('Guest');
} catch (ParticipantNotFoundException) {
$displayName = $this->l->t('Guest');
}
$messageParameters[$mentionParameterId] = [
'type' => $mention['type'],
'id' => $mention['id'],
'name' => $displayName,
'mention-id' => $search,
];
} elseif ($mention['type'] === 'federated_user') {
try {
$cloudId = $this->cloudIdManager->resolveCloudId($mention['id']);
} catch (\Throwable) {
continue;
}
try {
$participant = $this->participantService->getParticipantByActor($chatMessage->getRoom(), Attendee::ACTOR_FEDERATED_USERS, $mention['id']);
$displayName = $participant->getAttendee()->getDisplayName() ?: $cloudId->getDisplayId();
} catch (ParticipantNotFoundException) {
$displayName = $mention['id'];
}
$messageParameters[$mentionParameterId] = [
'type' => 'user',
'id' => $cloudId->getUser(),
'name' => $displayName,
'server' => $cloudId->getRemote(),
'mention-id' => $search,
];
} elseif ($mention['type'] === 'group') {
$group = $this->groupManager->get($mention['id']);
if ($group instanceof IGroup) {
$displayName = $group->getDisplayName();
} else {
$displayName = $mention['id'];
}
$messageParameters[$mentionParameterId] = [
'type' => 'user-group',
'id' => $mention['id'],
'name' => $displayName,
'mention-id' => $search,
];
} elseif ($mention['type'] === 'team') {
$messageParameters[$mentionParameterId] = $this->getCircle($mention['id']);
} else {
try {
$displayName = $this->commentsManager->resolveDisplayName($mention['type'], $mention['id']);
} catch (\OutOfBoundsException $e) {
// There is no registered display name resolver for the mention
// type, so the client decides what to display.
$displayName = '';
}
$messageParameters[$mentionParameterId] = [
'type' => $mention['type'],
'id' => $mention['id'],
'name' => $displayName,
'mention-id' => $search,
];
}
}
if (str_starts_with($message, '//')) {
$message = substr($message, 1);
}
$chatMessage->setMessage($message, $messageParameters);
}
/**
* @param Room $room
* @return string
* @throws \InvalidArgumentException
*/
protected function getRoomType(Room $room): string {
switch ($room->getType()) {
case Room::TYPE_ONE_TO_ONE:
case Room::TYPE_ONE_TO_ONE_FORMER:
case Room::TYPE_NOTE_TO_SELF:
return 'one2one';
case Room::TYPE_GROUP:
return 'group';
case Room::TYPE_PUBLIC:
return 'public';
default:
throw new \InvalidArgumentException('Unknown room type');
}
}
protected function getCircle(string $circleId): array {
if (!$this->appManager->isEnabledForUser('circles')) {
return [
'type' => 'highlight',
'id' => $circleId,
'name' => $circleId,
];
}
if (!isset($this->circleNames[$circleId])) {
$this->loadCircleDetails($circleId);
}
if (!isset($this->circleNames[$circleId])) {
return [
'type' => 'highlight',
'id' => $circleId,
'name' => $circleId,
];
}
return [
'type' => 'circle',
'id' => $circleId,
'name' => $this->circleNames[$circleId],
'link' => $this->circleLinks[$circleId],
'mention-id' => 'team/' . $circleId,
];
}
protected function loadCircleDetails(string $circleId): void {
try {
$circlesManager = Server::get(CirclesManager::class);
$circlesManager->startSuperSession();
$circle = $circlesManager->getCircle($circleId);
$this->circleNames[$circleId] = $circle->getDisplayName();
$this->circleLinks[$circleId] = $circle->getUrl();
} catch (\Exception) {
} finally {
$circlesManager?->stopSession();
}
}
}
+210
View File
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat;
use OCA\Talk\Events\BeforeReactionAddedEvent;
use OCA\Talk\Events\BeforeReactionRemovedEvent;
use OCA\Talk\Events\ReactionAddedEvent;
use OCA\Talk\Events\ReactionRemovedEvent;
use OCA\Talk\Exceptions\ReactionAlreadyExistsException;
use OCA\Talk\Exceptions\ReactionNotSupportedException;
use OCA\Talk\Exceptions\ReactionOutOfContextException;
use OCA\Talk\Participant;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Room;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Comments\IComment;
use OCP\Comments\NotFoundException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
use OCP\PreConditionNotMetException;
/**
* @psalm-import-type TalkReaction from ResponseDefinitions
*/
class ReactionManager {
public function __construct(
private ChatManager $chatManager,
private CommentsManager $commentsManager,
private IL10N $l,
private MessageParser $messageParser,
private Notifier $notifier,
protected IEventDispatcher $dispatcher,
protected ITimeFactory $timeFactory,
) {
}
/**
* Add reaction
*
* @throws NotFoundException
* @throws ReactionAlreadyExistsException
* @throws ReactionNotSupportedException
* @throws ReactionOutOfContextException
*/
public function addReactionMessage(Room $chat, string $actorType, string $actorId, string $actorDisplayName, int $messageId, string $reaction): IComment {
$parentMessage = $this->getCommentToReact($chat, (string)$messageId);
try {
// Check if the user already reacted with the same reaction
$this->commentsManager->getReactionComment(
(int)$parentMessage->getId(),
$actorType,
$actorId,
$reaction
);
throw new ReactionAlreadyExistsException();
} catch (NotFoundException $e) {
}
/** @var IComment $comment */
$comment = $this->commentsManager->create(
$actorType,
$actorId,
'chat',
(string)$chat->getId()
);
$comment->setParentId($parentMessage->getId());
$comment->setMessage($reaction);
$comment->setVerb(ChatManager::VERB_REACTION);
$comment->setExpireDate($parentMessage->getExpireDate());
$event = new BeforeReactionAddedEvent($chat, $parentMessage, $actorType, $actorId, $actorDisplayName, $reaction);
$this->dispatcher->dispatchTyped($event);
$this->commentsManager->save($comment);
$event = new ReactionAddedEvent($chat, $parentMessage, $actorType, $actorId, $actorDisplayName, $reaction, $comment);
$this->dispatcher->dispatchTyped($event);
$this->notifier->notifyReacted($chat, $parentMessage, $comment);
return $comment;
}
/**
* Delete reaction
*
* @param Room $chat
* @param string $actorType
* @param string $actorId
* @param integer $messageId
* @param string $reaction
* @return IComment
* @throws NotFoundException
* @throws ReactionNotSupportedException
* @throws ReactionOutOfContextException
*/
public function deleteReactionMessage(Room $chat, string $actorType, string $actorId, string $actorDisplayName, int $messageId, string $reaction): IComment {
// Just to verify that messageId is part of the room and throw error if not.
$parentComment = $this->getCommentToReact($chat, (string)$messageId);
$event = new BeforeReactionRemovedEvent($chat, $parentComment, $actorType, $actorId, $actorDisplayName, $reaction);
$this->dispatcher->dispatchTyped($event);
$comment = $this->commentsManager->getReactionComment(
$messageId,
$actorType,
$actorId,
$reaction
);
$comment->setMessage(
json_encode([
'deleted_by_type' => $actorType,
'deleted_by_id' => $actorId,
'deleted_on' => $this->timeFactory->getDateTime()->getTimestamp(),
])
);
$comment->setVerb(ChatManager::VERB_REACTION_DELETED);
$this->commentsManager->save($comment);
$this->chatManager->addSystemMessage(
$chat,
null,
$actorType,
$actorId,
json_encode(['message' => 'reaction_revoked', 'parameters' => ['message' => (int)$comment->getId()]]),
$this->timeFactory->getDateTime(),
false,
null,
$parentComment,
true
);
$event = new ReactionRemovedEvent($chat, $parentComment, $actorType, $actorId, $actorDisplayName, $reaction, $comment);
$this->dispatcher->dispatchTyped($event);
return $comment;
}
/**
* @return array<string, list<TalkReaction>>
* @throws PreConditionNotMetException
*/
public function retrieveReactionMessages(Room $chat, Participant $participant, int $messageId, ?string $reaction = null): array {
if ($reaction) {
$comments = $this->commentsManager->retrieveAllReactionsWithSpecificReaction($messageId, $reaction);
} else {
$comments = $this->commentsManager->retrieveAllReactions($messageId);
}
$reactions = [];
foreach ($comments as $comment) {
$message = $this->messageParser->createMessage($chat, $participant, $comment, $this->l);
$this->messageParser->parseMessage($message);
$reactions[$comment->getMessage()][] = [
'actorType' => $comment->getActorType(),
'actorId' => $comment->getActorId(),
'actorDisplayName' => $message->getActorDisplayName(),
'timestamp' => $comment->getCreationDateTime()->getTimestamp(),
];
}
return $reactions;
}
/**
* @param Participant $participant
* @param array $messageIds
* @return array[]
* @psalm-return array<int, string[]>
*/
public function getReactionsByActorForMessages(Participant $participant, array $messageIds): array {
return $this->commentsManager->retrieveReactionsByActor(
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId(),
$messageIds
);
}
/**
* @param Room $chat
* @param string $messageId
* @return IComment
* @throws NotFoundException
* @throws ReactionNotSupportedException
* @throws ReactionOutOfContextException
*/
public function getCommentToReact(Room $chat, string $messageId): IComment {
if (!$this->commentsManager->supportReactions()) {
throw new ReactionNotSupportedException();
}
$comment = $this->commentsManager->get($messageId);
if ($comment->getObjectType() !== 'chat'
|| $comment->getObjectId() !== (string)$chat->getId()
|| !in_array($comment->getVerb(), [
ChatManager::VERB_MESSAGE,
ChatManager::VERB_OBJECT_SHARED,
], true)) {
throw new ReactionOutOfContextException();
}
return $comment;
}
}
+699
View File
@@ -0,0 +1,699 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Chat\SystemMessage;
use DateInterval;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Chat\MessageParser;
use OCA\Talk\Events\AAttendeeRemovedEvent;
use OCA\Talk\Events\AParticipantModifiedEvent;
use OCA\Talk\Events\ARoomEvent;
use OCA\Talk\Events\ARoomModifiedEvent;
use OCA\Talk\Events\AttendeeRemovedEvent;
use OCA\Talk\Events\AttendeesAddedEvent;
use OCA\Talk\Events\AttendeesRemovedEvent;
use OCA\Talk\Events\BeforeDuplicateShareSentEvent;
use OCA\Talk\Events\BeforeParticipantModifiedEvent;
use OCA\Talk\Events\LobbyModifiedEvent;
use OCA\Talk\Events\ParticipantModifiedEvent;
use OCA\Talk\Events\RoomCreatedEvent;
use OCA\Talk\Events\RoomModifiedEvent;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\BreakoutRoom;
use OCA\Talk\Model\Message;
use OCA\Talk\Model\Session;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCA\Talk\Service\NoteToSelfService;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\SampleConversationsService;
use OCA\Talk\Service\ThreadService;
use OCA\Talk\TalkSession;
use OCA\Talk\Webinary;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Comments\IComment;
use OCP\Comments\NotFoundException;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IL10N;
use OCP\IRequest;
use OCP\ISession;
use OCP\IUser;
use OCP\IUserSession;
use OCP\Share\Events\BeforeShareCreatedEvent;
use OCP\Share\Events\ShareCreatedEvent;
use OCP\Share\IShare;
use Psr\Log\LoggerInterface;
/**
* @template-implements IEventListener<Event>
*/
class Listener implements IEventListener {
public function __construct(
protected IRequest $request,
protected ChatManager $chatManager,
protected TalkSession $talkSession,
protected ISession $session,
protected IUserSession $userSession,
protected ITimeFactory $timeFactory,
protected Manager $manager,
protected ParticipantService $participantService,
protected MessageParser $messageParser,
protected ThreadService $threadService,
protected IL10N $l,
protected LoggerInterface $logger,
) {
}
#[\Override]
public function handle(Event $event): void {
if ($event instanceof ARoomEvent && $event->getRoom()->isFederatedConversation()) {
return;
}
if ($event instanceof AttendeesAddedEvent) {
$this->attendeesAddedEvent($event);
} elseif ($event instanceof AttendeeRemovedEvent) {
$this->sendSystemMessageUserRemoved($event);
} elseif ($event instanceof AttendeesRemovedEvent) {
$this->attendeesRemovedEvent($event);
} elseif ($event instanceof RoomCreatedEvent) {
$this->sendSystemMessageAboutConversationCreated($event);
} elseif ($event instanceof LobbyModifiedEvent) {
$this->sendSystemLobbyMessage($event);
} elseif ($event instanceof RoomModifiedEvent) {
match ($event->getProperty()) {
ARoomModifiedEvent::PROPERTY_AVATAR => $this->avatarChanged($event),
ARoomModifiedEvent::PROPERTY_CALL_RECORDING => $this->setCallRecording($event),
ARoomModifiedEvent::PROPERTY_DESCRIPTION => $this->sendSystemMessageAboutRoomDescriptionChanges($event),
ARoomModifiedEvent::PROPERTY_LISTABLE => $this->sendSystemListableMessage($event),
ARoomModifiedEvent::PROPERTY_MESSAGE_EXPIRATION => $this->afterSetMessageExpiration($event),
ARoomModifiedEvent::PROPERTY_NAME => $this->sendSystemMessageAboutConversationRenamed($event),
ARoomModifiedEvent::PROPERTY_PASSWORD => $this->sendSystemMessageAboutRoomPassword($event),
ARoomModifiedEvent::PROPERTY_READ_ONLY => $this->sendSystemReadOnlyMessage($event),
ARoomModifiedEvent::PROPERTY_TYPE => $this->sendSystemGuestPermissionsMessage($event),
default => null,
};
} elseif ($event instanceof BeforeParticipantModifiedEvent) {
match ($event->getProperty()) {
AParticipantModifiedEvent::PROPERTY_IN_CALL => $this->sendSystemMessageAboutBeginOfCall($event),
default => null,
};
} elseif ($event instanceof ParticipantModifiedEvent) {
match ($event->getProperty()) {
AParticipantModifiedEvent::PROPERTY_TYPE => $this->sendSystemMessageAboutPromoteOrDemoteModerator($event),
AParticipantModifiedEvent::PROPERTY_IN_CALL => $this->sendSystemMessageAboutCallLeft($event),
default => null,
};
} elseif ($event instanceof BeforeShareCreatedEvent) {
$this->setShareExpiration($event);
} elseif ($event instanceof BeforeDuplicateShareSentEvent || $event instanceof ShareCreatedEvent) {
$this->fixMimeTypeOfVoiceMessage($event);
}
}
protected function sendSystemMessageAboutBeginOfCall(BeforeParticipantModifiedEvent $event): void {
if ($event->getOldValue() !== Participant::FLAG_DISCONNECTED
|| $event->getNewValue() === Participant::FLAG_DISCONNECTED) {
return;
}
if ($this->participantService->hasActiveSessionsInCall($event->getRoom())) {
$this->sendSystemMessage($event->getRoom(), 'call_joined', [], $event->getParticipant());
} else {
$silent = $event->getDetail(AParticipantModifiedEvent::DETAIL_IN_CALL_SILENT) ?? false;
$this->sendSystemMessage($event->getRoom(), 'call_started', [], $event->getParticipant(), silent: $silent);
}
}
protected function sendSystemMessageAboutCallLeft(ParticipantModifiedEvent $event): void {
if ($event->getDetail(AParticipantModifiedEvent::DETAIL_IN_CALL_END_FOR_EVERYONE)) {
// No individual system message if the call is ended for everyone
return;
}
if ($event->getNewValue() === $event->getOldValue()) {
return;
}
if ($event->getOldValue() === Participant::FLAG_DISCONNECTED
|| $event->getNewValue() !== Participant::FLAG_DISCONNECTED) {
return;
}
$session = $event->getParticipant()->getSession();
if (!$session instanceof Session) {
// This happens in case the user was kicked/lobbied
return;
}
$this->sendSystemMessage($event->getRoom(), 'call_left', [], $event->getParticipant());
}
protected function sendSystemMessageAboutConversationCreated(RoomCreatedEvent $event): void {
if ($event->getRoom()->getType() === Room::TYPE_CHANGELOG || $this->isCreatingNoteToSelfAutomatically($event) || $this->isCreatingSample($event)) {
$this->sendSystemMessage($event->getRoom(), 'conversation_created', forceSystemAsActor: true);
} else {
$this->sendSystemMessage($event->getRoom(), 'conversation_created');
}
}
protected function sendSystemMessageAboutConversationRenamed(RoomModifiedEvent $event): void {
if ($event->getOldValue() === ''
|| $event->getNewValue() === '') {
return;
}
$this->sendSystemMessage($event->getRoom(), 'conversation_renamed', [
'newName' => $event->getNewValue(),
'oldName' => $event->getOldValue(),
]);
}
protected function sendSystemMessageAboutRoomDescriptionChanges(RoomModifiedEvent $event): void {
if ($event->getNewValue() !== '') {
if ($this->isCreatingNoteToSelf($event) || $this->isCreatingSample($event)) {
return;
}
$this->sendSystemMessage($event->getRoom(), 'description_set', [
'newDescription' => $event->getNewValue(),
]);
} else {
$this->sendSystemMessage($event->getRoom(), 'description_removed');
}
}
protected function sendSystemMessageAboutRoomPassword(RoomModifiedEvent $event): void {
if ($event->getNewValue() !== '') {
$this->sendSystemMessage($event->getRoom(), 'password_set');
} else {
$this->sendSystemMessage($event->getRoom(), 'password_removed');
}
}
protected function sendSystemGuestPermissionsMessage(RoomModifiedEvent $event): void {
if ($event->getOldValue() === Room::TYPE_ONE_TO_ONE) {
return;
}
if ($event->getNewValue() === Room::TYPE_PUBLIC) {
$this->sendSystemMessage($event->getRoom(), 'guests_allowed');
} elseif ($event->getNewValue() === Room::TYPE_GROUP) {
$this->sendSystemMessage($event->getRoom(), 'guests_disallowed');
}
}
protected function sendSystemReadOnlyMessage(RoomModifiedEvent $event): void {
$room = $event->getRoom();
if ($room->getType() === Room::TYPE_CHANGELOG) {
return;
}
if ($event->getNewValue() === Room::READ_ONLY) {
$this->sendSystemMessage($room, 'read_only');
} elseif ($event->getNewValue() === Room::READ_WRITE) {
$this->sendSystemMessage($room, 'read_only_off');
}
}
protected function sendSystemListableMessage(RoomModifiedEvent $event): void {
if ($event->getNewValue() === Room::LISTABLE_NONE) {
$this->sendSystemMessage($event->getRoom(), 'listable_none');
} elseif ($event->getNewValue() === Room::LISTABLE_USERS) {
$this->sendSystemMessage($event->getRoom(), 'listable_users');
} elseif ($event->getNewValue() === Room::LISTABLE_ALL) {
$this->sendSystemMessage($event->getRoom(), 'listable_all');
}
}
protected function sendSystemLobbyMessage(LobbyModifiedEvent $event): void {
if ($event->getNewValue() === $event->getOldValue()) {
return;
}
$room = $event->getRoom();
if ($room->getObjectType() === BreakoutRoom::PARENT_OBJECT_TYPE) {
if ($event->getNewValue() === Webinary::LOBBY_NONE) {
$this->sendSystemMessage($room, 'breakout_rooms_started');
} else {
$this->sendSystemMessage($room, 'breakout_rooms_stopped');
}
} elseif ($event->isTimerReached()) {
$this->sendSystemMessage($room, 'lobby_timer_reached');
} elseif ($event->getNewValue() === Webinary::LOBBY_NONE) {
$this->sendSystemMessage($room, 'lobby_none');
} elseif ($event->getNewValue() === Webinary::LOBBY_NON_MODERATORS) {
$this->sendSystemMessage($room, 'lobby_non_moderators');
}
}
protected function addSystemMessageUserAdded(AttendeesAddedEvent $event, Attendee $attendee): void {
$room = $event->getRoom();
if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
return;
}
if ($room->getType() === Room::TYPE_CHANGELOG) {
return;
}
$userJoinedFileRoom = $room->getObjectType() === Room::OBJECT_TYPE_FILE && $attendee->getParticipantType() !== Participant::USER_SELF_JOINED;
// add a message "X joined the conversation", whenever user $userId:
if (
// - has joined a file room but not through a public link
$userJoinedFileRoom
// - has been added by another user (and not when creating a conversation)
|| $this->getUserId() !== $attendee->getActorId()
// - has joined a listable room on their own
|| $attendee->getParticipantType() === Participant::USER) {
$this->logger->debug('User "' . $attendee->getActorId() . '" added to room "' . $room->getToken() . '"', ['app' => 'spreed-bfp']);
$comment = $this->sendSystemMessage(
$room,
'user_added',
['user' => $attendee->getActorId()],
null,
$event->shouldSkipLastMessageUpdate()
);
$event->setLastMessage($comment);
}
}
protected function sendSystemMessageUserRemoved(AttendeeRemovedEvent $event): void {
$room = $event->getRoom();
if ($event->getAttendee()->getActorType() !== Attendee::ACTOR_USERS) {
return;
}
if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
return;
}
if ($event->getReason() === AAttendeeRemovedEvent::REASON_LEFT
&& $event->getAttendee()->getParticipantType() === Participant::USER_SELF_JOINED) {
// Self-joined user closes the tab/window or leaves via the menu
return;
}
$this->logger->debug('User "' . $event->getAttendee()->getActorId() . '" removed from room "' . $room->getToken() . '"', ['app' => 'spreed-bfp']);
$this->sendSystemMessage($room, 'user_removed', ['user' => $event->getAttendee()->getActorId()]);
}
public function sendSystemMessageAboutPromoteOrDemoteModerator(ParticipantModifiedEvent $event): void {
$room = $event->getRoom();
$attendee = $event->getParticipant()->getAttendee();
if (!in_array($attendee->getActorType(), [
Attendee::ACTOR_USERS,
Attendee::ACTOR_EMAILS,
Attendee::ACTOR_GUESTS,
], true)) {
return;
}
if ($event->getNewValue() === Participant::MODERATOR) {
$this->sendSystemMessage($room, 'moderator_promoted', ['user' => $attendee->getActorId()]);
} elseif ($event->getNewValue() === Participant::USER) {
if ($event->getOldValue() === Participant::USER_SELF_JOINED) {
$this->sendSystemMessage($room, 'user_added', ['user' => $attendee->getActorId()]);
} else {
$this->sendSystemMessage($room, 'moderator_demoted', ['user' => $attendee->getActorId()]);
}
} elseif ($event->getNewValue() === Participant::GUEST_MODERATOR) {
$this->sendSystemMessage($room, 'guest_moderator_promoted', ['type' => $attendee->getActorType(), 'id' => $attendee->getActorId()]);
} elseif ($event->getNewValue() === Participant::GUEST) {
$this->sendSystemMessage($room, 'guest_moderator_demoted', ['type' => $attendee->getActorType(), 'id' => $attendee->getActorId()]);
}
}
protected function setShareExpiration(BeforeShareCreatedEvent $event): void {
$share = $event->getShare();
if ($share->getShareType() !== IShare::TYPE_ROOM) {
return;
}
$room = $this->manager->getRoomByToken($share->getSharedWith());
$messageExpiration = $room->getMessageExpiration();
if (!$messageExpiration) {
return;
}
$dateTime = $this->timeFactory->getDateTime();
$dateTime->add(DateInterval::createFromDateString($messageExpiration . ' seconds'));
$share->setExpirationDate($dateTime);
}
protected function fixMimeTypeOfVoiceMessage(ShareCreatedEvent|BeforeDuplicateShareSentEvent $event): void {
$share = $event->getShare();
if ($share->getShareType() !== IShare::TYPE_ROOM) {
return;
}
if (strtolower($this->request->getParam('_route')) === 'ocs.spreed.recording.sharetochat') {
return;
}
$room = $this->manager->getRoomByToken($share->getSharedWith());
$this->participantService->ensureOneToOneRoomIsFilled($room);
$metaData = $this->request->getParam('talkMetaData') ?? '';
$metaData = json_decode($metaData, true);
$metaData = is_array($metaData) ? $metaData : [];
if (isset($metaData['messageType']) && $metaData['messageType'] === ChatManager::VERB_VOICE_MESSAGE) {
if ($share->getNode()->getMimeType() !== 'audio/mpeg'
&& $share->getNode()->getMimeType() !== 'audio/wav') {
unset($metaData['messageType']);
}
}
$metaData['mimeType'] = $share->getNode()->getMimeType();
if (isset($metaData['caption'])) {
if (is_string($metaData['caption']) && trim($metaData['caption']) !== '') {
$metaData['caption'] = trim($metaData['caption']);
} else {
unset($metaData['caption']);
}
}
if (isset($metaData[Message::METADATA_SILENT])) {
$silent = (bool)$metaData[Message::METADATA_SILENT];
} else {
$silent = false;
}
$replyTo = null;
if (isset($metaData['replyTo'])) {
$replyTo = (int)$metaData['replyTo'];
unset($metaData['replyTo']);
}
$threadId = null;
if (isset($metaData['threadId'])) {
$threadId = (int)$metaData['threadId'];
unset($metaData['threadId']);
}
$threadTitle = '';
if (isset($metaData['threadTitle'])) {
if (is_string($metaData['threadTitle']) && trim($metaData['threadTitle']) !== '') {
$threadTitle = trim($metaData['threadTitle']);
}
unset($metaData['threadTitle']);
}
$comment = $this->sendSystemMessage(
$room,
'file_shared',
['share' => $share->getId(), 'metaData' => $metaData],
silent: $silent,
replyTo: $replyTo,
threadId: $threadId,
);
$messageId = (int)$comment->getId();
if ($threadTitle !== '' && $comment->getTopmostParentId() === '0') {
$thread = $this->threadService->createThread($room, $messageId, $threadTitle);
try {
// Add to subscribed threads list
$participant = $this->participantService->getParticipant($room, $this->getUserId());
$this->threadService->setNotificationLevel($participant->getAttendee(), $thread->getId(), Participant::NOTIFY_DEFAULT);
} catch (ParticipantNotFoundException) {
}
$this->sendSystemMessage(
$room,
'thread_created',
['thread' => $messageId, 'title' => $thread->getName()],
shouldSkipLastMessageUpdate: true,
silent: true,
parent: $comment,
);
}
}
protected function attendeesAddedEvent(AttendeesAddedEvent $event): void {
foreach ($event->getAttendees() as $attendee) {
$this->logger->debug($attendee->getActorType() . ' "' . $attendee->getActorId() . '" added to room "' . $event->getRoom()->getToken() . '"', ['app' => 'spreed-bfp']);
if ($attendee->getActorType() === Attendee::ACTOR_GROUPS) {
$this->sendSystemMessage($event->getRoom(), 'group_added', ['group' => $attendee->getActorId()]);
} elseif ($attendee->getActorType() === Attendee::ACTOR_CIRCLES) {
$this->sendSystemMessage($event->getRoom(), 'circle_added', ['circle' => $attendee->getActorId()]);
} elseif ($attendee->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
$this->sendSystemMessage($event->getRoom(), 'federated_user_added', ['federated_user' => $attendee->getActorId()]);
} elseif ($attendee->getActorType() === Attendee::ACTOR_PHONES) {
$this->sendSystemMessage($event->getRoom(), 'phone_added', ['phone' => $attendee->getActorId(), 'name' => $attendee->getDisplayName()]);
} elseif ($attendee->getActorType() === Attendee::ACTOR_USERS) {
$this->addSystemMessageUserAdded($event, $attendee);
}
}
}
protected function attendeesRemovedEvent(AttendeesRemovedEvent $event): void {
foreach ($event->getAttendees() as $attendee) {
$this->logger->debug($attendee->getActorType() . ' "' . $attendee->getActorId() . '" removed from room "' . $event->getRoom()->getToken() . '"', ['app' => 'spreed-bfp']);
if ($attendee->getActorType() === Attendee::ACTOR_GROUPS) {
$this->sendSystemMessage($event->getRoom(), 'group_removed', ['group' => $attendee->getActorId()]);
} elseif ($attendee->getActorType() === Attendee::ACTOR_CIRCLES) {
$this->sendSystemMessage($event->getRoom(), 'circle_removed', ['circle' => $attendee->getActorId()]);
} elseif ($attendee->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
$this->sendSystemMessage($event->getRoom(), 'federated_user_removed', ['federated_user' => $attendee->getActorId()]);
} elseif ($attendee->getActorType() === Attendee::ACTOR_PHONES) {
$this->sendSystemMessage($event->getRoom(), 'phone_removed', ['phone' => $attendee->getActorId(), 'name' => $attendee->getDisplayName()]);
}
}
}
protected function sendSystemMessage(
Room $room,
string $message,
array $parameters = [],
?Participant $participant = null,
bool $shouldSkipLastMessageUpdate = false,
bool $silent = false,
bool $forceSystemAsActor = false,
?int $replyTo = null,
?IComment $parent = null,
?int $threadId = null,
): IComment {
if ($participant instanceof Participant) {
$actorType = $participant->getAttendee()->getActorType();
$actorId = $participant->getAttendee()->getActorId();
} elseif ($forceSystemAsActor) {
$actorType = Attendee::ACTOR_GUESTS;
$actorId = Attendee::ACTOR_ID_SYSTEM;
} else {
$user = $this->userSession->getUser();
if ($user instanceof IUser) {
$actorType = Attendee::ACTOR_USERS;
$actorId = $user->getUID();
} elseif (\OC::$CLI || $this->session->exists('talk-overwrite-actor-cli')) {
$actorType = Attendee::ACTOR_GUESTS;
$actorId = Attendee::ACTOR_ID_CLI;
} elseif ($this->session->exists('talk-overwrite-actor-type')) {
$actorType = $this->session->get('talk-overwrite-actor-type');
$actorId = $this->session->get('talk-overwrite-actor-id');
} elseif ($this->session->exists('talk-overwrite-actor-id')) {
$actorType = Attendee::ACTOR_USERS;
$actorId = $this->session->get('talk-overwrite-actor-id');
} else {
$actorType = Attendee::ACTOR_GUESTS;
$sessionId = $this->talkSession->getSessionForRoom($room->getToken());
$actorId = $sessionId ? sha1($sessionId) : 'failed-to-get-session';
}
}
// Little hack to get the reference id from the share request into
// the system message left for the share in the chat.
$referenceId = $this->request->getParam('referenceId', null);
if ($referenceId !== null) {
$referenceId = (string)$referenceId;
}
if ($parent === null && $replyTo !== null) {
try {
$parentComment = $this->chatManager->getParentComment($room, (string)$replyTo);
$parentMessage = $this->messageParser->createMessage($room, $participant, $parentComment, $this->l);
$this->messageParser->parseMessage($parentMessage, true);
if ($parentMessage->isReplyable()) {
$parent = $parentComment;
}
} catch (NotFoundException) {
}
} elseif ($parent === null && $threadId !== null) {
if (!$this->threadService->validateThread($room->getId(), $threadId)) {
$threadId = null;
}
}
return $this->chatManager->addSystemMessage(
$room, $participant, $actorType, $actorId,
json_encode(['message' => $message, 'parameters' => $parameters]),
$this->timeFactory->getDateTime(),
$message === 'file_shared',
$referenceId,
$parent,
$shouldSkipLastMessageUpdate,
$silent,
$threadId ?? 0,
);
}
protected function getUserId(): ?string {
$user = $this->userSession->getUser();
return $user instanceof IUser ? $user->getUID() : null;
}
protected function afterSetMessageExpiration(RoomModifiedEvent $event): void {
$seconds = $event->getNewValue();
if ($seconds > 0) {
$message = 'message_expiration_enabled';
} else {
$message = 'message_expiration_disabled';
}
$this->sendSystemMessage(
$event->getRoom(),
$message,
[
'seconds' => $seconds,
]
);
}
protected function setCallRecording(RoomModifiedEvent $event): void {
$recordingHasStarted = in_array($event->getOldValue(), [Room::RECORDING_NONE, Room::RECORDING_VIDEO_STARTING, Room::RECORDING_AUDIO_STARTING, Room::RECORDING_FAILED], true)
&& in_array($event->getNewValue(), [Room::RECORDING_VIDEO, Room::RECORDING_AUDIO], true);
$recordingHasStopped = in_array($event->getOldValue(), [Room::RECORDING_VIDEO, Room::RECORDING_AUDIO], true)
&& $event->getNewValue() === Room::RECORDING_NONE;
$recordingHasFailed = in_array($event->getOldValue(), [Room::RECORDING_VIDEO, Room::RECORDING_AUDIO], true)
&& $event->getNewValue() === Room::RECORDING_FAILED;
if (!$recordingHasStarted && !$recordingHasStopped && !$recordingHasFailed) {
return;
}
$actor = $event->getActor();
if ($recordingHasStopped && $actor === null) {
// No actor means the recording was stopped by the end of the call.
// So we are not generating a system message
return;
}
$prefix = $this->getCallRecordingPrefix($event);
$suffix = $this->getCallRecordingSuffix($event);
$systemMessage = $prefix . 'recording_' . $suffix;
$this->sendSystemMessage($event->getRoom(), $systemMessage, [], $actor);
}
protected function getCallRecordingSuffix(RoomModifiedEvent $event): string {
$newStatus = $event->getNewValue();
$startStatus = [
Room::RECORDING_VIDEO,
Room::RECORDING_AUDIO,
];
if (in_array($newStatus, $startStatus, true)) {
return 'started';
}
if ($newStatus === Room::RECORDING_FAILED) {
return 'failed';
}
return 'stopped';
}
protected function getCallRecordingPrefix(RoomModifiedEvent $event): string {
$newValue = $event->getNewValue();
$oldValue = $event->getOldValue();
$isAudioStatus = $newValue === Room::RECORDING_AUDIO
|| ($oldValue === Room::RECORDING_AUDIO && $newValue !== Room::RECORDING_FAILED);
return $isAudioStatus ? 'audio_' : '';
}
protected function avatarChanged(RoomModifiedEvent $event): void {
if ($event->getNewValue()) {
if ($this->isCreatingNoteToSelf($event) || $this->isCreatingSample($event)) {
return;
}
$message = 'avatar_set';
} else {
$message = 'avatar_removed';
}
$this->sendSystemMessage($event->getRoom(), $message);
}
protected function isCreatingNoteToSelf(RoomModifiedEvent $event): bool {
if ($event->getRoom()->getType() !== Room::TYPE_NOTE_TO_SELF) {
return false;
}
$exception = new \Exception();
$trace = $exception->getTrace();
foreach ($trace as $step) {
if (isset($step['class']) && $step['class'] === NoteToSelfService::class
&& isset($step['function']) && $step['function'] === 'initialCreateNoteToSelfForUser') {
return true;
}
if (isset($step['class']) && $step['class'] === NoteToSelfService::class
&& isset($step['function']) && $step['function'] === 'ensureNoteToSelfExistsForUser') {
return true;
}
}
return false;
}
protected function isCreatingSample(ARoomEvent $event): bool {
if ($event->getRoom()->getType() !== Room::TYPE_GROUP) {
return false;
}
$exception = new \Exception();
$trace = $exception->getTrace();
foreach ($trace as $step) {
if (isset($step['class']) && $step['class'] === SampleConversationsService::class
&& isset($step['function']) && $step['function'] === 'initialCreateSamples') {
return true;
}
}
return false;
}
protected function isCreatingNoteToSelfAutomatically(RoomCreatedEvent $event): bool {
if ($event->getRoom()->getType() !== Room::TYPE_NOTE_TO_SELF) {
return false;
}
$exception = new \Exception();
$trace = $exception->getTrace();
foreach ($trace as $step) {
if (isset($step['class']) && $step['class'] === NoteToSelfService::class
&& isset($step['function']) && $step['function'] === 'initialCreateNoteToSelfForUser') {
return true;
}
}
return false;
}
}