Compare commits
29 Commits
main
...
v1.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a26396f0d | ||
|
|
2ace8bfdd4 | ||
|
|
3d745077b7 | ||
|
|
b44b4e309e | ||
|
|
ac97dcb092 | ||
|
|
6c96fb900b | ||
|
|
7f9f13b626 | ||
|
|
e173d9b4dd | ||
|
|
f119095ae5 | ||
|
|
889dc2828b | ||
|
|
82eb6a70ad | ||
|
|
2edcfa00f1 | ||
|
|
35571760d6 | ||
|
|
a4d4418963 | ||
|
|
b2ee8a1036 | ||
|
|
fd5bc5271b | ||
|
|
43b66f60f3 | ||
|
|
0c13d589d8 | ||
|
|
2c7c44ecbc | ||
|
|
12470a1d8b | ||
|
|
42e95a83f5 | ||
|
|
f83402432e | ||
|
|
851852df38 | ||
|
|
a2a2e54b61 | ||
|
|
4a410bce44 | ||
|
|
d4b0b53fb5 | ||
|
|
4c30f06857 | ||
|
|
53dd4565d7 | ||
|
|
b8d930df1a |
55
.github/CONTRIBUTING.md
vendored
Normal file
55
.github/CONTRIBUTING.md
vendored
Normal 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
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: Relaticle
|
||||
66
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
66
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal 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
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
3
.github/SECURITY.md
vendored
Normal 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
12
.github/dependabot.yml
vendored
Normal 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
21
.github/release.yml
vendored
Normal 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
33
.github/workflows/auto-merge.yml
vendored
Normal 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
42
.github/workflows/changelog.yml
vendored
Normal 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
137
.github/workflows/deploy-docs.yml
vendored
Normal 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
28
.github/workflows/pint.yml
vendored
Normal 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
51
.github/workflows/release.yml
vendored
Normal 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
51
.github/workflows/tests.yml
vendored
Normal 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
15
.gitignore
vendored
@@ -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
6
CHANGELOG.md
Normal 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
162
README.md
Normal 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.
|
||||
|
||||
[](https://packagist.org/packages/relaticle/comments)
|
||||
[](https://packagist.org/packages/relaticle/comments)
|
||||
[](https://php.net)
|
||||
[](https://laravel.com)
|
||||
[](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
BIN
art/preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
5
docs/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.output
|
||||
.nuxt
|
||||
dist
|
||||
.env
|
||||
64
docs/app.config.ts
Normal file
64
docs/app.config.ts
Normal 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'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
103
docs/components/AppHeader.vue
Normal file
103
docs/components/AppHeader.vue
Normal 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 →
|
||||
</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>
|
||||
16
docs/components/AppHeaderLogo.vue
Normal file
16
docs/components/AppHeaderLogo.vue
Normal 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>
|
||||
38
docs/components/AppVersionSwitcher.vue
Normal file
38
docs/components/AppVersionSwitcher.vue
Normal 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>
|
||||
60
docs/composables/useVersions.ts
Normal file
60
docs/composables/useVersions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
2
docs/content/1.getting-started/.navigation.yml
Normal file
2
docs/content/1.getting-started/.navigation.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
title: Getting Started
|
||||
icon: false
|
||||
112
docs/content/1.getting-started/1.installation.md
Normal file
112
docs/content/1.getting-started/1.installation.md
Normal 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 |
|
||||
12
docs/content/1.getting-started/2.upgrading.md
Normal file
12
docs/content/1.getting-started/2.upgrading.md
Normal 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.
|
||||
1
docs/content/2.essentials/.navigation.yml
Normal file
1
docs/content/2.essentials/.navigation.yml
Normal file
@@ -0,0 +1 @@
|
||||
title: Essentials
|
||||
202
docs/content/2.essentials/1.configuration.md
Normal file
202
docs/content/2.essentials/1.configuration.md
Normal 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.
|
||||
74
docs/content/2.essentials/2.authorization.md
Normal file
74
docs/content/2.essentials/2.authorization.md
Normal 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.
|
||||
74
docs/content/2.essentials/3.mentions.md
Normal file
74
docs/content/2.essentials/3.mentions.md
Normal 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 |
|
||||
51
docs/content/2.essentials/4.reactions.md
Normal file
51
docs/content/2.essentials/4.reactions.md
Normal 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.
|
||||
72
docs/content/2.essentials/5.attachments.md
Normal file
72
docs/content/2.essentials/5.attachments.md
Normal 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")
|
||||
```
|
||||
125
docs/content/2.essentials/6.notifications.md
Normal file
125
docs/content/2.essentials/6.notifications.md
Normal 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.
|
||||
107
docs/content/2.essentials/7.database-schema.md
Normal file
107
docs/content/2.essentials/7.database-schema.md
Normal 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.
|
||||
1
docs/content/4.community/.navigation.yml
Normal file
1
docs/content/4.community/.navigation.yml
Normal file
@@ -0,0 +1 @@
|
||||
title: Community
|
||||
39
docs/content/4.community/1.contributing.md
Normal file
39
docs/content/4.community/1.contributing.md
Normal 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
|
||||
39
docs/content/4.community/2.license.md
Normal file
39
docs/content/4.community/2.license.md
Normal 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
155
docs/content/index.md
Normal 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
69
docs/nuxt.config.ts
Normal 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
23204
docs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
docs/package.json
Normal file
22
docs/package.json
Normal 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
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 |
13
docs/public/logo-light.svg
Normal file
13
docs/public/logo-light.svg
Normal file
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
BIN
docs/public/preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 330 KiB |
285
resources/boost/skills/comments-development/SKILL.md
Normal file
285
resources/boost/skills/comments-development/SKILL.md
Normal 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)
|
||||
```
|
||||
@@ -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.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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">×</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">×</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" />
|
||||
|
||||
@@ -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">×</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">×</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>
|
||||
|
||||
@@ -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) }}">
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} */
|
||||
|
||||
@@ -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} */
|
||||
|
||||
@@ -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} */
|
||||
|
||||
@@ -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} */
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
),
|
||||
];
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>',
|
||||
]);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user