refactor(im): 拆分会话消息存储并合并草稿

- 新增 IM IndexedDB DB client,按当前用户初始化本地库
- 将会话与消息拆成 conversations / messages 逐条存储
- 将草稿合并进 Conversation.draft,删除 draftStore
- 优化 pull 批量写入,消息、会话摘要和游标同事务落库
- 统一 store action 命名,清理旧 localStorage key 和 TODO
- 保留 maxId settings 游标,避免本地消息回收后游标回退
im
YunaiV 2026-05-28 08:39:49 +08:00
parent 811b93d9f1
commit 664904bd06
20 changed files with 454 additions and 448 deletions

View File

@ -2,6 +2,7 @@ import request from '@/config/axios'
export interface ImChannelMessageRespVO {
id: number
clientMessageId?: string
channelId: number
materialId: number
type: number

View File

@ -1,6 +1,6 @@
import { watch } from 'vue'
import { useConversationStore } from '../store/conversationStore'
import { useMessageStore, type PulledMessageBatchItem } from '../store/messageStore'
import { useMessageStore, type PulledMessage } from '../store/messageStore'
import { useImWebSocketStore } from '../store/websocketStore'
import { useFriendStore } from '../store/friendStore'
import { getFriendDisplayName } from '../../utils/user'
@ -106,8 +106,7 @@ export const useMessagePuller = () => {
const convertChannelMessage = (message: ImChannelMessageRespVO): Message => {
return {
id: message.id,
// TODO @AI是不是都需要使用 message 的 clientMessageId注意后端也需要有 clientMessageId
clientMessageId: generateClientMessageId(),
clientMessageId: message.clientMessageId || generateClientMessageId(),
type: message.type,
content: message.content,
status: message.status ?? ImMessageStatus.UNREAD,
@ -190,14 +189,13 @@ export const useMessagePuller = () => {
break
}
// TODO @AI感觉这个 PulledMessageBatchItem 类名batchItems 变量名insertPulledBatch 方法名,不够能体现出 message
const batchItems: PulledMessageBatchItem[] = []
const pulledMessages: PulledMessage[] = []
// 逐条 dispatch原消息走批量 insertRECALL 信号走批量 recall 把同批内已 insert 的原消息更新为撤回提示。
// 后端按 id 升序返回,且信号 id 一定 > 原消息 id先更新 status 再插信号所以原消息一定先到、recallMessage 找得到
for (const raw of list) {
if (isChannel) {
const message = raw as ImChannelMessageRespVO
batchItems.push({
pulledMessages.push({
kind: 'insert',
conversationInfo: convertChannelConversation(message),
message: convertChannelMessage(message)
@ -208,7 +206,7 @@ export const useMessagePuller = () => {
const message = raw as ImPrivateMessageRespVO
// 特殊:撤回消息的处理
if (message.type === ImMessageType.RECALL) {
batchItems.push({
pulledMessages.push({
kind: 'recall',
conversationType: ImConversationType.PRIVATE,
targetId: getPrivatePeerId(message),
@ -226,7 +224,7 @@ export const useMessagePuller = () => {
}
}
// 其它消息正常入会话消息列表
batchItems.push({
pulledMessages.push({
kind: 'insert',
conversationInfo: convertPrivateConversation(message),
message: convertPrivateMessage(message)
@ -235,7 +233,7 @@ export const useMessagePuller = () => {
const message = raw as ImGroupMessageRespVO
// 特殊:撤回消息的处理
if (message.type === ImMessageType.RECALL) {
batchItems.push({
pulledMessages.push({
kind: 'recall',
conversationType: ImConversationType.GROUP,
targetId: message.groupId,
@ -244,7 +242,7 @@ export const useMessagePuller = () => {
continue
}
// 其它消息正常入会话消息列表
batchItems.push({
pulledMessages.push({
kind: 'insert',
conversationInfo: convertGroupConversation(message),
message: convertGroupMessage(message)
@ -255,11 +253,11 @@ export const useMessagePuller = () => {
// 游标推进到本批最大 id与后端返回顺序无关无有效 id 直接 break 避免死翻同一批
const validIds = list.map((message) => message.id).filter((id): id is number => id != null)
if (validIds.length === 0) {
await messageStore.insertPulledBatch(batchItems, conversationType)
await messageStore.applyPulledMessageList(pulledMessages, conversationType)
break
}
const nextMinId = Math.max(...validIds)
await messageStore.insertPulledBatch(batchItems, conversationType, nextMinId)
await messageStore.applyPulledMessageList(pulledMessages, conversationType, nextMinId)
// 游标没前进就停:当前后端契约是 id > minId理论不会出现防御后端契约变更或边界数据死翻
if (nextMinId <= minId) {
break
@ -378,7 +376,7 @@ export const useMessagePuller = () => {
}
// pull + replay 都完成后再排序,避免回放消息打乱顺序
conversationStore.sortConversations()
conversationStore.sortConversationList()
// 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」
// 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 Index.vue 的 watch 触发
@ -394,7 +392,7 @@ export const useMessagePuller = () => {
return
}
if (maxReadId) {
messageStore.applyReadReceipt({
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: active.targetId,
privateReadMaxId: maxReadId

View File

@ -111,10 +111,9 @@ export const useMessageSender = () => {
clientMessageId = options.existingClientMessageId
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
// TODO @AI尽量不要 m 缩写,全称
const stillExists = messageStore
.getMessageList(conversation.type, realTarget)
.some((m) => m.clientMessageId === clientMessageId && !m._ackMerging)
.some((message) => message.clientMessageId === clientMessageId && !message._ackMerging)
if (!stillExists) {
return false
}
@ -227,10 +226,13 @@ export const useMessageSender = () => {
// 本地标记已读:未读数清零 + 消息状态更新为 READUI 立刻响应)
conversationStore.markConversationAsRead(conversation.type, conversation.targetId)
messageStore.markConversationMessagesRead(conversation)
// TODO @AImessage不要用 m
const maxMessageId = messageStore
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
.reduce<number>((max, m) => (m.id && m.id > max ? m.id : max), 0)
.reduce<number>(
(maxMessageId, message) =>
message.id && message.id > maxMessageId ? message.id : maxMessageId,
0
)
if (!maxMessageId) {
return
}
@ -285,8 +287,8 @@ export const useMessageSender = () => {
if (!maxReadId) {
return
}
// applyReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ
messageStore.applyReadReceipt({
// applyMessageReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: peerId,
privateReadMaxId: maxReadId

View File

@ -39,7 +39,6 @@ import { useImWebSocketStore } from './store/websocketStore'
import { useFriendStore } from './store/friendStore'
import { useGroupStore } from './store/groupStore'
import { useGroupRequestStore } from './store/groupRequestStore'
import { useDraftStore } from './store/draftStore'
import { useFaceStore } from './store/faceStore'
import { useChannelStore } from './store/channelStore'
import { useMessagePuller } from './composables/useMessagePuller'
@ -65,7 +64,6 @@ const webSocketStore = useImWebSocketStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const draftStore = useDraftStore()
const faceStore = useFaceStore()
const channelStore = useChannelStore()
const { pullOnce, cancelPull } = useMessagePuller()
@ -81,18 +79,17 @@ onMounted(async () => {
.fetchUnhandledList()
.catch((e) => console.warn('[IM] 拉取未处理加群申请失败', e))
// 1.1 loading=true saveConversations + WebSocket connect pullOnce maxId pull 线
// 1.1 loading=true + WebSocket connect pullOnce maxId pull 线
conversationStore.loading = true
try {
// TODO @AI
// 1.2 IM DB
await initDb()
// 1.2 store IDB loadConversations / loadDrafts voidload{Friends,Groups,Channels}
const [, , hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([
// 1.3 store IDB loadConversations / loadMessageCursors voidload{Friends,Groups,Channels}
const [, , hasCachedFriends, hasCachedGroups, hasCachedChannels] = await Promise.all([
conversationStore.loadConversations(),
messageStore.loadCursors(),
messageStore.loadMessageCursors(),
friendStore.loadFriends(),
groupStore.loadGroups(),
draftStore.loadDrafts(),
channelStore.loadChannels()
])
@ -130,7 +127,7 @@ onMounted(async () => {
conversationStore.setActiveConversation(firstVisible)
}
} catch (e) {
// 1. loadingpullOnce finally saveConversations return
// 1. loadingpullOnce finally return
// 2. WebSocket disconnect onUnmounted
conversationStore.loading = false
console.error('[IM] 初始化失败', e)
@ -155,7 +152,7 @@ function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | un
/** 标签关闭前 flush 草稿队列debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */
function onBeforeUnload() {
draftStore.flushPersist()
conversationStore.flushDraftSave()
}
window.addEventListener('beforeunload', onBeforeUnload)
@ -163,12 +160,12 @@ window.addEventListener('beforeunload', onBeforeUnload)
onUnmounted(() => {
cancelPull()
webSocketStore.disconnect()
draftStore.flushPersist()
conversationStore.flushDraftSave()
faceStore.reset()
// audio
voicePlayer.stop()
window.removeEventListener('beforeunload', onBeforeUnload)
// TODO @AI
// IM session store
void stopRequests()
})

View File

@ -295,7 +295,7 @@
<!-- 进群审批仅群主可操作开启后普通成员的申请邀请路径都需群主 / 管理员同意群主 / 管理员邀请直进 -->
<div v-if="isOwner" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
<span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">进群需要群主 / 群管理确认</span>
<el-switch :model-value="!!group.joinApproval" @change="onJoinApprovalChange" />
<el-switch :model-value="!!group.joinApproval" @change="handleJoinApprovalChange" />
</div>
<!-- 进群申请子项仅当开启审批 + 当前用户是 owner / admin 时出现点击进列表 dialog -->
<div
@ -567,8 +567,7 @@ async function saveNotice() {
}
/** 群主:切换「进群审批」开关;开启后所有「申请」「邀请」路径都需群主 / 管理员同意 */
// TODO @AI handleXXX
async function onJoinApprovalChange(value: boolean | string | number) {
async function handleJoinApprovalChange(value: boolean | string | number) {
if (!props.group) {
return
}

View File

@ -107,7 +107,6 @@ import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
import { useImUiStore } from '../../../../store/uiStore'
import { useDraftStore } from '../../../../store/draftStore'
import { ImConversationType, ImMessageType, isNormalMessage } from '../../../../../utils/constants'
import { getSenderDisplayName } from '@/views/im/utils/user'
import { buildRecallTip } from '@/views/im/utils/conversation'
@ -128,7 +127,6 @@ const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const uiStore = useImUiStore()
const draftStore = useDraftStore()
const message = useMessage()
const isActive = computed(
@ -145,7 +143,7 @@ const draft = computed(() => {
if (isActive.value) {
return undefined
}
return draftStore.getDraft(props.conversation)
return conversationStore.getDraft(props.conversation)
})
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)

View File

@ -46,7 +46,7 @@
:quote="replyTarget"
closable
class="mx-3 mb-1.5"
@close="clearReply"
@close="clearReplyDraft"
/>
<!--
@ -167,7 +167,6 @@ import { updateFile } from '@/api/infra/file'
import { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useGroupStore } from '@/views/im/home/store/groupStore'
import { useFriendStore } from '@/views/im/home/store/friendStore'
import { useDraftStore } from '@/views/im/home/store/draftStore'
import { getMemberDisplayName } from '@/views/im/utils/user'
import { useMessage } from '@/hooks/web/useMessage'
import { useUserStore } from '@/store/modules/user'
@ -200,7 +199,6 @@ defineOptions({ name: 'ImMessageInput' })
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const draftStore = useDraftStore()
const userStore = useUserStore()
const message = useMessage()
const { send, sendRaw } = useMessageSender()
@ -257,7 +255,7 @@ watch(muteOverlay, () => {
}
})
/** 把 editor 当前内容写到 draftStoreplain 由 collectFromEditor 拿,与发送时同源避免列表与实发不一致 */
/** 把 editor 当前内容写到会话草稿plain 由 collectFromEditor 拿,与发送时同源避免列表与实发不一致 */
function syncDraftToStore(editor: HTMLDivElement) {
const conversation = conversationStore.activeConversation
if (!conversation) {
@ -266,8 +264,8 @@ function syncDraftToStore(editor: HTMLDivElement) {
// collectFromEditor trimplain store clearDraft
// reply setDraft reply
const { text } = collectFromEditor(editor)
const existing = draftStore.getDraft(conversation)
draftStore.setDraft(conversation, {
const existing = conversationStore.getDraft(conversation)
conversationStore.setDraft(conversation, {
html: editor.innerHTML,
plain: text,
reply: existing?.reply
@ -281,7 +279,7 @@ function restoreDraftToEditor() {
return
}
const conversation = conversationStore.activeConversation
const draft = conversation ? draftStore.getDraft(conversation) : undefined
const draft = conversation ? conversationStore.getDraft(conversation) : undefined
editor.innerHTML = draft?.html || ''
applyEditorUiState(editor)
// focus
@ -383,7 +381,7 @@ async function handleSend(options?: { receipt?: boolean }) {
const replyQuote = replyTarget.value
editor.innerHTML = ''
if (conversationStore.activeConversation) {
draftStore.clearDraft(conversationStore.activeConversation)
conversationStore.clearDraft(conversationStore.activeConversation)
}
syncEditorState()
// 2.
@ -568,29 +566,29 @@ function onInput() {
// ==================== / ====================
/** 当前会话的「正在回复」对象,从 draftStore 派生(MessageItem 写、MessageInput 读) */
/** 当前会话的「正在回复」对象,从会话草稿派生 */
const replyTarget = computed<QuoteMessage | undefined>(() => {
const conversation = conversationStore.activeConversation
if (!conversation) {
return undefined
}
return draftStore.getDraft(conversation)?.reply
return conversationStore.getDraft(conversation)?.reply
})
/** 清掉当前 reply 但保留正文草稿:点 × 关闭 / 发送即将进行时调 */
function clearReply() {
function clearReplyDraft() {
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
draftStore.clearReply(conversation)
conversationStore.clearReplyDraft(conversation)
}
/** 取走当前 reply 快照(抓一次清一次),媒体上传链路在动手前统一调它拿 quote */
function consumeReply(): QuoteMessage | undefined {
const quote = replyTarget.value
if (quote) {
clearReply()
clearReplyDraft()
}
return quote
}

View File

@ -139,37 +139,36 @@ function handleTopClick() {
}
/** 点击置顶消息行 → 触发跳转 + 收起弹出层 */
function handleLocate(msg: Message) {
if (!msg.id) {
function handleLocate(message: Message) {
if (!message.id) {
return
}
emit('locate', msg.id)
emit('locate', message.id)
expanded.value = false
}
// TODO @AI message msg
/** 置顶消息发送人显示名 */
function getSenderName(msg: Message): string {
function getSenderName(message: Message): string {
return group.value
? getSenderDisplayName(msg.senderId, ImConversationType.GROUP, group.value.id)
? getSenderDisplayName(message.senderId, ImConversationType.GROUP, group.value.id)
: ''
}
/** 置顶消息预览文本:复用会话最后一条摘要逻辑([图片] / [文件] / 文本等) */
function getPreview(msg: Message): string {
function getPreview(message: Message): string {
return group.value
? resolveConversationLastContent(msg, ImConversationType.GROUP, group.value.id)
? resolveConversationLastContent(message, ImConversationType.GROUP, group.value.id)
: ''
}
/** 移除置顶:调后端 APIloading 期间禁止重复点;后端广播 GROUP_MESSAGE_UNPIN 由 dispatcher 自动同步本地 */
async function handleRemove(msg: Message) {
if (!group.value || !msg.id || removingId.value !== null) {
async function handleRemove(pinnedMessage: Message) {
if (!group.value || !pinnedMessage.id || removingId.value !== null) {
return
}
removingId.value = msg.id
removingId.value = pinnedMessage.id
try {
await apiUnpinGroupMessage({ id: group.value.id, messageId: msg.id })
await apiUnpinGroupMessage({ id: group.value.id, messageId: pinnedMessage.id })
message.success('已取消置顶')
} finally {
removingId.value = null

View File

@ -604,9 +604,9 @@ async function loadEarlier() {
if (pageLength < HISTORY_PAGE_SIZE) {
hasMore.value = false
}
// messageStoreprependMessages + + IndexedDB
// messageStoreprependMessageList + + IndexedDB
// messages
messageStore.prependMessages(requestedType, requestedTargetId, earlier)
messageStore.prependMessageList(requestedType, requestedTargetId, earlier)
} finally {
loadingMore.value = false
}

View File

@ -235,7 +235,6 @@ import { useUserStore } from '@/store/modules/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useDraftStore } from '../../../../store/draftStore'
import { useFaceStore } from '../../../../store/faceStore'
import {
getMemberDisplayName,
@ -293,7 +292,6 @@ const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const draftStore = useDraftStore()
const faceStore = useFaceStore()
const uiStore = useImUiStore()
const { recall, sendRaw } = useMessageSender()
@ -602,7 +600,7 @@ type MenuKey = (typeof MENU_KEYS)[keyof typeof MENU_KEYS]
/**
* 右键菜单项
* - 引用已落库 + 未撤回的消息可引用引用块写入 draftStore.reply
* - 引用已落库 + 未撤回的消息可引用引用块写入会话草稿
* - 撤回 / 删除互斥自己发送 + 已落库 + 未撤回 + 2 分钟内显示撤回推服务器其它显示删除仅本地清
*
* 好友事件气泡态不弹菜单
@ -892,13 +890,13 @@ async function handleCopy() {
successMessage('内容已复制到剪贴板')
}
/** 进入引用模式:把当前消息构造成 QuoteMessage 写入 draftStoreMessageInput 顶部引用条响应式出现 */
/** 进入引用模式:把当前消息构造成 QuoteMessage 写入会话草稿 */
function handleReply() {
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
draftStore.setReply(conversation, buildQuoteFromMessage(props.message))
conversationStore.setReplyDraft(conversation, buildQuoteFromMessage(props.message))
}
/** 转发当前消息:打开 ForwardDialog单条模式mode=single 即原样转) */

View File

@ -150,7 +150,7 @@ async function loadReadUsers() {
// flip DONE label ""
// readCountreceiptStatus PENDING / READING
const allRead = readCount > 0 && readCount >= visibleMembers.value.length
messageStore.applyReadReceipt({
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.GROUP,
targetId: props.groupId,
groupMessageId: props.message.id,

View File

@ -45,8 +45,8 @@ export const useChannelStore = defineStore('imChannelStore', {
}
},
/** 整桶持久化频道列表(量级小,不维护增量) */
saveChannels(): void {
/** 保存频道列表 */
saveChannelList(): void {
const userId = getCurrentUserId()
if (!userId) {
return
@ -64,15 +64,15 @@ export const useChannelStore = defineStore('imChannelStore', {
try {
this.channels = (await getSimpleChannelList()) || []
this.loaded = true
this.syncConversationMetadata()
this.saveChannels()
this.syncChannelConversationMetadata()
this.saveChannelList()
} catch (e) {
console.warn('[IM channelStore] fetchChannels 失败', e)
}
},
/** 用最新的频道信息覆盖已有 CHANNEL 会话的 name / avatarconversationStore 持久化的旧占位被刷掉 */
syncConversationMetadata() {
syncChannelConversationMetadata() {
const conversationStore = useConversationStore()
const indexed = new Map(this.channels.map((c) => [c.id, c]))
conversationStore.conversations.forEach((conversation) => {
@ -105,5 +105,3 @@ if (import.meta.hot) {
}
export const useChannelStoreWithOut = () => useChannelStore(store)
// TODO @AI这里重名名是不是没必要问问。
export const useImChannelStore = useChannelStoreWithOut

View File

@ -1,18 +1,55 @@
import { acceptHMRUpdate, defineStore } from 'pinia'
import { debounce } from 'lodash-es'
import { store } from '@/store'
import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config'
import { ImConversationType } from '../../utils/constants'
import { getClientConversationId, getDb, type DbTx } from '../../utils/db'
import { getClientConversationId, getDb, type DbTransaction } from '../../utils/db'
import { getCurrentUserId } from '../../utils/storage'
import { useDraftStore } from './draftStore'
import { useMessageStore } from './messageStore'
import type { Conversation, ConversationDO } from '../types'
const PERSIST_DRAFT_DEBOUNCE_MS = 500
const pendingDraftConversations = new Set<Conversation>()
/** 会话转 IndexedDB 记录 */
function toConversationDO(conversation: Conversation): ConversationDO {
const draft = conversation.draft
return {
...conversation,
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,
deleted: conversation.deleted,
top: conversation.top,
silent: conversation.silent,
atMe: conversation.atMe,
atAll: conversation.atAll,
draft: draft
? {
html: draft.html,
plain: draft.plain,
reply: draft.reply
? {
messageId: draft.reply.messageId,
senderId: draft.reply.senderId,
type: draft.reply.type,
content: draft.reply.content
}
: undefined
}
: undefined,
clientConversationId: getClientConversationId(conversation.type, conversation.targetId)
}
}
@ -64,8 +101,8 @@ export const useConversationStore = defineStore('imConversationStore', {
actions: {
/** 加载会话 */
// TODO @AI方法里的代码段注释写一下。
async loadConversations() {
// 1. 清理旧账号内存
const userId = getCurrentUserId()
if (!userId) {
this.clear()
@ -75,6 +112,7 @@ export const useConversationStore = defineStore('imConversationStore', {
? getClientConversationId(this.activeConversation.type, this.activeConversation.targetId)
: null
this.clear()
// 2. 从 IndexedDB 读取会话和轻量设置
const db = getDb()
const [conversations, recent] = await Promise.all([
db.getAll<ConversationDO>('conversations'),
@ -84,6 +122,7 @@ export const useConversationStore = defineStore('imConversationStore', {
if (Array.isArray(recent)) {
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
}
// 3. 恢复当前激活会话
if (previousActiveKey) {
this.activeConversation =
this.conversations.find(
@ -97,47 +136,59 @@ export const useConversationStore = defineStore('imConversationStore', {
/** 清空会话内存 */
clear() {
saveDraftConversationsDebounced.cancel()
pendingDraftConversations.clear()
this.conversations = []
this.activeConversation = null
this.recentForwardConversationKeys = []
},
/** 执行会话持久化 */
// TODO @AI想了下DbTx 改成 DbTransaction 吧,变量可以叫 tx
// TODO @AI方法里的代码段注释写一下。
async persistConversations(
target?: Conversation | Conversation[] | null,
tx?: DbTx
/** 执行会话记录持久化 */
async persistConversationRecords(
target: Conversation | Conversation[] | null | undefined,
tx?: DbTransaction
): Promise<void> {
const db = getDb()
const conversations = (
Array.isArray(target) ? target : target ? [target] : this.conversations
).map(toConversationDO)
const conversations = (Array.isArray(target) ? target : target ? [target] : []).map(
toConversationDO
)
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 (innerTx) => {
await db.transaction(['conversations'], 'readwrite', async (tx) => {
for (const conversation of conversations) {
await db.put('conversations', conversation, innerTx)
await db.put('conversations', conversation, tx)
}
})
},
/** 持久化会话 */
saveConversations(target?: Conversation | Conversation[] | null, tx?: DbTx): void {
/** 持久化单个会话 */
saveConversation(conversation: Conversation | null | undefined, tx?: DbTransaction): void {
if (!conversation) {
return
}
void this.persistConversationRecords(conversation, tx).catch((e) =>
console.warn('[IM conversationStore] 会话写入失败', e)
)
},
/** 持久化会话列表 */
saveConversationList(conversations?: Conversation[] | null, tx?: DbTransaction): void {
if (this.loading && !tx) {
return
}
void this.persistConversations(target, tx).catch((e) =>
void this.persistConversationRecords(conversations || this.conversations, tx).catch((e) =>
console.warn('[IM conversationStore] 会话写入失败', e)
)
},
/** 确保会话存在 */
// TODO @AI方法里的代码段注释写一下。
ensureConversation(info: {
type: number
targetId: number
@ -145,6 +196,7 @@ export const useConversationStore = defineStore('imConversationStore', {
avatar: string
silent?: boolean
}): Conversation {
// 1. 创建不存在的会话
let conversation = this.getConversation(info.type, info.targetId)
if (!conversation) {
conversation = this.createEmptyConversation(
@ -156,6 +208,7 @@ export const useConversationStore = defineStore('imConversationStore', {
)
this.conversations.unshift(conversation)
} else if (conversation.deleted) {
// 2. 恢复软删除会话
conversation.deleted = false
conversation.name = info.name || conversation.name
conversation.avatar = info.avatar || conversation.avatar
@ -163,6 +216,7 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation.silent = info.silent
}
} else {
// 3. 同步会话展示元数据
if (info.name) {
conversation.name = info.name
}
@ -177,7 +231,6 @@ export const useConversationStore = defineStore('imConversationStore', {
},
/** 打开或创建会话 */
// TODO @AI方法里的代码段注释写一下。
openConversation(
targetId: number,
type: number,
@ -185,6 +238,7 @@ export const useConversationStore = defineStore('imConversationStore', {
avatar: string,
options?: { silent?: boolean }
): Conversation {
// 1. 确保会话在列表中
const conversation = this.ensureConversation({
type,
targetId,
@ -192,23 +246,25 @@ export const useConversationStore = defineStore('imConversationStore', {
avatar,
silent: options?.silent
})
// 2. 激活会话并保存
this.setActiveConversation(conversation)
this.saveConversations(conversation)
this.saveConversation(conversation)
return conversation
},
/** 设置当前会话 */
// TODO @AI方法里的代码段注释写一下。
setActiveConversation(conversation: Conversation | null) {
this.activeConversation = conversation
if (!conversation) {
return
}
// 1. 清理会话级未读状态
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
void useMessageStore().ensureLoaded(conversation)
this.saveConversations(conversation)
// 2. 懒加载消息并保存会话摘要
void useMessageStore().ensureConversationMessagesLoaded(conversation)
this.saveConversation(conversation)
},
/** 创建空会话 */
@ -242,7 +298,7 @@ export const useConversationStore = defineStore('imConversationStore', {
return
}
conversation.top = top
this.saveConversations(conversation)
this.saveConversation(conversation)
},
/** 设置免打扰 */
@ -252,13 +308,12 @@ export const useConversationStore = defineStore('imConversationStore', {
return
}
conversation.silent = silent
// TODO @AIsaveConversations 拆成 saveConversationList、saveConversation 两个方法;
this.saveConversations(conversation)
this.saveConversation(conversation)
},
/** 删除会话 */
// TODO @AI方法里的代码段注释写一下。
removeConversation(type: number, targetId: number) {
// 1. 标记会话删除
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
@ -267,9 +322,10 @@ export const useConversationStore = defineStore('imConversationStore', {
this.activeConversation = null
}
conversation.deleted = true
// 2. 删除会话关联的消息和草稿
useMessageStore().deleteConversationMessages(type, targetId)
useDraftStore().clearDraft({ type, targetId })
this.saveConversations(conversation)
this.clearDraft(conversation)
this.saveConversation(conversation)
},
/** 删除私聊会话 */
@ -294,10 +350,10 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
this.saveConversations(conversation)
this.saveConversation(conversation)
},
// TODO @AI把最近转发 ==== 拆分下???
// ==================== 最近转发 ====================
/** 推送最近转发会话 */
pushRecentForwardConversationKeys(keys: string[]) {
@ -309,7 +365,7 @@ export const useConversationStore = defineStore('imConversationStore', {
0,
CONVERSATION_RECENT_FORWARD_MAX
)
this.persistRecentForwardConversationKeys()
this.saveRecentForwardConversationKeys()
},
/** 移除最近转发会话 */
@ -319,11 +375,11 @@ export const useConversationStore = defineStore('imConversationStore', {
return
}
this.recentForwardConversationKeys.splice(index, 1)
this.persistRecentForwardConversationKeys()
this.saveRecentForwardConversationKeys()
},
/** 持久化最近转发会话 */
persistRecentForwardConversationKeys() {
/** 保存最近转发会话 */
saveRecentForwardConversationKeys() {
void getDb()
.setSetting(
'recentForwardConversationKeys',
@ -332,10 +388,12 @@ export const useConversationStore = defineStore('imConversationStore', {
.catch((e) => console.warn('[IM conversationStore] 最近转发列表写入失败', e))
},
// ==================== 会话维护 ====================
/** 重排会话 */
sortConversations() {
sortConversationList() {
this.conversations.sort((a, b) => (b.lastSendTime || 0) - (a.lastSendTime || 0))
this.saveConversations(this.conversations)
this.saveConversationList(this.conversations)
},
/** 同步会话展示元数据 */
@ -362,14 +420,96 @@ export const useConversationStore = defineStore('imConversationStore', {
changed = true
}
if (changed) {
this.saveConversations(conversation)
this.saveConversation(conversation)
}
},
// ==================== 草稿 ====================
/** 获取草稿 */
getDraft(conversation: { type: number; targetId: number }): Conversation['draft'] | undefined {
return this.getConversation(conversation.type, conversation.targetId)?.draft
},
/** 设置草稿 */
setDraft(
conversation: { type: number; targetId: number },
snapshot: NonNullable<Conversation['draft']>
): void {
if (!snapshot.plain.trim() && !snapshot.reply) {
this.clearDraft(conversation)
return
}
const target = this.getConversation(conversation.type, conversation.targetId)
if (!target) {
return
}
target.draft = snapshot
this.scheduleDraftSave(target)
},
/** 清除草稿 */
clearDraft(conversation: { type: number; targetId: number }): void {
const target = this.getConversation(conversation.type, conversation.targetId)
if (!target?.draft) {
return
}
target.draft = undefined
this.scheduleDraftSave(target)
},
/** 设置回复草稿 */
setReplyDraft(
conversation: { type: number; targetId: number },
quote: NonNullable<Conversation['draft']>['reply']
) {
if (!quote) {
return
}
const existing = this.getDraft(conversation)
this.setDraft(conversation, {
html: existing?.html ?? '',
plain: existing?.plain ?? '',
reply: quote
})
},
/** 清除回复草稿 */
clearReplyDraft(conversation: { type: number; targetId: number }): void {
const existing = this.getDraft(conversation)
if (!existing?.reply) {
return
}
this.setDraft(conversation, { ...existing, reply: undefined })
},
/** 调度草稿保存 */
scheduleDraftSave(conversation: Conversation): void {
pendingDraftConversations.add(conversation)
saveDraftConversationsDebounced()
},
/** 立即保存草稿 */
flushDraftSave(): void {
saveDraftConversationsDebounced.flush()
}
}
})
export const useConversationStoreWithOut = () => useConversationStore(store)
/** 合并草稿写入 */
const saveDraftConversationsDebounced = debounce(() => {
const conversations = Array.from(pendingDraftConversations)
pendingDraftConversations.clear()
if (conversations.length === 0) {
return
}
void useConversationStoreWithOut()
.persistConversationRecords(conversations)
.catch((e) => console.warn('[IM conversationStore] 草稿写入失败', e))
}, PERSIST_DRAFT_DEBOUNCE_MS)
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useConversationStore, import.meta.hot))
}

View File

@ -1,145 +0,0 @@
import { defineStore, acceptHMRUpdate } from 'pinia'
import { debounce } from 'lodash-es'
import { store } from '@/store'
import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage'
import { getConversationKey } from '../../utils/conversation'
import type { QuoteMessage } from '../../utils/message'
/**
* 稿
* - htmleditor contenteditable @-token / <br> innerHTML
* - plain [稿] strip HTML
* - reply,稿;,
*/
export interface DraftSnapshot {
html: string
plain: string
reply?: QuoteMessage
}
/** 草稿持久化整桶结构Record<会话 key, 快照>;草稿量级小(每会话至多几百字节),整桶整写够用 */
type DraftBucket = Record<string, DraftSnapshot>
/** 写盘 debounce用户连续敲键盘时合并写入避免高频 IDB 写放大 */
const PERSIST_DEBOUNCE_MS = 500
/** 合并连续输入的 IDB 写setQuietly 内部 toRaw 拆 reactive proxy避免 structuredClone 抛错 */
const persistBucket = debounce((userId: number, bucket: DraftBucket) => {
setQuietly(StorageKeys.drafts(userId), bucket, '[IM draftStore] persist 失败')
}, PERSIST_DEBOUNCE_MS)
export const useDraftStore = defineStore('imDraft', {
state: () => ({
/** 内存草稿表key = getConversationKey */
drafts: {} as DraftBucket
}),
actions: {
/**
* IDB 稿稿 IM
*
* / IM store drafts
* targetId [稿]
*/
async loadDrafts(): Promise<void> {
this.drafts = {}
const userId = getCurrentUserId()
if (!userId) {
return
}
try {
const bucket = await imStorage.getItem<DraftBucket>(StorageKeys.drafts(userId))
if (bucket && typeof bucket === 'object') {
this.drafts = bucket
}
} catch (e) {
console.warn('[IM draftStore] loadDrafts 失败', e)
}
},
/** 取草稿;返回 undefined 表示该会话无草稿 */
getDraft(conversation: { type: number; targetId: number }): DraftSnapshot | undefined {
return this.drafts[getConversationKey(conversation)]
},
/**
* 稿 + debounce
*
* plain reply clear
*/
setDraft(
conversation: { type: number; targetId: number },
snapshot: DraftSnapshot
): void {
if (!snapshot.plain.trim() && !snapshot.reply) {
this.clearDraft(conversation)
return
}
this.drafts[getConversationKey(conversation)] = snapshot
this.schedulePersist()
},
/** 清草稿:发送成功 / 编辑器清空 / 会话被软删除时调用 */
clearDraft(conversation: { type: number; targetId: number }): void {
const key = getConversationKey(conversation)
if (!(key in this.drafts)) {
return
}
delete this.drafts[key]
this.schedulePersist()
},
/** 进入回复模式:把 quote 写到当前草稿,正文 html / plain 保留 */
setReply(
conversation: { type: number; targetId: number },
quote: QuoteMessage
): void {
const existing = this.getDraft(conversation)
this.setDraft(conversation, {
html: existing?.html ?? '',
plain: existing?.plain ?? '',
reply: quote
})
},
/** 退出回复模式:仅清掉 reply正文草稿保留无 reply 时直接返回 */
clearReply(conversation: { type: number; targetId: number }): void {
const existing = this.getDraft(conversation)
if (!existing?.reply) {
return
}
this.setDraft(conversation, { ...existing, reply: undefined })
},
/** 调度 debounce 写盘;未登录时直接跳过(无主 key 不写) */
schedulePersist(): void {
const userId = getCurrentUserId()
if (!userId) {
return
}
persistBucket(userId, this.drafts)
},
/** 立即落盘待写的草稿beforeunload 时调,避免最后一次输入卡在 debounce 队列里丢失 */
flushPersist(): void {
persistBucket.flush()
},
/** 清空草稿内存 */
// TODO @AI写草稿是不是融合到 conversationStore 里。
clear(): void {
persistBucket.cancel()
this.drafts = {}
}
}
})
export const useDraftStoreWithOut = () => {
return useDraftStore(store)
}
// dev: 让 Pinia 的 actions / state 改动支持 HMR避免每次改 store 都得硬刷
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useDraftStore, import.meta.hot))
}

View File

@ -16,7 +16,7 @@ import {
getServerMessageKey,
parseClientConversationId,
setMessageMaxId,
type DbTx
type DbTransaction
} from '../../utils/db'
import {
generateClientMessageId,
@ -30,7 +30,8 @@ import { useGroupStore } from './groupStore'
import { useConversationStore } from './conversationStore'
import type { Conversation, Message, MessageDO } from '../types'
const MESSAGE_CACHE_CONVERSATION_LIMIT = 5
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 {
@ -41,8 +42,8 @@ interface MessageConversationInfo {
silent?: boolean
}
// TODO @AI叫这个 type 有点奇怪,可能需要再考虑下。
export type PulledMessageBatchItem =
/** 拉取消息批量处理项 */
export type PulledMessage =
| {
kind: 'insert'
conversationInfo: MessageConversationInfo
@ -82,16 +83,22 @@ function ensureClientMessageId(message: Message): Message {
}
/** 转换为 IndexedDB 消息记录 */
// TODO @AIbuildXXX 更合理。
function toMessageDO(message: Message, conversationType: number): MessageDO {
const {
uploadProgress: _uploadProgress,
_localFile: _localFile,
_ackMerging: _ackMerging,
...rest
} = message
function buildMessageDO(message: Message, conversationType: number): MessageDO {
return {
...rest,
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)
@ -99,8 +106,7 @@ function toMessageDO(message: Message, conversationType: number): MessageDO {
}
/** IndexedDB 消息记录转前端消息 */
// TODO @AIbuildXXX 更合理。
function fromMessageDO(message: MessageDO): Message {
function buildMessageFromDO(message: MessageDO): Message {
const {
messageKey: _messageKey,
conversationType: _conversationType,
@ -111,15 +117,16 @@ function fromMessageDO(message: MessageDO): Message {
}
/** 算出末条消息的发送人快照 */
// TODO @AI里面的代码注释最好写下
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)
@ -256,9 +263,8 @@ export const useMessageStore = defineStore('imMessageStore', {
},
/** 从 settings 加载消息游标 */
async loadCursors() {
async loadMessageCursors() {
const db = getDb()
// TODO @AI可以通过 message 表去算么?不通过这个。
const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([
db.getSetting<number>('privateMessageMaxId'),
db.getSetting<number>('groupMessageMaxId'),
@ -270,7 +276,7 @@ export const useMessageStore = defineStore('imMessageStore', {
},
/** 更新内存游标 */
updateMaxId(conversationType: number, messageId?: number) {
updateMessageMaxId(conversationType: number, messageId?: number) {
if (!messageId) {
return
}
@ -290,14 +296,14 @@ export const useMessageStore = defineStore('imMessageStore', {
},
/** 标记会话近期使用 */
touchConversation(clientConversationId: string) {
touchConversationMessageCache(clientConversationId: string) {
this.loadedConversationKeys = [
clientConversationId,
...this.loadedConversationKeys.filter((key) => key !== clientConversationId)
]
// 保留当前活跃会话 + 最近打开过的 5 个会话
const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_CONVERSATION_LIMIT + 1)
const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_CONVERSATION_LIMIT + 1)
// 保留当前活跃会话 + 最近打开过的会话
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) => {
delete this.messagesByConversation[key]
@ -305,61 +311,59 @@ export const useMessageStore = defineStore('imMessageStore', {
},
/** 加载当前会话最近消息 */
async loadMore(
async loadMoreMessages(
clientConversationId: string,
beforeSendTime?: number,
limit = 50
): Promise<Message[]> {
// TODO @AI代码段的注释
const list = await getDb().getMessagesByConversation(clientConversationId, {
// 1. 从 IndexedDB 倒序读取一页,返回前已按时间升序排列
const list = await getDb().getMessageListByConversation(clientConversationId, {
beforeSendTime,
limit
})
// 2. 合并到内存缓存,过滤已存在的消息
const parsed = parseClientConversationId(clientConversationId)
if (!parsed) {
return []
}
const messages = list.map(fromMessageDO)
const messages = list.map(buildMessageFromDO)
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))
)
// TODO @AImessageA、messageB
this.messagesByConversation[clientConversationId] = [...fresh, ...existing].sort(
(a, b) => (a.sendTime || 0) - (b.sendTime || 0)
(messageA, messageB) => (messageA.sendTime || 0) - (messageB.sendTime || 0)
)
this.touchConversation(clientConversationId)
this.touchConversationMessageCache(clientConversationId)
return fresh
},
/** 确保会话消息已加载 */
async ensureLoaded(conversation: Conversation) {
// TODO @AI代码段的注释
async ensureConversationMessagesLoaded(conversation: Conversation) {
const key = getMessageCacheKey(conversation.type, conversation.targetId)
if (this.messagesByConversation[key]) {
this.touchConversation(key)
this.touchConversationMessageCache(key)
return
}
await this.loadMore(key)
await this.loadMoreMessages(key)
},
/** 获取内存消息数组 */
getMessageList(conversationType: number, targetId: number): Message[] {
// TODO @AI代码段的注释
const key = getMessageCacheKey(conversationType, targetId)
if (!this.messagesByConversation[key]) {
this.messagesByConversation[key] = []
}
this.touchConversation(key)
this.touchConversationMessageCache(key)
return this.messagesByConversation[key]
},
/** 持久化单条消息 */
async persistMessage(message: Message, conversationType: number, tx?: DbTx) {
// TODO @AI代码段的注释
/** 持久化消息记录 */
async persistMessageRecord(message: Message, conversationType: number, tx?: DbTransaction) {
const db = getDb()
const next = toMessageDO(message, conversationType)
const next = buildMessageDO(message, conversationType)
// ack 后服务端 key 替换 client key
if (message.id && message.clientMessageId) {
const existing = await db.getByIndex<MessageDO>(
'messages',
@ -374,15 +378,19 @@ export const useMessageStore = defineStore('imMessageStore', {
await db.put('messages', next, tx)
},
/** 持久化消息游标 */
async persistMaxId(conversationType: number, messageId?: number, tx?: DbTx) {
this.updateMaxId(conversationType, messageId)
/** 保存消息游标 */
async saveMessageMaxId(conversationType: number, messageId?: number, tx?: DbTransaction) {
this.updateMessageMaxId(conversationType, messageId)
await setMessageMaxId(conversationType, messageId, tx)
},
/** 应用撤回到内存 */
applyRecallInMemory(conversationType: number, targetId: number, recallSignalContent: string) {
// TODO @AI代码段的注释
applyRecallMessageInMemory(
conversationType: number,
targetId: number,
recallSignalContent: string
) {
// 1. 定位被撤回的原消息
const messageId = parseRecallMessageId(recallSignalContent)
if (!messageId) {
return null
@ -397,6 +405,7 @@ export const useMessageStore = defineStore('imMessageStore', {
if (!message) {
return null
}
// 2. 更新消息和会话摘要
message.type = ImMessageType.RECALL
message.status = ImMessageStatus.RECALL
message.content = ''
@ -407,14 +416,14 @@ export const useMessageStore = defineStore('imMessageStore', {
},
/** 批量写入拉取消息 */
async insertPulledBatch(
items: PulledMessageBatchItem[],
async applyPulledMessageList(
pulledMessages: PulledMessage[],
conversationType: number,
maxMessageId?: number
) {
// TODO @AI代码段的注释
if (items.length === 0) {
await this.persistMaxId(conversationType, maxMessageId)
if (pulledMessages.length === 0) {
// 1. 空批次只推进游标
await this.saveMessageMaxId(conversationType, maxMessageId)
return
}
const conversationStore = useConversationStore()
@ -433,13 +442,14 @@ export const useMessageStore = defineStore('imMessageStore', {
})
}
// TODO @AI是不是最好 mesages
for (const item of items) {
if (item.kind === 'recall') {
const changed = this.applyRecallInMemory(
item.conversationType,
item.targetId,
item.recallSignalContent
// 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)
@ -447,8 +457,9 @@ export const useMessageStore = defineStore('imMessageStore', {
continue
}
const { conversationInfo } = item
const message = ensureClientMessageId(item.message)
const { conversationInfo } = pulledMessage
const message = ensureClientMessageId(pulledMessage.message)
// 1.2 群通知先同步群资料
if (
conversationInfo.type === ImConversationType.GROUP &&
isGroupNotification(message.type)
@ -460,21 +471,23 @@ export const useMessageStore = defineStore('imMessageStore', {
)
}
// 1.3 确保会话和消息缓存存在
const conversation = conversationStore.ensureConversation(conversationInfo)
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
if (existingIndex >= 0) {
// 1.4 已存在消息合并服务端状态
applyServerMessageUpdate(messages[existingIndex], message)
if (existingIndex === messages.length - 1) {
recomputeConversationLast(conversation, messages)
syncConversationAtFlags(conversation, message)
}
this.updateMaxId(conversationInfo.type, message.id)
this.updateMessageMaxId(conversationInfo.type, message.id)
addChanged(conversation, messages[existingIndex])
continue
}
// TODO @AIapplyConversationSummary 要 await 么?不然会有报错;
// 1.5 新消息更新会话摘要和未读状态
applyConversationSummary(conversation, message)
syncConversationAtFlags(conversation, message)
const isActive =
@ -490,6 +503,7 @@ export const useMessageStore = defineStore('imMessageStore', {
conversation.unreadCount++
}
// 1.6 新消息按服务端 id 插入内存列表
let insertIndex = messages.length
if (message.id) {
for (let index = 0; index < messages.length; index++) {
@ -501,17 +515,22 @@ export const useMessageStore = defineStore('imMessageStore', {
}
}
messages.splice(insertIndex, 0, message)
this.updateMaxId(conversationInfo.type, message.id)
this.updateMessageMaxId(conversationInfo.type, message.id)
addChanged(conversation, message)
}
this.updateMaxId(conversationType, maxMessageId)
// 2. 更新内存游标
this.updateMessageMaxId(conversationType, maxMessageId)
// 3. 单事务写入消息、会话摘要和游标
await getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
// 3.1 写入本批变更消息
for (const item of persistedMessages.values()) {
await this.persistMessage(item.message, item.conversationType, tx)
await this.persistMessageRecord(item.message, item.conversationType, tx)
}
await conversationStore.persistConversations([...changedConversations.values()], tx)
// 3.2 写入本批变更会话
await conversationStore.persistConversationRecords([...changedConversations.values()], tx)
// 3.3 写入本批游标
await setMessageMaxId(conversationType, maxMessageId, tx)
})
.catch((e) => console.error('[IM messageStore] 批量消息写入失败', e))
@ -521,11 +540,11 @@ export const useMessageStore = defineStore('imMessageStore', {
insertMessage(
conversationInfo: MessageConversationInfo,
messageInfo: Message,
options?: { persistMaxId?: boolean }
options?: { saveMaxId?: boolean }
) {
// TODO @AI代码段的注释类似上面的问题
const conversationStore = useConversationStore()
const message = ensureClientMessageId(messageInfo)
// 1. 先处理消息带来的群资料变更
if (conversationInfo.type === ImConversationType.GROUP && isGroupNotification(message.type)) {
useGroupStore().applyGroupNotification(
conversationInfo.targetId,
@ -534,21 +553,23 @@ export const useMessageStore = defineStore('imMessageStore', {
)
}
// 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 >= 0) {
applyServerMessageUpdate(messages[existingIndex], message)
if (existingIndex === messages.length - 1) {
recomputeConversationLast(conversation, messages)
syncConversationAtFlags(conversation, message)
}
this.updateMaxId(conversationInfo.type, message.id)
this.updateMessageMaxId(conversationInfo.type, message.id)
void getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.persistMessage(messages[existingIndex], conversationInfo.type, tx)
await conversationStore.persistConversations(conversation, tx)
if (options?.persistMaxId !== false) {
await this.persistMessageRecord(messages[existingIndex], conversationInfo.type, tx)
await conversationStore.persistConversationRecords(conversation, tx)
if (options?.saveMaxId !== false) {
await setMessageMaxId(conversationInfo.type, message.id, tx)
}
})
@ -556,6 +577,7 @@ export const useMessageStore = defineStore('imMessageStore', {
return
}
// 4. 新消息更新会话摘要和未读状态
applyConversationSummary(conversation, message)
syncConversationAtFlags(conversation, message)
@ -572,6 +594,7 @@ export const useMessageStore = defineStore('imMessageStore', {
conversation.unreadCount++
}
// 5. 新消息按 id 插入到内存数组
let insertIndex = messages.length
if (message.id) {
for (let index = 0; index < messages.length; index++) {
@ -583,12 +606,13 @@ export const useMessageStore = defineStore('imMessageStore', {
}
}
messages.splice(insertIndex, 0, message)
this.updateMaxId(conversationInfo.type, message.id)
this.updateMessageMaxId(conversationInfo.type, message.id)
// 6. 单事务写入消息、会话摘要和游标
void getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.persistMessage(message, conversationInfo.type, tx)
await conversationStore.persistConversations(conversation, tx)
if (options?.persistMaxId !== false) {
await this.persistMessageRecord(message, conversationInfo.type, tx)
await conversationStore.persistConversationRecords(conversation, tx)
if (options?.saveMaxId !== false) {
await setMessageMaxId(conversationInfo.type, message.id, tx)
}
})
@ -626,6 +650,7 @@ export const useMessageStore = defineStore('imMessageStore', {
clientMessageId: string,
updates: Partial<Message>
) {
// 1. 定位待合并消息
const conversationStore = useConversationStore()
const conversation = conversationStore.getConversation(conversationType, targetId)
if (!conversation) {
@ -638,19 +663,22 @@ export const useMessageStore = defineStore('imMessageStore', {
}
message._ackMerging = true
try {
// 2. 合并服务端 ack 到内存
applyServerMessageUpdate(message, updates)
if (messages[messages.length - 1] === message) {
recomputeConversationLast(conversation, messages)
}
this.updateMaxId(conversationType, message.id)
this.updateMessageMaxId(conversationType, message.id)
// 3. 单事务写入消息、会话摘要和游标
await getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.persistMessage(message, conversationType, tx)
await conversationStore.persistConversations(conversation, tx)
await this.persistMessageRecord(message, conversationType, tx)
await conversationStore.persistConversationRecords(conversation, tx)
await setMessageMaxId(conversationType, message.id, tx)
})
.catch((e) => console.error('[IM messageStore] ack 写入失败', e))
} finally {
// 4. 清理合并标记
message._ackMerging = false
}
},
@ -662,7 +690,6 @@ export const useMessageStore = defineStore('imMessageStore', {
clientMessageId: string,
patch: Partial<Message>
) {
// TODO @AI代码段的注释
const message = this.getMessageList(conversationType, targetId).find(
(item) => item.clientMessageId === clientMessageId
)
@ -688,18 +715,22 @@ export const useMessageStore = defineStore('imMessageStore', {
/** 撤回消息 */
recallMessage(conversationType: number, targetId: number, recallSignalContent: string) {
const conversationStore = useConversationStore()
const changed = this.applyRecallInMemory(conversationType, targetId, recallSignalContent)
const changed = this.applyRecallMessageInMemory(
conversationType,
targetId,
recallSignalContent
)
if (!changed) {
return
}
this.persistMessage(changed.message, conversationType).catch((e) =>
this.persistMessageRecord(changed.message, conversationType).catch((e) =>
console.error('[IM messageStore] 撤回消息写入失败', e)
)
conversationStore.saveConversations(changed.conversation)
conversationStore.saveConversation(changed.conversation)
},
/** 应用已读回执 */
applyReadReceipt(options: {
applyMessageReadReceipt(options: {
conversationType: number
targetId: number
privateReadMaxId?: number
@ -707,9 +738,9 @@ export const useMessageStore = defineStore('imMessageStore', {
readCount?: number
receiptStatus?: number
}) {
// TODO @AI代码段的注释
const messages = this.getMessageList(options.conversationType, options.targetId)
const changed: Message[] = []
// 1. 私聊回执批量更新自己发送的消息
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
messages.forEach((message) => {
if (
@ -723,6 +754,7 @@ export const useMessageStore = defineStore('imMessageStore', {
}
})
} else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
// 2. 群聊回执更新单条消息
const message = messages.find((item) => item.id === options.groupMessageId)
if (message) {
if (options.readCount !== undefined) {
@ -734,16 +766,21 @@ export const useMessageStore = defineStore('imMessageStore', {
changed.push(message)
}
}
changed.forEach((message) => {
this.persistMessage(message, options.conversationType).catch((e) =>
console.warn('[IM messageStore] 回执写入失败', e)
)
})
if (changed.length === 0) {
return
}
// 3. 单事务写入变更消息
void getDb()
.transaction(['messages'], 'readwrite', async (tx) => {
for (const message of changed) {
await this.persistMessageRecord(message, options.conversationType, tx)
}
})
.catch((e) => console.warn('[IM messageStore] 回执写入失败', e))
},
/** 前置历史消息 */
prependMessages(conversationType: number, targetId: number, earlierMessages: Message[]) {
// TODO @AI代码段的注释
prependMessageList(conversationType: number, targetId: number, earlierMessages: Message[]) {
if (earlierMessages.length === 0) {
return
}
@ -758,11 +795,13 @@ export const useMessageStore = defineStore('imMessageStore', {
}
const key = getMessageCacheKey(conversationType, targetId)
this.messagesByConversation[key] = [...fresh, ...messages]
fresh.forEach((message) => {
this.persistMessage(message, conversationType).catch((e) =>
console.warn('[IM messageStore] 历史消息写入失败', e)
)
})
void getDb()
.transaction(['messages'], 'readwrite', async (tx) => {
for (const message of fresh) {
await this.persistMessageRecord(message, conversationType, tx)
}
})
.catch((e) => console.warn('[IM messageStore] 历史消息写入失败', e))
},
/** 删除单条消息 */
@ -771,7 +810,7 @@ export const useMessageStore = defineStore('imMessageStore', {
targetId: number,
key: { id?: number; clientMessageId?: string }
) {
// TODO @AI代码段的注释
// 1. 定位会话和消息
const conversationStore = useConversationStore()
const conversation = conversationStore.getConversation(conversationType, targetId)
if (!conversation) {
@ -787,25 +826,26 @@ export const useMessageStore = defineStore('imMessageStore', {
if (index < 0) {
return
}
// 2. 从内存移除消息
const [removed] = messages.splice(index, 1)
revokeBlobUrlsInContent(removed.content)
if (index === messages.length) {
recomputeConversationLast(conversation, messages)
}
// 3. 删除本地记录并保存会话摘要
getDb()
.delete('messages', getMessageKey(removed, conversationType))
.catch((e) => console.warn('[IM messageStore] 消息删除失败', e))
conversationStore.saveConversations()
conversationStore.saveConversation(conversation)
},
/** 当前会话标记已读 */
markConversationMessagesRead(conversation: Conversation) {
// TODO @AI代码段的注释
const messages = this.getMessageList(conversation.type, conversation.targetId)
messages.forEach((message) => {
if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
message.status = ImMessageStatus.READ
this.persistMessage(message, conversation.type).catch((e) =>
this.persistMessageRecord(message, conversation.type).catch((e) =>
console.warn('[IM messageStore] 已读状态写入失败', e)
)
}
@ -814,7 +854,7 @@ export const useMessageStore = defineStore('imMessageStore', {
/** 删除会话全部消息 */
deleteConversationMessages(conversationType: number, targetId: number) {
// TODO @AI代码段的注释
// 1. 清理内存消息和媒体资源
const clientConversationId = getClientConversationId(conversationType, targetId)
const messages = this.messagesByConversation[clientConversationId] || []
messages.forEach((message) => {
@ -825,6 +865,7 @@ export const useMessageStore = defineStore('imMessageStore', {
this.loadedConversationKeys = this.loadedConversationKeys.filter(
(key) => key !== clientConversationId
)
// 2. 删除 IndexedDB 消息
getDb()
.deleteByIndex('messages', 'clientConversationId', clientConversationId)
.catch((e) => console.warn('[IM messageStore] 会话消息删除失败', e))

View File

@ -312,8 +312,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
)
if (conversation) {
conversation.unreadCount = 0
conversationStore.saveConversation(conversation)
}
conversationStore.saveConversations()
},
/**
@ -416,7 +416,6 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
}
} else if (isGroupRequestNotification(websocketMessage.type)) {
// 加群申请通知1503 / 1505 / 1506走私聊通道与好友通知同段位但分开 dispatcher
// TODO @AI改成走群聊通道。不然消息不好拉到
this.handleGroupRequestNotification(websocketMessage)
} else {
// TEXT / IMAGE / FILE / VOICE / VIDEO 等普通消息
@ -585,7 +584,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!websocketMessage.id) {
return
}
useMessageStore().applyReadReceipt({
useMessageStore().applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: websocketMessage.senderId,
privateReadMaxId: websocketMessage.id
@ -708,7 +707,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!MESSAGE_GROUP_READ_ENABLED) {
return
}
useMessageStore().applyReadReceipt({
useMessageStore().applyMessageReadReceipt({
conversationType: ImConversationType.GROUP,
targetId: websocketMessage.groupId,
groupMessageId: websocketMessage.id,

View File

@ -37,6 +37,14 @@ export interface ImGroupMessageDTO {
// ==================== 本地会话 / 消息结构 ====================
/** 引用消息 */
export interface QuoteMessage {
messageId: number // 引用消息编号
senderId: number // 引用消息发送人编号
type: number // 引用消息类型
content: string // 引用消息内容
}
// 会话数据结构(前端自有结构,后端无对应实体)
export interface Conversation {
// ========== 核心标识 ==========
@ -66,12 +74,16 @@ export interface Conversation {
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean // 群聊:是否有人 @我
atAll?: boolean // 群聊:是否有人 @全体成员
draft?: {
html: string // 输入框 HTML
plain: string // 输入框纯文本
reply?: QuoteMessage // 引用消息
} // 输入框草稿
}
// 消息数据结构
export interface Message {
// ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO ==========
// TODO @AI全局的 id 占位 0是不是枚举下
id?: number // 服务端消息编号,发送中为空
clientMessageId: string // 客户端消息编号,本地生成用于合并去重
type: number // 消息类型,对齐 ImMessageType

View File

@ -17,8 +17,7 @@ export type DbStoreName =
| 'channels'
| 'settings'
// TODO @AI是不是继续使用 IDBTransaction不用新的类型定义
export type DbTx = IDBTransaction
export type DbTransaction = IDBTransaction
let currentDb: IDBDatabase | null = null
let currentUserId: number | null = null
@ -116,18 +115,18 @@ function upgradeSchema(db: IDBDatabase) {
}
/** 打开 IM IndexedDB */
// TODO @AI是不是方法里代码段的注释是不是要增加下
function openDb(name: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, DB_SCHEMA_VERSION)
// 创建或升级对象仓库
request.onupgradeneeded = () => upgradeSchema(request.result)
// 返回可复用连接
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
/** 初始化当前用户 IM DB */
// TODO @AI是不是方法里代码段的注释是不是要增加下
export async function initDb(): Promise<void> {
const userId = getCurrentUserId()
if (!Number.isFinite(userId) || userId <= 0) {
@ -142,10 +141,8 @@ export async function initDb(): Promise<void> {
currentDb = await openDb(getDbName(userId))
}
/** 关闭当前 IM DB session */
// TODO @AI是不是需要被调用下
export function closeDbSession() {
currentSession++
/** 关闭当前 IM DB 连接 */
function closeDbConnection() {
currentDb?.close()
currentDb = null
currentUserId = null
@ -171,27 +168,28 @@ function toDbValue<T>(value: T): T {
return toRaw(value) as T
}
// TODO @AI我们讨论下DbWrapper 会不会有点怪?
// TODO @AI是不是改成 selectOne、selectAll、selectList、insert、update、delete、save 这种。
class DbWrapper {
class DbClient {
/** 获取单条记录 */
// TODO @AI是不是不用缩写tx 改成 transaction 更好理解;
async get<T>(storeName: DbStoreName, key: IDBValidKey, tx?: DbTx): Promise<T | undefined> {
async get<T>(
storeName: DbStoreName,
key: IDBValidKey,
tx?: DbTransaction
): Promise<T | undefined> {
if (tx) {
return requestToPromise<T | undefined>(tx.objectStore(storeName).get(key))
}
return this.transaction<T | undefined>([storeName], 'readonly', (innerTx) =>
this.get<T>(storeName, key, innerTx)
return this.transaction<T | undefined>([storeName], 'readonly', (tx) =>
this.get<T>(storeName, key, tx)
)
}
/** 获取 store 全量记录 */
async getAll<T>(storeName: DbStoreName, tx?: DbTx): Promise<T[]> {
async getAll<T>(storeName: DbStoreName, tx?: DbTransaction): Promise<T[]> {
if (tx) {
return requestToPromise<T[]>(tx.objectStore(storeName).getAll())
}
return this.transaction<T[]>([storeName], 'readonly', (innerTx) =>
this.getAll<T>(storeName, innerTx)
return this.transaction<T[]>([storeName], 'readonly', (tx) =>
this.getAll<T>(storeName, tx)
)
}
@ -200,13 +198,13 @@ class DbWrapper {
storeName: DbStoreName,
indexName: string,
query: IDBValidKey | IDBKeyRange,
tx?: DbTx
tx?: DbTransaction
): Promise<T | undefined> {
if (tx) {
return requestToPromise<T | undefined>(tx.objectStore(storeName).index(indexName).get(query))
}
return this.transaction<T | undefined>([storeName], 'readonly', (innerTx) =>
this.getByIndex<T>(storeName, indexName, query, innerTx)
return this.transaction<T | undefined>([storeName], 'readonly', (tx) =>
this.getByIndex<T>(storeName, indexName, query, tx)
)
}
@ -215,35 +213,33 @@ class DbWrapper {
storeName: DbStoreName,
indexName: string,
query?: IDBValidKey | IDBKeyRange,
tx?: DbTx
tx?: DbTransaction
): Promise<T[]> {
if (tx) {
return requestToPromise<T[]>(tx.objectStore(storeName).index(indexName).getAll(query))
}
return this.transaction<T[]>([storeName], 'readonly', (innerTx) =>
this.getAllByIndex<T>(storeName, indexName, query, innerTx)
return this.transaction<T[]>([storeName], 'readonly', (tx) =>
this.getAllByIndex<T>(storeName, indexName, query, tx)
)
}
/** 写入记录 */
async put<T>(storeName: DbStoreName, value: T, tx?: DbTx): Promise<void> {
async put<T>(storeName: DbStoreName, value: T, tx?: DbTransaction): Promise<void> {
if (tx) {
await requestToPromise(tx.objectStore(storeName).put(toDbValue(value)))
return
}
await this.transaction([storeName], 'readwrite', (innerTx) =>
this.put(storeName, value, innerTx)
)
await this.transaction([storeName], 'readwrite', (tx) => this.put(storeName, value, tx))
}
/** 删除记录 */
async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTx): Promise<void> {
async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTransaction): Promise<void> {
if (tx) {
await requestToPromise(tx.objectStore(storeName).delete(key))
return
}
await this.transaction([storeName], 'readwrite', (innerTx) =>
this.delete(storeName, key, innerTx)
await this.transaction([storeName], 'readwrite', (tx) =>
this.delete(storeName, key, tx)
)
}
@ -252,11 +248,11 @@ class DbWrapper {
storeName: DbStoreName,
indexName: string,
query: IDBValidKey | IDBKeyRange,
tx?: DbTx
tx?: DbTransaction
): Promise<void> {
if (!tx) {
await this.transaction([storeName], 'readwrite', (innerTx) =>
this.deleteByIndex(storeName, indexName, query, innerTx)
await this.transaction([storeName], 'readwrite', (tx) =>
this.deleteByIndex(storeName, indexName, query, tx)
)
return
}
@ -277,39 +273,38 @@ class DbWrapper {
}
/** 执行事务 */
// TODO @AI方法里的方法段的注释需要写么
async transaction<T>(
storeNames: DbStoreName[],
mode: IDBTransactionMode,
runner: (tx: DbTx) => Promise<T>
runner: (tx: DbTransaction) => Promise<T>
): Promise<T> {
// 开启事务前校验 session
const session = getDbSession()
guardSession(session)
const tx = getRawDb().transaction(storeNames, mode)
const done = transactionDone(tx)
let result: T
try {
// 事务内只执行 IndexedDB request 链
result = await runner(tx)
} catch (e) {
// TODO @AI这种 logger error 要打印么?
try {
tx.abort()
} catch {}
await done.catch(() => undefined)
throw e
}
// commit 后再次校验 session
await done
guardSession(session)
return result
}
/** 按会话分页获取消息 */
// TODO @AI这个方法里代码段的注释是不是要增加下比如说分页的逻辑游标的逻辑等等
// TODO @AI项目里一般方法名是使用 getListByXXXX
async getMessagesByConversation(
async getMessageListByConversation(
clientConversationId: string,
options?: { beforeSendTime?: number; limit?: number },
tx?: DbTx
tx?: DbTransaction
): Promise<MessageDO[]> {
const limit = options?.limit ?? 50
const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER
@ -319,10 +314,11 @@ class DbWrapper {
false,
true
)
const read = async (innerTx: DbTx): Promise<MessageDO[]> => {
const index = innerTx.objectStore('messages').index('clientConversationId+sendTime')
const read = async (tx: DbTransaction): Promise<MessageDO[]> => {
const index = tx.objectStore('messages').index('clientConversationId+sendTime')
const out: MessageDO[] = []
await new Promise<void>((resolve, reject) => {
// 从新到旧读取一页
const request = index.openCursor(range, 'prev')
request.onerror = () => reject(request.error)
request.onsuccess = () => {
@ -335,6 +331,7 @@ class DbWrapper {
cursor.continue()
}
})
// 气泡渲染需要按时间升序
return out.reverse()
}
if (tx) {
@ -344,22 +341,22 @@ class DbWrapper {
}
/** 读取设置 */
async getSetting<T>(key: string, tx?: DbTx): Promise<T | undefined> {
async getSetting<T>(key: string, tx?: DbTransaction): Promise<T | undefined> {
const item = await this.get<SettingDO<T>>('settings', key, tx)
return item?.value
}
/** 写入设置 */
async setSetting<T>(key: string, value: T, tx?: DbTx): Promise<void> {
async setSetting<T>(key: string, value: T, tx?: DbTransaction): Promise<void> {
await this.put<SettingDO<T>>('settings', { key, value, updateTime: Date.now() }, tx)
}
}
const dbWrapper = new DbWrapper()
const dbClient = new DbClient()
/** 获取当前 IM DB wrapper */
export function getDb(): DbWrapper {
return dbWrapper
/** 获取当前 IM DB client */
export function getDb(): DbClient {
return dbClient
}
/** 当前用户会话主键 */
@ -375,7 +372,6 @@ export function parseClientConversationId(
const type = Number(typeText)
const targetId = Number(targetIdText)
if (!Number.isFinite(type) || !Number.isFinite(targetId) || targetId <= 0) {
// TODO @AIlogger info
return null
}
return { type, targetId }
@ -392,7 +388,6 @@ export function getClientMessageKey(clientMessageId: string): string {
}
/** 解析本地消息主键 */
// TODO @AI这个方法貌似没调用
export function parseMessageKey(
messageKey: string
):
@ -419,7 +414,7 @@ export function parseMessageKey(
export async function setMessageMaxId(
conversationType: number,
maxId: number | undefined,
tx?: DbTx
tx?: DbTransaction
): Promise<void> {
if (!maxId) {
return
@ -446,7 +441,6 @@ export async function setMessageMaxId(
}
/** 停止当前 IM DB session */
// TODO @AI这里的注释要写下方法注释
export async function stopRequests(): Promise<void> {
const [
{ useMessageStoreWithOut },
@ -455,7 +449,6 @@ export async function stopRequests(): Promise<void> {
{ useGroupStoreWithOut },
{ useChannelStoreWithOut },
{ useGroupRequestStoreWithOut },
{ useDraftStoreWithOut },
{ useFaceStoreWithOut }
] = await Promise.all([
import('../home/store/messageStore'),
@ -464,7 +457,6 @@ export async function stopRequests(): Promise<void> {
import('../home/store/groupStore'),
import('../home/store/channelStore'),
import('../home/store/groupRequestStore'),
import('../home/store/draftStore'),
import('../home/store/faceStore')
])
currentSession++
@ -474,9 +466,6 @@ export async function stopRequests(): Promise<void> {
useGroupStoreWithOut().clear()
useChannelStoreWithOut().clear()
useGroupRequestStoreWithOut().reset()
useDraftStoreWithOut().clear()
useFaceStoreWithOut().reset()
currentDb?.close()
currentDb = null
currentUserId = null
closeDbConnection()
}

View File

@ -10,7 +10,9 @@ import { getCurrentUserId } from './storage'
import { formatCallDuration } from './time'
import { useFriendStore } from '../home/store/friendStore'
import { useGroupStore } from '../home/store/groupStore'
import type { Conversation, Message, User, GroupLite } from '../home/types'
import type { Conversation, Message, User, GroupLite, QuoteMessage } from '../home/types'
export type { QuoteMessage } from '../home/types'
// ====================================================================
// IM 消息 content 编解码 & 展示工具
@ -177,14 +179,6 @@ export function parseTextSegments(text: string, mentions: MentionCandidate[] = [
// ==================== 引用消息 ====================
/** 引用消息 payload(对齐后端 QuoteMessage) */
export interface QuoteMessage {
messageId: number
senderId: number
type: number
content: string
}
/** 引用容器5 种普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO)都可携带 quote */
interface Quotable {
quote?: QuoteMessage

View File

@ -21,13 +21,6 @@ export const imStorage = localforage.createInstance({
* key userId in-memoryIDB / /
*/
export const StorageKeys = {
/**
* 稿Record<`${type}:${targetId}`, DraftSnapshot>
*
* 稿 userId
*/
drafts: (userId: number | string) => `drafts:${userId}`,
/** 好友列表整桶(含 DISABLE 软删记录);好友量级有限,不维护增量 */
friends: (userId: number | string) => `friends:${userId}`,
/** 群列表整桶(不含 members剥离到独立 key保证整桶写不带成员爆量 */
@ -37,10 +30,6 @@ export const StorageKeys = {
/** 单群成员,按 groupId 分桶——单群可上百-千级,跟懒加载粒度对齐;群解散时物理删 */
groupMembers: (userId: number | string, groupId: number) => `groupMembers:${userId}:${groupId}`,
/** 最近转发会话 key 列表(按 userId 分桶ConversationPickerPanel 左栏顶部头像区使用 */
recentForwardConversationKeys: (userId: number | string) =>
`recentForwardConversationKeys:${userId}`,
/** 侧边栏宽度localStorage三个 Tab 共用一份记忆,对齐微信(拖一次到处一致)。 */
asideWidth: 'im:aside',
/** 会话列表置顶折叠展开态localStorage轻量 UI 偏好。 */
@ -65,7 +54,6 @@ export function removeQuietly(key: string, errorLabel: string): void {
}
/** 转换为 IndexedDB 可存储的数据 */
// TODO @AI后续是不是可以删除掉尽量使用 db.ts 对不对哈?
function toStorageValue<T>(value: T, seen = new WeakMap<object, unknown>()): T {
const raw = value && typeof value === 'object' ? toRaw(value) : value
if (!raw || typeof raw !== 'object') {