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;
use App\Events\MessageSent;
use App\Exceptions\MessageNotSendException;
use App\Models\User;
use DB;
final readonly class SendMessageAction
{
@ -14,18 +16,30 @@ public function __construct(private CreateOrGetInboxAction $inboxAction) {}
*/
public function execute(User $sender, User $recipient, array $data): void
{
// find the inbox between the two users
\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,
]);
try {
broadcast(new MessageSent($message))->toOthers();
\DB::commit();
// find the inbox between the two users
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\SendMessageAction;
use App\Exceptions\MessageNotSendException;
use App\Http\Requests\SendMessageRequest;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Log;
use Throwable;
class ChatController extends Controller
{
@ -21,8 +24,8 @@ public function show(#[CurrentUser] User $sender, User $recipient, CreateOrGetIn
{
try {
$inbox = $action->execute($recipient, $sender);
} catch (\Throwable $e) {
\Log::error('Inbox instantiation Failed: ', [$e->getMessage()]);
} catch (Throwable $e) {
Log::error('Inbox instantiation Failed: ', [$e->getMessage()]);
abort(500);
}
@ -33,7 +36,7 @@ public function show(#[CurrentUser] User $sender, User $recipient, CreateOrGetIn
}
/**
* @throws \Throwable
* @throws Throwable
*/
public function store(
#[CurrentUser] User $sender,
@ -41,8 +44,16 @@ public function store(
SendMessageRequest $request,
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;
use Eloquent;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
@ -14,24 +17,24 @@
* @property string|null $delivered_at
* @property string|null $deleted_at
* @property string|null $failed_at
* @property \Illuminate\Support\Carbon $created_at
* @property-read \App\Models\Inbox|null $inbox
* @property-read \App\Models\User|null $user
* @property Carbon $created_at
* @property-read Inbox|null $inbox
* @property-read User|null $user
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereDeliveredAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereFailedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereInboxId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereMessage($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereReadAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Message whereUserId($value)
* @method static Builder<static>|Message newModelQuery()
* @method static Builder<static>|Message newQuery()
* @method static Builder<static>|Message query()
* @method static Builder<static>|Message whereCreatedAt($value)
* @method static Builder<static>|Message whereDeletedAt($value)
* @method static Builder<static>|Message whereDeliveredAt($value)
* @method static Builder<static>|Message whereFailedAt($value)
* @method static Builder<static>|Message whereId($value)
* @method static Builder<static>|Message whereInboxId($value)
* @method static Builder<static>|Message whereMessage($value)
* @method static Builder<static>|Message whereReadAt($value)
* @method static Builder<static>|Message whereUserId($value)
*
* @mixin \Eloquent
* @mixin Eloquent
*/
class Message extends Model
{
@ -43,6 +46,10 @@ class Message extends Model
'created_at', 'delivered_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function inbox(): BelongsTo
{
return $this->belongsTo(Inbox::class);

View File

@ -1,34 +1,51 @@
import {showToast} from "./toast.js";
export const sendMessage = async (element, event) => {
event.preventDefault()
event.preventDefault();
const messageInput = element.querySelector('[name="message"]');
if (!messageInput.value) return;
const message = messageInput.value;
if (!message) return;
const recipentId = element.dataset.recipientId;
const message = 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) => {
const chatContainer = document.getElementById('chat-container');
if (!chatContainer) return;
const tempId = 'msg-' + Date.now();
const placeholder = chatContainer.querySelector('#no-messages-placeholder');
if (placeholder) {
placeholder.remove();
}
if (placeholder) placeholder.remove();
const messagePlaceholder = `
<div 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 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-full ${right ? 'rounded-br-none' : 'rounded-tl-none'} bg-gray-200">
${message.message}
</div>
</div>`;
chatContainer.insertAdjacentHTML('afterbegin', messagePlaceholder);
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' => []])
@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"/>
<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 }}"
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)
<x-chat.message :right="$message->user_id === auth()->user()->id">{{$message->message}}</x-chat.message>
@empty
<div id="no-messages-placeholder" class="grid px-4 my-1 w-full h-full place-items-center ">
<p class="text-gray-600">No Messages Found!</p>
@forelse($groupedMessages as $date => $dayMessages)
@foreach($dayMessages as $message)
<x-chat.message :right="$message->user_id === auth()->id()">
<p>{{ $message->message }}</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>
@empty
<div id="no-messages-placeholder">No Messages Found !</div>
@endforelse
</div>
<x-chat.message-input :recipient_id="$recipient->id"/>

View File

@ -1,7 +1,11 @@
@props(['right' => false])
<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">
{{ $slot }}
<div class="max-w-[70vw] md:max-w-[40vw] py-2 px-4 bg-gray-200 rounded-3xl
@if($right)
rounded-br-none
@else
rounded-tl-none
@endif">
{{$slot}}
</div>
</div>

View File

@ -1,27 +1,30 @@
@php use App\Enums\UserTypes; @endphp
@props(['profileLink' => ''])
<div class="flex items-center">
@auth
<div class="relative group">
<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">
<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)
<div class="relative group">
<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">
<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">
<x-heroicon-o-adjustments-horizontal class="w-4"/>
<x-heroicon-o-user class="w-4"/>
</div>
<p>Control Panel</p>
<p>Profile</p>
</a>
</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">
<a href="{{route('chat')}}" class="flex space-x-4">
<div class="p-1 bg-gray-200 rounded-xl text-gray-900">
@ -30,10 +33,8 @@
<p>Chat</p>
</a>
</li>
@endif
</ul>
</div>
</ul>
</div>
@endauth
@guest
<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-slot:sidebarItems>
@forelse($inboxes as $inbox)
<x-chat.sidebar-item
:name="$inbox->recipient->name"
: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"/>
@empty
No chats found !
@ -20,4 +21,5 @@
</div>
@endif
</div>
<x-ui.toast/>
</x-chat.layout>