Skip to content

Commit bb27cc8

Browse files
Add support for reactions in comments (#18)
* Add cursor rules and repomix templates * Update cursor rules * Udpate rules * Add comment reactions feature - Introduced a new `comment_reactions` table to store user reactions to comments. - Updated the `Comment` model to include a relationship for reactions. - Implemented reaction toggling in the Livewire component, allowing users to add or remove reactions. - Enhanced the comment view to display reactions and their counts. - Added tests to verify the functionality of adding and removing reactions. This feature enhances user engagement by allowing reactions to comments, similar to social media platforms. * Style fixes * Enhance comment retrieval by eager loading reactions and their reactors - Updated the `getComments` method in the `HasComments` trait to include reactions and their associated reactors. - Simplified the `comments` method in the `CommentList` Livewire component to utilize the updated `getComments` method for improved performance and clarity. * Remove unnecessary configuration resolution in CommentReactionTest * Update comments when updating reactions * Update plan * Add allowed reactions feature for comments - Introduced a new configuration option for allowed reactions in comments, enabling customization of emoji reactions. - Updated the comment view to dynamically display allowed reactions and their counts. - Enhanced the reaction toggling functionality to validate against the allowed reactions. - Added tests to ensure users can only react with configured emojis and that the system handles reactions correctly. This update improves user interaction by allowing a broader range of reactions to comments, enhancing engagement. * Refactor reaction summary access in comment view - Updated the comment view to directly access the reaction summary from the component's property instead of calling a method. - Adjusted the logic for displaying reactions to ensure consistency with the allowed reactions configuration. This change simplifies the code and improves performance by reducing method calls during rendering. * Only display reacted emojis, added sub-component, fixed re-render issue * Dispatching event with the save comment * Fixed tests * Review edits, improvements and tweaks * General tweaks --------- Co-authored-by: Luís Dalmolin <luis.nh@gmail.com>
1 parent f541f59 commit bb27cc8

20 files changed

Lines changed: 592 additions & 49 deletions

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
vendor
22
composer.lock
33
node_modules
4-
.pint.cache
4+
.pint.cache
5+
.repomix/output
6+
.cursor/rules/local.mdc

config/commentions.php

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,39 @@
11
<?php
22

33
return [
4-
/**
5-
* The table name.
6-
*/
7-
'table_name' => 'comments',
4+
/*
5+
|--------------------------------------------------------------------------
6+
| Table name configurations
7+
|--------------------------------------------------------------------------
8+
*/
9+
'tables' => [
10+
'comments' => 'comments',
11+
'comment_reactions' => 'comment_reactions',
12+
],
813

9-
/**
10-
* The commenter config.
11-
*/
14+
/*
15+
|--------------------------------------------------------------------------
16+
| Commenter model configuration
17+
|--------------------------------------------------------------------------
18+
*/
1219
'commenter' => [
1320
'model' => \App\Models\User::class,
1421
],
1522

16-
/**
17-
* Comment editing/deleting options.
18-
*/
23+
/*
24+
|--------------------------------------------------------------------------
25+
| Comment Moderation
26+
|--------------------------------------------------------------------------
27+
*/
1928
'allow_edits' => true,
2029
'allow_deletes' => true,
30+
31+
/*
32+
|--------------------------------------------------------------------------
33+
| Reactions
34+
|--------------------------------------------------------------------------
35+
*/
36+
'reactions' => [
37+
'allowed' => ['👍', '❤️', '😂', '😮', '😢', '🤔'],
38+
],
2139
];
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up()
10+
{
11+
Schema::create(config('commentions.tables.comment_reactions', 'comment_reactions'), function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('comment_id')->constrained(config('commentions.table_name'))->cascadeOnDelete();
14+
$table->morphs('reactor');
15+
$table->string('reaction', 50);
16+
$table->timestamps();
17+
18+
$table->unique(['comment_id', 'reactor_id', 'reactor_type', 'reaction'], 'comment_reactor_reaction_unique');
19+
});
20+
}
21+
22+
public function down(): void
23+
{
24+
Schema::dropIfExists(config('commentions.tables.comment_reactions', 'comment_reactions'));
25+
}
26+
};

database/migrations/create_commentions_tables.php.stub

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@ return new class extends Migration
88
{
99
public function up()
1010
{
11-
Schema::create(config('commentions.table_name'), function (Blueprint $table) {
11+
Schema::create(config('commentions.tables.comments', 'comments'), function (Blueprint $table) {
1212
$table->id();
1313
$table->morphs('author');
1414
$table->morphs('commentable');
1515
$table->text('body');
1616
$table->timestamps();
1717
});
1818
}
19+
20+
public function down(): void
21+
{
22+
Schema::dropIfExists(config('commentions.tables.comments', 'comments'));
23+
}
1924
};

resources/dist/commentions.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/views/comment.blade.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ class="text-xs text-gray-300 ml-1"
8080
</div>
8181
@else
8282
<div class="mt-1 space-y-6 text-sm text-gray-800 dark:text-gray-200">{!! $comment->getParsedBody() !!}</div>
83+
84+
@if ($comment->isComment())
85+
<livewire:commentions::reactions
86+
:comment="$comment"
87+
:wire:key="'reaction-manager-' . $comment->getId()"
88+
/>
89+
@endif
8390
@endif
8491
</div>
8592

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<div class="relative mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 flex items-center gap-x-1 flex-wrap">
2+
{{-- Inline buttons for existing reactions --}}
3+
@foreach ($this->reactionSummary as $reactionData)
4+
<span wire:key="inline-reaction-button-{{ $reactionData['reaction'] }}-{{ $comment->getId() }}">
5+
<button
6+
x-cloak
7+
wire:click="handleReactionToggle('{{ $reactionData['reaction'] }}')"
8+
type="button"
9+
class="inline-flex items-center justify-center gap-1 rounded-full border px-2 h-8 text-sm font-medium transition focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed
10+
{{ $reactionData['reacted_by_current_user']
11+
? 'bg-primary-100 dark:bg-primary-800 border-primary-300 dark:border-primary-600 text-primary-700 dark:text-primary-200 hover:bg-primary-200 dark:hover:bg-primary-600'
12+
: 'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600' }}"
13+
title="{{ $reactionData['reaction'] }}"
14+
15+
>
16+
<span>{{ $reactionData['reaction'] }}</span>
17+
<span wire:key="inline-reaction-count-{{ $reactionData['reaction'] }}-{{ $comment->getId() }}">{{ $reactionData['count'] }}</span>
18+
</button>
19+
</span>
20+
@endforeach
21+
22+
{{-- Add Reaction Button --}}
23+
<div class="relative" x-data="{ open: false }" wire:ignore.self>
24+
<button
25+
x-on:click="open = !open"
26+
type="button"
27+
@disabled(! auth()->check())
28+
class="inline-flex items-center justify-center gap-1 rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 w-8 h-8 text-sm font-medium text-gray-700 dark:text-gray-200 transition hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
29+
title="Add Reaction"
30+
wire:key="add-reaction-button-{{ $comment->getId() }}"
31+
>
32+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
33+
<path stroke-linecap="round" stroke-linejoin="round" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
34+
</svg>
35+
</button>
36+
37+
{{-- Reaction Popup --}}
38+
<div
39+
x-show="open"
40+
x-cloak
41+
x-on:click.away="open = false"
42+
class="absolute bottom-full mb-2 z-10 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg p-2 flex flex-wrap gap-1 w-max max-w-xs"
43+
>
44+
@foreach ($allowedReactions as $reactionEmoji)
45+
@php
46+
$reactionData = $this->reactionSummary[$reactionEmoji] ?? ['count' => 0, 'reacted_by_current_user' => false];
47+
@endphp
48+
49+
<button
50+
wire:click="handleReactionToggle('{{ $reactionEmoji }}')"
51+
x-on:click="open = false"
52+
type="button"
53+
@disabled(! auth()->check())
54+
class="inline-flex items-center justify-center gap-1 rounded-full border w-8 h-8 text-sm font-medium transition focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed
55+
{{ $reactionData['reacted_by_current_user']
56+
? 'bg-primary-100 dark:bg-primary-800 border-primary-300 dark:border-primary-600 text-primary-700 dark:text-primary-200 hover:bg-primary-200 dark:hover:bg-primary-600'
57+
: 'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600' }}"
58+
title="{{ $reactionEmoji }}"
59+
wire:key="popup-reaction-button-{{ $reactionEmoji }}-{{ $comment->getId() }}"
60+
>
61+
<span>{{ $reactionEmoji }}</span>
62+
</button>
63+
@endforeach
64+
</div>
65+
</div>
66+
67+
{{-- Display summary of reactions not explicitly in the allowed list --}}
68+
@foreach ($this->reactionSummary as $reactionEmoji => $data)
69+
@if (! in_array($reactionEmoji, $allowedReactions) && $data['count'] > 0)
70+
<span
71+
wire:key="reaction-extra-{{ $reactionEmoji }}-{{ $comment->getId() }}"
72+
class="inline-flex items-center justify-center gap-1 rounded-full border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 px-2 h-8 text-sm font-medium text-gray-600 dark:text-gray-300"
73+
title="{{ $reactionEmoji }}"
74+
>
75+
<span>{{ $reactionEmoji }}</span>
76+
<span>{{ $data['count'] }}</span>
77+
</span>
78+
@endif
79+
@endforeach
80+
</div>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace Kirschbaum\Commentions\Actions;
4+
5+
use Kirschbaum\Commentions\Config;
6+
use Kirschbaum\Commentions\Comment;
7+
use Kirschbaum\Commentions\CommentReaction;
8+
use Kirschbaum\Commentions\Contracts\Commenter;
9+
use Kirschbaum\Commentions\Events\CommentReactionToggledEvent;
10+
11+
class ToggleCommentReaction
12+
{
13+
public static function run(Comment $comment, string $reaction, ?Commenter $user = null): void
14+
{
15+
if (! $user) {
16+
return;
17+
}
18+
19+
if (! in_array($reaction, Config::getAllowedReactions())) {
20+
return;
21+
}
22+
23+
/** @var CommentReaction $existingReaction */
24+
$existingReaction = $comment
25+
->reactions()
26+
->where('reactor_id', $user->getKey())
27+
->where('reactor_type', $user->getMorphClass())
28+
->where('reaction', $reaction)
29+
->first();
30+
31+
if ($existingReaction) {
32+
$existingReaction->delete();
33+
34+
event(new CommentReactionToggledEvent(
35+
comment: $comment,
36+
reaction: $existingReaction,
37+
user: $user,
38+
reactionType: $reaction,
39+
wasCreated: false
40+
));
41+
} else {
42+
$newReaction = $comment->reactions()->create([
43+
'reactor_id' => $user->getKey(),
44+
'reactor_type' => $user->getMorphClass(),
45+
'reaction' => $reaction,
46+
]);
47+
48+
event(new CommentReactionToggledEvent(
49+
comment: $comment,
50+
reaction: $newReaction,
51+
user: $user,
52+
reactionType: $reaction,
53+
wasCreated: true
54+
));
55+
}
56+
}
57+
}

src/Comment.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
use Illuminate\Database\Eloquent\Casts\Attribute;
99
use Illuminate\Database\Eloquent\Factories\HasFactory;
1010
use Illuminate\Database\Eloquent\Model;
11+
use Illuminate\Database\Eloquent\Relations\HasMany;
1112
use Illuminate\Database\Eloquent\Relations\MorphTo;
1213
use Illuminate\Support\Collection;
1314
use Kirschbaum\Commentions\Actions\HtmlToMarkdown;
1415
use Kirschbaum\Commentions\Actions\ParseComment;
16+
use Kirschbaum\Commentions\Actions\ToggleCommentReaction;
1517
use Kirschbaum\Commentions\Contracts\Commentable;
1618
use Kirschbaum\Commentions\Contracts\Commenter;
1719
use Kirschbaum\Commentions\Contracts\RenderableComment;
@@ -151,6 +153,11 @@ public function getUpdatedAt(): \DateTime|\Carbon\Carbon
151153
return $this->updated_at;
152154
}
153155

156+
public function reactions(): HasMany
157+
{
158+
return $this->hasMany(CommentReaction::class);
159+
}
160+
154161
public function canEdit(): bool
155162
{
156163
return Config::allowEdits() && $this->isAuthor(Config::resolveAuthenticatedUser());
@@ -161,6 +168,11 @@ public function canDelete(): bool
161168
return Config::allowDeletes() && $this->isAuthor(Config::resolveAuthenticatedUser());
162169
}
163170

171+
public function toggleReaction(string $reaction): void
172+
{
173+
ToggleCommentReaction::run($this, $reaction, Config::resolveAuthenticatedUser());
174+
}
175+
164176
public function getLabel(): ?string
165177
{
166178
return null;

src/CommentReaction.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace Kirschbaum\Commentions;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
use Illuminate\Database\Eloquent\Relations\MorphTo;
8+
9+
class CommentReaction extends Model
10+
{
11+
protected $fillable = [
12+
'comment_id',
13+
'reactor_id',
14+
'reactor_type',
15+
'reaction',
16+
];
17+
18+
public function comment(): BelongsTo
19+
{
20+
return $this->belongsTo(Comment::class);
21+
}
22+
23+
public function reactor(): MorphTo
24+
{
25+
return $this->morphTo();
26+
}
27+
}

0 commit comments

Comments
 (0)