Files
boursecu_admin/src/utils/adminChat.ts
T
mohmdsdgandCursor de97d7caa6 Add customer multi-level referral profit editor and related settings UI.
Expose enable_customer_level_profit in config and let admins edit per-customer level percents from the customers table/mobile sheets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 16:48:53 +03:30

1788 lines
45 KiB
TypeScript

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