import { computed, ref, watch } from 'vue' import { useWebSocket } from '@vueuse/core' import { useUserStore } from '@/store/userStore' import { createUuid } from '@/utils/index' import { postAdminChatFocus, postAdminChatUnfocus, postAdminChatEnsureWebhook } from '@/api/chatFocus' type RawChatRoom = Record type RawChatMessage = Record type FetchMode = 'replace' | 'prepend' | 'sync_latest' type MessageSource = 'api' | 'socket' | 'optimistic' type InternalAdminMessage = RawChatMessage & { _source?: MessageSource _client_key?: string _optimistic?: boolean _local_created_at_ms?: number } const ADMIN_USER_ID = 'admin' export const PAGE_SIZE = 50 // const SAME_MESSAGE_TIME_WINDOW_MS = 2000 const HEARTBEAT_CHECK_INTERVAL_MS = 10000 const STALE_FALLBACK_MS = 60000 const PING_INTERVAL_MS = 15000 const PONG_WAIT_MS = 8000 const OUTGOING_ACK_TIMEOUT_MS = 7000 const MAX_RESEND_ATTEMPTS = 1 const CONNECTING_TIMEOUT_MS = 12000 const RECONNECT_WATCH_INTERVAL_MS = 5000 const READ_SIGNAL_DEBOUNCE_MS = 1200 const FOCUS_HEARTBEAT_MS = 20000 type AdminOutgoingMessageType = 'text' | 'image' | 'voice' type PendingOutgoingAdminMessage = { payload: { id: string type: AdminOutgoingMessageType room_id: string content: string reply?: string } optimisticKey: string | null attempts: number timer: number | null } const pendingOutgoingById = new Map() const socketUrl = ref('') const rooms = ref([]) const messagesByRoom = ref>({}) const activeRoomId = ref('') let reconnectWatchTimer: number | null = null const connectStartedAt = ref(0) const isAdminActiveRoomAtBottom = ref(false) const roomsLoading = ref(false) const hasMoreByRoom = ref>({}) const isFetchingOlderByRoom = ref>({}) const isInitialLoadingByRoom = ref>({}) const paginationOffsetByRoom = ref>({}) const adminChatBootstrapped = ref(false) const notificationPermissionAsked = ref(false) const notifiedMessageKeys = new Set() const lastReadSignalAt = ref(0) const lastReadSignalRoomId = ref('') let readSignalTimer: number | null = null let pendingAdminReadSignalRoomId = '' let focusHeartbeatTimer: number | null = null let lastReportedFocusRoomId = '' let bootstrapPromise: Promise | null = null let connectPromise: Promise | null = null let reconnectingPromise: Promise | null = null let networkListenersBound = false let manuallyClosed = false const lastSocketActivityAt = ref(0) const lastPingAt = ref(0) const lastPongAt = ref(0) let staleCheckTimer: number | null = null let heartbeatInterval: number | null = null let pongTimeoutTimer: number | null = null const isAdminSupportPageOpen = ref(false) const hasLoadedInitialByRoom = ref>({}) const clearReconnectWatchTimer = () => { if (reconnectWatchTimer !== null) { clearInterval(reconnectWatchTimer) reconnectWatchTimer = null } } const shouldKeepAdminSocketAlive = () => { return !manuallyClosed && !!getApiKey() && !!normalizeHost() } const startReconnectWatchdog = () => { if (reconnectWatchTimer !== null) return reconnectWatchTimer = window.setInterval(() => { if (!shouldKeepAdminSocketAlive()) return const now = Date.now() if (status.value === 'OPEN') return const connectingTooLong = status.value === 'CONNECTING' && connectStartedAt.value > 0 && now - connectStartedAt.value > CONNECTING_TIMEOUT_MS if (status.value === 'CLOSED' || connectingTooLong) { safeReconnect(connectingTooLong ? 'admin connecting timeout' : 'admin closed watchdog').catch( (error) => { console.error('admin reconnect watchdog failed:', error) } ) } }, RECONNECT_WATCH_INTERVAL_MS) } export const setAdminSupportPageOpen = (value: boolean) => { isAdminSupportPageOpen.value = value if (!value) { isAdminActiveRoomAtBottom.value = false clearReadSignalTimer() pendingAdminReadSignalRoomId = '' const previousRoom = activeRoomId.value || lastReportedFocusRoomId activeRoomId.value = '' stopFocusHeartbeat() if (previousRoom) { void syncAdminChatFocus(null, previousRoom) } return } if ( typeof document !== 'undefined' && document.visibilityState === 'visible' && activeRoomId.value && isAdminActiveRoomAtBottom.value ) { clearRoomUnread(activeRoomId.value) scheduleAdminReadSignal(80) } refreshFocusHeartbeat() if (value && !manuallyClosed && status.value !== 'OPEN') { safeReconnect('admin support page opened').catch((error) => { console.error('admin reconnect on page open failed:', error) }) } } const isViewingRoomNow = (roomId: string) => { if (!roomId) return false return ( isAdminSupportPageOpen.value && typeof document !== 'undefined' && document.visibilityState === 'visible' && activeRoomId.value === roomId ) } const syncAdminChatFocus = async (roomId: string | null, previousRoomId?: string) => { try { if (!roomId) { if (previousRoomId || lastReportedFocusRoomId) { await postAdminChatUnfocus(previousRoomId || lastReportedFocusRoomId) } lastReportedFocusRoomId = '' return } await postAdminChatFocus({ room_id: roomId, previous_room_id: previousRoomId && previousRoomId !== roomId ? previousRoomId : undefined }) lastReportedFocusRoomId = roomId } catch (error) { console.error('admin chat focus sync failed:', error) } } const stopFocusHeartbeat = () => { if (focusHeartbeatTimer !== null) { window.clearInterval(focusHeartbeatTimer) focusHeartbeatTimer = null } } const refreshFocusHeartbeat = () => { stopFocusHeartbeat() const roomId = activeRoomId.value if (!isViewingRoomNow(roomId)) { if (lastReportedFocusRoomId) { void syncAdminChatFocus(null, lastReportedFocusRoomId) } return } void syncAdminChatFocus( roomId, lastReportedFocusRoomId && lastReportedFocusRoomId !== roomId ? lastReportedFocusRoomId : undefined ) focusHeartbeatTimer = window.setInterval(() => { if (!isViewingRoomNow(activeRoomId.value)) { stopFocusHeartbeat() if (lastReportedFocusRoomId) { void syncAdminChatFocus(null, lastReportedFocusRoomId) } return } void syncAdminChatFocus(activeRoomId.value) }, FOCUS_HEARTBEAT_MS) } const canTreatAdminRoomAsRead = (roomId = activeRoomId.value) => { return Boolean( roomId && isViewingRoomNow(roomId) && isAdminActiveRoomAtBottom.value ) } const latestIncomingNotification = ref<{ roomId: string title: string body: string at: number } | null>(null) const normalizeHost = () => { const host = String(useUserStore().findSettingByKey('chat_api') || '').trim() // const host = String('192.168.70.136:8585' || '').trim() return host .replace(/^wss?:\/\//, '') .replace(/^https?:\/\//, '') .replace(/\/+$/, '') } const getApiKey = () => String(useUserStore().findSettingByKey('chat_api_key') || '').trim() const clearReadSignalTimer = () => { if (readSignalTimer !== null) { window.clearTimeout(readSignalTimer) readSignalTimer = null } } const canSendAdminReadSignal = () => { const roomId = activeRoomId.value return ( isAdminSupportPageOpen.value && status.value === 'OPEN' && !!roomId && !!hasLoadedInitialByRoom.value[roomId] && canTreatAdminRoomAsRead(roomId) ) } const sendAdminReadSignal = () => { const roomId = activeRoomId.value if (!canSendAdminReadSignal()) { if (roomId && canTreatAdminRoomAsRead(roomId)) { pendingAdminReadSignalRoomId = roomId } return } const now = Date.now() const elapsed = now - lastReadSignalAt.value if ( lastReadSignalRoomId.value === roomId && elapsed < READ_SIGNAL_DEBOUNCE_MS ) { // Keep pending and retry after debounce — dropping here skips seen for new messages. pendingAdminReadSignalRoomId = roomId if (readSignalTimer === null) { scheduleAdminReadSignal(READ_SIGNAL_DEBOUNCE_MS - elapsed + 50) } return } send( JSON.stringify({ type: 'read', room_id: roomId }) ) lastReadSignalAt.value = now lastReadSignalRoomId.value = roomId pendingAdminReadSignalRoomId = '' } const flushPendingAdminReadSignal = () => { if (!pendingAdminReadSignalRoomId || pendingAdminReadSignalRoomId !== activeRoomId.value) return if (!canTreatAdminRoomAsRead(pendingAdminReadSignalRoomId)) { pendingAdminReadSignalRoomId = '' return } sendAdminReadSignal() } const scheduleAdminReadSignal = (delay = 120) => { clearReadSignalTimer() readSignalTimer = window.setTimeout(() => { readSignalTimer = null sendAdminReadSignal() }, delay) } export const setAdminActiveRoomAtBottom = (roomId: string, value: boolean) => { if (!roomId || roomId !== activeRoomId.value) return isAdminActiveRoomAtBottom.value = value if (!value) { clearReadSignalTimer() return } if (canTreatAdminRoomAsRead(roomId)) { clearRoomUnread(roomId) scheduleAdminReadSignal(80) } } const applyAdminSeenPayload = (payload: any) => { if (payload?.type !== 'seen') return false const roomId = String(payload?.room_id || '') if (!roomId) return true const readerId = String(payload?.reader_id || '') if (readerId === ADMIN_USER_ID) { return true } const ids = new Set( Array.isArray(payload?.seen_message_ids) ? payload.seen_message_ids.map((id: any) => String(id)) : [] ) if (!ids.size) return true const seenAt = payload?.seen_at || new Date().toISOString() const roomMessages = messagesByRoom.value[roomId] || [] messagesByRoom.value = { ...messagesByRoom.value, [roomId]: roomMessages.map((item) => { const messageId = getRealMessageId(item) const senderId = String(item?.sender_id || '') if (!messageId || !ids.has(String(messageId))) return item if (senderId !== ADMIN_USER_ID) return item return { ...item, read_at: seenAt } }) } return true } const clearPendingOutgoing = (messageId: string) => { const pending = pendingOutgoingById.get(messageId) if (!pending) return if (pending.timer !== null) { window.clearTimeout(pending.timer) } pendingOutgoingById.delete(messageId) } const clearAllPendingOutgoing = () => { for (const [messageId, pending] of pendingOutgoingById.entries()) { if (pending.timer !== null) { window.clearTimeout(pending.timer) } pendingOutgoingById.delete(messageId) } } const isSocketFreshForSend = () => { if (status.value !== 'OPEN') return false const now = Date.now() const pongAge = lastPongAt.value ? now - lastPongAt.value : Number.POSITIVE_INFINITY const activityAge = lastSocketActivityAt.value ? now - lastSocketActivityAt.value : Number.POSITIVE_INFINITY return pongAge <= PING_INTERVAL_MS + PONG_WAIT_MS + 2000 && activityAge <= STALE_FALLBACK_MS } const armOutgoingAckTimeout = (messageId: string) => { const pending = pendingOutgoingById.get(messageId) if (!pending) return if (pending.timer !== null) { window.clearTimeout(pending.timer) } pending.timer = window.setTimeout(async () => { const current = pendingOutgoingById.get(messageId) if (!current) return try { await safeReconnect(`admin outgoing ack timeout: ${messageId}`) const latest = pendingOutgoingById.get(messageId) if (!latest) return if (status.value !== 'OPEN') return if (latest.attempts >= MAX_RESEND_ATTEMPTS) { console.warn('admin message ack timeout, max resend reached:', messageId) return } latest.attempts += 1 send(JSON.stringify(latest.payload)) armOutgoingAckTimeout(messageId) } catch (error) { console.error('admin resend after ack timeout failed:', error) } }, OUTGOING_ACK_TIMEOUT_MS) } const getRoomId = (room: any) => { return String(room?.room_id ?? room?.id ?? room?.uuid ?? room?.room?.id ?? '') } const canUseNotifications = () => { return typeof window !== 'undefined' && 'Notification' in window } export const requestAdminNotificationPermission = async () => { if (!canUseNotifications()) return 'unsupported' if (Notification.permission === 'granted') return 'granted' if (Notification.permission === 'denied') return 'denied' try { const result = await Notification.requestPermission() notificationPermissionAsked.value = true return result } catch (error) { console.error('notification permission failed:', error) return 'denied' } } const getNotifyMessageKey = (message: InternalAdminMessage) => { return ( getRealMessageId(message) || message._client_key || `${message.room_id}-${message.sender_id}-${getContentText(message)}-${getMessageTs(message)}` ) } const showIncomingMessageNotification = (message: InternalAdminMessage) => { if (!message) return if (String(message.sender_id) === ADMIN_USER_ID) return if (!canUseNotifications()) return if (Notification.permission !== 'granted') return const roomId = String(message.room_id || '') if (isViewingRoomNow(roomId)) return const key = getNotifyMessageKey(message) if (notifiedMessageKeys.has(key)) return notifiedMessageKeys.add(key) try { const room = rooms.value.find((item) => getRoomId(item) === roomId) const title = getRoomName(room || message) const body = getContentText(message) || 'پیام جدید' const notification = new Notification(title, { body, tag: roomId || key, dir: 'rtl' }) notification.onclick = async () => { try { window.focus() if (roomId) { await setAdminActiveRoom(roomId, { fetchIfNeeded: true }) } notification.close() } catch (error) { console.error('notification click failed:', error) } } } catch (error) { console.error('show notification failed:', error) } } const getRoomName = (room: any) => { return ( room?.name || room?.user?.name || room?.user_name || room?.customer?.name || room?.full_name || room?.title || 'کاربر' ) } const buildAdminWsUrl = () => { const host = normalizeHost() const apiKey = getApiKey() if (!host || !apiKey) return '' return `wss://${host}/ws?api_key=${encodeURIComponent(apiKey)}&user_id=${encodeURIComponent(ADMIN_USER_ID)}` } const buildRoomsApiUrl = () => { const host = normalizeHost() const apiKey = getApiKey() if (!host || !apiKey) { throw new Error(`missing chat settings: host=${host}, apiKey=${apiKey}`) } return `https://${host}/api/users/rooms?api_key=${encodeURIComponent(apiKey)}&user_id=${encodeURIComponent(ADMIN_USER_ID)}` } const buildMessagesApiUrl = (roomId: string, limit = PAGE_SIZE, offset = 0) => { const host = normalizeHost() const apiKey = getApiKey() if (!host || !apiKey) { throw new Error(`missing chat settings: host=${host}, apiKey=${apiKey}`) } return `https://${host}/api/messages?api_key=${encodeURIComponent(apiKey)}&room_id=${encodeURIComponent(roomId)}&user_id=${encodeURIComponent(ADMIN_USER_ID)}&limit=${limit}&offset=${offset}` } const markSocketActivity = () => { lastSocketActivityAt.value = Date.now() } const extractList = (result: any) => { if (Array.isArray(result)) return result if (Array.isArray(result?.data)) return result.data if (Array.isArray(result?.rooms)) return result.rooms if (Array.isArray(result?.items)) return result.items return [] } const sortRoomsByDate = (list: RawChatRoom[]) => { return [...list].sort((a, b) => { const aDate = new Date( a?.last_message?.created_at || a?.updated_at || a?.created_at || 0 ).getTime() const bDate = new Date( b?.last_message?.created_at || b?.updated_at || b?.created_at || 0 ).getTime() return bDate - aDate }) } const patchRoom = (roomId: string, patcher: (room: RawChatRoom) => RawChatRoom) => { const idx = rooms.value.findIndex((room) => getRoomId(room) === roomId) if (idx === -1) return const cloned = [...rooms.value] cloned[idx] = patcher(cloned[idx]) rooms.value = sortRoomsByDate(cloned) } const ensureRoomExists = (payload: any) => { const roomId = String(payload?.room_id ?? payload?.room?.id ?? '') if (!roomId) return const exists = rooms.value.some((room) => getRoomId(room) === roomId) if (exists) return rooms.value = sortRoomsByDate([ { id: roomId, room_id: roomId, name: getRoomName(payload), unread_count: isViewingRoomNow(roomId) ? 0 : 1, unreadCount: isViewingRoomNow(roomId) ? 0 : 1, last_message: { content: payload?.content || payload?.text || payload?.message?.content || '', created_at: payload?.created_at || new Date().toISOString() } }, ...rooms.value ]) } const clearRoomUnread = (roomId: string) => { if (!roomId) return patchRoom(roomId, (room) => ({ ...room, unread_count: 0, unreadCount: 0 })) } const incrementRoomUnread = (roomId: string) => { if (!roomId) return if (canTreatAdminRoomAsRead(roomId)) { clearRoomUnread(roomId) return } patchRoom(roomId, (room) => { const current = Number(room?.unread_count ?? room?.unreadCount ?? 0) return { ...room, unread_count: current + 1, unreadCount: current + 1 } }) } const updateRoomPreviewFromMessage = (payload: any) => { const roomId = String(payload?.room_id ?? payload?.room?.id ?? '') if (!roomId) return ensureRoomExists(payload) patchRoom(roomId, (room) => ({ ...room, name: getRoomName(room) || getRoomName(payload), last_message: { ...(room?.last_message || {}), content: payload?.content || payload?.text || payload?.message?.content || '', created_at: payload?.created_at || new Date().toISOString() } })) } const getContentText = (item: any) => { return String(item?.content ?? item?.text ?? item?.body ?? '').trim() } const getRealMessageId = (item: any) => { return String(item?.id ?? item?.message_id ?? '') } const getMessageTs = (item: any) => { const raw = item?.created_at || item?.createdAt || item?.timestamp const parsed = raw ? new Date(raw).getTime() : NaN if (!Number.isNaN(parsed)) return parsed if (item?._local_created_at_ms) return item._local_created_at_ms return 0 } const sortMessagesAsc = (items: InternalAdminMessage[]) => { return [...items].sort((a, b) => { const diff = getMessageTs(a) - getMessageTs(b) if (diff !== 0) return diff return String(getRealMessageId(a) || a?._client_key || '').localeCompare( String(getRealMessageId(b) || b?._client_key || '') ) }) } const normalizeMessage = (payload: any, source: MessageSource): InternalAdminMessage | null => { if (!payload) return null const nestedObjectMessage = payload?.message && typeof payload.message === 'object' ? payload.message : null const nestedStringMessage = typeof payload?.message === 'string' ? payload.message : '' const base = nestedObjectMessage ?? payload const content = String( base?.content ?? base?.text ?? base?.body ?? nestedStringMessage ?? '' ).trim() const attachments = base?.attachments ?? base?.files ?? base?.media if (!content && !Array.isArray(attachments)) return null const createdAt = base?.created_at ?? payload?.created_at ?? base?.createdAt ?? payload?.createdAt ?? base?.timestamp ?? payload?.timestamp ?? new Date().toISOString() const localCreatedAtMs = (() => { const parsed = new Date(createdAt).getTime() return Number.isNaN(parsed) ? Date.now() : parsed })() const roomId = String(base?.room_id ?? payload?.room_id ?? payload?.room?.id ?? '') const senderId = String( base?.sender_id ?? payload?.sender_id ?? base?.user_id ?? payload?.user_id ?? base?.from_user_id ?? payload?.from_user_id ?? base?.sender?.id ?? payload?.sender?.id ?? '' ) const normalized: InternalAdminMessage = { ...base, room_id: roomId, sender_id: senderId, content, type_content: base?.type_content ?? payload?.type_content ?? base?.type ?? payload?.type ?? 'text', created_at: createdAt, reply: base?.reply ?? payload?.reply ?? undefined, _source: source, _local_created_at_ms: localCreatedAtMs } if (!normalized.id && (base?.message_id || payload?.message_id)) { normalized.message_id = base?.message_id ?? payload?.message_id } normalized._client_key = getRealMessageId(normalized) || `${source}-${roomId}-${senderId}-${content}-${localCreatedAtMs}-${Math.random().toString(36).slice(2, 8)}` if (source === 'optimistic') { normalized._optimistic = true } return normalized } /*const findMatchingOptimisticIndex = ( list: InternalAdminMessage[], incoming: InternalAdminMessage ) => { const incomingRoomId = String(incoming.room_id || '') const incomingSenderId = String(incoming.sender_id || incoming.user_id || '') const incomingText = getContentText(incoming) if (!incomingRoomId || !incomingText) return -1 if (incomingSenderId !== ADMIN_USER_ID) return -1 if (incoming._optimistic) return -1 return list.findIndex((item) => { return ( !!item._optimistic && String(item.room_id || '') === incomingRoomId && String(item.sender_id || item.user_id || '') === incomingSenderId && getContentText(item) === incomingText ) }) }*/ const isSameLogicalMessage = (a: InternalAdminMessage, b: InternalAdminMessage) => { const aId = getRealMessageId(a) const bId = getRealMessageId(b) if (aId && bId) { return aId === bId } if (a._client_key && b._client_key) { return a._client_key === b._client_key } return false } const pickBetterMessage = (oldItem: InternalAdminMessage, newItem: InternalAdminMessage) => { const oldHasRealId = !!getRealMessageId(oldItem) const newHasRealId = !!getRealMessageId(newItem) const preservedClientKey = oldItem._client_key || newItem._client_key if (!oldHasRealId && newHasRealId) { return { ...oldItem, ...newItem, _client_key: preservedClientKey, _optimistic: false } } if (oldItem._optimistic && !newItem._optimistic) { return { ...oldItem, ...newItem, _client_key: preservedClientKey, _optimistic: false } } if (oldItem._source === 'socket' && newItem._source === 'api') { return { ...oldItem, ...newItem, _client_key: preservedClientKey, _optimistic: false } } return { ...oldItem, ...newItem, _client_key: preservedClientKey } } const mergeMessageLists = (items: InternalAdminMessage[]) => { const map = new Map() for (const item of items) { const key = getRealMessageId(item) || item._client_key if (!key) continue const prev = map.get(key) map.set(key, prev ? pickBetterMessage(prev, item) : item) } return sortMessagesAsc([...map.values()]) } const appendMessageToRoom = (message: InternalAdminMessage) => { const roomId = String(message?.room_id || '') if (!roomId) return ensureRoomExists(message) const existing = messagesByRoom.value[roomId] || [] messagesByRoom.value = { ...messagesByRoom.value, [roomId]: mergeMessageLists([...existing, message]) } updateRoomPreviewFromMessage(message) if (String(message.sender_id) !== ADMIN_USER_ID) { if (isViewingRoomNow(roomId) && isAdminActiveRoomAtBottom.value) { clearRoomUnread(roomId) scheduleAdminReadSignal(120) } else { incrementRoomUnread(roomId) if (!isViewingRoomNow(roomId)) { emitIncomingToastEvent(message) showIncomingMessageNotification(message) } } } } const removeMessageByClientKey = (roomId: string, clientKey: string | null) => { if (!roomId || !clientKey) return messagesByRoom.value = { ...messagesByRoom.value, [roomId]: (messagesByRoom.value[roomId] || []).filter((item) => item._client_key !== clientKey) } } const appendOptimisticAdminMessage = ( roomId: string, content: string, messageId: string, typeContent: AdminOutgoingMessageType = 'text', replyId: string | null = null ) => { const optimistic = normalizeMessage( { id: messageId, room_id: roomId, sender_id: ADMIN_USER_ID, content, type_content: typeContent, type: typeContent, reply: replyId || undefined, created_at: new Date().toISOString() }, 'optimistic' ) if (!optimistic) return null appendMessageToRoom(optimistic) if (isViewingRoomNow(roomId)) { clearRoomUnread(roomId) } return optimistic._client_key || null } const isHeartbeatPayload = (payload: any) => { if (!payload) return false if (typeof payload === 'string') { const value = payload.trim().toLowerCase() return ['ping', 'pong', 'heartbeat', 'keepalive'].includes(value) } const type = String(payload?.type || payload?.event || payload?.message || '').toLowerCase() return ['ping', 'pong', 'heartbeat', 'keepalive'].includes(type) } const replyToHeartbeatIfNeeded = (payload: any) => { try { if (status.value !== 'OPEN') return if (typeof payload === 'string') { const value = payload.trim().toLowerCase() if (value === 'ping') { send('pong') } else if (value === 'pong') { markPongReceived() } return } const type = String(payload?.type || payload?.event || payload?.message || '').toLowerCase() if (type === 'ping') { send(JSON.stringify({ type: 'pong' })) } else if (type === 'pong') { markPongReceived() } } catch (error) { console.error('admin heartbeat reply failed:', error) } } const clearHeartbeatTimers = () => { if (heartbeatInterval !== null) { clearInterval(heartbeatInterval) heartbeatInterval = null } if (pongTimeoutTimer !== null) { clearTimeout(pongTimeoutTimer) pongTimeoutTimer = null } } const waitForSocketOpen = (timeout = 10000) => { return new Promise((resolve, reject) => { if (status.value === 'OPEN') { resolve() return } const stop = watch(status, (newStatus) => { if (newStatus === 'OPEN') { clearTimeout(timer) stop() resolve() } }) const timer = window.setTimeout(() => { stop() reject(new Error('admin websocket open timeout')) }, timeout) }) } const waitForSocketClosed = (timeout = 5000) => { return new Promise((resolve, reject) => { if (status.value === 'CLOSED') { resolve() return } const stop = watch(status, (newStatus) => { if (newStatus === 'CLOSED') { clearTimeout(timer) stop() resolve() } }) const timer = window.setTimeout(() => { stop() reject(new Error('admin websocket close timeout')) }, timeout) }) } const markPongReceived = () => { lastPongAt.value = Date.now() markSocketActivity() if (pongTimeoutTimer !== null) { clearTimeout(pongTimeoutTimer) pongTimeoutTimer = null } } const startHeartbeat = () => { clearHeartbeatTimers() heartbeatInterval = window.setInterval(() => { if (status.value !== 'OPEN') return try { lastPingAt.value = Date.now() send('ping') if (pongTimeoutTimer !== null) { clearTimeout(pongTimeoutTimer) } pongTimeoutTimer = window.setTimeout(() => { const pongIsFresh = lastPongAt.value >= lastPingAt.value if (pongIsFresh) return safeReconnect('pong timeout').catch((error) => { console.error('admin reconnect after pong timeout failed:', error) }) }, PONG_WAIT_MS) } catch (error) { console.error('admin heartbeat send failed:', error) } }, PING_INTERVAL_MS) } const startStaleConnectionWatcher = () => { if (staleCheckTimer !== null) return staleCheckTimer = window.setInterval(() => { if (manuallyClosed) return if (status.value !== 'OPEN') return if (!lastSocketActivityAt.value) return const diff = Date.now() - lastSocketActivityAt.value if (diff < STALE_FALLBACK_MS) return safeReconnect('stale fallback').catch((error) => { console.error('admin stale reconnect failed:', error) }) }, HEARTBEAT_CHECK_INTERVAL_MS) } const bindNetworkListeners = () => { if (networkListenersBound) return networkListenersBound = true startStaleConnectionWatcher() window.addEventListener('online', () => { if (manuallyClosed) return safeReconnect('browser online').catch((error) => { console.error('admin reconnect on online failed:', error) }) }) document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') { stopFocusHeartbeat() if (lastReportedFocusRoomId) { void syncAdminChatFocus(null, lastReportedFocusRoomId) } return } if (isAdminSupportPageOpen.value && activeRoomId.value) { if (canTreatAdminRoomAsRead(activeRoomId.value)) { clearRoomUnread(activeRoomId.value) scheduleAdminReadSignal(80) } } refreshFocusHeartbeat() if (!shouldKeepAdminSocketAlive()) return if (status.value !== 'OPEN') { safeReconnect('admin tab visible').catch((error) => { console.error('admin reconnect on visibilitychange failed:', error) }) } }) startReconnectWatchdog() } const { status, send, open, close } = useWebSocket(socketUrl, { immediate: false, autoReconnect: false, onConnected() { markSocketActivity() lastPongAt.value = Date.now() manuallyClosed = false startHeartbeat() // console.log('Admin chat websocket connected') if (canTreatAdminRoomAsRead(activeRoomId.value)) { scheduleAdminReadSignal(120) flushPendingAdminReadSignal() } }, onDisconnected() { clearHeartbeatTimers() // console.log('Admin chat websocket disconnected') if (!manuallyClosed) { safeReconnect('disconnected').catch((error) => { console.error('reconnect after disconnect failed:', error) }) } }, onError(_, event) { console.error('Admin websocket error:', event) if (!manuallyClosed) { safeReconnect('admin socket error').catch((error) => { console.error('admin reconnect after error failed:', error) }) } }, onMessage(_, event) { markSocketActivity() const rawData = event.data if (typeof rawData !== 'string') return const trimmed = rawData.trim() if (isHeartbeatPayload(trimmed)) { replyToHeartbeatIfNeeded(trimmed) return } try { const parsed = JSON.parse(trimmed) if (isHeartbeatPayload(parsed)) { replyToHeartbeatIfNeeded(parsed) return } if (parsed?.type === 'seen') { applyAdminSeenPayload(parsed) return } if (parsed?.type === 'typing') return const message = normalizeMessage(parsed, 'socket') if (!message) return const messageId = getRealMessageId(message) if (messageId) { clearPendingOutgoing(messageId) } appendMessageToRoom(message) } catch (error) { console.error('Invalid admin WS message:', event.data, error) } } }) const safeReconnect = async (reason = 'unknown') => { if (manuallyClosed) return if (reconnectingPromise) return reconnectingPromise reconnectingPromise = (async () => { try { console.warn('admin safeReconnect:', reason) clearHeartbeatTimers() if (status.value === 'OPEN' || status.value === 'CONNECTING') { try { close() await waitForSocketClosed(4000) } catch (error) { console.warn('socket did not fully close before reopen:', error) } } const url = buildAdminWsUrl() if (!url) { throw new Error('admin websocket url could not be built') } socketUrl.value = url connectStartedAt.value = Date.now() open() await waitForSocketOpen() connectStartedAt.value = 0 markSocketActivity() lastPongAt.value = Date.now() await fetchAdminRooms().catch((err) => { console.error('fetch admin rooms after reconnect failed:', err) }) if (isAdminSupportPageOpen.value && activeRoomId.value) { fetchAdminRoomMessages(activeRoomId.value, PAGE_SIZE, 0, 'sync_latest') .then(() => { if (isViewingRoomNow(activeRoomId.value)) { scheduleAdminReadSignal(120) } }) .catch((err) => { console.error('sync_latest after reconnect failed:', err) }) } } finally { connectStartedAt.value = 0 reconnectingPromise = null } })() return reconnectingPromise } export const connectAdminChat = async () => { bindNetworkListeners() const url = buildAdminWsUrl() if (!url) { throw new Error('admin websocket url could not be built') } if (status.value === 'OPEN' && socketUrl.value === url) return if (reconnectingPromise) return await reconnectingPromise if (connectPromise) return await connectPromise connectPromise = (async () => { try { manuallyClosed = false socketUrl.value = url if (status.value === 'OPEN') { markSocketActivity() return } if (status.value === 'CONNECTING') { await waitForSocketOpen() connectStartedAt.value = 0 markSocketActivity() return } connectStartedAt.value = Date.now() open() await waitForSocketOpen(CONNECTING_TIMEOUT_MS) connectStartedAt.value = 0 markSocketActivity() lastPongAt.value = Date.now() } finally { connectPromise = null } })() return await connectPromise } export const disconnectAdminChat = () => { manuallyClosed = true clearHeartbeatTimers() clearAllPendingOutgoing() clearReadSignalTimer() lastReadSignalAt.value = 0 lastReadSignalRoomId.value = '' if (status.value === 'OPEN' || status.value === 'CONNECTING') { close() } } let roomsFetchPromise: Promise | null = null export const fetchAdminRooms = async () => { if (roomsFetchPromise) return await roomsFetchPromise roomsFetchPromise = (async () => { roomsLoading.value = true try { const response = await fetch(buildRoomsApiUrl(), { method: 'GET', headers: { Accept: 'application/json' } }) if (!response.ok) { throw new Error(`fetch admin rooms failed: ${response.status}`) } const result = await response.json() const fetched = extractList(result) const existingMap = new Map(rooms.value.map((room) => [getRoomId(room), room])) const merged = fetched.map((room: any) => { const roomId = getRoomId(room) const existing = existingMap.get(roomId) const serverUnread = Number(room?.unread_count ?? room?.unreadCount ?? 0) const localUnread = Number(existing?.unread_count ?? existing?.unreadCount ?? 0) const unread = canTreatAdminRoomAsRead(roomId) ? 0 : Math.max(serverUnread, localUnread) return { ...existing, ...room, unread_count: unread, unreadCount: unread } }) rooms.value = sortRoomsByDate(merged) return rooms.value } finally { roomsLoading.value = false roomsFetchPromise = null } })() return await roomsFetchPromise } export const fetchAdminRoomMessages = async ( roomId: string, limit = PAGE_SIZE, offset = 0, mode: FetchMode = 'replace' ) => { if (!roomId) return [] if (mode === 'replace') { isInitialLoadingByRoom.value = { ...isInitialLoadingByRoom.value, [roomId]: true } } else if (mode === 'prepend') { isFetchingOlderByRoom.value = { ...isFetchingOlderByRoom.value, [roomId]: true } } try { const response = await fetch(buildMessagesApiUrl(roomId, limit, offset), { method: 'GET', headers: { Accept: 'application/json' } }) if (!response.ok) { throw new Error(`fetch admin room messages failed: ${response.status}`) } const result = await response.json() const fetched = extractList(result) const normalizedFetched = fetched .map((item: any) => normalizeMessage(item, 'api')) .filter(Boolean) as InternalAdminMessage[] for (const item of normalizedFetched) { const messageId = getRealMessageId(item) if (messageId) { clearPendingOutgoing(messageId) } } const existing = messagesByRoom.value[roomId] || [] if (mode === 'replace') { const pendingOptimistic = existing.filter((item) => item._optimistic) messagesByRoom.value = { ...messagesByRoom.value, [roomId]: mergeMessageLists([...normalizedFetched, ...pendingOptimistic]) } paginationOffsetByRoom.value = { ...paginationOffsetByRoom.value, [roomId]: fetched.length } hasMoreByRoom.value = { ...hasMoreByRoom.value, [roomId]: fetched.length === limit } } else if (mode === 'prepend') { messagesByRoom.value = { ...messagesByRoom.value, [roomId]: mergeMessageLists([...normalizedFetched, ...existing]) } paginationOffsetByRoom.value = { ...paginationOffsetByRoom.value, [roomId]: Number(paginationOffsetByRoom.value[roomId] || 0) + fetched.length } hasMoreByRoom.value = { ...hasMoreByRoom.value, [roomId]: fetched.length === limit } } else { messagesByRoom.value = { ...messagesByRoom.value, [roomId]: mergeMessageLists([...existing, ...normalizedFetched]) } } if (mode === 'replace' || mode === 'sync_latest') { hasLoadedInitialByRoom.value = { ...hasLoadedInitialByRoom.value, [roomId]: true } flushPendingAdminReadSignal() } if (canTreatAdminRoomAsRead(roomId)) { clearRoomUnread(roomId) scheduleAdminReadSignal(100) } return messagesByRoom.value[roomId] || [] } finally { if (mode === 'replace') { isInitialLoadingByRoom.value = { ...isInitialLoadingByRoom.value, [roomId]: false } } else if (mode === 'prepend') { isFetchingOlderByRoom.value = { ...isFetchingOlderByRoom.value, [roomId]: false } } } } export const fetchOlderAdminRoomMessages = async (roomId: string, limit = PAGE_SIZE) => { if (!roomId) return [] if (isFetchingOlderByRoom.value[roomId]) return messagesByRoom.value[roomId] || [] if (!hasMoreByRoom.value[roomId]) return messagesByRoom.value[roomId] || [] const offset = Number(paginationOffsetByRoom.value[roomId] || 0) return await fetchAdminRoomMessages(roomId, limit, offset, 'prepend') } export const setAdminActiveRoom = async ( roomId: string, options: { fetchIfNeeded?: boolean; syncIfAlreadyLoaded?: boolean } = {} ) => { if (!roomId) return [] const roomChanged = activeRoomId.value !== roomId activeRoomId.value = roomId if (roomChanged) { isAdminActiveRoomAtBottom.value = false clearReadSignalTimer() pendingAdminReadSignalRoomId = '' } refreshFocusHeartbeat() if (isViewingRoomNow(roomId) && isAdminActiveRoomAtBottom.value) { clearRoomUnread(roomId) } const fetchIfNeeded = options.fetchIfNeeded ?? true const syncIfAlreadyLoaded = options.syncIfAlreadyLoaded ?? false const existing = messagesByRoom.value[roomId] || [] const alreadyLoaded = Boolean(hasLoadedInitialByRoom.value[roomId]) if (!fetchIfNeeded) return existing if (!alreadyLoaded) { const result = await fetchAdminRoomMessages(roomId, PAGE_SIZE, 0, 'replace') if (canTreatAdminRoomAsRead(roomId)) { scheduleAdminReadSignal(100) } return result } if (syncIfAlreadyLoaded) { const result = await fetchAdminRoomMessages(roomId, PAGE_SIZE, 0, 'sync_latest') if (canTreatAdminRoomAsRead(roomId)) { scheduleAdminReadSignal(100) } return result } if (canTreatAdminRoomAsRead(roomId)) { scheduleAdminReadSignal(100) } return existing } export const sendAdminMessage = async ( roomId: string, content: string, type: AdminOutgoingMessageType = 'text', replyId: string | null = null ) => { const value = content.trim() const reply = replyId ? String(replyId).trim() : '' if (!roomId) throw new Error('room_id is required') if (!value) return if (status.value !== 'OPEN') { await connectAdminChat() } if (!isSocketFreshForSend()) { await safeReconnect('admin send on stale socket') } if (status.value !== 'OPEN') { throw new Error('admin websocket is not open') } const messageId = createUuid() const optimisticKey = appendOptimisticAdminMessage( roomId, value, messageId, type, reply || null ) const payload: PendingOutgoingAdminMessage['payload'] = { id: messageId, type, room_id: roomId, content: value } if (reply) { payload.reply = reply } pendingOutgoingById.set(messageId, { payload, optimisticKey, attempts: 0, timer: null }) try { send(JSON.stringify(payload)) armOutgoingAckTimeout(messageId) } catch (error) { clearPendingOutgoing(messageId) removeMessageByClientKey(roomId, optimisticKey) throw error } } export const sendAdminTextMessage = async ( roomId: string, content: string, replyId: string | null = null ) => { return await sendAdminMessage(roomId, content, 'text', replyId) } export const sendAdminMediaMessage = async ( roomId: string, url: string, mediaType: 'image' | 'voice', replyId?: string | null ) => { const content = url.trim() if (!roomId) throw new Error('room_id is required') if (!content) return if (status.value !== 'OPEN') { await connectAdminChat() } if (!isSocketFreshForSend()) { await safeReconnect('admin send media on stale socket') } if (status.value !== 'OPEN') { throw new Error('admin websocket is not open') } const messageId = createUuid() const optimistic = normalizeMessage( { id: messageId, room_id: roomId, sender_id: ADMIN_USER_ID, content, type_content: mediaType, type: mediaType, reply: replyId || undefined, created_at: new Date().toISOString() }, 'optimistic' ) const optimisticKey = optimistic?._client_key || null if (optimistic) { appendMessageToRoom(optimistic) } const payload = { id: messageId, type: mediaType, room_id: roomId, content, ...(replyId ? { reply: replyId } : {}) } pendingOutgoingById.set(messageId, { payload, optimisticKey, attempts: 0, timer: null }) try { send(JSON.stringify(payload)) armOutgoingAckTimeout(messageId) } catch (error) { clearPendingOutgoing(messageId) removeMessageByClientKey(roomId, optimisticKey) throw error } } export const bootstrapAdminChat = async ( options: { connectEvenIfNoRooms?: boolean askNotificationPermission?: boolean } = {} ) => { const { connectEvenIfNoRooms = true, askNotificationPermission = false } = options const host = normalizeHost() const apiKey = getApiKey() if (!host || !apiKey) return if (adminChatBootstrapped.value) return if (bootstrapPromise) return await bootstrapPromise bootstrapPromise = (async () => { try { void postAdminChatEnsureWebhook().catch((error) => { console.warn('admin chat ensure webhook failed:', error) }) await fetchAdminRooms() const shouldConnect = connectEvenIfNoRooms || rooms.value.length > 0 if (shouldConnect) { await connectAdminChat() } if (askNotificationPermission && !notificationPermissionAsked.value) { await requestAdminNotificationPermission() } adminChatBootstrapped.value = true } catch (error) { adminChatBootstrapped.value = false throw error } finally { bootstrapPromise = null } })() return await bootstrapPromise } const totalUnreadCount = computed(() => { return rooms.value.reduce((sum, room) => { return sum + Number(room?.unread_count ?? room?.unreadCount ?? 0) }, 0) }) const hasUnreadSupport = computed(() => totalUnreadCount.value > 0) const emitIncomingToastEvent = (message: InternalAdminMessage) => { if (!message) return if (String(message.sender_id) === ADMIN_USER_ID) return const roomId = String(message.room_id || '') if (isViewingRoomNow(roomId)) return const room = rooms.value.find((item) => getRoomId(item) === roomId) const title = getRoomName(room || message) const body = getContentText(message) || 'پیام جدید' latestIncomingNotification.value = { roomId, title, body, at: Date.now() } } export const useAdminChatService = () => { return { status, rooms: computed(() => rooms.value), messagesByRoom: computed(() => messagesByRoom.value), activeRoomId: computed(() => activeRoomId.value), hasMoreByRoom: computed(() => hasMoreByRoom.value), latestIncomingNotification: computed(() => latestIncomingNotification.value), isFetchingOlderByRoom: computed(() => isFetchingOlderByRoom.value), isInitialLoadingByRoom: computed(() => isInitialLoadingByRoom.value), adminRoomsLoading: computed(() => roomsLoading.value), totalUnreadCount, hasUnreadSupport, connectAdminChat, disconnectAdminChat, fetchAdminRooms, fetchAdminRoomMessages, fetchOlderAdminRoomMessages, setAdminActiveRoom, sendAdminTextMessage, sendAdminMessage, setAdminActiveRoomAtBottom, sendAdminMediaMessage } }