From 221c6c7e9f5f03dd1f5f1cef7ed10e3af4f43e46 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 15 Dec 2025 15:59:43 +0000 Subject: [PATCH] Comment Mentions: Added core back-end logic - Added new user notification preference, opt-in by default - Added parser to extract mentions from comment HTML, with tests to cover. - Added notification and notification handling Not yet tested, needs testing coverage. --- app/Activity/Models/MentionHistory.php | 19 +++++ .../CommentMentionNotificationHandler.php | 80 +++++++++++++++++++ .../Messages/CommentMentionNotification.php | 37 +++++++++ .../Notifications/NotificationManager.php | 3 + app/Activity/Tools/MentionParser.php | 28 +++++++ app/App/Providers/AppServiceProvider.php | 2 + app/Config/setting-defaults.php | 1 + app/Settings/UserNotificationPreferences.php | 7 +- ...15_140219_create_mention_history_table.php | 31 +++++++ lang/en/notifications.php | 2 + lang/en/preferences.php | 1 + .../users/account/notifications.blade.php | 7 ++ tests/Activity/MentionParserTest.php | 43 ++++++++++ 13 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 app/Activity/Models/MentionHistory.php create mode 100644 app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php create mode 100644 app/Activity/Notifications/Messages/CommentMentionNotification.php create mode 100644 app/Activity/Tools/MentionParser.php create mode 100644 database/migrations/2025_12_15_140219_create_mention_history_table.php create mode 100644 tests/Activity/MentionParserTest.php diff --git a/app/Activity/Models/MentionHistory.php b/app/Activity/Models/MentionHistory.php new file mode 100644 index 000000000..bfa242df5 --- /dev/null +++ b/app/Activity/Models/MentionHistory.php @@ -0,0 +1,19 @@ +entity; + if (!($detail instanceof Comment) || !($page instanceof Page)) { + throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page"); + } + + $parser = new MentionParser(); + $mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html); + $realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get(); + + $receivingNotifications = $realMentionedUsers->filter(function (User $user) { + $prefs = new UserNotificationPreferences($user); + return $prefs->notifyOnCommentMentions(); + }); + $receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray(); + + $userMentionsToLog = $realMentionedUsers; + + // When an edit, we check our history to see if we've already notified the user about this comment before + // so that we can filter them out to avoid double notifications. + if ($activity->type === ActivityType::COMMENT_UPDATE) { + $previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail); + $receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds)); + $userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) { + return !in_array($user->id, $previouslyNotifiedUserIds); + }); + } + + $this->logMentions($userMentionsToLog, $detail, $user); + $this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page); + } + + protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void + { + $mentions = []; + $now = Carbon::now(); + + foreach ($mentionedUsers as $mentionedUser) { + $mentions[] = [ + 'mentionable_type' => $comment->getMorphClass(), + 'mentionable_id' => $comment->id, + 'from_user_id' => $fromUser->id, + 'to_user_id' => $mentionedUser->id, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + MentionHistory::query()->insert($mentions); + } + + protected function getPreviouslyNotifiedUserIds(Comment $comment): array + { + return MentionHistory::query() + ->where('mentionable_id', $comment->id) + ->where('mentionable_type', $comment->getMorphClass()) + ->pluck('to_user_id') + ->toArray(); + } +} diff --git a/app/Activity/Notifications/Messages/CommentMentionNotification.php b/app/Activity/Notifications/Messages/CommentMentionNotification.php new file mode 100644 index 000000000..de9e71963 --- /dev/null +++ b/app/Activity/Notifications/Messages/CommentMentionNotification.php @@ -0,0 +1,37 @@ +detail; + /** @var Page $page */ + $page = $comment->entity; + + $locale = $notifiable->getLocale(); + + $listLines = array_filter([ + $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), + $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), + $locale->trans('notifications.detail_commenter') => $this->user->name, + $locale->trans('notifications.detail_comment') => strip_tags($comment->html), + ]); + + return $this->newMailMessage($locale) + ->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()])) + ->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')])) + ->line(new ListMessageLine($listLines)) + ->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id)) + ->line($this->buildReasonFooterLine($locale)); + } +} diff --git a/app/Activity/Notifications/NotificationManager.php b/app/Activity/Notifications/NotificationManager.php index 294f56ebb..8a6c26ffb 100644 --- a/app/Activity/Notifications/NotificationManager.php +++ b/app/Activity/Notifications/NotificationManager.php @@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType; use BookStack\Activity\Models\Activity; use BookStack\Activity\Models\Loggable; use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler; +use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler; use BookStack\Activity\Notifications\Handlers\NotificationHandler; use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler; use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler; @@ -48,5 +49,7 @@ class NotificationManager $this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class); $this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class); $this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class); + $this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class); + $this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class); } } diff --git a/app/Activity/Tools/MentionParser.php b/app/Activity/Tools/MentionParser.php new file mode 100644 index 000000000..d7bcac5e6 --- /dev/null +++ b/app/Activity/Tools/MentionParser.php @@ -0,0 +1,28 @@ +queryXPath('//a[@data-mention-user-id]'); + + foreach ($mentionLinks as $link) { + if ($link instanceof DOMElement) { + $id = intval($link->getAttribute('data-mention-user-id')); + if ($id > 0) { + $ids[] = $id; + } + } + } + + return array_values(array_unique($ids)); + } +} diff --git a/app/App/Providers/AppServiceProvider.php b/app/App/Providers/AppServiceProvider.php index 9012a07eb..debba7944 100644 --- a/app/App/Providers/AppServiceProvider.php +++ b/app/App/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace BookStack\App\Providers; use BookStack\Access\SocialDriverManager; +use BookStack\Activity\Models\Comment; use BookStack\Activity\Tools\ActivityLogger; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; @@ -73,6 +74,7 @@ class AppServiceProvider extends ServiceProvider 'book' => Book::class, 'chapter' => Chapter::class, 'page' => Page::class, + 'comment' => Comment::class, ]); } } diff --git a/app/Config/setting-defaults.php b/app/Config/setting-defaults.php index 88c4612ca..2f270b283 100644 --- a/app/Config/setting-defaults.php +++ b/app/Config/setting-defaults.php @@ -41,6 +41,7 @@ return [ 'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'), 'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'), 'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'), + 'notifications#comment-mentions' => true, ], ]; diff --git a/app/Settings/UserNotificationPreferences.php b/app/Settings/UserNotificationPreferences.php index 5b267b533..752d92de6 100644 --- a/app/Settings/UserNotificationPreferences.php +++ b/app/Settings/UserNotificationPreferences.php @@ -26,9 +26,14 @@ class UserNotificationPreferences return $this->getNotificationSetting('comment-replies'); } + public function notifyOnCommentMentions(): bool + { + return $this->getNotificationSetting('comment-mentions'); + } + public function updateFromSettingsArray(array $settings) { - $allowList = ['own-page-changes', 'own-page-comments', 'comment-replies']; + $allowList = ['own-page-changes', 'own-page-comments', 'comment-replies', 'comment-mentions']; foreach ($settings as $setting => $status) { if (!in_array($setting, $allowList)) { continue; diff --git a/database/migrations/2025_12_15_140219_create_mention_history_table.php b/database/migrations/2025_12_15_140219_create_mention_history_table.php new file mode 100644 index 000000000..2ab522dd8 --- /dev/null +++ b/database/migrations/2025_12_15_140219_create_mention_history_table.php @@ -0,0 +1,31 @@ +increments('id'); + $table->string('mentionable_type', 50)->index(); + $table->unsignedBigInteger('mentionable_id')->index(); + $table->unsignedInteger('from_user_id')->index(); + $table->unsignedInteger('to_user_id'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('mention_history'); + } +}; diff --git a/lang/en/notifications.php b/lang/en/notifications.php index 1afd23f1d..9d6d94574 100644 --- a/lang/en/notifications.php +++ b/lang/en/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You were mentioned in a comment on :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/en/preferences.php b/lang/en/preferences.php index 2872f5f3c..f4459d738 100644 --- a/lang/en/preferences.php +++ b/lang/en/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/resources/views/users/account/notifications.blade.php b/resources/views/users/account/notifications.blade.php index b3b082bd7..c61cf4af8 100644 --- a/resources/views/users/account/notifications.blade.php +++ b/resources/views/users/account/notifications.blade.php @@ -33,6 +33,13 @@ 'label' => trans('preferences.notifications_opt_comment_replies'), ]) +
+ @include('form.toggle-switch', [ + 'name' => 'preferences[comment-mentions]', + 'value' => $preferences->notifyOnCommentMentions(), + 'label' => trans('preferences.notifications_opt_comment_mentions'), + ]) +
@endif diff --git a/tests/Activity/MentionParserTest.php b/tests/Activity/MentionParserTest.php new file mode 100644 index 000000000..08bfc10d2 --- /dev/null +++ b/tests/Activity/MentionParserTest.php @@ -0,0 +1,43 @@ +Hello @User

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([5], $result); + + // Test multiple mentions + $html = '

@Alice and @Bob

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([1, 2], $result); + + // Test filtering out invalid IDs (zero and negative) + $html = '

@Invalid @Negative @Valid

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([3], $result); + + // Test non-mention links are ignored + $html = '

Normal Link @User

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([7], $result); + + // Test empty HTML + $result = $parser->parseUserIdsFromHtml(''); + $this->assertEquals([], $result); + + // Test duplicate user IDs + $html = '

@User mentioned @User again

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([4], $result); + } +}