mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Merge pull request #4390 from BookStackApp/content_notifications
Content user notifications
This commit is contained in:
commit
fef433a9cb
65
app/Activity/Controllers/WatchController.php
Normal file
65
app/Activity/Controllers/WatchController.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
45
app/Activity/Models/Watch.php
Normal file
45
app/Activity/Models/Watch.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
17
app/Activity/Notifications/Handlers/NotificationHandler.php
Normal file
17
app/Activity/Notifications/Handlers/NotificationHandler.php
Normal 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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
26
app/Activity/Notifications/MessageParts/ListMessageLine.php
Normal file
26
app/Activity/Notifications/MessageParts/ListMessageLine.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
52
app/Activity/Notifications/NotificationManager.php
Normal file
52
app/Activity/Notifications/NotificationManager.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
|
86
app/Activity/Tools/EntityWatchers.php
Normal file
86
app/Activity/Tools/EntityWatchers.php
Normal 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();
|
||||
}
|
||||
}
|
131
app/Activity/Tools/UserEntityWatchOptions.php
Normal file
131
app/Activity/Tools/UserEntityWatchOptions.php
Normal 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);
|
||||
}
|
||||
}
|
19
app/Activity/Tools/WatchedParentDetails.php
Normal file
19
app/Activity/Tools/WatchedParentDetails.php
Normal 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;
|
||||
}
|
||||
}
|
91
app/Activity/WatchLevels.php
Normal file
91
app/Activity/WatchLevels.php
Normal 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';
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
]);
|
||||
|
@ -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(),
|
||||
|
@ -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),
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
46
app/Settings/UserNotificationPreferences.php
Normal file
46
app/Settings/UserNotificationPreferences.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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', '');
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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();
|
||||
}
|
||||
};
|
@ -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');
|
||||
}
|
||||
};
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -42,6 +42,7 @@ return [
|
||||
'remove' => 'Remove',
|
||||
'add' => 'Add',
|
||||
'configure' => 'Configure',
|
||||
'manage' => 'Manage',
|
||||
'fullscreen' => 'Fullscreen',
|
||||
'favourite' => 'Favourite',
|
||||
'unfavourite' => 'Unfavourite',
|
||||
|
@ -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
26
lang/en/notifications.php
Normal 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',
|
||||
];
|
@ -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.',
|
||||
];
|
||||
|
@ -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.',
|
||||
|
1
resources/icons/user-preferences.svg
Normal file
1
resources/icons/user-preferences.svg
Normal 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 |
1
resources/icons/watch-ignore.svg
Normal file
1
resources/icons/watch-ignore.svg
Normal 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 |
@ -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 |
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -365,6 +365,7 @@ li.checkbox-item, li.task-list-item {
|
||||
}
|
||||
|
||||
.break-text {
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
///////////////
|
||||
|
||||
// Screen breakpoints
|
||||
$xxxl: 1700px;
|
||||
$xxl: 1400px;
|
||||
$xl: 1100px;
|
||||
$l: 1000px;
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
7
resources/views/entities/icon-link.blade.php
Normal file
7
resources/views/entities/icon-link.blade.php
Normal 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>
|
@ -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>
|
13
resources/views/entities/watch-action.blade.php
Normal file
13
resources/views/entities/watch-action.blade.php
Normal 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>
|
46
resources/views/entities/watch-controls.blade.php
Normal file
46
resources/views/entities/watch-controls.blade.php
Normal 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>
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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))
|
||||
|
41
resources/views/users/preferences/index.blade.php
Normal file
41
resources/views/users/preferences/index.blade.php
Normal 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
|
73
resources/views/users/preferences/notifications.blade.php
Normal file
73
resources/views/users/preferences/notifications.blade.php
Normal 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
|
@ -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
|
||||
|
@ -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']);
|
||||
|
332
tests/Activity/WatchTest.php
Normal file
332
tests/Activity/WatchTest.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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.
|
||||
*/
|
||||
|
@ -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.
|
||||
|
@ -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();
|
||||
|
Loading…
Reference in New Issue
Block a user