feat: add Reverb realtime messaging

This commit is contained in:
2026-03-21 12:51:59 +01:00
parent 60f78e8235
commit e8b5edf5d2
45 changed files with 3609 additions and 339 deletions

View File

@@ -41,9 +41,29 @@ SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local 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) # Upload UI feature flag (legacy upload remains default unless explicitly enabled)
SKINBASE_UPLOADS_V2=false SKINBASE_UPLOADS_V2=false

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Events;
use App\Models\Conversation;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ConversationUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public int $userId,
public Conversation $conversation,
public string $reason,
) {
$this->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),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Events;
use App\Models\Conversation;
use App\Models\Message;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageCreated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public Conversation $conversation,
public Message $message,
int $originUserId,
) {
$this->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),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageDeleted implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(public Message $message)
{
$this->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(),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Events;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\User;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageRead implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public Conversation $conversation,
public ConversationParticipant $participant,
public User $reader,
) {
$this->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(),
];
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageSent
{
use Dispatchable, SerializesModels;
public function __construct(
public int $conversationId,
public int $messageId,
public int $senderId,
) {}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Events;
use App\Models\Message;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(public Message $message)
{
$this->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),
];
}
}

View File

@@ -2,15 +2,46 @@
namespace App\Events; 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\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; 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 function __construct(
public int $conversationId, 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,
];
}
} }

View File

@@ -2,15 +2,45 @@
namespace App\Events; 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\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; 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 function __construct(
public int $conversationId, 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),
];
}
} }

View File

@@ -2,12 +2,17 @@
namespace App\Http\Controllers\Api\Messaging; namespace App\Http\Controllers\Api\Messaging;
use App\Events\ConversationUpdated;
use App\Http\Controllers\Controller; 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\Conversation;
use App\Models\ConversationParticipant; use App\Models\ConversationParticipant;
use App\Models\Message; use App\Models\Message;
use App\Models\User; 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\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -16,6 +21,11 @@ use Illuminate\Support\Facades\Schema;
class ConversationController extends Controller class ConversationController extends Controller
{ {
public function __construct(
private readonly ConversationStateService $conversationState,
private readonly SendMessageAction $sendMessage,
) {}
// ── GET /api/messages/conversations ───────────────────────────────────── // ── GET /api/messages/conversations ─────────────────────────────────────
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
@@ -40,10 +50,13 @@ class ConversationController extends Controller
->where('messages.sender_id', '!=', $user->id) ->where('messages.sender_id', '!=', $user->id)
->whereNull('messages.deleted_at') ->whereNull('messages.deleted_at')
->where(function ($query) { ->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'); ->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at');
}), }),
]) ])
->where('conversations.is_active', true)
->with([ ->with([
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']), 'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
'latestMessage.sender:id,username', 'latestMessage.sender:id,username',
@@ -80,18 +93,10 @@ class ConversationController extends Controller
// ── POST /api/messages/conversation ───────────────────────────────────── // ── POST /api/messages/conversation ─────────────────────────────────────
public function store(Request $request): JsonResponse public function store(StoreConversationRequest $request): JsonResponse
{ {
$user = $request->user(); $user = $request->user();
$data = $request->validated();
$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',
]);
if ($data['type'] === 'direct') { if ($data['type'] === 'direct') {
return $this->createDirect($request, $user, $data); return $this->createDirect($request, $user, $data);
@@ -104,20 +109,28 @@ class ConversationController extends Controller
public function markRead(Request $request, int $id): JsonResponse public function markRead(Request $request, int $id): JsonResponse
{ {
$participant = $this->participantRecord($request, $id); $conversation = $this->findAuthorized($request, $id);
$participant->update(['last_read_at' => now()]); $participant = $this->conversationState->markConversationRead(
$this->touchConversationCachesForUsers([$request->user()->id]); $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 ───────────────────────── // ── POST /api/messages/{conversation_id}/archive ─────────────────────────
public function archive(Request $request, int $id): JsonResponse public function archive(Request $request, int $id): JsonResponse
{ {
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id); $participant = $this->participantRecord($request, $id);
$participant->update(['is_archived' => ! $participant->is_archived]); $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]); return response()->json(['is_archived' => $participant->is_archived]);
} }
@@ -126,27 +139,30 @@ class ConversationController extends Controller
public function mute(Request $request, int $id): JsonResponse public function mute(Request $request, int $id): JsonResponse
{ {
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id); $participant = $this->participantRecord($request, $id);
$participant->update(['is_muted' => ! $participant->is_muted]); $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]); return response()->json(['is_muted' => $participant->is_muted]);
} }
public function pin(Request $request, int $id): JsonResponse public function pin(Request $request, int $id): JsonResponse
{ {
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id); $participant = $this->participantRecord($request, $id);
$participant->update(['is_pinned' => true, 'pinned_at' => now()]); $participant->update(['is_pinned' => true, 'pinned_at' => now()]);
$this->touchConversationCachesForUsers([$request->user()->id]); $this->broadcastConversationUpdate($conversation, 'conversation.pinned');
return response()->json(['is_pinned' => true]); return response()->json(['is_pinned' => true]);
} }
public function unpin(Request $request, int $id): JsonResponse public function unpin(Request $request, int $id): JsonResponse
{ {
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id); $participant = $this->participantRecord($request, $id);
$participant->update(['is_pinned' => false, 'pinned_at' => null]); $participant->update(['is_pinned' => false, 'pinned_at' => null]);
$this->touchConversationCachesForUsers([$request->user()->id]); $this->broadcastConversationUpdate($conversation, 'conversation.unpinned');
return response()->json(['is_pinned' => false]); return response()->json(['is_pinned' => false]);
} }
@@ -182,14 +198,15 @@ class ConversationController extends Controller
} }
$participant->update(['left_at' => now()]); $participant->update(['left_at' => now()]);
$this->touchConversationCachesForUsers($participantUserIds); $this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.left', $participantUserIds);
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }
// ── POST /api/messages/{conversation_id}/add-user ──────────────────────── // ── 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); $conv = $this->findAuthorized($request, $id);
$this->requireAdmin($request, $id); $this->requireAdmin($request, $id);
@@ -198,9 +215,7 @@ class ConversationController extends Controller
->pluck('user_id') ->pluck('user_id')
->all(); ->all();
$data = $request->validate([ $data = $request->validated();
'user_id' => 'required|integer|exists:users,id',
]);
$existing = ConversationParticipant::where('conversation_id', $id) $existing = ConversationParticipant::where('conversation_id', $id)
->where('user_id', $data['user_id']) ->where('user_id', $data['user_id'])
@@ -220,20 +235,18 @@ class ConversationController extends Controller
} }
$participantUserIds[] = (int) $data['user_id']; $participantUserIds[] = (int) $data['user_id'];
$this->touchConversationCachesForUsers($participantUserIds); $this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.participant_added', $participantUserIds);
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }
// ── DELETE /api/messages/{conversation_id}/remove-user ─────────────────── // ── 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); $this->requireAdmin($request, $id);
$data = $request->validated();
$data = $request->validate([
'user_id' => 'required|integer',
]);
// Cannot remove the conversation creator // Cannot remove the conversation creator
$conv = Conversation::findOrFail($id); $conv = Conversation::findOrFail($id);
@@ -263,26 +276,28 @@ class ConversationController extends Controller
->whereNull('left_at') ->whereNull('left_at')
->update(['left_at' => now()]); ->update(['left_at' => now()]);
$this->touchConversationCachesForUsers($participantUserIds); $this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.participant_removed', $participantUserIds);
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }
// ── POST /api/messages/{conversation_id}/rename ────────────────────────── // ── 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); $conv = $this->findAuthorized($request, $id);
abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.'); abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.');
$this->requireAdmin($request, $id); $this->requireAdmin($request, $id);
$data = $request->validate(['title' => 'required|string|max:120']); $data = $request->validated();
$conv->update(['title' => $data['title']]); $conv->update(['title' => $data['title']]);
$participantUserIds = ConversationParticipant::where('conversation_id', $id) $participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at') ->whereNull('left_at')
->pluck('user_id') ->pluck('user_id')
->all(); ->all();
$this->touchConversationCachesForUsers($participantUserIds); $this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.renamed', $participantUserIds);
return response()->json(['title' => $conv->title]); return response()->json(['title' => $conv->title]);
} }
@@ -307,8 +322,10 @@ class ConversationController extends Controller
if (! $conv) { if (! $conv) {
$conv = DB::transaction(function () use ($user, $recipient) { $conv = DB::transaction(function () use ($user, $recipient) {
$conv = Conversation::create([ $conv = Conversation::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'type' => 'direct', 'type' => 'direct',
'created_by' => $user->id, 'created_by' => $user->id,
'is_active' => true,
]); ]);
ConversationParticipant::insert([ ConversationParticipant::insert([
@@ -320,17 +337,12 @@ class ConversationController extends Controller
}); });
} }
// Insert first / next message $this->sendMessage->execute($conv, $user, [
$message = $conv->messages()->create([ 'body' => $data['body'],
'sender_id' => $user->id, 'client_temp_id' => $data['client_temp_id'] ?? null,
'body' => $data['body'],
]); ]);
$conv->update(['last_message_at' => $message->created_at]); return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
app(MessageNotificationService::class)->notifyNewMessage($conv, $message, $user);
$this->touchConversationCachesForUsers([$user->id, $recipient->id]);
return response()->json($conv->load('allParticipants.user:id,username'), 201);
} }
private function createGroup(Request $request, User $user, array $data): JsonResponse 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 = DB::transaction(function () use ($user, $data, $participantIds) {
$conv = Conversation::create([ $conv = Conversation::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'type' => 'group', 'type' => 'group',
'title' => $data['title'], 'title' => $data['title'],
'created_by' => $user->id, 'created_by' => $user->id,
'is_active' => true,
]); ]);
$rows = array_map(fn ($uid) => [ $rows = array_map(fn ($uid) => [
@@ -353,27 +367,21 @@ class ConversationController extends Controller
ConversationParticipant::insert($rows); ConversationParticipant::insert($rows);
$message = $conv->messages()->create([ return $conv;
'sender_id' => $user->id,
'body' => $data['body'],
]);
$conv->update(['last_message_at' => $message->created_at]);
return [$conv, $message];
}); });
[$conversation, $message] = $conv; $this->sendMessage->execute($conv, $user, [
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user); 'body' => $data['body'],
$this->touchConversationCachesForUsers($participantIds); '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 private function findAuthorized(Request $request, int $id): Conversation
{ {
$conv = Conversation::findOrFail($id); $conv = Conversation::findOrFail($id);
$this->assertParticipant($request, $id); $this->authorize('view', $conv);
return $conv; return $conv;
} }
@@ -399,28 +407,13 @@ class ConversationController extends Controller
private function requireAdmin(Request $request, int $id): void private function requireAdmin(Request $request, int $id): void
{ {
abort_unless( $conversation = Conversation::findOrFail($id);
ConversationParticipant::where('conversation_id', $id) $this->authorize('manageParticipants', $conversation);
->where('user_id', $request->user()->id)
->where('role', 'admin')
->whereNull('left_at')
->exists(),
403,
'Only admins can perform this action.'
);
} }
private function touchConversationCachesForUsers(array $userIds): void private function touchConversationCachesForUsers(array $userIds): void
{ {
foreach (array_unique($userIds) as $userId) { $this->conversationState->touchConversationCachesForUsers($userIds);
if (! $userId) {
continue;
}
$versionKey = $this->cacheVersionKey((int) $userId);
Cache::add($versionKey, 1, now()->addDay());
Cache::increment($versionKey);
}
} }
private function cacheVersionKey(int $userId): string private function cacheVersionKey(int $userId): string
@@ -433,6 +426,16 @@ class ConversationController extends Controller
return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}"; 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 private function assertNotBlockedBetween(User $sender, User $recipient): void
{ {
if (! Schema::hasTable('user_blocks')) { if (! Schema::hasTable('user_blocks')) {

View File

@@ -2,31 +2,59 @@
namespace App\Http\Controllers\Api\Messaging; 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\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\Conversation;
use App\Models\ConversationParticipant; use App\Models\ConversationParticipant;
use App\Models\Message; use App\Models\Message;
use App\Models\MessageAttachment;
use App\Models\MessageReaction; use App\Models\MessageReaction;
use App\Services\Messaging\ConversationStateService;
use App\Services\Messaging\MessagingPayloadFactory;
use App\Services\Messaging\MessageSearchIndexer; use App\Services\Messaging\MessageSearchIndexer;
use App\Services\Messaging\MessageNotificationService; use App\Services\Messaging\SendMessageAction;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
class MessageController extends Controller class MessageController extends Controller
{ {
private const PAGE_SIZE = 30; 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} ────────────────────────────────── // ── GET /api/messages/{conversation_id} ──────────────────────────────────
public function index(Request $request, int $conversationId): JsonResponse public function index(Request $request, int $conversationId): JsonResponse
{ {
$this->assertParticipant($request, $conversationId); $conversation = $this->findConversationOrFail($conversationId);
$cursor = $request->integer('cursor'); $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() $query = Message::withTrashed()
->where('conversation_id', $conversationId) ->where('conversation_id', $conversationId)
@@ -44,65 +72,33 @@ class MessageController extends Controller
$nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null; $nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null;
return response()->json([ return response()->json([
'data' => $messages, 'data' => $messages->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))->values(),
'next_cursor' => $nextCursor, 'next_cursor' => $nextCursor,
]); ]);
} }
// ── POST /api/messages/{conversation_id} ───────────────────────────────── // ── 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); $conversation = $this->findConversationOrFail($conversationId);
$data = $request->validated();
$data = $request->validate([ $data['attachments'] = $request->file('attachments', []);
'body' => 'nullable|string|max:5000',
'attachments' => 'sometimes|array|max:5',
'attachments.*' => 'file|max:25600',
]);
$body = trim((string) ($data['body'] ?? '')); $body = trim((string) ($data['body'] ?? ''));
$files = $request->file('attachments', []); abort_if($body === '' && empty($data['attachments']), 422, 'Message body or attachment is required.');
abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.');
$message = Message::create([ $message = $this->sendMessage->execute($conversation, $request->user(), $data);
'conversation_id' => $conversationId,
'sender_id' => $request->user()->id,
'body' => $body,
]);
foreach ($files as $file) { return response()->json($this->payloadFactory->message($message, (int) $request->user()->id), 201);
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);
} }
// ── POST /api/messages/{conversation_id}/react ─────────────────────────── // ── 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); $this->findConversationOrFail($conversationId);
$data = $request->validated();
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->assertAllowedReaction($data['reaction']); $this->assertAllowedReaction($data['reaction']);
$existing = MessageReaction::where([ $existing = MessageReaction::where([
@@ -126,11 +122,10 @@ class MessageController extends Controller
// ── DELETE /api/messages/{conversation_id}/react ───────────────────────── // ── 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); $this->findConversationOrFail($conversationId);
$data = $request->validated();
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->assertAllowedReaction($data['reaction']); $this->assertAllowedReaction($data['reaction']);
MessageReaction::where([ MessageReaction::where([
@@ -142,12 +137,11 @@ class MessageController extends Controller
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); 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); $message = Message::query()->findOrFail($messageId);
$this->assertParticipant($request, (int) $message->conversation_id); $this->findConversationOrFail((int) $message->conversation_id);
$data = $request->validated();
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->assertAllowedReaction($data['reaction']); $this->assertAllowedReaction($data['reaction']);
$existing = MessageReaction::where([ $existing = MessageReaction::where([
@@ -169,12 +163,11 @@ class MessageController extends Controller
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); 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); $message = Message::query()->findOrFail($messageId);
$this->assertParticipant($request, (int) $message->conversation_id); $this->findConversationOrFail((int) $message->conversation_id);
$data = $request->validated();
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->assertAllowedReaction($data['reaction']); $this->assertAllowedReaction($data['reaction']);
MessageReaction::where([ MessageReaction::where([
@@ -188,19 +181,15 @@ class MessageController extends Controller
// ── PATCH /api/messages/message/{messageId} ─────────────────────────────── // ── 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); $message = Message::findOrFail($messageId);
abort_unless( $this->authorize('update', $message);
$message->sender_id === $request->user()->id,
403,
'You may only edit your own messages.'
);
abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted 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([ $message->update([
'body' => $data['body'], 'body' => $data['body'],
@@ -208,13 +197,21 @@ class MessageController extends Controller
]); ]);
app(MessageSearchIndexer::class)->updateMessage($message); app(MessageSearchIndexer::class)->updateMessage($message);
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id) $participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
->whereNull('left_at') $this->conversationState->touchConversationCachesForUsers($participantUserIds);
->pluck('user_id')
->all();
$this->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} ────────────────────────────── // ── DELETE /api/messages/message/{messageId} ──────────────────────────────
@@ -223,19 +220,24 @@ class MessageController extends Controller
{ {
$message = Message::findOrFail($messageId); $message = Message::findOrFail($messageId);
abort_unless( $this->authorize('delete', $message);
$message->sender_id === $request->user()->id || $request->user()->isAdmin(),
403,
'You may only delete your own messages.'
);
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id) $participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
->whereNull('left_at')
->pluck('user_id')
->all();
app(MessageSearchIndexer::class)->deleteMessage($message); app(MessageSearchIndexer::class)->deleteMessage($message);
$message->delete(); $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]); return response()->json(['ok' => true]);
} }
@@ -256,15 +258,7 @@ class MessageController extends Controller
private function touchConversationCachesForUsers(array $userIds): void private function touchConversationCachesForUsers(array $userIds): void
{ {
foreach (array_unique($userIds) as $userId) { $this->conversationState->touchConversationCachesForUsers($userIds);
if (! $userId) {
continue;
}
$versionKey = "messages:conversations:version:{$userId}";
Cache::add($versionKey, 1, now()->addDay());
Cache::increment($versionKey);
}
} }
private function assertAllowedReaction(string $reaction): void private function assertAllowedReaction(string $reaction): void
@@ -298,54 +292,11 @@ class MessageController extends Controller
return $summary; return $summary;
} }
private function storeAttachment(UploadedFile $file, Message $message, int $userId): void private function findConversationOrFail(int $conversationId): Conversation
{ {
$mime = (string) $file->getMimeType(); $conversation = Conversation::query()->findOrFail($conversationId);
$finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname()); $this->authorize('view', $conversation);
$detectedMime = $finfoMime !== '' ? $finfoMime : $mime;
$allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []); return $conversation;
$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(),
]);
} }
} }

View File

@@ -71,18 +71,12 @@ class MessageSearchController extends Controller
$hits = collect($result->getHits() ?? []); $hits = collect($result->getHits() ?? []);
$estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count()); $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(); if ($hits->isEmpty()) {
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]); [$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(); $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 public function rebuild(Request $request): JsonResponse
{ {
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.'); abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');

View File

@@ -16,9 +16,13 @@ class MessagingSettingsController extends Controller
{ {
public function show(Request $request): JsonResponse 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([ return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone', 'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
'realtime_enabled' => (bool) config('messaging.realtime', false), 'realtime_enabled' => $realtimeReady,
]); ]);
} }

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Messaging;
use App\Events\TypingStarted; use App\Events\TypingStarted;
use App\Events\TypingStopped; use App\Events\TypingStopped;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Models\ConversationParticipant; use App\Models\ConversationParticipant;
use Illuminate\Cache\Repository; use Illuminate\Cache\Repository;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -15,13 +16,13 @@ class TypingController extends Controller
{ {
public function start(Request $request, int $conversationId): JsonResponse 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)); $ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8));
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl)); $this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
if ((bool) config('messaging.realtime', false)) { 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]); return response()->json(['ok' => true]);
@@ -29,11 +30,11 @@ class TypingController extends Controller
public function stop(Request $request, int $conversationId): JsonResponse 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)); $this->store()->forget($this->key($conversationId, (int) $request->user()->id));
if ((bool) config('messaging.realtime', false)) { 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]); return response()->json(['ok' => true]);
@@ -41,7 +42,7 @@ class TypingController extends Controller
public function index(Request $request, int $conversationId): JsonResponse public function index(Request $request, int $conversationId): JsonResponse
{ {
$this->assertParticipant($request, $conversationId); $this->findConversationOrFail($conversationId);
$userId = (int) $request->user()->id; $userId = (int) $request->user()->id;
$participants = ConversationParticipant::query() $participants = ConversationParticipant::query()
@@ -93,4 +94,12 @@ class TypingController extends Controller
return Cache::store(); return Cache::store();
} }
} }
private function findConversationOrFail(int $conversationId): Conversation
{
$conversation = Conversation::query()->findOrFail($conversationId);
$this->authorize('view', $conversation);
return $conversation;
}
} }

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class ManageConversationParticipantRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'user_id' => 'required|integer|exists:users,id',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class RenameConversationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'title' => 'required|string|max:120',
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class StoreConversationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->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',
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class StoreMessageRequest extends FormRequest
{
public function authorize(): bool
{
return $this->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',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class ToggleMessageReactionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'reaction' => 'required|string|max:32',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class UpdateMessageRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'body' => 'required|string|max:5000',
];
}
}

View File

@@ -23,14 +23,18 @@ class Conversation extends Model
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'uuid',
'type', 'type',
'title', 'title',
'created_by', 'created_by',
'last_message_id',
'last_message_at', 'last_message_at',
'is_active',
]; ];
protected $casts = [ protected $casts = [
'last_message_at' => 'datetime', 'last_message_at' => 'datetime',
'is_active' => 'boolean',
]; ];
// ── Relationships ──────────────────────────────────────────────────────── // ── Relationships ────────────────────────────────────────────────────────
@@ -81,6 +85,7 @@ class Conversation extends Model
{ {
return self::query() return self::query()
->where('type', 'direct') ->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', $userA)->whereNull('left_at'))
->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userB)->whereNull('left_at')) ->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userB)->whereNull('left_at'))
->whereRaw( ->whereRaw(
@@ -108,6 +113,11 @@ class Conversation extends Model
->whereNull('deleted_at') ->whereNull('deleted_at')
->where('sender_id', '!=', $userId); ->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) { if ($participant->last_read_at) {
$query->where('created_at', '>', $participant->last_read_at); $query->where('created_at', '>', $participant->last_read_at);
} }

View File

@@ -30,9 +30,11 @@ class ConversationParticipant extends Model
'user_id', 'user_id',
'role', 'role',
'last_read_at', 'last_read_at',
'last_read_message_id',
'is_muted', 'is_muted',
'is_archived', 'is_archived',
'is_pinned', 'is_pinned',
'is_hidden',
'pinned_at', 'pinned_at',
'joined_at', 'joined_at',
'left_at', 'left_at',
@@ -40,9 +42,11 @@ class ConversationParticipant extends Model
protected $casts = [ protected $casts = [
'last_read_at' => 'datetime', 'last_read_at' => 'datetime',
'last_read_message_id' => 'integer',
'is_muted' => 'boolean', 'is_muted' => 'boolean',
'is_archived' => 'boolean', 'is_archived' => 'boolean',
'is_pinned' => 'boolean', 'is_pinned' => 'boolean',
'is_hidden' => 'boolean',
'pinned_at' => 'datetime', 'pinned_at' => 'datetime',
'joined_at' => 'datetime', 'joined_at' => 'datetime',
'left_at' => 'datetime', 'left_at' => 'datetime',

View File

@@ -7,8 +7,11 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable; use Laravel\Scout\Searchable;
use App\Models\MessageRead;
/** /**
* @property int $id * @property int $id
* @property int $conversation_id * @property int $conversation_id
@@ -24,16 +27,31 @@ class Message extends Model
use HasFactory, SoftDeletes, Searchable; use HasFactory, SoftDeletes, Searchable;
protected $fillable = [ protected $fillable = [
'uuid',
'client_temp_id',
'conversation_id', 'conversation_id',
'sender_id', 'sender_id',
'message_type',
'body', 'body',
'meta_json',
'reply_to_message_id',
'edited_at', 'edited_at',
]; ];
protected $casts = [ protected $casts = [
'meta_json' => 'array',
'edited_at' => 'datetime', 'edited_at' => 'datetime',
]; ];
protected static function booted(): void
{
static::creating(function (self $message): void {
if (! $message->uuid) {
$message->uuid = (string) Str::uuid();
}
});
}
// ── Relationships ──────────────────────────────────────────────────────── // ── Relationships ────────────────────────────────────────────────────────
public function conversation(): BelongsTo public function conversation(): BelongsTo
@@ -56,9 +74,14 @@ class Message extends Model
return $this->hasMany(MessageAttachment::class); 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; $this->attributes['body'] = $sanitized;
} }

View File

@@ -14,6 +14,7 @@ class MessageAttachment extends Model
protected $fillable = [ protected $fillable = [
'message_id', 'message_id',
'disk',
'user_id', 'user_id',
'type', 'type',
'mime', 'mime',

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MessageRead extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'message_id',
'user_id',
'read_at',
];
protected $casts = [
'read_at' => 'datetime',
];
public function message(): BelongsTo
{
return $this->belongsTo(Message::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Policies;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\User;
class ConversationPolicy
{
public function view(User $user, Conversation $conversation): bool
{
return $this->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();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Policies;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
class MessagePolicy
{
public function view(User $user, Message $message): bool
{
return ConversationParticipant::query()
->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();
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Services\Messaging;
use App\Events\ConversationUpdated;
use App\Events\MessageRead;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class ConversationStateService
{
public function activeParticipantIds(Conversation|int $conversation): array
{
$conversationId = $conversation instanceof Conversation ? $conversation->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']);
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Services\Messaging;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\MessageAttachment;
use App\Models\User;
class MessagingPayloadFactory
{
public function message(Message $message, ?int $viewerId = null): array
{
$message->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;
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Services\Messaging;
use App\Events\ConversationUpdated;
use App\Events\MessageCreated;
use App\Models\Conversation;
use App\Models\Message;
use App\Models\MessageAttachment;
use App\Models\User;
use App\Services\Messaging\ConversationStateService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class SendMessageAction
{
public function __construct(
private readonly ConversationStateService $conversationState,
private readonly MessageNotificationService $notifications,
private readonly MessageSearchIndexer $searchIndexer,
) {}
public function execute(Conversation $conversation, User $sender, array $payload): Message
{
$body = trim((string) ($payload['body'] ?? ''));
$files = $payload['attachments'] ?? [];
/** @var Message $message */
$message = DB::transaction(function () use ($conversation, $sender, $payload, $body, $files) {
$message = Message::query()->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(),
]);
}
}

View File

@@ -17,6 +17,7 @@
"intervention/image": "^3.11", "intervention/image": "^3.11",
"jenssegers/agent": "*", "jenssegers/agent": "*",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/reverb": "^1.0",
"laravel/scout": "^10.24", "laravel/scout": "^10.24",
"laravel/socialite": "^5.24", "laravel/socialite": "^5.24",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",

1006
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,10 @@
return [ return [
'realtime' => (bool) env('MESSAGING_REALTIME', false), 'realtime' => (bool) env('MESSAGING_REALTIME', false),
'broadcast' => [
'queue' => env('MESSAGING_BROADCAST_QUEUE', 'broadcasts'),
],
'typing' => [ 'typing' => [
'ttl_seconds' => (int) env('MESSAGING_TYPING_TTL', 8), 'ttl_seconds' => (int) env('MESSAGING_TYPING_TTL', 8),
'cache_store' => env('MESSAGING_TYPING_CACHE_STORE', 'redis'), 'cache_store' => env('MESSAGING_TYPING_CACHE_STORE', 'redis'),

96
config/reverb.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => 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'),
],
],
],
];

View File

@@ -0,0 +1,192 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
Schema::table('conversations', function (Blueprint $table): void {
if (! Schema::hasColumn('conversations', 'uuid')) {
$table->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');
}
});
}
};

View File

@@ -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.

30
package-lock.json generated
View File

@@ -21,7 +21,9 @@
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"laravel-echo": "^2.3.1",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"pusher-js": "^8.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "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": { "node_modules/laravel-vite-plugin": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz",
@@ -5912,6 +5927,15 @@
"node": ">=6" "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": { "node_modules/qs": {
"version": "6.14.1", "version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
@@ -6807,6 +6831,12 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/uc.micro": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",

View File

@@ -48,7 +48,9 @@
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"laravel-echo": "^2.3.1",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"pusher-js": "^8.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",

View File

@@ -85,6 +85,92 @@
.nova-scrollbar::-webkit-scrollbar-corner { .nova-scrollbar::-webkit-scrollbar-corner {
background: transparent; 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 ─── */ /* ─── TipTap rich text editor ─── */

View File

@@ -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 { createRoot } from 'react-dom/client'
import { getEcho } from '../../bootstrap'
import ConversationList from '../../components/messaging/ConversationList' import ConversationList from '../../components/messaging/ConversationList'
import ConversationThread from '../../components/messaging/ConversationThread' import ConversationThread from '../../components/messaging/ConversationThread'
import NewConversationModal from '../../components/messaging/NewConversationModal' import NewConversationModal from '../../components/messaging/NewConversationModal'
@@ -10,12 +11,17 @@ function getCsrf() {
async function apiFetch(url, options = {}) { async function apiFetch(url, options = {}) {
const isFormData = options.body instanceof FormData const isFormData = options.body instanceof FormData
const socketId = getEcho()?.socketId?.()
const headers = { const headers = {
'X-CSRF-TOKEN': getCsrf(), 'X-CSRF-TOKEN': getCsrf(),
Accept: 'application/json', Accept: 'application/json',
...options.headers, ...options.headers,
} }
if (socketId) {
headers['X-Socket-ID'] = socketId
}
if (!isFormData) { if (!isFormData) {
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
} }
@@ -58,11 +64,12 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
const [loadingConvs, setLoadingConvs] = useState(true) const [loadingConvs, setLoadingConvs] = useState(true)
const [activeId, setActiveId] = useState(initialId ?? null) const [activeId, setActiveId] = useState(initialId ?? null)
const [realtimeEnabled, setRealtimeEnabled] = useState(false) const [realtimeEnabled, setRealtimeEnabled] = useState(false)
const [realtimeStatus, setRealtimeStatus] = useState('offline')
const [typingByConversation, setTypingByConversation] = useState({})
const [showNewModal, setShowNewModal] = useState(false) const [showNewModal, setShowNewModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([]) const [searchResults, setSearchResults] = useState([])
const [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
const pollRef = useRef(null)
const loadConversations = useCallback(async () => { const loadConversations = useCallback(async () => {
try { try {
@@ -81,28 +88,215 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
apiFetch('/api/messages/settings') apiFetch('/api/messages/settings')
.then((data) => setRealtimeEnabled(!!data?.realtime_enabled)) .then((data) => setRealtimeEnabled(!!data?.realtime_enabled))
.catch(() => setRealtimeEnabled(false)) .catch(() => setRealtimeEnabled(false))
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [loadConversations]) }, [loadConversations])
useEffect(() => { useEffect(() => {
if (pollRef.current) { const handlePopState = () => {
clearInterval(pollRef.current) const match = window.location.pathname.match(/^\/messages\/(\d+)$/)
pollRef.current = null setActiveId(match ? Number(match[1]) : null)
} }
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
useEffect(() => {
if (realtimeEnabled) { if (realtimeEnabled) {
return undefined 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 () => { 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) => { const handleSelectConversation = useCallback((id) => {
setActiveId(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(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -182,7 +384,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|| 'Conversation' || 'Conversation'
return ( return (
<div className="px-4 pb-16 pt-4 md:px-6 lg:px-8 lg:pt-6"> <div className="messages-page px-4 pb-16 pt-4 md:px-6 lg:px-8 lg:pt-6">
<div className="grid gap-5 lg:items-start lg:grid-cols-[340px_minmax(0,1fr)] xl:grid-cols-[360px_minmax(0,1fr)] xl:gap-6"> <div className="grid gap-5 lg:items-start lg:grid-cols-[340px_minmax(0,1fr)] xl:grid-cols-[360px_minmax(0,1fr)] xl:gap-6">
<aside className={`flex min-h-[calc(100vh-18rem)] flex-col overflow-hidden rounded-[30px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.96),rgba(7,11,18,0.92))] shadow-[0_20px_60px_rgba(0,0,0,0.28)] lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] ${activeId ? 'hidden lg:flex' : 'flex'}`}> <aside className={`flex min-h-[calc(100vh-18rem)] flex-col overflow-hidden rounded-[30px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.96),rgba(7,11,18,0.92))] shadow-[0_20px_60px_rgba(0,0,0,0.28)] lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] ${activeId ? 'hidden lg:flex' : 'flex'}`}>
<div className="border-b border-white/[0.06] p-5"> <div className="border-b border-white/[0.06] p-5">
@@ -209,9 +411,9 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
</div> </div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-white/45"> <div className="mt-4 flex flex-wrap gap-2 text-xs text-white/45">
<span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 ${realtimeEnabled ? 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200' : 'border-white/[0.08] bg-white/[0.04] text-white/55'}`}> <span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 ${connectionBadgeClass(realtimeEnabled, realtimeStatus)}`}>
<span className={`h-1.5 w-1.5 rounded-full ${realtimeEnabled ? 'bg-emerald-300' : 'bg-white/30'}`} /> <span className={`h-1.5 w-1.5 rounded-full ${connectionDotClass(realtimeEnabled, realtimeStatus)}`} />
{realtimeEnabled ? 'Realtime active' : 'Polling every 15s'} {connectionBadgeLabel(realtimeEnabled, realtimeStatus)}
</span> </span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/55"> <span className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/55">
<i className="fa-solid fa-comments text-[10px]" /> <i className="fa-solid fa-comments text-[10px]" />
@@ -273,6 +475,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
loading={loadingConvs} loading={loadingConvs}
activeId={activeId} activeId={activeId}
currentUserId={userId} currentUserId={userId}
typingByConversation={typingByConversation}
onSelect={handleSelectConversation} onSelect={handleSelectConversation}
/> />
</aside> </aside>
@@ -284,6 +487,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
conversationId={activeId} conversationId={activeId}
conversation={activeConversation} conversation={activeConversation}
realtimeEnabled={realtimeEnabled} realtimeEnabled={realtimeEnabled}
realtimeStatus={realtimeStatus}
currentUserId={userId} currentUserId={userId}
currentUsername={username} currentUsername={username}
apiFetch={apiFetch} apiFetch={apiFetch}
@@ -292,7 +496,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
history.replaceState(null, '', '/messages') history.replaceState(null, '', '/messages')
}} }}
onMarkRead={handleMarkRead} onMarkRead={handleMarkRead}
onConversationUpdated={loadConversations} onConversationPatched={handleConversationPatched}
/> />
) : ( ) : (
<div className="flex flex-1 items-center justify-center p-8"> <div className="flex flex-1 items-center justify-center p-8">
@@ -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') const el = document.getElementById('messages-root')
if (el) { if (el) {

View File

@@ -1,9 +1,51 @@
import axios from 'axios'; import axios from 'axios'
window.axios = 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) { 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
} }

View File

@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
export default function ConversationList({ conversations, loading, activeId, currentUserId, onSelect }) { export default function ConversationList({ conversations, loading, activeId, currentUserId, typingByConversation = {}, onSelect }) {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3"> <div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
@@ -13,7 +13,7 @@ export default function ConversationList({ conversations, loading, activeId, cur
</span> </span>
</div> </div>
<ul className="flex-1 space-y-2 overflow-y-auto p-3"> <ul className="nova-scrollbar-message flex-1 space-y-2 overflow-y-auto p-3 pr-2">
{loading ? ( {loading ? (
<li className="rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-8 text-center text-sm text-white/40">Loading conversations</li> <li className="rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-8 text-center text-sm text-white/40">Loading conversations</li>
) : null} ) : null}
@@ -28,6 +28,7 @@ export default function ConversationList({ conversations, loading, activeId, cur
conv={conversation} conv={conversation}
isActive={conversation.id === activeId} isActive={conversation.id === activeId}
currentUserId={currentUserId} currentUserId={currentUserId}
typingUsers={typingByConversation[conversation.id] ?? []}
onClick={() => onSelect(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 label = convLabel(conv, currentUserId)
const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message 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 unread = conv.unread_count ?? 0
const myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId) const myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId)
const isArchived = myParticipant?.is_archived ?? false const isArchived = myParticipant?.is_archived ?? false
@@ -89,8 +92,11 @@ function ConversationRow({ conv, isActive, currentUserId, onClick }) {
</div> </div>
<div className="mt-3 rounded-2xl border border-white/[0.04] bg-black/10 px-3 py-2.5"> <div className="mt-3 rounded-2xl border border-white/[0.04] bg-black/10 px-3 py-2.5">
{senderLabel ? <p className="text-[11px] text-white/35">{senderLabel}</p> : null} {typingUsers.length === 0 && senderLabel ? <p className="text-[11px] text-white/35">{senderLabel}</p> : null}
<p className="mt-1 truncate text-sm text-white/62">{preview}</p> <p className={`mt-1 flex items-center gap-2 truncate text-sm ${typingUsers.length > 0 ? 'text-emerald-200' : 'text-white/62'}`}>
{typingUsers.length > 0 ? <SidebarTypingIcon /> : null}
<span className="truncate">{preview}</span>
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -110,6 +116,23 @@ function truncate(str, max) {
return str.length > max ? `${str.slice(0, max)}` : str 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 (
<span className="inline-flex shrink-0 items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300 animate-[pulse_1s_ease-in-out_infinite]" />
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300/80 animate-[pulse_1s_ease-in-out_150ms_infinite]" />
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300/60 animate-[pulse_1s_ease-in-out_300ms_infinite]" />
</span>
)
}
function relativeTime(iso) { function relativeTime(iso) {
if (!iso) return 'No activity' if (!iso) return 'No activity'
const diff = (Date.now() - new Date(iso).getTime()) / 1000 const diff = (Date.now() - new Date(iso).getTime()) / 1000

View File

@@ -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' import MessageBubble from './MessageBubble'
export default function ConversationThread({ export default function ConversationThread({
conversationId, conversationId,
conversation, conversation,
realtimeEnabled, realtimeEnabled,
realtimeStatus,
currentUserId, currentUserId,
currentUsername, currentUsername,
apiFetch, apiFetch,
onBack, onBack,
onMarkRead, onMarkRead,
onConversationUpdated, onConversationPatched,
}) { }) {
const [messages, setMessages] = useState([]) const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -21,6 +23,8 @@ export default function ConversationThread({
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [typingUsers, setTypingUsers] = useState([]) const [typingUsers, setTypingUsers] = useState([])
const [participantState, setParticipantState] = useState(conversation?.all_participants ?? [])
const [presenceUsers, setPresenceUsers] = useState([])
const [threadSearch, setThreadSearch] = useState('') const [threadSearch, setThreadSearch] = useState('')
const [busyAction, setBusyAction] = useState(null) const [busyAction, setBusyAction] = useState(null)
const [lightbox, setLightbox] = useState(null) const [lightbox, setLightbox] = useState(null)
@@ -29,8 +33,15 @@ export default function ConversationThread({
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const typingRef = useRef(null) const typingRef = useRef(null)
const stopTypingRef = useRef(null) const stopTypingRef = useRef(null)
const readReceiptRef = useRef(null)
const typingExpiryTimersRef = useRef(new Map())
const messagesRef = useRef([])
const lastStartRef = useRef(0) const lastStartRef = useRef(0)
const initialLoadRef = useRef(true) 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 knownMessageIdsRef = useRef(new Set())
const animatedMessageIdsRef = useRef(new Set()) const animatedMessageIdsRef = useRef(new Set())
const [animatedMessageIds, setAnimatedMessageIds] = useState({}) const [animatedMessageIds, setAnimatedMessageIds] = useState({})
@@ -38,16 +49,22 @@ export default function ConversationThread({
const myParticipant = useMemo(() => ( const myParticipant = useMemo(() => (
conversation?.my_participant conversation?.my_participant
?? conversation?.all_participants?.find((participant) => participant.user_id === currentUserId) ?? participantState.find((participant) => participant.user_id === currentUserId)
?? null ?? null
), [conversation, currentUserId]) ), [conversation, currentUserId, participantState])
const participants = useMemo(() => conversation?.all_participants ?? [], [conversation]) const participants = useMemo(() => participantState, [participantState])
const participantNames = useMemo(() => ( const participantNames = useMemo(() => (
participants participants
.map((participant) => participant.user?.username) .map((participant) => participant.user?.username)
.filter(Boolean) .filter(Boolean)
), [participants]) ), [participants])
const remoteParticipantNames = useMemo(() => (
participants
.filter((participant) => participant.user_id !== currentUserId)
.map((participant) => participant.user?.username)
.filter(Boolean)
), [currentUserId, participants])
const filteredMessages = useMemo(() => { const filteredMessages = useMemo(() => {
const query = threadSearch.trim().toLowerCase() 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' return participants.find((participant) => participant.user_id !== currentUserId)?.user?.username ?? 'Direct message'
}, [conversation, currentUserId, participants]) }, [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 } = {}) => { const loadMessages = useCallback(async ({ cursor = null, append = false, silent = false } = {}) => {
if (append) setLoadingMore(true) if (append) setLoadingMore(true)
else if (!silent) setLoading(true) else if (!silent) setLoading(true)
if (append && listRef.current) {
previousScrollHeightRef.current = listRef.current.scrollHeight
pendingPrependRef.current = true
}
try { try {
const url = cursor const url = cursor
? `/api/messages/${conversationId}?cursor=${encodeURIComponent(cursor)}` ? `/api/messages/${conversationId}?cursor=${encodeURIComponent(cursor)}`
@@ -96,30 +169,76 @@ export default function ConversationThread({
} }
}, [apiFetch, conversationId]) }, [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(() => { useEffect(() => {
initialLoadRef.current = true initialLoadRef.current = true
shouldStickToBottomRef.current = true
previousScrollHeightRef.current = 0
pendingPrependRef.current = false
pendingComposerScrollRef.current = false
knownMessageIdsRef.current = new Set()
animatedMessageIdsRef.current = new Set()
setMessages([]) setMessages([])
setPresenceUsers([])
setTypingUsers([])
setNextCursor(null) setNextCursor(null)
setBody('') setBody('')
setFiles([]) setFiles([])
setDraftTitle(conversation?.title ?? '')
loadMessages() loadMessages()
loadTyping() if (!realtimeEnabled) {
}, [conversation?.title, conversationId, loadMessages, loadTyping]) loadTyping()
}
}, [conversationId, loadMessages, loadTyping, realtimeEnabled])
useEffect(() => { useEffect(() => {
apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' }) setParticipantState(conversation?.all_participants ?? [])
.then(() => onMarkRead?.(conversationId)) setDraftTitle(conversation?.title ?? '')
.catch(() => {}) }, [conversation?.all_participants, conversation?.title])
}, [apiFetch, conversationId, onMarkRead])
useEffect(() => { useEffect(() => {
markConversationRead()
}, [markConversationRead])
useEffect(() => {
if (realtimeEnabled) {
return undefined
}
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
loadTyping() loadTyping()
if (!realtimeEnabled) { loadMessages({ silent: true })
loadMessages({ silent: true }) }, 8000)
}
}, realtimeEnabled ? 5000 : 8000)
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [loadMessages, loadTyping, realtimeEnabled]) }, [loadMessages, loadTyping, realtimeEnabled])
@@ -127,21 +246,176 @@ export default function ConversationThread({
useEffect(() => () => { useEffect(() => () => {
if (typingRef.current) window.clearTimeout(typingRef.current) if (typingRef.current) window.clearTimeout(typingRef.current)
if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current) if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
if (readReceiptRef.current) window.clearTimeout(readReceiptRef.current)
typingExpiryTimersRef.current.forEach((timer) => window.clearTimeout(timer))
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!listRef.current) return messagesRef.current = messages
if (initialLoadRef.current) { }, [messages])
listRef.current.scrollTop = listRef.current.scrollHeight
initialLoadRef.current = false useLayoutEffect(() => {
const container = listRef.current
if (!container) {
return return
} }
const nearBottom = listRef.current.scrollHeight - listRef.current.scrollTop - listRef.current.clientHeight < 180 if (initialLoadRef.current) {
if (nearBottom) { scrollToBottom()
listRef.current.scrollTop = listRef.current.scrollHeight 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(() => { useEffect(() => {
const known = knownMessageIdsRef.current const known = knownMessageIdsRef.current
@@ -183,6 +457,12 @@ export default function ConversationThread({
const handleBodyChange = useCallback((value) => { const handleBodyChange = useCallback((value) => {
setBody(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() const now = Date.now()
if (now - lastStartRef.current > 2500) { if (now - lastStartRef.current > 2500) {
lastStartRef.current = now lastStartRef.current = now
@@ -208,8 +488,10 @@ export default function ConversationThread({
if (!trimmed && files.length === 0) return if (!trimmed && files.length === 0) return
const optimisticId = `optimistic-${Date.now()}` const optimisticId = `optimistic-${Date.now()}`
const clientTempId = `tmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
const optimisticMessage = normalizeMessage({ const optimisticMessage = normalizeMessage({
id: optimisticId, id: optimisticId,
client_temp_id: clientTempId,
body: trimmed, body: trimmed,
sender: { id: currentUserId, username: currentUsername }, sender: { id: currentUserId, username: currentUsername },
sender_id: currentUserId, sender_id: currentUserId,
@@ -223,6 +505,8 @@ export default function ConversationThread({
_optimistic: true, _optimistic: true,
}, currentUserId) }, currentUserId)
pendingComposerScrollRef.current = true
shouldStickToBottomRef.current = true
setMessages((prev) => mergeMessageLists(prev, [optimisticMessage])) setMessages((prev) => mergeMessageLists(prev, [optimisticMessage]))
setBody('') setBody('')
setFiles([]) setFiles([])
@@ -232,6 +516,7 @@ export default function ConversationThread({
const formData = new FormData() const formData = new FormData()
if (trimmed) formData.append('body', trimmed) if (trimmed) formData.append('body', trimmed)
formData.append('client_temp_id', clientTempId)
files.forEach((file) => formData.append('attachments[]', file)) files.forEach((file) => formData.append('attachments[]', file))
try { try {
@@ -241,10 +526,10 @@ export default function ConversationThread({
}) })
const normalized = normalizeMessage(created, currentUserId) const normalized = normalizeMessage(created, currentUserId)
setMessages((prev) => prev.map((message) => message.id === optimisticId ? normalized : message)) setMessages((prev) => mergeMessageLists(prev, [normalized]))
onConversationUpdated?.() patchLastMessage(normalized, { unread_count: 0 })
} catch (err) { } 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) setBody(trimmed)
setFiles(files) setFiles(files)
setError(err.message) setError(err.message)
@@ -252,7 +537,7 @@ export default function ConversationThread({
setSending(false) setSending(false)
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) 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) => { const updateReactions = useCallback((messageId, summary) => {
setMessages((prev) => prev.map((message) => { setMessages((prev) => prev.map((message) => {
@@ -289,8 +574,8 @@ export default function ConversationThread({
setMessages((prev) => prev.map((message) => ( setMessages((prev) => prev.map((message) => (
message.id === messageId ? normalizeMessage({ ...message, ...updated }, currentUserId) : message message.id === messageId ? normalizeMessage({ ...message, ...updated }, currentUserId) : message
))) )))
onConversationUpdated?.() patchLastMessage(normalizeMessage(updated, currentUserId))
}, [apiFetch, currentUserId, onConversationUpdated]) }, [apiFetch, currentUserId, patchLastMessage])
const handleDelete = useCallback(async (messageId) => { const handleDelete = useCallback(async (messageId) => {
await apiFetch(`/api/messages/message/${messageId}`, { method: 'DELETE' }) 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, body: '', deleted_at: new Date().toISOString(), attachments: [] }
: message : message
))) )))
onConversationUpdated?.() patchConversation({ last_message_at: new Date().toISOString() })
}, [apiFetch, onConversationUpdated]) }, [apiFetch, patchConversation])
const runConversationAction = useCallback(async (action, url, apply) => { const runConversationAction = useCallback(async (action, url, apply) => {
setBusyAction(action) setBusyAction(action)
@@ -308,14 +593,13 @@ export default function ConversationThread({
try { try {
const response = await apiFetch(url, { method: action === 'leave' ? 'DELETE' : 'POST' }) const response = await apiFetch(url, { method: action === 'leave' ? 'DELETE' : 'POST' })
apply?.(response) apply?.(response)
onConversationUpdated?.()
if (action === 'leave') onBack?.() if (action === 'leave') onBack?.()
} catch (e) { } catch (e) {
setError(e.message) setError(e.message)
} finally { } finally {
setBusyAction(null) setBusyAction(null)
} }
}, [apiFetch, onBack, onConversationUpdated]) }, [apiFetch, onBack])
const handleRename = useCallback(async () => { const handleRename = useCallback(async () => {
const title = draftTitle.trim() const title = draftTitle.trim()
@@ -329,17 +613,21 @@ export default function ConversationThread({
method: 'POST', method: 'POST',
body: JSON.stringify({ title }), body: JSON.stringify({ title }),
}) })
onConversationUpdated?.() patchConversation({ title })
} catch (e) { } catch (e) {
setError(e.message) setError(e.message)
} finally { } finally {
setBusyAction(null) setBusyAction(null)
} }
}, [apiFetch, conversation?.title, conversationId, draftTitle, onConversationUpdated]) }, [apiFetch, conversation?.title, conversationId, draftTitle, patchConversation])
const visibleMessages = filteredMessages const visibleMessages = filteredMessages
const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant?.last_read_at ?? null), [visibleMessages, currentUserId, myParticipant?.last_read_at]) const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant?.last_read_at ?? null), [visibleMessages, currentUserId, myParticipant?.last_read_at])
const typingLabel = buildTypingLabel(typingUsers) 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 ( return (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
@@ -356,6 +644,7 @@ export default function ConversationThread({
<div className="mt-1 flex flex-wrap items-center gap-2"> <div className="mt-1 flex flex-wrap items-center gap-2">
<h2 className="truncate text-2xl font-semibold text-white">{conversationLabel}</h2> <h2 className="truncate text-2xl font-semibold text-white">{conversationLabel}</h2>
{conversation?.type === 'group' ? <span className="rounded-full border border-fuchsia-400/20 bg-fuchsia-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-fuchsia-200">Group</span> : <span className="rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200">Direct</span>} {conversation?.type === 'group' ? <span className="rounded-full border border-fuchsia-400/20 bg-fuchsia-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-fuchsia-200">Group</span> : <span className="rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200">Direct</span>}
{realtimeEnabled ? <RealtimeStatusBadge status={realtimeStatus} /> : null}
{myParticipant?.is_pinned ? <span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-200">Pinned</span> : null} {myParticipant?.is_pinned ? <span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-200">Pinned</span> : null}
{myParticipant?.is_muted ? <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">Muted</span> : null} {myParticipant?.is_muted ? <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">Muted</span> : null}
{myParticipant?.is_archived ? <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">Archived</span> : null} {myParticipant?.is_archived ? <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">Archived</span> : null}
@@ -366,12 +655,18 @@ export default function ConversationThread({
? `Participants: ${participantNames.join(', ')}` ? `Participants: ${participantNames.join(', ')}`
: `Private thread with ${participantNames.find((name) => name !== currentUsername) ?? conversationLabel}.`} : `Private thread with ${participantNames.find((name) => name !== currentUsername) ?? conversationLabel}.`}
</p> </p>
{typingSummary ? (
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-emerald-400/18 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-100/90">
<TypingPulse />
<span>{typingSummary}</span>
</div>
) : presenceLabel ? <p className="mt-1 text-xs text-emerald-200/70">{presenceLabel}</p> : conversation?.type === 'direct' && remoteParticipantNames.length > 0 ? <p className="mt-1 text-xs text-white/38">Chatting with @{remoteParticipantNames[0]} in realtime.</p> : null}
</div> </div>
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 lg:mx-0 lg:flex-wrap lg:overflow-visible lg:px-0 lg:pb-0"> <div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 lg:mx-0 lg:flex-wrap lg:overflow-visible lg:px-0 lg:pb-0">
<button <button
onClick={() => runConversationAction('archive', `/api/messages/${conversationId}/archive`, (response) => { onClick={() => runConversationAction('archive', `/api/messages/${conversationId}/archive`, (response) => {
if (conversation?.my_participant) conversation.my_participant.is_archived = !!response.is_archived patchMyParticipantState({ is_archived: !!response.is_archived })
})} })}
className={actionButtonClass(busyAction === 'archive')} className={actionButtonClass(busyAction === 'archive')}
> >
@@ -380,7 +675,7 @@ export default function ConversationThread({
</button> </button>
<button <button
onClick={() => runConversationAction('mute', `/api/messages/${conversationId}/mute`, (response) => { onClick={() => runConversationAction('mute', `/api/messages/${conversationId}/mute`, (response) => {
if (conversation?.my_participant) conversation.my_participant.is_muted = !!response.is_muted patchMyParticipantState({ is_muted: !!response.is_muted })
})} })}
className={actionButtonClass(busyAction === 'mute')} className={actionButtonClass(busyAction === 'mute')}
> >
@@ -389,7 +684,10 @@ export default function ConversationThread({
</button> </button>
<button <button
onClick={() => runConversationAction(myParticipant?.is_pinned ? 'unpin' : 'pin', `/api/messages/${conversationId}/${myParticipant?.is_pinned ? 'unpin' : 'pin'}`, (response) => { onClick={() => runConversationAction(myParticipant?.is_pinned ? 'unpin' : 'pin', `/api/messages/${conversationId}/${myParticipant?.is_pinned ? 'unpin' : 'pin'}`, (response) => {
if (conversation?.my_participant) conversation.my_participant.is_pinned = !!response.is_pinned patchMyParticipantState({
is_pinned: !!response.is_pinned,
pinned_at: response.is_pinned ? new Date().toISOString() : null,
})
})} })}
className={actionButtonClass(busyAction === 'pin' || busyAction === 'unpin')} className={actionButtonClass(busyAction === 'pin' || busyAction === 'unpin')}
> >
@@ -449,7 +747,17 @@ export default function ConversationThread({
</div> </div>
) : null} ) : null}
<div ref={listRef} className="flex-1 overflow-y-auto px-3 py-4 sm:px-6 sm:py-5"> <div
ref={listRef}
onScroll={() => {
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 ? ( {nextCursor ? (
<div className="mb-4 flex justify-center"> <div className="mb-4 flex justify-center">
<button <button
@@ -650,11 +958,66 @@ function summaryToReactionArray(summary, currentUserId) {
} }
function mergeMessageLists(existing, incoming) { function mergeMessageLists(existing, incoming) {
const map = new Map() const next = [...existing]
for (const message of [...existing, ...incoming]) {
map.set(message.id, message) for (const incomingMessage of incoming) {
const existingIndex = next.findIndex((message) => messagesMatch(message, incomingMessage))
if (existingIndex >= 0) {
next[existingIndex] = {
...next[existingIndex],
...incomingMessage,
_optimistic: false,
}
continue
}
next.push(incomingMessage)
} }
return Array.from(map.values()).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
return next.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
}
function messagesMatch(left, right) {
if (!left || !right) return false
if (left.id && right.id && String(left.id) === String(right.id)) return true
if (left.uuid && right.uuid && left.uuid === right.uuid) return true
if (left.client_temp_id && right.client_temp_id && left.client_temp_id === right.client_temp_id) return true
return false
}
function mergeTypingUsers(existing, incoming) {
const matchIndex = existing.findIndex((user) => String(user.user_id ?? user.id) === String(incoming.user_id ?? incoming.id))
if (matchIndex === -1) {
return [...existing, incoming]
}
const next = [...existing]
next[matchIndex] = { ...next[matchIndex], ...incoming }
return next
}
function normalizePresenceUsers(users, currentUserId) {
return (users ?? []).filter((user) => user?.id !== currentUserId)
}
function mergePresenceUsers(existing, incoming, currentUserId) {
if (!incoming?.id || incoming.id === currentUserId) {
return existing
}
const next = [...existing]
const index = next.findIndex((user) => user.id === incoming.id)
if (index >= 0) {
next[index] = { ...next[index], ...incoming }
return next
}
next.push(incoming)
return next
}
function isNearBottom(container, threshold = 120) {
return container.scrollHeight - container.scrollTop - container.clientHeight <= threshold
} }
function buildTypingLabel(users) { function buildTypingLabel(users) {
@@ -759,6 +1122,43 @@ function UnreadMarker({ prefersReducedMotion }) {
) )
} }
function TypingPulse() {
return (
<span className="inline-flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-emerald-300 animate-[pulse_1s_ease-in-out_infinite]" />
<span className="h-2 w-2 rounded-full bg-emerald-300/80 animate-[pulse_1s_ease-in-out_160ms_infinite]" />
<span className="h-2 w-2 rounded-full bg-emerald-300/60 animate-[pulse_1s_ease-in-out_320ms_infinite]" />
</span>
)
}
function RealtimeStatusBadge({ status }) {
const label = status === 'connected'
? 'Socket live'
: status === 'connecting'
? 'Socket connecting'
: 'Socket offline'
const tone = status === 'connected'
? 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200'
: status === 'connecting'
? 'border-amber-400/20 bg-amber-500/10 text-amber-200'
: 'border-rose-400/18 bg-rose-500/10 text-rose-200'
const dot = status === 'connected'
? 'bg-emerald-300'
: status === 'connecting'
? 'bg-amber-300'
: 'bg-rose-300'
return (
<span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone}`}>
<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />
{label}
</span>
)
}
function usePrefersReducedMotion() { function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false) const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)

View File

@@ -1,7 +1,52 @@
<?php <?php
use App\Models\Conversation;
use App\Policies\ConversationPolicy;
use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function ($user, $id) { Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id; return (int) $user->id === (int) $id;
}); });
Broadcast::channel('user.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
Broadcast::channel('private-user.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
Broadcast::channel('conversation.{conversationId}', function ($user, $conversationId) {
$conversation = Conversation::query()->find($conversationId);
if (! $conversation) {
return false;
}
return app(ConversationPolicy::class)->view($user, $conversation);
});
Broadcast::channel('private-conversation.{conversationId}', function ($user, $conversationId) {
$conversation = Conversation::query()->find($conversationId);
if (! $conversation) {
return false;
}
return app(ConversationPolicy::class)->view($user, $conversation);
});
Broadcast::channel('presence-conversation.{conversationId}', function ($user, $conversationId) {
$conversation = Conversation::query()->find($conversationId);
if (! $conversation || ! app(ConversationPolicy::class)->joinPresence($user, $conversation)) {
return false;
}
return [
'id' => (int) $user->id,
'username' => (string) $user->username,
'display_name' => (string) ($user->name ?: $user->username),
'avatar_thumb_url' => null,
];
});

View File

@@ -8,8 +8,13 @@ use App\Models\Message;
use App\Models\MessageAttachment; use App\Models\MessageAttachment;
use App\Models\Report; use App\Models\Report;
use App\Models\User; use App\Models\User;
use App\Policies\ConversationPolicy;
use App\Events\ConversationUpdated;
use App\Events\MessageCreated;
use App\Events\MessageRead;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -150,6 +155,110 @@ test('pin and unpin endpoints toggle participant pin state', function () {
->assertJsonFragment(['is_pinned' => false]); ->assertJsonFragment(['is_pinned' => false]);
}); });
test('sending a message dispatches realtime events and preserves client temp id', function () {
Event::fake([MessageCreated::class, ConversationUpdated::class]);
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$response = $this->actingAs($userA)->postJson("/api/messages/{$conv->id}", [
'body' => 'Realtime hello',
'client_temp_id' => 'tmp_feature_test_123',
]);
$response->assertStatus(201)
->assertJsonFragment(['client_temp_id' => 'tmp_feature_test_123'])
->assertJsonFragment(['body' => 'Realtime hello']);
Event::assertDispatched(MessageCreated::class);
Event::assertDispatched(ConversationUpdated::class);
});
test('non participant cannot send a message', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$outsider = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$this->actingAs($outsider)->postJson("/api/messages/{$conv->id}", [
'body' => 'intrusion',
])->assertStatus(403);
});
test('channel authorization denies non participant and allows participant', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$outsider = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$policy = app(ConversationPolicy::class);
expect($policy->view($outsider, $conv))->toBeFalse()
->and($policy->view($userA, $conv))->toBeTrue()
->and($policy->joinPresence($outsider, $conv))->toBeFalse()
->and($policy->joinPresence($userB, $conv))->toBeTrue();
});
test('mark read updates last read message id and dispatches read event', function () {
Event::fake([MessageRead::class, ConversationUpdated::class]);
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$first = Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userB->id,
'body' => 'One',
]);
$last = Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userB->id,
'body' => 'Two',
]);
$this->actingAs($userA)
->postJson("/api/messages/{$conv->id}/read", ['message_id' => $last->id])
->assertStatus(200)
->assertJsonFragment(['last_read_message_id' => $last->id]);
$participant = ConversationParticipant::query()
->where('conversation_id', $conv->id)
->where('user_id', $userA->id)
->firstOrFail();
expect($participant->last_read_message_id)->toBe($last->id);
$this->assertDatabaseHas('message_reads', [
'message_id' => $first->id,
'user_id' => $userA->id,
]);
$this->assertDatabaseHas('message_reads', [
'message_id' => $last->id,
'user_id' => $userA->id,
]);
Event::assertDispatched(MessageRead::class);
});
test('typing endpoints reject non participants', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$outsider = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$this->actingAs($outsider)
->postJson("/api/messages/{$conv->id}/typing")
->assertStatus(403);
$this->actingAs($outsider)
->postJson("/api/messages/{$conv->id}/typing/stop")
->assertStatus(403);
});
test('report endpoint creates moderation report entry', function () { test('report endpoint creates moderation report entry', function () {
$userA = makeMessagingUser(); $userA = makeMessagingUser();
$userB = makeMessagingUser(); $userB = makeMessagingUser();