Add internal org chat (phase 1) with DM, groups, and org settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
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;
|
||||
|
||||
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;
|
||||
const id = String(base.id ?? base.message_id ?? crypto.randomUUID());
|
||||
return {
|
||||
id,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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', (event) => {
|
||||
const raw = String(event.data ?? '').trim();
|
||||
if (!raw || raw === 'pong' || raw === 'ping') return;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (parsed.type === 'seen' || parsed.type === 'typing' || parsed.type === 'read') return;
|
||||
const msg = normalizeIncoming(parsed, activeRoomId.value);
|
||||
if (!msg || msg.roomId !== activeRoomId.value) return;
|
||||
messages.value = [...messages.value, msg];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
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 ?? [];
|
||||
messages.value = list
|
||||
.map((item: Record<string, unknown>) => normalizeIncoming(item, roomId))
|
||||
.filter(Boolean) as ChatMessage[];
|
||||
} 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,
|
||||
};
|
||||
messages.value = [...messages.value, optimistic];
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user