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\Users\Models\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
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).
*/
public function parent()
public function parent(): BelongsTo
{
return $this->belongsTo(Comment::class);
}
@ -50,20 +51,16 @@ class Comment extends Model implements Loggable
/**
* Get created date as a relative diff.
*
* @return mixed
*/
public function getCreatedAttribute()
public function getCreatedAttribute(): string
{
return $this->created_at->diffForHumans();
}
/**
* Get updated date as a relative diff.
*
* @return mixed
*/
public function getUpdatedAttribute()
public function getUpdatedAttribute(): string
{
return $this->updated_at->diffForHumans();
}

View file

@ -2,6 +2,7 @@
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
use BookStack\Entities\Models\Entity;
use BookStack\Permissions\PermissionApplicator;
@ -18,7 +19,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
* @param class-string<BaseActivityNotification> $notification
* @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();
@ -39,7 +40,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
}
// 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;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\CommentCreationNotification;
@ -12,7 +13,7 @@ use BookStack\Users\Models\User;
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)) {
throw new \InvalidArgumentException("Detail for comment creation notifications must be a comment");
@ -24,10 +25,10 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
$watcherIds = $watchers->getWatcherUserIds();
// Page owner if user preferences allow
if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
if (!$watchers->isUserIgnoring($detail->created_by) && $detail->createdBy) {
$userNotificationPrefs = new UserNotificationPreferences($detail->createdBy);
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;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;
@ -9,8 +10,8 @@ interface NotificationHandler
{
/**
* 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.
*/
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;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\PageCreationNotification;
use BookStack\Activity\Tools\EntityWatchers;
@ -11,13 +12,13 @@ use BookStack\Users\Models\User;
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)) {
throw new \InvalidArgumentException("Detail for page create notifications must be a page");
}
$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;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\PageUpdateNotification;
use BookStack\Activity\Tools\EntityWatchers;
@ -12,15 +14,31 @@ use BookStack\Users\Models\User;
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)) {
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);
$watcherIds = $watchers->getWatcherUserIds();
// Add page owner if preferences allow
if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
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
namespace BookStack\Activity\Notifications;
namespace BookStack\Activity\Notifications\MessageParts;
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;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
@ -47,4 +48,16 @@ abstract class BaseActivityNotification extends Notification
'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;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Notifications\LinkedMailMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use Illuminate\Notifications\Messages\MailMessage;
@ -17,16 +17,14 @@ class CommentCreationNotification extends BaseActivityNotification
$page = $comment->entity;
return (new MailMessage())
->subject("New Comment on Page: " . $page->getShortName())
->line("A user has commented on a page in " . setting('app-name') . ':')
->line("Page Name: " . $page->name)
->line("Commenter: " . $this->user->name)
->line("Comment: " . strip_tags($comment->html))
->action('View Comment', $page->getUrl('#comment' . $comment->local_id))
->line(new LinkedMailMessageLine(
url('/preferences/notifications'),
'This notification was sent to you because :link cover this type of activity for this item.',
'your notification preferences',
));
->subject(trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()]))
->line(trans('notifications.new_comment_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine([
trans('notifications.detail_page_name') => $page->name,
trans('notifications.detail_commenter') => $this->user->name,
trans('notifications.detail_comment') => strip_tags($comment->html),
]))
->action(trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
->line($this->buildReasonFooterLine());
}
}

View file

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

View file

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

View file

@ -3,6 +3,7 @@
namespace BookStack\Activity\Notifications;
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\NotificationHandler;
@ -17,13 +18,14 @@ class NotificationManager
*/
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] ?? [];
foreach ($handlersToRun as $handlerClass) {
/** @var NotificationHandler $handler */
$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->dispatchWebhooks($type, $detail);
$this->notifications->handle($type, $detail, user());
$this->notifications->handle($activity, $detail, user());
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
}

View file

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

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 = [
/* 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;',
/* 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;',
'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-sub' => 'margin-top: 0; color: #74787E; font-size: 12px; line-height: 1.5em;',
'paragraph' => 'margin-top: 0; color: #444444; font-size: 16px; line-height: 1.5em;',
'paragraph-sub' => 'margin-top: 0; color: #444444; font-size: 12px; line-height: 1.5em;',
'paragraph-center' => 'text-align: center;',
/* Buttons ------------------------------ */
@ -147,7 +147,7 @@ $style = [
<!-- Outro -->
@foreach ($outroLines as $line)
<p style="{{ $style['paragraph'] }}">
<p style="{{ $style['paragraph-sub'] }}">
{{ $line }}
</p>
@endforeach