@@ -76,6 +79,7 @@
@error('attachments.*')
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 = '';
+ $styledSpan = '';
- $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']);