refactor(im): 拆分会话消息存储并合并草稿
- 新增 IM IndexedDB DB client,按当前用户初始化本地库 - 将会话与消息拆成 conversations / messages 逐条存储 - 将草稿合并进 Conversation.draft,删除 draftStore - 优化 pull 批量写入,消息、会话摘要和游标同事务落库 - 统一 store action 命名,清理旧 localStorage key 和 TODO - 保留 maxId settings 游标,避免本地消息回收后游标回退im
parent
811b93d9f1
commit
664904bd06
|
|
@ -2,6 +2,7 @@ import request from '@/config/axios'
|
|||
|
||||
export interface ImChannelMessageRespVO {
|
||||
id: number
|
||||
clientMessageId?: string
|
||||
channelId: number
|
||||
materialId: number
|
||||
type: number
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { watch } from 'vue'
|
||||
import { useConversationStore } from '../store/conversationStore'
|
||||
import { useMessageStore, type PulledMessageBatchItem } from '../store/messageStore'
|
||||
import { useMessageStore, type PulledMessage } from '../store/messageStore'
|
||||
import { useImWebSocketStore } from '../store/websocketStore'
|
||||
import { useFriendStore } from '../store/friendStore'
|
||||
import { getFriendDisplayName } from '../../utils/user'
|
||||
|
|
@ -106,8 +106,7 @@ export const useMessagePuller = () => {
|
|||
const convertChannelMessage = (message: ImChannelMessageRespVO): Message => {
|
||||
return {
|
||||
id: message.id,
|
||||
// TODO @AI:是不是都需要使用 message 的 clientMessageId;注意,后端也需要有 clientMessageId;
|
||||
clientMessageId: generateClientMessageId(),
|
||||
clientMessageId: message.clientMessageId || generateClientMessageId(),
|
||||
type: message.type,
|
||||
content: message.content,
|
||||
status: message.status ?? ImMessageStatus.UNREAD,
|
||||
|
|
@ -190,14 +189,13 @@ export const useMessagePuller = () => {
|
|||
break
|
||||
}
|
||||
|
||||
// TODO @AI:感觉这个 PulledMessageBatchItem 类名,batchItems 变量名,insertPulledBatch 方法名,不够能体现出 message;
|
||||
const batchItems: PulledMessageBatchItem[] = []
|
||||
const pulledMessages: PulledMessage[] = []
|
||||
// 逐条 dispatch:原消息走批量 insert;RECALL 信号走批量 recall 把同批内已 insert 的原消息更新为撤回提示。
|
||||
// 后端按 id 升序返回,且信号 id 一定 > 原消息 id(先更新 status 再插信号),所以原消息一定先到、recallMessage 找得到
|
||||
for (const raw of list) {
|
||||
if (isChannel) {
|
||||
const message = raw as ImChannelMessageRespVO
|
||||
batchItems.push({
|
||||
pulledMessages.push({
|
||||
kind: 'insert',
|
||||
conversationInfo: convertChannelConversation(message),
|
||||
message: convertChannelMessage(message)
|
||||
|
|
@ -208,7 +206,7 @@ export const useMessagePuller = () => {
|
|||
const message = raw as ImPrivateMessageRespVO
|
||||
// 特殊:撤回消息的处理
|
||||
if (message.type === ImMessageType.RECALL) {
|
||||
batchItems.push({
|
||||
pulledMessages.push({
|
||||
kind: 'recall',
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: getPrivatePeerId(message),
|
||||
|
|
@ -226,7 +224,7 @@ export const useMessagePuller = () => {
|
|||
}
|
||||
}
|
||||
// 其它消息正常入会话消息列表
|
||||
batchItems.push({
|
||||
pulledMessages.push({
|
||||
kind: 'insert',
|
||||
conversationInfo: convertPrivateConversation(message),
|
||||
message: convertPrivateMessage(message)
|
||||
|
|
@ -235,7 +233,7 @@ export const useMessagePuller = () => {
|
|||
const message = raw as ImGroupMessageRespVO
|
||||
// 特殊:撤回消息的处理
|
||||
if (message.type === ImMessageType.RECALL) {
|
||||
batchItems.push({
|
||||
pulledMessages.push({
|
||||
kind: 'recall',
|
||||
conversationType: ImConversationType.GROUP,
|
||||
targetId: message.groupId,
|
||||
|
|
@ -244,7 +242,7 @@ export const useMessagePuller = () => {
|
|||
continue
|
||||
}
|
||||
// 其它消息正常入会话消息列表
|
||||
batchItems.push({
|
||||
pulledMessages.push({
|
||||
kind: 'insert',
|
||||
conversationInfo: convertGroupConversation(message),
|
||||
message: convertGroupMessage(message)
|
||||
|
|
@ -255,11 +253,11 @@ export const useMessagePuller = () => {
|
|||
// 游标推进到本批最大 id,与后端返回顺序无关;无有效 id 直接 break 避免死翻同一批
|
||||
const validIds = list.map((message) => message.id).filter((id): id is number => id != null)
|
||||
if (validIds.length === 0) {
|
||||
await messageStore.insertPulledBatch(batchItems, conversationType)
|
||||
await messageStore.applyPulledMessageList(pulledMessages, conversationType)
|
||||
break
|
||||
}
|
||||
const nextMinId = Math.max(...validIds)
|
||||
await messageStore.insertPulledBatch(batchItems, conversationType, nextMinId)
|
||||
await messageStore.applyPulledMessageList(pulledMessages, conversationType, nextMinId)
|
||||
// 游标没前进就停:当前后端契约是 id > minId,理论不会出现;防御后端契约变更或边界数据死翻
|
||||
if (nextMinId <= minId) {
|
||||
break
|
||||
|
|
@ -378,7 +376,7 @@ export const useMessagePuller = () => {
|
|||
}
|
||||
|
||||
// pull + replay 都完成后再排序,避免回放消息打乱顺序
|
||||
conversationStore.sortConversations()
|
||||
conversationStore.sortConversationList()
|
||||
|
||||
// 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」
|
||||
// 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 Index.vue 的 watch 触发
|
||||
|
|
@ -394,7 +392,7 @@ export const useMessagePuller = () => {
|
|||
return
|
||||
}
|
||||
if (maxReadId) {
|
||||
messageStore.applyReadReceipt({
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: active.targetId,
|
||||
privateReadMaxId: maxReadId
|
||||
|
|
|
|||
|
|
@ -111,10 +111,9 @@ export const useMessageSender = () => {
|
|||
clientMessageId = options.existingClientMessageId
|
||||
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
|
||||
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
|
||||
// TODO @AI:尽量不要 m 缩写,全称
|
||||
const stillExists = messageStore
|
||||
.getMessageList(conversation.type, realTarget)
|
||||
.some((m) => m.clientMessageId === clientMessageId && !m._ackMerging)
|
||||
.some((message) => message.clientMessageId === clientMessageId && !message._ackMerging)
|
||||
if (!stillExists) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -227,10 +226,13 @@ export const useMessageSender = () => {
|
|||
// 本地标记已读:未读数清零 + 消息状态更新为 READ(UI 立刻响应)
|
||||
conversationStore.markConversationAsRead(conversation.type, conversation.targetId)
|
||||
messageStore.markConversationMessagesRead(conversation)
|
||||
// TODO @AI:message;不要用 m;
|
||||
const maxMessageId = messageStore
|
||||
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
|
||||
.reduce<number>((max, m) => (m.id && m.id > max ? m.id : max), 0)
|
||||
.reduce<number>(
|
||||
(maxMessageId, message) =>
|
||||
message.id && message.id > maxMessageId ? message.id : maxMessageId,
|
||||
0
|
||||
)
|
||||
if (!maxMessageId) {
|
||||
return
|
||||
}
|
||||
|
|
@ -285,8 +287,8 @@ export const useMessageSender = () => {
|
|||
if (!maxReadId) {
|
||||
return
|
||||
}
|
||||
// applyReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ
|
||||
messageStore.applyReadReceipt({
|
||||
// applyMessageReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: peerId,
|
||||
privateReadMaxId: maxReadId
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ import { useImWebSocketStore } from './store/websocketStore'
|
|||
import { useFriendStore } from './store/friendStore'
|
||||
import { useGroupStore } from './store/groupStore'
|
||||
import { useGroupRequestStore } from './store/groupRequestStore'
|
||||
import { useDraftStore } from './store/draftStore'
|
||||
import { useFaceStore } from './store/faceStore'
|
||||
import { useChannelStore } from './store/channelStore'
|
||||
import { useMessagePuller } from './composables/useMessagePuller'
|
||||
|
|
@ -65,7 +64,6 @@ const webSocketStore = useImWebSocketStore()
|
|||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const draftStore = useDraftStore()
|
||||
const faceStore = useFaceStore()
|
||||
const channelStore = useChannelStore()
|
||||
const { pullOnce, cancelPull } = useMessagePuller()
|
||||
|
|
@ -81,18 +79,17 @@ onMounted(async () => {
|
|||
.fetchUnhandledList()
|
||||
.catch((e) => console.warn('[IM] 拉取未处理加群申请失败', e))
|
||||
|
||||
// 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
||||
// 1.1 整段 loading=true 阻断会话列表抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
||||
conversationStore.loading = true
|
||||
try {
|
||||
// TODO @AI:这里要写个注释???!!!
|
||||
// 1.2 打开当前用户 IM DB
|
||||
await initDb()
|
||||
// 1.2 五个 store 并发从 IDB 读取本地缓存(loadConversations / loadDrafts 返回 void;load{Friends,Groups,Channels} 返回是否命中缓存)
|
||||
const [, , hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([
|
||||
// 1.3 五个 store 并发从 IDB 读取本地缓存(loadConversations / loadMessageCursors 返回 void;load{Friends,Groups,Channels} 返回是否命中缓存)
|
||||
const [, , hasCachedFriends, hasCachedGroups, hasCachedChannels] = await Promise.all([
|
||||
conversationStore.loadConversations(),
|
||||
messageStore.loadCursors(),
|
||||
messageStore.loadMessageCursors(),
|
||||
friendStore.loadFriends(),
|
||||
groupStore.loadGroups(),
|
||||
draftStore.loadDrafts(),
|
||||
channelStore.loadChannels()
|
||||
])
|
||||
|
||||
|
|
@ -130,7 +127,7 @@ onMounted(async () => {
|
|||
conversationStore.setActiveConversation(firstVisible)
|
||||
}
|
||||
} catch (e) {
|
||||
// 1. 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续 saveConversations 全被早 return 阻断
|
||||
// 1. 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续会话列表写入全被早 return 阻断
|
||||
// 2. WebSocket 不在这里 disconnect——路由离开会走 onUnmounted 自然清理,用户也可以刷新重试
|
||||
conversationStore.loading = false
|
||||
console.error('[IM] 初始化失败', e)
|
||||
|
|
@ -155,7 +152,7 @@ function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | un
|
|||
|
||||
/** 标签关闭前 flush 草稿队列;debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */
|
||||
function onBeforeUnload() {
|
||||
draftStore.flushPersist()
|
||||
conversationStore.flushDraftSave()
|
||||
}
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
|
||||
|
|
@ -163,12 +160,12 @@ window.addEventListener('beforeunload', onBeforeUnload)
|
|||
onUnmounted(() => {
|
||||
cancelPull()
|
||||
webSocketStore.disconnect()
|
||||
draftStore.flushPersist()
|
||||
conversationStore.flushDraftSave()
|
||||
faceStore.reset()
|
||||
// 模块级单例 audio 不会随视图卸载自动停,主动停掉避免切路由后语音继续响
|
||||
voicePlayer.stop()
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
// TODO @AI:写个注释?!
|
||||
// 停止当前 IM session 并清理各 store 内存
|
||||
void stopRequests()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@
|
|||
<!-- 进群审批:仅群主可操作;开启后普通成员的「申请」「邀请」路径都需群主 / 管理员同意;群主 / 管理员邀请直进 -->
|
||||
<div v-if="isOwner" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
|
||||
<span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">进群需要群主 / 群管理确认</span>
|
||||
<el-switch :model-value="!!group.joinApproval" @change="onJoinApprovalChange" />
|
||||
<el-switch :model-value="!!group.joinApproval" @change="handleJoinApprovalChange" />
|
||||
</div>
|
||||
<!-- 进群申请子项:仅当开启审批 + 当前用户是 owner / admin 时出现;点击进列表 dialog -->
|
||||
<div
|
||||
|
|
@ -567,8 +567,7 @@ async function saveNotice() {
|
|||
}
|
||||
|
||||
/** 群主:切换「进群审批」开关;开启后所有「申请」「邀请」路径都需群主 / 管理员同意 */
|
||||
// TODO @AI:应该是 handleXXX;别的方法也看看,是不是统一调整过来。
|
||||
async function onJoinApprovalChange(value: boolean | string | number) {
|
||||
async function handleJoinApprovalChange(value: boolean | string | number) {
|
||||
if (!props.group) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,6 @@ import { useFriendStore } from '../../../../store/friendStore'
|
|||
import { useGroupStore } from '../../../../store/groupStore'
|
||||
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
|
||||
import { useImUiStore } from '../../../../store/uiStore'
|
||||
import { useDraftStore } from '../../../../store/draftStore'
|
||||
import { ImConversationType, ImMessageType, isNormalMessage } from '../../../../../utils/constants'
|
||||
import { getSenderDisplayName } from '@/views/im/utils/user'
|
||||
import { buildRecallTip } from '@/views/im/utils/conversation'
|
||||
|
|
@ -128,7 +127,6 @@ const friendStore = useFriendStore()
|
|||
const groupStore = useGroupStore()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const uiStore = useImUiStore()
|
||||
const draftStore = useDraftStore()
|
||||
const message = useMessage()
|
||||
|
||||
const isActive = computed(
|
||||
|
|
@ -145,7 +143,7 @@ const draft = computed(() => {
|
|||
if (isActive.value) {
|
||||
return undefined
|
||||
}
|
||||
return draftStore.getDraft(props.conversation)
|
||||
return conversationStore.getDraft(props.conversation)
|
||||
})
|
||||
|
||||
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
:quote="replyTarget"
|
||||
closable
|
||||
class="mx-3 mb-1.5"
|
||||
@close="clearReply"
|
||||
@close="clearReplyDraft"
|
||||
/>
|
||||
|
||||
<!--
|
||||
|
|
@ -167,7 +167,6 @@ import { updateFile } from '@/api/infra/file'
|
|||
import { useConversationStore } from '@/views/im/home/store/conversationStore'
|
||||
import { useGroupStore } from '@/views/im/home/store/groupStore'
|
||||
import { useFriendStore } from '@/views/im/home/store/friendStore'
|
||||
import { useDraftStore } from '@/views/im/home/store/draftStore'
|
||||
import { getMemberDisplayName } from '@/views/im/utils/user'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
|
@ -200,7 +199,6 @@ defineOptions({ name: 'ImMessageInput' })
|
|||
const conversationStore = useConversationStore()
|
||||
const groupStore = useGroupStore()
|
||||
const friendStore = useFriendStore()
|
||||
const draftStore = useDraftStore()
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
const { send, sendRaw } = useMessageSender()
|
||||
|
|
@ -257,7 +255,7 @@ watch(muteOverlay, () => {
|
|||
}
|
||||
})
|
||||
|
||||
/** 把 editor 当前内容写到 draftStore;plain 由 collectFromEditor 拿,与发送时同源避免列表与实发不一致 */
|
||||
/** 把 editor 当前内容写到会话草稿;plain 由 collectFromEditor 拿,与发送时同源避免列表与实发不一致 */
|
||||
function syncDraftToStore(editor: HTMLDivElement) {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation) {
|
||||
|
|
@ -266,8 +264,8 @@ function syncDraftToStore(editor: HTMLDivElement) {
|
|||
// collectFromEditor 已 trim,plain 为空时 store 内部按 clearDraft 处理
|
||||
// reply 透传当前快照:setDraft 是整对象替换,不读旧 reply 会让用户每敲一个键就把引用条擦掉
|
||||
const { text } = collectFromEditor(editor)
|
||||
const existing = draftStore.getDraft(conversation)
|
||||
draftStore.setDraft(conversation, {
|
||||
const existing = conversationStore.getDraft(conversation)
|
||||
conversationStore.setDraft(conversation, {
|
||||
html: editor.innerHTML,
|
||||
plain: text,
|
||||
reply: existing?.reply
|
||||
|
|
@ -281,7 +279,7 @@ function restoreDraftToEditor() {
|
|||
return
|
||||
}
|
||||
const conversation = conversationStore.activeConversation
|
||||
const draft = conversation ? draftStore.getDraft(conversation) : undefined
|
||||
const draft = conversation ? conversationStore.getDraft(conversation) : undefined
|
||||
editor.innerHTML = draft?.html || ''
|
||||
applyEditorUiState(editor)
|
||||
// 把光标移到末尾,让用户接着输入;空内容直接 focus 即可
|
||||
|
|
@ -383,7 +381,7 @@ async function handleSend(options?: { receipt?: boolean }) {
|
|||
const replyQuote = replyTarget.value
|
||||
editor.innerHTML = ''
|
||||
if (conversationStore.activeConversation) {
|
||||
draftStore.clearDraft(conversationStore.activeConversation)
|
||||
conversationStore.clearDraft(conversationStore.activeConversation)
|
||||
}
|
||||
syncEditorState()
|
||||
// 2. 发送
|
||||
|
|
@ -568,29 +566,29 @@ function onInput() {
|
|||
|
||||
// ==================== 引用 / 回复 ====================
|
||||
|
||||
/** 当前会话的「正在回复」对象,从 draftStore 派生(MessageItem 写、MessageInput 读) */
|
||||
/** 当前会话的「正在回复」对象,从会话草稿派生 */
|
||||
const replyTarget = computed<QuoteMessage | undefined>(() => {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation) {
|
||||
return undefined
|
||||
}
|
||||
return draftStore.getDraft(conversation)?.reply
|
||||
return conversationStore.getDraft(conversation)?.reply
|
||||
})
|
||||
|
||||
/** 清掉当前 reply 但保留正文草稿:点 × 关闭 / 发送即将进行时调 */
|
||||
function clearReply() {
|
||||
function clearReplyDraft() {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
draftStore.clearReply(conversation)
|
||||
conversationStore.clearReplyDraft(conversation)
|
||||
}
|
||||
|
||||
/** 取走当前 reply 快照(抓一次清一次),媒体上传链路在动手前统一调它拿 quote */
|
||||
function consumeReply(): QuoteMessage | undefined {
|
||||
const quote = replyTarget.value
|
||||
if (quote) {
|
||||
clearReply()
|
||||
clearReplyDraft()
|
||||
}
|
||||
return quote
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,37 +139,36 @@ function handleTopClick() {
|
|||
}
|
||||
|
||||
/** 点击置顶消息行 → 触发跳转 + 收起弹出层 */
|
||||
function handleLocate(msg: Message) {
|
||||
if (!msg.id) {
|
||||
function handleLocate(message: Message) {
|
||||
if (!message.id) {
|
||||
return
|
||||
}
|
||||
emit('locate', msg.id)
|
||||
emit('locate', message.id)
|
||||
expanded.value = false
|
||||
}
|
||||
|
||||
// TODO @AI:变量是不是都改成 message,不要 msg?!
|
||||
/** 置顶消息发送人显示名 */
|
||||
function getSenderName(msg: Message): string {
|
||||
function getSenderName(message: Message): string {
|
||||
return group.value
|
||||
? getSenderDisplayName(msg.senderId, ImConversationType.GROUP, group.value.id)
|
||||
? getSenderDisplayName(message.senderId, ImConversationType.GROUP, group.value.id)
|
||||
: ''
|
||||
}
|
||||
|
||||
/** 置顶消息预览文本:复用会话最后一条摘要逻辑([图片] / [文件] / 文本等) */
|
||||
function getPreview(msg: Message): string {
|
||||
function getPreview(message: Message): string {
|
||||
return group.value
|
||||
? resolveConversationLastContent(msg, ImConversationType.GROUP, group.value.id)
|
||||
? resolveConversationLastContent(message, ImConversationType.GROUP, group.value.id)
|
||||
: ''
|
||||
}
|
||||
|
||||
/** 移除置顶:调后端 API,loading 期间禁止重复点;后端广播 GROUP_MESSAGE_UNPIN 由 dispatcher 自动同步本地 */
|
||||
async function handleRemove(msg: Message) {
|
||||
if (!group.value || !msg.id || removingId.value !== null) {
|
||||
async function handleRemove(pinnedMessage: Message) {
|
||||
if (!group.value || !pinnedMessage.id || removingId.value !== null) {
|
||||
return
|
||||
}
|
||||
removingId.value = msg.id
|
||||
removingId.value = pinnedMessage.id
|
||||
try {
|
||||
await apiUnpinGroupMessage({ id: group.value.id, messageId: msg.id })
|
||||
await apiUnpinGroupMessage({ id: group.value.id, messageId: pinnedMessage.id })
|
||||
message.success('已取消置顶')
|
||||
} finally {
|
||||
removingId.value = null
|
||||
|
|
|
|||
|
|
@ -604,9 +604,9 @@ async function loadEarlier() {
|
|||
if (pageLength < HISTORY_PAGE_SIZE) {
|
||||
hasMore.value = false
|
||||
}
|
||||
// 合并到 messageStore:prependMessages 内部去重 + 升序合并 + 落 IndexedDB;
|
||||
// 合并到 messageStore:prependMessageList 内部去重 + 升序合并 + 落 IndexedDB;
|
||||
// 主聊天面板的 messages 是同一份引用,老消息也会一起出现在主面板里(符合预期)
|
||||
messageStore.prependMessages(requestedType, requestedTargetId, earlier)
|
||||
messageStore.prependMessageList(requestedType, requestedTargetId, earlier)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -235,7 +235,6 @@ import { useUserStore } from '@/store/modules/user'
|
|||
import { useConversationStore } from '../../../../store/conversationStore'
|
||||
import { useGroupStore } from '../../../../store/groupStore'
|
||||
import { useFriendStore } from '../../../../store/friendStore'
|
||||
import { useDraftStore } from '../../../../store/draftStore'
|
||||
import { useFaceStore } from '../../../../store/faceStore'
|
||||
import {
|
||||
getMemberDisplayName,
|
||||
|
|
@ -293,7 +292,6 @@ const conversationStore = useConversationStore()
|
|||
const messageStore = useMessageStore()
|
||||
const groupStore = useGroupStore()
|
||||
const friendStore = useFriendStore()
|
||||
const draftStore = useDraftStore()
|
||||
const faceStore = useFaceStore()
|
||||
const uiStore = useImUiStore()
|
||||
const { recall, sendRaw } = useMessageSender()
|
||||
|
|
@ -602,7 +600,7 @@ type MenuKey = (typeof MENU_KEYS)[keyof typeof MENU_KEYS]
|
|||
|
||||
/**
|
||||
* 右键菜单项:
|
||||
* - 引用:已落库 + 未撤回的消息可引用,引用块写入 draftStore.reply
|
||||
* - 引用:已落库 + 未撤回的消息可引用,引用块写入会话草稿
|
||||
* - 撤回 / 删除:互斥;自己发送 + 已落库 + 未撤回 + 2 分钟内显示「撤回」(推服务器),其它显示「删除」(仅本地清)
|
||||
*
|
||||
* 好友事件气泡态不弹菜单
|
||||
|
|
@ -892,13 +890,13 @@ async function handleCopy() {
|
|||
successMessage('内容已复制到剪贴板')
|
||||
}
|
||||
|
||||
/** 进入引用模式:把当前消息构造成 QuoteMessage 写入 draftStore,MessageInput 顶部引用条响应式出现 */
|
||||
/** 进入引用模式:把当前消息构造成 QuoteMessage 写入会话草稿 */
|
||||
function handleReply() {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
draftStore.setReply(conversation, buildQuoteFromMessage(props.message))
|
||||
conversationStore.setReplyDraft(conversation, buildQuoteFromMessage(props.message))
|
||||
}
|
||||
|
||||
/** 转发当前消息:打开 ForwardDialog(单条模式;mode=single 即原样转) */
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ async function loadReadUsers() {
|
|||
// 全可见成员都已读 → flip 到 DONE,让外面 label 直接命中"全部已读"分支;
|
||||
// 否则只更新 readCount,receiptStatus 维持不变(PENDING / READING)
|
||||
const allRead = readCount > 0 && readCount >= visibleMembers.value.length
|
||||
messageStore.applyReadReceipt({
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.GROUP,
|
||||
targetId: props.groupId,
|
||||
groupMessageId: props.message.id,
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ export const useChannelStore = defineStore('imChannelStore', {
|
|||
}
|
||||
},
|
||||
|
||||
/** 整桶持久化频道列表(量级小,不维护增量) */
|
||||
saveChannels(): void {
|
||||
/** 保存频道列表 */
|
||||
saveChannelList(): void {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) {
|
||||
return
|
||||
|
|
@ -64,15 +64,15 @@ export const useChannelStore = defineStore('imChannelStore', {
|
|||
try {
|
||||
this.channels = (await getSimpleChannelList()) || []
|
||||
this.loaded = true
|
||||
this.syncConversationMetadata()
|
||||
this.saveChannels()
|
||||
this.syncChannelConversationMetadata()
|
||||
this.saveChannelList()
|
||||
} catch (e) {
|
||||
console.warn('[IM channelStore] fetchChannels 失败', e)
|
||||
}
|
||||
},
|
||||
|
||||
/** 用最新的频道信息覆盖已有 CHANNEL 会话的 name / avatar;conversationStore 持久化的旧占位被刷掉 */
|
||||
syncConversationMetadata() {
|
||||
syncChannelConversationMetadata() {
|
||||
const conversationStore = useConversationStore()
|
||||
const indexed = new Map(this.channels.map((c) => [c.id, c]))
|
||||
conversationStore.conversations.forEach((conversation) => {
|
||||
|
|
@ -105,5 +105,3 @@ if (import.meta.hot) {
|
|||
}
|
||||
|
||||
export const useChannelStoreWithOut = () => useChannelStore(store)
|
||||
// TODO @AI:这里,重名名,是不是没必要???(问问。)
|
||||
export const useImChannelStore = useChannelStoreWithOut
|
||||
|
|
|
|||
|
|
@ -1,18 +1,55 @@
|
|||
import { acceptHMRUpdate, defineStore } from 'pinia'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { store } from '@/store'
|
||||
|
||||
import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config'
|
||||
import { ImConversationType } from '../../utils/constants'
|
||||
import { getClientConversationId, getDb, type DbTx } from '../../utils/db'
|
||||
import { getClientConversationId, getDb, type DbTransaction } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/storage'
|
||||
import { useDraftStore } from './draftStore'
|
||||
import { useMessageStore } from './messageStore'
|
||||
import type { Conversation, ConversationDO } from '../types'
|
||||
|
||||
const PERSIST_DRAFT_DEBOUNCE_MS = 500
|
||||
const pendingDraftConversations = new Set<Conversation>()
|
||||
|
||||
/** 会话转 IndexedDB 记录 */
|
||||
function toConversationDO(conversation: Conversation): ConversationDO {
|
||||
const draft = conversation.draft
|
||||
return {
|
||||
...conversation,
|
||||
targetId: conversation.targetId,
|
||||
type: conversation.type,
|
||||
name: conversation.name,
|
||||
avatar: conversation.avatar,
|
||||
unreadCount: conversation.unreadCount,
|
||||
lastContent: conversation.lastContent,
|
||||
lastSendTime: conversation.lastSendTime,
|
||||
lastSenderId: conversation.lastSenderId,
|
||||
lastMessageType: conversation.lastMessageType,
|
||||
lastMessageId: conversation.lastMessageId,
|
||||
lastClientMessageId: conversation.lastClientMessageId,
|
||||
lastMessageStatus: conversation.lastMessageStatus,
|
||||
lastReceiptStatus: conversation.lastReceiptStatus,
|
||||
lastSelfSend: conversation.lastSelfSend,
|
||||
lastSenderDisplayName: conversation.lastSenderDisplayName,
|
||||
deleted: conversation.deleted,
|
||||
top: conversation.top,
|
||||
silent: conversation.silent,
|
||||
atMe: conversation.atMe,
|
||||
atAll: conversation.atAll,
|
||||
draft: draft
|
||||
? {
|
||||
html: draft.html,
|
||||
plain: draft.plain,
|
||||
reply: draft.reply
|
||||
? {
|
||||
messageId: draft.reply.messageId,
|
||||
senderId: draft.reply.senderId,
|
||||
type: draft.reply.type,
|
||||
content: draft.reply.content
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: undefined,
|
||||
clientConversationId: getClientConversationId(conversation.type, conversation.targetId)
|
||||
}
|
||||
}
|
||||
|
|
@ -64,8 +101,8 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
|
||||
actions: {
|
||||
/** 加载会话 */
|
||||
// TODO @AI:方法里的代码段注释,写一下。
|
||||
async loadConversations() {
|
||||
// 1. 清理旧账号内存
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) {
|
||||
this.clear()
|
||||
|
|
@ -75,6 +112,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
? getClientConversationId(this.activeConversation.type, this.activeConversation.targetId)
|
||||
: null
|
||||
this.clear()
|
||||
// 2. 从 IndexedDB 读取会话和轻量设置
|
||||
const db = getDb()
|
||||
const [conversations, recent] = await Promise.all([
|
||||
db.getAll<ConversationDO>('conversations'),
|
||||
|
|
@ -84,6 +122,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
if (Array.isArray(recent)) {
|
||||
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
|
||||
}
|
||||
// 3. 恢复当前激活会话
|
||||
if (previousActiveKey) {
|
||||
this.activeConversation =
|
||||
this.conversations.find(
|
||||
|
|
@ -97,47 +136,59 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
|
||||
/** 清空会话内存 */
|
||||
clear() {
|
||||
saveDraftConversationsDebounced.cancel()
|
||||
pendingDraftConversations.clear()
|
||||
this.conversations = []
|
||||
this.activeConversation = null
|
||||
this.recentForwardConversationKeys = []
|
||||
},
|
||||
|
||||
/** 执行会话持久化 */
|
||||
// TODO @AI:想了下,DbTx 改成 DbTransaction 吧,变量可以叫 tx;
|
||||
// TODO @AI:方法里的代码段注释,写一下。
|
||||
async persistConversations(
|
||||
target?: Conversation | Conversation[] | null,
|
||||
tx?: DbTx
|
||||
/** 执行会话记录持久化 */
|
||||
async persistConversationRecords(
|
||||
target: Conversation | Conversation[] | null | undefined,
|
||||
tx?: DbTransaction
|
||||
): Promise<void> {
|
||||
const db = getDb()
|
||||
const conversations = (
|
||||
Array.isArray(target) ? target : target ? [target] : this.conversations
|
||||
).map(toConversationDO)
|
||||
const conversations = (Array.isArray(target) ? target : target ? [target] : []).map(
|
||||
toConversationDO
|
||||
)
|
||||
if (conversations.length === 0) {
|
||||
return
|
||||
}
|
||||
if (tx) {
|
||||
for (const conversation of conversations) {
|
||||
await db.put('conversations', conversation, tx)
|
||||
}
|
||||
return
|
||||
}
|
||||
await db.transaction(['conversations'], 'readwrite', async (innerTx) => {
|
||||
await db.transaction(['conversations'], 'readwrite', async (tx) => {
|
||||
for (const conversation of conversations) {
|
||||
await db.put('conversations', conversation, innerTx)
|
||||
await db.put('conversations', conversation, tx)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/** 持久化会话 */
|
||||
saveConversations(target?: Conversation | Conversation[] | null, tx?: DbTx): void {
|
||||
/** 持久化单个会话 */
|
||||
saveConversation(conversation: Conversation | null | undefined, tx?: DbTransaction): void {
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
void this.persistConversationRecords(conversation, tx).catch((e) =>
|
||||
console.warn('[IM conversationStore] 会话写入失败', e)
|
||||
)
|
||||
},
|
||||
|
||||
/** 持久化会话列表 */
|
||||
saveConversationList(conversations?: Conversation[] | null, tx?: DbTransaction): void {
|
||||
if (this.loading && !tx) {
|
||||
return
|
||||
}
|
||||
void this.persistConversations(target, tx).catch((e) =>
|
||||
void this.persistConversationRecords(conversations || this.conversations, tx).catch((e) =>
|
||||
console.warn('[IM conversationStore] 会话写入失败', e)
|
||||
)
|
||||
},
|
||||
|
||||
/** 确保会话存在 */
|
||||
// TODO @AI:方法里的代码段注释,写一下。
|
||||
ensureConversation(info: {
|
||||
type: number
|
||||
targetId: number
|
||||
|
|
@ -145,6 +196,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
avatar: string
|
||||
silent?: boolean
|
||||
}): Conversation {
|
||||
// 1. 创建不存在的会话
|
||||
let conversation = this.getConversation(info.type, info.targetId)
|
||||
if (!conversation) {
|
||||
conversation = this.createEmptyConversation(
|
||||
|
|
@ -156,6 +208,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
)
|
||||
this.conversations.unshift(conversation)
|
||||
} else if (conversation.deleted) {
|
||||
// 2. 恢复软删除会话
|
||||
conversation.deleted = false
|
||||
conversation.name = info.name || conversation.name
|
||||
conversation.avatar = info.avatar || conversation.avatar
|
||||
|
|
@ -163,6 +216,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
conversation.silent = info.silent
|
||||
}
|
||||
} else {
|
||||
// 3. 同步会话展示元数据
|
||||
if (info.name) {
|
||||
conversation.name = info.name
|
||||
}
|
||||
|
|
@ -177,7 +231,6 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
},
|
||||
|
||||
/** 打开或创建会话 */
|
||||
// TODO @AI:方法里的代码段注释,写一下。
|
||||
openConversation(
|
||||
targetId: number,
|
||||
type: number,
|
||||
|
|
@ -185,6 +238,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
avatar: string,
|
||||
options?: { silent?: boolean }
|
||||
): Conversation {
|
||||
// 1. 确保会话在列表中
|
||||
const conversation = this.ensureConversation({
|
||||
type,
|
||||
targetId,
|
||||
|
|
@ -192,23 +246,25 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
avatar,
|
||||
silent: options?.silent
|
||||
})
|
||||
// 2. 激活会话并保存
|
||||
this.setActiveConversation(conversation)
|
||||
this.saveConversations(conversation)
|
||||
this.saveConversation(conversation)
|
||||
return conversation
|
||||
},
|
||||
|
||||
/** 设置当前会话 */
|
||||
// TODO @AI:方法里的代码段注释,写一下。
|
||||
setActiveConversation(conversation: Conversation | null) {
|
||||
this.activeConversation = conversation
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
// 1. 清理会话级未读状态
|
||||
conversation.unreadCount = 0
|
||||
conversation.atMe = false
|
||||
conversation.atAll = false
|
||||
void useMessageStore().ensureLoaded(conversation)
|
||||
this.saveConversations(conversation)
|
||||
// 2. 懒加载消息并保存会话摘要
|
||||
void useMessageStore().ensureConversationMessagesLoaded(conversation)
|
||||
this.saveConversation(conversation)
|
||||
},
|
||||
|
||||
/** 创建空会话 */
|
||||
|
|
@ -242,7 +298,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
return
|
||||
}
|
||||
conversation.top = top
|
||||
this.saveConversations(conversation)
|
||||
this.saveConversation(conversation)
|
||||
},
|
||||
|
||||
/** 设置免打扰 */
|
||||
|
|
@ -252,13 +308,12 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
return
|
||||
}
|
||||
conversation.silent = silent
|
||||
// TODO @AI:saveConversations 拆成 saveConversationList、saveConversation 两个方法;
|
||||
this.saveConversations(conversation)
|
||||
this.saveConversation(conversation)
|
||||
},
|
||||
|
||||
/** 删除会话 */
|
||||
// TODO @AI:方法里的代码段注释,写一下。
|
||||
removeConversation(type: number, targetId: number) {
|
||||
// 1. 标记会话删除
|
||||
const conversation = this.getConversation(type, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
|
|
@ -267,9 +322,10 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
this.activeConversation = null
|
||||
}
|
||||
conversation.deleted = true
|
||||
// 2. 删除会话关联的消息和草稿
|
||||
useMessageStore().deleteConversationMessages(type, targetId)
|
||||
useDraftStore().clearDraft({ type, targetId })
|
||||
this.saveConversations(conversation)
|
||||
this.clearDraft(conversation)
|
||||
this.saveConversation(conversation)
|
||||
},
|
||||
|
||||
/** 删除私聊会话 */
|
||||
|
|
@ -294,10 +350,10 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
conversation.unreadCount = 0
|
||||
conversation.atMe = false
|
||||
conversation.atAll = false
|
||||
this.saveConversations(conversation)
|
||||
this.saveConversation(conversation)
|
||||
},
|
||||
|
||||
// TODO @AI:把最近转发 ==== 拆分下???
|
||||
// ==================== 最近转发 ====================
|
||||
|
||||
/** 推送最近转发会话 */
|
||||
pushRecentForwardConversationKeys(keys: string[]) {
|
||||
|
|
@ -309,7 +365,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
0,
|
||||
CONVERSATION_RECENT_FORWARD_MAX
|
||||
)
|
||||
this.persistRecentForwardConversationKeys()
|
||||
this.saveRecentForwardConversationKeys()
|
||||
},
|
||||
|
||||
/** 移除最近转发会话 */
|
||||
|
|
@ -319,11 +375,11 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
return
|
||||
}
|
||||
this.recentForwardConversationKeys.splice(index, 1)
|
||||
this.persistRecentForwardConversationKeys()
|
||||
this.saveRecentForwardConversationKeys()
|
||||
},
|
||||
|
||||
/** 持久化最近转发会话 */
|
||||
persistRecentForwardConversationKeys() {
|
||||
/** 保存最近转发会话 */
|
||||
saveRecentForwardConversationKeys() {
|
||||
void getDb()
|
||||
.setSetting(
|
||||
'recentForwardConversationKeys',
|
||||
|
|
@ -332,10 +388,12 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
.catch((e) => console.warn('[IM conversationStore] 最近转发列表写入失败', e))
|
||||
},
|
||||
|
||||
// ==================== 会话维护 ====================
|
||||
|
||||
/** 重排会话 */
|
||||
sortConversations() {
|
||||
sortConversationList() {
|
||||
this.conversations.sort((a, b) => (b.lastSendTime || 0) - (a.lastSendTime || 0))
|
||||
this.saveConversations(this.conversations)
|
||||
this.saveConversationList(this.conversations)
|
||||
},
|
||||
|
||||
/** 同步会话展示元数据 */
|
||||
|
|
@ -362,14 +420,96 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
this.saveConversations(conversation)
|
||||
this.saveConversation(conversation)
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 草稿 ====================
|
||||
|
||||
/** 获取草稿 */
|
||||
getDraft(conversation: { type: number; targetId: number }): Conversation['draft'] | undefined {
|
||||
return this.getConversation(conversation.type, conversation.targetId)?.draft
|
||||
},
|
||||
|
||||
/** 设置草稿 */
|
||||
setDraft(
|
||||
conversation: { type: number; targetId: number },
|
||||
snapshot: NonNullable<Conversation['draft']>
|
||||
): void {
|
||||
if (!snapshot.plain.trim() && !snapshot.reply) {
|
||||
this.clearDraft(conversation)
|
||||
return
|
||||
}
|
||||
const target = this.getConversation(conversation.type, conversation.targetId)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
target.draft = snapshot
|
||||
this.scheduleDraftSave(target)
|
||||
},
|
||||
|
||||
/** 清除草稿 */
|
||||
clearDraft(conversation: { type: number; targetId: number }): void {
|
||||
const target = this.getConversation(conversation.type, conversation.targetId)
|
||||
if (!target?.draft) {
|
||||
return
|
||||
}
|
||||
target.draft = undefined
|
||||
this.scheduleDraftSave(target)
|
||||
},
|
||||
|
||||
/** 设置回复草稿 */
|
||||
setReplyDraft(
|
||||
conversation: { type: number; targetId: number },
|
||||
quote: NonNullable<Conversation['draft']>['reply']
|
||||
) {
|
||||
if (!quote) {
|
||||
return
|
||||
}
|
||||
const existing = this.getDraft(conversation)
|
||||
this.setDraft(conversation, {
|
||||
html: existing?.html ?? '',
|
||||
plain: existing?.plain ?? '',
|
||||
reply: quote
|
||||
})
|
||||
},
|
||||
|
||||
/** 清除回复草稿 */
|
||||
clearReplyDraft(conversation: { type: number; targetId: number }): void {
|
||||
const existing = this.getDraft(conversation)
|
||||
if (!existing?.reply) {
|
||||
return
|
||||
}
|
||||
this.setDraft(conversation, { ...existing, reply: undefined })
|
||||
},
|
||||
|
||||
/** 调度草稿保存 */
|
||||
scheduleDraftSave(conversation: Conversation): void {
|
||||
pendingDraftConversations.add(conversation)
|
||||
saveDraftConversationsDebounced()
|
||||
},
|
||||
|
||||
/** 立即保存草稿 */
|
||||
flushDraftSave(): void {
|
||||
saveDraftConversationsDebounced.flush()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const useConversationStoreWithOut = () => useConversationStore(store)
|
||||
|
||||
/** 合并草稿写入 */
|
||||
const saveDraftConversationsDebounced = debounce(() => {
|
||||
const conversations = Array.from(pendingDraftConversations)
|
||||
pendingDraftConversations.clear()
|
||||
if (conversations.length === 0) {
|
||||
return
|
||||
}
|
||||
void useConversationStoreWithOut()
|
||||
.persistConversationRecords(conversations)
|
||||
.catch((e) => console.warn('[IM conversationStore] 草稿写入失败', e))
|
||||
}, PERSIST_DRAFT_DEBOUNCE_MS)
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useConversationStore, import.meta.hot))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
/**
|
||||
* 草稿快照
|
||||
* - html:editor 是 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))
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ import {
|
|||
getServerMessageKey,
|
||||
parseClientConversationId,
|
||||
setMessageMaxId,
|
||||
type DbTx
|
||||
type DbTransaction
|
||||
} from '../../utils/db'
|
||||
import {
|
||||
generateClientMessageId,
|
||||
|
|
@ -30,7 +30,8 @@ import { useGroupStore } from './groupStore'
|
|||
import { useConversationStore } from './conversationStore'
|
||||
import type { Conversation, Message, MessageDO } from '../types'
|
||||
|
||||
const MESSAGE_CACHE_CONVERSATION_LIMIT = 5
|
||||
const MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT = 5
|
||||
const MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT = MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT + 1
|
||||
const ackMergingPromises = new Map<string, Promise<void>>()
|
||||
|
||||
interface MessageConversationInfo {
|
||||
|
|
@ -41,8 +42,8 @@ interface MessageConversationInfo {
|
|||
silent?: boolean
|
||||
}
|
||||
|
||||
// TODO @AI:叫这个 type 有点奇怪,可能需要再考虑下。
|
||||
export type PulledMessageBatchItem =
|
||||
/** 拉取消息批量处理项 */
|
||||
export type PulledMessage =
|
||||
| {
|
||||
kind: 'insert'
|
||||
conversationInfo: MessageConversationInfo
|
||||
|
|
@ -82,16 +83,22 @@ function ensureClientMessageId(message: Message): Message {
|
|||
}
|
||||
|
||||
/** 转换为 IndexedDB 消息记录 */
|
||||
// TODO @AI:buildXXX 更合理。
|
||||
function toMessageDO(message: Message, conversationType: number): MessageDO {
|
||||
const {
|
||||
uploadProgress: _uploadProgress,
|
||||
_localFile: _localFile,
|
||||
_ackMerging: _ackMerging,
|
||||
...rest
|
||||
} = message
|
||||
function buildMessageDO(message: Message, conversationType: number): MessageDO {
|
||||
return {
|
||||
...rest,
|
||||
id: message.id,
|
||||
clientMessageId: message.clientMessageId,
|
||||
type: message.type,
|
||||
content: message.content,
|
||||
status: message.status,
|
||||
sendTime: message.sendTime,
|
||||
senderId: message.senderId,
|
||||
atUserIds: message.atUserIds ? [...message.atUserIds] : undefined,
|
||||
receiverUserIds: message.receiverUserIds ? [...message.receiverUserIds] : undefined,
|
||||
receiptStatus: message.receiptStatus,
|
||||
readCount: message.readCount,
|
||||
materialId: message.materialId,
|
||||
targetId: message.targetId,
|
||||
selfSend: message.selfSend,
|
||||
messageKey: getMessageKey(message, conversationType),
|
||||
conversationType,
|
||||
clientConversationId: getClientConversationId(conversationType, message.targetId)
|
||||
|
|
@ -99,8 +106,7 @@ function toMessageDO(message: Message, conversationType: number): MessageDO {
|
|||
}
|
||||
|
||||
/** IndexedDB 消息记录转前端消息 */
|
||||
// TODO @AI:buildXXX 更合理。
|
||||
function fromMessageDO(message: MessageDO): Message {
|
||||
function buildMessageFromDO(message: MessageDO): Message {
|
||||
const {
|
||||
messageKey: _messageKey,
|
||||
conversationType: _conversationType,
|
||||
|
|
@ -111,15 +117,16 @@ function fromMessageDO(message: MessageDO): Message {
|
|||
}
|
||||
|
||||
/** 算出末条消息的发送人快照 */
|
||||
// TODO @AI:里面的代码注释;最好写下;
|
||||
function deriveLastSenderDisplayName(
|
||||
conversation: Conversation,
|
||||
senderId: number
|
||||
): string | undefined {
|
||||
// 1. 优先使用当前内存中的好友 / 群成员信息
|
||||
const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
|
||||
if (liveSenderName) {
|
||||
return liveSenderName
|
||||
}
|
||||
// 2. 群成员缓存缺失时异步补齐
|
||||
if (conversation.type === ImConversationType.GROUP) {
|
||||
const groupStore = useGroupStore()
|
||||
const group = groupStore.getGroup(conversation.targetId)
|
||||
|
|
@ -256,9 +263,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
},
|
||||
|
||||
/** 从 settings 加载消息游标 */
|
||||
async loadCursors() {
|
||||
async loadMessageCursors() {
|
||||
const db = getDb()
|
||||
// TODO @AI:可以通过 message 表去算么?不通过这个。
|
||||
const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([
|
||||
db.getSetting<number>('privateMessageMaxId'),
|
||||
db.getSetting<number>('groupMessageMaxId'),
|
||||
|
|
@ -270,7 +276,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
},
|
||||
|
||||
/** 更新内存游标 */
|
||||
updateMaxId(conversationType: number, messageId?: number) {
|
||||
updateMessageMaxId(conversationType: number, messageId?: number) {
|
||||
if (!messageId) {
|
||||
return
|
||||
}
|
||||
|
|
@ -290,14 +296,14 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
},
|
||||
|
||||
/** 标记会话近期使用 */
|
||||
touchConversation(clientConversationId: string) {
|
||||
touchConversationMessageCache(clientConversationId: string) {
|
||||
this.loadedConversationKeys = [
|
||||
clientConversationId,
|
||||
...this.loadedConversationKeys.filter((key) => key !== clientConversationId)
|
||||
]
|
||||
// 保留当前活跃会话 + 最近打开过的 5 个会话。
|
||||
const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_CONVERSATION_LIMIT + 1)
|
||||
const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_CONVERSATION_LIMIT + 1)
|
||||
// 保留当前活跃会话 + 最近打开过的会话
|
||||
const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT)
|
||||
const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT)
|
||||
this.loadedConversationKeys = retained
|
||||
removed.forEach((key) => {
|
||||
delete this.messagesByConversation[key]
|
||||
|
|
@ -305,61 +311,59 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
},
|
||||
|
||||
/** 加载当前会话最近消息 */
|
||||
async loadMore(
|
||||
async loadMoreMessages(
|
||||
clientConversationId: string,
|
||||
beforeSendTime?: number,
|
||||
limit = 50
|
||||
): Promise<Message[]> {
|
||||
// TODO @AI:代码段的注释;
|
||||
const list = await getDb().getMessagesByConversation(clientConversationId, {
|
||||
// 1. 从 IndexedDB 倒序读取一页,返回前已按时间升序排列
|
||||
const list = await getDb().getMessageListByConversation(clientConversationId, {
|
||||
beforeSendTime,
|
||||
limit
|
||||
})
|
||||
// 2. 合并到内存缓存,过滤已存在的消息
|
||||
const parsed = parseClientConversationId(clientConversationId)
|
||||
if (!parsed) {
|
||||
return []
|
||||
}
|
||||
const messages = list.map(fromMessageDO)
|
||||
const messages = list.map(buildMessageFromDO)
|
||||
const existing = this.messagesByConversation[clientConversationId] || []
|
||||
const existingKeys = new Set(existing.map((message) => getMessageKey(message, parsed.type)))
|
||||
const fresh = messages.filter(
|
||||
(message) => !existingKeys.has(getMessageKey(message, parsed.type))
|
||||
)
|
||||
// TODO @AI:messageA、messageB;
|
||||
this.messagesByConversation[clientConversationId] = [...fresh, ...existing].sort(
|
||||
(a, b) => (a.sendTime || 0) - (b.sendTime || 0)
|
||||
(messageA, messageB) => (messageA.sendTime || 0) - (messageB.sendTime || 0)
|
||||
)
|
||||
this.touchConversation(clientConversationId)
|
||||
this.touchConversationMessageCache(clientConversationId)
|
||||
return fresh
|
||||
},
|
||||
|
||||
/** 确保会话消息已加载 */
|
||||
async ensureLoaded(conversation: Conversation) {
|
||||
// TODO @AI:代码段的注释;
|
||||
async ensureConversationMessagesLoaded(conversation: Conversation) {
|
||||
const key = getMessageCacheKey(conversation.type, conversation.targetId)
|
||||
if (this.messagesByConversation[key]) {
|
||||
this.touchConversation(key)
|
||||
this.touchConversationMessageCache(key)
|
||||
return
|
||||
}
|
||||
await this.loadMore(key)
|
||||
await this.loadMoreMessages(key)
|
||||
},
|
||||
|
||||
/** 获取内存消息数组 */
|
||||
getMessageList(conversationType: number, targetId: number): Message[] {
|
||||
// TODO @AI:代码段的注释;
|
||||
const key = getMessageCacheKey(conversationType, targetId)
|
||||
if (!this.messagesByConversation[key]) {
|
||||
this.messagesByConversation[key] = []
|
||||
}
|
||||
this.touchConversation(key)
|
||||
this.touchConversationMessageCache(key)
|
||||
return this.messagesByConversation[key]
|
||||
},
|
||||
|
||||
/** 持久化单条消息 */
|
||||
async persistMessage(message: Message, conversationType: number, tx?: DbTx) {
|
||||
// TODO @AI:代码段的注释;
|
||||
/** 持久化消息记录 */
|
||||
async persistMessageRecord(message: Message, conversationType: number, tx?: DbTransaction) {
|
||||
const db = getDb()
|
||||
const next = toMessageDO(message, conversationType)
|
||||
const next = buildMessageDO(message, conversationType)
|
||||
// ack 后服务端 key 替换 client key
|
||||
if (message.id && message.clientMessageId) {
|
||||
const existing = await db.getByIndex<MessageDO>(
|
||||
'messages',
|
||||
|
|
@ -374,15 +378,19 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
await db.put('messages', next, tx)
|
||||
},
|
||||
|
||||
/** 持久化消息游标 */
|
||||
async persistMaxId(conversationType: number, messageId?: number, tx?: DbTx) {
|
||||
this.updateMaxId(conversationType, messageId)
|
||||
/** 保存消息游标 */
|
||||
async saveMessageMaxId(conversationType: number, messageId?: number, tx?: DbTransaction) {
|
||||
this.updateMessageMaxId(conversationType, messageId)
|
||||
await setMessageMaxId(conversationType, messageId, tx)
|
||||
},
|
||||
|
||||
/** 应用撤回到内存 */
|
||||
applyRecallInMemory(conversationType: number, targetId: number, recallSignalContent: string) {
|
||||
// TODO @AI:代码段的注释;
|
||||
applyRecallMessageInMemory(
|
||||
conversationType: number,
|
||||
targetId: number,
|
||||
recallSignalContent: string
|
||||
) {
|
||||
// 1. 定位被撤回的原消息
|
||||
const messageId = parseRecallMessageId(recallSignalContent)
|
||||
if (!messageId) {
|
||||
return null
|
||||
|
|
@ -397,6 +405,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
if (!message) {
|
||||
return null
|
||||
}
|
||||
// 2. 更新消息和会话摘要
|
||||
message.type = ImMessageType.RECALL
|
||||
message.status = ImMessageStatus.RECALL
|
||||
message.content = ''
|
||||
|
|
@ -407,14 +416,14 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
},
|
||||
|
||||
/** 批量写入拉取消息 */
|
||||
async insertPulledBatch(
|
||||
items: PulledMessageBatchItem[],
|
||||
async applyPulledMessageList(
|
||||
pulledMessages: PulledMessage[],
|
||||
conversationType: number,
|
||||
maxMessageId?: number
|
||||
) {
|
||||
// TODO @AI:代码段的注释;
|
||||
if (items.length === 0) {
|
||||
await this.persistMaxId(conversationType, maxMessageId)
|
||||
if (pulledMessages.length === 0) {
|
||||
// 1. 空批次只推进游标
|
||||
await this.saveMessageMaxId(conversationType, maxMessageId)
|
||||
return
|
||||
}
|
||||
const conversationStore = useConversationStore()
|
||||
|
|
@ -433,13 +442,14 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
})
|
||||
}
|
||||
|
||||
// TODO @AI:是不是最好 mesages?
|
||||
for (const item of items) {
|
||||
if (item.kind === 'recall') {
|
||||
const changed = this.applyRecallInMemory(
|
||||
item.conversationType,
|
||||
item.targetId,
|
||||
item.recallSignalContent
|
||||
// 1. 先更新内存,收集需要持久化的消息和会话
|
||||
for (const pulledMessage of pulledMessages) {
|
||||
if (pulledMessage.kind === 'recall') {
|
||||
// 1.1 撤回信号更新原消息
|
||||
const changed = this.applyRecallMessageInMemory(
|
||||
pulledMessage.conversationType,
|
||||
pulledMessage.targetId,
|
||||
pulledMessage.recallSignalContent
|
||||
)
|
||||
if (changed) {
|
||||
addChanged(changed.conversation, changed.message)
|
||||
|
|
@ -447,8 +457,9 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
continue
|
||||
}
|
||||
|
||||
const { conversationInfo } = item
|
||||
const message = ensureClientMessageId(item.message)
|
||||
const { conversationInfo } = pulledMessage
|
||||
const message = ensureClientMessageId(pulledMessage.message)
|
||||
// 1.2 群通知先同步群资料
|
||||
if (
|
||||
conversationInfo.type === ImConversationType.GROUP &&
|
||||
isGroupNotification(message.type)
|
||||
|
|
@ -460,21 +471,23 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
)
|
||||
}
|
||||
|
||||
// 1.3 确保会话和消息缓存存在
|
||||
const conversation = conversationStore.ensureConversation(conversationInfo)
|
||||
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
|
||||
const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
|
||||
if (existingIndex >= 0) {
|
||||
// 1.4 已存在消息合并服务端状态
|
||||
applyServerMessageUpdate(messages[existingIndex], message)
|
||||
if (existingIndex === messages.length - 1) {
|
||||
recomputeConversationLast(conversation, messages)
|
||||
syncConversationAtFlags(conversation, message)
|
||||
}
|
||||
this.updateMaxId(conversationInfo.type, message.id)
|
||||
this.updateMessageMaxId(conversationInfo.type, message.id)
|
||||
addChanged(conversation, messages[existingIndex])
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO @AI:applyConversationSummary 要 await 么?不然会有报错;
|
||||
// 1.5 新消息更新会话摘要和未读状态
|
||||
applyConversationSummary(conversation, message)
|
||||
syncConversationAtFlags(conversation, message)
|
||||
const isActive =
|
||||
|
|
@ -490,6 +503,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
conversation.unreadCount++
|
||||
}
|
||||
|
||||
// 1.6 新消息按服务端 id 插入内存列表
|
||||
let insertIndex = messages.length
|
||||
if (message.id) {
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
|
|
@ -501,17 +515,22 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
}
|
||||
}
|
||||
messages.splice(insertIndex, 0, message)
|
||||
this.updateMaxId(conversationInfo.type, message.id)
|
||||
this.updateMessageMaxId(conversationInfo.type, message.id)
|
||||
addChanged(conversation, message)
|
||||
}
|
||||
|
||||
this.updateMaxId(conversationType, maxMessageId)
|
||||
// 2. 更新内存游标
|
||||
this.updateMessageMaxId(conversationType, maxMessageId)
|
||||
// 3. 单事务写入消息、会话摘要和游标
|
||||
await getDb()
|
||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||
// 3.1 写入本批变更消息
|
||||
for (const item of persistedMessages.values()) {
|
||||
await this.persistMessage(item.message, item.conversationType, tx)
|
||||
await this.persistMessageRecord(item.message, item.conversationType, tx)
|
||||
}
|
||||
await conversationStore.persistConversations([...changedConversations.values()], tx)
|
||||
// 3.2 写入本批变更会话
|
||||
await conversationStore.persistConversationRecords([...changedConversations.values()], tx)
|
||||
// 3.3 写入本批游标
|
||||
await setMessageMaxId(conversationType, maxMessageId, tx)
|
||||
})
|
||||
.catch((e) => console.error('[IM messageStore] 批量消息写入失败', e))
|
||||
|
|
@ -521,11 +540,11 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
insertMessage(
|
||||
conversationInfo: MessageConversationInfo,
|
||||
messageInfo: Message,
|
||||
options?: { persistMaxId?: boolean }
|
||||
options?: { saveMaxId?: boolean }
|
||||
) {
|
||||
// TODO @AI:代码段的注释;类似上面的问题;;;
|
||||
const conversationStore = useConversationStore()
|
||||
const message = ensureClientMessageId(messageInfo)
|
||||
// 1. 先处理消息带来的群资料变更
|
||||
if (conversationInfo.type === ImConversationType.GROUP && isGroupNotification(message.type)) {
|
||||
useGroupStore().applyGroupNotification(
|
||||
conversationInfo.targetId,
|
||||
|
|
@ -534,21 +553,23 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
)
|
||||
}
|
||||
|
||||
// 2. 确保会话和消息缓存存在
|
||||
const conversation = conversationStore.ensureConversation(conversationInfo)
|
||||
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
|
||||
const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
|
||||
// 3. 已存在消息走覆盖更新
|
||||
if (existingIndex >= 0) {
|
||||
applyServerMessageUpdate(messages[existingIndex], message)
|
||||
if (existingIndex === messages.length - 1) {
|
||||
recomputeConversationLast(conversation, messages)
|
||||
syncConversationAtFlags(conversation, message)
|
||||
}
|
||||
this.updateMaxId(conversationInfo.type, message.id)
|
||||
this.updateMessageMaxId(conversationInfo.type, message.id)
|
||||
void getDb()
|
||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||
await this.persistMessage(messages[existingIndex], conversationInfo.type, tx)
|
||||
await conversationStore.persistConversations(conversation, tx)
|
||||
if (options?.persistMaxId !== false) {
|
||||
await this.persistMessageRecord(messages[existingIndex], conversationInfo.type, tx)
|
||||
await conversationStore.persistConversationRecords(conversation, tx)
|
||||
if (options?.saveMaxId !== false) {
|
||||
await setMessageMaxId(conversationInfo.type, message.id, tx)
|
||||
}
|
||||
})
|
||||
|
|
@ -556,6 +577,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
return
|
||||
}
|
||||
|
||||
// 4. 新消息更新会话摘要和未读状态
|
||||
applyConversationSummary(conversation, message)
|
||||
syncConversationAtFlags(conversation, message)
|
||||
|
||||
|
|
@ -572,6 +594,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
conversation.unreadCount++
|
||||
}
|
||||
|
||||
// 5. 新消息按 id 插入到内存数组
|
||||
let insertIndex = messages.length
|
||||
if (message.id) {
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
|
|
@ -583,12 +606,13 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
}
|
||||
}
|
||||
messages.splice(insertIndex, 0, message)
|
||||
this.updateMaxId(conversationInfo.type, message.id)
|
||||
this.updateMessageMaxId(conversationInfo.type, message.id)
|
||||
// 6. 单事务写入消息、会话摘要和游标
|
||||
void getDb()
|
||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||
await this.persistMessage(message, conversationInfo.type, tx)
|
||||
await conversationStore.persistConversations(conversation, tx)
|
||||
if (options?.persistMaxId !== false) {
|
||||
await this.persistMessageRecord(message, conversationInfo.type, tx)
|
||||
await conversationStore.persistConversationRecords(conversation, tx)
|
||||
if (options?.saveMaxId !== false) {
|
||||
await setMessageMaxId(conversationInfo.type, message.id, tx)
|
||||
}
|
||||
})
|
||||
|
|
@ -626,6 +650,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
clientMessageId: string,
|
||||
updates: Partial<Message>
|
||||
) {
|
||||
// 1. 定位待合并消息
|
||||
const conversationStore = useConversationStore()
|
||||
const conversation = conversationStore.getConversation(conversationType, targetId)
|
||||
if (!conversation) {
|
||||
|
|
@ -638,19 +663,22 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
}
|
||||
message._ackMerging = true
|
||||
try {
|
||||
// 2. 合并服务端 ack 到内存
|
||||
applyServerMessageUpdate(message, updates)
|
||||
if (messages[messages.length - 1] === message) {
|
||||
recomputeConversationLast(conversation, messages)
|
||||
}
|
||||
this.updateMaxId(conversationType, message.id)
|
||||
this.updateMessageMaxId(conversationType, message.id)
|
||||
// 3. 单事务写入消息、会话摘要和游标
|
||||
await getDb()
|
||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||
await this.persistMessage(message, conversationType, tx)
|
||||
await conversationStore.persistConversations(conversation, tx)
|
||||
await this.persistMessageRecord(message, conversationType, tx)
|
||||
await conversationStore.persistConversationRecords(conversation, tx)
|
||||
await setMessageMaxId(conversationType, message.id, tx)
|
||||
})
|
||||
.catch((e) => console.error('[IM messageStore] ack 写入失败', e))
|
||||
} finally {
|
||||
// 4. 清理合并标记
|
||||
message._ackMerging = false
|
||||
}
|
||||
},
|
||||
|
|
@ -662,7 +690,6 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
clientMessageId: string,
|
||||
patch: Partial<Message>
|
||||
) {
|
||||
// TODO @AI:代码段的注释;
|
||||
const message = this.getMessageList(conversationType, targetId).find(
|
||||
(item) => item.clientMessageId === clientMessageId
|
||||
)
|
||||
|
|
@ -688,18 +715,22 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
/** 撤回消息 */
|
||||
recallMessage(conversationType: number, targetId: number, recallSignalContent: string) {
|
||||
const conversationStore = useConversationStore()
|
||||
const changed = this.applyRecallInMemory(conversationType, targetId, recallSignalContent)
|
||||
const changed = this.applyRecallMessageInMemory(
|
||||
conversationType,
|
||||
targetId,
|
||||
recallSignalContent
|
||||
)
|
||||
if (!changed) {
|
||||
return
|
||||
}
|
||||
this.persistMessage(changed.message, conversationType).catch((e) =>
|
||||
this.persistMessageRecord(changed.message, conversationType).catch((e) =>
|
||||
console.error('[IM messageStore] 撤回消息写入失败', e)
|
||||
)
|
||||
conversationStore.saveConversations(changed.conversation)
|
||||
conversationStore.saveConversation(changed.conversation)
|
||||
},
|
||||
|
||||
/** 应用已读回执 */
|
||||
applyReadReceipt(options: {
|
||||
applyMessageReadReceipt(options: {
|
||||
conversationType: number
|
||||
targetId: number
|
||||
privateReadMaxId?: number
|
||||
|
|
@ -707,9 +738,9 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
readCount?: number
|
||||
receiptStatus?: number
|
||||
}) {
|
||||
// TODO @AI:代码段的注释;
|
||||
const messages = this.getMessageList(options.conversationType, options.targetId)
|
||||
const changed: Message[] = []
|
||||
// 1. 私聊回执批量更新自己发送的消息
|
||||
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
|
||||
messages.forEach((message) => {
|
||||
if (
|
||||
|
|
@ -723,6 +754,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
}
|
||||
})
|
||||
} else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
|
||||
// 2. 群聊回执更新单条消息
|
||||
const message = messages.find((item) => item.id === options.groupMessageId)
|
||||
if (message) {
|
||||
if (options.readCount !== undefined) {
|
||||
|
|
@ -734,16 +766,21 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
changed.push(message)
|
||||
}
|
||||
}
|
||||
changed.forEach((message) => {
|
||||
this.persistMessage(message, options.conversationType).catch((e) =>
|
||||
console.warn('[IM messageStore] 回执写入失败', e)
|
||||
)
|
||||
})
|
||||
if (changed.length === 0) {
|
||||
return
|
||||
}
|
||||
// 3. 单事务写入变更消息
|
||||
void getDb()
|
||||
.transaction(['messages'], 'readwrite', async (tx) => {
|
||||
for (const message of changed) {
|
||||
await this.persistMessageRecord(message, options.conversationType, tx)
|
||||
}
|
||||
})
|
||||
.catch((e) => console.warn('[IM messageStore] 回执写入失败', e))
|
||||
},
|
||||
|
||||
/** 前置历史消息 */
|
||||
prependMessages(conversationType: number, targetId: number, earlierMessages: Message[]) {
|
||||
// TODO @AI:代码段的注释;
|
||||
prependMessageList(conversationType: number, targetId: number, earlierMessages: Message[]) {
|
||||
if (earlierMessages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
|
@ -758,11 +795,13 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
}
|
||||
const key = getMessageCacheKey(conversationType, targetId)
|
||||
this.messagesByConversation[key] = [...fresh, ...messages]
|
||||
fresh.forEach((message) => {
|
||||
this.persistMessage(message, conversationType).catch((e) =>
|
||||
console.warn('[IM messageStore] 历史消息写入失败', e)
|
||||
)
|
||||
})
|
||||
void getDb()
|
||||
.transaction(['messages'], 'readwrite', async (tx) => {
|
||||
for (const message of fresh) {
|
||||
await this.persistMessageRecord(message, conversationType, tx)
|
||||
}
|
||||
})
|
||||
.catch((e) => console.warn('[IM messageStore] 历史消息写入失败', e))
|
||||
},
|
||||
|
||||
/** 删除单条消息 */
|
||||
|
|
@ -771,7 +810,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
targetId: number,
|
||||
key: { id?: number; clientMessageId?: string }
|
||||
) {
|
||||
// TODO @AI:代码段的注释;
|
||||
// 1. 定位会话和消息
|
||||
const conversationStore = useConversationStore()
|
||||
const conversation = conversationStore.getConversation(conversationType, targetId)
|
||||
if (!conversation) {
|
||||
|
|
@ -787,25 +826,26 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
// 2. 从内存移除消息
|
||||
const [removed] = messages.splice(index, 1)
|
||||
revokeBlobUrlsInContent(removed.content)
|
||||
if (index === messages.length) {
|
||||
recomputeConversationLast(conversation, messages)
|
||||
}
|
||||
// 3. 删除本地记录并保存会话摘要
|
||||
getDb()
|
||||
.delete('messages', getMessageKey(removed, conversationType))
|
||||
.catch((e) => console.warn('[IM messageStore] 消息删除失败', e))
|
||||
conversationStore.saveConversations()
|
||||
conversationStore.saveConversation(conversation)
|
||||
},
|
||||
|
||||
/** 当前会话标记已读 */
|
||||
markConversationMessagesRead(conversation: Conversation) {
|
||||
// TODO @AI:代码段的注释;
|
||||
const messages = this.getMessageList(conversation.type, conversation.targetId)
|
||||
messages.forEach((message) => {
|
||||
if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
|
||||
message.status = ImMessageStatus.READ
|
||||
this.persistMessage(message, conversation.type).catch((e) =>
|
||||
this.persistMessageRecord(message, conversation.type).catch((e) =>
|
||||
console.warn('[IM messageStore] 已读状态写入失败', e)
|
||||
)
|
||||
}
|
||||
|
|
@ -814,7 +854,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
|
||||
/** 删除会话全部消息 */
|
||||
deleteConversationMessages(conversationType: number, targetId: number) {
|
||||
// TODO @AI:代码段的注释;
|
||||
// 1. 清理内存消息和媒体资源
|
||||
const clientConversationId = getClientConversationId(conversationType, targetId)
|
||||
const messages = this.messagesByConversation[clientConversationId] || []
|
||||
messages.forEach((message) => {
|
||||
|
|
@ -825,6 +865,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
this.loadedConversationKeys = this.loadedConversationKeys.filter(
|
||||
(key) => key !== clientConversationId
|
||||
)
|
||||
// 2. 删除 IndexedDB 消息
|
||||
getDb()
|
||||
.deleteByIndex('messages', 'clientConversationId', clientConversationId)
|
||||
.catch((e) => console.warn('[IM messageStore] 会话消息删除失败', e))
|
||||
|
|
|
|||
|
|
@ -312,8 +312,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
)
|
||||
if (conversation) {
|
||||
conversation.unreadCount = 0
|
||||
conversationStore.saveConversation(conversation)
|
||||
}
|
||||
conversationStore.saveConversations()
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -416,7 +416,6 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
}
|
||||
} else if (isGroupRequestNotification(websocketMessage.type)) {
|
||||
// 加群申请通知(1503 / 1505 / 1506)走私聊通道,与好友通知同段位但分开 dispatcher
|
||||
// TODO @AI:改成走群聊通道。不然消息不好拉到!!!
|
||||
this.handleGroupRequestNotification(websocketMessage)
|
||||
} else {
|
||||
// TEXT / IMAGE / FILE / VOICE / VIDEO 等普通消息
|
||||
|
|
@ -585,7 +584,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
if (!websocketMessage.id) {
|
||||
return
|
||||
}
|
||||
useMessageStore().applyReadReceipt({
|
||||
useMessageStore().applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: websocketMessage.senderId,
|
||||
privateReadMaxId: websocketMessage.id
|
||||
|
|
@ -708,7 +707,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
if (!MESSAGE_GROUP_READ_ENABLED) {
|
||||
return
|
||||
}
|
||||
useMessageStore().applyReadReceipt({
|
||||
useMessageStore().applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.GROUP,
|
||||
targetId: websocketMessage.groupId,
|
||||
groupMessageId: websocketMessage.id,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,14 @@ export interface ImGroupMessageDTO {
|
|||
|
||||
// ==================== 本地会话 / 消息结构 ====================
|
||||
|
||||
/** 引用消息 */
|
||||
export interface QuoteMessage {
|
||||
messageId: number // 引用消息编号
|
||||
senderId: number // 引用消息发送人编号
|
||||
type: number // 引用消息类型
|
||||
content: string // 引用消息内容
|
||||
}
|
||||
|
||||
// 会话数据结构(前端自有结构,后端无对应实体)
|
||||
export interface Conversation {
|
||||
// ========== 核心标识 ==========
|
||||
|
|
@ -66,12 +74,16 @@ export interface Conversation {
|
|||
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
||||
atMe?: boolean // 群聊:是否有人 @我
|
||||
atAll?: boolean // 群聊:是否有人 @全体成员
|
||||
draft?: {
|
||||
html: string // 输入框 HTML
|
||||
plain: string // 输入框纯文本
|
||||
reply?: QuoteMessage // 引用消息
|
||||
} // 输入框草稿
|
||||
}
|
||||
|
||||
// 消息数据结构
|
||||
export interface Message {
|
||||
// ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO) ==========
|
||||
// TODO @AI:全局的 id 占位 0,是不是枚举下!!!
|
||||
id?: number // 服务端消息编号,发送中为空
|
||||
clientMessageId: string // 客户端消息编号,本地生成用于合并去重
|
||||
type: number // 消息类型,对齐 ImMessageType
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@ export type DbStoreName =
|
|||
| 'channels'
|
||||
| 'settings'
|
||||
|
||||
// TODO @AI:是不是继续使用 IDBTransaction,不用新的类型定义;
|
||||
export type DbTx = IDBTransaction
|
||||
export type DbTransaction = IDBTransaction
|
||||
|
||||
let currentDb: IDBDatabase | null = null
|
||||
let currentUserId: number | null = null
|
||||
|
|
@ -116,18 +115,18 @@ function upgradeSchema(db: IDBDatabase) {
|
|||
}
|
||||
|
||||
/** 打开 IM IndexedDB */
|
||||
// TODO @AI:是不是方法里,代码段的注释,是不是要增加下?
|
||||
function openDb(name: string): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(name, DB_SCHEMA_VERSION)
|
||||
// 创建或升级对象仓库
|
||||
request.onupgradeneeded = () => upgradeSchema(request.result)
|
||||
// 返回可复用连接
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
/** 初始化当前用户 IM DB */
|
||||
// TODO @AI:是不是方法里,代码段的注释,是不是要增加下?
|
||||
export async function initDb(): Promise<void> {
|
||||
const userId = getCurrentUserId()
|
||||
if (!Number.isFinite(userId) || userId <= 0) {
|
||||
|
|
@ -142,10 +141,8 @@ export async function initDb(): Promise<void> {
|
|||
currentDb = await openDb(getDbName(userId))
|
||||
}
|
||||
|
||||
/** 关闭当前 IM DB session */
|
||||
// TODO @AI:是不是需要被调用下???
|
||||
export function closeDbSession() {
|
||||
currentSession++
|
||||
/** 关闭当前 IM DB 连接 */
|
||||
function closeDbConnection() {
|
||||
currentDb?.close()
|
||||
currentDb = null
|
||||
currentUserId = null
|
||||
|
|
@ -171,27 +168,28 @@ function toDbValue<T>(value: T): T {
|
|||
return toRaw(value) as T
|
||||
}
|
||||
|
||||
// TODO @AI:我们讨论下,DbWrapper 会不会有点怪?
|
||||
// TODO @AI:是不是改成 selectOne、selectAll、selectList、insert、update、delete、save 这种。
|
||||
class DbWrapper {
|
||||
class DbClient {
|
||||
/** 获取单条记录 */
|
||||
// TODO @AI:是不是不用缩写,tx 改成 transaction 更好理解;
|
||||
async get<T>(storeName: DbStoreName, key: IDBValidKey, tx?: DbTx): Promise<T | undefined> {
|
||||
async get<T>(
|
||||
storeName: DbStoreName,
|
||||
key: IDBValidKey,
|
||||
tx?: DbTransaction
|
||||
): Promise<T | undefined> {
|
||||
if (tx) {
|
||||
return requestToPromise<T | undefined>(tx.objectStore(storeName).get(key))
|
||||
}
|
||||
return this.transaction<T | undefined>([storeName], 'readonly', (innerTx) =>
|
||||
this.get<T>(storeName, key, innerTx)
|
||||
return this.transaction<T | undefined>([storeName], 'readonly', (tx) =>
|
||||
this.get<T>(storeName, key, tx)
|
||||
)
|
||||
}
|
||||
|
||||
/** 获取 store 全量记录 */
|
||||
async getAll<T>(storeName: DbStoreName, tx?: DbTx): Promise<T[]> {
|
||||
async getAll<T>(storeName: DbStoreName, tx?: DbTransaction): Promise<T[]> {
|
||||
if (tx) {
|
||||
return requestToPromise<T[]>(tx.objectStore(storeName).getAll())
|
||||
}
|
||||
return this.transaction<T[]>([storeName], 'readonly', (innerTx) =>
|
||||
this.getAll<T>(storeName, innerTx)
|
||||
return this.transaction<T[]>([storeName], 'readonly', (tx) =>
|
||||
this.getAll<T>(storeName, tx)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -200,13 +198,13 @@ class DbWrapper {
|
|||
storeName: DbStoreName,
|
||||
indexName: string,
|
||||
query: IDBValidKey | IDBKeyRange,
|
||||
tx?: DbTx
|
||||
tx?: DbTransaction
|
||||
): Promise<T | undefined> {
|
||||
if (tx) {
|
||||
return requestToPromise<T | undefined>(tx.objectStore(storeName).index(indexName).get(query))
|
||||
}
|
||||
return this.transaction<T | undefined>([storeName], 'readonly', (innerTx) =>
|
||||
this.getByIndex<T>(storeName, indexName, query, innerTx)
|
||||
return this.transaction<T | undefined>([storeName], 'readonly', (tx) =>
|
||||
this.getByIndex<T>(storeName, indexName, query, tx)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -215,35 +213,33 @@ class DbWrapper {
|
|||
storeName: DbStoreName,
|
||||
indexName: string,
|
||||
query?: IDBValidKey | IDBKeyRange,
|
||||
tx?: DbTx
|
||||
tx?: DbTransaction
|
||||
): Promise<T[]> {
|
||||
if (tx) {
|
||||
return requestToPromise<T[]>(tx.objectStore(storeName).index(indexName).getAll(query))
|
||||
}
|
||||
return this.transaction<T[]>([storeName], 'readonly', (innerTx) =>
|
||||
this.getAllByIndex<T>(storeName, indexName, query, innerTx)
|
||||
return this.transaction<T[]>([storeName], 'readonly', (tx) =>
|
||||
this.getAllByIndex<T>(storeName, indexName, query, tx)
|
||||
)
|
||||
}
|
||||
|
||||
/** 写入记录 */
|
||||
async put<T>(storeName: DbStoreName, value: T, tx?: DbTx): Promise<void> {
|
||||
async put<T>(storeName: DbStoreName, value: T, tx?: DbTransaction): Promise<void> {
|
||||
if (tx) {
|
||||
await requestToPromise(tx.objectStore(storeName).put(toDbValue(value)))
|
||||
return
|
||||
}
|
||||
await this.transaction([storeName], 'readwrite', (innerTx) =>
|
||||
this.put(storeName, value, innerTx)
|
||||
)
|
||||
await this.transaction([storeName], 'readwrite', (tx) => this.put(storeName, value, tx))
|
||||
}
|
||||
|
||||
/** 删除记录 */
|
||||
async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTx): Promise<void> {
|
||||
async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTransaction): Promise<void> {
|
||||
if (tx) {
|
||||
await requestToPromise(tx.objectStore(storeName).delete(key))
|
||||
return
|
||||
}
|
||||
await this.transaction([storeName], 'readwrite', (innerTx) =>
|
||||
this.delete(storeName, key, innerTx)
|
||||
await this.transaction([storeName], 'readwrite', (tx) =>
|
||||
this.delete(storeName, key, tx)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -252,11 +248,11 @@ class DbWrapper {
|
|||
storeName: DbStoreName,
|
||||
indexName: string,
|
||||
query: IDBValidKey | IDBKeyRange,
|
||||
tx?: DbTx
|
||||
tx?: DbTransaction
|
||||
): Promise<void> {
|
||||
if (!tx) {
|
||||
await this.transaction([storeName], 'readwrite', (innerTx) =>
|
||||
this.deleteByIndex(storeName, indexName, query, innerTx)
|
||||
await this.transaction([storeName], 'readwrite', (tx) =>
|
||||
this.deleteByIndex(storeName, indexName, query, tx)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
|
@ -277,39 +273,38 @@ class DbWrapper {
|
|||
}
|
||||
|
||||
/** 执行事务 */
|
||||
// TODO @AI:方法里的方法段的注释,需要写么?
|
||||
async transaction<T>(
|
||||
storeNames: DbStoreName[],
|
||||
mode: IDBTransactionMode,
|
||||
runner: (tx: DbTx) => Promise<T>
|
||||
runner: (tx: DbTransaction) => Promise<T>
|
||||
): Promise<T> {
|
||||
// 开启事务前校验 session
|
||||
const session = getDbSession()
|
||||
guardSession(session)
|
||||
const tx = getRawDb().transaction(storeNames, mode)
|
||||
const done = transactionDone(tx)
|
||||
let result: T
|
||||
try {
|
||||
// 事务内只执行 IndexedDB request 链
|
||||
result = await runner(tx)
|
||||
} catch (e) {
|
||||
// TODO @AI:这种 logger error 要打印么?
|
||||
try {
|
||||
tx.abort()
|
||||
} catch {}
|
||||
await done.catch(() => undefined)
|
||||
throw e
|
||||
}
|
||||
// commit 后再次校验 session
|
||||
await done
|
||||
guardSession(session)
|
||||
return result
|
||||
}
|
||||
|
||||
/** 按会话分页获取消息 */
|
||||
// TODO @AI:这个方法里,代码段的注释,是不是要增加下?比如说,分页的逻辑,游标的逻辑,等等;
|
||||
// TODO @AI:项目里,一般方法名,是使用 getListByXXXX;
|
||||
async getMessagesByConversation(
|
||||
async getMessageListByConversation(
|
||||
clientConversationId: string,
|
||||
options?: { beforeSendTime?: number; limit?: number },
|
||||
tx?: DbTx
|
||||
tx?: DbTransaction
|
||||
): Promise<MessageDO[]> {
|
||||
const limit = options?.limit ?? 50
|
||||
const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER
|
||||
|
|
@ -319,10 +314,11 @@ class DbWrapper {
|
|||
false,
|
||||
true
|
||||
)
|
||||
const read = async (innerTx: DbTx): Promise<MessageDO[]> => {
|
||||
const index = innerTx.objectStore('messages').index('clientConversationId+sendTime')
|
||||
const read = async (tx: DbTransaction): Promise<MessageDO[]> => {
|
||||
const index = tx.objectStore('messages').index('clientConversationId+sendTime')
|
||||
const out: MessageDO[] = []
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// 从新到旧读取一页
|
||||
const request = index.openCursor(range, 'prev')
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
|
|
@ -335,6 +331,7 @@ class DbWrapper {
|
|||
cursor.continue()
|
||||
}
|
||||
})
|
||||
// 气泡渲染需要按时间升序
|
||||
return out.reverse()
|
||||
}
|
||||
if (tx) {
|
||||
|
|
@ -344,22 +341,22 @@ class DbWrapper {
|
|||
}
|
||||
|
||||
/** 读取设置 */
|
||||
async getSetting<T>(key: string, tx?: DbTx): Promise<T | undefined> {
|
||||
async getSetting<T>(key: string, tx?: DbTransaction): Promise<T | undefined> {
|
||||
const item = await this.get<SettingDO<T>>('settings', key, tx)
|
||||
return item?.value
|
||||
}
|
||||
|
||||
/** 写入设置 */
|
||||
async setSetting<T>(key: string, value: T, tx?: DbTx): Promise<void> {
|
||||
async setSetting<T>(key: string, value: T, tx?: DbTransaction): Promise<void> {
|
||||
await this.put<SettingDO<T>>('settings', { key, value, updateTime: Date.now() }, tx)
|
||||
}
|
||||
}
|
||||
|
||||
const dbWrapper = new DbWrapper()
|
||||
const dbClient = new DbClient()
|
||||
|
||||
/** 获取当前 IM DB wrapper */
|
||||
export function getDb(): DbWrapper {
|
||||
return dbWrapper
|
||||
/** 获取当前 IM DB client */
|
||||
export function getDb(): DbClient {
|
||||
return dbClient
|
||||
}
|
||||
|
||||
/** 当前用户会话主键 */
|
||||
|
|
@ -375,7 +372,6 @@ export function parseClientConversationId(
|
|||
const type = Number(typeText)
|
||||
const targetId = Number(targetIdText)
|
||||
if (!Number.isFinite(type) || !Number.isFinite(targetId) || targetId <= 0) {
|
||||
// TODO @AI:logger info?
|
||||
return null
|
||||
}
|
||||
return { type, targetId }
|
||||
|
|
@ -392,7 +388,6 @@ export function getClientMessageKey(clientMessageId: string): string {
|
|||
}
|
||||
|
||||
/** 解析本地消息主键 */
|
||||
// TODO @AI:这个方法,貌似没调用;
|
||||
export function parseMessageKey(
|
||||
messageKey: string
|
||||
):
|
||||
|
|
@ -419,7 +414,7 @@ export function parseMessageKey(
|
|||
export async function setMessageMaxId(
|
||||
conversationType: number,
|
||||
maxId: number | undefined,
|
||||
tx?: DbTx
|
||||
tx?: DbTransaction
|
||||
): Promise<void> {
|
||||
if (!maxId) {
|
||||
return
|
||||
|
|
@ -446,7 +441,6 @@ export async function setMessageMaxId(
|
|||
}
|
||||
|
||||
/** 停止当前 IM DB session */
|
||||
// TODO @AI:这里的注释,要写下;方法注释;
|
||||
export async function stopRequests(): Promise<void> {
|
||||
const [
|
||||
{ useMessageStoreWithOut },
|
||||
|
|
@ -455,7 +449,6 @@ export async function stopRequests(): Promise<void> {
|
|||
{ useGroupStoreWithOut },
|
||||
{ useChannelStoreWithOut },
|
||||
{ useGroupRequestStoreWithOut },
|
||||
{ useDraftStoreWithOut },
|
||||
{ useFaceStoreWithOut }
|
||||
] = await Promise.all([
|
||||
import('../home/store/messageStore'),
|
||||
|
|
@ -464,7 +457,6 @@ export async function stopRequests(): Promise<void> {
|
|||
import('../home/store/groupStore'),
|
||||
import('../home/store/channelStore'),
|
||||
import('../home/store/groupRequestStore'),
|
||||
import('../home/store/draftStore'),
|
||||
import('../home/store/faceStore')
|
||||
])
|
||||
currentSession++
|
||||
|
|
@ -474,9 +466,6 @@ export async function stopRequests(): Promise<void> {
|
|||
useGroupStoreWithOut().clear()
|
||||
useChannelStoreWithOut().clear()
|
||||
useGroupRequestStoreWithOut().reset()
|
||||
useDraftStoreWithOut().clear()
|
||||
useFaceStoreWithOut().reset()
|
||||
currentDb?.close()
|
||||
currentDb = null
|
||||
currentUserId = null
|
||||
closeDbConnection()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import { getCurrentUserId } from './storage'
|
|||
import { formatCallDuration } from './time'
|
||||
import { useFriendStore } from '../home/store/friendStore'
|
||||
import { useGroupStore } from '../home/store/groupStore'
|
||||
import type { Conversation, Message, User, GroupLite } from '../home/types'
|
||||
import type { Conversation, Message, User, GroupLite, QuoteMessage } from '../home/types'
|
||||
|
||||
export type { QuoteMessage } from '../home/types'
|
||||
|
||||
// ====================================================================
|
||||
// IM 消息 content 编解码 & 展示工具
|
||||
|
|
@ -177,14 +179,6 @@ export function parseTextSegments(text: string, mentions: MentionCandidate[] = [
|
|||
|
||||
// ==================== 引用消息 ====================
|
||||
|
||||
/** 引用消息 payload(对齐后端 QuoteMessage) */
|
||||
export interface QuoteMessage {
|
||||
messageId: number
|
||||
senderId: number
|
||||
type: number
|
||||
content: string
|
||||
}
|
||||
|
||||
/** 引用容器:5 种普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO)都可携带 quote */
|
||||
interface Quotable {
|
||||
quote?: QuoteMessage
|
||||
|
|
|
|||
|
|
@ -21,13 +21,6 @@ export const imStorage = localforage.createInstance({
|
|||
* 所有业务 key 都注入 userId:多账号切换按用户隔离避免数据互串;账号切换时只清 in-memory、IDB 数据保留——回切旧账号能秒开,不浪费已下载好友 / 群 / 成员快照
|
||||
*/
|
||||
export const StorageKeys = {
|
||||
/**
|
||||
* 输入框草稿整桶:Record<`${type}:${targetId}`, DraftSnapshot>
|
||||
*
|
||||
* 草稿端本地、量级小(每会话至多几百字节),整桶整写够用;持久化按 userId 分桶与其它业务一致
|
||||
*/
|
||||
drafts: (userId: number | string) => `drafts:${userId}`,
|
||||
|
||||
/** 好友列表整桶(含 DISABLE 软删记录);好友量级有限,不维护增量 */
|
||||
friends: (userId: number | string) => `friends:${userId}`,
|
||||
/** 群列表整桶(不含 members,剥离到独立 key),保证整桶写不带成员爆量 */
|
||||
|
|
@ -37,10 +30,6 @@ export const StorageKeys = {
|
|||
/** 单群成员,按 groupId 分桶——单群可上百-千级,跟懒加载粒度对齐;群解散时物理删 */
|
||||
groupMembers: (userId: number | string, groupId: number) => `groupMembers:${userId}:${groupId}`,
|
||||
|
||||
/** 最近转发会话 key 列表(按 userId 分桶);ConversationPickerPanel 左栏顶部头像区使用 */
|
||||
recentForwardConversationKeys: (userId: number | string) =>
|
||||
`recentForwardConversationKeys:${userId}`,
|
||||
|
||||
/** 侧边栏宽度(localStorage);三个 Tab 共用一份记忆,对齐微信(拖一次到处一致)。 */
|
||||
asideWidth: 'im:aside',
|
||||
/** 会话列表置顶折叠展开态(localStorage);轻量 UI 偏好。 */
|
||||
|
|
@ -65,7 +54,6 @@ export function removeQuietly(key: string, errorLabel: string): void {
|
|||
}
|
||||
|
||||
/** 转换为 IndexedDB 可存储的数据 */
|
||||
// TODO @AI:后续,是不是可以删除掉?尽量使用 db.ts 对不对哈?
|
||||
function toStorageValue<T>(value: T, seen = new WeakMap<object, unknown>()): T {
|
||||
const raw = value && typeof value === 'object' ? toRaw(value) : value
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue