Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
133 lines
3.5 KiB
TypeScript
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)
|
|
}
|
|
}
|