]> BookStack Code Mirror - bookstack/commitdiff
Notifications: Cleaned up mails, added debounce for updates
authorDan Brown <redacted>
Tue, 15 Aug 2023 13:39:39 +0000 (14:39 +0100)
committerDan Brown <redacted>
Tue, 15 Aug 2023 13:39:39 +0000 (14:39 +0100)
- 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.

17 files changed:
app/Activity/Models/Comment.php
app/Activity/Notifications/Handlers/BaseNotificationHandler.php
app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php
app/Activity/Notifications/Handlers/NotificationHandler.php
app/Activity/Notifications/Handlers/PageCreationNotificationHandler.php
app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php
app/Activity/Notifications/MessageParts/LinkedMailMessageLine.php [moved from app/Activity/Notifications/LinkedMailMessageLine.php with 91% similarity]
app/Activity/Notifications/MessageParts/ListMessageLine.php [new file with mode: 0644]
app/Activity/Notifications/Messages/BaseActivityNotification.php
app/Activity/Notifications/Messages/CommentCreationNotification.php
app/Activity/Notifications/Messages/PageCreationNotification.php
app/Activity/Notifications/Messages/PageUpdateNotification.php
app/Activity/Notifications/NotificationManager.php
app/Activity/Tools/ActivityLogger.php
app/Activity/Tools/UserEntityWatchOptions.php
lang/en/notifications.php [new file with mode: 0644]
resources/views/vendor/notifications/email.blade.php

index 72098a3c3c34d3974d3bfb09bd6a52d3f9629c53..bcbed6c56f03d7fd3dfe753b395c9dc6d2fc5d5e 100644 (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();
     }
index e0b3f3ceba338d1ce82f8e21f289f5afe934e1ac..f1742592e792d0808f1bc1f51c5c38a7d56fa703 100644 (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));
         }
     }
 }
index 27d61307a2e215400672bb52225438b7b3baa521..112852cf9e3b5a3c4f1b63d137b42dd0a985f65d 100644 (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);
     }
 }
index fecca21813783525e79918fc412cab0ff28bab62..8c5498664e1d4e55916a85d984309a6064469efc 100644 (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;
 }
index e9aca2f231fae7509275db3b5ad0935149fc5d5d..2492021e22517f5a87821f3b7d90ffd45d668175 100644 (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);
     }
 }
index 5a2bf4e9cddfe89cf86d9b229c02a39155c68715..744aba18f631782e38b739874a3f8e5e44b3c34b 100644 (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);
     }
 }
similarity index 91%
rename from app/Activity/Notifications/LinkedMailMessageLine.php
rename to app/Activity/Notifications/MessageParts/LinkedMailMessageLine.php
index 224d8e87c913d319aa7d1d1ea2b8db485d797efa..8f6a4e2b9f84723692d41e7b4102ac13fb6fdc53 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-namespace BookStack\Activity\Notifications;
+namespace BookStack\Activity\Notifications\MessageParts;
 
 use Illuminate\Contracts\Support\Htmlable;
 
diff --git a/app/Activity/Notifications/MessageParts/ListMessageLine.php b/app/Activity/Notifications/MessageParts/ListMessageLine.php
new file mode 100644 (file)
index 0000000..f808d25
--- /dev/null
@@ -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);
+    }
+}
index 285e2803ee77aa591aa003ecc19e1d7127285d5f..eb6eb0cc8fde5f91fc936fc63c8e6bc65006b17e 100644 (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'),
+        );
+    }
 }
index 817eb7b84a3b0f273132199a1a09e059e49de8b1..ce358724baaf889053516f8733e7ca6e96cedd5d 100644 (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());
     }
 }
index 2e9a6debcb6bc191d4bd4963d05b5f6e50a6c166..068f95acc2b0feaeeedffce36cedf0646802cdf4 100644 (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());
     }
 }
index f29f50dde44ed0170557a97a50edb8a6ca55d71d..c4a6de0bd1c06cdfd6e286834311335e3c622035 100644 (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());
     }
 }
index 01361c1ee5fd8ae55195a27f147f1a1125eb6d03..fc6a5f57c60c75e661d3bf79641ded997da55849 100644 (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);
         }
     }
 
index e8ea7c29391e99370b2115123666462e3dcd3ef0..adda36c1b813a3f2728ae4c3eec64416fd1e8b64 100644 (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);
     }
 
index 26d830851c51e2e5263ca2cdca5445d73ab12126..08fbe2e8d94dab240186d2fc2c54c819bfa337c4 100644 (file)
@@ -76,14 +76,16 @@ class UserEntityWatchOptions
             $entities[] = $this->entity->chapter;
         }
 
-        $query = Watch::query()->where(function (Builder $subQuery) use ($entities) {
-            foreach ($entities as $entity) {
-                $subQuery->orWhere(function (Builder $whereQuery) use ($entity) {
-                    $whereQuery->where('watchable_type', '=', $entity->getMorphClass())
+        $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())
                         ->where('watchable_id', '=', $entity->id);
-                });
-            }
-        });
+                    });
+                }
+            });
 
         $this->watchMap = $query->get(['watchable_type', 'level'])
             ->pluck('level', 'watchable_type')
diff --git a/lang/en/notifications.php b/lang/en/notifications.php
new file mode 100644 (file)
index 0000000..5539ae9
--- /dev/null
@@ -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',
+];
index f73b87b597853ccf5becce9eacbf5c4c7db68048..88cdbd89024e4c1372e1b34b5016282dac4da6a9 100644 (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