Compare commits
8 Commits
b2339d389d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| edc7f1955e | |||
| 8475ccf873 | |||
| a68dd90bd9 | |||
| 7b2541dd35 | |||
| 1121939c25 | |||
| 3188999a96 | |||
| 9162c9b5c9 | |||
| 35b5ea5efd |
@@ -37,6 +37,10 @@ BROADCAST_CONNECTION=log
|
|||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
CHAT_ATTACHMENT_MAX_FILES=5
|
||||||
|
CHAT_ATTACHMENT_MAX_FILE_SIZE_KB=2097152
|
||||||
|
CHAT_LIVEWIRE_MAX_UPLOAD_TIME=60
|
||||||
|
|
||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
|||||||
199
README.md
199
README.md
@@ -1,2 +1,197 @@
|
|||||||
# fluent-chat
|
# Fluent Chat
|
||||||
Fluent chat project with laravel and Flux
|
|
||||||
|
Fluent Chat is a modern Laravel chat platform built with Laravel 13, Livewire 4, Flux UI, Tailwind CSS 4, and Fortify. It focuses on a polished SaaS-style messaging experience with direct conversations, group conversations, profile settings, file sharing, and responsive chat layouts.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Premium responsive chat dashboard with conversation sidebar, message stream, sticky headers, composer, and details panel.
|
||||||
|
- Direct and group conversations with member management.
|
||||||
|
- Conversation search, message search, unread badges, online indicators, pinned conversations, and muted conversations.
|
||||||
|
- Message composer with multiline input, Enter to send, Shift + Enter for new lines, emoji picker, attachment upload UI, loading states, and validation.
|
||||||
|
- File messages with download support and a conversation details "Files" browser.
|
||||||
|
- User profile settings for name, email, password, appearance, and two-factor authentication.
|
||||||
|
- Fortify-backed authentication with verified dashboard access.
|
||||||
|
- Demo seed data for realistic conversations and messages.
|
||||||
|
- Pest feature tests covering core chat flows.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|------------|----------------------------------|
|
||||||
|
| Backend | Laravel 13, PHP 8.4 |
|
||||||
|
| UI | Livewire 4, Flux UI, Blade |
|
||||||
|
| Styling | Tailwind CSS 4, Vite |
|
||||||
|
| Auth | Laravel Fortify |
|
||||||
|
| Database | MySQL by default in this project |
|
||||||
|
| Testing | Pest 4, Laravel testing tools |
|
||||||
|
| Formatting | Laravel Pint |
|
||||||
|
|
||||||
|
## Main Routes
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|----------------------------------|----------------------------------------|
|
||||||
|
| `/` | Public welcome page |
|
||||||
|
| `/dashboard` | Authenticated chat dashboard |
|
||||||
|
| `/settings/profile` | Profile settings |
|
||||||
|
| `/settings/security` | Password and two-factor authentication |
|
||||||
|
| `/settings/appearance` | Appearance settings |
|
||||||
|
| `/messages/{message}/attachment` | Authenticated file download route |
|
||||||
|
|
||||||
|
When served through Laravel Herd, the dashboard resolves to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://fluent-chat.test/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 8.4+
|
||||||
|
- Composer
|
||||||
|
- Node.js and npm
|
||||||
|
- MySQL or another Laravel-supported database
|
||||||
|
- Laravel Herd, Valet, Sail, or another local PHP environment
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Clone the project and install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the environment file and application key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure the database in `.env`. For the current local setup, this project uses MySQL:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=fluent_chat_db
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=
|
||||||
|
```
|
||||||
|
|
||||||
|
Run migrations and seed demo data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan migrate --seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Build frontend assets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
For active frontend development, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Demo Account
|
||||||
|
|
||||||
|
The database seeder creates a demo user:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Email: test@example.com
|
||||||
|
Password: password
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the Vite dev server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build production assets
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Run the test suite
|
||||||
|
php artisan test --compact
|
||||||
|
|
||||||
|
# Run only chat feature tests
|
||||||
|
php artisan test --compact tests/Feature/ChatTest.php
|
||||||
|
|
||||||
|
# Format dirty PHP files
|
||||||
|
vendor/bin/pint --dirty --format agent
|
||||||
|
|
||||||
|
# Clear cached Laravel state
|
||||||
|
php artisan optimize:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
The chat system is centered on four main models:
|
||||||
|
|
||||||
|
- `User`: authenticated account, profile, security settings, and sent messages.
|
||||||
|
- `Conversation`: direct or group conversation.
|
||||||
|
- `ConversationParticipant`: per-user conversation membership, role, read state, mute state, and pin state.
|
||||||
|
- `Message`: text or file message belonging to a conversation and sender.
|
||||||
|
|
||||||
|
Important relationships:
|
||||||
|
|
||||||
|
- A conversation has many participants.
|
||||||
|
- A conversation has many messages.
|
||||||
|
- A message belongs to a conversation.
|
||||||
|
- A message belongs to a sender.
|
||||||
|
- A user belongs to many conversations through participants.
|
||||||
|
- A user has many messages.
|
||||||
|
|
||||||
|
## Livewire Components
|
||||||
|
|
||||||
|
Core chat UI is split into focused Livewire components:
|
||||||
|
|
||||||
|
- `ChatPage`: dashboard shell and selected conversation state.
|
||||||
|
- `ConversationList`: search, create conversation modal, unread counts, pinned and muted indicators.
|
||||||
|
- `ConversationHeader`: selected conversation header and message search toggle.
|
||||||
|
- `ConversationView`: message stream, grouped messages, search results, and loading states.
|
||||||
|
- `MessageComposer`: text, emoji, and file sending.
|
||||||
|
- `ConversationDetailsPanel`: participants, add members, files, mute, and pin actions.
|
||||||
|
|
||||||
|
Settings components live under `App\Livewire\Settings`.
|
||||||
|
|
||||||
|
## File Uploads
|
||||||
|
|
||||||
|
File messages are stored on Laravel's configured filesystem disk. The app captures attachment metadata before moving the temporary Livewire upload and serves files through an authenticated download route.
|
||||||
|
|
||||||
|
Useful environment value:
|
||||||
|
|
||||||
|
```env
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run the main test suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan test --compact
|
||||||
|
```
|
||||||
|
|
||||||
|
The chat feature tests cover authenticated dashboard access, conversation visibility, direct and group creation, message sending, emoji insertion, file messages, file downloads, details panel files, member management, message search, and empty message validation.
|
||||||
|
|
||||||
|
## Production Notes
|
||||||
|
|
||||||
|
- Run `php artisan migrate --force` during deployment.
|
||||||
|
- Run `npm run build` before serving production assets.
|
||||||
|
- Use a queue worker if queued jobs are introduced.
|
||||||
|
- Keep `APP_DEBUG=false` in production.
|
||||||
|
- Configure mail before enabling real user-facing email flows.
|
||||||
|
- Configure HTTPS and secure session/cookie settings for production.
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Real-time broadcasting for live message delivery and typing indicators.
|
||||||
|
- Read receipts and delivery receipts.
|
||||||
|
- Message reactions, edits, deletes, and replies.
|
||||||
|
- Drag-and-drop attachment uploads.
|
||||||
|
- Conversation-level roles and moderation controls.
|
||||||
|
- Notification preferences beyond mute state.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ class ConversationHeader extends Component
|
|||||||
$this->dispatch('conversation-details-toggled');
|
$this->dispatch('conversation-details-toggled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function toggleSearch(): void
|
||||||
|
{
|
||||||
|
$this->dispatch('conversation-search-toggled', conversationId: $this->conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
private function otherParticipant(): ?User
|
private function otherParticipant(): ?User
|
||||||
{
|
{
|
||||||
return $this->conversation->participants
|
return $this->conversation->participants
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ use App\Models\ConversationParticipant;
|
|||||||
use App\Models\Message;
|
use App\Models\Message;
|
||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -20,6 +22,10 @@ class ConversationView extends Component
|
|||||||
|
|
||||||
public int $messageLimit = 40;
|
public int $messageLimit = 40;
|
||||||
|
|
||||||
|
public bool $messageSearchOpen = false;
|
||||||
|
|
||||||
|
public string $messageSearch = '';
|
||||||
|
|
||||||
public function mount(int $conversationId): void
|
public function mount(int $conversationId): void
|
||||||
{
|
{
|
||||||
$this->conversationId = $conversationId;
|
$this->conversationId = $conversationId;
|
||||||
@@ -33,6 +39,42 @@ class ConversationView extends Component
|
|||||||
$this->messageLimit += 25;
|
$this->messageLimit += 25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[On('conversation-search-toggled')]
|
||||||
|
public function toggleMessageSearch(int $conversationId): void
|
||||||
|
{
|
||||||
|
if ($conversationId !== $this->conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->messageSearchOpen = ! $this->messageSearchOpen;
|
||||||
|
|
||||||
|
if (! $this->messageSearchOpen) {
|
||||||
|
$this->clearMessageSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedMessageSearch(): void
|
||||||
|
{
|
||||||
|
$this->messageLimit = 40;
|
||||||
|
|
||||||
|
unset($this->messages, $this->hasMoreMessages, $this->searchResultsCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearMessageSearch(): void
|
||||||
|
{
|
||||||
|
$this->messageSearch = '';
|
||||||
|
$this->messageLimit = 40;
|
||||||
|
|
||||||
|
unset($this->messages, $this->hasMoreMessages, $this->searchResultsCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeMessageSearch(): void
|
||||||
|
{
|
||||||
|
$this->messageSearchOpen = false;
|
||||||
|
|
||||||
|
$this->clearMessageSearch();
|
||||||
|
}
|
||||||
|
|
||||||
#[On('message-created')]
|
#[On('message-created')]
|
||||||
public function refreshMessages(int $conversationId): void
|
public function refreshMessages(int $conversationId): void
|
||||||
{
|
{
|
||||||
@@ -76,8 +118,7 @@ class ConversationView extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function messages(): Collection
|
public function messages(): Collection
|
||||||
{
|
{
|
||||||
return Message::query()
|
return $this->messageQuery()
|
||||||
->where('conversation_id', $this->conversationId)
|
|
||||||
->with('sender:id,name,email')
|
->with('sender:id,name,email')
|
||||||
->latest()
|
->latest()
|
||||||
->limit($this->messageLimit)
|
->limit($this->messageLimit)
|
||||||
@@ -89,11 +130,20 @@ class ConversationView extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function hasMoreMessages(): bool
|
public function hasMoreMessages(): bool
|
||||||
{
|
{
|
||||||
return Message::query()
|
return $this->messageQuery()
|
||||||
->where('conversation_id', $this->conversationId)
|
|
||||||
->count() > $this->messageLimit;
|
->count() > $this->messageLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function searchResultsCount(): int
|
||||||
|
{
|
||||||
|
if (! $this->messageSearchIsActive()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->messageQuery()->count();
|
||||||
|
}
|
||||||
|
|
||||||
public function dateLabel(CarbonInterface $timestamp): string
|
public function dateLabel(CarbonInterface $timestamp): string
|
||||||
{
|
{
|
||||||
if ($timestamp->isToday()) {
|
if ($timestamp->isToday()) {
|
||||||
@@ -107,6 +157,30 @@ class ConversationView extends Component
|
|||||||
return $timestamp->format('F j, Y');
|
return $timestamp->format('F j, Y');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function messageSearchIsActive(): bool
|
||||||
|
{
|
||||||
|
return $this->messageSearchOpen && trim($this->messageSearch) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function highlightedText(string $text): HtmlString
|
||||||
|
{
|
||||||
|
$escapedText = e($text);
|
||||||
|
$search = trim($this->messageSearch);
|
||||||
|
|
||||||
|
if (! $this->messageSearchIsActive() || $search === '') {
|
||||||
|
return new HtmlString($escapedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
$escapedSearch = e($search);
|
||||||
|
$highlighted = preg_replace(
|
||||||
|
'/('.preg_quote($escapedSearch, '/').')/iu',
|
||||||
|
'<mark class="rounded bg-amber-200 px-0.5 text-zinc-950">$1</mark>',
|
||||||
|
$escapedText,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new HtmlString($highlighted ?? $escapedText);
|
||||||
|
}
|
||||||
|
|
||||||
private function authorizeConversation(): void
|
private function authorizeConversation(): void
|
||||||
{
|
{
|
||||||
$conversation = Conversation::query()
|
$conversation = Conversation::query()
|
||||||
@@ -124,6 +198,24 @@ class ConversationView extends Component
|
|||||||
->update(['last_read_at' => now()]);
|
->update(['last_read_at' => now()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Builder<Message>
|
||||||
|
*/
|
||||||
|
private function messageQuery(): Builder
|
||||||
|
{
|
||||||
|
$search = trim($this->messageSearch);
|
||||||
|
|
||||||
|
return Message::query()
|
||||||
|
->where('conversation_id', $this->conversationId)
|
||||||
|
->when($this->messageSearchIsActive(), fn (Builder $query) => $query->where(function (Builder $query) use ($search): void {
|
||||||
|
$query
|
||||||
|
->where('body', 'like', "%{$search}%")
|
||||||
|
->orWhereHas('sender', fn (Builder $sender) => $sender
|
||||||
|
->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%"));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
public function render(): View
|
public function render(): View
|
||||||
{
|
{
|
||||||
return view('livewire.chat.conversation-view');
|
return view('livewire.chat.conversation-view');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Illuminate\Contracts\View\View;
|
|||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
@@ -23,6 +24,8 @@ class MessageComposer extends Component
|
|||||||
|
|
||||||
public string $body = '';
|
public string $body = '';
|
||||||
|
|
||||||
|
public bool $emojiPickerOpen = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, TemporaryUploadedFile>
|
* @var array<int, TemporaryUploadedFile>
|
||||||
*/
|
*/
|
||||||
@@ -39,16 +42,19 @@ class MessageComposer extends Component
|
|||||||
{
|
{
|
||||||
$this->body = trim($this->body);
|
$this->body = trim($this->body);
|
||||||
$hasAttachments = $this->hasAttachments();
|
$hasAttachments = $this->hasAttachments();
|
||||||
|
$maxAttachmentFiles = $this->maxAttachmentFiles();
|
||||||
|
$maxAttachmentFileSizeKilobytes = $this->maxAttachmentFileSizeKilobytes();
|
||||||
|
$maxAttachmentFileSizeLabel = $this->maxAttachmentFileSizeLabel();
|
||||||
|
|
||||||
$validated = $this->validate([
|
$validated = $this->validate([
|
||||||
'body' => [$hasAttachments ? 'nullable' : 'required', 'string', 'max:4000'],
|
'body' => [$hasAttachments ? 'nullable' : 'required', 'string', 'max:4000'],
|
||||||
'attachments' => ['array', 'max:5'],
|
'attachments' => ['array', 'max:'.$maxAttachmentFiles],
|
||||||
'attachments.*' => ['file', 'max:10240'],
|
'attachments.*' => ['file', 'max:'.$maxAttachmentFileSizeKilobytes],
|
||||||
], [
|
], [
|
||||||
'body.required' => __('Write a message or attach a file before sending.'),
|
'body.required' => __('Write a message or attach a file before sending.'),
|
||||||
'attachments.max' => __('Attach up to :max files at a time.'),
|
'attachments.max' => __('Attach up to :max files at a time.', ['max' => $maxAttachmentFiles]),
|
||||||
'attachments.*.file' => __('Each attachment must be a valid file.'),
|
'attachments.*.file' => __('Each attachment must be a valid file.'),
|
||||||
'attachments.*.max' => __('Each attachment must be 10 MB or smaller.'),
|
'attachments.*.max' => __('Each attachment must be :size or smaller.', ['size' => $maxAttachmentFileSizeLabel]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$conversation = $this->conversation();
|
$conversation = $this->conversation();
|
||||||
@@ -103,11 +109,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 +149,41 @@ 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')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachmentLimitSummary(): string
|
||||||
|
{
|
||||||
|
return __('Up to :count files, :size each', [
|
||||||
|
'count' => $this->maxAttachmentFiles(),
|
||||||
|
'size' => $this->maxAttachmentFileSizeLabel(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private function conversation(): Conversation
|
private function conversation(): Conversation
|
||||||
{
|
{
|
||||||
$conversation = Conversation::query()
|
$conversation = Conversation::query()
|
||||||
@@ -140,6 +202,34 @@ class MessageComposer extends Component
|
|||||||
->isNotEmpty();
|
->isNotEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function maxAttachmentFiles(): int
|
||||||
|
{
|
||||||
|
return (int) config('chat.attachments.max_files', 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maxAttachmentFileSizeKilobytes(): int
|
||||||
|
{
|
||||||
|
return (int) config('chat.attachments.max_file_size_kilobytes', 2 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maxAttachmentFileSizeLabel(): string
|
||||||
|
{
|
||||||
|
return Number::fileSize($this->maxAttachmentFileSizeKilobytes() * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
$this->configureFileUploads();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,4 +47,14 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function configureFileUploads(): void
|
||||||
|
{
|
||||||
|
$maxFileSizeKilobytes = (int) config('chat.attachments.max_file_size_kilobytes');
|
||||||
|
|
||||||
|
config([
|
||||||
|
'livewire.temporary_file_upload.rules' => ['required', 'file', 'max:'.$maxFileSizeKilobytes],
|
||||||
|
'livewire.temporary_file_upload.max_upload_time' => (int) config('chat.attachments.livewire_max_upload_time'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://getcomposer.org/schema.json",
|
"$schema": "https://getcomposer.org/schema.json",
|
||||||
"name": "laravel/livewire-starter-kit",
|
"name": "meghdadfadaee/fluent-chat",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "The official Laravel starter kit for Livewire.",
|
"description": "Fluent chat project with laravel and Flux.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"laravel",
|
"laravel",
|
||||||
"framework"
|
"framework",
|
||||||
|
"chat",
|
||||||
|
"flux"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.4",
|
||||||
"laravel/fortify": "^1.34",
|
"laravel/fortify": "^1.34",
|
||||||
"laravel/framework": "^13.7",
|
"laravel/framework": "^13.7",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
|
|||||||
16
composer.lock
generated
16
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "f8bf4402fcc5a2102d8cc579e484a8f4",
|
"content-hash": "a3ca7feabe07b0ac40f38a9bb55d43e1",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@@ -2261,16 +2261,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "livewire/livewire",
|
"name": "livewire/livewire",
|
||||||
"version": "v4.2.4",
|
"version": "v4.3.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/livewire/livewire.git",
|
"url": "https://github.com/livewire/livewire.git",
|
||||||
"reference": "7d0bfa46269b1ec186b8cdd38baffee5cc647d10"
|
"reference": "19ebb1ee4d057debceccf70ff01950e6a6114edc"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/livewire/livewire/zipball/7d0bfa46269b1ec186b8cdd38baffee5cc647d10",
|
"url": "https://api.github.com/repos/livewire/livewire/zipball/19ebb1ee4d057debceccf70ff01950e6a6114edc",
|
||||||
"reference": "7d0bfa46269b1ec186b8cdd38baffee5cc647d10",
|
"reference": "19ebb1ee4d057debceccf70ff01950e6a6114edc",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2325,7 +2325,7 @@
|
|||||||
"description": "A front-end framework for Laravel.",
|
"description": "A front-end framework for Laravel.",
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/livewire/livewire/issues",
|
"issues": "https://github.com/livewire/livewire/issues",
|
||||||
"source": "https://github.com/livewire/livewire/tree/v4.2.4"
|
"source": "https://github.com/livewire/livewire/tree/v4.3.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2333,7 +2333,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-04-02T20:48:35+00:00"
|
"time": "2026-05-01T00:46:07+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
@@ -10084,7 +10084,7 @@
|
|||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.3"
|
"php": "^8.4"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.9.0"
|
||||||
|
|||||||
9
config/chat.php
Normal file
9
config/chat.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'attachments' => [
|
||||||
|
'max_files' => (int) env('CHAT_ATTACHMENT_MAX_FILES', 5),
|
||||||
|
'max_file_size_kilobytes' => (int) env('CHAT_ATTACHMENT_MAX_FILE_SIZE_KB', 2 * 1024 * 1024),
|
||||||
|
'livewire_max_upload_time' => (int) env('CHAT_LIVEWIRE_MAX_UPLOAD_TIME', 60),
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
4
public/.user.ini
Normal file
4
public/.user.ini
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
upload_max_filesize=2048M
|
||||||
|
post_max_size=2050M
|
||||||
|
max_input_time=3600
|
||||||
|
max_execution_time=3600
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 197 KiB |
@@ -1,3 +1,16 @@
|
|||||||
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Fluent Chat">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M162.041 38.7592C162.099 38.9767 162.129 39.201 162.13 39.4264V74.4524C162.13 74.9019 162.011 75.3435 161.786 75.7325C161.561 76.1216 161.237 76.4442 160.847 76.6678L131.462 93.5935V127.141C131.462 128.054 130.977 128.897 130.186 129.357L68.8474 164.683C68.707 164.763 68.5538 164.814 68.4007 164.868C68.3432 164.887 68.289 164.922 68.2284 164.938C67.7996 165.051 67.3489 165.051 66.9201 164.938C66.8499 164.919 66.7861 164.881 66.7191 164.855C66.5787 164.804 66.4319 164.76 66.2979 164.683L4.97219 129.357C4.58261 129.133 4.2589 128.81 4.0337 128.421C3.8085 128.032 3.68976 127.591 3.68945 127.141L3.68945 22.0634C3.68945 21.8336 3.72136 21.6101 3.7788 21.393C3.79794 21.3196 3.84262 21.2526 3.86814 21.1791C3.91601 21.0451 3.96068 20.9078 4.03088 20.7833C4.07874 20.7003 4.14894 20.6333 4.20638 20.5566C4.27977 20.4545 4.34678 20.3491 4.43293 20.2598C4.50632 20.1863 4.60205 20.1321 4.68501 20.0682C4.77755 19.9916 4.86051 19.9086 4.96581 19.848L35.6334 2.18492C36.0217 1.96139 36.4618 1.84375 36.9098 1.84375C37.3578 1.84375 37.7979 1.96139 38.1862 2.18492L68.8506 19.848H68.857C68.9591 19.9118 69.0452 19.9916 69.1378 20.065C69.2207 20.1289 69.3133 20.1863 69.3867 20.2566C69.476 20.3491 69.5398 20.4545 69.6164 20.5566C69.6707 20.6333 69.7441 20.7003 69.7887 20.7833C69.8621 20.911 69.9036 21.0451 69.9546 21.1791C69.9802 21.2526 70.0248 21.3196 70.044 21.3962C70.1027 21.6138 70.1328 21.8381 70.1333 22.0634V87.6941L95.686 72.9743V39.4232C95.686 39.1997 95.7179 38.9731 95.7753 38.7592C95.7977 38.6826 95.8391 38.6155 95.8647 38.5421C95.9157 38.408 95.9604 38.2708 96.0306 38.1463C96.0785 38.0633 96.1487 37.9962 96.2029 37.9196C96.2795 37.8175 96.3433 37.7121 96.4326 37.6227C96.506 37.5493 96.5986 37.495 96.6815 37.4312C96.7773 37.3546 96.8602 37.2716 96.9623 37.2109L127.633 19.5479C128.021 19.324 128.461 19.2062 128.91 19.2062C129.358 19.2062 129.798 19.324 130.186 19.5479L160.85 37.2109C160.959 37.2748 161.042 37.3546 161.137 37.428C161.217 37.4918 161.31 37.5493 161.383 37.6195C161.473 37.7121 161.536 37.8175 161.613 37.9196C161.67 37.9962 161.741 38.0633 161.785 38.1463C161.859 38.2708 161.9 38.408 161.951 38.5421C161.98 38.6155 162.021 38.6826 162.041 38.7592ZM157.018 72.9743V43.8477L146.287 50.028L131.462 58.5675V87.6941L157.021 72.9743H157.018ZM126.354 125.663V96.5176L111.771 104.85L70.1301 128.626V158.046L126.354 125.663ZM8.80126 26.4848V125.663L65.0183 158.043V128.629L35.6494 112L35.6398 111.994L35.6271 111.988C35.5281 111.93 35.4452 111.847 35.3526 111.777C35.2729 111.713 35.1803 111.662 35.1101 111.592L35.1038 111.582C35.0208 111.502 34.9634 111.403 34.8932 111.314C34.8293 111.228 34.7528 111.154 34.7017 111.065L34.6985 111.055C34.6411 110.96 34.606 110.845 34.5645 110.736C34.523 110.64 34.4688 110.551 34.4432 110.449C34.4113 110.328 34.4049 110.197 34.3922 110.072C34.3794 109.976 34.3539 109.881 34.3539 109.785V109.778V41.2045L19.5322 32.6619L8.80126 26.4848ZM36.913 7.35007L11.3635 22.0634L36.9066 36.7768L62.4529 22.0602L36.9066 7.35007H36.913ZM50.1999 99.1736L65.0215 90.6374V26.4848L54.2906 32.6651L39.4657 41.2045V105.357L50.1999 99.1736ZM128.91 24.713L103.363 39.4264L128.91 54.1397L154.453 39.4232L128.91 24.713ZM126.354 58.5675L111.529 50.028L100.798 43.8477V72.9743L115.619 81.5106L126.354 87.6941V58.5675ZM67.5711 124.205L105.042 102.803L123.772 92.109L98.2451 77.4053L68.8538 94.3341L42.0663 109.762L67.5711 124.205Z" fill="#FF2D20"/>
|
<rect width="64" height="64" rx="15" fill="#0A0A0A"/>
|
||||||
|
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#fluent-chat-gradient)"/>
|
||||||
|
<path d="M18 18.5C18 15.4624 20.4624 13 23.5 13H43.5C46.5376 13 49 15.4624 49 18.5V34.5C49 37.5376 46.5376 40 43.5 40H35.8918L25.75 48V40H23.5C20.4624 40 18 37.5376 18 34.5V18.5Z" fill="#0A0A0A"/>
|
||||||
|
<path d="M26 24H41" stroke="white" stroke-width="3.75" stroke-linecap="round"/>
|
||||||
|
<path d="M26 31H36" stroke="white" stroke-width="3.75" stroke-linecap="round"/>
|
||||||
|
<circle cx="46" cy="17" r="5" fill="white"/>
|
||||||
|
<circle cx="46" cy="17" r="2" fill="#10B981"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fluent-chat-gradient" x1="10" y1="9" x2="56" y2="57" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#2DD4BF"/>
|
||||||
|
<stop offset="0.58" stop-color="#34D399"/>
|
||||||
|
<stop offset="1" stop-color="#FACC15"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -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">
|
||||||
@@ -146,7 +172,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<flux:modal wire:model="showAddMembersModal" class="w-full max-w-xl">
|
<flux:modal wire:model="showAddMembersModal" :closable="false" class="w-full max-w-xl">
|
||||||
<form wire:submit="addMembers" class="space-y-6">
|
<form wire:submit="addMembers" class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<flux:heading size="lg">{{ __('Add people') }}</flux:heading>
|
<flux:heading size="lg">{{ __('Add people') }}</flux:heading>
|
||||||
@@ -258,7 +284,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
|
|
||||||
<flux:modal wire:model="showFilesModal" class="w-full max-w-2xl">
|
<flux:modal wire:model="showFilesModal" :closable="false" class="w-full max-w-2xl">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -45,7 +45,13 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<flux:tooltip :content="__('Search messages')" position="bottom">
|
<flux:tooltip :content="__('Search messages')" position="bottom">
|
||||||
<flux:button type="button" variant="ghost" icon="magnifying-glass" aria-label="{{ __('Search messages') }}" />
|
<flux:button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
icon="magnifying-glass"
|
||||||
|
wire:click="toggleSearch"
|
||||||
|
aria-label="{{ __('Search messages') }}"
|
||||||
|
/>
|
||||||
</flux:tooltip>
|
</flux:tooltip>
|
||||||
|
|
||||||
<flux:tooltip :content="__('Conversation details')" position="bottom">
|
<flux:tooltip :content="__('Conversation details')" position="bottom">
|
||||||
|
|||||||
@@ -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([
|
||||||
@@ -215,7 +239,7 @@
|
|||||||
</flux:dropdown>
|
</flux:dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<flux:modal wire:model="showCreateConversationModal" class="w-full max-w-2xl">
|
<flux:modal wire:model="showCreateConversationModal" :closable="false" class="w-full max-w-2xl">
|
||||||
<form wire:submit="createConversation" class="space-y-6">
|
<form wire:submit="createConversation" class="space-y-6">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -15,6 +15,57 @@
|
|||||||
:key="'conversation-header-'.$conversationId"
|
:key="'conversation-header-'.$conversationId"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@if ($messageSearchOpen)
|
||||||
|
<section class="shrink-0 border-b border-zinc-200 bg-white/95 p-3 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/95 sm:px-5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:input
|
||||||
|
wire:model.live.debounce.250ms="messageSearch"
|
||||||
|
icon="magnifying-glass"
|
||||||
|
:placeholder="__('Search this conversation')"
|
||||||
|
aria-label="{{ __('Search this conversation') }}"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if ($messageSearch !== '')
|
||||||
|
<flux:tooltip :content="__('Clear search')" position="bottom">
|
||||||
|
<flux:button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
icon="x-mark"
|
||||||
|
wire:click="clearMessageSearch"
|
||||||
|
aria-label="{{ __('Clear search') }}"
|
||||||
|
/>
|
||||||
|
</flux:tooltip>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<flux:tooltip :content="__('Close search')" position="bottom">
|
||||||
|
<flux:button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
icon="chevron-up"
|
||||||
|
wire:click="closeMessageSearch"
|
||||||
|
aria-label="{{ __('Close search') }}"
|
||||||
|
/>
|
||||||
|
</flux:tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex min-h-5 items-center justify-between gap-3">
|
||||||
|
<flux:text class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
@if ($this->messageSearchIsActive())
|
||||||
|
{{ trans_choice(':count matching message|:count matching messages', $this->searchResultsCount, ['count' => $this->searchResultsCount]) }}
|
||||||
|
@else
|
||||||
|
{{ __('Search by message text, file name, sender, or email.') }}
|
||||||
|
@endif
|
||||||
|
</flux:text>
|
||||||
|
|
||||||
|
<div wire:loading.flex wire:target="messageSearch" class="items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<flux:icon.arrow-path class="size-3.5 animate-spin" />
|
||||||
|
<span>{{ __('Searching') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div x-ref="messages" class="min-h-0 flex-1 overflow-y-auto scroll-smooth bg-zinc-50/40 px-4 py-6 dark:bg-zinc-950 sm:px-6">
|
<div x-ref="messages" class="min-h-0 flex-1 overflow-y-auto scroll-smooth bg-zinc-50/40 px-4 py-6 dark:bg-zinc-950 sm:px-6">
|
||||||
@if ($this->hasMoreMessages)
|
@if ($this->hasMoreMessages)
|
||||||
<div class="mb-6 flex justify-center">
|
<div class="mb-6 flex justify-center">
|
||||||
@@ -26,7 +77,7 @@
|
|||||||
wire:loading.attr="disabled"
|
wire:loading.attr="disabled"
|
||||||
wire:target="loadEarlier"
|
wire:target="loadEarlier"
|
||||||
>
|
>
|
||||||
{{ __('Load earlier') }}
|
{{ $this->messageSearchIsActive() ? __('Load more results') : __('Load earlier') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@@ -37,9 +88,17 @@
|
|||||||
<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">
|
<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.chat-bubble-left-ellipsis class="size-6" />
|
<flux:icon.chat-bubble-left-ellipsis class="size-6" />
|
||||||
</div>
|
</div>
|
||||||
<flux:heading size="sm">{{ __('No messages yet') }}</flux:heading>
|
|
||||||
|
<flux:heading size="sm">
|
||||||
|
{{ $this->messageSearchIsActive() ? __('No matching messages') : __('No messages yet') }}
|
||||||
|
</flux:heading>
|
||||||
|
|
||||||
<flux:text class="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
<flux:text class="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
@if ($this->messageSearchIsActive())
|
||||||
|
{{ __('No messages match your current search.') }}
|
||||||
|
@else
|
||||||
{{ __('Start the conversation with a short note.') }}
|
{{ __('Start the conversation with a short note.') }}
|
||||||
|
@endif
|
||||||
</flux:text>
|
</flux:text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +174,7 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
<span class="block truncate font-medium">{{ $message->attachmentName() }}</span>
|
<span class="block truncate font-medium">{!! $this->highlightedText($message->attachmentName()) !!}</span>
|
||||||
<span @class([
|
<span @class([
|
||||||
'mt-0.5 block text-xs',
|
'mt-0.5 block text-xs',
|
||||||
'text-white/70 dark:text-zinc-600' => $isMine,
|
'text-white/70 dark:text-zinc-600' => $isMine,
|
||||||
@@ -132,7 +191,7 @@
|
|||||||
]) />
|
]) />
|
||||||
</a>
|
</a>
|
||||||
@else
|
@else
|
||||||
<p class="whitespace-pre-wrap break-words">{{ $message->body }}</p>
|
<p class="whitespace-pre-wrap break-words">{!! $this->highlightedText($message->body) !!}</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ __('Attachments') }}</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 class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">{{ $this->attachmentLimitSummary() }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<flux:button
|
<flux:button
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</flux:button>
|
</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
|
|
||||||
<flux:modal name="confirm-user-deletion" :show="$errors->isNotEmpty()" focusable class="max-w-lg">
|
<flux:modal name="confirm-user-deletion" :show="$errors->isNotEmpty()" :closable="false" focusable class="max-w-lg">
|
||||||
<form method="POST" wire:submit="deleteUser" class="space-y-6">
|
<form method="POST" wire:submit="deleteUser" class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<flux:heading size="lg">{{ __('Are you sure you want to delete your account?') }}</flux:heading>
|
<flux:heading size="lg">{{ __('Are you sure you want to delete your account?') }}</flux:heading>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
<flux:modal
|
<flux:modal
|
||||||
name="two-factor-setup-modal"
|
name="two-factor-setup-modal"
|
||||||
class="max-w-md md:min-w-md"
|
class="max-w-md md:min-w-md"
|
||||||
|
:closable="false"
|
||||||
@close="closeModal"
|
@close="closeModal"
|
||||||
wire:model="showModal"
|
wire:model="showModal"
|
||||||
>
|
>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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');
|
||||||
|
|
||||||
@@ -157,6 +180,42 @@ test('participants can send file messages', function () {
|
|||||||
Storage::disk('local')->assertExists($message->attachmentPath());
|
Storage::disk('local')->assertExists($message->attachmentPath());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('chat file uploads are configured for two gigabyte attachments', function () {
|
||||||
|
expect(config('chat.attachments.max_file_size_kilobytes'))->toBe(2 * 1024 * 1024)
|
||||||
|
->and(config('livewire.temporary_file_upload.rules'))->toContain('max:2097152')
|
||||||
|
->and(config('livewire.temporary_file_upload.max_upload_time'))->toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('participants can send files larger than the previous ten megabyte limit', 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('large-export.zip', 13 * 1024, 'application/zip'),
|
||||||
|
])
|
||||||
|
->call('sendMessage')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$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('large-export.zip');
|
||||||
|
});
|
||||||
|
|
||||||
test('file metadata is captured before temporary uploads are moved', function () {
|
test('file metadata is captured before temporary uploads are moved', function () {
|
||||||
Storage::fake('local');
|
Storage::fake('local');
|
||||||
|
|
||||||
@@ -262,6 +321,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']);
|
||||||
@@ -318,6 +438,60 @@ test('selected conversations render the stream header and details', function ()
|
|||||||
->assertSee('Mina Partner');
|
->assertSee('Mina Partner');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('conversation header search button opens message search', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$teammate = User::factory()->create(['name' => 'Mina Partner']);
|
||||||
|
$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(ConversationHeader::class, ['conversationId' => $conversation->id])
|
||||||
|
->call('toggleSearch')
|
||||||
|
->assertDispatched('conversation-search-toggled');
|
||||||
|
|
||||||
|
Livewire::test(ConversationView::class, ['conversationId' => $conversation->id])
|
||||||
|
->dispatch('conversation-search-toggled', conversationId: $conversation->id)
|
||||||
|
->assertSet('messageSearchOpen', true)
|
||||||
|
->assertSee('Search this conversation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('message search filters the selected conversation', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$teammate = User::factory()->create();
|
||||||
|
$conversation = Conversation::factory()
|
||||||
|
->direct()
|
||||||
|
->for($user, 'creator')
|
||||||
|
->create();
|
||||||
|
|
||||||
|
ConversationParticipant::factory()->for($conversation)->for($user)->create();
|
||||||
|
ConversationParticipant::factory()->for($conversation)->for($teammate)->create();
|
||||||
|
|
||||||
|
Message::factory()
|
||||||
|
->for($conversation)
|
||||||
|
->for($teammate, 'sender')
|
||||||
|
->create(['body' => 'The deployment checklist is ready.']);
|
||||||
|
|
||||||
|
Message::factory()
|
||||||
|
->for($conversation)
|
||||||
|
->for($teammate, 'sender')
|
||||||
|
->create(['body' => 'Budget review moved to Tuesday.']);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(ConversationView::class, ['conversationId' => $conversation->id])
|
||||||
|
->dispatch('conversation-search-toggled', conversationId: $conversation->id)
|
||||||
|
->set('messageSearch', 'deployment')
|
||||||
|
->assertSee('1 matching message')
|
||||||
|
->assertSee('deployment')
|
||||||
|
->assertDontSee('Budget review moved to Tuesday.');
|
||||||
|
});
|
||||||
|
|
||||||
test('empty messages are rejected', function () {
|
test('empty messages are rejected', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$conversation = Conversation::factory()
|
$conversation = Conversation::factory()
|
||||||
|
|||||||
18
tests/Feature/ModalChromeTest.php
Normal file
18
tests/Feature/ModalChromeTest.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
test('flux modals hide the built in top right close button', function () {
|
||||||
|
$modalDeclarations = collect([
|
||||||
|
resource_path('views/livewire/chat/conversation-details-panel.blade.php'),
|
||||||
|
resource_path('views/livewire/chat/conversation-list.blade.php'),
|
||||||
|
resource_path('views/livewire/settings/delete-user-form.blade.php'),
|
||||||
|
resource_path('views/livewire/settings/security.blade.php'),
|
||||||
|
])->flatMap(function (string $path): array {
|
||||||
|
preg_match_all('/<flux:modal(?![\\w.-])(?:[^"\'>]|"[^"]*"|\'[^\']*\')*>/s', file_get_contents($path), $matches);
|
||||||
|
|
||||||
|
return $matches[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
expect($modalDeclarations)->not->toBeEmpty();
|
||||||
|
|
||||||
|
$modalDeclarations->each(fn (string $declaration) => expect($declaration)->toContain(':closable="false"'));
|
||||||
|
});
|
||||||
37
tests/Feature/WelcomePageTest.php
Normal file
37
tests/Feature/WelcomePageTest.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
test('guests see the branded welcome page', function () {
|
||||||
|
$this->get(route('home'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Fluent Chat')
|
||||||
|
->assertSee('Private team messaging with files built in')
|
||||||
|
->assertSee('Enter workspace')
|
||||||
|
->assertSee('Files in context')
|
||||||
|
->assertSee(route('login'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated users see the dashboard call to action', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('home'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Open dashboard')
|
||||||
|
->assertSee(route('dashboard'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('favicon assets are branded for fluent chat', function () {
|
||||||
|
$svg = file_get_contents(public_path('favicon.svg'));
|
||||||
|
|
||||||
|
expect($svg)
|
||||||
|
->toContain('Fluent Chat')
|
||||||
|
->not->toContain('#FF2D20');
|
||||||
|
|
||||||
|
expect(public_path('favicon.ico'))
|
||||||
|
->toBeFile()
|
||||||
|
->and(filesize(public_path('favicon.ico')))->toBeGreaterThan(1000)
|
||||||
|
->and(public_path('apple-touch-icon.png'))->toBeFile()
|
||||||
|
->and(filesize(public_path('apple-touch-icon.png')))->toBeGreaterThan(1000);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user