├── .editorconfig ├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock ├── config └── commentify.php ├── database ├── .gitignore ├── factories │ ├── CommentFactory.php │ └── UserFactory.php └── migrations │ ├── 2023_02_24_000000_create_comments_table.php │ ├── 2023_03_24_000000_create_comment_likes_table.php │ └── 2025_01_01_000001_add_comment_banned_until_to_users_table.php ├── lang ├── en │ └── commentify.php └── es │ └── commentify.php ├── phpunit.xml ├── public └── images │ └── commentify.gif ├── resources └── views │ └── livewire │ ├── comment.blade.php │ ├── comments.blade.php │ ├── like.blade.php │ └── partials │ ├── comment-form.blade.php │ ├── comment-reply.blade.php │ ├── dropdowns │ └── users.blade.php │ └── loader.blade.php ├── src ├── Http │ └── Livewire │ │ ├── Comment.php │ │ ├── Comments.php │ │ └── Like.php ├── Models │ ├── Comment.php │ ├── CommentLike.php │ ├── Presenters │ │ └── CommentPresenter.php │ └── User.php ├── Policies │ └── CommentPolicy.php ├── Providers │ ├── CommentifyServiceProvider.php │ └── MarkdownServiceProvider.php ├── Scopes │ ├── CommentScopes.php │ └── HasLikes.php └── Traits │ ├── Commentable.php │ ├── HasCommentBan.php │ └── HasUserAvatar.php ├── tailwind.config.js └── tests ├── CommentPresenterTest.php ├── CommentTest.php ├── TestCase.php ├── Unit └── Http │ └── Livewire │ ├── CommentComponentTest.php │ ├── CommentsComponentTest.php │ └── LikeComponentTest.php └── stubs ├── ArticleStub.php ├── CommentStub.php └── EpisodeStub.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | 17 | [docker-compose.yml] 18 | indent_size = 4 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Usama Muneer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Commentify - Laravel Livewire Comments 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/usamamuneerchaudhary/commentify?style=flat-square&g)](https://packagist.org/packages/usamamuneerchaudhary/commentify) 4 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/usamamuneerchaudhary/commentify/badges/quality-score.png?b=main)](https://scrutinizer-ci.com/g/usamamuneerchaudhary/commentify/?branch=main) 5 | [![CodeFactor](https://www.codefactor.io/repository/github/usamamuneerchaudhary/commentify/badge)](https://www.codefactor.io/repository/github/usamamuneerchaudhary/commentify) 6 | [![Build Status](https://scrutinizer-ci.com/g/usamamuneerchaudhary/commentify/badges/build.png?b=main)](https://scrutinizer-ci.com/g/usamamuneerchaudhary/commentify/build-status/main) 7 | [![Code Intelligence Status](https://scrutinizer-ci.com/g/usamamuneerchaudhary/commentify/badges/code-intelligence.svg?b=main)](https://scrutinizer-ci.com/code-intelligence) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/usamamuneerchaudhary/commentify?style=flat-square)](https://packagist.org/packages/usamamuneerchaudhary/commentify) 9 | [![Licence](https://img.shields.io/packagist/l/usamamuneerchaudhary/commentify?style=flat-square)](https://github.com/usamamuneerchaudhary/commentify/blob/HEAD/LICENSE.md) 10 | 11 | ![commentify](public/images/commentify.gif) 12 | 13 | ## Introduction 14 | 15 | Commentify is a powerful Laravel Livewire package designed to provide an easy-to-integrate commenting system for any 16 | model in your Laravel application. Powered by Livewire, this package offers a seamless commenting experience that is 17 | powered by Tailwind UI, making it easy for users to engage with your content. With features like comments pagination 18 | and YouTube-style like/unlike buttons, this package is perfect for applications that require robust commenting 19 | capabilities. Additionally, guest users can like and unlike comments based on their IP addresses. Mentions can be 20 | used with "@" to tag specific users in replies and edits, while Markdown support allows for rich formatting in 21 | comments. Whether you're building a blog, an e-commerce platform, or any other type of web application, Commentify is a 22 | powerful tool for enhancing user engagement and collaboration. 23 | 24 | ## Some Features Highlight 25 | 26 | - Easy to integrate 27 | - Laravel 12+ support 28 | - Supports Livewire 3 29 | - Livewire powered commenting system 30 | - Tailwind UI 31 | - Read-only mode (configurable via `config/commentify.php`) 32 | - Add comments to any model 33 | - Nested Comments 34 | - Temporary user comment bans (block users from commenting until a set date) 35 | - Comments Pagination 36 | - Youtube style Like/unlike feature 37 | - Guest like/unlike of comments (based on `IP` & `UserAgent`) 38 | - Mention User with @ in Replies/Edits 39 | - Markdown Support 40 | - Full language/translation support (publish and override as needed) 41 | - Customizable views (publish and override as needed) 42 | - Policy-based authorization for all comment actions 43 | 44 | ## Prerequisites 45 | 46 | - [Livewire](https://laravel-livewire.com/docs/2.x/installation) 47 | - [TailwindCSS](https://tailwindcss.com/) 48 | - [AlpineJS](https://alpinejs.dev/essentials/installation) 49 | 50 | ## Installation Guide 51 | 52 | You can install the package via composer: 53 | 54 | ```composer require usamamuneerchaudhary/commentify``` 55 | 56 | ### Register Service Provider 57 | 58 | Add the service provider in `config/app.php`: 59 | 60 | ```php 61 | Usamamuneerchaudhary\Commentify\Providers\CommentifyServiceProvider::class, 62 | ``` 63 | 64 | ### Run Migrations 65 | 66 | Once the package is installed, you can run migrations, 67 | ```php artisan migrate``` 68 | 69 | ### Publish config, views, and lang files as needed 70 | 71 | ```php 72 | php artisan vendor:publish --tag="commentify-config" 73 | php artisan vendor:publish --tag=commentify-views 74 | php artisan vendor:publish --tag=commentify-lang 75 | php artisan vendor:publish --tag=commentify-migrations 76 | ``` 77 | This will publish `commentify.php` file in config directory. Here you can configure user route and pagination count etc. 78 | 79 | ### Publish `tailwind.config.js` file, 80 | 81 | This package utilizes TailwindCSS, and use some custom configurations. You can publish package's `tailwind.config. 82 | js` file by running the following command: 83 | 84 | ```php 85 | php artisan vendor:publish --tag="commentify-tailwind-config" 86 | ``` 87 | 88 | ## Usage 89 | In your model, where you want to integrate comments, simply add the `Commentable` trait in that model. 90 | For example: 91 | ```php 92 | use Usamamuneerchaudhary\Commentify\Traits\Commentable; 93 | 94 | class Article extends Model 95 | { 96 | use Commentable; 97 | } 98 | ``` 99 | 100 | Next, in your view, pass in the livewire comment component. For example, if your view file is `articles/show.blade. 101 | php`. We can add the following code: 102 | ```html 103 | 104 | ``` 105 | 106 | #### Additionally, add the `HasUserAvatar` trait in `App\Models\User`, to use avatars: 107 | ```php 108 | use Usamamuneerchaudhary\Commentify\Traits\HasUserAvatar; 109 | 110 | class User extends Model 111 | { 112 | use HasUserAvatar; 113 | } 114 | ``` 115 | --- 116 | ## 🔒 Read-Only Mode 117 | 118 | Temporarily disable all commenting (for maintenance, etc): 119 | 120 | - In `config/commentify.php`: 121 | ```php 122 | 'read_only' => true, 123 | ``` 124 | 125 | --- 126 | 127 | ## 🚫 Temporarily Block Users from Commenting 128 | 129 | - Add the provided migration to your app to add a `comment_banned_until` column to your `users` table. 130 | - Add the `HasCommentBan` trait to your User model: 131 | ```php 132 | use Usamamuneerchaudhary\Commentify\Traits\HasCommentBan; 133 | class User extends Authenticatable 134 | { 135 | use HasCommentBan; 136 | } 137 | ``` 138 | - Set `comment_banned_until` to a future date to block a user. 139 | 140 | --- 141 | 142 | ## 🌍 Language Support 143 | 144 | - All strings are translatable. 145 | - Publish lang files and override as needed in `lang/vendor/commentify`. 146 | 147 | --- 148 | 149 | ## 🛡️ Authorization 150 | 151 | - All comment actions use Laravel policies. 152 | - You can customize permissions and ban logic in your `CommentPolicy`. 153 | 154 | --- 155 | 156 | ## Tests 157 | 158 | `composer test` 159 | 160 | ## Security 161 | 162 | If you discover any security related issues, please email hello@usamamuneer.me instead of using the issue tracker. 163 | 164 | ## Credits 165 | 166 | - [Laravel](https://laravel.com) 167 | - [Tailwind](https://tailwindcss.com/) 168 | - [Livewire](https://laravel-livewire.com/) 169 | - [FlowBite](https://flowbite.com) 170 | - [All Contributors](https://github.com/usamamuneerchaudhary/commentify/graphs/contributors) 171 | 172 | ## License 173 | 174 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 175 | 176 | 177 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "usamamuneerchaudhary/commentify", 3 | "description": "Easy Laravel Livewire Comments with TailwindCSS UI", 4 | "keywords": [ 5 | "usamamuneerchaudhary", 6 | "laravel-livewire", 7 | "livewire", 8 | "comments", 9 | "commentify", 10 | "livewire-comments" 11 | ], 12 | "license": "MIT", 13 | "type": "library", 14 | "authors": [ 15 | { 16 | "name": "Usama Muneer", 17 | "email": "hello@usamamuneer.me", 18 | "role": "Developer" 19 | } 20 | ], 21 | "autoload": { 22 | "psr-4": { 23 | "Usamamuneerchaudhary\\Commentify\\": "src", 24 | "Usamamuneerchaudhary\\Commentify\\Database\\Factories\\": "database/factories" 25 | } 26 | }, 27 | "require": { 28 | "php": "^8.3", 29 | "illuminate/database": "^12.0", 30 | "illuminate/support": "^12.0", 31 | "livewire/livewire": "^3.0", 32 | "livewire/flux": "^2.1.1", 33 | "laravel/framework": "^12.0", 34 | "guzzlehttp/guzzle": "^7.9", 35 | "laravel/telescope": "^5.0" 36 | }, 37 | "require-dev": { 38 | "orchestra/testbench": "^10.0", 39 | "phpunit/phpunit": "^11.0" 40 | }, 41 | "autoload-dev": { 42 | "classmap": [ 43 | "tests/TestCase.php", 44 | "tests/stubs/ArticleStub.php", 45 | "tests/stubs/CommentStub.php", 46 | "tests/stubs/EpisodeStub.php" 47 | ] 48 | }, 49 | "scripts": { 50 | "test": "vendor/bin/phpunit" 51 | }, 52 | "config": { 53 | "sort-packages": true 54 | }, 55 | "extra": { 56 | "laravel": { 57 | "providers": [ 58 | "Usamamuneerchaudhary\\Commentify\\Providers\\MarkdownServiceProvider" 59 | ] 60 | } 61 | }, 62 | "minimum-stability": "dev", 63 | "prefer-stable": true 64 | } 65 | -------------------------------------------------------------------------------- /config/commentify.php: -------------------------------------------------------------------------------- 1 | 'users', //set this prefix to anything that you wish to use for users profile routes 5 | 'pagination_count' => 10, 6 | 'css_framework' => 'tailwind', // or 'bootstrap' 7 | 'comment_nesting' => true, // set to false if you don't want to allow nesting of comments 8 | 'read_only' => false, // set to true if you want to make comments read only 9 | ]; 10 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite* 2 | -------------------------------------------------------------------------------- /database/factories/CommentFactory.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public function definition(): array 21 | { 22 | return [ 23 | 'body' => fake()->text, 24 | 'user_id' => function () { 25 | return User::factory()->create()->id; 26 | }, 27 | 'parent_id' => null, 28 | 'commentable_type' => '\ArticleStub', 29 | 'commentable_id' => 1, 30 | 'created_at' => now() 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UserFactory extends Factory 13 | { 14 | protected $model = User::class; 15 | 16 | /** 17 | * Define the model's default state. 18 | * 19 | * @return array 20 | */ 21 | public function definition(): array 22 | { 23 | return [ 24 | 'name' => fake()->name(), 25 | 'email' => fake()->unique()->safeEmail(), 26 | 'email_verified_at' => now(), 27 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 28 | 'remember_token' => Str::random(10), 29 | ]; 30 | } 31 | 32 | /** 33 | * Indicate that the model's email address should be unverified. 34 | */ 35 | public function unverified(): static 36 | { 37 | return $this->state(fn(array $attributes) => [ 38 | 'email_verified_at' => null, 39 | ]); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /database/migrations/2023_02_24_000000_create_comments_table.php: -------------------------------------------------------------------------------- 1 | id(); 15 | $table->foreignId('user_id')->constrained()->onDelete('cascade'); 16 | $table->foreignId('parent_id')->nullable()->constrained('comments')->onDelete('cascade'); 17 | $table->text('body'); 18 | $table->morphs('commentable'); 19 | $table->softDeletes(); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | */ 27 | public function down(): void 28 | { 29 | Schema::dropIfExists('comments'); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /database/migrations/2023_03_24_000000_create_comment_likes_table.php: -------------------------------------------------------------------------------- 1 | id(); 15 | $table->foreignId('user_id')->nullable(); 16 | $table->foreignId('comment_id'); 17 | $table->ipAddress('ip')->nullable(); 18 | $table->string('user_agent')->nullable(); 19 | $table->timestamps(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | */ 26 | public function down(): void 27 | { 28 | Schema::dropIfExists('comment_likes'); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /database/migrations/2025_01_01_000001_add_comment_banned_until_to_users_table.php: -------------------------------------------------------------------------------- 1 | timestamp('comment_banned_until')->nullable()->after('remember_token'); 13 | }); 14 | } 15 | 16 | public function down() 17 | { 18 | Schema::table('users', function (Blueprint $table) { 19 | $table->dropColumn('comment_banned_until'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /lang/en/commentify.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'discussion' => 'Discussion', 6 | 'your_comment' => 'Your comment', 7 | 'post_comment' => 'Post comment', 8 | 'login_to_comment' => 'Log in to comment!', 9 | 'no_comments' => 'No comments yet!', 10 | 'edit' => 'Edit', 11 | 'delete' => 'Delete', 12 | 'delete_confirm' => 'You sure to delete this comment?', 13 | 'reply' => 'Reply', 14 | 'view_replies' => 'View all Replies (:count)', 15 | 'hide_replies' => 'Hide Replies', 16 | 'write_comment' => 'Write a comment...', 17 | 'success' => 'Success!', 18 | 'likes' => 'likes', 19 | 'your_reply' => 'Your Reply', 20 | 'edit_comment' => 'Edit Comment', 21 | 'post_reply' => 'Post Reply', 22 | 'read_only_message' => 'Commenting is currently disabled.', 23 | 'banned_message' => 'You are temporarily restricted from commenting.', 24 | ], 25 | ]; 26 | -------------------------------------------------------------------------------- /lang/es/commentify.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ./src 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /public/images/commentify.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usamamuneerchaudhary/commentify/e0e651658c379f2a23bc3ccbbfbf2af75c9ff5c4/public/images/commentify.gif -------------------------------------------------------------------------------- /resources/views/livewire/comment.blade.php: -------------------------------------------------------------------------------- 1 |
2 | @if($isEditing) 3 | @include('commentify::livewire.partials.comment-form',[ 4 | 'method'=>'editComment', 5 | 'state'=>'editState', 6 | 'inputId'=> 'reply-comment', 7 | 'inputLabel'=> __('commentify::commentify.comments.your_reply'), 8 | 'button'=> __('commentify::commentify.comments.edit_comment') 9 | ]) 10 | @else 11 |
12 |
13 |
14 |

15 | {{$comment->user->name}} 16 | {{Str::ucfirst($comment->user->name)}} 17 |

18 |

19 | 22 |

23 |
24 |
25 | 30 | @if($showOptions) 31 |
32 |
    33 | @can('update',$comment) 34 |
  • 35 | 38 |
  • 39 | @endcan 40 | @can('destroy',$comment) 41 |
  • 42 | 45 |
  • 46 | @endcan 47 |
48 |
49 | @endif 50 |
51 |
52 |

53 | {!! $comment->presenter()->replaceUserMentions($comment->presenter()->markdownBody()) !!} 54 |

55 |
56 | 57 | @include('commentify::livewire.partials.comment-reply') 58 |
59 |
60 | @endif 61 | @if($isReplying) 62 | @include('commentify::livewire.partials.comment-form',[ 63 | 'method'=>'postReply', 64 | 'state'=>'replyState', 65 | 'inputId'=> 'reply-comment', 66 | 'inputLabel'=> __('commentify::commentify.comments.your_reply'), 67 | 'button'=> __('commentify::commentify.comments.post_reply') 68 | ]) 69 | @endif 70 | @if($hasReplies) 71 |
72 | @foreach($comment->children as $child) 73 | 74 | @endforeach 75 |
76 | @endif 77 |
78 | 79 | 80 | -------------------------------------------------------------------------------- /resources/views/livewire/comments.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |
6 |

{{ __('commentify::commentify.comments.discussion') }} 7 | ({{$comments->total()}})

8 |
9 | @auth 10 | @include('commentify::livewire.partials.comment-form',[ 11 | 'method'=>'postComment', 12 | 'state'=>'newCommentState', 13 | 'inputId'=> 'comment', 14 | 'inputLabel'=> __('commentify::commentify.comments.your_comment'), 15 | 'button'=> __('commentify::commentify.comments.post_comment') 16 | ]) 17 | @else 18 | {{ __('commentify::commentify.comments.login_to_comment') }} 19 | @endauth 20 | @if($comments->count()) 21 | @foreach($comments as $comment) 22 | 23 | @endforeach 24 | {{$comments->links()}} 25 | @else 26 |

{{ __('commentify::commentify.comments.no_comments') }}

27 | @endif 28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /resources/views/livewire/like.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | -------------------------------------------------------------------------------- /resources/views/livewire/partials/comment-form.blade.php: -------------------------------------------------------------------------------- 1 | @if (!config('commentify.read_only')) 2 |
3 | @if (session()->has('message')) 4 | @php 5 | $alertType = session('alertType', 'success'); 6 | $alertClasses = [ 7 | 'success' => 'text-green-800 bg-green-50 dark:text-green-400', 8 | 'warning' => 'text-yellow-800 bg-yellow-50 dark:text-yellow-400', 9 | 'error' => 'text-red-800 bg-red-50 dark:text-red-400', 10 | ]; 11 | @endphp 12 |
13 | 17 |
18 | @endif 19 | @csrf 20 |
23 | 24 |
38 | 46 |
47 | @if(!empty($users) && $users->count() > 0) 48 | @include('commentify::livewire.partials.dropdowns.users') 49 | @endif 50 | @error($state.'.body') 51 |

52 | {{$message}} 53 |

54 | @enderror 55 |
56 | 57 | 58 | 62 | 63 | @include('commentify::livewire.partials.loader') 64 | 65 | 66 | {{ $button }} 67 | 68 | 69 | 70 |
71 | @else 72 |
{{ __('commentify::commentify.comments.read_only_message') }}
73 | @endif 74 | -------------------------------------------------------------------------------- /resources/views/livewire/partials/comment-reply.blade.php: -------------------------------------------------------------------------------- 1 | @if(config('commentify.comment_nesting') === true) 2 | @auth 3 | @if($comment->isParent()) 4 | 13 |
14 | @include('commentify::livewire.partials.loader') 15 |
16 | @endif 17 | @endauth 18 | @if($comment->children->count()) 19 | 27 |
28 | @include('commentify::livewire.partials.loader') 29 |
30 | @endif 31 | @endif 32 | -------------------------------------------------------------------------------- /resources/views/livewire/partials/dropdowns/users.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 15 |
16 | -------------------------------------------------------------------------------- /resources/views/livewire/partials/loader.blade.php: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/Http/Livewire/Comment.php: -------------------------------------------------------------------------------- 1 | '' 33 | ]; 34 | 35 | public $editState = [ 36 | 'body' => '' 37 | ]; 38 | 39 | protected $validationAttributes = [ 40 | 'replyState.body' => 'Reply', 41 | 'editState.body' => 'Reply' 42 | ]; 43 | 44 | 45 | /** 46 | * @param $isEditing 47 | * @return void 48 | */ 49 | public function updatedIsEditing($isEditing): void 50 | { 51 | if (!$isEditing) { 52 | return; 53 | } 54 | $this->editState = [ 55 | 'body' => $this->comment->body 56 | ]; 57 | } 58 | 59 | /** 60 | * @return void 61 | * @throws \Illuminate\Auth\Access\AuthorizationException 62 | */ 63 | public function editComment(): void 64 | { 65 | $this->authorize('update', $this->comment); 66 | $this->validate([ 67 | 'editState.body' => 'required|min:2' 68 | ]); 69 | $this->comment->update($this->editState); 70 | $this->isEditing = false; 71 | $this->showOptions = false; 72 | } 73 | 74 | /** 75 | * @return void 76 | * @throws AuthorizationException 77 | */ 78 | #[On('refresh')] 79 | public function deleteComment(): void 80 | { 81 | $this->authorize('destroy', $this->comment); 82 | $this->comment->delete(); 83 | $this->showOptions = false; 84 | $this->dispatch('refresh'); 85 | } 86 | 87 | /** 88 | * @return Factory|Application|View|\Illuminate\Contracts\Foundation\Application|null 89 | */ 90 | public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\Foundation\Application|\Illuminate\Contracts\View\View|\Illuminate\Contracts\Foundation\Application|null 91 | { 92 | return view('commentify::livewire.comment'); 93 | } 94 | 95 | /** 96 | * @return void 97 | */ 98 | #[On('refresh')] 99 | public function postReply(): void 100 | { 101 | if (config('commentify.read_only')) { 102 | session()->flash('message', __('commentify::commentify.comments.read_only_message')); 103 | session()->flash('alertType', 'warning'); 104 | return; 105 | } 106 | 107 | $this->authorize('create', \Usamamuneerchaudhary\Commentify\Models\Comment::class); 108 | 109 | if (!$this->comment->isParent()) { 110 | return; 111 | } 112 | $this->validate([ 113 | 'replyState.body' => 'required' 114 | ]); 115 | $reply = $this->comment->children()->make($this->replyState); 116 | $reply->user()->associate(auth()->user()); 117 | $reply->commentable()->associate($this->comment->commentable); 118 | $reply->save(); 119 | 120 | $this->replyState = [ 121 | 'body' => '' 122 | ]; 123 | $this->isReplying = false; 124 | $this->showOptions = false; 125 | $this->dispatch('refresh')->self(); 126 | } 127 | 128 | /** 129 | * @param $userName 130 | * @return void 131 | */ 132 | public function selectUser($userName): void 133 | { 134 | if ($this->replyState['body']) { 135 | $this->replyState['body'] = preg_replace('/@(\w+)$/', '@' . str_replace(' ', '_', Str::lower($userName)) . ' ', 136 | $this->replyState['body']); 137 | // $this->replyState['body'] =$userName; 138 | $this->users = []; 139 | } elseif ($this->editState['body']) { 140 | $this->editState['body'] = preg_replace('/@(\w+)$/', '@' . str_replace(' ', '_', Str::lower($userName)) . ' ', 141 | $this->editState['body']); 142 | $this->users = []; 143 | } 144 | } 145 | 146 | 147 | /** 148 | * @param $searchTerm 149 | * @return void 150 | */ 151 | #[On('getUsers')] 152 | public function getUsers($searchTerm): void 153 | { 154 | if (!empty($searchTerm)) { 155 | $this->users = User::where('name', 'like', '%' . $searchTerm . '%')->take(5)->get(); 156 | } else { 157 | $this->users = []; 158 | } 159 | } 160 | 161 | } 162 | -------------------------------------------------------------------------------- /src/Http/Livewire/Comments.php: -------------------------------------------------------------------------------- 1 | '' 29 | ]; 30 | 31 | protected $listeners = [ 32 | 'refresh' => '$refresh' 33 | ]; 34 | 35 | protected $validationAttributes = [ 36 | 'newCommentState.body' => 'comment' 37 | ]; 38 | 39 | public function mount(Model $model) 40 | { 41 | $this->model = $model; 42 | } 43 | 44 | /** 45 | * @return Factory|Application|View|\Illuminate\Contracts\Foundation\Application|null 46 | */ 47 | public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\Foundation\Application|\Illuminate\Contracts\View\View|\Illuminate\Contracts\Foundation\Application|null 48 | { 49 | $comments = $this->model 50 | ->comments() 51 | ->with('user', 'children.user', 'children.children') 52 | ->parent() 53 | ->latest() 54 | ->paginate(config('commentify.pagination_count', 10)); 55 | return view('commentify::livewire.comments', [ 56 | 'comments' => $comments 57 | ]); 58 | } 59 | 60 | /** 61 | * @return void 62 | */ 63 | #[On('refresh')] 64 | public function postComment(): void 65 | { 66 | if (config('commentify.read_only')) { 67 | session()->flash('message', __('commentify::commentify.comments.read_only_message')); 68 | session()->flash('alertType', 'warning'); 69 | return; 70 | } 71 | 72 | // Authorize using the CommentPolicy@create method 73 | $this->authorize('create', \Usamamuneerchaudhary\Commentify\Models\Comment::class); 74 | 75 | $this->validate([ 76 | 'newCommentState.body' => 'required' 77 | ]); 78 | 79 | $comment = $this->model->comments()->make($this->newCommentState); 80 | $comment->user()->associate(auth()->user()); 81 | $comment->save(); 82 | 83 | $this->newCommentState = [ 84 | 'body' => '' 85 | ]; 86 | $this->users = []; 87 | $this->showDropdown = false; 88 | 89 | $this->resetPage(); 90 | session()->flash('message', 'Comment Posted Successfully!'); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Http/Livewire/Like.php: -------------------------------------------------------------------------------- 1 | comment = $comment; 21 | $this->count = $comment->likes_count; 22 | } 23 | 24 | public function like(): void 25 | { 26 | $ip = request()->ip(); 27 | $userAgent = request()->userAgent(); 28 | if ($this->comment->isLiked()) { 29 | $this->comment->removeLike(); 30 | 31 | $this->count--; 32 | } elseif (auth()->user()) { 33 | $this->comment->likes()->create([ 34 | 'user_id' => auth()->id(), 35 | ]); 36 | 37 | $this->count++; 38 | } elseif ($ip && $userAgent) { 39 | $this->comment->likes()->create([ 40 | 'ip' => $ip, 41 | 'user_agent' => $userAgent, 42 | ]); 43 | 44 | $this->count++; 45 | } 46 | } 47 | 48 | /** 49 | * @return Factory|Application|View|\Illuminate\Contracts\Foundation\Application|null 50 | */ 51 | public function render( 52 | ): \Illuminate\Contracts\View\Factory|\Illuminate\Foundation\Application|\Illuminate\Contracts\View\View|\Illuminate\Contracts\Foundation\Application|null 53 | { 54 | return view('commentify::livewire.like'); 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/Models/Comment.php: -------------------------------------------------------------------------------- 1 | parent_id); 49 | } 50 | 51 | /** 52 | * @return BelongsTo 53 | */ 54 | public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo 55 | { 56 | return $this->belongsTo(User::class); 57 | } 58 | 59 | /** 60 | * @return HasMany 61 | */ 62 | public function children(): \Illuminate\Database\Eloquent\Relations\HasMany 63 | { 64 | return $this->hasMany(Comment::class, 'parent_id')->oldest(); 65 | } 66 | 67 | /** 68 | * @return MorphTo 69 | */ 70 | public function commentable(): \Illuminate\Database\Eloquent\Relations\MorphTo 71 | { 72 | return $this->morphTo(); 73 | } 74 | 75 | /** 76 | * @return CommentFactory 77 | */ 78 | protected static function newFactory(): CommentFactory 79 | { 80 | return CommentFactory::new(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Models/CommentLike.php: -------------------------------------------------------------------------------- 1 | where('ip', $ip); 41 | } 42 | 43 | /** 44 | * @param $query 45 | * @param string $userAgent 46 | * @return mixed 47 | */ 48 | public function scopeForUserAgent($query, string $userAgent): mixed 49 | { 50 | return $query->where('user_agent', $userAgent); 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/Models/Presenters/CommentPresenter.php: -------------------------------------------------------------------------------- 1 | comment = $comment; 22 | } 23 | 24 | /** 25 | * @return HtmlString 26 | */ 27 | public function markdownBody(): HtmlString 28 | { 29 | return new HtmlString(app('markdown')->convertToHtml($this->comment->body)); 30 | } 31 | 32 | /** 33 | * @return mixed 34 | */ 35 | public function relativeCreatedAt(): mixed 36 | { 37 | return $this->comment->created_at->diffForHumans(); 38 | } 39 | 40 | /** 41 | * @param $text 42 | * @return array|string 43 | */ 44 | public function replaceUserMentions($text): array|string 45 | { 46 | preg_match_all('/@([A-Za-z0-9_]+)/', $text, $matches); 47 | $usernames = $matches[1]; 48 | $replacements = []; 49 | 50 | foreach ($usernames as $username) { 51 | $user = User::where('name', $username)->first(); 52 | 53 | if ($user) { 54 | $userRoutePrefix = config('commentify.users_route_prefix', 'users'); 55 | 56 | $replacements['@'.$username] = '@'.$username. 57 | ''; 58 | } else { 59 | $replacements['@'.$username] = '@'.$username; 60 | } 61 | } 62 | 63 | return str_replace(array_keys($replacements), array_values($replacements), $text); 64 | } 65 | 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/Models/User.php: -------------------------------------------------------------------------------- 1 | hasMany(CommentLike::class); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Policies/CommentPolicy.php: -------------------------------------------------------------------------------- 1 | isCommentBanned()) { 23 | return Response::deny(__('commentify::commentify.comments.banned_message'), 403); 24 | } 25 | 26 | return Response::allow(); 27 | } 28 | 29 | /** 30 | * @param $user 31 | * @param Comment $comment 32 | * @return Response 33 | */ 34 | public function update($user, Comment $comment): Response 35 | { 36 | if (method_exists($user, 'isCommentBanned') && $user->isCommentBanned()) { 37 | return Response::deny(__('commentify::commentify.comments.banned_message'), 403); 38 | } 39 | return $user->id === $comment->user_id 40 | ? Response::allow() 41 | : Response::denyWithStatus(401); 42 | } 43 | 44 | 45 | /** 46 | * @param $user 47 | * @param Comment $comment 48 | * @return Response 49 | */ 50 | public function destroy($user, Comment $comment): Response 51 | { 52 | if (method_exists($user, 'isCommentBanned') && $user->isCommentBanned()) { 53 | return Response::deny(__('commentify::commentify.comments.banned_message'), 403); 54 | } 55 | return $user->id === $comment->user_id 56 | ? Response::allow() 57 | : Response::denyWithStatus(401); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Providers/CommentifyServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->bind(CommentPolicy::class, function ($app) { 22 | return new CommentPolicy; 23 | }); 24 | 25 | Gate::policy(\Usamamuneerchaudhary\Commentify\Models\Comment::class, CommentPolicy::class); 26 | 27 | $this->app->register(MarkdownServiceProvider::class); 28 | } 29 | 30 | 31 | /** 32 | * @return void 33 | */ 34 | public function boot(): void 35 | { 36 | if ($this->app->runningInConsole()) { 37 | // Publish config file 38 | $this->publishes([ 39 | __DIR__.'/../../config/commentify.php' => config_path('commentify.php'), 40 | ], 'commentify-config'); 41 | 42 | $this->publishes([ 43 | __DIR__.'/../../tailwind.config.js' => base_path('tailwind.config.js'), 44 | ], 'commentify-tailwind-config'); 45 | 46 | // Add this line to publish your views 47 | $this->publishes([ 48 | __DIR__.'/../../resources/views' => resource_path('views/vendor/commentify'), 49 | ], 'commentify-views'); 50 | 51 | // Publish language files 52 | $this->publishes([ 53 | __DIR__.'/../../lang' => resource_path('../lang/vendor/commentify'), 54 | ], 'commentify-lang'); 55 | } 56 | $this->loadMigrationsFrom(__DIR__.'/../../database/migrations'); 57 | $this->loadViewsFrom(__DIR__.'/../../resources/views', 'commentify'); 58 | $this->loadTranslationsFrom(__DIR__.'/../../lang', 'commentify'); 59 | Livewire::component('comments', Comments::class); 60 | Livewire::component('comment', Comment::class); 61 | Livewire::component('like', Like::class); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Providers/MarkdownServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton('markdown', function () { 21 | $environment = new Environment([ 22 | 'allow_unsafe_links' => false, 23 | 'html_input' => 'strip' 24 | ]); 25 | 26 | $environment->addExtension(new CommonMarkCoreExtension); 27 | 28 | return new MarkdownConverter($environment); 29 | }); 30 | } 31 | 32 | 33 | /** 34 | * @return void 35 | */ 36 | public function boot(): void 37 | { 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Scopes/CommentScopes.php: -------------------------------------------------------------------------------- 1 | whereNull('parent_id'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Scopes/HasLikes.php: -------------------------------------------------------------------------------- 1 | hasMany(CommentLike::class); 17 | } 18 | 19 | /** 20 | * @return bool 21 | */ 22 | public function isLiked(): bool 23 | { 24 | $ip = request()->ip(); 25 | $userAgent = request()->userAgent(); 26 | if (auth()->user()) { 27 | return $this->likes()->where('user_id', auth()->user()->id)->exists(); 28 | } 29 | 30 | if ($ip && $userAgent) { 31 | return $this->likes()->forIp($ip)->forUserAgent($userAgent)->exists(); 32 | } 33 | 34 | return false; 35 | } 36 | 37 | /** 38 | * @return bool 39 | */ 40 | public function removeLike(): bool 41 | { 42 | $ip = request()->ip(); 43 | $userAgent = request()->userAgent(); 44 | if (auth()->user()) { 45 | return $this->likes()->where('user_id', auth()->user()->id)->where('comment_id', $this->id)->delete(); 46 | } 47 | 48 | if ($ip && $userAgent) { 49 | return $this->likes()->forIp($ip)->forUserAgent($userAgent)->delete(); 50 | } 51 | 52 | return false; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Traits/Commentable.php: -------------------------------------------------------------------------------- 1 | morphMany(Comment::class, 'commentable'); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/Traits/HasCommentBan.php: -------------------------------------------------------------------------------- 1 | comment_banned_until)) { 17 | return false; 18 | } 19 | return $this->comment_banned_until && Carbon::parse($this->comment_banned_until)->isFuture(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Traits/HasUserAvatar.php: -------------------------------------------------------------------------------- 1 | email).'?s=80&d=mp'; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme'); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: [ 6 | './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', 7 | './storage/framework/views/*.php', 8 | './resources/views/**/*.blade.php', 9 | ], 10 | 11 | theme: { 12 | extend: { 13 | colors: { 14 | primary: { 15 | "50": "#eff6ff", 16 | "100": "#dbeafe", 17 | "200": "#bfdbfe", 18 | "300": "#93c5fd", 19 | "400": "#60a5fa", 20 | "500": "#3b82f6", 21 | "600": "#2563eb", 22 | "700": "#1d4ed8", 23 | "800": "#1e40af", 24 | "900": "#1e3a8a" 25 | } 26 | } 27 | }, 28 | fontFamily: { 29 | 'body': [ 30 | 'Open Sans', 31 | 'ui-sans-serif', 32 | 'system-ui', 33 | '-apple-system', 34 | 'system-ui', 35 | 'Segoe UI', 36 | 'Roboto', 37 | 'Helvetica Neue', 38 | 'Arial', 39 | 'Noto Sans', 40 | 'sans-serif', 41 | 'Apple Color Emoji', 42 | 'Segoe UI Emoji', 43 | 'Segoe UI Symbol', 44 | 'Noto Color Emoji' 45 | ], 46 | 'sans': [ 47 | 'Open Sans', 48 | 'ui-sans-serif', 49 | 'system-ui', 50 | '-apple-system', 51 | 'system-ui', 52 | 'Segoe UI', 53 | 'Roboto', 54 | 'Helvetica Neue', 55 | 'Arial', 56 | 'Noto Sans', 57 | 'sans-serif', 58 | 'Apple Color Emoji', 59 | 'Segoe UI Emoji', 60 | 'Segoe UI Symbol', 61 | 'Noto Color Emoji' 62 | ] 63 | } 64 | }, 65 | 66 | plugins: [require('@tailwindcss/forms')], 67 | }; 68 | -------------------------------------------------------------------------------- /tests/CommentPresenterTest.php: -------------------------------------------------------------------------------- 1 | article = \ArticleStub::create([ 25 | 'slug' => \Illuminate\Support\Str::slug('Article One') 26 | ]); 27 | $this->user = User::factory()->create([ 28 | 'comment_banned_until' => null, // Not banned 29 | ]); 30 | 31 | $this->comment = $this->article->comments()->create([ 32 | 'body' => 'This is a test comment', 33 | 'commentable_type' => '\ArticleStub', 34 | 'commentable_id' => $this->article->id, 35 | 'user_id' => $this->user->id, 36 | 'parent_id' => null, 37 | 'created_at' => date('Y-m-d H:i:s', strtotime('-1 hour')) 38 | ]); 39 | 40 | $this->commentPresenter = new CommentPresenter($this->comment); 41 | } 42 | 43 | /** @test */ 44 | public function it_can_convert_comment_body_to_markdown_html() 45 | { 46 | $expectedOutput = 'This is a test comment'; 47 | $this->assertEquals(new HtmlString(app('markdown')->convertToHtml($expectedOutput)), 48 | $this->commentPresenter->markdownBody()); 49 | } 50 | 51 | /** @test */ 52 | public function it_can_get_relative_created_at_time() 53 | { 54 | $expectedOutput = '1 hour ago'; 55 | $this->assertEquals($expectedOutput, $this->commentPresenter->relativeCreatedAt()); 56 | } 57 | 58 | 59 | /** @test */ 60 | public function it_can_replace_user_mentions_in_text_with_links() 61 | { 62 | $expectedOutput = 'Hello @usama, this is a test comment mentioning!'; 63 | $this->assertEquals($expectedOutput, $this->commentPresenter->replaceUserMentions($expectedOutput)); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/CommentTest.php: -------------------------------------------------------------------------------- 1 | article = \ArticleStub::create([ 17 | 'slug' => \Illuminate\Support\Str::slug('Article One') 18 | ]); 19 | $this->user = User::factory()->create([ 20 | 'comment_banned_until' => null, // Not banned 21 | ]); 22 | 23 | $this->comment = $this->article->comments()->create([ 24 | 'body' => 'This is a test comment!', 25 | 'commentable_type' => '\ArticleStub', 26 | 'commentable_id' => $this->article->id, 27 | 'user_id' => $this->user->id, 28 | 'parent_id' => null, 29 | 'created_at' => now() 30 | ]); 31 | } 32 | 33 | /** @test */ 34 | public function comment_can_be_persisted_in_database() 35 | { 36 | $user = User::factory()->create(); 37 | $comment = Comment::factory()->create([ 38 | 'user_id' => $user->id 39 | ]); 40 | 41 | $this->assertDatabaseHas('comments', [ 42 | 'id' => $comment->id, 43 | 'body' => $comment->body 44 | ]); 45 | } 46 | 47 | /** @test */ 48 | public function comment_has_user_relation() 49 | { 50 | $user = User::factory()->create(); 51 | $comment = Comment::factory()->create([ 52 | 'user_id' => $user->id 53 | ]); 54 | 55 | $this->assertInstanceOf(User::class, $comment->user); 56 | } 57 | 58 | /** @test */ 59 | public function comment_has_children_relation() 60 | { 61 | $comment = Comment::factory()->create([ 62 | 'parent_id' => null 63 | ]); 64 | Comment::factory()->count(2)->create([ 65 | 'parent_id' => $comment->id 66 | ]); 67 | 68 | $this->assertInstanceOf(Comment::class, $comment->children->first()); 69 | $this->assertCount(2, $comment->children); 70 | } 71 | 72 | /** @test */ 73 | public function comment_has_commentable_relation() 74 | { 75 | $this->assertEquals('ArticleStub', $this->comment->commentable_type); 76 | $this->assertEquals(1, $this->comment->commentable_id); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | name('login'); 33 | $this->app->register(\Flux\FluxServiceProvider::class); 34 | Model::unguard(); 35 | 36 | $this->artisan('migrate', [ 37 | '--database' => 'testbench', 38 | '--realpath' => realpath(__DIR__ . '/../database/migrations') 39 | ]); 40 | } 41 | 42 | /** 43 | * @return void 44 | */ 45 | public function tearDown(): void 46 | { 47 | Schema::drop('articles'); 48 | Schema::drop('episodes'); 49 | Schema::drop('comments'); 50 | } 51 | 52 | /** 53 | * @param $app 54 | * @return void 55 | */ 56 | protected function getEnvironmentSetUp($app): void 57 | { 58 | $app['config']->set('database.default', 'testbench'); 59 | $app['config']->set('database.connections.testbench', [ 60 | 'driver' => 'sqlite', 61 | 'database' => ':memory:', 62 | 'prefix' => '' 63 | ]); 64 | 65 | 66 | Schema::create('users', function ($table) { 67 | $table->increments('id'); 68 | $table->string('name'); 69 | $table->string('email')->unique(); 70 | $table->timestamp('email_verified_at')->nullable(); 71 | $table->string('password'); 72 | $table->rememberToken(); 73 | $table->timestamps(); 74 | }); 75 | Schema::create('articles', function ($table) { 76 | $table->increments('id'); 77 | $table->string('slug')->unique(); 78 | $table->timestamps(); 79 | }); 80 | 81 | Schema::create('episodes', function ($table) { 82 | $table->increments('id'); 83 | $table->string('slug')->unique(); 84 | $table->timestamps(); 85 | }); 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /tests/Unit/Http/Livewire/CommentComponentTest.php: -------------------------------------------------------------------------------- 1 | article = \ArticleStub::create([ 22 | 'slug' => \Illuminate\Support\Str::slug('Article One') 23 | ]); 24 | $this->episode = \EpisodeStub::create([ 25 | 'slug' => \Illuminate\Support\Str::slug('Episode One') 26 | ]); 27 | $this->user = User::factory()->create([ 28 | 'comment_banned_until' => null, 29 | ]); 30 | 31 | $this->comment = $this->article->comments()->create([ 32 | 'body' => 'This is a test comment!', 33 | 'commentable_type' => '\ArticleStub', 34 | 'commentable_id' => $this->article->id, 35 | 'user_id' => $this->user->id, 36 | 'parent_id' => null, 37 | 'created_at' => now() 38 | ]); 39 | } 40 | 41 | /** @test */ 42 | public function it_can_edit_a_comment() 43 | { 44 | $this->actingAs($this->user); 45 | Livewire::test(\Usamamuneerchaudhary\Commentify\Http\Livewire\Comments::class, [ 46 | 'model' => $this->article 47 | ]) 48 | ->set('newCommentState.body', $this->comment->body) 49 | ->call('postComment') 50 | ->assertSee($this->comment->body); 51 | 52 | Livewire::test(LivewireComment::class, [ 53 | 'comment' => $this->comment 54 | ]) 55 | ->set('editState.body', 'Updated comment!!!') 56 | ->call('editComment') 57 | ->assertSee('Updated comment!!!'); 58 | } 59 | 60 | /** @test */ 61 | public function only_authenticated_user_can_edit_a_comment() 62 | { 63 | Livewire::test(LivewireComment::class, [ 64 | 'comment' => $this->comment 65 | ]) 66 | ->assertSee($this->comment->body) 67 | ->assertDontSee('Reply'); 68 | } 69 | 70 | /** @test */ 71 | public function it_can_reply_to_a_comment() 72 | { 73 | $this->actingAs($this->user); 74 | $reply = $this->comment->children()->make([ 75 | 'body' => 'this is a reply', 76 | 'parent_id' => $this->comment->id, 77 | 'created_at' => now(), 78 | 'updated_at' => now() 79 | ]); 80 | $reply->user()->associate($this->user); 81 | $reply->commentable()->associate($this->comment->commentable); 82 | $reply->save(); 83 | 84 | Livewire::test(LivewireComment::class, [ 85 | 'comment' => $reply 86 | ]) 87 | ->set('replyState.body', $reply) 88 | ->call('postReply') 89 | ->assertSee($reply->body); 90 | } 91 | 92 | /** @test */ 93 | public function it_can_only_edit_to_a_comment_if_owner() 94 | { 95 | $this->actingAs($this->user); 96 | 97 | if ($this->user->id == $this->comment->user_id) { 98 | Livewire::test(LivewireComment::class, [ 99 | 'comment' => $this->comment 100 | ]) 101 | ->set('editState.body', 'Updated comment!!!') 102 | ->call('editComment') 103 | ->assertSee('Updated comment!!!'); 104 | } 105 | } 106 | 107 | /** @test */ 108 | public function it_can_not_edit_a_comment_if_owned_by_another_user() 109 | { 110 | $newUser = User::factory()->create(); 111 | $this->actingAs($newUser); 112 | 113 | if ($newUser->id != $this->comment->user_id) { 114 | Livewire::test(LivewireComment::class, [ 115 | 'comment' => $this->comment 116 | ]) 117 | ->set('editState.body', 'Updated comment!!!') 118 | ->call('editComment') 119 | ->assertUnauthorized(); 120 | } 121 | } 122 | 123 | /** @test */ 124 | public function it_can_only_delete_a_comment_if_owner() 125 | { 126 | $this->actingAs($this->user); 127 | 128 | if ($this->user->id == $this->comment->user_id) { 129 | Livewire::test(LivewireComment::class, [ 130 | 'comment' => $this->comment 131 | ]) 132 | ->call('deleteComment') 133 | ->dispatch('refresh') 134 | ->set('showOptions', false); 135 | $this->assertTrue($this->comment->delete()); 136 | $this->assertDatabaseHas('comments', []); 137 | } 138 | } 139 | 140 | /** @test */ 141 | public function only_authorized_users_can_edit_comments() 142 | { 143 | $user = User::factory()->create(); 144 | $comment = Comment::factory()->create([ 145 | 'user_id' => $user->id 146 | ]); 147 | $this->actingAs(User::factory()->create()); 148 | Livewire::test(LivewireComment::class, ['comment' => $comment]) 149 | ->set('isEditing', true) 150 | ->set('editState.body', 'edited commented') 151 | ->call('editComment') 152 | ->assertStatus(401); 153 | } 154 | 155 | /** @test */ 156 | public function only_authorized_users_can_delete_comments() 157 | { 158 | $user = User::factory()->create(); 159 | $comment = Comment::factory()->create([ 160 | 'user_id' => $user->id 161 | ]); 162 | 163 | $this->actingAs(User::factory()->create()); 164 | Livewire::test(LivewireComment::class, ['comment' => $comment]) 165 | ->call('deleteComment') 166 | ->assertStatus(401); 167 | } 168 | 169 | /** @test */ 170 | public function it_can_show_reply_boxes_for_child_comments() 171 | { 172 | $this->actingAs($this->user); 173 | $childComment = Comment::factory()->create([ 174 | 'parent_id' => $this->comment->id 175 | ]); 176 | 177 | Livewire::test(LivewireComment::class, ['comment' => $childComment]) 178 | ->set('isReplying', true) 179 | ->assertSee('Post Reply') 180 | ->assertDontSee('Cancel'); 181 | } 182 | 183 | /** @test */ 184 | public function it_can_mention_users_in_reply() 185 | { 186 | $this->actingAs($this->user); 187 | $user = User::factory()->create([ 188 | 'name' => 'Usama Munir' 189 | ]); 190 | $childComment = Comment::factory()->create([ 191 | 'parent_id' => $this->comment->id 192 | ]); 193 | 194 | $component = Livewire::test(LivewireComment::class, ['comment' => $childComment]) 195 | ->set('isReplying', true) 196 | ->assertDontSee('@Usama_Munir') 197 | ->assertDontSee('@usamamunir') 198 | ->set('replyState.body', '@usama') 199 | ->call('getUsers', 'usama') 200 | ->assertSee('Usama') 201 | ->call('selectUser', 'usama'); 202 | $this->assertEquals('@usama ', $component->get('replyState.body')); 203 | } 204 | 205 | /** @test */ 206 | public function it_can_mention_users_when_editing_a_child_comment() 207 | { 208 | $this->actingAs($this->user); 209 | $user = User::factory()->create([ 210 | 'name' => 'Usama Munir' 211 | ]); 212 | $childComment = Comment::factory()->create([ 213 | 'parent_id' => $this->comment->id 214 | ]); 215 | 216 | $component = Livewire::test(LivewireComment::class, ['comment' => $childComment]) 217 | ->set('isEditing', true) 218 | ->assertDontSee('@Usama_Munir') 219 | ->assertDontSee('@usamamunir') 220 | ->set('editState.body', '@usama') 221 | ->call('getUsers', 'usama') 222 | ->assertSee('Usama') 223 | ->call('selectUser', 'usama'); 224 | $this->assertEquals('@usama ', $component->get('editState.body')); 225 | } 226 | 227 | /** @test */ 228 | public function it_can_mention_users_when_editing_a_comment() 229 | { 230 | $this->actingAs($this->user); 231 | $user = User::factory()->create([ 232 | 'name' => 'Usama Munir' 233 | ]); 234 | 235 | 236 | $component = Livewire::test(LivewireComment::class, ['comment' => $this->comment]) 237 | ->set('isEditing', true) 238 | ->assertDontSee('@Usama_Munir') 239 | ->assertDontSee('@usamamunir') 240 | ->set('editState.body', '@usama') 241 | ->call('getUsers', 'usama') 242 | ->assertSee('Usama') 243 | ->call('selectUser', 'usama'); 244 | $this->assertEquals('@usama ', $component->get('editState.body')); 245 | } 246 | 247 | /** @test */ 248 | public function it_can_edit_comment() 249 | { 250 | $this->actingAs($this->user); 251 | Livewire::test(LivewireComment::class, ['comment' => $this->comment]) 252 | ->set('isEditing', true) 253 | ->set('editState.body', 'This is an edited comment!!!!') 254 | ->call('editComment') 255 | ->assertSee('This is an edited comment!!!!'); 256 | } 257 | 258 | /** @test */ 259 | public function it_can_delete_comment() 260 | { 261 | $this->actingAs($this->user); 262 | $this->assertNotNull($this->comment); 263 | Livewire::test(LivewireComment::class, ['comment' => $this->comment]) 264 | ->call('deleteComment') 265 | ->dispatch('refresh') 266 | ->set('showOptions', false); 267 | $this->assertTrue($this->comment->delete()); 268 | $this->assertDatabaseHas('comments', []); 269 | } 270 | 271 | /** @test */ 272 | public function it_shows_validation_error_on_edit_submit_if_required_fields_empty() 273 | { 274 | $this->actingAs($this->user); 275 | Livewire::test(LivewireComment::class, ['comment' => $this->comment]) 276 | ->set('isEditing', true) 277 | ->set('editState.body', '') 278 | ->call('editComment') 279 | ->assertHasErrors(['editState.body' => 'required']); 280 | } 281 | 282 | /** @test */ 283 | public function it_shows_validation_error_on_reply_post_if_body_empty() 284 | { 285 | $user = User::factory()->create([ 286 | 'comment_banned_until' => null, 287 | ]); 288 | $this->actingAs($user); 289 | Livewire::test(LivewireComment::class, ['comment' => $this->comment]) 290 | ->set('isReplying', true) 291 | ->set('replyState.body', '') 292 | ->call('postReply') 293 | ->assertHasErrors(['replyState.body' => 'required']); 294 | } 295 | 296 | /** @test */ 297 | public function it_renders_livewire_component_correctly() 298 | { 299 | $this->actingAs($this->user); 300 | 301 | $view = Livewire::test(LivewireComment::class, ['comment' => $this->comment]) 302 | ->assertViewIs('commentify::livewire.comment') 303 | ->assertViewHas('comment'); 304 | } 305 | 306 | /** @test */ 307 | public function can_search_for_users_for_mentioning() 308 | { 309 | $this->actingAs($this->user); 310 | $user1 = User::factory()->create([ 311 | 'name' => 'Usama Munir' 312 | ]); 313 | $user2 = User::factory()->create([ 314 | 'name' => 'John Doe' 315 | ]); 316 | Livewire::test(LivewireComment::class, ['comment' => $this->comment]) 317 | ->set('isEditing', true) 318 | ->call('getUsers', 'usa') 319 | ->assertSee($user1->name); 320 | } 321 | 322 | /** @test */ 323 | public function it_should_not_set_edit_state_if_not_editing() 324 | { 325 | $this->actingAs($this->user); 326 | 327 | Livewire::test(LivewireComment::class, ['comment' => $this->comment]) 328 | ->set('isEditing', false); 329 | 330 | $this->assertNotEquals([ 331 | 'body' => $this->comment->body 332 | ], $this->comment->editState); 333 | } 334 | 335 | /** @test */ 336 | public function it_should_not_set_reply_state_if_not_replying() 337 | { 338 | $this->actingAs($this->user); 339 | 340 | Livewire::test(LivewireComment::class, ['comment' => $this->comment]) 341 | ->set('isReplying', false); 342 | 343 | $this->assertNotEquals([ 344 | 'body' => $this->comment->body 345 | ], $this->comment->replyState); 346 | } 347 | 348 | /** @test */ 349 | public function it_should_only_post_reply_if_parent_comment() 350 | { 351 | $this->actingAs($this->user); 352 | $reply = $this->comment->children()->make([ 353 | 'body' => 'this is a reply', 354 | 'parent_id' => $this->comment->id, 355 | 'created_at' => now(), 356 | 'updated_at' => now() 357 | ]); 358 | $reply->user()->associate($this->user); 359 | $reply->commentable()->associate($this->comment->commentable); 360 | $reply->save(); 361 | 362 | Livewire::test(LivewireComment::class, [ 363 | 'comment' => $reply 364 | ]) 365 | ->set('isReplying', true) 366 | ->set('replyState.body', $reply) 367 | ->call('postReply') 368 | ->assertSee($reply->body); 369 | 370 | $this->assertCount(1, $this->comment->children); 371 | $this->assertEquals('this is a reply', $this->comment->children->first()->body); 372 | } 373 | 374 | } 375 | -------------------------------------------------------------------------------- /tests/Unit/Http/Livewire/CommentsComponentTest.php: -------------------------------------------------------------------------------- 1 | article = \ArticleStub::create([ 20 | 'slug' => \Illuminate\Support\Str::slug('Article One') 21 | ]); 22 | $this->episode = \EpisodeStub::create([ 23 | 'slug' => \Illuminate\Support\Str::slug('Episode One') 24 | ]); 25 | $this->user = User::factory()->create([ 26 | 'comment_banned_until' => null, // Not banned 27 | ]); 28 | 29 | $this->comment = $this->article->comments()->create([ 30 | 'body' => 'This is a test comment!', 31 | 'commentable_type' => '\ArticleStub', 32 | 'commentable_id' => $this->article->id, 33 | 'user_id' => $this->user->id, 34 | 'parent_id' => null, 35 | 'created_at' => now() 36 | ]); 37 | } 38 | 39 | /** @test */ 40 | public function it_shows_comment_component_livewire() 41 | { 42 | $this->actingAs($this->user); 43 | Livewire::test(Comments::class, [ 44 | 'model' => $this->article 45 | ]) 46 | ->set('newCommentState.body', $this->comment->body) 47 | ->call('postComment') 48 | ->assertSee($this->comment->body); 49 | } 50 | 51 | /** @test */ 52 | public function it_shows_no_comments_text_if_empty_for_model() 53 | { 54 | Livewire::test(Comments::class, [ 55 | 'model' => $this->episode 56 | ]) 57 | ->assertSee('No comments yet!'); 58 | } 59 | 60 | /** @test */ 61 | public function it_doesnt_show_comment_form_if_logged_out() 62 | { 63 | Livewire::test(Comments::class, [ 64 | 'model' => $this->article 65 | ]) 66 | ->assertSee($this->comment->body) 67 | ->assertSee('Log in to comment!'); 68 | } 69 | 70 | /** @test */ 71 | public function it_show_comment_form_if_logged_in() 72 | { 73 | $this->actingAs($this->user); 74 | Livewire::test(Comments::class, [ 75 | 'model' => $this->article 76 | ]) 77 | ->set('newCommentState.body', $this->comment->body) 78 | ->call('postComment') 79 | ->assertSee($this->comment->body) 80 | ->assertSee('Your comment') 81 | ->assertSee('Post comment'); 82 | $this->assertTrue(Comment::where('body', $this->comment->body)->exists()); 83 | $this->assertDatabaseHas('comments', [ 84 | 'body' => $this->comment->body, 85 | 'user_id' => $this->user->id, 86 | 'commentable_id' => $this->article->id 87 | ]); 88 | } 89 | 90 | /** @test */ 91 | public function only_logged_in_user_can_post_a_new_comment() 92 | { 93 | $this->actingAs($this->user); 94 | $this->episode->comments()->create([ 95 | 'body' => 'This is an episode comment!', 96 | 'commentable_type' => 'App\Models\Episode', 97 | 'commentable_id' => $this->episode->id, 98 | 'user_id' => $this->user->id, 99 | 'parent_id' => null, 100 | 'created_at' => now() 101 | ]); 102 | 103 | Livewire::test(Comments::class, [ 104 | 'model' => $this->episode 105 | ]) 106 | ->set('newCommentState.body', $this->episode->comments()->first()->body) 107 | ->call('postComment') 108 | ->assertSee($this->episode->comments()->first()->body); 109 | $this->assertTrue(Comment::where('body', $this->episode->comments()->first()->body) 110 | ->exists()); 111 | $this->assertDatabaseHas('comments', [ 112 | 'body' => $this->episode->comments()->first()->body, 113 | 'user_id' => $this->user->id, 114 | 'commentable_id' => $this->episode->id 115 | ]); 116 | } 117 | 118 | /** @test */ 119 | public function it_shows_validation_error_on_adding_comment_if_required_fields_empty() 120 | { 121 | $user = User::factory()->create([ 122 | 'comment_banned_until' => null, 123 | ]); 124 | $this->actingAs($user); 125 | 126 | Livewire::test(Comments::class, ['model' => $this->article]) 127 | ->set('newCommentState.body', '') 128 | ->call('postComment') 129 | ->assertHasErrors(['newCommentState.body' => 'required']); 130 | } 131 | 132 | /** @test */ 133 | public function it_can_see_comments_total_count() 134 | { 135 | Livewire::test(Comments::class, ['model' => $this->article]) 136 | ->assertSee($this->article->comments()->count()); 137 | } 138 | 139 | 140 | /** @test */ 141 | public function test_pagination_links_if_comments_count() 142 | { 143 | Comment::factory(15)->create([ 144 | 'commentable_id' => $this->article->id, 145 | 'commentable_type' => 'ArticleStub' 146 | ]); 147 | 148 | Livewire::test(Comments::class, ['model' => $this->article]) 149 | ->assertSee(10) 150 | ->assertSeeHtml('') 151 | ->assertSee(2);//second page link 152 | } 153 | 154 | /** @test */ 155 | public function test_no_pagination_links_if_comments_count_less_than_10() 156 | { 157 | Comment::factory(5)->create([ 158 | 'commentable_id' => $this->article->id, 159 | 'commentable_type' => 'ArticleStub' 160 | ]); 161 | 162 | Livewire::test(Comments::class, ['model' => $this->article]) 163 | ->assertSee(6) 164 | ->assertDontSeeHtml(''); 165 | } 166 | 167 | /** @test */ 168 | public function it_renders_livewire_component_correctly() 169 | { 170 | $this->actingAs($this->user); 171 | 172 | Livewire::test(Comments::class, ['model' => $this->article]) 173 | ->assertViewIs('commentify::livewire.comments') 174 | ->assertViewHas('comments'); 175 | } 176 | 177 | } 178 | -------------------------------------------------------------------------------- /tests/Unit/Http/Livewire/LikeComponentTest.php: -------------------------------------------------------------------------------- 1 | article = \ArticleStub::create([ 19 | 'slug' => \Illuminate\Support\Str::slug('Article One') 20 | ]); 21 | $this->episode = \EpisodeStub::create([ 22 | 'slug' => \Illuminate\Support\Str::slug('Episode One') 23 | ]); 24 | $this->user = User::factory()->create([ 25 | 'comment_banned_until' => null, // Not banned 26 | ]); 27 | 28 | $this->comment = $this->article->comments()->create([ 29 | 'body' => 'This is a test comment!', 30 | 'commentable_type' => '\ArticleStub', 31 | 'commentable_id' => $this->article->id, 32 | 'user_id' => $this->user->id, 33 | 'parent_id' => null, 34 | 'created_at' => now() 35 | ]); 36 | } 37 | 38 | /** @test */ 39 | public function it_can_like_comment() 40 | { 41 | Livewire::test(Like::class, ['comment' => $this->comment, 'count' => 0]) 42 | ->call('like') 43 | ->assertSee($this->comment->likes_count + 1); 44 | } 45 | 46 | /** @test */ 47 | public function it_can_unlike_comment() 48 | { 49 | $this->comment->likes()->create(['user_id' => 1]); 50 | 51 | Livewire::test(Like::class, ['comment' => $this->comment, 'count' => 1]) 52 | ->call('like') 53 | ->assertSee($this->comment->likes_count - 1); 54 | } 55 | 56 | /** @test */ 57 | public function auth_users_can_like_comment() 58 | { 59 | $this->actingAs($this->user); 60 | Livewire::test(Like::class, ['comment' => $this->comment, 'count' => 0]) 61 | ->call('like') 62 | ->assertSee($this->comment->likes_count + 1); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/stubs/ArticleStub.php: -------------------------------------------------------------------------------- 1 |