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\Activity;
|
||||||
use BookStack\Activity\Models\Loggable;
|
use BookStack\Activity\Models\Loggable;
|
||||||
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
||||||
|
use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler;
|
||||||
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
||||||
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
||||||
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
||||||
@@ -48,5 +49,7 @@ class NotificationManager
|
|||||||
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
||||||
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
||||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::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;
|
namespace BookStack\App\Providers;
|
||||||
|
|
||||||
use BookStack\Access\SocialDriverManager;
|
use BookStack\Access\SocialDriverManager;
|
||||||
|
use BookStack\Activity\Models\Comment;
|
||||||
use BookStack\Activity\Tools\ActivityLogger;
|
use BookStack\Activity\Tools\ActivityLogger;
|
||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
use BookStack\Entities\Models\Bookshelf;
|
use BookStack\Entities\Models\Bookshelf;
|
||||||
@@ -73,6 +74,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
'book' => Book::class,
|
'book' => Book::class,
|
||||||
'chapter' => Chapter::class,
|
'chapter' => Chapter::class,
|
||||||
'page' => Page::class,
|
'page' => Page::class,
|
||||||
|
'comment' => Comment::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ return [
|
|||||||
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
|
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
|
||||||
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
|
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
|
||||||
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
|
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
|
||||||
|
'notifications#comment-mentions' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ class UserNotificationPreferences
|
|||||||
return $this->getNotificationSetting('comment-replies');
|
return $this->getNotificationSetting('comment-replies');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function notifyOnCommentMentions(): bool
|
||||||
|
{
|
||||||
|
return $this->getNotificationSetting('comment-mentions');
|
||||||
|
}
|
||||||
|
|
||||||
public function updateFromSettingsArray(array $settings)
|
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) {
|
foreach ($settings as $setting => $status) {
|
||||||
if (!in_array($setting, $allowList)) {
|
if (!in_array($setting, $allowList)) {
|
||||||
continue;
|
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_subject' => 'Updated page: :pageName',
|
||||||
'updated_page_intro' => 'A page has been updated in :appName:',
|
'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.',
|
'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_name' => 'Page Name:',
|
||||||
'detail_page_path' => 'Page Path:',
|
'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_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_changes' => 'Notify upon changes to pages I own',
|
||||||
'notifications_opt_own_page_comments' => 'Notify upon comments on 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_opt_comment_replies' => 'Notify upon replies to my comments',
|
||||||
'notifications_save' => 'Save Preferences',
|
'notifications_save' => 'Save Preferences',
|
||||||
'notifications_update_success' => 'Notification preferences have been updated!',
|
'notifications_update_success' => 'Notification preferences have been updated!',
|
||||||
|
|||||||
@@ -33,6 +33,13 @@
|
|||||||
'label' => trans('preferences.notifications_opt_comment_replies'),
|
'label' => trans('preferences.notifications_opt_comment_replies'),
|
||||||
])
|
])
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
@include('form.toggle-switch', [
|
||||||
|
'name' => 'preferences[comment-mentions]',
|
||||||
|
'value' => $preferences->notifyOnCommentMentions(),
|
||||||
|
'label' => trans('preferences.notifications_opt_comment_mentions'),
|
||||||
|
])
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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