implement search in messages
This commit is contained in:
@@ -98,6 +98,11 @@ class ConversationHeader extends Component
|
|||||||
$this->dispatch('conversation-details-toggled');
|
$this->dispatch('conversation-details-toggled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function toggleSearch(): void
|
||||||
|
{
|
||||||
|
$this->dispatch('conversation-search-toggled', conversationId: $this->conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
private function otherParticipant(): ?User
|
private function otherParticipant(): ?User
|
||||||
{
|
{
|
||||||
return $this->conversation->participants
|
return $this->conversation->participants
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ use App\Models\ConversationParticipant;
|
|||||||
use App\Models\Message;
|
use App\Models\Message;
|
||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -20,6 +22,10 @@ class ConversationView extends Component
|
|||||||
|
|
||||||
public int $messageLimit = 40;
|
public int $messageLimit = 40;
|
||||||
|
|
||||||
|
public bool $messageSearchOpen = false;
|
||||||
|
|
||||||
|
public string $messageSearch = '';
|
||||||
|
|
||||||
public function mount(int $conversationId): void
|
public function mount(int $conversationId): void
|
||||||
{
|
{
|
||||||
$this->conversationId = $conversationId;
|
$this->conversationId = $conversationId;
|
||||||
@@ -33,6 +39,42 @@ class ConversationView extends Component
|
|||||||
$this->messageLimit += 25;
|
$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')]
|
#[On('message-created')]
|
||||||
public function refreshMessages(int $conversationId): void
|
public function refreshMessages(int $conversationId): void
|
||||||
{
|
{
|
||||||
@@ -76,8 +118,7 @@ class ConversationView extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function messages(): Collection
|
public function messages(): Collection
|
||||||
{
|
{
|
||||||
return Message::query()
|
return $this->messageQuery()
|
||||||
->where('conversation_id', $this->conversationId)
|
|
||||||
->with('sender:id,name,email')
|
->with('sender:id,name,email')
|
||||||
->latest()
|
->latest()
|
||||||
->limit($this->messageLimit)
|
->limit($this->messageLimit)
|
||||||
@@ -89,11 +130,20 @@ class ConversationView extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function hasMoreMessages(): bool
|
public function hasMoreMessages(): bool
|
||||||
{
|
{
|
||||||
return Message::query()
|
return $this->messageQuery()
|
||||||
->where('conversation_id', $this->conversationId)
|
|
||||||
->count() > $this->messageLimit;
|
->count() > $this->messageLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function searchResultsCount(): int
|
||||||
|
{
|
||||||
|
if (! $this->messageSearchIsActive()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->messageQuery()->count();
|
||||||
|
}
|
||||||
|
|
||||||
public function dateLabel(CarbonInterface $timestamp): string
|
public function dateLabel(CarbonInterface $timestamp): string
|
||||||
{
|
{
|
||||||
if ($timestamp->isToday()) {
|
if ($timestamp->isToday()) {
|
||||||
@@ -107,6 +157,30 @@ class ConversationView extends Component
|
|||||||
return $timestamp->format('F j, Y');
|
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',
|
||||||
|
'<mark class="rounded bg-amber-200 px-0.5 text-zinc-950">$1</mark>',
|
||||||
|
$escapedText,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new HtmlString($highlighted ?? $escapedText);
|
||||||
|
}
|
||||||
|
|
||||||
private function authorizeConversation(): void
|
private function authorizeConversation(): void
|
||||||
{
|
{
|
||||||
$conversation = Conversation::query()
|
$conversation = Conversation::query()
|
||||||
@@ -124,6 +198,24 @@ class ConversationView extends Component
|
|||||||
->update(['last_read_at' => now()]);
|
->update(['last_read_at' => now()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Builder<Message>
|
||||||
|
*/
|
||||||
|
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
|
public function render(): View
|
||||||
{
|
{
|
||||||
return view('livewire.chat.conversation-view');
|
return view('livewire.chat.conversation-view');
|
||||||
|
|||||||
@@ -45,7 +45,13 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<flux:tooltip :content="__('Search messages')" position="bottom">
|
<flux:tooltip :content="__('Search messages')" position="bottom">
|
||||||
<flux:button type="button" variant="ghost" icon="magnifying-glass" aria-label="{{ __('Search messages') }}" />
|
<flux:button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
icon="magnifying-glass"
|
||||||
|
wire:click="toggleSearch"
|
||||||
|
aria-label="{{ __('Search messages') }}"
|
||||||
|
/>
|
||||||
</flux:tooltip>
|
</flux:tooltip>
|
||||||
|
|
||||||
<flux:tooltip :content="__('Conversation details')" position="bottom">
|
<flux:tooltip :content="__('Conversation details')" position="bottom">
|
||||||
|
|||||||
@@ -15,6 +15,57 @@
|
|||||||
:key="'conversation-header-'.$conversationId"
|
:key="'conversation-header-'.$conversationId"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@if ($messageSearchOpen)
|
||||||
|
<section class="shrink-0 border-b border-zinc-200 bg-white/95 p-3 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/95 sm:px-5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:input
|
||||||
|
wire:model.live.debounce.250ms="messageSearch"
|
||||||
|
icon="magnifying-glass"
|
||||||
|
:placeholder="__('Search this conversation')"
|
||||||
|
aria-label="{{ __('Search this conversation') }}"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if ($messageSearch !== '')
|
||||||
|
<flux:tooltip :content="__('Clear search')" position="bottom">
|
||||||
|
<flux:button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
icon="x-mark"
|
||||||
|
wire:click="clearMessageSearch"
|
||||||
|
aria-label="{{ __('Clear search') }}"
|
||||||
|
/>
|
||||||
|
</flux:tooltip>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<flux:tooltip :content="__('Close search')" position="bottom">
|
||||||
|
<flux:button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
icon="chevron-up"
|
||||||
|
wire:click="closeMessageSearch"
|
||||||
|
aria-label="{{ __('Close search') }}"
|
||||||
|
/>
|
||||||
|
</flux:tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex min-h-5 items-center justify-between gap-3">
|
||||||
|
<flux:text class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
@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
|
||||||
|
</flux:text>
|
||||||
|
|
||||||
|
<div wire:loading.flex wire:target="messageSearch" class="items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<flux:icon.arrow-path class="size-3.5 animate-spin" />
|
||||||
|
<span>{{ __('Searching') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div x-ref="messages" class="min-h-0 flex-1 overflow-y-auto scroll-smooth bg-zinc-50/40 px-4 py-6 dark:bg-zinc-950 sm:px-6">
|
<div x-ref="messages" class="min-h-0 flex-1 overflow-y-auto scroll-smooth bg-zinc-50/40 px-4 py-6 dark:bg-zinc-950 sm:px-6">
|
||||||
@if ($this->hasMoreMessages)
|
@if ($this->hasMoreMessages)
|
||||||
<div class="mb-6 flex justify-center">
|
<div class="mb-6 flex justify-center">
|
||||||
@@ -26,7 +77,7 @@
|
|||||||
wire:loading.attr="disabled"
|
wire:loading.attr="disabled"
|
||||||
wire:target="loadEarlier"
|
wire:target="loadEarlier"
|
||||||
>
|
>
|
||||||
{{ __('Load earlier') }}
|
{{ $this->messageSearchIsActive() ? __('Load more results') : __('Load earlier') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@@ -37,9 +88,17 @@
|
|||||||
<div class="mx-auto mb-4 flex size-12 items-center justify-center rounded-lg border border-dashed border-zinc-300 text-zinc-400 dark:border-zinc-700">
|
<div class="mx-auto mb-4 flex size-12 items-center justify-center rounded-lg border border-dashed border-zinc-300 text-zinc-400 dark:border-zinc-700">
|
||||||
<flux:icon.chat-bubble-left-ellipsis class="size-6" />
|
<flux:icon.chat-bubble-left-ellipsis class="size-6" />
|
||||||
</div>
|
</div>
|
||||||
<flux:heading size="sm">{{ __('No messages yet') }}</flux:heading>
|
|
||||||
|
<flux:heading size="sm">
|
||||||
|
{{ $this->messageSearchIsActive() ? __('No matching messages') : __('No messages yet') }}
|
||||||
|
</flux:heading>
|
||||||
|
|
||||||
<flux:text class="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
<flux:text class="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
@if ($this->messageSearchIsActive())
|
||||||
|
{{ __('No messages match your current search.') }}
|
||||||
|
@else
|
||||||
{{ __('Start the conversation with a short note.') }}
|
{{ __('Start the conversation with a short note.') }}
|
||||||
|
@endif
|
||||||
</flux:text>
|
</flux:text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +174,7 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
<span class="block truncate font-medium">{{ $message->attachmentName() }}</span>
|
<span class="block truncate font-medium">{!! $this->highlightedText($message->attachmentName()) !!}</span>
|
||||||
<span @class([
|
<span @class([
|
||||||
'mt-0.5 block text-xs',
|
'mt-0.5 block text-xs',
|
||||||
'text-white/70 dark:text-zinc-600' => $isMine,
|
'text-white/70 dark:text-zinc-600' => $isMine,
|
||||||
@@ -132,7 +191,7 @@
|
|||||||
]) />
|
]) />
|
||||||
</a>
|
</a>
|
||||||
@else
|
@else
|
||||||
<p class="whitespace-pre-wrap break-words">{{ $message->body }}</p>
|
<p class="whitespace-pre-wrap break-words">{!! $this->highlightedText($message->body) !!}</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -318,6 +318,60 @@ test('selected conversations render the stream header and details', function ()
|
|||||||
->assertSee('Mina Partner');
|
->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 () {
|
test('empty messages are rejected', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$conversation = Conversation::factory()
|
$conversation = Conversation::factory()
|
||||||
|
|||||||
Reference in New Issue
Block a user