/** * useChat — composable for the public chat room. * * Global SSE connection (module-level singleton) for: * - messages (shared array) * - authorOnline status (reactive) * - lastMention notification (for toast) */ import { request, unwrapApiBody, type ApiResponse } from "../utils/http/factory" import type { ChatMessage } from "~~/server/service/chat" import { useAuthSession } from "./useAuthSession" // ══════ Shared SSE state (module-level singleton) ══════ const messages = ref([]) const connected = ref(false) const authorOnline = ref(false) const lastMention = ref<{ from: string; content: string } | null>(null) let sseStarted = false let sseAbort: AbortController | null = null function startSSE() { if (import.meta.server) return if (sseStarted) return sseStarted = true sseAbort?.abort() sseAbort = new AbortController() fetch('/api/chat/sse', { signal: sseAbort.signal, credentials: 'include' }) .then(async (response) => { if (!response.ok || !response.body) throw new Error(`SSE failed: ${response.status}`) const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() || '' for (const line of lines) { if (!line.startsWith('data: ')) continue const payload = line.slice(6) if (payload.startsWith(':')) continue try { const parsed = JSON.parse(payload) if (parsed.type === 'connected') { connected.value = true authorOnline.value = !!parsed.authorOnline } else if (parsed.type === 'authorOnline') { authorOnline.value = !!parsed.data } else if (parsed.type === 'mentionNotify') { lastMention.value = parsed.data } else if (parsed.type === 'message' && parsed.data) { messages.value = [...messages.value, parsed.data] } } catch { /* skip malformed */ } } } }) .catch((err: any) => { if (err?.name !== 'AbortError') { sseStarted = false setTimeout(startSSE, 3000) } }) } // ══════ Per-instance helpers ══════ const NICKNAME_PREFIXES = ['小', '大', '阿', '老', '' as string] const NICKNAME_SUFFIXES = ['白', '黑', '花', '黄', '蓝', '云', '风', '月', '星', '龙', '虎', '猫', '狗', '鹿', '鹏'] function randomNickname(): string { const prefix = NICKNAME_PREFIXES[Math.floor(Math.random() * NICKNAME_PREFIXES.length)] const suffix = NICKNAME_SUFFIXES[Math.floor(Math.random() * NICKNAME_SUFFIXES.length)] return `${prefix}${suffix}` } function getClientId(): string { if (import.meta.server) return '' const key = 'chat_client_id' let id = sessionStorage.getItem(key) if (!id) { id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` sessionStorage.setItem(key, id) } return id } export function useChat() { const loading = ref(false) const loadingMore = ref(false) const hasMore = ref(true) const error = ref(null) const clientId = getClientId() const _anonNickname = ref('') function getAnonNickname(): string { if (import.meta.server) return '' if (!_anonNickname.value) { _anonNickname.value = localStorage.getItem('chat_anon_nickname') || randomNickname() localStorage.setItem('chat_anon_nickname', _anonNickname.value) } return _anonNickname.value } async function loadHistory() { loading.value = true error.value = null try { const res = await request>('/api/chat/messages') messages.value = unwrapApiBody(res) hasMore.value = messages.value.length >= 20 } catch (e: any) { error.value = e?.message || '加载消息失败' } finally { loading.value = false } } async function loadMore() { if (loadingMore.value || !hasMore.value || messages.value.length === 0) return loadingMore.value = true try { const oldestId = messages.value[0]?.id if (!oldestId) { hasMore.value = false; return } const res = await request>(`/api/chat/messages?before=${oldestId}&limit=20`) const older = unwrapApiBody(res) if (older.length < 20) hasMore.value = false // Prepend older messages messages.value = [...older, ...messages.value] } catch (e: any) { error.value = e?.message || '加载更多失败' } finally { loadingMore.value = false } } async function send(content: string, nickname?: string) { const trimmed = content.trim() if (!trimmed) return error.value = null try { await request('/api/chat/send', { method: 'POST', body: { content: trimmed, nickname: nickname || getAnonNickname(), clientId }, }) } catch (e: any) { error.value = e?.data?.message || e?.message || '发送失败' throw e } } if (import.meta.client) { const { loggedIn } = useAuthSession() onMounted(() => { if (loggedIn.value) { startSSE() } watch(loggedIn, (val) => { if (val) startSSE() }) loadHistory() }) } return { messages, connected, authorOnline, lastMention, loading, loadingMore, hasMore, error, send, loadHistory, loadMore, } }