Notifications: Cleaned up mails, added debounce for updates

- Updated mail notification design to be a bit prettier, and extracted
  text to new lang file for translation.
- Added debounce logic for page update notifications.
- Fixed watch options not being filtered to current user.
This commit is contained in:
Dan Brown 2023-08-15 14:39:39 +01:00
parent 371779205a
commit 615741af9d
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
17 changed files with 152 additions and 70 deletions

View File

@ -5,6 +5,7 @@ namespace BookStack\Activity\Models;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Users\Models\HasCreatorAndUpdater; use BookStack\Users\Models\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
/** /**
@ -35,7 +36,7 @@ class Comment extends Model implements Loggable
/** /**
* Get the parent comment this is in reply to (if existing). * Get the parent comment this is in reply to (if existing).
*/ */
public function parent() public function parent(): BelongsTo
{ {
return $this->belongsTo(Comment::class); return $this->belongsTo(Comment::class);
} }
@ -50,20 +51,16 @@ class Comment extends Model implements Loggable
/** /**
* Get created date as a relative diff. * Get created date as a relative diff.
*
* @return mixed
*/ */
public function getCreatedAttribute() public function getCreatedAttribute(): string
{ {
return $this->created_at->diffForHumans(); return $this->created_at->diffForHumans();
} }
/** /**
* Get updated date as a relative diff. * Get updated date as a relative diff.
*
* @return mixed
*/ */
public function getUpdatedAttribute() public function getUpdatedAttribute(): string
{ {
return $this->updated_at->diffForHumans(); return $this->updated_at->diffForHumans();
} }

View File

@ -2,6 +2,7 @@
namespace BookStack\Activity\Notifications\Handlers; namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\BaseActivityNotification; use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
@ -18,7 +19,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
* @param class-string<BaseActivityNotification> $notification * @param class-string<BaseActivityNotification> $notification
* @param int[] $userIds * @param int[] $userIds
*/ */
protected function sendNotificationToUserIds(string $notification, array $userIds, User $initiator, Entity $relatedModel): void protected function sendNotificationToUserIds(string $notification, array $userIds, User $initiator, string|Loggable $detail, Entity $relatedModel): void
{ {
$users = User::query()->whereIn('id', array_unique($userIds))->get(); $users = User::query()->whereIn('id', array_unique($userIds))->get();
@ -39,7 +40,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
} }
// Send the notification // Send the notification
$user->notify(new $notification($relatedModel, $initiator)); $user->notify(new $notification($detail, $initiator));
} }
} }
} }

View File

@ -2,6 +2,7 @@
namespace BookStack\Activity\Notifications\Handlers; namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Comment; use BookStack\Activity\Models\Comment;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\CommentCreationNotification; use BookStack\Activity\Notifications\Messages\CommentCreationNotification;
@ -12,7 +13,7 @@ use BookStack\Users\Models\User;
class CommentCreationNotificationHandler extends BaseNotificationHandler class CommentCreationNotificationHandler extends BaseNotificationHandler
{ {
public function handle(string $activityType, Loggable|string $detail, User $user): void public function handle(Activity $activity, Loggable|string $detail, User $user): void
{ {
if (!($detail instanceof Comment)) { if (!($detail instanceof Comment)) {
throw new \InvalidArgumentException("Detail for comment creation notifications must be a comment"); throw new \InvalidArgumentException("Detail for comment creation notifications must be a comment");
@ -24,10 +25,10 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
$watcherIds = $watchers->getWatcherUserIds(); $watcherIds = $watchers->getWatcherUserIds();
// Page owner if user preferences allow // Page owner if user preferences allow
if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) { if (!$watchers->isUserIgnoring($detail->created_by) && $detail->createdBy) {
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy); $userNotificationPrefs = new UserNotificationPreferences($detail->createdBy);
if ($userNotificationPrefs->notifyOnOwnPageComments()) { if ($userNotificationPrefs->notifyOnOwnPageComments()) {
$watcherIds[] = $detail->owned_by; $watcherIds[] = $detail->created_by;
} }
} }
@ -40,6 +41,6 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
} }
} }
$this->sendNotificationToUserIds(CommentCreationNotification::class, $watcherIds, $user, $page); $this->sendNotificationToUserIds(CommentCreationNotification::class, $watcherIds, $user, $detail, $page);
} }
} }

View File

@ -2,6 +2,7 @@
namespace BookStack\Activity\Notifications\Handlers; namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
@ -9,8 +10,8 @@ interface NotificationHandler
{ {
/** /**
* Run this handler. * Run this handler.
* Provides the activity type, related activity detail/model * Provides the activity, related activity detail/model
* along with the user that triggered the activity. * along with the user that triggered the activity.
*/ */
public function handle(string $activityType, string|Loggable $detail, User $user): void; public function handle(Activity $activity, string|Loggable $detail, User $user): void;
} }

View File

@ -2,6 +2,7 @@
namespace BookStack\Activity\Notifications\Handlers; namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\PageCreationNotification; use BookStack\Activity\Notifications\Messages\PageCreationNotification;
use BookStack\Activity\Tools\EntityWatchers; use BookStack\Activity\Tools\EntityWatchers;
@ -11,13 +12,13 @@ use BookStack\Users\Models\User;
class PageCreationNotificationHandler extends BaseNotificationHandler class PageCreationNotificationHandler extends BaseNotificationHandler
{ {
public function handle(string $activityType, Loggable|string $detail, User $user): void public function handle(Activity $activity, Loggable|string $detail, User $user): void
{ {
if (!($detail instanceof Page)) { if (!($detail instanceof Page)) {
throw new \InvalidArgumentException("Detail for page create notifications must be a page"); throw new \InvalidArgumentException("Detail for page create notifications must be a page");
} }
$watchers = new EntityWatchers($detail, WatchLevels::NEW); $watchers = new EntityWatchers($detail, WatchLevels::NEW);
$this->sendNotificationToUserIds(PageCreationNotification::class, $watchers->getWatcherUserIds(), $user, $detail); $this->sendNotificationToUserIds(PageCreationNotification::class, $watchers->getWatcherUserIds(), $user, $detail, $detail);
} }
} }

View File

@ -2,6 +2,8 @@
namespace BookStack\Activity\Notifications\Handlers; namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\PageUpdateNotification; use BookStack\Activity\Notifications\Messages\PageUpdateNotification;
use BookStack\Activity\Tools\EntityWatchers; use BookStack\Activity\Tools\EntityWatchers;
@ -12,15 +14,31 @@ use BookStack\Users\Models\User;
class PageUpdateNotificationHandler extends BaseNotificationHandler class PageUpdateNotificationHandler extends BaseNotificationHandler
{ {
public function handle(string $activityType, Loggable|string $detail, User $user): void public function handle(Activity $activity, Loggable|string $detail, User $user): void
{ {
if (!($detail instanceof Page)) { if (!($detail instanceof Page)) {
throw new \InvalidArgumentException("Detail for page update notifications must be a page"); throw new \InvalidArgumentException("Detail for page update notifications must be a page");
} }
// Get last update from activity
$lastUpdate = $detail->activity()
->where('type', '=', ActivityType::PAGE_UPDATE)
->where('id', '!=', $activity->id)
->latest('created_at')
->first();
// Return if the same user has already updated the page in the last 15 mins
if ($lastUpdate && $lastUpdate->user_id === $user->id) {
if ($lastUpdate->created_at->gt(now()->subMinutes(15))) {
return;
}
}
// Get active watchers
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES); $watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
$watcherIds = $watchers->getWatcherUserIds(); $watcherIds = $watchers->getWatcherUserIds();
// Add page owner if preferences allow
if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) { if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy); $userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageChanges()) { if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
@ -28,6 +46,6 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
} }
} }
$this->sendNotificationToUserIds(PageUpdateNotification::class, $watcherIds, $user, $detail); $this->sendNotificationToUserIds(PageUpdateNotification::class, $watcherIds, $user, $detail, $detail);
} }
} }

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Activity\Notifications; namespace BookStack\Activity\Notifications\MessageParts;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;

View File

@ -0,0 +1,26 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use Illuminate\Contracts\Support\Htmlable;
/**
* A bullet point list of content, where the keys of the given list array
* are bolded header elements, and the values follow.
*/
class ListMessageLine implements Htmlable
{
public function __construct(
protected array $list
) {
}
public function toHtml(): string
{
$list = [];
foreach ($this->list as $header => $content) {
$list[] = '<strong>' . e($header) . '</strong> ' . e($content);
}
return implode("<br>\n", $list);
}
}

View File

@ -3,6 +3,7 @@
namespace BookStack\Activity\Notifications\Messages; namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
@ -47,4 +48,16 @@ abstract class BaseActivityNotification extends Notification
'activity_creator' => $this->user, 'activity_creator' => $this->user,
]; ];
} }
/**
* Build the common reason footer line used in mail messages.
*/
protected function buildReasonFooterLine(): LinkedMailMessageLine
{
return new LinkedMailMessageLine(
url('/preferences/notifications'),
trans('notifications.footer_reason'),
trans('notifications.footer_reason_link'),
);
}
} }

View File

@ -3,7 +3,7 @@
namespace BookStack\Activity\Notifications\Messages; namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Comment; use BookStack\Activity\Models\Comment;
use BookStack\Activity\Notifications\LinkedMailMessageLine; use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
@ -17,16 +17,14 @@ class CommentCreationNotification extends BaseActivityNotification
$page = $comment->entity; $page = $comment->entity;
return (new MailMessage()) return (new MailMessage())
->subject("New Comment on Page: " . $page->getShortName()) ->subject(trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()]))
->line("A user has commented on a page in " . setting('app-name') . ':') ->line(trans('notifications.new_comment_intro', ['appName' => setting('app-name')]))
->line("Page Name: " . $page->name) ->line(new ListMessageLine([
->line("Commenter: " . $this->user->name) trans('notifications.detail_page_name') => $page->name,
->line("Comment: " . strip_tags($comment->html)) trans('notifications.detail_commenter') => $this->user->name,
->action('View Comment', $page->getUrl('#comment' . $comment->local_id)) trans('notifications.detail_comment') => strip_tags($comment->html),
->line(new LinkedMailMessageLine( ]))
url('/preferences/notifications'), ->action(trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
'This notification was sent to you because :link cover this type of activity for this item.', ->line($this->buildReasonFooterLine());
'your notification preferences',
));
} }
} }

View File

@ -2,7 +2,7 @@
namespace BookStack\Activity\Notifications\Messages; namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\LinkedMailMessageLine; use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
@ -14,15 +14,13 @@ class PageCreationNotification extends BaseActivityNotification
$page = $this->detail; $page = $this->detail;
return (new MailMessage()) return (new MailMessage())
->subject("New Page: " . $page->getShortName()) ->subject(trans('notifications.new_page_subject', ['pageName' => $page->getShortName()]))
->line("A new page has been created in " . setting('app-name') . ':') ->line(trans('notifications.new_page_intro', ['appName' => setting('app-name')]))
->line("Page Name: " . $page->name) ->line(new ListMessageLine([
->line("Created By: " . $this->user->name) trans('notifications.detail_page_name') => $page->name,
->action('View Page', $page->getUrl()) trans('notifications.detail_created_by') => $this->user->name,
->line(new LinkedMailMessageLine( ]))
url('/preferences/notifications'), ->action(trans('notifications.action_view_page'), $page->getUrl())
'This notification was sent to you because :link cover this type of activity for this item.', ->line($this->buildReasonFooterLine());
'your notification preferences',
));
} }
} }

View File

@ -2,7 +2,7 @@
namespace BookStack\Activity\Notifications\Messages; namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\LinkedMailMessageLine; use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
@ -14,16 +14,14 @@ class PageUpdateNotification extends BaseActivityNotification
$page = $this->detail; $page = $this->detail;
return (new MailMessage()) return (new MailMessage())
->subject("Updated Page: " . $page->getShortName()) ->subject(trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()]))
->line("A page has been updated in " . setting('app-name') . ':') ->line(trans('notifications.updated_page_intro', ['appName' => setting('app-name')]))
->line("Page Name: " . $page->name) ->line(new ListMessageLine([
->line("Updated By: " . $this->user->name) trans('notifications.detail_page_name') => $page->name,
->line("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.") trans('notifications.detail_updated_by') => $this->user->name,
->action('View Page', $page->getUrl()) ]))
->line(new LinkedMailMessageLine( ->line(trans('notifications.updated_page_debounce'))
url('/preferences/notifications'), ->action(trans('notifications.action_view_page'), $page->getUrl())
'This notification was sent to you because :link cover this type of activity for this item.', ->line($this->buildReasonFooterLine());
'your notification preferences',
));
} }
} }

View File

@ -3,6 +3,7 @@
namespace BookStack\Activity\Notifications; namespace BookStack\Activity\Notifications;
use BookStack\Activity\ActivityType; use BookStack\Activity\ActivityType;
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\NotificationHandler; use BookStack\Activity\Notifications\Handlers\NotificationHandler;
@ -17,13 +18,14 @@ class NotificationManager
*/ */
protected array $handlers = []; protected array $handlers = [];
public function handle(string $activityType, string|Loggable $detail, User $user): void public function handle(Activity $activity, string|Loggable $detail, User $user): void
{ {
$activityType = $activity->type;
$handlersToRun = $this->handlers[$activityType] ?? []; $handlersToRun = $this->handlers[$activityType] ?? [];
foreach ($handlersToRun as $handlerClass) { foreach ($handlersToRun as $handlerClass) {
/** @var NotificationHandler $handler */ /** @var NotificationHandler $handler */
$handler = app()->make($handlerClass); $handler = app()->make($handlerClass);
$handler->handle($activityType, $detail, $user); $handler->handle($activity, $detail, $user);
} }
} }

View File

@ -40,7 +40,7 @@ class ActivityLogger
$this->setNotification($type); $this->setNotification($type);
$this->dispatchWebhooks($type, $detail); $this->dispatchWebhooks($type, $detail);
$this->notifications->handle($type, $detail, user()); $this->notifications->handle($activity, $detail, user());
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail); Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
} }

View File

@ -76,14 +76,16 @@ class UserEntityWatchOptions
$entities[] = $this->entity->chapter; $entities[] = $this->entity->chapter;
} }
$query = Watch::query()->where(function (Builder $subQuery) use ($entities) { $query = Watch::query()
foreach ($entities as $entity) { ->where('user_id', '=', $this->user->id)
$subQuery->orWhere(function (Builder $whereQuery) use ($entity) { ->where(function (Builder $subQuery) use ($entities) {
$whereQuery->where('watchable_type', '=', $entity->getMorphClass()) foreach ($entities as $entity) {
$subQuery->orWhere(function (Builder $whereQuery) use ($entity) {
$whereQuery->where('watchable_type', '=', $entity->getMorphClass())
->where('watchable_id', '=', $entity->id); ->where('watchable_id', '=', $entity->id);
}); });
} }
}); });
$this->watchMap = $query->get(['watchable_type', 'level']) $this->watchMap = $query->get(['watchable_type', 'level'])
->pluck('level', 'watchable_type') ->pluck('level', 'watchable_type')

26
lang/en/notifications.php Normal file
View File

@ -0,0 +1,26 @@
<?php
/**
* Text used for activity-based notifications.
*/
return [
'new_comment_subject' => 'New comment on page: :pageName',
'new_comment_intro' => 'A user has commented on a page in :appName:',
'new_page_subject' => 'New page: :pageName',
'new_page_intro' => 'A new page has been created in :appName:',
'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.',
'detail_page_name' => 'Page Name:',
'detail_commenter' => 'Commenter:',
'detail_comment' => 'Comment:',
'detail_created_by' => 'Created By:',
'detail_updated_by' => 'Updated By:',
'action_view_comment' => 'View Comment',
'action_view_page' => 'View Page',
'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.',
'footer_reason_link' => 'your notification preferences',
];

View File

@ -30,7 +30,7 @@
$style = [ $style = [
/* Layout ------------------------------ */ /* Layout ------------------------------ */
'body' => 'margin: 0; padding: 0; width: 100%; background-color: #F2F4F6;', 'body' => 'margin: 0; padding: 0; width: 100%; background-color: #F2F4F6;color:#444444;',
'email-wrapper' => 'width: 100%; margin: 0; padding: 0; background-color: #F2F4F6;', 'email-wrapper' => 'width: 100%; margin: 0; padding: 0; background-color: #F2F4F6;',
/* Masthead ----------------------- */ /* Masthead ----------------------- */
@ -54,8 +54,8 @@ $style = [
'anchor' => 'color: '.setting('app-color').';overflow-wrap: break-word;word-wrap: break-word;word-break: break-all;word-break:break-word;', 'anchor' => 'color: '.setting('app-color').';overflow-wrap: break-word;word-wrap: break-word;word-break: break-all;word-break:break-word;',
'header-1' => 'margin-top: 0; color: #2F3133; font-size: 19px; font-weight: bold; text-align: left;', 'header-1' => 'margin-top: 0; color: #2F3133; font-size: 19px; font-weight: bold; text-align: left;',
'paragraph' => 'margin-top: 0; color: #74787E; font-size: 16px; line-height: 1.5em;', 'paragraph' => 'margin-top: 0; color: #444444; font-size: 16px; line-height: 1.5em;',
'paragraph-sub' => 'margin-top: 0; color: #74787E; font-size: 12px; line-height: 1.5em;', 'paragraph-sub' => 'margin-top: 0; color: #444444; font-size: 12px; line-height: 1.5em;',
'paragraph-center' => 'text-align: center;', 'paragraph-center' => 'text-align: center;',
/* Buttons ------------------------------ */ /* Buttons ------------------------------ */
@ -147,7 +147,7 @@ $style = [
<!-- Outro --> <!-- Outro -->
@foreach ($outroLines as $line) @foreach ($outroLines as $line)
<p style="{{ $style['paragraph'] }}"> <p style="{{ $style['paragraph-sub'] }}">
{{ $line }} {{ $line }}
</p> </p>
@endforeach @endforeach