implement sending Emoji, pin or mute conversation
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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('👍', 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']);
|
||||||
|
|||||||
Reference in New Issue
Block a user