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

@@ -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',
];
}
}