refactor: chat time, grouping and toast

- show message time
- group messages by date
- show toast when message sent is failed
This commit is contained in:
kusowl 2026-02-18 11:38:03 +05:30
parent 44b13a5ebf
commit b2132f8c02
9 changed files with 154 additions and 73 deletions

View File

@ -3,7 +3,9 @@
namespace App\Actions; namespace App\Actions;
use App\Events\MessageSent; use App\Events\MessageSent;
use App\Exceptions\MessageNotSendException;
use App\Models\User; use App\Models\User;
use DB;
final readonly class SendMessageAction final readonly class SendMessageAction
{ {
@ -14,18 +16,30 @@ public function __construct(private CreateOrGetInboxAction $inboxAction) {}
*/ */
public function execute(User $sender, User $recipient, array $data): void public function execute(User $sender, User $recipient, array $data): void
{ {
// find the inbox between the two users try {
\DB::beginTransaction();
$inbox = $this->inboxAction->execute($recipient, $sender);
$inbox->last_message = $data['message'];
$inbox->last_user_id = $sender->id;
$inbox->save();
$message = $inbox->messages()->create([
'message' => $data['message'],
'user_id' => $sender->id,
]);
broadcast(new MessageSent($message))->toOthers(); // find the inbox between the two users
\DB::commit(); DB::beginTransaction();
$inbox = $this->inboxAction->execute($recipient, $sender);
// update the inbox with the last message and last user as current user
$inbox->last_message = $data['message'];
$inbox->last_user_id = $sender->id;
$inbox->save();
// create a new message in the inbox
$message = $inbox->messages()->create([
'message' => $data['message'],
'user_id' => $sender->id,
]);
// Send the message to all other users in the inbox
broadcast(new MessageSent($message))->toOthers();
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw new MessageNotSendException('Message not sent.', previous: $e);
}
} }
} }

View File

@ -0,0 +1,7 @@
<?php
namespace App\Exceptions;
use Exception;
class MessageNotSendException extends Exception {}

View File

@ -4,9 +4,12 @@
use App\Actions\CreateOrGetInboxAction; use App\Actions\CreateOrGetInboxAction;
use App\Actions\SendMessageAction; use App\Actions\SendMessageAction;
use App\Exceptions\MessageNotSendException;
use App\Http\Requests\SendMessageRequest; use App\Http\Requests\SendMessageRequest;
use App\Models\User; use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Container\Attributes\CurrentUser;
use Log;
use Throwable;
class ChatController extends Controller class ChatController extends Controller
{ {
@ -21,8 +24,8 @@ public function show(#[CurrentUser] User $sender, User $recipient, CreateOrGetIn
{ {
try { try {
$inbox = $action->execute($recipient, $sender); $inbox = $action->execute($recipient, $sender);
} catch (\Throwable $e) { } catch (Throwable $e) {
\Log::error('Inbox instantiation Failed: ', [$e->getMessage()]); Log::error('Inbox instantiation Failed: ', [$e->getMessage()]);
abort(500); abort(500);
} }
@ -33,7 +36,7 @@ public function show(#[CurrentUser] User $sender, User $recipient, CreateOrGetIn
} }
/** /**
* @throws \Throwable * @throws Throwable
*/ */
public function store( public function store(
#[CurrentUser] User $sender, #[CurrentUser] User $sender,
@ -41,8 +44,16 @@ public function store(
SendMessageRequest $request, SendMessageRequest $request,
SendMessageAction $action SendMessageAction $action
) { ) {
$action->execute($sender, $recipient, $request->validated()); try {
$action->execute($sender, $recipient, $request->validated());
response()->json(['message' => 'Message sent successfully.']); return response()->json(['message' => 'Message sent successfully.']);
} catch (MessageNotSendException $e) {
Log::error('Message send failed', [$e->getMessage()]);
return response()->json(['message' => 'Message sent failed.'], 500);
}
} }
} }

View File

@ -2,8 +2,11 @@
namespace App\Models; namespace App\Models;
use Eloquent;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/** /**
* @property int $id * @property int $id
@ -14,24 +17,24 @@
* @property string|null $delivered_at * @property string|null $delivered_at
* @property string|null $deleted_at * @property string|null $deleted_at
* @property string|null $failed_at * @property string|null $failed_at
* @property \Illuminate\Support\Carbon $created_at * @property Carbon $created_at
* @property-read \App\Models\Inbox|null $inbox * @property-read Inbox|null $inbox
* @property-read \App\Models\User|null $user * @property-read User|null $user
* *
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message newModelQuery() * @method static Builder<static>|Message newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message newQuery() * @method static Builder<static>|Message newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message query() * @method static Builder<static>|Message query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereCreatedAt($value) * @method static Builder<static>|Message whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereDeletedAt($value) * @method static Builder<static>|Message whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereDeliveredAt($value) * @method static Builder<static>|Message whereDeliveredAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereFailedAt($value) * @method static Builder<static>|Message whereFailedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereId($value) * @method static Builder<static>|Message whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereInboxId($value) * @method static Builder<static>|Message whereInboxId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereMessage($value) * @method static Builder<static>|Message whereMessage($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereReadAt($value) * @method static Builder<static>|Message whereReadAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereUserId($value) * @method static Builder<static>|Message whereUserId($value)
* *
* @mixin \Eloquent * @mixin Eloquent
*/ */
class Message extends Model class Message extends Model
{ {
@ -43,6 +46,10 @@ class Message extends Model
'created_at', 'delivered_at', 'created_at', 'delivered_at',
]; ];
protected $casts = [
'created_at' => 'datetime',
];
public function inbox(): BelongsTo public function inbox(): BelongsTo
{ {
return $this->belongsTo(Inbox::class); return $this->belongsTo(Inbox::class);

View File

@ -1,34 +1,51 @@
import {showToast} from "./toast.js";
export const sendMessage = async (element, event) => { export const sendMessage = async (element, event) => {
event.preventDefault() event.preventDefault();
const messageInput = element.querySelector('[name="message"]'); const messageInput = element.querySelector('[name="message"]');
if (!messageInput.value) return; const message = messageInput.value;
if (!message) return;
const recipentId = element.dataset.recipientId; const recipentId = element.dataset.recipientId;
const message = messageInput.value;
messageInput.value = ''; messageInput.value = '';
addMessageToChat({message: message}, true); // Capture the ID of the UI element we just added
const tempMessageId = addMessageToChat({message: message}, true);
const response = await axios.post(`/api/chat/${recipentId}/message`, {message: message}); try {
await axios.post(`/api/chat/${recipentId}/message`, {message: message});
} catch (e) {
console.error(e);
showToast('Message could not be sent.');
const failedMessage = document.getElementById(tempMessageId);
if (failedMessage) {
failedMessage.remove();
}
messageInput.value = message;
}
} }
export const addMessageToChat = (message, right = false) => { export const addMessageToChat = (message, right = false) => {
const chatContainer = document.getElementById('chat-container'); const chatContainer = document.getElementById('chat-container');
if (!chatContainer) return; if (!chatContainer) return;
const tempId = 'msg-' + Date.now();
const placeholder = chatContainer.querySelector('#no-messages-placeholder'); const placeholder = chatContainer.querySelector('#no-messages-placeholder');
if (placeholder) { if (placeholder) placeholder.remove();
placeholder.remove();
}
const messagePlaceholder = ` const messagePlaceholder = `
<div class="grid px-4 my-1 w-full ${right ? 'place-items-end' : 'place-items-start'}"> <div id="${tempId}" class="grid px-4 my-1 w-full ${right ? 'place-items-end' : 'place-items-start'}">
<div class="max-w-[40vw] py-2 px-4 rounded-2xl bg-gray-200"> <div class="max-w-[40vw] py-2 px-4 rounded-full ${right ? 'rounded-br-none' : 'rounded-tl-none'} bg-gray-200">
${message.message} ${message.message}
</div> </div>
</div>`; </div>`;
chatContainer.insertAdjacentHTML('afterbegin', messagePlaceholder); chatContainer.insertAdjacentHTML('afterbegin', messagePlaceholder);
chatContainer.scrollTop = 0; chatContainer.scrollTop = 0;
// Return the ID so the caller can find this specific message later
return tempId;
} }

View File

@ -1,14 +1,32 @@
@props(['recipient', 'messages' => []]) @props(['recipient', 'messages' => []])
@php
$groupedMessages = $messages->groupBy(function($msg) {
return $msg->created_at->format('M j, Y');
});
@endphp
<x-dashboard.page-heading class="m-0 mb-0.5" :title="$recipient->name" description="offline"/> <x-dashboard.page-heading class="m-0 mb-0.5" :title="$recipient->name" description="offline"/>
<div class="bg-gray-50 h-full overflow-hidden flex-shrink-0"> <div class="bg-gray-50 h-full overflow-hidden flex-shrink-0">
<div id="chat-container" data-auth-id="{{ auth()->id() }}" data-partner-id="{{ $recipient->id }}" <div id="chat-container" data-auth-id="{{ auth()->id() }}" data-partner-id="{{ $recipient->id }}"
class="text-sm flex flex-col-reverse overflow-y-scroll h-full max-h-screen pb-50 scroll-snap-y-container"> class="text-sm flex flex-col-reverse overflow-y-scroll h-full max-h-screen pb-50 scroll-snap-y-container">
@forelse($messages as $message) @forelse($groupedMessages as $date => $dayMessages)
<x-chat.message :right="$message->user_id === auth()->user()->id">{{$message->message}}</x-chat.message> @foreach($dayMessages as $message)
@empty <x-chat.message :right="$message->user_id === auth()->id()">
<div id="no-messages-placeholder" class="grid px-4 my-1 w-full h-full place-items-center "> <p>{{ $message->message }}</p>
<p class="text-gray-600">No Messages Found!</p> <span class="text-[10px] opacity-70 block text-right mt-1">
{{ $message->created_at->format('g:i A') }}
</span>
</x-chat.message>
@endforeach
{{-- The Date Divider --}}
<div class="flex justify-center my-4 top-0">
<span class="bg-gray-200 text-gray-600 text-xs px-3 py-1 rounded-full shadow-sm">
{{ $date }}
</span>
</div> </div>
@empty
<div id="no-messages-placeholder">No Messages Found !</div>
@endforelse @endforelse
</div> </div>
<x-chat.message-input :recipient_id="$recipient->id"/> <x-chat.message-input :recipient_id="$recipient->id"/>

View File

@ -1,7 +1,11 @@
@props(['right' => false]) @props(['right' => false])
<div class="grid px-4 my-1 w-full @if($right) place-items-end @else place-items-start @endif"> <div class="grid px-4 my-1 w-full @if($right) place-items-end @else place-items-start @endif">
<div class="max-w-[40vw] py-2 px-4 @if($right) rounded-l-xl rounded-br-xl @else rounded-r-xl rounded-bl-xl @endif bg-gray-200"> <div class="max-w-[70vw] md:max-w-[40vw] py-2 px-4 bg-gray-200 rounded-3xl
{{ $slot }} @if($right)
rounded-br-none
@else
rounded-tl-none
@endif">
{{$slot}}
</div> </div>
</div> </div>

View File

@ -1,27 +1,30 @@
@php use App\Enums\UserTypes; @endphp
@props(['profileLink' => '']) @props(['profileLink' => ''])
<div class="flex items-center"> <div class="flex items-center">
@auth @auth
<div class="relative group"> <div class="relative group">
<x-ui.button icon="user-circle" class="cursor-pointer" onclick="showMenu(this)"></x-ui.button> <x-ui.button icon="user-circle" class="cursor-pointer" onclick="showMenu(this)"></x-ui.button>
<ul class="menu opacity-0 z-10 scale-10 group-hover:scale-100 group-hover:opacity-100 transition-all duration-300 ease-in-out w-48 absolute right-0 bg-white border border-gray-300 rounded-md shadow-xl py-2 text-accent-600"> <ul class="menu opacity-0 z-10 scale-10 group-hover:scale-100 group-hover:opacity-100 transition-all duration-300 ease-in-out w-48 absolute right-0 bg-white border border-gray-300 rounded-md shadow-xl py-2 text-accent-600">
<li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold">
<a href="{{$profileLink}}" class="flex space-x-4">
<div class="p-1 bg-gray-200 rounded-xl text-gray-900">
<x-heroicon-o-user class="w-4"/>
</div>
<p>Profile</p>
</a>
</li>
@if(auth()->check() && auth()->user()->role === \App\Enums\UserTypes::Broker->value)
<li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold"> <li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold">
<a href="{{route('broker.dashboard')}}" class="flex space-x-4"> <a href="{{$profileLink}}" class="flex space-x-4">
<div class="p-1 bg-gray-200 rounded-xl text-gray-900"> <div class="p-1 bg-gray-200 rounded-xl text-gray-900">
<x-heroicon-o-adjustments-horizontal class="w-4"/> <x-heroicon-o-user class="w-4"/>
</div> </div>
<p>Control Panel</p> <p>Profile</p>
</a> </a>
</li> </li>
@if(auth()->check() && auth()->user()->role === UserTypes::Broker->value)
<li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold">
<a href="{{route('broker.dashboard')}}" class="flex space-x-4">
<div class="p-1 bg-gray-200 rounded-xl text-gray-900">
<x-heroicon-o-adjustments-horizontal class="w-4"/>
</div>
<p>Control Panel</p>
</a>
</li>
@endif
<li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold"> <li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold">
<a href="{{route('chat')}}" class="flex space-x-4"> <a href="{{route('chat')}}" class="flex space-x-4">
<div class="p-1 bg-gray-200 rounded-xl text-gray-900"> <div class="p-1 bg-gray-200 rounded-xl text-gray-900">
@ -30,10 +33,8 @@
<p>Chat</p> <p>Chat</p>
</a> </a>
</li> </li>
@endif </ul>
</div>
</ul>
</div>
@endauth @endauth
@guest @guest
<x-ui.button variant="neutral" link="{{route('login.create')}}"> <x-ui.button variant="neutral" link="{{route('login.create')}}">

View File

@ -1,10 +1,11 @@
@php use App\Services\ProfileInitialsService; @endphp
<x-chat.layout> <x-chat.layout>
<x-slot:sidebarItems> <x-slot:sidebarItems>
@forelse($inboxes as $inbox) @forelse($inboxes as $inbox)
<x-chat.sidebar-item <x-chat.sidebar-item
:name="$inbox->recipient->name" :name="$inbox->recipient->name"
:link="route('chat.show', $inbox->recipient->id)" :link="route('chat.show', $inbox->recipient->id)"
:avatar="(new \App\Services\ProfileInitialsService)->create($inbox->recipient->name)" :avatar="(new ProfileInitialsService)->create($inbox->recipient->name)"
:message="$inbox->last_message"/> :message="$inbox->last_message"/>
@empty @empty
No chats found ! No chats found !
@ -20,4 +21,5 @@
</div> </div>
@endif @endif
</div> </div>
<x-ui.toast/>
</x-chat.layout> </x-chat.layout>