diff options
Diffstat (limited to 'apps/comments/lib')
18 files changed, 1396 insertions, 0 deletions
diff --git a/apps/comments/lib/Activity/Filter.php b/apps/comments/lib/Activity/Filter.php new file mode 100644 index 00000000000..8dcafd872d7 --- /dev/null +++ b/apps/comments/lib/Activity/Filter.php @@ -0,0 +1,50 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments\Activity; + +use OCP\Activity\IFilter; +use OCP\IL10N; +use OCP\IURLGenerator; + +class Filter implements IFilter { + public function __construct( + protected IL10N $l, + protected IURLGenerator $url, + ) { + } + + public function getIdentifier(): string { + return 'comments'; + } + + public function getName(): string { + return $this->l->t('Comments'); + } + + public function getPriority(): int { + return 40; + } + + public function getIcon(): string { + return $this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/comment.svg')); + } + + /** + * @param string[] $types + * @return string[] An array of allowed apps from which activities should be displayed + */ + public function filterTypes(array $types): array { + return $types; + } + + /** + * @return string[] An array of allowed apps from which activities should be displayed + */ + public function allowedApps(): array { + return ['comments']; + } +} diff --git a/apps/comments/lib/Activity/Listener.php b/apps/comments/lib/Activity/Listener.php new file mode 100644 index 00000000000..45064f4a6be --- /dev/null +++ b/apps/comments/lib/Activity/Listener.php @@ -0,0 +1,88 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Comments\Activity; + +use OCP\Activity\IManager; +use OCP\App\IAppManager; +use OCP\Comments\CommentsEvent; +use OCP\Files\Config\IMountProviderCollection; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\IUser; +use OCP\IUserSession; +use OCP\Share\IShareHelper; + +class Listener { + public function __construct( + protected IManager $activityManager, + protected IUserSession $session, + protected IAppManager $appManager, + protected IMountProviderCollection $mountCollection, + protected IRootFolder $rootFolder, + protected IShareHelper $shareHelper, + ) { + } + + public function commentEvent(CommentsEvent $event): void { + if ($event->getComment()->getObjectType() !== 'files' + || $event->getEvent() !== CommentsEvent::EVENT_ADD + || !$this->appManager->isEnabledForAnyone('activity')) { + // Comment not for file, not adding a comment or no activity-app enabled (save the energy) + return; + } + + // Get all mount point owners + $cache = $this->mountCollection->getMountCache(); + $mounts = $cache->getMountsForFileId((int)$event->getComment()->getObjectId()); + if (empty($mounts)) { + return; + } + + $users = []; + foreach ($mounts as $mount) { + $owner = $mount->getUser()->getUID(); + $ownerFolder = $this->rootFolder->getUserFolder($owner); + $nodes = $ownerFolder->getById((int)$event->getComment()->getObjectId()); + if (!empty($nodes)) { + /** @var Node $node */ + $node = array_shift($nodes); + $al = $this->shareHelper->getPathsForAccessList($node); + $users += $al['users']; + } + } + + $actor = $this->session->getUser(); + if ($actor instanceof IUser) { + $actor = $actor->getUID(); + } else { + $actor = ''; + } + + $activity = $this->activityManager->generateEvent(); + $activity->setApp('comments') + ->setType('comments') + ->setAuthor($actor) + ->setObject($event->getComment()->getObjectType(), (int)$event->getComment()->getObjectId()) + ->setMessage('add_comment_message', [ + 'commentId' => $event->getComment()->getId(), + ]); + + foreach ($users as $user => $path) { + // numerical user ids end up as integers from array keys, but string + // is required + $activity->setAffectedUser((string)$user); + + $activity->setSubject('add_comment_subject', [ + 'actor' => $actor, + 'fileId' => (int)$event->getComment()->getObjectId(), + 'filePath' => trim($path, '/'), + ]); + $this->activityManager->publish($activity); + } + } +} diff --git a/apps/comments/lib/Activity/Provider.php b/apps/comments/lib/Activity/Provider.php new file mode 100644 index 00000000000..ee53357efdb --- /dev/null +++ b/apps/comments/lib/Activity/Provider.php @@ -0,0 +1,198 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments\Activity; + +use OCP\Activity\Exceptions\UnknownActivityException; +use OCP\Activity\IEvent; +use OCP\Activity\IManager; +use OCP\Activity\IProvider; +use OCP\Comments\ICommentsManager; +use OCP\Comments\NotFoundException; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\L10N\IFactory; + +class Provider implements IProvider { + protected ?IL10N $l = null; + + public function __construct( + protected IFactory $languageFactory, + protected IURLGenerator $url, + protected ICommentsManager $commentsManager, + protected IUserManager $userManager, + protected IManager $activityManager, + ) { + } + + /** + * @param string $language + * @param IEvent $event + * @param IEvent|null $previousEvent + * @return IEvent + * @throws UnknownActivityException + * @since 11.0.0 + */ + public function parse($language, IEvent $event, ?IEvent $previousEvent = null): IEvent { + if ($event->getApp() !== 'comments') { + throw new UnknownActivityException(); + } + + $this->l = $this->languageFactory->get('comments', $language); + + if ($event->getSubject() === 'add_comment_subject') { + $this->parseMessage($event); + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/comment.png'))); + } else { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/comment.svg'))); + } + + if ($this->activityManager->isFormattingFilteredObject()) { + try { + return $this->parseShortVersion($event); + } catch (UnknownActivityException) { + // Ignore and simply use the long version... + } + } + + return $this->parseLongVersion($event); + } + throw new UnknownActivityException(); + + } + + /** + * @throws UnknownActivityException + */ + protected function parseShortVersion(IEvent $event): IEvent { + $subjectParameters = $this->getSubjectParameters($event); + + if ($event->getSubject() === 'add_comment_subject') { + if ($subjectParameters['actor'] === $this->activityManager->getCurrentUserId()) { + $event->setRichSubject($this->l->t('You commented'), []); + } else { + $author = $this->generateUserParameter($subjectParameters['actor']); + $event->setRichSubject($this->l->t('{author} commented'), [ + 'author' => $author, + ]); + } + } else { + throw new UnknownActivityException(); + } + + return $event; + } + + /** + * @throws UnknownActivityException + */ + protected function parseLongVersion(IEvent $event): IEvent { + $subjectParameters = $this->getSubjectParameters($event); + + if ($event->getSubject() === 'add_comment_subject') { + if ($subjectParameters['actor'] === $this->activityManager->getCurrentUserId()) { + $event->setParsedSubject($this->l->t('You commented on %1$s', [ + $subjectParameters['filePath'], + ])) + ->setRichSubject($this->l->t('You commented on {file}'), [ + 'file' => $this->generateFileParameter($subjectParameters['fileId'], $subjectParameters['filePath']), + ]); + } else { + $author = $this->generateUserParameter($subjectParameters['actor']); + $event->setParsedSubject($this->l->t('%1$s commented on %2$s', [ + $author['name'], + $subjectParameters['filePath'], + ])) + ->setRichSubject($this->l->t('{author} commented on {file}'), [ + 'author' => $author, + 'file' => $this->generateFileParameter($subjectParameters['fileId'], $subjectParameters['filePath']), + ]); + } + } else { + throw new UnknownActivityException(); + } + + return $event; + } + + protected function getSubjectParameters(IEvent $event): array { + $subjectParameters = $event->getSubjectParameters(); + if (isset($subjectParameters['fileId'])) { + return $subjectParameters; + } + + // Fix subjects from 12.0.3 and older + // + // Do NOT Remove unless necessary + // Removing this will break parsing of activities that were created on + // Nextcloud 12, so we should keep this as long as it's acceptable. + // Otherwise if people upgrade over multiple releases in a short period, + // they will get the dead entries in their stream. + return [ + 'actor' => $subjectParameters[0], + 'fileId' => $event->getObjectId(), + 'filePath' => trim($subjectParameters[1], '/'), + ]; + } + + protected function parseMessage(IEvent $event): void { + $messageParameters = $event->getMessageParameters(); + if (empty($messageParameters)) { + // Email + return; + } + + $commentId = $messageParameters['commentId'] ?? $messageParameters[0]; + + try { + $comment = $this->commentsManager->get((string)$commentId); + $message = $comment->getMessage(); + + $mentionCount = 1; + $mentions = []; + foreach ($comment->getMentions() as $mention) { + if ($mention['type'] !== 'user') { + continue; + } + + $message = str_replace('@"' . $mention['id'] . '"', '{mention' . $mentionCount . '}', $message); + if (!str_contains($mention['id'], ' ') && !str_starts_with($mention['id'], 'guest/')) { + $message = str_replace('@' . $mention['id'], '{mention' . $mentionCount . '}', $message); + } + + $mentions['mention' . $mentionCount] = $this->generateUserParameter($mention['id']); + $mentionCount++; + } + + $event->setParsedMessage($comment->getMessage()) + ->setRichMessage($message, $mentions); + } catch (NotFoundException $e) { + } + } + + /** + * @return array<string, string> + */ + protected function generateFileParameter(int $id, string $path): array { + return [ + 'type' => 'file', + 'id' => (string)$id, + 'name' => basename($path), + 'path' => $path, + 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $id]), + ]; + } + + protected function generateUserParameter(string $uid): array { + return [ + 'type' => 'user', + 'id' => $uid, + 'name' => $this->userManager->getDisplayName($uid) ?? $uid, + ]; + } +} diff --git a/apps/comments/lib/Activity/Setting.php b/apps/comments/lib/Activity/Setting.php new file mode 100644 index 00000000000..7fbf4001b20 --- /dev/null +++ b/apps/comments/lib/Activity/Setting.php @@ -0,0 +1,53 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments\Activity; + +use OCP\Activity\ActivitySettings; +use OCP\IL10N; + +class Setting extends ActivitySettings { + public function __construct( + protected IL10N $l, + ) { + } + + public function getIdentifier(): string { + return 'comments'; + } + + public function getName(): string { + return $this->l->t('<strong>Comments</strong> for files'); + } + + public function getGroupIdentifier() { + return 'files'; + } + + public function getGroupName() { + return $this->l->t('Files'); + } + + public function getPriority(): int { + return 50; + } + + public function canChangeStream(): bool { + return true; + } + + public function isDefaultEnabledStream(): bool { + return true; + } + + public function canChangeMail(): bool { + return true; + } + + public function isDefaultEnabledMail(): bool { + return false; + } +} diff --git a/apps/comments/lib/AppInfo/Application.php b/apps/comments/lib/AppInfo/Application.php new file mode 100644 index 00000000000..db4a2ce614c --- /dev/null +++ b/apps/comments/lib/AppInfo/Application.php @@ -0,0 +1,62 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments\AppInfo; + +use OCA\Comments\Capabilities; +use OCA\Comments\Listener\CommentsEntityEventListener; +use OCA\Comments\Listener\CommentsEventListener; +use OCA\Comments\Listener\LoadAdditionalScripts; +use OCA\Comments\Listener\LoadSidebarScripts; +use OCA\Comments\MaxAutoCompleteResultsInitialState; +use OCA\Comments\Notification\Notifier; +use OCA\Comments\Search\CommentsSearchProvider; +use OCA\Files\Event\LoadAdditionalScriptsEvent; +use OCA\Files\Event\LoadSidebar; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Comments\CommentsEntityEvent; +use OCP\Comments\CommentsEvent; + +class Application extends App implements IBootstrap { + public const APP_ID = 'comments'; + + public function __construct(array $urlParams = []) { + parent::__construct(self::APP_ID, $urlParams); + } + + public function register(IRegistrationContext $context): void { + $context->registerCapability(Capabilities::class); + + $context->registerEventListener( + LoadAdditionalScriptsEvent::class, + LoadAdditionalScripts::class + ); + $context->registerEventListener( + LoadSidebar::class, + LoadSidebarScripts::class + ); + $context->registerEventListener( + CommentsEntityEvent::class, + CommentsEntityEventListener::class + ); + $context->registerEventListener( + CommentsEvent::class, + CommentsEventListener::class, + ); + + $context->registerSearchProvider(CommentsSearchProvider::class); + + $context->registerInitialStateProvider(MaxAutoCompleteResultsInitialState::class); + + $context->registerNotifierService(Notifier::class); + } + + public function boot(IBootContext $context): void { + } +} diff --git a/apps/comments/lib/Capabilities.php b/apps/comments/lib/Capabilities.php new file mode 100644 index 00000000000..2057803d867 --- /dev/null +++ b/apps/comments/lib/Capabilities.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments; + +use OCP\Capabilities\ICapability; + +class Capabilities implements ICapability { + /** + * @return array{files: array{comments: bool}} + */ + public function getCapabilities(): array { + return [ + 'files' => [ + 'comments' => true, + ] + ]; + } +} diff --git a/apps/comments/lib/Collaboration/CommentersSorter.php b/apps/comments/lib/Collaboration/CommentersSorter.php new file mode 100644 index 00000000000..baa27155573 --- /dev/null +++ b/apps/comments/lib/Collaboration/CommentersSorter.php @@ -0,0 +1,92 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments\Collaboration; + +use OCP\Collaboration\AutoComplete\ISorter; +use OCP\Comments\ICommentsManager; + +class CommentersSorter implements ISorter { + public function __construct( + private ICommentsManager $commentsManager, + ) { + } + + public function getId(): string { + return 'commenters'; + } + + /** + * Sorts people who commented on the given item atop (descelating) of the + * others + * + * @param array &$sortArray + * @param array $context + */ + public function sort(array &$sortArray, array $context): void { + $commenters = $this->retrieveCommentsInformation($context['itemType'], $context['itemId']); + if (count($commenters) === 0) { + return; + } + + foreach ($sortArray as $type => &$byType) { + if (!isset($commenters[$type])) { + continue; + } + + // at least on PHP 5.6 usort turned out to be not stable. So we add + // the current index to the value and compare it on a draw + $i = 0; + $workArray = array_map(function ($element) use (&$i) { + return [$i++, $element]; + }, $byType); + + usort($workArray, function ($a, $b) use ($commenters, $type) { + $r = $this->compare($a[1], $b[1], $commenters[$type]); + if ($r === 0) { + $r = $a[0] - $b[0]; + } + return $r; + }); + + // and remove the index values again + $byType = array_column($workArray, 1); + } + } + + /** + * @return array<string, array<string, int>> + */ + protected function retrieveCommentsInformation(string $type, string $id): array { + $comments = $this->commentsManager->getForObject($type, $id); + if (count($comments) === 0) { + return []; + } + + $actors = []; + foreach ($comments as $comment) { + if (!isset($actors[$comment->getActorType()])) { + $actors[$comment->getActorType()] = []; + } + if (!isset($actors[$comment->getActorType()][$comment->getActorId()])) { + $actors[$comment->getActorType()][$comment->getActorId()] = 1; + } else { + $actors[$comment->getActorType()][$comment->getActorId()]++; + } + } + return $actors; + } + + protected function compare(array $a, array $b, array $commenters): int { + $a = $a['value']['shareWith']; + $b = $b['value']['shareWith']; + + $valueA = $commenters[$a] ?? 0; + $valueB = $commenters[$b] ?? 0; + + return $valueB - $valueA; + } +} diff --git a/apps/comments/lib/Controller/NotificationsController.php b/apps/comments/lib/Controller/NotificationsController.php new file mode 100644 index 00000000000..0937b6929b8 --- /dev/null +++ b/apps/comments/lib/Controller/NotificationsController.php @@ -0,0 +1,103 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments\Controller; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\Comments\IComment; +use OCP\Comments\ICommentsManager; +use OCP\Files\IRootFolder; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserSession; +use OCP\Notification\IManager; + +/** + * @package OCA\Comments\Controller + */ +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class NotificationsController extends Controller { + public function __construct( + string $appName, + IRequest $request, + protected ICommentsManager $commentsManager, + protected IRootFolder $rootFolder, + protected IURLGenerator $urlGenerator, + protected IManager $notificationManager, + protected IUserSession $userSession, + ) { + parent::__construct($appName, $request); + } + + /** + * View a notification + * + * @param string $id ID of the notification + * + * @return RedirectResponse<Http::STATUS_SEE_OTHER, array{}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> + * + * 303: Redirected to notification + * 404: Notification not found + */ + #[PublicPage] + #[NoCSRFRequired] + public function view(string $id): RedirectResponse|NotFoundResponse { + $currentUser = $this->userSession->getUser(); + if (!$currentUser instanceof IUser) { + return new RedirectResponse( + $this->urlGenerator->linkToRoute('core.login.showLoginForm', [ + 'redirect_url' => $this->urlGenerator->linkToRoute( + 'comments.Notifications.view', + ['id' => $id] + ), + ]) + ); + } + + try { + $comment = $this->commentsManager->get($id); + if ($comment->getObjectType() !== 'files') { + return new NotFoundResponse(); + } + $userFolder = $this->rootFolder->getUserFolder($currentUser->getUID()); + $files = $userFolder->getById((int)$comment->getObjectId()); + + $this->markProcessed($comment, $currentUser); + + if (empty($files)) { + return new NotFoundResponse(); + } + + $url = $this->urlGenerator->linkToRouteAbsolute( + 'files.viewcontroller.showFile', + [ 'fileid' => $comment->getObjectId() ] + ); + + return new RedirectResponse($url); + } catch (\Exception $e) { + return new NotFoundResponse(); + } + } + + /** + * Marks the notification about a comment as processed + */ + protected function markProcessed(IComment $comment, IUser $currentUser): void { + $notification = $this->notificationManager->createNotification(); + $notification->setApp('comments') + ->setObject('comment', $comment->getId()) + ->setSubject('mention') + ->setUser($currentUser->getUID()); + $this->notificationManager->markProcessed($notification); + } +} diff --git a/apps/comments/lib/Listener/CommentsEntityEventListener.php b/apps/comments/lib/Listener/CommentsEntityEventListener.php new file mode 100644 index 00000000000..5aeeb3c63ea --- /dev/null +++ b/apps/comments/lib/Listener/CommentsEntityEventListener.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments\Listener; + +use OCP\Comments\CommentsEntityEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\IRootFolder; + +/** @template-implements IEventListener<CommentsEntityEvent> */ +class CommentsEntityEventListener implements IEventListener { + public function __construct( + private IRootFolder $rootFolder, + private ?string $userId = null, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof CommentsEntityEvent)) { + // Unrelated + return; + } + + $event->addEntityCollection('files', function ($name): bool { + $nodes = $this->rootFolder->getUserFolder($this->userId)->getById((int)$name); + return !empty($nodes); + }); + } +} diff --git a/apps/comments/lib/Listener/CommentsEventListener.php b/apps/comments/lib/Listener/CommentsEventListener.php new file mode 100644 index 00000000000..a1e44995162 --- /dev/null +++ b/apps/comments/lib/Listener/CommentsEventListener.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + + +namespace OCA\Comments\Listener; + +use OCA\Comments\Activity\Listener as ActivityListener; +use OCA\Comments\Notification\Listener as NotificationListener; +use OCP\Comments\CommentsEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** @template-implements IEventListener<CommentsEvent|Event> */ +class CommentsEventListener implements IEventListener { + public function __construct( + private ActivityListener $activityListener, + private NotificationListener $notificationListener, + ) { + } + + public function handle(Event $event): void { + if (!$event instanceof CommentsEvent) { + return; + } + + if ($event->getComment()->getObjectType() !== 'files') { + // this is a 'files'-specific Handler + return; + } + + $eventType = $event->getEvent(); + if ($eventType === CommentsEvent::EVENT_ADD + ) { + $this->notificationHandler($event); + $this->activityHandler($event); + return; + } + + $applicableEvents = [ + CommentsEvent::EVENT_PRE_UPDATE, + CommentsEvent::EVENT_UPDATE, + CommentsEvent::EVENT_DELETE, + ]; + if (in_array($eventType, $applicableEvents)) { + $this->notificationHandler($event); + return; + } + } + + private function activityHandler(CommentsEvent $event): void { + $this->activityListener->commentEvent($event); + } + + private function notificationHandler(CommentsEvent $event): void { + $this->notificationListener->evaluate($event); + } +} diff --git a/apps/comments/lib/Listener/LoadAdditionalScripts.php b/apps/comments/lib/Listener/LoadAdditionalScripts.php new file mode 100644 index 00000000000..81e1bfe5310 --- /dev/null +++ b/apps/comments/lib/Listener/LoadAdditionalScripts.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments\Listener; + +use OCA\Comments\AppInfo\Application; +use OCA\Files\Event\LoadAdditionalScriptsEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; + +/** @template-implements IEventListener<LoadAdditionalScriptsEvent> */ +class LoadAdditionalScripts implements IEventListener { + public function handle(Event $event): void { + if (!($event instanceof LoadAdditionalScriptsEvent)) { + return; + } + + // Adding init script for file list inline actions + Util::addInitScript(Application::APP_ID, 'init'); + } +} diff --git a/apps/comments/lib/Listener/LoadSidebarScripts.php b/apps/comments/lib/Listener/LoadSidebarScripts.php new file mode 100644 index 00000000000..906fe40fed2 --- /dev/null +++ b/apps/comments/lib/Listener/LoadSidebarScripts.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments\Listener; + +use OCA\Comments\AppInfo\Application; +use OCA\Files\Event\LoadSidebar; +use OCP\App\IAppManager; +use OCP\AppFramework\Services\IInitialState; +use OCP\Comments\ICommentsManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; + +/** @template-implements IEventListener<LoadSidebar> */ +class LoadSidebarScripts implements IEventListener { + public function __construct( + private ICommentsManager $commentsManager, + private IInitialState $initialState, + private IAppManager $appManager, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof LoadSidebar)) { + return; + } + + $this->commentsManager->load(); + + $this->initialState->provideInitialState('activityEnabled', $this->appManager->isEnabledForUser('activity')); + // Add comments sidebar tab script + Util::addScript(Application::APP_ID, 'comments-tab', 'files'); + } +} diff --git a/apps/comments/lib/MaxAutoCompleteResultsInitialState.php b/apps/comments/lib/MaxAutoCompleteResultsInitialState.php new file mode 100644 index 00000000000..b4c8f8719db --- /dev/null +++ b/apps/comments/lib/MaxAutoCompleteResultsInitialState.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments; + +use OCP\AppFramework\Services\InitialStateProvider; +use OCP\IConfig; + +class MaxAutoCompleteResultsInitialState extends InitialStateProvider { + public function __construct( + private IConfig $config, + ) { + } + + public function getKey(): string { + return 'maxAutoCompleteResults'; + } + + public function getData(): int { + return (int)$this->config->getAppValue('comments', 'maxAutoCompleteResults', '10'); + } +} diff --git a/apps/comments/lib/Notification/Listener.php b/apps/comments/lib/Notification/Listener.php new file mode 100644 index 00000000000..43922f85e59 --- /dev/null +++ b/apps/comments/lib/Notification/Listener.php @@ -0,0 +1,84 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Comments\Notification; + +use OCP\Comments\CommentsEvent; +use OCP\Comments\IComment; +use OCP\IUserManager; +use OCP\Notification\IManager; +use OCP\Notification\INotification; + +class Listener { + public function __construct( + protected IManager $notificationManager, + protected IUserManager $userManager, + ) { + } + + public function evaluate(CommentsEvent $event): void { + $comment = $event->getComment(); + + $mentions = $this->extractMentions($comment->getMentions()); + if (empty($mentions)) { + // no one to notify + return; + } + + $notification = $this->instantiateNotification($comment); + + foreach ($mentions as $uid) { + if (($comment->getActorType() === 'users' && $uid === $comment->getActorId()) + || !$this->userManager->userExists($uid) + ) { + // do not notify unknown users or yourself + continue; + } + + $notification->setUser($uid); + if ($event->getEvent() === CommentsEvent::EVENT_DELETE + || $event->getEvent() === CommentsEvent::EVENT_PRE_UPDATE) { + $this->notificationManager->markProcessed($notification); + } else { + $this->notificationManager->notify($notification); + } + } + } + + /** + * Creates a notification instance and fills it with comment data + */ + public function instantiateNotification(IComment $comment): INotification { + $notification = $this->notificationManager->createNotification(); + $notification + ->setApp('comments') + ->setObject('comment', $comment->getId()) + ->setSubject('mention', [ $comment->getObjectType(), $comment->getObjectId() ]) + ->setDateTime($comment->getCreationDateTime()); + + return $notification; + } + + /** + * Flattens the mention array returned from comments to a list of user ids. + * + * @param array $mentions + * @return list<string> containing the mentions, e.g. ['alice', 'bob'] + */ + public function extractMentions(array $mentions): array { + if (empty($mentions)) { + return []; + } + $uids = []; + foreach ($mentions as $mention) { + if ($mention['type'] === 'user') { + $uids[] = $mention['id']; + } + } + return $uids; + } +} diff --git a/apps/comments/lib/Notification/Notifier.php b/apps/comments/lib/Notification/Notifier.php new file mode 100644 index 00000000000..62562bf42aa --- /dev/null +++ b/apps/comments/lib/Notification/Notifier.php @@ -0,0 +1,177 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Comments\Notification; + +use OCP\Comments\IComment; +use OCP\Comments\ICommentsManager; +use OCP\Comments\NotFoundException; +use OCP\Files\IRootFolder; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\L10N\IFactory; +use OCP\Notification\AlreadyProcessedException; +use OCP\Notification\INotification; +use OCP\Notification\INotifier; +use OCP\Notification\UnknownNotificationException; + +class Notifier implements INotifier { + public function __construct( + protected IFactory $l10nFactory, + protected IRootFolder $rootFolder, + protected ICommentsManager $commentsManager, + protected IURLGenerator $url, + protected IUserManager $userManager, + ) { + } + + /** + * Identifier of the notifier, only use [a-z0-9_] + * + * @return string + * @since 17.0.0 + */ + public function getID(): string { + return 'comments'; + } + + /** + * Human readable name describing the notifier + * + * @return string + * @since 17.0.0 + */ + public function getName(): string { + return $this->l10nFactory->get('comments')->t('Comments'); + } + + /** + * @param INotification $notification + * @param string $languageCode The code of the language that should be used to prepare the notification + * @return INotification + * @throws UnknownNotificationException When the notification was not prepared by a notifier + * @throws AlreadyProcessedException When the notification is not needed anymore and should be deleted + * @since 9.0.0 + */ + public function prepare(INotification $notification, string $languageCode): INotification { + if ($notification->getApp() !== 'comments') { + throw new UnknownNotificationException(); + } + try { + $comment = $this->commentsManager->get($notification->getObjectId()); + } catch (NotFoundException $e) { + // needs to be converted to InvalidArgumentException, otherwise none Notifications will be shown at all + throw new UnknownNotificationException('Comment not found', 0, $e); + } + $l = $this->l10nFactory->get('comments', $languageCode); + $displayName = $comment->getActorId(); + $isDeletedActor = $comment->getActorType() === ICommentsManager::DELETED_USER; + if ($comment->getActorType() === 'users') { + $commenter = $this->userManager->getDisplayName($comment->getActorId()); + if ($commenter !== null) { + $displayName = $commenter; + } + } + + switch ($notification->getSubject()) { + case 'mention': + $parameters = $notification->getSubjectParameters(); + if ($parameters[0] !== 'files') { + throw new UnknownNotificationException('Unsupported comment object'); + } + $userFolder = $this->rootFolder->getUserFolder($notification->getUser()); + $nodes = $userFolder->getById((int)$parameters[1]); + if (empty($nodes)) { + throw new AlreadyProcessedException(); + } + $node = $nodes[0]; + + $path = rtrim($node->getPath(), '/'); + if (str_starts_with($path, '/' . $notification->getUser() . '/files/')) { + // Remove /user/files/... + $fullPath = $path; + [,,, $path] = explode('/', $fullPath, 4); + } + $subjectParameters = [ + 'file' => [ + 'type' => 'file', + 'id' => $comment->getObjectId(), + 'name' => $node->getName(), + 'path' => $path, + 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $comment->getObjectId()]), + ], + ]; + + if ($isDeletedActor) { + $subject = $l->t('You were mentioned on "{file}", in a comment by an account that has since been deleted'); + } else { + $subject = $l->t('{user} mentioned you in a comment on "{file}"'); + $subjectParameters['user'] = [ + 'type' => 'user', + 'id' => $comment->getActorId(), + 'name' => $displayName, + ]; + } + [$message, $messageParameters] = $this->commentToRichMessage($comment); + $notification->setRichSubject($subject, $subjectParameters) + ->setRichMessage($message, $messageParameters) + ->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/comment.svg'))) + ->setLink($this->url->linkToRouteAbsolute( + 'comments.Notifications.view', + ['id' => $comment->getId()]) + ); + + return $notification; + break; + + default: + throw new UnknownNotificationException('Invalid subject'); + } + } + + public function commentToRichMessage(IComment $comment): array { + $message = $comment->getMessage(); + $messageParameters = []; + $mentionTypeCount = []; + $mentions = $comment->getMentions(); + foreach ($mentions as $mention) { + 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']]++; + // 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-' . $mention['type'] . $mentionTypeCount[$mention['type']]; + $message = str_replace('@"' . $mention['id'] . '"', '{' . $mentionParameterId . '}', $message); + if (!str_contains($mention['id'], ' ') && !str_starts_with($mention['id'], 'guest/')) { + $message = str_replace('@' . $mention['id'], '{' . $mentionParameterId . '}', $message); + } + + 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 + ]; + } + return [$message, $messageParameters]; + } +} diff --git a/apps/comments/lib/Search/CommentsSearchProvider.php b/apps/comments/lib/Search/CommentsSearchProvider.php new file mode 100644 index 00000000000..87a658cab1c --- /dev/null +++ b/apps/comments/lib/Search/CommentsSearchProvider.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments\Search; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Search\IProvider; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; +use OCP\Search\SearchResultEntry; +use function array_map; +use function pathinfo; + +class CommentsSearchProvider implements IProvider { + public function __construct( + private IUserManager $userManager, + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private LegacyProvider $legacyProvider, + ) { + } + + public function getId(): string { + return 'comments'; + } + + public function getName(): string { + return $this->l10n->t('Comments'); + } + + public function getOrder(string $route, array $routeParameters): int { + if ($route === 'files.View.index') { + // Files first + return 0; + } + return 10; + } + + public function search(IUser $user, ISearchQuery $query): SearchResult { + return SearchResult::complete( + $this->l10n->t('Comments'), + array_map(function (Result $result) { + $path = $result->path; + $pathInfo = pathinfo($path); + $isUser = $this->userManager->userExists($result->authorId); + $avatarUrl = $isUser + ? $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $result->authorId, 'size' => 42]) + : $this->urlGenerator->linkToRouteAbsolute('core.GuestAvatar.getAvatar', ['guestName' => $result->authorId, 'size' => 42]); + return new SearchResultEntry( + $avatarUrl, + $result->name, + $path, + $this->urlGenerator->linkToRouteAbsolute('files.view.index', [ + 'dir' => $pathInfo['dirname'], + 'scrollto' => $pathInfo['basename'], + ]), + '', + true + ); + }, $this->legacyProvider->search($query->getTerm())) + ); + } +} diff --git a/apps/comments/lib/Search/LegacyProvider.php b/apps/comments/lib/Search/LegacyProvider.php new file mode 100644 index 00000000000..a269c418d06 --- /dev/null +++ b/apps/comments/lib/Search/LegacyProvider.php @@ -0,0 +1,97 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments\Search; + +use OCP\Comments\IComment; +use OCP\Comments\ICommentsManager; +use OCP\Files\Folder; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\IUser; +use OCP\IUserSession; +use OCP\Search\Provider; +use OCP\Server; +use function count; + +class LegacyProvider extends Provider { + /** + * Search for $query + * + * @param string $query + * @return array An array of OCP\Search\Result's + * @since 7.0.0 + */ + public function search($query): array { + $cm = Server::get(ICommentsManager::class); + $us = Server::get(IUserSession::class); + + $user = $us->getUser(); + if (!$user instanceof IUser) { + return []; + } + $uf = \OC::$server->getUserFolder($user->getUID()); + + if ($uf === null) { + return []; + } + + $result = []; + $numComments = 50; + $offset = 0; + + while (count($result) < $numComments) { + /** @var IComment[] $comments */ + $comments = $cm->search($query, 'files', '', 'comment', $offset, $numComments); + + foreach ($comments as $comment) { + if ($comment->getActorType() !== 'users') { + continue; + } + + $displayName = $cm->resolveDisplayName('user', $comment->getActorId()); + + try { + $file = $this->getFileForComment($uf, $comment); + $result[] = new Result($query, + $comment, + $displayName, + $file->getPath() + ); + } catch (NotFoundException $e) { + continue; + } + } + + if (count($comments) < $numComments) { + // Didn't find more comments when we tried to get, so there are no more comments. + return $result; + } + + $offset += $numComments; + $numComments = 50 - count($result); + } + + return $result; + } + + /** + * @param Folder $userFolder + * @param IComment $comment + * @return Node + * @throws NotFoundException + */ + protected function getFileForComment(Folder $userFolder, IComment $comment): Node { + $nodes = $userFolder->getById((int)$comment->getObjectId()); + if (empty($nodes)) { + throw new NotFoundException('File not found'); + } + + return array_shift($nodes); + } +} diff --git a/apps/comments/lib/Search/Result.php b/apps/comments/lib/Search/Result.php new file mode 100644 index 00000000000..7478c110d63 --- /dev/null +++ b/apps/comments/lib/Search/Result.php @@ -0,0 +1,105 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Comments\Search; + +use OCP\Comments\IComment; +use OCP\Files\NotFoundException; +use OCP\Search\Result as BaseResult; + +/** + * @deprecated 20.0.0 + */ +class Result extends BaseResult { + /** + * @deprecated 20.0.0 + */ + public $type = 'comment'; + /** + * @deprecated 20.0.0 + */ + public $comment; + /** + * @deprecated 20.0.0 + */ + public $authorId; + /** + * @deprecated 20.0.0 + */ + public $path; + /** + * @deprecated 20.0.0 + */ + public $fileName; + + /** + * @throws NotFoundException + * @deprecated 20.0.0 + */ + public function __construct( + string $search, + IComment $comment, + /** + * @deprecated 20.0.0 + */ + public string $authorName, + string $path, + ) { + parent::__construct( + $comment->getId(), + $comment->getMessage() + /* @todo , [link to file] */ + ); + + $this->comment = $this->getRelevantMessagePart($comment->getMessage(), $search); + $this->authorId = $comment->getActorId(); + $this->fileName = basename($path); + $this->path = $this->getVisiblePath($path); + } + + /** + * @throws NotFoundException + */ + protected function getVisiblePath(string $path): string { + $segments = explode('/', trim($path, '/'), 3); + + if (!isset($segments[2])) { + throw new NotFoundException('Path not inside visible section'); + } + + return $segments[2]; + } + + /** + * @throws NotFoundException + */ + protected function getRelevantMessagePart(string $message, string $search): string { + $start = mb_stripos($message, $search); + if ($start === false) { + throw new NotFoundException('Comment section not found'); + } + + $end = $start + mb_strlen($search); + + if ($start <= 25) { + $start = 0; + $prefix = ''; + } else { + $start -= 25; + $prefix = '…'; + } + + if ((mb_strlen($message) - $end) <= 25) { + $end = mb_strlen($message); + $suffix = ''; + } else { + $end += 25; + $suffix = '…'; + } + + return $prefix . mb_substr($message, $start, $end - $start) . $suffix; + } +} |