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);
+ }
+}