diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3e44c14c7bc..d50322263db 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -24,6 +24,8 @@ namespace OCA\Talk\AppInfo; +use OCA\Talk\Collaboration\Reference\ReferenceInvalidationListener; +use OCA\Talk\Collaboration\Reference\TalkReferenceProvider; use OCP\Util; use OCA\Circles\Events\AddingCircleMemberEvent; use OCA\Circles\Events\CircleDestroyedEvent; @@ -145,6 +147,8 @@ public function register(IRegistrationContext $context): void { $context->registerProfileLinkAction(TalkAction::class); + $context->registerReferenceProvider(TalkReferenceProvider::class); + $context->registerTalkBackend(TalkBackend::class); } @@ -172,6 +176,7 @@ public function boot(IBootContext $context): void { CommandListener::register($dispatcher); CollaboratorsListener::register($dispatcher); ResourceListener::register($dispatcher); + ReferenceInvalidationListener::register($dispatcher); // Register only when Talk Updates are not disabled if ($server->getConfig()->getAppValue('spreed', 'changelog', 'yes') === 'yes') { ChangelogListener::register($dispatcher); diff --git a/lib/Chat/ChatManager.php b/lib/Chat/ChatManager.php index d26cbeb40dd..e62e44b62c3 100644 --- a/lib/Chat/ChatManager.php +++ b/lib/Chat/ChatManager.php @@ -38,6 +38,7 @@ use OCA\Talk\Service\PollService; use OCA\Talk\Share\RoomShareProvider; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Collaboration\Reference\IReferenceManager; use OCP\Comments\IComment; use OCP\Comments\ICommentsManager; use OCP\Comments\NotFoundException; @@ -86,7 +87,6 @@ class ChatManager { private IDBConnection $connection; private INotificationManager $notificationManager; private IManager $shareManager; - private Manager $manager; private RoomShareProvider $shareProvider; private ParticipantService $participantService; private PollService $pollService; @@ -95,26 +95,26 @@ class ChatManager { protected ICache $cache; protected ICache $unreadCountCache; protected AttachmentService $attachmentService; + protected IReferenceManager $referenceManager; public function __construct(CommentsManager $commentsManager, IEventDispatcher $dispatcher, IDBConnection $connection, INotificationManager $notificationManager, IManager $shareManager, - Manager $manager, RoomShareProvider $shareProvider, ParticipantService $participantService, PollService $pollService, Notifier $notifier, ICacheFactory $cacheFactory, ITimeFactory $timeFactory, - AttachmentService $attachmentService) { + AttachmentService $attachmentService, + IReferenceManager $referenceManager) { $this->commentsManager = $commentsManager; $this->dispatcher = $dispatcher; $this->connection = $connection; $this->notificationManager = $notificationManager; $this->shareManager = $shareManager; - $this->manager = $manager; $this->shareProvider = $shareProvider; $this->participantService = $participantService; $this->pollService = $pollService; @@ -123,6 +123,7 @@ public function __construct(CommentsManager $commentsManager, $this->unreadCountCache = $cacheFactory->createDistributed('talk/unreadcount'); $this->timeFactory = $timeFactory; $this->attachmentService = $attachmentService; + $this->referenceManager = $referenceManager; } /** @@ -378,6 +379,8 @@ public function deleteMessage(Room $chat, IComment $comment, Participant $partic $this->attachmentService->deleteAttachmentByMessageId((int) $comment->getId()); + $this->referenceManager->invalidateCache($chat->getToken()); + return $this->addSystemMessage( $chat, $participant->getAttendee()->getActorType(), diff --git a/lib/Collaboration/Reference/ReferenceInvalidationListener.php b/lib/Collaboration/Reference/ReferenceInvalidationListener.php new file mode 100644 index 00000000000..2db37c824f3 --- /dev/null +++ b/lib/Collaboration/Reference/ReferenceInvalidationListener.php @@ -0,0 +1,52 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Collaboration\Reference; + +use OCA\Talk\Events\RoomEvent; +use OCA\Talk\Room; +use OCP\Collaboration\Reference\IReferenceManager; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Server; + +class ReferenceInvalidationListener { + public static function register(IEventDispatcher $dispatcher): void { + $listener = static function (RoomEvent $event): void { + $room = $event->getRoom(); + $referenceManager = Server::get(IReferenceManager::class); + + $referenceManager->invalidateCache($room->getToken()); + }; + + $dispatcher->addListener(Room::EVENT_AFTER_ROOM_DELETE, $listener); + $dispatcher->addListener(Room::EVENT_AFTER_USERS_ADD, $listener); + $dispatcher->addListener(Room::EVENT_AFTER_USER_REMOVE, $listener); + $dispatcher->addListener(Room::EVENT_AFTER_DESCRIPTION_SET, $listener); + $dispatcher->addListener(Room::EVENT_AFTER_LISTABLE_SET, $listener); + $dispatcher->addListener(Room::EVENT_AFTER_LOBBY_STATE_SET, $listener); + $dispatcher->addListener(Room::EVENT_AFTER_NAME_SET, $listener); + $dispatcher->addListener(Room::EVENT_AFTER_PARTICIPANT_REMOVE, $listener); + $dispatcher->addListener(Room::EVENT_AFTER_PASSWORD_SET, $listener); + $dispatcher->addListener(Room::EVENT_AFTER_SET_MESSAGE_EXPIRATION, $listener); + } +} diff --git a/lib/Collaboration/Reference/TalkReferenceProvider.php b/lib/Collaboration/Reference/TalkReferenceProvider.php new file mode 100644 index 00000000000..f3b0b1ba27f --- /dev/null +++ b/lib/Collaboration/Reference/TalkReferenceProvider.php @@ -0,0 +1,282 @@ + + * + * @author Joas Schilling + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Talk\Collaboration\Reference; + +use OCA\Talk\Chat\ChatManager; +use OCA\Talk\Chat\MessageParser; +use OCA\Talk\Exceptions\ParticipantNotFoundException; +use OCA\Talk\Exceptions\RoomNotFoundException; +use OCA\Talk\Manager; +use OCA\Talk\Model\Attendee; +use OCA\Talk\Participant; +use OCA\Talk\Room; +use OCP\Collaboration\Reference\IReference; +use OCP\Collaboration\Reference\IReferenceProvider; +use OCP\Collaboration\Reference\Reference; +use OCP\IL10N; +use OCP\IURLGenerator; + +/** + * @psalm-type ReferenceMatch = array{token: string, message: int|null} + */ +class TalkReferenceProvider implements IReferenceProvider { + protected IURLGenerator $urlGenerator; + protected Manager $roomManager; + protected ChatManager $chatManager; + protected MessageParser $messageParser; + protected IL10N $l; + protected ?string $userId; + + public function __construct(IURLGenerator $urlGenerator, + Manager $manager, + ChatManager $chatManager, + MessageParser $messageParser, + IL10N $l, + ?string $userId) { + $this->urlGenerator = $urlGenerator; + $this->roomManager = $manager; + $this->chatManager = $chatManager; + $this->messageParser = $messageParser; + $this->l = $l; + $this->userId = $userId; + } + + + public function matchReference(string $referenceText): bool { + return $this->getTalkAppLinkToken($referenceText) !== null; + } + + /** + * @param string $referenceText + * @return array|null + * @psalm-return ReferenceMatch|null + */ + protected function getTalkAppLinkToken(string $referenceText): ?array { + $indexPhpUrl = $this->urlGenerator->getAbsoluteURL('/index.php/call/'); + $rewriteUrl = $this->urlGenerator->getAbsoluteURL('/call/'); + + if (str_starts_with($referenceText, $indexPhpUrl)) { + $urlOfInterest = substr($referenceText, strlen($indexPhpUrl)); + } elseif (str_starts_with($referenceText, $rewriteUrl)) { + $urlOfInterest = substr($referenceText, strlen($rewriteUrl)); + } else { + return null; + } + + $hashPosition = strpos($urlOfInterest, '#'); + $queryPosition = strpos($urlOfInterest, '?'); + + if ($hashPosition === false && $queryPosition === false) { + return [ + 'token' => $urlOfInterest, + 'message' => null, + ]; + } + + if ($hashPosition !== false && $queryPosition !== false) { + $cutPosition = min($hashPosition, $queryPosition); + } elseif ($hashPosition !== false) { + $cutPosition = $hashPosition; + } else { + $cutPosition = $queryPosition; + } + + $token = substr($urlOfInterest, 0, $cutPosition); + $messageId = null; + if ($hashPosition !== false) { + $afterHash = substr($urlOfInterest, $hashPosition + 1); + if (preg_match('/^message_(\d+)$/', $afterHash, $matches)) { + $messageId = (int) $matches[1]; + } + } + + return [ + 'token' => $token, + 'message' => $messageId, + ]; + } + + /** + * @inheritDoc + */ + public function resolveReference(string $referenceText): ?IReference { + if ($this->matchReference($referenceText)) { + $reference = new Reference($referenceText); + try { + $this->fetchReference($reference); + } catch (RoomNotFoundException|ParticipantNotFoundException $e) { + $reference->setRichObject('call', null); + $reference->setAccessible(false); + } + return $reference; + } + + return null; + } + + /** + * @throws RoomNotFoundException + */ + protected function fetchReference(Reference $reference): void { + if ($this->userId === null) { + throw new RoomNotFoundException(); + } + + $referenceMatch = $this->getTalkAppLinkToken($reference->getId()); + if ($referenceMatch === null) { + throw new RoomNotFoundException(); + } + + $room = $this->roomManager->getRoomForUserByToken($referenceMatch['token'], $this->userId); + try { + $participant = $room->getParticipant($this->userId); + } catch (ParticipantNotFoundException $e) { + $participant = null; + } + + /** + * Default handling: + * Title is the conversation name + * Description the conversation description + */ + $roomName = $room->getDisplayName($this->userId); + $title = $roomName; + $description = ''; + + if ($participant instanceof Participant + || $this->roomManager->isRoomListableByUser($room, $this->userId)) { + $description = $room->getDescription(); + } + + + /** + * If linking to a comment and the user is already a participant + * Title is "Message of {user} in {conversation}" + * Description is the plain text chat message + */ + if ($participant && !empty($referenceMatch['message'])) { + $comment = $this->chatManager->getComment($room, (string) $referenceMatch['message']); + $message = $this->messageParser->createMessage($room, $participant, $comment, $this->l); + $this->messageParser->parseMessage($message); + + $placeholders = $replacements = []; + foreach ($message->getMessageParameters() as $placeholder => $parameter) { + $placeholders[] = '{' . $placeholder . '}'; + if ($parameter['type'] === 'user' || $parameter['type'] === 'guest') { + $replacements[] = '@' . $parameter['name']; + } else { + $replacements[] = $parameter['name']; + } + } + $description = str_replace($placeholders, $replacements, $message->getMessage()); + + $titleLine = $this->l->t('Message of {user} in {conversation}'); + if ($room->getType() === Room::TYPE_ONE_TO_ONE) { + $titleLine = $this->l->t('Message of {user}'); + } + + $displayName = $message->getActorDisplayName(); + if ($message->getActorType() === Attendee::ACTOR_GUESTS) { + if ($displayName === '') { + $displayName = $this->l->t('Guest'); + } else { + $displayName = $this->l->t('%s (guest)', $displayName); + } + } elseif ($displayName === '') { + $titleLine = $this->l->t('Message of a deleted user in {conversation}'); + } + + $title = str_replace( + ['{user}', '{conversation}'], + [$displayName, $title], + $titleLine + ); + } + + $reference->setTitle($title); + $reference->setDescription($description); + $reference->setUrl($this->urlGenerator->linkToRouteAbsolute('spreed.Page.showCall', ['token' => $room->getToken()])); + $reference->setImageUrl($this->getRoomIconUrl($room, $this->userId)); + + $reference->setRichObject('call', [ + 'id' => $room->getToken(), + 'name' => $roomName, + 'link' => $reference->getUrl(), + 'call-type' => $this->getRoomType($room), + ]); + } + + /** + * @inheritDoc + */ + public function getCachePrefix(string $referenceId): string { + $referenceMatch = $this->getTalkAppLinkToken($referenceId); + if ($referenceMatch === null) { + return ''; + } + + return $referenceMatch['token']; + } + + /** + * @inheritDoc + */ + public function getCacheKey(string $referenceId): ?string { + $referenceMatch = $this->getTalkAppLinkToken($referenceId); + if ($referenceMatch === null) { + return ''; + } + + return ($this->userId ?? '') . '#' . ($referenceMatch['message'] ?? 0); + } + + protected function getRoomIconUrl(Room $room, string $userId): string { + if ($room->getType() === Room::TYPE_ONE_TO_ONE) { + return $this->urlGenerator->linkToRouteAbsolute( + 'core.avatar.getAvatar', + [ + 'userId' => $room->getSecondParticipant($userId), + 'size' => 64, + ] + ); + } + + return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('spreed', 'changelog.svg')); + } + + protected function getRoomType(Room $room): string { + switch ($room->getType()) { + case Room::TYPE_ONE_TO_ONE: + return 'one2one'; + case Room::TYPE_GROUP: + return 'group'; + case Room::TYPE_PUBLIC: + return 'public'; + default: + return 'unknown'; + } + } +} diff --git a/lib/Room.php b/lib/Room.php index aee8ff8ce64..032325008e1 100644 --- a/lib/Room.php +++ b/lib/Room.php @@ -369,6 +369,21 @@ public function getName(): string { return $this->name; } + public function getSecondParticipant(string $userId): string { + if ($this->getType() !== self::TYPE_ONE_TO_ONE) { + throw new \InvalidArgumentException('Not a one-to-one room'); + } + $participants = json_decode($this->getName(), true); + + foreach ($participants as $uid) { + if ($uid !== $userId) { + return $uid; + } + } + + return $this->getName(); + } + public function getDisplayName(string $userId): string { return $this->manager->resolveRoomDisplayName($this, $userId); } diff --git a/tests/php/Chat/ChatManagerTest.php b/tests/php/Chat/ChatManagerTest.php index 09efc319c96..37d998d4ece 100644 --- a/tests/php/Chat/ChatManagerTest.php +++ b/tests/php/Chat/ChatManagerTest.php @@ -27,7 +27,6 @@ use OCA\Talk\Chat\ChatManager; use OCA\Talk\Chat\CommentsManager; use OCA\Talk\Chat\Notifier; -use OCA\Talk\Manager; use OCA\Talk\Model\Attendee; use OCA\Talk\Model\AttendeeMapper; use OCA\Talk\Participant; @@ -37,6 +36,7 @@ use OCA\Talk\Service\PollService; use OCA\Talk\Share\RoomShareProvider; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Collaboration\Reference\IReferenceManager; use OCP\Comments\IComment; use OCP\Comments\ICommentsManager; use OCP\EventDispatcher\IEventDispatcher; @@ -61,8 +61,6 @@ class ChatManagerTest extends TestCase { protected $notificationManager; /** @var IManager|MockObject */ protected $shareManager; - /** @var Manager|MockObject */ - protected $manager; /** @var RoomShareProvider|MockObject */ protected $shareProvider; /** @var ParticipantService|MockObject */ @@ -75,6 +73,8 @@ class ChatManagerTest extends TestCase { protected $timeFactory; /** @var AttachmentService|MockObject */ protected $attachmentService; + /** @var IReferenceManager|MockObject */ + protected $referenceManager; protected ?ChatManager $chatManager = null; public function setUp(): void { @@ -84,13 +84,13 @@ public function setUp(): void { $this->dispatcher = $this->createMock(IEventDispatcher::class); $this->notificationManager = $this->createMock(INotificationManager::class); $this->shareManager = $this->createMock(IManager::class); - $this->manager = $this->createMock(Manager::class); $this->shareProvider = $this->createMock(RoomShareProvider::class); $this->participantService = $this->createMock(ParticipantService::class); $this->pollService = $this->createMock(PollService::class); $this->notifier = $this->createMock(Notifier::class); $this->timeFactory = $this->createMock(ITimeFactory::class); $this->attachmentService = $this->createMock(AttachmentService::class); + $this->referenceManager = $this->createMock(IReferenceManager::class); $cacheFactory = $this->createMock(ICacheFactory::class); $this->chatManager = new ChatManager( @@ -99,14 +99,14 @@ public function setUp(): void { \OC::$server->getDatabaseConnection(), $this->notificationManager, $this->shareManager, - $this->manager, $this->shareProvider, $this->participantService, $this->pollService, $this->notifier, $cacheFactory, $this->timeFactory, - $this->attachmentService + $this->attachmentService, + $this->referenceManager ); } @@ -125,7 +125,6 @@ protected function getManager(array $methods = []): ChatManager { \OC::$server->getDatabaseConnection(), $this->notificationManager, $this->shareManager, - $this->manager, $this->shareProvider, $this->participantService, $this->pollService, @@ -133,6 +132,7 @@ protected function getManager(array $methods = []): ChatManager { $cacheFactory, $this->timeFactory, $this->attachmentService, + $this->referenceManager, ]) ->onlyMethods($methods) ->getMock(); @@ -144,14 +144,14 @@ protected function getManager(array $methods = []): ChatManager { \OC::$server->getDatabaseConnection(), $this->notificationManager, $this->shareManager, - $this->manager, $this->shareProvider, $this->participantService, $this->pollService, $this->notifier, $cacheFactory, $this->timeFactory, - $this->attachmentService + $this->attachmentService, + $this->referenceManager ); } diff --git a/tests/php/Collaboration/Reference/TalkReferenceProviderTest.php b/tests/php/Collaboration/Reference/TalkReferenceProviderTest.php new file mode 100644 index 00000000000..08c86db95c2 --- /dev/null +++ b/tests/php/Collaboration/Reference/TalkReferenceProviderTest.php @@ -0,0 +1,95 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Tests\php\Collaboration\Resources; + +use OCA\Talk\Chat\ChatManager; +use OCA\Talk\Chat\MessageParser; +use OCA\Talk\Collaboration\Reference\TalkReferenceProvider; +use OCA\Talk\Manager; +use OCP\IL10N; +use OCP\IURLGenerator; +use PHPUnit\Framework\MockObject\MockObject; +use Test\TestCase; + +class TalkReferenceProviderTest extends TestCase { + /** @var IURLGenerator|MockObject */ + protected $urlGenerator; + /** @var Manager|MockObject */ + protected $roomManager; + /** @var ChatManager|MockObject */ + protected $chatManager; + /** @var MessageParser|MockObject */ + protected $messageParser; + /** @var IL10N|MockObject */ + protected $l; + protected ?TalkReferenceProvider $provider = null; + + public function setUp(): void { + parent::setUp(); + + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->roomManager = $this->createMock(Manager::class); + $this->chatManager = $this->createMock(ChatManager::class); + $this->messageParser = $this->createMock(MessageParser::class); + $this->l = $this->createMock(IL10N::class); + + $this->provider = new TalkReferenceProvider( + $this->urlGenerator, + $this->roomManager, + $this->chatManager, + $this->messageParser, + $this->l, + 'test' + ); + } + + public function dataGetTalkAppLinkToken(): array { + return [ + ['https://localhost/', null], + ['https://localhost/call', null], + ['https://localhost/call/abcdef', ['token' => 'abcdef', 'message' => null]], + ['https://localhost/call/abcdef?query=1', ['token' => 'abcdef', 'message' => null]], + ['https://localhost/call/abcdef#hash=1', ['token' => 'abcdef', 'message' => null]], + ['https://localhost/call/abcdef#message_123', ['token' => 'abcdef', 'message' => 123]], + ['https://localhost/call/abcdef?query=1#message_123', ['token' => 'abcdef', 'message' => 123]], + ['https://localhost/call/abcdef?query=1#message_123bcd', ['token' => 'abcdef', 'message' => null]], + ]; + } + + /** + * @dataProvider dataGetTalkAppLinkToken + * @param string $reference + * @param array|null $expected + * @return void + */ + public function testGetTalkAppLinkToken(string $reference, ?array $expected): void { + $this->urlGenerator->expects($this->any()) + ->method('getAbsoluteURL') + ->willReturnCallback(static fn ($url) => 'https://localhost' . $url); + + $actual = self::invokePrivate($this->provider, 'getTalkAppLinkToken', [$reference]); + self::assertSame($expected, $actual); + } +}