Files
ghatreh_user/src/utils/chat.ts
T

1578 lines
40 KiB
TypeScript

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, getToken } 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 {
if (!getToken()) return false
const settings = useSettingsStore()
return (
isSettingFlagOn(settings.getSettingByKey('show_to_users_chat')) &&
isSettingFlagOn(settings.getSettingByKey('has_chat'))
)
}
type ChatRawMessage = Record<string, any>
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<string, PendingOutgoingMessage>()
const socketUrl = ref('')
const messages = ref<InternalChatMessage[]>([])
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<any[]> | 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<string, InternalChatMessage>()
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<void>((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<void>((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)
}
}