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>
1788 lines
45 KiB
TypeScript
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
|
|
}
|
|
}
|