feat: add Reverb realtime messaging
This commit is contained in:
24
.env.example
24
.env.example
@@ -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
|
||||||
|
|||||||
46
app/Events/ConversationUpdated.php
Normal file
46
app/Events/ConversationUpdated.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Events/MessageCreated.php
Normal file
51
app/Events/MessageCreated.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Events/MessageDeleted.php
Normal file
45
app/Events/MessageDeleted.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Events/MessageRead.php
Normal file
51
app/Events/MessageRead.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
44
app/Events/MessageUpdated.php
Normal file
44
app/Events/MessageUpdated.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([
|
|
||||||
'sender_id' => $user->id,
|
|
||||||
'body' => $data['body'],
|
'body' => $data['body'],
|
||||||
|
'client_temp_id' => $data['client_temp_id'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$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')) {
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Http/Requests/Messaging/RenameConversationRequest.php
Normal file
20
app/Http/Requests/Messaging/RenameConversationRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Http/Requests/Messaging/StoreConversationRequest.php
Normal file
26
app/Http/Requests/Messaging/StoreConversationRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Http/Requests/Messaging/StoreMessageRequest.php
Normal file
24
app/Http/Requests/Messaging/StoreMessageRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Http/Requests/Messaging/ToggleMessageReactionRequest.php
Normal file
20
app/Http/Requests/Messaging/ToggleMessageReactionRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Http/Requests/Messaging/UpdateMessageRequest.php
Normal file
20
app/Http/Requests/Messaging/UpdateMessageRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
34
app/Models/MessageRead.php
Normal file
34
app/Models/MessageRead.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Policies/ConversationPolicy.php
Normal file
47
app/Policies/ConversationPolicy.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Policies/MessagePolicy.php
Normal file
29
app/Policies/MessagePolicy.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/Services/Messaging/ConversationStateService.php
Normal file
98
app/Services/Messaging/ConversationStateService.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/Services/Messaging/MessagingPayloadFactory.php
Normal file
152
app/Services/Messaging/MessagingPayloadFactory.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/Services/Messaging/SendMessageAction.php
Normal file
126
app/Services/Messaging/SendMessageAction.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
1006
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
96
config/reverb.php
Normal 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'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
26
docs/realtime-messaging.md
Normal file
26
docs/realtime-messaging.md
Normal 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
30
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 ─── */
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}, [loadConversations, realtimeEnabled])
|
window.removeEventListener('focus', syncConnectionState)
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilitySync)
|
||||||
|
channel.stopListening('.conversation.updated', handleConversationUpdated)
|
||||||
|
echo.leaveChannel(`private-user.${userId}`)
|
||||||
|
}
|
||||||
|
}, [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) {
|
||||||
|
|||||||
52
resources/js/bootstrap.js
vendored
52
resources/js/bootstrap.js
vendored
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
if (!realtimeEnabled) {
|
||||||
loadTyping()
|
loadTyping()
|
||||||
}, [conversation?.title, conversationId, loadMessages, 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,
|
||||||
}
|
}
|
||||||
return Array.from(map.values()).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
next.push(incomingMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user