diff --git a/apps/web-antd/src/views/im/home/pages/conversation/components/message/message-panel.vue b/apps/web-antd/src/views/im/home/pages/conversation/components/message/message-panel.vue index afffdbd71..02dfe1476 100644 --- a/apps/web-antd/src/views/im/home/pages/conversation/components/message/message-panel.vue +++ b/apps/web-antd/src/views/im/home/pages/conversation/components/message/message-panel.vue @@ -486,6 +486,56 @@ function waitMediaSettled(): Promise { return Promise.race([loadAll, timeout]); } +/** 加载并定位当前会话的未读 @ 消息 */ +async function handleLocateMention() { + const messageId = conversationStore.consumeActiveMentionMessageId(); + const conversation = conversationStore.activeConversation; + if (!messageId || !conversation) { + return; + } + const clientConversationId = getClientConversationId( + conversation.type, + conversation.targetId, + ); + const isActive = () => { + const activeConversation = conversationStore.activeConversation; + return ( + !!activeConversation && + getClientConversationId( + activeConversation.type, + activeConversation.targetId, + ) === clientConversationId + ); + }; + for (let guard = 0; guard < 50; guard++) { + const loadedMessages = messageStore.getMessages(clientConversationId); + if (loadedMessages.some((item) => item.id === messageId)) { + break; + } + const { hasMore } = await messageStore.loadMoreMessageList( + clientConversationId, + 50, + ); + if (!isActive()) { + return; + } + if ( + messageStore + .getMessages(clientConversationId) + .some((item) => item.id === messageId) + ) { + break; + } + if (!hasMore) { + break; + } + } + if (!isActive()) { + return; + } + await handleLocate(messageId, isActive); +} + /** * 定位到聊天位置:MessageHistory 行上"定位"按钮 / 气泡内引用块点击触发 * @@ -495,11 +545,14 @@ function waitMediaSettled(): Promise { * 4. 加 --highlight class 短暂高亮,提示用户"就是这条" * 5. 找不到 wrapper(原消息已分页出去)时弹 warning 提示,与微信"消息已不在窗口"观感一致 */ -async function handleLocate(messageId: number) { +async function handleLocate(messageId: number, isActive?: () => boolean) { if (!messageId) { return; } await nextTick(); + if (isActive && !isActive()) { + return; + } if (!listRef.value) { return; } @@ -733,6 +786,17 @@ watch( /> + + +
+ 查看 @消息 +
+
+
>, -): number { - let maxMessageId = 0; +/** 按读位置重算会话未读与 @ 状态 */ +function applyConversationUnreadState( + conversation: Conversation, + messages: MessageDO[], + readMessageId: number, +): boolean { + const currentUserId = getCurrentUserId(); + let unreadCount = 0; + let atMessageId: number | undefined; + let atAllMessageId: number | undefined; for (const message of messages) { if ( - message.id && - !message.selfSend && - isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL && - message.id > maxMessageId + !message.id || + message.id <= readMessageId || + message.selfSend || + !isNormalMessage(message.type) || + message.status === ImMessageStatus.RECALL ) { - maxMessageId = message.id; + continue; + } + unreadCount++; + if ( + currentUserId && + message.atUserIds?.includes(currentUserId) && + message.id > (atMessageId || 0) + ) { + atMessageId = message.id; + } + if ( + message.atUserIds?.includes(IM_AT_ALL_USER_ID) && + message.id > (atAllMessageId || 0) + ) { + atAllMessageId = message.id; } } - return maxMessageId; + const changed = + conversation.unreadCount !== unreadCount || + conversation.atMe !== !!atMessageId || + conversation.atAll !== !!atAllMessageId || + conversation.atMessageId !== atMessageId || + conversation.atAllMessageId !== atAllMessageId; + conversation.unreadCount = unreadCount; + conversation.atMe = !!atMessageId; + conversation.atAll = !!atAllMessageId; + conversation.atMessageId = atMessageId; + conversation.atAllMessageId = atAllMessageId; + return changed; +} + +/** 无读位置时按原未读窗口应用撤回状态 */ +function applyConversationRecallStateWithoutRead( + conversation: Conversation, + messages: MessageDO[], + originalMessage: MessageDO, +): boolean { + const previousUnreadCount = conversation.unreadCount; + const originalWasIncomingNormal = + !!originalMessage.id && + !originalMessage.selfSend && + isNormalMessage(originalMessage.type) && + originalMessage.status !== ImMessageStatus.RECALL; + const incomingNormalMessages = messages + .filter( + (message) => + !!message.id && + !message.selfSend && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL, + ) + .toSorted((left, right) => (right.id || 0) - (left.id || 0)); + const previousUnreadMessages = [ + ...incomingNormalMessages.filter( + (message) => message.id !== originalMessage.id, + ), + ...(originalWasIncomingNormal ? [originalMessage] : []), + ] + .toSorted((left, right) => (right.id || 0) - (left.id || 0)) + .slice(0, previousUnreadCount); + const recalledUnread = previousUnreadMessages.some( + (message) => message.id === originalMessage.id, + ); + const unreadCount = Math.max( + 0, + previousUnreadCount - (recalledUnread ? 1 : 0), + ); + const unreadMessages = incomingNormalMessages.slice(0, unreadCount); + const currentUserId = getCurrentUserId(); + let atMessageId = conversation.atMessageId; + let atAllMessageId = conversation.atAllMessageId; + if ( + conversation.atMessageId === originalMessage.id || + (!conversation.atMessageId && + conversation.atMe && + !!currentUserId && + originalMessage.atUserIds?.includes(currentUserId)) + ) { + atMessageId = unreadMessages.find((message) => + message.atUserIds?.includes(currentUserId), + )?.id; + } + if ( + conversation.atAllMessageId === originalMessage.id || + (!conversation.atAllMessageId && + conversation.atAll && + originalMessage.atUserIds?.includes(IM_AT_ALL_USER_ID)) + ) { + atAllMessageId = unreadMessages.find((message) => + message.atUserIds?.includes(IM_AT_ALL_USER_ID), + )?.id; + } + const changed = + conversation.unreadCount !== unreadCount || + conversation.atMe !== !!atMessageId || + conversation.atAll !== !!atAllMessageId || + conversation.atMessageId !== atMessageId || + conversation.atAllMessageId !== atAllMessageId; + conversation.unreadCount = unreadCount; + conversation.atMe = !!atMessageId; + conversation.atAll = !!atAllMessageId; + conversation.atMessageId = atMessageId; + conversation.atAllMessageId = atAllMessageId; + return changed; +} + +/** 为旧会话回填未读 @ 消息编号 */ +function backfillConversationMentionIds( + conversation: Conversation, + messages: MessageDO[], +): boolean { + const currentUserId = getCurrentUserId(); + const unreadMessages = messages + .filter( + (message) => + !!message.id && + !message.selfSend && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL, + ) + .toSorted((left, right) => (right.id || 0) - (left.id || 0)) + .slice(0, conversation.unreadCount); + const atMessageId = + conversation.atMessageId || + (conversation.atMe && currentUserId + ? unreadMessages.find((message) => + message.atUserIds?.includes(currentUserId), + )?.id + : undefined); + const atAllMessageId = + conversation.atAllMessageId || + (conversation.atAll + ? unreadMessages.find((message) => + message.atUserIds?.includes(IM_AT_ALL_USER_ID), + )?.id + : undefined); + const changed = + conversation.atMessageId !== atMessageId || + conversation.atAllMessageId !== atAllMessageId; + conversation.atMessageId = atMessageId; + conversation.atAllMessageId = atAllMessageId; + return changed; } export const useConversationStore = defineStore('imConversationStore', { @@ -161,6 +307,7 @@ export const useConversationStore = defineStore('imConversationStore', { conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊 + 频道) conversationReads: {} as Record, // 会话读位置 activeConversation: null as Conversation | null, // 当前激活的会话 + activeMentionMessageId: undefined as number | undefined, // 当前会话待定位的未读 @ 消息编号 loading: false, // 是否正在批量加载 recentForwardConversationKeys: [] as string[], // 最近转发会话 key 列表 }), @@ -271,6 +418,7 @@ export const useConversationStore = defineStore('imConversationStore', { this.conversations = []; this.conversationReads = {}; this.activeConversation = null; + this.activeMentionMessageId = undefined; this.recentForwardConversationKeys = []; }, @@ -312,18 +460,10 @@ export const useConversationStore = defineStore('imConversationStore', { conversation.type, conversation.targetId, ); - if (!record) { - continue; - } - if (this.applyReadToConversation(conversation, record.messageId)) { - changedConversations.push(conversation); - continue; - } - if ( - conversation.unreadCount === 0 && - !conversation.atMe && - !conversation.atAll - ) { + const needsMentionBackfill = + (conversation.atMe && !conversation.atMessageId) || + (conversation.atAll && !conversation.atAllMessageId); + if (!record && !needsMentionBackfill) { continue; } const messages = await getDb().getAllByIndex( @@ -331,14 +471,14 @@ export const useConversationStore = defineStore('imConversationStore', { 'clientConversationId', getClientConversationId(conversation.type, conversation.targetId), ); - const maxIncomingMessageId = getMaxIncomingNormalMessageId(messages); - if ( - maxIncomingMessageId > 0 && - maxIncomingMessageId <= record.messageId - ) { - conversation.unreadCount = 0; - conversation.atMe = false; - conversation.atAll = false; + const changed = record + ? this.applyReadToConversation( + conversation, + record.messageId, + messages, + ) + : backfillConversationMentionIds(conversation, messages); + if (changed) { changedConversations.push(conversation); } } @@ -392,24 +532,28 @@ export const useConversationStore = defineStore('imConversationStore', { applyReadToConversation( conversation: Conversation, messageId: number, + messages: MessageDO[], ): boolean { - if ( - !conversation.lastMessageId || - conversation.lastMessageId > messageId - ) { - return false; - } - if ( - conversation.unreadCount === 0 && - !conversation.atMe && - !conversation.atAll - ) { - return false; - } - conversation.unreadCount = 0; - conversation.atMe = false; - conversation.atAll = false; - return true; + return applyConversationUnreadState(conversation, messages, messageId); + }, + + /** 应用撤回后的会话未读与 @ 状态 */ + applyRecallToConversation( + conversation: Conversation, + messages: MessageDO[], + originalMessage: MessageDO, + ): boolean { + const read = this.getConversationRead( + conversation.type, + conversation.targetId, + ); + return read + ? applyConversationUnreadState(conversation, messages, read.messageId) + : applyConversationRecallStateWithoutRead( + conversation, + messages, + originalMessage, + ); }, /** 应用会话读位置 */ @@ -475,19 +619,13 @@ export const useConversationStore = defineStore('imConversationStore', { if ( conversation && - this.applyReadToConversation(conversation, messageId) + this.applyReadToConversation( + conversation, + messageId, + await getStoredMessages(), + ) ) { changedConversations.set(clientConversationId, conversation); - } else if (conversation) { - const maxIncomingMessageId = getMaxIncomingNormalMessageId( - await getStoredMessages(), - ); - if (maxIncomingMessageId > 0 && maxIncomingMessageId <= messageId) { - conversation.unreadCount = 0; - conversation.atMe = false; - conversation.atAll = false; - changedConversations.set(clientConversationId, conversation); - } } if (record.conversationType !== ImConversationType.CHANNEL) { continue; @@ -693,6 +831,8 @@ export const useConversationStore = defineStore('imConversationStore', { /** 设置当前会话 */ setActiveConversation(conversation: Conversation | null) { + this.activeMentionMessageId = + conversation?.atMessageId || conversation?.atAllMessageId; this.activeConversation = conversation; if (!conversation) { return; @@ -702,6 +842,13 @@ export const useConversationStore = defineStore('imConversationStore', { this.saveConversation(conversation); }, + /** 消费当前会话待定位的未读 @ 消息编号 */ + consumeActiveMentionMessageId(): number | undefined { + const messageId = this.activeMentionMessageId; + this.activeMentionMessageId = undefined; + return messageId; + }, + /** 创建空会话 */ createEmptyConversation( type: number, @@ -755,6 +902,7 @@ export const useConversationStore = defineStore('imConversationStore', { } if (this.activeConversation === conversation) { this.activeConversation = null; + this.activeMentionMessageId = undefined; } conversation.deleted = true; // 2. 删除会话关联的消息和草稿 @@ -798,6 +946,8 @@ export const useConversationStore = defineStore('imConversationStore', { conversation.unreadCount = 0; conversation.atMe = false; conversation.atAll = false; + conversation.atMessageId = undefined; + conversation.atAllMessageId = undefined; if (readMessageIdAdvanced) { const record = createConversationRead(type, targetId, messageId); this.conversationReads[key] = record; diff --git a/apps/web-antd/src/views/im/home/store/messageStore.ts b/apps/web-antd/src/views/im/home/store/messageStore.ts index b91d23f54..a9a424936 100644 --- a/apps/web-antd/src/views/im/home/store/messageStore.ts +++ b/apps/web-antd/src/views/im/home/store/messageStore.ts @@ -1,4 +1,7 @@ -import type { DbTransaction } from '../../utils/db'; +import type { + DbTransaction, + MessageDOPageCursor, +} from '../../utils/db'; import type { Conversation, Message, MessageDO } from '../types'; import { acceptHMRUpdate, defineStore } from 'pinia'; @@ -50,6 +53,11 @@ interface PersistMessageRecordOptions { mergeClientRecord?: boolean; } +interface MessagePageResult { + messages: Message[]; + hasMore: boolean; +} + /** 拉取消息批量处理项 */ export type PulledMessage = | { @@ -79,6 +87,27 @@ function getMessageKey( : getClientMessageKey(message.clientMessageId); } +/** 获取数据库分页结果的最早游标 */ +function getMessageDOPageCursor(message?: MessageDO): MessageDOPageCursor | undefined { + return message + ? { + messageKey: message.messageKey, + sendTime: message.sendTime, + } + : undefined; +} + +/** 判断两个消息分页游标是否一致 */ +function isSameMessageDOPageCursor( + left?: MessageDOPageCursor, + right?: MessageDOPageCursor, +): boolean { + if (!left || !right) { + return left === right; + } + return left.sendTime === right.sendTime && left.messageKey === right.messageKey; +} + /** 补齐客户端消息编号 */ function ensureClientMessageId(message: Message): Message { if (!message.clientMessageId) { @@ -237,9 +266,15 @@ function syncConversationAtFlags( const currentUserId = getCurrentUserId(); if (currentUserId && message.atUserIds.includes(currentUserId)) { conversation.atMe = true; + if (message.id && message.id > (conversation.atMessageId || 0)) { + conversation.atMessageId = message.id; + } } if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) { conversation.atAll = true; + if (message.id && message.id > (conversation.atAllMessageId || 0)) { + conversation.atAllMessageId = message.id; + } } } @@ -279,6 +314,10 @@ function isSameMessage(left: Message, right: Message): boolean { export const useMessageStore = defineStore('imMessageStore', { state: () => ({ messagesByConversation: {} as Record, + messageDOPageCursors: {} as Record< + string, + MessageDOPageCursor | undefined + >, loadedConversationKeys: [] as string[], privateReadMaxIds: {} as Partial>, privateMessageMaxId: 0, @@ -304,6 +343,7 @@ export const useMessageStore = defineStore('imMessageStore', { }); }); this.messagesByConversation = {}; + this.messageDOPageCursors = {}; this.loadedConversationKeys = []; this.privateReadMaxIds = {}; this.privateMessageMaxId = 0; @@ -397,29 +437,43 @@ export const useMessageStore = defineStore('imMessageStore', { this.loadedConversationKeys = retained; removed.forEach((key) => { Reflect.deleteProperty(this.messagesByConversation, key); + Reflect.deleteProperty(this.messageDOPageCursors, key); }); }, /** 加载当前会话最近消息 */ async loadMoreMessageList( clientConversationId: string, - beforeSendTime?: number, limit = 50, - ): Promise { + ): Promise { + const parsed = parseClientConversationId(clientConversationId); + if (!parsed) { + return { messages: [], hasMore: false }; + } + const before = this.messageDOPageCursors[clientConversationId]; // 1. 从 IndexedDB 倒序读取一页,返回前已按时间升序排列 - const list = await getDb().getMessageListByConversation( + const page = await getDb().getMessageListByConversation( clientConversationId, { - beforeSendTime, + before, limit, }, ); - // 2. 合并到内存缓存,过滤已存在的消息 - const parsed = parseClientConversationId(clientConversationId); - if (!parsed) { - return []; + if ( + isSameMessageDOPageCursor( + this.messageDOPageCursors[clientConversationId], + before, + ) + ) { + const nextCursor = getMessageDOPageCursor(page.list[0]); + if (nextCursor) { + this.messageDOPageCursors[clientConversationId] = nextCursor; + } } - const messages = list.map((message) => buildMessageFromDO(message)); + // 2. 合并到内存缓存,过滤已存在的消息 + const messages = page.list.map((message) => + buildMessageFromDO(message), + ); const existing = this.messagesByConversation[clientConversationId] || []; const existingKeys = new Set( existing.map((message) => getMessageKey(message, parsed.type)), @@ -435,7 +489,7 @@ export const useMessageStore = defineStore('imMessageStore', { (messageA.sendTime || 0) - (messageB.sendTime || 0), ); this.touchConversationMessageCache(clientConversationId); - return fresh; + return { messages: fresh, hasMore: page.hasMore }; }, /** 确保会话消息已加载 */ @@ -492,13 +546,14 @@ export const useMessageStore = defineStore('imMessageStore', { this.updateMessageCursor(conversationType, messageId); }, - /** 应用撤回到内存 */ - applyRecallMessageInMemory( + /** 应用撤回到本地消息与会话状态 */ + async applyRecallMessageRecord( conversationType: number, targetId: number, recallSignalContent: string, + tx: DbTransaction, ) { - // 1. 定位被撤回的原消息 + // 1. 定位被撤回的原消息和会话 const messageId = parseRecallMessageId(recallSignalContent); if (!messageId) { return null; @@ -511,19 +566,48 @@ export const useMessageStore = defineStore('imMessageStore', { if (!conversation) { return null; } - const messages = this.getMessageList(conversationType, targetId); - const message = messages.find((item) => item.id === messageId); - if (!message) { + const clientConversationId = getClientConversationId( + conversationType, + targetId, + ); + const cachedMessage = this.messagesByConversation[ + clientConversationId + ]?.find((item) => item.id === messageId); + const storedMessage = await getDb().get( + 'messages', + getServerMessageKey(conversationType, messageId), + tx, + ); + const originalMessage = cachedMessage + ? buildMessageDO(cachedMessage, conversationType) + : storedMessage; + if (!originalMessage) { return null; } - // 2. 更新消息和会话摘要 - message.type = ImContentType.RECALL; - message.status = ImMessageStatus.RECALL; - message.content = ''; - if (messages[messages.length - 1]?.id === messageId) { - recomputeConversationLast(conversation, messages); + // 2. 更新内存消息与数据库记录 + const recalledMessage = + cachedMessage || buildMessageFromDO(originalMessage); + revokeBlobUrlsInContent(recalledMessage.content); + recalledMessage.type = ImContentType.RECALL; + recalledMessage.status = ImMessageStatus.RECALL; + recalledMessage.content = ''; + await this.saveMessageRecord(recalledMessage, conversationType, tx); + // 3. 按本地完整消息和读位置重算未读与 @ 状态 + const storedMessages = await getDb().getAllByIndex( + 'messages', + 'clientConversationId', + clientConversationId, + tx, + ); + conversationStore.applyRecallToConversation( + conversation, + storedMessages, + originalMessage, + ); + if (conversation.lastMessageId === messageId) { + applyConversationSummary(conversation, recalledMessage); } - return { conversation, message }; + return { conversation, message: recalledMessage }; }, /** 批量写入拉取消息 */ @@ -547,6 +631,10 @@ export const useMessageStore = defineStore('imMessageStore', { } >(); const changedConversations = new Map(); + const recallMessages: Extract< + PulledMessage, + { kind: 'recall' } + >[] = []; const addChanged = ( conversation: Conversation, @@ -568,15 +656,8 @@ export const useMessageStore = defineStore('imMessageStore', { // 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); - } + // 1.1 撤回信号在事务内读取原消息后统一处理 + recallMessages.push(pulledMessage); continue; } @@ -591,6 +672,20 @@ export const useMessageStore = defineStore('imMessageStore', { conversationInfo.type, conversationInfo.targetId, ); + const isActive = + conversationStore.activeConversation?.type === + conversationInfo.type && + conversationStore.activeConversation?.targetId === + conversationInfo.targetId; + const isUnread = + !message.selfSend && + !isActive && + !conversationStore.isMessageCoveredByReadPosition( + conversation, + message, + ) && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL; const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message), ); @@ -603,6 +698,8 @@ export const useMessageStore = defineStore('imMessageStore', { applyServerMessageUpdate(existing, message); if (existingIndex === messages.length - 1) { recomputeConversationLast(conversation, messages); + } + if (isUnread) { syncConversationAtFlags(conversation, message); } addChanged(conversation, existing, { @@ -613,22 +710,8 @@ export const useMessageStore = defineStore('imMessageStore', { // 1.4 新消息更新会话摘要和未读状态 applyConversationSummary(conversation, message); - syncConversationAtFlags(conversation, message); - const isActive = - conversationStore.activeConversation?.type === - conversationInfo.type && - conversationStore.activeConversation?.targetId === - conversationInfo.targetId; - if ( - !message.selfSend && - !isActive && - !conversationStore.isMessageCoveredByReadPosition( - conversation, - message, - ) && - isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL - ) { + if (isUnread) { + syncConversationAtFlags(conversation, message); conversation.unreadCount++; } @@ -664,12 +747,30 @@ export const useMessageStore = defineStore('imMessageStore', { }, ); } - // 2.2 写入本批变更会话 + // 2.2 应用本批撤回信号 + for (const recallMessage of recallMessages) { + const changed = await this.applyRecallMessageRecord( + recallMessage.conversationType, + recallMessage.targetId, + recallMessage.recallSignalContent, + tx, + ); + if (changed) { + changedConversations.set( + getClientConversationId( + changed.conversation.type, + changed.conversation.targetId, + ), + changed.conversation, + ); + } + } + // 2.3 写入本批变更会话 await conversationStore.saveConversationRecord( [...changedConversations.values()], tx, ); - // 2.3 写入本批游标 + // 2.4 写入本批游标 await setMessageMaxId(conversationType, maxMessageId, tx); }, ); @@ -708,6 +809,19 @@ export const useMessageStore = defineStore('imMessageStore', { conversationInfo.type, conversationInfo.targetId, ); + const isActive = + conversationStore.activeConversation?.type === conversationInfo.type && + conversationStore.activeConversation?.targetId === + conversationInfo.targetId; + const isUnread = + !message.selfSend && + !isActive && + !conversationStore.isMessageCoveredByReadPosition( + conversation, + message, + ) && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL; const existingIndex = messages.findIndex((item) => isSameMessage(item, message), ); @@ -720,6 +834,8 @@ export const useMessageStore = defineStore('imMessageStore', { applyServerMessageUpdate(existing, message); if (existingIndex === messages.length - 1) { recomputeConversationLast(conversation, messages); + } + if (isUnread) { syncConversationAtFlags(conversation, message); } return getDb() @@ -752,22 +868,8 @@ export const useMessageStore = defineStore('imMessageStore', { // 4. 新消息更新会话摘要和未读状态 applyConversationSummary(conversation, message); - syncConversationAtFlags(conversation, message); - - const isActive = - conversationStore.activeConversation?.type === conversationInfo.type && - conversationStore.activeConversation?.targetId === - conversationInfo.targetId; - if ( - !message.selfSend && - !isActive && - !conversationStore.isMessageCoveredByReadPosition( - conversation, - message, - ) && - isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL - ) { + if (isUnread) { + syncConversationAtFlags(conversation, message); conversation.unreadCount++; } @@ -920,17 +1022,17 @@ export const useMessageStore = defineStore('imMessageStore', { recallSignalContent: string, ): Promise { const conversationStore = useConversationStore(); - const changed = this.applyRecallMessageInMemory( - conversationType, - targetId, - recallSignalContent, - ); - if (!changed) { - return; - } await getDb() .transaction(['messages', 'conversations'], 'readwrite', async (tx) => { - await this.saveMessageRecord(changed.message, conversationType, tx); + const changed = await this.applyRecallMessageRecord( + conversationType, + targetId, + recallSignalContent, + tx, + ); + if (!changed) { + return; + } await conversationStore.saveConversationRecord( changed.conversation, tx, @@ -1101,6 +1203,7 @@ export const useMessageStore = defineStore('imMessageStore', { message._localFile = undefined; }); Reflect.deleteProperty(this.messagesByConversation, clientConversationId); + Reflect.deleteProperty(this.messageDOPageCursors, clientConversationId); this.loadedConversationKeys = this.loadedConversationKeys.filter( (key) => key !== clientConversationId, ); diff --git a/apps/web-antd/src/views/im/home/types/index.ts b/apps/web-antd/src/views/im/home/types/index.ts index df50c50d6..071cb6fb8 100644 --- a/apps/web-antd/src/views/im/home/types/index.ts +++ b/apps/web-antd/src/views/im/home/types/index.ts @@ -110,6 +110,8 @@ export interface Conversation { silent?: boolean; // 是否免打扰(不展示未读徽标 + 不响提示音) atMe?: boolean; // 群聊:是否有人 @我 atAll?: boolean; // 群聊:是否有人 @全体成员 + atMessageId?: number; // 最近一次未读 @我的消息编号,用于点击提醒后定位 + atAllMessageId?: number; // 最近一次未读 @全体成员的消息编号,用于点击提醒后定位 reportedReadMessageId?: number; // 已上报到服务端的最大已读消息编号 draft?: { html: string; // 输入框 HTML diff --git a/apps/web-antd/src/views/im/utils/db.ts b/apps/web-antd/src/views/im/utils/db.ts index 036ec942a..432423544 100644 --- a/apps/web-antd/src/views/im/utils/db.ts +++ b/apps/web-antd/src/views/im/utils/db.ts @@ -22,6 +22,18 @@ export type DbStoreName = export type DbTransaction = IDBTransaction; +/** 数据库消息分页游标 */ +export interface MessageDOPageCursor { + messageKey: string; + sendTime: number; +} + +/** 数据库消息分页结果 */ +export interface MessageDOPageResult { + hasMore: boolean; + list: MessageDO[]; +} + /** IM 本地存储 key */ export const StorageKeys = { localStorage: { @@ -363,18 +375,19 @@ class DbClient { /** 按会话分页获取消息 */ async getMessageListByConversation( clientConversationId: string, - options?: { beforeSendTime?: number; limit?: number }, + options?: { before?: MessageDOPageCursor; limit?: number }, tx?: DbTransaction, - ): Promise { + ): Promise { const limit = options?.limit ?? 50; - const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER; + const before = options?.before; + const upper = before?.sendTime ?? Number.MAX_SAFE_INTEGER; const range = IDBKeyRange.bound( [clientConversationId, 0], [clientConversationId, upper], false, - true, + !before, ); - const read = async (tx: DbTransaction): Promise => { + const read = async (tx: DbTransaction): Promise => { const index = tx .objectStore('messages') .index('clientConversationId+sendTime'); @@ -385,21 +398,41 @@ class DbClient { request.addEventListener('error', () => reject(request.error)); request.addEventListener('success', () => { const cursor = request.result; - if (!cursor || out.length >= limit) { + if (!cursor) { + resolve(); + return; + } + const message = cursor.value as MessageDO; + if ( + before && + message.sendTime === before.sendTime && + message.messageKey >= before.messageKey + ) { + cursor.continue(); + return; + } + out.push(message); + if (out.length > limit) { resolve(); return; } - out.push(cursor.value as MessageDO); cursor.continue(); }); }); // 气泡渲染需要按时间升序 - return out.toReversed(); + return { + hasMore: out.length > limit, + list: out.slice(0, limit).toReversed(), + }; }; if (tx) { return read(tx); } - return this.transaction(['messages'], 'readonly', read); + return this.transaction( + ['messages'], + 'readonly', + read, + ); } /** 读取设置 */ @@ -506,29 +539,6 @@ export function getClientMessageKey(clientMessageId: string): string { return `client:${clientMessageId}`; } -/** 解析本地消息主键 */ -export function parseMessageKey( - messageKey: string, -): - | null - | { clientMessageId: string; kind: 'client' } - | { conversationType: number; id: number; kind: 'server' } { - if (!messageKey) { - return null; - } - if (messageKey.startsWith('client:')) { - const clientMessageId = messageKey.slice('client:'.length); - return clientMessageId ? { kind: 'client', clientMessageId } : null; - } - const [conversationTypeText, idText] = messageKey.split(':'); - const conversationType = Number(conversationTypeText); - const id = Number(idText); - if (!Number.isFinite(conversationType) || !Number.isFinite(id) || id <= 0) { - return null; - } - return { kind: 'server', conversationType, id }; -} - /** 更新消息拉取游标 */ export async function setMessageMaxId( conversationType: number, diff --git a/apps/web-antdv-next/src/views/im/home/pages/conversation/components/message/message-panel.vue b/apps/web-antdv-next/src/views/im/home/pages/conversation/components/message/message-panel.vue index b22d08600..9df049f0d 100644 --- a/apps/web-antdv-next/src/views/im/home/pages/conversation/components/message/message-panel.vue +++ b/apps/web-antdv-next/src/views/im/home/pages/conversation/components/message/message-panel.vue @@ -486,6 +486,56 @@ function waitMediaSettled(): Promise { return Promise.race([loadAll, timeout]); } +/** 加载并定位当前会话的未读 @ 消息 */ +async function handleLocateMention() { + const messageId = conversationStore.consumeActiveMentionMessageId(); + const conversation = conversationStore.activeConversation; + if (!messageId || !conversation) { + return; + } + const clientConversationId = getClientConversationId( + conversation.type, + conversation.targetId, + ); + const isActive = () => { + const activeConversation = conversationStore.activeConversation; + return ( + !!activeConversation && + getClientConversationId( + activeConversation.type, + activeConversation.targetId, + ) === clientConversationId + ); + }; + for (let guard = 0; guard < 50; guard++) { + const loadedMessages = messageStore.getMessages(clientConversationId); + if (loadedMessages.some((item) => item.id === messageId)) { + break; + } + const { hasMore } = await messageStore.loadMoreMessageList( + clientConversationId, + 50, + ); + if (!isActive()) { + return; + } + if ( + messageStore + .getMessages(clientConversationId) + .some((item) => item.id === messageId) + ) { + break; + } + if (!hasMore) { + break; + } + } + if (!isActive()) { + return; + } + await handleLocate(messageId, isActive); +} + /** * 定位到聊天位置:MessageHistory 行上"定位"按钮 / 气泡内引用块点击触发 * @@ -495,11 +545,14 @@ function waitMediaSettled(): Promise { * 4. 加 --highlight class 短暂高亮,提示用户"就是这条" * 5. 找不到 wrapper(原消息已分页出去)时弹 warning 提示,与微信"消息已不在窗口"观感一致 */ -async function handleLocate(messageId: number) { +async function handleLocate(messageId: number, isActive?: () => boolean) { if (!messageId) { return; } await nextTick(); + if (isActive && !isActive()) { + return; + } if (!listRef.value) { return; } @@ -733,6 +786,17 @@ watch( />
+ + +
+ 查看 @消息 +
+
+
>, -): number { - let maxMessageId = 0; +/** 按读位置重算会话未读与 @ 状态 */ +function applyConversationUnreadState( + conversation: Conversation, + messages: MessageDO[], + readMessageId: number, +): boolean { + const currentUserId = getCurrentUserId(); + let unreadCount = 0; + let atMessageId: number | undefined; + let atAllMessageId: number | undefined; for (const message of messages) { if ( - message.id && - !message.selfSend && - isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL && - message.id > maxMessageId + !message.id || + message.id <= readMessageId || + message.selfSend || + !isNormalMessage(message.type) || + message.status === ImMessageStatus.RECALL ) { - maxMessageId = message.id; + continue; + } + unreadCount++; + if ( + currentUserId && + message.atUserIds?.includes(currentUserId) && + message.id > (atMessageId || 0) + ) { + atMessageId = message.id; + } + if ( + message.atUserIds?.includes(IM_AT_ALL_USER_ID) && + message.id > (atAllMessageId || 0) + ) { + atAllMessageId = message.id; } } - return maxMessageId; + const changed = + conversation.unreadCount !== unreadCount || + conversation.atMe !== !!atMessageId || + conversation.atAll !== !!atAllMessageId || + conversation.atMessageId !== atMessageId || + conversation.atAllMessageId !== atAllMessageId; + conversation.unreadCount = unreadCount; + conversation.atMe = !!atMessageId; + conversation.atAll = !!atAllMessageId; + conversation.atMessageId = atMessageId; + conversation.atAllMessageId = atAllMessageId; + return changed; +} + +/** 无读位置时按原未读窗口应用撤回状态 */ +function applyConversationRecallStateWithoutRead( + conversation: Conversation, + messages: MessageDO[], + originalMessage: MessageDO, +): boolean { + const previousUnreadCount = conversation.unreadCount; + const originalWasIncomingNormal = + !!originalMessage.id && + !originalMessage.selfSend && + isNormalMessage(originalMessage.type) && + originalMessage.status !== ImMessageStatus.RECALL; + const incomingNormalMessages = messages + .filter( + (message) => + !!message.id && + !message.selfSend && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL, + ) + .toSorted((left, right) => (right.id || 0) - (left.id || 0)); + const previousUnreadMessages = [ + ...incomingNormalMessages.filter( + (message) => message.id !== originalMessage.id, + ), + ...(originalWasIncomingNormal ? [originalMessage] : []), + ] + .toSorted((left, right) => (right.id || 0) - (left.id || 0)) + .slice(0, previousUnreadCount); + const recalledUnread = previousUnreadMessages.some( + (message) => message.id === originalMessage.id, + ); + const unreadCount = Math.max( + 0, + previousUnreadCount - (recalledUnread ? 1 : 0), + ); + const unreadMessages = incomingNormalMessages.slice(0, unreadCount); + const currentUserId = getCurrentUserId(); + let atMessageId = conversation.atMessageId; + let atAllMessageId = conversation.atAllMessageId; + if ( + conversation.atMessageId === originalMessage.id || + (!conversation.atMessageId && + conversation.atMe && + !!currentUserId && + originalMessage.atUserIds?.includes(currentUserId)) + ) { + atMessageId = unreadMessages.find((message) => + message.atUserIds?.includes(currentUserId), + )?.id; + } + if ( + conversation.atAllMessageId === originalMessage.id || + (!conversation.atAllMessageId && + conversation.atAll && + originalMessage.atUserIds?.includes(IM_AT_ALL_USER_ID)) + ) { + atAllMessageId = unreadMessages.find((message) => + message.atUserIds?.includes(IM_AT_ALL_USER_ID), + )?.id; + } + const changed = + conversation.unreadCount !== unreadCount || + conversation.atMe !== !!atMessageId || + conversation.atAll !== !!atAllMessageId || + conversation.atMessageId !== atMessageId || + conversation.atAllMessageId !== atAllMessageId; + conversation.unreadCount = unreadCount; + conversation.atMe = !!atMessageId; + conversation.atAll = !!atAllMessageId; + conversation.atMessageId = atMessageId; + conversation.atAllMessageId = atAllMessageId; + return changed; +} + +/** 为旧会话回填未读 @ 消息编号 */ +function backfillConversationMentionIds( + conversation: Conversation, + messages: MessageDO[], +): boolean { + const currentUserId = getCurrentUserId(); + const unreadMessages = messages + .filter( + (message) => + !!message.id && + !message.selfSend && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL, + ) + .toSorted((left, right) => (right.id || 0) - (left.id || 0)) + .slice(0, conversation.unreadCount); + const atMessageId = + conversation.atMessageId || + (conversation.atMe && currentUserId + ? unreadMessages.find((message) => + message.atUserIds?.includes(currentUserId), + )?.id + : undefined); + const atAllMessageId = + conversation.atAllMessageId || + (conversation.atAll + ? unreadMessages.find((message) => + message.atUserIds?.includes(IM_AT_ALL_USER_ID), + )?.id + : undefined); + const changed = + conversation.atMessageId !== atMessageId || + conversation.atAllMessageId !== atAllMessageId; + conversation.atMessageId = atMessageId; + conversation.atAllMessageId = atAllMessageId; + return changed; } export const useConversationStore = defineStore('imConversationStore', { @@ -161,6 +307,7 @@ export const useConversationStore = defineStore('imConversationStore', { conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊 + 频道) conversationReads: {} as Record, // 会话读位置 activeConversation: null as Conversation | null, // 当前激活的会话 + activeMentionMessageId: undefined as number | undefined, // 当前会话待定位的未读 @ 消息编号 loading: false, // 是否正在批量加载 recentForwardConversationKeys: [] as string[], // 最近转发会话 key 列表 }), @@ -271,6 +418,7 @@ export const useConversationStore = defineStore('imConversationStore', { this.conversations = []; this.conversationReads = {}; this.activeConversation = null; + this.activeMentionMessageId = undefined; this.recentForwardConversationKeys = []; }, @@ -312,18 +460,10 @@ export const useConversationStore = defineStore('imConversationStore', { conversation.type, conversation.targetId, ); - if (!record) { - continue; - } - if (this.applyReadToConversation(conversation, record.messageId)) { - changedConversations.push(conversation); - continue; - } - if ( - conversation.unreadCount === 0 && - !conversation.atMe && - !conversation.atAll - ) { + const needsMentionBackfill = + (conversation.atMe && !conversation.atMessageId) || + (conversation.atAll && !conversation.atAllMessageId); + if (!record && !needsMentionBackfill) { continue; } const messages = await getDb().getAllByIndex( @@ -331,14 +471,14 @@ export const useConversationStore = defineStore('imConversationStore', { 'clientConversationId', getClientConversationId(conversation.type, conversation.targetId), ); - const maxIncomingMessageId = getMaxIncomingNormalMessageId(messages); - if ( - maxIncomingMessageId > 0 && - maxIncomingMessageId <= record.messageId - ) { - conversation.unreadCount = 0; - conversation.atMe = false; - conversation.atAll = false; + const changed = record + ? this.applyReadToConversation( + conversation, + record.messageId, + messages, + ) + : backfillConversationMentionIds(conversation, messages); + if (changed) { changedConversations.push(conversation); } } @@ -392,24 +532,28 @@ export const useConversationStore = defineStore('imConversationStore', { applyReadToConversation( conversation: Conversation, messageId: number, + messages: MessageDO[], ): boolean { - if ( - !conversation.lastMessageId || - conversation.lastMessageId > messageId - ) { - return false; - } - if ( - conversation.unreadCount === 0 && - !conversation.atMe && - !conversation.atAll - ) { - return false; - } - conversation.unreadCount = 0; - conversation.atMe = false; - conversation.atAll = false; - return true; + return applyConversationUnreadState(conversation, messages, messageId); + }, + + /** 应用撤回后的会话未读与 @ 状态 */ + applyRecallToConversation( + conversation: Conversation, + messages: MessageDO[], + originalMessage: MessageDO, + ): boolean { + const read = this.getConversationRead( + conversation.type, + conversation.targetId, + ); + return read + ? applyConversationUnreadState(conversation, messages, read.messageId) + : applyConversationRecallStateWithoutRead( + conversation, + messages, + originalMessage, + ); }, /** 应用会话读位置 */ @@ -475,19 +619,13 @@ export const useConversationStore = defineStore('imConversationStore', { if ( conversation && - this.applyReadToConversation(conversation, messageId) + this.applyReadToConversation( + conversation, + messageId, + await getStoredMessages(), + ) ) { changedConversations.set(clientConversationId, conversation); - } else if (conversation) { - const maxIncomingMessageId = getMaxIncomingNormalMessageId( - await getStoredMessages(), - ); - if (maxIncomingMessageId > 0 && maxIncomingMessageId <= messageId) { - conversation.unreadCount = 0; - conversation.atMe = false; - conversation.atAll = false; - changedConversations.set(clientConversationId, conversation); - } } if (record.conversationType !== ImConversationType.CHANNEL) { continue; @@ -693,6 +831,8 @@ export const useConversationStore = defineStore('imConversationStore', { /** 设置当前会话 */ setActiveConversation(conversation: Conversation | null) { + this.activeMentionMessageId = + conversation?.atMessageId || conversation?.atAllMessageId; this.activeConversation = conversation; if (!conversation) { return; @@ -702,6 +842,13 @@ export const useConversationStore = defineStore('imConversationStore', { this.saveConversation(conversation); }, + /** 消费当前会话待定位的未读 @ 消息编号 */ + consumeActiveMentionMessageId(): number | undefined { + const messageId = this.activeMentionMessageId; + this.activeMentionMessageId = undefined; + return messageId; + }, + /** 创建空会话 */ createEmptyConversation( type: number, @@ -755,6 +902,7 @@ export const useConversationStore = defineStore('imConversationStore', { } if (this.activeConversation === conversation) { this.activeConversation = null; + this.activeMentionMessageId = undefined; } conversation.deleted = true; // 2. 删除会话关联的消息和草稿 @@ -798,6 +946,8 @@ export const useConversationStore = defineStore('imConversationStore', { conversation.unreadCount = 0; conversation.atMe = false; conversation.atAll = false; + conversation.atMessageId = undefined; + conversation.atAllMessageId = undefined; if (readMessageIdAdvanced) { const record = createConversationRead(type, targetId, messageId); this.conversationReads[key] = record; diff --git a/apps/web-antdv-next/src/views/im/home/store/messageStore.ts b/apps/web-antdv-next/src/views/im/home/store/messageStore.ts index b91d23f54..a9a424936 100644 --- a/apps/web-antdv-next/src/views/im/home/store/messageStore.ts +++ b/apps/web-antdv-next/src/views/im/home/store/messageStore.ts @@ -1,4 +1,7 @@ -import type { DbTransaction } from '../../utils/db'; +import type { + DbTransaction, + MessageDOPageCursor, +} from '../../utils/db'; import type { Conversation, Message, MessageDO } from '../types'; import { acceptHMRUpdate, defineStore } from 'pinia'; @@ -50,6 +53,11 @@ interface PersistMessageRecordOptions { mergeClientRecord?: boolean; } +interface MessagePageResult { + messages: Message[]; + hasMore: boolean; +} + /** 拉取消息批量处理项 */ export type PulledMessage = | { @@ -79,6 +87,27 @@ function getMessageKey( : getClientMessageKey(message.clientMessageId); } +/** 获取数据库分页结果的最早游标 */ +function getMessageDOPageCursor(message?: MessageDO): MessageDOPageCursor | undefined { + return message + ? { + messageKey: message.messageKey, + sendTime: message.sendTime, + } + : undefined; +} + +/** 判断两个消息分页游标是否一致 */ +function isSameMessageDOPageCursor( + left?: MessageDOPageCursor, + right?: MessageDOPageCursor, +): boolean { + if (!left || !right) { + return left === right; + } + return left.sendTime === right.sendTime && left.messageKey === right.messageKey; +} + /** 补齐客户端消息编号 */ function ensureClientMessageId(message: Message): Message { if (!message.clientMessageId) { @@ -237,9 +266,15 @@ function syncConversationAtFlags( const currentUserId = getCurrentUserId(); if (currentUserId && message.atUserIds.includes(currentUserId)) { conversation.atMe = true; + if (message.id && message.id > (conversation.atMessageId || 0)) { + conversation.atMessageId = message.id; + } } if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) { conversation.atAll = true; + if (message.id && message.id > (conversation.atAllMessageId || 0)) { + conversation.atAllMessageId = message.id; + } } } @@ -279,6 +314,10 @@ function isSameMessage(left: Message, right: Message): boolean { export const useMessageStore = defineStore('imMessageStore', { state: () => ({ messagesByConversation: {} as Record, + messageDOPageCursors: {} as Record< + string, + MessageDOPageCursor | undefined + >, loadedConversationKeys: [] as string[], privateReadMaxIds: {} as Partial>, privateMessageMaxId: 0, @@ -304,6 +343,7 @@ export const useMessageStore = defineStore('imMessageStore', { }); }); this.messagesByConversation = {}; + this.messageDOPageCursors = {}; this.loadedConversationKeys = []; this.privateReadMaxIds = {}; this.privateMessageMaxId = 0; @@ -397,29 +437,43 @@ export const useMessageStore = defineStore('imMessageStore', { this.loadedConversationKeys = retained; removed.forEach((key) => { Reflect.deleteProperty(this.messagesByConversation, key); + Reflect.deleteProperty(this.messageDOPageCursors, key); }); }, /** 加载当前会话最近消息 */ async loadMoreMessageList( clientConversationId: string, - beforeSendTime?: number, limit = 50, - ): Promise { + ): Promise { + const parsed = parseClientConversationId(clientConversationId); + if (!parsed) { + return { messages: [], hasMore: false }; + } + const before = this.messageDOPageCursors[clientConversationId]; // 1. 从 IndexedDB 倒序读取一页,返回前已按时间升序排列 - const list = await getDb().getMessageListByConversation( + const page = await getDb().getMessageListByConversation( clientConversationId, { - beforeSendTime, + before, limit, }, ); - // 2. 合并到内存缓存,过滤已存在的消息 - const parsed = parseClientConversationId(clientConversationId); - if (!parsed) { - return []; + if ( + isSameMessageDOPageCursor( + this.messageDOPageCursors[clientConversationId], + before, + ) + ) { + const nextCursor = getMessageDOPageCursor(page.list[0]); + if (nextCursor) { + this.messageDOPageCursors[clientConversationId] = nextCursor; + } } - const messages = list.map((message) => buildMessageFromDO(message)); + // 2. 合并到内存缓存,过滤已存在的消息 + const messages = page.list.map((message) => + buildMessageFromDO(message), + ); const existing = this.messagesByConversation[clientConversationId] || []; const existingKeys = new Set( existing.map((message) => getMessageKey(message, parsed.type)), @@ -435,7 +489,7 @@ export const useMessageStore = defineStore('imMessageStore', { (messageA.sendTime || 0) - (messageB.sendTime || 0), ); this.touchConversationMessageCache(clientConversationId); - return fresh; + return { messages: fresh, hasMore: page.hasMore }; }, /** 确保会话消息已加载 */ @@ -492,13 +546,14 @@ export const useMessageStore = defineStore('imMessageStore', { this.updateMessageCursor(conversationType, messageId); }, - /** 应用撤回到内存 */ - applyRecallMessageInMemory( + /** 应用撤回到本地消息与会话状态 */ + async applyRecallMessageRecord( conversationType: number, targetId: number, recallSignalContent: string, + tx: DbTransaction, ) { - // 1. 定位被撤回的原消息 + // 1. 定位被撤回的原消息和会话 const messageId = parseRecallMessageId(recallSignalContent); if (!messageId) { return null; @@ -511,19 +566,48 @@ export const useMessageStore = defineStore('imMessageStore', { if (!conversation) { return null; } - const messages = this.getMessageList(conversationType, targetId); - const message = messages.find((item) => item.id === messageId); - if (!message) { + const clientConversationId = getClientConversationId( + conversationType, + targetId, + ); + const cachedMessage = this.messagesByConversation[ + clientConversationId + ]?.find((item) => item.id === messageId); + const storedMessage = await getDb().get( + 'messages', + getServerMessageKey(conversationType, messageId), + tx, + ); + const originalMessage = cachedMessage + ? buildMessageDO(cachedMessage, conversationType) + : storedMessage; + if (!originalMessage) { return null; } - // 2. 更新消息和会话摘要 - message.type = ImContentType.RECALL; - message.status = ImMessageStatus.RECALL; - message.content = ''; - if (messages[messages.length - 1]?.id === messageId) { - recomputeConversationLast(conversation, messages); + // 2. 更新内存消息与数据库记录 + const recalledMessage = + cachedMessage || buildMessageFromDO(originalMessage); + revokeBlobUrlsInContent(recalledMessage.content); + recalledMessage.type = ImContentType.RECALL; + recalledMessage.status = ImMessageStatus.RECALL; + recalledMessage.content = ''; + await this.saveMessageRecord(recalledMessage, conversationType, tx); + // 3. 按本地完整消息和读位置重算未读与 @ 状态 + const storedMessages = await getDb().getAllByIndex( + 'messages', + 'clientConversationId', + clientConversationId, + tx, + ); + conversationStore.applyRecallToConversation( + conversation, + storedMessages, + originalMessage, + ); + if (conversation.lastMessageId === messageId) { + applyConversationSummary(conversation, recalledMessage); } - return { conversation, message }; + return { conversation, message: recalledMessage }; }, /** 批量写入拉取消息 */ @@ -547,6 +631,10 @@ export const useMessageStore = defineStore('imMessageStore', { } >(); const changedConversations = new Map(); + const recallMessages: Extract< + PulledMessage, + { kind: 'recall' } + >[] = []; const addChanged = ( conversation: Conversation, @@ -568,15 +656,8 @@ export const useMessageStore = defineStore('imMessageStore', { // 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); - } + // 1.1 撤回信号在事务内读取原消息后统一处理 + recallMessages.push(pulledMessage); continue; } @@ -591,6 +672,20 @@ export const useMessageStore = defineStore('imMessageStore', { conversationInfo.type, conversationInfo.targetId, ); + const isActive = + conversationStore.activeConversation?.type === + conversationInfo.type && + conversationStore.activeConversation?.targetId === + conversationInfo.targetId; + const isUnread = + !message.selfSend && + !isActive && + !conversationStore.isMessageCoveredByReadPosition( + conversation, + message, + ) && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL; const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message), ); @@ -603,6 +698,8 @@ export const useMessageStore = defineStore('imMessageStore', { applyServerMessageUpdate(existing, message); if (existingIndex === messages.length - 1) { recomputeConversationLast(conversation, messages); + } + if (isUnread) { syncConversationAtFlags(conversation, message); } addChanged(conversation, existing, { @@ -613,22 +710,8 @@ export const useMessageStore = defineStore('imMessageStore', { // 1.4 新消息更新会话摘要和未读状态 applyConversationSummary(conversation, message); - syncConversationAtFlags(conversation, message); - const isActive = - conversationStore.activeConversation?.type === - conversationInfo.type && - conversationStore.activeConversation?.targetId === - conversationInfo.targetId; - if ( - !message.selfSend && - !isActive && - !conversationStore.isMessageCoveredByReadPosition( - conversation, - message, - ) && - isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL - ) { + if (isUnread) { + syncConversationAtFlags(conversation, message); conversation.unreadCount++; } @@ -664,12 +747,30 @@ export const useMessageStore = defineStore('imMessageStore', { }, ); } - // 2.2 写入本批变更会话 + // 2.2 应用本批撤回信号 + for (const recallMessage of recallMessages) { + const changed = await this.applyRecallMessageRecord( + recallMessage.conversationType, + recallMessage.targetId, + recallMessage.recallSignalContent, + tx, + ); + if (changed) { + changedConversations.set( + getClientConversationId( + changed.conversation.type, + changed.conversation.targetId, + ), + changed.conversation, + ); + } + } + // 2.3 写入本批变更会话 await conversationStore.saveConversationRecord( [...changedConversations.values()], tx, ); - // 2.3 写入本批游标 + // 2.4 写入本批游标 await setMessageMaxId(conversationType, maxMessageId, tx); }, ); @@ -708,6 +809,19 @@ export const useMessageStore = defineStore('imMessageStore', { conversationInfo.type, conversationInfo.targetId, ); + const isActive = + conversationStore.activeConversation?.type === conversationInfo.type && + conversationStore.activeConversation?.targetId === + conversationInfo.targetId; + const isUnread = + !message.selfSend && + !isActive && + !conversationStore.isMessageCoveredByReadPosition( + conversation, + message, + ) && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL; const existingIndex = messages.findIndex((item) => isSameMessage(item, message), ); @@ -720,6 +834,8 @@ export const useMessageStore = defineStore('imMessageStore', { applyServerMessageUpdate(existing, message); if (existingIndex === messages.length - 1) { recomputeConversationLast(conversation, messages); + } + if (isUnread) { syncConversationAtFlags(conversation, message); } return getDb() @@ -752,22 +868,8 @@ export const useMessageStore = defineStore('imMessageStore', { // 4. 新消息更新会话摘要和未读状态 applyConversationSummary(conversation, message); - syncConversationAtFlags(conversation, message); - - const isActive = - conversationStore.activeConversation?.type === conversationInfo.type && - conversationStore.activeConversation?.targetId === - conversationInfo.targetId; - if ( - !message.selfSend && - !isActive && - !conversationStore.isMessageCoveredByReadPosition( - conversation, - message, - ) && - isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL - ) { + if (isUnread) { + syncConversationAtFlags(conversation, message); conversation.unreadCount++; } @@ -920,17 +1022,17 @@ export const useMessageStore = defineStore('imMessageStore', { recallSignalContent: string, ): Promise { const conversationStore = useConversationStore(); - const changed = this.applyRecallMessageInMemory( - conversationType, - targetId, - recallSignalContent, - ); - if (!changed) { - return; - } await getDb() .transaction(['messages', 'conversations'], 'readwrite', async (tx) => { - await this.saveMessageRecord(changed.message, conversationType, tx); + const changed = await this.applyRecallMessageRecord( + conversationType, + targetId, + recallSignalContent, + tx, + ); + if (!changed) { + return; + } await conversationStore.saveConversationRecord( changed.conversation, tx, @@ -1101,6 +1203,7 @@ export const useMessageStore = defineStore('imMessageStore', { message._localFile = undefined; }); Reflect.deleteProperty(this.messagesByConversation, clientConversationId); + Reflect.deleteProperty(this.messageDOPageCursors, clientConversationId); this.loadedConversationKeys = this.loadedConversationKeys.filter( (key) => key !== clientConversationId, ); diff --git a/apps/web-antdv-next/src/views/im/home/types/index.ts b/apps/web-antdv-next/src/views/im/home/types/index.ts index df50c50d6..071cb6fb8 100644 --- a/apps/web-antdv-next/src/views/im/home/types/index.ts +++ b/apps/web-antdv-next/src/views/im/home/types/index.ts @@ -110,6 +110,8 @@ export interface Conversation { silent?: boolean; // 是否免打扰(不展示未读徽标 + 不响提示音) atMe?: boolean; // 群聊:是否有人 @我 atAll?: boolean; // 群聊:是否有人 @全体成员 + atMessageId?: number; // 最近一次未读 @我的消息编号,用于点击提醒后定位 + atAllMessageId?: number; // 最近一次未读 @全体成员的消息编号,用于点击提醒后定位 reportedReadMessageId?: number; // 已上报到服务端的最大已读消息编号 draft?: { html: string; // 输入框 HTML diff --git a/apps/web-antdv-next/src/views/im/utils/db.ts b/apps/web-antdv-next/src/views/im/utils/db.ts index 036ec942a..432423544 100644 --- a/apps/web-antdv-next/src/views/im/utils/db.ts +++ b/apps/web-antdv-next/src/views/im/utils/db.ts @@ -22,6 +22,18 @@ export type DbStoreName = export type DbTransaction = IDBTransaction; +/** 数据库消息分页游标 */ +export interface MessageDOPageCursor { + messageKey: string; + sendTime: number; +} + +/** 数据库消息分页结果 */ +export interface MessageDOPageResult { + hasMore: boolean; + list: MessageDO[]; +} + /** IM 本地存储 key */ export const StorageKeys = { localStorage: { @@ -363,18 +375,19 @@ class DbClient { /** 按会话分页获取消息 */ async getMessageListByConversation( clientConversationId: string, - options?: { beforeSendTime?: number; limit?: number }, + options?: { before?: MessageDOPageCursor; limit?: number }, tx?: DbTransaction, - ): Promise { + ): Promise { const limit = options?.limit ?? 50; - const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER; + const before = options?.before; + const upper = before?.sendTime ?? Number.MAX_SAFE_INTEGER; const range = IDBKeyRange.bound( [clientConversationId, 0], [clientConversationId, upper], false, - true, + !before, ); - const read = async (tx: DbTransaction): Promise => { + const read = async (tx: DbTransaction): Promise => { const index = tx .objectStore('messages') .index('clientConversationId+sendTime'); @@ -385,21 +398,41 @@ class DbClient { request.addEventListener('error', () => reject(request.error)); request.addEventListener('success', () => { const cursor = request.result; - if (!cursor || out.length >= limit) { + if (!cursor) { + resolve(); + return; + } + const message = cursor.value as MessageDO; + if ( + before && + message.sendTime === before.sendTime && + message.messageKey >= before.messageKey + ) { + cursor.continue(); + return; + } + out.push(message); + if (out.length > limit) { resolve(); return; } - out.push(cursor.value as MessageDO); cursor.continue(); }); }); // 气泡渲染需要按时间升序 - return out.toReversed(); + return { + hasMore: out.length > limit, + list: out.slice(0, limit).toReversed(), + }; }; if (tx) { return read(tx); } - return this.transaction(['messages'], 'readonly', read); + return this.transaction( + ['messages'], + 'readonly', + read, + ); } /** 读取设置 */ @@ -506,29 +539,6 @@ export function getClientMessageKey(clientMessageId: string): string { return `client:${clientMessageId}`; } -/** 解析本地消息主键 */ -export function parseMessageKey( - messageKey: string, -): - | null - | { clientMessageId: string; kind: 'client' } - | { conversationType: number; id: number; kind: 'server' } { - if (!messageKey) { - return null; - } - if (messageKey.startsWith('client:')) { - const clientMessageId = messageKey.slice('client:'.length); - return clientMessageId ? { kind: 'client', clientMessageId } : null; - } - const [conversationTypeText, idText] = messageKey.split(':'); - const conversationType = Number(conversationTypeText); - const id = Number(idText); - if (!Number.isFinite(conversationType) || !Number.isFinite(id) || id <= 0) { - return null; - } - return { kind: 'server', conversationType, id }; -} - /** 更新消息拉取游标 */ export async function setMessageMaxId( conversationType: number, diff --git a/apps/web-ele/src/views/im/home/pages/conversation/components/message/message-panel.vue b/apps/web-ele/src/views/im/home/pages/conversation/components/message/message-panel.vue index 9a270e992..43f800785 100644 --- a/apps/web-ele/src/views/im/home/pages/conversation/components/message/message-panel.vue +++ b/apps/web-ele/src/views/im/home/pages/conversation/components/message/message-panel.vue @@ -486,6 +486,56 @@ function waitMediaSettled(): Promise { return Promise.race([loadAll, timeout]); } +/** 加载并定位当前会话的未读 @ 消息 */ +async function handleLocateMention() { + const messageId = conversationStore.consumeActiveMentionMessageId(); + const conversation = conversationStore.activeConversation; + if (!messageId || !conversation) { + return; + } + const clientConversationId = getClientConversationId( + conversation.type, + conversation.targetId, + ); + const isActive = () => { + const activeConversation = conversationStore.activeConversation; + return ( + !!activeConversation && + getClientConversationId( + activeConversation.type, + activeConversation.targetId, + ) === clientConversationId + ); + }; + for (let guard = 0; guard < 50; guard++) { + const loadedMessages = messageStore.getMessages(clientConversationId); + if (loadedMessages.some((item) => item.id === messageId)) { + break; + } + const { hasMore } = await messageStore.loadMoreMessageList( + clientConversationId, + 50, + ); + if (!isActive()) { + return; + } + if ( + messageStore + .getMessages(clientConversationId) + .some((item) => item.id === messageId) + ) { + break; + } + if (!hasMore) { + break; + } + } + if (!isActive()) { + return; + } + await handleLocate(messageId, isActive); +} + /** * 定位到聊天位置:MessageHistory 行上"定位"按钮 / 气泡内引用块点击触发 * @@ -495,11 +545,14 @@ function waitMediaSettled(): Promise { * 4. 加 --highlight class 短暂高亮,提示用户"就是这条" * 5. 找不到 wrapper(原消息已分页出去)时弹 warning 提示,与微信"消息已不在窗口"观感一致 */ -async function handleLocate(messageId: number) { +async function handleLocate(messageId: number, isActive?: () => boolean) { if (!messageId) { return; } await nextTick(); + if (isActive && !isActive()) { + return; + } if (!listRef.value) { return; } @@ -742,6 +795,17 @@ watch( />
+ + +
+ 查看 @消息 +
+
+
>, -): number { - let maxMessageId = 0; +/** 按读位置重算会话未读与 @ 状态 */ +function applyConversationUnreadState( + conversation: Conversation, + messages: MessageDO[], + readMessageId: number, +): boolean { + const currentUserId = getCurrentUserId(); + let unreadCount = 0; + let atMessageId: number | undefined; + let atAllMessageId: number | undefined; for (const message of messages) { if ( - message.id && - !message.selfSend && - isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL && - message.id > maxMessageId + !message.id || + message.id <= readMessageId || + message.selfSend || + !isNormalMessage(message.type) || + message.status === ImMessageStatus.RECALL ) { - maxMessageId = message.id; + continue; + } + unreadCount++; + if ( + currentUserId && + message.atUserIds?.includes(currentUserId) && + message.id > (atMessageId || 0) + ) { + atMessageId = message.id; + } + if ( + message.atUserIds?.includes(IM_AT_ALL_USER_ID) && + message.id > (atAllMessageId || 0) + ) { + atAllMessageId = message.id; } } - return maxMessageId; + const changed = + conversation.unreadCount !== unreadCount || + conversation.atMe !== !!atMessageId || + conversation.atAll !== !!atAllMessageId || + conversation.atMessageId !== atMessageId || + conversation.atAllMessageId !== atAllMessageId; + conversation.unreadCount = unreadCount; + conversation.atMe = !!atMessageId; + conversation.atAll = !!atAllMessageId; + conversation.atMessageId = atMessageId; + conversation.atAllMessageId = atAllMessageId; + return changed; +} + +/** 无读位置时按原未读窗口应用撤回状态 */ +function applyConversationRecallStateWithoutRead( + conversation: Conversation, + messages: MessageDO[], + originalMessage: MessageDO, +): boolean { + const previousUnreadCount = conversation.unreadCount; + const originalWasIncomingNormal = + !!originalMessage.id && + !originalMessage.selfSend && + isNormalMessage(originalMessage.type) && + originalMessage.status !== ImMessageStatus.RECALL; + const incomingNormalMessages = messages + .filter( + (message) => + !!message.id && + !message.selfSend && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL, + ) + .toSorted((left, right) => (right.id || 0) - (left.id || 0)); + const previousUnreadMessages = [ + ...incomingNormalMessages.filter( + (message) => message.id !== originalMessage.id, + ), + ...(originalWasIncomingNormal ? [originalMessage] : []), + ] + .toSorted((left, right) => (right.id || 0) - (left.id || 0)) + .slice(0, previousUnreadCount); + const recalledUnread = previousUnreadMessages.some( + (message) => message.id === originalMessage.id, + ); + const unreadCount = Math.max( + 0, + previousUnreadCount - (recalledUnread ? 1 : 0), + ); + const unreadMessages = incomingNormalMessages.slice(0, unreadCount); + const currentUserId = getCurrentUserId(); + let atMessageId = conversation.atMessageId; + let atAllMessageId = conversation.atAllMessageId; + if ( + conversation.atMessageId === originalMessage.id || + (!conversation.atMessageId && + conversation.atMe && + !!currentUserId && + originalMessage.atUserIds?.includes(currentUserId)) + ) { + atMessageId = unreadMessages.find((message) => + message.atUserIds?.includes(currentUserId), + )?.id; + } + if ( + conversation.atAllMessageId === originalMessage.id || + (!conversation.atAllMessageId && + conversation.atAll && + originalMessage.atUserIds?.includes(IM_AT_ALL_USER_ID)) + ) { + atAllMessageId = unreadMessages.find((message) => + message.atUserIds?.includes(IM_AT_ALL_USER_ID), + )?.id; + } + const changed = + conversation.unreadCount !== unreadCount || + conversation.atMe !== !!atMessageId || + conversation.atAll !== !!atAllMessageId || + conversation.atMessageId !== atMessageId || + conversation.atAllMessageId !== atAllMessageId; + conversation.unreadCount = unreadCount; + conversation.atMe = !!atMessageId; + conversation.atAll = !!atAllMessageId; + conversation.atMessageId = atMessageId; + conversation.atAllMessageId = atAllMessageId; + return changed; +} + +/** 为旧会话回填未读 @ 消息编号 */ +function backfillConversationMentionIds( + conversation: Conversation, + messages: MessageDO[], +): boolean { + const currentUserId = getCurrentUserId(); + const unreadMessages = messages + .filter( + (message) => + !!message.id && + !message.selfSend && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL, + ) + .toSorted((left, right) => (right.id || 0) - (left.id || 0)) + .slice(0, conversation.unreadCount); + const atMessageId = + conversation.atMessageId || + (conversation.atMe && currentUserId + ? unreadMessages.find((message) => + message.atUserIds?.includes(currentUserId), + )?.id + : undefined); + const atAllMessageId = + conversation.atAllMessageId || + (conversation.atAll + ? unreadMessages.find((message) => + message.atUserIds?.includes(IM_AT_ALL_USER_ID), + )?.id + : undefined); + const changed = + conversation.atMessageId !== atMessageId || + conversation.atAllMessageId !== atAllMessageId; + conversation.atMessageId = atMessageId; + conversation.atAllMessageId = atAllMessageId; + return changed; } export const useConversationStore = defineStore('imConversationStore', { @@ -161,6 +307,7 @@ export const useConversationStore = defineStore('imConversationStore', { conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊 + 频道) conversationReads: {} as Record, // 会话读位置 activeConversation: null as Conversation | null, // 当前激活的会话 + activeMentionMessageId: undefined as number | undefined, // 当前会话待定位的未读 @ 消息编号 loading: false, // 是否正在批量加载 recentForwardConversationKeys: [] as string[], // 最近转发会话 key 列表 }), @@ -271,6 +418,7 @@ export const useConversationStore = defineStore('imConversationStore', { this.conversations = []; this.conversationReads = {}; this.activeConversation = null; + this.activeMentionMessageId = undefined; this.recentForwardConversationKeys = []; }, @@ -312,18 +460,10 @@ export const useConversationStore = defineStore('imConversationStore', { conversation.type, conversation.targetId, ); - if (!record) { - continue; - } - if (this.applyReadToConversation(conversation, record.messageId)) { - changedConversations.push(conversation); - continue; - } - if ( - conversation.unreadCount === 0 && - !conversation.atMe && - !conversation.atAll - ) { + const needsMentionBackfill = + (conversation.atMe && !conversation.atMessageId) || + (conversation.atAll && !conversation.atAllMessageId); + if (!record && !needsMentionBackfill) { continue; } const messages = await getDb().getAllByIndex( @@ -331,14 +471,14 @@ export const useConversationStore = defineStore('imConversationStore', { 'clientConversationId', getClientConversationId(conversation.type, conversation.targetId), ); - const maxIncomingMessageId = getMaxIncomingNormalMessageId(messages); - if ( - maxIncomingMessageId > 0 && - maxIncomingMessageId <= record.messageId - ) { - conversation.unreadCount = 0; - conversation.atMe = false; - conversation.atAll = false; + const changed = record + ? this.applyReadToConversation( + conversation, + record.messageId, + messages, + ) + : backfillConversationMentionIds(conversation, messages); + if (changed) { changedConversations.push(conversation); } } @@ -392,24 +532,28 @@ export const useConversationStore = defineStore('imConversationStore', { applyReadToConversation( conversation: Conversation, messageId: number, + messages: MessageDO[], ): boolean { - if ( - !conversation.lastMessageId || - conversation.lastMessageId > messageId - ) { - return false; - } - if ( - conversation.unreadCount === 0 && - !conversation.atMe && - !conversation.atAll - ) { - return false; - } - conversation.unreadCount = 0; - conversation.atMe = false; - conversation.atAll = false; - return true; + return applyConversationUnreadState(conversation, messages, messageId); + }, + + /** 应用撤回后的会话未读与 @ 状态 */ + applyRecallToConversation( + conversation: Conversation, + messages: MessageDO[], + originalMessage: MessageDO, + ): boolean { + const read = this.getConversationRead( + conversation.type, + conversation.targetId, + ); + return read + ? applyConversationUnreadState(conversation, messages, read.messageId) + : applyConversationRecallStateWithoutRead( + conversation, + messages, + originalMessage, + ); }, /** 应用会话读位置 */ @@ -475,19 +619,13 @@ export const useConversationStore = defineStore('imConversationStore', { if ( conversation && - this.applyReadToConversation(conversation, messageId) + this.applyReadToConversation( + conversation, + messageId, + await getStoredMessages(), + ) ) { changedConversations.set(clientConversationId, conversation); - } else if (conversation) { - const maxIncomingMessageId = getMaxIncomingNormalMessageId( - await getStoredMessages(), - ); - if (maxIncomingMessageId > 0 && maxIncomingMessageId <= messageId) { - conversation.unreadCount = 0; - conversation.atMe = false; - conversation.atAll = false; - changedConversations.set(clientConversationId, conversation); - } } if (record.conversationType !== ImConversationType.CHANNEL) { continue; @@ -693,6 +831,8 @@ export const useConversationStore = defineStore('imConversationStore', { /** 设置当前会话 */ setActiveConversation(conversation: Conversation | null) { + this.activeMentionMessageId = + conversation?.atMessageId || conversation?.atAllMessageId; this.activeConversation = conversation; if (!conversation) { return; @@ -702,6 +842,13 @@ export const useConversationStore = defineStore('imConversationStore', { this.saveConversation(conversation); }, + /** 消费当前会话待定位的未读 @ 消息编号 */ + consumeActiveMentionMessageId(): number | undefined { + const messageId = this.activeMentionMessageId; + this.activeMentionMessageId = undefined; + return messageId; + }, + /** 创建空会话 */ createEmptyConversation( type: number, @@ -755,6 +902,7 @@ export const useConversationStore = defineStore('imConversationStore', { } if (this.activeConversation === conversation) { this.activeConversation = null; + this.activeMentionMessageId = undefined; } conversation.deleted = true; // 2. 删除会话关联的消息和草稿 @@ -798,6 +946,8 @@ export const useConversationStore = defineStore('imConversationStore', { conversation.unreadCount = 0; conversation.atMe = false; conversation.atAll = false; + conversation.atMessageId = undefined; + conversation.atAllMessageId = undefined; if (readMessageIdAdvanced) { const record = createConversationRead(type, targetId, messageId); this.conversationReads[key] = record; diff --git a/apps/web-ele/src/views/im/home/store/messageStore.ts b/apps/web-ele/src/views/im/home/store/messageStore.ts index b91d23f54..a9a424936 100644 --- a/apps/web-ele/src/views/im/home/store/messageStore.ts +++ b/apps/web-ele/src/views/im/home/store/messageStore.ts @@ -1,4 +1,7 @@ -import type { DbTransaction } from '../../utils/db'; +import type { + DbTransaction, + MessageDOPageCursor, +} from '../../utils/db'; import type { Conversation, Message, MessageDO } from '../types'; import { acceptHMRUpdate, defineStore } from 'pinia'; @@ -50,6 +53,11 @@ interface PersistMessageRecordOptions { mergeClientRecord?: boolean; } +interface MessagePageResult { + messages: Message[]; + hasMore: boolean; +} + /** 拉取消息批量处理项 */ export type PulledMessage = | { @@ -79,6 +87,27 @@ function getMessageKey( : getClientMessageKey(message.clientMessageId); } +/** 获取数据库分页结果的最早游标 */ +function getMessageDOPageCursor(message?: MessageDO): MessageDOPageCursor | undefined { + return message + ? { + messageKey: message.messageKey, + sendTime: message.sendTime, + } + : undefined; +} + +/** 判断两个消息分页游标是否一致 */ +function isSameMessageDOPageCursor( + left?: MessageDOPageCursor, + right?: MessageDOPageCursor, +): boolean { + if (!left || !right) { + return left === right; + } + return left.sendTime === right.sendTime && left.messageKey === right.messageKey; +} + /** 补齐客户端消息编号 */ function ensureClientMessageId(message: Message): Message { if (!message.clientMessageId) { @@ -237,9 +266,15 @@ function syncConversationAtFlags( const currentUserId = getCurrentUserId(); if (currentUserId && message.atUserIds.includes(currentUserId)) { conversation.atMe = true; + if (message.id && message.id > (conversation.atMessageId || 0)) { + conversation.atMessageId = message.id; + } } if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) { conversation.atAll = true; + if (message.id && message.id > (conversation.atAllMessageId || 0)) { + conversation.atAllMessageId = message.id; + } } } @@ -279,6 +314,10 @@ function isSameMessage(left: Message, right: Message): boolean { export const useMessageStore = defineStore('imMessageStore', { state: () => ({ messagesByConversation: {} as Record, + messageDOPageCursors: {} as Record< + string, + MessageDOPageCursor | undefined + >, loadedConversationKeys: [] as string[], privateReadMaxIds: {} as Partial>, privateMessageMaxId: 0, @@ -304,6 +343,7 @@ export const useMessageStore = defineStore('imMessageStore', { }); }); this.messagesByConversation = {}; + this.messageDOPageCursors = {}; this.loadedConversationKeys = []; this.privateReadMaxIds = {}; this.privateMessageMaxId = 0; @@ -397,29 +437,43 @@ export const useMessageStore = defineStore('imMessageStore', { this.loadedConversationKeys = retained; removed.forEach((key) => { Reflect.deleteProperty(this.messagesByConversation, key); + Reflect.deleteProperty(this.messageDOPageCursors, key); }); }, /** 加载当前会话最近消息 */ async loadMoreMessageList( clientConversationId: string, - beforeSendTime?: number, limit = 50, - ): Promise { + ): Promise { + const parsed = parseClientConversationId(clientConversationId); + if (!parsed) { + return { messages: [], hasMore: false }; + } + const before = this.messageDOPageCursors[clientConversationId]; // 1. 从 IndexedDB 倒序读取一页,返回前已按时间升序排列 - const list = await getDb().getMessageListByConversation( + const page = await getDb().getMessageListByConversation( clientConversationId, { - beforeSendTime, + before, limit, }, ); - // 2. 合并到内存缓存,过滤已存在的消息 - const parsed = parseClientConversationId(clientConversationId); - if (!parsed) { - return []; + if ( + isSameMessageDOPageCursor( + this.messageDOPageCursors[clientConversationId], + before, + ) + ) { + const nextCursor = getMessageDOPageCursor(page.list[0]); + if (nextCursor) { + this.messageDOPageCursors[clientConversationId] = nextCursor; + } } - const messages = list.map((message) => buildMessageFromDO(message)); + // 2. 合并到内存缓存,过滤已存在的消息 + const messages = page.list.map((message) => + buildMessageFromDO(message), + ); const existing = this.messagesByConversation[clientConversationId] || []; const existingKeys = new Set( existing.map((message) => getMessageKey(message, parsed.type)), @@ -435,7 +489,7 @@ export const useMessageStore = defineStore('imMessageStore', { (messageA.sendTime || 0) - (messageB.sendTime || 0), ); this.touchConversationMessageCache(clientConversationId); - return fresh; + return { messages: fresh, hasMore: page.hasMore }; }, /** 确保会话消息已加载 */ @@ -492,13 +546,14 @@ export const useMessageStore = defineStore('imMessageStore', { this.updateMessageCursor(conversationType, messageId); }, - /** 应用撤回到内存 */ - applyRecallMessageInMemory( + /** 应用撤回到本地消息与会话状态 */ + async applyRecallMessageRecord( conversationType: number, targetId: number, recallSignalContent: string, + tx: DbTransaction, ) { - // 1. 定位被撤回的原消息 + // 1. 定位被撤回的原消息和会话 const messageId = parseRecallMessageId(recallSignalContent); if (!messageId) { return null; @@ -511,19 +566,48 @@ export const useMessageStore = defineStore('imMessageStore', { if (!conversation) { return null; } - const messages = this.getMessageList(conversationType, targetId); - const message = messages.find((item) => item.id === messageId); - if (!message) { + const clientConversationId = getClientConversationId( + conversationType, + targetId, + ); + const cachedMessage = this.messagesByConversation[ + clientConversationId + ]?.find((item) => item.id === messageId); + const storedMessage = await getDb().get( + 'messages', + getServerMessageKey(conversationType, messageId), + tx, + ); + const originalMessage = cachedMessage + ? buildMessageDO(cachedMessage, conversationType) + : storedMessage; + if (!originalMessage) { return null; } - // 2. 更新消息和会话摘要 - message.type = ImContentType.RECALL; - message.status = ImMessageStatus.RECALL; - message.content = ''; - if (messages[messages.length - 1]?.id === messageId) { - recomputeConversationLast(conversation, messages); + // 2. 更新内存消息与数据库记录 + const recalledMessage = + cachedMessage || buildMessageFromDO(originalMessage); + revokeBlobUrlsInContent(recalledMessage.content); + recalledMessage.type = ImContentType.RECALL; + recalledMessage.status = ImMessageStatus.RECALL; + recalledMessage.content = ''; + await this.saveMessageRecord(recalledMessage, conversationType, tx); + // 3. 按本地完整消息和读位置重算未读与 @ 状态 + const storedMessages = await getDb().getAllByIndex( + 'messages', + 'clientConversationId', + clientConversationId, + tx, + ); + conversationStore.applyRecallToConversation( + conversation, + storedMessages, + originalMessage, + ); + if (conversation.lastMessageId === messageId) { + applyConversationSummary(conversation, recalledMessage); } - return { conversation, message }; + return { conversation, message: recalledMessage }; }, /** 批量写入拉取消息 */ @@ -547,6 +631,10 @@ export const useMessageStore = defineStore('imMessageStore', { } >(); const changedConversations = new Map(); + const recallMessages: Extract< + PulledMessage, + { kind: 'recall' } + >[] = []; const addChanged = ( conversation: Conversation, @@ -568,15 +656,8 @@ export const useMessageStore = defineStore('imMessageStore', { // 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); - } + // 1.1 撤回信号在事务内读取原消息后统一处理 + recallMessages.push(pulledMessage); continue; } @@ -591,6 +672,20 @@ export const useMessageStore = defineStore('imMessageStore', { conversationInfo.type, conversationInfo.targetId, ); + const isActive = + conversationStore.activeConversation?.type === + conversationInfo.type && + conversationStore.activeConversation?.targetId === + conversationInfo.targetId; + const isUnread = + !message.selfSend && + !isActive && + !conversationStore.isMessageCoveredByReadPosition( + conversation, + message, + ) && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL; const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message), ); @@ -603,6 +698,8 @@ export const useMessageStore = defineStore('imMessageStore', { applyServerMessageUpdate(existing, message); if (existingIndex === messages.length - 1) { recomputeConversationLast(conversation, messages); + } + if (isUnread) { syncConversationAtFlags(conversation, message); } addChanged(conversation, existing, { @@ -613,22 +710,8 @@ export const useMessageStore = defineStore('imMessageStore', { // 1.4 新消息更新会话摘要和未读状态 applyConversationSummary(conversation, message); - syncConversationAtFlags(conversation, message); - const isActive = - conversationStore.activeConversation?.type === - conversationInfo.type && - conversationStore.activeConversation?.targetId === - conversationInfo.targetId; - if ( - !message.selfSend && - !isActive && - !conversationStore.isMessageCoveredByReadPosition( - conversation, - message, - ) && - isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL - ) { + if (isUnread) { + syncConversationAtFlags(conversation, message); conversation.unreadCount++; } @@ -664,12 +747,30 @@ export const useMessageStore = defineStore('imMessageStore', { }, ); } - // 2.2 写入本批变更会话 + // 2.2 应用本批撤回信号 + for (const recallMessage of recallMessages) { + const changed = await this.applyRecallMessageRecord( + recallMessage.conversationType, + recallMessage.targetId, + recallMessage.recallSignalContent, + tx, + ); + if (changed) { + changedConversations.set( + getClientConversationId( + changed.conversation.type, + changed.conversation.targetId, + ), + changed.conversation, + ); + } + } + // 2.3 写入本批变更会话 await conversationStore.saveConversationRecord( [...changedConversations.values()], tx, ); - // 2.3 写入本批游标 + // 2.4 写入本批游标 await setMessageMaxId(conversationType, maxMessageId, tx); }, ); @@ -708,6 +809,19 @@ export const useMessageStore = defineStore('imMessageStore', { conversationInfo.type, conversationInfo.targetId, ); + const isActive = + conversationStore.activeConversation?.type === conversationInfo.type && + conversationStore.activeConversation?.targetId === + conversationInfo.targetId; + const isUnread = + !message.selfSend && + !isActive && + !conversationStore.isMessageCoveredByReadPosition( + conversation, + message, + ) && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL; const existingIndex = messages.findIndex((item) => isSameMessage(item, message), ); @@ -720,6 +834,8 @@ export const useMessageStore = defineStore('imMessageStore', { applyServerMessageUpdate(existing, message); if (existingIndex === messages.length - 1) { recomputeConversationLast(conversation, messages); + } + if (isUnread) { syncConversationAtFlags(conversation, message); } return getDb() @@ -752,22 +868,8 @@ export const useMessageStore = defineStore('imMessageStore', { // 4. 新消息更新会话摘要和未读状态 applyConversationSummary(conversation, message); - syncConversationAtFlags(conversation, message); - - const isActive = - conversationStore.activeConversation?.type === conversationInfo.type && - conversationStore.activeConversation?.targetId === - conversationInfo.targetId; - if ( - !message.selfSend && - !isActive && - !conversationStore.isMessageCoveredByReadPosition( - conversation, - message, - ) && - isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL - ) { + if (isUnread) { + syncConversationAtFlags(conversation, message); conversation.unreadCount++; } @@ -920,17 +1022,17 @@ export const useMessageStore = defineStore('imMessageStore', { recallSignalContent: string, ): Promise { const conversationStore = useConversationStore(); - const changed = this.applyRecallMessageInMemory( - conversationType, - targetId, - recallSignalContent, - ); - if (!changed) { - return; - } await getDb() .transaction(['messages', 'conversations'], 'readwrite', async (tx) => { - await this.saveMessageRecord(changed.message, conversationType, tx); + const changed = await this.applyRecallMessageRecord( + conversationType, + targetId, + recallSignalContent, + tx, + ); + if (!changed) { + return; + } await conversationStore.saveConversationRecord( changed.conversation, tx, @@ -1101,6 +1203,7 @@ export const useMessageStore = defineStore('imMessageStore', { message._localFile = undefined; }); Reflect.deleteProperty(this.messagesByConversation, clientConversationId); + Reflect.deleteProperty(this.messageDOPageCursors, clientConversationId); this.loadedConversationKeys = this.loadedConversationKeys.filter( (key) => key !== clientConversationId, ); diff --git a/apps/web-ele/src/views/im/home/types/index.ts b/apps/web-ele/src/views/im/home/types/index.ts index df50c50d6..071cb6fb8 100644 --- a/apps/web-ele/src/views/im/home/types/index.ts +++ b/apps/web-ele/src/views/im/home/types/index.ts @@ -110,6 +110,8 @@ export interface Conversation { silent?: boolean; // 是否免打扰(不展示未读徽标 + 不响提示音) atMe?: boolean; // 群聊:是否有人 @我 atAll?: boolean; // 群聊:是否有人 @全体成员 + atMessageId?: number; // 最近一次未读 @我的消息编号,用于点击提醒后定位 + atAllMessageId?: number; // 最近一次未读 @全体成员的消息编号,用于点击提醒后定位 reportedReadMessageId?: number; // 已上报到服务端的最大已读消息编号 draft?: { html: string; // 输入框 HTML diff --git a/apps/web-ele/src/views/im/utils/db.ts b/apps/web-ele/src/views/im/utils/db.ts index 036ec942a..432423544 100644 --- a/apps/web-ele/src/views/im/utils/db.ts +++ b/apps/web-ele/src/views/im/utils/db.ts @@ -22,6 +22,18 @@ export type DbStoreName = export type DbTransaction = IDBTransaction; +/** 数据库消息分页游标 */ +export interface MessageDOPageCursor { + messageKey: string; + sendTime: number; +} + +/** 数据库消息分页结果 */ +export interface MessageDOPageResult { + hasMore: boolean; + list: MessageDO[]; +} + /** IM 本地存储 key */ export const StorageKeys = { localStorage: { @@ -363,18 +375,19 @@ class DbClient { /** 按会话分页获取消息 */ async getMessageListByConversation( clientConversationId: string, - options?: { beforeSendTime?: number; limit?: number }, + options?: { before?: MessageDOPageCursor; limit?: number }, tx?: DbTransaction, - ): Promise { + ): Promise { const limit = options?.limit ?? 50; - const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER; + const before = options?.before; + const upper = before?.sendTime ?? Number.MAX_SAFE_INTEGER; const range = IDBKeyRange.bound( [clientConversationId, 0], [clientConversationId, upper], false, - true, + !before, ); - const read = async (tx: DbTransaction): Promise => { + const read = async (tx: DbTransaction): Promise => { const index = tx .objectStore('messages') .index('clientConversationId+sendTime'); @@ -385,21 +398,41 @@ class DbClient { request.addEventListener('error', () => reject(request.error)); request.addEventListener('success', () => { const cursor = request.result; - if (!cursor || out.length >= limit) { + if (!cursor) { + resolve(); + return; + } + const message = cursor.value as MessageDO; + if ( + before && + message.sendTime === before.sendTime && + message.messageKey >= before.messageKey + ) { + cursor.continue(); + return; + } + out.push(message); + if (out.length > limit) { resolve(); return; } - out.push(cursor.value as MessageDO); cursor.continue(); }); }); // 气泡渲染需要按时间升序 - return out.toReversed(); + return { + hasMore: out.length > limit, + list: out.slice(0, limit).toReversed(), + }; }; if (tx) { return read(tx); } - return this.transaction(['messages'], 'readonly', read); + return this.transaction( + ['messages'], + 'readonly', + read, + ); } /** 读取设置 */ @@ -506,29 +539,6 @@ export function getClientMessageKey(clientMessageId: string): string { return `client:${clientMessageId}`; } -/** 解析本地消息主键 */ -export function parseMessageKey( - messageKey: string, -): - | null - | { clientMessageId: string; kind: 'client' } - | { conversationType: number; id: number; kind: 'server' } { - if (!messageKey) { - return null; - } - if (messageKey.startsWith('client:')) { - const clientMessageId = messageKey.slice('client:'.length); - return clientMessageId ? { kind: 'client', clientMessageId } : null; - } - const [conversationTypeText, idText] = messageKey.split(':'); - const conversationType = Number(conversationTypeText); - const id = Number(idText); - if (!Number.isFinite(conversationType) || !Number.isFinite(id) || id <= 0) { - return null; - } - return { kind: 'server', conversationType, id }; -} - /** 更新消息拉取游标 */ export async function setMessageMaxId( conversationType: number,