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 { export interface ImChannelMessageRespVO {
id: number id: number
clientMessageId?: string
channelId: number channelId: number
materialId: number materialId: number
type: number type: number

View File

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

View File

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

View File

@ -39,7 +39,6 @@ import { useImWebSocketStore } from './store/websocketStore'
import { useFriendStore } from './store/friendStore' import { useFriendStore } from './store/friendStore'
import { useGroupStore } from './store/groupStore' import { useGroupStore } from './store/groupStore'
import { useGroupRequestStore } from './store/groupRequestStore' import { useGroupRequestStore } from './store/groupRequestStore'
import { useDraftStore } from './store/draftStore'
import { useFaceStore } from './store/faceStore' import { useFaceStore } from './store/faceStore'
import { useChannelStore } from './store/channelStore' import { useChannelStore } from './store/channelStore'
import { useMessagePuller } from './composables/useMessagePuller' import { useMessagePuller } from './composables/useMessagePuller'
@ -65,7 +64,6 @@ const webSocketStore = useImWebSocketStore()
const friendStore = useFriendStore() const friendStore = useFriendStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore() const groupRequestStore = useGroupRequestStore()
const draftStore = useDraftStore()
const faceStore = useFaceStore() const faceStore = useFaceStore()
const channelStore = useChannelStore() const channelStore = useChannelStore()
const { pullOnce, cancelPull } = useMessagePuller() const { pullOnce, cancelPull } = useMessagePuller()
@ -81,18 +79,17 @@ onMounted(async () => {
.fetchUnhandledList() .fetchUnhandledList()
.catch((e) => console.warn('[IM] 拉取未处理加群申请失败', e)) .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 conversationStore.loading = true
try { try {
// TODO @AI // 1.2 IM DB
await initDb() await initDb()
// 1.2 store IDB loadConversations / loadDrafts voidload{Friends,Groups,Channels} // 1.3 store IDB loadConversations / loadMessageCursors voidload{Friends,Groups,Channels}
const [, , hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([ const [, , hasCachedFriends, hasCachedGroups, hasCachedChannels] = await Promise.all([
conversationStore.loadConversations(), conversationStore.loadConversations(),
messageStore.loadCursors(), messageStore.loadMessageCursors(),
friendStore.loadFriends(), friendStore.loadFriends(),
groupStore.loadGroups(), groupStore.loadGroups(),
draftStore.loadDrafts(),
channelStore.loadChannels() channelStore.loadChannels()
]) ])
@ -130,7 +127,7 @@ onMounted(async () => {
conversationStore.setActiveConversation(firstVisible) conversationStore.setActiveConversation(firstVisible)
} }
} catch (e) { } catch (e) {
// 1. loadingpullOnce finally saveConversations return // 1. loadingpullOnce finally return
// 2. WebSocket disconnect onUnmounted // 2. WebSocket disconnect onUnmounted
conversationStore.loading = false conversationStore.loading = false
console.error('[IM] 初始化失败', e) console.error('[IM] 初始化失败', e)
@ -155,7 +152,7 @@ function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | un
/** 标签关闭前 flush 草稿队列debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */ /** 标签关闭前 flush 草稿队列debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */
function onBeforeUnload() { function onBeforeUnload() {
draftStore.flushPersist() conversationStore.flushDraftSave()
} }
window.addEventListener('beforeunload', onBeforeUnload) window.addEventListener('beforeunload', onBeforeUnload)
@ -163,12 +160,12 @@ window.addEventListener('beforeunload', onBeforeUnload)
onUnmounted(() => { onUnmounted(() => {
cancelPull() cancelPull()
webSocketStore.disconnect() webSocketStore.disconnect()
draftStore.flushPersist() conversationStore.flushDraftSave()
faceStore.reset() faceStore.reset()
// audio // audio
voicePlayer.stop() voicePlayer.stop()
window.removeEventListener('beforeunload', onBeforeUnload) window.removeEventListener('beforeunload', onBeforeUnload)
// TODO @AI // IM session store
void stopRequests() 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"> <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> <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> </div>
<!-- 进群申请子项仅当开启审批 + 当前用户是 owner / admin 时出现点击进列表 dialog --> <!-- 进群申请子项仅当开启审批 + 当前用户是 owner / admin 时出现点击进列表 dialog -->
<div <div
@ -567,8 +567,7 @@ async function saveNotice() {
} }
/** 群主:切换「进群审批」开关;开启后所有「申请」「邀请」路径都需群主 / 管理员同意 */ /** 群主:切换「进群审批」开关;开启后所有「申请」「邀请」路径都需群主 / 管理员同意 */
// TODO @AI handleXXX async function handleJoinApprovalChange(value: boolean | string | number) {
async function onJoinApprovalChange(value: boolean | string | number) {
if (!props.group) { if (!props.group) {
return return
} }

View File

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

View File

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

View File

@ -139,37 +139,36 @@ function handleTopClick() {
} }
/** 点击置顶消息行 → 触发跳转 + 收起弹出层 */ /** 点击置顶消息行 → 触发跳转 + 收起弹出层 */
function handleLocate(msg: Message) { function handleLocate(message: Message) {
if (!msg.id) { if (!message.id) {
return return
} }
emit('locate', msg.id) emit('locate', message.id)
expanded.value = false expanded.value = false
} }
// TODO @AI message msg
/** 置顶消息发送人显示名 */ /** 置顶消息发送人显示名 */
function getSenderName(msg: Message): string { function getSenderName(message: Message): string {
return group.value 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 return group.value
? resolveConversationLastContent(msg, ImConversationType.GROUP, group.value.id) ? resolveConversationLastContent(message, ImConversationType.GROUP, group.value.id)
: '' : ''
} }
/** 移除置顶:调后端 APIloading 期间禁止重复点;后端广播 GROUP_MESSAGE_UNPIN 由 dispatcher 自动同步本地 */ /** 移除置顶:调后端 APIloading 期间禁止重复点;后端广播 GROUP_MESSAGE_UNPIN 由 dispatcher 自动同步本地 */
async function handleRemove(msg: Message) { async function handleRemove(pinnedMessage: Message) {
if (!group.value || !msg.id || removingId.value !== null) { if (!group.value || !pinnedMessage.id || removingId.value !== null) {
return return
} }
removingId.value = msg.id removingId.value = pinnedMessage.id
try { try {
await apiUnpinGroupMessage({ id: group.value.id, messageId: msg.id }) await apiUnpinGroupMessage({ id: group.value.id, messageId: pinnedMessage.id })
message.success('已取消置顶') message.success('已取消置顶')
} finally { } finally {
removingId.value = null removingId.value = null

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +1,55 @@
import { acceptHMRUpdate, defineStore } from 'pinia' import { acceptHMRUpdate, defineStore } from 'pinia'
import { debounce } from 'lodash-es'
import { store } from '@/store' import { store } from '@/store'
import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config' import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config'
import { ImConversationType } from '../../utils/constants' 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 { getCurrentUserId } from '../../utils/storage'
import { useDraftStore } from './draftStore'
import { useMessageStore } from './messageStore' import { useMessageStore } from './messageStore'
import type { Conversation, ConversationDO } from '../types' import type { Conversation, ConversationDO } from '../types'
const PERSIST_DRAFT_DEBOUNCE_MS = 500
const pendingDraftConversations = new Set<Conversation>()
/** 会话转 IndexedDB 记录 */ /** 会话转 IndexedDB 记录 */
function toConversationDO(conversation: Conversation): ConversationDO { function toConversationDO(conversation: Conversation): ConversationDO {
const draft = conversation.draft
return { 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) clientConversationId: getClientConversationId(conversation.type, conversation.targetId)
} }
} }
@ -64,8 +101,8 @@ export const useConversationStore = defineStore('imConversationStore', {
actions: { actions: {
/** 加载会话 */ /** 加载会话 */
// TODO @AI方法里的代码段注释写一下。
async loadConversations() { async loadConversations() {
// 1. 清理旧账号内存
const userId = getCurrentUserId() const userId = getCurrentUserId()
if (!userId) { if (!userId) {
this.clear() this.clear()
@ -75,6 +112,7 @@ export const useConversationStore = defineStore('imConversationStore', {
? getClientConversationId(this.activeConversation.type, this.activeConversation.targetId) ? getClientConversationId(this.activeConversation.type, this.activeConversation.targetId)
: null : null
this.clear() this.clear()
// 2. 从 IndexedDB 读取会话和轻量设置
const db = getDb() const db = getDb()
const [conversations, recent] = await Promise.all([ const [conversations, recent] = await Promise.all([
db.getAll<ConversationDO>('conversations'), db.getAll<ConversationDO>('conversations'),
@ -84,6 +122,7 @@ export const useConversationStore = defineStore('imConversationStore', {
if (Array.isArray(recent)) { if (Array.isArray(recent)) {
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX) this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
} }
// 3. 恢复当前激活会话
if (previousActiveKey) { if (previousActiveKey) {
this.activeConversation = this.activeConversation =
this.conversations.find( this.conversations.find(
@ -97,47 +136,59 @@ export const useConversationStore = defineStore('imConversationStore', {
/** 清空会话内存 */ /** 清空会话内存 */
clear() { clear() {
saveDraftConversationsDebounced.cancel()
pendingDraftConversations.clear()
this.conversations = [] this.conversations = []
this.activeConversation = null this.activeConversation = null
this.recentForwardConversationKeys = [] this.recentForwardConversationKeys = []
}, },
/** 执行会话持久化 */ /** 执行会话记录持久化 */
// TODO @AI想了下DbTx 改成 DbTransaction 吧,变量可以叫 tx async persistConversationRecords(
// TODO @AI方法里的代码段注释写一下。 target: Conversation | Conversation[] | null | undefined,
async persistConversations( tx?: DbTransaction
target?: Conversation | Conversation[] | null,
tx?: DbTx
): Promise<void> { ): Promise<void> {
const db = getDb() const db = getDb()
const conversations = ( const conversations = (Array.isArray(target) ? target : target ? [target] : []).map(
Array.isArray(target) ? target : target ? [target] : this.conversations toConversationDO
).map(toConversationDO) )
if (conversations.length === 0) {
return
}
if (tx) { if (tx) {
for (const conversation of conversations) { for (const conversation of conversations) {
await db.put('conversations', conversation, tx) await db.put('conversations', conversation, tx)
} }
return return
} }
await db.transaction(['conversations'], 'readwrite', async (innerTx) => { await db.transaction(['conversations'], 'readwrite', async (tx) => {
for (const conversation of conversations) { 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) { if (this.loading && !tx) {
return return
} }
void this.persistConversations(target, tx).catch((e) => void this.persistConversationRecords(conversations || this.conversations, tx).catch((e) =>
console.warn('[IM conversationStore] 会话写入失败', e) console.warn('[IM conversationStore] 会话写入失败', e)
) )
}, },
/** 确保会话存在 */ /** 确保会话存在 */
// TODO @AI方法里的代码段注释写一下。
ensureConversation(info: { ensureConversation(info: {
type: number type: number
targetId: number targetId: number
@ -145,6 +196,7 @@ export const useConversationStore = defineStore('imConversationStore', {
avatar: string avatar: string
silent?: boolean silent?: boolean
}): Conversation { }): Conversation {
// 1. 创建不存在的会话
let conversation = this.getConversation(info.type, info.targetId) let conversation = this.getConversation(info.type, info.targetId)
if (!conversation) { if (!conversation) {
conversation = this.createEmptyConversation( conversation = this.createEmptyConversation(
@ -156,6 +208,7 @@ export const useConversationStore = defineStore('imConversationStore', {
) )
this.conversations.unshift(conversation) this.conversations.unshift(conversation)
} else if (conversation.deleted) { } else if (conversation.deleted) {
// 2. 恢复软删除会话
conversation.deleted = false conversation.deleted = false
conversation.name = info.name || conversation.name conversation.name = info.name || conversation.name
conversation.avatar = info.avatar || conversation.avatar conversation.avatar = info.avatar || conversation.avatar
@ -163,6 +216,7 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation.silent = info.silent conversation.silent = info.silent
} }
} else { } else {
// 3. 同步会话展示元数据
if (info.name) { if (info.name) {
conversation.name = info.name conversation.name = info.name
} }
@ -177,7 +231,6 @@ export const useConversationStore = defineStore('imConversationStore', {
}, },
/** 打开或创建会话 */ /** 打开或创建会话 */
// TODO @AI方法里的代码段注释写一下。
openConversation( openConversation(
targetId: number, targetId: number,
type: number, type: number,
@ -185,6 +238,7 @@ export const useConversationStore = defineStore('imConversationStore', {
avatar: string, avatar: string,
options?: { silent?: boolean } options?: { silent?: boolean }
): Conversation { ): Conversation {
// 1. 确保会话在列表中
const conversation = this.ensureConversation({ const conversation = this.ensureConversation({
type, type,
targetId, targetId,
@ -192,23 +246,25 @@ export const useConversationStore = defineStore('imConversationStore', {
avatar, avatar,
silent: options?.silent silent: options?.silent
}) })
// 2. 激活会话并保存
this.setActiveConversation(conversation) this.setActiveConversation(conversation)
this.saveConversations(conversation) this.saveConversation(conversation)
return conversation return conversation
}, },
/** 设置当前会话 */ /** 设置当前会话 */
// TODO @AI方法里的代码段注释写一下。
setActiveConversation(conversation: Conversation | null) { setActiveConversation(conversation: Conversation | null) {
this.activeConversation = conversation this.activeConversation = conversation
if (!conversation) { if (!conversation) {
return return
} }
// 1. 清理会话级未读状态
conversation.unreadCount = 0 conversation.unreadCount = 0
conversation.atMe = false conversation.atMe = false
conversation.atAll = false conversation.atAll = false
void useMessageStore().ensureLoaded(conversation) // 2. 懒加载消息并保存会话摘要
this.saveConversations(conversation) void useMessageStore().ensureConversationMessagesLoaded(conversation)
this.saveConversation(conversation)
}, },
/** 创建空会话 */ /** 创建空会话 */
@ -242,7 +298,7 @@ export const useConversationStore = defineStore('imConversationStore', {
return return
} }
conversation.top = top conversation.top = top
this.saveConversations(conversation) this.saveConversation(conversation)
}, },
/** 设置免打扰 */ /** 设置免打扰 */
@ -252,13 +308,12 @@ export const useConversationStore = defineStore('imConversationStore', {
return return
} }
conversation.silent = silent conversation.silent = silent
// TODO @AIsaveConversations 拆成 saveConversationList、saveConversation 两个方法; this.saveConversation(conversation)
this.saveConversations(conversation)
}, },
/** 删除会话 */ /** 删除会话 */
// TODO @AI方法里的代码段注释写一下。
removeConversation(type: number, targetId: number) { removeConversation(type: number, targetId: number) {
// 1. 标记会话删除
const conversation = this.getConversation(type, targetId) const conversation = this.getConversation(type, targetId)
if (!conversation) { if (!conversation) {
return return
@ -267,9 +322,10 @@ export const useConversationStore = defineStore('imConversationStore', {
this.activeConversation = null this.activeConversation = null
} }
conversation.deleted = true conversation.deleted = true
// 2. 删除会话关联的消息和草稿
useMessageStore().deleteConversationMessages(type, targetId) useMessageStore().deleteConversationMessages(type, targetId)
useDraftStore().clearDraft({ type, targetId }) this.clearDraft(conversation)
this.saveConversations(conversation) this.saveConversation(conversation)
}, },
/** 删除私聊会话 */ /** 删除私聊会话 */
@ -294,10 +350,10 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation.unreadCount = 0 conversation.unreadCount = 0
conversation.atMe = false conversation.atMe = false
conversation.atAll = false conversation.atAll = false
this.saveConversations(conversation) this.saveConversation(conversation)
}, },
// TODO @AI把最近转发 ==== 拆分下??? // ==================== 最近转发 ====================
/** 推送最近转发会话 */ /** 推送最近转发会话 */
pushRecentForwardConversationKeys(keys: string[]) { pushRecentForwardConversationKeys(keys: string[]) {
@ -309,7 +365,7 @@ export const useConversationStore = defineStore('imConversationStore', {
0, 0,
CONVERSATION_RECENT_FORWARD_MAX CONVERSATION_RECENT_FORWARD_MAX
) )
this.persistRecentForwardConversationKeys() this.saveRecentForwardConversationKeys()
}, },
/** 移除最近转发会话 */ /** 移除最近转发会话 */
@ -319,11 +375,11 @@ export const useConversationStore = defineStore('imConversationStore', {
return return
} }
this.recentForwardConversationKeys.splice(index, 1) this.recentForwardConversationKeys.splice(index, 1)
this.persistRecentForwardConversationKeys() this.saveRecentForwardConversationKeys()
}, },
/** 持久化最近转发会话 */ /** 保存最近转发会话 */
persistRecentForwardConversationKeys() { saveRecentForwardConversationKeys() {
void getDb() void getDb()
.setSetting( .setSetting(
'recentForwardConversationKeys', 'recentForwardConversationKeys',
@ -332,10 +388,12 @@ export const useConversationStore = defineStore('imConversationStore', {
.catch((e) => console.warn('[IM conversationStore] 最近转发列表写入失败', e)) .catch((e) => console.warn('[IM conversationStore] 最近转发列表写入失败', e))
}, },
// ==================== 会话维护 ====================
/** 重排会话 */ /** 重排会话 */
sortConversations() { sortConversationList() {
this.conversations.sort((a, b) => (b.lastSendTime || 0) - (a.lastSendTime || 0)) 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 changed = true
} }
if (changed) { 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) 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) { if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useConversationStore, 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, getServerMessageKey,
parseClientConversationId, parseClientConversationId,
setMessageMaxId, setMessageMaxId,
type DbTx type DbTransaction
} from '../../utils/db' } from '../../utils/db'
import { import {
generateClientMessageId, generateClientMessageId,
@ -30,7 +30,8 @@ import { useGroupStore } from './groupStore'
import { useConversationStore } from './conversationStore' import { useConversationStore } from './conversationStore'
import type { Conversation, Message, MessageDO } from '../types' 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>>() const ackMergingPromises = new Map<string, Promise<void>>()
interface MessageConversationInfo { interface MessageConversationInfo {
@ -41,8 +42,8 @@ interface MessageConversationInfo {
silent?: boolean silent?: boolean
} }
// TODO @AI叫这个 type 有点奇怪,可能需要再考虑下。 /** 拉取消息批量处理项 */
export type PulledMessageBatchItem = export type PulledMessage =
| { | {
kind: 'insert' kind: 'insert'
conversationInfo: MessageConversationInfo conversationInfo: MessageConversationInfo
@ -82,16 +83,22 @@ function ensureClientMessageId(message: Message): Message {
} }
/** 转换为 IndexedDB 消息记录 */ /** 转换为 IndexedDB 消息记录 */
// TODO @AIbuildXXX 更合理。 function buildMessageDO(message: Message, conversationType: number): MessageDO {
function toMessageDO(message: Message, conversationType: number): MessageDO {
const {
uploadProgress: _uploadProgress,
_localFile: _localFile,
_ackMerging: _ackMerging,
...rest
} = message
return { 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), messageKey: getMessageKey(message, conversationType),
conversationType, conversationType,
clientConversationId: getClientConversationId(conversationType, message.targetId) clientConversationId: getClientConversationId(conversationType, message.targetId)
@ -99,8 +106,7 @@ function toMessageDO(message: Message, conversationType: number): MessageDO {
} }
/** IndexedDB 消息记录转前端消息 */ /** IndexedDB 消息记录转前端消息 */
// TODO @AIbuildXXX 更合理。 function buildMessageFromDO(message: MessageDO): Message {
function fromMessageDO(message: MessageDO): Message {
const { const {
messageKey: _messageKey, messageKey: _messageKey,
conversationType: _conversationType, conversationType: _conversationType,
@ -111,15 +117,16 @@ function fromMessageDO(message: MessageDO): Message {
} }
/** 算出末条消息的发送人快照 */ /** 算出末条消息的发送人快照 */
// TODO @AI里面的代码注释最好写下
function deriveLastSenderDisplayName( function deriveLastSenderDisplayName(
conversation: Conversation, conversation: Conversation,
senderId: number senderId: number
): string | undefined { ): string | undefined {
// 1. 优先使用当前内存中的好友 / 群成员信息
const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId) const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
if (liveSenderName) { if (liveSenderName) {
return liveSenderName return liveSenderName
} }
// 2. 群成员缓存缺失时异步补齐
if (conversation.type === ImConversationType.GROUP) { if (conversation.type === ImConversationType.GROUP) {
const groupStore = useGroupStore() const groupStore = useGroupStore()
const group = groupStore.getGroup(conversation.targetId) const group = groupStore.getGroup(conversation.targetId)
@ -256,9 +263,8 @@ export const useMessageStore = defineStore('imMessageStore', {
}, },
/** 从 settings 加载消息游标 */ /** 从 settings 加载消息游标 */
async loadCursors() { async loadMessageCursors() {
const db = getDb() const db = getDb()
// TODO @AI可以通过 message 表去算么?不通过这个。
const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([ const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([
db.getSetting<number>('privateMessageMaxId'), db.getSetting<number>('privateMessageMaxId'),
db.getSetting<number>('groupMessageMaxId'), 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) { if (!messageId) {
return return
} }
@ -290,14 +296,14 @@ export const useMessageStore = defineStore('imMessageStore', {
}, },
/** 标记会话近期使用 */ /** 标记会话近期使用 */
touchConversation(clientConversationId: string) { touchConversationMessageCache(clientConversationId: string) {
this.loadedConversationKeys = [ this.loadedConversationKeys = [
clientConversationId, clientConversationId,
...this.loadedConversationKeys.filter((key) => key !== clientConversationId) ...this.loadedConversationKeys.filter((key) => key !== clientConversationId)
] ]
// 保留当前活跃会话 + 最近打开过的 5 个会话 // 保留当前活跃会话 + 最近打开过的会话
const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_CONVERSATION_LIMIT + 1) const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT)
const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_CONVERSATION_LIMIT + 1) const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT)
this.loadedConversationKeys = retained this.loadedConversationKeys = retained
removed.forEach((key) => { removed.forEach((key) => {
delete this.messagesByConversation[key] delete this.messagesByConversation[key]
@ -305,61 +311,59 @@ export const useMessageStore = defineStore('imMessageStore', {
}, },
/** 加载当前会话最近消息 */ /** 加载当前会话最近消息 */
async loadMore( async loadMoreMessages(
clientConversationId: string, clientConversationId: string,
beforeSendTime?: number, beforeSendTime?: number,
limit = 50 limit = 50
): Promise<Message[]> { ): Promise<Message[]> {
// TODO @AI代码段的注释 // 1. 从 IndexedDB 倒序读取一页,返回前已按时间升序排列
const list = await getDb().getMessagesByConversation(clientConversationId, { const list = await getDb().getMessageListByConversation(clientConversationId, {
beforeSendTime, beforeSendTime,
limit limit
}) })
// 2. 合并到内存缓存,过滤已存在的消息
const parsed = parseClientConversationId(clientConversationId) const parsed = parseClientConversationId(clientConversationId)
if (!parsed) { if (!parsed) {
return [] return []
} }
const messages = list.map(fromMessageDO) const messages = list.map(buildMessageFromDO)
const existing = this.messagesByConversation[clientConversationId] || [] const existing = this.messagesByConversation[clientConversationId] || []
const existingKeys = new Set(existing.map((message) => getMessageKey(message, parsed.type))) const existingKeys = new Set(existing.map((message) => getMessageKey(message, parsed.type)))
const fresh = messages.filter( const fresh = messages.filter(
(message) => !existingKeys.has(getMessageKey(message, parsed.type)) (message) => !existingKeys.has(getMessageKey(message, parsed.type))
) )
// TODO @AImessageA、messageB
this.messagesByConversation[clientConversationId] = [...fresh, ...existing].sort( 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 return fresh
}, },
/** 确保会话消息已加载 */ /** 确保会话消息已加载 */
async ensureLoaded(conversation: Conversation) { async ensureConversationMessagesLoaded(conversation: Conversation) {
// TODO @AI代码段的注释
const key = getMessageCacheKey(conversation.type, conversation.targetId) const key = getMessageCacheKey(conversation.type, conversation.targetId)
if (this.messagesByConversation[key]) { if (this.messagesByConversation[key]) {
this.touchConversation(key) this.touchConversationMessageCache(key)
return return
} }
await this.loadMore(key) await this.loadMoreMessages(key)
}, },
/** 获取内存消息数组 */ /** 获取内存消息数组 */
getMessageList(conversationType: number, targetId: number): Message[] { getMessageList(conversationType: number, targetId: number): Message[] {
// TODO @AI代码段的注释
const key = getMessageCacheKey(conversationType, targetId) const key = getMessageCacheKey(conversationType, targetId)
if (!this.messagesByConversation[key]) { if (!this.messagesByConversation[key]) {
this.messagesByConversation[key] = [] this.messagesByConversation[key] = []
} }
this.touchConversation(key) this.touchConversationMessageCache(key)
return this.messagesByConversation[key] return this.messagesByConversation[key]
}, },
/** 持久化单条消息 */ /** 持久化消息记录 */
async persistMessage(message: Message, conversationType: number, tx?: DbTx) { async persistMessageRecord(message: Message, conversationType: number, tx?: DbTransaction) {
// TODO @AI代码段的注释
const db = getDb() const db = getDb()
const next = toMessageDO(message, conversationType) const next = buildMessageDO(message, conversationType)
// ack 后服务端 key 替换 client key
if (message.id && message.clientMessageId) { if (message.id && message.clientMessageId) {
const existing = await db.getByIndex<MessageDO>( const existing = await db.getByIndex<MessageDO>(
'messages', 'messages',
@ -374,15 +378,19 @@ export const useMessageStore = defineStore('imMessageStore', {
await db.put('messages', next, tx) await db.put('messages', next, tx)
}, },
/** 持久化消息游标 */ /** 保存消息游标 */
async persistMaxId(conversationType: number, messageId?: number, tx?: DbTx) { async saveMessageMaxId(conversationType: number, messageId?: number, tx?: DbTransaction) {
this.updateMaxId(conversationType, messageId) this.updateMessageMaxId(conversationType, messageId)
await setMessageMaxId(conversationType, messageId, tx) await setMessageMaxId(conversationType, messageId, tx)
}, },
/** 应用撤回到内存 */ /** 应用撤回到内存 */
applyRecallInMemory(conversationType: number, targetId: number, recallSignalContent: string) { applyRecallMessageInMemory(
// TODO @AI代码段的注释 conversationType: number,
targetId: number,
recallSignalContent: string
) {
// 1. 定位被撤回的原消息
const messageId = parseRecallMessageId(recallSignalContent) const messageId = parseRecallMessageId(recallSignalContent)
if (!messageId) { if (!messageId) {
return null return null
@ -397,6 +405,7 @@ export const useMessageStore = defineStore('imMessageStore', {
if (!message) { if (!message) {
return null return null
} }
// 2. 更新消息和会话摘要
message.type = ImMessageType.RECALL message.type = ImMessageType.RECALL
message.status = ImMessageStatus.RECALL message.status = ImMessageStatus.RECALL
message.content = '' message.content = ''
@ -407,14 +416,14 @@ export const useMessageStore = defineStore('imMessageStore', {
}, },
/** 批量写入拉取消息 */ /** 批量写入拉取消息 */
async insertPulledBatch( async applyPulledMessageList(
items: PulledMessageBatchItem[], pulledMessages: PulledMessage[],
conversationType: number, conversationType: number,
maxMessageId?: number maxMessageId?: number
) { ) {
// TODO @AI代码段的注释 if (pulledMessages.length === 0) {
if (items.length === 0) { // 1. 空批次只推进游标
await this.persistMaxId(conversationType, maxMessageId) await this.saveMessageMaxId(conversationType, maxMessageId)
return return
} }
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
@ -433,13 +442,14 @@ export const useMessageStore = defineStore('imMessageStore', {
}) })
} }
// TODO @AI是不是最好 mesages // 1. 先更新内存,收集需要持久化的消息和会话
for (const item of items) { for (const pulledMessage of pulledMessages) {
if (item.kind === 'recall') { if (pulledMessage.kind === 'recall') {
const changed = this.applyRecallInMemory( // 1.1 撤回信号更新原消息
item.conversationType, const changed = this.applyRecallMessageInMemory(
item.targetId, pulledMessage.conversationType,
item.recallSignalContent pulledMessage.targetId,
pulledMessage.recallSignalContent
) )
if (changed) { if (changed) {
addChanged(changed.conversation, changed.message) addChanged(changed.conversation, changed.message)
@ -447,8 +457,9 @@ export const useMessageStore = defineStore('imMessageStore', {
continue continue
} }
const { conversationInfo } = item const { conversationInfo } = pulledMessage
const message = ensureClientMessageId(item.message) const message = ensureClientMessageId(pulledMessage.message)
// 1.2 群通知先同步群资料
if ( if (
conversationInfo.type === ImConversationType.GROUP && conversationInfo.type === ImConversationType.GROUP &&
isGroupNotification(message.type) isGroupNotification(message.type)
@ -460,21 +471,23 @@ export const useMessageStore = defineStore('imMessageStore', {
) )
} }
// 1.3 确保会话和消息缓存存在
const conversation = conversationStore.ensureConversation(conversationInfo) const conversation = conversationStore.ensureConversation(conversationInfo)
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId) const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message)) const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
if (existingIndex >= 0) { if (existingIndex >= 0) {
// 1.4 已存在消息合并服务端状态
applyServerMessageUpdate(messages[existingIndex], message) applyServerMessageUpdate(messages[existingIndex], message)
if (existingIndex === messages.length - 1) { if (existingIndex === messages.length - 1) {
recomputeConversationLast(conversation, messages) recomputeConversationLast(conversation, messages)
syncConversationAtFlags(conversation, message) syncConversationAtFlags(conversation, message)
} }
this.updateMaxId(conversationInfo.type, message.id) this.updateMessageMaxId(conversationInfo.type, message.id)
addChanged(conversation, messages[existingIndex]) addChanged(conversation, messages[existingIndex])
continue continue
} }
// TODO @AIapplyConversationSummary 要 await 么?不然会有报错; // 1.5 新消息更新会话摘要和未读状态
applyConversationSummary(conversation, message) applyConversationSummary(conversation, message)
syncConversationAtFlags(conversation, message) syncConversationAtFlags(conversation, message)
const isActive = const isActive =
@ -490,6 +503,7 @@ export const useMessageStore = defineStore('imMessageStore', {
conversation.unreadCount++ conversation.unreadCount++
} }
// 1.6 新消息按服务端 id 插入内存列表
let insertIndex = messages.length let insertIndex = messages.length
if (message.id) { if (message.id) {
for (let index = 0; index < messages.length; index++) { for (let index = 0; index < messages.length; index++) {
@ -501,17 +515,22 @@ export const useMessageStore = defineStore('imMessageStore', {
} }
} }
messages.splice(insertIndex, 0, message) messages.splice(insertIndex, 0, message)
this.updateMaxId(conversationInfo.type, message.id) this.updateMessageMaxId(conversationInfo.type, message.id)
addChanged(conversation, message) addChanged(conversation, message)
} }
this.updateMaxId(conversationType, maxMessageId) // 2. 更新内存游标
this.updateMessageMaxId(conversationType, maxMessageId)
// 3. 单事务写入消息、会话摘要和游标
await getDb() await getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => { .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
// 3.1 写入本批变更消息
for (const item of persistedMessages.values()) { 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) await setMessageMaxId(conversationType, maxMessageId, tx)
}) })
.catch((e) => console.error('[IM messageStore] 批量消息写入失败', e)) .catch((e) => console.error('[IM messageStore] 批量消息写入失败', e))
@ -521,11 +540,11 @@ export const useMessageStore = defineStore('imMessageStore', {
insertMessage( insertMessage(
conversationInfo: MessageConversationInfo, conversationInfo: MessageConversationInfo,
messageInfo: Message, messageInfo: Message,
options?: { persistMaxId?: boolean } options?: { saveMaxId?: boolean }
) { ) {
// TODO @AI代码段的注释类似上面的问题
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const message = ensureClientMessageId(messageInfo) const message = ensureClientMessageId(messageInfo)
// 1. 先处理消息带来的群资料变更
if (conversationInfo.type === ImConversationType.GROUP && isGroupNotification(message.type)) { if (conversationInfo.type === ImConversationType.GROUP && isGroupNotification(message.type)) {
useGroupStore().applyGroupNotification( useGroupStore().applyGroupNotification(
conversationInfo.targetId, conversationInfo.targetId,
@ -534,21 +553,23 @@ export const useMessageStore = defineStore('imMessageStore', {
) )
} }
// 2. 确保会话和消息缓存存在
const conversation = conversationStore.ensureConversation(conversationInfo) const conversation = conversationStore.ensureConversation(conversationInfo)
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId) const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
const existingIndex = messages.findIndex((item) => isSameMessage(item, message)) const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
// 3. 已存在消息走覆盖更新
if (existingIndex >= 0) { if (existingIndex >= 0) {
applyServerMessageUpdate(messages[existingIndex], message) applyServerMessageUpdate(messages[existingIndex], message)
if (existingIndex === messages.length - 1) { if (existingIndex === messages.length - 1) {
recomputeConversationLast(conversation, messages) recomputeConversationLast(conversation, messages)
syncConversationAtFlags(conversation, message) syncConversationAtFlags(conversation, message)
} }
this.updateMaxId(conversationInfo.type, message.id) this.updateMessageMaxId(conversationInfo.type, message.id)
void getDb() void getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => { .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.persistMessage(messages[existingIndex], conversationInfo.type, tx) await this.persistMessageRecord(messages[existingIndex], conversationInfo.type, tx)
await conversationStore.persistConversations(conversation, tx) await conversationStore.persistConversationRecords(conversation, tx)
if (options?.persistMaxId !== false) { if (options?.saveMaxId !== false) {
await setMessageMaxId(conversationInfo.type, message.id, tx) await setMessageMaxId(conversationInfo.type, message.id, tx)
} }
}) })
@ -556,6 +577,7 @@ export const useMessageStore = defineStore('imMessageStore', {
return return
} }
// 4. 新消息更新会话摘要和未读状态
applyConversationSummary(conversation, message) applyConversationSummary(conversation, message)
syncConversationAtFlags(conversation, message) syncConversationAtFlags(conversation, message)
@ -572,6 +594,7 @@ export const useMessageStore = defineStore('imMessageStore', {
conversation.unreadCount++ conversation.unreadCount++
} }
// 5. 新消息按 id 插入到内存数组
let insertIndex = messages.length let insertIndex = messages.length
if (message.id) { if (message.id) {
for (let index = 0; index < messages.length; index++) { for (let index = 0; index < messages.length; index++) {
@ -583,12 +606,13 @@ export const useMessageStore = defineStore('imMessageStore', {
} }
} }
messages.splice(insertIndex, 0, message) messages.splice(insertIndex, 0, message)
this.updateMaxId(conversationInfo.type, message.id) this.updateMessageMaxId(conversationInfo.type, message.id)
// 6. 单事务写入消息、会话摘要和游标
void getDb() void getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => { .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.persistMessage(message, conversationInfo.type, tx) await this.persistMessageRecord(message, conversationInfo.type, tx)
await conversationStore.persistConversations(conversation, tx) await conversationStore.persistConversationRecords(conversation, tx)
if (options?.persistMaxId !== false) { if (options?.saveMaxId !== false) {
await setMessageMaxId(conversationInfo.type, message.id, tx) await setMessageMaxId(conversationInfo.type, message.id, tx)
} }
}) })
@ -626,6 +650,7 @@ export const useMessageStore = defineStore('imMessageStore', {
clientMessageId: string, clientMessageId: string,
updates: Partial<Message> updates: Partial<Message>
) { ) {
// 1. 定位待合并消息
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const conversation = conversationStore.getConversation(conversationType, targetId) const conversation = conversationStore.getConversation(conversationType, targetId)
if (!conversation) { if (!conversation) {
@ -638,19 +663,22 @@ export const useMessageStore = defineStore('imMessageStore', {
} }
message._ackMerging = true message._ackMerging = true
try { try {
// 2. 合并服务端 ack 到内存
applyServerMessageUpdate(message, updates) applyServerMessageUpdate(message, updates)
if (messages[messages.length - 1] === message) { if (messages[messages.length - 1] === message) {
recomputeConversationLast(conversation, messages) recomputeConversationLast(conversation, messages)
} }
this.updateMaxId(conversationType, message.id) this.updateMessageMaxId(conversationType, message.id)
// 3. 单事务写入消息、会话摘要和游标
await getDb() await getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => { .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.persistMessage(message, conversationType, tx) await this.persistMessageRecord(message, conversationType, tx)
await conversationStore.persistConversations(conversation, tx) await conversationStore.persistConversationRecords(conversation, tx)
await setMessageMaxId(conversationType, message.id, tx) await setMessageMaxId(conversationType, message.id, tx)
}) })
.catch((e) => console.error('[IM messageStore] ack 写入失败', e)) .catch((e) => console.error('[IM messageStore] ack 写入失败', e))
} finally { } finally {
// 4. 清理合并标记
message._ackMerging = false message._ackMerging = false
} }
}, },
@ -662,7 +690,6 @@ export const useMessageStore = defineStore('imMessageStore', {
clientMessageId: string, clientMessageId: string,
patch: Partial<Message> patch: Partial<Message>
) { ) {
// TODO @AI代码段的注释
const message = this.getMessageList(conversationType, targetId).find( const message = this.getMessageList(conversationType, targetId).find(
(item) => item.clientMessageId === clientMessageId (item) => item.clientMessageId === clientMessageId
) )
@ -688,18 +715,22 @@ export const useMessageStore = defineStore('imMessageStore', {
/** 撤回消息 */ /** 撤回消息 */
recallMessage(conversationType: number, targetId: number, recallSignalContent: string) { recallMessage(conversationType: number, targetId: number, recallSignalContent: string) {
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const changed = this.applyRecallInMemory(conversationType, targetId, recallSignalContent) const changed = this.applyRecallMessageInMemory(
conversationType,
targetId,
recallSignalContent
)
if (!changed) { if (!changed) {
return return
} }
this.persistMessage(changed.message, conversationType).catch((e) => this.persistMessageRecord(changed.message, conversationType).catch((e) =>
console.error('[IM messageStore] 撤回消息写入失败', e) console.error('[IM messageStore] 撤回消息写入失败', e)
) )
conversationStore.saveConversations(changed.conversation) conversationStore.saveConversation(changed.conversation)
}, },
/** 应用已读回执 */ /** 应用已读回执 */
applyReadReceipt(options: { applyMessageReadReceipt(options: {
conversationType: number conversationType: number
targetId: number targetId: number
privateReadMaxId?: number privateReadMaxId?: number
@ -707,9 +738,9 @@ export const useMessageStore = defineStore('imMessageStore', {
readCount?: number readCount?: number
receiptStatus?: number receiptStatus?: number
}) { }) {
// TODO @AI代码段的注释
const messages = this.getMessageList(options.conversationType, options.targetId) const messages = this.getMessageList(options.conversationType, options.targetId)
const changed: Message[] = [] const changed: Message[] = []
// 1. 私聊回执批量更新自己发送的消息
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) { if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
messages.forEach((message) => { messages.forEach((message) => {
if ( if (
@ -723,6 +754,7 @@ export const useMessageStore = defineStore('imMessageStore', {
} }
}) })
} else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) { } else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
// 2. 群聊回执更新单条消息
const message = messages.find((item) => item.id === options.groupMessageId) const message = messages.find((item) => item.id === options.groupMessageId)
if (message) { if (message) {
if (options.readCount !== undefined) { if (options.readCount !== undefined) {
@ -734,16 +766,21 @@ export const useMessageStore = defineStore('imMessageStore', {
changed.push(message) changed.push(message)
} }
} }
changed.forEach((message) => { if (changed.length === 0) {
this.persistMessage(message, options.conversationType).catch((e) => return
console.warn('[IM messageStore] 回执写入失败', e) }
) // 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[]) { prependMessageList(conversationType: number, targetId: number, earlierMessages: Message[]) {
// TODO @AI代码段的注释
if (earlierMessages.length === 0) { if (earlierMessages.length === 0) {
return return
} }
@ -758,11 +795,13 @@ export const useMessageStore = defineStore('imMessageStore', {
} }
const key = getMessageCacheKey(conversationType, targetId) const key = getMessageCacheKey(conversationType, targetId)
this.messagesByConversation[key] = [...fresh, ...messages] this.messagesByConversation[key] = [...fresh, ...messages]
fresh.forEach((message) => { void getDb()
this.persistMessage(message, conversationType).catch((e) => .transaction(['messages'], 'readwrite', async (tx) => {
console.warn('[IM messageStore] 历史消息写入失败', e) 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, targetId: number,
key: { id?: number; clientMessageId?: string } key: { id?: number; clientMessageId?: string }
) { ) {
// TODO @AI代码段的注释 // 1. 定位会话和消息
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const conversation = conversationStore.getConversation(conversationType, targetId) const conversation = conversationStore.getConversation(conversationType, targetId)
if (!conversation) { if (!conversation) {
@ -787,25 +826,26 @@ export const useMessageStore = defineStore('imMessageStore', {
if (index < 0) { if (index < 0) {
return return
} }
// 2. 从内存移除消息
const [removed] = messages.splice(index, 1) const [removed] = messages.splice(index, 1)
revokeBlobUrlsInContent(removed.content) revokeBlobUrlsInContent(removed.content)
if (index === messages.length) { if (index === messages.length) {
recomputeConversationLast(conversation, messages) recomputeConversationLast(conversation, messages)
} }
// 3. 删除本地记录并保存会话摘要
getDb() getDb()
.delete('messages', getMessageKey(removed, conversationType)) .delete('messages', getMessageKey(removed, conversationType))
.catch((e) => console.warn('[IM messageStore] 消息删除失败', e)) .catch((e) => console.warn('[IM messageStore] 消息删除失败', e))
conversationStore.saveConversations() conversationStore.saveConversation(conversation)
}, },
/** 当前会话标记已读 */ /** 当前会话标记已读 */
markConversationMessagesRead(conversation: Conversation) { markConversationMessagesRead(conversation: Conversation) {
// TODO @AI代码段的注释
const messages = this.getMessageList(conversation.type, conversation.targetId) const messages = this.getMessageList(conversation.type, conversation.targetId)
messages.forEach((message) => { messages.forEach((message) => {
if (!message.selfSend && message.status === ImMessageStatus.UNREAD) { if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
message.status = ImMessageStatus.READ message.status = ImMessageStatus.READ
this.persistMessage(message, conversation.type).catch((e) => this.persistMessageRecord(message, conversation.type).catch((e) =>
console.warn('[IM messageStore] 已读状态写入失败', e) console.warn('[IM messageStore] 已读状态写入失败', e)
) )
} }
@ -814,7 +854,7 @@ export const useMessageStore = defineStore('imMessageStore', {
/** 删除会话全部消息 */ /** 删除会话全部消息 */
deleteConversationMessages(conversationType: number, targetId: number) { deleteConversationMessages(conversationType: number, targetId: number) {
// TODO @AI代码段的注释 // 1. 清理内存消息和媒体资源
const clientConversationId = getClientConversationId(conversationType, targetId) const clientConversationId = getClientConversationId(conversationType, targetId)
const messages = this.messagesByConversation[clientConversationId] || [] const messages = this.messagesByConversation[clientConversationId] || []
messages.forEach((message) => { messages.forEach((message) => {
@ -825,6 +865,7 @@ export const useMessageStore = defineStore('imMessageStore', {
this.loadedConversationKeys = this.loadedConversationKeys.filter( this.loadedConversationKeys = this.loadedConversationKeys.filter(
(key) => key !== clientConversationId (key) => key !== clientConversationId
) )
// 2. 删除 IndexedDB 消息
getDb() getDb()
.deleteByIndex('messages', 'clientConversationId', clientConversationId) .deleteByIndex('messages', 'clientConversationId', clientConversationId)
.catch((e) => console.warn('[IM messageStore] 会话消息删除失败', e)) .catch((e) => console.warn('[IM messageStore] 会话消息删除失败', e))

View File

@ -312,8 +312,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
) )
if (conversation) { if (conversation) {
conversation.unreadCount = 0 conversation.unreadCount = 0
conversationStore.saveConversation(conversation)
} }
conversationStore.saveConversations()
}, },
/** /**
@ -416,7 +416,6 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
} }
} else if (isGroupRequestNotification(websocketMessage.type)) { } else if (isGroupRequestNotification(websocketMessage.type)) {
// 加群申请通知1503 / 1505 / 1506走私聊通道与好友通知同段位但分开 dispatcher // 加群申请通知1503 / 1505 / 1506走私聊通道与好友通知同段位但分开 dispatcher
// TODO @AI改成走群聊通道。不然消息不好拉到
this.handleGroupRequestNotification(websocketMessage) this.handleGroupRequestNotification(websocketMessage)
} else { } else {
// TEXT / IMAGE / FILE / VOICE / VIDEO 等普通消息 // TEXT / IMAGE / FILE / VOICE / VIDEO 等普通消息
@ -585,7 +584,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!websocketMessage.id) { if (!websocketMessage.id) {
return return
} }
useMessageStore().applyReadReceipt({ useMessageStore().applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE, conversationType: ImConversationType.PRIVATE,
targetId: websocketMessage.senderId, targetId: websocketMessage.senderId,
privateReadMaxId: websocketMessage.id privateReadMaxId: websocketMessage.id
@ -708,7 +707,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!MESSAGE_GROUP_READ_ENABLED) { if (!MESSAGE_GROUP_READ_ENABLED) {
return return
} }
useMessageStore().applyReadReceipt({ useMessageStore().applyMessageReadReceipt({
conversationType: ImConversationType.GROUP, conversationType: ImConversationType.GROUP,
targetId: websocketMessage.groupId, targetId: websocketMessage.groupId,
groupMessageId: websocketMessage.id, 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 { export interface Conversation {
// ========== 核心标识 ========== // ========== 核心标识 ==========
@ -66,12 +74,16 @@ export interface Conversation {
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean // 群聊:是否有人 @我 atMe?: boolean // 群聊:是否有人 @我
atAll?: boolean // 群聊:是否有人 @全体成员 atAll?: boolean // 群聊:是否有人 @全体成员
draft?: {
html: string // 输入框 HTML
plain: string // 输入框纯文本
reply?: QuoteMessage // 引用消息
} // 输入框草稿
} }
// 消息数据结构 // 消息数据结构
export interface Message { export interface Message {
// ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO ========== // ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO ==========
// TODO @AI全局的 id 占位 0是不是枚举下
id?: number // 服务端消息编号,发送中为空 id?: number // 服务端消息编号,发送中为空
clientMessageId: string // 客户端消息编号,本地生成用于合并去重 clientMessageId: string // 客户端消息编号,本地生成用于合并去重
type: number // 消息类型,对齐 ImMessageType type: number // 消息类型,对齐 ImMessageType

View File

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

View File

@ -10,7 +10,9 @@ import { getCurrentUserId } from './storage'
import { formatCallDuration } from './time' import { formatCallDuration } from './time'
import { useFriendStore } from '../home/store/friendStore' import { useFriendStore } from '../home/store/friendStore'
import { useGroupStore } from '../home/store/groupStore' 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 编解码 & 展示工具 // 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 */ /** 引用容器5 种普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO)都可携带 quote */
interface Quotable { interface Quotable {
quote?: QuoteMessage quote?: QuoteMessage

View File

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