feat: initial release of relaticle/comments
Filament comments package with: - Polymorphic commenting on any Eloquent model - Threaded replies with configurable depth - @mentions with autocomplete and user search - Emoji reactions with toggle and who-reacted tooltips - File attachments via Livewire uploads - Reply and mention notifications via Filament notification system - Thread subscriptions for notification control - Real-time broadcasting (opt-in Echo) with polling fallback - Dark mode support - CommentsAction, CommentsTableAction, CommentsEntry for Filament integration - 204 tests, 421 assertions
This commit is contained in:
159
src/Comment.php
Normal file
159
src/Comment.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
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\Database\Eloquent\Relations\MorphToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
use Relaticle\Comments\Database\Factories\CommentFactory;
|
||||
|
||||
class Comment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::saving(function (self $comment): void {
|
||||
$comment->body = Str::sanitizeHtml($comment->body);
|
||||
});
|
||||
|
||||
static::forceDeleting(function (self $comment): void {
|
||||
$comment->attachments()->delete();
|
||||
$comment->reactions()->delete();
|
||||
$comment->mentions()->detach();
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'body',
|
||||
'parent_id',
|
||||
'user_id',
|
||||
'user_type',
|
||||
'edited_at',
|
||||
];
|
||||
|
||||
public function getTable(): string
|
||||
{
|
||||
return Config::getCommentTable();
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'edited_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function newFactory(): CommentFactory
|
||||
{
|
||||
return CommentFactory::new();
|
||||
}
|
||||
|
||||
public function commentable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function user(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Config::getCommentModel(), 'parent_id');
|
||||
}
|
||||
|
||||
public function replies(): HasMany
|
||||
{
|
||||
return $this->hasMany(Config::getCommentModel(), 'parent_id');
|
||||
}
|
||||
|
||||
public function reactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CommentReaction::class);
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(CommentAttachment::class);
|
||||
}
|
||||
|
||||
public function mentions(): MorphToMany
|
||||
{
|
||||
return $this->morphedByMany(
|
||||
Config::getCommenterModel(),
|
||||
'user',
|
||||
'comment_mentions',
|
||||
'comment_id',
|
||||
'user_id',
|
||||
);
|
||||
}
|
||||
|
||||
public function isReply(): bool
|
||||
{
|
||||
return $this->parent_id !== null;
|
||||
}
|
||||
|
||||
public function isTopLevel(): bool
|
||||
{
|
||||
return $this->parent_id === null;
|
||||
}
|
||||
|
||||
public function hasReplies(): bool
|
||||
{
|
||||
return $this->replies()->exists();
|
||||
}
|
||||
|
||||
public function isEdited(): bool
|
||||
{
|
||||
return $this->edited_at !== null;
|
||||
}
|
||||
|
||||
public function canReply(): bool
|
||||
{
|
||||
return $this->depth() < Config::getMaxDepth();
|
||||
}
|
||||
|
||||
public function depth(): int
|
||||
{
|
||||
$depth = 0;
|
||||
$comment = $this;
|
||||
|
||||
while ($comment->parent_id !== null) {
|
||||
$comment = $comment->parent;
|
||||
$depth++;
|
||||
|
||||
if ($depth >= Config::getMaxDepth()) {
|
||||
return Config::getMaxDepth();
|
||||
}
|
||||
}
|
||||
|
||||
return $depth;
|
||||
}
|
||||
|
||||
public function renderBodyWithMentions(): string
|
||||
{
|
||||
$body = $this->body;
|
||||
$mentionNames = $this->mentions->pluck('name')->filter()->unique();
|
||||
|
||||
foreach ($mentionNames as $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>';
|
||||
|
||||
$body = str_replace("@{$name}", $styledSpan, $body);
|
||||
$body = str_replace("@{$name}", $styledSpan, $body);
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
45
src/CommentAttachment.php
Normal file
45
src/CommentAttachment.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
class CommentAttachment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'comment_id',
|
||||
'file_path',
|
||||
'original_name',
|
||||
'mime_type',
|
||||
'size',
|
||||
'disk',
|
||||
];
|
||||
|
||||
public function getTable(): string
|
||||
{
|
||||
return 'comment_attachments';
|
||||
}
|
||||
|
||||
public function comment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Config::getCommentModel());
|
||||
}
|
||||
|
||||
public function isImage(): bool
|
||||
{
|
||||
return str_starts_with($this->mime_type, 'image/');
|
||||
}
|
||||
|
||||
public function url(): string
|
||||
{
|
||||
return Storage::disk($this->disk)->url($this->file_path);
|
||||
}
|
||||
|
||||
public function formattedSize(): string
|
||||
{
|
||||
return Number::fileSize($this->size);
|
||||
}
|
||||
}
|
||||
32
src/CommentReaction.php
Normal file
32
src/CommentReaction.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class CommentReaction extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'comment_id',
|
||||
'user_id',
|
||||
'user_type',
|
||||
'reaction',
|
||||
];
|
||||
|
||||
public function getTable(): string
|
||||
{
|
||||
return 'comment_reactions';
|
||||
}
|
||||
|
||||
public function comment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Config::getCommentModel());
|
||||
}
|
||||
|
||||
public function user(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
73
src/CommentSubscription.php
Normal file
73
src/CommentSubscription.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class CommentSubscription extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'commentable_type',
|
||||
'commentable_id',
|
||||
'user_type',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
public function getTable(): string
|
||||
{
|
||||
return 'comment_subscriptions';
|
||||
}
|
||||
|
||||
public function commentable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function user(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public static function isSubscribed(Model $commentable, Model $user): bool
|
||||
{
|
||||
return static::where([
|
||||
'commentable_type' => $commentable->getMorphClass(),
|
||||
'commentable_id' => $commentable->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'user_id' => $user->getKey(),
|
||||
])->exists();
|
||||
}
|
||||
|
||||
public static function subscribe(Model $commentable, Model $user): void
|
||||
{
|
||||
static::firstOrCreate([
|
||||
'commentable_type' => $commentable->getMorphClass(),
|
||||
'commentable_id' => $commentable->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'user_id' => $user->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function unsubscribe(Model $commentable, Model $user): void
|
||||
{
|
||||
static::where([
|
||||
'commentable_type' => $commentable->getMorphClass(),
|
||||
'commentable_id' => $commentable->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'user_id' => $user->getKey(),
|
||||
])->delete();
|
||||
}
|
||||
|
||||
/** @return Collection<int, Model> */
|
||||
public static function subscribersFor(Model $commentable): Collection
|
||||
{
|
||||
return static::where([
|
||||
'commentable_type' => $commentable->getMorphClass(),
|
||||
'commentable_id' => $commentable->getKey(),
|
||||
])->with('user')->get()->pluck('user')->filter()->values();
|
||||
}
|
||||
}
|
||||
37
src/CommentsPlugin.php
Normal file
37
src/CommentsPlugin.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
class CommentsPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return CommentsServiceProvider::$name;
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
|
||||
public static function get(): static
|
||||
{
|
||||
/** @var static $plugin */
|
||||
$plugin = filament(app(static::class)->getId());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
}
|
||||
68
src/CommentsServiceProvider.php
Normal file
68
src/CommentsServiceProvider.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Livewire;
|
||||
use Relaticle\Comments\Contracts\MentionResolver;
|
||||
use Relaticle\Comments\Events\CommentCreated;
|
||||
use Relaticle\Comments\Events\UserMentioned;
|
||||
use Relaticle\Comments\Listeners\SendCommentRepliedNotification;
|
||||
use Relaticle\Comments\Listeners\SendUserMentionedNotification;
|
||||
use Relaticle\Comments\Livewire\CommentItem;
|
||||
use Relaticle\Comments\Livewire\Comments;
|
||||
use Relaticle\Comments\Livewire\Reactions;
|
||||
use Spatie\LaravelPackageTools\Package;
|
||||
use Spatie\LaravelPackageTools\PackageServiceProvider;
|
||||
|
||||
class CommentsServiceProvider extends PackageServiceProvider
|
||||
{
|
||||
public static string $name = 'comments';
|
||||
|
||||
public static string $viewNamespace = 'comments';
|
||||
|
||||
public function configurePackage(Package $package): void
|
||||
{
|
||||
$package
|
||||
->name(static::$name)
|
||||
->hasConfigFile()
|
||||
->hasViews(static::$viewNamespace)
|
||||
->hasTranslations()
|
||||
->hasMigrations([
|
||||
'create_comments_table',
|
||||
'create_comment_mentions_table',
|
||||
'create_comment_reactions_table',
|
||||
'create_comment_subscriptions_table',
|
||||
'create_comment_attachments_table',
|
||||
]);
|
||||
}
|
||||
|
||||
public function packageRegistered(): void
|
||||
{
|
||||
Relation::morphMap([
|
||||
'comment' => Config::getCommentModel(),
|
||||
]);
|
||||
|
||||
$this->app->bind(
|
||||
MentionResolver::class,
|
||||
fn () => new (Config::getMentionResolver())
|
||||
);
|
||||
}
|
||||
|
||||
public function packageBooted(): void
|
||||
{
|
||||
Gate::policy(
|
||||
Config::getCommentModel(),
|
||||
Config::getPolicyClass(),
|
||||
);
|
||||
|
||||
Event::listen(CommentCreated::class, SendCommentRepliedNotification::class);
|
||||
Event::listen(UserMentioned::class, SendUserMentionedNotification::class);
|
||||
|
||||
Livewire::component('comments', Comments::class);
|
||||
Livewire::component('comment-item', CommentItem::class);
|
||||
Livewire::component('reactions', Reactions::class);
|
||||
}
|
||||
}
|
||||
0
src/Concerns/.gitkeep
Normal file
0
src/Concerns/.gitkeep
Normal file
24
src/Concerns/HasComments.php
Normal file
24
src/Concerns/HasComments.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
trait HasComments
|
||||
{
|
||||
public function comments(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Config::getCommentModel(), 'commentable');
|
||||
}
|
||||
|
||||
public function topLevelComments(): MorphMany
|
||||
{
|
||||
return $this->comments()->whereNull('parent_id');
|
||||
}
|
||||
|
||||
public function commentCount(): int
|
||||
{
|
||||
return $this->comments()->count();
|
||||
}
|
||||
}
|
||||
27
src/Concerns/IsCommenter.php
Normal file
27
src/Concerns/IsCommenter.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Concerns;
|
||||
|
||||
use Filament\Models\Contracts\HasAvatar;
|
||||
use Filament\Models\Contracts\HasName;
|
||||
|
||||
trait IsCommenter
|
||||
{
|
||||
public function getCommentName(): string
|
||||
{
|
||||
if ($this instanceof HasName) {
|
||||
return $this->getFilamentName();
|
||||
}
|
||||
|
||||
return $this->name ?? 'Unknown';
|
||||
}
|
||||
|
||||
public function getCommentAvatarUrl(): ?string
|
||||
{
|
||||
if ($this instanceof HasAvatar) {
|
||||
return $this->getFilamentAvatarUrl();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
157
src/Config.php
Normal file
157
src/Config.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Relaticle\Comments\Mentions\DefaultMentionResolver;
|
||||
use Relaticle\Comments\Policies\CommentPolicy;
|
||||
|
||||
class Config
|
||||
{
|
||||
protected static ?Closure $resolveAuthenticatedUser = null;
|
||||
|
||||
public static function getCommentModel(): string
|
||||
{
|
||||
return config('comments.models.comment', Comment::class);
|
||||
}
|
||||
|
||||
public static function getCommenterModel(): string
|
||||
{
|
||||
return config('comments.commenter.model', User::class);
|
||||
}
|
||||
|
||||
public static function getCommentTable(): string
|
||||
{
|
||||
return config('comments.tables.comments', 'comments');
|
||||
}
|
||||
|
||||
public static function getMaxDepth(): int
|
||||
{
|
||||
return (int) config('comments.threading.max_depth', 2);
|
||||
}
|
||||
|
||||
public static function getPerPage(): int
|
||||
{
|
||||
return (int) config('comments.pagination.per_page', 10);
|
||||
}
|
||||
|
||||
/** @return array<int, array<int, string>> */
|
||||
public static function getEditorToolbar(): array
|
||||
{
|
||||
return (array) config('comments.editor.toolbar', [
|
||||
['bold', 'italic', 'strike', 'link'],
|
||||
['bulletList', 'orderedList'],
|
||||
['codeBlock'],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPolicyClass(): string
|
||||
{
|
||||
return config('comments.policy', CommentPolicy::class);
|
||||
}
|
||||
|
||||
public static function getMentionResolver(): string
|
||||
{
|
||||
return config('comments.mentions.resolver', DefaultMentionResolver::class);
|
||||
}
|
||||
|
||||
public static function getMentionMaxResults(): int
|
||||
{
|
||||
return (int) config('comments.mentions.max_results', 5);
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public static function getReactionEmojiSet(): array
|
||||
{
|
||||
return (array) config('comments.reactions.emoji_set', [
|
||||
'thumbs_up' => "\u{1F44D}",
|
||||
'heart' => "\u{2764}\u{FE0F}",
|
||||
'celebrate' => "\u{1F389}",
|
||||
'laugh' => "\u{1F604}",
|
||||
'thinking' => "\u{1F914}",
|
||||
'sad' => "\u{1F622}",
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public static function getAllowedReactions(): array
|
||||
{
|
||||
return array_keys(static::getReactionEmojiSet());
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public static function getNotificationChannels(): array
|
||||
{
|
||||
return (array) config('comments.notifications.channels', ['database']);
|
||||
}
|
||||
|
||||
public static function areNotificationsEnabled(): bool
|
||||
{
|
||||
return (bool) config('comments.notifications.enabled', true);
|
||||
}
|
||||
|
||||
public static function shouldAutoSubscribe(): bool
|
||||
{
|
||||
return (bool) config('comments.subscriptions.auto_subscribe', true);
|
||||
}
|
||||
|
||||
public static function areAttachmentsEnabled(): bool
|
||||
{
|
||||
return (bool) config('comments.attachments.enabled', true);
|
||||
}
|
||||
|
||||
public static function getAttachmentDisk(): string
|
||||
{
|
||||
return (string) config('comments.attachments.disk', 'public');
|
||||
}
|
||||
|
||||
public static function getAttachmentMaxSize(): int
|
||||
{
|
||||
return (int) config('comments.attachments.max_size', 10240);
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public static function getAttachmentAllowedTypes(): array
|
||||
{
|
||||
return (array) config('comments.attachments.allowed_types', [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function isBroadcastingEnabled(): bool
|
||||
{
|
||||
return (bool) config('comments.broadcasting.enabled', false);
|
||||
}
|
||||
|
||||
public static function getBroadcastChannelPrefix(): string
|
||||
{
|
||||
return (string) config('comments.broadcasting.channel_prefix', 'comments');
|
||||
}
|
||||
|
||||
public static function getPollingInterval(): string
|
||||
{
|
||||
return (string) config('comments.polling.interval', '10s');
|
||||
}
|
||||
|
||||
public static function resolveAuthenticatedUser(): ?object
|
||||
{
|
||||
if (static::$resolveAuthenticatedUser) {
|
||||
return call_user_func(static::$resolveAuthenticatedUser);
|
||||
}
|
||||
|
||||
return auth()->user();
|
||||
}
|
||||
|
||||
public static function resolveAuthenticatedUserUsing(Closure $callback): void
|
||||
{
|
||||
static::$resolveAuthenticatedUser = $callback;
|
||||
}
|
||||
}
|
||||
0
src/Contracts/.gitkeep
Normal file
0
src/Contracts/.gitkeep
Normal file
14
src/Contracts/Commentable.php
Normal file
14
src/Contracts/Commentable.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
interface Commentable
|
||||
{
|
||||
public function comments(): MorphMany;
|
||||
|
||||
public function topLevelComments(): MorphMany;
|
||||
|
||||
public function commentCount(): int;
|
||||
}
|
||||
14
src/Contracts/Commenter.php
Normal file
14
src/Contracts/Commenter.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Contracts;
|
||||
|
||||
interface Commenter
|
||||
{
|
||||
public function getKey();
|
||||
|
||||
public function getMorphClass();
|
||||
|
||||
public function getCommentName(): string;
|
||||
|
||||
public function getCommentAvatarUrl(): ?string;
|
||||
}
|
||||
15
src/Contracts/MentionResolver.php
Normal file
15
src/Contracts/MentionResolver.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
interface MentionResolver
|
||||
{
|
||||
/** @return Collection<int, Model> */
|
||||
public function search(string $query): Collection;
|
||||
|
||||
/** @return Collection<int, Model> */
|
||||
public function resolveByNames(array $names): Collection;
|
||||
}
|
||||
51
src/Events/CommentCreated.php
Normal file
51
src/Events/CommentCreated.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithBroadcasting;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
class CommentCreated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithBroadcasting;
|
||||
use SerializesModels;
|
||||
|
||||
public readonly Model $commentable;
|
||||
|
||||
public function __construct(public readonly Comment $comment)
|
||||
{
|
||||
$this->commentable = $comment->commentable;
|
||||
}
|
||||
|
||||
/** @return array<int, PrivateChannel> */
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
$prefix = Config::getBroadcastChannelPrefix();
|
||||
|
||||
return [
|
||||
new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastWhen(): bool
|
||||
{
|
||||
return Config::isBroadcastingEnabled();
|
||||
}
|
||||
|
||||
/** @return array{comment_id: int|string, commentable_type: string, commentable_id: int|string} */
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'comment_id' => $this->comment->id,
|
||||
'commentable_type' => $this->comment->commentable_type,
|
||||
'commentable_id' => $this->comment->commentable_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
51
src/Events/CommentDeleted.php
Normal file
51
src/Events/CommentDeleted.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithBroadcasting;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
class CommentDeleted implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithBroadcasting;
|
||||
use SerializesModels;
|
||||
|
||||
public readonly Model $commentable;
|
||||
|
||||
public function __construct(public readonly Comment $comment)
|
||||
{
|
||||
$this->commentable = $comment->commentable;
|
||||
}
|
||||
|
||||
/** @return array<int, PrivateChannel> */
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
$prefix = Config::getBroadcastChannelPrefix();
|
||||
|
||||
return [
|
||||
new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastWhen(): bool
|
||||
{
|
||||
return Config::isBroadcastingEnabled();
|
||||
}
|
||||
|
||||
/** @return array{comment_id: int|string, commentable_type: string, commentable_id: int|string} */
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'comment_id' => $this->comment->id,
|
||||
'commentable_type' => $this->comment->commentable_type,
|
||||
'commentable_id' => $this->comment->commentable_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
50
src/Events/CommentReacted.php
Normal file
50
src/Events/CommentReacted.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithBroadcasting;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
class CommentReacted implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithBroadcasting;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Comment $comment,
|
||||
public readonly object $user,
|
||||
public readonly string $reaction,
|
||||
public readonly string $action,
|
||||
) {}
|
||||
|
||||
/** @return array<int, PrivateChannel> */
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
$prefix = Config::getBroadcastChannelPrefix();
|
||||
|
||||
return [
|
||||
new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastWhen(): bool
|
||||
{
|
||||
return Config::isBroadcastingEnabled();
|
||||
}
|
||||
|
||||
/** @return array{comment_id: int|string, reaction: string, action: string} */
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'comment_id' => $this->comment->id,
|
||||
'reaction' => $this->reaction,
|
||||
'action' => $this->action,
|
||||
];
|
||||
}
|
||||
}
|
||||
51
src/Events/CommentUpdated.php
Normal file
51
src/Events/CommentUpdated.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithBroadcasting;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
class CommentUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithBroadcasting;
|
||||
use SerializesModels;
|
||||
|
||||
public readonly Model $commentable;
|
||||
|
||||
public function __construct(public readonly Comment $comment)
|
||||
{
|
||||
$this->commentable = $comment->commentable;
|
||||
}
|
||||
|
||||
/** @return array<int, PrivateChannel> */
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
$prefix = Config::getBroadcastChannelPrefix();
|
||||
|
||||
return [
|
||||
new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastWhen(): bool
|
||||
{
|
||||
return Config::isBroadcastingEnabled();
|
||||
}
|
||||
|
||||
/** @return array{comment_id: int|string, commentable_type: string, commentable_id: int|string} */
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'comment_id' => $this->comment->id,
|
||||
'commentable_type' => $this->comment->commentable_type,
|
||||
'commentable_id' => $this->comment->commentable_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
19
src/Events/UserMentioned.php
Normal file
19
src/Events/UserMentioned.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Events;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Relaticle\Comments\Comment;
|
||||
|
||||
class UserMentioned
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Comment $comment,
|
||||
public readonly Model $mentionedUser,
|
||||
) {}
|
||||
}
|
||||
48
src/Filament/Actions/CommentsAction.php
Normal file
48
src/Filament/Actions/CommentsAction.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Filament\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Relaticle\Comments\Concerns\HasComments;
|
||||
|
||||
class CommentsAction extends Action
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this
|
||||
->label(__('Comments'))
|
||||
->icon('heroicon-o-chat-bubble-left-right')
|
||||
->slideOver()
|
||||
->modalHeading(__('Comments'))
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->modalContent(function (): View {
|
||||
return view('comments::filament.comments-action', [
|
||||
'record' => $this->getRecord(),
|
||||
]);
|
||||
})
|
||||
->badge(function (): ?int {
|
||||
$record = $this->getRecord();
|
||||
|
||||
if (! $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! in_array(HasComments::class, class_uses_recursive($record))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$count = $record->commentCount();
|
||||
|
||||
return $count > 0 ? $count : null;
|
||||
});
|
||||
}
|
||||
|
||||
public static function getDefaultName(): ?string
|
||||
{
|
||||
return 'comments';
|
||||
}
|
||||
}
|
||||
48
src/Filament/Actions/CommentsTableAction.php
Normal file
48
src/Filament/Actions/CommentsTableAction.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Filament\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Relaticle\Comments\Concerns\HasComments;
|
||||
|
||||
class CommentsTableAction extends Action
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this
|
||||
->label(__('Comments'))
|
||||
->icon('heroicon-o-chat-bubble-left-right')
|
||||
->slideOver()
|
||||
->modalHeading(__('Comments'))
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->modalContent(function (): View {
|
||||
return view('comments::filament.comments-action', [
|
||||
'record' => $this->getRecord(),
|
||||
]);
|
||||
})
|
||||
->badge(function (): ?int {
|
||||
$record = $this->getRecord();
|
||||
|
||||
if (! $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! in_array(HasComments::class, class_uses_recursive($record))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$count = $record->commentCount();
|
||||
|
||||
return $count > 0 ? $count : null;
|
||||
});
|
||||
}
|
||||
|
||||
public static function getDefaultName(): ?string
|
||||
{
|
||||
return 'comments';
|
||||
}
|
||||
}
|
||||
17
src/Filament/Infolists/Components/CommentsEntry.php
Normal file
17
src/Filament/Infolists/Components/CommentsEntry.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Filament\Infolists\Components;
|
||||
|
||||
use Filament\Infolists\Components\Entry;
|
||||
|
||||
class CommentsEntry extends Entry
|
||||
{
|
||||
protected string $view = 'comments::filament.infolists.components.comments-entry';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->columnSpanFull();
|
||||
}
|
||||
}
|
||||
43
src/Listeners/SendCommentRepliedNotification.php
Normal file
43
src/Listeners/SendCommentRepliedNotification.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Listeners;
|
||||
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Relaticle\Comments\CommentSubscription;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Events\CommentCreated;
|
||||
use Relaticle\Comments\Notifications\CommentRepliedNotification;
|
||||
|
||||
class SendCommentRepliedNotification
|
||||
{
|
||||
public function handle(CommentCreated $event): void
|
||||
{
|
||||
if (! Config::areNotificationsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$comment = $event->comment;
|
||||
$commentable = $event->commentable;
|
||||
|
||||
if (Config::shouldAutoSubscribe()) {
|
||||
CommentSubscription::subscribe($commentable, $comment->user);
|
||||
}
|
||||
|
||||
if (! $comment->isReply()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscribers = CommentSubscription::subscribersFor($commentable);
|
||||
|
||||
$recipients = $subscribers->filter(function ($user) use ($comment) {
|
||||
return ! ($user->getMorphClass() === $comment->user->getMorphClass()
|
||||
&& $user->getKey() === $comment->user->getKey());
|
||||
});
|
||||
|
||||
if ($recipients->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::send($recipients, new CommentRepliedNotification($comment));
|
||||
}
|
||||
}
|
||||
34
src/Listeners/SendUserMentionedNotification.php
Normal file
34
src/Listeners/SendUserMentionedNotification.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Listeners;
|
||||
|
||||
use Relaticle\Comments\CommentSubscription;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Events\UserMentioned;
|
||||
use Relaticle\Comments\Notifications\UserMentionedNotification;
|
||||
|
||||
class SendUserMentionedNotification
|
||||
{
|
||||
public function handle(UserMentioned $event): void
|
||||
{
|
||||
if (! Config::areNotificationsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$comment = $event->comment;
|
||||
$mentionedUser = $event->mentionedUser;
|
||||
|
||||
if (Config::shouldAutoSubscribe()) {
|
||||
CommentSubscription::subscribe($comment->commentable, $mentionedUser);
|
||||
}
|
||||
|
||||
$isSelf = $mentionedUser->getMorphClass() === $comment->user->getMorphClass()
|
||||
&& $mentionedUser->getKey() === $comment->user->getKey();
|
||||
|
||||
if ($isSelf) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mentionedUser->notify(new UserMentionedNotification($comment, $comment->user));
|
||||
}
|
||||
}
|
||||
183
src/Livewire/CommentItem.php
Normal file
183
src/Livewire/CommentItem.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Livewire;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Contracts\MentionResolver;
|
||||
use Relaticle\Comments\Events\CommentCreated;
|
||||
use Relaticle\Comments\Events\CommentDeleted;
|
||||
use Relaticle\Comments\Events\CommentUpdated;
|
||||
use Relaticle\Comments\Mentions\MentionParser;
|
||||
|
||||
class CommentItem extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public Comment $comment;
|
||||
|
||||
public bool $isEditing = false;
|
||||
|
||||
public bool $isReplying = false;
|
||||
|
||||
public string $editBody = '';
|
||||
|
||||
public string $replyBody = '';
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
public array $replyAttachments = [];
|
||||
|
||||
public function mount(Comment $comment): void
|
||||
{
|
||||
$this->comment = $comment;
|
||||
}
|
||||
|
||||
public function startEdit(): void
|
||||
{
|
||||
$this->authorize('update', $this->comment);
|
||||
|
||||
$this->isEditing = true;
|
||||
$this->editBody = $this->comment->body;
|
||||
}
|
||||
|
||||
public function cancelEdit(): void
|
||||
{
|
||||
$this->isEditing = false;
|
||||
$this->editBody = '';
|
||||
}
|
||||
|
||||
public function saveEdit(): void
|
||||
{
|
||||
$this->authorize('update', $this->comment);
|
||||
|
||||
$this->validate([
|
||||
'editBody' => ['required', 'string', 'min:1'],
|
||||
]);
|
||||
|
||||
$this->comment->update([
|
||||
'body' => $this->editBody,
|
||||
'edited_at' => now(),
|
||||
]);
|
||||
|
||||
event(new CommentUpdated($this->comment->fresh()));
|
||||
|
||||
app(MentionParser::class)->syncMentions($this->comment->fresh());
|
||||
|
||||
$this->dispatch('commentUpdated');
|
||||
|
||||
$this->isEditing = false;
|
||||
$this->editBody = '';
|
||||
}
|
||||
|
||||
public function deleteComment(): void
|
||||
{
|
||||
$this->authorize('delete', $this->comment);
|
||||
|
||||
$this->comment->delete();
|
||||
|
||||
event(new CommentDeleted($this->comment));
|
||||
|
||||
$this->dispatch('commentDeleted');
|
||||
}
|
||||
|
||||
public function startReply(): void
|
||||
{
|
||||
if (! $this->comment->canReply()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isReplying = true;
|
||||
}
|
||||
|
||||
public function cancelReply(): void
|
||||
{
|
||||
$this->isReplying = false;
|
||||
$this->replyBody = '';
|
||||
$this->replyAttachments = [];
|
||||
}
|
||||
|
||||
public function addReply(): void
|
||||
{
|
||||
$this->authorize('reply', $this->comment);
|
||||
|
||||
$rules = ['replyBody' => ['required', 'string', 'min:1']];
|
||||
|
||||
if (Config::areAttachmentsEnabled()) {
|
||||
$maxSize = Config::getAttachmentMaxSize();
|
||||
$allowedTypes = implode(',', Config::getAttachmentAllowedTypes());
|
||||
$rules['replyAttachments.*'] = ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"];
|
||||
}
|
||||
|
||||
$this->validate($rules);
|
||||
|
||||
$user = Config::resolveAuthenticatedUser();
|
||||
|
||||
$reply = $this->comment->commentable->comments()->create([
|
||||
'body' => $this->replyBody,
|
||||
'parent_id' => $this->comment->id,
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
]);
|
||||
|
||||
if (Config::areAttachmentsEnabled() && ! empty($this->replyAttachments)) {
|
||||
$disk = Config::getAttachmentDisk();
|
||||
|
||||
foreach ($this->replyAttachments as $file) {
|
||||
$path = $file->store("comments/attachments/{$reply->id}", $disk);
|
||||
|
||||
$reply->attachments()->create([
|
||||
'file_path' => $path,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
'disk' => $disk,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
event(new CommentCreated($reply));
|
||||
|
||||
app(MentionParser::class)->syncMentions($reply);
|
||||
|
||||
$this->dispatch('commentUpdated');
|
||||
|
||||
$this->isReplying = false;
|
||||
$this->replyBody = '';
|
||||
$this->replyAttachments = [];
|
||||
}
|
||||
|
||||
public function removeReplyAttachment(int $index): void
|
||||
{
|
||||
$attachments = $this->replyAttachments;
|
||||
unset($attachments[$index]);
|
||||
$this->replyAttachments = array_values($attachments);
|
||||
}
|
||||
|
||||
/** @return array<int, array{id: int, name: string, avatar_url: ?string}> */
|
||||
public function searchUsers(string $query): array
|
||||
{
|
||||
if (mb_strlen($query) < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolver = app(MentionResolver::class);
|
||||
|
||||
return $resolver->search($query)
|
||||
->map(fn ($user) => [
|
||||
'id' => $user->getKey(),
|
||||
'name' => $user->getCommentName(),
|
||||
'avatar_url' => $user->getCommentAvatarUrl(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('comments::livewire.comment-item');
|
||||
}
|
||||
}
|
||||
209
src/Livewire/Comments.php
Normal file
209
src/Livewire/Comments.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Livewire;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\CommentSubscription;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Contracts\MentionResolver;
|
||||
use Relaticle\Comments\Events\CommentCreated;
|
||||
use Relaticle\Comments\Mentions\MentionParser;
|
||||
|
||||
class Comments extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public Model $model;
|
||||
|
||||
public string $newComment = '';
|
||||
|
||||
public string $sortDirection = 'asc';
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
public array $attachments = [];
|
||||
|
||||
public int $perPage = 10;
|
||||
|
||||
public int $loadedCount = 10;
|
||||
|
||||
public function mount(Model $model): void
|
||||
{
|
||||
$this->model = $model;
|
||||
$this->perPage = Config::getPerPage();
|
||||
$this->loadedCount = $this->perPage;
|
||||
}
|
||||
|
||||
/** @return Collection<int, Comment> */
|
||||
#[Computed]
|
||||
public function comments(): Collection
|
||||
{
|
||||
return $this->model
|
||||
->topLevelComments()
|
||||
->with(['user', 'mentions', 'attachments', 'reactions.user', 'replies.user', 'replies.mentions', 'replies.attachments', 'replies.reactions.user'])
|
||||
->orderBy('created_at', $this->sortDirection)
|
||||
->take($this->loadedCount)
|
||||
->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function totalCount(): int
|
||||
{
|
||||
return $this->model->topLevelComments()->count();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function hasMore(): bool
|
||||
{
|
||||
return $this->totalCount > $this->loadedCount;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function isSubscribed(): bool
|
||||
{
|
||||
$user = Config::resolveAuthenticatedUser();
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return CommentSubscription::isSubscribed($this->model, $user);
|
||||
}
|
||||
|
||||
public function toggleSubscription(): void
|
||||
{
|
||||
$user = Config::resolveAuthenticatedUser();
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isSubscribed) {
|
||||
CommentSubscription::unsubscribe($this->model, $user);
|
||||
} else {
|
||||
CommentSubscription::subscribe($this->model, $user);
|
||||
}
|
||||
|
||||
unset($this->isSubscribed);
|
||||
}
|
||||
|
||||
public function addComment(): void
|
||||
{
|
||||
$rules = ['newComment' => ['required', 'string', 'min:1']];
|
||||
|
||||
if (Config::areAttachmentsEnabled()) {
|
||||
$maxSize = Config::getAttachmentMaxSize();
|
||||
$allowedTypes = implode(',', Config::getAttachmentAllowedTypes());
|
||||
$rules['attachments.*'] = ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"];
|
||||
}
|
||||
|
||||
$this->validate($rules);
|
||||
|
||||
$this->authorize('create', Config::getCommentModel());
|
||||
|
||||
$user = Config::resolveAuthenticatedUser();
|
||||
|
||||
$comment = $this->model->comments()->create([
|
||||
'body' => $this->newComment,
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
]);
|
||||
|
||||
if (Config::areAttachmentsEnabled() && ! empty($this->attachments)) {
|
||||
$disk = Config::getAttachmentDisk();
|
||||
|
||||
foreach ($this->attachments as $file) {
|
||||
$path = $file->store("comments/attachments/{$comment->id}", $disk);
|
||||
|
||||
$comment->attachments()->create([
|
||||
'file_path' => $path,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
'disk' => $disk,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
event(new CommentCreated($comment));
|
||||
|
||||
app(MentionParser::class)->syncMentions($comment);
|
||||
|
||||
$this->reset('newComment', 'attachments');
|
||||
}
|
||||
|
||||
public function removeAttachment(int $index): void
|
||||
{
|
||||
$attachments = $this->attachments;
|
||||
unset($attachments[$index]);
|
||||
$this->attachments = array_values($attachments);
|
||||
}
|
||||
|
||||
public function loadMore(): void
|
||||
{
|
||||
$this->loadedCount += $this->perPage;
|
||||
}
|
||||
|
||||
public function toggleSort(): void
|
||||
{
|
||||
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function getListeners(): array
|
||||
{
|
||||
$listeners = [
|
||||
'commentDeleted' => 'refreshComments',
|
||||
'commentUpdated' => 'refreshComments',
|
||||
];
|
||||
|
||||
if (Config::isBroadcastingEnabled()) {
|
||||
$prefix = Config::getBroadcastChannelPrefix();
|
||||
$type = $this->model->getMorphClass();
|
||||
$id = $this->model->getKey();
|
||||
$channel = "echo-private:{$prefix}.{$type}.{$id}";
|
||||
|
||||
$listeners["{$channel},CommentCreated"] = 'refreshComments';
|
||||
$listeners["{$channel},CommentUpdated"] = 'refreshComments';
|
||||
$listeners["{$channel},CommentDeleted"] = 'refreshComments';
|
||||
$listeners["{$channel},CommentReacted"] = 'refreshComments';
|
||||
}
|
||||
|
||||
return $listeners;
|
||||
}
|
||||
|
||||
public function refreshComments(): void
|
||||
{
|
||||
unset($this->comments, $this->totalCount, $this->hasMore);
|
||||
}
|
||||
|
||||
/** @return array<int, array{id: int, name: string, avatar_url: ?string}> */
|
||||
public function searchUsers(string $query): array
|
||||
{
|
||||
if (mb_strlen($query) < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolver = app(MentionResolver::class);
|
||||
|
||||
return $resolver->search($query)
|
||||
->map(fn ($user) => [
|
||||
'id' => $user->getKey(),
|
||||
'name' => $user->getCommentName(),
|
||||
'avatar_url' => $user->getCommentAvatarUrl(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('comments::livewire.comments');
|
||||
}
|
||||
}
|
||||
100
src/Livewire/Reactions.php
Normal file
100
src/Livewire/Reactions.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Livewire;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Events\CommentReacted;
|
||||
|
||||
class Reactions extends Component
|
||||
{
|
||||
public Comment $comment;
|
||||
|
||||
public bool $showPicker = false;
|
||||
|
||||
public function mount(Comment $comment): void
|
||||
{
|
||||
$this->comment = $comment;
|
||||
}
|
||||
|
||||
public function toggleReaction(string $reaction): void
|
||||
{
|
||||
$user = Config::resolveAuthenticatedUser();
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($reaction, Config::getAllowedReactions())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = $this->comment->reactions()
|
||||
->where('user_id', $user->getKey())
|
||||
->where('user_type', $user->getMorphClass())
|
||||
->where('reaction', $reaction)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
|
||||
event(new CommentReacted($this->comment, $user, $reaction, 'removed'));
|
||||
} else {
|
||||
$this->comment->reactions()->create([
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'reaction' => $reaction,
|
||||
]);
|
||||
|
||||
event(new CommentReacted($this->comment, $user, $reaction, 'added'));
|
||||
}
|
||||
|
||||
unset($this->reactionSummary);
|
||||
|
||||
$this->showPicker = false;
|
||||
}
|
||||
|
||||
public function togglePicker(): void
|
||||
{
|
||||
$this->showPicker = ! $this->showPicker;
|
||||
}
|
||||
|
||||
/** @return array<int, array{reaction: string, emoji: string, count: int, names: array<int, string>, total_reactors: int, reacted_by_user: bool}> */
|
||||
#[Computed]
|
||||
public function reactionSummary(): array
|
||||
{
|
||||
$user = Config::resolveAuthenticatedUser();
|
||||
$userId = $user?->getKey();
|
||||
$userType = $user?->getMorphClass();
|
||||
|
||||
$reactions = $this->comment->reactions()->with('user')->get();
|
||||
|
||||
$emojiSet = Config::getReactionEmojiSet();
|
||||
|
||||
return $reactions
|
||||
->groupBy('reaction')
|
||||
->map(function ($group, $key) use ($emojiSet, $userId, $userType) {
|
||||
return [
|
||||
'reaction' => $key,
|
||||
'emoji' => $emojiSet[$key] ?? $key,
|
||||
'count' => $group->count(),
|
||||
'names' => $group->pluck('user.name')->filter()->take(3)->values()->all(),
|
||||
'total_reactors' => $group->count(),
|
||||
'reacted_by_user' => $group->contains(
|
||||
fn ($r) => $r->user_id == $userId && $r->user_type === $userType
|
||||
),
|
||||
];
|
||||
})
|
||||
->sortByDesc('count')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('comments::livewire.reactions');
|
||||
}
|
||||
}
|
||||
32
src/Mentions/DefaultMentionResolver.php
Normal file
32
src/Mentions/DefaultMentionResolver.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Mentions;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Contracts\MentionResolver;
|
||||
|
||||
class DefaultMentionResolver implements MentionResolver
|
||||
{
|
||||
/** @return Collection<int, Model> */
|
||||
public function search(string $query): Collection
|
||||
{
|
||||
$model = Config::getCommenterModel();
|
||||
|
||||
return $model::query()
|
||||
->where('name', 'like', "{$query}%")
|
||||
->limit(Config::getMentionMaxResults())
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection<int, Model> */
|
||||
public function resolveByNames(array $names): Collection
|
||||
{
|
||||
$model = Config::getCommenterModel();
|
||||
|
||||
return $model::query()
|
||||
->whereIn('name', $names)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
52
src/Mentions/MentionParser.php
Normal file
52
src/Mentions/MentionParser.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Mentions;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Contracts\MentionResolver;
|
||||
use Relaticle\Comments\Events\UserMentioned;
|
||||
|
||||
class MentionParser
|
||||
{
|
||||
public function __construct(
|
||||
protected MentionResolver $resolver,
|
||||
) {}
|
||||
|
||||
/** @return Collection<int, int> */
|
||||
public function parse(string $body): Collection
|
||||
{
|
||||
$text = html_entity_decode(strip_tags($body), ENT_QUOTES, 'UTF-8');
|
||||
|
||||
preg_match_all('/(?<=@)[\w]+/', $text, $matches);
|
||||
|
||||
$names = array_unique($matches[0] ?? []);
|
||||
|
||||
if (empty($names)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $this->resolver->resolveByNames($names)->pluck('id');
|
||||
}
|
||||
|
||||
public function syncMentions(Comment $comment): void
|
||||
{
|
||||
$newMentionIds = $this->parse($comment->body);
|
||||
$existingMentionIds = $comment->mentions()->pluck('comment_mentions.user_id');
|
||||
|
||||
$addedIds = $newMentionIds->diff($existingMentionIds);
|
||||
|
||||
$comment->mentions()->sync($newMentionIds->all());
|
||||
|
||||
$commenterModel = Config::getCommenterModel();
|
||||
|
||||
$addedIds->each(function ($userId) use ($comment, $commenterModel) {
|
||||
$mentionedUser = $commenterModel::find($userId);
|
||||
|
||||
if ($mentionedUser) {
|
||||
UserMentioned::dispatch($comment, $mentionedUser);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
42
src/Notifications/CommentRepliedNotification.php
Normal file
42
src/Notifications/CommentRepliedNotification.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Notifications;
|
||||
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
class CommentRepliedNotification extends Notification
|
||||
{
|
||||
public function __construct(public readonly Comment $comment) {}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function via(mixed $notifiable): array
|
||||
{
|
||||
return Config::getNotificationChannels();
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toDatabase(mixed $notifiable): array
|
||||
{
|
||||
return [
|
||||
'comment_id' => $this->comment->id,
|
||||
'commentable_type' => $this->comment->commentable_type,
|
||||
'commentable_id' => $this->comment->commentable_id,
|
||||
'commenter_name' => $this->comment->user->getCommentName(),
|
||||
'body' => Str::limit(strip_tags($this->comment->body), 100),
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(mixed $notifiable): MailMessage
|
||||
{
|
||||
$commenterName = $this->comment->user->getCommentName();
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('New reply to your comment')
|
||||
->line("{$commenterName} replied to your comment:")
|
||||
->line(Str::limit(strip_tags($this->comment->body), 200));
|
||||
}
|
||||
}
|
||||
46
src/Notifications/UserMentionedNotification.php
Normal file
46
src/Notifications/UserMentionedNotification.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Notifications;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
class UserMentionedNotification extends Notification
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Comment $comment,
|
||||
public readonly Model $mentionedBy,
|
||||
) {}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function via(mixed $notifiable): array
|
||||
{
|
||||
return Config::getNotificationChannels();
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toDatabase(mixed $notifiable): array
|
||||
{
|
||||
return [
|
||||
'comment_id' => $this->comment->id,
|
||||
'commentable_type' => $this->comment->commentable_type,
|
||||
'commentable_id' => $this->comment->commentable_id,
|
||||
'mentioner_name' => $this->mentionedBy->getCommentName(),
|
||||
'body' => Str::limit(strip_tags($this->comment->body), 100),
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(mixed $notifiable): MailMessage
|
||||
{
|
||||
$mentionerName = $this->mentionedBy->getCommentName();
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('You were mentioned in a comment')
|
||||
->line("{$mentionerName} mentioned you in a comment:")
|
||||
->line(Str::limit(strip_tags($this->comment->body), 200));
|
||||
}
|
||||
}
|
||||
0
src/Policies/.gitkeep
Normal file
0
src/Policies/.gitkeep
Normal file
36
src/Policies/CommentPolicy.php
Normal file
36
src/Policies/CommentPolicy.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Policies;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Relaticle\Comments\Comment;
|
||||
|
||||
class CommentPolicy
|
||||
{
|
||||
public function viewAny(Authenticatable $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create(Authenticatable $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function update(Authenticatable $user, Comment $comment): bool
|
||||
{
|
||||
return $user->getKey() === $comment->user_id
|
||||
&& $user->getMorphClass() === $comment->user_type;
|
||||
}
|
||||
|
||||
public function delete(Authenticatable $user, Comment $comment): bool
|
||||
{
|
||||
return $user->getKey() === $comment->user_id
|
||||
&& $user->getMorphClass() === $comment->user_type;
|
||||
}
|
||||
|
||||
public function reply(Authenticatable $user, Comment $comment): bool
|
||||
{
|
||||
return $comment->canReply();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user