import { computed, ref, watch } from 'vue' import { useWebSocket } from '@vueuse/core' import { useUserStore } from '@/store/userStore' import { useSettingsStore } from '@/store/settingsStore' import { startChat } from '@/api/chat' import { createUuid } from '@/utils/index' /** true/1/yes/activate */ export function isSettingFlagOn(value: unknown): boolean { const v = String(value ?? '').trim().toLowerCase() return v === 'true' || v === '1' || v === 'yes' || v === 'activate' } /** نمایش چت برای کاربر نهایی: سوییچ دیپلوی + سوییچ مشتری */ export function isUserSupportChatEnabled(): boolean { const settings = useSettingsStore() return ( isSettingFlagOn(settings.getSettingByKey('show_to_users_chat')) && isSettingFlagOn(settings.getSettingByKey('has_chat')) ) } type ChatRawMessage = Record type FetchMode = 'replace' | 'prepend' | 'sync_latest' type MessageSource = 'api' | 'socket' | 'optimistic' type InternalChatMessage = ChatRawMessage & { _source?: MessageSource _client_key?: string _optimistic?: boolean _local_created_at_ms?: number } const PAGE_SIZE = 50 const PING_INTERVAL_MS = 15000 const PONG_WAIT_MS = 8000 const STALE_FALLBACK_MS = 60000 const STALE_CHECK_INTERVAL_MS = 10000 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 type PendingOutgoingMessage = { payload: { id: string type: 'text' | 'image' | 'voice' room_id: string content: string reply?: string | null } optimisticKey: string | null attempts: number timer: number | null } const pendingOutgoingById = new Map() const socketUrl = ref('') const messages = ref([]) const currentRoomId = ref('') const currentChatUserId = ref('') const currentOffset = ref(0) const isSupportChatPageOpen = ref(false) const lastReadSignalAt = ref(0) const lastReadSignalRoomId = ref('') const isSupportChatAtBottom = ref(false) const latestIncomingNotification = ref<{ title: string body: string at: number } | null>(null) export const unreadSupportCount = ref(0) let reconnectWatchTimer: number | null = null let readSignalTimer: number | null = null let pendingReadSignalRoomId = '' const connectStartedAt = ref(0) const clearReconnectWatchTimer = () => { if (reconnectWatchTimer !== null) { clearInterval(reconnectWatchTimer) reconnectWatchTimer = null } } const clearReadSignalTimer = () => { if (readSignalTimer !== null) { window.clearTimeout(readSignalTimer) readSignalTimer = null } } const canSendReadSignal = () => { return ( status.value === 'OPEN' && !!currentRoomId.value && initialMessagesLoaded.value && canTreatSupportAsRead() ) } export const setSupportChatAtBottom = (value: boolean) => { isSupportChatAtBottom.value = value if (!value) { clearReadSignalTimer() return } if (canTreatSupportAsRead()) { unreadSupportCount.value = 0 scheduleReadSignal(80) } } const sendReadSignal = () => { if (!canSendReadSignal()) { if (currentRoomId.value && canTreatSupportAsRead()) { pendingReadSignalRoomId = currentRoomId.value } return } const now = Date.now() const elapsed = now - lastReadSignalAt.value if ( lastReadSignalRoomId.value === currentRoomId.value && elapsed < READ_SIGNAL_DEBOUNCE_MS ) { // Keep pending and retry after debounce — dropping here skips seen for new messages. pendingReadSignalRoomId = currentRoomId.value if (readSignalTimer === null) { scheduleReadSignal(READ_SIGNAL_DEBOUNCE_MS - elapsed + 50) } return } send( JSON.stringify({ type: 'read', room_id: currentRoomId.value }) ) lastReadSignalAt.value = now lastReadSignalRoomId.value = currentRoomId.value pendingReadSignalRoomId = '' } const flushPendingReadSignal = () => { if (!pendingReadSignalRoomId || pendingReadSignalRoomId !== currentRoomId.value) return if (!canTreatSupportAsRead()) { pendingReadSignalRoomId = '' return } sendReadSignal() } const scheduleReadSignal = (delay = 120) => { clearReadSignalTimer() readSignalTimer = window.setTimeout(() => { readSignalTimer = null sendReadSignal() }, delay) } const applySeenPayload = (payload: any) => { if (payload?.type !== 'seen') return false const roomId = String(payload?.room_id || '') if (!roomId || roomId !== currentRoomId.value) return true const readerId = String(payload?.reader_id || '') const myId = String(currentChatUserId.value || '') if (!readerId || readerId === myId || readerId !== 'admin') { 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() messages.value = messages.value.map((item) => { const messageId = getRealMessageId(item) const senderId = String(item?.sender_id ?? item?.user_id ?? '') if (!messageId || !ids.has(String(messageId))) return item if (senderId !== myId) return item return { ...item, read_at: seenAt } }) return true } const shouldKeepChatSocketAlive = () => { const existing = getExistingChatSession() return ( !manuallyClosed && !!existing?.roomId && !!existing?.chatUserId && !!getApiKey() && !!normalizeHost() ) } export const setSupportChatPageOpen = (value: boolean) => { isSupportChatPageOpen.value = value if (!value) { isSupportChatAtBottom.value = false clearReadSignalTimer() pendingReadSignalRoomId = '' return } if (canTreatSupportAsRead()) { unreadSupportCount.value = 0 scheduleReadSignal(80) } } const isViewingSupportNow = () => { return ( isSupportChatPageOpen.value && typeof document !== 'undefined' && document.visibilityState === 'visible' ) } const canTreatSupportAsRead = () => { return isViewingSupportNow() && isSupportChatAtBottom.value } const emitIncomingToastEvent = (message: InternalChatMessage) => { if (String(message.sender_id) === String(currentChatUserId.value)) return if (isViewingSupportNow()) return latestIncomingNotification.value = { title: 'پشتیبانی', body: getContentText(message) || 'پیام جدید', at: Date.now() } } const handleIncomingSupportMessage = (message: InternalChatMessage) => { if (String(message.sender_id) === String(currentChatUserId.value)) return if (isViewingSupportNow()) { unreadSupportCount.value = 0 } else { unreadSupportCount.value += 1 } emitIncomingToastEvent(message) } const startReconnectWatchdog = () => { if (reconnectWatchTimer !== null) return reconnectWatchTimer = window.setInterval(() => { if (!shouldKeepChatSocketAlive()) 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) { shouldSyncOnReconnect = true safeReconnect(connectingTooLong ? 'chat connecting timeout' : 'chat closed watchdog').catch( (error) => { console.error('chat reconnect watchdog failed:', error) } ) } }, RECONNECT_WATCH_INTERVAL_MS) } export const chatLoading = ref(true) export const hasMoreMessages = ref(true) export const isFetchingOlderMessages = ref(false) const initialMessagesLoaded = ref(false) let connectPromise: Promise<{ roomId: string; chatUserId: string } | null> | null = null let reconnectingPromise: Promise<{ roomId: string; chatUserId: string } | null> | null = null let fetchPromise: Promise | null = null let networkListenersBound = false let staleCheckTimer: number | null = null let heartbeatInterval: number | null = null let pongTimeoutTimer: number | null = null let shouldSyncOnReconnect = false let manuallyClosed = false let pendingSyncAfterInitialLoad = false const lastSocketActivityAt = ref(0) const lastPingAt = ref(0) const lastPongAt = ref(0) const normalizeHost = () => { // const host = String('192.168.70.136:8585' || '').trim() const host = String(useSettingsStore().getSettingByKey('chat_api') || '').trim() return host .replace(/^wss?:\/\//, '') .replace(/^https?:\/\//, '') .replace(/\/+$/, '') } const getApiKey = () => String(useSettingsStore().getSettingByKey('chat_api_key') || '').trim() const buildWsUrl = (chatUserId: string) => { const host = normalizeHost() const apiKey = getApiKey() if (!host || !apiKey || !chatUserId) return '' return `wss://${host}/ws?api_key=${encodeURIComponent(apiKey)}&user_id=${encodeURIComponent(chatUserId)}` } const clearPendingOutgoing = (messageId: string) => { const pending = pendingOutgoingById.get(messageId) if (!pending) return 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 = now - lastPongAt.value const activityAge = now - lastSocketActivityAt.value 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(`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('message ack timeout, max resend reached:', messageId) return } latest.attempts += 1 send(JSON.stringify(latest.payload)) armOutgoingAckTimeout(messageId) } catch (error) { console.error('resend after ack timeout failed:', error) } }, OUTGOING_ACK_TIMEOUT_MS) } const buildMessagesApiUrl = (roomId: string, chatUserId: string, limit = PAGE_SIZE, offset = 0) => { const host = normalizeHost() const apiKey = getApiKey() if (!host || !apiKey || !roomId || !chatUserId) { throw new Error( `missing chat settings: host=${host}, apiKey=${apiKey}, roomId=${roomId}, chatUserId=${chatUserId}` ) } return `https://${host}/api/messages?api_key=${encodeURIComponent(apiKey)}&room_id=${encodeURIComponent(roomId)}&user_id=${encodeURIComponent(chatUserId)}&limit=${limit}&offset=${offset}` } const parseUnreadCountFromMessagesResponse = (result: any) => { const value = Number(result?.unread_count ?? result?.unreadCount ?? 0) if (!Number.isFinite(value)) return 0 return Math.max(0, value) } const setCurrentSession = (roomId: string, chatUserId: string) => { currentRoomId.value = roomId currentChatUserId.value = chatUserId } const resetPaginationState = () => { messages.value = [] currentOffset.value = 0 hasMoreMessages.value = true initialMessagesLoaded.value = false pendingSyncAfterInitialLoad = false clearReadSignalTimer() lastReadSignalAt.value = 0 lastReadSignalRoomId.value = '' pendingReadSignalRoomId = '' } const markSocketActivity = () => { lastSocketActivityAt.value = Date.now() } 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 getMessageKey = (item: any) => { return ( getRealMessageId(item) || item?._client_key || `${item?.room_id || 'room'}-${item?.sender_id || item?.user_id || 'x'}-${item?.created_at || item?.createdAt || item?.timestamp || item?._local_created_at_ms || ''}-${getContentText(item)}` ) } 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: InternalChatMessage[]) => { return [...items].sort((a, b) => { const diff = getMessageTs(a) - getMessageTs(b) if (diff !== 0) return diff return String(getMessageKey(a)).localeCompare(String(getMessageKey(b))) }) } 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 normalizeMessage = (payload: any, source: MessageSource): InternalChatMessage | null => { if (!payload) return null const nestedObjectMessage = payload?.message && typeof payload.message === 'object' ? payload.message : null const nestedStringMessage = typeof payload?.message === 'string' && !isHeartbeatPayload(payload?.message) ? 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 const reply = base?.reply ?? payload?.reply ?? base?.reply_id ?? payload?.reply_id ?? base?.reply_to_id ?? payload?.reply_to_id ?? null 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 normalized: InternalChatMessage = { ...base, content, room_id: base?.room_id ?? payload?.room_id ?? currentRoomId.value, sender_id: 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 ?? currentChatUserId.value, type_content: base?.type_content ?? payload?.type_content ?? base?.type ?? payload?.type ?? 'text', created_at: createdAt, reply: reply ? String(reply) : null, _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 = normalized._client_key || getRealMessageId(normalized) || `${source}-${normalized.room_id || 'room'}-${normalized.sender_id || 'sender'}-${content}-${localCreatedAtMs}-${Math.random().toString(36).slice(2, 8)}` if (source === 'optimistic') { normalized._optimistic = true } return normalized } const pickBetterMessage = (oldItem: InternalChatMessage, newItem: InternalChatMessage) => { 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: InternalChatMessage[]) => { const map = new Map() for (const item of items) { const key = getRealMessageId(item) || item._client_key || getMessageKey(item) const prev = map.get(key) map.set(key, prev ? pickBetterMessage(prev, item) : item) } return sortMessagesAsc([...map.values()]) } const appendIncomingMessage = (message: InternalChatMessage) => { messages.value = mergeMessageLists([...messages.value, message]) } const appendOptimisticOutgoingMessage = ( content: string, messageId: string, reply: string | null = null ) => { const optimistic = normalizeMessage( { id: messageId, room_id: currentRoomId.value, sender_id: currentChatUserId.value, content, reply: reply || null, type_content: 'text', created_at: new Date().toISOString() }, 'optimistic' ) if (!optimistic) return null messages.value = mergeMessageLists([...messages.value, optimistic]) return optimistic._client_key || null } const appendOptimisticOutgoingMediaMessage = ( content: string, messageId: string, type: 'image' | 'voice', reply: string | null = null ) => { const optimistic = normalizeMessage( { id: messageId, room_id: currentRoomId.value, sender_id: currentChatUserId.value, content, reply: reply || null, type_content: type, created_at: new Date().toISOString() }, 'optimistic' ) if (!optimistic) return null messages.value = mergeMessageLists([...messages.value, optimistic]) return optimistic._client_key || null } const removeMessageByClientKey = (clientKey: string | null) => { if (!clientKey) return messages.value = messages.value.filter((item) => item._client_key !== clientKey) } const clearHeartbeatTimers = () => { if (heartbeatInterval !== null) { clearInterval(heartbeatInterval) heartbeatInterval = null } if (pongTimeoutTimer !== null) { clearTimeout(pongTimeoutTimer) pongTimeoutTimer = null } } const clearLocalChatSession = () => { const userStore = useUserStore() if (userStore.userInfo) { userStore.userInfo = { ...userStore.userInfo, chat_room_id: null, chat_user_id: null } } currentRoomId.value = '' currentChatUserId.value = '' } const repairStaleChatSession = async () => { clearLocalChatSession() try { const session = await startChatSession() if (!session?.roomId || !session?.chatUserId) return null try { await connectChatSocket(session.roomId, session.chatUserId) } catch (error) { console.warn('reconnect after stale chat repair failed:', error) } return session } catch (error) { console.warn('stale chat session repair failed:', error) return null } } export async function refreshSupportUnreadCount(options?: { allowRepair?: boolean }) { const allowRepair = options?.allowRepair !== false const existing = getExistingChatSession() if (!existing) { unreadSupportCount.value = 0 return 0 } if (canTreatSupportAsRead()) { unreadSupportCount.value = 0 return 0 } try { const roomId = currentRoomId.value || existing.roomId const chatUserId = currentChatUserId.value || existing.chatUserId const url = buildMessagesApiUrl(roomId, chatUserId, 1, 0) const response = await fetch(url, { method: 'GET', cache: 'no-store', headers: { Accept: 'application/json' } }) if (!response.ok) { const detail = await response.text().catch(() => '') console.warn( 'refresh unread support count failed', response.status, detail.slice(0, 200) ) if (allowRepair && (response.status === 401 || response.status === 403)) { const repaired = await repairStaleChatSession() if (repaired) { return refreshSupportUnreadCount({ allowRepair: false }) } } return unreadSupportCount.value } const result = await response.json() const count = parseUnreadCountFromMessagesResponse(result) unreadSupportCount.value = count return count } catch (error) { console.warn('refresh unread support count error:', error) return unreadSupportCount.value } } 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('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('websocket close timeout')) }, timeout) }) } const markPongReceived = () => { lastPongAt.value = Date.now() markSocketActivity() if (pongTimeoutTimer !== null) { clearTimeout(pongTimeoutTimer) pongTimeoutTimer = null } } 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') { if (payload?.id) { send(JSON.stringify({ type: 'pong', id: payload.id })) } else { send(JSON.stringify({ type: 'pong' })) } } else if (type === 'pong') { markPongReceived() } } catch (error) { console.error('heartbeat reply failed:', error) } } 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('reconnect after pong timeout failed:', error) }) }, PONG_WAIT_MS) } catch (error) { console.error('heartbeat send failed:', error) } }, PING_INTERVAL_MS) } const syncMessages = async () => { if (!currentRoomId.value || !currentChatUserId.value) return [] if (fetchPromise) { return await fetchPromise } fetchPromise = fetchChatMessages(PAGE_SIZE, 0, 'sync_latest').finally(() => { fetchPromise = null }) return await fetchPromise } 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('stale reconnect failed:', error) }) }, STALE_CHECK_INTERVAL_MS) } const bindNetworkListeners = () => { if (networkListenersBound) return networkListenersBound = true startStaleConnectionWatcher() startReconnectWatchdog() window.addEventListener('online', () => { if (manuallyClosed) return shouldSyncOnReconnect = true safeReconnect('browser online').catch((error) => { console.error('reconnect on online failed:', error) }) }) document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return if (isSupportChatPageOpen.value) { if (canTreatSupportAsRead()) { unreadSupportCount.value = 0 scheduleReadSignal(80) } } else { refreshSupportUnreadCount().catch((error) => { console.error('refresh unread count on visibilitychange failed:', error) }) } if (!shouldKeepChatSocketAlive()) return shouldSyncOnReconnect = true if (status.value !== 'OPEN') { safeReconnect('tab visible').catch((error) => { console.error('sync on visibilitychange failed:', error) }) } }) } const { status, send, open, close } = useWebSocket(socketUrl, { immediate: false, autoReconnect: false, onConnected() { console.log('Chat websocket connected') markSocketActivity() lastPongAt.value = Date.now() manuallyClosed = false startHeartbeat() if (shouldSyncOnReconnect) { shouldSyncOnReconnect = false syncMessages() .then(() => { if (isViewingSupportNow()) { scheduleReadSignal(120) } else { refreshSupportUnreadCount().catch((error) => { console.error('refresh unread count after reconnect sync failed:', error) }) } }) .catch((error) => { console.error('sync after reconnect failed:', error) }) return } if (isViewingSupportNow()) { scheduleReadSignal(120) flushPendingReadSignal() } else { refreshSupportUnreadCount().catch((error) => { console.error('refresh unread count after socket connected failed:', error) }) } }, onDisconnected() { console.log('Chat websocket disconnected') clearHeartbeatTimers() if (!manuallyClosed && currentRoomId.value && currentChatUserId.value) { shouldSyncOnReconnect = true safeReconnect('disconnected').catch((error) => { console.error('reconnect after disconnect failed:', error) }) } }, onError(_, event) { console.error('WebSocket error:', event) if (!manuallyClosed) { shouldSyncOnReconnect = true safeReconnect('socket error').catch((error) => { console.error('reconnect after socket error failed:', error) }) } }, onMessage(_, event) { markSocketActivity() const rawData = event.data if (typeof rawData !== 'string') { console.log('Unhandled non-string WS payload:', rawData) 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') { applySeenPayload(parsed) return } if (parsed?.type === 'typing') return const normalizedMessage = normalizeMessage(parsed, 'socket') if (!normalizedMessage) { console.log('Unhandled WS payload:', parsed) return } const messageId = getRealMessageId(normalizedMessage) if (messageId) { clearPendingOutgoing(messageId) } appendIncomingMessage(normalizedMessage) handleIncomingSupportMessage(normalizedMessage) if (!initialMessagesLoaded.value) { pendingSyncAfterInitialLoad = true return } if ( String(normalizedMessage.sender_id) !== String(currentChatUserId.value) && isViewingSupportNow() && isSupportChatAtBottom.value ) { scheduleReadSignal(120) } } catch (error) { console.log('Non-JSON WS message:', trimmed) } } }) export const getExistingChatSession = () => { const userStore = useUserStore() const roomId = userStore.userInfo?.chat_room_id const chatUserId = userStore.userInfo?.chat_user_id if (roomId != null && chatUserId != null) { setCurrentSession(roomId, chatUserId) return { roomId: currentRoomId.value, chatUserId: currentChatUserId.value } } return null } const safeReconnect = async (reason = 'unknown') => { if (manuallyClosed) return null if (reconnectingPromise) return reconnectingPromise reconnectingPromise = (async () => { try { console.warn('chat safeReconnect:', reason) const existing = getExistingChatSession() if (!existing) return null shouldSyncOnReconnect = true 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 = buildWsUrl(existing.chatUserId) if (!url) { throw new Error('chat websocket url could not be built') } setCurrentSession(existing.roomId, existing.chatUserId) socketUrl.value = url connectStartedAt.value = Date.now() open() await waitForSocketOpen(CONNECTING_TIMEOUT_MS) markSocketActivity() lastPongAt.value = Date.now() if (shouldSyncOnReconnect) { shouldSyncOnReconnect = false syncMessages() .then(() => { if (isViewingSupportNow()) { scheduleReadSignal(120) } else { refreshSupportUnreadCount().catch((error) => { console.error('refresh unread count after reconnect sync failed:', error) }) } }) .catch((error) => { console.error('sync after reconnect failed:', error) }) return } return existing } finally { connectStartedAt.value = 0 reconnectingPromise = null } })() return await reconnectingPromise } export const startChatSession = async () => { const userStore = useUserStore() chatLoading.value = true try { const response = await startChat() if (!response?.user) { throw new Error('start-chat request failed') } const roomId = response.user?.chat_room_id const chatUserId = response.user?.chat_user_id if (!roomId || !chatUserId) { throw new Error('chat_room_id or chat_user_id missing in start-chat response') } const roomChanged = !!currentRoomId.value && String(currentRoomId.value) !== String(roomId) userStore.userInfo = { ...userStore.userInfo, chat_room_id: roomId, chat_user_id: chatUserId } if (roomChanged) { resetPaginationState() } setCurrentSession(roomId, chatUserId) return { roomId, chatUserId } } finally { chatLoading.value = false } } export const connectChatSocket = async (roomId: string, chatUserId: string) => { bindNetworkListeners() const url = buildWsUrl(chatUserId) if (!url) { throw new Error('chat websocket url could not be built') } const roomChanged = !!currentRoomId.value && currentRoomId.value !== roomId if (roomChanged) { resetPaginationState() } setCurrentSession(roomId, chatUserId) if ( status.value === 'OPEN' && socketUrl.value === url && currentRoomId.value === roomId && currentChatUserId.value === chatUserId ) { return { roomId, chatUserId } } if (reconnectingPromise) { return await reconnectingPromise } if (connectPromise) { return await connectPromise } connectPromise = (async () => { try { manuallyClosed = false socketUrl.value = url if (status.value === 'OPEN') { markSocketActivity() return { roomId, chatUserId } } if (status.value === 'CONNECTING') { await waitForSocketOpen(CONNECTING_TIMEOUT_MS) connectStartedAt.value = 0 markSocketActivity() return { roomId, chatUserId } } connectStartedAt.value = Date.now() open() await waitForSocketOpen(CONNECTING_TIMEOUT_MS) connectStartedAt.value = 0 markSocketActivity() lastPongAt.value = Date.now() return { roomId, chatUserId } } finally { connectStartedAt.value = 0 connectPromise = null } })() return await connectPromise } export const connectChatIfExists = async () => { const existing = getExistingChatSession() if (!existing) return null return await connectChatSocket(existing.roomId, existing.chatUserId) } export const ensureChatAndConnect = async () => { const session = await startChatSession() return await connectChatSocket(session.roomId, session.chatUserId) } export const disconnectChat = () => { manuallyClosed = true clearHeartbeatTimers() clearAllPendingOutgoing() clearReadSignalTimer() lastReadSignalAt.value = 0 lastReadSignalRoomId.value = '' pendingReadSignalRoomId = '' if (status.value === 'OPEN' || status.value === 'CONNECTING') { close() } } export const fetchChatMessages = async ( limit = PAGE_SIZE, offset = 0, mode: FetchMode = 'replace' ) => { if (!currentRoomId.value || !currentChatUserId.value) { const existing = getExistingChatSession() if (!existing) return [] } if (mode === 'replace') { chatLoading.value = true } else if (mode === 'prepend') { isFetchingOlderMessages.value = true } try { const url = buildMessagesApiUrl(currentRoomId.value, currentChatUserId.value, limit, offset) const response = await fetch(url, { method: 'GET', cache: 'no-store', headers: { Accept: 'application/json' } }) if (!response.ok) { throw new Error('fetch messages failed') } const result = await response.json() const fetched = Array.isArray(result) ? result : result?.data || [] if (!Array.isArray(result)) { const unreadCount = parseUnreadCountFromMessagesResponse(result) if (isViewingSupportNow()) { unreadSupportCount.value = 0 } else { unreadSupportCount.value = unreadCount } } const normalizedFetched = fetched .map((item: any) => normalizeMessage(item, 'api')) .filter(Boolean) as InternalChatMessage[] for (const item of normalizedFetched) { const messageId = getRealMessageId(item) if (messageId) { clearPendingOutgoing(messageId) } } if (mode === 'replace') { const pendingOptimistic = messages.value.filter( (item) => item._optimistic && item.room_id === currentRoomId.value ) messages.value = mergeMessageLists([...normalizedFetched, ...pendingOptimistic]) currentOffset.value = fetched.length hasMoreMessages.value = fetched.length === limit initialMessagesLoaded.value = true flushPendingReadSignal() if (pendingSyncAfterInitialLoad) { pendingSyncAfterInitialLoad = false window.setTimeout(() => { syncMessages().catch((error) => { console.error('sync after initial load failed:', error) }) }, 0) } } else if (mode === 'prepend') { messages.value = mergeMessageLists([...normalizedFetched, ...messages.value]) currentOffset.value += fetched.length hasMoreMessages.value = fetched.length === limit } else { messages.value = mergeMessageLists([...messages.value, ...normalizedFetched]) } if (isViewingSupportNow() && isSupportChatAtBottom.value) { unreadSupportCount.value = 0 scheduleReadSignal(100) } return messages.value } finally { if (mode === 'replace') { chatLoading.value = false } else if (mode === 'prepend') { isFetchingOlderMessages.value = false } } } export const fetchOlderChatMessages = async (limit = PAGE_SIZE) => { if (isFetchingOlderMessages.value) return messages.value if (!hasMoreMessages.value) return messages.value return await fetchChatMessages(limit, currentOffset.value, 'prepend') } export const sendTextMessage = async (content: string, reply: string | null = null) => { const text = content.trim() if (!text) return let existing = getExistingChatSession() if (!existing) { existing = await ensureChatAndConnect() } else if (status.value !== 'OPEN') { await connectChatSocket(existing.roomId, existing.chatUserId) } if (!isSocketFreshForSend()) { await safeReconnect('send on stale socket') } if (status.value !== 'OPEN') { throw new Error('websocket is not open') } if (!currentRoomId.value) { throw new Error('room_id is missing') } const messageId = createUuid() const replyId = reply ? String(reply) : null const optimisticKey = appendOptimisticOutgoingMessage(text, messageId, replyId) const payload: PendingOutgoingMessage['payload'] = { id: messageId, type: 'text', room_id: currentRoomId.value, content: text } if (replyId) { payload.reply = replyId } pendingOutgoingById.set(messageId, { payload, optimisticKey, attempts: 0, timer: null }) try { console.log('chat ws payload:', payload) send(JSON.stringify(payload)) armOutgoingAckTimeout(messageId) } catch (error) { clearPendingOutgoing(messageId) removeMessageByClientKey(optimisticKey) throw error } } export const sendMediaMessage = async ( content: string, type: 'image' | 'voice', reply: string | null = null ) => { const filePath = String(content || '').trim() if (!filePath) return let existing = getExistingChatSession() if (!existing) { existing = await ensureChatAndConnect() } else if (status.value !== 'OPEN') { await connectChatSocket(existing.roomId, existing.chatUserId) } if (!isSocketFreshForSend()) { await safeReconnect('send media on stale socket') } if (status.value !== 'OPEN') { throw new Error('websocket is not open') } if (!currentRoomId.value) { throw new Error('room_id is missing') } const messageId = createUuid() const replyId = reply ? String(reply) : null const optimisticKey = appendOptimisticOutgoingMediaMessage(filePath, messageId, type, replyId) const payload: PendingOutgoingMessage['payload'] = { id: messageId, type, room_id: currentRoomId.value, content: filePath } if (replyId) { payload.reply = replyId } pendingOutgoingById.set(messageId, { payload, optimisticKey, attempts: 0, timer: null }) try { console.log('chat media ws payload:', payload) send(JSON.stringify(payload)) armOutgoingAckTimeout(messageId) } catch (error) { clearPendingOutgoing(messageId) removeMessageByClientKey(optimisticKey) throw error } } const clearAllPendingOutgoing = () => { for (const [messageId, pending] of pendingOutgoingById.entries()) { if (pending.timer !== null) { window.clearTimeout(pending.timer) } pendingOutgoingById.delete(messageId) } } export const useChatService = () => { return { status, messages: computed(() => messages.value), currentRoomId: computed(() => currentRoomId.value), currentChatUserId: computed(() => currentChatUserId.value), unreadSupportCount: computed(() => unreadSupportCount.value), latestIncomingNotification: computed(() => latestIncomingNotification.value), setSupportChatPageOpen, getExistingChatSession, startChatSession, connectChatIfExists, ensureChatAndConnect, connectChatSocket, disconnectChat, fetchChatMessages, sendMediaMessage, fetchOlderChatMessages, sendTextMessage, refreshSupportUnreadCount, setSupportChatAtBottom, hasMoreMessages: computed(() => hasMoreMessages.value), isFetchingOlderMessages: computed(() => isFetchingOlderMessages.value) } }