aboutsummaryrefslogtreecommitdiffstats
path: root/apps/comments/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/comments/lib')
-rw-r--r--apps/comments/lib/Activity/Filter.php50
-rw-r--r--apps/comments/lib/Activity/Listener.php88
-rw-r--r--apps/comments/lib/Activity/Provider.php198
-rw-r--r--apps/comments/lib/Activity/Setting.php53
-rw-r--r--apps/comments/lib/AppInfo/Application.php62
-rw-r--r--apps/comments/lib/Capabilities.php24
-rw-r--r--apps/comments/lib/Collaboration/CommentersSorter.php92
-rw-r--r--apps/comments/lib/Controller/NotificationsController.php103
-rw-r--r--apps/comments/lib/Listener/CommentsEntityEventListener.php35
-rw-r--r--apps/comments/lib/Listener/CommentsEventListener.php63
-rw-r--r--apps/comments/lib/Listener/LoadAdditionalScripts.php27
-rw-r--r--apps/comments/lib/Listener/LoadSidebarScripts.php40
-rw-r--r--apps/comments/lib/MaxAutoCompleteResultsInitialState.php27
-rw-r--r--apps/comments/lib/Notification/Listener.php84
-rw-r--r--apps/comments/lib/Notification/Notifier.php177
-rw-r--r--apps/comments/lib/Search/CommentsSearchProvider.php71
-rw-r--r--apps/comments/lib/Search/LegacyProvider.php97
-rw-r--r--apps/comments/lib/Search/Result.php105
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;
+ }
+}