From 7b2541dd35c6d7b7f2de4723a45d2a892d80f4c8 Mon Sep 17 00:00:00 2001 From: Meghdad Date: Fri, 1 May 2026 11:30:14 +0330 Subject: [PATCH] implement sending Emoji, pin or mute conversation --- .../Chat/ConversationDetailsPanel.php | 64 +++++++++++++- app/Livewire/Chat/ConversationList.php | 26 +++++- app/Livewire/Chat/MessageComposer.php | 63 ++++++++++++++ app/Models/Conversation.php | 4 +- app/Models/ConversationParticipant.php | 15 +++- .../ConversationParticipantFactory.php | 1 + ..._at_to_conversation_participants_table.php | 24 ++++++ .../chat/conversation-details-panel.blade.php | 36 ++++++-- .../livewire/chat/conversation-list.blade.php | 24 ++++++ .../livewire/chat/message-composer.blade.php | 30 ++++++- tests/Feature/ChatTest.php | 84 +++++++++++++++++++ 11 files changed, 359 insertions(+), 12 deletions(-) create mode 100644 database/migrations/2026_05_01_075042_add_pinned_at_to_conversation_participants_table.php diff --git a/app/Livewire/Chat/ConversationDetailsPanel.php b/app/Livewire/Chat/ConversationDetailsPanel.php index e2940a5..5462129 100644 --- a/app/Livewire/Chat/ConversationDetailsPanel.php +++ b/app/Livewire/Chat/ConversationDetailsPanel.php @@ -65,7 +65,7 @@ class ConversationDetailsPanel extends Component ->forUser(Auth::user()) ->with([ 'participants' => fn ($query) => $query - ->select(['id', 'conversation_id', 'user_id', 'role', 'joined_at']) + ->select(['id', 'conversation_id', 'user_id', 'role', 'joined_at', 'muted_until', 'pinned_at']) ->with('user:id,name,email'), ]) ->withCount([ @@ -104,11 +104,73 @@ class ConversationDetailsPanel extends Component return Gate::allows('addMembers', $this->conversation); } + public function currentParticipant(): ConversationParticipant + { + $participant = $this->conversation->participants + ->first(fn (ConversationParticipant $participant) => $participant->user_id === Auth::id()); + + abort_unless($participant instanceof ConversationParticipant, 404); + + return $participant; + } + + public function isMuted(): bool + { + return $this->currentParticipant()->isMuted(); + } + + public function isPinned(): bool + { + return $this->currentParticipant()->isPinned(); + } + public function closeDetails(): void { $this->dispatch('conversation-details-toggled'); } + public function toggleMute(): void + { + Gate::authorize('view', $this->conversation); + + $participant = $this->currentParticipant(); + $wasMuted = $participant->isMuted(); + + $participant->forceFill([ + 'muted_until' => $wasMuted ? null : now()->addYear(), + ])->save(); + + unset($this->conversation); + + Flux::toast( + variant: 'success', + text: $wasMuted ? __('Conversation unmuted.') : __('Conversation muted.'), + ); + + $this->dispatch('conversation-updated', conversationId: $this->conversationId); + } + + public function togglePin(): void + { + Gate::authorize('view', $this->conversation); + + $participant = $this->currentParticipant(); + $wasPinned = $participant->isPinned(); + + $participant->forceFill([ + 'pinned_at' => $wasPinned ? null : now(), + ])->save(); + + unset($this->conversation); + + Flux::toast( + variant: 'success', + text: $wasPinned ? __('Conversation unpinned.') : __('Conversation pinned.'), + ); + + $this->dispatch('conversation-updated', conversationId: $this->conversationId); + } + public function openAddMembers(): void { Gate::authorize('addMembers', $this->conversation); diff --git a/app/Livewire/Chat/ConversationList.php b/app/Livewire/Chat/ConversationList.php index 2f51f0b..96f3b20 100644 --- a/app/Livewire/Chat/ConversationList.php +++ b/app/Livewire/Chat/ConversationList.php @@ -196,12 +196,19 @@ class ConversationList extends Component ->forUser($user) ->with([ 'participants' => fn ($query) => $query - ->select(['id', 'conversation_id', 'user_id', 'role', 'last_read_at']) + ->select(['id', 'conversation_id', 'user_id', 'role', 'last_read_at', 'muted_until', 'pinned_at']) ->with('user:id,name,email'), 'latestMessage' => fn ($query) => $query ->select(['messages.id', 'messages.conversation_id', 'messages.user_id', 'messages.type', 'messages.body', 'messages.metadata', 'messages.created_at']), 'latestMessage.sender:id,name,email', ]) + ->addSelect([ + 'current_participant_pinned_at' => ConversationParticipant::query() + ->select('pinned_at') + ->whereColumn('conversation_participants.conversation_id', 'conversations.id') + ->where('conversation_participants.user_id', $user->id) + ->limit(1), + ]) ->withMax('messages', 'created_at') ->withCount([ 'messages as unread_messages_count' => fn (Builder $messages) => $messages @@ -224,6 +231,7 @@ class ConversationList extends Component ->where('name', 'like', "%{$search}%") ->orWhere('email', 'like', "%{$search}%"))); })) + ->orderByDesc('current_participant_pinned_at') ->orderByDesc('messages_max_created_at') ->orderByDesc('updated_at') ->limit(40) @@ -336,6 +344,16 @@ class ConversationList extends Component ]); } + public function isPinnedFor(Conversation $conversation): bool + { + return (bool) $this->currentParticipantFor($conversation)?->isPinned(); + } + + public function isMutedFor(Conversation $conversation): bool + { + return (bool) $this->currentParticipantFor($conversation)?->isMuted(); + } + public function isOnline(Conversation $conversation): bool { $participant = $this->otherParticipant($conversation); @@ -343,6 +361,12 @@ class ConversationList extends Component return $participant instanceof User && $participant->id % 3 !== 0; } + private function currentParticipantFor(Conversation $conversation): ?ConversationParticipant + { + return $conversation->participants + ->first(fn (ConversationParticipant $participant) => $participant->user_id === Auth::id()); + } + private function otherParticipant(Conversation $conversation): ?User { return $conversation->participants diff --git a/app/Livewire/Chat/MessageComposer.php b/app/Livewire/Chat/MessageComposer.php index 07fd09c..b6df454 100644 --- a/app/Livewire/Chat/MessageComposer.php +++ b/app/Livewire/Chat/MessageComposer.php @@ -23,6 +23,8 @@ class MessageComposer extends Component public string $body = ''; + public bool $emojiPickerOpen = false; + /** * @var array */ @@ -103,11 +105,32 @@ class MessageComposer extends Component }); $this->reset('body', 'attachments'); + $this->emojiPickerOpen = false; $this->resetValidation(); $this->dispatch('message-created', conversationId: $conversation->id, messageId: $message->id); } + public function toggleEmojiPicker(): void + { + $this->emojiPickerOpen = ! $this->emojiPickerOpen; + } + + public function appendEmoji(string $code): void + { + $emoji = $this->emojiForCode($code); + + if ($emoji === null) { + return; + } + + $body = rtrim($this->body); + $this->body = $body === '' ? $emoji : $body.' '.$emoji; + $this->emojiPickerOpen = false; + + $this->resetValidation('body'); + } + public function removeAttachment(int $index): void { if (! array_key_exists($index, $this->attachments)) { @@ -122,6 +145,33 @@ class MessageComposer extends Component $this->resetValidation("attachments.{$index}"); } + /** + * @return array + */ + public function emojiOptions(): array + { + return [ + ['code' => '1F600', 'label' => __('Grinning face')], + ['code' => '1F604', 'label' => __('Smiling face')], + ['code' => '1F602', 'label' => __('Laughing face')], + ['code' => '1F60D', 'label' => __('Heart eyes')], + ['code' => '1F44B', 'label' => __('Wave')], + ['code' => '1F44D', 'label' => __('Thumbs up')], + ['code' => '1F44F', 'label' => __('Clap')], + ['code' => '1F64C', 'label' => __('Raised hands')], + ['code' => '1F525', 'label' => __('Fire')], + ['code' => '2728', 'label' => __('Sparkles')], + ['code' => '2705', 'label' => __('Check mark')], + ['code' => '1F680', 'label' => __('Rocket')], + ['code' => '1F4A1', 'label' => __('Idea')], + ['code' => '1F440', 'label' => __('Eyes')], + ['code' => '1F4CC', 'label' => __('Pin')], + ['code' => '1F4CE', 'label' => __('Paperclip')], + ['code' => '2764', 'label' => __('Heart')], + ['code' => '1F389', 'label' => __('Party')], + ]; + } + private function conversation(): Conversation { $conversation = Conversation::query() @@ -140,6 +190,19 @@ class MessageComposer extends Component ->isNotEmpty(); } + private function emojiForCode(string $code): ?string + { + $code = strtoupper($code); + $isAllowed = collect($this->emojiOptions()) + ->contains(fn (array $emoji): bool => $emoji['code'] === $code); + + if (! $isAllowed) { + return null; + } + + return html_entity_decode('&#x'.$code.';', ENT_QUOTES, 'UTF-8'); + } + public function render(): View { return view('livewire.chat.message-composer'); diff --git a/app/Models/Conversation.php b/app/Models/Conversation.php index 6945e0b..ea3a1f9 100644 --- a/app/Models/Conversation.php +++ b/app/Models/Conversation.php @@ -6,11 +6,11 @@ use Database\Factories\ConversationFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; -use Illuminate\Database\Eloquent\Model; #[Fillable(['created_by_id', 'type', 'name', 'description'])] class Conversation extends Model @@ -44,7 +44,7 @@ class Conversation extends Model public function users(): BelongsToMany { return $this->belongsToMany(User::class, 'conversation_participants') - ->withPivot(['role', 'joined_at', 'last_read_at', 'muted_until']) + ->withPivot(['role', 'joined_at', 'last_read_at', 'muted_until', 'pinned_at']) ->withTimestamps(); } diff --git a/app/Models/ConversationParticipant.php b/app/Models/ConversationParticipant.php index 2206816..3edba3c 100644 --- a/app/Models/ConversationParticipant.php +++ b/app/Models/ConversationParticipant.php @@ -5,10 +5,10 @@ namespace App\Models; use Database\Factories\ConversationParticipantFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; -#[Fillable(['conversation_id', 'user_id', 'role', 'joined_at', 'last_read_at', 'muted_until'])] +#[Fillable(['conversation_id', 'user_id', 'role', 'joined_at', 'last_read_at', 'muted_until', 'pinned_at'])] class ConversationParticipant extends Model { /** @use HasFactory */ @@ -27,9 +27,20 @@ class ConversationParticipant extends Model 'joined_at' => 'datetime', 'last_read_at' => 'datetime', 'muted_until' => 'datetime', + 'pinned_at' => 'datetime', ]; } + public function isMuted(): bool + { + return $this->muted_until !== null && $this->muted_until->isFuture(); + } + + public function isPinned(): bool + { + return $this->pinned_at !== null; + } + /** * @return BelongsTo */ diff --git a/database/factories/ConversationParticipantFactory.php b/database/factories/ConversationParticipantFactory.php index 753a376..f9af745 100644 --- a/database/factories/ConversationParticipantFactory.php +++ b/database/factories/ConversationParticipantFactory.php @@ -26,6 +26,7 @@ class ConversationParticipantFactory extends Factory 'joined_at' => fake()->dateTimeBetween('-2 months', 'now'), 'last_read_at' => fake()->optional(0.85)->dateTimeBetween('-2 weeks', 'now'), 'muted_until' => null, + 'pinned_at' => null, ]; } diff --git a/database/migrations/2026_05_01_075042_add_pinned_at_to_conversation_participants_table.php b/database/migrations/2026_05_01_075042_add_pinned_at_to_conversation_participants_table.php new file mode 100644 index 0000000..851e516 --- /dev/null +++ b/database/migrations/2026_05_01_075042_add_pinned_at_to_conversation_participants_table.php @@ -0,0 +1,24 @@ +timestamp('pinned_at')->nullable()->after('muted_until'); + $table->index(['user_id', 'pinned_at']); + }); + } + + public function down(): void + { + Schema::table('conversation_participants', function (Blueprint $table): void { + $table->dropIndex(['user_id', 'pinned_at']); + $table->dropColumn('pinned_at'); + }); + } +}; diff --git a/resources/views/livewire/chat/conversation-details-panel.blade.php b/resources/views/livewire/chat/conversation-details-panel.blade.php index 29ae9b3..cbd4b9e 100644 --- a/resources/views/livewire/chat/conversation-details-panel.blade.php +++ b/resources/views/livewire/chat/conversation-details-panel.blade.php @@ -43,12 +43,36 @@ {{ __('Quick actions') }}
- - + + + {{ $this->isMuted() ? __('Muted') : __('Mute') }} + - - + + + {{ $this->isPinned() ? __('Pinned') : __('Pin') }} + @@ -62,7 +86,9 @@ wire:target="openFiles" aria-label="{{ __('Files') }}" class="w-full" - /> + > + {{ __('Files') }} + @if ((int) $this->conversation->files_count > 0) diff --git a/resources/views/livewire/chat/conversation-list.blade.php b/resources/views/livewire/chat/conversation-list.blade.php index 8a7bd9e..c5d1ea2 100644 --- a/resources/views/livewire/chat/conversation-list.blade.php +++ b/resources/views/livewire/chat/conversation-list.blade.php @@ -48,6 +48,8 @@ @php $selected = $selectedConversationId === $conversation->id; $unreadCount = (int) ($conversation->unread_messages_count ?? 0); + $isPinned = $this->isPinnedFor($conversation); + $isMuted = $this->isMutedFor($conversation); @endphp

+ @if ($emojiPickerOpen) +

+
+ @foreach ($this->emojiOptions() as $emoji) + + @endforeach +
+
+ @endif +
- + exists())->toBeTrue(); }); +test('participants can add emojis from the composer picker', function () { + $user = User::factory()->create(); + $conversation = Conversation::factory() + ->direct() + ->for($user, 'creator') + ->create(); + + ConversationParticipant::factory()->for($conversation)->for($user)->create(); + + $emoji = html_entity_decode('👍', ENT_QUOTES, 'UTF-8'); + + $this->actingAs($user); + + Livewire::test(MessageComposer::class, ['conversationId' => $conversation->id]) + ->call('toggleEmojiPicker') + ->assertSet('emojiPickerOpen', true) + ->assertSee('Add Thumbs up emoji') + ->set('body', 'Looks good') + ->call('appendEmoji', '1F44D') + ->assertSet('body', 'Looks good '.$emoji) + ->assertSet('emojiPickerOpen', false); +}); + test('participants can send file messages', function () { Storage::fake('local'); @@ -262,6 +285,67 @@ test('conversation details files action shows shared files', function () { ->assertSee('Conversation files'); }); +test('participants can mute and pin conversations from details', function () { + $user = User::factory()->create(); + $conversation = Conversation::factory() + ->direct() + ->for($user, 'creator') + ->create(); + + $participant = ConversationParticipant::factory()->for($conversation)->for($user)->create(); + + $this->actingAs($user); + + Livewire::test(ConversationDetailsPanel::class, ['conversationId' => $conversation->id]) + ->call('toggleMute') + ->assertSee('Muted') + ->call('togglePin') + ->assertSee('Pinned'); + + $participant->refresh(); + + expect($participant->muted_until)->not->toBeNull() + ->and($participant->pinned_at)->not->toBeNull(); + + Livewire::test(ConversationDetailsPanel::class, ['conversationId' => $conversation->id]) + ->call('toggleMute') + ->assertSee('Mute') + ->call('togglePin') + ->assertSee('Pin'); + + $participant->refresh(); + + expect($participant->muted_until)->toBeNull() + ->and($participant->pinned_at)->toBeNull(); +}); + +test('pinned conversations stay at the top of the conversation list', function () { + $user = User::factory()->create(); + $pinnedTeammate = User::factory()->create(['name' => 'Priority Partner']); + $regularTeammate = User::factory()->create(['name' => 'Regular Partner']); + + $pinnedConversation = Conversation::factory() + ->direct() + ->for($user, 'creator') + ->create(['updated_at' => now()->subDay()]); + + $regularConversation = Conversation::factory() + ->direct() + ->for($user, 'creator') + ->create(['updated_at' => now()]); + + ConversationParticipant::factory()->for($pinnedConversation)->for($user)->create(['pinned_at' => now()]); + ConversationParticipant::factory()->for($pinnedConversation)->for($pinnedTeammate)->create(); + ConversationParticipant::factory()->for($regularConversation)->for($user)->create(); + ConversationParticipant::factory()->for($regularConversation)->for($regularTeammate)->create(); + + $this->actingAs($user); + + Livewire::test(ConversationList::class) + ->assertSeeInOrder(['Priority Partner', 'Regular Partner']) + ->assertSee('Pinned'); +}); + test('participants can add people to a direct conversation', function () { $user = User::factory()->create(); $teammate = User::factory()->create(['name' => 'Mina Partner']);