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:
19
app/Activity/Models/MentionHistory.php
Normal file
19
app/Activity/Models/MentionHistory.php
Normal 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
|
||||
{
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
28
app/Activity/Tools/MentionParser.php
Normal file
28
app/Activity/Tools/MentionParser.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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:',
|
||||
|
||||
@@ -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!',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
43
tests/Activity/MentionParserTest.php
Normal file
43
tests/Activity/MentionParserTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user