Put together an initial notification.
Started logic to query and identify watchers.
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
+use BookStack\Users\Models\User;
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
}
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
+use BookStack\Users\Models\User;
interface NotificationHandler
{
/**
* 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;
}
namespace BookStack\Activity\Notifications\Handlers;
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
{
- public function handle(string $activityType, Loggable|string $detail): void
+ public function handle(string $activityType, Loggable|string $detail, User $user): void
{
// 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?
}
}
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
+use BookStack\Users\Models\User;
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
}
--- /dev/null
+<?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));
+ }
+}
--- /dev/null
+<?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,
+ ];
+ }
+}
--- /dev/null
+<?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',
+ ));
+ }
+}
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
+use BookStack\Users\Models\User;
class NotificationManager
{
*/
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] ?? [];
foreach ($handlersToRun as $handlerClass) {
/** @var NotificationHandler $handler */
$handler = app()->make($handlerClass);
- $handler->handle($activityType, $detail);
+ $handler->handle($activityType, $detail, $user);
}
}
$this->setNotification($type);
$this->dispatchWebhooks($type, $detail);
- $this->notifications->handle($type, $detail);
+ $this->notifications->handle($type, $detail, user());
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
}
--- /dev/null
+<?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();
+ }
+}
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Watch;
+use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Entity;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
class UserWatchOptions
{
- protected static array $levelByName = [
- 'default' => -1,
- 'ignore' => 0,
- 'new' => 1,
- 'updates' => 2,
- 'comments' => 3,
- ];
-
public function __construct(
protected User $user,
) {
public function getEntityWatchLevel(Entity $entity): string
{
$levelValue = $this->entityQuery($entity)->first(['level'])->level ?? -1;
- return $this->levelValueToName($levelValue);
+ return WatchLevels::levelValueToName($levelValue);
}
public function isWatching(Entity $entity): bool
public function updateEntityWatchLevel(Entity $entity, string $level): void
{
- $levelValue = $this->levelNameToValue($level);
+ $levelValue = WatchLevels::levelNameToValue($level);
if ($levelValue < 0) {
$this->removeForEntity($entity);
return;
->where('watchable_type', '=', $entity->getMorphClass())
->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';
- }
}
--- /dev/null
+<?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';
+ }
+}
<input type="hidden" name="id" value="{{ $entity->id }}">
<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>
<button name="level" value="{{ $option }}" class="icon-item">
@if($watchLevel === $option)