import { computed, onUnmounted, ref, shallowRef } from 'vue'; import type { ConversationSessionDto } from '@mento/shared'; import { api } from '@/lib/api'; export type ChatMessage = { id: string; roomId: string; senderId: string; content: string; type: 'text' | 'image' | 'voice'; createdAt: string; reply: string | null; optimistic?: boolean; }; const PAGE_SIZE = 50; let socket: WebSocket | null = null; let sessionCache: ConversationSessionDto | null = null; const status = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle'); const messages = shallowRef([]); const activeRoomId = ref(''); let pingTimer: ReturnType | null = null; 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); if (d !== 0) return d; return a.id.localeCompare(b.id); }); } function normalizeIncoming( raw: Record, roomFallback: string, ): ChatMessage | null { const base = raw.message && typeof raw.message === 'object' ? (raw.message as Record) : raw; 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(); return { id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, roomId: String(base.room_id ?? raw.room_id ?? roomFallback), senderId: String( base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '', ), content, type: (String(base.type_content ?? base.type ?? 'text') as ChatMessage['type']) || 'text', createdAt: String( base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(), ), reply: base.reply ? String(base.reply) : null, }; } /** ادغام: همان id جایگزین می‌شود؛ optimistic مشابه با echo سوکت یکی می‌شود */ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[] { const byId = list.findIndex((m) => m.id === incoming.id); if (byId >= 0) { const next = [...list]; next[byId] = { ...list[byId], ...incoming, optimistic: false }; return sortAsc(next); } // echo سوکت بعد از optimistic با همان متن/فرستنده/اتاق const optimisticIdx = list.findIndex( (m) => m.optimistic && m.roomId === incoming.roomId && m.senderId === incoming.senderId && m.content === incoming.content, ); if (optimisticIdx >= 0) { const next = [...list]; next[optimisticIdx] = { ...list[optimisticIdx], ...incoming, optimistic: false, }; return sortAsc(next); } return sortAsc([...list, incoming]); } async function ensureSession(): Promise { if (sessionCache) return sessionCache; const { data } = await api.get('/conversations/session'); sessionCache = data; return data; } function clearPing() { if (pingTimer) { clearInterval(pingTimer); pingTimer = null; } } function disconnectSocket() { clearPing(); if (socket) { socket.close(); socket = null; } status.value = 'closed'; } function onSocketMessage(event: MessageEvent) { const raw = String(event.data ?? '').trim(); if (!raw || raw === 'pong' || raw === 'ping') return; try { const parsed = JSON.parse(raw) as Record; const type = String(parsed.type ?? '').toLowerCase(); if (type === 'seen' || type === 'typing' || type === 'read' || type === 'pong') return; const msg = normalizeIncoming(parsed, activeRoomId.value); if (!msg || !activeRoomId.value || msg.roomId !== activeRoomId.value) return; messages.value = mergeMessages(msg, messages.value); } catch { // ignore } } async function connectSocket() { const session = await ensureSession(); if (socket?.readyState === WebSocket.OPEN) return; disconnectSocket(); status.value = 'connecting'; socket = new WebSocket(session.wsUrl); socket.addEventListener('open', () => { status.value = 'open'; pingTimer = setInterval(() => { if (socket?.readyState === WebSocket.OPEN) socket.send('ping'); }, 15000); }); socket.addEventListener('close', () => { status.value = 'closed'; clearPing(); }); socket.addEventListener('message', onSocketMessage); await new Promise((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 function useOrgChat() { const loadingMessages = ref(false); async function loadMessages(roomId: string) { activeRoomId.value = roomId; loadingMessages.value = true; try { const session = await ensureSession(); await connectSocket(); const params = new URLSearchParams({ api_key: new URL(session.wsUrl).searchParams.get('api_key') ?? '', room_id: roomId, user_id: session.chatUserId, limit: String(PAGE_SIZE), offset: '0', }); const url = `${session.messagesBaseUrl}/api/messages?${params.toString()}`; const res = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' }); if (!res.ok) throw new Error('messages failed'); const body = await res.json(); const list = Array.isArray(body) ? body : body?.data ?? []; const normalized = list .map((item: Record) => normalizeIncoming(item, roomId)) .filter(Boolean) as ChatMessage[]; messages.value = sortAsc(normalized); } finally { loadingMessages.value = false; } } async function sendText(roomId: string, text: string, reply: string | null = null) { const trimmed = text.trim(); if (!trimmed) return; await connectSocket(); if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open'); const session = await ensureSession(); const id = crypto.randomUUID(); const payload: Record = { id, type: 'text', room_id: roomId, content: trimmed, }; if (reply) payload.reply = reply; const optimistic: ChatMessage = { id, roomId, senderId: session.chatUserId, content: trimmed, type: 'text', createdAt: new Date().toISOString(), reply, optimistic: true, }; messages.value = mergeMessages(optimistic, messages.value); socket.send(JSON.stringify(payload)); sendRead(roomId); } function sendRead(roomId: string) { if (!socket || socket.readyState !== WebSocket.OPEN) return; socket.send(JSON.stringify({ type: 'read', room_id: roomId })); } function resetSession() { sessionCache = null; disconnectSocket(); messages.value = []; activeRoomId.value = ''; } onUnmounted(() => { disconnectSocket(); }); return { status: computed(() => status.value), messages: computed(() => messages.value), loadingMessages, loadMessages, sendText, sendRead, resetSession, ensureSession, }; }