36 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
manukminasyan
6a26396f0d refactor: polish comment form layout - inline attach and comment button
Move Comment/Reply button to same row as Attach link using
justify-between flex layout. Shorten "Attach files" to "Attach".
Place Cancel on left side, action buttons on right for edit/reply forms.
Cleaner, more compact footer area.
2026-03-27 21:32:05 +04:00
manukminasyan
2ace8bfdd4 feat: make comment form sticky at bottom of slide-over
Pin the comment editor to the bottom of the slide-over panel so it's
always visible while scrolling through comments. Uses CSS sticky
positioning with border separator and background color.
2026-03-27 21:26:28 +04:00
manukminasyan
3d745077b7 fix: prevent lazy loading violation on replies relationship
Check relationLoaded('replies') before accessing $comment->replies
to avoid LazyLoadingViolationException when rendering nested comments
whose replies aren't eager loaded.
2026-03-27 21:23:18 +04:00
manukminasyan
b44b4e309e fix: avoid lazy loading parent relationship in depth calculation
Use a query-based approach instead of traversing the parent relationship
to prevent LazyLoadingViolationException when strict mode is enabled.
2026-03-27 21:20:20 +04:00
manukminasyan
ac97dcb092 fix: replace form elements with div+wire:click to prevent nested form conflicts
The CommentsAction slide-over wraps content in a Filament action form.
Nested <form> elements inside the comments Livewire templates caused the
browser to submit the outer action form instead, closing the slide-over
without storing the comment.

Replace <form wire:submit> with <div> and type="submit" buttons with
type="button" wire:click for all three forms (comment, edit, reply).
2026-03-27 21:04:34 +04:00
manukminasyan
6c96fb900b fix: update tests for RichEditor form data paths and service providers
Update all tests to use new form state paths (commentData.body,
editData.body, replyData.body) instead of removed public properties.
Remove searchUsers() tests (method replaced by MentionProvider).
Add BladeUI Icons service providers to TestCase for RichEditor views.
2026-03-27 19:20:56 +04:00
manukminasyan
7f9f13b626 fix: add missing Filament service providers to test setup
InteractsWithForms requires FormsServiceProvider, SchemasServiceProvider,
and ActionsServiceProvider to be registered in the test environment.
2026-03-27 18:46:40 +04:00
manukminasyan
e173d9b4dd refactor: replace custom textarea with Filament RichEditor and built-in mentions
Replace the custom Alpine.js textarea + mention system with Filament v5's
built-in RichEditor component and MentionProvider. This fixes Alpine scope
errors (showMentions/mentionResults not defined) that occurred during
Livewire DOM morphing inside Filament slide-over modals.

- Add InteractsWithForms + HasForms to Comments and CommentItem components
- Define commentForm(), editForm(), replyForm() with RichEditor + mentions
- Add CommentsConfig::makeMentionProvider() shared helper
- Update MentionParser to extract mention IDs from RichEditor HTML format
- Update Comment::renderBodyWithMentions() to use RichContentRenderer
- Remove all custom Alpine.js mention code from blade templates
- Backward compatible with existing plain text comments
2026-03-27 18:43:07 +04:00
manukminasyan
f119095ae5 fix: use schema() instead of modalContent() for Filament 5 compatibility
Replace deprecated modalContent() with schema([CommentsEntry::make('comments')])
in both CommentsAction and CommentsTableAction to fix comment creation in
Filament 5 modals.
2026-03-27 17:48:32 +04:00
manukminasyan
889dc2828b fix: use callout component for alpha warning 2026-03-27 16:08:55 +04:00
manukminasyan
82eb6a70ad fix: move alpha alert into hero description using correct alert component 2026-03-27 16:08:10 +04:00
manukminasyan
2edcfa00f1 fix: use inline alpha warning and aspect-video preview on docs homepage 2026-03-27 16:06:57 +04:00
manukminasyan
35571760d6 feat: use filament input wrapper for textareas, add alpha callout and preview image, remove redundant introduction page 2026-03-27 16:02:17 +04:00
manukminasyan
a4d4418963 docs: update all documentation for refactored naming conventions
- CanComment trait replaces IsCommenter
- Commentator interface replaces Commenter
- Models moved to Models\ namespace (Comment, Reaction, Attachment, Subscription)
- commenter_type/commenter_id columns replace user_type/user_id
- CommentsConfig replaces Config class
- table_names config key replaces tables
- getCommentDisplayName() replaces getCommentName()
2026-03-27 15:01:50 +04:00
manukminasyan
b2ee8a1036 fix: add preview images to ecosystem cards on docs homepage 2026-03-27 14:56:44 +04:00
manukminasyan
fd5bc5271b refactor: rename for Laravel conventions and better DX
- Rename IsCommenter trait to CanComment, Commenter interface to Commentator
- Move models to Models/ namespace (Comment, Reaction, Attachment, Subscription)
- Rename user_type/user_id polymorphic columns to commenter_type/commenter_id
- Rename Config class to CommentsConfig, update config key tables->table_names
- Rename getCommentName() to getCommentDisplayName() on commentator models
- Add column_names config section for commenter morph customization
- Add table_names config with all 5 tables individually configurable
- Expand translation file with structured i18n groups
- Update all Blade views, Livewire components, events, listeners, and tests
2026-03-27 14:53:12 +04:00
manukminasyan
43b66f60f3 fix: enlarge wordmark, top-align text, update ecosystem to FilaForms + Custom Fields 2026-03-27 14:40:05 +04:00
manukminasyan
0c13d589d8 feat: add header components and version switcher for docs 2026-03-27 14:27:35 +04:00
manukminasyan
2c7c44ecbc fix: use custom-fields 3.x branch for preview image 2026-03-27 14:25:37 +04:00
manukminasyan
12470a1d8b feat: add docs logo SVGs for light and dark themes 2026-03-27 14:24:18 +04:00
manukminasyan
42e95a83f5 fix: restrict pint workflow to branch pushes only 2026-03-27 14:18:41 +04:00
85 changed files with 1484 additions and 1220 deletions

View File

@@ -2,6 +2,7 @@ name: Pint
on: on:
push: push:
branches: [1.x]
paths: paths:
- '**.php' - '**.php'

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

@@ -61,12 +61,12 @@ class Project extends Model implements Commentable
Add the commenter trait to your User model: Add the commenter trait to your User model:
```php ```php
use Relaticle\Comments\Concerns\IsCommenter; use Relaticle\Comments\Concerns\CanComment;
use Relaticle\Comments\Contracts\Commenter; use Relaticle\Comments\Contracts\Commentator;
class User extends Authenticatable implements Commenter class User extends Authenticatable implements Commentator
{ {
use IsCommenter; use CanComment;
} }
``` ```
@@ -134,20 +134,20 @@ public static function infolist(Infolist $infolist): Infolist
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
### Custom Fields ### FilaForms
[<img src="https://github.com/Relaticle/custom-fields/raw/2.x/art/preview.png" width="100%" />](https://relaticle.github.io/custom-fields) [<img src="https://filaforms.app/img/og-image.png" width="100%" />](https://filaforms.app/)
Let users add custom fields to any model without code changes. Visual form builder for all your public-facing forms.
[Learn more ->](https://relaticle.github.io/custom-fields) [Learn more ->](https://filaforms.app)
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
### Flowforge ### Custom Fields
[<img src="https://github.com/Relaticle/flowforge/raw/4.x/art/preview.png" width="100%" />](https://relaticle.github.io/flowforge) [<img src="https://github.com/Relaticle/custom-fields/raw/3.x/art/preview.png" width="100%" />](https://relaticle.github.io/custom-fields)
Transform any Laravel model into a drag-and-drop Kanban board. Let users add custom fields to any model without code changes.
[Learn more ->](https://relaticle.github.io/flowforge) [Learn more ->](https://relaticle.github.io/custom-fields)
</td> </td>
</tr> </tr>

View File

@@ -1,19 +1,29 @@
<?php <?php
declare(strict_types=1);
use App\Models\User; use App\Models\User;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Mentions\DefaultMentionResolver; use Relaticle\Comments\Mentions\DefaultMentionResolver;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Policies\CommentPolicy; use Relaticle\Comments\Policies\CommentPolicy;
return [ return [
'tables' => [
'comments' => 'comments',
],
'models' => [ 'models' => [
'comment' => Comment::class, 'comment' => Comment::class,
], ],
'table_names' => [
'comments' => 'comments',
'reactions' => 'comment_reactions',
'mentions' => 'comment_mentions',
'subscriptions' => 'comment_subscriptions',
'attachments' => 'comment_attachments',
],
'column_names' => [
'commenter_morph' => 'commenter',
],
'commenter' => [ 'commenter' => [
'model' => User::class, 'model' => User::class,
], ],

View File

@@ -3,7 +3,7 @@
namespace Relaticle\Comments\Database\Factories; namespace Relaticle\Comments\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Relaticle\Comments\Comment; use Relaticle\Comments\Models\Comment;
class CommentFactory extends Factory class CommentFactory extends Factory
{ {

View File

@@ -8,10 +8,10 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('comment_attachments', function (Blueprint $table) { Schema::create(config('comments.table_names.attachments', 'comment_attachments'), function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('comment_id') $table->foreignId('comment_id')
->constrained(config('comments.tables.comments', 'comments')) ->constrained(config('comments.table_names.comments', 'comments'))
->cascadeOnDelete(); ->cascadeOnDelete();
$table->string('file_path'); $table->string('file_path');
$table->string('original_name'); $table->string('original_name');

View File

@@ -8,15 +8,15 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('comment_mentions', function (Blueprint $table) { Schema::create(config('comments.table_names.mentions', 'comment_mentions'), function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('comment_id') $table->foreignId('comment_id')
->constrained(config('comments.tables.comments', 'comments')) ->constrained(config('comments.table_names.comments', 'comments'))
->cascadeOnDelete(); ->cascadeOnDelete();
$table->morphs('user'); $table->morphs('commenter');
$table->timestamps(); $table->timestamps();
$table->unique(['comment_id', 'user_id', 'user_type']); $table->unique(['comment_id', 'commenter_id', 'commenter_type']);
}); });
} }
}; };

View File

@@ -8,16 +8,16 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('comment_reactions', function (Blueprint $table) { Schema::create(config('comments.table_names.reactions', 'comment_reactions'), function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('comment_id') $table->foreignId('comment_id')
->constrained(config('comments.tables.comments', 'comments')) ->constrained(config('comments.table_names.comments', 'comments'))
->cascadeOnDelete(); ->cascadeOnDelete();
$table->morphs('user'); $table->morphs('commenter');
$table->string('reaction'); $table->string('reaction');
$table->timestamps(); $table->timestamps();
$table->unique(['comment_id', 'user_id', 'user_type', 'reaction']); $table->unique(['comment_id', 'commenter_id', 'commenter_type', 'reaction']);
}); });
} }
}; };

View File

@@ -8,13 +8,13 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('comment_subscriptions', function (Blueprint $table) { Schema::create(config('comments.table_names.subscriptions', 'comment_subscriptions'), function (Blueprint $table) {
$table->id(); $table->id();
$table->morphs('commentable'); $table->morphs('commentable');
$table->morphs('user'); $table->morphs('commenter');
$table->timestamp('created_at')->nullable(); $table->timestamp('created_at')->nullable();
$table->unique(['commentable_type', 'commentable_id', 'user_type', 'user_id'], 'comment_subscriptions_unique'); $table->unique(['commentable_type', 'commentable_id', 'commenter_type', 'commenter_id'], 'comment_subscriptions_unique');
}); });
} }
}; };

View File

@@ -8,13 +8,13 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create(config('comments.tables.comments', 'comments'), function (Blueprint $table) { Schema::create(config('comments.table_names.comments', 'comments'), function (Blueprint $table) {
$table->id(); $table->id();
$table->morphs('commentable'); $table->morphs('commentable');
$table->morphs('user'); $table->morphs('commenter');
$table->foreignId('parent_id') $table->foreignId('parent_id')
->nullable() ->nullable()
->constrained(config('comments.tables.comments', 'comments')) ->constrained(config('comments.table_names.comments', 'comments'))
->cascadeOnDelete(); ->cascadeOnDelete();
$table->text('body'); $table->text('body');
$table->timestamp('edited_at')->nullable(); $table->timestamp('edited_at')->nullable();

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { useDocusI18n } from '#imports'
const appConfig = useAppConfig()
const site = useSiteConfig()
const { localePath, isEnabled, locales } = useDocusI18n()
const { currentVersion, isOldVersion, loadVersions } = useVersions()
onMounted(() => loadVersions())
const links = computed(() => appConfig.github && appConfig.github.url
? [
{
'icon': 'i-simple-icons-github',
'to': appConfig.github.url,
'target': '_blank',
'aria-label': 'GitHub',
},
]
: [])
</script>
<template>
<div class="sticky top-0 z-50">
<!-- Version Warning Banner -->
<div
v-if="isOldVersion"
class="bg-amber-100 dark:bg-amber-900/50 text-amber-800 dark:text-amber-200 px-4 py-2 text-center text-sm border-b border-amber-200 dark:border-amber-800"
>
You are viewing documentation for Comments {{ currentVersion }}.
<a
href="/comments/"
class="underline font-medium hover:text-amber-900 dark:hover:text-amber-100"
>
View the latest version &rarr;
</a>
</div>
<!-- Original Docus Header -->
<UHeader
:ui="{ center: 'flex-1' }"
:to="localePath('/')"
:title="appConfig.header?.title || site.name"
>
<AppHeaderCenter />
<template #title>
<AppHeaderLogo class="h-6 w-auto shrink-0" />
</template>
<template #right>
<AppVersionSwitcher />
<AppHeaderCTA />
<template v-if="isEnabled && locales.length > 1">
<ClientOnly>
<LanguageSelect />
<template #fallback>
<div class="h-8 w-8 animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-md" />
</template>
</ClientOnly>
<USeparator
orientation="vertical"
class="h-8"
/>
</template>
<UContentSearchButton class="lg:hidden" />
<ClientOnly>
<UColorModeButton />
<template #fallback>
<div class="h-8 w-8 animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-md" />
</template>
</ClientOnly>
<template v-if="links?.length">
<UButton
v-for="(link, index) of links"
:key="index"
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
/>
</template>
</template>
<template #toggle="{ open, toggle }">
<IconMenuToggle
:open="open"
class="lg:hidden"
@click="toggle"
/>
</template>
<template #body>
<AppHeaderBody />
</template>
</UHeader>
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const appConfig = useAppConfig()
</script>
<template>
<UColorModeImage
v-if="appConfig.docus?.header?.logo?.dark || appConfig.docus?.header?.logo?.light"
:light="appConfig.docus?.header?.logo?.light || appConfig.docus?.header?.logo?.dark"
:dark="appConfig.docus?.header?.logo?.dark || appConfig.docus?.header?.logo?.light"
:alt="appConfig.docus?.header?.logo?.alt || appConfig.docus?.title"
class="h-8 w-auto shrink-0"
/>
<span v-else class="text-lg font-semibold">
{{ appConfig.docus?.title || 'Comments' }}
</span>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
const { versions, currentVersion, currentTitle, loadVersions } = useVersions()
onMounted(() => loadVersions())
function switchVersion(version: { version: string; path: string }): void {
if (version.version !== currentVersion) {
window.location.href = version.path
}
}
</script>
<template>
<div v-if="versions.length > 1" class="relative" @click.stop>
<UPopover>
<UButton
variant="ghost"
size="sm"
:label="currentTitle"
trailing-icon="i-lucide-chevron-down"
/>
<template #content>
<div class="p-1">
<button
v-for="version in versions"
:key="version.version"
class="w-full px-3 py-2 text-left text-sm rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"
:class="{ 'font-medium text-primary': version.version === currentVersion }"
@click="switchVersion(version)"
>
{{ version.title }}
</button>
</div>
</template>
</UPopover>
</div>
<UBadge v-else-if="currentVersion" variant="subtle" color="neutral">{{ currentVersion }}</UBadge>
</template>

View File

@@ -0,0 +1,60 @@
interface Version {
version: string
title: string
path: string
branch: string
isLatest: boolean
}
const versions = ref<Version[]>([])
const isLoaded = ref(false)
const isLoading = ref(false)
export function useVersions() {
const config = useRuntimeConfig()
const currentVersion = config.public.docsVersion || '1.x'
async function loadVersions() {
if (isLoaded.value || isLoading.value) return
isLoading.value = true
try {
const res = await fetch('/comments/versions.json')
if (res.ok) {
versions.value = await res.json()
}
} catch (e) {
console.warn('Failed to load versions.json:', e)
} finally {
isLoaded.value = true
isLoading.value = false
}
}
const latestVersion = computed(() =>
versions.value.find(v => v.isLatest)
)
const currentVersionInfo = computed(() =>
versions.value.find(v => v.version === currentVersion)
)
const isOldVersion = computed(() => {
if (!isLoaded.value) return false
return currentVersionInfo.value?.isLatest === false
})
const currentTitle = computed(() =>
currentVersionInfo.value?.title || currentVersion
)
return {
versions,
currentVersion,
currentTitle,
latestVersion,
isOldVersion,
isLoaded,
loadVersions,
}
}

View File

@@ -73,15 +73,15 @@ class Project extends Model implements Commentable
} }
``` ```
Add the `IsCommenter` trait to your User model: Add the `CanComment` trait to your User model:
```php [app/Models/User.php] ```php [app/Models/User.php]
use Relaticle\Comments\Concerns\IsCommenter; use Relaticle\Comments\Concerns\CanComment;
use Relaticle\Comments\Contracts\Commenter; use Relaticle\Comments\Contracts\Commentator;
class User extends Authenticatable implements Commenter class User extends Authenticatable implements Commentator
{ {
use IsCommenter; use CanComment;
} }
``` ```

View File

@@ -1,52 +0,0 @@
---
title: Introduction
description: A full-featured commenting system for Filament panels.
navigation:
icon: i-lucide-home
seo:
title: Introduction
description: Learn about Comments - a full-featured commenting system for Filament panels with threaded replies, @mentions, emoji reactions, and real-time updates.
ogImage: /preview.png
---
Welcome to **Comments**, a powerful Laravel package that adds a full-featured commenting system to any Filament panel.
## What is Comments?
Comments provides polymorphic commenting on any Eloquent model with deep Filament integration. Add threaded discussions, @mentions, emoji reactions, file attachments, and real-time notifications to your admin panel with minimal setup.
## Why Choose Comments?
::card-group
:::card
---
icon: i-lucide-messages-square
title: Threaded Discussions
---
Nested replies with configurable depth limits keep conversations organized and easy to follow.
:::
:::card
---
icon: i-lucide-clock
title: Quick Setup
---
Add traits to your models, register the plugin, and you have a working comment system in minutes.
:::
:::card
---
icon: i-lucide-puzzle
title: 3 Integration Patterns
---
Use as a slide-over action, table row action, or inline infolist entry - whatever fits your resource.
:::
:::card
---
icon: i-lucide-bell
title: Built-in Notifications
---
Database and mail notifications with subscription management and auto-subscribe for authors and mentioned users.
:::
::

View File

@@ -15,21 +15,34 @@ php artisan vendor:publish --tag=comments-config
This creates `config/comments.php` with all available options. This creates `config/comments.php` with all available options.
## Table Name ## Table Names
```php ```php
'tables' => [ 'table_names' => [
'comments' => 'comments', 'comments' => 'comments',
'reactions' => 'comment_reactions',
'mentions' => 'comment_mentions',
'subscriptions' => 'comment_subscriptions',
'attachments' => 'comment_attachments',
], ],
``` ```
Change the table name if it conflicts with your application. Change the table names if they conflict with your application.
## Column Names
```php
'column_names' => [
'commenter_id' => 'commenter_id',
'commenter_type' => 'commenter_type',
],
```
## Models ## Models
```php ```php
'models' => [ 'models' => [
'comment' => \Relaticle\Comments\Comment::class, 'comment' => \Relaticle\Comments\Models\Comment::class,
], ],
'commenter' => [ 'commenter' => [
@@ -178,10 +191,10 @@ When broadcasting is disabled, the Livewire component polls for new comments at
Override how the authenticated user is resolved: Override how the authenticated user is resolved:
```php ```php
use Relaticle\Comments\Config; use Relaticle\Comments\CommentsConfig;
// In AppServiceProvider::boot() // In AppServiceProvider::boot()
Config::resolveAuthenticatedUserUsing(function () { CommentsConfig::resolveAuthenticatedUserUsing(function () {
return auth()->user(); return auth()->user();
}); });
``` ```

View File

@@ -26,34 +26,34 @@ Create your own policy to customize authorization:
```php ```php
namespace App\Policies; namespace App\Policies;
use Relaticle\Comments\Comment; use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Contracts\Commenter; use Relaticle\Comments\Contracts\Commentator;
class CustomCommentPolicy class CustomCommentPolicy
{ {
public function viewAny(Commenter $user): bool public function viewAny(Commentator $user): bool
{ {
return true; return true;
} }
public function create(Commenter $user): bool public function create(Commentator $user): bool
{ {
return true; return true;
} }
public function update(Commenter $user, Comment $comment): bool public function update(Commentator $user, Comment $comment): bool
{ {
return $comment->user_id === $user->getKey() return $comment->commenter_id === $user->getKey()
&& $comment->user_type === $user->getMorphClass(); && $comment->commenter_type === $user->getMorphClass();
} }
public function delete(Commenter $user, Comment $comment): bool public function delete(Commentator $user, Comment $comment): bool
{ {
return $comment->user_id === $user->getKey() return $comment->commenter_id === $user->getKey()
|| $user->hasRole('admin'); || $user->hasRole('admin');
} }
public function reply(Commenter $user, Comment $comment): bool public function reply(Commentator $user, Comment $comment): bool
{ {
return $comment->canReply(); return $comment->canReply();
} }

View File

@@ -48,4 +48,4 @@ Keys are stored in the database. If you change a key, existing reactions with th
## Storage ## Storage
Reactions are stored in the `comment_reactions` table with a unique constraint on `(comment_id, user_id, user_type, reaction)`, ensuring one reaction of each type per user per comment. Reactions are stored in the `comment_reactions` table with a unique constraint on `(comment_id, commenter_id, commenter_type, reaction)`, ensuring one reaction of each type per user per comment.

View File

@@ -63,7 +63,7 @@ When a comment is deleted, its attachments are cascade deleted from the database
## Helper Methods ## Helper Methods
The `CommentAttachment` model provides: The `Attachment` model (`Relaticle\Comments\Models\Attachment`) provides:
```php ```php
$attachment->isImage(); // Check if attachment is an image $attachment->isImage(); // Check if attachment is an image

View File

@@ -61,17 +61,17 @@ Users can toggle their subscription using the subscribe/unsubscribe button in th
### Programmatic Access ### Programmatic Access
```php ```php
use Relaticle\Comments\CommentSubscription; use Relaticle\Comments\Models\Subscription;
// Check subscription status // Check subscription status
CommentSubscription::isSubscribed($commentable, $user); Subscription::isSubscribed($commentable, $user);
// Subscribe/unsubscribe // Subscribe/unsubscribe
CommentSubscription::subscribe($commentable, $user); Subscription::subscribe($commentable, $user);
CommentSubscription::unsubscribe($commentable, $user); Subscription::unsubscribe($commentable, $user);
// Get all subscribers for a commentable // Get all subscribers for a commentable
$subscribers = CommentSubscription::subscribersFor($commentable); $subscribers = Subscription::subscribersFor($commentable);
``` ```
## Events ## Events

View File

@@ -20,8 +20,8 @@ The main comments table with polymorphic relationships and threading support.
| `id` | bigint | Primary key | | `id` | bigint | Primary key |
| `commentable_type` | string | Polymorphic model type | | `commentable_type` | string | Polymorphic model type |
| `commentable_id` | bigint | Polymorphic model ID | | `commentable_id` | bigint | Polymorphic model ID |
| `user_type` | string | Commenter model type | | `commenter_type` | string | Commenter model type |
| `user_id` | bigint | Commenter model ID | | `commenter_id` | bigint | Commenter model ID |
| `parent_id` | bigint (nullable) | Parent comment for replies | | `parent_id` | bigint (nullable) | Parent comment for replies |
| `body` | text | HTML comment content | | `body` | text | HTML comment content |
| `edited_at` | timestamp (nullable) | When the comment was last edited | | `edited_at` | timestamp (nullable) | When the comment was last edited |
@@ -39,12 +39,12 @@ Tracks emoji reactions per user per comment.
|--------|------|-------------| |--------|------|-------------|
| `id` | bigint | Primary key | | `id` | bigint | Primary key |
| `comment_id` | bigint | Foreign key to comments | | `comment_id` | bigint | Foreign key to comments |
| `user_type` | string | Reactor model type | | `commenter_type` | string | Reactor model type |
| `user_id` | bigint | Reactor model ID | | `commenter_id` | bigint | Reactor model ID |
| `reaction` | string | Reaction key (e.g., `thumbs_up`) | | `reaction` | string | Reaction key (e.g., `thumbs_up`) |
| `created_at` | timestamp | | | `created_at` | timestamp | |
**Unique constraint:** `(comment_id, user_id, user_type, reaction)` **Unique constraint:** `(comment_id, commenter_id, commenter_type, reaction)`
### comment_mentions ### comment_mentions
@@ -54,11 +54,11 @@ Tracks @mentioned users per comment.
|--------|------|-------------| |--------|------|-------------|
| `id` | bigint | Primary key | | `id` | bigint | Primary key |
| `comment_id` | bigint | Foreign key to comments | | `comment_id` | bigint | Foreign key to comments |
| `user_type` | string | Mentioned user model type | | `commenter_type` | string | Mentioned user model type |
| `user_id` | bigint | Mentioned user model ID | | `commenter_id` | bigint | Mentioned user model ID |
| `created_at` | timestamp | | | `created_at` | timestamp | |
**Unique constraint:** `(comment_id, user_id, user_type)` **Unique constraint:** `(comment_id, commenter_id, commenter_type)`
### comment_subscriptions ### comment_subscriptions
@@ -69,11 +69,11 @@ Tracks which users are subscribed to comment threads on specific models.
| `id` | bigint | Primary key | | `id` | bigint | Primary key |
| `commentable_type` | string | Subscribed model type | | `commentable_type` | string | Subscribed model type |
| `commentable_id` | bigint | Subscribed model ID | | `commentable_id` | bigint | Subscribed model ID |
| `user_type` | string | Subscriber model type | | `commenter_type` | string | Subscriber model type |
| `user_id` | bigint | Subscriber model ID | | `commenter_id` | bigint | Subscriber model ID |
| `created_at` | timestamp | | | `created_at` | timestamp | |
**Unique constraint:** `(commentable_type, commentable_id, user_type, user_id)` **Unique constraint:** `(commentable_type, commentable_id, commenter_type, commenter_id)`
### comment_attachments ### comment_attachments
@@ -96,11 +96,11 @@ Stores file attachment metadata for comments.
``` ```
Commentable Model (e.g., Project) Commentable Model (e.g., Project)
└── comments (morphMany) └── comments (morphMany)
├── user (morphTo → User) ├── commenter (morphTo → User)
├── parent (belongsTo → Comment) ├── parent (belongsTo → Comment)
├── replies (hasMany → Comment) ├── replies (hasMany → Comment)
├── reactions (hasMany → CommentReaction) ├── reactions (hasMany → Reaction)
├── attachments (hasMany → CommentAttachment) ├── attachments (hasMany → Attachment)
└── mentions (morphToMany → User) └── mentions (morphToMany → User)
``` ```

View File

@@ -14,6 +14,10 @@ A full-featured commenting system for Filament panels with threaded replies, @me
Drop-in integration with any Filament resource. Drop-in integration with any Filament resource.
:::callout{icon="i-lucide-triangle-alert" color="amber"}
**Alpha Software** — Breaking changes may occur between releases. Not recommended for production use.
:::
#links #links
:::u-button :::u-button
--- ---
@@ -37,6 +41,12 @@ Drop-in integration with any Filament resource.
::: :::
:: ::
<div class="text-center max-w-5xl mx-auto">
<div class="aspect-video rounded-lg shadow-lg overflow-hidden">
<img src="/preview.png" alt="Comments - threaded discussions in Filament" class="w-full h-full object-cover object-top" />
</div>
</div>
::u-page-section ::u-page-section
#title #title
Why choose Comments? Why choose Comments?
@@ -125,6 +135,8 @@ Extend your Laravel applications with our ecosystem of complementary tools
to: https://filaforms.app to: https://filaforms.app
target: _blank target: _blank
--- ---
:img{src="https://filaforms.app/img/og-image.png" alt="FilaForms" class="mb-4 rounded-lg w-full pointer-events-none"}
Visual form builder for all your public-facing forms. Visual form builder for all your public-facing forms.
::: :::
@@ -135,17 +147,9 @@ Extend your Laravel applications with our ecosystem of complementary tools
to: https://relaticle.github.io/custom-fields to: https://relaticle.github.io/custom-fields
target: _blank target: _blank
--- ---
:img{src="https://relaticle.github.io/custom-fields/og-image.png" alt="Custom Fields" class="mb-4 rounded-lg w-full pointer-events-none"}
Let users add custom fields to any model without code changes. Let users add custom fields to any model without code changes.
::: :::
:::card
---
title: Flowforge
icon: i-lucide-kanban
to: https://relaticle.github.io/flowforge
target: _blank
---
Transform any Laravel model into a drag-and-drop Kanban board.
:::
:: ::
:: ::

13
docs/public/logo-dark.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
docs/public/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

View File

@@ -31,12 +31,12 @@ class Project extends Model implements Commentable
``` ```
```php ```php
use Relaticle\Comments\Concerns\IsCommenter; use Relaticle\Comments\Concerns\CanComment;
use Relaticle\Comments\Contracts\Commenter; use Relaticle\Comments\Contracts\Commentator;
class User extends Authenticatable implements Commenter class User extends Authenticatable implements Commentator
{ {
use IsCommenter; use CanComment;
} }
``` ```
@@ -113,7 +113,7 @@ Publish config: `php artisan vendor:publish --tag=comments-config`
| Key | Default | Purpose | | Key | Default | Purpose |
|-----|---------|---------| |-----|---------|---------|
| `tables.comments` | `'comments'` | Main comments table name | | `table_names.comments` | `'comments'` | Main comments table name |
| `models.comment` | `Comment::class` | Comment model class | | `models.comment` | `Comment::class` | Comment model class |
| `commenter.model` | `User::class` | Commenter (user) model class | | `commenter.model` | `User::class` | Commenter (user) model class |
| `policy` | `CommentPolicy::class` | Authorization policy class | | `policy` | `CommentPolicy::class` | Authorization policy class |
@@ -183,14 +183,14 @@ Default `CommentPolicy` methods:
```php ```php
namespace App\Policies; namespace App\Policies;
use Relaticle\Comments\Comment; use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Contracts\Commenter; use Relaticle\Comments\Contracts\Commentator;
class CustomCommentPolicy class CustomCommentPolicy
{ {
public function delete(Commenter $user, Comment $comment): bool public function delete(Commentator $user, Comment $comment): bool
{ {
return $comment->user_id === $user->getKey() return $comment->commenter_id === $user->getKey()
|| $user->hasRole('admin'); || $user->hasRole('admin');
} }
} }
@@ -201,10 +201,10 @@ class CustomCommentPolicy
### Scoped Comments (Multi-tenancy) ### Scoped Comments (Multi-tenancy)
```php ```php
use Relaticle\Comments\Config; use Relaticle\Comments\CommentsConfig;
// In AppServiceProvider::boot() // In AppServiceProvider::boot()
Config::resolveAuthenticatedUserUsing(function () { CommentsConfig::resolveAuthenticatedUserUsing(function () {
return auth()->user(); return auth()->user();
}); });
``` ```
@@ -276,7 +276,7 @@ $model->commentCount(); // Total count
// On Comment model // On Comment model
$comment->commentable(); // Parent model (morphTo) $comment->commentable(); // Parent model (morphTo)
$comment->user(); // Commenter (morphTo) $comment->commenter(); // Commenter (morphTo)
$comment->parent(); // Parent comment (belongsTo) $comment->parent(); // Parent comment (belongsTo)
$comment->replies(); // Child comments (hasMany) $comment->replies(); // Child comments (hasMany)
$comment->reactions(); // Reactions (hasMany) $comment->reactions(); // Reactions (hasMany)

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

@@ -1,9 +1,58 @@
<?php <?php
return [ return [
'deleted_comment' => 'This comment was deleted.', 'comments' => [
'deleted' => 'This comment was deleted.',
'edited' => 'edited', 'edited' => 'edited',
'load_more' => 'Load more comments',
'no_comments' => 'No comments yet.', 'no_comments' => 'No comments yet.',
'comment_placeholder' => 'Write a comment...', 'placeholder' => 'Write a comment...',
'load_more' => 'Load more comments',
'sort_newest' => 'Newest first',
'sort_oldest' => 'Oldest first',
],
'actions' => [
'reply' => 'Reply',
'edit' => 'Edit',
'delete' => 'Delete',
'cancel' => 'Cancel',
'save' => 'Save',
'submit' => 'Submit',
],
'reactions' => [
'thumbs_up' => 'Thumbs up',
'heart' => 'Heart',
'celebrate' => 'Celebrate',
'laugh' => 'Laugh',
'thinking' => 'Thinking',
'sad' => 'Sad',
'reacted_by' => ':names reacted with :reaction',
'and_others' => 'and :count others',
],
'subscriptions' => [
'subscribe' => 'Subscribe to replies',
'unsubscribe' => 'Unsubscribe from replies',
'subscribed' => 'You will be notified of new replies.',
'unsubscribed' => 'You will no longer be notified.',
],
'mentions' => [
'no_results' => 'No users found',
],
'attachments' => [
'add' => 'Add attachment',
'remove' => 'Remove',
'too_large' => 'File is too large. Maximum size: :max KB.',
'invalid_type' => 'File type not allowed.',
],
'notifications' => [
'reply_subject' => 'New reply to your comment',
'reply_body' => ':name replied to your comment.',
'mention_subject' => 'You were mentioned in a comment',
'mention_body' => ':name mentioned you in a comment.',
],
]; ];

View File

@@ -3,11 +3,11 @@
<div class="shrink-0"> <div class="shrink-0">
@if ($comment->trashed()) @if ($comment->trashed())
<div class="h-8 w-8 rounded-full bg-gray-200 dark:bg-gray-700"></div> <div class="h-8 w-8 rounded-full bg-gray-200 dark:bg-gray-700"></div>
@elseif ($comment->user?->getCommentAvatarUrl()) @elseif ($comment->commenter?->getCommentAvatarUrl())
<img src="{{ $comment->user->getCommentAvatarUrl() }}" alt="{{ $comment->user->getCommentName() }}" class="h-8 w-8 rounded-full object-cover"> <img src="{{ $comment->commenter->getCommentAvatarUrl() }}" alt="{{ $comment->commenter->getCommentDisplayName() }}" class="h-8 w-8 rounded-full object-cover">
@else @else
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 text-sm font-medium text-primary-700 dark:bg-primary-800 dark:text-primary-300"> <div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 text-sm font-medium text-primary-700 dark:bg-primary-800 dark:text-primary-300">
{{ str($comment->user?->getCommentName() ?? '?')->substr(0, 1)->upper() }} {{ str($comment->commenter?->getCommentDisplayName() ?? '?')->substr(0, 1)->upper() }}
</div> </div>
@endif @endif
</div> </div>
@@ -20,7 +20,7 @@
{{-- Header: name + timestamp --}} {{-- Header: name + timestamp --}}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"> <span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $comment->user?->getCommentName() ?? 'Unknown' }} {{ $comment->commenter?->getCommentDisplayName() ?? 'Unknown' }}
</span> </span>
<span class="text-xs text-gray-500 dark:text-gray-400" title="{{ $comment->created_at->format('M j, Y g:i A') }}"> <span class="text-xs text-gray-500 dark:text-gray-400" title="{{ $comment->created_at->format('M j, Y g:i A') }}">
{{ $comment->created_at->diffForHumans() }} {{ $comment->created_at->diffForHumans() }}
@@ -32,18 +32,13 @@
{{-- Body or edit form --}} {{-- Body or edit form --}}
@if ($isEditing) @if ($isEditing)
<form wire:submit="saveEdit" class="mt-1"> <div class="mt-1">
<textarea wire:model="editBody" rows="3" {{ $this->editForm }}
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 sm:text-sm" <div class="mt-2 flex items-center justify-between">
></textarea> <button type="button" wire:click="cancelEdit" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
@error('editBody') <button type="button" wire:click="saveEdit" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">Save</button>
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p> </div>
@enderror
<div class="mt-2 flex gap-2">
<button type="submit" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">Save</button>
<button type="button" wire:click="cancelEdit" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
</div> </div>
</form>
@else @else
<div class="fi-prose prose prose-sm mt-1 max-w-none text-gray-700 dark:prose-invert dark:text-gray-300"> <div class="fi-prose prose prose-sm mt-1 max-w-none text-gray-700 dark:prose-invert dark:text-gray-300">
{!! $comment->renderBodyWithMentions() !!} {!! $comment->renderBodyWithMentions() !!}
@@ -109,107 +104,12 @@
{{-- Reply form --}} {{-- Reply form --}}
@if ($isReplying) @if ($isReplying)
<form wire:submit="addReply" class="relative mt-3" <div class="mt-3"
x-data="{ x-data="{ uploadError: null }"
showMentions: false, x-on:livewire-upload-error.window="uploadError = '{{ __('File upload failed. The file may be too large or an unsupported type.') }}'"
mentionQuery: '', x-on:livewire-upload-start.window="uploadError = null"
mentionResults: [], >
selectedIndex: 0, {{ $this->replyForm }}
mentionStart: null,
async handleInput(event) {
const textarea = event.target;
const value = textarea.value;
const cursorPos = textarea.selectionStart;
const textBeforeCursor = value.substring(0, cursorPos);
const atIndex = textBeforeCursor.lastIndexOf('@');
if (atIndex !== -1 && (atIndex === 0 || textBeforeCursor[atIndex - 1] === ' ' || textBeforeCursor[atIndex - 1] === '\n')) {
const query = textBeforeCursor.substring(atIndex + 1);
if (query.length > 0 && !query.includes(' ')) {
this.mentionStart = atIndex;
this.mentionQuery = query;
this.mentionResults = await $wire.searchUsers(query);
this.showMentions = this.mentionResults.length > 0;
this.selectedIndex = 0;
return;
}
}
this.showMentions = false;
},
handleKeydown(event) {
if (!this.showMentions) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.mentionResults.length - 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
} else if (event.key === 'Enter' || event.key === 'Tab') {
if (this.mentionResults.length > 0) {
event.preventDefault();
this.selectMention(this.mentionResults[this.selectedIndex]);
}
} else if (event.key === 'Escape') {
this.showMentions = false;
}
},
selectMention(user) {
const textarea = this.$refs.replyInput;
const value = textarea.value;
const before = value.substring(0, this.mentionStart);
const after = value.substring(textarea.selectionStart);
const newValue = before + '@' + user.name + ' ' + after;
$wire.set('replyBody', newValue);
this.showMentions = false;
this.$nextTick(() => {
const pos = before.length + user.name.length + 2;
textarea.focus();
textarea.setSelectionRange(pos, pos);
});
}
}">
<textarea x-ref="replyInput"
wire:model="replyBody"
@input="handleInput($event)"
@keydown="handleKeydown($event)"
rows="2"
placeholder="Write a reply..."
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:placeholder-gray-400 sm:text-sm"
></textarea>
{{-- Mention autocomplete dropdown --}}
<div x-show="showMentions" x-cloak
class="absolute z-50 mt-1 w-64 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800">
<template x-for="(user, index) in mentionResults" :key="user.id">
<button type="button"
@click="selectMention(user)"
:class="{ 'bg-primary-50 dark:bg-primary-900/20': index === selectedIndex }"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700">
<template x-if="user.avatar_url">
<img :src="user.avatar_url" class="h-6 w-6 rounded-full object-cover" />
</template>
<template x-if="!user.avatar_url">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-medium text-primary-700 dark:bg-primary-800 dark:text-primary-300"
x-text="user.name.charAt(0).toUpperCase()"></div>
</template>
<span x-text="user.name" class="text-gray-900 dark:text-gray-100"></span>
</button>
</template>
</div>
@error('replyBody')
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
@enderror
@if (\Relaticle\Comments\Config::areAttachmentsEnabled())
<div class="mt-2">
<label class="flex cursor-pointer items-center gap-2 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
</svg>
Attach files
<input type="file" wire:model="replyAttachments" multiple class="hidden" accept="{{ implode(',', \Relaticle\Comments\Config::getAttachmentAllowedTypes()) }}" />
</label>
</div>
@if (!empty($replyAttachments)) @if (!empty($replyAttachments))
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
@@ -220,22 +120,33 @@
</div> </div>
@endforeach @endforeach
</div> </div>
@endif
@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 gap-2"> <div class="mt-2 flex items-center justify-between">
<button type="submit" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">Reply</button> <div class="flex items-center gap-3">
<button type="button" wire:click="cancelReply" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button> @if (\Relaticle\Comments\CommentsConfig::areAttachmentsEnabled())
<label class="flex cursor-pointer items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
</svg>
Attach
<input type="file" wire:model="replyAttachments" multiple class="hidden" accept="{{ implode(',', \Relaticle\Comments\CommentsConfig::getAttachmentAllowedTypes()) }}" />
</label>
@endif
<button type="button" wire:click="cancelReply" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
</div>
<button type="button" wire:click="addReply" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">Reply</button>
</div>
</div> </div>
</form>
@endif @endif
{{-- Nested replies --}} {{-- Nested replies --}}
@if ($comment->replies->isNotEmpty()) @if ($comment->relationLoaded('replies') && $comment->replies->isNotEmpty())
<div class="mt-3 space-y-3 border-l-2 border-gray-200 pl-4 dark:border-gray-700"> <div class="mt-3 space-y-3 border-l-2 border-gray-200 pl-4 dark:border-gray-700">
@foreach ($comment->replies as $reply) @foreach ($comment->replies as $reply)
<livewire:comment-item :comment="$reply" :key="'comment-'.$reply->id" /> <livewire:comment-item :comment="$reply" :key="'comment-'.$reply->id" />

View File

@@ -1,12 +1,15 @@
<div class="space-y-4" <div class="space-y-4"
@if (!\Relaticle\Comments\Config::isBroadcastingEnabled()) @if (!\Relaticle\Comments\CommentsConfig::isBroadcastingEnabled())
wire:poll.{{ \Relaticle\Comments\Config::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">
@@ -57,111 +60,11 @@
</div> </div>
@endif @endif
{{-- New comment form - only for authorized users --}} {{-- New comment form - sticky at bottom of slide-over --}}
@auth @auth
@can('create', \Relaticle\Comments\Config::getCommentModel()) @can('create', \Relaticle\Comments\CommentsConfig::getCommentModel())
<form wire:submit="addComment" class="relative mt-4" <div class="sticky bottom-0 z-10 -mx-4 -mb-4 border-t border-gray-200 bg-white px-4 pb-4 pt-3 dark:border-gray-700 dark:bg-gray-900">
x-data="{ {{ $this->commentForm }}
showMentions: false,
mentionQuery: '',
mentionResults: [],
selectedIndex: 0,
mentionStart: null,
async handleInput(event) {
const textarea = event.target;
const value = textarea.value;
const cursorPos = textarea.selectionStart;
const textBeforeCursor = value.substring(0, cursorPos);
const atIndex = textBeforeCursor.lastIndexOf('@');
if (atIndex !== -1 && (atIndex === 0 || textBeforeCursor[atIndex - 1] === ' ' || textBeforeCursor[atIndex - 1] === '\n')) {
const query = textBeforeCursor.substring(atIndex + 1);
if (query.length > 0 && !query.includes(' ')) {
this.mentionStart = atIndex;
this.mentionQuery = query;
this.mentionResults = await $wire.searchUsers(query);
this.showMentions = this.mentionResults.length > 0;
this.selectedIndex = 0;
return;
}
}
this.showMentions = false;
},
handleKeydown(event) {
if (!this.showMentions) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.mentionResults.length - 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
} else if (event.key === 'Enter' || event.key === 'Tab') {
if (this.mentionResults.length > 0) {
event.preventDefault();
this.selectMention(this.mentionResults[this.selectedIndex]);
}
} else if (event.key === 'Escape') {
this.showMentions = false;
}
},
selectMention(user) {
const textarea = this.$refs.commentInput;
const value = textarea.value;
const before = value.substring(0, this.mentionStart);
const after = value.substring(textarea.selectionStart);
const newValue = before + '@' + user.name + ' ' + after;
$wire.set('newComment', newValue);
this.showMentions = false;
this.$nextTick(() => {
const pos = before.length + user.name.length + 2;
textarea.focus();
textarea.setSelectionRange(pos, pos);
});
}
}">
<textarea
x-ref="commentInput"
wire:model="newComment"
@input="handleInput($event)"
@keydown="handleKeydown($event)"
rows="3"
placeholder="Write a comment..."
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:placeholder-gray-400 sm:text-sm"
></textarea>
{{-- Mention autocomplete dropdown --}}
<div x-show="showMentions" x-cloak
class="absolute z-50 mt-1 w-64 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800">
<template x-for="(user, index) in mentionResults" :key="user.id">
<button type="button"
@click="selectMention(user)"
:class="{ 'bg-primary-50 dark:bg-primary-900/20': index === selectedIndex }"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700">
<template x-if="user.avatar_url">
<img :src="user.avatar_url" class="h-6 w-6 rounded-full object-cover" />
</template>
<template x-if="!user.avatar_url">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-medium text-primary-700 dark:bg-primary-800 dark:text-primary-300"
x-text="user.name.charAt(0).toUpperCase()"></div>
</template>
<span x-text="user.name" class="text-gray-900 dark:text-gray-100"></span>
</button>
</template>
</div>
@error('newComment')
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
@enderror
@if (\Relaticle\Comments\Config::areAttachmentsEnabled())
<div class="mt-2">
<label class="flex cursor-pointer items-center gap-2 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
</svg>
Attach files
<input type="file" wire:model="attachments" multiple class="hidden" accept="{{ implode(',', \Relaticle\Comments\Config::getAttachmentAllowedTypes()) }}" />
</label>
</div>
@if (!empty($attachments)) @if (!empty($attachments))
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
@@ -172,22 +75,34 @@
</div> </div>
@endforeach @endforeach
</div> </div>
@endif
@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 justify-end"> <div class="mt-2 flex items-center justify-between">
<button type="submit" @if (\Relaticle\Comments\CommentsConfig::areAttachmentsEnabled())
<label class="flex cursor-pointer items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
</svg>
Attach
<input type="file" wire:model="attachments" multiple class="hidden" accept="{{ implode(',', \Relaticle\Comments\CommentsConfig::getAttachmentAllowedTypes()) }}" />
</label>
@else
<div></div>
@endif
<button type="button" wire:click="addComment"
class="inline-flex items-center rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:bg-primary-500 dark:hover:bg-primary-400 dark:focus:ring-offset-gray-800" class="inline-flex items-center rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:bg-primary-500 dark:hover:bg-primary-400 dark:focus:ring-offset-gray-800"
wire:loading.attr="disabled" wire:target="addComment"> wire:loading.attr="disabled" wire:target="addComment">
<span wire:loading.remove wire:target="addComment">Comment</span> <span wire:loading.remove wire:target="addComment">Comment</span>
<span wire:loading wire:target="addComment">Posting...</span> <span wire:loading wire:target="addComment">Posting...</span>
</button> </button>
</div> </div>
</form> </div>
@endcan @endcan
@endauth @endauth
</div> </div>

View File

@@ -25,7 +25,7 @@
{{-- Emoji picker dropdown --}} {{-- Emoji picker dropdown --}}
<div x-show="open" x-cloak @click.outside="open = false" <div x-show="open" x-cloak @click.outside="open = false"
class="absolute bottom-full left-0 z-50 mb-1 flex gap-1 rounded-lg border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-600 dark:bg-gray-800"> class="absolute bottom-full left-0 z-50 mb-1 flex gap-1 rounded-lg border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-600 dark:bg-gray-800">
@foreach (\Relaticle\Comments\Config::getReactionEmojiSet() as $key => $emoji) @foreach (\Relaticle\Comments\CommentsConfig::getReactionEmojiSet() as $key => $emoji)
<button wire:click="toggleReaction('{{ $key }}')" type="button" <button wire:click="toggleReaction('{{ $key }}')" type="button"
class="rounded p-1 text-base hover:bg-gray-100 dark:hover:bg-gray-700" class="rounded p-1 text-base hover:bg-gray-100 dark:hover:bg-gray-700"
title="{{ str_replace('_', ' ', $key) }}"> title="{{ str_replace('_', ' ', $key) }}">

View File

@@ -4,10 +4,12 @@ namespace Relaticle\Comments;
use App\Models\User; use App\Models\User;
use Closure; use Closure;
use Filament\Forms\Components\RichEditor\MentionProvider;
use Relaticle\Comments\Mentions\DefaultMentionResolver; use Relaticle\Comments\Mentions\DefaultMentionResolver;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Policies\CommentPolicy; use Relaticle\Comments\Policies\CommentPolicy;
class Config class CommentsConfig
{ {
protected static ?Closure $resolveAuthenticatedUser = null; protected static ?Closure $resolveAuthenticatedUser = null;
@@ -23,7 +25,25 @@ class Config
public static function getCommentTable(): string public static function getCommentTable(): string
{ {
return config('comments.tables.comments', 'comments'); return static::getTableName('comments');
}
public static function getTableName(string $table): string
{
$defaults = [
'comments' => 'comments',
'reactions' => 'comment_reactions',
'mentions' => 'comment_mentions',
'subscriptions' => 'comment_subscriptions',
'attachments' => 'comment_attachments',
];
return config("comments.table_names.{$table}", $defaults[$table] ?? $table);
}
public static function getCommenterMorphName(): string
{
return config('comments.column_names.commenter_morph', 'commenter');
} }
public static function getMaxDepth(): int public static function getMaxDepth(): int
@@ -154,4 +174,19 @@ class Config
{ {
static::$resolveAuthenticatedUser = $callback; static::$resolveAuthenticatedUser = $callback;
} }
public static function makeMentionProvider(): MentionProvider
{
return MentionProvider::make('@')
->getSearchResultsUsing(fn (string $search): array => static::getCommenterModel()::query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(static::getMentionMaxResults())
->pluck('name', 'id')
->all())
->getLabelsUsing(fn (array $ids): array => static::getCommenterModel()::query()
->whereIn('id', $ids)
->pluck('name', 'id')
->all());
}
} }

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
{ {
@@ -42,20 +46,41 @@ class CommentsServiceProvider extends PackageServiceProvider
public function packageRegistered(): void public function packageRegistered(): void
{ {
Relation::morphMap([ Relation::morphMap([
'comment' => Config::getCommentModel(), 'comment' => CommentsConfig::getCommentModel(),
]); ]);
$this->app->bind( $this->app->bind(
MentionResolver::class, MentionResolver::class,
fn () => new (Config::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
{ {
Gate::policy( Gate::policy(
Config::getCommentModel(), CommentsConfig::getCommentModel(),
Config::getPolicyClass(), CommentsConfig::getPolicyClass(),
); );
Event::listen(CommentCreated::class, SendCommentRepliedNotification::class); Event::listen(CommentCreated::class, SendCommentRepliedNotification::class);
@@ -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

@@ -5,9 +5,9 @@ namespace Relaticle\Comments\Concerns;
use Filament\Models\Contracts\HasAvatar; use Filament\Models\Contracts\HasAvatar;
use Filament\Models\Contracts\HasName; use Filament\Models\Contracts\HasName;
trait IsCommenter trait CanComment
{ {
public function getCommentName(): string public function getCommentDisplayName(): string
{ {
if ($this instanceof HasName) { if ($this instanceof HasName) {
return $this->getFilamentName(); return $this->getFilamentName();

View File

@@ -3,13 +3,13 @@
namespace Relaticle\Comments\Concerns; namespace Relaticle\Comments\Concerns;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Relaticle\Comments\Config; use Relaticle\Comments\CommentsConfig;
trait HasComments trait HasComments
{ {
public function comments(): MorphMany public function comments(): MorphMany
{ {
return $this->morphMany(Config::getCommentModel(), 'commentable'); return $this->morphMany(CommentsConfig::getCommentModel(), 'commentable');
} }
public function topLevelComments(): MorphMany public function topLevelComments(): MorphMany

View File

@@ -2,13 +2,13 @@
namespace Relaticle\Comments\Contracts; namespace Relaticle\Comments\Contracts;
interface Commenter interface Commentator
{ {
public function getKey(); public function getKey();
public function getMorphClass(); public function getMorphClass();
public function getCommentName(): string; public function getCommentDisplayName(): string;
public function getCommentAvatarUrl(): ?string; public function getCommentAvatarUrl(): ?string;
} }

View File

@@ -8,8 +8,8 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config; use Relaticle\Comments\Models\Comment;
class CommentCreated implements ShouldBroadcast class CommentCreated implements ShouldBroadcast
{ {
@@ -27,7 +27,7 @@ class CommentCreated implements ShouldBroadcast
/** @return array<int, PrivateChannel> */ /** @return array<int, PrivateChannel> */
public function broadcastOn(): array public function broadcastOn(): array
{ {
$prefix = Config::getBroadcastChannelPrefix(); $prefix = CommentsConfig::getBroadcastChannelPrefix();
return [ return [
new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"), new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"),
@@ -36,7 +36,7 @@ class CommentCreated implements ShouldBroadcast
public function broadcastWhen(): bool public function broadcastWhen(): bool
{ {
return Config::isBroadcastingEnabled(); return CommentsConfig::isBroadcastingEnabled();
} }
/** @return array{comment_id: int|string, commentable_type: string, commentable_id: int|string} */ /** @return array{comment_id: int|string, commentable_type: string, commentable_id: int|string} */

View File

@@ -8,8 +8,8 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config; use Relaticle\Comments\Models\Comment;
class CommentDeleted implements ShouldBroadcast class CommentDeleted implements ShouldBroadcast
{ {
@@ -27,7 +27,7 @@ class CommentDeleted implements ShouldBroadcast
/** @return array<int, PrivateChannel> */ /** @return array<int, PrivateChannel> */
public function broadcastOn(): array public function broadcastOn(): array
{ {
$prefix = Config::getBroadcastChannelPrefix(); $prefix = CommentsConfig::getBroadcastChannelPrefix();
return [ return [
new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"), new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"),
@@ -36,7 +36,7 @@ class CommentDeleted implements ShouldBroadcast
public function broadcastWhen(): bool public function broadcastWhen(): bool
{ {
return Config::isBroadcastingEnabled(); return CommentsConfig::isBroadcastingEnabled();
} }
/** @return array{comment_id: int|string, commentable_type: string, commentable_id: int|string} */ /** @return array{comment_id: int|string, commentable_type: string, commentable_id: int|string} */

View File

@@ -7,8 +7,8 @@ use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config; use Relaticle\Comments\Models\Comment;
class CommentReacted implements ShouldBroadcast class CommentReacted implements ShouldBroadcast
{ {
@@ -26,7 +26,7 @@ class CommentReacted implements ShouldBroadcast
/** @return array<int, PrivateChannel> */ /** @return array<int, PrivateChannel> */
public function broadcastOn(): array public function broadcastOn(): array
{ {
$prefix = Config::getBroadcastChannelPrefix(); $prefix = CommentsConfig::getBroadcastChannelPrefix();
return [ return [
new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"), new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"),
@@ -35,7 +35,7 @@ class CommentReacted implements ShouldBroadcast
public function broadcastWhen(): bool public function broadcastWhen(): bool
{ {
return Config::isBroadcastingEnabled(); return CommentsConfig::isBroadcastingEnabled();
} }
/** @return array{comment_id: int|string, reaction: string, action: string} */ /** @return array{comment_id: int|string, reaction: string, action: string} */

View File

@@ -8,8 +8,8 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config; use Relaticle\Comments\Models\Comment;
class CommentUpdated implements ShouldBroadcast class CommentUpdated implements ShouldBroadcast
{ {
@@ -27,7 +27,7 @@ class CommentUpdated implements ShouldBroadcast
/** @return array<int, PrivateChannel> */ /** @return array<int, PrivateChannel> */
public function broadcastOn(): array public function broadcastOn(): array
{ {
$prefix = Config::getBroadcastChannelPrefix(); $prefix = CommentsConfig::getBroadcastChannelPrefix();
return [ return [
new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"), new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"),
@@ -36,7 +36,7 @@ class CommentUpdated implements ShouldBroadcast
public function broadcastWhen(): bool public function broadcastWhen(): bool
{ {
return Config::isBroadcastingEnabled(); return CommentsConfig::isBroadcastingEnabled();
} }
/** @return array{comment_id: int|string, commentable_type: string, commentable_id: int|string} */ /** @return array{comment_id: int|string, commentable_type: string, commentable_id: int|string} */

View File

@@ -5,7 +5,7 @@ namespace Relaticle\Comments\Events;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Relaticle\Comments\Comment; use Relaticle\Comments\Models\Comment;
class UserMentioned class UserMentioned
{ {

View File

@@ -3,8 +3,8 @@
namespace Relaticle\Comments\Filament\Actions; namespace Relaticle\Comments\Filament\Actions;
use Filament\Actions\Action; use Filament\Actions\Action;
use Illuminate\Contracts\View\View;
use Relaticle\Comments\Concerns\HasComments; use Relaticle\Comments\Concerns\HasComments;
use Relaticle\Comments\Filament\Infolists\Components\CommentsEntry;
class CommentsAction extends Action class CommentsAction extends Action
{ {
@@ -19,11 +19,9 @@ class CommentsAction extends Action
->modalHeading(__('Comments')) ->modalHeading(__('Comments'))
->modalSubmitAction(false) ->modalSubmitAction(false)
->modalCancelAction(false) ->modalCancelAction(false)
->modalContent(function (): View { ->schema([
return view('comments::filament.comments-action', [ CommentsEntry::make('comments'),
'record' => $this->getRecord(), ])
]);
})
->badge(function (): ?int { ->badge(function (): ?int {
$record = $this->getRecord(); $record = $this->getRecord();
@@ -38,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

@@ -3,8 +3,8 @@
namespace Relaticle\Comments\Filament\Actions; namespace Relaticle\Comments\Filament\Actions;
use Filament\Actions\Action; use Filament\Actions\Action;
use Illuminate\Contracts\View\View;
use Relaticle\Comments\Concerns\HasComments; use Relaticle\Comments\Concerns\HasComments;
use Relaticle\Comments\Filament\Infolists\Components\CommentsEntry;
class CommentsTableAction extends Action class CommentsTableAction extends Action
{ {
@@ -19,11 +19,9 @@ class CommentsTableAction extends Action
->modalHeading(__('Comments')) ->modalHeading(__('Comments'))
->modalSubmitAction(false) ->modalSubmitAction(false)
->modalCancelAction(false) ->modalCancelAction(false)
->modalContent(function (): View { ->schema([
return view('comments::filament.comments-action', [ CommentsEntry::make('comments'),
'record' => $this->getRecord(), ])
]);
})
->badge(function (): ?int { ->badge(function (): ?int {
$record = $this->getRecord(); $record = $this->getRecord();

View File

@@ -3,35 +3,35 @@
namespace Relaticle\Comments\Listeners; namespace Relaticle\Comments\Listeners;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Relaticle\Comments\CommentSubscription; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config;
use Relaticle\Comments\Events\CommentCreated; use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Models\Subscription;
use Relaticle\Comments\Notifications\CommentRepliedNotification; use Relaticle\Comments\Notifications\CommentRepliedNotification;
class SendCommentRepliedNotification class SendCommentRepliedNotification
{ {
public function handle(CommentCreated $event): void public function handle(CommentCreated $event): void
{ {
if (! Config::areNotificationsEnabled()) { if (! CommentsConfig::areNotificationsEnabled()) {
return; return;
} }
$comment = $event->comment; $comment = $event->comment;
$commentable = $event->commentable; $commentable = $event->commentable;
if (Config::shouldAutoSubscribe()) { if (CommentsConfig::shouldAutoSubscribe()) {
CommentSubscription::subscribe($commentable, $comment->user); Subscription::subscribe($commentable, $comment->commenter);
} }
if (! $comment->isReply()) { if (! $comment->isReply()) {
return; return;
} }
$subscribers = CommentSubscription::subscribersFor($commentable); $subscribers = Subscription::subscribersFor($commentable);
$recipients = $subscribers->filter(function ($user) use ($comment) { $recipients = $subscribers->filter(function ($user) use ($comment) {
return ! ($user->getMorphClass() === $comment->user->getMorphClass() return ! ($user->getMorphClass() === $comment->commenter->getMorphClass()
&& $user->getKey() === $comment->user->getKey()); && $user->getKey() === $comment->commenter->getKey());
}); });
if ($recipients->isEmpty()) { if ($recipients->isEmpty()) {

View File

@@ -2,33 +2,33 @@
namespace Relaticle\Comments\Listeners; namespace Relaticle\Comments\Listeners;
use Relaticle\Comments\CommentSubscription; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config;
use Relaticle\Comments\Events\UserMentioned; use Relaticle\Comments\Events\UserMentioned;
use Relaticle\Comments\Models\Subscription;
use Relaticle\Comments\Notifications\UserMentionedNotification; use Relaticle\Comments\Notifications\UserMentionedNotification;
class SendUserMentionedNotification class SendUserMentionedNotification
{ {
public function handle(UserMentioned $event): void public function handle(UserMentioned $event): void
{ {
if (! Config::areNotificationsEnabled()) { if (! CommentsConfig::areNotificationsEnabled()) {
return; return;
} }
$comment = $event->comment; $comment = $event->comment;
$mentionedUser = $event->mentionedUser; $mentionedUser = $event->mentionedUser;
if (Config::shouldAutoSubscribe()) { if (CommentsConfig::shouldAutoSubscribe()) {
CommentSubscription::subscribe($comment->commentable, $mentionedUser); Subscription::subscribe($comment->commentable, $mentionedUser);
} }
$isSelf = $mentionedUser->getMorphClass() === $comment->user->getMorphClass() $isSelf = $mentionedUser->getMorphClass() === $comment->commenter->getMorphClass()
&& $mentionedUser->getKey() === $comment->user->getKey(); && $mentionedUser->getKey() === $comment->commenter->getKey();
if ($isSelf) { if ($isSelf) {
return; return;
} }
$mentionedUser->notify(new UserMentionedNotification($comment, $comment->user)); $mentionedUser->notify(new UserMentionedNotification($comment, $comment->commenter));
} }
} }

View File

@@ -2,20 +2,27 @@
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\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Schemas\Schema;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Livewire\Component; use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config;
use Relaticle\Comments\Contracts\MentionResolver;
use Relaticle\Comments\Events\CommentCreated; use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Events\CommentDeleted; use Relaticle\Comments\Events\CommentDeleted;
use Relaticle\Comments\Events\CommentUpdated; use Relaticle\Comments\Events\CommentUpdated;
use Relaticle\Comments\Mentions\MentionParser; use Relaticle\Comments\Mentions\MentionParser;
use Relaticle\Comments\Models\Comment;
class CommentItem extends Component class CommentItem extends Component implements HasActions, HasForms
{ {
use InteractsWithActions;
use InteractsWithForms;
use WithFileUploads; use WithFileUploads;
public Comment $comment; public Comment $comment;
@@ -24,9 +31,11 @@ class CommentItem extends Component
public bool $isReplying = false; public bool $isReplying = false;
public string $editBody = ''; /** @var array<string, mixed> */
public ?array $editData = [];
public string $replyBody = ''; /** @var array<string, mixed> */
public ?array $replyData = [];
/** @var array<int, TemporaryUploadedFile> */ /** @var array<int, TemporaryUploadedFile> */
public array $replyAttachments = []; public array $replyAttachments = [];
@@ -36,30 +45,60 @@ class CommentItem extends Component
$this->comment = $comment; $this->comment = $comment;
} }
public function editForm(Schema $schema): Schema
{
return $schema
->components([
RichEditor::make('body')
->hiddenLabel()
->required()
->placeholder(__('Edit your comment...'))
->toolbarButtons(CommentsConfig::getEditorToolbar())
->mentions([
CommentsConfig::makeMentionProvider(),
]),
])
->statePath('editData');
}
public function replyForm(Schema $schema): Schema
{
return $schema
->components([
RichEditor::make('body')
->hiddenLabel()
->required()
->placeholder(__('Write a reply...'))
->toolbarButtons(CommentsConfig::getEditorToolbar())
->mentions([
CommentsConfig::makeMentionProvider(),
]),
])
->statePath('replyData');
}
public function startEdit(): void public function startEdit(): void
{ {
$this->authorize('update', $this->comment); $this->authorize('update', $this->comment);
$this->isEditing = true; $this->isEditing = true;
$this->editBody = $this->comment->body; $this->editForm->fill(['body' => $this->comment->body]);
} }
public function cancelEdit(): void public function cancelEdit(): void
{ {
$this->isEditing = false; $this->isEditing = false;
$this->editBody = ''; $this->editForm->fill();
} }
public function saveEdit(): void public function saveEdit(): void
{ {
$this->authorize('update', $this->comment); $this->authorize('update', $this->comment);
$this->validate([ $data = $this->editForm->getState();
'editBody' => ['required', 'string', 'min:1'],
]);
$this->comment->update([ $this->comment->update([
'body' => $this->editBody, 'body' => $data['body'] ?? '',
'edited_at' => now(), 'edited_at' => now(),
]); ]);
@@ -70,7 +109,7 @@ class CommentItem extends Component
$this->dispatch('commentUpdated'); $this->dispatch('commentUpdated');
$this->isEditing = false; $this->isEditing = false;
$this->editBody = ''; $this->editForm->fill();
} }
public function deleteComment(): void public function deleteComment(): void
@@ -91,12 +130,13 @@ class CommentItem extends Component
} }
$this->isReplying = true; $this->isReplying = true;
$this->replyForm->fill();
} }
public function cancelReply(): void public function cancelReply(): void
{ {
$this->isReplying = false; $this->isReplying = false;
$this->replyBody = ''; $this->replyForm->fill();
$this->replyAttachments = []; $this->replyAttachments = [];
} }
@@ -104,27 +144,27 @@ class CommentItem extends Component
{ {
$this->authorize('reply', $this->comment); $this->authorize('reply', $this->comment);
$rules = ['replyBody' => ['required', 'string', 'min:1']]; $data = $this->replyForm->getState();
if (Config::areAttachmentsEnabled()) { if (CommentsConfig::areAttachmentsEnabled()) {
$maxSize = Config::getAttachmentMaxSize(); $maxSize = CommentsConfig::getAttachmentMaxSize();
$allowedTypes = implode(',', Config::getAttachmentAllowedTypes()); $allowedTypes = implode(',', CommentsConfig::getAttachmentAllowedTypes());
$rules['replyAttachments.*'] = ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"]; $this->validate([
'replyAttachments.*' => ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"],
]);
} }
$this->validate($rules); $user = CommentsConfig::resolveAuthenticatedUser();
$user = Config::resolveAuthenticatedUser();
$reply = $this->comment->commentable->comments()->create([ $reply = $this->comment->commentable->comments()->create([
'body' => $this->replyBody, 'body' => $data['body'] ?? '',
'parent_id' => $this->comment->id, 'parent_id' => $this->comment->id,
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
if (Config::areAttachmentsEnabled() && ! empty($this->replyAttachments)) { if (CommentsConfig::areAttachmentsEnabled() && ! empty($this->replyAttachments)) {
$disk = Config::getAttachmentDisk(); $disk = CommentsConfig::getAttachmentDisk();
foreach ($this->replyAttachments as $file) { foreach ($this->replyAttachments as $file) {
$path = $file->store("comments/attachments/{$reply->id}", $disk); $path = $file->store("comments/attachments/{$reply->id}", $disk);
@@ -143,10 +183,12 @@ class CommentItem extends Component
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;
$this->replyBody = ''; $this->replyForm->fill();
$this->replyAttachments = []; $this->replyAttachments = [];
} }
@@ -157,25 +199,6 @@ class CommentItem extends Component
$this->replyAttachments = array_values($attachments); $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 public function render(): View
{ {
return view('comments::livewire.comment-item'); return view('comments::livewire.comment-item');

View File

@@ -2,6 +2,12 @@
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\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Schemas\Schema;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -9,20 +15,22 @@ use Livewire\Attributes\Computed;
use Livewire\Component; use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\CommentSubscription;
use Relaticle\Comments\Config;
use Relaticle\Comments\Contracts\MentionResolver;
use Relaticle\Comments\Events\CommentCreated; use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Mentions\MentionParser; use Relaticle\Comments\Mentions\MentionParser;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Models\Subscription;
class Comments extends Component class Comments extends Component implements HasActions, HasForms
{ {
use InteractsWithActions;
use InteractsWithForms;
use WithFileUploads; use WithFileUploads;
public Model $model; public Model $model;
public string $newComment = ''; /** @var array<string, mixed> */
public ?array $commentData = [];
public string $sortDirection = 'asc'; public string $sortDirection = 'asc';
@@ -36,8 +44,25 @@ class Comments extends Component
public function mount(Model $model): void public function mount(Model $model): void
{ {
$this->model = $model; $this->model = $model;
$this->perPage = Config::getPerPage(); $this->perPage = CommentsConfig::getPerPage();
$this->loadedCount = $this->perPage; $this->loadedCount = $this->perPage;
$this->commentForm->fill();
}
public function commentForm(Schema $schema): Schema
{
return $schema
->components([
RichEditor::make('body')
->hiddenLabel()
->required()
->placeholder(__('Write a comment...'))
->toolbarButtons(CommentsConfig::getEditorToolbar())
->mentions([
CommentsConfig::makeMentionProvider(),
]),
])
->statePath('commentData');
} }
/** @return Collection<int, Comment> */ /** @return Collection<int, Comment> */
@@ -46,7 +71,7 @@ class Comments extends Component
{ {
return $this->model return $this->model
->topLevelComments() ->topLevelComments()
->with(['user', 'mentions', 'attachments', 'reactions.user', 'replies.user', 'replies.mentions', 'replies.attachments', 'replies.reactions.user']) ->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();
@@ -58,6 +83,12 @@ class Comments extends Component
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
{ {
@@ -67,27 +98,27 @@ class Comments extends Component
#[Computed] #[Computed]
public function isSubscribed(): bool public function isSubscribed(): bool
{ {
$user = Config::resolveAuthenticatedUser(); $user = CommentsConfig::resolveAuthenticatedUser();
if (! $user) { if (! $user) {
return false; return false;
} }
return CommentSubscription::isSubscribed($this->model, $user); return Subscription::isSubscribed($this->model, $user);
} }
public function toggleSubscription(): void public function toggleSubscription(): void
{ {
$user = Config::resolveAuthenticatedUser(); $user = CommentsConfig::resolveAuthenticatedUser();
if (! $user) { if (! $user) {
return; return;
} }
if ($this->isSubscribed) { if ($this->isSubscribed) {
CommentSubscription::unsubscribe($this->model, $user); Subscription::unsubscribe($this->model, $user);
} else { } else {
CommentSubscription::subscribe($this->model, $user); Subscription::subscribe($this->model, $user);
} }
unset($this->isSubscribed); unset($this->isSubscribed);
@@ -95,28 +126,28 @@ class Comments extends Component
public function addComment(): void public function addComment(): void
{ {
$rules = ['newComment' => ['required', 'string', 'min:1']]; $data = $this->commentForm->getState();
if (Config::areAttachmentsEnabled()) { if (CommentsConfig::areAttachmentsEnabled()) {
$maxSize = Config::getAttachmentMaxSize(); $maxSize = CommentsConfig::getAttachmentMaxSize();
$allowedTypes = implode(',', Config::getAttachmentAllowedTypes()); $allowedTypes = implode(',', CommentsConfig::getAttachmentAllowedTypes());
$rules['attachments.*'] = ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"]; $this->validate([
'attachments.*' => ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"],
]);
} }
$this->validate($rules); $this->authorize('create', CommentsConfig::getCommentModel());
$this->authorize('create', Config::getCommentModel()); $user = CommentsConfig::resolveAuthenticatedUser();
$user = Config::resolveAuthenticatedUser();
$comment = $this->model->comments()->create([ $comment = $this->model->comments()->create([
'body' => $this->newComment, 'body' => $data['body'] ?? '',
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
if (Config::areAttachmentsEnabled() && ! empty($this->attachments)) { if (CommentsConfig::areAttachmentsEnabled() && ! empty($this->attachments)) {
$disk = Config::getAttachmentDisk(); $disk = CommentsConfig::getAttachmentDisk();
foreach ($this->attachments as $file) { foreach ($this->attachments as $file) {
$path = $file->store("comments/attachments/{$comment->id}", $disk); $path = $file->store("comments/attachments/{$comment->id}", $disk);
@@ -135,7 +166,8 @@ class Comments extends Component
app(MentionParser::class)->syncMentions($comment); app(MentionParser::class)->syncMentions($comment);
$this->reset('newComment', 'attachments'); $this->commentForm->fill();
$this->reset('attachments');
} }
public function removeAttachment(int $index): void public function removeAttachment(int $index): void
@@ -163,8 +195,8 @@ class Comments extends Component
'commentUpdated' => 'refreshComments', 'commentUpdated' => 'refreshComments',
]; ];
if (Config::isBroadcastingEnabled()) { if (CommentsConfig::isBroadcastingEnabled()) {
$prefix = Config::getBroadcastChannelPrefix(); $prefix = CommentsConfig::getBroadcastChannelPrefix();
$type = $this->model->getMorphClass(); $type = $this->model->getMorphClass();
$id = $this->model->getKey(); $id = $this->model->getKey();
$channel = "echo-private:{$prefix}.{$type}.{$id}"; $channel = "echo-private:{$prefix}.{$type}.{$id}";
@@ -180,26 +212,7 @@ class Comments extends Component
public function refreshComments(): void public function refreshComments(): void
{ {
unset($this->comments, $this->totalCount, $this->hasMore); unset($this->comments, $this->totalCount, $this->hasMore, $this->allCommentsCount);
}
/** @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 public function render(): View

View File

@@ -5,9 +5,9 @@ namespace Relaticle\Comments\Livewire;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Component; use Livewire\Component;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config;
use Relaticle\Comments\Events\CommentReacted; use Relaticle\Comments\Events\CommentReacted;
use Relaticle\Comments\Models\Comment;
class Reactions extends Component class Reactions extends Component
{ {
@@ -22,19 +22,19 @@ class Reactions extends Component
public function toggleReaction(string $reaction): void public function toggleReaction(string $reaction): void
{ {
$user = Config::resolveAuthenticatedUser(); $user = CommentsConfig::resolveAuthenticatedUser();
if (! $user) { if (! $user) {
return; return;
} }
if (! in_array($reaction, Config::getAllowedReactions())) { if (! in_array($reaction, CommentsConfig::getAllowedReactions())) {
return; return;
} }
$existing = $this->comment->reactions() $existing = $this->comment->reactions()
->where('user_id', $user->getKey()) ->where('commenter_id', $user->getKey())
->where('user_type', $user->getMorphClass()) ->where('commenter_type', $user->getMorphClass())
->where('reaction', $reaction) ->where('reaction', $reaction)
->first(); ->first();
@@ -44,8 +44,8 @@ class Reactions extends Component
event(new CommentReacted($this->comment, $user, $reaction, 'removed')); event(new CommentReacted($this->comment, $user, $reaction, 'removed'));
} else { } else {
$this->comment->reactions()->create([ $this->comment->reactions()->create([
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'reaction' => $reaction, 'reaction' => $reaction,
]); ]);
@@ -66,13 +66,13 @@ class Reactions extends Component
#[Computed] #[Computed]
public function reactionSummary(): array public function reactionSummary(): array
{ {
$user = Config::resolveAuthenticatedUser(); $user = CommentsConfig::resolveAuthenticatedUser();
$userId = $user?->getKey(); $userId = $user?->getKey();
$userType = $user?->getMorphClass(); $userType = $user?->getMorphClass();
$reactions = $this->comment->reactions()->with('user')->get(); $reactions = $this->comment->reactions()->with('commenter')->get();
$emojiSet = Config::getReactionEmojiSet(); $emojiSet = CommentsConfig::getReactionEmojiSet();
return $reactions return $reactions
->groupBy('reaction') ->groupBy('reaction')
@@ -81,10 +81,10 @@ class Reactions extends Component
'reaction' => $key, 'reaction' => $key,
'emoji' => $emojiSet[$key] ?? $key, 'emoji' => $emojiSet[$key] ?? $key,
'count' => $group->count(), 'count' => $group->count(),
'names' => $group->pluck('user.name')->filter()->take(3)->values()->all(), 'names' => $group->pluck('commenter.name')->filter()->take(3)->values()->all(),
'total_reactors' => $group->count(), 'total_reactors' => $group->count(),
'reacted_by_user' => $group->contains( 'reacted_by_user' => $group->contains(
fn ($r) => $r->user_id == $userId && $r->user_type === $userType fn ($r) => $r->commenter_id == $userId && $r->commenter_type === $userType
), ),
]; ];
}) })

View File

@@ -4,7 +4,7 @@ namespace Relaticle\Comments\Mentions;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Relaticle\Comments\Config; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Contracts\MentionResolver; use Relaticle\Comments\Contracts\MentionResolver;
class DefaultMentionResolver implements MentionResolver class DefaultMentionResolver implements MentionResolver
@@ -12,18 +12,18 @@ class DefaultMentionResolver implements MentionResolver
/** @return Collection<int, Model> */ /** @return Collection<int, Model> */
public function search(string $query): Collection public function search(string $query): Collection
{ {
$model = Config::getCommenterModel(); $model = CommentsConfig::getCommenterModel();
return $model::query() return $model::query()
->where('name', 'like', "{$query}%") ->where('name', 'like', "{$query}%")
->limit(Config::getMentionMaxResults()) ->limit(CommentsConfig::getMentionMaxResults())
->get(); ->get();
} }
/** @return Collection<int, Model> */ /** @return Collection<int, Model> */
public function resolveByNames(array $names): Collection public function resolveByNames(array $names): Collection
{ {
$model = Config::getCommenterModel(); $model = CommentsConfig::getCommenterModel();
return $model::query() return $model::query()
->whereIn('name', $names) ->whereIn('name', $names)

View File

@@ -3,10 +3,10 @@
namespace Relaticle\Comments\Mentions; namespace Relaticle\Comments\Mentions;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config;
use Relaticle\Comments\Contracts\MentionResolver; use Relaticle\Comments\Contracts\MentionResolver;
use Relaticle\Comments\Events\UserMentioned; use Relaticle\Comments\Events\UserMentioned;
use Relaticle\Comments\Models\Comment;
class MentionParser class MentionParser
{ {
@@ -16,6 +16,32 @@ class MentionParser
/** @return Collection<int, int> */ /** @return Collection<int, int> */
public function parse(string $body): Collection public function parse(string $body): Collection
{
$ids = $this->parseRichEditorMentions($body);
if ($ids->isNotEmpty()) {
return $ids;
}
return $this->parsePlainTextMentions($body);
}
/** @return Collection<int, int> */
protected function parseRichEditorMentions(string $body): Collection
{
preg_match_all('/data-type=["\']mention["\'][^>]*data-id=["\'](\d+)["\']/', $body, $matches);
if (empty($matches[1])) {
preg_match_all('/data-id=["\'](\d+)["\'][^>]*data-type=["\']mention["\']/', $body, $matches);
}
$ids = array_unique(array_map('intval', $matches[1] ?? []));
return collect($ids);
}
/** @return Collection<int, int> */
protected function parsePlainTextMentions(string $body): Collection
{ {
$text = html_entity_decode(strip_tags($body), ENT_QUOTES, 'UTF-8'); $text = html_entity_decode(strip_tags($body), ENT_QUOTES, 'UTF-8');
@@ -33,13 +59,13 @@ class MentionParser
public function syncMentions(Comment $comment): void public function syncMentions(Comment $comment): void
{ {
$newMentionIds = $this->parse($comment->body); $newMentionIds = $this->parse($comment->body);
$existingMentionIds = $comment->mentions()->pluck('comment_mentions.user_id'); $existingMentionIds = $comment->mentions()->pluck('comment_mentions.commenter_id');
$addedIds = $newMentionIds->diff($existingMentionIds); $addedIds = $newMentionIds->diff($existingMentionIds);
$comment->mentions()->sync($newMentionIds->all()); $comment->mentions()->sync($newMentionIds->all());
$commenterModel = Config::getCommenterModel(); $commenterModel = CommentsConfig::getCommenterModel();
$addedIds->each(function ($userId) use ($comment, $commenterModel) { $addedIds->each(function ($userId) use ($comment, $commenterModel) {
$mentionedUser = $commenterModel::find($userId); $mentionedUser = $commenterModel::find($userId);

View File

@@ -1,13 +1,14 @@
<?php <?php
namespace Relaticle\Comments; namespace Relaticle\Comments\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Number; use Illuminate\Support\Number;
use Relaticle\Comments\CommentsConfig;
class CommentAttachment extends Model class Attachment extends Model
{ {
protected $fillable = [ protected $fillable = [
'comment_id', 'comment_id',
@@ -20,12 +21,12 @@ class CommentAttachment extends Model
public function getTable(): string public function getTable(): string
{ {
return 'comment_attachments'; return CommentsConfig::getTableName('attachments');
} }
public function comment(): BelongsTo public function comment(): BelongsTo
{ {
return $this->belongsTo(Config::getCommentModel()); return $this->belongsTo(CommentsConfig::getCommentModel());
} }
public function isImage(): bool public function isImage(): bool

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Relaticle\Comments; namespace Relaticle\Comments\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -9,7 +9,7 @@ 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\Database\Factories\CommentFactory; use Relaticle\Comments\Database\Factories\CommentFactory;
class Comment extends Model class Comment extends Model
@@ -22,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 {
@@ -35,14 +39,14 @@ class Comment extends Model
protected $fillable = [ protected $fillable = [
'body', 'body',
'parent_id', 'parent_id',
'user_id', 'commenter_id',
'user_type', 'commenter_type',
'edited_at', 'edited_at',
]; ];
public function getTable(): string public function getTable(): string
{ {
return Config::getCommentTable(); return CommentsConfig::getCommentTable();
} }
/** @return array<string, string> */ /** @return array<string, string> */
@@ -63,39 +67,39 @@ class Comment extends Model
return $this->morphTo(); return $this->morphTo();
} }
public function user(): MorphTo public function commenter(): MorphTo
{ {
return $this->morphTo(); return $this->morphTo();
} }
public function parent(): BelongsTo public function parent(): BelongsTo
{ {
return $this->belongsTo(Config::getCommentModel(), 'parent_id'); return $this->belongsTo(CommentsConfig::getCommentModel(), 'parent_id');
} }
public function replies(): HasMany public function replies(): HasMany
{ {
return $this->hasMany(Config::getCommentModel(), 'parent_id'); return $this->hasMany(CommentsConfig::getCommentModel(), 'parent_id');
} }
public function reactions(): HasMany public function reactions(): HasMany
{ {
return $this->hasMany(CommentReaction::class); return $this->hasMany(Reaction::class);
} }
public function attachments(): HasMany public function attachments(): HasMany
{ {
return $this->hasMany(CommentAttachment::class); return $this->hasMany(Attachment::class);
} }
public function mentions(): MorphToMany public function mentions(): MorphToMany
{ {
return $this->morphedByMany( return $this->morphedByMany(
Config::getCommenterModel(), CommentsConfig::getCommenterModel(),
'user', 'commenter',
'comment_mentions', CommentsConfig::getTableName('mentions'),
'comment_id', 'comment_id',
'user_id', 'commenter_id',
); );
} }
@@ -121,21 +125,18 @@ class Comment extends Model
public function canReply(): bool public function canReply(): bool
{ {
return $this->depth() < Config::getMaxDepth(); return $this->depth() < CommentsConfig::getMaxDepth();
} }
public function depth(): int public function depth(): int
{ {
$depth = 0; $depth = 0;
$comment = $this; $maxDepth = CommentsConfig::getMaxDepth();
$parentId = $this->parent_id;
while ($comment->parent_id !== null) { while ($parentId !== null && $depth < $maxDepth) {
$comment = $comment->parent;
$depth++; $depth++;
$parentId = static::where('id', $parentId)->value('parent_id');
if ($depth >= Config::getMaxDepth()) {
return Config::getMaxDepth();
}
} }
return $depth; return $depth;
@@ -144,15 +145,23 @@ class Comment extends Model
public function renderBodyWithMentions(): string public function renderBodyWithMentions(): string
{ {
$body = $this->body; $body = $this->body;
$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>';
$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("&#64;{$name}", $styledSpan, $body); $body = str_replace("&#64;{$name}", $styledSpan, $body);
$body = str_replace("@{$name}", $styledSpan, $body); $body = str_replace("@{$name}", $styledSpan, $body);
} }
}
return $body; return $body;
} }

View File

@@ -1,31 +1,32 @@
<?php <?php
namespace Relaticle\Comments; namespace Relaticle\Comments\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Relaticle\Comments\CommentsConfig;
class CommentReaction extends Model class Reaction extends Model
{ {
protected $fillable = [ protected $fillable = [
'comment_id', 'comment_id',
'user_id', 'commenter_id',
'user_type', 'commenter_type',
'reaction', 'reaction',
]; ];
public function getTable(): string public function getTable(): string
{ {
return 'comment_reactions'; return CommentsConfig::getTableName('reactions');
} }
public function comment(): BelongsTo public function comment(): BelongsTo
{ {
return $this->belongsTo(Config::getCommentModel()); return $this->belongsTo(CommentsConfig::getCommentModel());
} }
public function user(): MorphTo public function commenter(): MorphTo
{ {
return $this->morphTo(); return $this->morphTo();
} }

View File

@@ -1,25 +1,26 @@
<?php <?php
namespace Relaticle\Comments; namespace Relaticle\Comments\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Relaticle\Comments\CommentsConfig;
class CommentSubscription extends Model class Subscription extends Model
{ {
public const UPDATED_AT = null; public const UPDATED_AT = null;
protected $fillable = [ protected $fillable = [
'commentable_type', 'commentable_type',
'commentable_id', 'commentable_id',
'user_type', 'commenter_type',
'user_id', 'commenter_id',
]; ];
public function getTable(): string public function getTable(): string
{ {
return 'comment_subscriptions'; return CommentsConfig::getTableName('subscriptions');
} }
public function commentable(): MorphTo public function commentable(): MorphTo
@@ -27,7 +28,7 @@ class CommentSubscription extends Model
return $this->morphTo(); return $this->morphTo();
} }
public function user(): MorphTo public function commenter(): MorphTo
{ {
return $this->morphTo(); return $this->morphTo();
} }
@@ -37,8 +38,8 @@ class CommentSubscription extends Model
return static::where([ return static::where([
'commentable_type' => $commentable->getMorphClass(), 'commentable_type' => $commentable->getMorphClass(),
'commentable_id' => $commentable->getKey(), 'commentable_id' => $commentable->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
])->exists(); ])->exists();
} }
@@ -47,8 +48,8 @@ class CommentSubscription extends Model
static::firstOrCreate([ static::firstOrCreate([
'commentable_type' => $commentable->getMorphClass(), 'commentable_type' => $commentable->getMorphClass(),
'commentable_id' => $commentable->getKey(), 'commentable_id' => $commentable->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
]); ]);
} }
@@ -57,8 +58,8 @@ class CommentSubscription extends Model
static::where([ static::where([
'commentable_type' => $commentable->getMorphClass(), 'commentable_type' => $commentable->getMorphClass(),
'commentable_id' => $commentable->getKey(), 'commentable_id' => $commentable->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
])->delete(); ])->delete();
} }
@@ -68,6 +69,6 @@ class CommentSubscription extends Model
return static::where([ return static::where([
'commentable_type' => $commentable->getMorphClass(), 'commentable_type' => $commentable->getMorphClass(),
'commentable_id' => $commentable->getKey(), 'commentable_id' => $commentable->getKey(),
])->with('user')->get()->pluck('user')->filter()->values(); ])->with('commenter')->get()->pluck('commenter')->filter()->values();
} }
} }

View File

@@ -5,8 +5,8 @@ namespace Relaticle\Comments\Notifications;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config; use Relaticle\Comments\Models\Comment;
class CommentRepliedNotification extends Notification class CommentRepliedNotification extends Notification
{ {
@@ -15,7 +15,7 @@ class CommentRepliedNotification extends Notification
/** @return array<int, string> */ /** @return array<int, string> */
public function via(mixed $notifiable): array public function via(mixed $notifiable): array
{ {
return Config::getNotificationChannels(); return CommentsConfig::getNotificationChannels();
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */
@@ -25,14 +25,14 @@ class CommentRepliedNotification extends Notification
'comment_id' => $this->comment->id, 'comment_id' => $this->comment->id,
'commentable_type' => $this->comment->commentable_type, 'commentable_type' => $this->comment->commentable_type,
'commentable_id' => $this->comment->commentable_id, 'commentable_id' => $this->comment->commentable_id,
'commenter_name' => $this->comment->user->getCommentName(), 'commenter_name' => $this->comment->commenter->getCommentDisplayName(),
'body' => Str::limit(strip_tags($this->comment->body), 100), 'body' => Str::limit(strip_tags($this->comment->body), 100),
]; ];
} }
public function toMail(mixed $notifiable): MailMessage public function toMail(mixed $notifiable): MailMessage
{ {
$commenterName = $this->comment->user->getCommentName(); $commenterName = $this->comment->commenter->getCommentDisplayName();
return (new MailMessage) return (new MailMessage)
->subject('New reply to your comment') ->subject('New reply to your comment')

View File

@@ -6,8 +6,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config; use Relaticle\Comments\Models\Comment;
class UserMentionedNotification extends Notification class UserMentionedNotification extends Notification
{ {
@@ -19,7 +19,7 @@ class UserMentionedNotification extends Notification
/** @return array<int, string> */ /** @return array<int, string> */
public function via(mixed $notifiable): array public function via(mixed $notifiable): array
{ {
return Config::getNotificationChannels(); return CommentsConfig::getNotificationChannels();
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */
@@ -29,14 +29,14 @@ class UserMentionedNotification extends Notification
'comment_id' => $this->comment->id, 'comment_id' => $this->comment->id,
'commentable_type' => $this->comment->commentable_type, 'commentable_type' => $this->comment->commentable_type,
'commentable_id' => $this->comment->commentable_id, 'commentable_id' => $this->comment->commentable_id,
'mentioner_name' => $this->mentionedBy->getCommentName(), 'mentioner_name' => $this->mentionedBy->getCommentDisplayName(),
'body' => Str::limit(strip_tags($this->comment->body), 100), 'body' => Str::limit(strip_tags($this->comment->body), 100),
]; ];
} }
public function toMail(mixed $notifiable): MailMessage public function toMail(mixed $notifiable): MailMessage
{ {
$mentionerName = $this->mentionedBy->getCommentName(); $mentionerName = $this->mentionedBy->getCommentDisplayName();
return (new MailMessage) return (new MailMessage)
->subject('You were mentioned in a comment') ->subject('You were mentioned in a comment')

View File

@@ -3,7 +3,7 @@
namespace Relaticle\Comments\Policies; namespace Relaticle\Comments\Policies;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Relaticle\Comments\Comment; use Relaticle\Comments\Models\Comment;
class CommentPolicy class CommentPolicy
{ {
@@ -19,14 +19,14 @@ class CommentPolicy
public function update(Authenticatable $user, Comment $comment): bool public function update(Authenticatable $user, Comment $comment): bool
{ {
return $user->getKey() === $comment->user_id return $user->getKey() === $comment->commenter_id
&& $user->getMorphClass() === $comment->user_type; && $user->getMorphClass() === $comment->commenter_type;
} }
public function delete(Authenticatable $user, Comment $comment): bool public function delete(Authenticatable $user, Comment $comment): bool
{ {
return $user->getKey() === $comment->user_id return $user->getKey() === $comment->commenter_id
&& $user->getMorphClass() === $comment->user_type; && $user->getMorphClass() === $comment->commenter_type;
} }
public function reply(Authenticatable $user, Comment $comment): bool public function reply(Authenticatable $user, Comment $comment): bool

View File

@@ -3,11 +3,11 @@
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Livewire; use Livewire\Livewire;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\CommentAttachment;
use Relaticle\Comments\Config;
use Relaticle\Comments\Livewire\CommentItem; use Relaticle\Comments\Livewire\CommentItem;
use Relaticle\Comments\Livewire\Comments; use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Models\Attachment;
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;
@@ -22,14 +22,13 @@ it('creates comment with file attachment via Livewire component', function () {
$file = UploadedFile::fake()->image('photo.jpg', 100, 100); $file = UploadedFile::fake()->image('photo.jpg', 100, 100);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Comment with attachment</p>') ->set('commentData.body', '<p>Comment with attachment</p>')
->set('attachments', [$file]) ->set('attachments', [$file])
->call('addComment') ->call('addComment')
->assertSet('newComment', '')
->assertSet('attachments', []); ->assertSet('attachments', []);
expect(Comment::count())->toBe(1); expect(Comment::count())->toBe(1);
expect(CommentAttachment::count())->toBe(1); expect(Attachment::count())->toBe(1);
}); });
it('stores attachment with correct metadata', function () { it('stores attachment with correct metadata', function () {
@@ -43,11 +42,11 @@ it('stores attachment with correct metadata', function () {
$file = UploadedFile::fake()->image('vacation.jpg', 200, 200)->size(512); $file = UploadedFile::fake()->image('vacation.jpg', 200, 200)->size(512);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Vacation photos</p>') ->set('commentData.body', '<p>Vacation photos</p>')
->set('attachments', [$file]) ->set('attachments', [$file])
->call('addComment'); ->call('addComment');
$attachment = CommentAttachment::first(); $attachment = Attachment::first();
$comment = Comment::first(); $comment = Comment::first();
expect($attachment->original_name)->toBe('vacation.jpg') expect($attachment->original_name)->toBe('vacation.jpg')
@@ -69,11 +68,11 @@ it('stores file on configured disk at comments/attachments/{comment_id}/ path',
$file = UploadedFile::fake()->image('test.png', 50, 50); $file = UploadedFile::fake()->image('test.png', 50, 50);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>File path test</p>') ->set('commentData.body', '<p>File path test</p>')
->set('attachments', [$file]) ->set('attachments', [$file])
->call('addComment'); ->call('addComment');
$attachment = CommentAttachment::first(); $attachment = Attachment::first();
Storage::disk('public')->assertExists($attachment->file_path); Storage::disk('public')->assertExists($attachment->file_path);
expect($attachment->file_path)->toContain("comments/attachments/{$attachment->comment_id}/"); expect($attachment->file_path)->toContain("comments/attachments/{$attachment->comment_id}/");
@@ -88,15 +87,15 @@ it('displays image attachment thumbnail in comment item view', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Image comment</p>', 'body' => '<p>Image comment</p>',
]); ]);
$file = UploadedFile::fake()->image('photo.jpg', 100, 100); $file = UploadedFile::fake()->image('photo.jpg', 100, 100);
$path = $file->store("comments/attachments/{$comment->id}", 'public'); $path = $file->store("comments/attachments/{$comment->id}", 'public');
CommentAttachment::create([ Attachment::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'file_path' => $path, 'file_path' => $path,
'original_name' => 'photo.jpg', 'original_name' => 'photo.jpg',
@@ -123,15 +122,15 @@ it('displays non-image attachment as download link', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>PDF comment</p>', 'body' => '<p>PDF comment</p>',
]); ]);
$file = UploadedFile::fake()->create('document.pdf', 2048, 'application/pdf'); $file = UploadedFile::fake()->create('document.pdf', 2048, 'application/pdf');
$path = $file->store("comments/attachments/{$comment->id}", 'public'); $path = $file->store("comments/attachments/{$comment->id}", 'public');
CommentAttachment::create([ Attachment::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'file_path' => $path, 'file_path' => $path,
'original_name' => 'document.pdf', 'original_name' => 'document.pdf',
@@ -157,16 +156,16 @@ it('rejects file exceeding max size', function () {
$this->actingAs($user); $this->actingAs($user);
$oversizedFile = UploadedFile::fake()->create('big.pdf', Config::getAttachmentMaxSize() + 1, 'application/pdf'); $oversizedFile = UploadedFile::fake()->create('big.pdf', CommentsConfig::getAttachmentMaxSize() + 1, 'application/pdf');
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Oversized file</p>') ->set('commentData.body', '<p>Oversized file</p>')
->set('attachments', [$oversizedFile]) ->set('attachments', [$oversizedFile])
->call('addComment') ->call('addComment')
->assertHasErrors('attachments.0'); ->assertHasErrors('attachments.0');
expect(Comment::count())->toBe(0); expect(Comment::count())->toBe(0);
expect(CommentAttachment::count())->toBe(0); expect(Attachment::count())->toBe(0);
}); });
it('rejects disallowed file type', function () { it('rejects disallowed file type', function () {
@@ -180,13 +179,13 @@ it('rejects disallowed file type', function () {
$exeFile = UploadedFile::fake()->create('script.exe', 100, 'application/x-msdownload'); $exeFile = UploadedFile::fake()->create('script.exe', 100, 'application/x-msdownload');
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Malicious file</p>') ->set('commentData.body', '<p>Malicious file</p>')
->set('attachments', [$exeFile]) ->set('attachments', [$exeFile])
->call('addComment') ->call('addComment')
->assertHasErrors('attachments.0'); ->assertHasErrors('attachments.0');
expect(Comment::count())->toBe(0); expect(Comment::count())->toBe(0);
expect(CommentAttachment::count())->toBe(0); expect(Attachment::count())->toBe(0);
}); });
it('accepts allowed file types', function () { it('accepts allowed file types', function () {
@@ -200,13 +199,13 @@ it('accepts allowed file types', function () {
$imageFile = UploadedFile::fake()->image('photo.jpg', 100, 100); $imageFile = UploadedFile::fake()->image('photo.jpg', 100, 100);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Valid file</p>') ->set('commentData.body', '<p>Valid file</p>')
->set('attachments', [$imageFile]) ->set('attachments', [$imageFile])
->call('addComment') ->call('addComment')
->assertHasNoErrors('attachments.0'); ->assertHasNoErrors('attachments.0');
expect(Comment::count())->toBe(1); expect(Comment::count())->toBe(1);
expect(CommentAttachment::count())->toBe(1); expect(Attachment::count())->toBe(1);
}); });
it('hides upload UI when attachments disabled', function () { it('hides upload UI when attachments disabled', function () {
@@ -218,7 +217,7 @@ it('hides upload UI when attachments disabled', function () {
$this->actingAs($user); $this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->assertDontSeeHtml('Attach files'); ->assertDontSeeHtml('wire:model="attachments"');
}); });
it('shows upload UI when attachments enabled', function () { it('shows upload UI when attachments enabled', function () {
@@ -228,7 +227,7 @@ it('shows upload UI when attachments enabled', function () {
$this->actingAs($user); $this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->assertSeeHtml('Attach files'); ->assertSeeHtml('wire:model="attachments"');
}); });
it('creates comment with multiple file attachments', function () { it('creates comment with multiple file attachments', function () {
@@ -243,14 +242,14 @@ it('creates comment with multiple file attachments', function () {
$file2 = UploadedFile::fake()->create('notes.pdf', 512, 'application/pdf'); $file2 = UploadedFile::fake()->create('notes.pdf', 512, 'application/pdf');
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Multiple files</p>') ->set('commentData.body', '<p>Multiple files</p>')
->set('attachments', [$file1, $file2]) ->set('attachments', [$file1, $file2])
->call('addComment'); ->call('addComment');
expect(Comment::count())->toBe(1); expect(Comment::count())->toBe(1);
expect(CommentAttachment::count())->toBe(2); expect(Attachment::count())->toBe(2);
$attachments = CommentAttachment::all(); $attachments = Attachment::all();
expect($attachments->pluck('original_name')->toArray()) expect($attachments->pluck('original_name')->toArray())
->toContain('photo1.jpg') ->toContain('photo1.jpg')
->toContain('notes.pdf'); ->toContain('notes.pdf');
@@ -265,8 +264,8 @@ it('creates reply with file attachment via CommentItem component', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Parent comment</p>', 'body' => '<p>Parent comment</p>',
]); ]);
@@ -276,11 +275,10 @@ it('creates reply with file attachment via CommentItem component', function () {
Livewire::test(CommentItem::class, ['comment' => $comment]) Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply') ->call('startReply')
->set('replyBody', '<p>Reply with attachment</p>') ->set('replyData.body', '<p>Reply with attachment</p>')
->set('replyAttachments', [$file]) ->set('replyAttachments', [$file])
->call('addReply') ->call('addReply')
->assertSet('isReplying', false) ->assertSet('isReplying', false)
->assertSet('replyBody', '')
->assertSet('replyAttachments', []); ->assertSet('replyAttachments', []);
$reply = Comment::where('parent_id', $comment->id)->first(); $reply = Comment::where('parent_id', $comment->id)->first();

View File

@@ -2,11 +2,11 @@
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Events\CommentCreated; use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Events\CommentDeleted; use Relaticle\Comments\Events\CommentDeleted;
use Relaticle\Comments\Events\CommentReacted; use Relaticle\Comments\Events\CommentReacted;
use Relaticle\Comments\Events\CommentUpdated; use Relaticle\Comments\Events\CommentUpdated;
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;
@@ -17,8 +17,8 @@ it('CommentCreated event implements ShouldBroadcast', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$event = new CommentCreated($comment); $event = new CommentCreated($comment);
@@ -33,8 +33,8 @@ it('CommentUpdated event implements ShouldBroadcast', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$event = new CommentUpdated($comment); $event = new CommentUpdated($comment);
@@ -49,8 +49,8 @@ it('CommentDeleted event implements ShouldBroadcast', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$event = new CommentDeleted($comment); $event = new CommentDeleted($comment);
@@ -65,8 +65,8 @@ it('CommentReacted event implements ShouldBroadcast', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$event = new CommentReacted($comment, $user, 'thumbs_up', 'added'); $event = new CommentReacted($comment, $user, 'thumbs_up', 'added');
@@ -81,8 +81,8 @@ it('broadcastOn returns PrivateChannel with correct channel name', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$event = new CommentCreated($comment); $event = new CommentCreated($comment);
@@ -100,8 +100,8 @@ it('broadcastWhen returns false when broadcasting is disabled', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$event = new CommentCreated($comment); $event = new CommentCreated($comment);
@@ -118,8 +118,8 @@ it('broadcastWhen returns true when broadcasting is enabled', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$event = new CommentCreated($comment); $event = new CommentCreated($comment);
@@ -134,8 +134,8 @@ it('broadcastWith returns array with comment_id for CommentCreated', function ()
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$event = new CommentCreated($comment); $event = new CommentCreated($comment);
@@ -154,8 +154,8 @@ it('broadcastWith returns array with comment_id, reaction, and action for Commen
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$event = new CommentReacted($comment, $user, 'thumbs_up', 'added'); $event = new CommentReacted($comment, $user, 'thumbs_up', 'added');
@@ -176,8 +176,8 @@ it('uses custom channel prefix from config in broadcastOn', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$event = new CommentCreated($comment); $event = new CommentCreated($comment);

View File

@@ -1,8 +1,8 @@
<?php <?php
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\CommentAttachment; use Relaticle\Comments\Models\Attachment;
use Relaticle\Comments\Config; 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;
@@ -13,12 +13,12 @@ it('creates a comment attachment with all metadata fields', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test comment</p>', 'body' => '<p>Test comment</p>',
]); ]);
$attachment = CommentAttachment::create([ $attachment = Attachment::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/photo.jpg', 'file_path' => 'comments/attachments/1/photo.jpg',
'original_name' => 'photo.jpg', 'original_name' => 'photo.jpg',
@@ -27,7 +27,7 @@ it('creates a comment attachment with all metadata fields', function () {
'disk' => 'public', 'disk' => 'public',
]); ]);
expect($attachment)->toBeInstanceOf(CommentAttachment::class) expect($attachment)->toBeInstanceOf(Attachment::class)
->and($attachment->file_path)->toBe('comments/attachments/1/photo.jpg') ->and($attachment->file_path)->toBe('comments/attachments/1/photo.jpg')
->and($attachment->original_name)->toBe('photo.jpg') ->and($attachment->original_name)->toBe('photo.jpg')
->and($attachment->mime_type)->toBe('image/jpeg') ->and($attachment->mime_type)->toBe('image/jpeg')
@@ -42,12 +42,12 @@ it('belongs to a comment via comment() relationship', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test</p>', 'body' => '<p>Test</p>',
]); ]);
$attachment = CommentAttachment::create([ $attachment = Attachment::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/test.png', 'file_path' => 'comments/attachments/1/test.png',
'original_name' => 'test.png', 'original_name' => 'test.png',
@@ -67,12 +67,12 @@ it('has attachments() hasMany relationship on Comment', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test</p>', 'body' => '<p>Test</p>',
]); ]);
CommentAttachment::create([ Attachment::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/file1.png', 'file_path' => 'comments/attachments/1/file1.png',
'original_name' => 'file1.png', 'original_name' => 'file1.png',
@@ -81,7 +81,7 @@ it('has attachments() hasMany relationship on Comment', function () {
'disk' => 'public', 'disk' => 'public',
]); ]);
CommentAttachment::create([ Attachment::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/file2.pdf', 'file_path' => 'comments/attachments/1/file2.pdf',
'original_name' => 'file2.pdf', 'original_name' => 'file2.pdf',
@@ -91,7 +91,7 @@ it('has attachments() hasMany relationship on Comment', function () {
]); ]);
expect($comment->attachments)->toHaveCount(2) expect($comment->attachments)->toHaveCount(2)
->and($comment->attachments->first())->toBeInstanceOf(CommentAttachment::class); ->and($comment->attachments->first())->toBeInstanceOf(Attachment::class);
}); });
it('cascade deletes attachments when comment is force deleted', function () { it('cascade deletes attachments when comment is force deleted', function () {
@@ -101,12 +101,12 @@ it('cascade deletes attachments when comment is force deleted', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test</p>', 'body' => '<p>Test</p>',
]); ]);
CommentAttachment::create([ Attachment::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/photo.jpg', 'file_path' => 'comments/attachments/1/photo.jpg',
'original_name' => 'photo.jpg', 'original_name' => 'photo.jpg',
@@ -115,15 +115,15 @@ it('cascade deletes attachments when comment is force deleted', function () {
'disk' => 'public', 'disk' => 'public',
]); ]);
expect(CommentAttachment::where('comment_id', $comment->id)->count())->toBe(1); expect(Attachment::where('comment_id', $comment->id)->count())->toBe(1);
$comment->forceDelete(); $comment->forceDelete();
expect(CommentAttachment::where('comment_id', $comment->id)->count())->toBe(0); expect(Attachment::where('comment_id', $comment->id)->count())->toBe(0);
}); });
it('correctly identifies image and non-image mime types via isImage()', function (string $mimeType, bool $expected) { it('correctly identifies image and non-image mime types via isImage()', function (string $mimeType, bool $expected) {
$attachment = new CommentAttachment(['mime_type' => $mimeType]); $attachment = new Attachment(['mime_type' => $mimeType]);
expect($attachment->isImage())->toBe($expected); expect($attachment->isImage())->toBe($expected);
})->with([ })->with([
@@ -142,12 +142,12 @@ it('formats bytes into human-readable size via formattedSize()', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test</p>', 'body' => '<p>Test</p>',
]); ]);
$attachment = CommentAttachment::create([ $attachment = Attachment::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/file.pdf', 'file_path' => 'comments/attachments/1/file.pdf',
'original_name' => 'file.pdf', 'original_name' => 'file.pdf',
@@ -160,15 +160,15 @@ it('formats bytes into human-readable size via formattedSize()', function () {
}); });
it('returns default attachment disk as public', function () { it('returns default attachment disk as public', function () {
expect(Config::getAttachmentDisk())->toBe('public'); expect(CommentsConfig::getAttachmentDisk())->toBe('public');
}); });
it('returns default attachment max size as 10240', function () { it('returns default attachment max size as 10240', function () {
expect(Config::getAttachmentMaxSize())->toBe(10240); expect(CommentsConfig::getAttachmentMaxSize())->toBe(10240);
}); });
it('returns default allowed attachment types', function () { it('returns default allowed attachment types', function () {
$allowedTypes = Config::getAttachmentAllowedTypes(); $allowedTypes = CommentsConfig::getAttachmentAllowedTypes();
expect($allowedTypes)->toBeArray() expect($allowedTypes)->toBeArray()
->toContain('image/jpeg') ->toContain('image/jpeg')
@@ -181,17 +181,17 @@ it('respects custom config overrides for attachment settings', function () {
config(['comments.attachments.max_size' => 5120]); config(['comments.attachments.max_size' => 5120]);
config(['comments.attachments.allowed_types' => ['image/png']]); config(['comments.attachments.allowed_types' => ['image/png']]);
expect(Config::getAttachmentDisk())->toBe('s3') expect(CommentsConfig::getAttachmentDisk())->toBe('s3')
->and(Config::getAttachmentMaxSize())->toBe(5120) ->and(CommentsConfig::getAttachmentMaxSize())->toBe(5120)
->and(Config::getAttachmentAllowedTypes())->toBe(['image/png']); ->and(CommentsConfig::getAttachmentAllowedTypes())->toBe(['image/png']);
}); });
it('reports attachments as enabled by default', function () { it('reports attachments as enabled by default', function () {
expect(Config::areAttachmentsEnabled())->toBeTrue(); expect(CommentsConfig::areAttachmentsEnabled())->toBeTrue();
}); });
it('respects disabled attachments config', function () { it('respects disabled attachments config', function () {
config(['comments.attachments.enabled' => false]); config(['comments.attachments.enabled' => false]);
expect(Config::areAttachmentsEnabled())->toBeFalse(); expect(CommentsConfig::areAttachmentsEnabled())->toBeFalse();
}); });

View File

@@ -2,12 +2,12 @@
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Livewire\Livewire; use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Events\CommentCreated; use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Events\CommentDeleted; use Relaticle\Comments\Events\CommentDeleted;
use Relaticle\Comments\Events\CommentUpdated; use Relaticle\Comments\Events\CommentUpdated;
use Relaticle\Comments\Livewire\CommentItem; use Relaticle\Comments\Livewire\CommentItem;
use Relaticle\Comments\Livewire\Comments; use Relaticle\Comments\Livewire\Comments;
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;
@@ -20,7 +20,7 @@ it('fires CommentCreated event when adding a comment', function () {
$this->actingAs($user); $this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>New comment</p>') ->set('commentData.body', '<p>New comment</p>')
->call('addComment'); ->call('addComment');
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post) { Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post) {
@@ -38,8 +38,8 @@ it('fires CommentUpdated event when editing a comment', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Original</p>', 'body' => '<p>Original</p>',
]); ]);
@@ -47,7 +47,7 @@ it('fires CommentUpdated event when editing a comment', function () {
Livewire::test(CommentItem::class, ['comment' => $comment]) Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit') ->call('startEdit')
->set('editBody', '<p>Edited</p>') ->set('editData.body', '<p>Edited</p>')
->call('saveEdit'); ->call('saveEdit');
Event::assertDispatched(CommentUpdated::class, function (CommentUpdated $event) use ($comment) { Event::assertDispatched(CommentUpdated::class, function (CommentUpdated $event) use ($comment) {
@@ -64,8 +64,8 @@ it('fires CommentDeleted event when deleting a comment', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@@ -87,15 +87,15 @@ it('fires CommentCreated event when adding a reply', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment]) Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply') ->call('startReply')
->set('replyBody', '<p>Reply text</p>') ->set('replyData.body', '<p>Reply text</p>')
->call('addReply'); ->call('addReply');
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($comment) { Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($comment) {
@@ -113,12 +113,12 @@ it('carries correct comment and commentable in event payload', function () {
$this->actingAs($user); $this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Payload test</p>') ->set('commentData.body', '<p>Payload test</p>')
->call('addComment'); ->call('addComment');
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post, $user) { Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post, $user) {
return $event->comment instanceof Comment return $event->comment instanceof Comment
&& $event->commentable->id === $post->id && $event->commentable->id === $post->id
&& $event->comment->user_id === $user->id; && $event->comment->commenter_id === $user->id;
}); });
}); });

View File

@@ -1,8 +1,8 @@
<?php <?php
use Livewire\Livewire; use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Livewire\CommentItem; use Relaticle\Comments\Livewire\CommentItem;
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;
@@ -13,8 +13,8 @@ it('allows author to start and save edit on their comment', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Original body</p>', 'body' => '<p>Original body</p>',
]); ]);
@@ -23,11 +23,9 @@ it('allows author to start and save edit on their comment', function () {
Livewire::test(CommentItem::class, ['comment' => $comment]) Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit') ->call('startEdit')
->assertSet('isEditing', true) ->assertSet('isEditing', true)
->assertSet('editBody', '<p>Original body</p>') ->set('editData.body', '<p>Updated body</p>')
->set('editBody', '<p>Updated body</p>')
->call('saveEdit') ->call('saveEdit')
->assertSet('isEditing', false) ->assertSet('isEditing', false);
->assertSet('editBody', '');
$comment->refresh(); $comment->refresh();
@@ -42,8 +40,8 @@ it('marks edited comment with edited indicator', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Original</p>', 'body' => '<p>Original</p>',
]); ]);
@@ -53,7 +51,7 @@ it('marks edited comment with edited indicator', function () {
Livewire::test(CommentItem::class, ['comment' => $comment]) Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit') ->call('startEdit')
->set('editBody', '<p>Changed</p>') ->set('editData.body', '<p>Changed</p>')
->call('saveEdit'); ->call('saveEdit');
$comment->refresh(); $comment->refresh();
@@ -70,8 +68,8 @@ it('prevents non-author from editing a comment', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'body' => '<p>Author comment</p>', 'body' => '<p>Author comment</p>',
]); ]);
@@ -89,8 +87,8 @@ it('allows author to delete their own comment', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@@ -108,8 +106,8 @@ it('preserves replies when parent comment is deleted', function () {
$attrs = [ $attrs = [
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]; ];
$parent = Comment::factory()->create($attrs); $parent = Comment::factory()->create($attrs);
@@ -133,8 +131,8 @@ it('prevents non-author from deleting a comment', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
]); ]);
$this->actingAs($otherUser); $this->actingAs($otherUser);
@@ -151,8 +149,8 @@ it('allows user to reply to a comment', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@@ -160,16 +158,15 @@ it('allows user to reply to a comment', function () {
Livewire::test(CommentItem::class, ['comment' => $comment]) Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply') ->call('startReply')
->assertSet('isReplying', true) ->assertSet('isReplying', true)
->set('replyBody', '<p>My reply</p>') ->set('replyData.body', '<p>My reply</p>')
->call('addReply') ->call('addReply')
->assertSet('isReplying', false) ->assertSet('isReplying', false);
->assertSet('replyBody', '');
$reply = Comment::where('parent_id', $comment->id)->first(); $reply = Comment::where('parent_id', $comment->id)->first();
expect($reply)->not->toBeNull(); expect($reply)->not->toBeNull();
expect($reply->body)->toBe('<p>My reply</p>'); expect($reply->body)->toBe('<p>My reply</p>');
expect($reply->user_id)->toBe($user->id); expect($reply->commenter_id)->toBe($user->id);
expect($reply->commentable_id)->toBe($post->id); expect($reply->commentable_id)->toBe($post->id);
}); });
@@ -179,8 +176,8 @@ it('respects max depth for replies', function () {
$attrs = [ $attrs = [
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]; ];
config(['comments.threading.max_depth' => 1]); config(['comments.threading.max_depth' => 1]);
@@ -202,8 +199,8 @@ it('resets state when cancelling edit', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Some body</p>', 'body' => '<p>Some body</p>',
]); ]);
@@ -213,8 +210,7 @@ it('resets state when cancelling edit', function () {
->call('startEdit') ->call('startEdit')
->assertSet('isEditing', true) ->assertSet('isEditing', true)
->call('cancelEdit') ->call('cancelEdit')
->assertSet('isEditing', false) ->assertSet('isEditing', false);
->assertSet('editBody', '');
}); });
it('resets state when cancelling reply', function () { it('resets state when cancelling reply', function () {
@@ -224,8 +220,8 @@ it('resets state when cancelling reply', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@@ -233,10 +229,9 @@ it('resets state when cancelling reply', function () {
Livewire::test(CommentItem::class, ['comment' => $comment]) Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply') ->call('startReply')
->assertSet('isReplying', true) ->assertSet('isReplying', true)
->set('replyBody', '<p>Draft reply</p>') ->set('replyData.body', '<p>Draft reply</p>')
->call('cancelReply') ->call('cancelReply')
->assertSet('isReplying', false) ->assertSet('isReplying', false);
->assertSet('replyBody', '');
}); });
it('loads all replies within a thread eagerly', function () { it('loads all replies within a thread eagerly', function () {
@@ -245,14 +240,14 @@ it('loads all replies within a thread eagerly', function () {
$attrs = [ $attrs = [
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]; ];
$parent = Comment::factory()->create($attrs); $parent = Comment::factory()->create($attrs);
Comment::factory()->count(3)->withParent($parent)->create($attrs); Comment::factory()->count(3)->withParent($parent)->create($attrs);
$parentWithReplies = Comment::with('replies.user')->find($parent->id); $parentWithReplies = Comment::with('replies.commenter')->find($parent->id);
$this->actingAs($user); $this->actingAs($user);

View File

@@ -1,9 +1,9 @@
<?php <?php
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentReaction;
use Relaticle\Comments\Events\CommentReacted; use Relaticle\Comments\Events\CommentReacted;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Models\Reaction;
use Relaticle\Comments\Tests\Models\Post; use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User; use Relaticle\Comments\Tests\Models\User;
@@ -14,15 +14,15 @@ it('belongs to a comment via comment() relationship', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test</p>', 'body' => '<p>Test</p>',
]); ]);
$reaction = CommentReaction::create([ $reaction = Reaction::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up', 'reaction' => 'thumbs_up',
]); ]);
@@ -30,27 +30,27 @@ it('belongs to a comment via comment() relationship', function () {
->and($reaction->comment->id)->toBe($comment->id); ->and($reaction->comment->id)->toBe($comment->id);
}); });
it('belongs to a user via polymorphic user() relationship', function () { it('belongs to a commenter via polymorphic commenter() relationship', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test</p>', 'body' => '<p>Test</p>',
]); ]);
$reaction = CommentReaction::create([ $reaction = Reaction::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'reaction' => 'heart', 'reaction' => 'heart',
]); ]);
expect($reaction->user)->toBeInstanceOf(User::class) expect($reaction->commenter)->toBeInstanceOf(User::class)
->and($reaction->user->id)->toBe($user->id); ->and($reaction->commenter->id)->toBe($user->id);
}); });
it('prevents duplicate reactions with unique constraint', function () { it('prevents duplicate reactions with unique constraint', function () {
@@ -60,22 +60,22 @@ it('prevents duplicate reactions with unique constraint', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test</p>', 'body' => '<p>Test</p>',
]); ]);
CommentReaction::create([ Reaction::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up', 'reaction' => 'thumbs_up',
]); ]);
expect(fn () => CommentReaction::create([ expect(fn () => Reaction::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up', 'reaction' => 'thumbs_up',
]))->toThrow(QueryException::class); ]))->toThrow(QueryException::class);
}); });
@@ -87,8 +87,8 @@ it('carries comment, user, reaction key, and action in CommentReacted event', fu
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test</p>', 'body' => '<p>Test</p>',
]); ]);

View File

@@ -1,8 +1,7 @@
<?php <?php
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\CommentSubscription; use Relaticle\Comments\Models\Subscription;
use Relaticle\Comments\Config;
use Relaticle\Comments\Tests\Models\Post; use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User; use Relaticle\Comments\Tests\Models\User;
@@ -10,98 +9,98 @@ it('has commentable morphTo relationship', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
$subscription = CommentSubscription::create([ $subscription = Subscription::create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
expect($subscription->commentable)->toBeInstanceOf(Post::class) expect($subscription->commentable)->toBeInstanceOf(Post::class)
->and($subscription->commentable->id)->toBe($post->id); ->and($subscription->commentable->id)->toBe($post->id);
}); });
it('has user morphTo relationship', function () { it('has commenter morphTo relationship', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
$subscription = CommentSubscription::create([ $subscription = Subscription::create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
expect($subscription->user)->toBeInstanceOf(User::class) expect($subscription->commenter)->toBeInstanceOf(User::class)
->and($subscription->user->id)->toBe($user->id); ->and($subscription->commenter->id)->toBe($user->id);
}); });
it('returns database as default notification channel', function () { it('returns database as default notification channel', function () {
expect(Config::getNotificationChannels())->toBe(['database']); expect(CommentsConfig::getNotificationChannels())->toBe(['database']);
}); });
it('returns custom channels when configured', function () { it('returns custom channels when configured', function () {
config()->set('comments.notifications.channels', ['database', 'mail']); config()->set('comments.notifications.channels', ['database', 'mail']);
expect(Config::getNotificationChannels())->toBe(['database', 'mail']); expect(CommentsConfig::getNotificationChannels())->toBe(['database', 'mail']);
}); });
it('returns true for shouldAutoSubscribe by default', function () { it('returns true for shouldAutoSubscribe by default', function () {
expect(Config::shouldAutoSubscribe())->toBeTrue(); expect(CommentsConfig::shouldAutoSubscribe())->toBeTrue();
}); });
it('returns false for shouldAutoSubscribe when configured', function () { it('returns false for shouldAutoSubscribe when configured', function () {
config()->set('comments.subscriptions.auto_subscribe', false); config()->set('comments.subscriptions.auto_subscribe', false);
expect(Config::shouldAutoSubscribe())->toBeFalse(); expect(CommentsConfig::shouldAutoSubscribe())->toBeFalse();
}); });
it('checks if user is subscribed to a commentable via isSubscribed()', function () { it('checks if user is subscribed to a commentable via isSubscribed()', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
expect(CommentSubscription::isSubscribed($post, $user))->toBeFalse(); expect(Subscription::isSubscribed($post, $user))->toBeFalse();
CommentSubscription::create([ Subscription::create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue(); expect(Subscription::isSubscribed($post, $user))->toBeTrue();
}); });
it('creates subscription via subscribe() static method', function () { it('creates subscription via subscribe() static method', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $user); Subscription::subscribe($post, $user);
expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue(); expect(Subscription::isSubscribed($post, $user))->toBeTrue();
}); });
it('removes subscription via unsubscribe() static method', function () { it('removes subscription via unsubscribe() static method', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $user); Subscription::subscribe($post, $user);
CommentSubscription::unsubscribe($post, $user); Subscription::unsubscribe($post, $user);
expect(CommentSubscription::isSubscribed($post, $user))->toBeFalse(); expect(Subscription::isSubscribed($post, $user))->toBeFalse();
}); });
it('is idempotent when subscribing twice', function () { it('is idempotent when subscribing twice', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $user); Subscription::subscribe($post, $user);
CommentSubscription::subscribe($post, $user); Subscription::subscribe($post, $user);
expect(CommentSubscription::where([ expect(Subscription::where([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
])->count())->toBe(1); ])->count())->toBe(1);
}); });

View File

@@ -1,7 +1,7 @@
<?php <?php
use Carbon\Carbon; use Carbon\Carbon;
use Relaticle\Comments\Comment; 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;
@@ -12,14 +12,14 @@ it('can be created with factory', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
expect($comment)->toBeInstanceOf(Comment::class); expect($comment)->toBeInstanceOf(Comment::class);
expect($comment->body)->toBeString(); expect($comment->body)->toBeString();
expect($comment->commentable_id)->toBe($post->id); expect($comment->commentable_id)->toBe($post->id);
expect($comment->user_id)->toBe($user->id); expect($comment->commenter_id)->toBe($user->id);
}); });
it('belongs to a commentable model via morphTo', function () { it('belongs to a commentable model via morphTo', function () {
@@ -29,27 +29,27 @@ it('belongs to a commentable model via morphTo', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
expect($comment->commentable)->toBeInstanceOf(Post::class); expect($comment->commentable)->toBeInstanceOf(Post::class);
expect($comment->commentable->id)->toBe($post->id); expect($comment->commentable->id)->toBe($post->id);
}); });
it('belongs to a user via morphTo', function () { it('belongs to a commenter via morphTo', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
expect($comment->user)->toBeInstanceOf(User::class); expect($comment->commenter)->toBeInstanceOf(User::class);
expect($comment->user->id)->toBe($user->id); expect($comment->commenter->id)->toBe($user->id);
}); });
it('supports threading with parent and replies', function () { it('supports threading with parent and replies', function () {
@@ -59,15 +59,15 @@ it('supports threading with parent and replies', function () {
$parent = Comment::factory()->create([ $parent = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
$reply = Comment::factory()->withParent($parent)->create([ $reply = Comment::factory()->withParent($parent)->create([
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
expect($reply->parent->id)->toBe($parent->id); expect($reply->parent->id)->toBe($parent->id);
@@ -82,15 +82,15 @@ it('identifies top-level vs reply comments', function () {
$topLevel = Comment::factory()->create([ $topLevel = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
$reply = Comment::factory()->withParent($topLevel)->create([ $reply = Comment::factory()->withParent($topLevel)->create([
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
expect($topLevel->isTopLevel())->toBeTrue(); expect($topLevel->isTopLevel())->toBeTrue();
@@ -105,8 +105,8 @@ it('calculates depth correctly', function () {
$attrs = [ $attrs = [
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]; ];
$level0 = Comment::factory()->create($attrs); $level0 = Comment::factory()->create($attrs);
@@ -124,8 +124,8 @@ it('checks canReply based on max depth', function () {
$attrs = [ $attrs = [
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]; ];
$level0 = Comment::factory()->create($attrs); $level0 = Comment::factory()->create($attrs);
@@ -144,8 +144,8 @@ it('supports soft deletes', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
$comment->delete(); $comment->delete();
@@ -162,8 +162,8 @@ it('tracks edited state', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
expect($comment->isEdited())->toBeFalse(); expect($comment->isEdited())->toBeFalse();
@@ -171,8 +171,8 @@ it('tracks edited state', function () {
$edited = Comment::factory()->edited()->create([ $edited = Comment::factory()->edited()->create([
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
expect($edited->isEdited())->toBeTrue(); expect($edited->isEdited())->toBeTrue();
@@ -185,8 +185,8 @@ it('detects when it has replies', function () {
$attrs = [ $attrs = [
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]; ];
$parent = Comment::factory()->create($attrs); $parent = Comment::factory()->create($attrs);

View File

@@ -1,7 +1,7 @@
<?php <?php
use Relaticle\Comments\Comment;
use Relaticle\Comments\Filament\Actions\CommentsAction; use Relaticle\Comments\Filament\Actions\CommentsAction;
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;
@@ -29,10 +29,11 @@ it('has a chat bubble icon', function () {
expect($action->getIcon())->toBe('heroicon-o-chat-bubble-left-right'); expect($action->getIcon())->toBe('heroicon-o-chat-bubble-left-right');
}); });
it('has modal content configured', function () { it('disables modal submit and cancel actions', function () {
$action = CommentsAction::make('comments'); $action = CommentsAction::make('comments');
expect($action->hasModalContent())->toBeTrue(); expect($action->getModalSubmitAction())->toBeFalsy()
->and($action->getModalCancelAction())->toBeFalsy();
}); });
it('shows badge with comment count when comments exist', function () { it('shows badge with comment count when comments exist', function () {
@@ -42,8 +43,8 @@ it('shows badge with comment count when comments exist', function () {
Comment::factory()->count(3)->create([ Comment::factory()->count(3)->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$action = CommentsAction::make('comments'); $action = CommentsAction::make('comments');

View File

@@ -1,8 +1,8 @@
<?php <?php
use Livewire\Livewire; use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Livewire\Comments; use Relaticle\Comments\Livewire\Comments;
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;
@@ -13,9 +13,8 @@ it('allows authenticated user to create a comment on a post', function () {
$this->actingAs($user); $this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Hello World</p>') ->set('commentData.body', '<p>Hello World</p>')
->call('addComment') ->call('addComment');
->assertSet('newComment', '');
expect(Comment::count())->toBe(1); expect(Comment::count())->toBe(1);
expect(Comment::first()->body)->toBe('<p>Hello World</p>'); expect(Comment::first()->body)->toBe('<p>Hello World</p>');
@@ -28,13 +27,13 @@ it('associates new comment with the authenticated user', function () {
$this->actingAs($user); $this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Test</p>') ->set('commentData.body', '<p>Test</p>')
->call('addComment'); ->call('addComment');
$comment = Comment::first(); $comment = Comment::first();
expect($comment->user_id)->toBe($user->id); expect($comment->commenter_id)->toBe($user->id);
expect($comment->user_type)->toBe($user->getMorphClass()); expect($comment->commenter_type)->toBe($user->getMorphClass());
expect($comment->commentable_id)->toBe($post->id); expect($comment->commentable_id)->toBe($post->id);
expect($comment->commentable_type)->toBe($post->getMorphClass()); expect($comment->commentable_type)->toBe($post->getMorphClass());
}); });
@@ -43,7 +42,7 @@ it('requires authentication to create a comment', function () {
$post = Post::factory()->create(); $post = Post::factory()->create();
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Hello</p>') ->set('commentData.body', '<p>Hello</p>')
->call('addComment') ->call('addComment')
->assertForbidden(); ->assertForbidden();
}); });
@@ -55,9 +54,9 @@ it('validates that comment body is not empty', function () {
$this->actingAs($user); $this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '') ->set('commentData.body', '')
->call('addComment') ->call('addComment')
->assertHasErrors('newComment'); ->assertHasErrors('commentData.body');
expect(Comment::count())->toBe(0); expect(Comment::count())->toBe(0);
}); });
@@ -71,8 +70,8 @@ it('paginates top-level comments with load more', function () {
Comment::factory()->count(12)->create([ Comment::factory()->count(12)->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@@ -99,8 +98,8 @@ it('hides load more button when all comments are loaded', function () {
Comment::factory()->count(5)->create([ Comment::factory()->count(5)->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@@ -130,8 +129,8 @@ it('returns comments in correct sort order via computed property', function () {
$older = Comment::factory()->create([ $older = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Older comment</p>', 'body' => '<p>Older comment</p>',
'created_at' => now()->subHour(), 'created_at' => now()->subHour(),
]); ]);
@@ -139,8 +138,8 @@ it('returns comments in correct sort order via computed property', function () {
$newer = Comment::factory()->create([ $newer = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Newer comment</p>', 'body' => '<p>Newer comment</p>',
'created_at' => now(), 'created_at' => now(),
]); ]);
@@ -167,8 +166,8 @@ it('displays total comment count', function () {
Comment::factory()->count(3)->create([ Comment::factory()->count(3)->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);

View File

@@ -1,7 +1,7 @@
<?php <?php
use Relaticle\Comments\Comment;
use Relaticle\Comments\Filament\Actions\CommentsTableAction; use Relaticle\Comments\Filament\Actions\CommentsTableAction;
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;
@@ -17,10 +17,11 @@ it('configures as a slide-over', function () {
expect($action->isModalSlideOver())->toBeTrue(); expect($action->isModalSlideOver())->toBeTrue();
}); });
it('has modal content configured', function () { it('disables modal submit and cancel actions', function () {
$action = CommentsTableAction::make('comments'); $action = CommentsTableAction::make('comments');
expect($action->hasModalContent())->toBeTrue(); expect($action->getModalSubmitAction())->toBeFalsy()
->and($action->getModalCancelAction())->toBeFalsy();
}); });
it('shows badge with comment count for the record', function () { it('shows badge with comment count for the record', function () {
@@ -30,8 +31,8 @@ it('shows badge with comment count for the record', function () {
Comment::factory()->count(5)->create([ Comment::factory()->count(5)->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$action = CommentsTableAction::make('comments'); $action = CommentsTableAction::make('comments');

View File

@@ -1,8 +1,8 @@
<?php <?php
use Livewire\Livewire; use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Livewire\Comments; use Relaticle\Comments\Livewire\Comments;
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;
@@ -13,8 +13,8 @@ it('strips script tags from comment body on create', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Hello</p><script>alert(1)</script>', 'body' => '<p>Hello</p><script>alert(1)</script>',
]); ]);
@@ -29,8 +29,8 @@ it('strips event handler attributes from comment body', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<img onerror="alert(1)" src="x">', 'body' => '<img onerror="alert(1)" src="x">',
]); ]);
@@ -45,8 +45,8 @@ it('strips style tags from comment body', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Hi</p><style>body{display:none}</style>', 'body' => '<p>Hi</p><style>body{display:none}</style>',
]); ]);
@@ -61,8 +61,8 @@ it('strips iframe tags from comment body', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Hi</p><iframe src="evil.com"></iframe>', 'body' => '<p>Hi</p><iframe src="evil.com"></iframe>',
]); ]);
@@ -85,8 +85,8 @@ it('preserves safe HTML formatting through sanitization', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => $safeHtml, 'body' => $safeHtml,
]); ]);
@@ -108,8 +108,8 @@ it('sanitizes comment body on update', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Clean content</p>', 'body' => '<p>Clean content</p>',
]); ]);
@@ -130,8 +130,8 @@ it('strips javascript protocol from link href', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<a href="javascript:alert(1)">click me</a>', 'body' => '<a href="javascript:alert(1)">click me</a>',
]); ]);
@@ -146,8 +146,8 @@ it('strips onclick handler from elements', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<div onclick="alert(1)">click me</div>', 'body' => '<div onclick="alert(1)">click me</div>',
]); ]);
@@ -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();
@@ -162,7 +182,7 @@ it('sanitizes content submitted through livewire component', function () {
$this->actingAs($user); $this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Hello</p><script>alert("xss")</script>') ->set('commentData.body', '<p>Hello</p><script>alert("xss")</script>')
->call('addComment'); ->call('addComment');
$comment = Comment::first(); $comment = Comment::first();

View File

@@ -1,6 +1,6 @@
<?php <?php
use Relaticle\Comments\Comment; 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;
@@ -11,8 +11,8 @@ it('provides comments relationship on commentable model', function () {
Comment::factory()->count(3)->create([ Comment::factory()->count(3)->create([
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
expect($post->comments)->toHaveCount(3); expect($post->comments)->toHaveCount(3);
@@ -25,8 +25,8 @@ it('provides topLevelComments excluding replies', function () {
$attrs = [ $attrs = [
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]; ];
$topLevel = Comment::factory()->create($attrs); $topLevel = Comment::factory()->create($attrs);
@@ -46,8 +46,8 @@ it('provides comment count', function () {
Comment::factory()->count(5)->create([ Comment::factory()->count(5)->create([
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
expect($post->commentCount())->toBe(5); expect($post->commentCount())->toBe(5);
@@ -61,15 +61,15 @@ it('scopes comments to the specific commentable', function () {
Comment::factory()->count(3)->create([ Comment::factory()->count(3)->create([
'commentable_type' => $post1->getMorphClass(), 'commentable_type' => $post1->getMorphClass(),
'commentable_id' => $post1->id, 'commentable_id' => $post1->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
Comment::factory()->count(2)->create([ Comment::factory()->count(2)->create([
'commentable_type' => $post2->getMorphClass(), 'commentable_type' => $post2->getMorphClass(),
'commentable_id' => $post2->id, 'commentable_id' => $post2->id,
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'user_id' => $user->id, 'commenter_id' => $user->id,
]); ]);
expect($post1->commentCount())->toBe(3); expect($post1->commentCount())->toBe(3);

View File

@@ -1,8 +1,8 @@
<?php <?php
use Livewire\Livewire; use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Livewire\CommentItem; use Relaticle\Comments\Livewire\CommentItem;
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;
@@ -14,12 +14,12 @@ it('renders mention with styled span', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>@Alice said hi</p>', 'body' => '@Alice said hi',
]); ]);
$comment->mentions()->attach($alice->id, ['user_type' => $alice->getMorphClass()]); $comment->mentions()->attach($alice->id, ['commenter_type' => $alice->getMorphClass()]);
$rendered = $comment->renderBodyWithMentions(); $rendered = $comment->renderBodyWithMentions();
@@ -36,13 +36,13 @@ it('renders multiple mentions with styled spans', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>@Alice and @Bob</p>', 'body' => '@Alice and @Bob',
]); ]);
$comment->mentions()->attach($alice->id, ['user_type' => $alice->getMorphClass()]); $comment->mentions()->attach($alice->id, ['commenter_type' => $alice->getMorphClass()]);
$comment->mentions()->attach($bob->id, ['user_type' => $bob->getMorphClass()]); $comment->mentions()->attach($bob->id, ['commenter_type' => $bob->getMorphClass()]);
$rendered = $comment->renderBodyWithMentions(); $rendered = $comment->renderBodyWithMentions();
@@ -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();
@@ -58,9 +80,9 @@ it('does not style non-mentioned @text', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>@ghost is not here</p>', 'body' => '@ghost is not here',
]); ]);
$rendered = $comment->renderBodyWithMentions(); $rendered = $comment->renderBodyWithMentions();
@@ -76,12 +98,12 @@ it('renders comment-mention class in Livewire component', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Hello @Alice</p>', 'body' => 'Hello @Alice',
]); ]);
$comment->mentions()->attach($alice->id, ['user_type' => $alice->getMorphClass()]); $comment->mentions()->attach($alice->id, ['commenter_type' => $alice->getMorphClass()]);
$this->actingAs($user); $this->actingAs($user);

View File

@@ -2,14 +2,25 @@
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Contracts\MentionResolver; use Relaticle\Comments\Contracts\MentionResolver;
use Relaticle\Comments\Events\UserMentioned; use Relaticle\Comments\Events\UserMentioned;
use Relaticle\Comments\Mentions\DefaultMentionResolver; use Relaticle\Comments\Mentions\DefaultMentionResolver;
use Relaticle\Comments\Mentions\MentionParser; use Relaticle\Comments\Mentions\MentionParser;
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']);
@@ -62,8 +73,8 @@ it('stores mentions in comment_mentions table on create', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Hello @john and @jane</p>', 'body' => '<p>Hello @john and @jane</p>',
]); ]);
@@ -85,8 +96,8 @@ it('dispatches UserMentioned event for each mentioned user', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Hello @john and @jane</p>', 'body' => '<p>Hello @john and @jane</p>',
]); ]);
@@ -116,8 +127,8 @@ it('only dispatches UserMentioned for newly added mentions on update', function
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Hello @john</p>', 'body' => '<p>Hello @john</p>',
]); ]);
@@ -146,8 +157,8 @@ it('removes mentions from pivot when user removed from body', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Hello @john and @jane</p>', 'body' => '<p>Hello @john and @jane</p>',
]); ]);

View File

@@ -1,72 +1,12 @@
<?php <?php
use Livewire\Livewire; use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Livewire\CommentItem; use Relaticle\Comments\Livewire\CommentItem;
use Relaticle\Comments\Livewire\Comments; use Relaticle\Comments\Livewire\Comments;
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('returns matching users for search query', function () {
$alice = User::factory()->create(['name' => 'Alice']);
User::factory()->create(['name' => 'Bob']);
$post = Post::factory()->create();
$this->actingAs($alice);
$component = Livewire::test(Comments::class, ['model' => $post]);
$results = $component->instance()->searchUsers('Ali');
expect($results)->toHaveCount(1);
expect($results[0])->toMatchArray([
'id' => $alice->id,
'name' => 'Alice',
]);
expect($results[0])->toHaveKey('avatar_url');
});
it('returns empty array for empty query', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
$component = Livewire::test(Comments::class, ['model' => $post]);
$results = $component->instance()->searchUsers('');
expect($results)->toBeEmpty();
});
it('returns empty array for no matches', function () {
$user = User::factory()->create(['name' => 'Alice']);
$post = Post::factory()->create();
$this->actingAs($user);
$component = Livewire::test(Comments::class, ['model' => $post]);
$results = $component->instance()->searchUsers('zzz');
expect($results)->toBeEmpty();
});
it('limits search results to configured max', function () {
$user = User::factory()->create(['name' => 'Admin']);
$post = Post::factory()->create();
for ($i = 1; $i <= 10; $i++) {
User::factory()->create(['name' => "Test User {$i}"]);
}
config(['comments.mentions.max_results' => 3]);
$this->actingAs($user);
$component = Livewire::test(Comments::class, ['model' => $post]);
$results = $component->instance()->searchUsers('Test');
expect($results)->toHaveCount(3);
});
it('stores mentions when creating comment with @mention', function () { it('stores mentions when creating comment with @mention', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$alice = User::factory()->create(['name' => 'Alice']); $alice = User::factory()->create(['name' => 'Alice']);
@@ -75,7 +15,7 @@ it('stores mentions when creating comment with @mention', function () {
$this->actingAs($user); $this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Hey @Alice check this</p>') ->set('commentData.body', '<p>Hey @Alice check this</p>')
->call('addComment'); ->call('addComment');
$comment = Comment::first(); $comment = Comment::first();
@@ -92,8 +32,8 @@ it('stores mentions when editing comment with @mention', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Original comment</p>', 'body' => '<p>Original comment</p>',
]); ]);
@@ -101,7 +41,7 @@ it('stores mentions when editing comment with @mention', function () {
Livewire::test(CommentItem::class, ['comment' => $comment]) Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit') ->call('startEdit')
->set('editBody', '<p>Updated @Bob</p>') ->set('editData.body', '<p>Updated @Bob</p>')
->call('saveEdit'); ->call('saveEdit');
$comment->refresh(); $comment->refresh();

View File

@@ -1,12 +1,12 @@
<?php <?php
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentSubscription;
use Relaticle\Comments\Events\CommentCreated; use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Events\UserMentioned; use Relaticle\Comments\Events\UserMentioned;
use Relaticle\Comments\Listeners\SendCommentRepliedNotification; use Relaticle\Comments\Listeners\SendCommentRepliedNotification;
use Relaticle\Comments\Listeners\SendUserMentionedNotification; use Relaticle\Comments\Listeners\SendUserMentionedNotification;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Models\Subscription;
use Relaticle\Comments\Notifications\CommentRepliedNotification; use Relaticle\Comments\Notifications\CommentRepliedNotification;
use Relaticle\Comments\Notifications\UserMentionedNotification; use Relaticle\Comments\Notifications\UserMentionedNotification;
use Relaticle\Comments\Tests\Models\Post; use Relaticle\Comments\Tests\Models\Post;
@@ -19,21 +19,21 @@ it('sends CommentRepliedNotification to parent comment author when reply is crea
$replyAuthor = User::factory()->create(); $replyAuthor = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $parentAuthor); Subscription::subscribe($post, $parentAuthor);
$parentComment = Comment::factory()->create([ $parentComment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $parentAuthor->getKey(), 'commenter_id' => $parentAuthor->getKey(),
'user_type' => $parentAuthor->getMorphClass(), 'commenter_type' => $parentAuthor->getMorphClass(),
'body' => '<p>Parent comment</p>', 'body' => '<p>Parent comment</p>',
]); ]);
$reply = Comment::factory()->create([ $reply = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $replyAuthor->getKey(), 'commenter_id' => $replyAuthor->getKey(),
'user_type' => $replyAuthor->getMorphClass(), 'commenter_type' => $replyAuthor->getMorphClass(),
'parent_id' => $parentComment->id, 'parent_id' => $parentComment->id,
'body' => '<p>A reply</p>', 'body' => '<p>A reply</p>',
]); ]);
@@ -51,13 +51,13 @@ it('does NOT send reply notification for top-level comments', function () {
$subscriber = User::factory()->create(); $subscriber = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $subscriber); Subscription::subscribe($post, $subscriber);
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'body' => '<p>Top-level comment</p>', 'body' => '<p>Top-level comment</p>',
]); ]);
@@ -73,21 +73,21 @@ it('does NOT send reply notification to the reply author', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $user); Subscription::subscribe($post, $user);
$parentComment = Comment::factory()->create([ $parentComment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>My comment</p>', 'body' => '<p>My comment</p>',
]); ]);
$reply = Comment::factory()->create([ $reply = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'parent_id' => $parentComment->id, 'parent_id' => $parentComment->id,
'body' => '<p>My own reply</p>', 'body' => '<p>My own reply</p>',
]); ]);
@@ -108,8 +108,8 @@ it('sends UserMentionedNotification when a user is mentioned', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'body' => '<p>Hey @someone</p>', 'body' => '<p>Hey @someone</p>',
]); ]);
@@ -128,8 +128,8 @@ it('does NOT send mention notification to the comment author', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'body' => '<p>Hey @myself</p>', 'body' => '<p>Hey @myself</p>',
]); ]);
@@ -146,22 +146,22 @@ it('does NOT send reply notification to unsubscribed user', function () {
$unsubscribedUser = User::factory()->create(); $unsubscribedUser = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $unsubscribedUser); Subscription::subscribe($post, $unsubscribedUser);
CommentSubscription::unsubscribe($post, $unsubscribedUser); Subscription::unsubscribe($post, $unsubscribedUser);
$parentComment = Comment::factory()->create([ $parentComment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $unsubscribedUser->getKey(), 'commenter_id' => $unsubscribedUser->getKey(),
'user_type' => $unsubscribedUser->getMorphClass(), 'commenter_type' => $unsubscribedUser->getMorphClass(),
'body' => '<p>Original</p>', 'body' => '<p>Original</p>',
]); ]);
$reply = Comment::factory()->create([ $reply = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'parent_id' => $parentComment->id, 'parent_id' => $parentComment->id,
'body' => '<p>Reply</p>', 'body' => '<p>Reply</p>',
]); ]);
@@ -178,20 +178,20 @@ it('auto-subscribes the comment author when creating a comment', function () {
$author = User::factory()->create(); $author = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
expect(CommentSubscription::isSubscribed($post, $author))->toBeFalse(); expect(Subscription::isSubscribed($post, $author))->toBeFalse();
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'body' => '<p>My comment</p>', 'body' => '<p>My comment</p>',
]); ]);
$listener = new SendCommentRepliedNotification; $listener = new SendCommentRepliedNotification;
$listener->handle(new CommentCreated($comment)); $listener->handle(new CommentCreated($comment));
expect(CommentSubscription::isSubscribed($post, $author))->toBeTrue(); expect(Subscription::isSubscribed($post, $author))->toBeTrue();
}); });
it('suppresses all notifications when notifications are disabled via config', function () { it('suppresses all notifications when notifications are disabled via config', function () {
@@ -203,21 +203,21 @@ it('suppresses all notifications when notifications are disabled via config', fu
$mentioned = User::factory()->create(); $mentioned = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $subscriber); Subscription::subscribe($post, $subscriber);
$parentComment = Comment::factory()->create([ $parentComment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $subscriber->getKey(), 'commenter_id' => $subscriber->getKey(),
'user_type' => $subscriber->getMorphClass(), 'commenter_type' => $subscriber->getMorphClass(),
'body' => '<p>Original</p>', 'body' => '<p>Original</p>',
]); ]);
$reply = Comment::factory()->create([ $reply = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'parent_id' => $parentComment->id, 'parent_id' => $parentComment->id,
'body' => '<p>Reply</p>', 'body' => '<p>Reply</p>',
]); ]);

View File

@@ -1,13 +1,12 @@
<?php <?php
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentSubscription;
use Relaticle\Comments\Config;
use Relaticle\Comments\Events\CommentCreated; use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Events\UserMentioned; use Relaticle\Comments\Events\UserMentioned;
use Relaticle\Comments\Listeners\SendCommentRepliedNotification; use Relaticle\Comments\Listeners\SendCommentRepliedNotification;
use Relaticle\Comments\Listeners\SendUserMentionedNotification; use Relaticle\Comments\Listeners\SendUserMentionedNotification;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Models\Subscription;
use Relaticle\Comments\Notifications\CommentRepliedNotification; use Relaticle\Comments\Notifications\CommentRepliedNotification;
use Relaticle\Comments\Notifications\UserMentionedNotification; use Relaticle\Comments\Notifications\UserMentionedNotification;
use Relaticle\Comments\Tests\Models\Post; use Relaticle\Comments\Tests\Models\Post;
@@ -22,8 +21,8 @@ it('returns correct via channels from config for CommentRepliedNotification', fu
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Hello</p>', 'body' => '<p>Hello</p>',
]); ]);
@@ -39,8 +38,8 @@ it('returns toDatabase array with comment data for CommentRepliedNotification',
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>This is a reply body</p>', 'body' => '<p>This is a reply body</p>',
]); ]);
@@ -51,7 +50,7 @@ it('returns toDatabase array with comment data for CommentRepliedNotification',
->and($data['comment_id'])->toBe($comment->id) ->and($data['comment_id'])->toBe($comment->id)
->and($data['commentable_type'])->toBe($post->getMorphClass()) ->and($data['commentable_type'])->toBe($post->getMorphClass())
->and($data['commentable_id'])->toBe($post->id) ->and($data['commentable_id'])->toBe($post->id)
->and($data['commenter_name'])->toBe($user->getCommentName()); ->and($data['commenter_name'])->toBe($user->getCommentDisplayName());
}); });
it('returns correct via channels from config for UserMentionedNotification', function () { it('returns correct via channels from config for UserMentionedNotification', function () {
@@ -64,8 +63,8 @@ it('returns correct via channels from config for UserMentionedNotification', fun
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $mentionedBy->getKey(), 'commenter_id' => $mentionedBy->getKey(),
'user_type' => $mentionedBy->getMorphClass(), 'commenter_type' => $mentionedBy->getMorphClass(),
'body' => '<p>Hey @someone</p>', 'body' => '<p>Hey @someone</p>',
]); ]);
@@ -82,8 +81,8 @@ it('returns toDatabase array with mention data for UserMentionedNotification', f
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $mentioner->getKey(), 'commenter_id' => $mentioner->getKey(),
'user_type' => $mentioner->getMorphClass(), 'commenter_type' => $mentioner->getMorphClass(),
'body' => '<p>Hey @mentioned</p>', 'body' => '<p>Hey @mentioned</p>',
]); ]);
@@ -92,7 +91,7 @@ it('returns toDatabase array with mention data for UserMentionedNotification', f
expect($data)->toHaveKeys(['comment_id', 'commentable_type', 'commentable_id', 'mentioner_name', 'body']) expect($data)->toHaveKeys(['comment_id', 'commentable_type', 'commentable_id', 'mentioner_name', 'body'])
->and($data['comment_id'])->toBe($comment->id) ->and($data['comment_id'])->toBe($comment->id)
->and($data['mentioner_name'])->toBe($mentioner->getCommentName()); ->and($data['mentioner_name'])->toBe($mentioner->getCommentDisplayName());
}); });
it('sends notification to subscribers when reply comment is created', function () { it('sends notification to subscribers when reply comment is created', function () {
@@ -102,21 +101,21 @@ it('sends notification to subscribers when reply comment is created', function (
$subscriber = User::factory()->create(); $subscriber = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $subscriber); Subscription::subscribe($post, $subscriber);
$parentComment = Comment::factory()->create([ $parentComment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $subscriber->getKey(), 'commenter_id' => $subscriber->getKey(),
'user_type' => $subscriber->getMorphClass(), 'commenter_type' => $subscriber->getMorphClass(),
'body' => '<p>Original comment</p>', 'body' => '<p>Original comment</p>',
]); ]);
$reply = Comment::factory()->create([ $reply = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'parent_id' => $parentComment->id, 'parent_id' => $parentComment->id,
'body' => '<p>Reply to original</p>', 'body' => '<p>Reply to original</p>',
]); ]);
@@ -134,13 +133,13 @@ it('does NOT send notification for top-level comments', function () {
$subscriber = User::factory()->create(); $subscriber = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $subscriber); Subscription::subscribe($post, $subscriber);
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'body' => '<p>Top-level comment</p>', 'body' => '<p>Top-level comment</p>',
]); ]);
@@ -156,21 +155,21 @@ it('does NOT notify the reply author themselves', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $user); Subscription::subscribe($post, $user);
$parentComment = Comment::factory()->create([ $parentComment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>My comment</p>', 'body' => '<p>My comment</p>',
]); ]);
$reply = Comment::factory()->create([ $reply = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'parent_id' => $parentComment->id, 'parent_id' => $parentComment->id,
'body' => '<p>My own reply</p>', 'body' => '<p>My own reply</p>',
]); ]);
@@ -187,20 +186,20 @@ it('auto-subscribes comment author to the thread', function () {
$author = User::factory()->create(); $author = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
expect(CommentSubscription::isSubscribed($post, $author))->toBeFalse(); expect(Subscription::isSubscribed($post, $author))->toBeFalse();
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'body' => '<p>Comment</p>', 'body' => '<p>Comment</p>',
]); ]);
$listener = new SendCommentRepliedNotification; $listener = new SendCommentRepliedNotification;
$listener->handle(new CommentCreated($comment)); $listener->handle(new CommentCreated($comment));
expect(CommentSubscription::isSubscribed($post, $author))->toBeTrue(); expect(Subscription::isSubscribed($post, $author))->toBeTrue();
}); });
it('only notifies subscribed users for reply notifications', function () { it('only notifies subscribed users for reply notifications', function () {
@@ -211,21 +210,21 @@ it('only notifies subscribed users for reply notifications', function () {
$nonSubscriber = User::factory()->create(); $nonSubscriber = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $subscriber); Subscription::subscribe($post, $subscriber);
$parentComment = Comment::factory()->create([ $parentComment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $subscriber->getKey(), 'commenter_id' => $subscriber->getKey(),
'user_type' => $subscriber->getMorphClass(), 'commenter_type' => $subscriber->getMorphClass(),
'body' => '<p>Original</p>', 'body' => '<p>Original</p>',
]); ]);
$reply = Comment::factory()->create([ $reply = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'parent_id' => $parentComment->id, 'parent_id' => $parentComment->id,
'body' => '<p>Reply</p>', 'body' => '<p>Reply</p>',
]); ]);
@@ -247,8 +246,8 @@ it('sends mention notification to mentioned user', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'body' => '<p>Hey @mentioned</p>', 'body' => '<p>Hey @mentioned</p>',
]); ]);
@@ -268,8 +267,8 @@ it('does NOT send mention notification to the comment author', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'body' => '<p>Hey @myself</p>', 'body' => '<p>Hey @myself</p>',
]); ]);
@@ -287,13 +286,13 @@ it('auto-subscribes mentioned user to the thread', function () {
$mentioned = User::factory()->create(); $mentioned = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
expect(CommentSubscription::isSubscribed($post, $mentioned))->toBeFalse(); expect(Subscription::isSubscribed($post, $mentioned))->toBeFalse();
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'body' => '<p>Hey @mentioned</p>', 'body' => '<p>Hey @mentioned</p>',
]); ]);
@@ -301,7 +300,7 @@ it('auto-subscribes mentioned user to the thread', function () {
$listener = new SendUserMentionedNotification; $listener = new SendUserMentionedNotification;
$listener->handle($event); $listener->handle($event);
expect(CommentSubscription::isSubscribed($post, $mentioned))->toBeTrue(); expect(Subscription::isSubscribed($post, $mentioned))->toBeTrue();
}); });
it('does not send notifications when notifications are disabled', function () { it('does not send notifications when notifications are disabled', function () {
@@ -313,21 +312,21 @@ it('does not send notifications when notifications are disabled', function () {
$mentioned = User::factory()->create(); $mentioned = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $subscriber); Subscription::subscribe($post, $subscriber);
$parentComment = Comment::factory()->create([ $parentComment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $subscriber->getKey(), 'commenter_id' => $subscriber->getKey(),
'user_type' => $subscriber->getMorphClass(), 'commenter_type' => $subscriber->getMorphClass(),
'body' => '<p>Original</p>', 'body' => '<p>Original</p>',
]); ]);
$reply = Comment::factory()->create([ $reply = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(), 'commenter_id' => $author->getKey(),
'user_type' => $author->getMorphClass(), 'commenter_type' => $author->getMorphClass(),
'parent_id' => $parentComment->id, 'parent_id' => $parentComment->id,
'body' => '<p>Reply</p>', 'body' => '<p>Reply</p>',
]); ]);

View File

@@ -1,33 +1,33 @@
<?php <?php
use Relaticle\Comments\Config; use Relaticle\Comments\CommentsConfig;
it('returns false for isBroadcastingEnabled by default', function () { it('returns false for isBroadcastingEnabled by default', function () {
expect(Config::isBroadcastingEnabled())->toBeFalse(); expect(CommentsConfig::isBroadcastingEnabled())->toBeFalse();
}); });
it('returns true for isBroadcastingEnabled when config overridden', function () { it('returns true for isBroadcastingEnabled when config overridden', function () {
config()->set('comments.broadcasting.enabled', true); config()->set('comments.broadcasting.enabled', true);
expect(Config::isBroadcastingEnabled())->toBeTrue(); expect(CommentsConfig::isBroadcastingEnabled())->toBeTrue();
}); });
it('returns comments as default broadcast channel prefix', function () { it('returns comments as default broadcast channel prefix', function () {
expect(Config::getBroadcastChannelPrefix())->toBe('comments'); expect(CommentsConfig::getBroadcastChannelPrefix())->toBe('comments');
}); });
it('returns custom broadcast channel prefix when overridden', function () { it('returns custom broadcast channel prefix when overridden', function () {
config()->set('comments.broadcasting.channel_prefix', 'my-app-comments'); config()->set('comments.broadcasting.channel_prefix', 'my-app-comments');
expect(Config::getBroadcastChannelPrefix())->toBe('my-app-comments'); expect(CommentsConfig::getBroadcastChannelPrefix())->toBe('my-app-comments');
}); });
it('returns 10s as default polling interval', function () { it('returns 10s as default polling interval', function () {
expect(Config::getPollingInterval())->toBe('10s'); expect(CommentsConfig::getPollingInterval())->toBe('10s');
}); });
it('returns custom polling interval when overridden', function () { it('returns custom polling interval when overridden', function () {
config()->set('comments.polling.interval', '30s'); config()->set('comments.polling.interval', '30s');
expect(Config::getPollingInterval())->toBe('30s'); expect(CommentsConfig::getPollingInterval())->toBe('30s');
}); });

View File

@@ -2,11 +2,11 @@
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Livewire\Livewire; use Livewire\Livewire;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\CommentReaction;
use Relaticle\Comments\Config;
use Relaticle\Comments\Events\CommentReacted; use Relaticle\Comments\Events\CommentReacted;
use Relaticle\Comments\Livewire\Reactions; use Relaticle\Comments\Livewire\Reactions;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Models\Reaction;
use Relaticle\Comments\Tests\Models\Post; use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User; use Relaticle\Comments\Tests\Models\User;
@@ -17,8 +17,8 @@ it('adds a reaction when user clicks an emoji', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@@ -26,10 +26,10 @@ it('adds a reaction when user clicks an emoji', function () {
Livewire::test(Reactions::class, ['comment' => $comment]) Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'thumbs_up'); ->call('toggleReaction', 'thumbs_up');
expect(CommentReaction::where([ expect(Reaction::where([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up', 'reaction' => 'thumbs_up',
])->exists())->toBeTrue(); ])->exists())->toBeTrue();
}); });
@@ -41,14 +41,14 @@ it('removes a reaction when toggling same emoji', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
CommentReaction::create([ Reaction::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up', 'reaction' => 'thumbs_up',
]); ]);
@@ -57,10 +57,10 @@ it('removes a reaction when toggling same emoji', function () {
Livewire::test(Reactions::class, ['comment' => $comment]) Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'thumbs_up'); ->call('toggleReaction', 'thumbs_up');
expect(CommentReaction::where([ expect(Reaction::where([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up', 'reaction' => 'thumbs_up',
])->exists())->toBeFalse(); ])->exists())->toBeFalse();
}); });
@@ -74,8 +74,8 @@ it('fires CommentReacted event with added action', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@@ -98,14 +98,14 @@ it('fires CommentReacted event with removed action', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
CommentReaction::create([ Reaction::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'reaction' => 'heart', 'reaction' => 'heart',
]); ]);
@@ -133,35 +133,35 @@ it('returns correct reaction summary with counts', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user1->getKey(), 'commenter_id' => $user1->getKey(),
'user_type' => $user1->getMorphClass(), 'commenter_type' => $user1->getMorphClass(),
]); ]);
CommentReaction::create([ Reaction::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user1->getKey(), 'commenter_id' => $user1->getKey(),
'user_type' => $user1->getMorphClass(), 'commenter_type' => $user1->getMorphClass(),
'reaction' => 'thumbs_up', 'reaction' => 'thumbs_up',
]); ]);
CommentReaction::create([ Reaction::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user2->getKey(), 'commenter_id' => $user2->getKey(),
'user_type' => $user2->getMorphClass(), 'commenter_type' => $user2->getMorphClass(),
'reaction' => 'thumbs_up', 'reaction' => 'thumbs_up',
]); ]);
CommentReaction::create([ Reaction::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user3->getKey(), 'commenter_id' => $user3->getKey(),
'user_type' => $user3->getMorphClass(), 'commenter_type' => $user3->getMorphClass(),
'reaction' => 'thumbs_up', 'reaction' => 'thumbs_up',
]); ]);
CommentReaction::create([ Reaction::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user1->getKey(), 'commenter_id' => $user1->getKey(),
'user_type' => $user1->getMorphClass(), 'commenter_type' => $user1->getMorphClass(),
'reaction' => 'heart', 'reaction' => 'heart',
]); ]);
@@ -187,14 +187,14 @@ it('requires authentication to react', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
Livewire::test(Reactions::class, ['comment' => $comment]) Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'thumbs_up'); ->call('toggleReaction', 'thumbs_up');
expect(CommentReaction::count())->toBe(0); expect(Reaction::count())->toBe(0);
}); });
it('allows multiple reaction types from same user', function () { it('allows multiple reaction types from same user', function () {
@@ -204,8 +204,8 @@ it('allows multiple reaction types from same user', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@@ -215,17 +215,17 @@ it('allows multiple reaction types from same user', function () {
$component->call('toggleReaction', 'thumbs_up'); $component->call('toggleReaction', 'thumbs_up');
$component->call('toggleReaction', 'heart'); $component->call('toggleReaction', 'heart');
expect(CommentReaction::where([ expect(Reaction::where([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up', 'reaction' => 'thumbs_up',
])->exists())->toBeTrue(); ])->exists())->toBeTrue();
expect(CommentReaction::where([ expect(Reaction::where([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'reaction' => 'heart', 'reaction' => 'heart',
])->exists())->toBeTrue(); ])->exists())->toBeTrue();
}); });
@@ -238,8 +238,8 @@ it('allows same reaction from multiple users', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user1->getKey(), 'commenter_id' => $user1->getKey(),
'user_type' => $user1->getMorphClass(), 'commenter_type' => $user1->getMorphClass(),
]); ]);
$this->actingAs($user1); $this->actingAs($user1);
@@ -250,7 +250,7 @@ it('allows same reaction from multiple users', function () {
Livewire::test(Reactions::class, ['comment' => $comment]) Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'thumbs_up'); ->call('toggleReaction', 'thumbs_up');
expect(CommentReaction::where([ expect(Reaction::where([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'reaction' => 'thumbs_up', 'reaction' => 'thumbs_up',
])->count())->toBe(2); ])->count())->toBe(2);
@@ -263,8 +263,8 @@ it('rejects invalid reaction keys', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@@ -272,7 +272,7 @@ it('rejects invalid reaction keys', function () {
Livewire::test(Reactions::class, ['comment' => $comment]) Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'invalid_emoji'); ->call('toggleReaction', 'invalid_emoji');
expect(CommentReaction::count())->toBe(0); expect(Reaction::count())->toBe(0);
}); });
it('marks reacted_by_user correctly in summary', function () { it('marks reacted_by_user correctly in summary', function () {
@@ -283,14 +283,14 @@ it('marks reacted_by_user correctly in summary', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $userA->getKey(), 'commenter_id' => $userA->getKey(),
'user_type' => $userA->getMorphClass(), 'commenter_type' => $userA->getMorphClass(),
]); ]);
CommentReaction::create([ Reaction::create([
'comment_id' => $comment->id, 'comment_id' => $comment->id,
'user_id' => $userA->getKey(), 'commenter_id' => $userA->getKey(),
'user_type' => $userA->getMorphClass(), 'commenter_type' => $userA->getMorphClass(),
'reaction' => 'thumbs_up', 'reaction' => 'thumbs_up',
]); ]);
@@ -310,7 +310,7 @@ it('marks reacted_by_user correctly in summary', function () {
}); });
it('returns configured emoji set from config', function () { it('returns configured emoji set from config', function () {
$emojiSet = Config::getReactionEmojiSet(); $emojiSet = CommentsConfig::getReactionEmojiSet();
expect($emojiSet)->toBeArray(); expect($emojiSet)->toBeArray();
expect($emojiSet)->toHaveKey('thumbs_up'); expect($emojiSet)->toHaveKey('thumbs_up');
@@ -322,7 +322,7 @@ it('returns configured emoji set from config', function () {
}); });
it('returns allowed reaction keys from config', function () { it('returns allowed reaction keys from config', function () {
$allowed = Config::getAllowedReactions(); $allowed = CommentsConfig::getAllowedReactions();
expect($allowed)->toBeArray(); expect($allowed)->toBeArray();
expect($allowed)->toContain('thumbs_up'); expect($allowed)->toContain('thumbs_up');

View File

@@ -1,10 +1,10 @@
<?php <?php
use Livewire\Livewire; use Livewire\Livewire;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config;
use Relaticle\Comments\Livewire\CommentItem; use Relaticle\Comments\Livewire\CommentItem;
use Relaticle\Comments\Livewire\Comments; use Relaticle\Comments\Livewire\Comments;
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;
@@ -17,7 +17,7 @@ it('creates a comment with rich HTML content preserved', function () {
$html = '<p>Hello <strong>bold</strong> and <em>italic</em> world</p>'; $html = '<p>Hello <strong>bold</strong> and <em>italic</em> world</p>';
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->set('newComment', $html) ->set('commentData.body', $html)
->call('addComment'); ->call('addComment');
$comment = Comment::first(); $comment = Comment::first();
@@ -35,16 +35,22 @@ it('pre-fills editBody with existing comment HTML when starting edit', function
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => $originalHtml, 'body' => $originalHtml,
]); ]);
$this->actingAs($user); $this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment]) $component = Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit') ->call('startEdit');
->assertSet('editBody', $originalHtml);
$editBody = $component->get('editData')['body'];
$bodyJson = json_encode($editBody);
expect($bodyJson)->toContain('Hello ');
expect($bodyJson)->toContain('world');
expect($bodyJson)->toContain('bold');
}); });
it('saves edited HTML content through edit form', function () { it('saves edited HTML content through edit form', function () {
@@ -54,8 +60,8 @@ it('saves edited HTML content through edit form', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Original</p>', 'body' => '<p>Original</p>',
]); ]);
@@ -65,13 +71,14 @@ it('saves edited HTML content through edit form', function () {
Livewire::test(CommentItem::class, ['comment' => $comment]) Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit') ->call('startEdit')
->set('editBody', $updatedHtml) ->set('editData.body', $updatedHtml)
->call('saveEdit'); ->call('saveEdit');
$comment->refresh(); $comment->refresh();
expect($comment->body)->toContain('<strong>bold</strong>'); expect($comment->body)->toContain('<strong>bold</strong>');
expect($comment->body)->toContain('<a href="https://example.com">a link</a>'); expect($comment->body)->toContain('href="https://example.com"');
expect($comment->body)->toContain('>a link</a>');
}); });
it('creates reply with rich HTML content', function () { it('creates reply with rich HTML content', function () {
@@ -81,8 +88,8 @@ it('creates reply with rich HTML content', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@@ -91,7 +98,7 @@ it('creates reply with rich HTML content', function () {
Livewire::test(CommentItem::class, ['comment' => $comment]) Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply') ->call('startReply')
->set('replyBody', $replyHtml) ->set('replyData.body', $replyHtml)
->call('addReply'); ->call('addReply');
$reply = Comment::where('parent_id', $comment->id)->first(); $reply = Comment::where('parent_id', $comment->id)->first();
@@ -108,8 +115,8 @@ it('renders comment body with fi-prose class', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>Styled comment</p>', 'body' => '<p>Styled comment</p>',
]); ]);
@@ -120,7 +127,7 @@ it('renders comment body with fi-prose class', function () {
}); });
it('returns editor toolbar configuration as nested array', function () { it('returns editor toolbar configuration as nested array', function () {
$toolbar = Config::getEditorToolbar(); $toolbar = CommentsConfig::getEditorToolbar();
expect($toolbar)->toBeArray(); expect($toolbar)->toBeArray();
expect($toolbar)->not->toBeEmpty(); expect($toolbar)->not->toBeEmpty();
@@ -134,7 +141,7 @@ it('uses custom toolbar config when overridden', function () {
['bold', 'italic'], ['bold', 'italic'],
]]); ]]);
$toolbar = Config::getEditorToolbar(); $toolbar = CommentsConfig::getEditorToolbar();
expect($toolbar)->toHaveCount(1); expect($toolbar)->toHaveCount(1);
expect($toolbar[0])->toBe(['bold', 'italic']); expect($toolbar[0])->toBe(['bold', 'italic']);

View File

@@ -1,8 +1,8 @@
<?php <?php
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Relaticle\Comments\Comment; use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Config; use Relaticle\Comments\Models\Comment;
it('registers the config file', function () { it('registers the config file', function () {
expect(config('comments'))->toBeArray(); expect(config('comments'))->toBeArray();
@@ -11,15 +11,15 @@ it('registers the config file', function () {
}); });
it('resolves the comment model from config', function () { it('resolves the comment model from config', function () {
expect(Config::getCommentModel())->toBe(Comment::class); expect(CommentsConfig::getCommentModel())->toBe(Comment::class);
}); });
it('resolves the comment table from config', function () { it('resolves the comment table from config', function () {
expect(Config::getCommentTable())->toBe('comments'); expect(CommentsConfig::getCommentTable())->toBe('comments');
}); });
it('resolves max depth from config', function () { it('resolves max depth from config', function () {
expect(Config::getMaxDepth())->toBe(2); expect(CommentsConfig::getMaxDepth())->toBe(2);
}); });
it('registers the morph map for comment', function () { it('registers the morph map for comment', function () {
@@ -32,7 +32,7 @@ it('creates the comments table via migration', function () {
expect(Schema::hasTable('comments'))->toBeTrue(); expect(Schema::hasTable('comments'))->toBeTrue();
expect(Schema::hasColumns('comments', [ expect(Schema::hasColumns('comments', [
'id', 'commentable_type', 'commentable_id', 'id', 'commentable_type', 'commentable_id',
'user_type', 'user_id', 'parent_id', 'body', 'commenter_type', 'commenter_id', 'parent_id', 'body',
'edited_at', 'deleted_at', 'created_at', 'updated_at', 'edited_at', 'deleted_at', 'created_at', 'updated_at',
]))->toBeTrue(); ]))->toBeTrue();
}); });

View File

@@ -1,8 +1,8 @@
<?php <?php
use Livewire\Livewire; use Livewire\Livewire;
use Relaticle\Comments\CommentSubscription;
use Relaticle\Comments\Livewire\Comments; use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Models\Subscription;
use Relaticle\Comments\Tests\Models\Post; use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User; use Relaticle\Comments\Tests\Models\User;
@@ -12,35 +12,35 @@ it('subscribes user when toggling from unsubscribed state', function () {
$this->actingAs($user); $this->actingAs($user);
expect(CommentSubscription::isSubscribed($post, $user))->toBeFalse(); expect(Subscription::isSubscribed($post, $user))->toBeFalse();
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->call('toggleSubscription'); ->call('toggleSubscription');
expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue(); expect(Subscription::isSubscribed($post, $user))->toBeTrue();
}); });
it('unsubscribes user when toggling from subscribed state', function () { it('unsubscribes user when toggling from subscribed state', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $user); Subscription::subscribe($post, $user);
$this->actingAs($user); $this->actingAs($user);
expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue(); expect(Subscription::isSubscribed($post, $user))->toBeTrue();
Livewire::test(Comments::class, ['model' => $post]) Livewire::test(Comments::class, ['model' => $post])
->call('toggleSubscription'); ->call('toggleSubscription');
expect(CommentSubscription::isSubscribed($post, $user))->toBeFalse(); expect(Subscription::isSubscribed($post, $user))->toBeFalse();
}); });
it('returns true for isSubscribed computed when user is subscribed', function () { it('returns true for isSubscribed computed when user is subscribed', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $user); Subscription::subscribe($post, $user);
$this->actingAs($user); $this->actingAs($user);
@@ -64,7 +64,7 @@ it('renders Subscribed text for subscribed user', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$post = Post::factory()->create(); $post = Post::factory()->create();
CommentSubscription::subscribe($post, $user); Subscription::subscribe($post, $user);
$this->actingAs($user); $this->actingAs($user);

View File

@@ -1,7 +1,7 @@
<?php <?php
use Relaticle\Comments\Comment;
use Relaticle\Comments\Events\UserMentioned; use Relaticle\Comments\Events\UserMentioned;
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;
@@ -13,8 +13,8 @@ it('carries correct comment and mentioned user in payload', function () {
$comment = Comment::factory()->create([ $comment = Comment::factory()->create([
'commentable_id' => $post->id, 'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(), 'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(), 'commenter_id' => $user->getKey(),
'user_type' => $user->getMorphClass(), 'commenter_type' => $user->getMorphClass(),
'body' => '<p>@john</p>', 'body' => '<p>@john</p>',
]); ]);

View File

@@ -5,14 +5,14 @@ namespace Relaticle\Comments\Tests\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Relaticle\Comments\Concerns\IsCommenter; use Relaticle\Comments\Concerns\CanComment;
use Relaticle\Comments\Contracts\Commenter; use Relaticle\Comments\Contracts\Commentator;
use Relaticle\Comments\Tests\Database\Factories\UserFactory; use Relaticle\Comments\Tests\Database\Factories\UserFactory;
class User extends Authenticatable implements Commenter class User extends Authenticatable implements Commentator
{ {
use CanComment;
use HasFactory; use HasFactory;
use IsCommenter;
use Notifiable; use Notifiable;
protected $table = 'users'; protected $table = 'users';

View File

@@ -2,7 +2,12 @@
namespace Relaticle\Comments\Tests; namespace Relaticle\Comments\Tests;
use BladeUI\Heroicons\BladeHeroiconsServiceProvider;
use BladeUI\Icons\BladeIconsServiceProvider;
use Filament\Actions\ActionsServiceProvider;
use Filament\FilamentServiceProvider; use Filament\FilamentServiceProvider;
use Filament\Forms\FormsServiceProvider;
use Filament\Schemas\SchemasServiceProvider;
use Filament\Support\SupportServiceProvider; use Filament\Support\SupportServiceProvider;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -26,7 +31,12 @@ abstract class TestCase extends Orchestra
{ {
return [ return [
LivewireServiceProvider::class, LivewireServiceProvider::class,
BladeIconsServiceProvider::class,
BladeHeroiconsServiceProvider::class,
SupportServiceProvider::class, SupportServiceProvider::class,
SchemasServiceProvider::class,
FormsServiceProvider::class,
ActionsServiceProvider::class,
FilamentServiceProvider::class, FilamentServiceProvider::class,
CommentsServiceProvider::class, CommentsServiceProvider::class,
]; ];
@@ -48,13 +58,13 @@ abstract class TestCase extends Orchestra
$table->timestamps(); $table->timestamps();
}); });
Schema::create(config('comments.tables.comments', 'comments'), function (Blueprint $table) { Schema::create(config('comments.table_names.comments', 'comments'), function (Blueprint $table) {
$table->id(); $table->id();
$table->morphs('commentable'); $table->morphs('commentable');
$table->morphs('user'); $table->morphs('commenter');
$table->foreignId('parent_id') $table->foreignId('parent_id')
->nullable() ->nullable()
->constrained(config('comments.tables.comments', 'comments')) ->constrained(config('comments.table_names.comments', 'comments'))
->cascadeOnDelete(); ->cascadeOnDelete();
$table->text('body'); $table->text('body');
$table->timestamp('edited_at')->nullable(); $table->timestamp('edited_at')->nullable();
@@ -67,30 +77,30 @@ abstract class TestCase extends Orchestra
Schema::create('comment_mentions', function (Blueprint $table) { Schema::create('comment_mentions', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('comment_id') $table->foreignId('comment_id')
->constrained(config('comments.tables.comments', 'comments')) ->constrained(config('comments.table_names.comments', 'comments'))
->cascadeOnDelete(); ->cascadeOnDelete();
$table->morphs('user'); $table->morphs('commenter');
$table->timestamps(); $table->timestamps();
$table->unique(['comment_id', 'user_id', 'user_type']); $table->unique(['comment_id', 'commenter_id', 'commenter_type']);
}); });
Schema::create('comment_reactions', function (Blueprint $table) { Schema::create('comment_reactions', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('comment_id') $table->foreignId('comment_id')
->constrained(config('comments.tables.comments', 'comments')) ->constrained(config('comments.table_names.comments', 'comments'))
->cascadeOnDelete(); ->cascadeOnDelete();
$table->morphs('user'); $table->morphs('commenter');
$table->string('reaction'); $table->string('reaction');
$table->timestamps(); $table->timestamps();
$table->unique(['comment_id', 'user_id', 'user_type', 'reaction']); $table->unique(['comment_id', 'commenter_id', 'commenter_type', 'reaction']);
}); });
Schema::create('comment_attachments', function (Blueprint $table) { Schema::create('comment_attachments', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('comment_id') $table->foreignId('comment_id')
->constrained(config('comments.tables.comments', 'comments')) ->constrained(config('comments.table_names.comments', 'comments'))
->cascadeOnDelete(); ->cascadeOnDelete();
$table->string('file_path'); $table->string('file_path');
$table->string('original_name'); $table->string('original_name');
@@ -112,10 +122,10 @@ abstract class TestCase extends Orchestra
Schema::create('comment_subscriptions', function (Blueprint $table) { Schema::create('comment_subscriptions', function (Blueprint $table) {
$table->id(); $table->id();
$table->morphs('commentable'); $table->morphs('commentable');
$table->morphs('user'); $table->morphs('commenter');
$table->timestamp('created_at')->nullable(); $table->timestamp('created_at')->nullable();
$table->unique(['commentable_type', 'commentable_id', 'user_type', 'user_id'], 'comment_subscriptions_unique'); $table->unique(['commentable_type', 'commentable_id', 'commenter_type', 'commenter_id'], 'comment_subscriptions_unique');
}); });
} }