admin-vue3/src/views/im/home/store/conversationStore.ts

772 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { defineStore, acceptHMRUpdate } from 'pinia'
import { toRaw } from 'vue'
import { store } from '@/store'
import {
ImConversationType,
ImMessageType,
ImMessageStatus,
IM_AT_ALL_USER_ID,
TIME_TIP_GAP_MS
} from '../../utils/constants'
import { getCurrentUserId, imStorage, removeQuietly, StorageKeys } from '../../utils/storage'
import { generateClientMessageId, parseRecallMessageId } from '../../utils/message'
import { resolveConversationLastContent } from '../../utils/conversation'
import { tryGetSenderDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore'
import type { Conversation, ConversationStoreMeta, Message } from '../types'
/**
* 算出新一条 lastSenderDisplayName 快照——caller 拿这个值去赋 conversation.lastSenderDisplayName
*
* 1. 能算出真名 → 用真名
* 2. 算不出 + 同发送人 → 沿用旧快照(冷拉期间常见)
* 3. 算不出 + 换发送人 → undefined旧快照不再适用
*
* 群聊算不出真名时顺手触发兜底拉成员store 内部已并发去重),让后续渲染能命中真名
*/
function deriveLastSenderDisplayName(
conversation: Conversation,
senderId: number
): string | undefined {
// 1. 严格版算名字:能拿到 displayUserName / 备注 / 真实昵称就直接用,对应规则 1
const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
if (liveSenderName) {
return liveSenderName
}
// 2. 群聊兜底拉成员:分两种情况
// a. members 完全没加载(!membersLoaded→ 拉整群pullOnce 期间多个 senderId 都缺时,单飞表会 dedup 成一次请求)
// b. members 已加载但缺这一个(新加入的成员,本端未收到 GROUP_MEMBER_UPDATE→ 补齐这一个
if (conversation.type === ImConversationType.GROUP) {
const groupStore = useGroupStore()
const group = groupStore.getGroup(conversation.targetId)
const fetchPromise = group?.membersLoaded
? groupStore.fetchGroupMember(conversation.targetId, senderId)
: groupStore.fetchGroupMembers(conversation.targetId)
fetchPromise.catch((e) =>
console.warn(
'[IM conversationStore] 兜底拉群成员失败',
{ groupId: conversation.targetId, senderId, fullFetch: !group?.membersLoaded },
e
)
)
}
// 3. 算不出真名:同发送人沿用旧快照(规则 2换人则清掉避免显示成上一个人规则 3
const isSameSender = conversation.lastSenderId === senderId
return isSameSender ? conversation.lastSenderDisplayName : undefined
}
// TODO @芋艿:单个 conversation 的消息过多后,可能存储起来会很慢,后续看看怎么优化。
// TODO @芋艿首次拉取消息时如果消息过多可能导致渲染卡顿。1% 场景)
export const useConversationStore = defineStore('imConversationStore', {
state: () => ({
conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊)
activeConversation: null as Conversation | null, // 当前激活的会话
privateMessageMaxId: 0, // 私聊最大消息 id作为 pull 的游标
groupMessageMaxId: 0, // 群聊最大消息 id作为 pull 的游标
loading: false // 是否正在批量加载(例如离线消息拉取期间),避免频繁写存储
}),
getters: {
/**
* 会话列表排序规则:
* 1. 置顶优先top=true 的在前)
* 2. 同级别按 lastSendTime 降序
*/
getSortedConversations(state): Conversation[] {
return [...state.conversations]
.filter((c) => !c.deleted)
.sort((a, b) => {
const aTop = a.top ? 1 : 0
const bTop = b.top ? 1 : 0
if (aTop !== bTop) {
return bTop - aTop
}
return b.lastSendTime - a.lastSendTime
})
},
/** 当前会话的消息列表 */
getActiveMessages(state): Message[] {
return state.activeConversation?.messages || []
},
/** 未读总数(免打扰会话不计入)—— 用于 ToolBar 红点 */
getTotalUnread(state): number {
return state.conversations
.filter((c) => !c.deleted && !c.muted)
.reduce((sum, c) => sum + (c.unreadCount || 0), 0)
},
/** 查找会话:按 (type, targetId) 组合主键 */
getConversation:
(state) =>
(type: number, targetId: number): Conversation | undefined => {
return state.conversations.find((c) => c.type === type && c.targetId === targetId)
}
},
actions: {
// ==================== 本地存储 ====================
/**
* 从 IndexedDB 恢复会话数据
*
* 1. 读 meta游标 + 会话索引),无 meta 直接返回
* 2. 并发读取每个会话的消息 key组装回 Conversation
* 3. 修正重启前遗留的"发送中"状态为失败
*/
async loadConversations() {
const userId = getCurrentUserId()
if (!userId) {
return
}
try {
const meta = await imStorage.getItem<ConversationStoreMeta>(
StorageKeys.conversationMeta(userId)
)
if (!meta) {
return
}
this.privateMessageMaxId = Number(meta.privateMessageMaxId) || 0
this.groupMessageMaxId = Number(meta.groupMessageMaxId) || 0
if (!meta.conversations || meta.conversations.length === 0) {
return
}
// 并发拉取每个会话的消息,组装回完整 Conversation
// 单会话失败时退化为空消息列表 + 打印日志,避免拖垮整体加载
const tasks = meta.conversations.map(async (conversation): Promise<Conversation> => {
try {
const messages =
(await imStorage.getItem<Message[]>(
StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId)
)) || []
// 发送中状态的消息标记为失败:重启后不可能仍处在发送中
messages.forEach((message) => {
if (message.status === ImMessageStatus.SENDING) {
message.status = ImMessageStatus.FAILED
}
})
return { ...conversation, messages }
} catch (e) {
console.warn(
'[IM] 单会话消息加载失败',
{ type: conversation.type, targetId: conversation.targetId },
e
)
return { ...conversation, messages: [] }
}
})
this.conversations = await Promise.all(tasks)
} catch (e) {
console.error('[IM] 本地消息缓存读取失败', e)
}
},
/**
* 持久化到 IndexedDBfire-and-forget调用方无需 await
*
* - 不传 target仅写 meta适用于 top / muted / unread 等元数据变更)
* - 传单个 conversation写 meta + 该会话的消息(单条消息变更走这里)
* - 传数组:写 meta + 数组里所有未删除会话的消息loading 完成后兜底 flush 用)
*
* 按会话分桶后,单条消息变更只重写当前会话的消息 key避免老方案的全量序列化写入失败已在内部 catch 兜底(仅打印日志)不影响 UI 流程,所以接口签名设为 void
*/
saveConversations(target?: Conversation | Conversation[] | null): void {
// loading 期间跳过,避免离线消息批量到达时的密集写入
if (this.loading) {
return
}
const userId = getCurrentUserId()
if (!userId) {
return
}
// 1. meta游标 + 会话索引(剔除 messages过滤软删除
const meta: ConversationStoreMeta = {
privateMessageMaxId: this.privateMessageMaxId,
groupMessageMaxId: this.groupMessageMaxId,
conversations: this.conversations
.filter((c) => !c.deleted)
.map(({ messages, ...rest }) => rest)
}
const tasks: Promise<unknown>[] = [
imStorage.setItem(StorageKeys.conversationMeta(userId), meta)
]
// 2. 归一化 target 为待 flush 的会话列表,过滤掉已软删除的
const conversationsToFlush: Conversation[] = (
Array.isArray(target) ? target : target ? [target] : []
).filter((c) => !c.deleted)
for (const conversation of conversationsToFlush) {
// toRaw 拆掉 Vue reactive ProxyIDB 的 structuredClone 不接受 Proxy不拆会抛 DataCloneError 静默落盘失败(只 meta 写得进去messages 永远丢)
tasks.push(
imStorage.setItem(
StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId),
toRaw(conversation.messages)
)
)
}
// 3. fire-and-forget失败仅打日志不影响 UI
void Promise.all(tasks).catch((e) => console.error('[IM] 本地消息缓存存储失败', e))
},
/** 物理删除某个会话的消息 key软删除会话时释放空间fire-and-forget */
removeConversationMessages(type: number, targetId: number): void {
const userId = getCurrentUserId()
if (!userId) {
return
}
removeQuietly(
StorageKeys.conversationMessages(userId, type, targetId),
'[IM] 本地消息缓存删除失败'
)
},
// ==================== 会话查找 / 打开 ====================
/**
* 打开或创建一个会话,并设为激活
*
* 调用方应该把从 friendStore / groupStore 拿到的最新元数据muted 等)
* 通过 options 传进来,避免新建/复用的会话显示陈旧状态。
* 此处不在 conversationStore 里反向 import friendStore/groupStore是为了避免循环依赖。
*/
openConversation(
targetId: number,
type: number,
name: string,
avatar: string,
options?: { muted?: boolean }
): Conversation {
// 按 (type, targetId) 查找已有会话,不存在则新建并插到列表头部
let conversation = this.getConversation(type, targetId)
if (!conversation) {
conversation = this.createEmptyConversation(type, targetId, name, avatar)
if (options?.muted !== undefined) {
conversation.muted = options.muted
}
this.conversations.unshift(conversation)
} else {
// 已存在会话:用最新元数据刷新 name / avatar / muted
if (name) {
conversation.name = name
}
if (avatar) {
conversation.avatar = avatar
}
if (options?.muted !== undefined) {
conversation.muted = options.muted
}
}
this.setActiveConversation(conversation)
return conversation
},
/** 设置当前会话,同时清零未读数 + 清除 @ 标记 */
setActiveConversation(conversation: Conversation | null) {
this.activeConversation = conversation
if (conversation) {
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
// 仅元数据变更unreadCount / atMe / atAll不动 messages
this.saveConversations()
}
},
/** 创建空会话(抽取公共逻辑,供 insertMessage / openConversation 复用) */
createEmptyConversation(
type: number,
targetId: number,
name: string,
avatar: string
): Conversation {
return {
targetId,
type,
name,
avatar,
lastContent: '',
lastSendTime: 0,
unreadCount: 0,
messages: [],
deleted: false,
top: false,
muted: false,
atMe: false,
atAll: false,
lastTimeTip: 0
}
},
// ==================== 置顶 / 免打扰 / 删除会话 ====================
/** 将某个会话置顶态切换 */
setTop(type: number, targetId: number, top: boolean) {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
}
conversation.top = top
this.saveConversations()
},
/** 设置会话免打扰(本地状态;后端同步由 friendStore / groupStore + /muted API 负责) */
setMuted(type: number, targetId: number, muted: boolean) {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
}
conversation.muted = muted
this.saveConversations()
},
/** 删除会话(软删:标记 deleted=true持久化时过滤同步物理删除消息 key 释放空间)*/
removeConversation(type: number, targetId: number) {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
}
if (this.activeConversation === conversation) {
this.activeConversation = null
}
conversation.deleted = true
// 软删后会话的消息文件不再有用,物理删除该 key
this.removeConversationMessages(type, targetId)
this.saveConversations()
},
/** 删私聊会话的语义糖friendStore 删好友时调,避免外面手写 ImConversationType.PRIVATE */
removePrivateConversation(friendId: number) {
this.removeConversation(ImConversationType.PRIVATE, friendId)
},
/** 删群聊会话的语义糖groupStore 群解散时调,避免外面手写 ImConversationType.GROUP */
removeGroupConversation(groupId: number) {
this.removeConversation(ImConversationType.GROUP, groupId)
},
// ==================== 消息插入 / 更新 ====================
/**
* 插入消息到会话
*
* 主要行为(子步骤见函数内 // x.y 注释):
* 1. 会话定位:查找或创建 + 去重合并
* 2. 更新会话元数据:摘要、@ 标记、未读数
* 3. 插入消息:时间分隔线 + 按 id 有序插入
* 4. 收尾:更新游标 + 持久化
*/
insertMessage(
conversationInfo: { type: number; targetId: number; name: string; avatar: string },
messageInfo: Message
) {
// 1.1 查找或自动创建会话
let conversation = this.getConversation(conversationInfo.type, conversationInfo.targetId)
if (!conversation) {
conversation = this.createEmptyConversation(
conversationInfo.type,
conversationInfo.targetId,
conversationInfo.name,
conversationInfo.avatar
)
this.conversations.unshift(conversation)
}
// 1.2 去重合并:优先按 id其次按 clientMessageId。命中则覆盖更新并返回
const existingIndex = conversation.messages.findIndex((message) => {
if (messageInfo.id && message.id && message.id === messageInfo.id) {
return true
}
return !!(
messageInfo.clientMessageId &&
message.clientMessageId &&
message.clientMessageId === messageInfo.clientMessageId
)
})
if (existingIndex >= 0) {
// 覆盖更新:服务端字段优先,本地已有的扩展字段(如 selfSend保留
conversation.messages[existingIndex] = {
...conversation.messages[existingIndex],
...messageInfo
}
conversation.lastSendTime = messageInfo.sendTime || conversation.lastSendTime
this.updateMaxId(conversationInfo.type, messageInfo.id)
this.saveConversations(conversation)
return
}
// 2.1 更新会话摘要 + 最后一条消息事实索引(含发送人名快照)。
// deriveLastSenderDisplayName 必须在更新 lastSenderId 之前调用,靠旧值判断"同发送人"
const senderDisplayName = deriveLastSenderDisplayName(conversation, messageInfo.senderId)
conversation.lastContent = resolveConversationLastContent(
messageInfo,
conversation.type,
conversation.targetId,
senderDisplayName
)
conversation.lastSendTime = messageInfo.sendTime || Date.now()
conversation.lastSenderId = messageInfo.senderId
conversation.lastMessageType = messageInfo.type
conversation.lastSelfSend = messageInfo.selfSend
conversation.lastSenderDisplayName = senderDisplayName
// 2.2 群聊 @ 标记(仅对方消息 + 未读态有效)
if (
!messageInfo.selfSend &&
conversation.type === ImConversationType.GROUP &&
messageInfo.atUserIds &&
messageInfo.atUserIds.length > 0 &&
messageInfo.status !== ImMessageStatus.READ
) {
const currentUserId = getCurrentUserId()
if (currentUserId && messageInfo.atUserIds.includes(currentUserId)) {
conversation.atMe = true
}
if (messageInfo.atUserIds.includes(IM_AT_ALL_USER_ID)) {
conversation.atAll = true
}
}
// 2.3 未读数:非当前会话 + 非自己发送 + 非系统 tip + 非已读 => +1
const isActive =
this.activeConversation?.type === conversationInfo.type &&
this.activeConversation?.targetId === conversationInfo.targetId
const isTipMessage =
messageInfo.type === ImMessageType.TIP_TEXT || messageInfo.type === ImMessageType.TIP_TIME
if (
!messageInfo.selfSend &&
!isActive &&
!isTipMessage &&
messageInfo.status !== ImMessageStatus.READ &&
messageInfo.status !== ImMessageStatus.RECALL
) {
conversation.unreadCount++
}
// 3.1 时间分隔线:距上条 TIP_TIME 超过 10 分钟则插入一条
const sendTime = messageInfo.sendTime || Date.now()
if (!conversation.lastTimeTip || conversation.lastTimeTip < sendTime - TIME_TIP_GAP_MS) {
conversation.messages.push({
id: 0,
clientMessageId: generateClientMessageId(),
type: ImMessageType.TIP_TIME,
content: '',
status: ImMessageStatus.UNREAD,
sendTime,
senderId: 0,
targetId: conversationInfo.targetId,
selfSend: false
})
conversation.lastTimeTip = sendTime
}
// 3.2 根据 id 插入到正确位置防止乱序tip 消息 / 本地临时消息直接追加末尾
let insertIndex = conversation.messages.length
if (messageInfo.id) {
for (let index = 0; index < conversation.messages.length; index++) {
const existing = conversation.messages[index]
// TIP_TIME 没有 id不参与排序
if (existing.type === ImMessageType.TIP_TIME) {
continue
}
if (existing.id && messageInfo.id < existing.id) {
insertIndex = index
break
}
}
}
conversation.messages.splice(insertIndex, 0, messageInfo)
// 4.1 更新游标
this.updateMaxId(conversationInfo.type, messageInfo.id)
// 4.2 持久化:消息 + meta
this.saveConversations(conversation)
},
/**
* 根据 clientMessageId 更新消息状态
*
* 乐观更新回填:本地先以 SENDING 状态插入临时消息id=0 + clientMessageId
* 待服务端返回后再用此方法回填真实 id、sendTime、status 等字段。
*/
ackMessage(
conversationType: number,
targetId: number,
clientMessageId: string,
updates: Partial<Message>
) {
const conversation = this.getConversation(conversationType, targetId)
if (!conversation) {
return
}
const message = conversation.messages.find((item) => item.clientMessageId === clientMessageId)
if (!message) {
return
}
Object.assign(message, updates)
if (updates.id) {
this.updateMaxId(conversationType, updates.id)
}
this.saveConversations(conversation)
},
/**
* 撤回消息:解析撤回信号 content`{"messageId": xxx}`),找到原消息更新为 RECALL 态 + 刷新会话摘要
* 撤回提示文案不固化,由 ConversationItem / MessageItem 渲染时调 buildRecallTip 实时算
*/
recallMessage(conversationType: number, targetId: number, recallSignalContent: string) {
const messageId = parseRecallMessageId(recallSignalContent)
if (messageId <= 0) {
return
}
const conversation = this.getConversation(conversationType, targetId)
if (!conversation) {
return
}
const message = conversation.messages.find((item) => item.id === messageId)
if (!message) {
return
}
message.type = ImMessageType.RECALL
message.status = ImMessageStatus.RECALL
// 清空 content撤回文案由渲染层 buildRecallTip 实时算,老 content 留着会被误认为有效消息文本
message.content = ''
// 最后一条消息是刚撤回的,才更新会话摘要 + lastMessageTypesenderId 不变,沿用旧快照)
if (conversation.messages[conversation.messages.length - 1]?.id === messageId) {
conversation.lastContent = resolveConversationLastContent(
message,
conversation.type,
conversation.targetId,
conversation.lastSenderDisplayName
)
conversation.lastMessageType = ImMessageType.RECALL
}
this.saveConversations(conversation)
},
/** 处理对方已读 / 群回执:更新发送方自己消息的 status / readCount / receiptStatus */
applyReadReceipt(options: {
conversationType: number
targetId: number
// 私聊把和该好友的「自己发送的、id <= privateReadMaxId 的」消息标为已读
// 必须卡 maxId 边界:回执在路上时新发的消息不能被误标为已读
privateReadMaxId?: number
// 群聊:针对单条消息的回执刷新
groupMessageId?: number
readCount?: number
receiptStatus?: number
}) {
const conversation = this.getConversation(options.conversationType, options.targetId)
if (!conversation) {
return
}
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
const maxReadId = options.privateReadMaxId
conversation.messages.forEach((message) => {
if (
message.selfSend &&
message.id &&
message.id <= maxReadId &&
message.status !== ImMessageStatus.RECALL
) {
message.status = ImMessageStatus.READ
}
})
} else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
const message = conversation.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
}
}
}
this.saveConversations(conversation)
},
/**
* 把"更早的历史消息"批量插到会话消息列表的最前面(合并 + 去重)
*
* MessageHistory 弹窗的"加载更早"按钮调用:调用方先调 /im/message/{private,group}/list 拉一页 +
* 用 useMessagePuller 的 convert 函数转好,再传进来。
*
* 不更新 lastContent / lastSendTime / unreadCount这些字段反映"最新一条",加载老消息时不应改动;
* 也不触发 conversation 排序,避免会话列表抖动
*/
prependMessages(conversationType: number, targetId: number, earlierMessages: Message[]) {
if (earlierMessages.length === 0) {
return
}
const conversation = this.getConversation(conversationType, targetId)
if (!conversation) {
return
}
// 1. 去重:拿当前会话已有消息的 id 集合,把入参里 id 撞上的过滤掉
// (后端返回的"更早消息"可能跟本地缓存有重叠,比如增量 pull 拉到过同一段)
// id=0 是本地占位消息,不参与去重判定(也不会被 prepend下面 filter 一并卡掉)
const existingIds = new Set(
conversation.messages.map((message) => message.id).filter((id) => id > 0)
)
// 2. 过滤后按 id 升序list 接口虽然按 id desc 返回,前端要展示成"早 → 晚"
// 所以 prepend 之前先 sort asc让 fresh 数组本身的相对顺序符合时间线
const fresh = earlierMessages
.filter((message) => message.id > 0 && !existingIds.has(message.id))
.sort((a, b) => a.id - b.id)
if (fresh.length === 0) {
return
}
// 3. 拼接 + 落盘fresh 在前、原 messages 在后;持久化让下次冷启动不用再调接口
conversation.messages = [...fresh, ...conversation.messages]
this.saveConversations(conversation)
},
/**
* 从本地消息列表移除一条消息(右键"删除";不同步后端)
* 按 id 优先匹配;若 id 为 0本地发送中则按 clientMessageId 匹配
*/
removeMessage(
conversationType: number,
targetId: number,
key: { id?: number; clientMessageId?: string }
) {
const conversation = this.getConversation(conversationType, targetId)
if (!conversation) {
return
}
const index = conversation.messages.findIndex((message) => {
if (key.id && message.id && message.id === key.id) {
return true
}
return !!(
key.clientMessageId &&
message.clientMessageId &&
message.clientMessageId === key.clientMessageId
)
})
if (index < 0) {
return
}
conversation.messages.splice(index, 1)
// 如果删的是最后一条,按倒数第二条重算摘要 + 事实索引
if (index === conversation.messages.length) {
const last = conversation.messages[conversation.messages.length - 1]
if (last) {
const senderDisplayName = deriveLastSenderDisplayName(conversation, last.senderId)
conversation.lastContent = resolveConversationLastContent(
last,
conversation.type,
conversation.targetId,
senderDisplayName
)
conversation.lastSendTime = last.sendTime || conversation.lastSendTime
conversation.lastSenderId = last.senderId
conversation.lastMessageType = last.type
conversation.lastSelfSend = last.selfSend
conversation.lastSenderDisplayName = senderDisplayName
} else {
conversation.lastContent = ''
conversation.lastSenderId = undefined
conversation.lastMessageType = undefined
conversation.lastSelfSend = undefined
conversation.lastSenderDisplayName = undefined
}
}
this.saveConversations(conversation)
},
/**
* 当前会话全部标记为已读(切换会话 / 手动触发)
* 只处理「对方发来的、尚未读」的消息
*/
markActiveAsRead() {
if (!this.activeConversation) {
return
}
this.activeConversation.unreadCount = 0
this.activeConversation.atMe = false
this.activeConversation.atAll = false
this.activeConversation.messages.forEach((message) => {
if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
message.status = ImMessageStatus.READ
}
})
this.saveConversations(this.activeConversation)
},
/** 更新 privateMessageMaxId / groupMessageMaxId 游标 */
updateMaxId(conversationType: number, messageId?: number) {
if (!messageId) {
return
}
if (conversationType === ImConversationType.PRIVATE) {
if (messageId > this.privateMessageMaxId) {
this.privateMessageMaxId = messageId
}
} else if (conversationType === ImConversationType.GROUP) {
if (messageId > this.groupMessageMaxId) {
this.groupMessageMaxId = messageId
}
}
},
/**
* 离线消息加载完后重排:按 lastSendTime 倒序,并把 loading 期间累积的内存变更全量 flush
*
* loading 期间 saveConversations 都会被早 return 跳过,这里把所有会话作为数组传入兜底,
* 否则离线拉取的消息只在内存里、未落盘,重启会丢。
*/
sortConversations() {
this.conversations.sort((a, b) => b.lastSendTime - a.lastSendTime)
this.saveConversations(this.conversations)
},
/**
* 同步会话的展示元数据name / avatar / muted
*
* 调用方负责把好友 / 群的信息整理成 Conversation 视角的字段:
* - 私聊name = friend.nicknameavatar = friend.avatar
* - 群聊name = group.name或叠加 displayGroupNameavatar = group.avatar
*/
updateConversation(
type: number,
targetId: number,
info: { name?: string; avatar?: string; muted?: 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.muted !== undefined && conversation.muted !== info.muted) {
conversation.muted = info.muted
changed = true
}
if (changed) {
this.saveConversations()
}
}
}
})
export const useConversationStoreWithOut = () => {
return useConversationStore(store)
}
// dev: 让 Pinia 的 actions / state 改动支持 HMR避免每次改 store 都得硬刷
// 否则 Vite 把新模块推下来后,老 store 实例的 action 闭包仍指向旧函数体
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useConversationStore, import.meta.hot))
}