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

@@ -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;
use App\Models\User;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TypingStarted
class TypingStarted implements ShouldBroadcast
{
use Dispatchable, SerializesModels;
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public int $conversationId,
public int $userId,
) {}
public User $user,
) {
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
$this->dontBroadcastToCurrentUser();
}
public function broadcastOn(): array
{
return [new PresenceChannel('conversation.' . $this->conversationId)];
}
public function broadcastAs(): string
{
return 'typing.started';
}
public function broadcastWith(): array
{
return [
'event' => 'typing.started',
'conversation_id' => $this->conversationId,
'user' => app(MessagingPayloadFactory::class)->userSummary($this->user),
'expires_in_ms' => (int) config('messaging.typing.ttl_seconds', 8) * 1000,
];
}
}

View File

@@ -2,15 +2,45 @@
namespace App\Events;
use App\Models\User;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TypingStopped
class TypingStopped implements ShouldBroadcast
{
use Dispatchable, SerializesModels;
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public int $conversationId,
public int $userId,
) {}
public User $user,
) {
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
$this->dontBroadcastToCurrentUser();
}
public function broadcastOn(): array
{
return [new PresenceChannel('conversation.' . $this->conversationId)];
}
public function broadcastAs(): string
{
return 'typing.stopped';
}
public function broadcastWith(): array
{
return [
'event' => 'typing.stopped',
'conversation_id' => $this->conversationId,
'user' => app(MessagingPayloadFactory::class)->userSummary($this->user),
];
}
}

View File

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

View File

@@ -2,31 +2,59 @@
namespace App\Http\Controllers\Api\Messaging;
use App\Events\MessageSent;
use App\Events\ConversationUpdated;
use App\Events\MessageDeleted;
use App\Events\MessageUpdated;
use App\Http\Controllers\Controller;
use App\Http\Requests\Messaging\StoreMessageRequest;
use App\Http\Requests\Messaging\ToggleMessageReactionRequest;
use App\Http\Requests\Messaging\UpdateMessageRequest;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\MessageAttachment;
use App\Models\MessageReaction;
use App\Services\Messaging\ConversationStateService;
use App\Services\Messaging\MessagingPayloadFactory;
use App\Services\Messaging\MessageSearchIndexer;
use App\Services\Messaging\MessageNotificationService;
use App\Services\Messaging\SendMessageAction;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
class MessageController extends Controller
{
private const PAGE_SIZE = 30;
public function __construct(
private readonly ConversationStateService $conversationState,
private readonly MessagingPayloadFactory $payloadFactory,
private readonly SendMessageAction $sendMessage,
) {}
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
public function index(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$cursor = $request->integer('cursor');
$conversation = $this->findConversationOrFail($conversationId);
$cursor = $request->integer('cursor') ?: $request->integer('before_id');
$afterId = $request->integer('after_id');
if ($afterId) {
$messages = Message::withTrashed()
->where('conversation_id', $conversationId)
->with(['sender:id,username', 'reactions', 'attachments'])
->where('id', '>', $afterId)
->orderBy('id')
->limit(100)
->get()
->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))
->values();
return response()->json([
'data' => $messages,
'next_cursor' => null,
]);
}
$query = Message::withTrashed()
->where('conversation_id', $conversationId)
@@ -44,65 +72,33 @@ class MessageController extends Controller
$nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null;
return response()->json([
'data' => $messages,
'data' => $messages->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))->values(),
'next_cursor' => $nextCursor,
]);
}
// ── POST /api/messages/{conversation_id} ─────────────────────────────────
public function store(Request $request, int $conversationId): JsonResponse
public function store(StoreMessageRequest $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$data = $request->validate([
'body' => 'nullable|string|max:5000',
'attachments' => 'sometimes|array|max:5',
'attachments.*' => 'file|max:25600',
]);
$conversation = $this->findConversationOrFail($conversationId);
$data = $request->validated();
$data['attachments'] = $request->file('attachments', []);
$body = trim((string) ($data['body'] ?? ''));
$files = $request->file('attachments', []);
abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.');
abort_if($body === '' && empty($data['attachments']), 422, 'Message body or attachment is required.');
$message = Message::create([
'conversation_id' => $conversationId,
'sender_id' => $request->user()->id,
'body' => $body,
]);
$message = $this->sendMessage->execute($conversation, $request->user(), $data);
foreach ($files as $file) {
if ($file instanceof UploadedFile) {
$this->storeAttachment($file, $message, (int) $request->user()->id);
}
}
Conversation::where('id', $conversationId)
->update(['last_message_at' => $message->created_at]);
$conversation = Conversation::findOrFail($conversationId);
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $request->user());
app(MessageSearchIndexer::class)->indexMessage($message);
event(new MessageSent($conversationId, $message->id, $request->user()->id));
$participantUserIds = ConversationParticipant::where('conversation_id', $conversationId)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($participantUserIds);
$message->load(['sender:id,username', 'attachments']);
return response()->json($message, 201);
return response()->json($this->payloadFactory->message($message, (int) $request->user()->id), 201);
}
// ── POST /api/messages/{conversation_id}/react ───────────────────────────
public function react(Request $request, int $conversationId, int $messageId): JsonResponse
public function react(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->findConversationOrFail($conversationId);
$data = $request->validated();
$this->assertAllowedReaction($data['reaction']);
$existing = MessageReaction::where([
@@ -126,11 +122,10 @@ class MessageController extends Controller
// ── DELETE /api/messages/{conversation_id}/react ─────────────────────────
public function unreact(Request $request, int $conversationId, int $messageId): JsonResponse
public function unreact(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->findConversationOrFail($conversationId);
$data = $request->validated();
$this->assertAllowedReaction($data['reaction']);
MessageReaction::where([
@@ -142,12 +137,11 @@ class MessageController extends Controller
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
}
public function reactByMessage(Request $request, int $messageId): JsonResponse
public function reactByMessage(ToggleMessageReactionRequest $request, int $messageId): JsonResponse
{
$message = Message::query()->findOrFail($messageId);
$this->assertParticipant($request, (int) $message->conversation_id);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->findConversationOrFail((int) $message->conversation_id);
$data = $request->validated();
$this->assertAllowedReaction($data['reaction']);
$existing = MessageReaction::where([
@@ -169,12 +163,11 @@ class MessageController extends Controller
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
}
public function unreactByMessage(Request $request, int $messageId): JsonResponse
public function unreactByMessage(ToggleMessageReactionRequest $request, int $messageId): JsonResponse
{
$message = Message::query()->findOrFail($messageId);
$this->assertParticipant($request, (int) $message->conversation_id);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->findConversationOrFail((int) $message->conversation_id);
$data = $request->validated();
$this->assertAllowedReaction($data['reaction']);
MessageReaction::where([
@@ -188,19 +181,15 @@ class MessageController extends Controller
// ── PATCH /api/messages/message/{messageId} ───────────────────────────────
public function update(Request $request, int $messageId): JsonResponse
public function update(UpdateMessageRequest $request, int $messageId): JsonResponse
{
$message = Message::findOrFail($messageId);
abort_unless(
$message->sender_id === $request->user()->id,
403,
'You may only edit your own messages.'
);
$this->authorize('update', $message);
abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.');
$data = $request->validate(['body' => 'required|string|max:5000']);
$data = $request->validated();
$message->update([
'body' => $data['body'],
@@ -208,13 +197,21 @@ class MessageController extends Controller
]);
app(MessageSearchIndexer::class)->updateMessage($message);
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($participantUserIds);
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
return response()->json($message->fresh());
DB::afterCommit(function () use ($message, $participantUserIds): void {
event(new MessageUpdated($message->fresh(['sender:id,username,name', 'attachments', 'reactions'])));
$conversation = Conversation::find($message->conversation_id);
if ($conversation) {
foreach ($participantUserIds as $participantId) {
event(new ConversationUpdated((int) $participantId, $conversation, 'message.updated'));
}
}
});
return response()->json($this->payloadFactory->message($message->fresh(['sender:id,username,name', 'attachments', 'reactions']), (int) $request->user()->id));
}
// ── DELETE /api/messages/message/{messageId} ──────────────────────────────
@@ -223,19 +220,24 @@ class MessageController extends Controller
{
$message = Message::findOrFail($messageId);
abort_unless(
$message->sender_id === $request->user()->id || $request->user()->isAdmin(),
403,
'You may only delete your own messages.'
);
$this->authorize('delete', $message);
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
->whereNull('left_at')
->pluck('user_id')
->all();
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
app(MessageSearchIndexer::class)->deleteMessage($message);
$message->delete();
$this->touchConversationCachesForUsers($participantUserIds);
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
DB::afterCommit(function () use ($message, $participantUserIds): void {
$message->refresh();
event(new MessageDeleted($message));
$conversation = Conversation::find($message->conversation_id);
if ($conversation) {
foreach ($participantUserIds as $participantId) {
event(new ConversationUpdated((int) $participantId, $conversation, 'message.deleted'));
}
}
});
return response()->json(['ok' => true]);
}
@@ -256,15 +258,7 @@ class MessageController extends Controller
private function touchConversationCachesForUsers(array $userIds): void
{
foreach (array_unique($userIds) as $userId) {
if (! $userId) {
continue;
}
$versionKey = "messages:conversations:version:{$userId}";
Cache::add($versionKey, 1, now()->addDay());
Cache::increment($versionKey);
}
$this->conversationState->touchConversationCachesForUsers($userIds);
}
private function assertAllowedReaction(string $reaction): void
@@ -298,54 +292,11 @@ class MessageController extends Controller
return $summary;
}
private function storeAttachment(UploadedFile $file, Message $message, int $userId): void
private function findConversationOrFail(int $conversationId): Conversation
{
$mime = (string) $file->getMimeType();
$finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname());
$detectedMime = $finfoMime !== '' ? $finfoMime : $mime;
$conversation = Conversation::query()->findOrFail($conversationId);
$this->authorize('view', $conversation);
$allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []);
$allowedFile = (array) config('messaging.attachments.allowed_file_mimes', []);
$type = in_array($detectedMime, $allowedImage, true) ? 'image' : 'file';
$allowed = $type === 'image' ? $allowedImage : $allowedFile;
abort_unless(in_array($detectedMime, $allowed, true), 422, 'Unsupported attachment type.');
$maxBytes = $type === 'image'
? ((int) config('messaging.attachments.max_image_kb', 10240) * 1024)
: ((int) config('messaging.attachments.max_file_kb', 25600) * 1024);
abort_if($file->getSize() > $maxBytes, 422, 'Attachment exceeds allowed size.');
$year = now()->format('Y');
$month = now()->format('m');
$ext = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin');
$path = "messages/{$message->conversation_id}/{$year}/{$month}/" . uniqid('att_', true) . ".{$ext}";
$diskName = (string) config('messaging.attachments.disk', 'local');
Storage::disk($diskName)->put($path, file_get_contents($file->getPathname()));
$width = null;
$height = null;
if ($type === 'image') {
$dimensions = @getimagesize($file->getPathname());
$width = isset($dimensions[0]) ? (int) $dimensions[0] : null;
$height = isset($dimensions[1]) ? (int) $dimensions[1] : null;
}
MessageAttachment::query()->create([
'message_id' => $message->id,
'user_id' => $userId,
'type' => $type,
'mime' => $detectedMime,
'size_bytes' => (int) $file->getSize(),
'width' => $width,
'height' => $height,
'sha256' => hash_file('sha256', $file->getPathname()),
'original_name' => substr((string) $file->getClientOriginalName(), 0, 255),
'storage_path' => $path,
'created_at' => now(),
]);
return $conversation;
}
}

View File

@@ -71,18 +71,12 @@ class MessageSearchController extends Controller
$hits = collect($result->getHits() ?? []);
$estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count());
} catch (\Throwable) {
$query = Message::query()
->select('id')
->whereNull('deleted_at')
->whereIn('conversation_id', $allowedConversationIds)
->when($conversationId !== null, fn ($q) => $q->where('conversation_id', $conversationId))
->where('body', 'like', '%' . (string) $data['q'] . '%')
->orderByDesc('created_at')
->orderByDesc('id');
$estimated = (clone $query)->count();
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
if ($hits->isEmpty()) {
[$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit);
}
} catch (\Throwable) {
[$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit);
}
$messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all();
@@ -122,6 +116,23 @@ class MessageSearchController extends Controller
]);
}
private function fallbackHits(array $allowedConversationIds, ?int $conversationId, string $queryString, int $offset, int $limit): array
{
$query = Message::query()
->select('id')
->whereNull('deleted_at')
->whereIn('conversation_id', $allowedConversationIds)
->when($conversationId !== null, fn ($builder) => $builder->where('conversation_id', $conversationId))
->where('body', 'like', '%' . $queryString . '%')
->orderByDesc('created_at')
->orderByDesc('id');
$estimated = (clone $query)->count();
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
return [$hits, $estimated];
}
public function rebuild(Request $request): JsonResponse
{
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');

View File

@@ -16,9 +16,13 @@ class MessagingSettingsController extends Controller
{
public function show(Request $request): JsonResponse
{
$realtimeReady = (bool) config('messaging.realtime', false)
&& config('broadcasting.default') === 'reverb'
&& filled(config('broadcasting.connections.reverb.key'));
return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
'realtime_enabled' => (bool) config('messaging.realtime', false),
'realtime_enabled' => $realtimeReady,
]);
}

View File

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

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ class MessageAttachment extends Model
protected $fillable = [
'message_id',
'disk',
'user_id',
'type',
'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(),
]);
}
}