gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/store/messageStore.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,953 @@
import type { Conversation, Message, MessageDO } from '../types'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { getCurrentUserId } from '#/views/im/utils/auth'
import {
  IM_AT_ALL_USER_ID,
  ImContentType,
  ImConversationType,
  ImMessageReceiptStatus,
  ImMessageStatus,
  isGroupNotification,
  isNormalMessage
} from '../../utils/constants'
import { resolveConversationLastContent } from '../../utils/conversation'
import {
  type DbTransaction,
  getClientConversationId,
  getClientMessageKey,
  getDb,
  getServerMessageKey,
  parseClientConversationId,
  setMessageMaxId,
  StorageKeys
} from '../../utils/db'
import {
  generateClientMessageId,
  parseRecallMessageId,
  revokeBlobUrlsInContent
} from '../../utils/message'
import { isGroupQuit, tryGetSenderDisplayName } from '../../utils/user'
import { useConversationStore } from './conversationStore'
import { useGroupStore } from './groupStore'
const MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT = 5
const MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT = MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT + 1
const ackMergingPromises = new Map<string, Promise<void>>()
interface MessageConversationInfo {
  type: number
  targetId: number
  name: string
  avatar: string
  silent?: boolean
}
interface PersistMessageRecordOptions {
  mergeClientRecord?: boolean
}
/** æ‹‰å–消息批量处理项 */
export type PulledMessage =
  | {
      conversationInfo: MessageConversationInfo
      kind: 'insert'
      message: Message
    }
  | {
      conversationType: number
      kind: 'recall'
      recallSignalContent: string
      targetId: number
    }
/** èŽ·å–ä¼šè¯çš„æ¶ˆæ¯ç¼“å­˜ key */
function getMessageCacheKey(type: number, targetId: number): string {
  return getClientConversationId(type, targetId)
}
/** ç”Ÿæˆæ¶ˆæ¯æœ¬åœ°ä¸»é”® */
function getMessageKey(
  message: Pick<Message, 'clientMessageId' | 'id'>,
  conversationType: number
): string {
  return message.id
    ? getServerMessageKey(conversationType, message.id)
    : getClientMessageKey(message.clientMessageId)
}
/** è¡¥é½å®¢æˆ·ç«¯æ¶ˆæ¯ç¼–号 */
function ensureClientMessageId(message: Message): Message {
  if (!message.clientMessageId) {
    message.clientMessageId = generateClientMessageId()
  }
  if (!message.id) {
    message.id = undefined
  }
  return message
}
/** è½¬æ¢ä¸º IndexedDB æ¶ˆæ¯è®°å½• */
function buildMessageDO(message: Message, conversationType: number): MessageDO {
  return {
    id: message.id,
    clientMessageId: message.clientMessageId,
    type: message.type,
    content: message.content,
    status: message.status,
    sendTime: message.sendTime,
    senderId: message.senderId,
    atUserIds: message.atUserIds ? [...message.atUserIds] : undefined,
    receiverUserIds: message.receiverUserIds ? [...message.receiverUserIds] : undefined,
    receiptStatus: message.receiptStatus,
    readCount: message.readCount,
    materialId: message.materialId,
    targetId: message.targetId,
    selfSend: message.selfSend,
    messageKey: getMessageKey(message, conversationType),
    conversationType,
    clientConversationId: getClientConversationId(conversationType, message.targetId)
  }
}
/** IndexedDB æ¶ˆæ¯è®°å½•转前端消息 */
function buildMessageFromDO(message: MessageDO): Message {
  const {
    messageKey: _messageKey,
    conversationType: _conversationType,
    clientConversationId: _clientConversationId,
    ...rest
  } = message
  return rest
}
/** ç®—出末条消息的发送人快照 */
function deriveLastSenderDisplayName(
  conversation: Conversation,
  senderId: number
): string | undefined {
  // 1. ä¼˜å…ˆä½¿ç”¨å½“前内存中的好友 / ç¾¤æˆå‘˜ä¿¡æ¯
  const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
  if (liveSenderName) {
    return liveSenderName
  }
  // 2. ç¾¤æˆå‘˜ç¼“存缺失时异步补齐
  if (conversation.type === ImConversationType.GROUP) {
    const groupStore = useGroupStore()
    const group = groupStore.getGroup(conversation.targetId)
    if (!group || isGroupQuit(group)) {
      return conversation.lastSenderId === senderId ? conversation.lastSenderDisplayName : undefined
    }
    const fetchPromise =
      group?.membersLoaded && !group.membersExpired
        ? groupStore.fetchGroupMember(conversation.targetId, senderId)
        : groupStore.fetchGroupMemberList(conversation.targetId)
    fetchPromise.catch((error) =>
      console.warn(
        '[IM messageStore] å…œåº•拉群成员失败',
        { groupId: conversation.targetId, senderId, fullFetch: !group?.membersLoaded },
        error
      )
    )
  }
  return conversation.lastSenderId === senderId ? conversation.lastSenderDisplayName : undefined
}
/** æŒ‰æ¶ˆæ¯æ›´æ–°ä¼šè¯æ‘˜è¦ */
function applyConversationSummary(conversation: Conversation, message: Message): void {
  const senderDisplayName = deriveLastSenderDisplayName(conversation, message.senderId)
  conversation.lastContent = resolveConversationLastContent(
    message,
    conversation.type,
    conversation.targetId,
    senderDisplayName
  )
  conversation.lastSendTime = message.sendTime || Date.now()
  conversation.lastSenderId = message.senderId
  conversation.lastMessageType = message.type
  conversation.lastMessageId = message.id
  conversation.lastClientMessageId = message.clientMessageId
  conversation.lastMessageStatus = message.status
  conversation.lastReceiptStatus = message.receiptStatus
  conversation.lastSelfSend = message.selfSend
  conversation.lastSenderDisplayName = senderDisplayName
}
/** æŒ‰æœ«æ¡æ¶ˆæ¯é‡ç®—会话摘要 */
function recomputeConversationLast(conversation: Conversation, messages: Message[]): void {
  const last = messages[messages.length - 1]
  if (last) {
    applyConversationSummary(conversation, last)
    return
  }
  conversation.lastContent = ''
  conversation.lastSendTime = 0
  conversation.lastSenderId = undefined
  conversation.lastMessageType = undefined
  conversation.lastMessageId = undefined
  conversation.lastClientMessageId = undefined
  conversation.lastMessageStatus = undefined
  conversation.lastReceiptStatus = undefined
  conversation.lastSelfSend = undefined
  conversation.lastSenderDisplayName = undefined
}
/** åŒæ­¥ç¾¤ @ çŠ¶æ€ */
function syncConversationAtFlags(conversation: Conversation, message: Message): void {
  if (
    message.selfSend ||
    conversation.type !== ImConversationType.GROUP ||
    !message.atUserIds ||
    message.atUserIds.length === 0
  ) {
    return
  }
  const currentUserId = getCurrentUserId()
  if (currentUserId && message.atUserIds.includes(currentUserId)) {
    conversation.atMe = true
  }
  if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) {
    conversation.atAll = true
  }
}
/** åº”用服务端消息更新 */
function applyServerMessageUpdate(message: Message, updates: Partial<Message>): void {
  if (updates.content && updates.content !== message.content) {
    revokeBlobUrlsInContent(message.content)
  }
  Object.assign(message, updates)
  if (updates.id === 0) {
    message.id = undefined
  }
  if (updates.status !== undefined && updates.status !== ImMessageStatus.SENDING) {
    message.uploadProgress = undefined
    if (updates.status !== ImMessageStatus.FAILED) {
      message._localFile = undefined
    }
  }
}
/** åˆ¤æ–­æ˜¯å¦ä¸ºåŒä¸€æ¡æ¶ˆæ¯ */
function isSameMessage(left: Message, right: Message): boolean {
  if (left.id && right.id && left.id === right.id) {
    return true
  }
  return !!left.clientMessageId && left.clientMessageId === right.clientMessageId
}
export const useMessageStore = defineStore('imMessageStore', {
  state: () => ({
    messagesByConversation: {} as Record<string, Message[]>,
    loadedConversationKeys: [] as string[],
    privateReadMaxIds: {} as Partial<Record<number, number>>,
    privateMessageMaxId: 0,
    groupMessageMaxId: 0,
    channelMessageMaxId: 0
  }),
  getters: {
    /** èŽ·å–ä¼šè¯å·²åŠ è½½æ¶ˆæ¯ */
    getMessages:
      (state) =>
      (clientConversationId: string): Message[] =>
        state.messagesByConversation[clientConversationId] || []
  },
  actions: {
    /** æ¸…空消息内存 */
    clear() {
      Object.values(this.messagesByConversation).forEach((messages) => {
        messages.forEach((message) => {
          revokeBlobUrlsInContent(message.content)
          message._localFile = undefined
        })
      })
      this.messagesByConversation = {}
      this.loadedConversationKeys = []
      this.privateReadMaxIds = {}
      this.privateMessageMaxId = 0
      this.groupMessageMaxId = 0
      this.channelMessageMaxId = 0
      ackMergingPromises.clear()
    },
    /** ä»Ž settings åŠ è½½æ¶ˆæ¯æ¸¸æ ‡ */
    async loadMessageCursorList() {
      const db = getDb()
      const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([
        db.getSetting<number>(StorageKeys.settings.privateMessageMaxId),
        db.getSetting<number>(StorageKeys.settings.groupMessageMaxId),
        db.getSetting<number>(StorageKeys.settings.channelMessageMaxId)
      ])
      this.privateMessageMaxId = privateMaxId || 0
      this.groupMessageMaxId = groupMaxId || 0
      this.channelMessageMaxId = channelMaxId || 0
    },
    /** æ›´æ–°å†…存游标 */
    updateMessageCursor(conversationType: number, messageId?: number) {
      if (!messageId) {
        return
      }
      if (conversationType === ImConversationType.PRIVATE && messageId > this.privateMessageMaxId) {
        this.privateMessageMaxId = messageId
      } else if (
        conversationType === ImConversationType.GROUP &&
        messageId > this.groupMessageMaxId
      ) {
        this.groupMessageMaxId = messageId
      } else if (
        conversationType === ImConversationType.CHANNEL &&
        messageId > this.channelMessageMaxId
      ) {
        this.channelMessageMaxId = messageId
      }
    },
    /** èŽ·å–ç§èŠå¯¹æ–¹å·²è¯»ä½ç½®ç¼“å­˜ */
    getPrivateReadMaxId(peerId: number): number | undefined {
      return this.privateReadMaxIds[peerId]
    },
    /** æ›´æ–°ç§èŠå¯¹æ–¹å·²è¯»ä½ç½®ç¼“å­˜ */
    updatePrivateReadMaxId(peerId: number, maxReadId: null | number = 0): number {
      if (!peerId) {
        return 0
      }
      const nextMaxReadId = maxReadId || 0
      const current = this.getPrivateReadMaxId(peerId)
      if (current !== undefined && nextMaxReadId <= current) {
        return current
      }
      this.privateReadMaxIds = { ...this.privateReadMaxIds, [peerId]: nextMaxReadId }
      return nextMaxReadId
    },
    /** æ¸…空私聊对方已读位置缓存 */
    clearPrivateReadMaxIdCache(): void {
      this.privateReadMaxIds = {}
    },
    /** æ ‡è®°ä¼šè¯è¿‘期使用 */
    touchConversationMessageCache(clientConversationId: string) {
      this.loadedConversationKeys = [
        clientConversationId,
        ...this.loadedConversationKeys.filter((key) => key !== clientConversationId)
      ]
      // ä¿ç•™å½“前活跃会话 + æœ€è¿‘打开过的会话
      const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT)
      const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT)
      this.loadedConversationKeys = retained
      removed.forEach((key) => {
        Reflect.deleteProperty(this.messagesByConversation, key)
      })
    },
    /** åŠ è½½å½“å‰ä¼šè¯æœ€è¿‘æ¶ˆæ¯ */
    async loadMoreMessageList(
      clientConversationId: string,
      beforeSendTime?: number,
      limit = 50
    ): Promise<Message[]> {
      // 1. ä»Ž IndexedDB å€’序读取一页,返回前已按时间升序排列
      const list = await getDb().getMessageListByConversation(clientConversationId, {
        beforeSendTime,
        limit
      })
      // 2. åˆå¹¶åˆ°å†…存缓存,过滤已存在的消息
      const parsed = parseClientConversationId(clientConversationId)
      if (!parsed) {
        return []
      }
      const messages = list.map((message) => buildMessageFromDO(message))
      const existing = this.messagesByConversation[clientConversationId] || []
      const existingKeys = new Set(existing.map((message) => getMessageKey(message, parsed.type)))
      const fresh = messages.filter(
        (message) => !existingKeys.has(getMessageKey(message, parsed.type))
      )
      this.messagesByConversation[clientConversationId] = [...fresh, ...existing].toSorted(
        (messageA, messageB) => (messageA.sendTime || 0) - (messageB.sendTime || 0)
      )
      this.touchConversationMessageCache(clientConversationId)
      return fresh
    },
    /** ç¡®ä¿ä¼šè¯æ¶ˆæ¯å·²åŠ è½½ */
    async ensureConversationMessageListLoaded(conversation: Conversation) {
      const key = getMessageCacheKey(conversation.type, conversation.targetId)
      if (this.messagesByConversation[key]) {
        this.touchConversationMessageCache(key)
        return
      }
      await this.loadMoreMessageList(key)
    },
    /** èŽ·å–å†…å­˜æ¶ˆæ¯æ•°ç»„ */
    getMessageList(conversationType: number, targetId: number): Message[] {
      const key = getMessageCacheKey(conversationType, targetId)
      if (!this.messagesByConversation[key]) {
        this.messagesByConversation[key] = []
      }
      this.touchConversationMessageCache(key)
      return this.messagesByConversation[key]
    },
    /** æŒä¹…化消息记录 */
    async saveMessageRecord(
      message: Message,
      conversationType: number,
      tx?: DbTransaction,
      options?: PersistMessageRecordOptions
    ) {
      const db = getDb()
      const next = buildMessageDO(message, conversationType)
      // æœåŠ¡ç«¯ key æ›¿æ¢ client key
      if (options?.mergeClientRecord && message.id && message.clientMessageId) {
        const existing = await db.getByIndex<MessageDO>(
          'messages',
          'clientMessageId',
          message.clientMessageId,
          tx
        )
        if (existing && existing.messageKey !== next.messageKey) {
          await db.delete('messages', existing.messageKey, tx)
        }
      }
      await db.put('messages', next, tx)
    },
    /** ä¿å­˜æ¶ˆæ¯æ¸¸æ ‡ */
    async saveMessageCursor(conversationType: number, messageId?: number, tx?: DbTransaction) {
      await setMessageMaxId(conversationType, messageId, tx)
      this.updateMessageCursor(conversationType, messageId)
    },
    /** åº”用撤回到内存 */
    applyRecallMessageInMemory(
      conversationType: number,
      targetId: number,
      recallSignalContent: string
    ) {
      // 1. å®šä½è¢«æ’¤å›žçš„原消息
      const messageId = parseRecallMessageId(recallSignalContent)
      if (!messageId) {
        return null
      }
      const conversationStore = useConversationStore()
      const conversation = conversationStore.getConversation(conversationType, targetId)
      if (!conversation) {
        return null
      }
      const messages = this.getMessageList(conversationType, targetId)
      const message = messages.find((item) => item.id === messageId)
      if (!message) {
        return null
      }
      // 2. æ›´æ–°æ¶ˆæ¯å’Œä¼šè¯æ‘˜è¦
      message.type = ImContentType.RECALL
      message.status = ImMessageStatus.RECALL
      message.content = ''
      if (messages[messages.length - 1]?.id === messageId) {
        recomputeConversationLast(conversation, messages)
      }
      return { conversation, message }
    },
    /** æ‰¹é‡å†™å…¥æ‹‰å–消息 */
    async applyPulledMessageList(
      pulledMessages: PulledMessage[],
      conversationType: number,
      maxMessageId?: number
    ) {
      if (pulledMessages.length === 0) {
        // 1. ç©ºæ‰¹æ¬¡åªæŽ¨è¿›æ¸¸æ ‡
        await this.saveMessageCursor(conversationType, maxMessageId)
        return
      }
      const conversationStore = useConversationStore()
      const persistedMessages = new Map<
        string,
        { conversationType: number; mergeClientRecord?: boolean; message: Message; }
      >()
      const changedConversations = new Map<string, Conversation>()
      const addChanged = (
        conversation: Conversation,
        message: Message,
        options?: PersistMessageRecordOptions
      ) => {
        const clientConversationId = getClientConversationId(
          conversation.type,
          conversation.targetId
        )
        changedConversations.set(clientConversationId, conversation)
        persistedMessages.set(getMessageKey(message, conversation.type), {
          message,
          conversationType: conversation.type,
          mergeClientRecord: options?.mergeClientRecord
        })
      }
      // 1. å…ˆæ›´æ–°å†…存,收集需要持久化的消息和会话
      for (const pulledMessage of pulledMessages) {
        if (pulledMessage.kind === 'recall') {
          // 1.1 æ’¤å›žä¿¡å·æ›´æ–°åŽŸæ¶ˆæ¯
          const changed = this.applyRecallMessageInMemory(
            pulledMessage.conversationType,
            pulledMessage.targetId,
            pulledMessage.recallSignalContent
          )
          if (changed) {
            addChanged(changed.conversation, changed.message)
          }
          continue
        }
        const { conversationInfo } = pulledMessage
        const hasServerClientMessageId = !!pulledMessage.message.clientMessageId
        const message = ensureClientMessageId(pulledMessage.message)
        // 1.2 ç¡®ä¿ä¼šè¯å’Œæ¶ˆæ¯ç¼“存存在
        const conversation = conversationStore.ensureConversation(conversationInfo)
        const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
        const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
        if (existingIndex !== -1) {
          const existing = messages[existingIndex]
          if (!existing) {
            continue
          }
          // 1.3 å·²å­˜åœ¨æ¶ˆæ¯åˆå¹¶æœåŠ¡ç«¯çŠ¶æ€
          applyServerMessageUpdate(existing, message)
          if (existingIndex === messages.length - 1) {
            recomputeConversationLast(conversation, messages)
            syncConversationAtFlags(conversation, message)
          }
          addChanged(conversation, existing, {
            mergeClientRecord: hasServerClientMessageId
          })
          continue
        }
        // 1.4 æ–°æ¶ˆæ¯æ›´æ–°ä¼šè¯æ‘˜è¦å’Œæœªè¯»çŠ¶æ€
        applyConversationSummary(conversation, message)
        syncConversationAtFlags(conversation, message)
        const isActive =
          conversationStore.activeConversation?.type === conversationInfo.type &&
          conversationStore.activeConversation?.targetId === conversationInfo.targetId
        if (
          !message.selfSend &&
          !isActive &&
          !conversationStore.isMessageCoveredByReadPosition(conversation, message) &&
          isNormalMessage(message.type) &&
          message.status !== ImMessageStatus.RECALL
        ) {
          conversation.unreadCount++
        }
        // 1.5 æ–°æ¶ˆæ¯æŒ‰æœåŠ¡ç«¯ id æ’入内存列表
        let insertIndex = messages.length
        if (message.id) {
          for (const [index, existing] of messages.entries()) {
            if (existing.id && message.id < existing.id) {
              insertIndex = index
              break
            }
          }
        }
        messages.splice(insertIndex, 0, message)
        addChanged(conversation, message, {
          mergeClientRecord: hasServerClientMessageId && !!message.id
        })
      }
      // 2. å•事务写入消息、会话摘要和游标
      await getDb().transaction(
        ['messages', 'conversations', 'settings'],
        'readwrite',
        async (tx) => {
          // 2.1 å†™å…¥æœ¬æ‰¹å˜æ›´æ¶ˆæ¯
          for (const item of persistedMessages.values()) {
            await this.saveMessageRecord(item.message, item.conversationType, tx, {
              mergeClientRecord: item.mergeClientRecord
            })
          }
          // 2.2 å†™å…¥æœ¬æ‰¹å˜æ›´ä¼šè¯
          await conversationStore.saveConversationRecord([...changedConversations.values()], tx)
          // 2.3 å†™å…¥æœ¬æ‰¹æ¸¸æ ‡
          await setMessageMaxId(conversationType, maxMessageId, tx)
        }
      )
      // 3. æŒä¹…化成功后推进内存游标
      this.updateMessageCursor(conversationType, maxMessageId)
      for (const item of persistedMessages.values()) {
        this.updateMessageCursor(item.conversationType, item.message.id)
      }
    },
    /** æ’入消息 */
    insertMessage(
      conversationInfo: MessageConversationInfo,
      messageInfo: Message,
      options?: { saveMaxId?: boolean }
    ): Promise<void> {
      const conversationStore = useConversationStore()
      const hasIncomingClientMessageId = !!messageInfo.clientMessageId
      const message = ensureClientMessageId(messageInfo)
      // 1. å…ˆå¤„理消息带来的群资料变更
      if (conversationInfo.type === ImConversationType.GROUP && isGroupNotification(message.type)) {
        useGroupStore().applyGroupNotification(
          conversationInfo.targetId,
          message.type,
          message.content
        )
      }
      // 2. ç¡®ä¿ä¼šè¯å’Œæ¶ˆæ¯ç¼“存存在
      const conversation = conversationStore.ensureConversation(conversationInfo)
      const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
      const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
      // 3. å·²å­˜åœ¨æ¶ˆæ¯èµ°è¦†ç›–æ›´æ–°
      if (existingIndex !== -1) {
        const existing = messages[existingIndex]
        if (!existing) {
          return Promise.resolve()
        }
        applyServerMessageUpdate(existing, message)
        if (existingIndex === messages.length - 1) {
          recomputeConversationLast(conversation, messages)
          syncConversationAtFlags(conversation, message)
        }
        return getDb()
          .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
            await this.saveMessageRecord(existing, conversationInfo.type, tx, {
              mergeClientRecord: hasIncomingClientMessageId
            })
            await conversationStore.saveConversationRecord(conversation, tx)
            if (options?.saveMaxId !== false) {
              await setMessageMaxId(conversationInfo.type, message.id, tx)
            }
          })
          .catch((error) => {
            console.error('[IM messageStore] æ¶ˆæ¯å†™å…¥å¤±è´¥', error)
            throw error
          })
          .then(() => {
            this.updateMessageCursor(conversationInfo.type, message.id)
          })
      }
      // 4. æ–°æ¶ˆæ¯æ›´æ–°ä¼šè¯æ‘˜è¦å’Œæœªè¯»çŠ¶æ€
      applyConversationSummary(conversation, message)
      syncConversationAtFlags(conversation, message)
      const isActive =
        conversationStore.activeConversation?.type === conversationInfo.type &&
        conversationStore.activeConversation?.targetId === conversationInfo.targetId
      if (
        !message.selfSend &&
        !isActive &&
        !conversationStore.isMessageCoveredByReadPosition(conversation, message) &&
        isNormalMessage(message.type) &&
        message.status !== ImMessageStatus.RECALL
      ) {
        conversation.unreadCount++
      }
      // 5. æ–°æ¶ˆæ¯æŒ‰ id æ’入到内存数组
      let insertIndex = messages.length
      if (message.id) {
        for (const [index, existing] of messages.entries()) {
          if (existing.id && message.id < existing.id) {
            insertIndex = index
            break
          }
        }
      }
      messages.splice(insertIndex, 0, message)
      // 6. å•事务写入消息、会话摘要和游标
      return getDb()
        .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
          await this.saveMessageRecord(message, conversationInfo.type, tx, {
            mergeClientRecord: hasIncomingClientMessageId && !!message.id
          })
          await conversationStore.saveConversationRecord(conversation, tx)
          if (options?.saveMaxId !== false) {
            await setMessageMaxId(conversationInfo.type, message.id, tx)
          }
        })
        .catch((error) => {
          console.error('[IM messageStore] æ¶ˆæ¯å†™å…¥å¤±è´¥', error)
          throw error
        })
        .then(() => {
          this.updateMessageCursor(conversationInfo.type, message.id)
        })
    },
    /** ack åˆå¹¶ */
    ackMessage(
      conversationType: number,
      targetId: number,
      clientMessageId: string,
      updates: Partial<Message>
    ) {
      const mergeKey = `${conversationType}:${targetId}:${clientMessageId}`
      const existingPromise = ackMergingPromises.get(mergeKey)
      if (existingPromise) {
        return existingPromise
      }
      const promise = this.doAckMessage(
        conversationType,
        targetId,
        clientMessageId,
        updates
      ).finally(() => {
        ackMergingPromises.delete(mergeKey)
      })
      ackMergingPromises.set(mergeKey, promise)
      return promise
    },
    /** æ‰§è¡Œ ack åˆå¹¶ */
    async doAckMessage(
      conversationType: number,
      targetId: number,
      clientMessageId: string,
      updates: Partial<Message>
    ) {
      // 1. å®šä½å¾…合并消息
      const conversationStore = useConversationStore()
      const conversation = conversationStore.getConversation(conversationType, targetId)
      if (!conversation) {
        return
      }
      const messages = this.getMessageList(conversationType, targetId)
      const message = messages.find((item) => item.clientMessageId === clientMessageId)
      if (!message) {
        return
      }
      message._ackMerging = true
      try {
        // 2. åˆå¹¶æœåŠ¡ç«¯ ack åˆ°å†…å­˜
        applyServerMessageUpdate(message, updates)
        if (messages[messages.length - 1] === message) {
          recomputeConversationLast(conversation, messages)
        }
        // 3. å•事务写入消息、会话摘要和游标
        await getDb()
          .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
            await this.saveMessageRecord(message, conversationType, tx, {
              mergeClientRecord: true
            })
            await conversationStore.saveConversationRecord(conversation, tx)
            await setMessageMaxId(conversationType, message.id, tx)
          })
          .catch((error) => {
            console.error('[IM messageStore] ack å†™å…¥å¤±è´¥', error)
            throw error
          })
        this.updateMessageCursor(conversationType, message.id)
      } finally {
        // 4. æ¸…理合并标记
        message._ackMerging = false
      }
    },
    /** å±€éƒ¨æ›´æ–°æ¶ˆæ¯ */
    patchMessage(
      conversationType: number,
      targetId: number,
      clientMessageId: string,
      patch: Partial<Message>
    ) {
      const message = this.getMessageList(conversationType, targetId).find(
        (item) => item.clientMessageId === clientMessageId
      )
      if (!message) {
        return
      }
      let changed = false
      for (const key in patch) {
        if (
          Object.prototype.hasOwnProperty.call(patch, key) &&
          (patch as Record<string, unknown>)[key] !==
            (message as unknown as Record<string, unknown>)[key]
        ) {
          changed = true
          break
        }
      }
      if (changed) {
        applyServerMessageUpdate(message, patch)
      }
    },
    /** æ’¤å›žæ¶ˆæ¯ */
    async recallMessage(
      conversationType: number,
      targetId: number,
      recallSignalContent: string
    ): Promise<void> {
      const conversationStore = useConversationStore()
      const changed = this.applyRecallMessageInMemory(
        conversationType,
        targetId,
        recallSignalContent
      )
      if (!changed) {
        return
      }
      await getDb()
        .transaction(['messages', 'conversations'], 'readwrite', async (tx) => {
          await this.saveMessageRecord(changed.message, conversationType, tx)
          await conversationStore.saveConversationRecord(changed.conversation, tx)
        })
        .catch((error) => {
          console.error('[IM messageStore] æ’¤å›žæ¶ˆæ¯å†™å…¥å¤±è´¥', error)
          throw error
        })
    },
    /** åº”用已读回执 */
    applyMessageReadReceipt(options: {
      conversationType: number
      groupMessageId?: number
      privateReadMaxId?: number
      readCount?: number
      receiptStatus?: number
      targetId: number
    }) {
      const messages = this.getMessageList(options.conversationType, options.targetId)
      const changed: Message[] = []
      // 1. ç§èŠå›žæ‰§æ‰¹é‡æ›´æ–°è‡ªå·±å‘送的消息
      if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
        this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId)
        const privateReadMaxId = options.privateReadMaxId
        messages.forEach((message) => {
          if (
            message.selfSend &&
            message.id &&
            message.id <= privateReadMaxId &&
            message.receiptStatus === ImMessageReceiptStatus.PENDING
          ) {
            message.receiptStatus = ImMessageReceiptStatus.DONE
            changed.push(message)
          }
        })
      } else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
        // 2. ç¾¤èŠå›žæ‰§æ›´æ–°å•条消息
        const message = messages.find((item) => item.id === options.groupMessageId)
        if (message) {
          if (options.readCount !== undefined) {
            message.readCount = options.readCount
          }
          if (options.receiptStatus !== undefined) {
            message.receiptStatus = options.receiptStatus
          }
          changed.push(message)
        }
      }
      if (changed.length === 0) {
        return
      }
      // 3. å•事务写入变更消息
      void getDb()
        .transaction(['messages'], 'readwrite', async (tx) => {
          for (const message of changed) {
            await this.saveMessageRecord(message, options.conversationType, tx)
          }
        })
        .catch((error) => console.warn('[IM messageStore] å›žæ‰§å†™å…¥å¤±è´¥', error))
    },
    /** å‰ç½®åŽ†å²æ¶ˆæ¯ */
    prependMessageList(conversationType: number, targetId: number, earlierMessages: Message[]) {
      if (earlierMessages.length === 0) {
        return
      }
      const messages = this.getMessageList(conversationType, targetId)
      const existingIds = new Set(messages.map((message) => message.id).filter(Boolean))
      const fresh = earlierMessages
        .map((message) => ensureClientMessageId(message))
        .filter((message) => message.id && !existingIds.has(message.id))
        .toSorted((messageA, messageB) => (messageA.id || 0) - (messageB.id || 0))
      if (fresh.length === 0) {
        return
      }
      const key = getMessageCacheKey(conversationType, targetId)
      this.messagesByConversation[key] = [...fresh, ...messages]
      void getDb()
        .transaction(['messages'], 'readwrite', async (tx) => {
          for (const message of fresh) {
            await this.saveMessageRecord(message, conversationType, tx)
          }
        })
        .catch((error) => console.warn('[IM messageStore] åŽ†å²æ¶ˆæ¯å†™å…¥å¤±è´¥', error))
    },
    /** åˆ é™¤å•条消息 */
    removeMessage(
      conversationType: number,
      targetId: number,
      key: { clientMessageId?: string; id?: number; }
    ) {
      // 1. å®šä½ä¼šè¯å’Œæ¶ˆæ¯
      const conversationStore = useConversationStore()
      const conversation = conversationStore.getConversation(conversationType, targetId)
      if (!conversation) {
        return
      }
      const messages = this.getMessageList(conversationType, targetId)
      const index = messages.findIndex((message) => {
        if (key.id && message.id && message.id === key.id) {
          return true
        }
        return !!key.clientMessageId && message.clientMessageId === key.clientMessageId
      })
      if (index === -1) {
        return
      }
      // 2. ä»Žå†…存移除消息
      const [removed] = messages.splice(index, 1)
      if (!removed) {
        return
      }
      revokeBlobUrlsInContent(removed.content)
      if (index === messages.length) {
        recomputeConversationLast(conversation, messages)
      }
      // 3. åˆ é™¤æœ¬åœ°è®°å½•并保存会话摘要
      getDb()
        .delete('messages', getMessageKey(removed, conversationType))
        .catch((error) => console.warn('[IM messageStore] æ¶ˆæ¯åˆ é™¤å¤±è´¥', error))
      conversationStore.saveConversation(conversation)
    },
    /** åˆ é™¤ä¼šè¯å…¨éƒ¨æ¶ˆæ¯ */
    deleteConversationMessageList(conversationType: number, targetId: number) {
      // 1. æ¸…理内存消息和媒体资源
      const clientConversationId = getClientConversationId(conversationType, targetId)
      const messages = this.messagesByConversation[clientConversationId] || []
      messages.forEach((message) => {
        revokeBlobUrlsInContent(message.content)
        message._localFile = undefined
      })
      Reflect.deleteProperty(this.messagesByConversation, clientConversationId)
      this.loadedConversationKeys = this.loadedConversationKeys.filter(
        (key) => key !== clientConversationId
      )
      // 2. åˆ é™¤ IndexedDB æ¶ˆæ¯
      getDb()
        .deleteByIndex('messages', 'clientConversationId', clientConversationId)
        .catch((error) => console.warn('[IM messageStore] ä¼šè¯æ¶ˆæ¯åˆ é™¤å¤±è´¥', error))
    }
  }
})
export const useMessageStoreWithOut = () => useMessageStore()
if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useMessageStore, import.meta.hot))
}