29 Commits

Author SHA1 Message Date
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
manukminasyan
f83402432e fix: tests badge branch reference to 1.x 2026-03-27 14:12:03 +04:00
manukminasyan
851852df38 fix: use npm install instead of npm ci for docs deployment 2026-03-27 14:08:36 +04:00
Manuk
a2a2e54b61 Merge pull request #1 from relaticle/dependabot/github_actions/dependabot/fetch-metadata-3.0.0
chore(deps): bump dependabot/fetch-metadata from 2.5.0 to 3.0.0
2026-03-27 14:00:58 +04:00
manukminasyan
4a410bce44 fix: use node 22 for docs deployment and regenerate lock file 2026-03-27 13:57:59 +04:00
manukminasyan
d4b0b53fb5 chore: add docs package-lock.json for CI 2026-03-27 00:42:10 +04:00
dependabot[bot]
4c30f06857 chore(deps): bump dependabot/fetch-metadata from 2.5.0 to 3.0.0
Bumps [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) from 2.5.0 to 3.0.0.
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/v2.5.0...v3.0.0)

---
updated-dependencies:
- dependency-name: dependabot/fetch-metadata
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 20:36:13 +00:00
manukminasyan
53dd4565d7 chore: add GitHub workflows, issue templates, and changelog
- Tests, Pint, Release, Changelog, Auto-merge, Deploy Docs workflows
- Bug report template, security policy, contributing guide
- Dependabot config for GitHub Actions
- Remove .idea from tracking
- Keep only premium packages in ecosystem section
2026-03-27 00:35:35 +04:00
manukminasyan
b8d930df1a docs: add README, boost skill, and documentation site 2026-03-27 00:29:57 +04:00
108 changed files with 26658 additions and 1087 deletions

55
.github/CONTRIBUTING.md vendored Normal file
View File

@@ -0,0 +1,55 @@
# Contributing
Contributions are **welcome** and will be fully **credited**.
Please read and understand the contribution guide before creating an issue or pull request.
## Etiquette
This project is open source, and as such, the maintainers give their free time to build and maintain the source code
held within. They make the code freely available in the hope that it will be of use to other developers. It would be
extremely unfair for them to suffer abuse or anger for their hard work.
Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the
world that developers are civilized and selfless people.
It's the duty of the maintainer to ensure that all submissions to the project are of sufficient
quality to benefit the project. Many developers have different skills, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used.
## Viability
When requesting or submitting new features, first consider whether it might be useful to others. Open
source projects are used by many developers, who may have entirely different needs to your own. Think about
whether or not your feature is likely to be used by other users of the project.
## Procedure
Before filing an issue:
- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident.
- Check to make sure your feature suggestion isn't already present within the project.
- Check the pull requests tab to ensure that the bug doesn't have a fix in progress.
- Check the pull requests tab to ensure that the feature isn't already in progress.
Before submitting a pull request:
- Check the codebase to ensure that your feature doesn't already exist.
- Check the pull requests to ensure that another person hasn't already submitted the feature or fix.
## Requirements
If the project maintainer has any additional requirements, you will find them listed here.
- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer).
- **Add tests!** - Your patch won't be accepted if it doesn't have tests.
- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date.
- **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option.
- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests.
- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting.
**Happy coding**!

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: Relaticle

66
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: Bug Report
description: Report an Issue or Bug with the Package
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
We're sorry to hear you have a problem. Can you help us solve it by providing the following details.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: What did you expect to happen?
placeholder: I cannot currently do X thing because when I do, it breaks X thing.
validations:
required: true
- type: textarea
id: how-to-reproduce
attributes:
label: How to reproduce the bug
description: How did this occur, please add any config values used and provide a set of reliable steps if possible.
placeholder: When I do X I see Y.
validations:
required: true
- type: input
id: package-version
attributes:
label: Package Version
description: What version of our Package are you running? Please be as specific as possible
placeholder: 1.0.0
validations:
required: true
- type: input
id: php-version
attributes:
label: PHP Version
description: What version of PHP are you running? Please be as specific as possible
placeholder: 8.2.0
validations:
required: true
- type: input
id: laravel-version
attributes:
label: Laravel Version
description: What version of Laravel are you running? Please be as specific as possible
placeholder: 12.0.0
validations:
required: true
- type: dropdown
id: operating-systems
attributes:
label: Which operating systems does with happen with?
description: You may select more than one.
multiple: true
options:
- macOS
- Windows
- Linux
- type: textarea
id: notes
attributes:
label: Notes
description: Use this field to provide any other notes that you feel might be relevant to the issue.
validations:
required: false

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Ask a question
url: https://github.com/Relaticle/comments/discussions/new?category=q-a
about: Ask the community for help
- name: Request a feature
url: https://github.com/Relaticle/comments/discussions/new?category=ideas
about: Share ideas for new features
- name: Report a security issue
url: https://github.com/Relaticle/comments/security/policy
about: Learn how to notify us for sensitive bugs

3
.github/SECURITY.md vendored Normal file
View File

@@ -0,0 +1,3 @@
# Security Policy
If you discover any security related issues, please email manuk.minasyan1@gmail.com instead of using the issue tracker.

12
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"

21
.github/release.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
changelog:
exclude:
labels:
- skip-changelog
authors:
- dependabot
categories:
- title: Bug Fixes
labels:
- bug
- fix
- title: New Features
labels:
- feature
- enhancement
- title: Breaking Changes
labels:
- breaking
- title: Other Changes
labels:
- "*"

33
.github/workflows/auto-merge.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Auto-Merge
on: pull_request_target
permissions:
pull-requests: write
contents: write
jobs:
dependabot:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v3.0.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Auto-merge Dependabot PRs for semver-minor updates
if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}}
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Auto-merge Dependabot PRs for semver-patch updates
if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}}
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

42
.github/workflows/changelog.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Changelog
on:
release:
types: [released]
permissions:
contents: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- name: Determine target branch
id: branch
run: |
TAG="${{ github.event.release.tag_name }}"
MAJOR=$(echo "$TAG" | sed -E 's/^v?([0-9]+)\..*/\1/')
BRANCH="${MAJOR}.x"
if ! git ls-remote --exit-code --heads "https://github.com/${{ github.repository }}" "$BRANCH" > /dev/null 2>&1; then
BRANCH="${{ github.event.repository.default_branch }}"
fi
echo "name=${BRANCH}" >> $GITHUB_OUTPUT
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ steps.branch.outputs.name }}
- name: Update Changelog
uses: stefanzweifel/changelog-updater-action@v1
with:
latest-version: ${{ github.event.release.name }}
release-notes: ${{ github.event.release.body }}
- name: Commit updated CHANGELOG
uses: stefanzweifel/git-auto-commit-action@v7
with:
branch: ${{ steps.branch.outputs.name }}
commit_message: Update CHANGELOG
file_pattern: CHANGELOG.md

137
.github/workflows/deploy-docs.yml vendored Normal file
View File

@@ -0,0 +1,137 @@
name: Deploy Docs
on:
push:
branches: ["1.x"]
paths:
- 'docs/**'
- '.github/workflows/deploy-docs.yml'
workflow_dispatch:
inputs:
version:
description: 'Version branch to deploy (e.g., 1.x)'
required: true
type: choice
options:
- '1.x'
# Prevent concurrent deploys to avoid push conflicts
concurrency:
group: deploy-docs
cancel-in-progress: false
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Determine version
id: version
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "branch=${{ inputs.version }}" >> $GITHUB_OUTPUT
else
echo "branch=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT
fi
- name: Set version config
id: config
run: |
BRANCH="${{ steps.version.outputs.branch }}"
case $BRANCH in
1.x)
echo "dest_folder=." >> $GITHUB_OUTPUT
echo "base_url=/comments/" >> $GITHUB_OUTPUT
echo "is_latest=true" >> $GITHUB_OUTPUT
;;
*)
echo "Unknown branch: $BRANCH"
exit 1
;;
esac
- name: Checkout source branch
uses: actions/checkout@v6
with:
ref: ${{ steps.version.outputs.branch }}
- name: Checkout gh-pages
uses: actions/checkout@v6
with:
ref: gh-pages
path: gh-pages
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: 'docs/package-lock.json'
- name: Install dependencies
working-directory: ./docs
run: npm install
- name: Build documentation
working-directory: ./docs
run: npm run generate
env:
NUXT_APP_BASE_URL: ${{ steps.config.outputs.base_url }}
NUXT_SITE_URL: https://relaticle.github.io
DOCS_VERSION: ${{ steps.version.outputs.branch }}
NUXT_PUBLIC_FATHOM_SITE_ID: ${{ secrets.FATHOM_SITE_ID }}
- name: Deploy to gh-pages
run: |
DEST="${{ steps.config.outputs.dest_folder }}"
IS_LATEST="${{ steps.config.outputs.is_latest }}"
if [ "$IS_LATEST" == "true" ]; then
echo "Deploying latest version to root..."
cd gh-pages
# List of items to preserve
PRESERVE="versions.json .nojekyll README.md .git"
# Remove everything except preserved items
for item in *; do
if [ -e "$item" ]; then
SKIP=false
for keep in $PRESERVE; do
if [ "$item" == "$keep" ]; then
SKIP=true
break
fi
done
if [ "$SKIP" == "false" ]; then
rm -rf "$item"
fi
fi
done
# Also remove hidden files except .git, .nojekyll
for item in .[!.]*; do
if [ -e "$item" ] && [ "$item" != ".git" ] && [ "$item" != ".nojekyll" ]; then
rm -rf "$item"
fi
done
cd ..
# Copy new build to root
cp -r docs/.output/public/* gh-pages/
else
echo "Deploying to $DEST subfolder..."
rm -rf "gh-pages/$DEST"
cp -r docs/.output/public "gh-pages/$DEST"
fi
- name: Commit and push
working-directory: ./gh-pages
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
if git diff --staged --quiet; then
echo "No changes to deploy"
else
git commit -m "Deploy ${{ steps.version.outputs.branch }} docs"
git push
fi

28
.github/workflows/pint.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Pint
on:
push:
branches: [1.x]
paths:
- '**.php'
permissions:
contents: write
jobs:
php-code-styling:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
- name: Fix PHP code style issues
uses: aglipanci/laravel-pint-action@2.6
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: Fix styling

51
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
permissions:
contents: write
jobs:
tests:
uses: ./.github/workflows/tests.yml
secrets: inherit
release:
needs: tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Determine if pre-release
id: prerelease
run: |
TAG="${{ github.ref_name }}"
if [[ "$TAG" == *"-"* ]]; then
echo "flag=--prerelease" >> $GITHUB_OUTPUT
else
echo "flag=" >> $GITHUB_OUTPUT
fi
- name: Create GitHub Release
run: gh release create "${{ github.ref_name }}" --generate-notes ${{ steps.prerelease.outputs.flag }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
cleanup:
needs: tests
if: failure()
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Delete tag on test failure
run: git push --delete origin "${{ github.ref_name }}"

51
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Tests
on:
push:
branches: [1.x]
pull_request:
branches: [1.x]
workflow_call:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
matrix:
os: [ubuntu-latest]
php: [8.2, 8.3, 8.4]
laravel: [12.*]
stability: [prefer-stable]
include:
- laravel: 12.*
testbench: 10.*
name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo
coverage: xdebug
- name: Setup problem matchers
run: |
echo "::add-matcher::${{ runner.tool_cache }}/php.json"
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Install dependencies
run: |
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
composer update --${{ matrix.stability }} --prefer-dist --no-interaction
- name: List Installed Dependencies
run: composer show -D
- name: Execute tests
run: vendor/bin/pest --ci

15
.gitignore vendored
View File

@@ -1,5 +1,14 @@
/vendor
/node_modules
/.phpunit.cache
.DS_Store
.idea
.vscode
.claude
.phpunit.cache
.phpunit.result.cache
build
composer.lock
coverage
node_modules
phpunit.xml
phpstan.neon
testbench.yaml
vendor

6
CHANGELOG.md Normal file
View File

@@ -0,0 +1,6 @@
# Changelog
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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

162
README.md Normal file
View File

@@ -0,0 +1,162 @@
# Comments
<img src="art/preview.png" alt="Comments System" width="800">
A full-featured commenting system for Filament panels with threaded replies, @mentions, emoji reactions, and real-time updates.
[![Latest Version](https://img.shields.io/packagist/v/relaticle/comments.svg?style=for-the-badge)](https://packagist.org/packages/relaticle/comments)
[![Total Downloads](https://img.shields.io/packagist/dt/relaticle/comments.svg?style=for-the-badge)](https://packagist.org/packages/relaticle/comments)
[![PHP 8.2+](https://img.shields.io/badge/php-8.2%2B-blue.svg?style=for-the-badge)](https://php.net)
[![Laravel 12+](https://img.shields.io/badge/laravel-12%2B-red.svg?style=for-the-badge)](https://laravel.com)
[![Tests](https://img.shields.io/github/actions/workflow/status/relaticle/comments/tests.yml?branch=1.x&style=for-the-badge&label=tests)](https://github.com/relaticle/comments/actions)
## Features
- **Threaded Replies** - Nested comment threads with configurable depth limits
- **@Mentions** - Autocomplete user mentions with customizable resolver
- **Emoji Reactions** - 6 built-in reactions with configurable emoji sets
- **File Attachments** - Image and document uploads with validation
- **Notifications & Subscriptions** - Database and mail notifications with auto-subscribe
- **3 Filament Integrations** - Slide-over action, table action, and infolist entry
## Requirements
- **PHP:** 8.2+
- **Laravel:** 12+
- **Livewire:** 3.5+ / 4.x
- **Filament:** 4.x / 5.x
## Installation
```bash
composer require relaticle/comments
```
Publish and run migrations:
```bash
php artisan vendor:publish --tag=comments-migrations
php artisan migrate
```
## Usage
### Set Up Your Models
Add the commenting traits to your models:
```php
use Relaticle\Comments\Concerns\HasComments;
use Relaticle\Comments\Contracts\Commentable;
class Project extends Model implements Commentable
{
use HasComments;
}
```
Add the commenter trait to your User model:
```php
use Relaticle\Comments\Concerns\CanComment;
use Relaticle\Comments\Contracts\Commentator;
class User extends Authenticatable implements Commentator
{
use CanComment;
}
```
### Register the Filament Plugin
```php
use Relaticle\Comments\CommentsPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugins([
CommentsPlugin::make(),
]);
}
```
### Add Comments to Your Resources
Use the slide-over action on view/edit pages:
```php
use Relaticle\Comments\Filament\Actions\CommentsAction;
protected function getHeaderActions(): array
{
return [
CommentsAction::make(),
];
}
```
Or add as a table action:
```php
use Relaticle\Comments\Filament\Actions\CommentsTableAction;
public static function table(Table $table): Table
{
return $table
->actions([
CommentsTableAction::make(),
]);
}
```
Or embed in an infolist:
```php
use Relaticle\Comments\Filament\Infolists\Components\CommentsEntry;
public static function infolist(Infolist $infolist): Infolist
{
return $infolist->schema([
CommentsEntry::make('comments'),
]);
}
```
**[View Complete Documentation ->](https://relaticle.github.io/comments/)**
## Our Ecosystem
<table>
<tr>
<td width="50%" valign="top">
### FilaForms
[<img src="https://filaforms.app/img/og-image.png" width="100%" />](https://filaforms.app/)
Visual form builder for all your public-facing forms.
[Learn more ->](https://filaforms.app)
</td>
<td width="50%" valign="top">
### Custom Fields
[<img src="https://github.com/Relaticle/custom-fields/raw/3.x/art/preview.png" width="100%" />](https://relaticle.github.io/custom-fields)
Let users add custom fields to any model without code changes.
[Learn more ->](https://relaticle.github.io/custom-fields)
</td>
</tr>
</table>
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
MIT License. See [LICENSE](LICENSE) for details.

BIN
art/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

View File

@@ -1,19 +1,29 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Mentions\DefaultMentionResolver;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Policies\CommentPolicy;
return [
'tables' => [
'comments' => 'comments',
],
'models' => [
'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' => [
'model' => User::class,
],

View File

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

View File

@@ -8,10 +8,10 @@ return new class extends Migration
{
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->foreignId('comment_id')
->constrained(config('comments.tables.comments', 'comments'))
->constrained(config('comments.table_names.comments', 'comments'))
->cascadeOnDelete();
$table->string('file_path');
$table->string('original_name');

View File

@@ -8,15 +8,15 @@ return new class extends Migration
{
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->foreignId('comment_id')
->constrained(config('comments.tables.comments', 'comments'))
->constrained(config('comments.table_names.comments', 'comments'))
->cascadeOnDelete();
$table->morphs('user');
$table->morphs('commenter');
$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
{
Schema::create('comment_reactions', function (Blueprint $table) {
Schema::create(config('comments.table_names.reactions', 'comment_reactions'), function (Blueprint $table) {
$table->id();
$table->foreignId('comment_id')
->constrained(config('comments.tables.comments', 'comments'))
->constrained(config('comments.table_names.comments', 'comments'))
->cascadeOnDelete();
$table->morphs('user');
$table->morphs('commenter');
$table->string('reaction');
$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
{
Schema::create('comment_subscriptions', function (Blueprint $table) {
Schema::create(config('comments.table_names.subscriptions', 'comment_subscriptions'), function (Blueprint $table) {
$table->id();
$table->morphs('commentable');
$table->morphs('user');
$table->morphs('commenter');
$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
{
Schema::create(config('comments.tables.comments', 'comments'), function (Blueprint $table) {
Schema::create(config('comments.table_names.comments', 'comments'), function (Blueprint $table) {
$table->id();
$table->morphs('commentable');
$table->morphs('user');
$table->morphs('commenter');
$table->foreignId('parent_id')
->nullable()
->constrained(config('comments.tables.comments', 'comments'))
->constrained(config('comments.table_names.comments', 'comments'))
->cascadeOnDelete();
$table->text('body');
$table->timestamp('edited_at')->nullable();

5
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.output
.nuxt
dist
.env

64
docs/app.config.ts Normal file
View File

@@ -0,0 +1,64 @@
export default defineAppConfig({
docus: {
title: 'Comments',
description: 'A full-featured commenting system for Filament panels with threaded replies, @mentions, emoji reactions, and real-time updates.',
header: {
logo: {
alt: 'Comments Logo',
}
}
},
seo: {
title: 'Comments',
description: 'A full-featured commenting system for Filament panels with threaded replies, @mentions, emoji reactions, and real-time updates.',
},
github: {
repo: 'comments',
owner: 'Relaticle',
edit: true,
rootDir: 'docs'
},
socials: {
discord: 'https://discord.gg/b9WxzUce4Q'
},
ui: {
colors: {
primary: 'violet',
neutral: 'zinc'
}
},
uiPro: {
pageHero: {
slots: {
container: 'flex flex-col lg:grid py-16 sm:py-20 lg:py-24 gap-16 sm:gap-y-2'
}
}
},
toc: {
title: 'On this page',
bottom: {
title: 'Ecosystem',
edit: 'https://github.com/Relaticle/comments',
links: [
{
icon: 'i-simple-icons-laravel',
label: 'FilaForms',
to: 'https://filaforms.app',
target: '_blank'
},
{
icon: 'i-lucide-sliders',
label: 'Custom Fields',
to: 'https://relaticle.github.io/custom-fields',
target: '_blank'
},
{
icon: 'i-lucide-kanban',
label: 'Flowforge',
to: 'https://relaticle.github.io/flowforge',
target: '_blank'
}
]
}
}
})

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

@@ -0,0 +1,2 @@
title: Getting Started
icon: false

View File

@@ -0,0 +1,112 @@
---
title: Installation
description: Get started with Comments in minutes.
navigation:
icon: i-lucide-download
seo:
description: Install Comments and add commenting to your Filament resources.
ogImage: /preview.png
---
## Requirements
- **PHP:** 8.2+
- **Laravel:** 12+
- **Filament:** 4.x / 5.x
- **Livewire:** 3.5+ / 4.x
## Quick Setup
::steps
### Install Package
```bash [Terminal]
composer require relaticle/comments
```
### Publish and Run Migrations
```bash [Terminal]
php artisan vendor:publish --tag=comments-migrations
php artisan migrate
```
### Include CSS Assets
Prerequisite: You need a custom Filament theme to include the Comments styles.
::alert{type="warning"}
If you haven't set up a custom theme for Filament, follow the [Filament Docs](https://filamentphp.com/docs/5.x/styling/overview#creating-a-custom-theme) first.
::
Add the plugin's views to your theme CSS file:
```css [resources/css/filament/admin/theme.css]
@source "../../../../vendor/relaticle/comments/resources/views/**/*.blade.php";
```
### Register the Plugin
```php [AdminPanelProvider.php]
use Relaticle\Comments\CommentsPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugins([
CommentsPlugin::make(),
]);
}
```
### Set Up Your Models
Add the `HasComments` trait to any model you want to comment on:
```php [app/Models/Project.php]
use Relaticle\Comments\Concerns\HasComments;
use Relaticle\Comments\Contracts\Commentable;
class Project extends Model implements Commentable
{
use HasComments;
}
```
Add the `CanComment` trait to your User model:
```php [app/Models/User.php]
use Relaticle\Comments\Concerns\CanComment;
use Relaticle\Comments\Contracts\Commentator;
class User extends Authenticatable implements Commentator
{
use CanComment;
}
```
### Add to Your Resources
Use the slide-over action on view or edit pages:
```php [app/Filament/Resources/ProjectResource/Pages/ViewProject.php]
use Relaticle\Comments\Filament\Actions\CommentsAction;
protected function getHeaderActions(): array
{
return [
CommentsAction::make(),
];
}
```
::
**Done!** Visit your Filament panel to see comments in action.
## Optional Configuration
| Command | Action |
|---------|--------|
| `php artisan vendor:publish --tag=comments-config` | Publish the configuration file |
| `php artisan vendor:publish --tag=comments-views` | Publish the Blade views for customization |
| `php artisan vendor:publish --tag=comments-translations` | Publish the translation files |

View File

@@ -0,0 +1,12 @@
---
title: Upgrading
description: Upgrade guide for Comments.
navigation:
icon: i-lucide-arrow-up-circle
seo:
description: How to upgrade Comments between versions.
---
## 1.x
This is the initial release of Comments. Future upgrade guides will be documented here as new versions are released.

View File

@@ -0,0 +1 @@
title: Essentials

View File

@@ -0,0 +1,202 @@
---
title: Configuration
description: Configure threading, reactions, mentions, attachments, notifications, and more.
navigation:
icon: i-lucide-settings
seo:
description: Complete configuration reference for the Comments package.
---
Publish the configuration file:
```bash
php artisan vendor:publish --tag=comments-config
```
This creates `config/comments.php` with all available options.
## Table Names
```php
'table_names' => [
'comments' => 'comments',
'reactions' => 'comment_reactions',
'mentions' => 'comment_mentions',
'subscriptions' => 'comment_subscriptions',
'attachments' => 'comment_attachments',
],
```
Change the table names if they conflict with your application.
## Column Names
```php
'column_names' => [
'commenter_id' => 'commenter_id',
'commenter_type' => 'commenter_type',
],
```
## Models
```php
'models' => [
'comment' => \Relaticle\Comments\Models\Comment::class,
],
'commenter' => [
'model' => \App\Models\User::class,
],
```
Override the Comment model to add custom behavior. The commenter model defines which class represents the user who comments.
## Policy
```php
'policy' => \Relaticle\Comments\Policies\CommentPolicy::class,
```
See the [Authorization](/essentials/authorization) page for customization details.
## Threading
```php
'threading' => [
'max_depth' => 2,
],
```
Controls how many levels of nested replies are allowed. A depth of `2` means top-level comments and one level of replies. Set to `1` to disable replies entirely.
## Pagination
```php
'pagination' => [
'per_page' => 10,
],
```
Number of comments loaded initially and per "Load More" click.
## Reactions
```php
'reactions' => [
'emoji_set' => [
'thumbs_up' => "\u{1F44D}",
'heart' => "\u{2764}\u{FE0F}",
'celebrate' => "\u{1F389}",
'laugh' => "\u{1F604}",
'thinking' => "\u{1F914}",
'sad' => "\u{1F622}",
],
],
```
Customize the available emoji reactions. Keys are used as identifiers in the database, values are the displayed emoji characters.
## Mentions
```php
'mentions' => [
'resolver' => \Relaticle\Comments\Mentions\DefaultMentionResolver::class,
'max_results' => 5,
],
```
The resolver handles searching for users during @mention autocomplete. See the [Mentions](/essentials/mentions) page for creating a custom resolver.
## Editor Toolbar
```php
'editor' => [
'toolbar' => [
['bold', 'italic', 'strike', 'link'],
['bulletList', 'orderedList'],
['codeBlock'],
],
],
```
Defines which formatting buttons appear in the comment editor. Groups create visual separators in the toolbar.
## Notifications
```php
'notifications' => [
'channels' => ['database'],
'enabled' => true,
],
```
Add `'mail'` to the channels array to send email notifications. Set `enabled` to `false` to disable all notifications.
## Subscriptions
```php
'subscriptions' => [
'auto_subscribe' => true,
],
```
When enabled, users are automatically subscribed to a thread when they create a comment or are mentioned. They receive notifications for subsequent replies.
## Attachments
```php
'attachments' => [
'enabled' => true,
'disk' => 'public',
'max_size' => 10240, // KB
'allowed_types' => [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
],
```
Controls file upload behavior. Set `enabled` to `false` to remove the attachment UI entirely. The `max_size` is in kilobytes (default 10 MB).
## Broadcasting
```php
'broadcasting' => [
'enabled' => false,
'channel_prefix' => 'comments',
],
```
When enabled, comment events are broadcast on private channels using the format `{prefix}.{commentable_type}.{commentable_id}`. Requires Laravel Echo and a broadcasting driver.
## Polling
```php
'polling' => [
'interval' => '10s',
],
```
When broadcasting is disabled, the Livewire component polls for new comments at this interval. Set to `null` to disable polling.
## Custom User Resolution
Override how the authenticated user is resolved:
```php
use Relaticle\Comments\CommentsConfig;
// In AppServiceProvider::boot()
CommentsConfig::resolveAuthenticatedUserUsing(function () {
return auth()->user();
});
```
This is useful for multi-guard applications or custom authentication flows.

View File

@@ -0,0 +1,74 @@
---
title: Authorization
description: Control who can create, edit, delete, and reply to comments.
navigation:
icon: i-lucide-shield
seo:
description: Configure comment authorization policies.
---
## Default Policy
The built-in `CommentPolicy` provides sensible defaults:
| Method | Default | Description |
|--------|---------|-------------|
| `viewAny()` | `true` | Everyone can view comments |
| `create()` | `true` | Everyone can create comments |
| `update()` | Owner only | Only the comment author can edit |
| `delete()` | Owner only | Only the comment author can delete |
| `reply()` | Depth check | Can reply if `max_depth` not exceeded |
## Custom Policy
Create your own policy to customize authorization:
```php
namespace App\Policies;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Contracts\Commentator;
class CustomCommentPolicy
{
public function viewAny(Commentator $user): bool
{
return true;
}
public function create(Commentator $user): bool
{
return true;
}
public function update(Commentator $user, Comment $comment): bool
{
return $comment->commenter_id === $user->getKey()
&& $comment->commenter_type === $user->getMorphClass();
}
public function delete(Commentator $user, Comment $comment): bool
{
return $comment->commenter_id === $user->getKey()
|| $user->hasRole('admin');
}
public function reply(Commentator $user, Comment $comment): bool
{
return $comment->canReply();
}
}
```
Register it in your config:
```php
// config/comments.php
'policy' => App\Policies\CustomCommentPolicy::class,
```
## How Authorization Works
The Livewire components check the policy before rendering action buttons. Edit and delete buttons only appear for authorized users. Reply buttons are hidden when the thread has reached the configured `max_depth`.
The policy is registered automatically by the service provider using Laravel's Gate system.

View File

@@ -0,0 +1,74 @@
---
title: Mentions
description: User @mentions with autocomplete and notification support.
navigation:
icon: i-lucide-at-sign
seo:
description: Configure @mention autocomplete and create custom mention resolvers.
---
## How Mentions Work
Type `@` in the comment editor to trigger user autocomplete. Select a user to insert a mention. When the comment is saved, the `MentionParser` extracts mentions and:
1. Syncs mention records in the `comment_mentions` table
2. Dispatches a `UserMentioned` event for each newly mentioned user
3. The `SendUserMentionedNotification` listener sends notifications
4. If auto-subscribe is enabled, mentioned users are subscribed to the thread
## Default Resolver
The `DefaultMentionResolver` searches the commenter model by name:
```php
// Searches: User::where('name', 'like', "{$query}%")
// Limited to: config('comments.mentions.max_results') results
```
## Custom Mention Resolver
Implement the `MentionResolver` interface to customize user search behavior:
```php
namespace App\Comments;
use Illuminate\Support\Collection;
use Relaticle\Comments\Contracts\MentionResolver;
class TeamMentionResolver implements MentionResolver
{
public function search(string $query): Collection
{
return User::query()
->where('team_id', auth()->user()->team_id)
->where('name', 'like', "{$query}%")
->limit(config('comments.mentions.max_results'))
->get();
}
public function resolveByNames(array $names): Collection
{
return User::query()
->where('team_id', auth()->user()->team_id)
->whereIn('name', $names)
->get();
}
}
```
Register it in your config:
```php
// config/comments.php
'mentions' => [
'resolver' => App\Comments\TeamMentionResolver::class,
'max_results' => 5,
],
```
## Configuration
| Key | Default | Description |
|-----|---------|-------------|
| `mentions.resolver` | `DefaultMentionResolver::class` | User search implementation |
| `mentions.max_results` | `5` | Maximum autocomplete results |

View File

@@ -0,0 +1,51 @@
---
title: Reactions
description: Emoji reactions on comments.
navigation:
icon: i-lucide-smile
seo:
description: Configure emoji reactions for comments.
---
## Default Reactions
Six emoji reactions are available out of the box:
| Key | Emoji | Label |
|-----|-------|-------|
| `thumbs_up` | :thumbsup: | Like |
| `heart` | :heart: | Love |
| `celebrate` | :tada: | Celebrate |
| `laugh` | :smile: | Laugh |
| `thinking` | :thinking: | Thinking |
| `sad` | :cry: | Sad |
## How Reactions Work
- Each user can add one reaction of each type per comment
- Clicking the same reaction again removes it (toggle behavior)
- The reaction summary shows which users reacted with each emoji
- A `CommentReacted` event is dispatched with `action: 'added'` or `'removed'`
## Customizing Reactions
Override the emoji set in your config:
```php
// config/comments.php
'reactions' => [
'emoji_set' => [
'thumbs_up' => "\u{1F44D}",
'thumbs_down' => "\u{1F44E}",
'heart' => "\u{2764}\u{FE0F}",
'fire' => "\u{1F525}",
'eyes' => "\u{1F440}",
],
],
```
Keys are stored in the database. If you change a key, existing reactions with the old key will no longer display.
## Storage
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

@@ -0,0 +1,72 @@
---
title: Attachments
description: File uploads for comments.
navigation:
icon: i-lucide-paperclip
seo:
description: Configure file attachments for comments.
---
## Overview
Comments support file attachments for both images and documents. Images are displayed inline within the comment body, while documents appear as downloadable links.
## Configuration
```php
// config/comments.php
'attachments' => [
'enabled' => true,
'disk' => 'public',
'max_size' => 10240, // KB (10 MB)
'allowed_types' => [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
],
```
| Key | Default | Description |
|-----|---------|-------------|
| `enabled` | `true` | Show/hide the attachment upload UI |
| `disk` | `'public'` | Laravel filesystem disk for storage |
| `max_size` | `10240` | Maximum file size in kilobytes |
| `allowed_types` | images, pdf, text, word | Array of allowed MIME types |
## Disabling Attachments
```php
'attachments' => [
'enabled' => false,
],
```
This removes the file upload UI from the comment form entirely.
## Storage
Attachments are stored via Livewire's file upload mechanism. Each attachment record tracks:
- `file_path` -- Path on the configured disk
- `original_name` -- Original filename for display
- `mime_type` -- MIME type for rendering decisions
- `size` -- File size in bytes
- `disk` -- Storage disk name
When a comment is deleted, its attachments are cascade deleted from the database. The physical files are removed from the disk.
## Helper Methods
The `Attachment` model (`Relaticle\Comments\Models\Attachment`) provides:
```php
$attachment->isImage(); // Check if attachment is an image
$attachment->url(); // Get the storage URL
$attachment->formattedSize(); // Human-readable size (e.g., "2.5 MB")
```

View File

@@ -0,0 +1,125 @@
---
title: Notifications
description: Comment notifications, subscriptions, and real-time updates.
navigation:
icon: i-lucide-bell
seo:
description: Configure comment notifications, subscriptions, broadcasting, and polling.
---
## Notification Types
Two notification classes are included:
### CommentRepliedNotification
Sent to all thread subscribers when a new comment or reply is posted. The comment author is excluded from receiving their own notification.
### UserMentionedNotification
Sent to a user when they are @mentioned in a comment. Self-mentions are ignored.
## Channels
```php
// config/comments.php
'notifications' => [
'channels' => ['database'],
'enabled' => true,
],
```
Available channels: `'database'` and `'mail'`. Add both to send email notifications alongside database notifications:
```php
'notifications' => [
'channels' => ['database', 'mail'],
'enabled' => true,
],
```
## Subscriptions
Users can subscribe to comment threads on any commentable model. Subscribers receive notifications when new comments are posted.
### Auto-Subscribe
```php
'subscriptions' => [
'auto_subscribe' => true,
],
```
When enabled:
- Users are auto-subscribed when they post a comment
- Users are auto-subscribed when they are @mentioned
### Manual Subscription
Users can toggle their subscription using the subscribe/unsubscribe button in the comments UI.
### Programmatic Access
```php
use Relaticle\Comments\Models\Subscription;
// Check subscription status
Subscription::isSubscribed($commentable, $user);
// Subscribe/unsubscribe
Subscription::subscribe($commentable, $user);
Subscription::unsubscribe($commentable, $user);
// Get all subscribers for a commentable
$subscribers = Subscription::subscribersFor($commentable);
```
## Events
| Event | Trigger | Broadcasts |
|-------|---------|------------|
| `CommentCreated` | New comment or reply | Yes |
| `CommentUpdated` | Comment edited | Yes |
| `CommentDeleted` | Comment soft-deleted | Yes |
| `CommentReacted` | Reaction added/removed | Yes |
| `UserMentioned` | User @mentioned | No |
## Real-time Updates
### Broadcasting
Enable broadcasting for instant updates across browser sessions:
```php
// config/comments.php
'broadcasting' => [
'enabled' => true,
'channel_prefix' => 'comments',
],
```
Events are broadcast on private channels: `{prefix}.{commentable_type}.{commentable_id}`
This requires Laravel Echo and a broadcasting driver (Pusher, Ably, etc.) configured in your application.
### Polling Fallback
When broadcasting is disabled, the Livewire component polls for updates:
```php
'polling' => [
'interval' => '10s',
],
```
Set to `null` to disable polling entirely.
## Disabling Notifications
```php
'notifications' => [
'enabled' => false,
],
```
This disables all notification dispatching. Subscriptions and events still work, but no notifications are sent.

View File

@@ -0,0 +1,107 @@
---
title: Database Schema
description: Tables, relationships, and indexes used by the Comments package.
navigation:
icon: i-lucide-database
seo:
description: Database schema reference for the Comments package.
---
## Tables
Five tables are created by the package migrations.
### comments
The main comments table with polymorphic relationships and threading support.
| Column | Type | Description |
|--------|------|-------------|
| `id` | bigint | Primary key |
| `commentable_type` | string | Polymorphic model type |
| `commentable_id` | bigint | Polymorphic model ID |
| `commenter_type` | string | Commenter model type |
| `commenter_id` | bigint | Commenter model ID |
| `parent_id` | bigint (nullable) | Parent comment for replies |
| `body` | text | HTML comment content |
| `edited_at` | timestamp (nullable) | When the comment was last edited |
| `deleted_at` | timestamp (nullable) | Soft delete timestamp |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |
**Indexes:** `(commentable_type, commentable_id, parent_id)`
### comment_reactions
Tracks emoji reactions per user per comment.
| Column | Type | Description |
|--------|------|-------------|
| `id` | bigint | Primary key |
| `comment_id` | bigint | Foreign key to comments |
| `commenter_type` | string | Reactor model type |
| `commenter_id` | bigint | Reactor model ID |
| `reaction` | string | Reaction key (e.g., `thumbs_up`) |
| `created_at` | timestamp | |
**Unique constraint:** `(comment_id, commenter_id, commenter_type, reaction)`
### comment_mentions
Tracks @mentioned users per comment.
| Column | Type | Description |
|--------|------|-------------|
| `id` | bigint | Primary key |
| `comment_id` | bigint | Foreign key to comments |
| `commenter_type` | string | Mentioned user model type |
| `commenter_id` | bigint | Mentioned user model ID |
| `created_at` | timestamp | |
**Unique constraint:** `(comment_id, commenter_id, commenter_type)`
### comment_subscriptions
Tracks which users are subscribed to comment threads on specific models.
| Column | Type | Description |
|--------|------|-------------|
| `id` | bigint | Primary key |
| `commentable_type` | string | Subscribed model type |
| `commentable_id` | bigint | Subscribed model ID |
| `commenter_type` | string | Subscriber model type |
| `commenter_id` | bigint | Subscriber model ID |
| `created_at` | timestamp | |
**Unique constraint:** `(commentable_type, commentable_id, commenter_type, commenter_id)`
### comment_attachments
Stores file attachment metadata for comments.
| Column | Type | Description |
|--------|------|-------------|
| `id` | bigint | Primary key |
| `comment_id` | bigint | Foreign key to comments |
| `file_path` | string | Path on the storage disk |
| `original_name` | string | Original uploaded filename |
| `mime_type` | string | File MIME type |
| `size` | bigint | File size in bytes |
| `disk` | string | Laravel filesystem disk |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |
## Relationships
```
Commentable Model (e.g., Project)
└── comments (morphMany)
├── commenter (morphTo → User)
├── parent (belongsTo → Comment)
├── replies (hasMany → Comment)
├── reactions (hasMany → Reaction)
├── attachments (hasMany → Attachment)
└── mentions (morphToMany → User)
```
All relationships are polymorphic, allowing the same comment system to work across any number of models in your application.

View File

@@ -0,0 +1 @@
title: Community

View File

@@ -0,0 +1,39 @@
---
title: Contributing
description: How to contribute to Comments
navigation:
icon: i-lucide-heart-handshake
---
## Quick Start
1. **Fork** the repository
2. **Create** a feature branch
3. **Make** your changes
4. **Run** tests: `composer test`
5. **Submit** a pull request
## Guidelines
- Follow the existing code style
- Add tests for new features
- Update documentation as needed
- One feature per pull request
## Development Commands
```bash
# Run tests
composer test
# Format code
composer pint
# Static analysis
composer analyse
```
## Need Help?
- [Open an issue](https://github.com/relaticle/comments/issues) for bugs or questions
- Check [existing issues](https://github.com/relaticle/comments/issues) first

View File

@@ -0,0 +1,39 @@
---
title: License
description: MIT License terms and what it means for you
navigation:
icon: i-lucide-scale
---
## MIT License
```
Copyright (c) Relaticle
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
## What This Means
You **can** use Comments in commercial projects.
You **can** modify and distribute it.
You **can** use it in closed source projects.
You **can** sell applications that include it.
Just include the license notice in your copy.

155
docs/content/index.md Normal file
View File

@@ -0,0 +1,155 @@
---
seo:
title: Filament Comments System
description: A full-featured commenting system for Filament panels with threaded replies, @mentions, emoji reactions, file attachments, and real-time updates.
ogImage: /og-image.png
---
::u-page-hero
#title
Comments
#description
A full-featured commenting system for Filament panels with threaded replies, @mentions, emoji reactions, and real-time updates.
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
:::u-button
---
color: neutral
size: xl
to: /getting-started/installation
trailing-icon: i-lucide-arrow-right
---
Get started
:::
:::u-button
---
color: neutral
icon: simple-icons:github
size: xl
to: https://github.com/relaticle/comments
variant: outline
---
GitHub
:::
::
<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
#title
Why choose Comments?
#features
:::u-page-feature
---
icon: i-lucide-messages-square
---
#title
Threaded Replies
#description
Nested comment threads with configurable depth limits. Users can reply to specific comments creating organized discussions.
:::
:::u-page-feature
---
icon: i-lucide-at-sign
---
#title
@Mentions
#description
Autocomplete user mentions with a customizable resolver interface. Dispatches events for notification handling.
:::
:::u-page-feature
---
icon: i-lucide-smile
---
#title
Emoji Reactions
#description
Six built-in emoji reactions with a configurable set. Users can react to comments with a single click.
:::
:::u-page-feature
---
icon: i-lucide-paperclip
---
#title
File Attachments
#description
Upload images and documents to comments with configurable storage, size limits, and MIME type validation.
:::
:::u-page-feature
---
icon: i-lucide-radio
---
#title
Real-time Updates
#description
Optional broadcasting via private channels with automatic polling fallback. Comments stay in sync across sessions.
:::
:::u-page-feature
---
icon: i-lucide-puzzle
---
#title
Full Filament Integration
#description
Three integration patterns: slide-over action, table row action, and infolist entry. Works with any Filament resource.
:::
::
::u-page-section
#title
Our Ecosystem
#description
Extend your Laravel applications with our ecosystem of complementary tools
#default
::card-group
:::card
---
title: FilaForms
icon: i-simple-icons-laravel
to: https://filaforms.app
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.
:::
:::card
---
title: Custom Fields
icon: i-lucide-sliders
to: https://relaticle.github.io/custom-fields
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.
:::
::
::

69
docs/nuxt.config.ts Normal file
View File

@@ -0,0 +1,69 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
const baseURL = process.env.NUXT_APP_BASE_URL || '/'
const docsVersion = process.env.DOCS_VERSION || '1.x'
export default defineNuxtConfig({
extends: 'docus',
modules: ['@nuxt/image', '@nuxt/scripts', 'nuxt-fathom'],
fathom: {
siteId: process.env.NUXT_PUBLIC_FATHOM_SITE_ID || '',
},
devtools: { enabled: true },
site: {
name: 'Comments',
},
runtimeConfig: {
public: {
docsVersion,
},
},
appConfig: {
docus: {
url: `https://relaticle.github.io${baseURL}`,
image: `${baseURL}preview.png`,
header: {
logo: {
light: `${baseURL}logo-light.svg`,
dark: `${baseURL}logo-dark.svg`,
},
},
},
seo: {
ogImage: `${baseURL}preview.png`,
},
github: {
branch: docsVersion,
},
},
app: {
baseURL,
buildAssetsDir: 'assets',
head: {
link: [
{
rel: 'icon',
type: 'image/x-icon',
href: baseURL + 'favicon.ico',
},
],
},
},
image: {
provider: 'none',
},
content: {
build: {
markdown: {
highlight: {
langs: ['php', 'blade', 'bash', 'json'],
},
},
},
},
llms: {
domain: `https://relaticle.github.io${baseURL.replace(/\/$/, '')}`,
},
nitro: {
preset: 'github_pages',
},
})

23204
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
docs/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "comments-docs",
"scripts": {
"dev": "nuxt dev --extends docus",
"build": "nuxt build --extends docus",
"generate": "nuxt build --preset github_pages",
"preview": "nuxt preview"
},
"dependencies": {
"@nuxt/image": "^1.11.0",
"@nuxt/scripts": "^0.12.1",
"@nuxt/ui": "^4.0.0",
"@unhead/vue": "^2.0.17",
"better-sqlite3": "^12.4.1",
"docus": "latest",
"nuxt": "^4.1.2",
"typescript": "^5.9.2"
},
"devDependencies": {
"nuxt-fathom": "^0.0.3"
}
}

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

@@ -0,0 +1,285 @@
---
name: comments-development
description: Full-featured commenting system for Filament panels with polymorphic comments, threaded replies, @mentions, emoji reactions, file attachments, and real-time notifications. Use when adding the HasComments trait to models, integrating CommentsAction/CommentsTableAction/CommentsEntry in Filament, configuring threading, reactions, mentions, attachments, notifications, broadcasting, or customizing the CommentPolicy.
---
# Comments Development
## When to Use This Skill
Use when:
- Adding commenting capability to an Eloquent model
- Integrating comments into Filament resources (actions, table actions, infolists)
- Configuring threading depth, reactions, mentions, or attachments
- Working with comment notifications and subscriptions
- Customizing the CommentPolicy for authorization
- Implementing real-time updates via broadcasting or polling
- Creating a custom MentionResolver
## Quick Start
### 1. Add Traits to Models
```php
use Relaticle\Comments\Concerns\HasComments;
use Relaticle\Comments\Contracts\Commentable;
class Project extends Model implements Commentable
{
use HasComments;
}
```
```php
use Relaticle\Comments\Concerns\CanComment;
use Relaticle\Comments\Contracts\Commentator;
class User extends Authenticatable implements Commentator
{
use CanComment;
}
```
### 2. Register Plugin in Panel
```php
use Relaticle\Comments\CommentsPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugins([
CommentsPlugin::make(),
]);
}
```
### 3. Publish and Run Migrations
```bash
php artisan vendor:publish --tag=comments-migrations
php artisan migrate
```
## Filament Integration
### Slide-Over Action (View/Edit Pages)
```php
use Relaticle\Comments\Filament\Actions\CommentsAction;
protected function getHeaderActions(): array
{
return [
CommentsAction::make(),
];
}
```
Shows a comment count badge and opens a slide-over modal with the full comment thread.
### Table Row Action
```php
use Relaticle\Comments\Filament\Actions\CommentsTableAction;
public static function table(Table $table): Table
{
return $table
->actions([
CommentsTableAction::make(),
]);
}
```
### Infolist Entry
```php
use Relaticle\Comments\Filament\Infolists\Components\CommentsEntry;
public static function infolist(Infolist $infolist): Infolist
{
return $infolist->schema([
CommentsEntry::make('comments'),
]);
}
```
Embeds the full comment component inline within an infolist.
## Configuration Reference
Publish config: `php artisan vendor:publish --tag=comments-config`
| Key | Default | Purpose |
|-----|---------|---------|
| `table_names.comments` | `'comments'` | Main comments table name |
| `models.comment` | `Comment::class` | Comment model class |
| `commenter.model` | `User::class` | Commenter (user) model class |
| `policy` | `CommentPolicy::class` | Authorization policy class |
| `threading.max_depth` | `2` | Maximum reply nesting depth |
| `pagination.per_page` | `10` | Comments per page |
| `reactions.emoji_set` | 6 emojis | Available reaction emojis |
| `mentions.resolver` | `DefaultMentionResolver::class` | User search resolver |
| `mentions.max_results` | `5` | Autocomplete results limit |
| `editor.toolbar` | bold, italic, strike, link, lists, codeBlock | Rich text toolbar buttons |
| `notifications.enabled` | `true` | Enable/disable notifications |
| `notifications.channels` | `['database']` | Notification channels |
| `subscriptions.auto_subscribe` | `true` | Auto-subscribe on comment/mention |
| `attachments.enabled` | `true` | Enable file attachments |
| `attachments.disk` | `'public'` | Storage disk |
| `attachments.max_size` | `10240` | Max file size (KB) |
| `attachments.allowed_types` | images, pdf, text, word | Allowed MIME types |
| `broadcasting.enabled` | `false` | Enable real-time broadcasting |
| `broadcasting.channel_prefix` | `'comments'` | Private channel prefix |
| `polling.interval` | `'10s'` | Livewire polling interval (fallback) |
### Default Reactions
```php
'reactions' => [
'emoji_set' => [
'thumbs_up' => ['emoji' => "\u{1F44D}", 'label' => 'Like'],
'heart' => ['emoji' => "\u{2764}\u{FE0F}", 'label' => 'Love'],
'celebrate' => ['emoji' => "\u{1F389}", 'label' => 'Celebrate'],
'laugh' => ['emoji' => "\u{1F602}", 'label' => 'Laugh'],
'thinking' => ['emoji' => "\u{1F914}", 'label' => 'Thinking'],
'sad' => ['emoji' => "\u{1F622}", 'label' => 'Sad'],
],
],
```
## Events
| Event | Broadcast | Payload |
|-------|-----------|---------|
| `CommentCreated` | Yes | `$comment` |
| `CommentUpdated` | Yes | `$comment` |
| `CommentDeleted` | Yes | `$comment` |
| `CommentReacted` | Yes | `$comment`, `$user`, `$reaction`, `$action` (added/removed) |
| `UserMentioned` | No | `$comment`, `$mentionedUser` |
Broadcasting uses private channels: `{prefix}.{commentable_type}.{commentable_id}`
## Authorization
Default `CommentPolicy` methods:
| Method | Default | Description |
|--------|---------|-------------|
| `viewAny()` | `true` | Everyone can view comments |
| `create()` | `true` | Everyone can create comments |
| `update()` | Owner only | Only comment author can edit |
| `delete()` | Owner only | Only comment author can delete |
| `reply()` | Depth check | Can reply if max_depth not exceeded |
### Custom Policy
```php
// config/comments.php
'policy' => App\Policies\CustomCommentPolicy::class,
```
```php
namespace App\Policies;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Contracts\Commentator;
class CustomCommentPolicy
{
public function delete(Commentator $user, Comment $comment): bool
{
return $comment->commenter_id === $user->getKey()
|| $user->hasRole('admin');
}
}
```
## Common Patterns
### Scoped Comments (Multi-tenancy)
```php
use Relaticle\Comments\CommentsConfig;
// In AppServiceProvider::boot()
CommentsConfig::resolveAuthenticatedUserUsing(function () {
return auth()->user();
});
```
### Custom Mention Resolver
```php
use Relaticle\Comments\Contracts\MentionResolver;
class TeamMentionResolver implements MentionResolver
{
public function search(string $query): Collection
{
return User::query()
->where('team_id', auth()->user()->team_id)
->where('name', 'like', "{$query}%")
->limit(config('comments.mentions.max_results'))
->get();
}
public function resolveByNames(array $names): Collection
{
return User::query()
->where('team_id', auth()->user()->team_id)
->whereIn('name', $names)
->get();
}
}
```
Register in config:
```php
// config/comments.php
'mentions' => [
'resolver' => App\Comments\TeamMentionResolver::class,
],
```
### Enable Broadcasting
```php
// config/comments.php
'broadcasting' => [
'enabled' => true,
'channel_prefix' => 'comments',
],
```
When broadcasting is enabled, the Livewire component listens for real-time events. When disabled, it falls back to polling at the configured interval.
## Database Schema
Five tables are created:
- `comments` -- Polymorphic comments with parent_id for threading, soft deletes
- `comment_reactions` -- Unique reactions per user+comment+emoji
- `comment_mentions` -- Tracks @mentioned users per comment
- `comment_subscriptions` -- Thread subscription tracking per user+commentable
- `comment_attachments` -- File metadata (path, name, MIME type, size, disk)
## Model Relationships
```php
// On any Commentable model
$model->comments(); // All comments (morphMany)
$model->topLevelComments(); // Top-level only (no parent)
$model->commentCount(); // Total count
// On Comment model
$comment->commentable(); // Parent model (morphTo)
$comment->commenter(); // Commenter (morphTo)
$comment->parent(); // Parent comment (belongsTo)
$comment->replies(); // Child comments (hasMany)
$comment->reactions(); // Reactions (hasMany)
$comment->attachments(); // File attachments (hasMany)
$comment->mentions(); // Mentioned users (morphToMany)
```

View File

@@ -1,9 +1,58 @@
<?php
return [
'deleted_comment' => 'This comment was deleted.',
'edited' => 'edited',
'load_more' => 'Load more comments',
'no_comments' => 'No comments yet.',
'comment_placeholder' => 'Write a comment...',
'comments' => [
'deleted' => 'This comment was deleted.',
'edited' => 'edited',
'no_comments' => 'No comments yet.',
'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">
@if ($comment->trashed())
<div class="h-8 w-8 rounded-full bg-gray-200 dark:bg-gray-700"></div>
@elseif ($comment->user?->getCommentAvatarUrl())
<img src="{{ $comment->user->getCommentAvatarUrl() }}" alt="{{ $comment->user->getCommentName() }}" class="h-8 w-8 rounded-full object-cover">
@elseif ($comment->commenter?->getCommentAvatarUrl())
<img src="{{ $comment->commenter->getCommentAvatarUrl() }}" alt="{{ $comment->commenter->getCommentDisplayName() }}" class="h-8 w-8 rounded-full object-cover">
@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">
{{ str($comment->user?->getCommentName() ?? '?')->substr(0, 1)->upper() }}
{{ str($comment->commenter?->getCommentDisplayName() ?? '?')->substr(0, 1)->upper() }}
</div>
@endif
</div>
@@ -20,7 +20,7 @@
{{-- Header: name + timestamp --}}
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $comment->user?->getCommentName() ?? 'Unknown' }}
{{ $comment->commenter?->getCommentDisplayName() ?? 'Unknown' }}
</span>
<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() }}
@@ -32,18 +32,13 @@
{{-- Body or edit form --}}
@if ($isEditing)
<form wire:submit="saveEdit" class="mt-1">
<textarea wire:model="editBody" rows="3"
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"
></textarea>
@error('editBody')
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
@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 class="mt-1">
{{ $this->editForm }}
<div class="mt-2 flex items-center justify-between">
<button type="button" wire:click="cancelEdit" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
<button type="button" wire:click="saveEdit" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">Save</button>
</div>
</form>
</div>
@else
<div class="fi-prose prose prose-sm mt-1 max-w-none text-gray-700 dark:prose-invert dark:text-gray-300">
{!! $comment->renderBodyWithMentions() !!}
@@ -109,133 +104,44 @@
{{-- Reply form --}}
@if ($isReplying)
<form wire:submit="addReply" class="relative mt-3"
x-data="{
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.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>
<div class="mt-3">
{{ $this->replyForm }}
{{-- 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>
@if (!empty($replyAttachments))
<div class="mt-2 flex flex-wrap gap-2">
@foreach ($replyAttachments as $index => $file)
<div class="flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300">
<span>{{ $file->getClientOriginalName() }}</span>
<button type="button" wire:click="removeReplyAttachment({{ $index }})" class="text-gray-400 hover:text-danger-500 dark:text-gray-500 dark:hover:text-danger-400">&times;</button>
</div>
@endforeach
</div>
@if (!empty($replyAttachments))
<div class="mt-2 flex flex-wrap gap-2">
@foreach ($replyAttachments as $index => $file)
<div class="flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300">
<span>{{ $file->getClientOriginalName() }}</span>
<button type="button" wire:click="removeReplyAttachment({{ $index }})" class="text-gray-400 hover:text-danger-500 dark:text-gray-500 dark:hover:text-danger-400">&times;</button>
</div>
@endforeach
</div>
@endif
@error('replyAttachments.*')
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
@enderror
@endif
<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">Reply</button>
<button type="button" wire:click="cancelReply" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center gap-3">
@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>
</form>
</div>
@endif
{{-- 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">
@foreach ($comment->replies as $reply)
<livewire:comment-item :comment="$reply" :key="'comment-'.$reply->id" />

View File

@@ -1,6 +1,6 @@
<div class="space-y-4"
@if (!\Relaticle\Comments\Config::isBroadcastingEnabled())
wire:poll.{{ \Relaticle\Comments\Config::getPollingInterval() }}
@if (!\Relaticle\Comments\CommentsConfig::isBroadcastingEnabled())
wire:poll.{{ \Relaticle\Comments\CommentsConfig::getPollingInterval() }}
@endif
>
{{-- Sort toggle --}}
@@ -57,137 +57,48 @@
</div>
@endif
{{-- New comment form - only for authorized users --}}
{{-- New comment form - sticky at bottom of slide-over --}}
@auth
@can('create', \Relaticle\Comments\Config::getCommentModel())
<form wire:submit="addComment" class="relative mt-4"
x-data="{
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>
@can('create', \Relaticle\Comments\CommentsConfig::getCommentModel())
<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">
{{ $this->commentForm }}
{{-- 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>
@if (!empty($attachments))
<div class="mt-2 flex flex-wrap gap-2">
@foreach ($attachments as $index => $file)
<div class="flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300">
<span>{{ $file->getClientOriginalName() }}</span>
<button type="button" wire:click="removeAttachment({{ $index }})" class="text-gray-400 hover:text-danger-500 dark:text-gray-500 dark:hover:text-danger-400">&times;</button>
</div>
@endforeach
</div>
@if (!empty($attachments))
<div class="mt-2 flex flex-wrap gap-2">
@foreach ($attachments as $index => $file)
<div class="flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300">
<span>{{ $file->getClientOriginalName() }}</span>
<button type="button" wire:click="removeAttachment({{ $index }})" class="text-gray-400 hover:text-danger-500 dark:text-gray-500 dark:hover:text-danger-400">&times;</button>
</div>
@endforeach
</div>
@endif
@error('attachments.*')
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
@enderror
@endif
<div class="mt-2 flex justify-end">
<button type="submit"
<div class="mt-2 flex items-center justify-between">
@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"
wire:loading.attr="disabled" wire:target="addComment">
<span wire:loading.remove wire:target="addComment">Comment</span>
<span wire:loading wire:target="addComment">Posting...</span>
</button>
</div>
</form>
</div>
@endcan
@endauth
</div>

View File

@@ -25,7 +25,7 @@
{{-- Emoji picker dropdown --}}
<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">
@foreach (\Relaticle\Comments\Config::getReactionEmojiSet() as $key => $emoji)
@foreach (\Relaticle\Comments\CommentsConfig::getReactionEmojiSet() as $key => $emoji)
<button wire:click="toggleReaction('{{ $key }}')" type="button"
class="rounded p-1 text-base hover:bg-gray-100 dark:hover:bg-gray-700"
title="{{ str_replace('_', ' ', $key) }}">

View File

@@ -4,10 +4,12 @@ namespace Relaticle\Comments;
use App\Models\User;
use Closure;
use Filament\Forms\Components\RichEditor\MentionProvider;
use Relaticle\Comments\Mentions\DefaultMentionResolver;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Policies\CommentPolicy;
class Config
class CommentsConfig
{
protected static ?Closure $resolveAuthenticatedUser = null;
@@ -23,7 +25,25 @@ class Config
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
@@ -154,4 +174,19 @@ class Config
{
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,20 +2,24 @@
namespace Relaticle\Comments\Livewire;
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 Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Config;
use Relaticle\Comments\Contracts\MentionResolver;
use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Events\CommentDeleted;
use Relaticle\Comments\Events\CommentUpdated;
use Relaticle\Comments\Mentions\MentionParser;
use Relaticle\Comments\Models\Comment;
class CommentItem extends Component
class CommentItem extends Component implements HasForms
{
use InteractsWithForms;
use WithFileUploads;
public Comment $comment;
@@ -24,9 +28,11 @@ class CommentItem extends Component
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> */
public array $replyAttachments = [];
@@ -36,30 +42,60 @@ class CommentItem extends Component
$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
{
$this->authorize('update', $this->comment);
$this->isEditing = true;
$this->editBody = $this->comment->body;
$this->editForm->fill(['body' => $this->comment->body]);
}
public function cancelEdit(): void
{
$this->isEditing = false;
$this->editBody = '';
$this->editForm->fill();
}
public function saveEdit(): void
{
$this->authorize('update', $this->comment);
$this->validate([
'editBody' => ['required', 'string', 'min:1'],
]);
$data = $this->editForm->getState();
$this->comment->update([
'body' => $this->editBody,
'body' => $data['body'] ?? '',
'edited_at' => now(),
]);
@@ -70,7 +106,7 @@ class CommentItem extends Component
$this->dispatch('commentUpdated');
$this->isEditing = false;
$this->editBody = '';
$this->editForm->fill();
}
public function deleteComment(): void
@@ -91,12 +127,13 @@ class CommentItem extends Component
}
$this->isReplying = true;
$this->replyForm->fill();
}
public function cancelReply(): void
{
$this->isReplying = false;
$this->replyBody = '';
$this->replyForm->fill();
$this->replyAttachments = [];
}
@@ -104,27 +141,27 @@ class CommentItem extends Component
{
$this->authorize('reply', $this->comment);
$rules = ['replyBody' => ['required', 'string', 'min:1']];
$data = $this->replyForm->getState();
if (Config::areAttachmentsEnabled()) {
$maxSize = Config::getAttachmentMaxSize();
$allowedTypes = implode(',', Config::getAttachmentAllowedTypes());
$rules['replyAttachments.*'] = ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"];
if (CommentsConfig::areAttachmentsEnabled()) {
$maxSize = CommentsConfig::getAttachmentMaxSize();
$allowedTypes = implode(',', CommentsConfig::getAttachmentAllowedTypes());
$this->validate([
'replyAttachments.*' => ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"],
]);
}
$this->validate($rules);
$user = Config::resolveAuthenticatedUser();
$user = CommentsConfig::resolveAuthenticatedUser();
$reply = $this->comment->commentable->comments()->create([
'body' => $this->replyBody,
'body' => $data['body'] ?? '',
'parent_id' => $this->comment->id,
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
]);
if (Config::areAttachmentsEnabled() && ! empty($this->replyAttachments)) {
$disk = Config::getAttachmentDisk();
if (CommentsConfig::areAttachmentsEnabled() && ! empty($this->replyAttachments)) {
$disk = CommentsConfig::getAttachmentDisk();
foreach ($this->replyAttachments as $file) {
$path = $file->store("comments/attachments/{$reply->id}", $disk);
@@ -146,7 +183,7 @@ class CommentItem extends Component
$this->dispatch('commentUpdated');
$this->isReplying = false;
$this->replyBody = '';
$this->replyForm->fill();
$this->replyAttachments = [];
}
@@ -157,25 +194,6 @@ class CommentItem extends Component
$this->replyAttachments = array_values($attachments);
}
/** @return array<int, array{id: int, name: string, avatar_url: ?string}> */
public function searchUsers(string $query): array
{
if (mb_strlen($query) < 1) {
return [];
}
$resolver = app(MentionResolver::class);
return $resolver->search($query)
->map(fn ($user) => [
'id' => $user->getKey(),
'name' => $user->getCommentName(),
'avatar_url' => $user->getCommentAvatarUrl(),
])
->values()
->all();
}
public function render(): View
{
return view('comments::livewire.comment-item');

View File

@@ -2,6 +2,10 @@
namespace Relaticle\Comments\Livewire;
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\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
@@ -9,20 +13,21 @@ use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentSubscription;
use Relaticle\Comments\Config;
use Relaticle\Comments\Contracts\MentionResolver;
use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Mentions\MentionParser;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Models\Subscription;
class Comments extends Component
class Comments extends Component implements HasForms
{
use InteractsWithForms;
use WithFileUploads;
public Model $model;
public string $newComment = '';
/** @var array<string, mixed> */
public ?array $commentData = [];
public string $sortDirection = 'asc';
@@ -36,8 +41,25 @@ class Comments extends Component
public function mount(Model $model): void
{
$this->model = $model;
$this->perPage = Config::getPerPage();
$this->perPage = CommentsConfig::getPerPage();
$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> */
@@ -46,7 +68,7 @@ class Comments extends Component
{
return $this->model
->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'])
->orderBy('created_at', $this->sortDirection)
->take($this->loadedCount)
->get();
@@ -67,27 +89,27 @@ class Comments extends Component
#[Computed]
public function isSubscribed(): bool
{
$user = Config::resolveAuthenticatedUser();
$user = CommentsConfig::resolveAuthenticatedUser();
if (! $user) {
return false;
}
return CommentSubscription::isSubscribed($this->model, $user);
return Subscription::isSubscribed($this->model, $user);
}
public function toggleSubscription(): void
{
$user = Config::resolveAuthenticatedUser();
$user = CommentsConfig::resolveAuthenticatedUser();
if (! $user) {
return;
}
if ($this->isSubscribed) {
CommentSubscription::unsubscribe($this->model, $user);
Subscription::unsubscribe($this->model, $user);
} else {
CommentSubscription::subscribe($this->model, $user);
Subscription::subscribe($this->model, $user);
}
unset($this->isSubscribed);
@@ -95,28 +117,28 @@ class Comments extends Component
public function addComment(): void
{
$rules = ['newComment' => ['required', 'string', 'min:1']];
$data = $this->commentForm->getState();
if (Config::areAttachmentsEnabled()) {
$maxSize = Config::getAttachmentMaxSize();
$allowedTypes = implode(',', Config::getAttachmentAllowedTypes());
$rules['attachments.*'] = ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"];
if (CommentsConfig::areAttachmentsEnabled()) {
$maxSize = CommentsConfig::getAttachmentMaxSize();
$allowedTypes = implode(',', CommentsConfig::getAttachmentAllowedTypes());
$this->validate([
'attachments.*' => ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"],
]);
}
$this->validate($rules);
$this->authorize('create', CommentsConfig::getCommentModel());
$this->authorize('create', Config::getCommentModel());
$user = Config::resolveAuthenticatedUser();
$user = CommentsConfig::resolveAuthenticatedUser();
$comment = $this->model->comments()->create([
'body' => $this->newComment,
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => $data['body'] ?? '',
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
]);
if (Config::areAttachmentsEnabled() && ! empty($this->attachments)) {
$disk = Config::getAttachmentDisk();
if (CommentsConfig::areAttachmentsEnabled() && ! empty($this->attachments)) {
$disk = CommentsConfig::getAttachmentDisk();
foreach ($this->attachments as $file) {
$path = $file->store("comments/attachments/{$comment->id}", $disk);
@@ -135,7 +157,8 @@ class Comments extends Component
app(MentionParser::class)->syncMentions($comment);
$this->reset('newComment', 'attachments');
$this->commentForm->fill();
$this->reset('attachments');
}
public function removeAttachment(int $index): void
@@ -163,8 +186,8 @@ class Comments extends Component
'commentUpdated' => 'refreshComments',
];
if (Config::isBroadcastingEnabled()) {
$prefix = Config::getBroadcastChannelPrefix();
if (CommentsConfig::isBroadcastingEnabled()) {
$prefix = CommentsConfig::getBroadcastChannelPrefix();
$type = $this->model->getMorphClass();
$id = $this->model->getKey();
$channel = "echo-private:{$prefix}.{$type}.{$id}";
@@ -183,25 +206,6 @@ class Comments extends Component
unset($this->comments, $this->totalCount, $this->hasMore);
}
/** @return array<int, array{id: int, name: string, avatar_url: ?string}> */
public function searchUsers(string $query): array
{
if (mb_strlen($query) < 1) {
return [];
}
$resolver = app(MentionResolver::class);
return $resolver->search($query)
->map(fn ($user) => [
'id' => $user->getKey(),
'name' => $user->getCommentName(),
'avatar_url' => $user->getCommentAvatarUrl(),
])
->values()
->all();
}
public function render(): View
{
return view('comments::livewire.comments');

View File

@@ -5,9 +5,9 @@ namespace Relaticle\Comments\Livewire;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Config;
use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Events\CommentReacted;
use Relaticle\Comments\Models\Comment;
class Reactions extends Component
{
@@ -22,19 +22,19 @@ class Reactions extends Component
public function toggleReaction(string $reaction): void
{
$user = Config::resolveAuthenticatedUser();
$user = CommentsConfig::resolveAuthenticatedUser();
if (! $user) {
return;
}
if (! in_array($reaction, Config::getAllowedReactions())) {
if (! in_array($reaction, CommentsConfig::getAllowedReactions())) {
return;
}
$existing = $this->comment->reactions()
->where('user_id', $user->getKey())
->where('user_type', $user->getMorphClass())
->where('commenter_id', $user->getKey())
->where('commenter_type', $user->getMorphClass())
->where('reaction', $reaction)
->first();
@@ -44,8 +44,8 @@ class Reactions extends Component
event(new CommentReacted($this->comment, $user, $reaction, 'removed'));
} else {
$this->comment->reactions()->create([
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'reaction' => $reaction,
]);
@@ -66,13 +66,13 @@ class Reactions extends Component
#[Computed]
public function reactionSummary(): array
{
$user = Config::resolveAuthenticatedUser();
$user = CommentsConfig::resolveAuthenticatedUser();
$userId = $user?->getKey();
$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
->groupBy('reaction')
@@ -81,10 +81,10 @@ class Reactions extends Component
'reaction' => $key,
'emoji' => $emojiSet[$key] ?? $key,
'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(),
'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\Support\Collection;
use Relaticle\Comments\Config;
use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Contracts\MentionResolver;
class DefaultMentionResolver implements MentionResolver
@@ -12,18 +12,18 @@ class DefaultMentionResolver implements MentionResolver
/** @return Collection<int, Model> */
public function search(string $query): Collection
{
$model = Config::getCommenterModel();
$model = CommentsConfig::getCommenterModel();
return $model::query()
->where('name', 'like', "{$query}%")
->limit(Config::getMentionMaxResults())
->limit(CommentsConfig::getMentionMaxResults())
->get();
}
/** @return Collection<int, Model> */
public function resolveByNames(array $names): Collection
{
$model = Config::getCommenterModel();
$model = CommentsConfig::getCommenterModel();
return $model::query()
->whereIn('name', $names)

View File

@@ -3,10 +3,10 @@
namespace Relaticle\Comments\Mentions;
use Illuminate\Support\Collection;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Config;
use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Contracts\MentionResolver;
use Relaticle\Comments\Events\UserMentioned;
use Relaticle\Comments\Models\Comment;
class MentionParser
{
@@ -16,6 +16,32 @@ class MentionParser
/** @return Collection<int, int> */
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');
@@ -33,13 +59,13 @@ class MentionParser
public function syncMentions(Comment $comment): void
{
$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);
$comment->mentions()->sync($newMentionIds->all());
$commenterModel = Config::getCommenterModel();
$commenterModel = CommentsConfig::getCommenterModel();
$addedIds->each(function ($userId) use ($comment, $commenterModel) {
$mentionedUser = $commenterModel::find($userId);

View File

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

View File

@@ -1,7 +1,9 @@
<?php
namespace Relaticle\Comments;
namespace Relaticle\Comments\Models;
use Filament\Forms\Components\RichEditor\MentionProvider;
use Filament\Forms\Components\RichEditor\RichContentRenderer;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -10,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Database\Factories\CommentFactory;
class Comment extends Model
@@ -35,14 +38,14 @@ class Comment extends Model
protected $fillable = [
'body',
'parent_id',
'user_id',
'user_type',
'commenter_id',
'commenter_type',
'edited_at',
];
public function getTable(): string
{
return Config::getCommentTable();
return CommentsConfig::getCommentTable();
}
/** @return array<string, string> */
@@ -63,39 +66,39 @@ class Comment extends Model
return $this->morphTo();
}
public function user(): MorphTo
public function commenter(): MorphTo
{
return $this->morphTo();
}
public function parent(): BelongsTo
{
return $this->belongsTo(Config::getCommentModel(), 'parent_id');
return $this->belongsTo(CommentsConfig::getCommentModel(), 'parent_id');
}
public function replies(): HasMany
{
return $this->hasMany(Config::getCommentModel(), 'parent_id');
return $this->hasMany(CommentsConfig::getCommentModel(), 'parent_id');
}
public function reactions(): HasMany
{
return $this->hasMany(CommentReaction::class);
return $this->hasMany(Reaction::class);
}
public function attachments(): HasMany
{
return $this->hasMany(CommentAttachment::class);
return $this->hasMany(Attachment::class);
}
public function mentions(): MorphToMany
{
return $this->morphedByMany(
Config::getCommenterModel(),
'user',
'comment_mentions',
CommentsConfig::getCommenterModel(),
'commenter',
CommentsConfig::getTableName('mentions'),
'comment_id',
'user_id',
'commenter_id',
);
}
@@ -121,21 +124,18 @@ class Comment extends Model
public function canReply(): bool
{
return $this->depth() < Config::getMaxDepth();
return $this->depth() < CommentsConfig::getMaxDepth();
}
public function depth(): int
{
$depth = 0;
$comment = $this;
$maxDepth = CommentsConfig::getMaxDepth();
$parentId = $this->parent_id;
while ($comment->parent_id !== null) {
$comment = $comment->parent;
while ($parentId !== null && $depth < $maxDepth) {
$depth++;
if ($depth >= Config::getMaxDepth()) {
return Config::getMaxDepth();
}
$parentId = static::where('id', $parentId)->value('parent_id');
}
return $depth;
@@ -144,6 +144,19 @@ class Comment extends Model
public function renderBodyWithMentions(): string
{
$body = $this->body;
if ($this->hasRichEditorMentions($body)) {
return RichContentRenderer::make($body)
->mentions([
MentionProvider::make('@')
->getLabelsUsing(fn (array $ids): array => CommentsConfig::getCommenterModel()::query()
->whereIn('id', $ids)
->pluck('name', 'id')
->all()),
])
->toHtml();
}
$mentionNames = $this->mentions->pluck('name')->filter()->unique();
foreach ($mentionNames as $name) {
@@ -156,4 +169,9 @@ class Comment extends Model
return $body;
}
protected function hasRichEditorMentions(string $body): bool
{
return str_contains($body, 'data-type="mention"') || str_contains($body, '<p>') || str_contains($body, '<br');
}
}

View File

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

View File

@@ -1,25 +1,26 @@
<?php
namespace Relaticle\Comments;
namespace Relaticle\Comments\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Collection;
use Relaticle\Comments\CommentsConfig;
class CommentSubscription extends Model
class Subscription extends Model
{
public const UPDATED_AT = null;
protected $fillable = [
'commentable_type',
'commentable_id',
'user_type',
'user_id',
'commenter_type',
'commenter_id',
];
public function getTable(): string
{
return 'comment_subscriptions';
return CommentsConfig::getTableName('subscriptions');
}
public function commentable(): MorphTo
@@ -27,7 +28,7 @@ class CommentSubscription extends Model
return $this->morphTo();
}
public function user(): MorphTo
public function commenter(): MorphTo
{
return $this->morphTo();
}
@@ -37,8 +38,8 @@ class CommentSubscription extends Model
return static::where([
'commentable_type' => $commentable->getMorphClass(),
'commentable_id' => $commentable->getKey(),
'user_type' => $user->getMorphClass(),
'user_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
])->exists();
}
@@ -47,8 +48,8 @@ class CommentSubscription extends Model
static::firstOrCreate([
'commentable_type' => $commentable->getMorphClass(),
'commentable_id' => $commentable->getKey(),
'user_type' => $user->getMorphClass(),
'user_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
]);
}
@@ -57,8 +58,8 @@ class CommentSubscription extends Model
static::where([
'commentable_type' => $commentable->getMorphClass(),
'commentable_id' => $commentable->getKey(),
'user_type' => $user->getMorphClass(),
'user_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
])->delete();
}
@@ -68,6 +69,6 @@ class CommentSubscription extends Model
return static::where([
'commentable_type' => $commentable->getMorphClass(),
'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\Notification;
use Illuminate\Support\Str;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Config;
use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Models\Comment;
class CommentRepliedNotification extends Notification
{
@@ -15,7 +15,7 @@ class CommentRepliedNotification extends Notification
/** @return array<int, string> */
public function via(mixed $notifiable): array
{
return Config::getNotificationChannels();
return CommentsConfig::getNotificationChannels();
}
/** @return array<string, mixed> */
@@ -25,14 +25,14 @@ class CommentRepliedNotification extends Notification
'comment_id' => $this->comment->id,
'commentable_type' => $this->comment->commentable_type,
'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),
];
}
public function toMail(mixed $notifiable): MailMessage
{
$commenterName = $this->comment->user->getCommentName();
$commenterName = $this->comment->commenter->getCommentDisplayName();
return (new MailMessage)
->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\Notification;
use Illuminate\Support\Str;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Config;
use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Models\Comment;
class UserMentionedNotification extends Notification
{
@@ -19,7 +19,7 @@ class UserMentionedNotification extends Notification
/** @return array<int, string> */
public function via(mixed $notifiable): array
{
return Config::getNotificationChannels();
return CommentsConfig::getNotificationChannels();
}
/** @return array<string, mixed> */
@@ -29,14 +29,14 @@ class UserMentionedNotification extends Notification
'comment_id' => $this->comment->id,
'commentable_type' => $this->comment->commentable_type,
'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),
];
}
public function toMail(mixed $notifiable): MailMessage
{
$mentionerName = $this->mentionedBy->getCommentName();
$mentionerName = $this->mentionedBy->getCommentDisplayName();
return (new MailMessage)
->subject('You were mentioned in a comment')

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
<?php
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentAttachment;
use Relaticle\Comments\Config;
use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Models\Attachment;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
@@ -13,12 +13,12 @@ it('creates a comment attachment with all metadata fields', function () {
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test comment</p>',
]);
$attachment = CommentAttachment::create([
$attachment = Attachment::create([
'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/photo.jpg',
'original_name' => 'photo.jpg',
@@ -27,7 +27,7 @@ it('creates a comment attachment with all metadata fields', function () {
'disk' => 'public',
]);
expect($attachment)->toBeInstanceOf(CommentAttachment::class)
expect($attachment)->toBeInstanceOf(Attachment::class)
->and($attachment->file_path)->toBe('comments/attachments/1/photo.jpg')
->and($attachment->original_name)->toBe('photo.jpg')
->and($attachment->mime_type)->toBe('image/jpeg')
@@ -42,12 +42,12 @@ it('belongs to a comment via comment() relationship', function () {
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test</p>',
]);
$attachment = CommentAttachment::create([
$attachment = Attachment::create([
'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/test.png',
'original_name' => 'test.png',
@@ -67,12 +67,12 @@ it('has attachments() hasMany relationship on Comment', function () {
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test</p>',
]);
CommentAttachment::create([
Attachment::create([
'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/file1.png',
'original_name' => 'file1.png',
@@ -81,7 +81,7 @@ it('has attachments() hasMany relationship on Comment', function () {
'disk' => 'public',
]);
CommentAttachment::create([
Attachment::create([
'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/file2.pdf',
'original_name' => 'file2.pdf',
@@ -91,7 +91,7 @@ it('has attachments() hasMany relationship on Comment', function () {
]);
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 () {
@@ -101,12 +101,12 @@ it('cascade deletes attachments when comment is force deleted', function () {
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test</p>',
]);
CommentAttachment::create([
Attachment::create([
'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/photo.jpg',
'original_name' => 'photo.jpg',
@@ -115,15 +115,15 @@ it('cascade deletes attachments when comment is force deleted', function () {
'disk' => 'public',
]);
expect(CommentAttachment::where('comment_id', $comment->id)->count())->toBe(1);
expect(Attachment::where('comment_id', $comment->id)->count())->toBe(1);
$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) {
$attachment = new CommentAttachment(['mime_type' => $mimeType]);
$attachment = new Attachment(['mime_type' => $mimeType]);
expect($attachment->isImage())->toBe($expected);
})->with([
@@ -142,12 +142,12 @@ it('formats bytes into human-readable size via formattedSize()', function () {
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<p>Test</p>',
]);
$attachment = CommentAttachment::create([
$attachment = Attachment::create([
'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/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 () {
expect(Config::getAttachmentDisk())->toBe('public');
expect(CommentsConfig::getAttachmentDisk())->toBe('public');
});
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 () {
$allowedTypes = Config::getAttachmentAllowedTypes();
$allowedTypes = CommentsConfig::getAttachmentAllowedTypes();
expect($allowedTypes)->toBeArray()
->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.allowed_types' => ['image/png']]);
expect(Config::getAttachmentDisk())->toBe('s3')
->and(Config::getAttachmentMaxSize())->toBe(5120)
->and(Config::getAttachmentAllowedTypes())->toBe(['image/png']);
expect(CommentsConfig::getAttachmentDisk())->toBe('s3')
->and(CommentsConfig::getAttachmentMaxSize())->toBe(5120)
->and(CommentsConfig::getAttachmentAllowedTypes())->toBe(['image/png']);
});
it('reports attachments as enabled by default', function () {
expect(Config::areAttachmentsEnabled())->toBeTrue();
expect(CommentsConfig::areAttachmentsEnabled())->toBeTrue();
});
it('respects disabled attachments config', function () {
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 Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Events\CommentDeleted;
use Relaticle\Comments\Events\CommentUpdated;
use Relaticle\Comments\Livewire\CommentItem;
use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
@@ -20,7 +20,7 @@ it('fires CommentCreated event when adding a comment', function () {
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>New comment</p>')
->set('commentData.body', '<p>New comment</p>')
->call('addComment');
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([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<p>Original</p>',
]);
@@ -47,7 +47,7 @@ it('fires CommentUpdated event when editing a comment', function () {
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->set('editBody', '<p>Edited</p>')
->set('editData.body', '<p>Edited</p>')
->call('saveEdit');
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([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
@@ -87,15 +87,15 @@ it('fires CommentCreated event when adding a reply', function () {
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply')
->set('replyBody', '<p>Reply text</p>')
->set('replyData.body', '<p>Reply text</p>')
->call('addReply');
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);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Payload test</p>')
->set('commentData.body', '<p>Payload test</p>')
->call('addComment');
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post, $user) {
return $event->comment instanceof Comment
&& $event->commentable->id === $post->id
&& $event->comment->user_id === $user->id;
&& $event->comment->commenter_id === $user->id;
});
});

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
<?php
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentSubscription;
use Relaticle\Comments\Config;
use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Models\Subscription;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
@@ -10,98 +9,98 @@ it('has commentable morphTo relationship', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$subscription = CommentSubscription::create([
$subscription = Subscription::create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
]);
expect($subscription->commentable)->toBeInstanceOf(Post::class)
->and($subscription->commentable->id)->toBe($post->id);
});
it('has user morphTo relationship', function () {
it('has commenter morphTo relationship', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$subscription = CommentSubscription::create([
$subscription = Subscription::create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
]);
expect($subscription->user)->toBeInstanceOf(User::class)
->and($subscription->user->id)->toBe($user->id);
expect($subscription->commenter)->toBeInstanceOf(User::class)
->and($subscription->commenter->id)->toBe($user->id);
});
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 () {
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 () {
expect(Config::shouldAutoSubscribe())->toBeTrue();
expect(CommentsConfig::shouldAutoSubscribe())->toBeTrue();
});
it('returns false for shouldAutoSubscribe when configured', function () {
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 () {
$user = User::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_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
]);
expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue();
expect(Subscription::isSubscribed($post, $user))->toBeTrue();
});
it('creates subscription via subscribe() static method', function () {
$user = User::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 () {
$user = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $user);
CommentSubscription::unsubscribe($post, $user);
Subscription::subscribe($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 () {
$user = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $user);
CommentSubscription::subscribe($post, $user);
Subscription::subscribe($post, $user);
Subscription::subscribe($post, $user);
expect(CommentSubscription::where([
expect(Subscription::where([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
])->count())->toBe(1);
});

View File

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

View File

@@ -1,7 +1,7 @@
<?php
use Relaticle\Comments\Comment;
use Relaticle\Comments\Filament\Actions\CommentsAction;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Tests\Models\Post;
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');
});
it('has modal content configured', function () {
it('disables modal submit and cancel actions', function () {
$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 () {
@@ -42,8 +43,8 @@ it('shows badge with comment count when comments exist', function () {
Comment::factory()->count(3)->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
]);
$action = CommentsAction::make('comments');

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
<?php
use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
@@ -13,8 +13,8 @@ it('strips script tags from comment body on create', function () {
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'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([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<img onerror="alert(1)" src="x">',
]);
@@ -45,8 +45,8 @@ it('strips style tags from comment body', function () {
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'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([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'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([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => $safeHtml,
]);
@@ -108,8 +108,8 @@ it('sanitizes comment body on update', function () {
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<p>Clean content</p>',
]);
@@ -130,8 +130,8 @@ it('strips javascript protocol from link href', function () {
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<a href="javascript:alert(1)">click me</a>',
]);
@@ -146,8 +146,8 @@ it('strips onclick handler from elements', function () {
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<div onclick="alert(1)">click me</div>',
]);
@@ -162,7 +162,7 @@ it('sanitizes content submitted through livewire component', function () {
$this->actingAs($user);
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');
$comment = Comment::first();

View File

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

View File

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

View File

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

View File

@@ -1,72 +1,12 @@
<?php
use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Livewire\CommentItem;
use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Tests\Models\Post;
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 () {
$user = User::factory()->create();
$alice = User::factory()->create(['name' => 'Alice']);
@@ -75,7 +15,7 @@ it('stores mentions when creating comment with @mention', function () {
$this->actingAs($user);
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');
$comment = Comment::first();
@@ -92,8 +32,8 @@ it('stores mentions when editing comment with @mention', function () {
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<p>Original comment</p>',
]);
@@ -101,7 +41,7 @@ it('stores mentions when editing comment with @mention', function () {
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->set('editBody', '<p>Updated @Bob</p>')
->set('editData.body', '<p>Updated @Bob</p>')
->call('saveEdit');
$comment->refresh();

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More