implement uploading file
This commit is contained in:
26
app/Http/Controllers/Chat/MessageAttachmentController.php
Normal file
26
app/Http/Controllers/Chat/MessageAttachmentController.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Chat;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Message;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class MessageAttachmentController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Message $message): StreamedResponse
|
||||||
|
{
|
||||||
|
$message->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Chat;
|
|||||||
|
|
||||||
use App\Models\Conversation;
|
use App\Models\Conversation;
|
||||||
use App\Models\ConversationParticipant;
|
use App\Models\ConversationParticipant;
|
||||||
|
use App\Models\Message;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Flux\Flux;
|
use Flux\Flux;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
@@ -25,6 +26,8 @@ class ConversationDetailsPanel extends Component
|
|||||||
|
|
||||||
public bool $showAddMembersModal = false;
|
public bool $showAddMembersModal = false;
|
||||||
|
|
||||||
|
public bool $showFilesModal = false;
|
||||||
|
|
||||||
public string $memberSearch = '';
|
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]
|
#[Computed]
|
||||||
public function conversation(): Conversation
|
public function conversation(): Conversation
|
||||||
{
|
{
|
||||||
@@ -57,7 +68,10 @@ class ConversationDetailsPanel extends Component
|
|||||||
->select(['id', 'conversation_id', 'user_id', 'role', 'joined_at'])
|
->select(['id', 'conversation_id', 'user_id', 'role', 'joined_at'])
|
||||||
->with('user:id,name,email'),
|
->with('user:id,name,email'),
|
||||||
])
|
])
|
||||||
->withCount('messages')
|
->withCount([
|
||||||
|
'messages',
|
||||||
|
'messages as files_count' => fn (Builder $messages) => $messages->where('type', Message::TypeFile),
|
||||||
|
])
|
||||||
->findOrFail($this->conversationId);
|
->findOrFail($this->conversationId);
|
||||||
|
|
||||||
Gate::authorize('view', $conversation);
|
Gate::authorize('view', $conversation);
|
||||||
@@ -104,6 +118,15 @@ class ConversationDetailsPanel extends Component
|
|||||||
$this->showAddMembersModal = true;
|
$this->showAddMembersModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function openFiles(): void
|
||||||
|
{
|
||||||
|
Gate::authorize('view', $this->conversation);
|
||||||
|
|
||||||
|
unset($this->files);
|
||||||
|
|
||||||
|
$this->showFilesModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
public function toggleMember(int $userId): void
|
public function toggleMember(int $userId): void
|
||||||
{
|
{
|
||||||
if ($userId === Auth::id()) {
|
if ($userId === Auth::id()) {
|
||||||
@@ -233,6 +256,23 @@ class ConversationDetailsPanel extends Component
|
|||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Message>
|
||||||
|
*/
|
||||||
|
#[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
|
private function suggestedGroupName(?Collection $newMemberIds = null): string
|
||||||
{
|
{
|
||||||
$participantNames = $this->conversation->participants
|
$participantNames = $this->conversation->participants
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class ConversationList extends Component
|
|||||||
->select(['id', 'conversation_id', 'user_id', 'role', 'last_read_at'])
|
->select(['id', 'conversation_id', 'user_id', 'role', 'last_read_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.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',
|
'latestMessage.sender:id,name,email',
|
||||||
])
|
])
|
||||||
->withMax('messages', 'created_at')
|
->withMax('messages', 'created_at')
|
||||||
@@ -298,7 +298,11 @@ class ConversationList extends Component
|
|||||||
? __('You: ')
|
? __('You: ')
|
||||||
: ($conversation->isGroup() ? $conversation->latestMessage->sender?->name.': ' : '');
|
: ($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()
|
->squish()
|
||||||
->limit(86)
|
->limit(86)
|
||||||
->toString();
|
->toString();
|
||||||
|
|||||||
@@ -7,17 +7,27 @@ use App\Models\ConversationParticipant;
|
|||||||
use App\Models\Message;
|
use App\Models\Message;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
class MessageComposer extends Component
|
class MessageComposer extends Component
|
||||||
{
|
{
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
#[Locked]
|
#[Locked]
|
||||||
public int $conversationId;
|
public int $conversationId;
|
||||||
|
|
||||||
public string $body = '';
|
public string $body = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, TemporaryUploadedFile>
|
||||||
|
*/
|
||||||
|
public array $attachments = [];
|
||||||
|
|
||||||
public function mount(int $conversationId): void
|
public function mount(int $conversationId): void
|
||||||
{
|
{
|
||||||
$this->conversationId = $conversationId;
|
$this->conversationId = $conversationId;
|
||||||
@@ -28,23 +38,59 @@ class MessageComposer extends Component
|
|||||||
public function sendMessage(): void
|
public function sendMessage(): void
|
||||||
{
|
{
|
||||||
$this->body = trim($this->body);
|
$this->body = trim($this->body);
|
||||||
|
$hasAttachments = $this->hasAttachments();
|
||||||
|
|
||||||
$validated = $this->validate([
|
$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();
|
$conversation = $this->conversation();
|
||||||
|
|
||||||
Gate::authorize('sendMessage', $conversation);
|
Gate::authorize('sendMessage', $conversation);
|
||||||
|
|
||||||
$message = Message::query()->create([
|
$message = DB::transaction(function () use ($conversation, $validated): Message {
|
||||||
|
$latestMessage = null;
|
||||||
|
$body = trim((string) ($validated['body'] ?? ''));
|
||||||
|
|
||||||
|
if ($body !== '') {
|
||||||
|
$latestMessage = Message::query()->create([
|
||||||
'conversation_id' => $conversation->id,
|
'conversation_id' => $conversation->id,
|
||||||
'user_id' => Auth::id(),
|
'user_id' => Auth::id(),
|
||||||
'type' => Message::TypeText,
|
'type' => Message::TypeText,
|
||||||
'body' => $validated['body'],
|
'body' => $body,
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->attachments as $attachment) {
|
||||||
|
$metadata = [
|
||||||
|
'disk' => 'local',
|
||||||
|
'original_name' => $attachment->getClientOriginalName(),
|
||||||
|
'mime_type' => $attachment->getMimeType(),
|
||||||
|
'size' => $attachment->getSize(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$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();
|
$conversation->touch();
|
||||||
|
|
||||||
@@ -53,12 +99,29 @@ class MessageComposer extends Component
|
|||||||
->where('user_id', Auth::id())
|
->where('user_id', Auth::id())
|
||||||
->update(['last_read_at' => now()]);
|
->update(['last_read_at' => now()]);
|
||||||
|
|
||||||
$this->reset('body');
|
return $latestMessage;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->reset('body', 'attachments');
|
||||||
$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 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
|
private function conversation(): Conversation
|
||||||
{
|
{
|
||||||
$conversation = Conversation::query()
|
$conversation = Conversation::query()
|
||||||
@@ -70,6 +133,13 @@ class MessageComposer extends Component
|
|||||||
return $conversation;
|
return $conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function hasAttachments(): bool
|
||||||
|
{
|
||||||
|
return collect($this->attachments)
|
||||||
|
->filter()
|
||||||
|
->isNotEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
public function render(): View
|
public function render(): View
|
||||||
{
|
{
|
||||||
return view('livewire.chat.message-composer');
|
return view('livewire.chat.message-composer');
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ namespace App\Models;
|
|||||||
use Database\Factories\MessageFactory;
|
use Database\Factories\MessageFactory;
|
||||||
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;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
|
|
||||||
#[Fillable(['conversation_id', 'user_id', 'type', 'body', 'metadata', 'edited_at'])]
|
#[Fillable(['conversation_id', 'user_id', 'type', 'body', 'metadata', 'edited_at'])]
|
||||||
class Message extends Model
|
class Message extends Model
|
||||||
@@ -16,6 +17,8 @@ class Message extends Model
|
|||||||
|
|
||||||
public const string TypeText = 'text';
|
public const string TypeText = 'text';
|
||||||
|
|
||||||
|
public const string TypeFile = 'file';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -50,4 +53,51 @@ class Message extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,4 +35,25 @@ class MessageFactory extends Factory
|
|||||||
'edited_at' => null,
|
'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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,24 @@
|
|||||||
</flux:tooltip>
|
</flux:tooltip>
|
||||||
|
|
||||||
<flux:tooltip :content="__('Files')" position="top">
|
<flux:tooltip :content="__('Files')" position="top">
|
||||||
<flux:button type="button" variant="filled" icon="folder" aria-label="{{ __('Files') }}" />
|
<div class="relative">
|
||||||
|
<flux:button
|
||||||
|
type="button"
|
||||||
|
variant="filled"
|
||||||
|
icon="folder"
|
||||||
|
wire:click="openFiles"
|
||||||
|
wire:loading.attr="disabled"
|
||||||
|
wire:target="openFiles"
|
||||||
|
aria-label="{{ __('Files') }}"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@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">
|
||||||
|
{{ $this->conversation->files_count }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</flux:tooltip>
|
</flux:tooltip>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -120,6 +137,11 @@
|
|||||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">{{ __('Messages') }}</div>
|
<div class="text-xs text-zinc-500 dark:text-zinc-400">{{ __('Messages') }}</div>
|
||||||
<div class="mt-1 font-semibold text-zinc-900 dark:text-zinc-100">{{ $this->conversation->messages_count }}</div>
|
<div class="mt-1 font-semibold text-zinc-900 dark:text-zinc-100">{{ $this->conversation->messages_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<div class="text-xs text-zinc-500 dark:text-zinc-400">{{ __('Files') }}</div>
|
||||||
|
<div class="mt-1 font-semibold text-zinc-900 dark:text-zinc-100">{{ $this->conversation->files_count }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,4 +257,70 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
|
|
||||||
|
<flux:modal wire:model="showFilesModal" class="w-full max-w-2xl">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">{{ __('Conversation files') }}</flux:heading>
|
||||||
|
<flux:text class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('Recent files shared in this conversation.') }}
|
||||||
|
</flux:text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:badge color="zinc">
|
||||||
|
{{ trans_choice(':count file|:count files', $this->conversation->files_count, ['count' => $this->conversation->files_count]) }}
|
||||||
|
</flux:badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[30rem] overflow-y-auto rounded-lg border border-zinc-200 bg-zinc-50 p-2 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
@forelse ($this->files as $file)
|
||||||
|
<a
|
||||||
|
wire:key="details-file-{{ $file->id }}"
|
||||||
|
href="{{ route('messages.attachment.download', $file) }}"
|
||||||
|
class="group flex items-center gap-3 rounded-md p-3 text-left transition hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent dark:hover:bg-zinc-900"
|
||||||
|
>
|
||||||
|
<span class="flex size-11 shrink-0 items-center justify-center rounded-lg bg-white text-zinc-500 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:text-zinc-300 dark:ring-zinc-800">
|
||||||
|
<flux:icon.document class="size-5" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
|
{{ $file->attachmentName() }}
|
||||||
|
</span>
|
||||||
|
<span class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<span>{{ $file->formattedAttachmentSize() }}</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>{{ $file->sender?->name ?? __('Deleted user') }}</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>{{ $file->created_at->format('M j, H:i') }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="flex size-9 shrink-0 items-center justify-center rounded-full text-zinc-400 transition group-hover:bg-zinc-100 group-hover:text-zinc-700 dark:text-zinc-500 dark:group-hover:bg-zinc-800 dark:group-hover:text-zinc-200">
|
||||||
|
<flux:icon.arrow-down-tray class="size-4" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
@empty
|
||||||
|
<div class="flex min-h-56 items-center justify-center p-8 text-center">
|
||||||
|
<div class="max-w-xs">
|
||||||
|
<div class="mx-auto mb-4 flex size-12 items-center justify-center rounded-lg border border-dashed border-zinc-300 text-zinc-400 dark:border-zinc-700">
|
||||||
|
<flux:icon.folder-open class="size-6" />
|
||||||
|
</div>
|
||||||
|
<flux:heading size="sm">{{ __('No files shared yet') }}</flux:heading>
|
||||||
|
<flux:text class="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('Files you send in the composer will appear here for quick access.') }}
|
||||||
|
</flux:text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||||
|
<flux:modal.close>
|
||||||
|
<flux:button type="button" variant="ghost">{{ __('Close') }}</flux:button>
|
||||||
|
</flux:modal.close>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -97,7 +97,43 @@
|
|||||||
'bg-zinc-900 text-white dark:bg-white dark:text-zinc-950' => $isMine,
|
'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,
|
'border border-zinc-200 bg-white text-zinc-800 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100' => ! $isMine,
|
||||||
])>
|
])>
|
||||||
|
@if ($message->isFile())
|
||||||
|
<a
|
||||||
|
href="{{ route('messages.attachment.download', $message) }}"
|
||||||
|
@class([
|
||||||
|
'group flex min-w-0 items-center gap-3 rounded-md border p-3 text-left transition',
|
||||||
|
'border-white/15 bg-white/10 hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60 dark:border-zinc-950/10 dark:bg-zinc-950/5 dark:hover:bg-zinc-950/10 dark:focus-visible:ring-zinc-950/40' => $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,
|
||||||
|
])
|
||||||
|
>
|
||||||
|
<span @class([
|
||||||
|
'flex size-10 shrink-0 items-center justify-center rounded-md',
|
||||||
|
'bg-white/15 text-white dark:bg-zinc-950/10 dark:text-zinc-800' => $isMine,
|
||||||
|
'bg-white text-zinc-500 shadow-sm dark:bg-zinc-900 dark:text-zinc-300' => ! $isMine,
|
||||||
|
])>
|
||||||
|
<flux:icon.document class="size-5" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate font-medium">{{ $message->attachmentName() }}</span>
|
||||||
|
<span @class([
|
||||||
|
'mt-0.5 block text-xs',
|
||||||
|
'text-white/70 dark:text-zinc-600' => $isMine,
|
||||||
|
'text-zinc-500 dark:text-zinc-400' => ! $isMine,
|
||||||
|
])>
|
||||||
|
{{ $message->formattedAttachmentSize() }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<flux:icon.arrow-down-tray @class([
|
||||||
|
'size-4 shrink-0 transition group-hover:translate-y-0.5',
|
||||||
|
'text-white/70 dark:text-zinc-600' => $isMine,
|
||||||
|
'text-zinc-400 dark:text-zinc-500' => ! $isMine,
|
||||||
|
]) />
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
<p class="whitespace-pre-wrap break-words">{{ $message->body }}</p>
|
<p class="whitespace-pre-wrap break-words">{{ $message->body }}</p>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div @class([
|
<div @class([
|
||||||
|
|||||||
@@ -1,7 +1,93 @@
|
|||||||
<form wire:submit="sendMessage" class="shrink-0 border-t border-zinc-200 bg-white/90 p-3 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/90 sm:p-4">
|
<form
|
||||||
|
wire:submit="sendMessage"
|
||||||
|
x-data="{ attachmentsOpen: {{ count($attachments) > 0 ? 'true' : 'false' }} }"
|
||||||
|
class="shrink-0 border-t border-zinc-200 bg-white/90 p-3 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/90 sm:p-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
x-ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
wire:model="attachments"
|
||||||
|
x-on:change="attachmentsOpen = true"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-cloak
|
||||||
|
x-show="attachmentsOpen || {{ count($attachments) > 0 ? 'true' : 'false' }}"
|
||||||
|
x-transition.opacity.duration.150ms
|
||||||
|
class="mb-2 rounded-lg border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ __('Attachments') }}</div>
|
||||||
|
<div class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">{{ __('Up to 5 files, 10 MB each') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon="plus"
|
||||||
|
x-on:click="$refs.fileInput.click()"
|
||||||
|
>
|
||||||
|
{{ __('Add files') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div wire:loading.flex wire:target="attachments" class="mt-3 items-center gap-3 rounded-md border border-dashed border-zinc-300 bg-zinc-50 p-3 text-sm text-zinc-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-400">
|
||||||
|
<flux:icon.arrow-path class="size-4 animate-spin" />
|
||||||
|
<span>{{ __('Uploading files...') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (count($attachments) > 0)
|
||||||
|
<div class="mt-3 grid gap-2">
|
||||||
|
@foreach ($attachments as $index => $attachment)
|
||||||
|
<div wire:key="composer-attachment-{{ $index }}" class="flex items-center gap-3 rounded-md border border-zinc-200 bg-zinc-50 p-2 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<div class="flex size-9 shrink-0 items-center justify-center rounded-md bg-white text-zinc-500 shadow-sm dark:bg-zinc-900 dark:text-zinc-300">
|
||||||
|
<flux:icon.document class="size-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
|
{{ $attachment->getClientOriginalName() }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ \Illuminate\Support\Number::fileSize((int) $attachment->getSize()) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon="x-mark"
|
||||||
|
wire:click="removeAttachment({{ $index }})"
|
||||||
|
aria-label="{{ __('Remove attachment') }}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@error('attachments')
|
||||||
|
<flux:text class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</flux:text>
|
||||||
|
@enderror
|
||||||
|
|
||||||
|
@error('attachments.*')
|
||||||
|
<flux:text class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</flux:text>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
<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 type="button" variant="ghost" icon="paper-clip" aria-label="{{ __('Attach file') }}" />
|
<flux:button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
icon="paper-clip"
|
||||||
|
x-on:click="$refs.fileInput.click()"
|
||||||
|
aria-label="{{ __('Attach file') }}"
|
||||||
|
/>
|
||||||
</flux:tooltip>
|
</flux:tooltip>
|
||||||
|
|
||||||
<flux:textarea
|
<flux:textarea
|
||||||
@@ -22,9 +108,9 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
icon="paper-airplane"
|
icon="paper-airplane"
|
||||||
:disabled="blank(trim($body))"
|
:disabled="blank(trim($body)) && count($attachments) === 0"
|
||||||
wire:loading.attr="disabled"
|
wire:loading.attr="disabled"
|
||||||
wire:target="sendMessage"
|
wire:target="attachments,sendMessage"
|
||||||
aria-label="{{ __('Send message') }}"
|
aria-label="{{ __('Send message') }}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Chat\MessageAttachmentController;
|
||||||
use App\Livewire\Chat\ChatPage;
|
use App\Livewire\Chat\ChatPage;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ Route::view('/', 'welcome')->name('home');
|
|||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::livewire('dashboard', ChatPage::class)->name('dashboard');
|
Route::livewire('dashboard', ChatPage::class)->name('dashboard');
|
||||||
|
Route::get('messages/{message}/attachment', MessageAttachmentController::class)
|
||||||
|
->name('messages.attachment.download');
|
||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use App\Models\ConversationParticipant;
|
|||||||
use App\Models\Message;
|
use App\Models\Message;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(LazilyRefreshDatabase::class);
|
uses(LazilyRefreshDatabase::class);
|
||||||
@@ -124,6 +126,145 @@ test('participants can send messages', function () {
|
|||||||
->exists())->toBeTrue();
|
->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 () {
|
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