mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-05-02 07:20:05 +00:00
Notifications: Started core user notification logic
Put together an initial notification. Started logic to query and identify watchers.
This commit is contained in:
parent
9d149e4d36
commit
9779c1a357
13 changed files with 258 additions and 42 deletions
app/Activity
Notifications
Handlers
CommentCreationNotificationHandler.phpNotificationHandler.phpPageCreationNotificationHandler.phpPageUpdateNotificationHandler.php
LinkedMailMessageLine.phpMessages
NotificationManager.phpTools
WatchLevels.phpresources/views/entities
|
@ -3,10 +3,11 @@
|
||||||
namespace BookStack\Activity\Notifications\Handlers;
|
namespace BookStack\Activity\Notifications\Handlers;
|
||||||
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
use BookStack\Activity\Models\Loggable;
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
|
|
||||||
class CommentCreationNotificationHandler implements NotificationHandler
|
class CommentCreationNotificationHandler implements NotificationHandler
|
||||||
{
|
{
|
||||||
public function handle(string $activityType, Loggable|string $detail): void
|
public function handle(string $activityType, Loggable|string $detail, User $user): void
|
||||||
{
|
{
|
||||||
// TODO
|
// TODO
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,14 @@
|
||||||
namespace BookStack\Activity\Notifications\Handlers;
|
namespace BookStack\Activity\Notifications\Handlers;
|
||||||
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
use BookStack\Activity\Models\Loggable;
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
|
|
||||||
interface NotificationHandler
|
interface NotificationHandler
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Run this handler.
|
* Run this handler.
|
||||||
|
* Provides the activity type, related activity detail/model
|
||||||
|
* along with the user that triggered the activity.
|
||||||
*/
|
*/
|
||||||
public function handle(string $activityType, string|Loggable $detail): void;
|
public function handle(string $activityType, string|Loggable $detail, User $user): void;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,32 @@
|
||||||
namespace BookStack\Activity\Notifications\Handlers;
|
namespace BookStack\Activity\Notifications\Handlers;
|
||||||
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
use BookStack\Activity\Models\Loggable;
|
||||||
|
use BookStack\Activity\Models\Watch;
|
||||||
|
use BookStack\Activity\Tools\EntityWatchers;
|
||||||
|
use BookStack\Activity\WatchLevels;
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
|
|
||||||
class PageCreationNotificationHandler implements NotificationHandler
|
class PageCreationNotificationHandler implements NotificationHandler
|
||||||
{
|
{
|
||||||
public function handle(string $activityType, Loggable|string $detail): void
|
public function handle(string $activityType, Loggable|string $detail, User $user): void
|
||||||
{
|
{
|
||||||
// TODO
|
// TODO
|
||||||
|
|
||||||
|
// No user-level preferences to care about here.
|
||||||
|
// Possible Scenarios:
|
||||||
|
// ✅ User watching parent chapter
|
||||||
|
// ✅ User watching parent book
|
||||||
|
// ❌ User ignoring parent book
|
||||||
|
// ❌ User ignoring parent chapter
|
||||||
|
// ❌ User watching parent book, ignoring chapter
|
||||||
|
// ✅ User watching parent book, watching chapter
|
||||||
|
// ❌ User ignoring parent book, ignoring chapter
|
||||||
|
// ✅ User ignoring parent book, watching chapter
|
||||||
|
|
||||||
|
// Get all relevant watchers
|
||||||
|
$watchers = new EntityWatchers($detail, WatchLevels::NEW);
|
||||||
|
|
||||||
|
// TODO - need to check entity visibility and receive-notifications permissions.
|
||||||
|
// Maybe abstract this to a generic late-stage filter?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,11 @@
|
||||||
namespace BookStack\Activity\Notifications\Handlers;
|
namespace BookStack\Activity\Notifications\Handlers;
|
||||||
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
use BookStack\Activity\Models\Loggable;
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
|
|
||||||
class PageUpdateNotificationHandler implements NotificationHandler
|
class PageUpdateNotificationHandler implements NotificationHandler
|
||||||
{
|
{
|
||||||
public function handle(string $activityType, Loggable|string $detail): void
|
public function handle(string $activityType, Loggable|string $detail, User $user): void
|
||||||
{
|
{
|
||||||
// TODO
|
// TODO
|
||||||
}
|
}
|
||||||
|
|
26
app/Activity/Notifications/LinkedMailMessageLine.php
Normal file
26
app/Activity/Notifications/LinkedMailMessageLine.php
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A line of text with linked text included, intended for use
|
||||||
|
* in MailMessages. The line should have a ':link' placeholder for
|
||||||
|
* where the link should be inserted within the line.
|
||||||
|
*/
|
||||||
|
class LinkedMailMessageLine implements Htmlable
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected string $url,
|
||||||
|
protected string $line,
|
||||||
|
protected string $linkText,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toHtml(): string
|
||||||
|
{
|
||||||
|
$link = '<a href="' . e($this->url) . '">' . e($this->linkText) . '</a>';
|
||||||
|
return str_replace(':link', $link, e($this->line));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Notifications\Messages;
|
||||||
|
|
||||||
|
use BookStack\Activity\Models\Loggable;
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
abstract class BaseActivityNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected Loggable|string $detail,
|
||||||
|
protected User $user,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @param mixed $notifiable
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function via($notifiable)
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
abstract public function toMail(mixed $notifiable): MailMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @param mixed $notifiable
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function toArray($notifiable)
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'activity_detail' => $this->detail,
|
||||||
|
'activity_creator' => $this->user,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Notifications\Messages;
|
||||||
|
|
||||||
|
use BookStack\Activity\Notifications\LinkedMailMessageLine;
|
||||||
|
use BookStack\Entities\Models\Page;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
|
||||||
|
class PageCreationNotification extends BaseActivityNotification
|
||||||
|
{
|
||||||
|
public function toMail(mixed $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
/** @var Page $page */
|
||||||
|
$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',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler
|
||||||
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;
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
|
|
||||||
class NotificationManager
|
class NotificationManager
|
||||||
{
|
{
|
||||||
|
@ -16,13 +17,13 @@ class NotificationManager
|
||||||
*/
|
*/
|
||||||
protected array $handlers = [];
|
protected array $handlers = [];
|
||||||
|
|
||||||
public function handle(string $activityType, string|Loggable $detail): void
|
public function handle(string $activityType, string|Loggable $detail, User $user): void
|
||||||
{
|
{
|
||||||
$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);
|
$handler->handle($activityType, $detail, $user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
$this->notifications->handle($type, $detail, user());
|
||||||
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
|
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
55
app/Activity/Tools/EntityWatchers.php
Normal file
55
app/Activity/Tools/EntityWatchers.php
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Tools;
|
||||||
|
|
||||||
|
use BookStack\Activity\Models\Watch;
|
||||||
|
use BookStack\Entities\Models\BookChild;
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
|
use BookStack\Entities\Models\Page;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class EntityWatchers
|
||||||
|
{
|
||||||
|
protected array $watchers = [];
|
||||||
|
protected array $ignorers = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected Entity $entity,
|
||||||
|
protected int $watchLevel,
|
||||||
|
) {
|
||||||
|
$this->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function build(): void
|
||||||
|
{
|
||||||
|
$watches = $this->getRelevantWatches();
|
||||||
|
|
||||||
|
// TODO - De-dupe down watches per-user across entity types
|
||||||
|
// so we end up with [user_id => status] values
|
||||||
|
// then filter to current watch level, considering ignores,
|
||||||
|
// then populate the class watchers/ignores with ids.
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getRelevantWatches(): array
|
||||||
|
{
|
||||||
|
/** @var Entity[] $entitiesInvolved */
|
||||||
|
$entitiesInvolved = array_filter([
|
||||||
|
$this->entity,
|
||||||
|
$this->entity instanceof BookChild ? $this->entity->book : null,
|
||||||
|
$this->entity instanceof Page ? $this->entity->chapter : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$query = Watch::query()->where(function (Builder $query) use ($entitiesInvolved) {
|
||||||
|
foreach ($entitiesInvolved as $entity) {
|
||||||
|
$query->orWhere(function (Builder $query) use ($entity) {
|
||||||
|
$query->where('watchable_type', '=', $entity->getMorphClass())
|
||||||
|
->where('watchable_id', '=', $entity->id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $query->get([
|
||||||
|
'level', 'watchable_id', 'watchable_type', 'user_id'
|
||||||
|
])->all();
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,20 +3,13 @@
|
||||||
namespace BookStack\Activity\Tools;
|
namespace BookStack\Activity\Tools;
|
||||||
|
|
||||||
use BookStack\Activity\Models\Watch;
|
use BookStack\Activity\Models\Watch;
|
||||||
|
use BookStack\Activity\WatchLevels;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class UserWatchOptions
|
class UserWatchOptions
|
||||||
{
|
{
|
||||||
protected static array $levelByName = [
|
|
||||||
'default' => -1,
|
|
||||||
'ignore' => 0,
|
|
||||||
'new' => 1,
|
|
||||||
'updates' => 2,
|
|
||||||
'comments' => 3,
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected User $user,
|
protected User $user,
|
||||||
) {
|
) {
|
||||||
|
@ -30,7 +23,7 @@ class UserWatchOptions
|
||||||
public function getEntityWatchLevel(Entity $entity): string
|
public function getEntityWatchLevel(Entity $entity): string
|
||||||
{
|
{
|
||||||
$levelValue = $this->entityQuery($entity)->first(['level'])->level ?? -1;
|
$levelValue = $this->entityQuery($entity)->first(['level'])->level ?? -1;
|
||||||
return $this->levelValueToName($levelValue);
|
return WatchLevels::levelValueToName($levelValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isWatching(Entity $entity): bool
|
public function isWatching(Entity $entity): bool
|
||||||
|
@ -40,7 +33,7 @@ class UserWatchOptions
|
||||||
|
|
||||||
public function updateEntityWatchLevel(Entity $entity, string $level): void
|
public function updateEntityWatchLevel(Entity $entity, string $level): void
|
||||||
{
|
{
|
||||||
$levelValue = $this->levelNameToValue($level);
|
$levelValue = WatchLevels::levelNameToValue($level);
|
||||||
if ($levelValue < 0) {
|
if ($levelValue < 0) {
|
||||||
$this->removeForEntity($entity);
|
$this->removeForEntity($entity);
|
||||||
return;
|
return;
|
||||||
|
@ -71,28 +64,4 @@ class UserWatchOptions
|
||||||
->where('watchable_type', '=', $entity->getMorphClass())
|
->where('watchable_type', '=', $entity->getMorphClass())
|
||||||
->where('user_id', '=', $this->user->id);
|
->where('user_id', '=', $this->user->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
public static function getAvailableLevelNames(): array
|
|
||||||
{
|
|
||||||
return array_keys(static::$levelByName);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function levelNameToValue(string $level): int
|
|
||||||
{
|
|
||||||
return static::$levelByName[$level] ?? -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function levelValueToName(int $level): string
|
|
||||||
{
|
|
||||||
foreach (static::$levelByName as $name => $value) {
|
|
||||||
if ($level === $value) {
|
|
||||||
return $name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
61
app/Activity/WatchLevels.php
Normal file
61
app/Activity/WatchLevels.php
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity;
|
||||||
|
|
||||||
|
class WatchLevels
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Default level, No specific option set
|
||||||
|
* Typically not a stored status
|
||||||
|
*/
|
||||||
|
const DEFAULT = -1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ignore all notifications.
|
||||||
|
*/
|
||||||
|
const IGNORE = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for new content.
|
||||||
|
*/
|
||||||
|
const NEW = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for updates and new content
|
||||||
|
*/
|
||||||
|
const UPDATES = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for comments, updates and new content.
|
||||||
|
*/
|
||||||
|
const COMMENTS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the possible values as an option_name => value array.
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
$options = [];
|
||||||
|
foreach ((new \ReflectionClass(static::class))->getConstants() as $name => $value) {
|
||||||
|
$options[strtolower($name)] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function levelNameToValue(string $level): int
|
||||||
|
{
|
||||||
|
return static::all()[$level] ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function levelValueToName(int $level): string
|
||||||
|
{
|
||||||
|
foreach (static::all() as $name => $value) {
|
||||||
|
if ($level === $value) {
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,7 +5,7 @@
|
||||||
<input type="hidden" name="id" value="{{ $entity->id }}">
|
<input type="hidden" name="id" value="{{ $entity->id }}">
|
||||||
|
|
||||||
<ul refs="dropdown@menu" class="dropdown-menu xl-limited anchor-left pb-none">
|
<ul refs="dropdown@menu" class="dropdown-menu xl-limited anchor-left pb-none">
|
||||||
@foreach(\BookStack\Activity\Tools\UserWatchOptions::getAvailableLevelNames() as $option)
|
@foreach(\BookStack\Activity\WatchLevels::all() as $option)
|
||||||
<li>
|
<li>
|
||||||
<button name="level" value="{{ $option }}" class="icon-item">
|
<button name="level" value="{{ $option }}" class="icon-item">
|
||||||
@if($watchLevel === $option)
|
@if($watchLevel === $option)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue