admin/lib/chatSocket.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

133 lines
3.5 KiB
TypeScript

import type { Socket } from 'socket.io-client'
import { CHAT_SOCKET_NAMESPACE, CHAT_SOCKET_URL } from '@/config/chatSocket'
let socket: Socket | null = null
let connectAbortNonce = 0
export interface ChatMessageEvent {
id: string
conversationId: string
senderId: string
senderFirstName: string | null
senderLastName: string | null
senderAvatarUrl?: string | null
senderGender?: string | null
body: string | null
imageUrl: string | null
createdAt: string
}
export interface ConversationUpdatedEvent {
conversationId: string
lastMessageAt: string
preview: string | null
unreadCount?: number
}
export const getChatSocket = () => socket
/**
* Connect chat socket on-demand.
*
* Important: `socket.io-client` must be loaded lazily to keep anonymous
* guest first-load bundles small.
*/
export const connectChatSocket = async (accessToken: string): Promise<Socket> => {
if (socket?.connected) {
// Keep the stored auth token current even while already connected: if
// this connection later drops (network blip, server restart) and
// socket.io-client auto-reconnects, it replays whatever is in
// `socket.auth` at that time — a stale token here would make every
// reconnect after the token's ~15min TTL fail silently.
socket.auth = { token: accessToken }
return socket
}
if (socket) {
socket.auth = { token: accessToken }
socket.connect()
return socket
}
const nonce = ++connectAbortNonce
const mod = await import('socket.io-client')
const { io } = mod
// Another tab/navigation/token change can call disconnectChatSocket while
// this module is still loading.
if (nonce !== connectAbortNonce) {
throw new Error('CHAT_SOCKET_CONNECT_ABORTED')
}
socket = io(`${CHAT_SOCKET_URL}${CHAT_SOCKET_NAMESPACE}`, {
transports: ['websocket'],
autoConnect: true,
auth: { token: accessToken },
})
return socket
}
export const disconnectChatSocket = () => {
if (!socket) return
connectAbortNonce += 1
socket.removeAllListeners()
socket.disconnect()
socket = null
}
export interface JoinConversationAck {
ok: boolean
error?: string
conversationId?: string
}
export const joinConversationRoom = (conversationId: string): Promise<JoinConversationAck> => {
const activeSocket = socket
if (!activeSocket) return Promise.resolve({ ok: false, error: 'NOT_CONNECTED' })
return new Promise((resolve) => {
activeSocket.timeout(5000).emit('conversation:join', { conversationId }, (error: Error | null, ack?: JoinConversationAck) => {
if (error) {
resolve({ ok: false, error: 'ACK_TIMEOUT' })
return
}
resolve(ack ?? { ok: false, error: 'NO_ACK' })
})
})
}
export const leaveConversationRoom = (conversationId: string) => {
socket?.emit('conversation:leave', { conversationId })
}
export const subscribeChatEvents = (handlers: {
onMessage?: (message: ChatMessageEvent) => void
onConversationUpdated?: (payload: ConversationUpdatedEvent) => void
}) => {
if (!socket) return () => undefined
const messageHandler = (payload: { message: ChatMessageEvent }) => {
handlers.onMessage?.(payload.message)
}
const conversationHandler = (payload: ConversationUpdatedEvent) => {
handlers.onConversationUpdated?.(payload)
}
socket.on('message:new', messageHandler)
socket.on('conversation:updated', conversationHandler)
return () => {
socket?.off('message:new', messageHandler)
socket?.off('conversation:updated', conversationHandler)
}
}