1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-12-19 10:42:29 +03:00

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.
This commit is contained in:
Dan Brown
2025-12-15 15:59:43 +00:00
parent e2f91c2bbb
commit 221c6c7e9f
13 changed files with 260 additions and 1 deletions

View File

@@ -0,0 +1,19 @@
<?php
namespace BookStack\Activity\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property string $mentionable_type
* @property int $mentionable_id
* @property int $from_user_id
* @property int $to_user_id
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class MentionHistory extends Model
{
}

View File

@@ -0,0 +1,80 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\MentionHistory;
use BookStack\Activity\Notifications\Messages\CommentMentionNotification;
use BookStack\Activity\Tools\MentionParser;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
class CommentMentionNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
$page = $detail->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();
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class CommentMentionNotification extends BaseActivityNotification
{
public function toMail(User $notifiable): MailMessage
{
/** @var Comment $comment */
$comment = $this->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));
}
}

View File

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

View File

@@ -0,0 +1,28 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Util\HtmlDocument;
use DOMElement;
class MentionParser
{
public function parseUserIdsFromHtml(string $html): array
{
$doc = new HtmlDocument($html);
$ids = [];
$mentionLinks = $doc->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));
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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,
],
];

View File

@@ -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;

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('mention_history', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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:',

View File

@@ -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!',

View File

@@ -33,6 +33,13 @@
'label' => trans('preferences.notifications_opt_comment_replies'),
])
</div>
<div>
@include('form.toggle-switch', [
'name' => 'preferences[comment-mentions]',
'value' => $preferences->notifyOnCommentMentions(),
'label' => trans('preferences.notifications_opt_comment_mentions'),
])
</div>
@endif
</div>

View File

@@ -0,0 +1,43 @@
<?php
namespace Tests\Activity;
use BookStack\Activity\Tools\MentionParser;
use Tests\TestCase;
class MentionParserTest extends TestCase
{
public function test_it_extracts_mentions()
{
$parser = new MentionParser();
// Test basic mention extraction
$html = '<p>Hello <a href="/user/5" data-mention-user-id="5">@User</a></p>';
$result = $parser->parseUserIdsFromHtml($html);
$this->assertEquals([5], $result);
// Test multiple mentions
$html = '<p><a data-mention-user-id="1">@Alice</a> and <a data-mention-user-id="2">@Bob</a></p>';
$result = $parser->parseUserIdsFromHtml($html);
$this->assertEquals([1, 2], $result);
// Test filtering out invalid IDs (zero and negative)
$html = '<p><a data-mention-user-id="0">@Invalid</a> <a data-mention-user-id="-5">@Negative</a> <a data-mention-user-id="3">@Valid</a></p>';
$result = $parser->parseUserIdsFromHtml($html);
$this->assertEquals([3], $result);
// Test non-mention links are ignored
$html = '<p><a href="/page/1">Normal Link</a> <a data-mention-user-id="7">@User</a></p>';
$result = $parser->parseUserIdsFromHtml($html);
$this->assertEquals([7], $result);
// Test empty HTML
$result = $parser->parseUserIdsFromHtml('');
$this->assertEquals([], $result);
// Test duplicate user IDs
$html = '<p><a data-mention-user-id="4">@User</a> mentioned <a data-mention-user-id="4">@User</a> again</p>';
$result = $parser->parseUserIdsFromHtml($html);
$this->assertEquals([4], $result);
}
}