diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index d45044daf..dbb8c55da 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -19,7 +19,6 @@ import { pullChannelMessages as apiPullChannelMessages, type ImChannelMessageRespVO } from '@/api/im/message/channel' -import { pullMyConversationReadList as apiPullMyConversationReadList } from '@/api/im/conversation/read' import { ImConversationType, ImMessageStatus, @@ -34,8 +33,7 @@ import { } from '../../utils/config' import { buildChannelConversationStub } from '../../utils/channel' import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message' -import { runIncrementalPull, runMinIdPull } from '../../utils/pull' -import { StorageKeys } from '../../utils/db' +import { runMinIdPull } from '../../utils/pull' import { getCurrentUserId } from '@/utils/auth' import type { Message } from '../types' @@ -285,25 +283,6 @@ export const useMessagePuller = () => { wsStore.discardBuffer() } - /** 增量拉取我的会话读位置并合并到本地展示态 */ - const pullConversationReads = async (isActive: () => boolean): Promise => { - await runIncrementalPull( - StorageKeys.settings.conversationReadPullCursor, - apiPullMyConversationReadList, - async (records) => { - if (!isActive()) { - return false - } - await messageStore.applyConversationReadList(records, isActive) - if (!isActive()) { - return false - } - return true - }, - isActive - ) - } - /** * 状态事件补偿:好友 / 好友申请走增量;群列表和群申请红点走快照刷新 * @@ -315,6 +294,7 @@ export const useMessagePuller = () => { const results = await Promise.allSettled([ friendStore.pullFriends(), friendStore.pullFriendRequests(), + conversationStore.pullConversationReads(), groupStore.fetchGroupList(true), groupRequestStore.pullGroupRequests(), groupRequestStore.fetchUnhandledGroupRequestList() @@ -415,12 +395,6 @@ export const useMessagePuller = () => { // pull + replay 都完成后再排序,避免回放消息打乱顺序 conversationStore.sortConversationList() - // 消息和缓冲帧落库后再补读位置,避免读位置游标先推进导致新消息展示态漏更新 - await pullConversationReads(isCurrentPull) - if (!isCurrentPull()) { - return - } - // 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」 // 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 Index.vue 的 watch 触发 // 私聊已读关闭时跳过,避免打到已禁用接口触发错误日志 diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts index edc9c7efe..8e4f7d219 100644 --- a/src/views/im/home/composables/useMessageSender.ts +++ b/src/views/im/home/composables/useMessageSender.ts @@ -229,8 +229,6 @@ export const useMessageSender = () => { if (!conversation) { return } - // 本地标记已读:未读数清零(UI 立刻响应) - conversationStore.markConversationRead(conversation.type, conversation.targetId) const loadedMaxMessageId = messageStore .getMessages(getClientConversationId(conversation.type, conversation.targetId)) .reduce( @@ -239,6 +237,8 @@ export const useMessageSender = () => { 0 ) const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0) + // 本地标记已读:未读数清零(UI 立刻响应) + conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId) if (!maxMessageId) { return } diff --git a/src/views/im/home/index.vue b/src/views/im/home/index.vue index 7695b1fc1..1d8e0e5f9 100644 --- a/src/views/im/home/index.vue +++ b/src/views/im/home/index.vue @@ -125,11 +125,16 @@ onMounted(async () => { .pullFriendRequests() .catch((e) => console.warn('[IM] 后台增量拉好友申请失败', e)) - // 3. 实时通信:建 WebSocket 长连接 + 拉离线消息(pullOnce finally 把 loading 归位) + // 3. 会话读位置先补偿,消息入库时可直接过滤已读历史消息 + await conversationStore + .pullConversationReads() + .catch((e) => console.warn('[IM] 拉取会话读位置失败', e)) + + // 4. 实时通信:建 WebSocket 长连接 + 拉离线消息(pullOnce finally 把 loading 归位) webSocketStore.connect() await pullOnce() - // 4. 默认选中第一个会话;若置顶分组处于折叠态,需跳过被折叠隐藏的置顶项,避免自动展开折叠 + // 5. 默认选中第一个会话;若置顶分组处于折叠态,需跳过被折叠隐藏的置顶项,避免自动展开折叠 const sorted = conversationStore.getSortedConversationList const firstVisible = pickFirstVisibleConversation(sorted) if (firstVisible && !conversationStore.activeConversation) { diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index a847455fe..46571a2c7 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -3,15 +3,33 @@ import { debounce } from 'lodash-es' import { store } from '@/store' import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config' -import { ImConversationType } from '../../utils/constants' +import { + ImConversationType, + ImMessageReceiptStatus, + ImMessageStatus, + isNormalMessage +} from '../../utils/constants' import { getClientConversationId, getDb, StorageKeys, type DbTransaction } from '../../utils/db' +import { runIncrementalPull } from '../../utils/pull' import { getCurrentUserId } from '@/utils/auth' import { useMessageStore } from './messageStore' -import type { Conversation, ConversationDO } from '../types' +import { + pullMyConversationReadList as apiPullMyConversationReadList, + type ImConversationReadRespVO +} from '@/api/im/conversation/read' +import type { + Conversation, + ConversationDO, + ConversationRead, + ConversationReadDO, + MessageDO +} from '../types' const PERSIST_DRAFT_DEBOUNCE_MS = 500 const pendingDraftConversations = new Set() +type LegacyConversationDO = ConversationDO & { readMessageId?: number } + /** 会话转 IndexedDB 记录 */ function toConversationDO(conversation: Conversation): ConversationDO { const draft = conversation.draft @@ -42,14 +60,59 @@ function toConversationDO(conversation: Conversation): ConversationDO { } /** IndexedDB 记录转会话 */ -function fromConversationDO(conversation: ConversationDO): Conversation { - const { clientConversationId: _clientConversationId, ...rest } = conversation +function fromConversationDO(conversation: LegacyConversationDO): Conversation { + const { + clientConversationId: _clientConversationId, + readMessageId: _readMessageId, + ...rest + } = conversation return rest } +/** 会话读位置转 IndexedDB 记录 */ +function toConversationReadDO(record: ConversationRead): ConversationReadDO { + return { + conversationType: record.conversationType, + targetId: record.targetId, + messageId: record.messageId, + updateTime: record.updateTime, + clientConversationId: getClientConversationId(record.conversationType, record.targetId) + } +} + +/** IndexedDB 记录转会话读位置 */ +function fromConversationReadDO(record: ConversationReadDO): ConversationRead { + const { clientConversationId: _clientConversationId, ...rest } = record + return rest +} + +/** 是否为有效会话读位置 */ +function isValidConversationReadRecord(record: ImConversationReadRespVO): boolean { + return !!record.conversationType && !!record.targetId && !!record.messageId +} + +/** 获取对方普通消息最大编号 */ +function getMaxIncomingNormalMessageId( + messages: Array> +): number { + return messages.reduce((maxMessageId, message) => { + if ( + message.id && + !message.selfSend && + isNormalMessage(message.type) && + message.status !== ImMessageStatus.RECALL && + message.id > maxMessageId + ) { + return message.id + } + return maxMessageId + }, 0) +} + export const useConversationStore = defineStore('imConversationStore', { state: () => ({ conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊 + 频道) + conversationReads: {} as Record, // 会话读位置 activeConversation: null as Conversation | null, // 当前激活的会话 loading: false, // 是否正在批量加载 recentForwardConversationKeys: [] as string[] // 最近转发会话 key 列表 @@ -83,7 +146,13 @@ export const useConversationStore = defineStore('imConversationStore', { (type: number, targetId: number): Conversation | undefined => state.conversations.find( (conversation) => conversation.type === type && conversation.targetId === targetId - ) + ), + + /** 查找会话读位置 */ + getConversationRead: + (state) => + (type: number, targetId: number): ConversationRead | undefined => + state.conversationReads[getClientConversationId(type, targetId)] }, actions: { @@ -101,11 +170,42 @@ export const useConversationStore = defineStore('imConversationStore', { this.clear() // 2. 从 IndexedDB 读取会话和轻量设置 const db = getDb() - const [conversations, recent] = await Promise.all([ + const [conversations, conversationReads, recent] = await Promise.all([ db.getAll('conversations'), + db.getAll('conversationReads'), db.getSetting(StorageKeys.settings.recentForwardConversationKeys) ]) - this.conversations = conversations.map(fromConversationDO) + const nextConversationReads: Record = {} + for (const record of conversationReads) { + const item = fromConversationReadDO(record) + nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item + } + const migratedReads: ConversationRead[] = [] + for (const conversation of conversations as LegacyConversationDO[]) { + if (!conversation.readMessageId) { + continue + } + const key = getClientConversationId(conversation.type, conversation.targetId) + if (nextConversationReads[key]) { + continue + } + const record = { + conversationType: conversation.type, + targetId: conversation.targetId, + messageId: conversation.readMessageId + } + nextConversationReads[key] = record + migratedReads.push(record) + } + const nextConversations = (conversations as LegacyConversationDO[]).map(fromConversationDO) + this.conversationReads = nextConversationReads + await this.applyLocalConversationReads(nextConversations) + this.conversations = nextConversations + if (migratedReads.length > 0) { + void this.saveConversationReadRecord(migratedReads).catch((e) => + console.warn('[IM conversationStore] 会话读位置迁移失败', e) + ) + } if (Array.isArray(recent)) { this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX) } @@ -126,10 +226,236 @@ export const useConversationStore = defineStore('imConversationStore', { saveDraftConversationListDebounced.cancel() pendingDraftConversations.clear() this.conversations = [] + this.conversationReads = {} this.activeConversation = null this.recentForwardConversationKeys = [] }, + /** 持久化会话读位置 */ + async saveConversationReadRecord( + target: ConversationRead | ConversationRead[] | null | undefined, + tx?: DbTransaction + ): Promise { + const records = (Array.isArray(target) ? target : target ? [target] : []).map( + toConversationReadDO + ) + if (records.length === 0) { + return + } + const db = getDb() + if (tx) { + for (const record of records) { + await db.put('conversationReads', record, tx) + } + return + } + await db.transaction(['conversationReads'], 'readwrite', async (tx) => { + for (const record of records) { + await db.put('conversationReads', record, tx) + } + }) + }, + + /** 应用本地会话读位置 */ + async applyLocalConversationReads(conversations?: Conversation[]) { + const targetConversations = conversations || this.conversations + const changedConversations: Conversation[] = [] + for (const conversation of targetConversations) { + const record = this.getConversationRead(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) { + continue + } + const messages = await getDb().getAllByIndex( + 'messages', + '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 + changedConversations.push(conversation) + } + } + if (changedConversations.length > 0) { + await this.saveConversationRecord(changedConversations) + } + }, + + /** 判断消息是否已被会话读位置覆盖 */ + isMessageCoveredByReadPosition( + conversation: Pick, + message?: { id?: number } | null + ): boolean { + if (!message?.id) { + return false + } + const record = this.getConversationRead(conversation.type, conversation.targetId) + return !!record && message.id <= record.messageId + }, + + /** 应用读位置到会话 */ + applyReadToConversation(conversation: Conversation, messageId: number): 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 + }, + + /** 应用会话读位置 */ + async applyConversationReadList( + records: ImConversationReadRespVO[], + isActive?: () => boolean + ): Promise { + if (records.length === 0) { + return + } + const changedReads = new Map() + const changedConversations = new Map() + const changedMessages = new Map() + const db = getDb() + const messageStore = useMessageStore() + + // 1. 按读位置更新会话未读和频道已读态 + for (const record of records) { + if (isActive && !isActive()) { + return + } + if (!isValidConversationReadRecord(record)) { + continue + } + const clientConversationId = getClientConversationId( + record.conversationType, + record.targetId + ) + let storedMessages: MessageDO[] | undefined + const getStoredMessages = async () => { + if (!storedMessages) { + storedMessages = await db.getAllByIndex( + 'messages', + 'clientConversationId', + clientConversationId + ) + } + return storedMessages + } + const current = this.conversationReads[clientConversationId] + const messageId = Math.max(record.messageId, current?.messageId || 0) + if (!current || messageId > current.messageId) { + const next = { + conversationType: record.conversationType, + targetId: record.targetId, + messageId, + updateTime: record.updateTime + } + this.conversationReads[clientConversationId] = next + changedReads.set(clientConversationId, next) + } + + const conversation = this.getConversation(record.conversationType, record.targetId) + if (conversation && this.applyReadToConversation(conversation, messageId)) { + 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 + } + const memoryMessages = messageStore.getMessages(clientConversationId) + for (const message of memoryMessages) { + if ( + message.id && + message.id <= messageId && + message.receiptStatus !== ImMessageReceiptStatus.DONE + ) { + message.receiptStatus = ImMessageReceiptStatus.DONE + } + } + for (const message of await getStoredMessages()) { + if ( + message.id && + message.id <= messageId && + message.receiptStatus !== ImMessageReceiptStatus.DONE + ) { + message.receiptStatus = ImMessageReceiptStatus.DONE + changedMessages.set(message.messageKey, message) + } + } + } + + // 2. 持久化本轮变更 + if ( + changedReads.size === 0 && + changedConversations.size === 0 && + changedMessages.size === 0 + ) { + return + } + if (isActive && !isActive()) { + return + } + const stores: Array<'conversationReads' | 'conversations' | 'messages'> = [] + if (changedReads.size > 0) { + stores.push('conversationReads') + } + if (changedConversations.size > 0) { + stores.push('conversations') + } + if (changedMessages.size > 0) { + stores.push('messages') + } + await db.transaction(stores, 'readwrite', async (tx) => { + if (changedReads.size > 0) { + await this.saveConversationReadRecord([...changedReads.values()], tx) + } + if (changedConversations.size > 0) { + await this.saveConversationRecord([...changedConversations.values()], tx) + } + for (const message of changedMessages.values()) { + await db.put('messages', message, tx) + } + }) + }, + + /** 增量拉取会话读位置 */ + async pullConversationReads(isActive?: () => boolean): Promise { + await runIncrementalPull( + StorageKeys.settings.conversationReadPullCursor, + apiPullMyConversationReadList, + async (records) => { + if (isActive && !isActive()) { + return false + } + await this.applyConversationReadList(records, isActive) + if (isActive && !isActive()) { + return false + } + return true + }, + isActive + ) + }, + /** 执行会话记录持久化 */ async saveConversationRecord( target: Conversation | Conversation[] | null | undefined, @@ -326,17 +652,41 @@ export const useConversationStore = defineStore('imConversationStore', { }, /** 标记会话已读 */ - markConversationRead(type: number, targetId: number) { + markConversationRead(type: number, targetId: number, messageId?: number) { const conversation = this.getConversation(type, targetId) if (!conversation) { return } - if (conversation.unreadCount === 0 && !conversation.atMe && !conversation.atAll) { + const key = getClientConversationId(type, targetId) + const current = this.conversationReads[key] + const readMessageIdAdvanced = !!messageId && messageId > (current?.messageId || 0) + if ( + conversation.unreadCount === 0 && + !conversation.atMe && + !conversation.atAll && + !readMessageIdAdvanced + ) { return } conversation.unreadCount = 0 conversation.atMe = false conversation.atAll = false + if (readMessageIdAdvanced) { + const record = { + conversationType: type, + targetId, + messageId, + updateTime: Date.now() + } + this.conversationReads[key] = record + void getDb() + .transaction(['conversations', 'conversationReads'], 'readwrite', async (tx) => { + await this.saveConversationRecord(conversation, tx) + await this.saveConversationReadRecord(record, tx) + }) + .catch((e) => console.warn('[IM conversationStore] 会话已读写入失败', e)) + return + } this.saveConversation(conversation) }, diff --git a/src/views/im/home/store/messageStore.ts b/src/views/im/home/store/messageStore.ts index 69ac1bfa8..de9032db5 100644 --- a/src/views/im/home/store/messageStore.ts +++ b/src/views/im/home/store/messageStore.ts @@ -30,7 +30,6 @@ import { getCurrentUserId } from '@/utils/auth' import { isGroupQuit, tryGetSenderDisplayName } from '../../utils/user' import { useGroupStore } from './groupStore' import { useConversationStore } from './conversationStore' -import type { ImConversationReadRespVO } from '@/api/im/conversation/read' import type { Conversation, Message, MessageDO } from '../types' const MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT = 5 @@ -238,24 +237,6 @@ function isSameMessage(left: Message, right: Message): boolean { return !!left.clientMessageId && left.clientMessageId === right.clientMessageId } -/** 获取对方普通消息最大编号 */ -function getMaxIncomingNormalMessageId( - messages: Array> -): number { - return messages.reduce((maxMessageId, message) => { - if ( - message.id && - !message.selfSend && - isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL && - message.id > maxMessageId - ) { - return message.id - } - return maxMessageId - }, 0) -} - export const useMessageStore = defineStore('imMessageStore', { state: () => ({ messagesByConversation: {} as Record, @@ -527,6 +508,7 @@ export const useMessageStore = defineStore('imMessageStore', { if ( !message.selfSend && !isActive && + !conversationStore.isMessageCoveredByReadPosition(conversation, message) && isNormalMessage(message.type) && message.status !== ImMessageStatus.RECALL ) { @@ -632,6 +614,7 @@ export const useMessageStore = defineStore('imMessageStore', { if ( !message.selfSend && !isActive && + !conversationStore.isMessageCoveredByReadPosition(conversation, message) && isNormalMessage(message.type) && message.status !== ImMessageStatus.RECALL ) { @@ -844,127 +827,6 @@ export const useMessageStore = defineStore('imMessageStore', { .catch((e) => console.warn('[IM messageStore] 回执写入失败', e)) }, - /** 应用会话读位置补偿 */ - async applyConversationReadList( - records: ImConversationReadRespVO[], - isActive?: () => boolean - ): Promise { - if (records.length === 0) { - return - } - const conversationStore = useConversationStore() - const changedConversations = new Map() - const changedMessages = new Map() - const db = getDb() - - // 1. 按读位置更新会话未读和频道已读态 - for (const record of records) { - if (isActive && !isActive()) { - return - } - if (!record.conversationType || !record.targetId || !record.messageId) { - continue - } - const clientConversationId = getClientConversationId( - record.conversationType, - record.targetId - ) - let storedMessages: MessageDO[] | undefined - const getStoredMessages = async () => { - if (!storedMessages) { - storedMessages = await db.getAllByIndex( - 'messages', - 'clientConversationId', - clientConversationId - ) - } - return storedMessages - } - const conversation = conversationStore.getConversation( - record.conversationType, - record.targetId - ) - if ( - conversation && - (conversation.unreadCount > 0 || conversation.atMe || conversation.atAll) - ) { - const memoryMessages = this.messagesByConversation[clientConversationId] - let readCovered = - !!conversation.lastMessageId && conversation.lastMessageId <= record.messageId - const latestMessageLoaded = - !!conversation.lastMessageId && - memoryMessages?.some((message) => message.id === conversation.lastMessageId) - if (!readCovered && latestMessageLoaded && memoryMessages) { - const maxIncomingMessageId = getMaxIncomingNormalMessageId(memoryMessages) - readCovered = maxIncomingMessageId > 0 && maxIncomingMessageId <= record.messageId - } - if (!readCovered && !latestMessageLoaded) { - const storedMessages = await getStoredMessages() - const latestMessageStored = - !!conversation.lastMessageId && - storedMessages.some((message) => message.id === conversation.lastMessageId) - if (latestMessageStored) { - const storedMaxIncomingMessageId = getMaxIncomingNormalMessageId(storedMessages) - readCovered = - storedMaxIncomingMessageId > 0 && storedMaxIncomingMessageId <= record.messageId - } - } - if (readCovered) { - conversation.unreadCount = 0 - conversation.atMe = false - conversation.atAll = false - changedConversations.set(clientConversationId, conversation) - } - } - if (record.conversationType !== ImConversationType.CHANNEL) { - continue - } - const memoryMessages = this.messagesByConversation[clientConversationId] || [] - for (const message of memoryMessages) { - if ( - message.id && - message.id <= record.messageId && - message.receiptStatus !== ImMessageReceiptStatus.DONE - ) { - message.receiptStatus = ImMessageReceiptStatus.DONE - } - } - for (const message of await getStoredMessages()) { - if ( - message.id && - message.id <= record.messageId && - message.receiptStatus !== ImMessageReceiptStatus.DONE - ) { - message.receiptStatus = ImMessageReceiptStatus.DONE - changedMessages.set(message.messageKey, message) - } - } - } - - // 2. 持久化本轮变更 - if (changedConversations.size === 0 && changedMessages.size === 0) { - return - } - if (isActive && !isActive()) { - return - } - const stores: Array<'conversations' | 'messages'> = [] - if (changedConversations.size > 0) { - stores.push('conversations') - } - if (changedMessages.size > 0) { - stores.push('messages') - } - await db.transaction(stores, 'readwrite', async (tx) => { - if (changedConversations.size > 0) { - await conversationStore.saveConversationRecord([...changedConversations.values()], tx) - } - for (const message of changedMessages.values()) { - await db.put('messages', message, tx) - } - }) - }, - /** 前置历史消息 */ prependMessageList(conversationType: number, targetId: number, earlierMessages: Message[]) { if (earlierMessages.length === 0) { diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 42d049ea1..da0178d78 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -366,7 +366,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { /** 频道 READ:自己其它终端在某频道里标为已读,本端同步清零该频道未读 */ handleChannelRead(websocketMessage: ImChannelMessageRespVO) { - void useMessageStore() + void useConversationStore() .applyConversationReadList([ { id: websocketMessage.id, @@ -425,7 +425,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { // 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后 conversationStore.markConversationRead( ImConversationType.CHANNEL, - websocketMessage.channelId + websocketMessage.channelId, + websocketMessage.id ) apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id).catch((e) => { console.warn('[IM WS] 频道自动已读上报失败', e) @@ -606,7 +607,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { if (isActive) { // 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读" // 已读位置直接用刚到的消息 id(这条就是当前会话最大 id) - conversationStore.markConversationRead(ImConversationType.PRIVATE, peerId) + conversationStore.markConversationRead( + ImConversationType.PRIVATE, + peerId, + websocketMessage.id + ) if (MESSAGE_PRIVATE_READ_ENABLED) { apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => { console.warn('[IM WS] 自动已读上报失败', e) @@ -628,7 +633,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { if (!websocketMessage.id || !websocketMessage.receiverId) { return } - void useMessageStore() + void useConversationStore() .applyConversationReadList([ { id: websocketMessage.id, @@ -741,7 +746,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.activeConversation?.targetId === websocketMessage.groupId if (isActive) { // 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零 - conversationStore.markConversationRead(ImConversationType.GROUP, websocketMessage.groupId) + conversationStore.markConversationRead( + ImConversationType.GROUP, + websocketMessage.groupId, + websocketMessage.id + ) if (MESSAGE_GROUP_READ_ENABLED) { apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => { console.warn('[IM WS] 自动已读上报失败', e) @@ -766,7 +775,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { if (!readMessageId || !websocketMessage.groupId) { return } - void useMessageStore() + void useConversationStore() .applyConversationReadList([ { id: readMessageId, diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index ecf30c3b8..5fea28717 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -151,6 +151,17 @@ export interface ConversationDO extends Conversation { clientConversationId: string // `${type}:${targetId}` } +export interface ConversationRead { + conversationType: number // 会话类型,对齐 ImConversationType + targetId: number // 会话目标编号 + messageId: number // 当前用户已读到的最大消息编号 + updateTime?: number // 更新时间 +} + +export interface ConversationReadDO extends ConversationRead { + clientConversationId: string // `${conversationType}:${targetId}` +} + export interface MessageDO extends Omit { messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}` conversationType: number // 会话类型,对齐 ImConversationType diff --git a/src/views/im/utils/db.ts b/src/views/im/utils/db.ts index 586619919..1f0c3c82c 100644 --- a/src/views/im/utils/db.ts +++ b/src/views/im/utils/db.ts @@ -4,10 +4,11 @@ import { getCurrentUserId } from '@/utils/auth' import { ImConversationType } from './constants' import type { MessageDO, SettingDO } from '../home/types' -export const DB_SCHEMA_VERSION = 1 +export const DB_SCHEMA_VERSION = 2 export type DbStoreName = | 'conversations' + | 'conversationReads' | 'messages' | 'friends' | 'friendRequests' @@ -103,6 +104,12 @@ function upgradeSchema(db: IDBDatabase) { const store = db.createObjectStore('conversations', { keyPath: 'clientConversationId' }) createIndex(store, 'lastSendTime', 'lastSendTime') } + if (!db.objectStoreNames.contains('conversationReads')) { + const store = db.createObjectStore('conversationReads', { keyPath: 'clientConversationId' }) + createIndex(store, 'conversationType+targetId', ['conversationType', 'targetId'], { + unique: true + }) + } if (!db.objectStoreNames.contains('messages')) { const store = db.createObjectStore('messages', { keyPath: 'messageKey' }) createIndex(store, 'clientConversationId', 'clientConversationId')