Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/Activity/Models/MentionHistory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?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
{
protected $table = 'mention_history';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?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
{
if (!($detail instanceof Comment) || !($detail->entity instanceof Page)) {
throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page");
}

/** @var Page $page */
$page = $detail->entity;

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

/**
* @param Collection<User> $mentionedUsers
*/
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();
}
}
37 changes: 37 additions & 0 deletions app/Activity/Notifications/Messages/CommentMentionNotification.php
Original file line number Diff line number Diff line change
@@ -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));
}
}
3 changes: 3 additions & 0 deletions app/Activity/Notifications/NotificationManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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;
Expand Down Expand Up @@ -48,5 +49,7 @@ public function loadDefaultHandlers(): void
$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 changes: 28 additions & 0 deletions app/Activity/Tools/MentionParser.php
Original file line number Diff line number Diff line change
@@ -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));
}
}
2 changes: 2 additions & 0 deletions app/App/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -73,6 +74,7 @@ public function boot(): void
'book' => Book::class,
'chapter' => Chapter::class,
'page' => Page::class,
'comment' => Comment::class,
]);
}
}
3 changes: 2 additions & 1 deletion app/Config/database.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@
'strict' => false,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
// @phpstan-ignore class.notFound
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],

Expand Down
1 change: 1 addition & 0 deletions app/Config/setting-defaults.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
'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,
],

];
7 changes: 6 additions & 1 deletion app/Settings/UserNotificationPreferences.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,14 @@ public function notifyOnCommentReplies(): bool
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;
Expand Down
38 changes: 37 additions & 1 deletion app/Users/Controllers/UserSearchController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;

class UserSearchController extends Controller
Expand Down Expand Up @@ -34,8 +35,43 @@ public function forSelect(Request $request)
$query->where('name', 'like', '%' . $search . '%');
}

/** @var Collection<User> $users */
$users = $query->get();

return view('form.user-select-list', [
'users' => $query->get(),
'users' => $users,
]);
}

/**
* Search users in the system, with the response formatted
* for use in a list of mentions.
*/
public function forMentions(Request $request)
{
$hasPermission = !user()->isGuest() && (
userCan(Permission::CommentCreateAll)
|| userCan(Permission::CommentUpdate)
);

if (!$hasPermission) {
$this->showPermissionError();
}

$search = $request->get('search', '');
$query = User::query()
->orderBy('name', 'asc')
->take(20);

if (!empty($search)) {
$query->where('name', 'like', '%' . $search . '%');
}

/** @var Collection<User> $users */
$users = $query->get();

return view('form.user-mention-list', [
'users' => $users,
]);
}
}
2 changes: 1 addition & 1 deletion app/Util/HtmlDescriptionFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class HtmlDescriptionFilter
*/
protected static array $allowedAttrsByElements = [
'p' => [],
'a' => ['href', 'title', 'target'],
'a' => ['href', 'title', 'target', 'data-mention-user-id'],
'ol' => [],
'ul' => [],
'li' => [],
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
$table->unsignedInteger('to_user_id');
$table->timestamps();
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('mention_history');
}
};
Loading