diff --git a/app/Enums/CommentStateEnum.php b/app/Enums/CommentStateEnum.php new file mode 100644 index 00000000..a4acfc11 --- /dev/null +++ b/app/Enums/CommentStateEnum.php @@ -0,0 +1,14 @@ + 'Blur', + self::Comment => 'Comment', + self::Edit => 'Edit', + self::Remove => 'Remove', + self::Replace => 'Replace', + self::Wrap => 'Wrap', + }; + } +} diff --git a/app/Livewire/Comments/CommentBlur.php b/app/Livewire/Comments/CommentBlur.php new file mode 100644 index 00000000..0ecaffd7 --- /dev/null +++ b/app/Livewire/Comments/CommentBlur.php @@ -0,0 +1,31 @@ +moderatorCommentsByType?->get(ModerationTypeEnum::Blur->value); + + return view('livewire.comments.comment-blur', [ + 'comment' => $this->comment, + 'childComments' => $this->childComments, + 'blurMessage' => $moderatorComment?->body ?? '', + ]); + } +} diff --git a/app/Livewire/Comments/CommentComponent.php b/app/Livewire/Comments/CommentComponent.php index cf65ed09..3f78da63 100644 --- a/app/Livewire/Comments/CommentComponent.php +++ b/app/Livewire/Comments/CommentComponent.php @@ -4,14 +4,14 @@ namespace App\Livewire\Comments; +use App\Enums\CommentStateEnum; use App\Enums\LivewireEventEnum; +use App\Enums\ModerationTypeEnum; use App\Models\Comment; -use App\Models\Flag; -use App\Models\Post; -use App\Models\User; use App\Traits\CommentComponentTrait; use Illuminate\Contracts\View\View; -use Illuminate\Support\Facades\DB; +use Illuminate\Support\Collection; +use Livewire\Attributes\Computed; use Livewire\Attributes\On; use Livewire\Component; @@ -19,106 +19,108 @@ final class CommentComponent extends Component { use CommentComponentTrait; - // Data - public ?int $authorizedUserId; - public string $body = ''; - public ?int $parentId = null; - public int $flagCount = 0; - public string $flagIconFilename = 'flag'; - public string $flagButtonText = ''; - public bool $userFlagged = false; - // State - public bool $isEditing = false; - public bool $isFlagging = false; - public bool $isReplying = false; - - public Comment $comment; - public CommentForm $commentForm; - public Post $post; - public ?User $user; + public CommentStateEnum $state = CommentStateEnum::Viewing; - public function mount(Comment $comment, Post $post): void + #[Computed] + public function isInitiallyBlurred(): bool { - $this->authorizedUserId = auth()->id() ?? null; - - $this->comment = $comment; - $this->commentForm->setComment($comment); - - $this->post = $post; - - $this->body = $comment->body; - - $this->user = auth()->user() ?? null; - - $this->updateFlagCount(); - $this->hasUserFlagged(); - - $this->flagIconFilename = $this->getFlagIconFilename(); - $this->flagButtonText = $this->getFlagTitleText(); - } - - public function render(): View - { - return view('livewire.comments.comment-component'); + return $this->moderatorCommentsByType?->get(ModerationTypeEnum::Blur->value) !== null; } - private function getFlagIconFilename(): string + public function mount(int $commentId, ?Comment $comment, ?Collection $childComments): void { - return $this->userFlagged ? 'flag-fill' : 'flag'; + // On mount we expect the comment list to provide the comment model + // and the moderator comments collection. + $this->commentId = $commentId; + $this->comment = $comment ?? Comment::find($commentId); + $this->childComments = $childComments; + + // Decide whether to blur the component initially. + $this->isBlurred = $this->isInitiallyBlurred; } - private function getFlagTitleText(): string + public function render(): View { - return $this->userFlagged ? trans('Remove flag') : trans('Flag this comment'); + // If the comment has been replaced, just render the moderation message. + // TODO: if it is possible to have a top-level comment be marked with a + // moderation type, we may want to render it via this view. But it may + // also just be about changing the border for a regular rendering. + $moderatorReplaceComment = $this->moderatorCommentsByType?->get(ModerationTypeEnum::Replace->value); + $moderatorWrapComment = $this->moderatorCommentsByType?->get(ModerationTypeEnum::Wrap->value); + $moderatorBlurComment = $this->moderatorCommentsByType?->get(ModerationTypeEnum::Blur->value); + $moderationType = null; + + if ($moderatorReplaceComment !== null && + ($moderatorWrapComment === null || $moderatorReplaceComment->created_at > $moderatorWrapComment->created_at)) { + $moderationType = ModerationTypeEnum::Replace; + } elseif ($moderatorWrapComment !== null) { + $moderationType = ModerationTypeEnum::Wrap; + } + + // If there are no decorations to apply, just render the basic comment component. + return view('livewire.comments.comment-component', [ + 'comment' => $this->comment, + 'childComments' => $this->childComments, + 'moderationType' => $moderationType, + 'isInitiallyBlurred' => $this->isInitiallyBlurred, + 'replacedByCommentId' => $moderatorReplaceComment?->id, + 'wrappedByCommentId' => $moderatorWrapComment?->id, + 'blurredByCommentId' => $moderatorBlurComment?->id, + 'isEditing' => $this->state === CommentStateEnum::Editing, + 'isFlagging' => $this->state === CommentStateEnum::Flagging, + 'isReplying' => $this->state === CommentStateEnum::Replying, + 'isModerating' => $this->state === CommentStateEnum::Moderating, + ]); } - private function hasUserFlagged(): void + #[On([ + LivewireEventEnum::EscapeKeyClicked->value, + ])] + public function closeForm(): void { - $userFlagCount = DB::table(table: 'markable_flags') - ->where(column: 'user_id', operator: '=', value: auth()->id()) - ->where(column: 'markable_id', operator: '=', value: $this->comment->id) - ->where(column: 'markable_type', operator: 'LIKE', value: '%Comment%') - ->count(); - - $this->userFlagged = $userFlagCount > 0; + $this->state = CommentStateEnum::Viewing; } #[On([ LivewireEventEnum::CommentStored->value, - LivewireEventEnum::CommentDeleted->value, - LivewireEventEnum::CommentFlagged->value, LivewireEventEnum::CommentUpdated->value, - LivewireEventEnum::EscapeKeyClicked->value, ])] - public function closeForm(): void + public function reloadChildComments(int $id, ?int $parentId): void { - $this->reset([ - 'body', - ]); + if ($parentId === $this->commentId) { + $this->childComments = $this->commentRepository->getCommentsByParentId($parentId); + unset($this->moderatorCommentsByType, $this->isInitiallyBlurred); - $this->stopEditing(); - $this->stopFlagging(); - $this->stopReplying(); + // Re-evaluate whether the comment should be blurred. + $this->isBlurred = $this->isInitiallyBlurred; + } } - private function updateFlagCount(): void + public function setState(CommentStateEnum $state): void { - $this->flagCount = Flag::count($this->comment); + $this->state = $state; } - #[On('comment-flagged.{comment.id}')] - public function addUserFlag(): void + public function addUserFlag(int $id): void { - \Log::debug('CommentComponent::addUserFlag'); - $this->userFlagged = true; - $this->flagCount++; + if ($id !== $this->comment->id) { + return; + } + + unset($this->userFlagged, $this->flagCount); + + $this->state = CommentStateEnum::Viewing; } - #[On('comment-flag-deleted.{comment.id}')] - public function removeUserFlag(): void + public function removeUserFlag(int $id): void { - $this->userFlagged = false; - $this->flagCount--; + if ($id !== $this->comment->id) { + return; + } + + unset($this->userFlagged, $this->flagCount); + + $this->state = CommentStateEnum::Viewing; } } diff --git a/app/Livewire/Comments/CommentContent.php b/app/Livewire/Comments/CommentContent.php new file mode 100644 index 00000000..f78bfb61 --- /dev/null +++ b/app/Livewire/Comments/CommentContent.php @@ -0,0 +1,35 @@ + auth()->id() ?? null, + 'comment' => $this->comment, + 'body' => $this->comment->body, + 'flagCount' => $this->flagCount, + 'userFlagged' => $this->userFlagged, + 'isEditing' => $this->state === CommentStateEnum::Editing, + 'isFlagging' => $this->state === CommentStateEnum::Flagging, + 'isReplying' => $this->state === CommentStateEnum::Replying, + 'isModerating' => $this->state === CommentStateEnum::Moderating, + ]; + + // If there are no decorations to apply, just render the basic comment component. + return view('livewire.comments.comment-content', $data); + } +} diff --git a/app/Livewire/Comments/CommentFormComponent.php b/app/Livewire/Comments/CommentFormComponent.php index f784619f..f8b315ca 100644 --- a/app/Livewire/Comments/CommentFormComponent.php +++ b/app/Livewire/Comments/CommentFormComponent.php @@ -5,55 +5,135 @@ namespace App\Livewire\Comments; use App\Enums\LivewireEventEnum; +use App\Enums\ModerationTypeEnum; use App\Http\Requests\Comment\StoreCommentRequest; use App\Models\Comment; use App\Traits\LoggingTrait; use Exception; use Illuminate\Contracts\View\View; -use Livewire\Attributes\On; +use Livewire\Attributes\Computed; +use Livewire\Attributes\Locked; use Livewire\Component; final class CommentFormComponent extends Component { use LoggingTrait; - public ?int $authorizedUserId; - public string $buttonText = ''; - public ?Comment $comment; + // Props + #[Locked] + public int $postId; + + #[Locked] + public ?int $commentId; + + #[Locked] + public ?int $parentId; + + #[Locked] + public string $idSuffix; + + // Form data + public string $body = ''; + public ?ModerationTypeEnum $moderationType = null; + public string $message = ''; + + // State public bool $isEditing; public bool $isReplying; - public string $body = ''; - public int $postId; - public ?int $parentId = null; - public ?string $message = null; - public string $editorId = ''; + public bool $isModerating; + + // Data not persisted in the client-side snapshot + protected ?Comment $comment; + + #[Computed] + public function isAdding(): bool + { + return !$this->isEditing && !$this->isReplying && !$this->isModerating; + } + + #[Computed] + public function isBodyEditable(): bool + { + return $this->isAdding || $this->isEditing || + ($this->isModerating && $this->moderationType === ModerationTypeEnum::Edit); + } + + #[Computed] + public function bodyEditorId(): string + { + return 'body-editor-' . ($this->commentId ?? 'new'); + } + + #[Computed] + public function messageEditorId(): string + { + return 'message-editor-' . ($this->commentId ?? 'new'); + } public function mount( - int $postId, - ?Comment $comment, + ?int $postId = null, + ?int $commentId = null, ?int $parentId = null, + ?Comment $comment = null, + ?string $moderationType = null, bool $isEditing = false, bool $isReplying = false, + bool $isModerating = false, ): void { - $this->authorizedUserId = auth()->id() ?? null; + $this->idSuffix = uniqid(); + // The implied alternative when not editing, replying or moderating is + // that we are adding a new comment. $this->isEditing = $isEditing; $this->isReplying = $isReplying; + $this->isModerating = $isModerating; + + $postId ??= $comment?->post_id; + $commentId ??= $comment?->id; + $parentId ??= ($isReplying || $isModerating ? $commentId : $comment?->parent_id); $this->postId = $postId; + $this->commentId = $commentId; $this->parentId = $parentId; + $this->comment = $comment; + $this->moderationType = ModerationTypeEnum::tryFrom($moderationType ?? ''); - $this->comment = $comment ?? null; - $this->body = $this->comment->body ?? ''; - - if ($this->editorId === '') { - $this->editorId = uniqid('comment-editor-' . ($this->comment->id ?? 'new') . '-'); - } + $this->body = $comment?->body ?? ''; } public function render(): View { - return view('livewire.comments.comment-form-component'); + $isAdding = !$this->isEditing && !$this->isReplying && !$this->isModerating; + $isBodyEditable = $isAdding || $this->isEditing || + ($this->isModerating && $this->moderationType === ModerationTypeEnum::Edit); + $data = [ + 'bodyLabel' => trans('Comment'), + 'messageLabel' => trans('Moderation message'), + 'buttonText' => trans('Add Comment'), + 'isAdding' => $isAdding, + 'isBodyEditable' => $isBodyEditable, + 'bodyEditorId' => $this->bodyEditorId, + 'messageEditorId' => $this->messageEditorId, + ]; + + if ($this->isModerating) { + $data['bodyLabel'] = trans('Original comment'); + $data['buttonText'] = trans(match ($this->moderationType) { + ModerationTypeEnum::Edit => 'Edit comment', + ModerationTypeEnum::Remove => 'Remove comment', + ModerationTypeEnum::Replace => 'Replace comment', + ModerationTypeEnum::Wrap => 'Wrap comment', + ModerationTypeEnum::Blur => 'Blur comment', + default => 'Moderate', + }); + } elseif ($this->isReplying) { + $data['bodyLabel'] = trans('Reply'); + $data['buttonText'] = trans('Reply'); + } elseif ($this->isEditing) { + $data['buttonText'] = trans('Edit comment'); + } + + return view('livewire.comments.comment-form-component', $data); } protected function rules(): array @@ -61,27 +141,62 @@ protected function rules(): array return (new StoreCommentRequest())->rules(); } - #[On(LivewireEventEnum::EditorUpdated->value)] - public function handleEditorUpdated($editorId, $content): void - { - if ($this->editorId !== $editorId) { - return; - } - - $this->body = $content; - } - public function submit(): void { $this->validate(); - if ($this->isEditing === true) { + if ($this->isModerating) { + $this->moderate(); + } elseif ($this->isEditing) { $this->update(); } else { $this->store(); } } + protected function clearEditors(): void + { + $this->reset('body'); + $this->reset('message'); + $this->dispatch('editor:clear', editorId: $this->bodyEditorId); + $this->dispatch('editor:clear', editorId: $this->messageEditorId); + } + + public function moderate(): void + { + try { + // If moderator has edited the comment, save the new body. + if ($this->moderationType === ModerationTypeEnum::Edit) { + $comment = Comment::find($this->commentId ?? 0); + + if ($comment->body !== $this->body) { + $comment->body = $this->body; + $comment->save(); + $this->dispatch(LivewireEventEnum::CommentUpdated->value, id: $comment->id, parentId: $comment->parent_id); + } + } + + // Create the moderation comment as a child of the original comment. + $comment = new Comment( + [ + 'post_id' => $this->postId, + 'parent_id' => $this->parentId, + 'user_id' => auth()->id() ?? null, + 'moderation_type' => $this->moderationType, + 'body' => $this->message, + ], + ); + + $comment->save(); + + $this->dispatch(LivewireEventEnum::CommentStored->value, id: $comment->id, parentId: $comment->parent_id); + + $this->clearEditors(); + } catch (Exception $exception) { + $this->logError($exception); + } + } + public function store(): void { try { @@ -89,32 +204,33 @@ public function store(): void [ 'body' => $this->body, 'post_id' => $this->postId, - 'user_id' => $this->authorizedUserId, + 'user_id' => auth()->id() ?? null, 'parent_id' => $this->parentId, ], ); $comment->save(); - $this->dispatch(LivewireEventEnum::CommentStored->value); + $this->dispatch(LivewireEventEnum::CommentStored->value, id: $comment->id, parentId: $comment->parent_id); + + $this->clearEditors(); $this->message = trans('Comment created.'); } catch (Exception $exception) { $this->logError($exception); } - - $this->reset('body'); - $this->dispatch('editor:clear', editorId: $this->editorId); } public function update(): void { try { - $comment = Comment::find($this->comment->id); + $comment = Comment::find($this->commentId ?? 0); $comment->body = $this->body; $comment->save(); - $this->dispatch(LivewireEventEnum::CommentUpdated->value); + $this->dispatch(LivewireEventEnum::CommentUpdated->value, id: $comment->id, parentId: $comment->parent_id); + + $this->clearEditors(); $this->message = trans('Comment updated.'); } catch (Exception $exception) { diff --git a/app/Livewire/Comments/CommentListComponent.php b/app/Livewire/Comments/CommentListComponent.php index 66fec685..9ee38c0f 100644 --- a/app/Livewire/Comments/CommentListComponent.php +++ b/app/Livewire/Comments/CommentListComponent.php @@ -5,12 +5,13 @@ namespace App\Livewire\Comments; use App\Enums\LivewireEventEnum; -use App\Models\Post; use App\Repositories\CommentRepositoryInterface; use App\Traits\AuthStatusTrait; use App\Traits\SubsiteTrait; use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; +use Livewire\Attributes\Computed; +use Livewire\Attributes\Locked; use Livewire\Attributes\On; use Livewire\Component; @@ -19,32 +20,36 @@ final class CommentListComponent extends Component use AuthStatusTrait; use SubsiteTrait; - public ?int $authorizedUserId; - public Post $post; - public Collection $comments; + #[Locked] + public int $postId = 0; + + #[Locked] public string $recordsText = 'comments'; protected CommentRepositoryInterface $commentRepository; + public function boot(CommentRepositoryInterface $commentRepository): void { $this->commentRepository = $commentRepository; } - public function mount(Post $post): void + public function mount(int $postId): void { - $this->authorizedUserId = $this->getAuthorizedUserId(); + $this->postId = $postId; - $this->post = $post; - $this->getComments(); $this->setRecordsLabel(); } - public function render(): View + #[Computed] + public function comments(): Collection { - $comments = $this->comments; + return $this->commentRepository->getCommentsByPostId($this->postId); + } + public function render(): View + { return view('livewire.comments.comment-list-component', [ - 'comments' => compact('comments'), + 'comments' => $this->comments, ]); } @@ -55,7 +60,7 @@ public function render(): View ])] public function getComments(): void { - $this->comments = $this->commentRepository->getCommentsByPostId($this->post->id); + unset($this->comments); } private function setRecordsLabel(): void diff --git a/app/Livewire/Comments/CommentReplacement.php b/app/Livewire/Comments/CommentReplacement.php new file mode 100644 index 00000000..c8f4c3d5 --- /dev/null +++ b/app/Livewire/Comments/CommentReplacement.php @@ -0,0 +1,30 @@ +moderatorCommentsByType?->get(ModerationTypeEnum::Replace->value); + + return view('livewire.comments.comment-replacement', [ + 'comment' => $this->comment, + 'childComments' => $this->childComments, + 'moderatorComment' => $moderatorComment, + 'isModerating' => $this->state === CommentStateEnum::Moderating, + ]); + } +} diff --git a/app/Livewire/Comments/CommentWrapper.php b/app/Livewire/Comments/CommentWrapper.php new file mode 100644 index 00000000..3f372fb7 --- /dev/null +++ b/app/Livewire/Comments/CommentWrapper.php @@ -0,0 +1,32 @@ +moderatorCommentsByType?->get(ModerationTypeEnum::Wrap->value); + + return view('livewire.comments.comment-wrapper', [ + 'comment' => $this->comment, + 'childComments' => $this->childComments, + 'moderatorComment' => $moderatorComment, + 'isInitiallyBlurred' => $this->isInitiallyBlurred, + ]); + } +} diff --git a/app/Livewire/Flags/FlagComponent.php b/app/Livewire/Flags/FlagComponent.php index 2470d3a4..765b1b70 100644 --- a/app/Livewire/Flags/FlagComponent.php +++ b/app/Livewire/Flags/FlagComponent.php @@ -11,8 +11,8 @@ use App\Traits\AuthStatusTrait; use App\Traits\LoggingTrait; use App\Traits\TypeTrait; -use Exception; use Illuminate\Contracts\View\View; +use Livewire\Attributes\Locked; use Livewire\Component; use Maize\Markable\Exceptions\InvalidMarkValueException; @@ -26,25 +26,42 @@ final class FlagComponent extends Component private const string MODEL_PATH = 'app\\models\\'; public Comment|Post $model; + public Flag|null $userFlag = null; + + #[Locked] + public int $modelId; + #[Locked] + public string $type; + #[Locked] public int $flagCount = 0; + #[Locked] public string $iconFilename = 'flag'; - public string $note = ''; + #[Locked] + public string $titleText; + #[Locked] public array $flagReasons = []; + + // Actual values we interact with + public string $note = ''; public string $selectedReason = ''; - public bool $showForm = false; public bool $showNoteField = false; - public string $titleText; - public string $type; - public bool $userFlagged = false; + public bool $formClosed = false; public function mount( Comment|Post $model, ): void { $configReasons = config('markable.allowed_values.flag', []); - $this->flagReasons = is_array($configReasons) ? $configReasons : []; + $this->flagReasons = is_array($configReasons) ? array_combine( + array_map(fn($reason) => mb_strtolower(preg_replace('/[^\w]+/', '-', $reason)), $configReasons), + $configReasons, + ) : []; + + $this->formClosed = false; $this->model = $model; + $this->modelId = $model->id; $this->type = $this->getType(); + $this->userFlag = $this->getUserFlag(); $this->updateFlagData(); } @@ -53,77 +70,98 @@ public function render(): View return view('livewire.flags.flag-component'); } - public function flagReasonSelected(string $selectedReason): void + public function isNoteVisibleForReason(string $reason): bool { - if ($selectedReason === self::FLAG_WITH_NOTE) { - $this->showNoteField = true; - } else { - $this->showNoteField = false; - $this->reset('note'); - } + return $reason === self::FLAG_WITH_NOTE; + } - $this->selectedReason = $selectedReason; + public function getUserFlag(): Flag | null + { + return Flag::where([ + 'user_id' => auth()->id(), + 'markable_id' => $this->model->getKey(), + 'markable_type' => $this->model->getMorphClass(), + ])->first(); } - public function updateFlagData(): void + protected function updateFlagData(): void { + $value = $this->userFlag?->value ?? ''; + $this->selectedReason = in_array($value, $this->flagReasons) ? $value : ''; + $this->note = $this->userFlag?->metadata['note'] ?? ''; + $this->showNoteField = $this->isNoteVisibleForReason($this->selectedReason); $this->setTitleText(); } - public function store(): void + protected function deleteUserFlag(): void { - // $rules = (new StoreFlagRequest())->rules(); + Flag::where([ + 'user_id' => auth()->id(), + 'markable_id' => $this->model->getKey(), + 'markable_type' => $this->model->getMorphClass(), + ])->get()->each->delete(); + } - // $this->validate($rules); + public function store(): void + { $metadata = []; $selectedReason = mb_trim($this->selectedReason); + $noteText = mb_trim($this->note); - try { - if ($selectedReason === self::FLAG_WITH_NOTE && mb_strlen($this->note) > 0) { - $metadata = ['note' => $this->note]; - } - - $event = $this->type === 'comment' ? - LivewireEventEnum::CommentFlagged->value : - LivewireEventEnum::PostFlagged->value; - - $this->dispatchEvent($event); + if ($this->isNoteVisibleForReason($selectedReason) && mb_strlen($noteText) > 0) { + $metadata = ['note' => $noteText]; + } - Flag::add($this->model, auth()->user(), $selectedReason, $metadata); + // Just cancel the change if the form is unmodified + if ($selectedReason === ($this->userFlag?->value ?? '') && $metadata === $this->userFlag?->metadata) { + $this->cancel(); + return; + } - $this->showForm = false; + $event = $this->type === 'comment' ? + LivewireEventEnum::CommentFlagged : + LivewireEventEnum::PostFlagged; - $this->userFlagged = false; + // Stop rendering while we are modifying data + $this->formClosed = true; - $this->updateFlagData(); + $this->deleteUserFlag(); + try { + $this->userFlag = Flag::add($this->model, auth()->user(), $selectedReason, $metadata); } catch (InvalidMarkValueException $exception) { $this->logError($exception); } + $this->updateFlagData(); + + $this->dispatchEvent($event); } public function delete(): void { - try { - $event = $this->type === 'comment' ? - LivewireEventEnum::CommentFlagDeleted->value : - LivewireEventEnum::PostFlagDeleted->value; - - $this->dispatchEvent($event); - Flag::remove($this->model, auth()->user()); + // Stop rendering while we are modifying data + $this->formClosed = true; + $event = $this->type === 'comment' ? + LivewireEventEnum::CommentFlagDeleted : + LivewireEventEnum::PostFlagDeleted; - $this->userFlagged = false; + $this->deleteUserFlag(); + $this->userFlag = null; + $this->updateFlagData(); - $this->updateFlagData(); - } catch (Exception $exception) { - $this->logError($exception); - } + $this->dispatchEvent($event); } - public function toggleForm(): void + public function cancel(): void { - $this->showForm = !$this->showForm; + $this->formClosed = true; + + $event = $this->type === 'comment' ? + LivewireEventEnum::CommentFlagCancelled : + LivewireEventEnum::PostFlagCancelled; + + $this->dispatchEvent($event); } private function getType(): string @@ -137,11 +175,11 @@ private function setTitleText(): void { $flagText = 'Flag this ' . $this->type; - $this->titleText = $this->userFlagged ? trans('Remove flag') : trans($flagText); + $this->titleText = $this->userFlag ? trans('Remove or edit flag') : trans($flagText); } - private function dispatchEvent(string $event): void + private function dispatchEvent(LivewireEventEnum $event): void { - $this->dispatch($event, id: $this->model->id); + $this->dispatch($event->value, id: $this->model->id); } } diff --git a/app/Livewire/Forms/CommentForm.php b/app/Livewire/Forms/CommentForm.php index 9e6062e8..c1242f05 100644 --- a/app/Livewire/Forms/CommentForm.php +++ b/app/Livewire/Forms/CommentForm.php @@ -5,6 +5,7 @@ namespace App\Livewire\Forms; use App\Enums\LivewireEventEnum; +use App\Enums\ModerationTypeEnum; use App\Http\Requests\Comment\StoreCommentRequest; use App\Models\Comment; use App\Traits\AuthStatusTrait; @@ -20,7 +21,9 @@ final class CommentForm extends Form public string $body = ''; public int $postId = 0; public ?int $parentId = null; + public ?ModerationTypeEnum $moderationType = null; public ?object $parent = null; + public ?object $moderatedComment = null; public function mount(): void { @@ -32,6 +35,8 @@ public function setComment(Comment $comment): void $this->body = $comment->body; $this->postId = $comment->post_id; $this->parentId = $comment->parent_id; + $this->moderationType = $comment->moderation_type; + $this->moderatedComment = $comment->moderated_comment; } protected function rules(): array @@ -47,6 +52,7 @@ public function store(): void 'body' => $this->body, 'post_id' => $this->postId, 'parent_id' => $this->parentId, + 'moderation_type' => $this->moderationType, 'user_id' => $this->authorizedUserId, ]); @@ -65,6 +71,7 @@ public function update(): void $this->comment->update([ 'body' => $this->body, + 'moderation_type' => $this->moderationType, ]); $this->parent->isEditing = false; diff --git a/app/Models/Comment.php b/app/Models/Comment.php index bfb8c4df..bc665668 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\ModerationTypeEnum; use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -20,6 +21,7 @@ /** * @property int $id * @property string $body + * @property ModerationTypeEnum|null $moderation_type * @property int $parent_id * @property int $post_id * @property int $user_id @@ -38,11 +40,16 @@ final class Comment extends BaseModel protected $fillable = [ 'body', + 'moderation_type', 'parent_id', 'post_id', 'user_id', ]; + protected $casts = [ + 'moderation_type' => ModerationTypeEnum::class, + ]; + protected static array $marks = [ Bookmark::class, Favorite::class, @@ -97,7 +104,19 @@ public function favoriteCount(): int public function flagCount(): int { - return Flag::count($this); + return Flag::where([ + 'markable_id' => $this->getKey(), + 'markable_type' => $this->getMorphClass(), + ])->count(); + } + + public function userFlagged(): bool + { + return Flag::where([ + 'user_id' => auth()->id(), + 'markable_id' => $this->getKey(), + 'markable_type' => $this->getMorphClass(), + ])->exists(); } public function post(): BelongsTo @@ -119,7 +138,6 @@ public function parent(): BelongsTo ); } - public function replies(): HasMany { return $this->hasMany( diff --git a/app/Models/Post.php b/app/Models/Post.php index f2eae3df..2f76bf97 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -155,7 +155,19 @@ public function favoriteCount(): int public function flagCount(): int { - return Flag::count($this); + return Flag::where([ + 'markable_id' => $this->getKey(), + 'markable_type' => $this->getMorphClass(), + ])->count(); + } + + public function userFlagged(): bool + { + return Flag::where([ + 'user_id' => auth()->id(), + 'markable_id' => $this->getKey(), + 'markable_type' => $this->getMorphClass(), + ])->exists(); } public function next(): Post|null diff --git a/app/Repositories/CommentRepository.php b/app/Repositories/CommentRepository.php index 3d291ab0..1691a491 100644 --- a/app/Repositories/CommentRepository.php +++ b/app/Repositories/CommentRepository.php @@ -13,6 +13,8 @@ final class CommentRepository extends BaseRepository implements CommentRepositor 'comments.id', 'comments.body', 'comments.post_id', + 'comments.parent_id', + 'comments.moderation_type', 'comments.user_id', 'comments.created_at', 'comments.deleted_at', @@ -49,4 +51,19 @@ public function getCommentsByPostId(int $postId, ?Comment $latestComment = null) return $query->get(); } + + public function getCommentsByParentId(int $parentId): Collection + { + $query = $this->model->newQuery() + ->join('users', 'comments.user_id', '=', 'users.id') + ->select(self::COLUMNS) + ->with([ + 'user', + ]) + ->where('comments.parent_id', '=', $parentId); + + $query->orderBy('comments.created_at'); + + return $query->get(); + } } diff --git a/app/Repositories/CommentRepositoryInterface.php b/app/Repositories/CommentRepositoryInterface.php index f6d69dde..d21a10db 100644 --- a/app/Repositories/CommentRepositoryInterface.php +++ b/app/Repositories/CommentRepositoryInterface.php @@ -11,4 +11,6 @@ interface CommentRepositoryInterface extends BaseRepositoryInterface public function getCommentByUserId(int $userId); public function getCommentsByPostId(int $postId, ?Comment $latestComment = null); + + public function getCommentsByParentId(int $parentId); } diff --git a/app/Traits/CommentComponentStateTrait.php b/app/Traits/CommentComponentStateTrait.php new file mode 100644 index 00000000..f57f7c88 --- /dev/null +++ b/app/Traits/CommentComponentStateTrait.php @@ -0,0 +1,15 @@ +comment?->flagCount() ?? 0; + } + + #[Computed] + public function userFlagged(): bool + { + return $this->comment?->userFlagged() ?? false; + } + + #[Computed] + public function moderatorCommentsByType(): ?Collection + { + return $this->childComments?->filter( + fn($comment) => + $comment->moderation_type !== null && + $comment->moderation_type->value !== ModerationTypeEnum::Comment->value, + )->keyBy(fn($comment) => $comment->moderation_type->value ?? 'none'); + } + + #[Computed] + public function isInitiallyBlurred(): bool + { + return $this->moderatorCommentsByType?->get(ModerationTypeEnum::Blur->value) !== null; + } + + protected CommentRepositoryInterface $commentRepository; + + public function bootCommentComponentTrait(CommentRepositoryInterface $commentRepository): void + { + $this->commentRepository = $commentRepository; + } + + public function mountCommentComponentTrait(int $commentId, ?Comment $comment, ?Collection $childComments): void + { + // On mount we expect the comment list to provide the comment model + // and the moderator comments collection. + $this->commentId = $commentId; + $this->comment = $comment ?? Comment::find($commentId); + $this->childComments = $childComments; + } + + public function hydrateCommentComponentTrait(): void + { + // On subsequent requests, we need to re-fetch the comment and moderator comments. + $this->comment = $this->commentId ? Comment::find($this->commentId) : null; + $this->childComments = $this->commentRepository->getCommentsByParentId($this->commentId); + } + + public function toggleEditing(): void { - if ($this->isEditing === true) { + if ($this->state === CommentStateEnum::Editing) { $this->stopEditing(); } else { $this->startEditing(); @@ -17,7 +87,7 @@ public function toggleEditing(): void public function toggleFlagging(): void { - if ($this->isFlagging === true) { + if ($this->state === CommentStateEnum::Flagging) { $this->stopFlagging(); } else { $this->startFlagging(); @@ -26,46 +96,64 @@ public function toggleFlagging(): void public function toggleReplying(): void { - if ($this->isReplying === true) { + if ($this->state === CommentStateEnum::Replying) { $this->stopReplying(); } else { $this->startReplying(); } } + public function toggleModerating(): void + { + if ($this->state === CommentStateEnum::Moderating) { + $this->stopModerating(); + } else { + $this->startModerating(); + } + } + + public function requestStateChange(CommentStateEnum $state): void + { + $this->dispatch(LivewireEventEnum::CommentFormStateChanged->value, id: $this->commentId, state: $state); + } + public function startEditing(): void { - $this->isEditing = true; - $this->stopFlagging(); - $this->stopReplying(); + $this->requestStateChange(CommentStateEnum::Editing); } public function stopEditing(): void { - $this->isEditing = false; + $this->requestStateChange(CommentStateEnum::Viewing); } public function startFlagging(): void { - $this->isFlagging = true; - $this->stopEditing(); - $this->stopReplying(); + $this->requestStateChange(CommentStateEnum::Flagging); } public function stopFlagging(): void { - $this->isFlagging = false; + $this->requestStateChange(CommentStateEnum::Viewing); } public function startReplying(): void { - $this->isReplying = true; - $this->stopEditing(); - $this->stopFlagging(); + $this->requestStateChange(CommentStateEnum::Replying); } public function stopReplying(): void { - $this->isReplying = false; + $this->requestStateChange(CommentStateEnum::Viewing); + } + + public function startModerating(): void + { + $this->requestStateChange(CommentStateEnum::Moderating); + } + + public function stopModerating(): void + { + $this->requestStateChange(CommentStateEnum::Viewing); } } diff --git a/database/migrations/2025_07_08_232421_add_comment_moderation_fields.php b/database/migrations/2025_07_08_232421_add_comment_moderation_fields.php new file mode 100644 index 00000000..b45f372f --- /dev/null +++ b/database/migrations/2025_07_08_232421_add_comment_moderation_fields.php @@ -0,0 +1,23 @@ +enum('moderation_type', ['blur', 'comment', 'edit', 'remove', 'replace', 'wrap'])->nullable(); + }); + } + + public function down(): void + { + Schema::table('comments', function (Blueprint $table) { + $table->dropColumn(['moderation_type']); + }); + } +}; diff --git a/public_html/images/icons/bars-rotate-fade.svg b/public_html/images/icons/bars-rotate-fade.svg new file mode 100644 index 00000000..bdcd8fcf --- /dev/null +++ b/public_html/images/icons/bars-rotate-fade.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public_html/images/icons/chevron-details.svg b/public_html/images/icons/chevron-details.svg new file mode 100644 index 00000000..052a2c5e --- /dev/null +++ b/public_html/images/icons/chevron-details.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public_html/images/icons/stoplights-fill.svg b/public_html/images/icons/stoplights-fill.svg new file mode 100644 index 00000000..f0b2d767 --- /dev/null +++ b/public_html/images/icons/stoplights-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/resources/sass/app.scss b/resources/sass/app.scss index dc3b37d5..6ea0cdd8 100644 --- a/resources/sass/app.scss +++ b/resources/sass/app.scss @@ -5,6 +5,7 @@ @use 'modules/accessibility'; @use 'modules/animation'; +@use 'modules/blurred-content'; @use 'modules/buttons'; @use 'modules/details'; @use 'modules/dropdowns'; @@ -31,4 +32,4 @@ @use 'modules/toast'; @use 'modules/typography'; @use 'modules/usability'; -@use 'modules/wysiwyg'; +@use 'modules/wysiwyg'; \ No newline at end of file diff --git a/resources/sass/modules/_blurred-content.scss b/resources/sass/modules/_blurred-content.scss new file mode 100644 index 00000000..60f71220 --- /dev/null +++ b/resources/sass/modules/_blurred-content.scss @@ -0,0 +1,49 @@ +.blur-container { + position: relative; + display: block; + border-radius: inherit; +} + +.blur-container.blurred { + display: grid; + grid-template-columns: auto; + grid-template-rows: auto; + + overflow: hidden; +} + +.blur-container.blurred>* { + grid-area: 1 / 1; +} + +.blur-container.blurred>.blur-content { + position: relative; + + max-height: 8rem; + overflow: hidden; + + border-radius: inherit; + + mask: + linear-gradient(to top, transparent 1rem, white 2rem calc(100% - 2rem), transparent calc(100% - 1rem)), + linear-gradient(to left, transparent 1rem, white 2rem calc(100% - 2rem), transparent calc(100% - 1rem)); + mask-position: center; + mask-repeat: no-repeat; + mask-composite: intersect; +} + +.blur-container>.blur-overlay { + display: none; + border-radius: inherit; +} + +.blur-container.blurred>.blur-overlay { + padding: 0.75rem; + + display: flex; + align-items: center; + justify-content: center; + + backdrop-filter: blur(0.5rem); + cursor: pointer; +} \ No newline at end of file diff --git a/resources/sass/modules/_details.scss b/resources/sass/modules/_details.scss index 61906dd1..0a5046b3 100644 --- a/resources/sass/modules/_details.scss +++ b/resources/sass/modules/_details.scss @@ -1,7 +1,5 @@ - summary { display: flex; - justify-content: space-between; cursor: pointer; } @@ -9,23 +7,28 @@ summary::-webkit-details-marker { display: none; } -summary { +details>summary { list-style-type: '⬇ '; } -details[open] > summary { +details[open]>summary { list-style-type: '⬆ '; } -summary::after { - content: ''; - width: 18px; - height: 10px; - background: url('https://uploads.sitepoint.com/wp-content/uploads/2023/10/1697699669arrow.svg') no-repeat; - background-size: cover; - transition: 0.2s; -} +.main-sidebar { + details>summary { + justify-content: space-between; + color: light-dark(var(--base-color), var(--yellow-green)) + } -details[open] > summary::after { - transform: rotate(180deg); -} + details>summary .icon.chevron-details { + width: auto; + height: auto; + bottom: 0; + margin: 0; + } + + details[open]>summary .chevron-details { + transform: rotate(180deg); + } +} \ No newline at end of file diff --git a/resources/sass/modules/_forms.scss b/resources/sass/modules/_forms.scss index 9890da9d..06f8b489 100644 --- a/resources/sass/modules/_forms.scss +++ b/resources/sass/modules/_forms.scss @@ -19,7 +19,7 @@ main input[type='url'] { } textarea.comment-textarea { - resize: block; + resize: vertical; } input[type='radio'] { @@ -60,6 +60,12 @@ input[type='radio']:focus { min-height: 10rem; } +label.radio-button-label { + display: flex; + align-items: center; + gap: 0.5rem; +} + .radio-button-label:focus-within { color: var(--dark-base-color); } @@ -68,10 +74,22 @@ input[type='radio']:focus { margin: 0 0 1rem 0; } -.fantastic { +.flag-form fieldset legend { margin-bottom: 1rem; } +.flag-form label:has(input[type='radio']) { + margin-bottom: 0.5rem; +} + +.flag-form label:has(input[type='radio'].flag-reason-fantastic) { + margin-bottom: 1rem; +} + +.flag-form textarea.flag-note { + resize: vertical; +} + .input-icon-wrap { display: flex; flex-direction: row; diff --git a/resources/sass/modules/_posts.scss b/resources/sass/modules/_posts.scss index 30e8f2b1..6c8be33a 100644 --- a/resources/sass/modules/_posts.scss +++ b/resources/sass/modules/_posts.scss @@ -1,7 +1,119 @@ - -.comment, .post { - margin: 0 0 2rem 0; + position: relative; + padding: 0.75rem; + border-radius: 0.25rem; + margin-bottom: 2rem; +} + +.comments { + display: flex; + flex-direction: column; + gap: 2rem; + margin-bottom: 2rem; +} + +.comment { + position: relative; + border-radius: 0.25rem; +} + +.comment>.moderator-replaced, +.comment>.moderator-hidden>summary, +.comment .comment-container { padding: 0.75rem; border-radius: 0.25rem; } + +.comment .blur-container { + border-radius: 0.25rem; +} + +.comment-footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} + +.comment-metadata { + display: flex; + align-items: center; + gap: 1rem; +} + +.comment-metadata>time { + display: inline-flex; + gap: 1rem; +} + +.moderator-message { + border: 1px solid light-dark(var(--base-color), var(--yellow-green)); + color: light-dark(var(--base-color), var(--yellow-green)); +} + +details.moderator-hidden { + padding: 0; + border-radius: inherit; + + &>summary { + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + + border-radius: inherit; + } + + &[open]>summary { + margin-bottom: 0.5rem; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + &>summary>footer.comment-controls:before { + display: block; + content: '▶︎'; + width: 1rem; + height: auto; + } + + &[open]>summary>footer.comment-controls:before { + content: '▼'; + } + + &>summary>main+footer, + &>summary>footer+footer { + margin-top: 0.5rem; + } + + &>summary>footer.comment-controls { + display: flex; + flex-direction: row; + gap: 0.25rem; + } + + &>summary>footer.comment-controls>span.show-comment, + &[open]>summary>footer.comment-controls>span.hide-comment { + display: block; + } + + &>summary>footer.comment-controls>span.hide-comment, + &[open]>summary>footer.comment-controls>span.show-comment { + display: none; + } +} + +.button:not(.loading)>span.icon:not(.loading-icon) { + display: inline; +} + +.button:not(.loading)>span.icon.loading-icon { + display: none; +} + +.button.loading>span.icon:not(.loading-icon) { + display: none; +} + +.button.loading>span.icon.loading-icon { + display: inline; +} \ No newline at end of file diff --git a/resources/views/comments/partials/comment-footer.blade.php b/resources/views/comments/partials/comment-footer.blade.php index b11b3230..ce7df77c 100644 --- a/resources/views/comments/partials/comment-footer.blade.php +++ b/resources/views/comments/partials/comment-footer.blade.php @@ -1,6 +1,6 @@ diff --git a/resources/views/livewire/comments/comment-wrapper.blade.php b/resources/views/livewire/comments/comment-wrapper.blade.php new file mode 100644 index 00000000..6211ac1a --- /dev/null +++ b/resources/views/livewire/comments/comment-wrapper.blade.php @@ -0,0 +1,54 @@ +
+ +
+ @if (!empty(trim($moderatorComment->body))) + {!! $moderatorComment->body !!} + @else + {{ trans('This comment has been hidden.') }} + @endif +
+ +
+ +
+ +
+ + {{ trans('Show comment')}} + + + {{ trans('Hide comment')}} + +
+
+ + @if ($isInitiallyBlurred) + + @else + + @endif +
+ +@script + +@endscript \ No newline at end of file diff --git a/resources/views/livewire/comments/partials/comment-blurrable.blade.php b/resources/views/livewire/comments/partials/comment-blurrable.blade.php new file mode 100644 index 00000000..6831422e --- /dev/null +++ b/resources/views/livewire/comments/partials/comment-blurrable.blade.php @@ -0,0 +1,12 @@ +
+
+ @include('livewire.comments.partials.comment-content') +
+
+ @if (!empty($blurMessage)) + {!! $blurMessage !!} + @endif +
+
diff --git a/resources/views/livewire/comments/partials/comment-content.blade.php b/resources/views/livewire/comments/partials/comment-content.blade.php new file mode 100644 index 00000000..611e4622 --- /dev/null +++ b/resources/views/livewire/comments/partials/comment-content.blade.php @@ -0,0 +1,38 @@ +
+
+ {!! $body !!} +
+ +
+ + + @auth + + + + + @if ($comment->user_id === auth()->id()) + @include('livewire.comments.partials.toggle-editing-button') + @endif + + @include('livewire.comments.partials.toggle-replying-button') + + @include('livewire.comments.partials.toggle-moderating-button') + @endauth + + @include('livewire.comments.partials.toggle-flagging-button') +
+
diff --git a/resources/views/livewire/comments/partials/comment-forms.blade.php b/resources/views/livewire/comments/partials/comment-forms.blade.php new file mode 100644 index 00000000..435d0e2e --- /dev/null +++ b/resources/views/livewire/comments/partials/comment-forms.blade.php @@ -0,0 +1,35 @@ +@if ($isEditing === true) + +@endif + +@if ($isFlagging === true) + +@endif + +@if ($isReplying === true) + +@endif + +@if ($isModerating === true) + +@endif diff --git a/resources/views/livewire/comments/partials/toggle-editing-button.blade.php b/resources/views/livewire/comments/partials/toggle-editing-button.blade.php index 364bb3ca..e00477e0 100644 --- a/resources/views/livewire/comments/partials/toggle-editing-button.blade.php +++ b/resources/views/livewire/comments/partials/toggle-editing-button.blade.php @@ -2,7 +2,7 @@ class="button footer-button" wire:click.prevent="toggleEditing()" aria-controls="edit-comment-form-{{ $comment->id }}" - aria-expanded="{{ $this->isEditing ? 'true' : 'false' }}"> + aria-expanded="{{ json_encode($isEditing) }}"> {{ trans('Edit') }} diff --git a/resources/views/livewire/comments/partials/toggle-flagging-button.blade.php b/resources/views/livewire/comments/partials/toggle-flagging-button.blade.php index 271be8cc..0c957a1d 100644 --- a/resources/views/livewire/comments/partials/toggle-flagging-button.blade.php +++ b/resources/views/livewire/comments/partials/toggle-flagging-button.blade.php @@ -1,22 +1,84 @@ @auth @endauth @guest - @endguest + +@script + +@endscript \ No newline at end of file diff --git a/resources/views/livewire/comments/partials/toggle-moderating-button.blade.php b/resources/views/livewire/comments/partials/toggle-moderating-button.blade.php new file mode 100644 index 00000000..cde77dcd --- /dev/null +++ b/resources/views/livewire/comments/partials/toggle-moderating-button.blade.php @@ -0,0 +1,8 @@ + diff --git a/resources/views/livewire/comments/partials/toggle-replying-button.blade.php b/resources/views/livewire/comments/partials/toggle-replying-button.blade.php index cd82b3c5..2020254c 100644 --- a/resources/views/livewire/comments/partials/toggle-replying-button.blade.php +++ b/resources/views/livewire/comments/partials/toggle-replying-button.blade.php @@ -2,7 +2,7 @@ class="button footer-button" wire:click.prevent="toggleReplying()" aria-controls="comment-reply-form-{{ $comment->id }}" - aria-expanded="{{ $this->isReplying ? 'true' : 'false' }}"> + aria-expanded="{{ json_encode($isReplying) }}"> {{ trans('Reply') }} diff --git a/resources/views/livewire/flags/flag-component.blade.php b/resources/views/livewire/flags/flag-component.blade.php index 9d9e6c47..6f14a6ec 100644 --- a/resources/views/livewire/flags/flag-component.blade.php +++ b/resources/views/livewire/flags/flag-component.blade.php @@ -1,9 +1,10 @@ -
+
{{ $titleText }} + @if ($userFlag === null) + @endif - @foreach ($flagReasons as $reason) -
-
+
+ @if ($userFlag !== null) + @endif - -
- + + + +
+ +@script + +@endscript \ No newline at end of file diff --git a/resources/views/posts/show.blade.php b/resources/views/posts/show.blade.php index 9ba8b775..52c81da7 100644 --- a/resources/views/posts/show.blade.php +++ b/resources/views/posts/show.blade.php @@ -48,7 +48,10 @@
- +
@if ($post->is_archived === false) diff --git a/vite.config.js b/vite.config.js index d64eca2b..a1b84b8a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -35,7 +35,7 @@ export default defineConfig({ server: { host: serverHost, allowedHosts, - hmr: { host: serverHost }, + hmr: { host: appHost }, cors: true, strictPort: true, port: serverPort,