From 3188999a963c037c85fba435bcf52738a26e282d Mon Sep 17 00:00:00 2001 From: Meghdad Date: Fri, 1 May 2026 11:06:33 +0330 Subject: [PATCH] implement search in messages --- app/Livewire/Chat/ConversationHeader.php | 5 + app/Livewire/Chat/ConversationView.php | 100 +++++++++++++++++- .../chat/conversation-header.blade.php | 8 +- .../livewire/chat/conversation-view.blade.php | 69 +++++++++++- tests/Feature/ChatTest.php | 54 ++++++++++ 5 files changed, 226 insertions(+), 10 deletions(-) diff --git a/app/Livewire/Chat/ConversationHeader.php b/app/Livewire/Chat/ConversationHeader.php index fe0e8e7..7de20ed 100644 --- a/app/Livewire/Chat/ConversationHeader.php +++ b/app/Livewire/Chat/ConversationHeader.php @@ -98,6 +98,11 @@ class ConversationHeader extends Component $this->dispatch('conversation-details-toggled'); } + public function toggleSearch(): void + { + $this->dispatch('conversation-search-toggled', conversationId: $this->conversationId); + } + private function otherParticipant(): ?User { return $this->conversation->participants diff --git a/app/Livewire/Chat/ConversationView.php b/app/Livewire/Chat/ConversationView.php index dc287a1..33cad10 100644 --- a/app/Livewire/Chat/ConversationView.php +++ b/app/Livewire/Chat/ConversationView.php @@ -7,9 +7,11 @@ use App\Models\ConversationParticipant; use App\Models\Message; use Carbon\CarbonInterface; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\HtmlString; use Livewire\Attributes\Computed; use Livewire\Attributes\On; use Livewire\Component; @@ -20,6 +22,10 @@ class ConversationView extends Component public int $messageLimit = 40; + public bool $messageSearchOpen = false; + + public string $messageSearch = ''; + public function mount(int $conversationId): void { $this->conversationId = $conversationId; @@ -33,6 +39,42 @@ class ConversationView extends Component $this->messageLimit += 25; } + #[On('conversation-search-toggled')] + public function toggleMessageSearch(int $conversationId): void + { + if ($conversationId !== $this->conversationId) { + return; + } + + $this->messageSearchOpen = ! $this->messageSearchOpen; + + if (! $this->messageSearchOpen) { + $this->clearMessageSearch(); + } + } + + public function updatedMessageSearch(): void + { + $this->messageLimit = 40; + + unset($this->messages, $this->hasMoreMessages, $this->searchResultsCount); + } + + public function clearMessageSearch(): void + { + $this->messageSearch = ''; + $this->messageLimit = 40; + + unset($this->messages, $this->hasMoreMessages, $this->searchResultsCount); + } + + public function closeMessageSearch(): void + { + $this->messageSearchOpen = false; + + $this->clearMessageSearch(); + } + #[On('message-created')] public function refreshMessages(int $conversationId): void { @@ -76,8 +118,7 @@ class ConversationView extends Component #[Computed] public function messages(): Collection { - return Message::query() - ->where('conversation_id', $this->conversationId) + return $this->messageQuery() ->with('sender:id,name,email') ->latest() ->limit($this->messageLimit) @@ -89,11 +130,20 @@ class ConversationView extends Component #[Computed] public function hasMoreMessages(): bool { - return Message::query() - ->where('conversation_id', $this->conversationId) + return $this->messageQuery() ->count() > $this->messageLimit; } + #[Computed] + public function searchResultsCount(): int + { + if (! $this->messageSearchIsActive()) { + return 0; + } + + return $this->messageQuery()->count(); + } + public function dateLabel(CarbonInterface $timestamp): string { if ($timestamp->isToday()) { @@ -107,6 +157,30 @@ class ConversationView extends Component return $timestamp->format('F j, Y'); } + public function messageSearchIsActive(): bool + { + return $this->messageSearchOpen && trim($this->messageSearch) !== ''; + } + + public function highlightedText(string $text): HtmlString + { + $escapedText = e($text); + $search = trim($this->messageSearch); + + if (! $this->messageSearchIsActive() || $search === '') { + return new HtmlString($escapedText); + } + + $escapedSearch = e($search); + $highlighted = preg_replace( + '/('.preg_quote($escapedSearch, '/').')/iu', + '$1', + $escapedText, + ); + + return new HtmlString($highlighted ?? $escapedText); + } + private function authorizeConversation(): void { $conversation = Conversation::query() @@ -124,6 +198,24 @@ class ConversationView extends Component ->update(['last_read_at' => now()]); } + /** + * @return Builder + */ + private function messageQuery(): Builder + { + $search = trim($this->messageSearch); + + return Message::query() + ->where('conversation_id', $this->conversationId) + ->when($this->messageSearchIsActive(), fn (Builder $query) => $query->where(function (Builder $query) use ($search): void { + $query + ->where('body', 'like', "%{$search}%") + ->orWhereHas('sender', fn (Builder $sender) => $sender + ->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%")); + })); + } + public function render(): View { return view('livewire.chat.conversation-view'); diff --git a/resources/views/livewire/chat/conversation-header.blade.php b/resources/views/livewire/chat/conversation-header.blade.php index 85d46a8..1ac8336 100644 --- a/resources/views/livewire/chat/conversation-header.blade.php +++ b/resources/views/livewire/chat/conversation-header.blade.php @@ -45,7 +45,13 @@
- + diff --git a/resources/views/livewire/chat/conversation-view.blade.php b/resources/views/livewire/chat/conversation-view.blade.php index 615925c..783182c 100644 --- a/resources/views/livewire/chat/conversation-view.blade.php +++ b/resources/views/livewire/chat/conversation-view.blade.php @@ -15,6 +15,57 @@ :key="'conversation-header-'.$conversationId" /> + @if ($messageSearchOpen) +
+
+ + + @if ($messageSearch !== '') + + + + @endif + + + + +
+ +
+ + @if ($this->messageSearchIsActive()) + {{ trans_choice(':count matching message|:count matching messages', $this->searchResultsCount, ['count' => $this->searchResultsCount]) }} + @else + {{ __('Search by message text, file name, sender, or email.') }} + @endif + + +
+ + {{ __('Searching') }} +
+
+
+ @endif +
@if ($this->hasMoreMessages)
@@ -26,7 +77,7 @@ wire:loading.attr="disabled" wire:target="loadEarlier" > - {{ __('Load earlier') }} + {{ $this->messageSearchIsActive() ? __('Load more results') : __('Load earlier') }}
@endif @@ -37,9 +88,17 @@
- {{ __('No messages yet') }} + + + {{ $this->messageSearchIsActive() ? __('No matching messages') : __('No messages yet') }} + + - {{ __('Start the conversation with a short note.') }} + @if ($this->messageSearchIsActive()) + {{ __('No messages match your current search.') }} + @else + {{ __('Start the conversation with a short note.') }} + @endif
@@ -115,7 +174,7 @@ - {{ $message->attachmentName() }} + {!! $this->highlightedText($message->attachmentName()) !!} $isMine, @@ -132,7 +191,7 @@ ]) /> @else -

{{ $message->body }}

+

{!! $this->highlightedText($message->body) !!}

@endif diff --git a/tests/Feature/ChatTest.php b/tests/Feature/ChatTest.php index 8b8b0ac..27271b1 100644 --- a/tests/Feature/ChatTest.php +++ b/tests/Feature/ChatTest.php @@ -318,6 +318,60 @@ test('selected conversations render the stream header and details', function () ->assertSee('Mina Partner'); }); +test('conversation header search button opens message search', function () { + $user = User::factory()->create(); + $teammate = User::factory()->create(['name' => 'Mina Partner']); + $conversation = Conversation::factory() + ->direct() + ->for($user, 'creator') + ->create(); + + ConversationParticipant::factory()->for($conversation)->for($user)->create(); + ConversationParticipant::factory()->for($conversation)->for($teammate)->create(); + + $this->actingAs($user); + + Livewire::test(ConversationHeader::class, ['conversationId' => $conversation->id]) + ->call('toggleSearch') + ->assertDispatched('conversation-search-toggled'); + + Livewire::test(ConversationView::class, ['conversationId' => $conversation->id]) + ->dispatch('conversation-search-toggled', conversationId: $conversation->id) + ->assertSet('messageSearchOpen', true) + ->assertSee('Search this conversation'); +}); + +test('message search filters the selected conversation', function () { + $user = User::factory()->create(); + $teammate = User::factory()->create(); + $conversation = Conversation::factory() + ->direct() + ->for($user, 'creator') + ->create(); + + ConversationParticipant::factory()->for($conversation)->for($user)->create(); + ConversationParticipant::factory()->for($conversation)->for($teammate)->create(); + + Message::factory() + ->for($conversation) + ->for($teammate, 'sender') + ->create(['body' => 'The deployment checklist is ready.']); + + Message::factory() + ->for($conversation) + ->for($teammate, 'sender') + ->create(['body' => 'Budget review moved to Tuesday.']); + + $this->actingAs($user); + + Livewire::test(ConversationView::class, ['conversationId' => $conversation->id]) + ->dispatch('conversation-search-toggled', conversationId: $conversation->id) + ->set('messageSearch', 'deployment') + ->assertSee('1 matching message') + ->assertSee('deployment') + ->assertDontSee('Budget review moved to Tuesday.'); +}); + test('empty messages are rejected', function () { $user = User::factory()->create(); $conversation = Conversation::factory()