Merge pull request #4390 from BookStackApp/content_notifications

Content user notifications
This commit is contained in:
Dan Brown 2023-08-17 21:09:52 +01:00 committed by GitHub
commit fef433a9cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 1877 additions and 50 deletions

View File

@ -0,0 +1,65 @@
<?php
namespace BookStack\Activity\Controllers;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\App\Model;
use BookStack\Entities\Models\Entity;
use BookStack\Http\Controller;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class WatchController extends Controller
{
public function update(Request $request)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();
$requestData = $this->validate($request, [
'level' => ['required', 'string'],
]);
$watchable = $this->getValidatedModelFromRequest($request);
$watchOptions = new UserEntityWatchOptions(user(), $watchable);
$watchOptions->updateLevelByName($requestData['level']);
$this->showSuccessNotification(trans('activities.watch_update_level_notification'));
return redirect()->back();
}
/**
* @throws ValidationException
* @throws Exception
*/
protected function getValidatedModelFromRequest(Request $request): Entity
{
$modelInfo = $this->validate($request, [
'type' => ['required', 'string'],
'id' => ['required', 'integer'],
]);
if (!class_exists($modelInfo['type'])) {
throw new Exception('Model not found');
}
/** @var Model $model */
$model = new $modelInfo['type']();
if (!$model instanceof Entity) {
throw new Exception('Model not an entity');
}
$modelInstance = $model->newQuery()
->where('id', '=', $modelInfo['id'])
->first(['id', 'name', 'owned_by']);
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
if (is_null($modelInstance) || $inaccessibleEntity) {
throw new Exception('Model instance not found');
}
return $modelInstance;
}
}

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;
/**
@ -32,6 +33,14 @@ class Comment extends Model implements Loggable
return $this->morphTo('entity');
}
/**
* Get the parent comment this is in reply to (if existing).
*/
public function parent(): BelongsTo
{
return $this->belongsTo(Comment::class);
}
/**
* Check if a comment has been updated since creation.
*/
@ -42,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

@ -0,0 +1,45 @@
<?php
namespace BookStack\Activity\Models;
use BookStack\Activity\WatchLevels;
use BookStack\Permissions\Models\JointPermission;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property int $user_id
* @property int $watchable_id
* @property string $watchable_type
* @property int $level
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Watch extends Model
{
protected $guarded = [];
public function watchable(): MorphTo
{
return $this->morphTo();
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'watchable_id')
->whereColumn('watches.watchable_type', '=', 'joint_permissions.entity_type');
}
public function getLevelName(): string
{
return WatchLevels::levelValueToName($this->level);
}
public function ignoring(): bool
{
return $this->level === WatchLevels::IGNORE;
}
}

View File

@ -0,0 +1,42 @@
<?php
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;
use BookStack\Users\Models\User;
abstract class BaseNotificationHandler implements NotificationHandler
{
/**
* @param class-string<BaseActivityNotification> $notification
* @param int[] $userIds
*/
protected function sendNotificationToUserIds(string $notification, array $userIds, User $initiator, string|Loggable $detail, Entity $relatedModel): void
{
$users = User::query()->whereIn('id', array_unique($userIds))->get();
foreach ($users as $user) {
// Prevent sending to the user that initiated the activity
if ($user->id === $initiator->id) {
continue;
}
// Prevent sending of the user does not have notification permissions
if (!$user->can('receive-notifications')) {
continue;
}
// Prevent sending if the user does not have access to the related content
$permissions = new PermissionApplicator($user);
if (!$permissions->checkOwnableUserAccess($relatedModel, 'view')) {
continue;
}
// Send the notification
$user->notify(new $notification($detail, $initiator));
}
}
}

View File

@ -0,0 +1,48 @@
<?php
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;
use BookStack\Activity\Tools\EntityWatchers;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;
class CommentCreationNotificationHandler extends BaseNotificationHandler
{
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");
}
// Main watchers
/** @var Page $page */
$page = $detail->entity;
$watchers = new EntityWatchers($page, WatchLevels::COMMENTS);
$watcherIds = $watchers->getWatcherUserIds();
// Page owner if user preferences allow
if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageComments()) {
$watcherIds[] = $page->owned_by;
}
}
// Parent comment creator if preferences allow
$parentComment = $detail->parent()->first();
if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
$watcherIds[] = $parentComment->created_by;
}
}
$this->sendNotificationToUserIds(CommentCreationNotification::class, $watcherIds, $user, $detail, $page);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;
interface NotificationHandler
{
/**
* Run this handler.
* Provides the activity, related activity detail/model
* along with the user that triggered the activity.
*/
public function handle(Activity $activity, string|Loggable $detail, User $user): void;
}

View File

@ -0,0 +1,24 @@
<?php
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;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
class PageCreationNotificationHandler extends BaseNotificationHandler
{
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, $detail);
}
}

View File

@ -0,0 +1,51 @@
<?php
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;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;
class PageUpdateNotificationHandler extends BaseNotificationHandler
{
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()) {
$watcherIds[] = $detail->owned_by;
}
}
$this->sendNotificationToUserIds(PageUpdateNotification::class, $watcherIds, $user, $detail, $detail);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
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));
}
}

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

@ -0,0 +1,63 @@
<?php
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;
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,
];
}
/**
* 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

@ -0,0 +1,30 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use Illuminate\Notifications\Messages\MailMessage;
class CommentCreationNotification extends BaseActivityNotification
{
public function toMail(mixed $notifiable): MailMessage
{
/** @var Comment $comment */
$comment = $this->detail;
/** @var Page $page */
$page = $comment->entity;
return (new MailMessage())
->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

@ -0,0 +1,26 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
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(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

@ -0,0 +1,27 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use Illuminate\Notifications\Messages\MailMessage;
class PageUpdateNotification extends BaseActivityNotification
{
public function toMail(mixed $notifiable): MailMessage
{
/** @var Page $page */
$page = $this->detail;
return (new MailMessage())
->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

@ -0,0 +1,52 @@
<?php
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;
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
use BookStack\Users\Models\User;
class NotificationManager
{
/**
* @var class-string<NotificationHandler>[]
*/
protected array $handlers = [];
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 = new $handlerClass();
$handler->handle($activity, $detail, $user);
}
}
/**
* @param class-string<NotificationHandler> $handlerClass
*/
public function registerHandler(string $activityType, string $handlerClass): void
{
if (!isset($this->handlers[$activityType])) {
$this->handlers[$activityType] = [];
}
if (!in_array($handlerClass, $this->handlers[$activityType])) {
$this->handlers[$activityType][] = $handlerClass;
}
}
public function loadDefaultHandlers(): void
{
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
}
}

View File

@ -6,7 +6,7 @@ use BookStack\Activity\DispatchWebhookJob;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Webhook;
use BookStack\App\Model;
use BookStack\Activity\Notifications\NotificationManager;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
@ -15,10 +15,16 @@ use Illuminate\Support\Facades\Log;
class ActivityLogger
{
public function __construct(
protected NotificationManager $notifications
) {
$this->notifications->loadDefaultHandlers();
}
/**
* Add a generic activity event to the database.
*/
public function add(string $type, $detail = '')
public function add(string $type, string|Loggable $detail = ''): void
{
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
@ -34,6 +40,7 @@ class ActivityLogger
$this->setNotification($type);
$this->dispatchWebhooks($type, $detail);
$this->notifications->handle($activity, $detail, user());
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
}

View File

@ -0,0 +1,86 @@
<?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
{
/**
* @var int[]
*/
protected array $watchers = [];
/**
* @var int[]
*/
protected array $ignorers = [];
public function __construct(
protected Entity $entity,
protected int $watchLevel,
) {
$this->build();
}
public function getWatcherUserIds(): array
{
return $this->watchers;
}
public function isUserIgnoring(int $userId): bool
{
return in_array($userId, $this->ignorers);
}
protected function build(): void
{
$watches = $this->getRelevantWatches();
// Sort before de-duping, so that the order looped below follows book -> chapter -> page ordering
usort($watches, function (Watch $watchA, Watch $watchB) {
$entityTypeDiff = $watchA->watchable_type <=> $watchB->watchable_type;
return $entityTypeDiff === 0 ? ($watchA->user_id <=> $watchB->user_id) : $entityTypeDiff;
});
// De-dupe by user id to get their most relevant level
$levelByUserId = [];
foreach ($watches as $watch) {
$levelByUserId[$watch->user_id] = $watch->level;
}
// Populate the class arrays
$this->watchers = array_keys(array_filter($levelByUserId, fn(int $level) => $level >= $this->watchLevel));
$this->ignorers = array_keys(array_filter($levelByUserId, fn(int $level) => $level === 0));
}
/**
* @return Watch[]
*/
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();
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Watch;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
class UserEntityWatchOptions
{
protected ?array $watchMap = null;
public function __construct(
protected User $user,
protected Entity $entity,
) {
}
public function canWatch(): bool
{
return $this->user->can('receive-notifications') && !$this->user->isDefault();
}
public function getWatchLevel(): string
{
return WatchLevels::levelValueToName($this->getWatchLevelValue());
}
public function isWatching(): bool
{
return $this->getWatchLevelValue() !== WatchLevels::DEFAULT;
}
public function getWatchedParent(): ?WatchedParentDetails
{
$watchMap = $this->getWatchMap();
unset($watchMap[$this->entity->getMorphClass()]);
if (isset($watchMap['chapter'])) {
return new WatchedParentDetails('chapter', $watchMap['chapter']);
}
if (isset($watchMap['book'])) {
return new WatchedParentDetails('book', $watchMap['book']);
}
return null;
}
public function updateLevelByName(string $level): void
{
$levelValue = WatchLevels::levelNameToValue($level);
$this->updateLevelByValue($levelValue);
}
public function updateLevelByValue(int $level): void
{
if ($level < 0) {
$this->remove();
return;
}
$this->updateLevel($level);
}
public function getWatchMap(): array
{
if (!is_null($this->watchMap)) {
return $this->watchMap;
}
$entities = [$this->entity];
if ($this->entity instanceof BookChild) {
$entities[] = $this->entity->book;
}
if ($this->entity instanceof Page && $this->entity->chapter) {
$entities[] = $this->entity->chapter;
}
$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')
->toArray();
return $this->watchMap;
}
protected function getWatchLevelValue()
{
return $this->getWatchMap()[$this->entity->getMorphClass()] ?? WatchLevels::DEFAULT;
}
protected function updateLevel(int $levelValue): void
{
Watch::query()->updateOrCreate([
'watchable_id' => $this->entity->id,
'watchable_type' => $this->entity->getMorphClass(),
'user_id' => $this->user->id,
], [
'level' => $levelValue,
]);
$this->watchMap = null;
}
protected function remove(): void
{
$this->entityQuery()->delete();
$this->watchMap = null;
}
protected function entityQuery(): Builder
{
return Watch::query()->where('watchable_id', '=', $this->entity->id)
->where('watchable_type', '=', $this->entity->getMorphClass())
->where('user_id', '=', $this->user->id);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\WatchLevels;
class WatchedParentDetails
{
public function __construct(
public string $type,
public int $level,
) {
}
public function ignoring(): bool
{
return $this->level === WatchLevels::IGNORE;
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace BookStack\Activity;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
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.
* @returns array<string, int>
*/
public static function all(): array
{
$options = [];
foreach ((new \ReflectionClass(static::class))->getConstants() as $name => $value) {
$options[strtolower($name)] = $value;
}
return $options;
}
/**
* Get the watch options suited for the given entity.
* @returns array<string, int>
*/
public static function allSuitedFor(Entity $entity): array
{
$options = static::all();
if ($entity instanceof Page) {
unset($options['new']);
} elseif ($entity instanceof Bookshelf) {
return [];
}
return $options;
}
/**
* Convert the given name to a level value.
* Defaults to default value if the level does not exist.
*/
public static function levelNameToValue(string $level): int
{
return static::all()[$level] ?? static::DEFAULT;
}
/**
* Convert the given int level value to a level name.
* Defaults to 'default' level name if not existing.
*/
public static function levelValueToName(int $level): string
{
foreach (static::all() as $name => $value) {
if ($level === $value) {
return $name;
}
}
return 'default';
}
}

View File

@ -9,6 +9,7 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\BookStackExceptionHandlerPage;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService;
use BookStack\Util\CspService;
use GuzzleHttp\Client;
@ -79,5 +80,9 @@ class AppServiceProvider extends ServiceProvider
'timeout' => 3,
]);
});
$this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null);
});
}
}

View File

@ -5,6 +5,7 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityQueries;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
@ -138,6 +139,7 @@ class BookController extends Controller
'current' => $book,
'bookChildren' => $bookChildren,
'bookParentShelves' => $bookParentShelves,
'watchOptions' => new UserEntityWatchOptions(user(), $book),
'activity' => $activities->entityActivity($book, 20, 1),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book),
]);

View File

@ -3,6 +3,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Tools\BookContents;
@ -81,6 +82,7 @@ class ChapterController extends Controller
'chapter' => $chapter,
'current' => $chapter,
'sidebarTree' => $sidebarTree,
'watchOptions' => new UserEntityWatchOptions(user(), $chapter),
'pages' => $pages,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),

View File

@ -4,6 +4,7 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\BookContents;
@ -151,6 +152,7 @@ class PageController extends Controller
'sidebarTree' => $sidebarTree,
'commentTree' => $commentTree,
'pageNav' => $pageNav,
'watchOptions' => new UserEntityWatchOptions(user(), $page),
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($page),

View File

@ -10,7 +10,7 @@ class TopFavourites extends EntityQuery
public function run(int $count, int $skip = 0)
{
$user = user();
if (is_null($user) || $user->isDefault()) {
if ($user->isDefault()) {
return collect();
}

View File

@ -66,6 +66,16 @@ abstract class Controller extends BaseController
}
}
/**
* Prevent access for guest users beyond this point.
*/
protected function preventGuestAccess(): void
{
if (!signedInUser()) {
$this->showPermissionError();
}
}
/**
* Check the current user's permissions against an ownable item otherwise throw an exception.
*/

View File

@ -8,7 +8,6 @@ use BookStack\Entities\Models\Page;
use BookStack\Permissions\Models\EntityPermission;
use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Users\Models\HasOwner;
use BookStack\Users\Models\Role;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
@ -16,6 +15,11 @@ use InvalidArgumentException;
class PermissionApplicator
{
public function __construct(
protected ?User $user = null
) {
}
/**
* Checks if an entity has a restriction set upon it.
*
@ -173,7 +177,7 @@ class PermissionApplicator
*/
protected function currentUser(): User
{
return user();
return $this->user ?? user();
}
/**

View File

@ -12,12 +12,11 @@ use Illuminate\Database\Eloquent\Collection;
class PermissionsRepo
{
protected JointPermissionBuilder $permissionBuilder;
protected array $systemRoles = ['admin', 'public'];
public function __construct(JointPermissionBuilder $permissionBuilder)
{
$this->permissionBuilder = $permissionBuilder;
public function __construct(
protected JointPermissionBuilder $permissionBuilder
) {
}
/**

View File

@ -0,0 +1,46 @@
<?php
namespace BookStack\Settings;
use BookStack\Users\Models\User;
class UserNotificationPreferences
{
public function __construct(
protected User $user
) {
}
public function notifyOnOwnPageChanges(): bool
{
return $this->getNotificationSetting('own-page-changes');
}
public function notifyOnOwnPageComments(): bool
{
return $this->getNotificationSetting('own-page-comments');
}
public function notifyOnCommentReplies(): bool
{
return $this->getNotificationSetting('comment-replies');
}
public function updateFromSettingsArray(array $settings)
{
$allowList = ['own-page-changes', 'own-page-comments', 'comment-replies'];
foreach ($settings as $setting => $status) {
if (!in_array($setting, $allowList)) {
continue;
}
$value = $status === 'true' ? 'true' : 'false';
setting()->putUser($this->user, 'notifications#' . $setting, $value);
}
}
protected function getNotificationSetting(string $key): bool
{
return setting()->getUser($this->user, 'notifications#' . $key);
}
}

View File

@ -13,11 +13,9 @@ use Illuminate\Http\Request;
class RoleController extends Controller
{
protected PermissionsRepo $permissionsRepo;
public function __construct(PermissionsRepo $permissionsRepo)
{
$this->permissionsRepo = $permissionsRepo;
public function __construct(
protected PermissionsRepo $permissionsRepo
) {
}
/**

View File

@ -2,18 +2,27 @@
namespace BookStack\Users\Controllers;
use BookStack\Activity\Models\Watch;
use BookStack\Http\Controller;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Settings\UserShortcutMap;
use BookStack\Users\UserRepo;
use Illuminate\Http\Request;
class UserPreferencesController extends Controller
{
protected UserRepo $userRepo;
public function __construct(
protected UserRepo $userRepo
) {
}
public function __construct(UserRepo $userRepo)
/**
* Show the overview for user preferences.
*/
public function index()
{
$this->userRepo = $userRepo;
return view('users.preferences.index');
}
/**
@ -24,6 +33,8 @@ class UserPreferencesController extends Controller
$shortcuts = UserShortcutMap::fromUserPreferences();
$enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
$this->setPageTitle(trans('preferences.shortcuts_interface'));
return view('users.preferences.shortcuts', [
'shortcuts' => $shortcuts,
'enabled' => $enabled,
@ -47,6 +58,46 @@ class UserPreferencesController extends Controller
return redirect('/preferences/shortcuts');
}
/**
* Show the notification preferences for the current user.
*/
public function showNotifications(PermissionApplicator $permissions)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();
$preferences = (new UserNotificationPreferences(user()));
$query = Watch::query()->where('user_id', '=', user()->id);
$query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
$watches = $query->with('watchable')->paginate(20);
$this->setPageTitle(trans('preferences.notifications'));
return view('users.preferences.notifications', [
'preferences' => $preferences,
'watches' => $watches,
]);
}
/**
* Update the notification preferences for the current user.
*/
public function updateNotifications(Request $request)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();
$data = $this->validate($request, [
'preferences' => ['required', 'array'],
'preferences.*' => ['required', 'string'],
]);
$preferences = (new UserNotificationPreferences(user()));
$preferences->updateFromSettingsArray($data['preferences']);
$this->showSuccessNotification(trans('preferences.notifications_update_success'));
return redirect('/preferences/notifications');
}
/**
* Update the preferred view format for a list view of the given type.
*/
@ -123,7 +174,7 @@ class UserPreferencesController extends Controller
{
$validated = $this->validate($request, [
'language' => ['required', 'string', 'max:20'],
'active' => ['required', 'bool'],
'active' => ['required', 'bool'],
]);
$currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', '');

View File

@ -88,8 +88,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* This holds the default user when loaded.
*
* @var null|User
*/
protected static ?User $defaultUser = null;
@ -107,6 +105,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return static::$defaultUser;
}
public static function clearDefault(): void
{
static::$defaultUser = null;
}
/**
* Check if the user is the default public user.
*/

View File

@ -0,0 +1,51 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// Create new receive-notifications permission and assign to admin role
$permissionId = DB::table('role_permissions')->insertGetId([
'name' => 'receive-notifications',
'display_name' => 'Receive & Manage Notifications',
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
$adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
'permission_id' => $permissionId,
]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
$permission = DB::table('role_permissions')
->where('name', '=', 'receive-notifications')
->first();
if ($permission) {
DB::table('permission_role')->where([
'permission_id' => $permission->id,
])->delete();
}
DB::table('role_permissions')
->where('name', '=', 'receive-notifications')
->delete();
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('watches', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->index();
$table->integer('watchable_id');
$table->string('watchable_type', 100);
$table->tinyInteger('level', false, true)->index();
$table->timestamps();
$table->index(['watchable_id', 'watchable_type'], 'watchable_index');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('watches');
}
};

View File

@ -27,6 +27,8 @@ class DummyContentSeeder extends Seeder
// Create an editor user
$editorUser = User::factory()->create();
$editorRole = Role::getRole('editor');
$additionalEditorPerms = ['receive-notifications', 'comment-create-all'];
$editorRole->permissions()->syncWithoutDetaching(RolePermission::whereIn('name', $additionalEditorPerms)->pluck('id'));
$editorUser->attachRole($editorRole);
// Create a viewer user

View File

@ -58,6 +58,9 @@ return [
'favourite_add_notification' => '":name" has been added to your favourites',
'favourite_remove_notification' => '":name" has been removed from your favourites',
// Watching
'watch_update_level_notification' => 'Watch preferences successfully updated',
// Auth
'auth_login' => 'logged in',
'auth_register' => 'registered as new user',

View File

@ -42,6 +42,7 @@ return [
'remove' => 'Remove',
'add' => 'Add',
'configure' => 'Configure',
'manage' => 'Manage',
'fullscreen' => 'Fullscreen',
'favourite' => 'Favourite',
'unfavourite' => 'Unfavourite',

View File

@ -403,4 +403,28 @@ return [
'references' => 'References',
'references_none' => 'There are no tracked references to this item.',
'references_to_desc' => 'Shown below are all the known pages in the system that link to this item.',
// Watch Options
'watch' => 'Watch',
'watch_title_default' => 'Default Preferences',
'watch_desc_default' => 'Revert watching to just your default notification preferences.',
'watch_title_ignore' => 'Ignore',
'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.',
'watch_title_new' => 'New Pages',
'watch_desc_new' => 'Notify when any new page is created within this item.',
'watch_title_updates' => 'All Page Updates',
'watch_desc_updates' => 'Notify upon all new pages and page changes.',
'watch_desc_updates_page' => 'Notify upon all page changes.',
'watch_title_comments' => 'All Page Updates & Comments',
'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.',
'watch_desc_comments_page' => 'Notify upon page changes and new comments.',
'watch_change_default' => 'Change default notification preferences',
'watch_detail_ignore' => 'Ignoring notifications',
'watch_detail_new' => 'Watching for new pages',
'watch_detail_updates' => 'Watching new pages and updates',
'watch_detail_comments' => 'Watching new pages, updates & comments',
'watch_detail_parent_book' => 'Watching via parent book',
'watch_detail_parent_book_ignore' => 'Ignoring via parent book',
'watch_detail_parent_chapter' => 'Watching via parent chapter',
'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter',
];

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

@ -5,6 +5,8 @@
*/
return [
'preferences' => 'Preferences',
'shortcuts' => 'Shortcuts',
'shortcuts_interface' => 'Interface Keyboard Shortcuts',
'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.',
@ -15,4 +17,17 @@ return [
'shortcuts_save' => 'Save Shortcuts',
'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.',
'shortcuts_update_success' => 'Shortcut preferences have been updated!',
];
'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.',
'notifications' => 'Notification Preferences',
'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.',
'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own',
'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own',
'notifications_opt_comment_replies' => 'Notify upon replies to my comments',
'notifications_save' => 'Save Preferences',
'notifications_update_success' => 'Notification preferences have been updated!',
'notifications_watched' => 'Watched & Ignored Items',
'notifications_watched_desc' => ' Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.',
'profile_overview_desc' => ' Manage your user profile details including preferred language and authentication options.',
];

View File

@ -163,6 +163,7 @@ return [
'role_manage_settings' => 'Manage app settings',
'role_export_content' => 'Export content',
'role_editor_change' => 'Change page editor',
'role_notifications' => 'Receive & manage notifications',
'role_asset' => 'Asset Permissions',
'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.',

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g><g><circle cx="10" cy="8" r="4"/><path d="M10.67,13.02C10.45,13.01,10.23,13,10,13c-2.42,0-4.68,0.67-6.61,1.82C2.51,15.34,2,16.32,2,17.35V20h9.26 C10.47,18.87,10,17.49,10,16C10,14.93,10.25,13.93,10.67,13.02z"/><path d="M20.75,16c0-0.22-0.03-0.42-0.06-0.63l1.14-1.01l-1-1.73l-1.45,0.49c-0.32-0.27-0.68-0.48-1.08-0.63L18,11h-2l-0.3,1.49 c-0.4,0.15-0.76,0.36-1.08,0.63l-1.45-0.49l-1,1.73l1.14,1.01c-0.03,0.21-0.06,0.41-0.06,0.63s0.03,0.42,0.06,0.63l-1.14,1.01 l1,1.73l1.45-0.49c0.32,0.27,0.68,0.48,1.08,0.63L16,21h2l0.3-1.49c0.4-0.15,0.76-0.36,1.08-0.63l1.45,0.49l1-1.73l-1.14-1.01 C20.72,16.42,20.75,16.22,20.75,16z M17,18c-1.1,0-2-0.9-2-2s0.9-2,2-2s2,0.9,2,2S18.1,18,17,18z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 752 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>

After

Width:  |  Height:  |  Size: 583 B

View File

@ -1,4 +1,3 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>

Before

Width:  |  Height:  |  Size: 335 B

After

Width:  |  Height:  |  Size: 293 B

View File

@ -132,6 +132,7 @@ export class Dropdown extends Component {
onSelect(this.toggle, event => {
event.stopPropagation();
event.preventDefault();
this.show(event);
if (event instanceof KeyboardEvent) {
keyboardNavHandler.focusNext();

View File

@ -353,7 +353,7 @@ body.flexbox {
margin-inline-end: $-xl;
grid-template-columns: 1fr 4fr 1fr;
grid-template-areas: "a b c";
grid-column-gap: $-xxl;
grid-column-gap: $-xl;
.tri-layout-right {
grid-area: c;
min-width: 0;
@ -378,6 +378,14 @@ body.flexbox {
padding-inline-end: $-l;
}
}
@include between($xxl, $xxxl) {
.tri-layout-container {
grid-template-columns: 1fr calc(940px + (2 * $-m)) 1fr;
grid-column-gap: $-s;
margin-inline-start: $-m;
margin-inline-end: $-m;
}
}
@include between($l, $xxl) {
.tri-layout-left {
position: sticky;

View File

@ -672,7 +672,7 @@ ul.pagination {
@include lightDark(color, #555, #eee);
fill: currentColor;
text-align: start !important;
max-height: 500px;
max-height: 80vh;
overflow-y: auto;
&.anchor-left {
inset-inline-end: auto;
@ -681,6 +681,10 @@ ul.pagination {
&.wide {
min-width: 220px;
}
&.xl-limited {
width: 280px;
max-width: 100%;
}
.text-muted {
color: #999;
fill: #999;
@ -705,6 +709,11 @@ ul.pagination {
white-space: nowrap;
line-height: 1.4;
cursor: pointer;
&.break-text {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
&:hover, &:focus {
text-decoration: none;
background-color: var(--color-primary-light);

View File

@ -365,6 +365,7 @@ li.checkbox-item, li.task-list-item {
}
.break-text {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}

View File

@ -2,6 +2,7 @@
///////////////
// Screen breakpoints
$xxxl: 1700px;
$xxl: 1400px;
$xl: 1100px;
$l: 1000px;

View File

@ -70,7 +70,7 @@
<div class="mb-xl">
<h5>{{ trans('common.details') }}</h5>
<div class="blended-links">
@include('entities.meta', ['entity' => $book])
@include('entities.meta', ['entity' => $book, 'watchOptions' => $watchOptions])
@if($book->hasPermissions())
<div class="active-restriction">
@if(userCan('restrictions-manage', $book))
@ -139,6 +139,9 @@
<hr class="primary-background">
@if($watchOptions->canWatch() && !$watchOptions->isWatching())
@include('entities.watch-action', ['entity' => $book])
@endif
@if(signedInUser())
@include('entities.favourite-action', ['entity' => $book])
@endif

View File

@ -67,7 +67,7 @@
<div class="mb-xl">
<h5>{{ trans('common.details') }}</h5>
<div class="blended-links">
@include('entities.meta', ['entity' => $chapter])
@include('entities.meta', ['entity' => $chapter, 'watchOptions' => $watchOptions])
@if($book->hasPermissions())
<div class="active-restriction">
@ -157,6 +157,9 @@
<hr class="primary-background"/>
@if($watchOptions->canWatch() && !$watchOptions->isWatching())
@include('entities.watch-action', ['entity' => $chapter])
@endif
@if(signedInUser())
@include('entities.favourite-action', ['entity' => $chapter])
@endif

View File

@ -104,9 +104,9 @@
</li>
<li><hr></li>
<li>
<a href="{{ url('/preferences/shortcuts') }}" class="icon-item">
@icon('shortcuts')
<div>{{ trans('preferences.shortcuts') }}</div>
<a href="{{ url('/preferences') }}" class="icon-item">
@icon('user-preferences')
<div>{{ trans('preferences.preferences') }}</div>
</a>
</li>
<li>

View File

@ -0,0 +1,7 @@
<a href="{{ $entity->getUrl() }}" class="flex-container-row items-center">
<span role="presentation"
class="icon flex-none text-{{$entity->getType()}}">@icon($entity->getType())</span>
<div class="flex text-{{ $entity->getType() }}">
{{ $entity->name }}
</div>
</a>

View File

@ -68,4 +68,22 @@
</div>
</a>
@endif
@if($watchOptions?->canWatch())
@if($watchOptions->isWatching())
@include('entities.watch-controls', [
'entity' => $entity,
'watchLevel' => $watchOptions->getWatchLevel(),
'label' => trans('entities.watch_detail_' . $watchOptions->getWatchLevel()),
'ignoring' => $watchOptions->getWatchLevel() === 'ignore',
])
@elseif($watchedParent = $watchOptions->getWatchedParent())
@include('entities.watch-controls', [
'entity' => $entity,
'watchLevel' => $watchOptions->getWatchLevel(),
'label' => trans('entities.watch_detail_parent_' . $watchedParent->type . ($watchedParent->ignoring() ? '_ignore' : '')),
'ignoring' => $watchedParent->ignoring(),
])
@endif
@endif
</div>

View File

@ -0,0 +1,13 @@
<form action="{{ url('/watching/update') }}" method="POST">
{{ csrf_field() }}
{{ method_field('PUT') }}
<input type="hidden" name="type" value="{{ get_class($entity) }}">
<input type="hidden" name="id" value="{{ $entity->id }}">
<button type="submit"
name="level"
value="updates"
class="icon-list-item text-link">
<span>@icon('watch')</span>
<span>{{ trans('entities.watch') }}</span>
</button>
</form>

View File

@ -0,0 +1,46 @@
<div component="dropdown"
class="dropdown-container block my-xxs">
<a refs="dropdown@toggle" href="#" class="entity-meta-item my-none">
@icon(($ignoring ? 'watch-ignore' : 'watch'))
<span>{{ $label }}</span>
</a>
<form action="{{ url('/watching/update') }}" method="POST">
{{ method_field('PUT') }}
{{ csrf_field() }}
<input type="hidden" name="type" value="{{ get_class($entity) }}">
<input type="hidden" name="id" value="{{ $entity->id }}">
<ul refs="dropdown@menu" class="dropdown-menu xl-limited anchor-left pb-none">
@foreach(\BookStack\Activity\WatchLevels::allSuitedFor($entity) as $option => $value)
<li>
<button name="level" value="{{ $option }}" class="icon-item">
@if($watchLevel === $option)
<span class="text-pos pt-m"
title="{{ trans('common.status_active') }}">@icon('check-circle')</span>
@else
<span title="{{ trans('common.status_inactive') }}"></span>
@endif
<div class="break-text">
<div class="mb-xxs"><strong>{{ trans('entities.watch_title_' . $option) }}</strong></div>
<div class="text-muted text-small">
@if(trans()->has('entities.watch_desc_' . $option . '_' . $entity->getMorphClass()))
{{ trans('entities.watch_desc_' . $option . '_' . $entity->getMorphClass()) }}
@else
{{ trans('entities.watch_desc_' . $option) }}
@endif
</div>
</div>
</button>
</li>
<li>
<hr class="my-none">
</li>
@endforeach
<li>
<a href="{{ url('/preferences/notifications') }}"
target="_blank"
class="text-item text-muted text-small break-text">{{ trans('entities.watch_change_default') }}</a>
</li>
</ul>
</form>
</div>

View File

@ -4,7 +4,7 @@
<div id="revision-details" class="entity-details mb-xl">
<h5>{{ trans('common.details') }}</h5>
<div class="body text-small text-muted">
@include('entities.meta', ['entity' => $revision])
@include('entities.meta', ['entity' => $revision, 'watchOptions' => null])
</div>
</div>
@stop

View File

@ -81,7 +81,7 @@
<div id="page-details" class="entity-details mb-xl">
<h5>{{ trans('common.details') }}</h5>
<div class="blended-links">
@include('entities.meta', ['entity' => $page])
@include('entities.meta', ['entity' => $page, 'watchOptions' => $watchOptions])
@if($book->hasPermissions())
<div class="active-restriction">
@ -185,6 +185,9 @@
<hr class="primary-background"/>
@if($watchOptions->canWatch() && !$watchOptions->isWatching())
@include('entities.watch-action', ['entity' => $page])
@endif
@if(signedInUser())
@include('entities.favourite-action', ['entity' => $page])
@endif

View File

@ -95,13 +95,7 @@
:</strong> {{ $activity->type }}</div>
<div class="flex-3 px-m py-xxs min-width-l">
@if($activity->entity)
<a href="{{ $activity->entity->getUrl() }}" class="flex-container-row items-center">
<span role="presentation"
class="icon flex-none text-{{$activity->entity->getType()}}">@icon($activity->entity->getType())</span>
<div class="flex text-{{ $activity->entity->getType() }}">
{{ $activity->entity->name }}
</div>
</a>
@include('entities.icon-link', ['entity' => $activity->entity])
@elseif($activity->detail && $activity->isForEntity())
<div>
{{ trans('settings.audit_deleted_item') }} <br>

View File

@ -38,6 +38,7 @@
<div>@include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div>
<div>@include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])</div>
<div>@include('settings.roles.parts.checkbox', ['permission' => 'editor-change', 'label' => trans('settings.role_editor_change')])</div>
<div>@include('settings.roles.parts.checkbox', ['permission' => 'receive-notifications', 'label' => trans('settings.role_notifications')])</div>
</div>
<div>
<div>@include('settings.roles.parts.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div>

View File

@ -79,7 +79,7 @@
<div id="details" class="mb-xl">
<h5>{{ trans('common.details') }}</h5>
<div class="blended-links">
@include('entities.meta', ['entity' => $shelf])
@include('entities.meta', ['entity' => $shelf, 'watchOptions' => null])
@if($shelf->hasPermissions())
<div class="active-restriction">
@if(userCan('restrictions-manage', $shelf))

View File

@ -0,0 +1,41 @@
@extends('layouts.simple')
@section('body')
<div class="container small my-xl">
<section class="card content-wrap auto-height items-center justify-space-between gap-m flex-container-row">
<div>
<h2 class="list-heading">{{ trans('preferences.shortcuts_interface') }}</h2>
<p class="text-muted">{{ trans('preferences.shortcuts_overview_desc') }}</p>
</div>
<div class="text-right">
<a href="{{ url('/preferences/shortcuts') }}" class="button outline">{{ trans('common.manage') }}</a>
</div>
</section>
@if(signedInUser() && userCan('receive-notifications'))
<section class="card content-wrap auto-height items-center justify-space-between gap-m flex-container-row">
<div>
<h2 class="list-heading">{{ trans('preferences.notifications') }}</h2>
<p class="text-muted">{{ trans('preferences.notifications_desc') }}</p>
</div>
<div class="text-right">
<a href="{{ url('/preferences/notifications') }}" class="button outline">{{ trans('common.manage') }}</a>
</div>
</section>
@endif
@if(signedInUser())
<section class="card content-wrap auto-height items-center justify-space-between gap-m flex-container-row">
<div>
<h2 class="list-heading">{{ trans('settings.users_edit_profile') }}</h2>
<p class="text-muted">{{ trans('preferences.profile_overview_desc') }}</p>
</div>
<div class="text-right">
<a href="{{ user()->getEditUrl() }}" class="button outline">{{ trans('common.manage') }}</a>
</div>
</section>
@endif
</div>
@stop

View File

@ -0,0 +1,73 @@
@extends('layouts.simple')
@section('body')
<div class="container small my-xl">
<section class="card content-wrap auto-height">
<form action="{{ url('/preferences/notifications') }}" method="post">
{{ method_field('put') }}
{{ csrf_field() }}
<h1 class="list-heading">{{ trans('preferences.notifications') }}</h1>
<p class="text-small text-muted">{{ trans('preferences.notifications_desc') }}</p>
<div class="flex-container-row wrap justify-space-between pb-m">
<div class="toggle-switch-list min-width-l">
<div>
@include('form.toggle-switch', [
'name' => 'preferences[own-page-changes]',
'value' => $preferences->notifyOnOwnPageChanges(),
'label' => trans('preferences.notifications_opt_own_page_changes'),
])
</div>
<div>
@include('form.toggle-switch', [
'name' => 'preferences[own-page-comments]',
'value' => $preferences->notifyOnOwnPageComments(),
'label' => trans('preferences.notifications_opt_own_page_comments'),
])
</div>
<div>
@include('form.toggle-switch', [
'name' => 'preferences[comment-replies]',
'value' => $preferences->notifyOnCommentReplies(),
'label' => trans('preferences.notifications_opt_comment_replies'),
])
</div>
</div>
<div class="mt-auto">
<button class="button">{{ trans('preferences.notifications_save') }}</button>
</div>
</div>
</form>
</section>
<section class="card content-wrap auto-height">
<h2 class="list-heading">{{ trans('preferences.notifications_watched') }}</h2>
<p class="text-small text-muted">{{ trans('preferences.notifications_watched_desc') }}</p>
@if($watches->isEmpty())
<p class="text-muted italic">{{ trans('common.no_items') }}</p>
@else
<div class="item-list">
@foreach($watches as $watch)
<div class="flex-container-row justify-space-between item-list-row items-center wrap px-m py-s">
<div class="py-xs px-s min-width-m">
@include('entities.icon-link', ['entity' => $watch->watchable])
</div>
<div class="py-xs min-width-m text-m-right px-m">
@icon('watch' . ($watch->ignoring() ? '-ignore' : ''))
{{ trans('entities.watch_title_' . $watch->getLevelName()) }}
</div>
</div>
@endforeach
</div>
@endif
<div class="my-m">{{ $watches->links() }}</div>
</section>
</div>
@stop

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

View File

@ -194,6 +194,9 @@ Route::middleware('auth')->group(function () {
Route::post('/favourites/add', [ActivityControllers\FavouriteController::class, 'add']);
Route::post('/favourites/remove', [ActivityControllers\FavouriteController::class, 'remove']);
// Watching
Route::put('/watching/update', [ActivityControllers\WatchController::class, 'update']);
// Other Pages
Route::get('/', [HomeController::class, 'index']);
Route::get('/home', [HomeController::class, 'index']);
@ -228,9 +231,11 @@ Route::middleware('auth')->group(function () {
Route::delete('/settings/users/{id}', [UserControllers\UserController::class, 'destroy']);
// User Preferences
Route::redirect('/preferences', '/');
Route::get('/preferences', [UserControllers\UserPreferencesController::class, 'index']);
Route::get('/preferences/shortcuts', [UserControllers\UserPreferencesController::class, 'showShortcuts']);
Route::put('/preferences/shortcuts', [UserControllers\UserPreferencesController::class, 'updateShortcuts']);
Route::get('/preferences/notifications', [UserControllers\UserPreferencesController::class, 'showNotifications']);
Route::put('/preferences/notifications', [UserControllers\UserPreferencesController::class, 'updateNotifications']);
Route::patch('/preferences/change-view/{type}', [UserControllers\UserPreferencesController::class, 'changeView']);
Route::patch('/preferences/change-sort/{type}', [UserControllers\UserPreferencesController::class, 'changeSort']);
Route::patch('/preferences/change-expansion/{type}', [UserControllers\UserPreferencesController::class, 'changeExpansion']);

View File

@ -0,0 +1,332 @@
<?php
namespace Tests\Activity;
use BookStack\Activity\Notifications\Messages\CommentCreationNotification;
use BookStack\Activity\Notifications\Messages\PageCreationNotification;
use BookStack\Activity\Notifications\Messages\PageUpdateNotification;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Entity;
use BookStack\Settings\UserNotificationPreferences;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class WatchTest extends TestCase
{
public function test_watch_action_exists_on_entity_unless_active()
{
$editor = $this->users->editor();
$this->actingAs($editor);
$entities = [$this->entities->book(), $this->entities->chapter(), $this->entities->page()];
/** @var Entity $entity */
foreach ($entities as $entity) {
$resp = $this->get($entity->getUrl());
$this->withHtml($resp)->assertElementContains('form[action$="/watching/update"] button.icon-list-item', 'Watch');
$watchOptions = new UserEntityWatchOptions($editor, $entity);
$watchOptions->updateLevelByValue(WatchLevels::COMMENTS);
$resp = $this->get($entity->getUrl());
$this->withHtml($resp)->assertElementNotExists('form[action$="/watching/update"] button.icon-list-item');
}
}
public function test_watch_action_only_shows_with_permission()
{
$viewer = $this->users->viewer();
$this->actingAs($viewer);
$entities = [$this->entities->book(), $this->entities->chapter(), $this->entities->page()];
/** @var Entity $entity */
foreach ($entities as $entity) {
$resp = $this->get($entity->getUrl());
$this->withHtml($resp)->assertElementNotExists('form[action$="/watching/update"] button.icon-list-item');
}
$this->permissions->grantUserRolePermissions($viewer, ['receive-notifications']);
/** @var Entity $entity */
foreach ($entities as $entity) {
$resp = $this->get($entity->getUrl());
$this->withHtml($resp)->assertElementExists('form[action$="/watching/update"] button.icon-list-item');
}
}
public function test_watch_update()
{
$editor = $this->users->editor();
$book = $this->entities->book();
$this->actingAs($editor)->get($book->getUrl());
$resp = $this->put('/watching/update', [
'type' => get_class($book),
'id' => $book->id,
'level' => 'comments'
]);
$resp->assertRedirect($book->getUrl());
$this->assertSessionHas('success');
$this->assertDatabaseHas('watches', [
'watchable_id' => $book->id,
'watchable_type' => $book->getMorphClass(),
'user_id' => $editor->id,
'level' => WatchLevels::COMMENTS,
]);
$resp = $this->put('/watching/update', [
'type' => get_class($book),
'id' => $book->id,
'level' => 'default'
]);
$resp->assertRedirect($book->getUrl());
$this->assertDatabaseMissing('watches', [
'watchable_id' => $book->id,
'watchable_type' => $book->getMorphClass(),
'user_id' => $editor->id,
]);
}
public function test_watch_update_fails_for_guest()
{
$this->setSettings(['app-public' => 'true']);
$guest = $this->users->guest();
$this->permissions->grantUserRolePermissions($guest, ['receive-notifications']);
$book = $this->entities->book();
$resp = $this->put('/watching/update', [
'type' => get_class($book),
'id' => $book->id,
'level' => 'comments'
]);
$this->assertPermissionError($resp);
$guest->unsetRelations();
}
public function test_watch_detail_display_reflects_state()
{
$editor = $this->users->editor();
$book = $this->entities->bookHasChaptersAndPages();
$chapter = $book->chapters()->first();
$page = $chapter->pages()->first();
(new UserEntityWatchOptions($editor, $book))->updateLevelByValue(WatchLevels::UPDATES);
$this->actingAs($editor)->get($book->getUrl())->assertSee('Watching new pages and updates');
$this->get($chapter->getUrl())->assertSee('Watching via parent book');
$this->get($page->getUrl())->assertSee('Watching via parent book');
(new UserEntityWatchOptions($editor, $chapter))->updateLevelByValue(WatchLevels::COMMENTS);
$this->get($chapter->getUrl())->assertSee('Watching new pages, updates & comments');
$this->get($page->getUrl())->assertSee('Watching via parent chapter');
(new UserEntityWatchOptions($editor, $page))->updateLevelByValue(WatchLevels::UPDATES);
$this->get($page->getUrl())->assertSee('Watching new pages and updates');
}
public function test_watch_detail_ignore_indicator_cascades()
{
$editor = $this->users->editor();
$book = $this->entities->bookHasChaptersAndPages();
(new UserEntityWatchOptions($editor, $book))->updateLevelByValue(WatchLevels::IGNORE);
$this->actingAs($editor)->get($book->getUrl())->assertSee('Ignoring notifications');
$this->get($book->chapters()->first()->getUrl())->assertSee('Ignoring via parent book');
$this->get($book->pages()->first()->getUrl())->assertSee('Ignoring via parent book');
}
public function test_watch_option_menu_shows_current_active_state()
{
$editor = $this->users->editor();
$book = $this->entities->book();
$options = new UserEntityWatchOptions($editor, $book);
$respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl()));
$respHtml->assertElementNotExists('form[action$="/watching/update"] svg[data-icon="check-circle"]');
$options->updateLevelByValue(WatchLevels::COMMENTS);
$respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl()));
$respHtml->assertElementExists('form[action$="/watching/update"] button[value="comments"] svg[data-icon="check-circle"]');
$options->updateLevelByValue(WatchLevels::IGNORE);
$respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl()));
$respHtml->assertElementExists('form[action$="/watching/update"] button[value="ignore"] svg[data-icon="check-circle"]');
}
public function test_watch_option_menu_limits_options_for_pages()
{
$editor = $this->users->editor();
$book = $this->entities->bookHasChaptersAndPages();
(new UserEntityWatchOptions($editor, $book))->updateLevelByValue(WatchLevels::IGNORE);
$respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl()));
$respHtml->assertElementExists('form[action$="/watching/update"] button[name="level"][value="new"]');
$respHtml = $this->withHtml($this->get($book->pages()->first()->getUrl()));
$respHtml->assertElementExists('form[action$="/watching/update"] button[name="level"][value="updates"]');
$respHtml->assertElementNotExists('form[action$="/watching/update"] button[name="level"][value="new"]');
}
public function test_notify_own_page_changes()
{
$editor = $this->users->editor();
$entities = $this->entities->createChainBelongingToUser($editor);
$prefs = new UserNotificationPreferences($editor);
$prefs->updateFromSettingsArray(['own-page-changes' => 'true']);
$notifications = Notification::fake();
$this->asAdmin();
$this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']);
$notifications->assertSentTo($editor, PageUpdateNotification::class);
}
public function test_notify_own_page_comments()
{
$editor = $this->users->editor();
$entities = $this->entities->createChainBelongingToUser($editor);
$prefs = new UserNotificationPreferences($editor);
$prefs->updateFromSettingsArray(['own-page-comments' => 'true']);
$notifications = Notification::fake();
$this->asAdmin()->post("/comment/{$entities['page']->id}", [
'text' => 'My new comment'
]);
$notifications->assertSentTo($editor, CommentCreationNotification::class);
}
public function test_notify_comment_replies()
{
$editor = $this->users->editor();
$entities = $this->entities->createChainBelongingToUser($editor);
$prefs = new UserNotificationPreferences($editor);
$prefs->updateFromSettingsArray(['comment-replies' => 'true']);
$notifications = Notification::fake();
$this->actingAs($editor)->post("/comment/{$entities['page']->id}", [
'text' => 'My new comment'
]);
$comment = $entities['page']->comments()->first();
$this->asAdmin()->post("/comment/{$entities['page']->id}", [
'text' => 'My new comment response',
'parent_id' => $comment->id,
]);
$notifications->assertSentTo($editor, CommentCreationNotification::class);
}
public function test_notify_watch_parent_book_ignore()
{
$editor = $this->users->editor();
$entities = $this->entities->createChainBelongingToUser($editor);
$watches = new UserEntityWatchOptions($editor, $entities['book']);
$prefs = new UserNotificationPreferences($editor);
$watches->updateLevelByValue(WatchLevels::IGNORE);
$prefs->updateFromSettingsArray(['own-page-changes' => 'true', 'own-page-comments' => true]);
$notifications = Notification::fake();
$this->asAdmin()->post("/comment/{$entities['page']->id}", [
'text' => 'My new comment response',
]);
$this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']);
$notifications->assertNothingSent();
}
public function test_notify_watch_parent_book_comments()
{
$notifications = Notification::fake();
$editor = $this->users->editor();
$admin = $this->users->admin();
$entities = $this->entities->createChainBelongingToUser($editor);
$watches = new UserEntityWatchOptions($editor, $entities['book']);
$watches->updateLevelByValue(WatchLevels::COMMENTS);
// Comment post
$this->actingAs($admin)->post("/comment/{$entities['page']->id}", [
'text' => 'My new comment response',
]);
$notifications->assertSentTo($editor, function (CommentCreationNotification $notification) use ($editor, $admin, $entities) {
$mail = $notification->toMail($editor);
$mailContent = html_entity_decode(strip_tags($mail->render()));
return $mail->subject === 'New comment on page: ' . $entities['page']->getShortName()
&& str_contains($mailContent, 'View Comment')
&& str_contains($mailContent, 'Page Name: ' . $entities['page']->name)
&& str_contains($mailContent, 'Commenter: ' . $admin->name)
&& str_contains($mailContent, 'Comment: My new comment response');
});
}
public function test_notify_watch_parent_book_updates()
{
$notifications = Notification::fake();
$editor = $this->users->editor();
$admin = $this->users->admin();
$entities = $this->entities->createChainBelongingToUser($editor);
$watches = new UserEntityWatchOptions($editor, $entities['book']);
$watches->updateLevelByValue(WatchLevels::UPDATES);
$this->actingAs($admin);
$this->entities->updatePage($entities['page'], ['name' => 'Updated page', 'html' => 'new page content']);
$notifications->assertSentTo($editor, function (PageUpdateNotification $notification) use ($editor, $admin) {
$mail = $notification->toMail($editor);
$mailContent = html_entity_decode(strip_tags($mail->render()));
return $mail->subject === 'Updated page: Updated page'
&& str_contains($mailContent, 'View Page')
&& str_contains($mailContent, 'Page Name: Updated page')
&& str_contains($mailContent, 'Updated By: ' . $admin->name)
&& str_contains($mailContent, 'you won\'t be sent notifications for further edits to this page by the same editor');
});
// Test debounce
$notifications = Notification::fake();
$this->entities->updatePage($entities['page'], ['name' => 'Updated page', 'html' => 'new page content']);
$notifications->assertNothingSentTo($editor);
}
public function test_notify_watch_parent_book_new()
{
$notifications = Notification::fake();
$editor = $this->users->editor();
$admin = $this->users->admin();
$entities = $this->entities->createChainBelongingToUser($editor);
$watches = new UserEntityWatchOptions($editor, $entities['book']);
$watches->updateLevelByValue(WatchLevels::NEW);
$this->actingAs($admin)->get($entities['chapter']->getUrl('/create-page'));
$page = $entities['chapter']->pages()->where('draft', '=', true)->first();
$this->post($page->getUrl(), ['name' => 'My new page', 'html' => 'My new page content']);
$notifications->assertSentTo($editor, function (PageCreationNotification $notification) use ($editor, $admin) {
$mail = $notification->toMail($editor);
$mailContent = html_entity_decode(strip_tags($mail->render()));
return $mail->subject === 'New page: My new page'
&& str_contains($mailContent, 'View Page')
&& str_contains($mailContent, 'Page Name: My new page')
&& str_contains($mailContent, 'Created By: ' . $admin->name);
});
}
public function test_notifications_not_sent_if_lacking_view_permission_for_related_item()
{
$notifications = Notification::fake();
$editor = $this->users->editor();
$page = $this->entities->page();
$watches = new UserEntityWatchOptions($editor, $page);
$watches->updateLevelByValue(WatchLevels::COMMENTS);
$this->permissions->disableEntityInheritedPermissions($page);
$this->asAdmin()->post("/comment/{$page->id}", [
'text' => 'My new comment response',
])->assertOk();
$notifications->assertNothingSentTo($editor);
}
}

View File

@ -50,6 +50,14 @@ class UserRoleProvider
return $user;
}
/**
* Get the system "guest" user.
*/
public function guest(): User
{
return User::getDefault();
}
/**
* Create a new fresh user without any relations.
*/

View File

@ -5,6 +5,7 @@ namespace Tests;
use BookStack\Entities\Models\Entity;
use BookStack\Settings\SettingService;
use BookStack\Uploads\HttpFetcher;
use BookStack\Users\Models\User;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
@ -46,6 +47,7 @@ abstract class TestCase extends BaseTestCase
$this->permissions = new PermissionsProvider($this->users);
$this->files = new FileProvider();
User::clearDefault();
parent::setUp();
// We can uncomment the below to run tests with failings upon deprecations.

View File

@ -2,10 +2,30 @@
namespace Tests\User;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Activity\WatchLevels;
use Tests\TestCase;
class UserPreferencesTest extends TestCase
{
public function test_index_view()
{
$resp = $this->asEditor()->get('/preferences');
$resp->assertOk();
$resp->assertSee('Interface Keyboard Shortcuts');
$resp->assertSee('Edit Profile');
}
public function test_index_view_accessible_but_without_profile_and_notifications_for_guest_user()
{
$this->setSettings(['app-public' => 'true']);
$this->permissions->grantUserRolePermissions($this->users->guest(), ['receive-notifications']);
$resp = $this->get('/preferences');
$resp->assertOk();
$resp->assertSee('Interface Keyboard Shortcuts');
$resp->assertDontSee('Edit Profile');
$resp->assertDontSee('Notification');
}
public function test_interface_shortcuts_updating()
{
$this->asEditor();
@ -45,6 +65,80 @@ class UserPreferencesTest extends TestCase
$this->withHtml($this->get('/'))->assertElementExists('body[component="shortcuts"]');
}
public function test_notification_routes_requires_notification_permission()
{
$viewer = $this->users->viewer();
$resp = $this->actingAs($viewer)->get('/preferences/notifications');
$this->assertPermissionError($resp);
$resp = $this->put('/preferences/notifications');
$this->assertPermissionError($resp);
$this->permissions->grantUserRolePermissions($viewer, ['receive-notifications']);
$resp = $this->get('/preferences/notifications');
$resp->assertOk();
$resp->assertSee('Notification Preferences');
}
public function test_notification_preferences_updating()
{
$editor = $this->users->editor();
// View preferences with defaults
$resp = $this->actingAs($editor)->get('/preferences/notifications');
$resp->assertSee('Notification Preferences');
$html = $this->withHtml($resp);
$html->assertFieldHasValue('preferences[comment-replies]', 'false');
// Update preferences
$resp = $this->put('/preferences/notifications', [
'preferences' => ['comment-replies' => 'true'],
]);
$resp->assertRedirect('/preferences/notifications');
$resp->assertSessionHas('success', 'Notification preferences have been updated!');
// View updates to preferences page
$resp = $this->get('/preferences/notifications');
$html = $this->withHtml($resp);
$html->assertFieldHasValue('preferences[comment-replies]', 'true');
}
public function test_notification_preferences_show_watches()
{
$editor = $this->users->editor();
$book = $this->entities->book();
$options = new UserEntityWatchOptions($editor, $book);
$options->updateLevelByValue(WatchLevels::COMMENTS);
$resp = $this->actingAs($editor)->get('/preferences/notifications');
$resp->assertSee($book->name);
$resp->assertSee('All Page Updates & Comments');
$options->updateLevelByValue(WatchLevels::DEFAULT);
$resp = $this->actingAs($editor)->get('/preferences/notifications');
$resp->assertDontSee($book->name);
$resp->assertDontSee('All Page Updates & Comments');
}
public function test_notification_preferences_not_accessible_to_guest()
{
$this->setSettings(['app-public' => 'true']);
$guest = $this->users->guest();
$this->permissions->grantUserRolePermissions($guest, ['receive-notifications']);
$resp = $this->get('/preferences/notifications');
$this->assertPermissionError($resp);
$resp = $this->put('/preferences/notifications', [
'preferences' => ['comment-replies' => 'true'],
]);
$this->assertPermissionError($resp);
}
public function test_update_sort_preference()
{
$editor = $this->users->editor();