Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions config/commentions.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@
'allowed' => ['👍', '❤️', '😂', '😮', '😢', '🤔'],
],

/*
|--------------------------------------------------------------------------
| Threading
|--------------------------------------------------------------------------
|
| Nested (threaded) comment replies. Disabled by default. `max_depth` is
| the deepest reply level allowed — top-level comments are depth 0, so a
| max_depth of 3 permits replies down to depth 3.
|
*/
'threading' => [
'enabled' => env('COMMENTIONS_THREADING_ENABLED', false),

'max_depth' => (int) env('COMMENTIONS_THREADING_MAX_DEPTH', 3),
],

/*
|--------------------------------------------------------------------------
| Subscriptions
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
$table = config('commentions.tables.comments', 'comments');

Schema::table($table, function (Blueprint $blueprint) use ($table) {
$blueprint->foreignId('parent_id')
->nullable()
->after('id')
->constrained($table)
->cascadeOnDelete();
});
}

public function down(): void
{
Schema::table(config('commentions.tables.comments', 'comments'), function (Blueprint $blueprint) {
$blueprint->dropConstrainedForeignId('parent_id');
});
}
};
51 changes: 51 additions & 0 deletions resources/css/commentions.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,54 @@
.mention-item:hover {
@apply comm:bg-gray-100;
}

/*
* Threaded reply connector — a Facebook-style curved elbow line.
* Rendered by the `.commentions-thread` element inside each reply (depth > 0),
* which sits absolutely inside the reply row's left padding gutter.
*/
.commentions-thread {
position: absolute;
inset: 0 auto 0 0;
width: 1.5rem;
pointer-events: none;
}

.commentions-thread::before,
.commentions-thread::after {
content: "";
position: absolute;
left: 0.5rem;
}

/* Vertical trunk running the full height of the reply row. */
.commentions-thread::before {
top: 0;
bottom: 0;
width: 2px;
background-color: #d1d5db;
}

/* Curved elbow branching off the trunk into the reply's avatar. */
.commentions-thread::after {
top: 0;
width: 1rem;
height: 1.5rem;
border-left: 2px solid #d1d5db;
border-bottom: 2px solid #d1d5db;
border-bottom-left-radius: 0.75rem;
}

/* The last reply ends the trunk at its own elbow rather than running on. */
.commentions-replies > :last-child .commentions-thread::before {
bottom: auto;
height: 1.5rem;
}

:where(.dark, .dark *) .commentions-thread::before {
background-color: #4b5563;
}

:where(.dark, .dark *) .commentions-thread::after {
border-color: #4b5563;
}
2 changes: 1 addition & 1 deletion resources/dist/commentions.css

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions resources/lang/ar/comments.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

'cancel' => 'إلغاء',
'delete' => 'حذف',
'reply' => 'رد',
'save' => 'حفظ',
'comment' => 'تعليق',
'add_reaction' => 'إضافة رد فعل',
Expand Down
3 changes: 3 additions & 0 deletions resources/lang/en/comments.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

'cancel' => 'Cancel',
'delete' => 'Delete',
'reply' => 'Reply',
'replies_count' => '{0} No replies|{1} :count reply|[2,*] :count replies',
'hide_replies' => 'Hide replies',
'save' => 'Save',
'comment' => 'Comment',
'add_reaction' => 'Add Reaction',
Expand Down
1 change: 1 addition & 0 deletions resources/lang/es/comments.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

'cancel' => 'Cancelar',
'delete' => 'Eliminar',
'reply' => 'Responder',
'save' => 'Guardar',
'comment' => 'Comentar',
'add_reaction' => 'Agregar reacción',
Expand Down
1 change: 1 addition & 0 deletions resources/lang/fr/comments.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

'cancel' => 'Annuler',
'delete' => 'Supprimer',
'reply' => 'Répondre',
'save' => 'Enregistrer',
'comment' => 'Commenter',
'add_reaction' => 'Ajouter une réaction',
Expand Down
1 change: 1 addition & 0 deletions resources/lang/nl/comments.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

'cancel' => 'Annuleren',
'delete' => 'Verwijderen',
'reply' => 'Beantwoorden',
'save' => 'Opslaan',
'comment' => 'Opmerking',
'add_reaction' => 'Reactie toevoegen',
Expand Down
1 change: 1 addition & 0 deletions resources/lang/ro/comments.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

'cancel' => 'Anulare',
'delete' => 'Șterge',
'reply' => 'Răspunde',
'save' => 'Salvare',
'comment' => 'Comentariu',
'add_reaction' => 'Adaugă reacție',
Expand Down
99 changes: 95 additions & 4 deletions resources/views/comment.blade.php
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
@use('\Kirschbaum\Commentions\Config')

<div class="comm:flex comm:items-start comm:gap-x-4 comm:border comm:border-gray-300 comm:dark:border-gray-700 comm:p-4 comm:rounded-lg comm:shadow-sm comm:mb-2" id="filament-comment-{{ $comment->getId() }}">
<div
@class([
'comm:flex comm:items-start',
'comm:gap-x-4 comm:border comm:border-gray-300 comm:dark:border-gray-700 comm:p-4 comm:rounded-lg comm:shadow-sm comm:mb-2' => $depth === 0,
'comm:relative comm:gap-x-3 comm:py-2 comm:pl-6' => $depth > 0,
])
id="filament-comment-{{ $comment->getId() }}"
>
@if ($depth > 0)
<div class="commentions-thread" aria-hidden="true"></div>
@endif

@if ($avatar = $comment->getAuthorAvatar())
<img
src="{{ $comment->getAuthorAvatar() }}"
alt="{{ __('commentions::comments.user_avatar_alt') }}"
class="comm:w-10 comm:h-10 comm:rounded-full comm:mt-0.5 comm:object-cover comm:object-center"
@class([
'comm:rounded-full comm:mt-0.5 comm:object-cover comm:object-center',
'comm:w-10 comm:h-10' => $depth === 0,
'comm:w-7 comm:h-7' => $depth > 0,
])
/>
@else
<div class="comm:w-10 comm:h-10 comm:rounded-full comm:mt-0.5 "></div>
<div @class([
'comm:rounded-full comm:mt-0.5',
'comm:w-10 comm:h-10' => $depth === 0,
'comm:w-7 comm:h-7' => $depth > 0,
])></div>
@endif

<div class="comm:flex-1">
Expand All @@ -34,8 +53,17 @@ class="comm:text-xs comm:text-gray-300 comm:ml-1"
@endif
</div>

@if ($comment->isComment() && Config::resolveAuthenticatedUser()?->canAny(['update', 'delete'], $comment))
@if ($comment->isComment() && ($this->canReply() || Config::resolveAuthenticatedUser()?->canAny(['update', 'delete'], $comment)))
<div class="comm:flex comm:gap-x-1">
@if ($this->canReply())
<x-filament::icon-button
icon="heroicon-s-arrow-uturn-left"
wire:click="reply"
size="xs"
color="gray"
/>
@endif

@if (Config::resolveAuthenticatedUser()?->can('update', $comment))
<x-filament::icon-button
icon="heroicon-s-pencil-square"
Expand Down Expand Up @@ -124,6 +152,69 @@ class="comm:text-xs comm:text-gray-300 comm:ml-1"
:wire:key="'reaction-manager-' . $comment->getId()"
/>
@endif

@if ($replying)
<div class="comm:mt-3">
<div class="tip-tap-container comm:mb-2" wire:ignore>
<div x-data="editor(@js($commentBody), @js($mentionables), 'comment', @js(__('commentions::comments.placeholder')), @js($this->getTipTapCssClasses()), @js($commentionsComponentPrefix . 'comment'))">
<div x-ref="element"></div>
</div>
</div>

<div class="comm:flex comm:gap-x-2">
<x-filament::button wire:click="saveReply" size="sm">
{{ __('commentions::comments.reply') }}
</x-filament::button>

<x-filament::button wire:click="cancelReplying" size="sm" color="gray">
{{ __('commentions::comments.cancel') }}
</x-filament::button>
</div>
</div>
@endif

@if ($comment->isComment() && config('commentions.threading.enabled', false) && $comment->replies->isNotEmpty())
<div x-data="{ expanded: true }" class="comm:mt-3">
<button
type="button"
@click="expanded = !expanded"
:aria-expanded="expanded ? 'true' : 'false'"
aria-controls="comment-replies-{{ $comment->getId() }}"
class="comm:flex comm:items-center comm:gap-x-1 comm:text-xs comm:font-medium comm:text-gray-500 comm:dark:text-gray-400 comm:hover:text-gray-700 comm:dark:hover:text-gray-200 comm:focus:outline-none comm:focus-visible:ring-2 comm:focus-visible:ring-blue-500 comm:rounded comm:mb-1"
>
<x-filament::icon
icon="heroicon-m-chevron-down"
class="comm:w-4 comm:h-4 comm:transition-transform"
x-bind:class="expanded ? '' : 'comm:-rotate-90'"
/>
<span x-show="expanded">{{ __('commentions::comments.hide_replies') }}</span>
<span x-show="!expanded" x-cloak>{{ trans_choice('commentions::comments.replies_count', $comment->repliesCount(), ['count' => $comment->repliesCount()]) }}</span>
</button>

<div
id="comment-replies-{{ $comment->getId() }}"
role="group"
aria-label="{{ trans_choice('commentions::comments.replies_count', $comment->repliesCount(), ['count' => $comment->repliesCount()]) }}"
x-show="expanded"
x-collapse
@class([
'commentions-replies',
'comm:pl-3' => $this->shouldIndentReplies(),
])
>
@foreach ($comment->replies as $reply)
<livewire:dynamic-component
:component="$commentionsComponentPrefix . 'comment'"
:key="'reply-' . $reply->getContentHash()"
:comment="$reply"
:depth="$depth + 1"
:mentionables="$mentionables"
:tip-tap-css-classes="$tipTapCssClasses"
/>
@endforeach
</div>
</div>
@endif
@endif
</div>
</div>
12 changes: 9 additions & 3 deletions src/Actions/SaveComment.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,23 @@ class SaveComment
/**
* @throws AuthorizationException
*/
public function __invoke(Model $commentable, Commenter $author, string $body): Comment
public function __invoke(Model $commentable, Commenter $author, string $body, ?int $parentId = null): Comment
{
if ($author->cannot('create', Config::getCommentModel())) {
throw new AuthorizationException('Cannot create comment');
}

$comment = $commentable->comments()->create([
$attributes = [
'body' => $body,
'author_id' => $author->getKey(),
'author_type' => $author->getMorphClass(),
]);
];

if ($parentId !== null) {
$attributes['parent_id'] = $parentId;
}

$comment = $commentable->comments()->create($attributes);

$this->dispatchEvents($comment);

Expand Down
49 changes: 49 additions & 0 deletions src/Comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Collection;
Expand All @@ -23,6 +24,7 @@

/**
* @property int $id
* @property int|null $parent_id
* @property string $body
* @property string $body_markdown
* @property string $body_parsed
Expand All @@ -37,6 +39,7 @@ class Comment extends Model implements RenderableComment
use HasFactory;

protected $fillable = [
'parent_id',
'body',
'author_type',
'author_id',
Expand Down Expand Up @@ -165,6 +168,45 @@ public function reactions(): HasMany
return $this->hasMany(CommentReaction::class);
}

/** @return BelongsTo<self, self> */
public function parent(): BelongsTo
{
return $this->belongsTo(static::class, 'parent_id');
}

/** @return HasMany<self> */
public function replies(): HasMany
{
return $this->hasMany(static::class, 'parent_id')->oldest();
}

/**
* The nesting depth of this comment — 0 for a top-level comment.
*/
public function depth(): int
{
$depth = 0;
$parent = $this->parent;

while ($parent !== null) {
$depth++;
$parent = $parent->parent;
}

return $depth;
}

/**
* Total number of descendant comments across all nested reply levels.
*/
public function repliesCount(): int
{
return $this->replies->reduce(
fn (int $carry, self $reply): int => $carry + 1 + $reply->repliesCount(),
0,
);
}

public function toggleReaction(string $reaction): void
{
ToggleCommentReaction::run($this, $reaction, Config::resolveAuthenticatedUser());
Expand All @@ -183,6 +225,13 @@ public function getContentHash(): string
]));
}

protected static function booted(): void
{
static::deleting(function (Comment $comment): void {
$comment->replies()->get()->each->delete();
});
}

protected static function newFactory()
{
return CommentFactory::new();
Expand Down
Loading
Loading