From 4776af5c2a6411c34b60166ad8e96843f969b840 Mon Sep 17 00:00:00 2001 From: Meghdad Date: Fri, 1 May 2026 01:36:04 +0330 Subject: [PATCH] implement creating new conversation --- app/Livewire/Chat/ChatPage.php | 4 +- .../Chat/ConversationDetailsPanel.php | 203 +++++++++++++ app/Livewire/Chat/ConversationHeader.php | 9 + app/Livewire/Chat/ConversationList.php | 213 ++++++++++++++ app/Livewire/Chat/ConversationView.php | 8 + app/Policies/ConversationPolicy.php | 16 ++ .../views/livewire/chat/chat-page.blade.php | 13 +- .../chat/conversation-details-panel.blade.php | 146 +++++++++- .../livewire/chat/conversation-list.blade.php | 272 +++++++++++++----- tests/Feature/ChatTest.php | 82 +++++- 10 files changed, 886 insertions(+), 80 deletions(-) diff --git a/app/Livewire/Chat/ChatPage.php b/app/Livewire/Chat/ChatPage.php index 1f510c8..c7228f2 100644 --- a/app/Livewire/Chat/ChatPage.php +++ b/app/Livewire/Chat/ChatPage.php @@ -17,7 +17,7 @@ class ChatPage extends Component { public ?int $selectedConversationId = null; - public bool $detailsPanelOpen = true; + public bool $detailsPanelOpen = false; #[On('conversation-selected')] public function selectConversation(int $conversationId): void @@ -29,7 +29,7 @@ class ChatPage extends Component Gate::authorize('view', $conversation); $this->selectedConversationId = $conversation->id; - $this->detailsPanelOpen = true; + $this->detailsPanelOpen = false; } #[On('conversation-closed')] diff --git a/app/Livewire/Chat/ConversationDetailsPanel.php b/app/Livewire/Chat/ConversationDetailsPanel.php index 1517f35..5236130 100644 --- a/app/Livewire/Chat/ConversationDetailsPanel.php +++ b/app/Livewire/Chat/ConversationDetailsPanel.php @@ -4,11 +4,18 @@ namespace App\Livewire\Chat; use App\Models\Conversation; use App\Models\ConversationParticipant; +use App\Models\User; +use Flux\Flux; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; +use Illuminate\Validation\Rule; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; +use Livewire\Attributes\On; use Livewire\Component; class ConversationDetailsPanel extends Component @@ -16,11 +23,30 @@ class ConversationDetailsPanel extends Component #[Locked] public int $conversationId; + public bool $showAddMembersModal = false; + + public string $memberSearch = ''; + + /** + * @var array + */ + public array $selectedMemberIds = []; + + public string $groupName = ''; + public function mount(int $conversationId): void { $this->conversationId = $conversationId; } + #[On('conversation-updated')] + public function refreshConversation(int $conversationId): void + { + if ($conversationId === $this->conversationId) { + unset($this->conversation); + } + } + #[Computed] public function conversation(): Conversation { @@ -59,6 +85,183 @@ class ConversationDetailsPanel extends Component ->implode(''); } + public function canAddMembers(): bool + { + return Gate::allows('addMembers', $this->conversation); + } + + public function closeDetails(): void + { + $this->dispatch('conversation-details-toggled'); + } + + public function openAddMembers(): void + { + Gate::authorize('addMembers', $this->conversation); + + $this->resetAddMembersForm(); + $this->groupName = $this->conversation->isGroup() ? '' : $this->suggestedGroupName(); + $this->showAddMembersModal = true; + } + + public function toggleMember(int $userId): void + { + if ($userId === Auth::id()) { + return; + } + + if ($this->conversation->participants->contains('user_id', $userId)) { + return; + } + + if (! User::query()->whereKey($userId)->exists()) { + return; + } + + if (in_array($userId, $this->selectedMemberIds, true)) { + $this->selectedMemberIds = array_values(array_diff($this->selectedMemberIds, [$userId])); + } else { + $this->selectedMemberIds[] = $userId; + } + + $this->resetValidation('selectedMemberIds'); + + unset($this->availableUsers, $this->selectedMembers); + } + + public function removeSelectedMember(int $userId): void + { + $this->selectedMemberIds = array_values(array_diff($this->selectedMemberIds, [$userId])); + + unset($this->availableUsers, $this->selectedMembers); + } + + public function addMembers(): void + { + $conversation = $this->conversation; + + Gate::authorize('addMembers', $conversation); + + $validated = $this->validate([ + 'selectedMemberIds' => ['required', 'array', 'min:1'], + 'selectedMemberIds.*' => ['integer', Rule::exists('users', 'id')], + 'groupName' => ['nullable', 'string', 'max:80'], + ]); + + $existingMemberIds = $conversation->participants->pluck('user_id'); + $memberIds = User::query() + ->whereKey($validated['selectedMemberIds']) + ->whereKeyNot(Auth::id()) + ->whereNotIn('id', $existingMemberIds) + ->pluck('id') + ->values(); + + if ($memberIds->isEmpty()) { + $this->addError('selectedMemberIds', __('Choose at least one new person.')); + + return; + } + + DB::transaction(function () use ($conversation, $memberIds): void { + if (! $conversation->isGroup()) { + $conversation->forceFill([ + 'type' => Conversation::TypeGroup, + 'name' => trim($this->groupName) ?: $this->suggestedGroupName($memberIds), + ])->save(); + + ConversationParticipant::query() + ->where('conversation_id', $conversation->id) + ->where('user_id', Auth::id()) + ->update(['role' => ConversationParticipant::RoleAdmin]); + } + + $memberIds->each(fn (int $memberId) => ConversationParticipant::query()->firstOrCreate([ + 'conversation_id' => $conversation->id, + 'user_id' => $memberId, + ], [ + 'role' => ConversationParticipant::RoleMember, + 'joined_at' => now(), + ])); + + $conversation->touch(); + }); + + $this->resetAddMembersForm(); + $this->showAddMembersModal = false; + + unset($this->conversation); + + Flux::toast(variant: 'success', text: __('Members added.')); + + $this->dispatch('conversation-updated', conversationId: $this->conversationId); + } + + /** + * @return Collection + */ + #[Computed] + public function availableUsers(): Collection + { + $search = trim($this->memberSearch); + $participantIds = $this->conversation->participants->pluck('user_id')->all(); + + return User::query() + ->select(['id', 'name', 'email']) + ->whereNotIn('id', $participantIds) + ->whereNotIn('id', $this->selectedMemberIds) + ->when($search !== '', fn (Builder $query) => $query->where(function (Builder $query) use ($search): void { + $query + ->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + })) + ->orderBy('name') + ->limit(10) + ->get(); + } + + /** + * @return Collection + */ + #[Computed] + public function selectedMembers(): Collection + { + return User::query() + ->select(['id', 'name', 'email']) + ->whereKey($this->selectedMemberIds) + ->get() + ->sortBy(fn (User $user) => array_search($user->id, $this->selectedMemberIds, true)) + ->values(); + } + + private function suggestedGroupName(?Collection $newMemberIds = null): string + { + $participantNames = $this->conversation->participants + ->pluck('user.name') + ->filter(); + + if ($newMemberIds) { + $participantNames = $participantNames->merge( + User::query() + ->whereKey($newMemberIds->all()) + ->orderBy('name') + ->pluck('name') + ); + } + + return $participantNames + ->unique() + ->take(4) + ->join(', '); + } + + private function resetAddMembersForm(): void + { + $this->reset('memberSearch', 'selectedMemberIds', 'groupName'); + $this->resetValidation(); + + unset($this->availableUsers, $this->selectedMembers); + } + public function render(): View { return view('livewire.chat.conversation-details-panel'); diff --git a/app/Livewire/Chat/ConversationHeader.php b/app/Livewire/Chat/ConversationHeader.php index 987cdbb..fe0e8e7 100644 --- a/app/Livewire/Chat/ConversationHeader.php +++ b/app/Livewire/Chat/ConversationHeader.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; +use Livewire\Attributes\On; use Livewire\Component; class ConversationHeader extends Component @@ -22,6 +23,14 @@ class ConversationHeader extends Component $this->conversationId = $conversationId; } + #[On('conversation-updated')] + public function refreshConversation(int $conversationId): void + { + if ($conversationId === $this->conversationId) { + unset($this->conversation); + } + } + #[Computed] public function conversation(): Conversation { diff --git a/app/Livewire/Chat/ConversationList.php b/app/Livewire/Chat/ConversationList.php index 370e5d2..adb90f0 100644 --- a/app/Livewire/Chat/ConversationList.php +++ b/app/Livewire/Chat/ConversationList.php @@ -6,10 +6,14 @@ use App\Models\Conversation; use App\Models\ConversationParticipant; use App\Models\User; use Carbon\CarbonInterface; +use Flux\Flux; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; +use Illuminate\Validation\Rule; use Livewire\Attributes\Computed; use Livewire\Attributes\On; use Livewire\Component; @@ -20,7 +24,24 @@ class ConversationList extends Component public string $search = ''; + public bool $showCreateConversationModal = false; + + public string $createType = Conversation::TypeDirect; + + public string $memberSearch = ''; + + /** + * @var array + */ + public array $selectedMemberIds = []; + + public string $groupName = ''; + + public string $groupDescription = ''; + #[On('message-created')] + #[On('conversation-created')] + #[On('conversation-updated')] public function refreshConversations(): void { unset($this->conversations); @@ -31,6 +52,136 @@ class ConversationList extends Component $this->dispatch('conversation-selected', conversationId: $conversationId); } + public function openCreateConversation(): void + { + $this->resetCreateConversationForm(); + + $this->showCreateConversationModal = true; + } + + public function updatedCreateType(string $createType): void + { + if ($createType === Conversation::TypeDirect && count($this->selectedMemberIds) > 1) { + $this->selectedMemberIds = [array_values($this->selectedMemberIds)[0]]; + } + + $this->resetValidation(); + + unset($this->candidateUsers, $this->selectedMembers); + } + + public function toggleMember(int $userId): void + { + if ($userId === Auth::id()) { + return; + } + + if (! User::query()->whereKey($userId)->exists()) { + return; + } + + if ($this->createType === Conversation::TypeDirect) { + $this->selectedMemberIds = [$userId]; + } elseif (in_array($userId, $this->selectedMemberIds, true)) { + $this->selectedMemberIds = array_values(array_diff($this->selectedMemberIds, [$userId])); + } else { + $this->selectedMemberIds[] = $userId; + } + + $this->resetValidation('selectedMemberIds'); + + unset($this->candidateUsers, $this->selectedMembers); + } + + public function removeSelectedMember(int $userId): void + { + $this->selectedMemberIds = array_values(array_diff($this->selectedMemberIds, [$userId])); + + unset($this->candidateUsers, $this->selectedMembers); + } + + public function createConversation(): void + { + Gate::authorize('create', Conversation::class); + + $validated = $this->validate([ + 'createType' => ['required', Rule::in([Conversation::TypeDirect, Conversation::TypeGroup])], + 'selectedMemberIds' => ['required', 'array', 'min:1'], + 'selectedMemberIds.*' => ['integer', Rule::exists('users', 'id')], + 'groupName' => [ + Rule::requiredIf($this->createType === Conversation::TypeGroup), + 'nullable', + 'string', + 'max:80', + ], + 'groupDescription' => ['nullable', 'string', 'max:180'], + ]); + + $memberIds = User::query() + ->whereKey($validated['selectedMemberIds']) + ->whereKeyNot(Auth::id()) + ->pluck('id') + ->map(fn (int $id) => $id) + ->values(); + + if ($memberIds->isEmpty()) { + $this->addError('selectedMemberIds', __('Choose at least one person.')); + + return; + } + + if ($this->createType === Conversation::TypeDirect && $memberIds->count() !== 1) { + $this->addError('selectedMemberIds', __('Choose one person for a direct conversation.')); + + return; + } + + $conversation = DB::transaction(function () use ($memberIds, $validated): Conversation { + if ($this->createType === Conversation::TypeDirect) { + $existingConversation = $this->findExistingDirectConversation($memberIds->first()); + + if ($existingConversation) { + return $existingConversation; + } + } + + $conversation = Conversation::query()->create([ + 'created_by_id' => Auth::id(), + 'type' => $this->createType, + 'name' => $this->createType === Conversation::TypeGroup ? trim((string) $validated['groupName']) : null, + 'description' => $this->createType === Conversation::TypeGroup ? trim((string) ($validated['groupDescription'] ?? '')) ?: null : null, + ]); + + ConversationParticipant::query()->create([ + 'conversation_id' => $conversation->id, + 'user_id' => Auth::id(), + 'role' => ConversationParticipant::RoleAdmin, + 'joined_at' => now(), + 'last_read_at' => now(), + ]); + + $memberIds->each(fn (int $memberId) => ConversationParticipant::query()->create([ + 'conversation_id' => $conversation->id, + 'user_id' => $memberId, + 'role' => ConversationParticipant::RoleMember, + 'joined_at' => now(), + ])); + + return $conversation; + }); + + $this->resetCreateConversationForm(); + + $this->showCreateConversationModal = false; + + unset($this->conversations); + + Flux::toast(variant: 'success', text: __('Conversation ready.')); + + $this->dispatch('conversation-created', conversationId: $conversation->id); + $this->dispatch('conversation-selected', conversationId: $conversation->id); + } + /** * @return Collection */ @@ -79,6 +230,42 @@ class ConversationList extends Component ->get(); } + /** + * @return Collection + */ + #[Computed] + public function candidateUsers(): Collection + { + $search = trim($this->memberSearch); + + return User::query() + ->select(['id', 'name', 'email']) + ->whereKeyNot(Auth::id()) + ->whereNotIn('id', $this->selectedMemberIds) + ->when($search !== '', fn (Builder $query) => $query->where(function (Builder $query) use ($search): void { + $query + ->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + })) + ->orderBy('name') + ->limit(10) + ->get(); + } + + /** + * @return Collection + */ + #[Computed] + public function selectedMembers(): Collection + { + return User::query() + ->select(['id', 'name', 'email']) + ->whereKey($this->selectedMemberIds) + ->get() + ->sortBy(fn (User $user) => array_search($user->id, $this->selectedMemberIds, true)) + ->values(); + } + public function titleFor(Conversation $conversation): string { if ($conversation->isGroup()) { @@ -159,6 +346,32 @@ class ConversationList extends Component ?->user; } + private function findExistingDirectConversation(int $memberId): ?Conversation + { + return Conversation::query() + ->forUser(Auth::user()) + ->where('type', Conversation::TypeDirect) + ->whereHas('participants', fn (Builder $participants) => $participants->where('user_id', $memberId)) + ->withCount('participants') + ->get() + ->first(fn (Conversation $conversation) => (int) $conversation->participants_count === 2); + } + + private function resetCreateConversationForm(): void + { + $this->reset( + 'createType', + 'memberSearch', + 'selectedMemberIds', + 'groupName', + 'groupDescription', + ); + + $this->resetValidation(); + + unset($this->candidateUsers, $this->selectedMembers); + } + public function render(): View { return view('livewire.chat.conversation-list'); diff --git a/app/Livewire/Chat/ConversationView.php b/app/Livewire/Chat/ConversationView.php index 18efd72..dc287a1 100644 --- a/app/Livewire/Chat/ConversationView.php +++ b/app/Livewire/Chat/ConversationView.php @@ -45,6 +45,14 @@ class ConversationView extends Component $this->markAsRead(); } + #[On('conversation-updated')] + public function refreshConversation(int $conversationId): void + { + if ($conversationId === $this->conversationId) { + unset($this->conversation); + } + } + #[Computed] public function conversation(): Conversation { diff --git a/app/Policies/ConversationPolicy.php b/app/Policies/ConversationPolicy.php index 720fb34..bab26be 100644 --- a/app/Policies/ConversationPolicy.php +++ b/app/Policies/ConversationPolicy.php @@ -72,6 +72,22 @@ class ConversationPolicy return $this->participates($user, $conversation); } + public function addMembers(User $user, Conversation $conversation): bool + { + if (! $this->participates($user, $conversation)) { + return false; + } + + if (! $conversation->isGroup()) { + return true; + } + + return $conversation->participants() + ->where('user_id', $user->id) + ->where('role', ConversationParticipant::RoleAdmin) + ->exists(); + } + private function participates(User $user, Conversation $conversation): bool { return $conversation->participants() diff --git a/resources/views/livewire/chat/chat-page.blade.php b/resources/views/livewire/chat/chat-page.blade.php index 80c80b5..05ddf67 100644 --- a/resources/views/livewire/chat/chat-page.blade.php +++ b/resources/views/livewire/chat/chat-page.blade.php @@ -41,10 +41,19 @@ @if ($selectedConversationId && $detailsPanelOpen) - diff --git a/resources/views/livewire/chat/conversation-list.blade.php b/resources/views/livewire/chat/conversation-list.blade.php index 35cf46a..9878963 100644 --- a/resources/views/livewire/chat/conversation-list.blade.php +++ b/resources/views/livewire/chat/conversation-list.blade.php @@ -1,78 +1,33 @@
-
-
-
- {{ __('Inbox') }} +
+
+
+ {{ __('Messages') }} {{ trans_choice(':count conversation|:count conversations', $this->conversations->count(), ['count' => $this->conversations->count()]) }}
-
- {{ __('Live') }} - - - - - -
-
- - -
-
{{ auth()->user()->name }}
-
{{ auth()->user()->email }}
-
-
-
- - - - - {{ __('Profile settings') }} - - - - {{ __('Password and 2FA') }} - - - - {{ __('Appearance') }} - - - - -
- @csrf - - {{ __('Log out') }} - -
-
-
-
+ + {{ __('New') }} +
- +
+ +
@@ -208,4 +163,187 @@ @endforelse
+ +
+ + + + + + {{ __('Profile settings') }} + + + + {{ __('Password and 2FA') }} + + + + {{ __('Appearance') }} + + + + +
+ @csrf + + {{ __('Log out') }} + +
+
+
+
+ + +
+
+
+ {{ __('New conversation') }} + + {{ __('Start a direct message or create a group with your team.') }} + +
+ +
+ {{ trans_choice(':count selected|:count selected', count($selectedMemberIds), ['count' => count($selectedMemberIds)]) }} +
+
+ + + + {{ __('Direct') }} + + + {{ __('Group') }} + + + + @if ($createType === \App\Models\Conversation::TypeGroup) +
+ + + +
+ @endif + +
+ + + @error('selectedMemberIds') + {{ $message }} + @enderror + + @if ($this->selectedMembers->isNotEmpty()) +
+ @foreach ($this->selectedMembers as $member) + + @endforeach +
+ @endif + +
+ @forelse ($this->candidateUsers as $candidate) + + @empty +
+
+
+ +
+ {{ __('No people found') }} + + {{ __('Try a different name or email address.') }} + +
+
+ @endforelse +
+
+ +
+ + {{ __('Cancel') }} + + + + {{ $createType === \App\Models\Conversation::TypeGroup ? __('Create group') : __('Start chat') }} + +
+
+
diff --git a/tests/Feature/ChatTest.php b/tests/Feature/ChatTest.php index 927420e..d3a4a8d 100644 --- a/tests/Feature/ChatTest.php +++ b/tests/Feature/ChatTest.php @@ -20,7 +20,7 @@ test('authenticated users can visit the chat dashboard', function () { $this->actingAs($user) ->get(route('dashboard')) ->assertOk() - ->assertSee('Inbox') + ->assertSee('Messages') ->assertSee('Profile settings') ->assertSee('Password and 2FA') ->assertDontSee('Repository') @@ -41,9 +41,9 @@ test('conversation list only shows conversations the user participates in', func ConversationParticipant::factory()->for($visibleConversation)->for($teammate)->create(); $hiddenConversation = Conversation::factory() - ->direct() + ->group() ->for($outsider, 'creator') - ->create(); + ->create(['name' => 'Hidden Group']); ConversationParticipant::factory()->for($hiddenConversation)->for($outsider)->create(); @@ -52,7 +52,53 @@ test('conversation list only shows conversations the user participates in', func Livewire::test(ConversationList::class) ->assertSee('Visible Teammate') ->assertSee('Opening') - ->assertDontSee('Hidden Teammate'); + ->assertDontSee('Hidden Group'); +}); + +test('users can create direct conversations', function () { + $user = User::factory()->create(); + $teammate = User::factory()->create(['name' => 'Direct Partner']); + + $this->actingAs($user); + + Livewire::test(ConversationList::class) + ->call('openCreateConversation') + ->call('toggleMember', $teammate->id) + ->call('createConversation') + ->assertHasNoErrors(); + + $conversation = Conversation::query() + ->where('type', Conversation::TypeDirect) + ->whereHas('participants', fn ($query) => $query->where('user_id', $user->id)) + ->whereHas('participants', fn ($query) => $query->where('user_id', $teammate->id)) + ->first(); + + expect($conversation)->not->toBeNull(); + expect($conversation->participants()->count())->toBe(2); +}); + +test('users can create group conversations with members', function () { + $user = User::factory()->create(); + $firstMember = User::factory()->create(['name' => 'Launch Lead']); + $secondMember = User::factory()->create(['name' => 'Design Lead']); + + $this->actingAs($user); + + Livewire::test(ConversationList::class) + ->set('createType', Conversation::TypeGroup) + ->set('groupName', 'Launch Room') + ->call('toggleMember', $firstMember->id) + ->call('toggleMember', $secondMember->id) + ->call('createConversation') + ->assertHasNoErrors(); + + $conversation = Conversation::query() + ->where('type', Conversation::TypeGroup) + ->where('name', 'Launch Room') + ->first(); + + expect($conversation)->not->toBeNull(); + expect($conversation->participants()->count())->toBe(3); }); test('participants can send messages', function () { @@ -78,6 +124,34 @@ test('participants can send messages', function () { ->exists())->toBeTrue(); }); +test('participants can add people to a direct conversation', function () { + $user = User::factory()->create(); + $teammate = User::factory()->create(['name' => 'Mina Partner']); + $newMember = User::factory()->create(['name' => 'Nima Added']); + $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(ConversationDetailsPanel::class, ['conversationId' => $conversation->id]) + ->call('openAddMembers') + ->set('groupName', 'Project Room') + ->call('toggleMember', $newMember->id) + ->call('addMembers') + ->assertHasNoErrors(); + + $conversation->refresh(); + + expect($conversation->type)->toBe(Conversation::TypeGroup); + expect($conversation->name)->toBe('Project Room'); + expect($conversation->participants()->where('user_id', $newMember->id)->exists())->toBeTrue(); +}); + test('selected conversations render the stream header and details', function () { $user = User::factory()->create(); $teammate = User::factory()->create(['name' => 'Mina Partner']);