From 12b6bb4d76b719b3238c5b60e6402faae09d3616 Mon Sep 17 00:00:00 2001 From: Meghdad Date: Fri, 1 May 2026 02:02:34 +0330 Subject: [PATCH] implement uploading file --- .../Chat/MessageAttachmentController.php | 26 ++++ .../Chat/ConversationDetailsPanel.php | 42 +++++- app/Livewire/Chat/ConversationList.php | 8 +- app/Livewire/Chat/MessageComposer.php | 98 ++++++++++-- app/Models/Message.php | 52 ++++++- database/factories/MessageFactory.php | 21 +++ .../chat/conversation-details-panel.blade.php | 90 ++++++++++- .../livewire/chat/conversation-view.blade.php | 38 ++++- .../livewire/chat/message-composer.blade.php | 94 +++++++++++- routes/web.php | 3 + tests/Feature/ChatTest.php | 141 ++++++++++++++++++ 11 files changed, 589 insertions(+), 24 deletions(-) create mode 100644 app/Http/Controllers/Chat/MessageAttachmentController.php diff --git a/app/Http/Controllers/Chat/MessageAttachmentController.php b/app/Http/Controllers/Chat/MessageAttachmentController.php new file mode 100644 index 0000000..87dbb17 --- /dev/null +++ b/app/Http/Controllers/Chat/MessageAttachmentController.php @@ -0,0 +1,26 @@ +loadMissing('conversation'); + + Gate::authorize('view', $message->conversation); + + $path = $message->attachmentPath(); + + abort_unless($message->isFile() && $path, 404); + abort_unless(Storage::disk($message->attachmentDisk())->exists($path), 404); + + return Storage::disk($message->attachmentDisk())->download($path, $message->attachmentName()); + } +} diff --git a/app/Livewire/Chat/ConversationDetailsPanel.php b/app/Livewire/Chat/ConversationDetailsPanel.php index 5236130..e2940a5 100644 --- a/app/Livewire/Chat/ConversationDetailsPanel.php +++ b/app/Livewire/Chat/ConversationDetailsPanel.php @@ -4,6 +4,7 @@ namespace App\Livewire\Chat; use App\Models\Conversation; use App\Models\ConversationParticipant; +use App\Models\Message; use App\Models\User; use Flux\Flux; use Illuminate\Contracts\View\View; @@ -25,6 +26,8 @@ class ConversationDetailsPanel extends Component public bool $showAddMembersModal = false; + public bool $showFilesModal = false; + public string $memberSearch = ''; /** @@ -47,6 +50,14 @@ class ConversationDetailsPanel extends Component } } + #[On('message-created')] + public function refreshFiles(int $conversationId): void + { + if ($conversationId === $this->conversationId) { + unset($this->conversation, $this->files); + } + } + #[Computed] public function conversation(): Conversation { @@ -57,7 +68,10 @@ class ConversationDetailsPanel extends Component ->select(['id', 'conversation_id', 'user_id', 'role', 'joined_at']) ->with('user:id,name,email'), ]) - ->withCount('messages') + ->withCount([ + 'messages', + 'messages as files_count' => fn (Builder $messages) => $messages->where('type', Message::TypeFile), + ]) ->findOrFail($this->conversationId); Gate::authorize('view', $conversation); @@ -104,6 +118,15 @@ class ConversationDetailsPanel extends Component $this->showAddMembersModal = true; } + public function openFiles(): void + { + Gate::authorize('view', $this->conversation); + + unset($this->files); + + $this->showFilesModal = true; + } + public function toggleMember(int $userId): void { if ($userId === Auth::id()) { @@ -233,6 +256,23 @@ class ConversationDetailsPanel extends Component ->values(); } + /** + * @return Collection + */ + #[Computed] + public function files(): Collection + { + Gate::authorize('view', $this->conversation); + + return Message::query() + ->where('conversation_id', $this->conversationId) + ->where('type', Message::TypeFile) + ->with('sender:id,name,email') + ->latest() + ->limit(50) + ->get(); + } + private function suggestedGroupName(?Collection $newMemberIds = null): string { $participantNames = $this->conversation->participants diff --git a/app/Livewire/Chat/ConversationList.php b/app/Livewire/Chat/ConversationList.php index adb90f0..2f51f0b 100644 --- a/app/Livewire/Chat/ConversationList.php +++ b/app/Livewire/Chat/ConversationList.php @@ -199,7 +199,7 @@ class ConversationList extends Component ->select(['id', 'conversation_id', 'user_id', 'role', 'last_read_at']) ->with('user:id,name,email'), 'latestMessage' => fn ($query) => $query - ->select(['messages.id', 'messages.conversation_id', 'messages.user_id', 'messages.body', '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', ]) ->withMax('messages', 'created_at') @@ -298,7 +298,11 @@ class ConversationList extends Component ? __('You: ') : ($conversation->isGroup() ? $conversation->latestMessage->sender?->name.': ' : ''); - return str($prefix.$conversation->latestMessage->body) + $preview = $conversation->latestMessage->isFile() + ? __('Sent a file: :name', ['name' => $conversation->latestMessage->attachmentName()]) + : $conversation->latestMessage->body; + + return str($prefix.$preview) ->squish() ->limit(86) ->toString(); diff --git a/app/Livewire/Chat/MessageComposer.php b/app/Livewire/Chat/MessageComposer.php index 8d517be..07fd09c 100644 --- a/app/Livewire/Chat/MessageComposer.php +++ b/app/Livewire/Chat/MessageComposer.php @@ -7,17 +7,27 @@ use App\Models\ConversationParticipant; use App\Models\Message; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; use Livewire\Attributes\Locked; use Livewire\Component; +use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; +use Livewire\WithFileUploads; class MessageComposer extends Component { + use WithFileUploads; + #[Locked] public int $conversationId; public string $body = ''; + /** + * @var array + */ + public array $attachments = []; + public function mount(int $conversationId): void { $this->conversationId = $conversationId; @@ -28,37 +38,90 @@ class MessageComposer extends Component public function sendMessage(): void { $this->body = trim($this->body); + $hasAttachments = $this->hasAttachments(); $validated = $this->validate([ - 'body' => ['required', 'string', 'max:4000'], + 'body' => [$hasAttachments ? 'nullable' : 'required', 'string', 'max:4000'], + 'attachments' => ['array', 'max:5'], + 'attachments.*' => ['file', 'max:10240'], ], [ - 'body.required' => __('Write a message before sending.'), + 'body.required' => __('Write a message or attach a file before sending.'), + 'attachments.max' => __('Attach up to :max files at a time.'), + 'attachments.*.file' => __('Each attachment must be a valid file.'), + 'attachments.*.max' => __('Each attachment must be 10 MB or smaller.'), ]); $conversation = $this->conversation(); Gate::authorize('sendMessage', $conversation); - $message = Message::query()->create([ - 'conversation_id' => $conversation->id, - 'user_id' => Auth::id(), - 'type' => Message::TypeText, - 'body' => $validated['body'], - ]); + $message = DB::transaction(function () use ($conversation, $validated): Message { + $latestMessage = null; + $body = trim((string) ($validated['body'] ?? '')); - $conversation->touch(); + if ($body !== '') { + $latestMessage = Message::query()->create([ + 'conversation_id' => $conversation->id, + 'user_id' => Auth::id(), + 'type' => Message::TypeText, + 'body' => $body, + ]); + } - ConversationParticipant::query() - ->where('conversation_id', $conversation->id) - ->where('user_id', Auth::id()) - ->update(['last_read_at' => now()]); + foreach ($this->attachments as $attachment) { + $metadata = [ + 'disk' => 'local', + 'original_name' => $attachment->getClientOriginalName(), + 'mime_type' => $attachment->getMimeType(), + 'size' => $attachment->getSize(), + ]; - $this->reset('body'); + $path = $attachment->store( + path: "chat-attachments/{$conversation->id}", + options: 'local', + ); + + $metadata['path'] = $path; + + $latestMessage = Message::query()->create([ + 'conversation_id' => $conversation->id, + 'user_id' => Auth::id(), + 'type' => Message::TypeFile, + 'body' => $metadata['original_name'], + 'metadata' => $metadata, + ]); + } + + $conversation->touch(); + + ConversationParticipant::query() + ->where('conversation_id', $conversation->id) + ->where('user_id', Auth::id()) + ->update(['last_read_at' => now()]); + + return $latestMessage; + }); + + $this->reset('body', 'attachments'); $this->resetValidation(); $this->dispatch('message-created', conversationId: $conversation->id, messageId: $message->id); } + public function removeAttachment(int $index): void + { + if (! array_key_exists($index, $this->attachments)) { + return; + } + + unset($this->attachments[$index]); + + $this->attachments = array_values($this->attachments); + + $this->resetValidation('attachments'); + $this->resetValidation("attachments.{$index}"); + } + private function conversation(): Conversation { $conversation = Conversation::query() @@ -70,6 +133,13 @@ class MessageComposer extends Component return $conversation; } + private function hasAttachments(): bool + { + return collect($this->attachments) + ->filter() + ->isNotEmpty(); + } + public function render(): View { return view('livewire.chat.message-composer'); diff --git a/app/Models/Message.php b/app/Models/Message.php index 48d0d9f..37d0614 100644 --- a/app/Models/Message.php +++ b/app/Models/Message.php @@ -5,8 +5,9 @@ namespace App\Models; use Database\Factories\MessageFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Number; #[Fillable(['conversation_id', 'user_id', 'type', 'body', 'metadata', 'edited_at'])] class Message extends Model @@ -16,6 +17,8 @@ class Message extends Model public const string TypeText = 'text'; + public const string TypeFile = 'file'; + /** * @return array */ @@ -50,4 +53,51 @@ class Message extends Model { return $this->belongsTo(User::class); } + + public function isFile(): bool + { + return $this->type === self::TypeFile; + } + + public function attachmentDisk(): string + { + return (string) data_get($this->metadata, 'disk', 'local'); + } + + public function attachmentPath(): ?string + { + $path = data_get($this->metadata, 'path'); + + return is_string($path) && $path !== '' ? $path : null; + } + + public function attachmentName(): string + { + $name = data_get($this->metadata, 'original_name'); + + if (is_string($name) && $name !== '') { + return $name; + } + + return $this->body ?: __('Attachment'); + } + + public function attachmentMimeType(): ?string + { + $mimeType = data_get($this->metadata, 'mime_type'); + + return is_string($mimeType) && $mimeType !== '' ? $mimeType : null; + } + + public function attachmentSize(): int + { + return (int) data_get($this->metadata, 'size', 0); + } + + public function formattedAttachmentSize(): string + { + $size = $this->attachmentSize(); + + return $size > 0 ? Number::fileSize($size) : __('Unknown size'); + } } diff --git a/database/factories/MessageFactory.php b/database/factories/MessageFactory.php index 992e63f..23ed928 100644 --- a/database/factories/MessageFactory.php +++ b/database/factories/MessageFactory.php @@ -35,4 +35,25 @@ class MessageFactory extends Factory 'edited_at' => null, ]; } + + public function file(array $metadata = []): static + { + $name = $metadata['original_name'] ?? fake()->randomElement([ + 'project-brief.pdf', + 'launch-notes.txt', + 'design-review.png', + ]); + + return $this->state(fn (array $attributes) => [ + 'type' => Message::TypeFile, + 'body' => $name, + 'metadata' => array_merge([ + 'disk' => 'local', + 'path' => 'chat-attachments/'.fake()->uuid().'/'.$name, + 'original_name' => $name, + 'mime_type' => 'application/octet-stream', + 'size' => fake()->numberBetween(24_000, 2_400_000), + ], $metadata), + ]); + } } diff --git a/resources/views/livewire/chat/conversation-details-panel.blade.php b/resources/views/livewire/chat/conversation-details-panel.blade.php index b1ba7a8..42f2841 100644 --- a/resources/views/livewire/chat/conversation-details-panel.blade.php +++ b/resources/views/livewire/chat/conversation-details-panel.blade.php @@ -52,7 +52,24 @@ - +
+ + + @if ((int) $this->conversation->files_count > 0) + + {{ $this->conversation->files_count }} + + @endif +
@@ -120,6 +137,11 @@
{{ __('Messages') }}
{{ $this->conversation->messages_count }}
+ +
+
{{ __('Files') }}
+
{{ $this->conversation->files_count }}
+
@@ -235,4 +257,70 @@ + + +
+
+
+ {{ __('Conversation files') }} + + {{ __('Recent files shared in this conversation.') }} + +
+ + + {{ trans_choice(':count file|:count files', $this->conversation->files_count, ['count' => $this->conversation->files_count]) }} + +
+ +
+ @forelse ($this->files as $file) + + + + + + + + {{ $file->attachmentName() }} + + + {{ $file->formattedAttachmentSize() }} + + {{ $file->sender?->name ?? __('Deleted user') }} + + {{ $file->created_at->format('M j, H:i') }} + + + + + + + + @empty +
+
+
+ +
+ {{ __('No files shared yet') }} + + {{ __('Files you send in the composer will appear here for quick access.') }} + +
+
+ @endforelse +
+ +
+ + {{ __('Close') }} + +
+
+
diff --git a/resources/views/livewire/chat/conversation-view.blade.php b/resources/views/livewire/chat/conversation-view.blade.php index 9f1a4a1..615925c 100644 --- a/resources/views/livewire/chat/conversation-view.blade.php +++ b/resources/views/livewire/chat/conversation-view.blade.php @@ -97,7 +97,43 @@ 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-950' => $isMine, 'border border-zinc-200 bg-white text-zinc-800 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100' => ! $isMine, ])> -

{{ $message->body }}

+ @if ($message->isFile()) + $isMine, + 'border-zinc-200 bg-zinc-50 hover:bg-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent dark:border-zinc-700 dark:bg-zinc-950 dark:hover:bg-zinc-800' => ! $isMine, + ]) + > + $isMine, + 'bg-white text-zinc-500 shadow-sm dark:bg-zinc-900 dark:text-zinc-300' => ! $isMine, + ])> + + + + + {{ $message->attachmentName() }} + $isMine, + 'text-zinc-500 dark:text-zinc-400' => ! $isMine, + ])> + {{ $message->formattedAttachmentSize() }} + + + + $isMine, + 'text-zinc-400 dark:text-zinc-500' => ! $isMine, + ]) /> + + @else +

{{ $message->body }}

+ @endif
+
+ + +
+
+
+
{{ __('Attachments') }}
+
{{ __('Up to 5 files, 10 MB each') }}
+
+ + + {{ __('Add files') }} + +
+ +
+ + {{ __('Uploading files...') }} +
+ + @if (count($attachments) > 0) +
+ @foreach ($attachments as $index => $attachment) +
+
+ +
+ +
+
+ {{ $attachment->getClientOriginalName() }} +
+
+ {{ \Illuminate\Support\Number::fileSize((int) $attachment->getSize()) }} +
+
+ + +
+ @endforeach +
+ @endif + + @error('attachments') + {{ $message }} + @enderror + + @error('attachments.*') + {{ $message }} + @enderror +
+
- +
diff --git a/routes/web.php b/routes/web.php index 1a2691d..2b427c5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ name('home'); Route::middleware(['auth', 'verified'])->group(function () { Route::livewire('dashboard', ChatPage::class)->name('dashboard'); + Route::get('messages/{message}/attachment', MessageAttachmentController::class) + ->name('messages.attachment.download'); }); require __DIR__.'/settings.php'; diff --git a/tests/Feature/ChatTest.php b/tests/Feature/ChatTest.php index d3a4a8d..f5ffe8a 100644 --- a/tests/Feature/ChatTest.php +++ b/tests/Feature/ChatTest.php @@ -10,6 +10,8 @@ use App\Models\ConversationParticipant; use App\Models\Message; use App\Models\User; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; uses(LazilyRefreshDatabase::class); @@ -124,6 +126,145 @@ test('participants can send messages', function () { ->exists())->toBeTrue(); }); +test('participants can send file messages', function () { + Storage::fake('local'); + + $user = User::factory()->create(); + $conversation = Conversation::factory() + ->direct() + ->for($user, 'creator') + ->create(); + + ConversationParticipant::factory()->for($conversation)->for($user)->create(); + + $this->actingAs($user); + + Livewire::test(MessageComposer::class, ['conversationId' => $conversation->id]) + ->set('attachments', [ + UploadedFile::fake()->create('handoff.pdf', 64, 'application/pdf'), + ]) + ->call('sendMessage') + ->assertHasNoErrors() + ->assertSet('body', '') + ->assertSet('attachments', []); + + $message = Message::query() + ->where('conversation_id', $conversation->id) + ->where('user_id', $user->id) + ->where('type', Message::TypeFile) + ->first(); + + expect($message)->not->toBeNull(); + expect($message->attachmentName())->toBe('handoff.pdf'); + + Storage::disk('local')->assertExists($message->attachmentPath()); +}); + +test('file metadata is captured before temporary uploads are moved', function () { + Storage::fake('local'); + + $user = User::factory()->create(); + $conversation = Conversation::factory() + ->direct() + ->for($user, 'creator') + ->create(); + + ConversationParticipant::factory()->for($conversation)->for($user)->create(); + + $this->actingAs($user); + + $originalEnvironment = app()->environment(); + $content = str_repeat('x', 131072); + + try { + app()->instance('env', 'local'); + + Livewire::test(MessageComposer::class, ['conversationId' => $conversation->id]) + ->set('attachments', [ + UploadedFile::fake()->createWithContent('screenshot.txt', $content), + ]) + ->call('sendMessage') + ->assertHasNoErrors(); + } finally { + app()->instance('env', $originalEnvironment); + } + + $message = Message::query() + ->where('conversation_id', $conversation->id) + ->where('type', Message::TypeFile) + ->first(); + + expect($message)->not->toBeNull(); + expect($message->metadata) + ->toMatchArray([ + 'original_name' => 'screenshot.txt', + 'size' => 131072, + ]); +}); + +test('participants can download shared files', function () { + Storage::fake('local'); + + $user = User::factory()->create(); + $outsider = User::factory()->create(); + $conversation = Conversation::factory() + ->direct() + ->for($user, 'creator') + ->create(); + + ConversationParticipant::factory()->for($conversation)->for($user)->create(); + + Storage::disk('local')->put('chat-attachments/'.$conversation->id.'/handoff.pdf', 'file contents'); + + $message = Message::factory() + ->file([ + 'path' => 'chat-attachments/'.$conversation->id.'/handoff.pdf', + 'original_name' => 'handoff.pdf', + 'mime_type' => 'application/pdf', + 'size' => 13, + ]) + ->for($conversation) + ->for($user, 'sender') + ->create(); + + $this->actingAs($user) + ->get(route('messages.attachment.download', $message)) + ->assertOk() + ->assertDownload('handoff.pdf'); + + $this->actingAs($outsider) + ->get(route('messages.attachment.download', $message)) + ->assertForbidden(); +}); + +test('conversation details files action shows shared files', function () { + $user = User::factory()->create(); + $conversation = Conversation::factory() + ->direct() + ->for($user, 'creator') + ->create(); + + ConversationParticipant::factory()->for($conversation)->for($user)->create(); + + Message::factory() + ->file([ + 'original_name' => 'launch-plan.pdf', + 'path' => 'chat-attachments/'.$conversation->id.'/launch-plan.pdf', + 'size' => 42000, + ]) + ->for($conversation) + ->for($user, 'sender') + ->create(); + + $this->actingAs($user); + + Livewire::test(ConversationDetailsPanel::class, ['conversationId' => $conversation->id]) + ->call('openFiles') + ->assertSet('showFilesModal', true) + ->assertSee('launch-plan.pdf') + ->assertSee('Conversation files'); +}); + test('participants can add people to a direct conversation', function () { $user = User::factory()->create(); $teammate = User::factory()->create(['name' => 'Mina Partner']);