gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/store/conversationStore.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,910 @@
import type {
  Conversation,
  ConversationDO,
  ConversationRead,
  ConversationReadDO,
  MessageDO
} from '../types'
import type { ImConversationReadApi } from '#/api/im/conversation/read'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { pullMyConversationReadList as apiPullMyConversationReadList } from '#/api/im/conversation/read'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config'
import {
  ImConversationType,
  ImMessageReceiptStatus,
  ImMessageStatus,
  isNormalMessage
} from '../../utils/constants'
import { type DbTransaction, getClientConversationId, getDb, StorageKeys } from '../../utils/db'
import { runIncrementalPull } from '../../utils/pull'
import { useMessageStore } from './messageStore'
const PERSIST_DRAFT_DEBOUNCE_MS = 500
const pendingDraftConversations = new Set<Conversation>()
/** åˆ›å»ºä¼šè¯è¯»ä½ç½®è®°å½• */
function createConversationRead(
  type: number,
  targetId: number,
  messageId: number
): ConversationRead {
  return {
    conversationType: type,
    targetId,
    messageId,
    updateTime: Date.now()
  }
}
/** åˆ›å»ºè‰ç¨¿ä¿å­˜é˜²æŠ–函数 */
function createDraftDebounce(fn: () => void, wait: number) {
  let timer: ReturnType<typeof setTimeout> | undefined
  const run = () => {
    if (timer) {
      clearTimeout(timer)
      timer = undefined
    }
    fn()
  }
  const debounced = () => {
    if (timer) {
      clearTimeout(timer)
    }
    timer = setTimeout(run, wait)
  }
  debounced.cancel = () => {
    if (timer) {
      clearTimeout(timer)
      timer = undefined
    }
  }
  debounced.flush = run
  return debounced
}
/** ä¼šè¯è½¬ IndexedDB è®°å½• */
function toConversationDO(conversation: Conversation): ConversationDO {
  const draft = conversation.draft
  return {
    targetId: conversation.targetId,
    type: conversation.type,
    name: conversation.name,
    avatar: conversation.avatar,
    unreadCount: conversation.unreadCount,
    lastContent: conversation.lastContent,
    lastSendTime: conversation.lastSendTime,
    lastSenderId: conversation.lastSenderId,
    lastMessageType: conversation.lastMessageType,
    lastMessageId: conversation.lastMessageId,
    lastClientMessageId: conversation.lastClientMessageId,
    lastMessageStatus: conversation.lastMessageStatus,
    lastReceiptStatus: conversation.lastReceiptStatus,
    lastSelfSend: conversation.lastSelfSend,
    lastSenderDisplayName: conversation.lastSenderDisplayName,
    reportedReadMessageId: conversation.reportedReadMessageId,
    deleted: conversation.deleted,
    top: conversation.top,
    silent: conversation.silent,
    atMe: conversation.atMe,
    atAll: conversation.atAll,
    draft: draft ? { ...draft, reply: draft.reply ? { ...draft.reply } : undefined } : undefined,
    clientConversationId: getClientConversationId(conversation.type, conversation.targetId)
  }
}
/** IndexedDB è®°å½•转会话 */
function fromConversationDO(conversation: ConversationDO): Conversation {
  const {
    clientConversationId: _clientConversationId,
    ...rest
  } = conversation
  return rest
}
/** ä¼šè¯è¯»ä½ç½®è½¬ IndexedDB è®°å½• */
function toConversationReadDO(record: ConversationRead): ConversationReadDO {
  return {
    conversationType: record.conversationType,
    targetId: record.targetId,
    messageId: record.messageId,
    updateTime: record.updateTime,
    clientConversationId: getClientConversationId(record.conversationType, record.targetId)
  }
}
/** IndexedDB è®°å½•转会话读位置 */
function fromConversationReadDO(record: ConversationReadDO): ConversationRead {
  const { clientConversationId: _clientConversationId, ...rest } = record
  return rest
}
/** æ˜¯å¦ä¸ºæœ‰æ•ˆä¼šè¯è¯»ä½ç½® */
function isValidConversationReadRecord(record: ImConversationReadApi.ConversationReadRespVO): boolean {
  return !!record.conversationType && !!record.targetId && !!record.messageId
}
/** èŽ·å–å¯¹æ–¹æ™®é€šæ¶ˆæ¯æœ€å¤§ç¼–å· */
function getMaxIncomingNormalMessageId(
  messages: Array<Pick<MessageDO, 'id' | 'selfSend' | 'status' | 'type'>>
): number {
  let maxMessageId = 0
  for (const message of messages) {
    if (
      message.id &&
      !message.selfSend &&
      isNormalMessage(message.type) &&
      message.status !== ImMessageStatus.RECALL &&
      message.id > maxMessageId
    ) {
      maxMessageId = message.id
    }
  }
  return maxMessageId
}
export const useConversationStore = defineStore('imConversationStore', {
  state: () => ({
    conversations: [] as Conversation[], // å…¨é‡ä¼šè¯åˆ—表(私聊 + ç¾¤èŠ + é¢‘道)
    conversationReads: {} as Record<string, ConversationRead>, // ä¼šè¯è¯»ä½ç½®
    activeConversation: null as Conversation | null, // å½“前激活的会话
    loading: false, // æ˜¯å¦æ­£åœ¨æ‰¹é‡åŠ è½½
    recentForwardConversationKeys: [] as string[] // æœ€è¿‘转发会话 key åˆ—表
  }),
  getters: {
    /** æŽ’序后的会话列表 */
    getSortedConversationList(state): Conversation[] {
      return state.conversations
        .filter((conversation) => !conversation.deleted)
        .toSorted((a, b) => {
          const aTop = a.top ? 1 : 0
          const bTop = b.top ? 1 : 0
          if (aTop !== bTop) {
            return bTop - aTop
          }
          return (b.lastSendTime || 0) - (a.lastSendTime || 0)
        })
    },
    /** æœªè¯»æ€»æ•° */
    getTotalUnreadCount(state): number {
      return state.conversations
        .filter((conversation) => !conversation.deleted && !conversation.silent)
        .reduce((sum, conversation) => sum + (conversation.unreadCount || 0), 0)
    },
    /** æŸ¥æ‰¾ä¼šè¯ */
    getConversation:
      (state) =>
      (type: number, targetId: number): Conversation | undefined =>
        state.conversations.find(
          (conversation) => conversation.type === type && conversation.targetId === targetId
        ),
    /** æŸ¥æ‰¾ä¼šè¯è¯»ä½ç½® */
    getConversationRead:
      (state) =>
      (type: number, targetId: number): ConversationRead | undefined =>
        state.conversationReads[getClientConversationId(type, targetId)]
  },
  actions: {
    /** åŠ è½½ä¼šè¯ */
    async loadConversationList() {
      // 1. æ¸…理旧账号内存
      const userId = getCurrentUserId()
      if (!userId) {
        this.clear()
        return
      }
      const previousActiveKey = this.activeConversation
        ? getClientConversationId(this.activeConversation.type, this.activeConversation.targetId)
        : null
      this.clear()
      // 2. ä»Ž IndexedDB è¯»å–会话和轻量设置
      const db = getDb()
      const [conversations, conversationReads, recent] = await Promise.all([
        db.getAll<ConversationDO>('conversations'),
        db.getAll<ConversationReadDO>('conversationReads'),
        db.getSetting<string[]>(StorageKeys.settings.recentForwardConversationKeys)
      ])
      const nextConversationReads: Record<string, ConversationRead> = {}
      for (const record of conversationReads) {
        const item = fromConversationReadDO(record)
        nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item
      }
      const nextConversations = conversations.map((conversation) => fromConversationDO(conversation))
      this.conversationReads = nextConversationReads
      await this.applyLocalConversationReads(nextConversations)
      this.conversations = nextConversations
      if (Array.isArray(recent)) {
        this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
      }
      // 3. æ¢å¤å½“前激活会话
      if (previousActiveKey) {
        this.activeConversation =
          this.conversations.find(
            (conversation) =>
              !conversation.deleted &&
              getClientConversationId(conversation.type, conversation.targetId) ===
                previousActiveKey
          ) ?? null
      }
    },
    /** æ¸…空会话内存 */
    clear() {
      saveDraftConversationListDebounced.cancel()
      pendingDraftConversations.clear()
      this.conversations = []
      this.conversationReads = {}
      this.activeConversation = null
      this.recentForwardConversationKeys = []
    },
    /** æŒä¹…化会话读位置 */
    async saveConversationReadRecord(
      target: ConversationRead | ConversationRead[] | null | undefined,
      tx?: DbTransaction
    ): Promise<void> {
      let targets: ConversationRead[] = []
      if (Array.isArray(target)) {
        targets = target
      } else if (target) {
        targets = [target]
      }
      const records = targets.map((record) => toConversationReadDO(record))
      if (records.length === 0) {
        return
      }
      const db = getDb()
      if (tx) {
        for (const record of records) {
          await db.put('conversationReads', record, tx)
        }
        return
      }
      await db.transaction(['conversationReads'], 'readwrite', async (tx) => {
        for (const record of records) {
          await db.put('conversationReads', record, tx)
        }
      })
    },
    /** åº”用本地会话读位置 */
    async applyLocalConversationReads(conversations?: Conversation[]) {
      const targetConversations = conversations || this.conversations
      const changedConversations: Conversation[] = []
      for (const conversation of targetConversations) {
        const record = this.getConversationRead(conversation.type, conversation.targetId)
        if (!record) {
          continue
        }
        if (this.applyReadToConversation(conversation, record.messageId)) {
          changedConversations.push(conversation)
          continue
        }
        if (conversation.unreadCount === 0 && !conversation.atMe && !conversation.atAll) {
          continue
        }
        const messages = await getDb().getAllByIndex<MessageDO>(
          'messages',
          'clientConversationId',
          getClientConversationId(conversation.type, conversation.targetId)
        )
        const maxIncomingMessageId = getMaxIncomingNormalMessageId(messages)
        if (maxIncomingMessageId > 0 && maxIncomingMessageId <= record.messageId) {
          conversation.unreadCount = 0
          conversation.atMe = false
          conversation.atAll = false
          changedConversations.push(conversation)
        }
      }
      if (changedConversations.length > 0) {
        await this.saveConversationRecord(changedConversations)
      }
    },
    /** åˆ¤æ–­æ¶ˆæ¯æ˜¯å¦å·²è¢«ä¼šè¯è¯»ä½ç½®è¦†ç›– */
    isMessageCoveredByReadPosition(
      conversation: Pick<Conversation, 'targetId' | 'type'>,
      message?: null | { id?: number }
    ): boolean {
      if (!message?.id) {
        return false
      }
      const record = this.getConversationRead(conversation.type, conversation.targetId)
      return !!record && message.id <= record.messageId
    },
    /** åˆ¤æ–­ä¼šè¯è¯»ä½ç½®æ˜¯å¦è¦†ç›–消息编号 */
    isReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
      if (!messageId) {
        return false
      }
      const record = this.getConversationRead(type, targetId)
      return !!record && record.messageId >= messageId
    },
    /** åˆ¤æ–­æœåŠ¡ç«¯å·²è¯»ä½ç½®æ˜¯å¦è¦†ç›–æ¶ˆæ¯ç¼–å· */
    isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
      if (!messageId) {
        return false
      }
      const conversation = this.getConversation(type, targetId)
      return (conversation?.reportedReadMessageId || 0) >= messageId
    },
    /** åº”用读位置到会话 */
    applyReadToConversation(conversation: Conversation, messageId: number): boolean {
      if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
        return false
      }
      if (conversation.unreadCount === 0 && !conversation.atMe && !conversation.atAll) {
        return false
      }
      conversation.unreadCount = 0
      conversation.atMe = false
      conversation.atAll = false
      return true
    },
    /** åº”用会话读位置 */
    async applyConversationReadList(
      records: ImConversationReadApi.ConversationReadRespVO[],
      isActive?: () => boolean
    ): Promise<void> {
      if (records.length === 0) {
        return
      }
      const changedReads = new Map<string, ConversationRead>()
      const changedConversations = new Map<string, Conversation>()
      const changedMessages = new Map<string, MessageDO>()
      const db = getDb()
      const messageStore = useMessageStore()
      // 1. æŒ‰è¯»ä½ç½®æ›´æ–°ä¼šè¯æœªè¯»å’Œé¢‘道已读态
      for (const record of records) {
        if (isActive && !isActive()) {
          return
        }
        if (!isValidConversationReadRecord(record)) {
          continue
        }
        const clientConversationId = getClientConversationId(
          record.conversationType,
          record.targetId
        )
        let storedMessages: MessageDO[] | undefined
        const getStoredMessages = async () => {
          if (!storedMessages) {
            storedMessages = await db.getAllByIndex<MessageDO>(
              'messages',
              'clientConversationId',
              clientConversationId
            )
          }
          return storedMessages
        }
        const current = this.conversationReads[clientConversationId]
        const messageId = Math.max(record.messageId, current?.messageId || 0)
        const conversation = this.getConversation(record.conversationType, record.targetId)
        if (conversation && record.messageId > (conversation.reportedReadMessageId || 0)) {
          conversation.reportedReadMessageId = record.messageId
          changedConversations.set(clientConversationId, conversation)
        }
        if (!current || messageId > current.messageId) {
          const next = {
            conversationType: record.conversationType,
            targetId: record.targetId,
            messageId,
            updateTime: record.updateTime
          }
          this.conversationReads[clientConversationId] = next
          changedReads.set(clientConversationId, next)
        }
        if (conversation && this.applyReadToConversation(conversation, messageId)) {
          changedConversations.set(clientConversationId, conversation)
        } else if (conversation) {
          const maxIncomingMessageId = getMaxIncomingNormalMessageId(await getStoredMessages())
          if (maxIncomingMessageId > 0 && maxIncomingMessageId <= messageId) {
            conversation.unreadCount = 0
            conversation.atMe = false
            conversation.atAll = false
            changedConversations.set(clientConversationId, conversation)
          }
        }
        if (record.conversationType !== ImConversationType.CHANNEL) {
          continue
        }
        const memoryMessages = messageStore.getMessages(clientConversationId)
        for (const message of memoryMessages) {
          if (
            message.id &&
            message.id <= messageId &&
            message.receiptStatus !== ImMessageReceiptStatus.DONE
          ) {
            message.receiptStatus = ImMessageReceiptStatus.DONE
          }
        }
        for (const message of await getStoredMessages()) {
          if (
            message.id &&
            message.id <= messageId &&
            message.receiptStatus !== ImMessageReceiptStatus.DONE
          ) {
            message.receiptStatus = ImMessageReceiptStatus.DONE
            changedMessages.set(message.messageKey, message)
          }
        }
      }
      // 2. æŒä¹…化本轮变更
      if (
        changedReads.size === 0 &&
        changedConversations.size === 0 &&
        changedMessages.size === 0
      ) {
        return
      }
      if (isActive && !isActive()) {
        return
      }
      const stores: Array<'conversationReads' | 'conversations' | 'messages'> = []
      if (changedReads.size > 0) {
        stores.push('conversationReads')
      }
      if (changedConversations.size > 0) {
        stores.push('conversations')
      }
      if (changedMessages.size > 0) {
        stores.push('messages')
      }
      await db.transaction(stores, 'readwrite', async (tx) => {
        if (changedReads.size > 0) {
          await this.saveConversationReadRecord([...changedReads.values()], tx)
        }
        if (changedConversations.size > 0) {
          await this.saveConversationRecord([...changedConversations.values()], tx)
        }
        for (const message of changedMessages.values()) {
          await db.put('messages', message, tx)
        }
      })
    },
    /** å¢žé‡æ‹‰å–会话读位置 */
    async pullConversationReads(isActive?: () => boolean): Promise<void> {
      await runIncrementalPull(
        StorageKeys.settings.conversationReadPullCursor,
        apiPullMyConversationReadList,
        async (records) => {
          if (isActive && !isActive()) {
            return false
          }
          await this.applyConversationReadList(records, isActive)
          if (isActive && !isActive()) {
            return false
          }
          return true
        },
        isActive
      )
    },
    /** æ‰§è¡Œä¼šè¯è®°å½•持久化 */
    async saveConversationRecord(
      target: Conversation | Conversation[] | null | undefined,
      tx?: DbTransaction
    ): Promise<void> {
      const db = getDb()
      const conversations = (Array.isArray(target) ? target : (target ? [target] : [])).map(
        (conversation) => toConversationDO(conversation)
      )
      if (conversations.length === 0) {
        return
      }
      if (tx) {
        for (const conversation of conversations) {
          await db.put('conversations', conversation, tx)
        }
        return
      }
      await db.transaction(['conversations'], 'readwrite', async (tx) => {
        for (const conversation of conversations) {
          await db.put('conversations', conversation, tx)
        }
      })
    },
    /** æŒä¹…化单个会话 */
    saveConversation(conversation: Conversation | null | undefined, tx?: DbTransaction): void {
      if (!conversation) {
        return
      }
      void this.saveConversationRecord(conversation, tx).catch((error) =>
        console.warn('[IM conversationStore] ä¼šè¯å†™å…¥å¤±è´¥', error)
      )
    },
    /** æŒä¹…化会话列表 */
    saveConversationList(conversations?: Conversation[] | null, tx?: DbTransaction): void {
      if (this.loading && !tx) {
        return
      }
      void this.saveConversationRecord(conversations || this.conversations, tx).catch((error) =>
        console.warn('[IM conversationStore] ä¼šè¯å†™å…¥å¤±è´¥', error)
      )
    },
    /** ç¡®ä¿ä¼šè¯å­˜åœ¨ */
    ensureConversation(info: {
      avatar: string
      name: string
      silent?: boolean
      targetId: number
      type: number
    }): Conversation {
      // 1. åˆ›å»ºä¸å­˜åœ¨çš„会话
      let conversation = this.getConversation(info.type, info.targetId)
      if (!conversation) {
        conversation = this.createEmptyConversation(
          info.type,
          info.targetId,
          info.name,
          info.avatar,
          info.silent
        )
        this.conversations.unshift(conversation)
      } else if (conversation.deleted) {
        // 2. æ¢å¤è½¯åˆ é™¤ä¼šè¯
        conversation.deleted = false
        conversation.name = info.name || conversation.name
        conversation.avatar = info.avatar || conversation.avatar
        if (info.silent !== undefined) {
          conversation.silent = info.silent
        }
      } else {
        // 3. åŒæ­¥ä¼šè¯å±•示元数据
        if (info.name) {
          conversation.name = info.name
        }
        if (info.avatar) {
          conversation.avatar = info.avatar
        }
        if (info.silent !== undefined) {
          conversation.silent = info.silent
        }
      }
      return conversation
    },
    /** æ‰“开或创建会话 */
    openConversation(
      targetId: number,
      type: number,
      name: string,
      avatar: string,
      options?: { silent?: boolean }
    ): Conversation {
      // 1. ç¡®ä¿ä¼šè¯åœ¨åˆ—表中
      const conversation = this.ensureConversation({
        type,
        targetId,
        name,
        avatar,
        silent: options?.silent
      })
      // 2. æ¿€æ´»ä¼šè¯å¹¶ä¿å­˜
      this.setActiveConversation(conversation)
      this.saveConversation(conversation)
      return conversation
    },
    /** è®¾ç½®å½“前会话 */
    setActiveConversation(conversation: Conversation | null) {
      this.activeConversation = conversation
      if (!conversation) {
        return
      }
      // æ‡’加载消息并保存会话摘要
      void useMessageStore().ensureConversationMessageListLoaded(conversation)
      this.saveConversation(conversation)
    },
    /** åˆ›å»ºç©ºä¼šè¯ */
    createEmptyConversation(
      type: number,
      targetId: number,
      name: string,
      avatar: string,
      silent = false
    ): Conversation {
      return {
        targetId,
        type,
        name,
        avatar,
        lastContent: '',
        lastSendTime: 0,
        unreadCount: 0,
        deleted: false,
        top: false,
        silent,
        atMe: false,
        atAll: false
      }
    },
    /** è®¾ç½®ç½®é¡¶ */
    setConversationTop(type: number, targetId: number, top: boolean) {
      const conversation = this.getConversation(type, targetId)
      if (!conversation) {
        return
      }
      conversation.top = top
      this.saveConversation(conversation)
    },
    /** è®¾ç½®å…æ‰“扰 */
    setConversationSilent(type: number, targetId: number, silent: boolean) {
      const conversation = this.getConversation(type, targetId)
      if (!conversation) {
        return
      }
      conversation.silent = silent
      this.saveConversation(conversation)
    },
    /** åˆ é™¤ä¼šè¯ */
    removeConversation(type: number, targetId: number) {
      // 1. æ ‡è®°ä¼šè¯åˆ é™¤
      const conversation = this.getConversation(type, targetId)
      if (!conversation) {
        return
      }
      if (this.activeConversation === conversation) {
        this.activeConversation = null
      }
      conversation.deleted = true
      // 2. åˆ é™¤ä¼šè¯å…³è”的消息和草稿
      useMessageStore().deleteConversationMessageList(type, targetId)
      this.clearConversationDraft(conversation)
      this.saveConversation(conversation)
    },
    /** åˆ é™¤ç§èŠä¼šè¯ */
    removePrivateConversation(friendId: number) {
      this.removeConversation(ImConversationType.PRIVATE, friendId)
    },
    /** åˆ é™¤ç¾¤èŠä¼šè¯ */
    removeGroupConversation(groupId: number) {
      this.removeConversation(ImConversationType.GROUP, groupId)
    },
    /** æ ‡è®°ä¼šè¯å·²è¯» */
    markConversationRead(type: number, targetId: number, messageId?: number): void {
      const conversation = this.getConversation(type, targetId)
      if (!conversation) {
        return
      }
      const key = getClientConversationId(type, targetId)
      const current = this.conversationReads[key]
      const readMessageIdAdvanced = !!messageId && messageId > (current?.messageId || 0)
      if (
        conversation.unreadCount === 0 &&
        !conversation.atMe &&
        !conversation.atAll &&
        !readMessageIdAdvanced
      ) {
        return
      }
      conversation.unreadCount = 0
      conversation.atMe = false
      conversation.atAll = false
      if (readMessageIdAdvanced) {
        const record = createConversationRead(type, targetId, messageId)
        this.conversationReads[key] = record
        void getDb()
          .transaction(['conversations', 'conversationReads'], 'readwrite', async (tx) => {
            await this.saveConversationRecord(conversation, tx)
            await this.saveConversationReadRecord(record, tx)
          })
          .catch((error) =>
            console.warn(
              '[IM conversationStore] ä¼šè¯å·²è¯»å†™å…¥å¤±è´¥',
              {
                conversationType: type,
                targetId,
                messageId,
                conversationKey: key
              },
              error
            )
          )
        return
      }
      this.saveConversation(conversation)
    },
    /** æ ‡è®°ä¼šè¯å·²ä¸ŠæŠ¥æœåŠ¡ç«¯è¯»ä½ç½® */
    markConversationReadReported(type: number, targetId: number, messageId?: number): void {
      if (!messageId) {
        return
      }
      const conversation = this.getConversation(type, targetId)
      if (!conversation || messageId <= (conversation.reportedReadMessageId || 0)) {
        return
      }
      conversation.reportedReadMessageId = messageId
      this.saveConversation(conversation)
    },
    // ==================== æœ€è¿‘转发 ====================
    /** æŽ¨é€æœ€è¿‘转发会话 */
    pushRecentForwardConversationKeyList(keys: string[]) {
      if (!keys || keys.length === 0) {
        return
      }
      const merged = [...keys, ...this.recentForwardConversationKeys]
      this.recentForwardConversationKeys = [...new Set(merged)].slice(
        0,
        CONVERSATION_RECENT_FORWARD_MAX
      )
      this.saveRecentForwardConversationKeyList()
    },
    /** ç§»é™¤æœ€è¿‘转发会话 */
    removeRecentForwardConversationKey(key: string) {
      const index = this.recentForwardConversationKeys.indexOf(key)
      if (index === -1) {
        return
      }
      this.recentForwardConversationKeys.splice(index, 1)
      this.saveRecentForwardConversationKeyList()
    },
    /** ä¿å­˜æœ€è¿‘转发会话 */
    saveRecentForwardConversationKeyList() {
      void getDb()
        .setSetting(
          StorageKeys.settings.recentForwardConversationKeys,
          this.recentForwardConversationKeys.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
        )
        .catch((error) => console.warn('[IM conversationStore] æœ€è¿‘转发列表写入失败', error))
    },
    // ==================== ä¼šè¯ç»´æŠ¤ ====================
    /** é‡æŽ’会话 */
    sortConversationList() {
      this.conversations.sort((a, b) => (b.lastSendTime || 0) - (a.lastSendTime || 0))
      this.saveConversationList(this.conversations)
    },
    /** åŒæ­¥ä¼šè¯å±•示元数据 */
    updateConversation(
      type: number,
      targetId: number,
      info: { avatar?: string; name?: string; silent?: boolean }
    ) {
      const conversation = this.getConversation(type, targetId)
      if (!conversation) {
        return
      }
      let changed = false
      if (info.name && conversation.name !== info.name) {
        conversation.name = info.name
        changed = true
      }
      if (info.avatar !== undefined && conversation.avatar !== info.avatar) {
        conversation.avatar = info.avatar || ''
        changed = true
      }
      if (info.silent !== undefined && conversation.silent !== info.silent) {
        conversation.silent = info.silent
        changed = true
      }
      if (changed) {
        this.saveConversation(conversation)
      }
    },
    // ==================== è‰ç¨¿ ====================
    /** èŽ·å–è‰ç¨¿ */
    getConversationDraft(conversation: { targetId: number; type: number; }): Conversation['draft'] | undefined {
      return this.getConversation(conversation.type, conversation.targetId)?.draft
    },
    /** è®¾ç½®è‰ç¨¿ */
    setConversationDraft(
      conversation: { targetId: number; type: number; },
      snapshot: NonNullable<Conversation['draft']>
    ): void {
      if (!snapshot.plain.trim() && !snapshot.reply) {
        this.clearConversationDraft(conversation)
        return
      }
      const target = this.getConversation(conversation.type, conversation.targetId)
      if (!target) {
        return
      }
      target.draft = snapshot
      this.scheduleConversationDraftSave(target)
    },
    /** æ¸…除草稿 */
    clearConversationDraft(conversation: { targetId: number; type: number; }): void {
      const target = this.getConversation(conversation.type, conversation.targetId)
      if (!target?.draft) {
        return
      }
      target.draft = undefined
      this.scheduleConversationDraftSave(target)
    },
    /** è®¾ç½®å›žå¤è‰ç¨¿ */
    setConversationReplyDraft(
      conversation: { targetId: number; type: number; },
      quote: NonNullable<Conversation['draft']>['reply']
    ) {
      if (!quote) {
        return
      }
      const existing = this.getConversationDraft(conversation)
      this.setConversationDraft(conversation, {
        html: existing?.html ?? '',
        plain: existing?.plain ?? '',
        reply: quote
      })
    },
    /** æ¸…除回复草稿 */
    clearConversationReplyDraft(conversation: { targetId: number; type: number; }): void {
      const existing = this.getConversationDraft(conversation)
      if (!existing?.reply) {
        return
      }
      this.setConversationDraft(conversation, { ...existing, reply: undefined })
    },
    /** è°ƒåº¦è‰ç¨¿ä¿å­˜ */
    scheduleConversationDraftSave(conversation: Conversation): void {
      pendingDraftConversations.add(conversation)
      saveDraftConversationListDebounced()
    },
    /** ç«‹å³ä¿å­˜è‰ç¨¿ */
    flushConversationDraftSave(): void {
      saveDraftConversationListDebounced.flush()
    }
  }
})
export const useConversationStoreWithOut = () => useConversationStore()
/** åˆå¹¶è‰ç¨¿å†™å…¥ */
const saveDraftConversationListDebounced = createDraftDebounce(() => {
  const conversations = [...pendingDraftConversations]
  pendingDraftConversations.clear()
  if (conversations.length === 0) {
    return
  }
  void useConversationStoreWithOut()
    .saveConversationRecord(conversations)
    .catch((error) => console.warn('[IM conversationStore] è‰ç¨¿å†™å…¥å¤±è´¥', error))
}, PERSIST_DRAFT_DEBOUNCE_MS)
if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useConversationStore, import.meta.hot))
}