implement sending Emoji, pin or mute conversation

This commit is contained in:
2026-05-01 11:30:14 +03:30
parent 1121939c25
commit 7b2541dd35
11 changed files with 359 additions and 12 deletions

View File

@@ -65,7 +65,7 @@ class ConversationDetailsPanel extends Component
->forUser(Auth::user()) ->forUser(Auth::user())
->with([ ->with([
'participants' => fn ($query) => $query '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'), ->with('user:id,name,email'),
]) ])
->withCount([ ->withCount([
@@ -104,11 +104,73 @@ class ConversationDetailsPanel extends Component
return Gate::allows('addMembers', $this->conversation); 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 public function closeDetails(): void
{ {
$this->dispatch('conversation-details-toggled'); $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 public function openAddMembers(): void
{ {
Gate::authorize('addMembers', $this->conversation); Gate::authorize('addMembers', $this->conversation);

View File

@@ -196,12 +196,19 @@ class ConversationList extends Component
->forUser($user) ->forUser($user)
->with([ ->with([
'participants' => fn ($query) => $query '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'), ->with('user:id,name,email'),
'latestMessage' => fn ($query) => $query 'latestMessage' => fn ($query) => $query
->select(['messages.id', 'messages.conversation_id', 'messages.user_id', 'messages.type', 'messages.body', 'messages.metadata', 'messages.created_at']), ->select(['messages.id', 'messages.conversation_id', 'messages.user_id', 'messages.type', 'messages.body', 'messages.metadata', 'messages.created_at']),
'latestMessage.sender:id,name,email', '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') ->withMax('messages', 'created_at')
->withCount([ ->withCount([
'messages as unread_messages_count' => fn (Builder $messages) => $messages 'messages as unread_messages_count' => fn (Builder $messages) => $messages
@@ -224,6 +231,7 @@ class ConversationList extends Component
->where('name', 'like', "%{$search}%") ->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"))); ->orWhere('email', 'like', "%{$search}%")));
})) }))
->orderByDesc('current_participant_pinned_at')
->orderByDesc('messages_max_created_at') ->orderByDesc('messages_max_created_at')
->orderByDesc('updated_at') ->orderByDesc('updated_at')
->limit(40) ->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 public function isOnline(Conversation $conversation): bool
{ {
$participant = $this->otherParticipant($conversation); $participant = $this->otherParticipant($conversation);
@@ -343,6 +361,12 @@ class ConversationList extends Component
return $participant instanceof User && $participant->id % 3 !== 0; 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 private function otherParticipant(Conversation $conversation): ?User
{ {
return $conversation->participants return $conversation->participants

View File

@@ -23,6 +23,8 @@ class MessageComposer extends Component
public string $body = ''; public string $body = '';
public bool $emojiPickerOpen = false;
/** /**
* @var array<int, TemporaryUploadedFile> * @var array<int, TemporaryUploadedFile>
*/ */
@@ -103,11 +105,32 @@ class MessageComposer extends Component
}); });
$this->reset('body', 'attachments'); $this->reset('body', 'attachments');
$this->emojiPickerOpen = false;
$this->resetValidation(); $this->resetValidation();
$this->dispatch('message-created', conversationId: $conversation->id, messageId: $message->id); $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 public function removeAttachment(int $index): void
{ {
if (! array_key_exists($index, $this->attachments)) { if (! array_key_exists($index, $this->attachments)) {
@@ -122,6 +145,33 @@ class MessageComposer extends Component
$this->resetValidation("attachments.{$index}"); $this->resetValidation("attachments.{$index}");
} }
/**
* @return array<int, array{code: string, label: string}>
*/
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 private function conversation(): Conversation
{ {
$conversation = Conversation::query() $conversation = Conversation::query()
@@ -140,6 +190,19 @@ class MessageComposer extends Component
->isNotEmpty(); ->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 public function render(): View
{ {
return view('livewire.chat.message-composer'); return view('livewire.chat.message-composer');

View File

@@ -6,11 +6,11 @@ use Database\Factories\ConversationFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['created_by_id', 'type', 'name', 'description'])] #[Fillable(['created_by_id', 'type', 'name', 'description'])]
class Conversation extends Model class Conversation extends Model
@@ -44,7 +44,7 @@ class Conversation extends Model
public function users(): BelongsToMany public function users(): BelongsToMany
{ {
return $this->belongsToMany(User::class, 'conversation_participants') 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(); ->withTimestamps();
} }

View File

@@ -5,10 +5,10 @@ namespace App\Models;
use Database\Factories\ConversationParticipantFactory; use Database\Factories\ConversationParticipantFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Model; 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 class ConversationParticipant extends Model
{ {
/** @use HasFactory<ConversationParticipantFactory> */ /** @use HasFactory<ConversationParticipantFactory> */
@@ -27,9 +27,20 @@ class ConversationParticipant extends Model
'joined_at' => 'datetime', 'joined_at' => 'datetime',
'last_read_at' => 'datetime', 'last_read_at' => 'datetime',
'muted_until' => '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<Conversation, $this> * @return BelongsTo<Conversation, $this>
*/ */

View File

@@ -26,6 +26,7 @@ class ConversationParticipantFactory extends Factory
'joined_at' => fake()->dateTimeBetween('-2 months', 'now'), 'joined_at' => fake()->dateTimeBetween('-2 months', 'now'),
'last_read_at' => fake()->optional(0.85)->dateTimeBetween('-2 weeks', 'now'), 'last_read_at' => fake()->optional(0.85)->dateTimeBetween('-2 weeks', 'now'),
'muted_until' => null, 'muted_until' => null,
'pinned_at' => null,
]; ];
} }

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('conversation_participants', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@@ -43,12 +43,36 @@
<flux:heading size="sm">{{ __('Quick actions') }}</flux:heading> <flux:heading size="sm">{{ __('Quick actions') }}</flux:heading>
<div class="mt-3 grid grid-cols-3 gap-2"> <div class="mt-3 grid grid-cols-3 gap-2">
<flux:tooltip :content="__('Mute')" position="top"> <flux:tooltip :content="$this->isMuted() ? __('Unmute') : __('Mute')" position="top">
<flux:button type="button" variant="filled" icon="bell-slash" aria-label="{{ __('Mute') }}" /> <flux:button
type="button"
:variant="$this->isMuted() ? 'primary' : 'filled'"
icon="bell-slash"
wire:click="toggleMute"
wire:loading.attr="disabled"
wire:target="toggleMute"
aria-pressed="{{ $this->isMuted() ? 'true' : 'false' }}"
aria-label="{{ $this->isMuted() ? __('Unmute conversation') : __('Mute conversation') }}"
class="w-full"
>
{{ $this->isMuted() ? __('Muted') : __('Mute') }}
</flux:button>
</flux:tooltip> </flux:tooltip>
<flux:tooltip :content="__('Pin')" position="top"> <flux:tooltip :content="$this->isPinned() ? __('Unpin') : __('Pin')" position="top">
<flux:button type="button" variant="filled" icon="bookmark" aria-label="{{ __('Pin') }}" /> <flux:button
type="button"
:variant="$this->isPinned() ? 'primary' : 'filled'"
icon="bookmark"
wire:click="togglePin"
wire:loading.attr="disabled"
wire:target="togglePin"
aria-pressed="{{ $this->isPinned() ? 'true' : 'false' }}"
aria-label="{{ $this->isPinned() ? __('Unpin conversation') : __('Pin conversation') }}"
class="w-full"
>
{{ $this->isPinned() ? __('Pinned') : __('Pin') }}
</flux:button>
</flux:tooltip> </flux:tooltip>
<flux:tooltip :content="__('Files')" position="top"> <flux:tooltip :content="__('Files')" position="top">
@@ -62,7 +86,9 @@
wire:target="openFiles" wire:target="openFiles"
aria-label="{{ __('Files') }}" aria-label="{{ __('Files') }}"
class="w-full" class="w-full"
/> >
{{ __('Files') }}
</flux:button>
@if ((int) $this->conversation->files_count > 0) @if ((int) $this->conversation->files_count > 0)
<span class="absolute -end-1 -top-1 flex min-w-5 items-center justify-center rounded-full bg-zinc-900 px-1 text-[10px] font-semibold text-white shadow-sm dark:bg-white dark:text-zinc-950"> <span class="absolute -end-1 -top-1 flex min-w-5 items-center justify-center rounded-full bg-zinc-900 px-1 text-[10px] font-semibold text-white shadow-sm dark:bg-white dark:text-zinc-950">

View File

@@ -48,6 +48,8 @@
@php @php
$selected = $selectedConversationId === $conversation->id; $selected = $selectedConversationId === $conversation->id;
$unreadCount = (int) ($conversation->unread_messages_count ?? 0); $unreadCount = (int) ($conversation->unread_messages_count ?? 0);
$isPinned = $this->isPinnedFor($conversation);
$isMuted = $this->isMutedFor($conversation);
@endphp @endphp
<button <button
@@ -111,6 +113,28 @@
'bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-300' => ! $selected, 'bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-300' => ! $selected,
])>{{ __('Group') }}</span> ])>{{ __('Group') }}</span>
@endif @endif
@if ($isPinned)
<span @class([
'inline-flex shrink-0 items-center',
'text-amber-300 dark:text-amber-600' => $selected,
'text-amber-500 dark:text-amber-400' => ! $selected,
])>
<span class="sr-only">{{ __('Pinned') }}</span>
<flux:icon.bookmark class="size-3.5" />
</span>
@endif
@if ($isMuted)
<span @class([
'inline-flex shrink-0 items-center',
'text-white/60 dark:text-zinc-600' => $selected,
'text-zinc-400 dark:text-zinc-500' => ! $selected,
])>
<span class="sr-only">{{ __('Muted') }}</span>
<flux:icon.bell-slash class="size-3.5" />
</span>
@endif
</div> </div>
<p @class([ <p @class([

View File

@@ -79,6 +79,27 @@
@enderror @enderror
</div> </div>
@if ($emojiPickerOpen)
<div
wire:key="emoji-picker"
class="mb-2 rounded-lg border border-zinc-200 bg-white p-2 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<div class="grid grid-cols-9 gap-1 sm:grid-cols-12">
@foreach ($this->emojiOptions() as $emoji)
<button
type="button"
wire:key="emoji-option-{{ $emoji['code'] }}"
wire:click="appendEmoji('{{ $emoji['code'] }}')"
class="flex aspect-square min-h-9 items-center justify-center rounded-md text-xl transition hover:bg-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent dark:hover:bg-zinc-800"
aria-label="{{ __('Add :emoji emoji', ['emoji' => $emoji['label']]) }}"
>
{{ html_entity_decode('&#x'.$emoji['code'].';', ENT_QUOTES, 'UTF-8') }}
</button>
@endforeach
</div>
</div>
@endif
<div class="flex items-end gap-2 rounded-lg border border-zinc-200 bg-white p-2 shadow-sm transition focus-within:border-zinc-300 focus-within:ring-2 focus-within:ring-accent focus-within:ring-offset-2 focus-within:ring-offset-white dark:border-zinc-800 dark:bg-zinc-900 dark:focus-within:border-zinc-700 dark:focus-within:ring-offset-zinc-950"> <div class="flex items-end gap-2 rounded-lg border border-zinc-200 bg-white p-2 shadow-sm transition focus-within:border-zinc-300 focus-within:ring-2 focus-within:ring-accent focus-within:ring-offset-2 focus-within:ring-offset-white dark:border-zinc-800 dark:bg-zinc-900 dark:focus-within:border-zinc-700 dark:focus-within:ring-offset-zinc-950">
<flux:tooltip :content="__('Attach file')" position="top"> <flux:tooltip :content="__('Attach file')" position="top">
<flux:button <flux:button
@@ -101,7 +122,14 @@
/> />
<flux:tooltip :content="__('Emoji')" position="top"> <flux:tooltip :content="__('Emoji')" position="top">
<flux:button type="button" variant="ghost" icon="face-smile" aria-label="{{ __('Emoji') }}" /> <flux:button
type="button"
variant="ghost"
icon="face-smile"
wire:click="toggleEmojiPicker"
aria-label="{{ __('Emoji') }}"
aria-expanded="{{ $emojiPickerOpen ? 'true' : 'false' }}"
/>
</flux:tooltip> </flux:tooltip>
<flux:button <flux:button

View File

@@ -123,6 +123,29 @@ test('participants can send messages', function () {
->exists())->toBeTrue(); ->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('&#x1F44D;', 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 () { test('participants can send file messages', function () {
Storage::fake('local'); Storage::fake('local');
@@ -262,6 +285,67 @@ test('conversation details files action shows shared files', function () {
->assertSee('Conversation files'); ->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 () { test('participants can add people to a direct conversation', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$teammate = User::factory()->create(['name' => 'Mina Partner']); $teammate = User::factory()->create(['name' => 'Mina Partner']);