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 @@