+
@@ -9,7 +9,7 @@
@@ -26,7 +26,7 @@ import MobileBottomNav from './MobileBottomNav.vue'
const route = useRoute()
const {
- hideMenu,
+ hideBottomNav,
navItemClass,
navigateTo,
} = useAppNavigation()
diff --git a/src/models/fund.ts b/src/models/fund.ts
index 3d193c1..af181f4 100644
--- a/src/models/fund.ts
+++ b/src/models/fund.ts
@@ -1,16 +1,19 @@
import type {Wallet} from '@/models/wallet';
+import type {Product} from '@/models/products';
export interface Fund {
id?: number,
title?: string,
file_id?: number,
customer_id?: number,
+ product_id?: number,
created_at?: string,
updated_at?: string,
deleted_at?: string,
- wallet?: Wallet[] | Wallet
+ wallet?: Wallet[] | Wallet,
+ product?: Product,
}
export interface FundTransaction {
fund_id?: number
value?: number
-}
\ No newline at end of file
+}
diff --git a/src/models/login.ts b/src/models/login.ts
index 1974c6b..953a3aa 100644
--- a/src/models/login.ts
+++ b/src/models/login.ts
@@ -52,6 +52,9 @@ export interface User {
finger_print: boolean
gold: number;
+ chat_room_id?: string | null
+ chat_user_id?: string | null
+ settings?: Array<{ key?: string; value?: string | number }>
}
export interface Login {
diff --git a/src/models/support.ts b/src/models/support.ts
new file mode 100644
index 0000000..3a23ca8
--- /dev/null
+++ b/src/models/support.ts
@@ -0,0 +1,77 @@
+export type ChatAttachmentType = 'image' | 'audio'
+
+export type ChatMessageSender = 'user' | 'support' | 'admin'
+
+export type ChatMessageStatus = 'sending' | 'sent' | 'seen' | 'failed'
+
+export type ChatAttachment = {
+ id: string
+ type: 'image' | 'audio'
+ url: string
+ name?: string
+ size?: number
+ mimeType?: string
+ durationMs?: number
+ file?: File
+ uploading?: boolean
+ uploadProgress?: number
+}
+
+export type ChatReplyPreview = {
+ id: string
+ sender: string
+ text?: string
+ attachment?: {
+ type: 'image' | 'audio'
+ url: string
+ name?: string
+ } | null
+}
+
+export interface ChatMessage {
+ id: string
+ text?: string
+ sender: 'user' | 'support'
+ createdAt: string
+ createdAtRaw?: string | number | Date | null
+ status?: 'sending' | 'sent' | 'seen'
+ attachments?: ChatAttachment[]
+
+ // reply
+ reply?: string | null
+ replyTo?: ChatReplyPreview | null
+}
+
+export type ComposerSendPayload = {
+ text: string
+ attachments: ChatAttachment[]
+ reply?: string | null
+ replyTo?: ChatReplyPreview | null
+}
+
+
+export interface SupportMessage {
+ id: string
+ sender: 'user' | 'admin'
+ text?: string
+ createdAt: string
+ createdAtRaw?: string | number | Date | null
+ status?: ChatMessageStatus
+ attachments?: ChatAttachment[]
+
+ // reply
+ reply?: string | null
+ replyTo?: ChatReplyPreview | null
+}
+
+export interface SupportConversation {
+ id: string
+ userName: string
+ avatar: string
+ online: boolean
+ unreadCount: number
+ lastSeenText: string
+ lastMessagePreview: string
+ updatedAtText: string
+ messages: SupportMessage[]
+}
diff --git a/src/router/index.ts b/src/router/index.ts
index ac8a03e..6cbfe4f 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -56,6 +56,12 @@ const router = createRouter({
meta: {hideMenu: true},
component: () => import('@/views/base/about/index.vue')
},
+ {
+ path: 'support',
+ name: 'support',
+ meta: { hideBottomNav: true },
+ component: () => import('@/views/support/index.vue')
+ },
{
path: 'notifyTrigger',
name: 'notifyTrigger',
@@ -127,6 +133,21 @@ const router = createRouter({
name: 'fund_add',
component: () => import('@/views/base/fund/newFund/index.vue')
},
+ {
+ path: 'piggy',
+ name: 'piggy',
+ component: () => import('@/views/base/piggy/index.vue')
+ },
+ {
+ path: 'piggy/add',
+ name: 'piggy_add',
+ component: () => import('@/views/base/piggy/new.vue')
+ },
+ {
+ path: 'piggy/:id',
+ name: 'piggy_info',
+ component: () => import('@/views/base/piggy/info.vue')
+ },
]
},
{
diff --git a/src/store/fundStore.ts b/src/store/fundStore.ts
index ea8f009..e10e680 100644
--- a/src/store/fundStore.ts
+++ b/src/store/fundStore.ts
@@ -1,16 +1,18 @@
-import { type ComputedRef, ref } from 'vue'
-// import { get } from 'lodash'
-import {defineStore} from "pinia";
-import {getMe} from "@/api/me";
-import {getFundTable} from "@/api/fund";
-import type {Fund} from "@/models/fund";
-import type {TableOutput} from "@/models/table";
+import { ref } from 'vue'
+import { defineStore } from 'pinia'
+import { getFundTable } from '@/api/fund'
+import type { Fund } from '@/models/fund'
+import type { TableOutput } from '@/models/table'
import { getProductInventory } from '@/api/products'
+
export const useFundUserStore = defineStore('fund', () => {
+ /** داراییها / موجودی محصول (صفحهٔ /fund فعلی) */
const myFunds = ref
| undefined>(undefined)
+ /** قلکهای واقعی (مدل Fund) */
+ const myPiggyFunds = ref | undefined>(undefined)
+
async function getMyFund() {
try {
- console.log('fff')
myFunds.value = await getProductInventory()
return myFunds.value
} catch (e) {
@@ -18,22 +20,14 @@ export const useFundUserStore = defineStore('fund', () => {
}
}
-/* const permissions: ComputedRef = computed(() => {
- const permissions: string[] = get(userInfo.value, 'permission', [])
- const excludes: string[] = get(userInfo.value, 'exclude', [])
- return permissions.filter((item) => !excludes.includes(item))
- })*/
-
-/*
- function canAccess(permissionKey: string) {
- if (isAdmin.value) return true
- return permissions.value.includes(permissionKey)
+ async function getMyPiggyFunds() {
+ try {
+ myPiggyFunds.value = await getFundTable()
+ return myPiggyFunds.value
+ } catch (e) {
+ console.log(e)
+ }
}
-*/
-/*
- const isAdmin = computed(() => {
- return userInfo.value?.is_admin
- })*/
- return { getMyFund, myFunds/* permissions, canAccess, isAdmin*/ }
+ return { getMyFund, getMyPiggyFunds, myFunds, myPiggyFunds }
})
diff --git a/src/utils/browserNotification.ts b/src/utils/browserNotification.ts
index e9fa3bd..a9c6460 100644
--- a/src/utils/browserNotification.ts
+++ b/src/utils/browserNotification.ts
@@ -1,4 +1,4 @@
-/** نوتیف مرورگر بدون Firebase — فقط با کلیک کاربر (gesture) قابل درخواست است. */
+/** نوتیف مرورگر بدون Firebase — ترجیحاً با gesture کاربر؛ بعد از لاگین هم تلاش میشود. */
export type BrowserNotifState =
| 'unsupported'
@@ -77,11 +77,28 @@ export async function requestBrowserNotificationPermission(): Promise
+type FetchMode = 'replace' | 'prepend' | 'sync_latest'
+type MessageSource = 'api' | 'socket' | 'optimistic'
+
+type InternalChatMessage = ChatRawMessage & {
+ _source?: MessageSource
+ _client_key?: string
+ _optimistic?: boolean
+ _local_created_at_ms?: number
+}
+
+const PAGE_SIZE = 50
+
+const PING_INTERVAL_MS = 15000
+const PONG_WAIT_MS = 8000
+const STALE_FALLBACK_MS = 60000
+const STALE_CHECK_INTERVAL_MS = 10000
+const OUTGOING_ACK_TIMEOUT_MS = 7000
+const MAX_RESEND_ATTEMPTS = 1
+const CONNECTING_TIMEOUT_MS = 12000
+const RECONNECT_WATCH_INTERVAL_MS = 5000
+const READ_SIGNAL_DEBOUNCE_MS = 1200
+
+type PendingOutgoingMessage = {
+ payload: {
+ id: string
+ type: 'text' | 'image' | 'voice'
+ room_id: string
+ content: string
+ reply?: string | null
+ }
+ optimisticKey: string | null
+ attempts: number
+ timer: number | null
+}
+
+const pendingOutgoingById = new Map()
+
+const socketUrl = ref('')
+const messages = ref([])
+const currentRoomId = ref('')
+const currentChatUserId = ref('')
+const currentOffset = ref(0)
+const isSupportChatPageOpen = ref(false)
+const lastReadSignalAt = ref(0)
+const lastReadSignalRoomId = ref('')
+const isSupportChatAtBottom = ref(false)
+const latestIncomingNotification = ref<{
+ title: string
+ body: string
+ at: number
+} | null>(null)
+
+export const unreadSupportCount = ref(0)
+
+let reconnectWatchTimer: number | null = null
+let readSignalTimer: number | null = null
+let pendingReadSignalRoomId = ''
+
+const connectStartedAt = ref(0)
+
+const clearReconnectWatchTimer = () => {
+ if (reconnectWatchTimer !== null) {
+ clearInterval(reconnectWatchTimer)
+ reconnectWatchTimer = null
+ }
+}
+
+const clearReadSignalTimer = () => {
+ if (readSignalTimer !== null) {
+ window.clearTimeout(readSignalTimer)
+ readSignalTimer = null
+ }
+}
+
+const canSendReadSignal = () => {
+ return (
+ status.value === 'OPEN' &&
+ !!currentRoomId.value &&
+ initialMessagesLoaded.value &&
+ canTreatSupportAsRead()
+ )
+}
+
+export const setSupportChatAtBottom = (value: boolean) => {
+ isSupportChatAtBottom.value = value
+
+ if (!value) {
+ clearReadSignalTimer()
+ return
+ }
+
+ if (canTreatSupportAsRead()) {
+ unreadSupportCount.value = 0
+ scheduleReadSignal(80)
+ }
+}
+
+const sendReadSignal = () => {
+ if (!canSendReadSignal()) {
+ if (currentRoomId.value && canTreatSupportAsRead()) {
+ pendingReadSignalRoomId = currentRoomId.value
+ }
+ return
+ }
+
+ const now = Date.now()
+ const elapsed = now - lastReadSignalAt.value
+
+ if (
+ lastReadSignalRoomId.value === currentRoomId.value &&
+ elapsed < READ_SIGNAL_DEBOUNCE_MS
+ ) {
+ // Keep pending and retry after debounce — dropping here skips seen for new messages.
+ pendingReadSignalRoomId = currentRoomId.value
+ if (readSignalTimer === null) {
+ scheduleReadSignal(READ_SIGNAL_DEBOUNCE_MS - elapsed + 50)
+ }
+ return
+ }
+
+ send(
+ JSON.stringify({
+ type: 'read',
+ room_id: currentRoomId.value
+ })
+ )
+
+ lastReadSignalAt.value = now
+ lastReadSignalRoomId.value = currentRoomId.value
+ pendingReadSignalRoomId = ''
+}
+
+const flushPendingReadSignal = () => {
+ if (!pendingReadSignalRoomId || pendingReadSignalRoomId !== currentRoomId.value) return
+ if (!canTreatSupportAsRead()) {
+ pendingReadSignalRoomId = ''
+ return
+ }
+ sendReadSignal()
+}
+
+const scheduleReadSignal = (delay = 120) => {
+ clearReadSignalTimer()
+
+ readSignalTimer = window.setTimeout(() => {
+ readSignalTimer = null
+ sendReadSignal()
+ }, delay)
+}
+
+const applySeenPayload = (payload: any) => {
+ if (payload?.type !== 'seen') return false
+
+ const roomId = String(payload?.room_id || '')
+ if (!roomId || roomId !== currentRoomId.value) return true
+
+ const readerId = String(payload?.reader_id || '')
+ const myId = String(currentChatUserId.value || '')
+
+ if (!readerId || readerId === myId || readerId !== 'admin') {
+ return true
+ }
+
+ const ids = new Set(
+ Array.isArray(payload?.seen_message_ids)
+ ? payload.seen_message_ids.map((id: any) => String(id))
+ : []
+ )
+
+ if (!ids.size) return true
+
+ const seenAt = payload?.seen_at || new Date().toISOString()
+
+ messages.value = messages.value.map((item) => {
+ const messageId = getRealMessageId(item)
+ const senderId = String(item?.sender_id ?? item?.user_id ?? '')
+
+ if (!messageId || !ids.has(String(messageId))) return item
+ if (senderId !== myId) return item
+
+ return {
+ ...item,
+ read_at: seenAt
+ }
+ })
+
+ return true
+}
+
+const shouldKeepChatSocketAlive = () => {
+ const existing = getExistingChatSession()
+ return (
+ !manuallyClosed &&
+ !!existing?.roomId &&
+ !!existing?.chatUserId &&
+ !!getApiKey() &&
+ !!normalizeHost()
+ )
+}
+
+export const setSupportChatPageOpen = (value: boolean) => {
+ isSupportChatPageOpen.value = value
+
+ if (!value) {
+ isSupportChatAtBottom.value = false
+ clearReadSignalTimer()
+ pendingReadSignalRoomId = ''
+ return
+ }
+
+ if (canTreatSupportAsRead()) {
+ unreadSupportCount.value = 0
+ scheduleReadSignal(80)
+ }
+}
+
+const isViewingSupportNow = () => {
+ return (
+ isSupportChatPageOpen.value &&
+ typeof document !== 'undefined' &&
+ document.visibilityState === 'visible'
+ )
+}
+
+const canTreatSupportAsRead = () => {
+ return isViewingSupportNow() && isSupportChatAtBottom.value
+}
+
+const emitIncomingToastEvent = (message: InternalChatMessage) => {
+ if (String(message.sender_id) === String(currentChatUserId.value)) return
+ if (isViewingSupportNow()) return
+
+ latestIncomingNotification.value = {
+ title: 'پشتیبانی',
+ body: getContentText(message) || 'پیام جدید',
+ at: Date.now()
+ }
+}
+
+const handleIncomingSupportMessage = (message: InternalChatMessage) => {
+ if (String(message.sender_id) === String(currentChatUserId.value)) return
+
+ if (isViewingSupportNow()) {
+ unreadSupportCount.value = 0
+ } else {
+ unreadSupportCount.value += 1
+ }
+
+ emitIncomingToastEvent(message)
+}
+
+const startReconnectWatchdog = () => {
+ if (reconnectWatchTimer !== null) return
+
+ reconnectWatchTimer = window.setInterval(() => {
+ if (!shouldKeepChatSocketAlive()) return
+
+ const now = Date.now()
+
+ if (status.value === 'OPEN') return
+
+ const connectingTooLong =
+ status.value === 'CONNECTING' &&
+ connectStartedAt.value > 0 &&
+ now - connectStartedAt.value > CONNECTING_TIMEOUT_MS
+
+ if (status.value === 'CLOSED' || connectingTooLong) {
+ shouldSyncOnReconnect = true
+ safeReconnect(connectingTooLong ? 'chat connecting timeout' : 'chat closed watchdog').catch(
+ (error) => {
+ console.error('chat reconnect watchdog failed:', error)
+ }
+ )
+ }
+ }, RECONNECT_WATCH_INTERVAL_MS)
+}
+
+export const chatLoading = ref(true)
+export const hasMoreMessages = ref(true)
+export const isFetchingOlderMessages = ref(false)
+
+const initialMessagesLoaded = ref(false)
+
+let connectPromise: Promise<{ roomId: string; chatUserId: string } | null> | null = null
+let reconnectingPromise: Promise<{ roomId: string; chatUserId: string } | null> | null = null
+let fetchPromise: Promise | null = null
+let networkListenersBound = false
+let staleCheckTimer: number | null = null
+let heartbeatInterval: number | null = null
+let pongTimeoutTimer: number | null = null
+
+let shouldSyncOnReconnect = false
+let manuallyClosed = false
+let pendingSyncAfterInitialLoad = false
+
+const lastSocketActivityAt = ref(0)
+const lastPingAt = ref(0)
+const lastPongAt = ref(0)
+
+const normalizeHost = () => {
+ // const host = String('192.168.70.136:8585' || '').trim()
+ const host = String(useSettingsStore().getSettingByKey('chat_api') || '').trim()
+
+ return host
+ .replace(/^wss?:\/\//, '')
+ .replace(/^https?:\/\//, '')
+ .replace(/\/+$/, '')
+}
+
+const getApiKey = () => String(useSettingsStore().getSettingByKey('chat_api_key') || '').trim()
+
+const buildWsUrl = (chatUserId: string) => {
+ const host = normalizeHost()
+ const apiKey = getApiKey()
+
+ if (!host || !apiKey || !chatUserId) return ''
+
+ return `wss://${host}/ws?api_key=${encodeURIComponent(apiKey)}&user_id=${encodeURIComponent(chatUserId)}`
+}
+
+const clearPendingOutgoing = (messageId: string) => {
+ const pending = pendingOutgoingById.get(messageId)
+ if (!pending) return
+
+ if (pending.timer !== null) {
+ window.clearTimeout(pending.timer)
+ }
+
+ pendingOutgoingById.delete(messageId)
+}
+
+const isSocketFreshForSend = () => {
+ if (status.value !== 'OPEN') return false
+
+ const now = Date.now()
+ const pongAge = now - lastPongAt.value
+ const activityAge = now - lastSocketActivityAt.value
+
+ return pongAge <= PING_INTERVAL_MS + PONG_WAIT_MS + 2000 && activityAge <= STALE_FALLBACK_MS
+}
+
+const armOutgoingAckTimeout = (messageId: string) => {
+ const pending = pendingOutgoingById.get(messageId)
+ if (!pending) return
+
+ if (pending.timer !== null) {
+ window.clearTimeout(pending.timer)
+ }
+
+ pending.timer = window.setTimeout(async () => {
+ const current = pendingOutgoingById.get(messageId)
+ if (!current) return
+
+ try {
+ await safeReconnect(`outgoing ack timeout: ${messageId}`)
+
+ const latest = pendingOutgoingById.get(messageId)
+ if (!latest) return
+ if (status.value !== 'OPEN') return
+
+ if (latest.attempts >= MAX_RESEND_ATTEMPTS) {
+ console.warn('message ack timeout, max resend reached:', messageId)
+ return
+ }
+
+ latest.attempts += 1
+ send(JSON.stringify(latest.payload))
+ armOutgoingAckTimeout(messageId)
+ } catch (error) {
+ console.error('resend after ack timeout failed:', error)
+ }
+ }, OUTGOING_ACK_TIMEOUT_MS)
+}
+
+const buildMessagesApiUrl = (roomId: string, chatUserId: string, limit = PAGE_SIZE, offset = 0) => {
+ const host = normalizeHost()
+ const apiKey = getApiKey()
+
+ if (!host || !apiKey || !roomId || !chatUserId) {
+ throw new Error(
+ `missing chat settings: host=${host}, apiKey=${apiKey}, roomId=${roomId}, chatUserId=${chatUserId}`
+ )
+ }
+
+ return `https://${host}/api/messages?api_key=${encodeURIComponent(apiKey)}&room_id=${encodeURIComponent(roomId)}&user_id=${encodeURIComponent(chatUserId)}&limit=${limit}&offset=${offset}`
+}
+
+const parseUnreadCountFromMessagesResponse = (result: any) => {
+ const value = Number(result?.unread_count ?? result?.unreadCount ?? 0)
+
+ if (!Number.isFinite(value)) return 0
+ return Math.max(0, value)
+}
+
+const setCurrentSession = (roomId: string, chatUserId: string) => {
+ currentRoomId.value = roomId
+ currentChatUserId.value = chatUserId
+}
+
+const resetPaginationState = () => {
+ messages.value = []
+ currentOffset.value = 0
+ hasMoreMessages.value = true
+ initialMessagesLoaded.value = false
+ pendingSyncAfterInitialLoad = false
+
+ clearReadSignalTimer()
+ lastReadSignalAt.value = 0
+ lastReadSignalRoomId.value = ''
+ pendingReadSignalRoomId = ''
+}
+
+const markSocketActivity = () => {
+ lastSocketActivityAt.value = Date.now()
+}
+
+const getContentText = (item: any) => {
+ return String(item?.content ?? item?.text ?? item?.body ?? '').trim()
+}
+
+const getRealMessageId = (item: any) => {
+ return String(item?.id ?? item?.message_id ?? '')
+}
+
+const getMessageKey = (item: any) => {
+ return (
+ getRealMessageId(item) ||
+ item?._client_key ||
+ `${item?.room_id || 'room'}-${item?.sender_id || item?.user_id || 'x'}-${item?.created_at || item?.createdAt || item?.timestamp || item?._local_created_at_ms || ''}-${getContentText(item)}`
+ )
+}
+
+const getMessageTs = (item: any) => {
+ const raw = item?.created_at || item?.createdAt || item?.timestamp
+ const parsed = raw ? new Date(raw).getTime() : NaN
+
+ if (!Number.isNaN(parsed)) return parsed
+ if (item?._local_created_at_ms) return item._local_created_at_ms
+
+ return 0
+}
+
+const sortMessagesAsc = (items: InternalChatMessage[]) => {
+ return [...items].sort((a, b) => {
+ const diff = getMessageTs(a) - getMessageTs(b)
+ if (diff !== 0) return diff
+
+ return String(getMessageKey(a)).localeCompare(String(getMessageKey(b)))
+ })
+}
+
+const isHeartbeatPayload = (payload: any) => {
+ if (!payload) return false
+
+ if (typeof payload === 'string') {
+ const value = payload.trim().toLowerCase()
+ return ['ping', 'pong', 'heartbeat', 'keepalive'].includes(value)
+ }
+
+ const type = String(payload?.type || payload?.event || payload?.message || '').toLowerCase()
+ return ['ping', 'pong', 'heartbeat', 'keepalive'].includes(type)
+}
+
+const normalizeMessage = (payload: any, source: MessageSource): InternalChatMessage | null => {
+ if (!payload) return null
+
+ const nestedObjectMessage =
+ payload?.message && typeof payload.message === 'object' ? payload.message : null
+
+ const nestedStringMessage =
+ typeof payload?.message === 'string' && !isHeartbeatPayload(payload?.message)
+ ? payload.message
+ : ''
+
+ const base = nestedObjectMessage ?? payload
+
+ const content = String(
+ base?.content ?? base?.text ?? base?.body ?? nestedStringMessage ?? ''
+ ).trim()
+
+ const attachments = base?.attachments ?? base?.files ?? base?.media
+
+ const reply =
+ base?.reply ??
+ payload?.reply ??
+ base?.reply_id ??
+ payload?.reply_id ??
+ base?.reply_to_id ??
+ payload?.reply_to_id ??
+ null
+
+ if (!content && !Array.isArray(attachments)) return null
+
+ const createdAt =
+ base?.created_at ??
+ payload?.created_at ??
+ base?.createdAt ??
+ payload?.createdAt ??
+ base?.timestamp ??
+ payload?.timestamp ??
+ new Date().toISOString()
+
+ const localCreatedAtMs = (() => {
+ const parsed = new Date(createdAt).getTime()
+ return Number.isNaN(parsed) ? Date.now() : parsed
+ })()
+
+ const normalized: InternalChatMessage = {
+ ...base,
+ content,
+ room_id: base?.room_id ?? payload?.room_id ?? currentRoomId.value,
+ sender_id:
+ base?.sender_id ??
+ payload?.sender_id ??
+ base?.user_id ??
+ payload?.user_id ??
+ base?.from_user_id ??
+ payload?.from_user_id ??
+ base?.sender?.id ??
+ payload?.sender?.id ??
+ currentChatUserId.value,
+ type_content:
+ base?.type_content ?? payload?.type_content ?? base?.type ?? payload?.type ?? 'text',
+ created_at: createdAt,
+ reply: reply ? String(reply) : null,
+ _source: source,
+ _local_created_at_ms: localCreatedAtMs
+ }
+
+ if (!normalized.id && (base?.message_id || payload?.message_id)) {
+ normalized.message_id = base?.message_id ?? payload?.message_id
+ }
+
+ normalized._client_key =
+ normalized._client_key ||
+ getRealMessageId(normalized) ||
+ `${source}-${normalized.room_id || 'room'}-${normalized.sender_id || 'sender'}-${content}-${localCreatedAtMs}-${Math.random().toString(36).slice(2, 8)}`
+
+ if (source === 'optimistic') {
+ normalized._optimistic = true
+ }
+
+ return normalized
+}
+
+const pickBetterMessage = (oldItem: InternalChatMessage, newItem: InternalChatMessage) => {
+ const oldHasRealId = !!getRealMessageId(oldItem)
+ const newHasRealId = !!getRealMessageId(newItem)
+
+ const preservedClientKey = oldItem._client_key || newItem._client_key
+
+ if (!oldHasRealId && newHasRealId) {
+ return {
+ ...oldItem,
+ ...newItem,
+ _client_key: preservedClientKey,
+ _optimistic: false
+ }
+ }
+
+ if (oldItem._optimistic && !newItem._optimistic) {
+ return {
+ ...oldItem,
+ ...newItem,
+ _client_key: preservedClientKey,
+ _optimistic: false
+ }
+ }
+
+ if (oldItem._source === 'socket' && newItem._source === 'api') {
+ return {
+ ...oldItem,
+ ...newItem,
+ _client_key: preservedClientKey,
+ _optimistic: false
+ }
+ }
+
+ return {
+ ...oldItem,
+ ...newItem,
+ _client_key: preservedClientKey
+ }
+}
+
+const mergeMessageLists = (items: InternalChatMessage[]) => {
+ const map = new Map()
+
+ for (const item of items) {
+ const key = getRealMessageId(item) || item._client_key || getMessageKey(item)
+ const prev = map.get(key)
+
+ map.set(key, prev ? pickBetterMessage(prev, item) : item)
+ }
+
+ return sortMessagesAsc([...map.values()])
+}
+
+const appendIncomingMessage = (message: InternalChatMessage) => {
+ messages.value = mergeMessageLists([...messages.value, message])
+}
+
+const appendOptimisticOutgoingMessage = (
+ content: string,
+ messageId: string,
+ reply: string | null = null
+) => {
+ const optimistic = normalizeMessage(
+ {
+ id: messageId,
+ room_id: currentRoomId.value,
+ sender_id: currentChatUserId.value,
+ content,
+ reply: reply || null,
+ type_content: 'text',
+ created_at: new Date().toISOString()
+ },
+ 'optimistic'
+ )
+
+ if (!optimistic) return null
+
+ messages.value = mergeMessageLists([...messages.value, optimistic])
+ return optimistic._client_key || null
+}
+
+const appendOptimisticOutgoingMediaMessage = (
+ content: string,
+ messageId: string,
+ type: 'image' | 'voice',
+ reply: string | null = null
+) => {
+ const optimistic = normalizeMessage(
+ {
+ id: messageId,
+ room_id: currentRoomId.value,
+ sender_id: currentChatUserId.value,
+ content,
+ reply: reply || null,
+ type_content: type,
+ created_at: new Date().toISOString()
+ },
+ 'optimistic'
+ )
+
+ if (!optimistic) return null
+
+ messages.value = mergeMessageLists([...messages.value, optimistic])
+ return optimistic._client_key || null
+}
+
+const removeMessageByClientKey = (clientKey: string | null) => {
+ if (!clientKey) return
+ messages.value = messages.value.filter((item) => item._client_key !== clientKey)
+}
+
+const clearHeartbeatTimers = () => {
+ if (heartbeatInterval !== null) {
+ clearInterval(heartbeatInterval)
+ heartbeatInterval = null
+ }
+
+ if (pongTimeoutTimer !== null) {
+ clearTimeout(pongTimeoutTimer)
+ pongTimeoutTimer = null
+ }
+}
+
+const clearLocalChatSession = () => {
+ const userStore = useUserStore()
+
+ if (userStore.userInfo) {
+ userStore.userInfo = {
+ ...userStore.userInfo,
+ chat_room_id: null,
+ chat_user_id: null
+ }
+ }
+
+ currentRoomId.value = ''
+ currentChatUserId.value = ''
+}
+
+const repairStaleChatSession = async () => {
+ clearLocalChatSession()
+
+ try {
+ const session = await startChatSession()
+ if (!session?.roomId || !session?.chatUserId) return null
+
+ try {
+ await connectChatSocket(session.roomId, session.chatUserId)
+ } catch (error) {
+ console.warn('reconnect after stale chat repair failed:', error)
+ }
+
+ return session
+ } catch (error) {
+ console.warn('stale chat session repair failed:', error)
+ return null
+ }
+}
+
+export async function refreshSupportUnreadCount(options?: { allowRepair?: boolean }) {
+ const allowRepair = options?.allowRepair !== false
+ const existing = getExistingChatSession()
+
+ if (!existing) {
+ unreadSupportCount.value = 0
+ return 0
+ }
+
+ if (canTreatSupportAsRead()) {
+ unreadSupportCount.value = 0
+ return 0
+ }
+
+ try {
+ const roomId = currentRoomId.value || existing.roomId
+ const chatUserId = currentChatUserId.value || existing.chatUserId
+ const url = buildMessagesApiUrl(roomId, chatUserId, 1, 0)
+
+ const response = await fetch(url, {
+ method: 'GET',
+ cache: 'no-store',
+ headers: {
+ Accept: 'application/json'
+ }
+ })
+
+ if (!response.ok) {
+ const detail = await response.text().catch(() => '')
+ console.warn(
+ 'refresh unread support count failed',
+ response.status,
+ detail.slice(0, 200)
+ )
+
+ if (allowRepair && (response.status === 401 || response.status === 403)) {
+ const repaired = await repairStaleChatSession()
+ if (repaired) {
+ return refreshSupportUnreadCount({ allowRepair: false })
+ }
+ }
+
+ return unreadSupportCount.value
+ }
+
+ const result = await response.json()
+ const count = parseUnreadCountFromMessagesResponse(result)
+
+ unreadSupportCount.value = count
+
+ return count
+ } catch (error) {
+ console.warn('refresh unread support count error:', error)
+ return unreadSupportCount.value
+ }
+}
+
+const waitForSocketOpen = (timeout = 10000) => {
+ return new Promise((resolve, reject) => {
+ if (status.value === 'OPEN') {
+ resolve()
+ return
+ }
+
+ const stop = watch(status, (newStatus) => {
+ if (newStatus === 'OPEN') {
+ clearTimeout(timer)
+ stop()
+ resolve()
+ }
+ })
+
+ const timer = window.setTimeout(() => {
+ stop()
+ reject(new Error('websocket open timeout'))
+ }, timeout)
+ })
+}
+
+const waitForSocketClosed = (timeout = 5000) => {
+ return new Promise((resolve, reject) => {
+ if (status.value === 'CLOSED') {
+ resolve()
+ return
+ }
+
+ const stop = watch(status, (newStatus) => {
+ if (newStatus === 'CLOSED') {
+ clearTimeout(timer)
+ stop()
+ resolve()
+ }
+ })
+
+ const timer = window.setTimeout(() => {
+ stop()
+ reject(new Error('websocket close timeout'))
+ }, timeout)
+ })
+}
+
+const markPongReceived = () => {
+ lastPongAt.value = Date.now()
+ markSocketActivity()
+
+ if (pongTimeoutTimer !== null) {
+ clearTimeout(pongTimeoutTimer)
+ pongTimeoutTimer = null
+ }
+}
+
+const replyToHeartbeatIfNeeded = (payload: any) => {
+ try {
+ if (status.value !== 'OPEN') return
+
+ if (typeof payload === 'string') {
+ const value = payload.trim().toLowerCase()
+
+ if (value === 'ping') {
+ send('pong')
+ } else if (value === 'pong') {
+ markPongReceived()
+ }
+
+ return
+ }
+
+ const type = String(payload?.type || payload?.event || payload?.message || '').toLowerCase()
+
+ if (type === 'ping') {
+ if (payload?.id) {
+ send(JSON.stringify({ type: 'pong', id: payload.id }))
+ } else {
+ send(JSON.stringify({ type: 'pong' }))
+ }
+ } else if (type === 'pong') {
+ markPongReceived()
+ }
+ } catch (error) {
+ console.error('heartbeat reply failed:', error)
+ }
+}
+
+const startHeartbeat = () => {
+ clearHeartbeatTimers()
+
+ heartbeatInterval = window.setInterval(() => {
+ if (status.value !== 'OPEN') return
+
+ try {
+ lastPingAt.value = Date.now()
+ send('ping')
+
+ if (pongTimeoutTimer !== null) {
+ clearTimeout(pongTimeoutTimer)
+ }
+
+ pongTimeoutTimer = window.setTimeout(() => {
+ const pongIsFresh = lastPongAt.value >= lastPingAt.value
+ if (pongIsFresh) return
+
+ safeReconnect('pong timeout').catch((error) => {
+ console.error('reconnect after pong timeout failed:', error)
+ })
+ }, PONG_WAIT_MS)
+ } catch (error) {
+ console.error('heartbeat send failed:', error)
+ }
+ }, PING_INTERVAL_MS)
+}
+
+const syncMessages = async () => {
+ if (!currentRoomId.value || !currentChatUserId.value) return []
+
+ if (fetchPromise) {
+ return await fetchPromise
+ }
+
+ fetchPromise = fetchChatMessages(PAGE_SIZE, 0, 'sync_latest').finally(() => {
+ fetchPromise = null
+ })
+
+ return await fetchPromise
+}
+
+const startStaleConnectionWatcher = () => {
+ if (staleCheckTimer !== null) return
+
+ staleCheckTimer = window.setInterval(() => {
+ if (manuallyClosed) return
+ if (status.value !== 'OPEN') return
+ if (!lastSocketActivityAt.value) return
+
+ const diff = Date.now() - lastSocketActivityAt.value
+ if (diff < STALE_FALLBACK_MS) return
+
+ safeReconnect('stale fallback').catch((error) => {
+ console.error('stale reconnect failed:', error)
+ })
+ }, STALE_CHECK_INTERVAL_MS)
+}
+
+const bindNetworkListeners = () => {
+ if (networkListenersBound) return
+ networkListenersBound = true
+
+ startStaleConnectionWatcher()
+ startReconnectWatchdog()
+
+ window.addEventListener('online', () => {
+ if (manuallyClosed) return
+
+ shouldSyncOnReconnect = true
+ safeReconnect('browser online').catch((error) => {
+ console.error('reconnect on online failed:', error)
+ })
+ })
+
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState !== 'visible') return
+
+ if (isSupportChatPageOpen.value) {
+ if (canTreatSupportAsRead()) {
+ unreadSupportCount.value = 0
+ scheduleReadSignal(80)
+ }
+ } else {
+ refreshSupportUnreadCount().catch((error) => {
+ console.error('refresh unread count on visibilitychange failed:', error)
+ })
+ }
+
+ if (!shouldKeepChatSocketAlive()) return
+
+ shouldSyncOnReconnect = true
+
+ if (status.value !== 'OPEN') {
+ safeReconnect('tab visible').catch((error) => {
+ console.error('sync on visibilitychange failed:', error)
+ })
+ }
+ })
+}
+
+const { status, send, open, close } = useWebSocket(socketUrl, {
+ immediate: false,
+ autoReconnect: false,
+
+ onConnected() {
+ console.log('Chat websocket connected')
+ markSocketActivity()
+ lastPongAt.value = Date.now()
+ manuallyClosed = false
+ startHeartbeat()
+
+ if (shouldSyncOnReconnect) {
+ shouldSyncOnReconnect = false
+
+ syncMessages()
+ .then(() => {
+ if (isViewingSupportNow()) {
+ scheduleReadSignal(120)
+ } else {
+ refreshSupportUnreadCount().catch((error) => {
+ console.error('refresh unread count after reconnect sync failed:', error)
+ })
+ }
+ })
+ .catch((error) => {
+ console.error('sync after reconnect failed:', error)
+ })
+
+ return
+ }
+
+ if (isViewingSupportNow()) {
+ scheduleReadSignal(120)
+ flushPendingReadSignal()
+ } else {
+ refreshSupportUnreadCount().catch((error) => {
+ console.error('refresh unread count after socket connected failed:', error)
+ })
+ }
+ },
+ onDisconnected() {
+ console.log('Chat websocket disconnected')
+ clearHeartbeatTimers()
+
+ if (!manuallyClosed && currentRoomId.value && currentChatUserId.value) {
+ shouldSyncOnReconnect = true
+
+ safeReconnect('disconnected').catch((error) => {
+ console.error('reconnect after disconnect failed:', error)
+ })
+ }
+ },
+
+ onError(_, event) {
+ console.error('WebSocket error:', event)
+
+ if (!manuallyClosed) {
+ shouldSyncOnReconnect = true
+ safeReconnect('socket error').catch((error) => {
+ console.error('reconnect after socket error failed:', error)
+ })
+ }
+ },
+
+ onMessage(_, event) {
+ markSocketActivity()
+
+ const rawData = event.data
+
+ if (typeof rawData !== 'string') {
+ console.log('Unhandled non-string WS payload:', rawData)
+ return
+ }
+
+ const trimmed = rawData.trim()
+
+ if (isHeartbeatPayload(trimmed)) {
+ replyToHeartbeatIfNeeded(trimmed)
+ return
+ }
+
+ try {
+ const parsed = JSON.parse(trimmed)
+
+ if (isHeartbeatPayload(parsed)) {
+ replyToHeartbeatIfNeeded(parsed)
+ return
+ }
+
+ if (parsed?.type === 'seen') {
+ applySeenPayload(parsed)
+ return
+ }
+
+ if (parsed?.type === 'typing') return
+
+ const normalizedMessage = normalizeMessage(parsed, 'socket')
+ if (!normalizedMessage) {
+ console.log('Unhandled WS payload:', parsed)
+ return
+ }
+
+ const messageId = getRealMessageId(normalizedMessage)
+ if (messageId) {
+ clearPendingOutgoing(messageId)
+ }
+
+ appendIncomingMessage(normalizedMessage)
+ handleIncomingSupportMessage(normalizedMessage)
+
+ if (!initialMessagesLoaded.value) {
+ pendingSyncAfterInitialLoad = true
+ return
+ }
+
+ if (
+ String(normalizedMessage.sender_id) !== String(currentChatUserId.value) &&
+ isViewingSupportNow() &&
+ isSupportChatAtBottom.value
+ ) {
+ scheduleReadSignal(120)
+ }
+ } catch (error) {
+ console.log('Non-JSON WS message:', trimmed)
+ }
+ }
+})
+
+export const getExistingChatSession = () => {
+ const userStore = useUserStore()
+ const roomId = userStore.userInfo?.chat_room_id
+ const chatUserId = userStore.userInfo?.chat_user_id
+
+ if (roomId != null && chatUserId != null) {
+ setCurrentSession(roomId, chatUserId)
+ return {
+ roomId: currentRoomId.value,
+ chatUserId: currentChatUserId.value
+ }
+ }
+
+ return null
+}
+
+const safeReconnect = async (reason = 'unknown') => {
+ if (manuallyClosed) return null
+ if (reconnectingPromise) return reconnectingPromise
+
+ reconnectingPromise = (async () => {
+ try {
+ console.warn('chat safeReconnect:', reason)
+
+ const existing = getExistingChatSession()
+ if (!existing) return null
+
+ shouldSyncOnReconnect = true
+ clearHeartbeatTimers()
+
+ if (status.value === 'OPEN' || status.value === 'CONNECTING') {
+ try {
+ close()
+ await waitForSocketClosed(4000)
+ } catch (error) {
+ console.warn('socket did not fully close before reopen:', error)
+ }
+ }
+
+ const url = buildWsUrl(existing.chatUserId)
+ if (!url) {
+ throw new Error('chat websocket url could not be built')
+ }
+
+ setCurrentSession(existing.roomId, existing.chatUserId)
+ socketUrl.value = url
+ connectStartedAt.value = Date.now()
+ open()
+
+ await waitForSocketOpen(CONNECTING_TIMEOUT_MS)
+ markSocketActivity()
+ lastPongAt.value = Date.now()
+
+ if (shouldSyncOnReconnect) {
+ shouldSyncOnReconnect = false
+
+ syncMessages()
+ .then(() => {
+ if (isViewingSupportNow()) {
+ scheduleReadSignal(120)
+ } else {
+ refreshSupportUnreadCount().catch((error) => {
+ console.error('refresh unread count after reconnect sync failed:', error)
+ })
+ }
+ })
+ .catch((error) => {
+ console.error('sync after reconnect failed:', error)
+ })
+
+ return
+ }
+
+ return existing
+ } finally {
+ connectStartedAt.value = 0
+ reconnectingPromise = null
+ }
+ })()
+
+ return await reconnectingPromise
+}
+
+export const startChatSession = async () => {
+ const userStore = useUserStore()
+
+ chatLoading.value = true
+
+ try {
+ const response = await startChat()
+
+ if (!response?.user) {
+ throw new Error('start-chat request failed')
+ }
+
+ const roomId = response.user?.chat_room_id
+ const chatUserId = response.user?.chat_user_id
+
+ if (!roomId || !chatUserId) {
+ throw new Error('chat_room_id or chat_user_id missing in start-chat response')
+ }
+
+ const roomChanged =
+ !!currentRoomId.value && String(currentRoomId.value) !== String(roomId)
+
+ userStore.userInfo = {
+ ...userStore.userInfo,
+ chat_room_id: roomId,
+ chat_user_id: chatUserId
+ }
+
+ if (roomChanged) {
+ resetPaginationState()
+ }
+
+ setCurrentSession(roomId, chatUserId)
+
+ return {
+ roomId,
+ chatUserId
+ }
+ } finally {
+ chatLoading.value = false
+ }
+}
+
+export const connectChatSocket = async (roomId: string, chatUserId: string) => {
+ bindNetworkListeners()
+
+ const url = buildWsUrl(chatUserId)
+ if (!url) {
+ throw new Error('chat websocket url could not be built')
+ }
+
+ const roomChanged = !!currentRoomId.value && currentRoomId.value !== roomId
+ if (roomChanged) {
+ resetPaginationState()
+ }
+
+ setCurrentSession(roomId, chatUserId)
+
+ if (
+ status.value === 'OPEN' &&
+ socketUrl.value === url &&
+ currentRoomId.value === roomId &&
+ currentChatUserId.value === chatUserId
+ ) {
+ return { roomId, chatUserId }
+ }
+
+ if (reconnectingPromise) {
+ return await reconnectingPromise
+ }
+
+ if (connectPromise) {
+ return await connectPromise
+ }
+
+ connectPromise = (async () => {
+ try {
+ manuallyClosed = false
+ socketUrl.value = url
+
+ if (status.value === 'OPEN') {
+ markSocketActivity()
+ return { roomId, chatUserId }
+ }
+
+ if (status.value === 'CONNECTING') {
+ await waitForSocketOpen(CONNECTING_TIMEOUT_MS)
+ connectStartedAt.value = 0
+ markSocketActivity()
+ return { roomId, chatUserId }
+ }
+
+ connectStartedAt.value = Date.now()
+ open()
+ await waitForSocketOpen(CONNECTING_TIMEOUT_MS)
+ connectStartedAt.value = 0
+
+ markSocketActivity()
+ lastPongAt.value = Date.now()
+
+ return { roomId, chatUserId }
+ } finally {
+ connectStartedAt.value = 0
+ connectPromise = null
+ }
+ })()
+
+ return await connectPromise
+}
+
+export const connectChatIfExists = async () => {
+ const existing = getExistingChatSession()
+ if (!existing) return null
+
+ return await connectChatSocket(existing.roomId, existing.chatUserId)
+}
+
+export const ensureChatAndConnect = async () => {
+ const session = await startChatSession()
+ return await connectChatSocket(session.roomId, session.chatUserId)
+}
+
+export const disconnectChat = () => {
+ manuallyClosed = true
+ clearHeartbeatTimers()
+ clearAllPendingOutgoing()
+ clearReadSignalTimer()
+
+ lastReadSignalAt.value = 0
+ lastReadSignalRoomId.value = ''
+ pendingReadSignalRoomId = ''
+
+ if (status.value === 'OPEN' || status.value === 'CONNECTING') {
+ close()
+ }
+}
+
+export const fetchChatMessages = async (
+ limit = PAGE_SIZE,
+ offset = 0,
+ mode: FetchMode = 'replace'
+) => {
+ if (!currentRoomId.value || !currentChatUserId.value) {
+ const existing = getExistingChatSession()
+ if (!existing) return []
+ }
+
+ if (mode === 'replace') {
+ chatLoading.value = true
+ } else if (mode === 'prepend') {
+ isFetchingOlderMessages.value = true
+ }
+
+ try {
+ const url = buildMessagesApiUrl(currentRoomId.value, currentChatUserId.value, limit, offset)
+
+ const response = await fetch(url, {
+ method: 'GET',
+ cache: 'no-store',
+ headers: {
+ Accept: 'application/json'
+ }
+ })
+
+ if (!response.ok) {
+ throw new Error('fetch messages failed')
+ }
+
+ const result = await response.json()
+ const fetched = Array.isArray(result) ? result : result?.data || []
+
+ if (!Array.isArray(result)) {
+ const unreadCount = parseUnreadCountFromMessagesResponse(result)
+
+ if (isViewingSupportNow()) {
+ unreadSupportCount.value = 0
+ } else {
+ unreadSupportCount.value = unreadCount
+ }
+ }
+
+ const normalizedFetched = fetched
+ .map((item: any) => normalizeMessage(item, 'api'))
+ .filter(Boolean) as InternalChatMessage[]
+
+ for (const item of normalizedFetched) {
+ const messageId = getRealMessageId(item)
+ if (messageId) {
+ clearPendingOutgoing(messageId)
+ }
+ }
+
+ if (mode === 'replace') {
+ const pendingOptimistic = messages.value.filter(
+ (item) => item._optimistic && item.room_id === currentRoomId.value
+ )
+
+ messages.value = mergeMessageLists([...normalizedFetched, ...pendingOptimistic])
+ currentOffset.value = fetched.length
+ hasMoreMessages.value = fetched.length === limit
+ initialMessagesLoaded.value = true
+ flushPendingReadSignal()
+
+ if (pendingSyncAfterInitialLoad) {
+ pendingSyncAfterInitialLoad = false
+ window.setTimeout(() => {
+ syncMessages().catch((error) => {
+ console.error('sync after initial load failed:', error)
+ })
+ }, 0)
+ }
+ } else if (mode === 'prepend') {
+ messages.value = mergeMessageLists([...normalizedFetched, ...messages.value])
+ currentOffset.value += fetched.length
+ hasMoreMessages.value = fetched.length === limit
+ } else {
+ messages.value = mergeMessageLists([...messages.value, ...normalizedFetched])
+ }
+
+ if (isViewingSupportNow() && isSupportChatAtBottom.value) {
+ unreadSupportCount.value = 0
+ scheduleReadSignal(100)
+ }
+
+ return messages.value
+ } finally {
+ if (mode === 'replace') {
+ chatLoading.value = false
+ } else if (mode === 'prepend') {
+ isFetchingOlderMessages.value = false
+ }
+ }
+}
+
+export const fetchOlderChatMessages = async (limit = PAGE_SIZE) => {
+ if (isFetchingOlderMessages.value) return messages.value
+ if (!hasMoreMessages.value) return messages.value
+
+ return await fetchChatMessages(limit, currentOffset.value, 'prepend')
+}
+
+export const sendTextMessage = async (content: string, reply: string | null = null) => {
+ const text = content.trim()
+ if (!text) return
+
+ let existing = getExistingChatSession()
+
+ if (!existing) {
+ existing = await ensureChatAndConnect()
+ } else if (status.value !== 'OPEN') {
+ await connectChatSocket(existing.roomId, existing.chatUserId)
+ }
+
+ if (!isSocketFreshForSend()) {
+ await safeReconnect('send on stale socket')
+ }
+
+ if (status.value !== 'OPEN') {
+ throw new Error('websocket is not open')
+ }
+
+ if (!currentRoomId.value) {
+ throw new Error('room_id is missing')
+ }
+
+ const messageId = createUuid()
+ const replyId = reply ? String(reply) : null
+
+ const optimisticKey = appendOptimisticOutgoingMessage(text, messageId, replyId)
+
+ const payload: PendingOutgoingMessage['payload'] = {
+ id: messageId,
+ type: 'text',
+ room_id: currentRoomId.value,
+ content: text
+ }
+
+ if (replyId) {
+ payload.reply = replyId
+ }
+
+ pendingOutgoingById.set(messageId, {
+ payload,
+ optimisticKey,
+ attempts: 0,
+ timer: null
+ })
+
+ try {
+ console.log('chat ws payload:', payload)
+ send(JSON.stringify(payload))
+ armOutgoingAckTimeout(messageId)
+ } catch (error) {
+ clearPendingOutgoing(messageId)
+ removeMessageByClientKey(optimisticKey)
+ throw error
+ }
+}
+
+export const sendMediaMessage = async (
+ content: string,
+ type: 'image' | 'voice',
+ reply: string | null = null
+) => {
+ const filePath = String(content || '').trim()
+ if (!filePath) return
+
+ let existing = getExistingChatSession()
+
+ if (!existing) {
+ existing = await ensureChatAndConnect()
+ } else if (status.value !== 'OPEN') {
+ await connectChatSocket(existing.roomId, existing.chatUserId)
+ }
+
+ if (!isSocketFreshForSend()) {
+ await safeReconnect('send media on stale socket')
+ }
+
+ if (status.value !== 'OPEN') {
+ throw new Error('websocket is not open')
+ }
+
+ if (!currentRoomId.value) {
+ throw new Error('room_id is missing')
+ }
+
+ const messageId = createUuid()
+ const replyId = reply ? String(reply) : null
+
+ const optimisticKey = appendOptimisticOutgoingMediaMessage(filePath, messageId, type, replyId)
+
+ const payload: PendingOutgoingMessage['payload'] = {
+ id: messageId,
+ type,
+ room_id: currentRoomId.value,
+ content: filePath
+ }
+
+ if (replyId) {
+ payload.reply = replyId
+ }
+
+ pendingOutgoingById.set(messageId, {
+ payload,
+ optimisticKey,
+ attempts: 0,
+ timer: null
+ })
+
+ try {
+ console.log('chat media ws payload:', payload)
+ send(JSON.stringify(payload))
+ armOutgoingAckTimeout(messageId)
+ } catch (error) {
+ clearPendingOutgoing(messageId)
+ removeMessageByClientKey(optimisticKey)
+ throw error
+ }
+}
+
+const clearAllPendingOutgoing = () => {
+ for (const [messageId, pending] of pendingOutgoingById.entries()) {
+ if (pending.timer !== null) {
+ window.clearTimeout(pending.timer)
+ }
+ pendingOutgoingById.delete(messageId)
+ }
+}
+
+export const useChatService = () => {
+ return {
+ status,
+ messages: computed(() => messages.value),
+ currentRoomId: computed(() => currentRoomId.value),
+ currentChatUserId: computed(() => currentChatUserId.value),
+ unreadSupportCount: computed(() => unreadSupportCount.value),
+ latestIncomingNotification: computed(() => latestIncomingNotification.value),
+ setSupportChatPageOpen,
+ getExistingChatSession,
+ startChatSession,
+ connectChatIfExists,
+ ensureChatAndConnect,
+ connectChatSocket,
+ disconnectChat,
+ fetchChatMessages,
+ sendMediaMessage,
+ fetchOlderChatMessages,
+ sendTextMessage,
+ refreshSupportUnreadCount,
+ setSupportChatAtBottom,
+ hasMoreMessages: computed(() => hasMoreMessages.value),
+ isFetchingOlderMessages: computed(() => isFetchingOlderMessages.value)
+ }
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 1e24f3b..82251ba 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -122,6 +122,27 @@ function isIphone() {
return /iPhone/.test(userAgent)
}
+function createUuid() {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID()
+ }
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
+ const bytes = new Uint8Array(16)
+ crypto.getRandomValues(bytes)
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
+ return [
+ hex.slice(0, 8),
+ hex.slice(8, 12),
+ hex.slice(12, 16),
+ hex.slice(16, 20),
+ hex.slice(20),
+ ].join('-')
+ }
+ return `${Date.now()}-${Math.floor(Math.random() * 1e9)}`
+}
+
export {
getToken,
setToken,
@@ -131,5 +152,6 @@ export {
isIphone,
removeFingerPrint,
getIsActiveFingerprint,
- setFingerPrint
+ setFingerPrint,
+ createUuid
}
diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts
index 988acc8..b980657 100644
--- a/src/utils/priceSocket.ts
+++ b/src/utils/priceSocket.ts
@@ -384,6 +384,15 @@ function handleAppNotificationPayload(payload: unknown) {
void applyAppNotificationFromStream(payload as Record)
}
+function handleUnreadCountPayload(payload: unknown) {
+ if (!payload || typeof payload !== 'object') {
+ return
+ }
+
+ touchStreamActivity()
+ void applyUnreadCountFromStreamPayload(payload as Record)
+}
+
async function applyAppNotificationFromStream(payload: Record) {
try {
const { bumpUnreadFromStream } = await import('@/composables/useAppNotificationBadge')
@@ -393,6 +402,15 @@ async function applyAppNotificationFromStream(payload: Record)
}
}
+async function applyUnreadCountFromStreamPayload(payload: Record) {
+ try {
+ const { applyUnreadCountFromStream } = await import('@/composables/useAppNotificationBadge')
+ applyUnreadCountFromStream(payload)
+ } catch (error) {
+ console.warn('Unread-count SSE apply failed', error)
+ }
+}
+
function bindStreamEvents(source: EventSource) {
source.addEventListener('last', (event) => {
try {
@@ -426,6 +444,14 @@ function bindStreamEvents(source: EventSource) {
}
})
+ source.addEventListener('unread-count', (event) => {
+ try {
+ handleUnreadCountPayload(JSON.parse(event.data))
+ } catch (error) {
+ console.warn('Unread-count SSE parse failed', error)
+ }
+ })
+
source.addEventListener('ping', () => {
touchStreamActivity()
})
diff --git a/src/utils/serviceFee.ts b/src/utils/serviceFee.ts
index b95de92..3b5df08 100644
--- a/src/utils/serviceFee.ts
+++ b/src/utils/serviceFee.ts
@@ -28,7 +28,18 @@ export function formatTradeGramDisplay(grams?: number | null): number {
}
function feeRate() {
- return Number(serviceFeePercentage.value)
+ return normalizeServiceFeeRatio(Number(serviceFeePercentage.value))
+}
+
+/** نسبت کارمزد (۰٫۰۱ = ۱٪). مقدار ≥۱ یعنی درصد انسانی اشتباه وارد شده. */
+export function normalizeServiceFeeRatio(value: number): number {
+ if (!Number.isFinite(value) || value <= 0) {
+ return 0
+ }
+ if (value >= 1) {
+ return value / 100
+ }
+ return value
}
export async function loadServiceFeeFromSettings() {
@@ -39,7 +50,7 @@ export async function loadServiceFeeFromSettings() {
(s) => s?.key === 'fee_service_charge_percentage' && s?.is_active === true
)
if (feeSetting?.value !== undefined && feeSetting?.value !== null) {
- serviceFeePercentage.value = Number(feeSetting.value)
+ serviceFeePercentage.value = normalizeServiceFeeRatio(Number(feeSetting.value))
}
const feeModeSetting = settings.find(
@@ -58,7 +69,7 @@ export async function loadServiceFeeFromSettings() {
}
export function calcFeeInGrams(goldValue?: number | null) {
- return parseFloat(((goldValue ?? 0) * Number(serviceFeePercentage.value)).toFixed(4))
+ return parseFloat(((goldValue ?? 0) * feeRate()).toFixed(4))
}
/** گرم معادل برای دریافت دقیق مبلغ ریالی در فروش */
diff --git a/src/utils/supportMessenger.ts b/src/utils/supportMessenger.ts
index 22fbe78..f52c61f 100644
--- a/src/utils/supportMessenger.ts
+++ b/src/utils/supportMessenger.ts
@@ -88,8 +88,11 @@ export function parseSupportMessengersJson(raw?: string | null): SupportMessenge
export function resolveSupportMessengersFromSettings(
settings: Array<{ key?: string; value?: unknown }>,
): SupportMessengerEntry[] {
- const fromJson = parseSupportMessengersJson(getSupportSettingValue(settings, 'support_messengers'))
- if (fromJson.length > 0) return fromJson
+ // اگر کلید جدید در تنظیمات هست (حتی خالی/null)، فقط همان لیست معتبر است — legacy بله را برنگردان
+ const hasMessengersKey = settings.some((row) => row?.key === 'support_messengers')
+ if (hasMessengersKey) {
+ return parseSupportMessengersJson(getSupportSettingValue(settings, 'support_messengers'))
+ }
const legacyMessenger = getSupportSettingValue(settings, 'support_messenger').toLowerCase()
const legacyId = getSupportSettingValue(settings, 'support_messenger_id')
@@ -108,9 +111,8 @@ export function resolveSupportMessengerEntries(
): ResolvedSupportMessengerEntry[] {
const resolved = settings ?? supportMessengerSettings.value
const text = (messageText ?? '').trim()
- const source = resolved.messengers?.length
- ? resolved.messengers
- : resolveLegacySingleMessenger(resolved)
+ // فقط لیست resolveشده؛ قبل از load خالی است تا legacy دوباره دکمه نسازد
+ const source = Array.isArray(resolved.messengers) ? resolved.messengers : []
return source
.map((entry) => {
@@ -126,14 +128,6 @@ export function resolveSupportMessengerEntries(
.filter((entry): entry is ResolvedSupportMessengerEntry => entry !== null)
}
-function resolveLegacySingleMessenger(settings: SupportMessengerSettings): SupportMessengerEntry[] {
- const messenger = (settings.messenger ?? '').trim().toLowerCase()
- const id = (settings.messengerId ?? '').trim()
- if (!messenger || !id) return []
- if (!VALID_SUPPORT_MESSENGERS.has(messenger)) return []
- return [{ messenger, id }]
-}
-
/** @deprecated از depositGuideText استفاده کنید */
export const DEFAULT_SUPPORT_MESSENGER_TEXT = DEFAULT_DEPOSIT_GUIDE_TEXT
diff --git a/src/utils/zIndex.ts b/src/utils/zIndex.ts
index ba4396a..7161000 100644
--- a/src/utils/zIndex.ts
+++ b/src/utils/zIndex.ts
@@ -1,3 +1,6 @@
+/** toast اعلان درونبرنامه — بالای هدر (1999) و ناوبری (5000)، زیر اکشنشیت */
+export const APP_Z_TOAST = 5_050
+
/**
* z-index مدال/اکشنشیت/دیالوگ.
* بالای ناوبری (5000)، هدر (1999) و دکمه پشتیبانی (5100) مینشیند تا این کرومها
diff --git a/src/views/base/home/components/AppHeader.vue b/src/views/base/home/components/AppHeader.vue
index 064ef7c..23cf35d 100644
--- a/src/views/base/home/components/AppHeader.vue
+++ b/src/views/base/home/components/AppHeader.vue
@@ -33,6 +33,20 @@
+