1578 lines
40 KiB
TypeScript
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)
|
|
}
|
|
}
|