♻️ refactor(im): conversationStore 存储改为 IndexedDB 按会话分桶 + 命名统一
- 持久化迁到 localforage(IndexedDB),meta 索引与单会话 messages 分 key 存,消除写放大 - saveConversations 支持 不传 / 单个 / 数组 三种粒度;签名改为 sync void(fire-and-forget) - 修复 sortConversations 仅刷 meta 不刷 messages 导致离线消息重启丢失的 bug - 方法重命名:saveToStorage→saveConversations、updateMessageState→ackMessage、applyRecall→recallMessage、refreshConversations→sortConversations、removeLocalMessage→removeMessage、_removeMessagesStorage→removeConversationMessages - 删除 dead field Conversation.lastReadCount;TIP_TIME clientMessageId 改用 uuidim
parent
2785e2bea6
commit
66514fc597
|
|
@ -8,14 +8,16 @@ import {
|
||||||
ImMessageStatus,
|
ImMessageStatus,
|
||||||
TIME_TIP_GAP_MS
|
TIME_TIP_GAP_MS
|
||||||
} from '../../utils/constants'
|
} from '../../utils/constants'
|
||||||
import { StorageKeys } from '../../utils/storage'
|
import { imStorage, StorageKeys } from '../../utils/storage'
|
||||||
import { parseMessage, buildRecallTip, type TextMessage } from '../../utils/message'
|
import {
|
||||||
import type { Conversation, Message, ConversationsData } from '../types'
|
buildRecallTip,
|
||||||
|
generateClientMessageId,
|
||||||
|
parseMessage,
|
||||||
|
type TextMessage
|
||||||
|
} from '../../utils/message'
|
||||||
|
import type { Conversation, ConversationStoreMeta, Message } from '../types'
|
||||||
|
|
||||||
const AT_ALL_FLAG = -1 // @全体成员 的特殊 userId 标识:atUserIds 中包含 -1 表示 @all
|
const AT_ALL_FLAG = -1 // @全体成员 的特殊 userId 标识:atUserIds 中包含 -1 表示 @all
|
||||||
// 单会话持久化消息数上限:localStorage 整体配额一般 5~10MB,全量序列化容易撑爆。
|
|
||||||
// 内存里保留完整历史,落盘只截最近 N 条;用户重启后历史不够再向后端拉。
|
|
||||||
const MAX_PERSISTED_MESSAGES_PER_CONVERSATION = 100
|
|
||||||
|
|
||||||
/** 获取当前登录用户编号 */
|
/** 获取当前登录用户编号 */
|
||||||
function getCurrentUserId(): number {
|
function getCurrentUserId(): number {
|
||||||
|
|
@ -24,18 +26,13 @@ function getCurrentUserId(): number {
|
||||||
return Number(user?.id) || 0
|
return Number(user?.id) || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前登录用户的会话列表 localStorage key */
|
|
||||||
function currentConversationsKey(): string {
|
|
||||||
return StorageKeys.conversations(getCurrentUserId())
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useConversationStore = defineStore('imConversationStore', {
|
export const useConversationStore = defineStore('imConversationStore', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊)
|
conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊)
|
||||||
activeConversation: null as Conversation | null, // 当前激活的会话
|
activeConversation: null as Conversation | null, // 当前激活的会话
|
||||||
privateMessageMaxId: 0, // 私聊最大消息 id,作为 pull 的游标
|
privateMessageMaxId: 0, // 私聊最大消息 id,作为 pull 的游标
|
||||||
groupMessageMaxId: 0, // 群聊最大消息 id,作为 pull 的游标
|
groupMessageMaxId: 0, // 群聊最大消息 id,作为 pull 的游标
|
||||||
loading: false // 是否正在批量加载(例如离线消息拉取期间),避免频繁写 localStorage
|
loading: false // 是否正在批量加载(例如离线消息拉取期间),避免频繁写存储
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
|
|
@ -77,62 +74,116 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
actions: {
|
actions: {
|
||||||
// ==================== 本地存储 ====================
|
// ==================== 本地存储 ====================
|
||||||
|
|
||||||
/** 从 localStorage 恢复会话数据 */
|
/**
|
||||||
loadConversations() {
|
* 从 IndexedDB 恢复会话数据
|
||||||
const item = localStorage.getItem(currentConversationsKey())
|
*
|
||||||
if (!item) {
|
* 1. 读 meta(游标 + 会话索引),无 meta 直接返回
|
||||||
|
* 2. 并发读取每个会话的消息 key,组装回 Conversation
|
||||||
|
* 3. 修正重启前遗留的"发送中"状态为失败
|
||||||
|
*/
|
||||||
|
async loadConversations() {
|
||||||
|
const userId = getCurrentUserId()
|
||||||
|
if (!userId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 反序列化缓存数据,恢复消息游标(privateMessageMaxId / groupMessageMaxId)
|
const meta = await imStorage.getItem<ConversationStoreMeta>(
|
||||||
const storageData = JSON.parse(item) as ConversationsData
|
StorageKeys.conversationMeta(userId)
|
||||||
this.privateMessageMaxId = Number(storageData.privateMessageMaxId) || 0
|
)
|
||||||
this.groupMessageMaxId = Number(storageData.groupMessageMaxId) || 0
|
if (!meta) {
|
||||||
|
return
|
||||||
// 回放会话列表,同时修正重启前遗留的"发送中"状态
|
|
||||||
if (storageData.conversations && storageData.conversations.length > 0) {
|
|
||||||
for (const conversation of storageData.conversations) {
|
|
||||||
if (conversation.messages) {
|
|
||||||
conversation.messages.forEach((message) => {
|
|
||||||
// 发送中状态的消息标记为失败:重启后不可能仍处在发送中
|
|
||||||
if (message.status === ImMessageStatus.SENDING) {
|
|
||||||
message.status = ImMessageStatus.FAILED
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.conversations = storageData.conversations
|
|
||||||
}
|
}
|
||||||
|
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.conversationMessage(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) {
|
} catch (e) {
|
||||||
console.error('[IM] 本地消息缓存读取失败', e)
|
console.error('[IM] 本地消息缓存读取失败', e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 持久化到 localStorage */
|
/**
|
||||||
saveToStorage() {
|
* 持久化到 IndexedDB(fire-and-forget;调用方无需 await)
|
||||||
// loading 期间跳过,避免大量写入阻塞主线程
|
*
|
||||||
|
* - 不传 target:仅写 meta(适用于 top / muted / unread 等元数据变更)
|
||||||
|
* - 传单个 conversation:写 meta + 该会话的消息(单条消息变更走这里)
|
||||||
|
* - 传数组:写 meta + 数组里所有未删除会话的消息(loading 完成后兜底 flush 用)
|
||||||
|
*
|
||||||
|
* 按会话分桶后,单条消息变更只重写当前会话的消息 key,避免老方案的全量序列化。
|
||||||
|
* 写入失败已在内部 catch 兜底(仅打印日志),不影响 UI 流程,所以接口签名设为 void。
|
||||||
|
*/
|
||||||
|
saveConversations(target?: Conversation | Conversation[] | null): void {
|
||||||
|
// loading 期间跳过,避免离线消息批量到达时的密集写入
|
||||||
if (this.loading) {
|
if (this.loading) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const userId = getCurrentUserId()
|
||||||
// TODO @AI:可能要调整存储方案;
|
if (!userId) {
|
||||||
// 落盘前对每个会话的 messages 做尾部截断,避免长会话把 localStorage 撑爆
|
return
|
||||||
const storageData: ConversationsData = {
|
}
|
||||||
|
// 1. meta:游标 + 会话索引(剔除 messages,过滤软删除)
|
||||||
|
const meta: ConversationStoreMeta = {
|
||||||
privateMessageMaxId: this.privateMessageMaxId,
|
privateMessageMaxId: this.privateMessageMaxId,
|
||||||
groupMessageMaxId: this.groupMessageMaxId,
|
groupMessageMaxId: this.groupMessageMaxId,
|
||||||
conversations: this.conversations
|
conversations: this.conversations
|
||||||
.filter((c) => !c.deleted)
|
.filter((c) => !c.deleted)
|
||||||
.map((c) => ({
|
.map(({ messages, ...rest }) => rest)
|
||||||
...c,
|
|
||||||
messages: c.messages.slice(-MAX_PERSISTED_MESSAGES_PER_CONVERSATION)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
try {
|
const tasks: Promise<unknown>[] = [
|
||||||
localStorage.setItem(currentConversationsKey(), JSON.stringify(storageData))
|
imStorage.setItem(StorageKeys.conversationMeta(userId), meta)
|
||||||
} catch (e) {
|
]
|
||||||
console.error('[IM] 本地消息缓存存储失败', e)
|
// 2. 归一化 target 为待 flush 的会话列表,过滤掉已软删除的
|
||||||
|
const conversationsToFlush: Conversation[] = (
|
||||||
|
Array.isArray(target) ? target : target ? [target] : []
|
||||||
|
).filter((c) => !c.deleted)
|
||||||
|
for (const conversation of conversationsToFlush) {
|
||||||
|
tasks.push(
|
||||||
|
imStorage.setItem(
|
||||||
|
StorageKeys.conversationMessage(userId, conversation.type, conversation.targetId),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
void imStorage
|
||||||
|
.removeItem(StorageKeys.conversationMessage(userId, type, targetId))
|
||||||
|
.catch((e) => console.error('[IM] 本地消息缓存删除失败', e))
|
||||||
},
|
},
|
||||||
|
|
||||||
// ==================== 会话查找 / 打开 ====================
|
// ==================== 会话查找 / 打开 ====================
|
||||||
|
|
@ -182,7 +233,8 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
conversation.unreadCount = 0
|
conversation.unreadCount = 0
|
||||||
conversation.atMe = false
|
conversation.atMe = false
|
||||||
conversation.atAll = false
|
conversation.atAll = false
|
||||||
this.saveToStorage()
|
// 仅元数据变更(unreadCount / atMe / atAll),不动 messages
|
||||||
|
this.saveConversations()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -216,7 +268,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
conversation.top = top
|
conversation.top = top
|
||||||
this.saveToStorage()
|
this.saveConversations()
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 设置会话免打扰(本地状态;后端同步由 friendStore / groupStore + /muted API 负责) */
|
/** 设置会话免打扰(本地状态;后端同步由 friendStore / groupStore + /muted API 负责) */
|
||||||
|
|
@ -226,10 +278,10 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
conversation.muted = muted
|
conversation.muted = muted
|
||||||
this.saveToStorage()
|
this.saveConversations()
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 删除会话(软删:标记 deleted=true,持久化时过滤)*/
|
/** 删除会话(软删:标记 deleted=true,持久化时过滤;同步物理删除消息 key 释放空间)*/
|
||||||
removeConversation(type: number, targetId: number) {
|
removeConversation(type: number, targetId: number) {
|
||||||
const conversation = this.getConversation(type, targetId)
|
const conversation = this.getConversation(type, targetId)
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
|
|
@ -239,7 +291,9 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
this.activeConversation = null
|
this.activeConversation = null
|
||||||
}
|
}
|
||||||
conversation.deleted = true
|
conversation.deleted = true
|
||||||
this.saveToStorage()
|
// 软删后会话的消息文件不再有用,物理删除该 key
|
||||||
|
this.removeConversationMessages(type, targetId)
|
||||||
|
this.saveConversations()
|
||||||
},
|
},
|
||||||
|
|
||||||
removePrivateConversation(friendId: number) {
|
removePrivateConversation(friendId: number) {
|
||||||
|
|
@ -289,7 +343,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
conversation.messages[existingIndex] = { ...conversation.messages[existingIndex], ...messageInfo }
|
conversation.messages[existingIndex] = { ...conversation.messages[existingIndex], ...messageInfo }
|
||||||
conversation.lastSendTime = messageInfo.sendTime || conversation.lastSendTime
|
conversation.lastSendTime = messageInfo.sendTime || conversation.lastSendTime
|
||||||
this.updateMaxId(conversationInfo.type, messageInfo.id)
|
this.updateMaxId(conversationInfo.type, messageInfo.id)
|
||||||
this.saveToStorage()
|
this.saveConversations(conversation)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,7 +390,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
if (!conversation.lastTimeTip || conversation.lastTimeTip < sendTime - TIME_TIP_GAP_MS) {
|
if (!conversation.lastTimeTip || conversation.lastTimeTip < sendTime - TIME_TIP_GAP_MS) {
|
||||||
conversation.messages.push({
|
conversation.messages.push({
|
||||||
id: 0,
|
id: 0,
|
||||||
clientMessageId: `tip-${sendTime}`,
|
clientMessageId: generateClientMessageId(),
|
||||||
type: ImMessageType.TIP_TIME,
|
type: ImMessageType.TIP_TIME,
|
||||||
content: '',
|
content: '',
|
||||||
status: ImMessageStatus.UNREAD,
|
status: ImMessageStatus.UNREAD,
|
||||||
|
|
@ -369,8 +423,8 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
// 4.1 更新游标
|
// 4.1 更新游标
|
||||||
this.updateMaxId(conversationInfo.type, messageInfo.id)
|
this.updateMaxId(conversationInfo.type, messageInfo.id)
|
||||||
|
|
||||||
// 4.2 持久化到 localStorage
|
// 4.2 持久化:消息 + meta
|
||||||
this.saveToStorage()
|
this.saveConversations(conversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 根据消息类型计算会话列表最后一条摘要 */
|
/** 根据消息类型计算会话列表最后一条摘要 */
|
||||||
|
|
@ -400,7 +454,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
* 乐观更新回填:本地先以 SENDING 状态插入临时消息(id=0 + clientMessageId),
|
* 乐观更新回填:本地先以 SENDING 状态插入临时消息(id=0 + clientMessageId),
|
||||||
* 待服务端返回后再用此方法回填真实 id、sendTime、status 等字段。
|
* 待服务端返回后再用此方法回填真实 id、sendTime、status 等字段。
|
||||||
*/
|
*/
|
||||||
updateMessageState(
|
ackMessage(
|
||||||
conversationType: number,
|
conversationType: number,
|
||||||
targetId: number,
|
targetId: number,
|
||||||
clientMessageId: string,
|
clientMessageId: string,
|
||||||
|
|
@ -418,14 +472,14 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
if (updates.id) {
|
if (updates.id) {
|
||||||
this.updateMaxId(conversationType, updates.id)
|
this.updateMaxId(conversationType, updates.id)
|
||||||
}
|
}
|
||||||
this.saveToStorage()
|
this.saveConversations(conversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 撤回消息:将原消息 type 改为 RECALL,并刷新会话摘要
|
* 撤回消息:将原消息 type 改为 RECALL,并刷新会话摘要
|
||||||
* 对应后端 RECALL 事件:按原 messageId 更新
|
* 对应后端 RECALL 事件:按原 messageId 更新
|
||||||
*/
|
*/
|
||||||
applyRecall(
|
recallMessage(
|
||||||
conversationType: number,
|
conversationType: number,
|
||||||
targetId: number,
|
targetId: number,
|
||||||
messageId: number,
|
messageId: number,
|
||||||
|
|
@ -449,7 +503,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
if (conversation.messages[conversation.messages.length - 1]?.id === messageId) {
|
if (conversation.messages[conversation.messages.length - 1]?.id === messageId) {
|
||||||
conversation.lastContent = buildRecallTip(senderNickName, selfSend)
|
conversation.lastContent = buildRecallTip(senderNickName, selfSend)
|
||||||
}
|
}
|
||||||
this.saveToStorage()
|
this.saveConversations(conversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 处理对方已读 / 群回执:更新发送方自己消息的 status / readCount / receiptStatus */
|
/** 处理对方已读 / 群回执:更新发送方自己消息的 status / readCount / receiptStatus */
|
||||||
|
|
@ -484,14 +538,14 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.saveToStorage()
|
this.saveConversations(conversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从本地消息列表移除一条消息(右键"删除";不同步后端)
|
* 从本地消息列表移除一条消息(右键"删除";不同步后端)
|
||||||
* 按 id 优先匹配;若 id 为 0(本地发送中),则按 clientMessageId 匹配
|
* 按 id 优先匹配;若 id 为 0(本地发送中),则按 clientMessageId 匹配
|
||||||
*/
|
*/
|
||||||
removeLocalMessage(
|
removeMessage(
|
||||||
conversationType: number,
|
conversationType: number,
|
||||||
targetId: number,
|
targetId: number,
|
||||||
key: { id?: number; clientMessageId?: string }
|
key: { id?: number; clientMessageId?: string }
|
||||||
|
|
@ -517,7 +571,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
conversation.lastSendTime = last?.sendTime || conversation.lastSendTime
|
conversation.lastSendTime = last?.sendTime || conversation.lastSendTime
|
||||||
conversation.senderNickName = last?.senderNickName || ''
|
conversation.senderNickName = last?.senderNickName || ''
|
||||||
}
|
}
|
||||||
this.saveToStorage()
|
this.saveConversations(conversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -536,7 +590,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
message.status = ImMessageStatus.READ
|
message.status = ImMessageStatus.READ
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.saveToStorage()
|
this.saveConversations(this.activeConversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 更新 privateMessageMaxId / groupMessageMaxId 游标 */
|
/** 更新 privateMessageMaxId / groupMessageMaxId 游标 */
|
||||||
|
|
@ -555,10 +609,15 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 离线消息加载完后重排:按 lastSendTime 倒序并持久化 */
|
/**
|
||||||
refreshConversations() {
|
* 离线消息加载完后重排:按 lastSendTime 倒序,并把 loading 期间累积的内存变更全量 flush
|
||||||
|
*
|
||||||
|
* loading 期间 saveConversations 都会被早 return 跳过,这里把所有会话作为数组传入兜底,
|
||||||
|
* 否则离线拉取的消息只在内存里、未落盘,重启会丢。
|
||||||
|
*/
|
||||||
|
sortConversations() {
|
||||||
this.conversations.sort((a, b) => b.lastSendTime - a.lastSendTime)
|
this.conversations.sort((a, b) => b.lastSendTime - a.lastSendTime)
|
||||||
this.saveToStorage()
|
this.saveConversations(this.conversations)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -591,7 +650,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if (changed) {
|
if (changed) {
|
||||||
this.saveToStorage()
|
this.saveConversations()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
* 流程:
|
* 流程:
|
||||||
* 1. 离线加载期缓冲(避开与 pull 回填的竞态)
|
* 1. 离线加载期缓冲(避开与 pull 回填的竞态)
|
||||||
* 2. 计算 selfSend / peerId 维度,拉好友信息回填展示字段
|
* 2. 计算 selfSend / peerId 维度,拉好友信息回填展示字段
|
||||||
* 3. 撤回 TIP 短路:转走 applyRecall,不进消息列表
|
* 3. 撤回 TIP 短路:转走 recallMessage,不进消息列表
|
||||||
* 4. 构造前端 Message,插入到对应私聊会话
|
* 4. 构造前端 Message,插入到对应私聊会话
|
||||||
* 5. 当前会话激活时自动上报已读;否则非免打扰响提示音
|
* 5. 当前会话激活时自动上报已读;否则非免打扰响提示音
|
||||||
*/
|
*/
|
||||||
|
|
@ -245,11 +245,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage)
|
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage)
|
||||||
// 这里拦截下来改走 applyRecall(把原消息翻转为 RECALL 态),不让它作为新消息进列表
|
// 这里拦截下来改走 recallMessage(把原消息翻转为 RECALL 态),不让它作为新消息进列表
|
||||||
if (websocketMessage.type === ImMessageType.RECALL) {
|
if (websocketMessage.type === ImMessageType.RECALL) {
|
||||||
const recallMessageId = parseRecallMessageId(websocketMessage.content)
|
const recallMessageId = parseRecallMessageId(websocketMessage.content)
|
||||||
if (recallMessageId) {
|
if (recallMessageId) {
|
||||||
conversationStore.applyRecall(
|
conversationStore.recallMessage(
|
||||||
ImConversationType.PRIVATE,
|
ImConversationType.PRIVATE,
|
||||||
peerId,
|
peerId,
|
||||||
recallMessageId,
|
recallMessageId,
|
||||||
|
|
@ -312,7 +312,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
conversation.unreadCount = 0
|
conversation.unreadCount = 0
|
||||||
}
|
}
|
||||||
conversationStore.saveToStorage()
|
conversationStore.saveConversations()
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 私聊 RECEIPT 事件:对方读了我的消息,把和对方会话里自己发的消息标为已读 */
|
/** 私聊 RECEIPT 事件:对方读了我的消息,把和对方会话里自己发的消息标为已读 */
|
||||||
|
|
@ -357,11 +357,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
const senderNickName = senderMember?.displayUserName || senderMember?.nickname || ''
|
const senderNickName = senderMember?.displayUserName || senderMember?.nickname || ''
|
||||||
|
|
||||||
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`
|
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`
|
||||||
// 这里拦截下来改走 applyRecall(把原消息翻转为 RECALL 态)
|
// 这里拦截下来改走 recallMessage(把原消息翻转为 RECALL 态)
|
||||||
if (websocketMessage.type === ImMessageType.RECALL) {
|
if (websocketMessage.type === ImMessageType.RECALL) {
|
||||||
const recallMessageId = parseRecallMessageId(websocketMessage.content)
|
const recallMessageId = parseRecallMessageId(websocketMessage.content)
|
||||||
if (recallMessageId) {
|
if (recallMessageId) {
|
||||||
conversationStore.applyRecall(
|
conversationStore.recallMessage(
|
||||||
ImConversationType.GROUP,
|
ImConversationType.GROUP,
|
||||||
websocketMessage.groupId,
|
websocketMessage.groupId,
|
||||||
recallMessageId,
|
recallMessageId,
|
||||||
|
|
@ -430,7 +430,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
conversation.unreadCount = 0
|
conversation.unreadCount = 0
|
||||||
}
|
}
|
||||||
conversationStore.saveToStorage()
|
conversationStore.saveConversations()
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 群聊 RECEIPT:更新某条群消息的 readCount / receiptStatus */
|
/** 群聊 RECEIPT:更新某条群消息的 readCount / receiptStatus */
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,6 @@ export interface Conversation {
|
||||||
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
||||||
atMe?: boolean // 群聊:是否有人 @我
|
atMe?: boolean // 群聊:是否有人 @我
|
||||||
atAll?: boolean // 群聊:是否有人 @全体成员
|
atAll?: boolean // 群聊:是否有人 @全体成员
|
||||||
lastReadCount?: number // 群回执:当前会话最近一条需回执消息的已读人数
|
|
||||||
lastTimeTip?: number // 最后一条"时间分隔线"的时间戳,判断是否需要插入下一条 TIP_TIME
|
lastTimeTip?: number // 最后一条"时间分隔线"的时间戳,判断是否需要插入下一条 TIP_TIME
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,11 +81,20 @@ export interface Message {
|
||||||
selfSend: boolean // 是否自己发送(前端按 senderId 计算)
|
selfSend: boolean // 是否自己发送(前端按 senderId 计算)
|
||||||
}
|
}
|
||||||
|
|
||||||
// localStorage 存储结构:按用户 ID 分桶,保存所有会话元数据
|
/**
|
||||||
export interface ConversationsData {
|
* 会话索引项:基于 Conversation 派生,但剥离 messages 字段(消息按会话独立存到 messages key)
|
||||||
|
*
|
||||||
|
* Omit<T, K> 是 TS 内置工具类型:从类型 T 中剔除 K 指定的字段,得到剩余字段组成的新类型。
|
||||||
|
* 这里 `Omit<Conversation, 'messages'>` 等价于"Conversation 去掉 messages 字段后的版本",
|
||||||
|
* 与"Conversation 派生但少一个 messages 字段"的语义一致,不需要再手写一份重复结构。
|
||||||
|
*/
|
||||||
|
export type ConversationMeta = Omit<Conversation, 'messages'>
|
||||||
|
|
||||||
|
// 持久化的会话索引:游标 + 会话元数据列表,按用户 ID 分桶
|
||||||
|
export interface ConversationStoreMeta {
|
||||||
privateMessageMaxId: number // 私聊消息最大编号
|
privateMessageMaxId: number // 私聊消息最大编号
|
||||||
groupMessageMaxId: number // 群聊消息最大编号
|
groupMessageMaxId: number // 群聊消息最大编号
|
||||||
conversations: Conversation[] // 会话列表
|
conversations: ConversationMeta[] // 会话索引(不含 messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 群 / 群成员 ====================
|
// ==================== 群 / 群成员 ====================
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,49 @@
|
||||||
// localStorage key 统一在此生成。im: 前缀避免与其他模块冲突。
|
import localforage from 'localforage'
|
||||||
// 当前数据量(会话 / 消息)直接用 localStorage 满足,不需要 IndexedDB。
|
|
||||||
|
/**
|
||||||
|
* IM 模块的 IndexedDB 实例(localforage 优先 IndexedDB,自动降级到 WebSQL / localStorage)
|
||||||
|
*
|
||||||
|
* 为什么不用 localStorage 直接存:
|
||||||
|
* 1. 配额:localStorage 整体上限 5~10MB,多会话长历史很容易撑爆
|
||||||
|
* 2. 写放大:localStorage 必须按 key 整体写入,单次写就是 MB 级序列化阻塞主线程
|
||||||
|
*
|
||||||
|
* 配套策略:会话与消息按 key 分桶(见 StorageKeys),让单次变更只重写最小粒度的 key。
|
||||||
|
* IndexedDB 默认配额一般是浏览器可用空间的 ~50%,远大于 localStorage,配合分桶才发挥效果。
|
||||||
|
*/
|
||||||
|
export const imStorage = localforage.createInstance({
|
||||||
|
name: 'im',
|
||||||
|
storeName: 'conversation',
|
||||||
|
description: 'IM 会话索引与消息缓存'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储 key 统一在此生成
|
||||||
|
*
|
||||||
|
* - 会话相关(meta / message)走 imStorage(IndexedDB),key 形如 `conversation:xxx:{userId}:...`
|
||||||
|
* - 轻量 UI 状态(侧边栏宽度)仍走 localStorage:体积小、跨 Tab 同步天然,没必要走 IndexedDB
|
||||||
|
*
|
||||||
|
* 所有会话相关 key 都注入 userId:多账号切换时按用户隔离,避免数据互串。
|
||||||
|
*/
|
||||||
export const StorageKeys = {
|
export const StorageKeys = {
|
||||||
conversations: (userId: number | string) => `im:conversations:${userId}`,
|
/**
|
||||||
asideWidth: (page: 'friend' | 'group') => `im:aside:${page}`
|
* 会话索引:游标 + 会话元数据(不含 messages),对应 ConversationStoreMeta
|
||||||
|
*
|
||||||
|
* 任何会话级元数据变更(top / muted / unread / 列表增删 / 排序)都会重写这一个 key;
|
||||||
|
* 由于 messages 已剥离到独立 key,单次写体积稳定(仅元数据,量级 KB 级)。
|
||||||
|
*/
|
||||||
|
conversationMeta: (userId: number | string) => `conversation:meta:${userId}`,
|
||||||
|
/**
|
||||||
|
* 单会话消息:按 (type, targetId) 分桶,存 Message[]
|
||||||
|
*
|
||||||
|
* - type:私聊 / 群聊(对齐 ImConversationType)
|
||||||
|
* - targetId:私聊的对方 userId / 群聊的 groupId
|
||||||
|
*
|
||||||
|
* 每条消息变更只重写当前会话这一个 key,避免老方案"全量写所有会话所有消息"的写放大。
|
||||||
|
* 软删除会话时由 conversationStore.removeConversationMessages 物理删除该 key,避免 orphan 残留。
|
||||||
|
*/
|
||||||
|
conversationMessage: (userId: number | string, type: number, targetId: number) =>
|
||||||
|
`conversation:message:${userId}:${type}:${targetId}`,
|
||||||
|
|
||||||
|
/** 侧边栏宽度(localStorage);page 区分消息 / 好友 / 群三个 Tab,独立记忆。 */
|
||||||
|
asideWidth: (page: 'message' | 'friend' | 'group') => `im:aside:${page}`
|
||||||
} as const
|
} as const
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue