diff --git a/.env.example b/.env.example index c5984b51..8e37d6af 100644 --- a/.env.example +++ b/.env.example @@ -41,9 +41,29 @@ SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null -BROADCAST_CONNECTION=log +BROADCAST_CONNECTION=reverb FILESYSTEM_DISK=local -QUEUE_CONNECTION=database +QUEUE_CONNECTION=redis + +MESSAGING_REALTIME=true +MESSAGING_BROADCAST_QUEUE=broadcasts +MESSAGING_TYPING_CACHE_STORE=redis + +REVERB_APP_ID=skinbase-local +REVERB_APP_KEY=skinbase-local-key +REVERB_APP_SECRET=skinbase-local-secret +REVERB_HOST=127.0.0.1 +REVERB_PORT=8080 +REVERB_SCHEME=http +REVERB_SERVER_HOST=0.0.0.0 +REVERB_SERVER_PORT=8080 +REVERB_SERVER_PATH= +REVERB_SCALING_ENABLED=false + +VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" +VITE_REVERB_HOST="${REVERB_HOST}" +VITE_REVERB_PORT="${REVERB_PORT}" +VITE_REVERB_SCHEME="${REVERB_SCHEME}" # Upload UI feature flag (legacy upload remains default unless explicitly enabled) SKINBASE_UPLOADS_V2=false diff --git a/app/Events/ConversationUpdated.php b/app/Events/ConversationUpdated.php new file mode 100644 index 00000000..1b730dbc --- /dev/null +++ b/app/Events/ConversationUpdated.php @@ -0,0 +1,46 @@ +queue = (string) config('messaging.broadcast.queue', 'broadcasts'); + } + + public function broadcastOn(): array + { + return [new PrivateChannel('user.' . $this->userId)]; + } + + public function broadcastAs(): string + { + return 'conversation.updated'; + } + + public function broadcastWith(): array + { + return [ + 'event' => 'conversation.updated', + 'reason' => $this->reason, + 'conversation' => app(MessagingPayloadFactory::class)->conversationSummary($this->conversation, $this->userId), + ]; + } +} \ No newline at end of file diff --git a/app/Events/MessageCreated.php b/app/Events/MessageCreated.php new file mode 100644 index 00000000..8c47dd2e --- /dev/null +++ b/app/Events/MessageCreated.php @@ -0,0 +1,51 @@ +queue = (string) config('messaging.broadcast.queue', 'broadcasts'); + + if ($originUserId === (int) $message->sender_id) { + $this->dontBroadcastToCurrentUser(); + } + } + + public function broadcastOn(): array + { + return [new PrivateChannel('conversation.' . $this->conversation->id)]; + } + + public function broadcastAs(): string + { + return 'message.created'; + } + + public function broadcastWith(): array + { + return [ + 'event' => 'message.created', + 'conversation_id' => (int) $this->conversation->id, + 'message' => app(MessagingPayloadFactory::class)->message($this->message, (int) $this->message->sender_id), + ]; + } +} \ No newline at end of file diff --git a/app/Events/MessageDeleted.php b/app/Events/MessageDeleted.php new file mode 100644 index 00000000..03f35cf5 --- /dev/null +++ b/app/Events/MessageDeleted.php @@ -0,0 +1,45 @@ +queue = (string) config('messaging.broadcast.queue', 'broadcasts'); + $this->dontBroadcastToCurrentUser(); + } + + public function broadcastOn(): array + { + return [new PrivateChannel('conversation.' . $this->message->conversation_id)]; + } + + public function broadcastAs(): string + { + return 'message.deleted'; + } + + public function broadcastWith(): array + { + return [ + 'event' => 'message.deleted', + 'conversation_id' => (int) $this->message->conversation_id, + 'message_id' => (int) $this->message->id, + 'uuid' => (string) $this->message->uuid, + 'deleted_at' => optional($this->message->deleted_at ?? now())?->toIso8601String(), + ]; + } +} \ No newline at end of file diff --git a/app/Events/MessageRead.php b/app/Events/MessageRead.php new file mode 100644 index 00000000..4bea4063 --- /dev/null +++ b/app/Events/MessageRead.php @@ -0,0 +1,51 @@ +queue = (string) config('messaging.broadcast.queue', 'broadcasts'); + $this->dontBroadcastToCurrentUser(); + } + + public function broadcastOn(): array + { + return [new PrivateChannel('conversation.' . $this->conversation->id)]; + } + + public function broadcastAs(): string + { + return 'message.read'; + } + + public function broadcastWith(): array + { + return [ + 'event' => 'message.read', + 'conversation_id' => (int) $this->conversation->id, + 'user' => app(MessagingPayloadFactory::class)->userSummary($this->reader), + 'last_read_message_id' => $this->participant->last_read_message_id ? (int) $this->participant->last_read_message_id : null, + 'last_read_at' => optional($this->participant->last_read_at)?->toIso8601String(), + ]; + } +} \ No newline at end of file diff --git a/app/Events/MessageSent.php b/app/Events/MessageSent.php deleted file mode 100644 index 56bbdf9e..00000000 --- a/app/Events/MessageSent.php +++ /dev/null @@ -1,19 +0,0 @@ -queue = (string) config('messaging.broadcast.queue', 'broadcasts'); + $this->dontBroadcastToCurrentUser(); + } + + public function broadcastOn(): array + { + return [new PrivateChannel('conversation.' . $this->message->conversation_id)]; + } + + public function broadcastAs(): string + { + return 'message.updated'; + } + + public function broadcastWith(): array + { + return [ + 'event' => 'message.updated', + 'conversation_id' => (int) $this->message->conversation_id, + 'message' => app(MessagingPayloadFactory::class)->message($this->message), + ]; + } +} \ No newline at end of file diff --git a/app/Events/TypingStarted.php b/app/Events/TypingStarted.php index 63cb4813..06698604 100644 --- a/app/Events/TypingStarted.php +++ b/app/Events/TypingStarted.php @@ -2,15 +2,46 @@ namespace App\Events; +use App\Models\User; +use App\Services\Messaging\MessagingPayloadFactory; +use Illuminate\Broadcasting\InteractsWithSockets; +use Illuminate\Broadcasting\PresenceChannel; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class TypingStarted +class TypingStarted implements ShouldBroadcast { - use Dispatchable, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels; + + public bool $afterCommit = true; + public string $queue; public function __construct( public int $conversationId, - public int $userId, - ) {} + public User $user, + ) { + $this->queue = (string) config('messaging.broadcast.queue', 'broadcasts'); + $this->dontBroadcastToCurrentUser(); + } + + public function broadcastOn(): array + { + return [new PresenceChannel('conversation.' . $this->conversationId)]; + } + + public function broadcastAs(): string + { + return 'typing.started'; + } + + public function broadcastWith(): array + { + return [ + 'event' => 'typing.started', + 'conversation_id' => $this->conversationId, + 'user' => app(MessagingPayloadFactory::class)->userSummary($this->user), + 'expires_in_ms' => (int) config('messaging.typing.ttl_seconds', 8) * 1000, + ]; + } } diff --git a/app/Events/TypingStopped.php b/app/Events/TypingStopped.php index 002454ba..a550dd1d 100644 --- a/app/Events/TypingStopped.php +++ b/app/Events/TypingStopped.php @@ -2,15 +2,45 @@ namespace App\Events; +use App\Models\User; +use App\Services\Messaging\MessagingPayloadFactory; +use Illuminate\Broadcasting\InteractsWithSockets; +use Illuminate\Broadcasting\PresenceChannel; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class TypingStopped +class TypingStopped implements ShouldBroadcast { - use Dispatchable, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels; + + public bool $afterCommit = true; + public string $queue; public function __construct( public int $conversationId, - public int $userId, - ) {} + public User $user, + ) { + $this->queue = (string) config('messaging.broadcast.queue', 'broadcasts'); + $this->dontBroadcastToCurrentUser(); + } + + public function broadcastOn(): array + { + return [new PresenceChannel('conversation.' . $this->conversationId)]; + } + + public function broadcastAs(): string + { + return 'typing.stopped'; + } + + public function broadcastWith(): array + { + return [ + 'event' => 'typing.stopped', + 'conversation_id' => $this->conversationId, + 'user' => app(MessagingPayloadFactory::class)->userSummary($this->user), + ]; + } } diff --git a/app/Http/Controllers/Api/Messaging/ConversationController.php b/app/Http/Controllers/Api/Messaging/ConversationController.php index 310870b5..64c10b72 100644 --- a/app/Http/Controllers/Api/Messaging/ConversationController.php +++ b/app/Http/Controllers/Api/Messaging/ConversationController.php @@ -2,12 +2,17 @@ namespace App\Http\Controllers\Api\Messaging; +use App\Events\ConversationUpdated; use App\Http\Controllers\Controller; +use App\Http\Requests\Messaging\ManageConversationParticipantRequest; +use App\Http\Requests\Messaging\RenameConversationRequest; +use App\Http\Requests\Messaging\StoreConversationRequest; use App\Models\Conversation; use App\Models\ConversationParticipant; use App\Models\Message; use App\Models\User; -use App\Services\Messaging\MessageNotificationService; +use App\Services\Messaging\ConversationStateService; +use App\Services\Messaging\SendMessageAction; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -16,6 +21,11 @@ use Illuminate\Support\Facades\Schema; class ConversationController extends Controller { + public function __construct( + private readonly ConversationStateService $conversationState, + private readonly SendMessageAction $sendMessage, + ) {} + // ── GET /api/messages/conversations ───────────────────────────────────── public function index(Request $request): JsonResponse @@ -40,10 +50,13 @@ class ConversationController extends Controller ->where('messages.sender_id', '!=', $user->id) ->whereNull('messages.deleted_at') ->where(function ($query) { - $query->whereNull('cp_me.last_read_at') + $query->whereNull('cp_me.last_read_message_id') + ->whereNull('cp_me.last_read_at') + ->orWhereColumn('messages.id', '>', 'cp_me.last_read_message_id') ->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at'); }), ]) + ->where('conversations.is_active', true) ->with([ 'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']), 'latestMessage.sender:id,username', @@ -80,18 +93,10 @@ class ConversationController extends Controller // ── POST /api/messages/conversation ───────────────────────────────────── - public function store(Request $request): JsonResponse + public function store(StoreConversationRequest $request): JsonResponse { $user = $request->user(); - - $data = $request->validate([ - 'type' => 'required|in:direct,group', - 'recipient_id' => 'required_if:type,direct|integer|exists:users,id', - 'participant_ids' => 'required_if:type,group|array|min:2', - 'participant_ids.*'=> 'integer|exists:users,id', - 'title' => 'required_if:type,group|nullable|string|max:120', - 'body' => 'required|string|max:5000', - ]); + $data = $request->validated(); if ($data['type'] === 'direct') { return $this->createDirect($request, $user, $data); @@ -104,20 +109,28 @@ class ConversationController extends Controller public function markRead(Request $request, int $id): JsonResponse { - $participant = $this->participantRecord($request, $id); - $participant->update(['last_read_at' => now()]); - $this->touchConversationCachesForUsers([$request->user()->id]); + $conversation = $this->findAuthorized($request, $id); + $participant = $this->conversationState->markConversationRead( + $conversation, + $request->user(), + $request->integer('message_id') ?: null, + ); - return response()->json(['ok' => true]); + return response()->json([ + 'ok' => true, + 'last_read_at' => optional($participant->last_read_at)?->toIso8601String(), + 'last_read_message_id' => $participant->last_read_message_id, + ]); } // ── POST /api/messages/{conversation_id}/archive ───────────────────────── public function archive(Request $request, int $id): JsonResponse { + $conversation = $this->findAuthorized($request, $id); $participant = $this->participantRecord($request, $id); $participant->update(['is_archived' => ! $participant->is_archived]); - $this->touchConversationCachesForUsers([$request->user()->id]); + $this->broadcastConversationUpdate($conversation, 'conversation.archived'); return response()->json(['is_archived' => $participant->is_archived]); } @@ -126,27 +139,30 @@ class ConversationController extends Controller public function mute(Request $request, int $id): JsonResponse { + $conversation = $this->findAuthorized($request, $id); $participant = $this->participantRecord($request, $id); $participant->update(['is_muted' => ! $participant->is_muted]); - $this->touchConversationCachesForUsers([$request->user()->id]); + $this->broadcastConversationUpdate($conversation, 'conversation.muted'); return response()->json(['is_muted' => $participant->is_muted]); } public function pin(Request $request, int $id): JsonResponse { + $conversation = $this->findAuthorized($request, $id); $participant = $this->participantRecord($request, $id); $participant->update(['is_pinned' => true, 'pinned_at' => now()]); - $this->touchConversationCachesForUsers([$request->user()->id]); + $this->broadcastConversationUpdate($conversation, 'conversation.pinned'); return response()->json(['is_pinned' => true]); } public function unpin(Request $request, int $id): JsonResponse { + $conversation = $this->findAuthorized($request, $id); $participant = $this->participantRecord($request, $id); $participant->update(['is_pinned' => false, 'pinned_at' => null]); - $this->touchConversationCachesForUsers([$request->user()->id]); + $this->broadcastConversationUpdate($conversation, 'conversation.unpinned'); return response()->json(['is_pinned' => false]); } @@ -182,14 +198,15 @@ class ConversationController extends Controller } $participant->update(['left_at' => now()]); - $this->touchConversationCachesForUsers($participantUserIds); + $this->conversationState->touchConversationCachesForUsers($participantUserIds); + $this->broadcastConversationUpdate($conv, 'conversation.left', $participantUserIds); return response()->json(['ok' => true]); } // ── POST /api/messages/{conversation_id}/add-user ──────────────────────── - public function addUser(Request $request, int $id): JsonResponse + public function addUser(ManageConversationParticipantRequest $request, int $id): JsonResponse { $conv = $this->findAuthorized($request, $id); $this->requireAdmin($request, $id); @@ -198,9 +215,7 @@ class ConversationController extends Controller ->pluck('user_id') ->all(); - $data = $request->validate([ - 'user_id' => 'required|integer|exists:users,id', - ]); + $data = $request->validated(); $existing = ConversationParticipant::where('conversation_id', $id) ->where('user_id', $data['user_id']) @@ -220,20 +235,18 @@ class ConversationController extends Controller } $participantUserIds[] = (int) $data['user_id']; - $this->touchConversationCachesForUsers($participantUserIds); + $this->conversationState->touchConversationCachesForUsers($participantUserIds); + $this->broadcastConversationUpdate($conv, 'conversation.participant_added', $participantUserIds); return response()->json(['ok' => true]); } // ── DELETE /api/messages/{conversation_id}/remove-user ─────────────────── - public function removeUser(Request $request, int $id): JsonResponse + public function removeUser(ManageConversationParticipantRequest $request, int $id): JsonResponse { $this->requireAdmin($request, $id); - - $data = $request->validate([ - 'user_id' => 'required|integer', - ]); + $data = $request->validated(); // Cannot remove the conversation creator $conv = Conversation::findOrFail($id); @@ -263,26 +276,28 @@ class ConversationController extends Controller ->whereNull('left_at') ->update(['left_at' => now()]); - $this->touchConversationCachesForUsers($participantUserIds); + $this->conversationState->touchConversationCachesForUsers($participantUserIds); + $this->broadcastConversationUpdate($conv, 'conversation.participant_removed', $participantUserIds); return response()->json(['ok' => true]); } // ── POST /api/messages/{conversation_id}/rename ────────────────────────── - public function rename(Request $request, int $id): JsonResponse + public function rename(RenameConversationRequest $request, int $id): JsonResponse { $conv = $this->findAuthorized($request, $id); abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.'); $this->requireAdmin($request, $id); - $data = $request->validate(['title' => 'required|string|max:120']); + $data = $request->validated(); $conv->update(['title' => $data['title']]); $participantUserIds = ConversationParticipant::where('conversation_id', $id) ->whereNull('left_at') ->pluck('user_id') ->all(); - $this->touchConversationCachesForUsers($participantUserIds); + $this->conversationState->touchConversationCachesForUsers($participantUserIds); + $this->broadcastConversationUpdate($conv, 'conversation.renamed', $participantUserIds); return response()->json(['title' => $conv->title]); } @@ -307,8 +322,10 @@ class ConversationController extends Controller if (! $conv) { $conv = DB::transaction(function () use ($user, $recipient) { $conv = Conversation::create([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), 'type' => 'direct', 'created_by' => $user->id, + 'is_active' => true, ]); ConversationParticipant::insert([ @@ -320,17 +337,12 @@ class ConversationController extends Controller }); } - // Insert first / next message - $message = $conv->messages()->create([ - 'sender_id' => $user->id, - 'body' => $data['body'], + $this->sendMessage->execute($conv, $user, [ + 'body' => $data['body'], + 'client_temp_id' => $data['client_temp_id'] ?? null, ]); - $conv->update(['last_message_at' => $message->created_at]); - app(MessageNotificationService::class)->notifyNewMessage($conv, $message, $user); - $this->touchConversationCachesForUsers([$user->id, $recipient->id]); - - return response()->json($conv->load('allParticipants.user:id,username'), 201); + return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201); } private function createGroup(Request $request, User $user, array $data): JsonResponse @@ -339,9 +351,11 @@ class ConversationController extends Controller $conv = DB::transaction(function () use ($user, $data, $participantIds) { $conv = Conversation::create([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), 'type' => 'group', 'title' => $data['title'], 'created_by' => $user->id, + 'is_active' => true, ]); $rows = array_map(fn ($uid) => [ @@ -353,27 +367,21 @@ class ConversationController extends Controller ConversationParticipant::insert($rows); - $message = $conv->messages()->create([ - 'sender_id' => $user->id, - 'body' => $data['body'], - ]); - - $conv->update(['last_message_at' => $message->created_at]); - - return [$conv, $message]; + return $conv; }); - [$conversation, $message] = $conv; - app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user); - $this->touchConversationCachesForUsers($participantIds); + $this->sendMessage->execute($conv, $user, [ + 'body' => $data['body'], + 'client_temp_id' => $data['client_temp_id'] ?? null, + ]); - return response()->json($conversation->load('allParticipants.user:id,username'), 201); + return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201); } private function findAuthorized(Request $request, int $id): Conversation { $conv = Conversation::findOrFail($id); - $this->assertParticipant($request, $id); + $this->authorize('view', $conv); return $conv; } @@ -399,28 +407,13 @@ class ConversationController extends Controller private function requireAdmin(Request $request, int $id): void { - abort_unless( - ConversationParticipant::where('conversation_id', $id) - ->where('user_id', $request->user()->id) - ->where('role', 'admin') - ->whereNull('left_at') - ->exists(), - 403, - 'Only admins can perform this action.' - ); + $conversation = Conversation::findOrFail($id); + $this->authorize('manageParticipants', $conversation); } private function touchConversationCachesForUsers(array $userIds): void { - foreach (array_unique($userIds) as $userId) { - if (! $userId) { - continue; - } - - $versionKey = $this->cacheVersionKey((int) $userId); - Cache::add($versionKey, 1, now()->addDay()); - Cache::increment($versionKey); - } + $this->conversationState->touchConversationCachesForUsers($userIds); } private function cacheVersionKey(int $userId): string @@ -433,6 +426,16 @@ class ConversationController extends Controller return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}"; } + private function broadcastConversationUpdate(Conversation $conversation, string $reason, ?array $participantIds = null): void + { + $participantIds ??= $this->conversationState->activeParticipantIds($conversation); + $this->conversationState->touchConversationCachesForUsers($participantIds); + + foreach ($participantIds as $participantId) { + event(new ConversationUpdated((int) $participantId, $conversation, $reason)); + } + } + private function assertNotBlockedBetween(User $sender, User $recipient): void { if (! Schema::hasTable('user_blocks')) { diff --git a/app/Http/Controllers/Api/Messaging/MessageController.php b/app/Http/Controllers/Api/Messaging/MessageController.php index b9434256..e6f2719b 100644 --- a/app/Http/Controllers/Api/Messaging/MessageController.php +++ b/app/Http/Controllers/Api/Messaging/MessageController.php @@ -2,31 +2,59 @@ namespace App\Http\Controllers\Api\Messaging; -use App\Events\MessageSent; +use App\Events\ConversationUpdated; +use App\Events\MessageDeleted; +use App\Events\MessageUpdated; use App\Http\Controllers\Controller; +use App\Http\Requests\Messaging\StoreMessageRequest; +use App\Http\Requests\Messaging\ToggleMessageReactionRequest; +use App\Http\Requests\Messaging\UpdateMessageRequest; use App\Models\Conversation; use App\Models\ConversationParticipant; use App\Models\Message; -use App\Models\MessageAttachment; use App\Models\MessageReaction; +use App\Services\Messaging\ConversationStateService; +use App\Services\Messaging\MessagingPayloadFactory; use App\Services\Messaging\MessageSearchIndexer; -use App\Services\Messaging\MessageNotificationService; +use App\Services\Messaging\SendMessageAction; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\DB; class MessageController extends Controller { private const PAGE_SIZE = 30; + public function __construct( + private readonly ConversationStateService $conversationState, + private readonly MessagingPayloadFactory $payloadFactory, + private readonly SendMessageAction $sendMessage, + ) {} + // ── GET /api/messages/{conversation_id} ────────────────────────────────── public function index(Request $request, int $conversationId): JsonResponse { - $this->assertParticipant($request, $conversationId); - $cursor = $request->integer('cursor'); + $conversation = $this->findConversationOrFail($conversationId); + $cursor = $request->integer('cursor') ?: $request->integer('before_id'); + $afterId = $request->integer('after_id'); + + if ($afterId) { + $messages = Message::withTrashed() + ->where('conversation_id', $conversationId) + ->with(['sender:id,username', 'reactions', 'attachments']) + ->where('id', '>', $afterId) + ->orderBy('id') + ->limit(100) + ->get() + ->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id)) + ->values(); + + return response()->json([ + 'data' => $messages, + 'next_cursor' => null, + ]); + } $query = Message::withTrashed() ->where('conversation_id', $conversationId) @@ -44,65 +72,33 @@ class MessageController extends Controller $nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null; return response()->json([ - 'data' => $messages, + 'data' => $messages->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))->values(), 'next_cursor' => $nextCursor, ]); } // ── POST /api/messages/{conversation_id} ───────────────────────────────── - public function store(Request $request, int $conversationId): JsonResponse + public function store(StoreMessageRequest $request, int $conversationId): JsonResponse { - $this->assertParticipant($request, $conversationId); - - $data = $request->validate([ - 'body' => 'nullable|string|max:5000', - 'attachments' => 'sometimes|array|max:5', - 'attachments.*' => 'file|max:25600', - ]); + $conversation = $this->findConversationOrFail($conversationId); + $data = $request->validated(); + $data['attachments'] = $request->file('attachments', []); $body = trim((string) ($data['body'] ?? '')); - $files = $request->file('attachments', []); - abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.'); + abort_if($body === '' && empty($data['attachments']), 422, 'Message body or attachment is required.'); - $message = Message::create([ - 'conversation_id' => $conversationId, - 'sender_id' => $request->user()->id, - 'body' => $body, - ]); + $message = $this->sendMessage->execute($conversation, $request->user(), $data); - foreach ($files as $file) { - if ($file instanceof UploadedFile) { - $this->storeAttachment($file, $message, (int) $request->user()->id); - } - } - - Conversation::where('id', $conversationId) - ->update(['last_message_at' => $message->created_at]); - - $conversation = Conversation::findOrFail($conversationId); - app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $request->user()); - app(MessageSearchIndexer::class)->indexMessage($message); - event(new MessageSent($conversationId, $message->id, $request->user()->id)); - - $participantUserIds = ConversationParticipant::where('conversation_id', $conversationId) - ->whereNull('left_at') - ->pluck('user_id') - ->all(); - $this->touchConversationCachesForUsers($participantUserIds); - - $message->load(['sender:id,username', 'attachments']); - - return response()->json($message, 201); + return response()->json($this->payloadFactory->message($message, (int) $request->user()->id), 201); } // ── POST /api/messages/{conversation_id}/react ─────────────────────────── - public function react(Request $request, int $conversationId, int $messageId): JsonResponse + public function react(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse { - $this->assertParticipant($request, $conversationId); - - $data = $request->validate(['reaction' => 'required|string|max:32']); + $this->findConversationOrFail($conversationId); + $data = $request->validated(); $this->assertAllowedReaction($data['reaction']); $existing = MessageReaction::where([ @@ -126,11 +122,10 @@ class MessageController extends Controller // ── DELETE /api/messages/{conversation_id}/react ───────────────────────── - public function unreact(Request $request, int $conversationId, int $messageId): JsonResponse + public function unreact(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse { - $this->assertParticipant($request, $conversationId); - - $data = $request->validate(['reaction' => 'required|string|max:32']); + $this->findConversationOrFail($conversationId); + $data = $request->validated(); $this->assertAllowedReaction($data['reaction']); MessageReaction::where([ @@ -142,12 +137,11 @@ class MessageController extends Controller return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); } - public function reactByMessage(Request $request, int $messageId): JsonResponse + public function reactByMessage(ToggleMessageReactionRequest $request, int $messageId): JsonResponse { $message = Message::query()->findOrFail($messageId); - $this->assertParticipant($request, (int) $message->conversation_id); - - $data = $request->validate(['reaction' => 'required|string|max:32']); + $this->findConversationOrFail((int) $message->conversation_id); + $data = $request->validated(); $this->assertAllowedReaction($data['reaction']); $existing = MessageReaction::where([ @@ -169,12 +163,11 @@ class MessageController extends Controller return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); } - public function unreactByMessage(Request $request, int $messageId): JsonResponse + public function unreactByMessage(ToggleMessageReactionRequest $request, int $messageId): JsonResponse { $message = Message::query()->findOrFail($messageId); - $this->assertParticipant($request, (int) $message->conversation_id); - - $data = $request->validate(['reaction' => 'required|string|max:32']); + $this->findConversationOrFail((int) $message->conversation_id); + $data = $request->validated(); $this->assertAllowedReaction($data['reaction']); MessageReaction::where([ @@ -188,19 +181,15 @@ class MessageController extends Controller // ── PATCH /api/messages/message/{messageId} ─────────────────────────────── - public function update(Request $request, int $messageId): JsonResponse + public function update(UpdateMessageRequest $request, int $messageId): JsonResponse { $message = Message::findOrFail($messageId); - abort_unless( - $message->sender_id === $request->user()->id, - 403, - 'You may only edit your own messages.' - ); + $this->authorize('update', $message); abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.'); - $data = $request->validate(['body' => 'required|string|max:5000']); + $data = $request->validated(); $message->update([ 'body' => $data['body'], @@ -208,13 +197,21 @@ class MessageController extends Controller ]); app(MessageSearchIndexer::class)->updateMessage($message); - $participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id) - ->whereNull('left_at') - ->pluck('user_id') - ->all(); - $this->touchConversationCachesForUsers($participantUserIds); + $participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id); + $this->conversationState->touchConversationCachesForUsers($participantUserIds); - return response()->json($message->fresh()); + DB::afterCommit(function () use ($message, $participantUserIds): void { + event(new MessageUpdated($message->fresh(['sender:id,username,name', 'attachments', 'reactions']))); + + $conversation = Conversation::find($message->conversation_id); + if ($conversation) { + foreach ($participantUserIds as $participantId) { + event(new ConversationUpdated((int) $participantId, $conversation, 'message.updated')); + } + } + }); + + return response()->json($this->payloadFactory->message($message->fresh(['sender:id,username,name', 'attachments', 'reactions']), (int) $request->user()->id)); } // ── DELETE /api/messages/message/{messageId} ────────────────────────────── @@ -223,19 +220,24 @@ class MessageController extends Controller { $message = Message::findOrFail($messageId); - abort_unless( - $message->sender_id === $request->user()->id || $request->user()->isAdmin(), - 403, - 'You may only delete your own messages.' - ); + $this->authorize('delete', $message); - $participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id) - ->whereNull('left_at') - ->pluck('user_id') - ->all(); + $participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id); app(MessageSearchIndexer::class)->deleteMessage($message); $message->delete(); - $this->touchConversationCachesForUsers($participantUserIds); + $this->conversationState->touchConversationCachesForUsers($participantUserIds); + + DB::afterCommit(function () use ($message, $participantUserIds): void { + $message->refresh(); + event(new MessageDeleted($message)); + + $conversation = Conversation::find($message->conversation_id); + if ($conversation) { + foreach ($participantUserIds as $participantId) { + event(new ConversationUpdated((int) $participantId, $conversation, 'message.deleted')); + } + } + }); return response()->json(['ok' => true]); } @@ -256,15 +258,7 @@ class MessageController extends Controller private function touchConversationCachesForUsers(array $userIds): void { - foreach (array_unique($userIds) as $userId) { - if (! $userId) { - continue; - } - - $versionKey = "messages:conversations:version:{$userId}"; - Cache::add($versionKey, 1, now()->addDay()); - Cache::increment($versionKey); - } + $this->conversationState->touchConversationCachesForUsers($userIds); } private function assertAllowedReaction(string $reaction): void @@ -298,54 +292,11 @@ class MessageController extends Controller return $summary; } - private function storeAttachment(UploadedFile $file, Message $message, int $userId): void + private function findConversationOrFail(int $conversationId): Conversation { - $mime = (string) $file->getMimeType(); - $finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname()); - $detectedMime = $finfoMime !== '' ? $finfoMime : $mime; + $conversation = Conversation::query()->findOrFail($conversationId); + $this->authorize('view', $conversation); - $allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []); - $allowedFile = (array) config('messaging.attachments.allowed_file_mimes', []); - - $type = in_array($detectedMime, $allowedImage, true) ? 'image' : 'file'; - $allowed = $type === 'image' ? $allowedImage : $allowedFile; - - abort_unless(in_array($detectedMime, $allowed, true), 422, 'Unsupported attachment type.'); - - $maxBytes = $type === 'image' - ? ((int) config('messaging.attachments.max_image_kb', 10240) * 1024) - : ((int) config('messaging.attachments.max_file_kb', 25600) * 1024); - - abort_if($file->getSize() > $maxBytes, 422, 'Attachment exceeds allowed size.'); - - $year = now()->format('Y'); - $month = now()->format('m'); - $ext = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin'); - $path = "messages/{$message->conversation_id}/{$year}/{$month}/" . uniqid('att_', true) . ".{$ext}"; - - $diskName = (string) config('messaging.attachments.disk', 'local'); - Storage::disk($diskName)->put($path, file_get_contents($file->getPathname())); - - $width = null; - $height = null; - if ($type === 'image') { - $dimensions = @getimagesize($file->getPathname()); - $width = isset($dimensions[0]) ? (int) $dimensions[0] : null; - $height = isset($dimensions[1]) ? (int) $dimensions[1] : null; - } - - MessageAttachment::query()->create([ - 'message_id' => $message->id, - 'user_id' => $userId, - 'type' => $type, - 'mime' => $detectedMime, - 'size_bytes' => (int) $file->getSize(), - 'width' => $width, - 'height' => $height, - 'sha256' => hash_file('sha256', $file->getPathname()), - 'original_name' => substr((string) $file->getClientOriginalName(), 0, 255), - 'storage_path' => $path, - 'created_at' => now(), - ]); + return $conversation; } } diff --git a/app/Http/Controllers/Api/Messaging/MessageSearchController.php b/app/Http/Controllers/Api/Messaging/MessageSearchController.php index f6e0e3f7..caa42524 100644 --- a/app/Http/Controllers/Api/Messaging/MessageSearchController.php +++ b/app/Http/Controllers/Api/Messaging/MessageSearchController.php @@ -71,18 +71,12 @@ class MessageSearchController extends Controller $hits = collect($result->getHits() ?? []); $estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count()); - } catch (\Throwable) { - $query = Message::query() - ->select('id') - ->whereNull('deleted_at') - ->whereIn('conversation_id', $allowedConversationIds) - ->when($conversationId !== null, fn ($q) => $q->where('conversation_id', $conversationId)) - ->where('body', 'like', '%' . (string) $data['q'] . '%') - ->orderByDesc('created_at') - ->orderByDesc('id'); - $estimated = (clone $query)->count(); - $hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]); + if ($hits->isEmpty()) { + [$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit); + } + } catch (\Throwable) { + [$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit); } $messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all(); @@ -122,6 +116,23 @@ class MessageSearchController extends Controller ]); } + private function fallbackHits(array $allowedConversationIds, ?int $conversationId, string $queryString, int $offset, int $limit): array + { + $query = Message::query() + ->select('id') + ->whereNull('deleted_at') + ->whereIn('conversation_id', $allowedConversationIds) + ->when($conversationId !== null, fn ($builder) => $builder->where('conversation_id', $conversationId)) + ->where('body', 'like', '%' . $queryString . '%') + ->orderByDesc('created_at') + ->orderByDesc('id'); + + $estimated = (clone $query)->count(); + $hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]); + + return [$hits, $estimated]; + } + public function rebuild(Request $request): JsonResponse { abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.'); diff --git a/app/Http/Controllers/Api/Messaging/MessagingSettingsController.php b/app/Http/Controllers/Api/Messaging/MessagingSettingsController.php index 368ce4d8..a72cac6c 100644 --- a/app/Http/Controllers/Api/Messaging/MessagingSettingsController.php +++ b/app/Http/Controllers/Api/Messaging/MessagingSettingsController.php @@ -16,9 +16,13 @@ class MessagingSettingsController extends Controller { public function show(Request $request): JsonResponse { + $realtimeReady = (bool) config('messaging.realtime', false) + && config('broadcasting.default') === 'reverb' + && filled(config('broadcasting.connections.reverb.key')); + return response()->json([ 'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone', - 'realtime_enabled' => (bool) config('messaging.realtime', false), + 'realtime_enabled' => $realtimeReady, ]); } diff --git a/app/Http/Controllers/Api/Messaging/TypingController.php b/app/Http/Controllers/Api/Messaging/TypingController.php index 45a0e045..48de65ac 100644 --- a/app/Http/Controllers/Api/Messaging/TypingController.php +++ b/app/Http/Controllers/Api/Messaging/TypingController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Messaging; use App\Events\TypingStarted; use App\Events\TypingStopped; use App\Http\Controllers\Controller; +use App\Models\Conversation; use App\Models\ConversationParticipant; use Illuminate\Cache\Repository; use Illuminate\Http\JsonResponse; @@ -15,13 +16,13 @@ class TypingController extends Controller { public function start(Request $request, int $conversationId): JsonResponse { - $this->assertParticipant($request, $conversationId); + $this->findConversationOrFail($conversationId); $ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8)); $this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl)); if ((bool) config('messaging.realtime', false)) { - event(new TypingStarted($conversationId, (int) $request->user()->id)); + event(new TypingStarted($conversationId, $request->user())); } return response()->json(['ok' => true]); @@ -29,11 +30,11 @@ class TypingController extends Controller public function stop(Request $request, int $conversationId): JsonResponse { - $this->assertParticipant($request, $conversationId); + $this->findConversationOrFail($conversationId); $this->store()->forget($this->key($conversationId, (int) $request->user()->id)); if ((bool) config('messaging.realtime', false)) { - event(new TypingStopped($conversationId, (int) $request->user()->id)); + event(new TypingStopped($conversationId, $request->user())); } return response()->json(['ok' => true]); @@ -41,7 +42,7 @@ class TypingController extends Controller public function index(Request $request, int $conversationId): JsonResponse { - $this->assertParticipant($request, $conversationId); + $this->findConversationOrFail($conversationId); $userId = (int) $request->user()->id; $participants = ConversationParticipant::query() @@ -93,4 +94,12 @@ class TypingController extends Controller return Cache::store(); } } + + private function findConversationOrFail(int $conversationId): Conversation + { + $conversation = Conversation::query()->findOrFail($conversationId); + $this->authorize('view', $conversation); + + return $conversation; + } } diff --git a/app/Http/Requests/Messaging/ManageConversationParticipantRequest.php b/app/Http/Requests/Messaging/ManageConversationParticipantRequest.php new file mode 100644 index 00000000..7c0227e7 --- /dev/null +++ b/app/Http/Requests/Messaging/ManageConversationParticipantRequest.php @@ -0,0 +1,20 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'user_id' => 'required|integer|exists:users,id', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Messaging/RenameConversationRequest.php b/app/Http/Requests/Messaging/RenameConversationRequest.php new file mode 100644 index 00000000..d5764b7a --- /dev/null +++ b/app/Http/Requests/Messaging/RenameConversationRequest.php @@ -0,0 +1,20 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'title' => 'required|string|max:120', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Messaging/StoreConversationRequest.php b/app/Http/Requests/Messaging/StoreConversationRequest.php new file mode 100644 index 00000000..91e9e6bf --- /dev/null +++ b/app/Http/Requests/Messaging/StoreConversationRequest.php @@ -0,0 +1,26 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'type' => 'required|in:direct,group', + 'recipient_id' => 'required_if:type,direct|integer|exists:users,id', + 'participant_ids' => 'required_if:type,group|array|min:2', + 'participant_ids.*' => 'integer|exists:users,id', + 'title' => 'required_if:type,group|nullable|string|max:120', + 'body' => 'required|string|max:5000', + 'client_temp_id' => 'nullable|string|max:120', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Messaging/StoreMessageRequest.php b/app/Http/Requests/Messaging/StoreMessageRequest.php new file mode 100644 index 00000000..1fcf1502 --- /dev/null +++ b/app/Http/Requests/Messaging/StoreMessageRequest.php @@ -0,0 +1,24 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'body' => 'nullable|string|max:5000', + 'attachments' => 'sometimes|array|max:5', + 'attachments.*' => 'file|max:25600', + 'client_temp_id' => 'nullable|string|max:120', + 'reply_to_message_id' => 'nullable|integer|exists:messages,id', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Messaging/ToggleMessageReactionRequest.php b/app/Http/Requests/Messaging/ToggleMessageReactionRequest.php new file mode 100644 index 00000000..a06d087e --- /dev/null +++ b/app/Http/Requests/Messaging/ToggleMessageReactionRequest.php @@ -0,0 +1,20 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'reaction' => 'required|string|max:32', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Messaging/UpdateMessageRequest.php b/app/Http/Requests/Messaging/UpdateMessageRequest.php new file mode 100644 index 00000000..918749e2 --- /dev/null +++ b/app/Http/Requests/Messaging/UpdateMessageRequest.php @@ -0,0 +1,20 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'body' => 'required|string|max:5000', + ]; + } +} \ No newline at end of file diff --git a/app/Models/Conversation.php b/app/Models/Conversation.php index 51605155..fe06a1d4 100644 --- a/app/Models/Conversation.php +++ b/app/Models/Conversation.php @@ -23,14 +23,18 @@ class Conversation extends Model use HasFactory; protected $fillable = [ + 'uuid', 'type', 'title', 'created_by', + 'last_message_id', 'last_message_at', + 'is_active', ]; protected $casts = [ 'last_message_at' => 'datetime', + 'is_active' => 'boolean', ]; // ── Relationships ──────────────────────────────────────────────────────── @@ -81,6 +85,7 @@ class Conversation extends Model { return self::query() ->where('type', 'direct') + ->where('is_active', true) ->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userA)->whereNull('left_at')) ->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userB)->whereNull('left_at')) ->whereRaw( @@ -108,6 +113,11 @@ class Conversation extends Model ->whereNull('deleted_at') ->where('sender_id', '!=', $userId); + if ($participant->last_read_message_id) { + $query->where('id', '>', $participant->last_read_message_id); + return $query->count(); + } + if ($participant->last_read_at) { $query->where('created_at', '>', $participant->last_read_at); } diff --git a/app/Models/ConversationParticipant.php b/app/Models/ConversationParticipant.php index 40ea4864..f56d1cd4 100644 --- a/app/Models/ConversationParticipant.php +++ b/app/Models/ConversationParticipant.php @@ -30,9 +30,11 @@ class ConversationParticipant extends Model 'user_id', 'role', 'last_read_at', + 'last_read_message_id', 'is_muted', 'is_archived', 'is_pinned', + 'is_hidden', 'pinned_at', 'joined_at', 'left_at', @@ -40,9 +42,11 @@ class ConversationParticipant extends Model protected $casts = [ 'last_read_at' => 'datetime', + 'last_read_message_id' => 'integer', 'is_muted' => 'boolean', 'is_archived' => 'boolean', 'is_pinned' => 'boolean', + 'is_hidden' => 'boolean', 'pinned_at' => 'datetime', 'joined_at' => 'datetime', 'left_at' => 'datetime', diff --git a/app/Models/Message.php b/app/Models/Message.php index aedcbf9b..516dbd41 100644 --- a/app/Models/Message.php +++ b/app/Models/Message.php @@ -7,8 +7,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Str; use Laravel\Scout\Searchable; +use App\Models\MessageRead; + /** * @property int $id * @property int $conversation_id @@ -24,16 +27,31 @@ class Message extends Model use HasFactory, SoftDeletes, Searchable; protected $fillable = [ + 'uuid', + 'client_temp_id', 'conversation_id', 'sender_id', + 'message_type', 'body', + 'meta_json', + 'reply_to_message_id', 'edited_at', ]; protected $casts = [ + 'meta_json' => 'array', 'edited_at' => 'datetime', ]; + protected static function booted(): void + { + static::creating(function (self $message): void { + if (! $message->uuid) { + $message->uuid = (string) Str::uuid(); + } + }); + } + // ── Relationships ──────────────────────────────────────────────────────── public function conversation(): BelongsTo @@ -56,9 +74,14 @@ class Message extends Model return $this->hasMany(MessageAttachment::class); } - public function setBodyAttribute(string $value): void + public function reads(): HasMany { - $sanitized = trim(strip_tags($value)); + return $this->hasMany(MessageRead::class); + } + + public function setBodyAttribute(?string $value): void + { + $sanitized = trim(strip_tags((string) $value)); $this->attributes['body'] = $sanitized; } diff --git a/app/Models/MessageAttachment.php b/app/Models/MessageAttachment.php index 9aa8fd23..b126b2cc 100644 --- a/app/Models/MessageAttachment.php +++ b/app/Models/MessageAttachment.php @@ -14,6 +14,7 @@ class MessageAttachment extends Model protected $fillable = [ 'message_id', + 'disk', 'user_id', 'type', 'mime', diff --git a/app/Models/MessageRead.php b/app/Models/MessageRead.php new file mode 100644 index 00000000..80f55083 --- /dev/null +++ b/app/Models/MessageRead.php @@ -0,0 +1,34 @@ + 'datetime', + ]; + + public function message(): BelongsTo + { + return $this->belongsTo(Message::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} \ No newline at end of file diff --git a/app/Policies/ConversationPolicy.php b/app/Policies/ConversationPolicy.php new file mode 100644 index 00000000..e33600a8 --- /dev/null +++ b/app/Policies/ConversationPolicy.php @@ -0,0 +1,47 @@ +participantRecord($user, $conversation) !== null + && (bool) ($conversation->is_active ?? true); + } + + public function send(User $user, Conversation $conversation): bool + { + return $this->view($user, $conversation); + } + + public function manageParticipants(User $user, Conversation $conversation): bool + { + $participant = $this->participantRecord($user, $conversation); + + return $participant !== null && $participant->role === 'admin'; + } + + public function rename(User $user, Conversation $conversation): bool + { + return $conversation->isGroup() && $this->manageParticipants($user, $conversation); + } + + public function joinPresence(User $user, Conversation $conversation): bool + { + return $this->view($user, $conversation); + } + + private function participantRecord(User $user, Conversation $conversation): ?ConversationParticipant + { + return ConversationParticipant::query() + ->where('conversation_id', $conversation->id) + ->where('user_id', $user->id) + ->whereNull('left_at') + ->first(); + } +} \ No newline at end of file diff --git a/app/Policies/MessagePolicy.php b/app/Policies/MessagePolicy.php new file mode 100644 index 00000000..aa75fe39 --- /dev/null +++ b/app/Policies/MessagePolicy.php @@ -0,0 +1,29 @@ +where('conversation_id', $message->conversation_id) + ->where('user_id', $user->id) + ->whereNull('left_at') + ->exists(); + } + + public function update(User $user, Message $message): bool + { + return $message->sender_id === $user->id && $message->deleted_at === null; + } + + public function delete(User $user, Message $message): bool + { + return $message->sender_id === $user->id || $user->isAdmin(); + } +} \ No newline at end of file diff --git a/app/Services/Messaging/ConversationStateService.php b/app/Services/Messaging/ConversationStateService.php new file mode 100644 index 00000000..2619aedc --- /dev/null +++ b/app/Services/Messaging/ConversationStateService.php @@ -0,0 +1,98 @@ +id : $conversation; + + return ConversationParticipant::query() + ->where('conversation_id', $conversationId) + ->whereNull('left_at') + ->pluck('user_id') + ->map(fn ($id) => (int) $id) + ->all(); + } + + public function touchConversationCachesForUsers(array $userIds): void + { + foreach (array_unique($userIds) as $userId) { + if (! $userId) { + continue; + } + + $versionKey = "messages:conversations:version:{$userId}"; + Cache::add($versionKey, 1, now()->addDay()); + Cache::increment($versionKey); + } + } + + public function markConversationRead(Conversation $conversation, User $user, ?int $messageId = null): ConversationParticipant + { + /** @var ConversationParticipant $participant */ + $participant = ConversationParticipant::query() + ->where('conversation_id', $conversation->id) + ->where('user_id', $user->id) + ->whereNull('left_at') + ->firstOrFail(); + + $lastReadableMessage = Message::query() + ->where('conversation_id', $conversation->id) + ->whereNull('deleted_at') + ->where('sender_id', '!=', $user->id) + ->when($messageId, fn ($query) => $query->where('id', '<=', $messageId)) + ->orderByDesc('id') + ->first(); + + $readAt = now(); + + $participant->update([ + 'last_read_at' => $readAt, + 'last_read_message_id' => $lastReadableMessage?->id, + ]); + + if ($lastReadableMessage) { + $messageReads = Message::query() + ->select(['id']) + ->where('conversation_id', $conversation->id) + ->whereNull('deleted_at') + ->where('sender_id', '!=', $user->id) + ->where('id', '<=', $lastReadableMessage->id) + ->get() + ->map(fn (Message $message) => [ + 'message_id' => $message->id, + 'user_id' => $user->id, + 'read_at' => $readAt, + ]) + ->all(); + + if (! empty($messageReads)) { + DB::table('message_reads')->upsert($messageReads, ['message_id', 'user_id'], ['read_at']); + } + } + + $participantIds = $this->activeParticipantIds($conversation); + $this->touchConversationCachesForUsers($participantIds); + + DB::afterCommit(function () use ($conversation, $participant, $user): void { + event(new MessageRead($conversation, $participant, $user)); + + foreach ($this->activeParticipantIds($conversation) as $participantId) { + event(new ConversationUpdated($participantId, $conversation, 'message.read')); + } + }); + + return $participant->fresh(['user']); + } +} \ No newline at end of file diff --git a/app/Services/Messaging/MessagingPayloadFactory.php b/app/Services/Messaging/MessagingPayloadFactory.php new file mode 100644 index 00000000..119015b2 --- /dev/null +++ b/app/Services/Messaging/MessagingPayloadFactory.php @@ -0,0 +1,152 @@ +loadMissing([ + 'sender:id,username,name', + 'attachments', + 'reactions', + ]); + + return [ + 'id' => (int) $message->id, + 'uuid' => (string) $message->uuid, + 'client_temp_id' => $message->client_temp_id, + 'conversation_id' => (int) $message->conversation_id, + 'sender_id' => (int) $message->sender_id, + 'sender' => $this->userSummary($message->sender), + 'message_type' => (string) ($message->message_type ?? 'text'), + 'body' => (string) ($message->body ?? ''), + 'reply_to_message_id' => $message->reply_to_message_id ? (int) $message->reply_to_message_id : null, + 'attachments' => $message->attachments->map(fn (MessageAttachment $attachment) => $this->attachment($attachment))->values()->all(), + 'reaction_summary' => $this->reactionSummary($message, $viewerId), + 'edited_at' => optional($message->edited_at)?->toIso8601String(), + 'deleted_at' => optional($message->deleted_at)?->toIso8601String(), + 'created_at' => optional($message->created_at)?->toIso8601String(), + 'updated_at' => optional($message->updated_at)?->toIso8601String(), + ]; + } + + public function conversationSummary(Conversation $conversation, int $viewerId): array + { + $conversation->loadMissing([ + 'allParticipants.user:id,username,name', + 'latestMessage.sender:id,username,name', + 'latestMessage.attachments', + 'latestMessage.reactions', + ]); + + /** @var ConversationParticipant|null $myParticipant */ + $myParticipant = $conversation->allParticipants->firstWhere('user_id', $viewerId); + + return [ + 'id' => (int) $conversation->id, + 'uuid' => (string) $conversation->uuid, + 'type' => (string) $conversation->type, + 'title' => $conversation->title, + 'is_active' => (bool) ($conversation->is_active ?? true), + 'last_message_at' => optional($conversation->last_message_at)?->toIso8601String(), + 'unread_count' => $conversation->unreadCountFor($viewerId), + 'my_participant' => $myParticipant ? $this->participant($myParticipant) : null, + 'all_participants' => $conversation->allParticipants + ->whereNull('left_at') + ->map(fn (ConversationParticipant $participant) => $this->participant($participant)) + ->values() + ->all(), + 'latest_message' => $conversation->latestMessage + ? $this->message($conversation->latestMessage, $viewerId) + : null, + ]; + } + + public function presenceUser(User $user): array + { + return [ + 'id' => (int) $user->id, + 'username' => (string) $user->username, + 'display_name' => (string) ($user->name ?: $user->username), + 'avatar_thumb_url' => null, + ]; + } + + public function userSummary(?User $user): array + { + if (! $user) { + return [ + 'id' => null, + 'username' => null, + 'display_name' => null, + 'avatar_thumb_url' => null, + ]; + } + + return [ + 'id' => (int) $user->id, + 'username' => (string) $user->username, + 'display_name' => (string) ($user->name ?: $user->username), + 'avatar_thumb_url' => null, + ]; + } + + private function participant(ConversationParticipant $participant): array + { + return [ + 'id' => (int) $participant->id, + 'user_id' => (int) $participant->user_id, + 'role' => (string) $participant->role, + 'last_read_at' => optional($participant->last_read_at)?->toIso8601String(), + 'last_read_message_id' => $participant->last_read_message_id ? (int) $participant->last_read_message_id : null, + 'is_muted' => (bool) $participant->is_muted, + 'is_archived' => (bool) $participant->is_archived, + 'is_pinned' => (bool) $participant->is_pinned, + 'is_hidden' => (bool) ($participant->is_hidden ?? false), + 'pinned_at' => optional($participant->pinned_at)?->toIso8601String(), + 'joined_at' => optional($participant->joined_at)?->toIso8601String(), + 'left_at' => optional($participant->left_at)?->toIso8601String(), + 'user' => $this->userSummary($participant->user), + ]; + } + + private function attachment(MessageAttachment $attachment): array + { + return [ + 'id' => (int) $attachment->id, + 'disk' => (string) ($attachment->disk ?: config('messaging.attachments.disk', 'local')), + 'type' => (string) $attachment->type, + 'mime' => (string) $attachment->mime, + 'size_bytes' => (int) $attachment->size_bytes, + 'width' => $attachment->width ? (int) $attachment->width : null, + 'height' => $attachment->height ? (int) $attachment->height : null, + 'original_name' => (string) $attachment->original_name, + ]; + } + + private function reactionSummary(Message $message, ?int $viewerId = null): array + { + $counts = []; + $mine = []; + + foreach ($message->reactions as $reaction) { + $emoji = (string) $reaction->reaction; + $counts[$emoji] = ($counts[$emoji] ?? 0) + 1; + + if ($viewerId !== null && (int) $reaction->user_id === $viewerId) { + $mine[] = $emoji; + } + } + + $counts['me'] = array_values(array_unique($mine)); + + return $counts; + } +} \ No newline at end of file diff --git a/app/Services/Messaging/SendMessageAction.php b/app/Services/Messaging/SendMessageAction.php new file mode 100644 index 00000000..aa808647 --- /dev/null +++ b/app/Services/Messaging/SendMessageAction.php @@ -0,0 +1,126 @@ +create([ + 'conversation_id' => $conversation->id, + 'sender_id' => $sender->id, + 'client_temp_id' => $payload['client_temp_id'] ?? null, + 'message_type' => empty($files) ? 'text' : ($body === '' ? 'attachment' : 'text'), + 'body' => $body, + 'reply_to_message_id' => $payload['reply_to_message_id'] ?? null, + ]); + + foreach ($files as $file) { + if ($file instanceof UploadedFile) { + $this->storeAttachment($file, $message, $sender->id); + } + } + + $conversation->forceFill([ + 'last_message_id' => $message->id, + 'last_message_at' => $message->created_at, + ])->save(); + + return $message; + }); + + $participantIds = $this->conversationState->activeParticipantIds($conversation); + $this->conversationState->touchConversationCachesForUsers($participantIds); + + DB::afterCommit(function () use ($conversation, $message, $sender, $participantIds): void { + $this->notifications->notifyNewMessage($conversation, $message, $sender); + $this->searchIndexer->indexMessage($message); + + event(new MessageCreated($conversation, $message, $sender->id)); + + foreach ($participantIds as $participantId) { + event(new ConversationUpdated($participantId, $conversation, 'message.created')); + } + }); + + return $message->fresh(['sender:id,username,name', 'attachments', 'reactions']); + } + + private function storeAttachment(UploadedFile $file, Message $message, int $userId): void + { + $mime = (string) $file->getMimeType(); + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $finfoMime = $finfo ? (string) finfo_file($finfo, $file->getPathname()) : ''; + if ($finfo) { + finfo_close($finfo); + } + $detectedMime = $finfoMime !== '' ? $finfoMime : $mime; + + $allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []); + $allowedFile = (array) config('messaging.attachments.allowed_file_mimes', []); + + $type = in_array($detectedMime, $allowedImage, true) ? 'image' : 'file'; + $allowed = $type === 'image' ? $allowedImage : $allowedFile; + + abort_unless(in_array($detectedMime, $allowed, true), 422, 'Unsupported attachment type.'); + + $maxBytes = $type === 'image' + ? ((int) config('messaging.attachments.max_image_kb', 10240) * 1024) + : ((int) config('messaging.attachments.max_file_kb', 25600) * 1024); + + abort_if($file->getSize() > $maxBytes, 422, 'Attachment exceeds allowed size.'); + + $year = now()->format('Y'); + $month = now()->format('m'); + $ext = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin'); + $path = "messages/{$message->conversation_id}/{$year}/{$month}/" . uniqid('att_', true) . ".{$ext}"; + $diskName = (string) config('messaging.attachments.disk', 'local'); + + Storage::disk($diskName)->put($path, file_get_contents($file->getPathname())); + + $width = null; + $height = null; + if ($type === 'image') { + $dimensions = @getimagesize($file->getPathname()); + $width = isset($dimensions[0]) ? (int) $dimensions[0] : null; + $height = isset($dimensions[1]) ? (int) $dimensions[1] : null; + } + + MessageAttachment::query()->create([ + 'message_id' => $message->id, + 'disk' => $diskName, + 'user_id' => $userId, + 'type' => $type, + 'mime' => $detectedMime, + 'size_bytes' => (int) $file->getSize(), + 'width' => $width, + 'height' => $height, + 'sha256' => hash_file('sha256', $file->getPathname()), + 'original_name' => substr((string) $file->getClientOriginalName(), 0, 255), + 'storage_path' => $path, + 'created_at' => now(), + ]); + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index 35d66103..99cf26b0 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "intervention/image": "^3.11", "jenssegers/agent": "*", "laravel/framework": "^12.0", + "laravel/reverb": "^1.0", "laravel/scout": "^10.24", "laravel/socialite": "^5.24", "laravel/tinker": "^2.10.1", diff --git a/composer.lock b/composer.lock index 8d569a7a..095e5891 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "55c6c34cf31c8b3b0426511b32c3ac6a", + "content-hash": "e1ededa537b256c2936370d7e28a4bd5", "packages": [ { "name": "alexusmai/laravel-file-manager", @@ -194,6 +194,136 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "clue/redis-protocol", + "version": "v0.3.2", + "source": { + "type": "git", + "url": "https://github.com/clue/redis-protocol.git", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/redis-protocol/zipball/6f565332f5531b7722d1e9c445314b91862f6d6c", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\Redis\\Protocol\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.", + "homepage": "https://github.com/clue/redis-protocol", + "keywords": [ + "parser", + "protocol", + "redis", + "resp", + "serializer", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/redis-protocol/issues", + "source": "https://github.com/clue/redis-protocol/tree/v0.3.2" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2024-08-07T11:06:28+00:00" + }, + { + "name": "clue/redis-react", + "version": "v2.8.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-redis.git", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca", + "shasum": "" + }, + "require": { + "clue/redis-protocol": "^0.3.2", + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.0 || ^1.1", + "react/promise-timer": "^1.11", + "react/socket": "^1.16" + }, + "require-dev": { + "clue/block-react": "^1.5", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\Redis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Async Redis client implementation, built on top of ReactPHP.", + "homepage": "https://github.com/clue/reactphp-redis", + "keywords": [ + "async", + "client", + "database", + "reactphp", + "redis" + ], + "support": { + "issues": "https://github.com/clue/reactphp-redis/issues", + "source": "https://github.com/clue/reactphp-redis/tree/v2.8.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2025-01-03T16:18:33+00:00" + }, { "name": "composer/installers", "version": "v2.3.0", @@ -713,6 +843,53 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, { "name": "firebase/php-jwt", "version": "v7.0.3", @@ -2091,6 +2268,85 @@ }, "time": "2026-02-06T12:17:10+00:00" }, + { + "name": "laravel/reverb", + "version": "v1.8.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/reverb.git", + "reference": "70e3d28ed31466da34de0c055f3681b75a5a538c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/reverb/zipball/70e3d28ed31466da34de0c055f3681b75a5a538c", + "reference": "70e3d28ed31466da34de0c055f3681b75a5a538c", + "shasum": "" + }, + "require": { + "clue/redis-react": "^2.6", + "guzzlehttp/psr7": "^2.6", + "illuminate/console": "^10.47|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.47|^11.0|^12.0|^13.0", + "illuminate/http": "^10.47|^11.0|^12.0|^13.0", + "illuminate/support": "^10.47|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1.15|^0.2.0|^0.3.0", + "php": "^8.2", + "pusher/pusher-php-server": "^7.2", + "ratchet/rfc6455": "^0.4", + "react/promise-timer": "^1.10", + "react/socket": "^1.14", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-foundation": "^6.3|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "pestphp/pest": "^2.0|^3.0|^4.0", + "phpstan/phpstan": "^1.10", + "ratchet/pawl": "^0.4.1", + "react/async": "^4.2", + "react/http": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Reverb\\ApplicationManagerServiceProvider", + "Laravel\\Reverb\\ReverbServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Reverb\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Joe Dixon", + "email": "joe@laravel.com" + } + ], + "description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.", + "keywords": [ + "WebSockets", + "laravel", + "real-time", + "websocket" + ], + "support": { + "issues": "https://github.com/laravel/reverb/issues", + "source": "https://github.com/laravel/reverb/tree/v1.8.1" + }, + "time": "2026-03-14T16:59:35+00:00" + }, { "name": "laravel/scout", "version": "v10.24.0", @@ -3777,6 +4033,102 @@ }, "time": "2020-10-15T08:29:30+00:00" }, + { + "name": "paragonie/sodium_compat", + "version": "v2.5.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/sodium_compat.git", + "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/4714da6efdc782c06690bc72ce34fae7941c2d9f", + "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f", + "shasum": "" + }, + "require": { + "php": "^8.1", + "php-64bit": "*" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^7|^8|^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "suggest": { + "ext-sodium": "Better performance, password hashing (Argon2i), secure memory management (memzero), and better security." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "ParagonIE\\Sodium\\": "namespaced/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com" + }, + { + "name": "Frank Denis", + "email": "jedisct1@pureftpd.org" + } + ], + "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists", + "keywords": [ + "Authentication", + "BLAKE2b", + "ChaCha20", + "ChaCha20-Poly1305", + "Chapoly", + "Curve25519", + "Ed25519", + "EdDSA", + "Edwards-curve Digital Signature Algorithm", + "Elliptic Curve Diffie-Hellman", + "Poly1305", + "Pure-PHP cryptography", + "RFC 7748", + "RFC 8032", + "Salpoly", + "Salsa20", + "X25519", + "XChaCha20-Poly1305", + "XSalsa20-Poly1305", + "Xchacha20", + "Xsalsa20", + "aead", + "cryptography", + "ecdh", + "elliptic curve", + "elliptic curve cryptography", + "encryption", + "libsodium", + "php", + "public-key cryptography", + "secret-key cryptography", + "side-channel resistant" + ], + "support": { + "issues": "https://github.com/paragonie/sodium_compat/issues", + "source": "https://github.com/paragonie/sodium_compat/tree/v2.5.0" + }, + "time": "2025-12-30T16:12:18+00:00" + }, { "name": "php-http/discovery", "version": "1.20.0", @@ -4595,6 +4947,67 @@ }, "time": "2026-03-06T21:21:28+00:00" }, + { + "name": "pusher/pusher-php-server", + "version": "7.2.7", + "source": { + "type": "git", + "url": "https://github.com/pusher/pusher-http-php.git", + "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/148b0b5100d000ed57195acdf548a2b1b38ee3f7", + "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.2", + "paragonie/sodium_compat": "^1.6|^2.0", + "php": "^7.3|^8.0", + "psr/log": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "overtrue/phplint": "^2.3", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Pusher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Library for interacting with the Pusher REST API", + "keywords": [ + "events", + "messaging", + "php-pusher-server", + "publish", + "push", + "pusher", + "real time", + "real-time", + "realtime", + "rest", + "trigger" + ], + "support": { + "issues": "https://github.com/pusher/pusher-http-php/issues", + "source": "https://github.com/pusher/pusher-http-php/tree/7.2.7" + }, + "time": "2025-01-06T10:56:20+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -4793,6 +5206,595 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "ratchet/rfc6455", + "version": "v0.4.0", + "source": { + "type": "git", + "url": "https://github.com/ratchetphp/RFC6455.git", + "reference": "859d95f85dda0912c6d5b936d036d044e3af47ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/859d95f85dda0912c6d5b936d036d044e3af47ef", + "reference": "859d95f85dda0912c6d5b936d036d044e3af47ef", + "shasum": "" + }, + "require": { + "php": ">=7.4", + "psr/http-factory-implementation": "^1.0", + "symfony/polyfill-php80": "^1.15" + }, + "require-dev": { + "guzzlehttp/psr7": "^2.7", + "phpunit/phpunit": "^9.5", + "react/socket": "^1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\RFC6455\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], + "description": "RFC6455 WebSocket protocol handler", + "homepage": "http://socketo.me", + "keywords": [ + "WebSockets", + "rfc6455", + "websocket" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/ratchetphp/RFC6455/issues", + "source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.0" + }, + "time": "2025-02-24T01:18:22+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/promise-timer", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise-timer.git", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7.0 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\Timer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.", + "homepage": "https://github.com/reactphp/promise-timer", + "keywords": [ + "async", + "event-loop", + "promise", + "reactphp", + "timeout", + "timer" + ], + "support": { + "issues": "https://github.com/reactphp/promise-timer/issues", + "source": "https://github.com/reactphp/promise-timer/tree/v1.11.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-04T14:27:45+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, { "name": "socialiteproviders/discord", "version": "4.2.0", @@ -11680,5 +12682,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/messaging.php b/config/messaging.php index 4f7e262f..54f57627 100644 --- a/config/messaging.php +++ b/config/messaging.php @@ -3,6 +3,10 @@ return [ 'realtime' => (bool) env('MESSAGING_REALTIME', false), + 'broadcast' => [ + 'queue' => env('MESSAGING_BROADCAST_QUEUE', 'broadcasts'), + ], + 'typing' => [ 'ttl_seconds' => (int) env('MESSAGING_TYPING_TTL', 8), 'cache_store' => env('MESSAGING_TYPING_CACHE_STORE', 'redis'), diff --git a/config/reverb.php b/config/reverb.php new file mode 100644 index 00000000..b0e7ec11 --- /dev/null +++ b/config/reverb.php @@ -0,0 +1,96 @@ + env('REVERB_SERVER', 'reverb'), + + /* + |-------------------------------------------------------------------------- + | Reverb Servers + |-------------------------------------------------------------------------- + | + | Here you may define details for each of the supported Reverb servers. + | Each server has its own configuration options that are defined in + | the array below. You should ensure all the options are present. + | + */ + + 'servers' => [ + + 'reverb' => [ + 'host' => env('REVERB_SERVER_HOST', '0.0.0.0'), + 'port' => env('REVERB_SERVER_PORT', 8080), + 'path' => env('REVERB_SERVER_PATH', ''), + 'hostname' => env('REVERB_HOST'), + 'options' => [ + 'tls' => [], + ], + 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), + 'scaling' => [ + 'enabled' => env('REVERB_SCALING_ENABLED', false), + 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), + 'server' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', '6379'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'database' => env('REDIS_DB', '0'), + 'timeout' => env('REDIS_TIMEOUT', 60), + ], + ], + 'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15), + 'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Reverb Applications + |-------------------------------------------------------------------------- + | + | Here you may define how Reverb applications are managed. If you choose + | to use the "config" provider, you may define an array of apps which + | your server will support, including their connection credentials. + | + */ + + 'apps' => [ + + 'provider' => 'config', + + 'apps' => [ + [ + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'allowed_origins' => ['*'], + 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), + 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), + 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), + 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + 'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'), + ], + ], + + ], + +]; diff --git a/database/migrations/2026_03_21_000100_add_realtime_fields_to_messaging_tables.php b/database/migrations/2026_03_21_000100_add_realtime_fields_to_messaging_tables.php new file mode 100644 index 00000000..6b21b830 --- /dev/null +++ b/database/migrations/2026_03_21_000100_add_realtime_fields_to_messaging_tables.php @@ -0,0 +1,192 @@ +uuid('uuid')->nullable()->after('id')->unique(); + } + + if (! Schema::hasColumn('conversations', 'last_message_id')) { + $table->unsignedBigInteger('last_message_id')->nullable()->after('created_by')->index(); + } + + if (! Schema::hasColumn('conversations', 'is_active')) { + $table->boolean('is_active')->default(true)->after('last_message_at')->index(); + } + }); + + Schema::table('conversation_participants', function (Blueprint $table): void { + if (! Schema::hasColumn('conversation_participants', 'last_read_message_id')) { + $table->unsignedBigInteger('last_read_message_id')->nullable()->after('last_read_at')->index(); + } + + if (! Schema::hasColumn('conversation_participants', 'is_hidden')) { + $table->boolean('is_hidden')->default(false)->after('is_archived'); + } + + $table->index(['user_id', 'last_read_at'], 'conversation_participants_user_last_read_idx'); + }); + + Schema::table('messages', function (Blueprint $table): void { + if (! Schema::hasColumn('messages', 'uuid')) { + $table->uuid('uuid')->nullable()->after('id')->unique(); + } + + if (! Schema::hasColumn('messages', 'client_temp_id')) { + $table->string('client_temp_id', 120)->nullable()->after('uuid'); + $table->index(['conversation_id', 'client_temp_id'], 'messages_conversation_client_temp_idx'); + } + + if (! Schema::hasColumn('messages', 'message_type')) { + $table->string('message_type', 32)->default('text')->after('sender_id'); + } + + if (! Schema::hasColumn('messages', 'meta_json')) { + $table->json('meta_json')->nullable()->after('body'); + } + + if (! Schema::hasColumn('messages', 'reply_to_message_id')) { + $table->unsignedBigInteger('reply_to_message_id')->nullable()->after('meta_json')->index(); + } + }); + + Schema::table('message_attachments', function (Blueprint $table): void { + if (! Schema::hasColumn('message_attachments', 'disk')) { + $table->string('disk', 64)->default('local')->after('message_id'); + } + }); + + if (! Schema::hasTable('message_reads')) { + Schema::create('message_reads', function (Blueprint $table): void { + $table->id(); + $table->foreignId('message_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamp('read_at'); + + $table->unique(['message_id', 'user_id']); + $table->index('user_id'); + }); + } + + DB::table('conversations') + ->select('id') + ->whereNull('uuid') + ->orderBy('id') + ->chunkById(200, function ($rows): void { + foreach ($rows as $row) { + DB::table('conversations') + ->where('id', $row->id) + ->update(['uuid' => (string) Str::uuid()]); + } + }); + + DB::table('messages') + ->select('id') + ->whereNull('uuid') + ->orderBy('id') + ->chunkById(200, function ($rows): void { + foreach ($rows as $row) { + DB::table('messages') + ->where('id', $row->id) + ->update(['uuid' => (string) Str::uuid()]); + } + }); + + DB::table('conversations') + ->select('id') + ->orderBy('id') + ->chunkById(200, function ($rows): void { + foreach ($rows as $row) { + $lastMessageId = DB::table('messages') + ->where('conversation_id', $row->id) + ->whereNull('deleted_at') + ->orderByDesc('created_at') + ->orderByDesc('id') + ->value('id'); + + DB::table('conversations') + ->where('id', $row->id) + ->update([ + 'last_message_id' => $lastMessageId, + 'is_active' => true, + ]); + } + }); + } + + public function down(): void + { + if (Schema::hasTable('message_reads')) { + Schema::drop('message_reads'); + } + + Schema::table('message_attachments', function (Blueprint $table): void { + if (Schema::hasColumn('message_attachments', 'disk')) { + $table->dropColumn('disk'); + } + }); + + Schema::table('messages', function (Blueprint $table): void { + if (Schema::hasColumn('messages', 'reply_to_message_id')) { + $table->dropIndex('messages_reply_to_message_id_index'); + $table->dropColumn('reply_to_message_id'); + } + + if (Schema::hasColumn('messages', 'meta_json')) { + $table->dropColumn('meta_json'); + } + + if (Schema::hasColumn('messages', 'message_type')) { + $table->dropColumn('message_type'); + } + + if (Schema::hasColumn('messages', 'client_temp_id')) { + $table->dropIndex('messages_conversation_client_temp_idx'); + $table->dropColumn('client_temp_id'); + } + + if (Schema::hasColumn('messages', 'uuid')) { + $table->dropUnique(['uuid']); + $table->dropColumn('uuid'); + } + }); + + Schema::table('conversation_participants', function (Blueprint $table): void { + if (Schema::hasColumn('conversation_participants', 'is_hidden')) { + $table->dropColumn('is_hidden'); + } + + if (Schema::hasColumn('conversation_participants', 'last_read_message_id')) { + $table->dropIndex('conversation_participants_last_read_message_id_index'); + $table->dropColumn('last_read_message_id'); + } + + $table->dropIndex('conversation_participants_user_last_read_idx'); + }); + + Schema::table('conversations', function (Blueprint $table): void { + if (Schema::hasColumn('conversations', 'is_active')) { + $table->dropColumn('is_active'); + } + + if (Schema::hasColumn('conversations', 'last_message_id')) { + $table->dropIndex('conversations_last_message_id_index'); + $table->dropColumn('last_message_id'); + } + + if (Schema::hasColumn('conversations', 'uuid')) { + $table->dropUnique(['uuid']); + $table->dropColumn('uuid'); + } + }); + } +}; \ No newline at end of file diff --git a/docs/realtime-messaging.md b/docs/realtime-messaging.md new file mode 100644 index 00000000..82463614 --- /dev/null +++ b/docs/realtime-messaging.md @@ -0,0 +1,26 @@ +# Realtime Messaging + +Skinbase Nova messaging now uses Laravel Reverb, Laravel Broadcasting, Laravel Echo, and Redis-backed queues. + +## Local setup + +1. Set the Reverb and Redis values in `.env`. +2. Run `php artisan migrate`. +3. Run `npm install` if dependencies are not installed. +4. Start the websocket server with `php artisan reverb:start --host=0.0.0.0 --port=8080`. +5. Start queue workers with `php artisan queue:work redis --queue=broadcasts,default,notifications --tries=1`. +6. Start the frontend with `npm run dev` or build assets with `npm run build`. + +## Production notes + +- Use `BROADCAST_CONNECTION=reverb` and `QUEUE_CONNECTION=redis`. +- Keep `MESSAGING_REALTIME=true` only when Reverb is configured and reachable from the browser. +- Terminate TLS in Nginx and proxy websocket traffic to the Reverb process. +- Run both `php artisan reverb:start` and `php artisan queue:work redis --queue=broadcasts,default,notifications --tries=1` under Supervisor or systemd. +- The chat UI falls back to HTTP polling only when realtime is disabled in config. + +## Reconnect model + +- The conversation view loads once via HTTP. +- Live message, read, and typing updates arrive over websocket channels. +- When the socket reconnects, the client requests message deltas with `after_id` to merge missed messages idempotently. diff --git a/package-lock.json b/package-lock.json index df5b0cbf..e49bbf56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,9 @@ "emoji-mart": "^5.6.0", "framer-motion": "^12.34.0", "highlight.js": "^11.11.1", + "laravel-echo": "^2.3.1", "lowlight": "^3.3.0", + "pusher-js": "^8.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", @@ -4125,6 +4127,19 @@ } } }, + "node_modules/laravel-echo": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.1.tgz", + "integrity": "sha512-o6oD1oR+XklU9TO7OPGeLh/G9SjcZm+YrpSdGkOaAJf5HpXwZKt+wGgnULvKl5I8xUuUY/AAvqR24+y8suwZTA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "pusher-js": "*", + "socket.io-client": "*" + } + }, "node_modules/laravel-vite-plugin": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz", @@ -5912,6 +5927,15 @@ "node": ">=6" } }, + "node_modules/pusher-js": { + "version": "8.4.3", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.3.tgz", + "integrity": "sha512-MYnVYhKxq2Oeg3HmTQxnKDj1oAZjqJCkEcYj8hYbH1Rw5pT0g8KtgOYVUKDRnyrPtwRvA9QR4wunwJW5xIbq0Q==", + "license": "MIT", + "dependencies": { + "tweetnacl": "^1.0.3" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -6807,6 +6831,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", diff --git a/package.json b/package.json index 3f159207..dfed6ac7 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,9 @@ "emoji-mart": "^5.6.0", "framer-motion": "^12.34.0", "highlight.js": "^11.11.1", + "laravel-echo": "^2.3.1", "lowlight": "^3.3.0", + "pusher-js": "^8.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", diff --git a/resources/css/app.css b/resources/css/app.css index fe71afc4..463ecf15 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -85,6 +85,92 @@ .nova-scrollbar::-webkit-scrollbar-corner { background: transparent; } + + .nova-scrollbar-message { + scrollbar-width: thin; + scrollbar-color: rgba(56, 189, 248, 0.55) rgba(255,255,255,0.03); + } + + .nova-scrollbar-message::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + .nova-scrollbar-message::-webkit-scrollbar-track { + border-radius: 999px; + background: + linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)), + rgba(7, 11, 18, 0.72); + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03); + } + + .nova-scrollbar-message::-webkit-scrollbar-thumb { + border: 2px solid rgba(7, 11, 18, 0.72); + border-radius: 999px; + background: + linear-gradient(180deg, rgba(125, 211, 252, 0.9), rgba(14, 165, 233, 0.78) 55%, rgba(217, 70, 239, 0.68)); + box-shadow: + 0 0 0 1px rgba(125, 211, 252, 0.18), + 0 6px 18px rgba(14, 165, 233, 0.22); + } + + .nova-scrollbar-message::-webkit-scrollbar-thumb:hover { + background: + linear-gradient(180deg, rgba(186, 230, 253, 0.98), rgba(56, 189, 248, 0.9) 50%, rgba(232, 121, 249, 0.78)); + box-shadow: + 0 0 0 1px rgba(186, 230, 253, 0.24), + 0 10px 24px rgba(56, 189, 248, 0.28); + } + + .nova-scrollbar-message::-webkit-scrollbar-corner { + background: transparent; + } + + .messages-page, + .messages-page * { + scrollbar-width: thin; + scrollbar-color: rgba(56, 189, 248, 0.55) rgba(255,255,255,0.03); + } + + .messages-page::-webkit-scrollbar, + .messages-page *::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + .messages-page::-webkit-scrollbar-track, + .messages-page *::-webkit-scrollbar-track { + border-radius: 999px; + background: + linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)), + rgba(7, 11, 18, 0.72); + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03); + } + + .messages-page::-webkit-scrollbar-thumb, + .messages-page *::-webkit-scrollbar-thumb { + border: 2px solid rgba(7, 11, 18, 0.72); + border-radius: 999px; + background: + linear-gradient(180deg, rgba(125, 211, 252, 0.9), rgba(14, 165, 233, 0.78) 55%, rgba(217, 70, 239, 0.68)); + box-shadow: + 0 0 0 1px rgba(125, 211, 252, 0.18), + 0 6px 18px rgba(14, 165, 233, 0.22); + } + + .messages-page::-webkit-scrollbar-thumb:hover, + .messages-page *::-webkit-scrollbar-thumb:hover { + background: + linear-gradient(180deg, rgba(186, 230, 253, 0.98), rgba(56, 189, 248, 0.9) 50%, rgba(232, 121, 249, 0.78)); + box-shadow: + 0 0 0 1px rgba(186, 230, 253, 0.24), + 0 10px 24px rgba(56, 189, 248, 0.28); + } + + .messages-page::-webkit-scrollbar-corner, + .messages-page *::-webkit-scrollbar-corner { + background: transparent; + } } /* ─── TipTap rich text editor ─── */ diff --git a/resources/js/Pages/Messages/Index.jsx b/resources/js/Pages/Messages/Index.jsx index 98e67f16..b1475984 100644 --- a/resources/js/Pages/Messages/Index.jsx +++ b/resources/js/Pages/Messages/Index.jsx @@ -1,5 +1,6 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react' +import React, { useState, useEffect, useCallback } from 'react' import { createRoot } from 'react-dom/client' +import { getEcho } from '../../bootstrap' import ConversationList from '../../components/messaging/ConversationList' import ConversationThread from '../../components/messaging/ConversationThread' import NewConversationModal from '../../components/messaging/NewConversationModal' @@ -10,12 +11,17 @@ function getCsrf() { async function apiFetch(url, options = {}) { const isFormData = options.body instanceof FormData + const socketId = getEcho()?.socketId?.() const headers = { 'X-CSRF-TOKEN': getCsrf(), Accept: 'application/json', ...options.headers, } + if (socketId) { + headers['X-Socket-ID'] = socketId + } + if (!isFormData) { headers['Content-Type'] = 'application/json' } @@ -58,11 +64,12 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { const [loadingConvs, setLoadingConvs] = useState(true) const [activeId, setActiveId] = useState(initialId ?? null) const [realtimeEnabled, setRealtimeEnabled] = useState(false) + const [realtimeStatus, setRealtimeStatus] = useState('offline') + const [typingByConversation, setTypingByConversation] = useState({}) const [showNewModal, setShowNewModal] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState([]) const [searching, setSearching] = useState(false) - const pollRef = useRef(null) const loadConversations = useCallback(async () => { try { @@ -81,28 +88,215 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { apiFetch('/api/messages/settings') .then((data) => setRealtimeEnabled(!!data?.realtime_enabled)) .catch(() => setRealtimeEnabled(false)) - - return () => { - if (pollRef.current) clearInterval(pollRef.current) - } }, [loadConversations]) useEffect(() => { - if (pollRef.current) { - clearInterval(pollRef.current) - pollRef.current = null + const handlePopState = () => { + const match = window.location.pathname.match(/^\/messages\/(\d+)$/) + setActiveId(match ? Number(match[1]) : null) } + window.addEventListener('popstate', handlePopState) + + return () => window.removeEventListener('popstate', handlePopState) + }, []) + + useEffect(() => { if (realtimeEnabled) { return undefined } - pollRef.current = setInterval(loadConversations, 15000) + const poll = window.setInterval(loadConversations, 15000) + + return () => window.clearInterval(poll) + }, [loadConversations, realtimeEnabled]) + + useEffect(() => { + if (!realtimeEnabled || !userId) { + setRealtimeStatus('offline') + return undefined + } + + const echo = getEcho() + if (!echo) { + setRealtimeStatus('offline') + return undefined + } + + const connection = echo.connector?.pusher?.connection + let heartbeatId = null + const mapConnectionState = (state) => { + if (state === 'connected') { + return 'connected' + } + + if (state === 'connecting' || state === 'initialized' || state === 'connecting_in') { + return 'connecting' + } + + return 'offline' + } + + const syncConnectionState = (payload = null) => { + const nextState = typeof payload?.current === 'string' + ? payload.current + : connection?.state + + if (echo.socketId?.()) { + setRealtimeStatus('connected') + return + } + + setRealtimeStatus(mapConnectionState(nextState)) + } + + const handleVisibilitySync = () => { + if (document.visibilityState === 'visible') { + syncConnectionState() + } + } + + syncConnectionState() + connection?.bind?.('state_change', syncConnectionState) + connection?.bind?.('connected', syncConnectionState) + connection?.bind?.('unavailable', syncConnectionState) + connection?.bind?.('disconnected', syncConnectionState) + heartbeatId = window.setInterval(syncConnectionState, 1000) + window.addEventListener('focus', syncConnectionState) + document.addEventListener('visibilitychange', handleVisibilitySync) + + const channel = echo.private(`user.${userId}`) + const handleConversationUpdated = (payload) => { + const nextConversation = payload?.conversation + if (!nextConversation?.id) { + return + } + + setConversations((prev) => mergeConversationSummary(prev, nextConversation)) + } + + channel.listen('.conversation.updated', handleConversationUpdated) return () => { - if (pollRef.current) clearInterval(pollRef.current) + connection?.unbind?.('state_change', syncConnectionState) + connection?.unbind?.('connected', syncConnectionState) + connection?.unbind?.('unavailable', syncConnectionState) + connection?.unbind?.('disconnected', syncConnectionState) + if (heartbeatId) { + window.clearInterval(heartbeatId) + } + window.removeEventListener('focus', syncConnectionState) + document.removeEventListener('visibilitychange', handleVisibilitySync) + channel.stopListening('.conversation.updated', handleConversationUpdated) + echo.leaveChannel(`private-user.${userId}`) } - }, [loadConversations, realtimeEnabled]) + }, [realtimeEnabled, userId]) + + useEffect(() => { + if (!realtimeEnabled) { + setTypingByConversation({}) + return undefined + } + + const echo = getEcho() + if (!echo || conversations.length === 0) { + return undefined + } + + const timers = new Map() + const joinedChannels = [] + + const removeTypingUser = (conversationId, userIdToRemove) => { + const timerKey = `${conversationId}:${userIdToRemove}` + const existingTimer = timers.get(timerKey) + if (existingTimer) { + window.clearTimeout(existingTimer) + timers.delete(timerKey) + } + + setTypingByConversation((prev) => { + const current = prev[conversationId] ?? [] + const nextUsers = current.filter((user) => String(user.user_id ?? user.id) !== String(userIdToRemove)) + + if (nextUsers.length === current.length) { + return prev + } + + if (nextUsers.length === 0) { + const next = { ...prev } + delete next[conversationId] + return next + } + + return { + ...prev, + [conversationId]: nextUsers, + } + }) + } + + conversations.forEach((conversation) => { + if (!conversation?.id) { + return + } + + const conversationId = conversation.id + const channel = echo.join(`conversation.${conversationId}`) + joinedChannels.push(conversationId) + + channel + .listen('.typing.started', (payload) => { + const user = payload?.user + if (!user?.id || user.id === userId) { + return + } + + setTypingByConversation((prev) => { + const current = prev[conversationId] ?? [] + const index = current.findIndex((entry) => String(entry.user_id ?? entry.id) === String(user.id)) + const nextUser = { user_id: user.id, username: user.username } + + if (index === -1) { + return { + ...prev, + [conversationId]: [...current, nextUser], + } + } + + const nextUsers = [...current] + nextUsers[index] = { ...nextUsers[index], ...nextUser } + return { + ...prev, + [conversationId]: nextUsers, + } + }) + + const timerKey = `${conversationId}:${user.id}` + const existingTimer = timers.get(timerKey) + if (existingTimer) { + window.clearTimeout(existingTimer) + } + + const timeout = window.setTimeout(() => removeTypingUser(conversationId, user.id), Number(payload?.expires_in_ms ?? 3500)) + timers.set(timerKey, timeout) + }) + .listen('.typing.stopped', (payload) => { + const typingUserId = payload?.user?.id + if (!typingUserId) { + return + } + + removeTypingUser(conversationId, typingUserId) + }) + }) + + return () => { + timers.forEach((timer) => window.clearTimeout(timer)) + joinedChannels.forEach((conversationId) => { + echo.leave(`conversation.${conversationId}`) + }) + } + }, [conversations, realtimeEnabled, userId]) const handleSelectConversation = useCallback((id) => { setActiveId(id) @@ -124,6 +318,14 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { ))) }, []) + const handleConversationPatched = useCallback((patch) => { + if (!patch?.id) { + return + } + + setConversations((prev) => mergeConversationSummary(prev, patch)) + }, []) + useEffect(() => { let cancelled = false @@ -182,7 +384,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { || 'Conversation' return ( -
+
@@ -284,6 +487,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { conversationId={activeId} conversation={activeConversation} realtimeEnabled={realtimeEnabled} + realtimeStatus={realtimeStatus} currentUserId={userId} currentUsername={username} apiFetch={apiFetch} @@ -292,7 +496,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { history.replaceState(null, '', '/messages') }} onMarkRead={handleMarkRead} - onConversationUpdated={loadConversations} + onConversationPatched={handleConversationPatched} /> ) : (
@@ -346,6 +550,83 @@ function StatChip({ label, value, tone = 'sky' }) { ) } +function mergeConversationSummary(existing, incoming) { + const next = [...existing] + const index = next.findIndex((conversation) => conversation.id === incoming.id) + + if (index >= 0) { + next[index] = { ...next[index], ...incoming } + } else { + next.unshift(incoming) + } + + return next.sort((left, right) => { + const leftPinned = left.my_participant?.is_pinned ? 1 : 0 + const rightPinned = right.my_participant?.is_pinned ? 1 : 0 + if (leftPinned !== rightPinned) { + return rightPinned - leftPinned + } + + const leftPinnedAt = left.my_participant?.pinned_at ? new Date(left.my_participant.pinned_at).getTime() : 0 + const rightPinnedAt = right.my_participant?.pinned_at ? new Date(right.my_participant.pinned_at).getTime() : 0 + if (leftPinnedAt !== rightPinnedAt) { + return rightPinnedAt - leftPinnedAt + } + + const leftTime = left.last_message_at ? new Date(left.last_message_at).getTime() : 0 + const rightTime = right.last_message_at ? new Date(right.last_message_at).getTime() : 0 + return rightTime - leftTime + }) +} + +function connectionBadgeClass(realtimeEnabled, realtimeStatus) { + if (!realtimeEnabled) { + return 'border-white/[0.08] bg-white/[0.04] text-white/55' + } + + if (realtimeStatus === 'connected') { + return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200' + } + + if (realtimeStatus === 'connecting') { + return 'border-amber-400/20 bg-amber-500/10 text-amber-200' + } + + return 'border-rose-400/18 bg-rose-500/10 text-rose-200' +} + +function connectionDotClass(realtimeEnabled, realtimeStatus) { + if (!realtimeEnabled) { + return 'bg-white/30' + } + + if (realtimeStatus === 'connected') { + return 'bg-emerald-300' + } + + if (realtimeStatus === 'connecting') { + return 'bg-amber-300' + } + + return 'bg-rose-300' +} + +function connectionBadgeLabel(realtimeEnabled, realtimeStatus) { + if (!realtimeEnabled) { + return 'Polling every 15s' + } + + if (realtimeStatus === 'connected') { + return 'Realtime connected' + } + + if (realtimeStatus === 'connecting') { + return 'Realtime connecting' + } + + return 'Realtime disconnected' +} + const el = document.getElementById('messages-root') if (el) { diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 8c0e4c8d..f1727c75 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -1,9 +1,51 @@ -import axios from 'axios'; -window.axios = axios; +import axios from 'axios' +import Echo from 'laravel-echo' +import Pusher from 'pusher-js' -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; +window.axios = axios +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' -const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); +const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') if (csrfToken) { - window.axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken; + window.axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken +} + +window.Pusher = Pusher + +let echoInstance = null + +export function getEcho() { + if (echoInstance !== null) { + return echoInstance || null + } + + const key = import.meta.env.VITE_REVERB_APP_KEY + if (!key) { + echoInstance = false + return null + } + + const scheme = import.meta.env.VITE_REVERB_SCHEME || window.location.protocol.replace(':', '') || 'https' + const forceTLS = scheme === 'https' + + echoInstance = new Echo({ + broadcaster: 'reverb', + key, + wsHost: import.meta.env.VITE_REVERB_HOST || window.location.hostname, + wsPort: Number(import.meta.env.VITE_REVERB_PORT || (forceTLS ? 443 : 80)), + wssPort: Number(import.meta.env.VITE_REVERB_PORT || 443), + forceTLS, + enabledTransports: ['ws', 'wss'], + authEndpoint: '/broadcasting/auth', + auth: { + headers: { + 'X-CSRF-TOKEN': csrfToken || '', + Accept: 'application/json', + }, + }, + }) + + window.Echo = echoInstance + + return echoInstance } diff --git a/resources/js/components/messaging/ConversationList.jsx b/resources/js/components/messaging/ConversationList.jsx index 1c699942..b4f6d2c8 100644 --- a/resources/js/components/messaging/ConversationList.jsx +++ b/resources/js/components/messaging/ConversationList.jsx @@ -1,6 +1,6 @@ import React from 'react' -export default function ConversationList({ conversations, loading, activeId, currentUserId, onSelect }) { +export default function ConversationList({ conversations, loading, activeId, currentUserId, typingByConversation = {}, onSelect }) { return (
@@ -13,7 +13,7 @@ export default function ConversationList({ conversations, loading, activeId, cur
-
    +
      {loading ? (
    • Loading conversations…
    • ) : null} @@ -28,6 +28,7 @@ export default function ConversationList({ conversations, loading, activeId, cur conv={conversation} isActive={conversation.id === activeId} currentUserId={currentUserId} + typingUsers={typingByConversation[conversation.id] ?? []} onClick={() => onSelect(conversation.id)} /> ))} @@ -36,10 +37,12 @@ export default function ConversationList({ conversations, loading, activeId, cur ) } -function ConversationRow({ conv, isActive, currentUserId, onClick }) { +function ConversationRow({ conv, isActive, currentUserId, typingUsers, onClick }) { const label = convLabel(conv, currentUserId) const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message - const preview = lastMsg?.body ? truncate(lastMsg.body, 88) : 'No messages yet' + const preview = typingUsers.length > 0 + ? buildTypingPreview(typingUsers) + : lastMsg?.body ? truncate(lastMsg.body, 88) : 'No messages yet' const unread = conv.unread_count ?? 0 const myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId) const isArchived = myParticipant?.is_archived ?? false @@ -89,8 +92,11 @@ function ConversationRow({ conv, isActive, currentUserId, onClick }) {
- {senderLabel ?

{senderLabel}

: null} -

{preview}

+ {typingUsers.length === 0 && senderLabel ?

{senderLabel}

: null} +

0 ? 'text-emerald-200' : 'text-white/62'}`}> + {typingUsers.length > 0 ? : null} + {preview} +

@@ -110,6 +116,23 @@ function truncate(str, max) { return str.length > max ? `${str.slice(0, max)}…` : str } +function buildTypingPreview(users) { + const names = users.map((user) => `@${user.username}`) + if (names.length === 1) return `${names[0]} is typing...` + if (names.length === 2) return `${names[0]} and ${names[1]} are typing...` + return `${names[0]}, ${names[1]} and ${names.length - 2} others are typing...` +} + +function SidebarTypingIcon() { + return ( + + + + + + ) +} + function relativeTime(iso) { if (!iso) return 'No activity' const diff = (Date.now() - new Date(iso).getTime()) / 1000 diff --git a/resources/js/components/messaging/ConversationThread.jsx b/resources/js/components/messaging/ConversationThread.jsx index e83b8443..58c0843d 100644 --- a/resources/js/components/messaging/ConversationThread.jsx +++ b/resources/js/components/messaging/ConversationThread.jsx @@ -1,16 +1,18 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { getEcho } from '../../bootstrap' import MessageBubble from './MessageBubble' export default function ConversationThread({ conversationId, conversation, realtimeEnabled, + realtimeStatus, currentUserId, currentUsername, apiFetch, onBack, onMarkRead, - onConversationUpdated, + onConversationPatched, }) { const [messages, setMessages] = useState([]) const [loading, setLoading] = useState(true) @@ -21,6 +23,8 @@ export default function ConversationThread({ const [sending, setSending] = useState(false) const [error, setError] = useState(null) const [typingUsers, setTypingUsers] = useState([]) + const [participantState, setParticipantState] = useState(conversation?.all_participants ?? []) + const [presenceUsers, setPresenceUsers] = useState([]) const [threadSearch, setThreadSearch] = useState('') const [busyAction, setBusyAction] = useState(null) const [lightbox, setLightbox] = useState(null) @@ -29,8 +33,15 @@ export default function ConversationThread({ const fileInputRef = useRef(null) const typingRef = useRef(null) const stopTypingRef = useRef(null) + const readReceiptRef = useRef(null) + const typingExpiryTimersRef = useRef(new Map()) + const messagesRef = useRef([]) const lastStartRef = useRef(0) const initialLoadRef = useRef(true) + const shouldStickToBottomRef = useRef(true) + const previousScrollHeightRef = useRef(0) + const pendingPrependRef = useRef(false) + const pendingComposerScrollRef = useRef(false) const knownMessageIdsRef = useRef(new Set()) const animatedMessageIdsRef = useRef(new Set()) const [animatedMessageIds, setAnimatedMessageIds] = useState({}) @@ -38,16 +49,22 @@ export default function ConversationThread({ const myParticipant = useMemo(() => ( conversation?.my_participant - ?? conversation?.all_participants?.find((participant) => participant.user_id === currentUserId) + ?? participantState.find((participant) => participant.user_id === currentUserId) ?? null - ), [conversation, currentUserId]) + ), [conversation, currentUserId, participantState]) - const participants = useMemo(() => conversation?.all_participants ?? [], [conversation]) + const participants = useMemo(() => participantState, [participantState]) const participantNames = useMemo(() => ( participants .map((participant) => participant.user?.username) .filter(Boolean) ), [participants]) + const remoteParticipantNames = useMemo(() => ( + participants + .filter((participant) => participant.user_id !== currentUserId) + .map((participant) => participant.user?.username) + .filter(Boolean) + ), [currentUserId, participants]) const filteredMessages = useMemo(() => { const query = threadSearch.trim().toLowerCase() @@ -66,10 +83,66 @@ export default function ConversationThread({ return participants.find((participant) => participant.user_id !== currentUserId)?.user?.username ?? 'Direct message' }, [conversation, currentUserId, participants]) + const patchConversation = useCallback((patch) => { + if (!patch) { + return + } + + onConversationPatched?.({ + id: conversationId, + ...patch, + }) + }, [conversationId, onConversationPatched]) + + const patchLastMessage = useCallback((message, extra = {}) => { + if (!message) { + return + } + + patchConversation({ + latest_message: message, + last_message_at: message.created_at ?? new Date().toISOString(), + ...extra, + }) + }, [patchConversation]) + + const patchMyParticipantState = useCallback((changes) => { + if (!changes) { + return + } + + setParticipantState((prev) => prev.map((participant) => ( + participant.user_id === currentUserId + ? { ...participant, ...changes } + : participant + ))) + + patchConversation({ + my_participant: { + ...(myParticipant ?? {}), + ...changes, + }, + }) + }, [currentUserId, myParticipant, patchConversation]) + + const scrollToBottom = useCallback(() => { + if (!listRef.current) { + return + } + + listRef.current.scrollTop = listRef.current.scrollHeight + shouldStickToBottomRef.current = true + }, []) + const loadMessages = useCallback(async ({ cursor = null, append = false, silent = false } = {}) => { if (append) setLoadingMore(true) else if (!silent) setLoading(true) + if (append && listRef.current) { + previousScrollHeightRef.current = listRef.current.scrollHeight + pendingPrependRef.current = true + } + try { const url = cursor ? `/api/messages/${conversationId}?cursor=${encodeURIComponent(cursor)}` @@ -96,30 +169,76 @@ export default function ConversationThread({ } }, [apiFetch, conversationId]) + const markConversationRead = useCallback(async (messageId = null) => { + try { + const response = await apiFetch(`/api/messages/${conversationId}/read`, { + method: 'POST', + body: JSON.stringify(messageId ? { message_id: messageId } : {}), + }) + + setParticipantState((prev) => prev.map((participant) => ( + participant.user_id === currentUserId + ? { + ...participant, + last_read_at: response.last_read_at ?? new Date().toISOString(), + last_read_message_id: response.last_read_message_id ?? messageId ?? participant.last_read_message_id, + } + : participant + ))) + onMarkRead?.(conversationId) + } catch { + // no-op + } + }, [apiFetch, conversationId, currentUserId, onMarkRead]) + + const queueReadReceipt = useCallback((messageId = null) => { + if (readReceiptRef.current) { + window.clearTimeout(readReceiptRef.current) + } + + readReceiptRef.current = window.setTimeout(() => { + markConversationRead(messageId) + }, 220) + }, [markConversationRead]) + useEffect(() => { initialLoadRef.current = true + shouldStickToBottomRef.current = true + previousScrollHeightRef.current = 0 + pendingPrependRef.current = false + pendingComposerScrollRef.current = false + knownMessageIdsRef.current = new Set() + animatedMessageIdsRef.current = new Set() setMessages([]) + setPresenceUsers([]) + setTypingUsers([]) setNextCursor(null) setBody('') setFiles([]) - setDraftTitle(conversation?.title ?? '') loadMessages() - loadTyping() - }, [conversation?.title, conversationId, loadMessages, loadTyping]) + if (!realtimeEnabled) { + loadTyping() + } + }, [conversationId, loadMessages, loadTyping, realtimeEnabled]) useEffect(() => { - apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' }) - .then(() => onMarkRead?.(conversationId)) - .catch(() => {}) - }, [apiFetch, conversationId, onMarkRead]) + setParticipantState(conversation?.all_participants ?? []) + setDraftTitle(conversation?.title ?? '') + }, [conversation?.all_participants, conversation?.title]) useEffect(() => { + markConversationRead() + }, [markConversationRead]) + + useEffect(() => { + if (realtimeEnabled) { + return undefined + } + const timer = window.setInterval(() => { loadTyping() - if (!realtimeEnabled) { - loadMessages({ silent: true }) - } - }, realtimeEnabled ? 5000 : 8000) + loadMessages({ silent: true }) + }, 8000) return () => window.clearInterval(timer) }, [loadMessages, loadTyping, realtimeEnabled]) @@ -127,21 +246,176 @@ export default function ConversationThread({ useEffect(() => () => { if (typingRef.current) window.clearTimeout(typingRef.current) if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current) + if (readReceiptRef.current) window.clearTimeout(readReceiptRef.current) + typingExpiryTimersRef.current.forEach((timer) => window.clearTimeout(timer)) }, []) useEffect(() => { - if (!listRef.current) return - if (initialLoadRef.current) { - listRef.current.scrollTop = listRef.current.scrollHeight - initialLoadRef.current = false + messagesRef.current = messages + }, [messages]) + + useLayoutEffect(() => { + const container = listRef.current + if (!container) { return } - const nearBottom = listRef.current.scrollHeight - listRef.current.scrollTop - listRef.current.clientHeight < 180 - if (nearBottom) { - listRef.current.scrollTop = listRef.current.scrollHeight + if (initialLoadRef.current) { + scrollToBottom() + initialLoadRef.current = false + previousScrollHeightRef.current = container.scrollHeight + return } - }, [messages.length]) + + if (pendingPrependRef.current) { + const heightDelta = container.scrollHeight - previousScrollHeightRef.current + container.scrollTop += heightDelta + pendingPrependRef.current = false + previousScrollHeightRef.current = container.scrollHeight + return + } + + const latestMessage = messages[messages.length - 1] ?? null + const shouldScroll = pendingComposerScrollRef.current + || shouldStickToBottomRef.current + || latestMessage?._optimistic + || latestMessage?.sender_id === currentUserId + + if (shouldScroll) { + scrollToBottom() + } + + pendingComposerScrollRef.current = false + previousScrollHeightRef.current = container.scrollHeight + }, [currentUserId, messages, scrollToBottom]) + + useEffect(() => { + if (!realtimeEnabled) { + return undefined + } + + const echo = getEcho() + if (!echo) { + return undefined + } + + const syncMissedMessages = async () => { + const lastServerMessage = [...messagesRef.current] + .reverse() + .find((message) => Number.isFinite(Number(message.id)) && !message._optimistic) + + if (!lastServerMessage?.id) { + return + } + + try { + const data = await apiFetch(`/api/messages/${conversationId}?after_id=${encodeURIComponent(lastServerMessage.id)}`) + const incoming = normalizeMessages(data.data ?? [], currentUserId) + if (incoming.length > 0) { + setMessages((prev) => mergeMessageLists(prev, incoming)) + } + } catch { + // no-op + } + } + + const handleMessageCreated = (payload) => { + if (!payload?.message) return + const incoming = normalizeMessage(payload.message, currentUserId) + setMessages((prev) => mergeMessageLists(prev, [incoming])) + patchLastMessage(incoming, { unread_count: 0 }) + + if (incoming.sender_id !== currentUserId && document.visibilityState === 'visible') { + queueReadReceipt(incoming.id) + } + } + + const handleMessageUpdated = (payload) => { + if (!payload?.message) return + const updated = normalizeMessage(payload.message, currentUserId) + setMessages((prev) => mergeMessageLists(prev, [updated])) + patchLastMessage(updated) + } + + const handleMessageDeleted = (payload) => { + const deletedAt = payload?.deleted_at ?? new Date().toISOString() + setMessages((prev) => prev.map((message) => ( + messagesMatch(message, payload) + ? { ...message, body: '', deleted_at: deletedAt, attachments: [] } + : message + ))) + patchConversation({ last_message_at: deletedAt }) + } + + const handleMessageRead = (payload) => { + if (!payload?.user?.id) return + setParticipantState((prev) => prev.map((participant) => ( + participant.user_id === payload.user.id + ? { + ...participant, + last_read_at: payload.last_read_at ?? participant.last_read_at, + last_read_message_id: payload.last_read_message_id ?? participant.last_read_message_id, + } + : participant + ))) + } + + const removeTypingUser = (userId) => { + const existingTimer = typingExpiryTimersRef.current.get(userId) + if (existingTimer) { + window.clearTimeout(existingTimer) + typingExpiryTimersRef.current.delete(userId) + } + + setTypingUsers((prev) => prev.filter((user) => user.user_id !== userId && user.id !== userId)) + } + + const handleTypingStarted = (payload) => { + const user = payload?.user + if (!user?.id || user.id === currentUserId) return + + setTypingUsers((prev) => mergeTypingUsers(prev, { + user_id: user.id, + username: user.username, + })) + + removeTypingUser(user.id) + const timeout = window.setTimeout(() => removeTypingUser(user.id), Number(payload?.expires_in_ms ?? 3500)) + typingExpiryTimersRef.current.set(user.id, timeout) + } + + const handleTypingStopped = (payload) => { + const userId = payload?.user?.id + if (!userId) return + removeTypingUser(userId) + } + + const privateChannel = echo.private(`conversation.${conversationId}`) + privateChannel.listen('.message.created', handleMessageCreated) + privateChannel.listen('.message.updated', handleMessageUpdated) + privateChannel.listen('.message.deleted', handleMessageDeleted) + privateChannel.listen('.message.read', handleMessageRead) + + const presenceChannel = echo.join(`conversation.${conversationId}`) + presenceChannel + .here((users) => setPresenceUsers(normalizePresenceUsers(users, currentUserId))) + .joining((user) => setPresenceUsers((prev) => mergePresenceUsers(prev, user, currentUserId))) + .leaving((user) => setPresenceUsers((prev) => prev.filter((member) => member.id !== user?.id))) + .listen('.typing.started', handleTypingStarted) + .listen('.typing.stopped', handleTypingStopped) + + const connection = echo.connector?.pusher?.connection + connection?.bind?.('connected', syncMissedMessages) + + syncMissedMessages() + + return () => { + connection?.unbind?.('connected', syncMissedMessages) + typingExpiryTimersRef.current.forEach((timer) => window.clearTimeout(timer)) + typingExpiryTimersRef.current.clear() + echo.leave(`conversation.${conversationId}`) + } + }, [apiFetch, conversationId, currentUserId, patchConversation, patchLastMessage, queueReadReceipt, realtimeEnabled]) useEffect(() => { const known = knownMessageIdsRef.current @@ -183,6 +457,12 @@ export default function ConversationThread({ const handleBodyChange = useCallback((value) => { setBody(value) + if (value.trim() === '') { + if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current) + apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) + return + } + const now = Date.now() if (now - lastStartRef.current > 2500) { lastStartRef.current = now @@ -208,8 +488,10 @@ export default function ConversationThread({ if (!trimmed && files.length === 0) return const optimisticId = `optimistic-${Date.now()}` + const clientTempId = `tmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` const optimisticMessage = normalizeMessage({ id: optimisticId, + client_temp_id: clientTempId, body: trimmed, sender: { id: currentUserId, username: currentUsername }, sender_id: currentUserId, @@ -223,6 +505,8 @@ export default function ConversationThread({ _optimistic: true, }, currentUserId) + pendingComposerScrollRef.current = true + shouldStickToBottomRef.current = true setMessages((prev) => mergeMessageLists(prev, [optimisticMessage])) setBody('') setFiles([]) @@ -232,6 +516,7 @@ export default function ConversationThread({ const formData = new FormData() if (trimmed) formData.append('body', trimmed) + formData.append('client_temp_id', clientTempId) files.forEach((file) => formData.append('attachments[]', file)) try { @@ -241,10 +526,10 @@ export default function ConversationThread({ }) const normalized = normalizeMessage(created, currentUserId) - setMessages((prev) => prev.map((message) => message.id === optimisticId ? normalized : message)) - onConversationUpdated?.() + setMessages((prev) => mergeMessageLists(prev, [normalized])) + patchLastMessage(normalized, { unread_count: 0 }) } catch (err) { - setMessages((prev) => prev.filter((message) => message.id !== optimisticId)) + setMessages((prev) => prev.filter((message) => !messagesMatch(message, { id: optimisticId, client_temp_id: clientTempId }))) setBody(trimmed) setFiles(files) setError(err.message) @@ -252,7 +537,7 @@ export default function ConversationThread({ setSending(false) apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) } - }, [apiFetch, body, conversationId, currentUserId, currentUsername, files, onConversationUpdated, sending]) + }, [apiFetch, body, conversationId, currentUserId, currentUsername, files, patchLastMessage, sending]) const updateReactions = useCallback((messageId, summary) => { setMessages((prev) => prev.map((message) => { @@ -289,8 +574,8 @@ export default function ConversationThread({ setMessages((prev) => prev.map((message) => ( message.id === messageId ? normalizeMessage({ ...message, ...updated }, currentUserId) : message ))) - onConversationUpdated?.() - }, [apiFetch, currentUserId, onConversationUpdated]) + patchLastMessage(normalizeMessage(updated, currentUserId)) + }, [apiFetch, currentUserId, patchLastMessage]) const handleDelete = useCallback(async (messageId) => { await apiFetch(`/api/messages/message/${messageId}`, { method: 'DELETE' }) @@ -299,8 +584,8 @@ export default function ConversationThread({ ? { ...message, body: '', deleted_at: new Date().toISOString(), attachments: [] } : message ))) - onConversationUpdated?.() - }, [apiFetch, onConversationUpdated]) + patchConversation({ last_message_at: new Date().toISOString() }) + }, [apiFetch, patchConversation]) const runConversationAction = useCallback(async (action, url, apply) => { setBusyAction(action) @@ -308,14 +593,13 @@ export default function ConversationThread({ try { const response = await apiFetch(url, { method: action === 'leave' ? 'DELETE' : 'POST' }) apply?.(response) - onConversationUpdated?.() if (action === 'leave') onBack?.() } catch (e) { setError(e.message) } finally { setBusyAction(null) } - }, [apiFetch, onBack, onConversationUpdated]) + }, [apiFetch, onBack]) const handleRename = useCallback(async () => { const title = draftTitle.trim() @@ -329,17 +613,21 @@ export default function ConversationThread({ method: 'POST', body: JSON.stringify({ title }), }) - onConversationUpdated?.() + patchConversation({ title }) } catch (e) { setError(e.message) } finally { setBusyAction(null) } - }, [apiFetch, conversation?.title, conversationId, draftTitle, onConversationUpdated]) + }, [apiFetch, conversation?.title, conversationId, draftTitle, patchConversation]) const visibleMessages = filteredMessages const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant?.last_read_at ?? null), [visibleMessages, currentUserId, myParticipant?.last_read_at]) const typingLabel = buildTypingLabel(typingUsers) + const presenceLabel = presenceUsers.length > 0 ? `${presenceUsers.length} active now` : null + const typingSummary = typingUsers.length > 0 + ? `${typingLabel} ${conversation?.type === 'group' ? '' : 'Reply will appear here instantly.'}`.trim() + : null return (
@@ -356,6 +644,7 @@ export default function ConversationThread({

{conversationLabel}

{conversation?.type === 'group' ? Group : Direct} + {realtimeEnabled ? : null} {myParticipant?.is_pinned ? Pinned : null} {myParticipant?.is_muted ? Muted : null} {myParticipant?.is_archived ? Archived : null} @@ -366,12 +655,18 @@ export default function ConversationThread({ ? `Participants: ${participantNames.join(', ')}` : `Private thread with ${participantNames.find((name) => name !== currentUsername) ?? conversationLabel}.`}

+ {typingSummary ? ( +
+ + {typingSummary} +
+ ) : presenceLabel ?

{presenceLabel}

: conversation?.type === 'direct' && remoteParticipantNames.length > 0 ?

Chatting with @{remoteParticipantNames[0]} in realtime.

: null}
) : null} -
+
{ + if (!listRef.current) { + return + } + + shouldStickToBottomRef.current = isNearBottom(listRef.current) + }} + className="nova-scrollbar-message flex-1 overflow-y-auto px-3 py-4 pr-2 sm:px-6 sm:py-5" + > {nextCursor ? (