15 Commits

Author SHA1 Message Date
ManukMinasyan
8e5d25686c Update CHANGELOG 2026-03-31 21:31:29 +00:00
ManukMinasyan
bbba63c57b Fix styling 2026-03-31 21:31:05 +00:00
Manuk
f9f9950cde Merge pull request #10 from Ilyapashayan20/fix/multiple-bugs
fix: show reply instantly after adding without page refresh
2026-04-01 01:30:42 +04:00
ilyapashayan
bb88583f09 fix: match mention badge style exactly to Filament RichEditor spec
Filament uses: bg-primary-50 text-primary-600 rounded px-1 font-medium
dark: bg-primary-400/10 text-primary-400 — no border, 0.25rem radius.
Previous commit added incorrect border/padding/font-weight overrides.
2026-04-01 01:24:25 +04:00
ilyapashayan
4b783e437f improve: polish mention badge styling with dark mode support
Add border, adjust sizing, font-weight and transition for both
light (primary-50 bg / primary-700 text / primary-200 border)
and dark (15% primary bg / primary-300 text / 30% primary border)
themes using Filament CSS variables.
2026-04-01 01:22:37 +04:00
ilyapashayan
d189743a9c fix: show user-friendly error on file upload failure (413)
Livewire fires a livewire-upload-error JS event when an upload fails,
including 413 responses from the server. Add x-on: Alpine listeners
on the comment and reply forms to display an error message instead of
silently failing. Use x-on: instead of @ shorthand to avoid Blade
parsing @livewire as a directive.
2026-04-01 01:10:13 +04:00
ilyapashayan
541d11ab90 fix: preserve mention data attributes through HTML sanitization
Filament's sanitizer strips data-id, data-label and data-char from
mention spans, breaking both display (unstyled @mention) and editing
(@-only shown in RichEditor). Register a package-scoped sanitizer that
explicitly allows these attributes on span elements.

Also fix double-replacement bug in renderBodyWithMentions() where both
the rich-editor regex and str_replace fallback could run on the same
mention, producing nested styled spans.
2026-04-01 01:10:05 +04:00
ilyapashayan
48fbd3c9d7 fix: use CSS variables for mention styling so dark mode and custom colors work 2026-03-30 19:20:50 +04:00
ilyapashayan
e7daa25fc2 fix: match mention styling exactly to RichEditor for custom primary color support 2026-03-30 19:17:40 +04:00
ilyapashayan
bff68f87a3 fix: render mentions by replacing spans directly instead of RichContentRenderer 2026-03-30 19:15:38 +04:00
ilyapashayan
583b49125f fix: add InteractsWithActions so RichEditor link toolbar works 2026-03-30 19:03:43 +04:00
ilyapashayan
5e44a4051a fix: eager load 2nd level replies so all nesting levels render correctly 2026-03-30 19:00:52 +04:00
ilyapashayan
812556cba2 fix: include replies in comment count and cascade delete to replies 2026-03-30 18:59:07 +04:00
ilyapashayan
20dba18e8e fix: show reply instantly after adding without page refresh 2026-03-30 18:48:11 +04:00
manukminasyan
a5bf29d6c2 fix: use gray badge color for comment count 2026-03-27 21:44:18 +04:00
12 changed files with 161 additions and 31 deletions

View File

@@ -4,3 +4,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.0.0-alpha.4 - 2026-03-31
<!-- Release notes generated using configuration in .github/release.yml at 1.x -->
### What's Changed
#### Other Changes
* fix: show reply instantly after adding without page refresh by @Ilyapashayan20 in https://github.com/relaticle/comments/pull/10
### New Contributors
* @Ilyapashayan20 made their first contribution in https://github.com/relaticle/comments/pull/10
**Full Changelog**: https://github.com/relaticle/comments/compare/v1.0.0-alpha.3...v1.0.0-alpha.4

View File

@@ -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));
}

View File

@@ -104,7 +104,11 @@
{{-- Reply form --}} {{-- Reply form --}}
@if ($isReplying) @if ($isReplying)
<div class="mt-3"> <div class="mt-3"
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"
>
{{ $this->replyForm }} {{ $this->replyForm }}
@if (!empty($replyAttachments)) @if (!empty($replyAttachments))
@@ -120,6 +124,7 @@
@error('replyAttachments.*') @error('replyAttachments.*')
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p> <p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
@enderror @enderror
<p x-show="uploadError" x-text="uploadError" class="mt-1 text-sm text-danger-600 dark:text-danger-400"></p>
@endif @endif
<div class="mt-2 flex items-center justify-between"> <div class="mt-2 flex items-center justify-between">

View File

@@ -2,11 +2,14 @@
@if (!\Relaticle\Comments\CommentsConfig::isBroadcastingEnabled()) @if (!\Relaticle\Comments\CommentsConfig::isBroadcastingEnabled())
wire:poll.{{ \Relaticle\Comments\CommentsConfig::getPollingInterval() }} wire:poll.{{ \Relaticle\Comments\CommentsConfig::getPollingInterval() }}
@endif @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 --}} {{-- Sort toggle --}}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300"> <h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Comments ({{ $this->totalCount }}) Comments ({{ $this->allCommentsCount }})
</h3> </h3>
@auth @auth
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -76,6 +79,7 @@
@error('attachments.*') @error('attachments.*')
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p> <p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
@enderror @enderror
<p x-show="uploadError" x-text="uploadError" class="mt-1 text-sm text-danger-600 dark:text-danger-400"></p>
@endif @endif
<div class="mt-2 flex items-center justify-between"> <div class="mt-2 flex items-center justify-between">

View File

@@ -2,6 +2,8 @@
namespace Relaticle\Comments; namespace Relaticle\Comments;
use Filament\Support\Assets\Css;
use Filament\Support\Facades\FilamentAsset;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
@@ -16,6 +18,8 @@ use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Livewire\Reactions; use Relaticle\Comments\Livewire\Reactions;
use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider; use Spatie\LaravelPackageTools\PackageServiceProvider;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
class CommentsServiceProvider extends PackageServiceProvider class CommentsServiceProvider extends PackageServiceProvider
{ {
@@ -49,6 +53,27 @@ class CommentsServiceProvider extends PackageServiceProvider
MentionResolver::class, MentionResolver::class,
fn () => new (CommentsConfig::getMentionResolver()) 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 public function packageBooted(): void
@@ -64,5 +89,9 @@ class CommentsServiceProvider extends PackageServiceProvider
Livewire::component('comments', Comments::class); Livewire::component('comments', Comments::class);
Livewire::component('comment-item', CommentItem::class); Livewire::component('comment-item', CommentItem::class);
Livewire::component('reactions', Reactions::class); Livewire::component('reactions', Reactions::class);
FilamentAsset::register([
Css::make('comments', __DIR__.'/../resources/css/comments.css'),
], 'relaticle/comments');
} }
} }

View File

@@ -36,7 +36,8 @@ class CommentsAction extends Action
$count = $record->commentCount(); $count = $record->commentCount();
return $count > 0 ? $count : null; return $count > 0 ? $count : null;
}); })
->badgeColor('gray');
} }
public static function getDefaultName(): ?string public static function getDefaultName(): ?string

View File

@@ -2,6 +2,8 @@
namespace Relaticle\Comments\Livewire; namespace Relaticle\Comments\Livewire;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\RichEditor;
use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms; use Filament\Forms\Contracts\HasForms;
@@ -17,8 +19,9 @@ use Relaticle\Comments\Events\CommentUpdated;
use Relaticle\Comments\Mentions\MentionParser; use Relaticle\Comments\Mentions\MentionParser;
use Relaticle\Comments\Models\Comment; use Relaticle\Comments\Models\Comment;
class CommentItem extends Component implements HasForms class CommentItem extends Component implements HasActions, HasForms
{ {
use InteractsWithActions;
use InteractsWithForms; use InteractsWithForms;
use WithFileUploads; use WithFileUploads;
@@ -180,6 +183,8 @@ class CommentItem extends Component implements HasForms
app(MentionParser::class)->syncMentions($reply); 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->dispatch('commentUpdated');
$this->isReplying = false; $this->isReplying = false;

View File

@@ -2,6 +2,8 @@
namespace Relaticle\Comments\Livewire; namespace Relaticle\Comments\Livewire;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\RichEditor;
use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms; use Filament\Forms\Contracts\HasForms;
@@ -19,8 +21,9 @@ use Relaticle\Comments\Mentions\MentionParser;
use Relaticle\Comments\Models\Comment; use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Models\Subscription; use Relaticle\Comments\Models\Subscription;
class Comments extends Component implements HasForms class Comments extends Component implements HasActions, HasForms
{ {
use InteractsWithActions;
use InteractsWithForms; use InteractsWithForms;
use WithFileUploads; use WithFileUploads;
@@ -68,7 +71,7 @@ class Comments extends Component implements HasForms
{ {
return $this->model return $this->model
->topLevelComments() ->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) ->orderBy('created_at', $this->sortDirection)
->take($this->loadedCount) ->take($this->loadedCount)
->get(); ->get();
@@ -80,6 +83,12 @@ class Comments extends Component implements HasForms
return $this->model->topLevelComments()->count(); return $this->model->topLevelComments()->count();
} }
#[Computed]
public function allCommentsCount(): int
{
return $this->model->commentCount();
}
#[Computed] #[Computed]
public function hasMore(): bool public function hasMore(): bool
{ {
@@ -203,7 +212,7 @@ class Comments extends Component implements HasForms
public function refreshComments(): void public function refreshComments(): void
{ {
unset($this->comments, $this->totalCount, $this->hasMore); unset($this->comments, $this->totalCount, $this->hasMore, $this->allCommentsCount);
} }
public function render(): View public function render(): View

View File

@@ -2,8 +2,6 @@
namespace Relaticle\Comments\Models; 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\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; 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\MorphTo;
use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Relaticle\Comments\CommentsConfig; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Database\Factories\CommentFactory; use Relaticle\Comments\Database\Factories\CommentFactory;
@@ -25,7 +22,11 @@ class Comment extends Model
parent::boot(); parent::boot();
static::saving(function (self $comment): void { 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 { static::forceDeleting(function (self $comment): void {
@@ -145,33 +146,23 @@ class Comment extends Model
{ {
$body = $this->body; $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(); $mentionNames = $this->mentions->pluck('name')->filter()->unique();
foreach ($mentionNames as $name) { foreach ($mentionNames as $name) {
$escapedName = e($name); $escapedName = e($name);
$styledSpan = '<span class="comment-mention inline rounded bg-primary-50 px-1 font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">@'.$escapedName.'</span>'; $styledSpan = '<span class="comment-mention">@'.$escapedName.'</span>';
$body = str_replace("&#64;{$name}", $styledSpan, $body); $pattern = '/<(?:span|a)[^>]*data-type="mention"[^>]*>@?'.preg_quote($escapedName, '/').'<\/(?:span|a)>/';
$body = str_replace("@{$name}", $styledSpan, $body);
if (preg_match($pattern, $body)) {
$body = preg_replace($pattern, $styledSpan, $body);
} else {
// Fallback for plain-text mentions
$body = str_replace("&#64;{$name}", $styledSpan, $body);
$body = str_replace("@{$name}", $styledSpan, $body);
}
} }
return $body; return $body;
} }
protected function hasRichEditorMentions(string $body): bool
{
return str_contains($body, 'data-type="mention"') || str_contains($body, '<p>') || str_contains($body, '<br');
}
} }

View File

@@ -155,6 +155,26 @@ it('strips onclick handler from elements', function () {
expect($comment->body)->toContain('click me'); expect($comment->body)->toContain('click me');
}); });
it('preserves mention data attributes in comment body', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$body = '<span data-type="mention" data-id="1" data-label="max" data-char="@">@max</span>';
$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 () { it('sanitizes content submitted through livewire component', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();

View File

@@ -51,6 +51,28 @@ it('renders multiple mentions with styled spans', function () {
expect($rendered)->toContain('comment-mention'); 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' => '<p><span data-type="mention" data-id="'.$alice->id.'" data-label="Alice" data-char="@">@Alice</span> said hi</p>',
]);
$comment->mentions()->attach($alice->id, ['commenter_type' => $alice->getMorphClass()]);
$rendered = $comment->renderBodyWithMentions();
expect($rendered)->toContain('comment-mention');
expect($rendered)->toContain('@Alice</span>');
expect($rendered)->not->toContain('data-type="mention"');
});
it('does not style non-mentioned @text', function () { it('does not style non-mentioned @text', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();

View File

@@ -10,6 +10,17 @@ use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Tests\Models\Post; use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User; 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 = '<p>Hello <span data-type="mention" data-id="'.$john->id.'" data-label="john" data-char="@">@john</span></p>';
$result = $parser->parse($body);
expect($result)->toHaveCount(1);
expect($result->first())->toBe($john->id);
});
it('parses @username from plain text body', function () { it('parses @username from plain text body', function () {
User::factory()->create(['name' => 'john']); User::factory()->create(['name' => 'john']);
User::factory()->create(['name' => 'jane']); User::factory()->create(['name' => 'jane']);