Files
CRM_Montzar/apps/web/src/lib/useOrgChat.ts
T

263 lines
7.5 KiB
TypeScript
Raw Normal View History

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<ChatMessage[]>([]);
const activeRoomId = ref('');
let pingTimer: ReturnType<typeof setInterval> | null = null;
2026-09-29 11:56:32 +03:30
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<string, unknown>,
roomFallback: string,
): ChatMessage | null {
const base =
raw.message && typeof raw.message === 'object'
? (raw.message as Record<string, unknown>)
: raw;
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
if (!content && !base.attachments) return null;
2026-09-29 11:56:32 +03:30
const id = String(base.id ?? base.message_id ?? '').trim();
return {
2026-09-29 11:56:32 +03:30
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,
};
}
2026-09-29 11:56:32 +03:30
/** ادغام: همان 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<ConversationSessionDto> {
if (sessionCache) return sessionCache;
const { data } = await api.get<ConversationSessionDto>('/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';
}
2026-09-29 11:56:32 +03:30
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<string, unknown>;
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();
});
2026-09-29 11:56:32 +03:30
socket.addEventListener('message', onSocketMessage);
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 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 ?? [];
2026-09-29 11:56:32 +03:30
const normalized = list
.map((item: Record<string, unknown>) => normalizeIncoming(item, roomId))
.filter(Boolean) as ChatMessage[];
2026-09-29 11:56:32 +03:30
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<string, string> = {
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,
};
2026-09-29 11:56:32 +03:30
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,
};
}