fix(im): 修复重新登录会话未读闪烁
- 新增会话读位置本地存储,独立维护 conversationReads - 启动时先恢复本地读位置,并在会话列表渲染前修正未读状态 - 消息入库时基于读位置过滤已读历史消息,避免重新累计未读 - READ 同步与主动已读统一走 conversationStore,保证读位置单调推进 - 兼容旧会话 readMessageId 数据迁移pull/884/MERGE
parent
8ba76813ae
commit
07c8f143ea
|
|
@ -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<void> => {
|
||||
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 触发
|
||||
// 私聊已读关闭时跳过,避免打到已禁用接口触发错误日志
|
||||
|
|
|
|||
|
|
@ -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<number>(
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<Conversation>()
|
||||
|
||||
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<Pick<MessageDO, 'id' | 'selfSend' | 'type' | 'status'>>
|
||||
): 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<string, ConversationRead>, // 会话读位置
|
||||
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<ConversationDO>('conversations'),
|
||||
db.getAll<ConversationReadDO>('conversationReads'),
|
||||
db.getSetting<string[]>(StorageKeys.settings.recentForwardConversationKeys)
|
||||
])
|
||||
this.conversations = conversations.map(fromConversationDO)
|
||||
const nextConversationReads: Record<string, ConversationRead> = {}
|
||||
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<void> {
|
||||
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<MessageDO>(
|
||||
'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<Conversation, 'type' | 'targetId'>,
|
||||
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<void> {
|
||||
if (records.length === 0) {
|
||||
return
|
||||
}
|
||||
const changedReads = new Map<string, ConversationRead>()
|
||||
const changedConversations = new Map<string, Conversation>()
|
||||
const changedMessages = new Map<string, MessageDO>()
|
||||
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<MessageDO>(
|
||||
'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<void> {
|
||||
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)
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Pick<Message, 'id' | 'selfSend' | 'type' | 'status'>>
|
||||
): 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<string, Message[]>,
|
||||
|
|
@ -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<void> {
|
||||
if (records.length === 0) {
|
||||
return
|
||||
}
|
||||
const conversationStore = useConversationStore()
|
||||
const changedConversations = new Map<string, Conversation>()
|
||||
const changedMessages = new Map<string, MessageDO>()
|
||||
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<MessageDO>(
|
||||
'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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Message, 'uploadProgress' | '_localFile' | '_ackMerging'> {
|
||||
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
|
||||
conversationType: number // 会话类型,对齐 ImConversationType
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue