diff --git a/resources/css/comments.css b/resources/css/comments.css new file mode 100644 index 0000000..ed75f65 --- /dev/null +++ b/resources/css/comments.css @@ -0,0 +1,18 @@ +.comment-mention { + background-color: rgb(var(--primary-50)); + color: rgb(var(--primary-600)); + margin-top: 0; + margin-bottom: 0; + display: inline-block; + border-radius: 0.25rem; + padding-left: 0.25rem; + padding-right: 0.25rem; + font-weight: 500; + white-space: nowrap; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.dark .comment-mention { + background-color: rgb(var(--primary-400) / 0.1); + color: rgb(var(--primary-400)); +} diff --git a/resources/views/livewire/comment-item.blade.php b/resources/views/livewire/comment-item.blade.php index 9a6c1d8..f6a6646 100644 --- a/resources/views/livewire/comment-item.blade.php +++ b/resources/views/livewire/comment-item.blade.php @@ -104,7 +104,11 @@ {{-- Reply form --}} @if ($isReplying) -
+
{{ $this->replyForm }} @if (!empty($replyAttachments)) @@ -120,6 +124,7 @@ @error('replyAttachments.*')

{{ $message }}

@enderror +

@endif
diff --git a/resources/views/livewire/comments.blade.php b/resources/views/livewire/comments.blade.php index 3764b17..1087cae 100644 --- a/resources/views/livewire/comments.blade.php +++ b/resources/views/livewire/comments.blade.php @@ -2,11 +2,14 @@ @if (!\Relaticle\Comments\CommentsConfig::isBroadcastingEnabled()) wire:poll.{{ \Relaticle\Comments\CommentsConfig::getPollingInterval() }} @endif + x-data="{ uploadError: null }" + x-on:livewire-upload-error.window="uploadError = '{{ __('File upload failed. The file may be too large or an unsupported type.') }}'" + x-on:livewire-upload-start.window="uploadError = null" > {{-- Sort toggle --}}

- Comments ({{ $this->totalCount }}) + Comments ({{ $this->allCommentsCount }})

@auth
@@ -76,6 +79,7 @@ @error('attachments.*')

{{ $message }}

@enderror +

@endif
diff --git a/src/CommentsServiceProvider.php b/src/CommentsServiceProvider.php index 94e5f58..2418923 100644 --- a/src/CommentsServiceProvider.php +++ b/src/CommentsServiceProvider.php @@ -2,6 +2,8 @@ namespace Relaticle\Comments; +use Filament\Support\Assets\Css; +use Filament\Support\Facades\FilamentAsset; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; @@ -16,6 +18,8 @@ use Relaticle\Comments\Livewire\Comments; use Relaticle\Comments\Livewire\Reactions; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; +use Symfony\Component\HtmlSanitizer\HtmlSanitizer; +use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; class CommentsServiceProvider extends PackageServiceProvider { @@ -49,6 +53,27 @@ class CommentsServiceProvider extends PackageServiceProvider MentionResolver::class, fn () => new (CommentsConfig::getMentionResolver()) ); + + $this->app->scoped( + 'comments.html_sanitizer', + fn (): HtmlSanitizer => new HtmlSanitizer( + (new HtmlSanitizerConfig) + ->allowSafeElements() + ->allowRelativeLinks() + ->allowRelativeMedias() + ->allowAttribute('class', allowedElements: '*') + ->allowAttribute('data-color', allowedElements: '*') + ->allowAttribute('data-from-breakpoint', allowedElements: '*') + ->allowAttribute('data-type', allowedElements: '*') + ->allowAttribute('data-id', allowedElements: 'span') + ->allowAttribute('data-label', allowedElements: 'span') + ->allowAttribute('data-char', allowedElements: 'span') + ->allowAttribute('style', allowedElements: '*') + ->allowAttribute('width', allowedElements: 'img') + ->allowAttribute('height', allowedElements: 'img') + ->withMaxInputLength(500000) + ), + ); } public function packageBooted(): void @@ -64,5 +89,9 @@ class CommentsServiceProvider extends PackageServiceProvider Livewire::component('comments', Comments::class); Livewire::component('comment-item', CommentItem::class); Livewire::component('reactions', Reactions::class); + + FilamentAsset::register([ + Css::make('comments', __DIR__.'/../resources/css/comments.css'), + ], 'relaticle/comments'); } } diff --git a/src/Livewire/CommentItem.php b/src/Livewire/CommentItem.php index ead7671..def25ef 100644 --- a/src/Livewire/CommentItem.php +++ b/src/Livewire/CommentItem.php @@ -2,6 +2,8 @@ namespace Relaticle\Comments\Livewire; +use Filament\Actions\Concerns\InteractsWithActions; +use Filament\Actions\Contracts\HasActions; use Filament\Forms\Components\RichEditor; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; @@ -17,9 +19,10 @@ use Relaticle\Comments\Events\CommentUpdated; use Relaticle\Comments\Mentions\MentionParser; use Relaticle\Comments\Models\Comment; -class CommentItem extends Component implements HasForms +class CommentItem extends Component implements HasForms, HasActions { use InteractsWithForms; + use InteractsWithActions; use WithFileUploads; public Comment $comment; @@ -180,6 +183,8 @@ class CommentItem extends Component implements HasForms app(MentionParser::class)->syncMentions($reply); + $this->comment->load(['replies.commenter', 'replies.mentions', 'replies.attachments', 'replies.reactions.commenter', 'replies.replies.commenter', 'replies.replies.mentions', 'replies.replies.attachments', 'replies.replies.reactions.commenter']); + $this->dispatch('commentUpdated'); $this->isReplying = false; diff --git a/src/Livewire/Comments.php b/src/Livewire/Comments.php index bfbe6bc..85ea251 100644 --- a/src/Livewire/Comments.php +++ b/src/Livewire/Comments.php @@ -2,6 +2,8 @@ namespace Relaticle\Comments\Livewire; +use Filament\Actions\Concerns\InteractsWithActions; +use Filament\Actions\Contracts\HasActions; use Filament\Forms\Components\RichEditor; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; @@ -19,9 +21,10 @@ use Relaticle\Comments\Mentions\MentionParser; use Relaticle\Comments\Models\Comment; use Relaticle\Comments\Models\Subscription; -class Comments extends Component implements HasForms +class Comments extends Component implements HasForms, HasActions { use InteractsWithForms; + use InteractsWithActions; use WithFileUploads; public Model $model; @@ -68,7 +71,7 @@ class Comments extends Component implements HasForms { return $this->model ->topLevelComments() - ->with(['commenter', 'mentions', 'attachments', 'reactions.commenter', 'replies.commenter', 'replies.mentions', 'replies.attachments', 'replies.reactions.commenter']) + ->with(['commenter', 'mentions', 'attachments', 'reactions.commenter', 'replies.commenter', 'replies.mentions', 'replies.attachments', 'replies.reactions.commenter', 'replies.replies.commenter', 'replies.replies.mentions', 'replies.replies.attachments', 'replies.replies.reactions.commenter']) ->orderBy('created_at', $this->sortDirection) ->take($this->loadedCount) ->get(); @@ -80,6 +83,12 @@ class Comments extends Component implements HasForms return $this->model->topLevelComments()->count(); } + #[Computed] + public function allCommentsCount(): int + { + return $this->model->commentCount(); + } + #[Computed] public function hasMore(): bool { @@ -203,7 +212,7 @@ class Comments extends Component implements HasForms public function refreshComments(): void { - unset($this->comments, $this->totalCount, $this->hasMore); + unset($this->comments, $this->totalCount, $this->hasMore, $this->allCommentsCount); } public function render(): View diff --git a/src/Models/Comment.php b/src/Models/Comment.php index eb3714d..a03f50a 100644 --- a/src/Models/Comment.php +++ b/src/Models/Comment.php @@ -2,8 +2,6 @@ namespace Relaticle\Comments\Models; -use Filament\Forms\Components\RichEditor\MentionProvider; -use Filament\Forms\Components\RichEditor\RichContentRenderer; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -11,7 +9,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Database\Eloquent\SoftDeletes; -use Illuminate\Support\Str; use Relaticle\Comments\CommentsConfig; use Relaticle\Comments\Database\Factories\CommentFactory; @@ -25,7 +22,11 @@ class Comment extends Model parent::boot(); static::saving(function (self $comment): void { - $comment->body = Str::sanitizeHtml($comment->body); + $comment->body = app('comments.html_sanitizer')->sanitize($comment->body); + }); + + static::deleting(function (self $comment): void { + $comment->replies()->each(fn ($reply) => $reply->delete()); }); static::forceDeleting(function (self $comment): void { @@ -145,33 +146,23 @@ class Comment extends Model { $body = $this->body; - if ($this->hasRichEditorMentions($body)) { - return RichContentRenderer::make($body) - ->mentions([ - MentionProvider::make('@') - ->getLabelsUsing(fn (array $ids): array => CommentsConfig::getCommenterModel()::query() - ->whereIn('id', $ids) - ->pluck('name', 'id') - ->all()), - ]) - ->toHtml(); - } - $mentionNames = $this->mentions->pluck('name')->filter()->unique(); foreach ($mentionNames as $name) { $escapedName = e($name); - $styledSpan = '@'.$escapedName.''; + $styledSpan = '@'.$escapedName.''; - $body = str_replace("@{$name}", $styledSpan, $body); - $body = str_replace("@{$name}", $styledSpan, $body); + $pattern = '/<(?:span|a)[^>]*data-type="mention"[^>]*>@?' . preg_quote($escapedName, '/') . '<\/(?:span|a)>/'; + + if (preg_match($pattern, $body)) { + $body = preg_replace($pattern, $styledSpan, $body); + } else { + // Fallback for plain-text mentions + $body = str_replace("@{$name}", $styledSpan, $body); + $body = str_replace("@{$name}", $styledSpan, $body); + } } return $body; } - - protected function hasRichEditorMentions(string $body): bool - { - return str_contains($body, 'data-type="mention"') || str_contains($body, '

') || str_contains($body, 'body)->toContain('click me'); }); +it('preserves mention data attributes in comment body', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $body = '@max'; + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'commenter_id' => $user->getKey(), + 'commenter_type' => $user->getMorphClass(), + 'body' => $body, + ]); + + expect($comment->body)->toContain('data-type="mention"'); + expect($comment->body)->toContain('data-id="1"'); + expect($comment->body)->toContain('data-label="max"'); + expect($comment->body)->toContain('data-char="@"'); +}); + it('sanitizes content submitted through livewire component', function () { $user = User::factory()->create(); $post = Post::factory()->create(); diff --git a/tests/Feature/MentionDisplayTest.php b/tests/Feature/MentionDisplayTest.php index fe96f86..c20124f 100644 --- a/tests/Feature/MentionDisplayTest.php +++ b/tests/Feature/MentionDisplayTest.php @@ -51,6 +51,28 @@ it('renders multiple mentions with styled spans', function () { expect($rendered)->toContain('comment-mention'); }); +it('renders rich-editor mention span as styled mention', function () { + $user = User::factory()->create(); + $alice = User::factory()->create(['name' => 'Alice']); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'commenter_id' => $user->getKey(), + 'commenter_type' => $user->getMorphClass(), + 'body' => '

@Alice said hi

', + ]); + + $comment->mentions()->attach($alice->id, ['commenter_type' => $alice->getMorphClass()]); + + $rendered = $comment->renderBodyWithMentions(); + + expect($rendered)->toContain('comment-mention'); + expect($rendered)->toContain('@Alice'); + expect($rendered)->not->toContain('data-type="mention"'); +}); + it('does not style non-mentioned @text', function () { $user = User::factory()->create(); $post = Post::factory()->create(); diff --git a/tests/Feature/MentionParserTest.php b/tests/Feature/MentionParserTest.php index ee7c01a..9fa23fc 100644 --- a/tests/Feature/MentionParserTest.php +++ b/tests/Feature/MentionParserTest.php @@ -10,6 +10,17 @@ use Relaticle\Comments\Models\Comment; use Relaticle\Comments\Tests\Models\Post; use Relaticle\Comments\Tests\Models\User; +it('parses rich-editor mention spans using data-id', function () { + $john = User::factory()->create(['name' => 'john']); + + $parser = app(MentionParser::class); + $body = '

Hello @john

'; + $result = $parser->parse($body); + + expect($result)->toHaveCount(1); + expect($result->first())->toBe($john->id); +}); + it('parses @username from plain text body', function () { User::factory()->create(['name' => 'john']); User::factory()->create(['name' => 'jane']);