Updating ConversationsView wiring and the nav badge next.

This commit is contained in:
2026-09-29 16:22:37 +03:30
parent 032eb70524
commit d4972891f1
3 changed files with 374 additions and 64 deletions
+241 -56
View File
@@ -1,7 +1,9 @@
import { computed, onUnmounted, ref, shallowRef } from 'vue';
import type { ConversationSessionDto } from '@mento/shared';
import { computed, ref, shallowRef } from 'vue';
import type { ConversationRoomDto, ConversationSessionDto } from '@mento/shared';
import { api } from '@/lib/api';
export type ChatDelivery = 'sending' | 'sent' | 'read';
export type ChatMessage = {
id: string;
roomId: string;
@@ -11,23 +13,55 @@ export type ChatMessage = {
createdAt: string;
reply: string | null;
optimistic?: boolean;
delivery?: ChatDelivery;
readAt?: string | null;
};
export type RoomLiveEvent = {
roomId: string;
preview: string;
at: string;
fromSelf: boolean;
};
const PAGE_SIZE = 50;
let socket: WebSocket | null = null;
let sessionCache: ConversationSessionDto | null = null;
let connectPromise: Promise<void> | null = null;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let wantSocket = false;
const status = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle');
const messages = shallowRef<ChatMessage[]>([]);
const activeRoomId = ref('');
let pingTimer: ReturnType<typeof setInterval> | null = null;
const unreadTotal = ref(0);
const pageOpen = ref(false);
type RoomListener = (event: RoomLiveEvent) => void;
const roomListeners = new Set<RoomListener>();
export function onRoomLiveEvent(listener: RoomListener) {
roomListeners.add(listener);
return () => roomListeners.delete(listener);
}
function emitRoomLive(event: RoomLiveEvent) {
for (const listener of roomListeners) {
try {
listener(event);
} catch {
// ignore listener errors
}
}
}
function messageTs(item: ChatMessage): number {
const t = new Date(item.createdAt).getTime();
return Number.isNaN(t) ? 0 : t;
}
/** قدیمی → جدید (آخرین پیام پایین لیست) */
function sortAsc(items: ChatMessage[]): ChatMessage[] {
return [...items].sort((a, b) => {
const d = messageTs(a) - messageTs(b);
@@ -47,6 +81,7 @@ function normalizeIncoming(
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
if (!content && !base.attachments) return null;
const id = String(base.id ?? base.message_id ?? '').trim();
const readAt = base.read_at ?? base.readAt ?? null;
return {
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
roomId: String(base.room_id ?? raw.room_id ?? roomFallback),
@@ -59,19 +94,29 @@ function normalizeIncoming(
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
),
reply: base.reply ? String(base.reply) : null,
readAt: readAt ? String(readAt) : null,
delivery: readAt ? 'read' : 'sent',
};
}
/** ادغام: همان id جایگزین می‌شود؛ optimistic مشابه با echo سوکت یکی می‌شود */
function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[] {
const byId = list.findIndex((m) => m.id === incoming.id);
if (byId >= 0) {
const prev = list[byId];
const next = [...list];
next[byId] = { ...list[byId], ...incoming, optimistic: false };
next[byId] = {
...prev,
...incoming,
optimistic: false,
delivery:
incoming.delivery === 'read' || prev.delivery === 'read'
? 'read'
: incoming.delivery || prev.delivery || 'sent',
readAt: incoming.readAt ?? prev.readAt,
};
return sortAsc(next);
}
// echo سوکت بعد از optimistic با همان متن/فرستنده/اتاق
const optimisticIdx = list.findIndex(
(m) =>
m.optimistic &&
@@ -80,11 +125,13 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
m.content === incoming.content,
);
if (optimisticIdx >= 0) {
const prev = list[optimisticIdx];
const next = [...list];
next[optimisticIdx] = {
...list[optimisticIdx],
...prev,
...incoming,
optimistic: false,
delivery: incoming.readAt ? 'read' : 'sent',
};
return sortAsc(next);
}
@@ -92,6 +139,38 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
return sortAsc([...list, incoming]);
}
function applySeenPayload(payload: Record<string, unknown>) {
const roomId = String(payload.room_id ?? '');
if (!roomId || roomId !== activeRoomId.value) return;
const myId = sessionCache?.chatUserId ?? '';
const readerId = String(payload.reader_id ?? '');
// وقتی طرف مقابل خوانده، پیام‌های من تیک دوتایی می‌خورند
if (!readerId || readerId === myId) return;
const ids = new Set(
Array.isArray(payload.seen_message_ids)
? payload.seen_message_ids.map((id) => String(id))
: [],
);
if (!ids.size) {
// اگر id لیست نبود، همه پیام‌های خودم در این room را read کن
messages.value = messages.value.map((m) =>
m.roomId === roomId && m.senderId === myId
? { ...m, delivery: 'read' as const, readAt: String(payload.seen_at ?? new Date().toISOString()) }
: m,
);
return;
}
const seenAt = String(payload.seen_at ?? new Date().toISOString());
messages.value = messages.value.map((m) => {
if (!ids.has(m.id)) return m;
if (m.senderId !== myId) return m;
return { ...m, delivery: 'read' as const, readAt: seenAt };
});
}
async function ensureSession(): Promise<ConversationSessionDto> {
if (sessionCache) return sessionCache;
const { data } = await api.get<ConversationSessionDto>('/conversations/session');
@@ -106,13 +185,62 @@ function clearPing() {
}
}
function clearReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function scheduleReconnect() {
if (!wantSocket || reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
void connectSocket().catch(() => {
scheduleReconnect();
});
}, 2500);
}
function disconnectSocket() {
clearPing();
clearReconnect();
if (socket) {
socket.onmessage = null;
socket.onclose = null;
socket.onerror = null;
socket.close();
socket = null;
}
status.value = 'closed';
connectPromise = null;
}
function handleIncomingMessage(msg: ChatMessage) {
const myId = sessionCache?.chatUserId ?? '';
const fromSelf = Boolean(myId && msg.senderId === myId);
if (msg.roomId === activeRoomId.value) {
messages.value = mergeMessages(
{
...msg,
delivery: fromSelf ? msg.delivery || 'sent' : msg.delivery,
},
messages.value,
);
if (!fromSelf && pageOpen.value) {
sendRead(msg.roomId);
}
} else if (!fromSelf) {
unreadTotal.value += 1;
}
emitRoomLive({
roomId: msg.roomId,
preview: msg.content,
at: msg.createdAt,
fromSelf,
});
}
function onSocketMessage(event: MessageEvent) {
@@ -120,63 +248,101 @@ function onSocketMessage(event: MessageEvent) {
if (!raw || raw === 'pong' || raw === 'ping') return;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const type = String(parsed.type ?? '').toLowerCase();
if (type === 'seen' || type === 'typing' || type === 'read' || type === 'pong') return;
const type = String(parsed.type ?? parsed.event ?? '').toLowerCase();
if (type === 'pong' || type === 'ping' || type === 'typing' || type === 'read') return;
if (type === 'seen') {
applySeenPayload(parsed);
return;
}
const msg = normalizeIncoming(parsed, activeRoomId.value);
if (!msg || !activeRoomId.value || msg.roomId !== activeRoomId.value) return;
messages.value = mergeMessages(msg, messages.value);
if (!msg) return;
handleIncomingMessage(msg);
} catch {
// ignore
}
}
async function connectSocket() {
const session = await ensureSession();
wantSocket = true;
if (socket?.readyState === WebSocket.OPEN) return;
if (connectPromise) return connectPromise;
disconnectSocket();
status.value = 'connecting';
socket = new WebSocket(session.wsUrl);
connectPromise = (async () => {
const session = await ensureSession();
if (socket?.readyState === WebSocket.OPEN) return;
socket.addEventListener('open', () => {
status.value = 'open';
pingTimer = setInterval(() => {
if (socket?.readyState === WebSocket.OPEN) socket.send('ping');
}, 15000);
disconnectSocket();
wantSocket = true;
status.value = 'connecting';
socket = new WebSocket(session.wsUrl);
await new Promise<void>((resolve, reject) => {
if (!socket) return reject(new Error('socket missing'));
const t = setTimeout(() => reject(new Error('ws timeout')), 12000);
socket.addEventListener(
'open',
() => {
clearTimeout(t);
status.value = 'open';
pingTimer = setInterval(() => {
if (socket?.readyState === WebSocket.OPEN) socket.send('ping');
}, 15000);
resolve();
},
{ once: true },
);
socket.addEventListener(
'error',
() => {
clearTimeout(t);
reject(new Error('ws error'));
},
{ once: true },
);
});
if (!socket) return;
socket.addEventListener('message', onSocketMessage);
socket.addEventListener('close', () => {
status.value = 'closed';
clearPing();
socket = null;
connectPromise = null;
if (wantSocket) scheduleReconnect();
});
})().finally(() => {
connectPromise = null;
});
socket.addEventListener('close', () => {
status.value = 'closed';
clearPing();
});
return connectPromise;
}
socket.addEventListener('message', onSocketMessage);
function sendRead(roomId: string) {
if (!socket || socket.readyState !== WebSocket.OPEN || !roomId) return;
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
}
await new Promise<void>((resolve, reject) => {
if (!socket) return reject(new Error('socket missing'));
const t = setTimeout(() => reject(new Error('ws timeout')), 12000);
socket.addEventListener(
'open',
() => {
clearTimeout(t);
resolve();
},
{ once: true },
);
socket.addEventListener(
'error',
() => {
clearTimeout(t);
reject(new Error('ws error'));
},
{ once: true },
);
});
export async function refreshUnreadTotal() {
try {
const { data } = await api.get<ConversationRoomDto[]>('/conversations/rooms');
unreadTotal.value = data.reduce((sum, room) => sum + (room.unreadCount || 0), 0);
return unreadTotal.value;
} catch {
return unreadTotal.value;
}
}
export function useOrgChat() {
const loadingMessages = ref(false);
async function ensureConnected() {
await connectSocket();
}
function setConversationsPageOpen(open: boolean) {
pageOpen.value = open;
}
async function loadMessages(roomId: string) {
activeRoomId.value = roomId;
loadingMessages.value = true;
@@ -195,10 +361,18 @@ export function useOrgChat() {
if (!res.ok) throw new Error('messages failed');
const body = await res.json();
const list = Array.isArray(body) ? body : body?.data ?? [];
const normalized = list
const myId = session.chatUserId;
const normalized = (list
.map((item: Record<string, unknown>) => normalizeIncoming(item, roomId))
.filter(Boolean) as ChatMessage[];
.filter(Boolean) as ChatMessage[]).map((m) => {
if (m.senderId !== myId) return m;
return {
...m,
delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery,
};
});
messages.value = sortAsc(normalized);
sendRead(roomId);
} finally {
loadingMessages.value = false;
}
@@ -227,36 +401,47 @@ export function useOrgChat() {
createdAt: new Date().toISOString(),
reply,
optimistic: true,
delivery: 'sending',
};
messages.value = mergeMessages(optimistic, messages.value);
emitRoomLive({
roomId,
preview: trimmed,
at: optimistic.createdAt,
fromSelf: true,
});
socket.send(JSON.stringify(payload));
sendRead(roomId);
messages.value = messages.value.map((m) =>
m.id === id ? { ...m, optimistic: false, delivery: 'sent' as const } : m,
);
}
function sendRead(roomId: string) {
if (!socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
function clearActiveRoom() {
activeRoomId.value = '';
messages.value = [];
}
function resetSession() {
wantSocket = false;
sessionCache = null;
disconnectSocket();
messages.value = [];
activeRoomId.value = '';
}
onUnmounted(() => {
disconnectSocket();
});
return {
status: computed(() => status.value),
messages: computed(() => messages.value),
unreadTotal: computed(() => unreadTotal.value),
loadingMessages,
loadMessages,
sendText,
sendRead,
ensureConnected,
setConversationsPageOpen,
clearActiveRoom,
resetSession,
ensureSession,
refreshUnreadTotal,
};
}