feat(im):优化已读上报补偿与群通话探测缓存
- 会话新增 readMessageId,记录已上报到服务端的最大已读消息编号 - readActive 与 WebSocket 自动已读改为基于服务端已上报读位置判断是否跳过接口 - read 接口成功后同步 readMessageId,失败时保留本端已读体验并允许后续重新进入补上报 - 拉取服务端 read 进度时同步更新会话 readMessageId,同时保持本地读位置单调合并 - 群信息新增 activeCallLoaded / activeCallExpired,首登与重连时失效群通话探测缓存 - 群通话胶囊在本地无通话且探测过期时懒加载 getActiveCall,避免离线错过通话后无法发现 - 群通话写入或移除时标记探测已加载,并避免通话探测状态写入 IndexedDB - 为 IndexedDB DO 类型补充存储结构注释im
parent
2172415cad
commit
b6e13c59c7
|
|
@ -67,6 +67,7 @@ import Icon from '@/components/Icon/src/Icon.vue'
|
||||||
import UserAvatar from '../user/UserAvatar.vue'
|
import UserAvatar from '../user/UserAvatar.vue'
|
||||||
import { useMessage } from '@/hooks/web/useMessage'
|
import { useMessage } from '@/hooks/web/useMessage'
|
||||||
import { useRtcStore } from '../../store/rtcStore'
|
import { useRtcStore } from '../../store/rtcStore'
|
||||||
|
import { useGroupStore } from '../../store/groupStore'
|
||||||
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
|
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
|
||||||
import { joinCall, getActiveCall } from '@/api/im/rtc'
|
import { joinCall, getActiveCall } from '@/api/im/rtc'
|
||||||
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
||||||
|
|
@ -79,6 +80,7 @@ const props = defineProps<{
|
||||||
defineOptions({ name: 'ImRtcGroupCallBanner' })
|
defineOptions({ name: 'ImRtcGroupCallBanner' })
|
||||||
|
|
||||||
const rtcStore = useRtcStore()
|
const rtcStore = useRtcStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const popoverVisible = ref(false)
|
const popoverVisible = ref(false)
|
||||||
|
|
@ -99,19 +101,43 @@ const pillText = computed(() => {
|
||||||
* 用 [groupId, room] 双源监听 + 已填充守卫,避免切群 / 首次填充触发的双次重复拉取
|
* 用 [groupId, room] 双源监听 + 已填充守卫,避免切群 / 首次填充触发的双次重复拉取
|
||||||
*/
|
*/
|
||||||
watch(
|
watch(
|
||||||
() => [props.groupId, activeCall.value?.room] as const,
|
() =>
|
||||||
|
[
|
||||||
|
props.groupId,
|
||||||
|
activeCall.value?.room,
|
||||||
|
groupStore.isGroupActiveCallExpired(props.groupId)
|
||||||
|
] as const,
|
||||||
async ([groupId, room], oldValues) => {
|
async ([groupId, room], oldValues) => {
|
||||||
if (!groupId || !activeCall.value) {
|
if (!groupId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 决策是否需要拉取:仅补齐本地已有通话;没有本地通话时等待实时事件创建
|
if (!activeCall.value) {
|
||||||
|
if (!groupStore.isGroupActiveCallExpired(groupId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await getActiveCall(groupId)
|
||||||
|
if (data) {
|
||||||
|
rtcStore.setGroupCall(data, true)
|
||||||
|
} else {
|
||||||
|
rtcStore.removeGroupCall(groupId)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, e)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 决策是否需要拉取:补齐本地已有通话;没有本地通话时按群缓存过期状态懒探测一次
|
||||||
const groupChanged = !oldValues || oldValues[0] !== groupId
|
const groupChanged = !oldValues || oldValues[0] !== groupId
|
||||||
const roomChanged = oldValues && oldValues[1] !== room
|
const roomChanged = oldValues && oldValues[1] !== room
|
||||||
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
|
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
|
||||||
|
const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId)
|
||||||
if (
|
if (
|
||||||
rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
|
!activeCallExpired &&
|
||||||
(!groupChanged && !roomChanged && participantsLoaded)
|
(rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
|
||||||
|
(!groupChanged && !roomChanged && participantsLoaded))
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,7 @@ export const useMessagePuller = () => {
|
||||||
// 1. 清理连接级缓存
|
// 1. 清理连接级缓存
|
||||||
messageStore.clearPrivateReadMaxIdCache()
|
messageStore.clearPrivateReadMaxIdCache()
|
||||||
rtcStore.clearGroupCallCache()
|
rtcStore.clearGroupCallCache()
|
||||||
|
groupStore.markAllGroupActiveCallsExpired()
|
||||||
groupStore.markAllGroupInfoExpired()
|
groupStore.markAllGroupInfoExpired()
|
||||||
groupStore.markAllGroupMembersExpired()
|
groupStore.markAllGroupMembersExpired()
|
||||||
// 2. 并发补偿远端状态
|
// 2. 并发补偿远端状态
|
||||||
|
|
|
||||||
|
|
@ -237,12 +237,12 @@ export const useMessageSender = () => {
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
|
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
|
||||||
const readCovered = conversationStore.isReadPositionCovered(
|
const readReported = conversationStore.isReportedReadPositionCovered(
|
||||||
conversation.type,
|
conversation.type,
|
||||||
conversation.targetId,
|
conversation.targetId,
|
||||||
maxMessageId
|
maxMessageId
|
||||||
)
|
)
|
||||||
if (readCovered) {
|
if (readReported) {
|
||||||
conversationStore.markConversationRead(conversation.type, conversation.targetId)
|
conversationStore.markConversationRead(conversation.type, conversation.targetId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -272,6 +272,11 @@ export const useMessageSender = () => {
|
||||||
} else {
|
} else {
|
||||||
await apiReadChannelMessages(conversation.targetId, maxMessageId)
|
await apiReadChannelMessages(conversation.targetId, maxMessageId)
|
||||||
}
|
}
|
||||||
|
conversationStore.markConversationReadReported(
|
||||||
|
conversation.type,
|
||||||
|
conversation.targetId,
|
||||||
|
maxMessageId
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
'[IM] 标记已读失败',
|
'[IM] 标记已读失败',
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ onMounted(async () => {
|
||||||
groupRequestStore.loadGroupRequestList()
|
groupRequestStore.loadGroupRequestList()
|
||||||
])
|
])
|
||||||
childRouteReady.value = true
|
childRouteReady.value = true
|
||||||
|
groupStore.markAllGroupActiveCallsExpired()
|
||||||
groupStore.markAllGroupMembersExpired()
|
groupStore.markAllGroupMembersExpired()
|
||||||
// 1.4 我管理的群下未处理加群申请红点:首登用 unhandled-list(服务端直接过滤未处理,语义精准、启动轻);
|
// 1.4 我管理的群下未处理加群申请红点:首登用 unhandled-list(服务端直接过滤未处理,语义精准、启动轻);
|
||||||
// pullGroupRequests 只在重连 / 后续补偿时跑(见 useMessagePuller.pullStateEvents),不进首登主链路
|
// pullGroupRequests 只在重连 / 后续补偿时跑(见 useMessagePuller.pullStateEvents),不进首登主链路
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ import type {
|
||||||
const PERSIST_DRAFT_DEBOUNCE_MS = 500
|
const PERSIST_DRAFT_DEBOUNCE_MS = 500
|
||||||
const pendingDraftConversations = new Set<Conversation>()
|
const pendingDraftConversations = new Set<Conversation>()
|
||||||
|
|
||||||
type LegacyConversationDO = ConversationDO & { readMessageId?: number }
|
|
||||||
|
|
||||||
/** 创建会话读位置记录 */
|
/** 创建会话读位置记录 */
|
||||||
function createConversationRead(
|
function createConversationRead(
|
||||||
type: number,
|
type: number,
|
||||||
|
|
@ -63,6 +61,7 @@ function toConversationDO(conversation: Conversation): ConversationDO {
|
||||||
lastReceiptStatus: conversation.lastReceiptStatus,
|
lastReceiptStatus: conversation.lastReceiptStatus,
|
||||||
lastSelfSend: conversation.lastSelfSend,
|
lastSelfSend: conversation.lastSelfSend,
|
||||||
lastSenderDisplayName: conversation.lastSenderDisplayName,
|
lastSenderDisplayName: conversation.lastSenderDisplayName,
|
||||||
|
readMessageId: conversation.readMessageId,
|
||||||
deleted: conversation.deleted,
|
deleted: conversation.deleted,
|
||||||
top: conversation.top,
|
top: conversation.top,
|
||||||
silent: conversation.silent,
|
silent: conversation.silent,
|
||||||
|
|
@ -74,10 +73,9 @@ function toConversationDO(conversation: Conversation): ConversationDO {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IndexedDB 记录转会话 */
|
/** IndexedDB 记录转会话 */
|
||||||
function fromConversationDO(conversation: LegacyConversationDO): Conversation {
|
function fromConversationDO(conversation: ConversationDO): Conversation {
|
||||||
const {
|
const {
|
||||||
clientConversationId: _clientConversationId,
|
clientConversationId: _clientConversationId,
|
||||||
readMessageId: _readMessageId,
|
|
||||||
...rest
|
...rest
|
||||||
} = conversation
|
} = conversation
|
||||||
return rest
|
return rest
|
||||||
|
|
@ -194,32 +192,10 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
const item = fromConversationReadDO(record)
|
const item = fromConversationReadDO(record)
|
||||||
nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item
|
nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item
|
||||||
}
|
}
|
||||||
const migratedReads: ConversationRead[] = []
|
const nextConversations = conversations.map(fromConversationDO)
|
||||||
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
|
this.conversationReads = nextConversationReads
|
||||||
await this.applyLocalConversationReads(nextConversations)
|
await this.applyLocalConversationReads(nextConversations)
|
||||||
this.conversations = nextConversations
|
this.conversations = nextConversations
|
||||||
if (migratedReads.length > 0) {
|
|
||||||
void this.saveConversationReadRecord(migratedReads).catch((e) =>
|
|
||||||
console.warn('[IM conversationStore] 会话读位置迁移失败', e)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (Array.isArray(recent)) {
|
if (Array.isArray(recent)) {
|
||||||
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
|
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
|
||||||
}
|
}
|
||||||
|
|
@ -325,6 +301,15 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
return !!record && record.messageId >= messageId
|
return !!record && record.messageId >= messageId
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 判断服务端已读位置是否覆盖消息编号 */
|
||||||
|
isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
|
||||||
|
if (!messageId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const conversation = this.getConversation(type, targetId)
|
||||||
|
return (conversation?.readMessageId || 0) >= messageId
|
||||||
|
},
|
||||||
|
|
||||||
/** 应用读位置到会话 */
|
/** 应用读位置到会话 */
|
||||||
applyReadToConversation(conversation: Conversation, messageId: number): boolean {
|
applyReadToConversation(conversation: Conversation, messageId: number): boolean {
|
||||||
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
|
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
|
||||||
|
|
@ -378,6 +363,14 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
}
|
}
|
||||||
const current = this.conversationReads[clientConversationId]
|
const current = this.conversationReads[clientConversationId]
|
||||||
const messageId = Math.max(record.messageId, current?.messageId || 0)
|
const messageId = Math.max(record.messageId, current?.messageId || 0)
|
||||||
|
const conversation = this.getConversation(record.conversationType, record.targetId)
|
||||||
|
if (
|
||||||
|
conversation &&
|
||||||
|
record.messageId > (conversation.readMessageId || 0)
|
||||||
|
) {
|
||||||
|
conversation.readMessageId = record.messageId
|
||||||
|
changedConversations.set(clientConversationId, conversation)
|
||||||
|
}
|
||||||
if (!current || messageId > current.messageId) {
|
if (!current || messageId > current.messageId) {
|
||||||
const next = {
|
const next = {
|
||||||
conversationType: record.conversationType,
|
conversationType: record.conversationType,
|
||||||
|
|
@ -389,7 +382,6 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
changedReads.set(clientConversationId, next)
|
changedReads.set(clientConversationId, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversation = this.getConversation(record.conversationType, record.targetId)
|
|
||||||
if (conversation && this.applyReadToConversation(conversation, messageId)) {
|
if (conversation && this.applyReadToConversation(conversation, messageId)) {
|
||||||
changedConversations.set(clientConversationId, conversation)
|
changedConversations.set(clientConversationId, conversation)
|
||||||
} else if (conversation) {
|
} else if (conversation) {
|
||||||
|
|
@ -594,11 +586,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 1. 清理会话级未读状态
|
// 懒加载消息并保存会话摘要
|
||||||
conversation.unreadCount = 0
|
|
||||||
conversation.atMe = false
|
|
||||||
conversation.atAll = false
|
|
||||||
// 2. 懒加载消息并保存会话摘要
|
|
||||||
void useMessageStore().ensureConversationMessageListLoaded(conversation)
|
void useMessageStore().ensureConversationMessageListLoaded(conversation)
|
||||||
this.saveConversation(conversation)
|
this.saveConversation(conversation)
|
||||||
},
|
},
|
||||||
|
|
@ -719,6 +707,19 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
this.saveConversation(conversation)
|
this.saveConversation(conversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 标记会话已上报服务端读位置 */
|
||||||
|
markConversationReadReported(type: number, targetId: number, messageId?: number): void {
|
||||||
|
if (!messageId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const conversation = this.getConversation(type, targetId)
|
||||||
|
if (!conversation || messageId <= (conversation.readMessageId || 0)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conversation.readMessageId = messageId
|
||||||
|
this.saveConversation(conversation)
|
||||||
|
},
|
||||||
|
|
||||||
// ==================== 最近转发 ====================
|
// ==================== 最近转发 ====================
|
||||||
|
|
||||||
/** 推送最近转发会话 */
|
/** 推送最近转发会话 */
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
|
||||||
/** 构建群 IndexedDB 记录 */
|
/** 构建群 IndexedDB 记录 */
|
||||||
function buildGroupDO(group: Group): GroupDO {
|
function buildGroupDO(group: Group): GroupDO {
|
||||||
const {
|
const {
|
||||||
|
activeCallExpired: _activeCallExpired,
|
||||||
|
activeCallLoaded: _activeCallLoaded,
|
||||||
infoLoaded: _infoLoaded,
|
infoLoaded: _infoLoaded,
|
||||||
members: _members,
|
members: _members,
|
||||||
membersLoaded: _membersLoaded,
|
membersLoaded: _membersLoaded,
|
||||||
|
|
@ -233,11 +235,13 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
this.groups = fresh.map((group) => {
|
this.groups = fresh.map((group) => {
|
||||||
const existing = groupMap.get(group.id)
|
const existing = groupMap.get(group.id)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return { ...group, infoLoaded: true }
|
return { ...group, activeCallExpired: true, infoLoaded: true }
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...group,
|
...group,
|
||||||
infoLoaded: true,
|
infoLoaded: true,
|
||||||
|
activeCallExpired: existing.activeCallExpired,
|
||||||
|
activeCallLoaded: existing.activeCallLoaded,
|
||||||
members: existing.members,
|
members: existing.members,
|
||||||
memberCount: existing.memberCount ?? group.memberCount,
|
memberCount: existing.memberCount ?? group.memberCount,
|
||||||
membersLoaded: existing.membersLoaded,
|
membersLoaded: existing.membersLoaded,
|
||||||
|
|
@ -291,6 +295,29 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 失效全部群通话探测缓存 */
|
||||||
|
markAllGroupActiveCallsExpired() {
|
||||||
|
for (const group of this.groups) {
|
||||||
|
group.activeCallExpired = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 标记群通话探测已加载 */
|
||||||
|
markGroupActiveCallLoaded(groupId: number) {
|
||||||
|
const group = this.getGroup(groupId)
|
||||||
|
if (!group) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group.activeCallLoaded = true
|
||||||
|
group.activeCallExpired = false
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 判断群通话是否需要重新探测 */
|
||||||
|
isGroupActiveCallExpired(groupId: number): boolean {
|
||||||
|
const group = this.getGroup(groupId)
|
||||||
|
return !group?.activeCallLoaded || !!group.activeCallExpired
|
||||||
|
},
|
||||||
|
|
||||||
/** 失效指定群成员缓存 */
|
/** 失效指定群成员缓存 */
|
||||||
markGroupMembersExpired(groupId: number) {
|
markGroupMembersExpired(groupId: number) {
|
||||||
const group = this.getGroup(groupId)
|
const group = this.getGroup(groupId)
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,7 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||||
if (!payload?.groupId) {
|
if (!payload?.groupId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
useGroupStore().markGroupActiveCallLoaded(payload.groupId)
|
||||||
// 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
|
// 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
|
||||||
const existing = groupActiveCalls.value.get(payload.groupId)
|
const existing = groupActiveCalls.value.get(payload.groupId)
|
||||||
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
|
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
|
||||||
|
|
@ -313,6 +314,7 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clearGroupCallCache(groupId)
|
clearGroupCallCache(groupId)
|
||||||
|
useGroupStore().markGroupActiveCallLoaded(groupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */
|
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */
|
||||||
|
|
|
||||||
|
|
@ -425,7 +425,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
)
|
)
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
|
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
|
||||||
const readCovered = conversationStore.isReadPositionCovered(
|
const readReported = conversationStore.isReportedReadPositionCovered(
|
||||||
ImConversationType.CHANNEL,
|
ImConversationType.CHANNEL,
|
||||||
websocketMessage.channelId,
|
websocketMessage.channelId,
|
||||||
websocketMessage.id
|
websocketMessage.id
|
||||||
|
|
@ -433,10 +433,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
conversationStore.markConversationRead(
|
conversationStore.markConversationRead(
|
||||||
ImConversationType.CHANNEL,
|
ImConversationType.CHANNEL,
|
||||||
websocketMessage.channelId,
|
websocketMessage.channelId,
|
||||||
readCovered ? undefined : websocketMessage.id
|
websocketMessage.id
|
||||||
)
|
)
|
||||||
if (!readCovered) {
|
if (!readReported) {
|
||||||
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
|
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
|
||||||
|
.then(() =>
|
||||||
|
conversationStore.markConversationReadReported(
|
||||||
|
ImConversationType.CHANNEL,
|
||||||
|
websocketMessage.channelId,
|
||||||
|
websocketMessage.id
|
||||||
|
)
|
||||||
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[IM WS] 频道自动已读上报失败',
|
'[IM WS] 频道自动已读上报失败',
|
||||||
|
|
@ -630,7 +637,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
|
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
|
||||||
// 已读位置直接用刚到的消息 id(这条就是当前会话最大 id)
|
// 已读位置直接用刚到的消息 id(这条就是当前会话最大 id)
|
||||||
const readCovered = conversationStore.isReadPositionCovered(
|
const readReported = conversationStore.isReportedReadPositionCovered(
|
||||||
ImConversationType.PRIVATE,
|
ImConversationType.PRIVATE,
|
||||||
peerId,
|
peerId,
|
||||||
websocketMessage.id
|
websocketMessage.id
|
||||||
|
|
@ -638,10 +645,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
conversationStore.markConversationRead(
|
conversationStore.markConversationRead(
|
||||||
ImConversationType.PRIVATE,
|
ImConversationType.PRIVATE,
|
||||||
peerId,
|
peerId,
|
||||||
readCovered ? undefined : websocketMessage.id
|
websocketMessage.id
|
||||||
)
|
)
|
||||||
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) {
|
if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) {
|
||||||
apiReadPrivateMessages(peerId, websocketMessage.id)
|
apiReadPrivateMessages(peerId, websocketMessage.id)
|
||||||
|
.then(() =>
|
||||||
|
conversationStore.markConversationReadReported(
|
||||||
|
ImConversationType.PRIVATE,
|
||||||
|
peerId,
|
||||||
|
websocketMessage.id
|
||||||
|
)
|
||||||
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[IM WS] 私聊自动已读上报失败',
|
'[IM WS] 私聊自动已读上报失败',
|
||||||
|
|
@ -785,7 +799,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
conversationStore.activeConversation?.targetId === websocketMessage.groupId
|
conversationStore.activeConversation?.targetId === websocketMessage.groupId
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
// 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零
|
// 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零
|
||||||
const readCovered = conversationStore.isReadPositionCovered(
|
const readReported = conversationStore.isReportedReadPositionCovered(
|
||||||
ImConversationType.GROUP,
|
ImConversationType.GROUP,
|
||||||
websocketMessage.groupId,
|
websocketMessage.groupId,
|
||||||
websocketMessage.id
|
websocketMessage.id
|
||||||
|
|
@ -793,10 +807,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
conversationStore.markConversationRead(
|
conversationStore.markConversationRead(
|
||||||
ImConversationType.GROUP,
|
ImConversationType.GROUP,
|
||||||
websocketMessage.groupId,
|
websocketMessage.groupId,
|
||||||
readCovered ? undefined : websocketMessage.id
|
websocketMessage.id
|
||||||
)
|
)
|
||||||
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) {
|
if (MESSAGE_GROUP_READ_ENABLED && !readReported) {
|
||||||
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
|
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
|
||||||
|
.then(() =>
|
||||||
|
conversationStore.markConversationReadReported(
|
||||||
|
ImConversationType.GROUP,
|
||||||
|
websocketMessage.groupId,
|
||||||
|
websocketMessage.id
|
||||||
|
)
|
||||||
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[IM WS] 群聊自动已读上报失败',
|
'[IM WS] 群聊自动已读上报失败',
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ export interface Conversation {
|
||||||
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
||||||
atMe?: boolean // 群聊:是否有人 @我
|
atMe?: boolean // 群聊:是否有人 @我
|
||||||
atAll?: boolean // 群聊:是否有人 @全体成员
|
atAll?: boolean // 群聊:是否有人 @全体成员
|
||||||
|
readMessageId?: number // 已上报到服务端的最大已读消息编号
|
||||||
draft?: {
|
draft?: {
|
||||||
html: string // 输入框 HTML
|
html: string // 输入框 HTML
|
||||||
plain: string // 输入框纯文本
|
plain: string // 输入框纯文本
|
||||||
|
|
@ -147,6 +148,7 @@ export interface Message {
|
||||||
|
|
||||||
// ==================== IndexedDB 本地存储结构 ====================
|
// ==================== IndexedDB 本地存储结构 ====================
|
||||||
|
|
||||||
|
/** 会话 IndexedDB 存储结构 */
|
||||||
export interface ConversationDO extends Conversation {
|
export interface ConversationDO extends Conversation {
|
||||||
clientConversationId: string // `${type}:${targetId}`
|
clientConversationId: string // `${type}:${targetId}`
|
||||||
}
|
}
|
||||||
|
|
@ -158,16 +160,19 @@ export interface ConversationRead {
|
||||||
updateTime?: number // 更新时间
|
updateTime?: number // 更新时间
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 会话读位置 IndexedDB 存储结构 */
|
||||||
export interface ConversationReadDO extends ConversationRead {
|
export interface ConversationReadDO extends ConversationRead {
|
||||||
clientConversationId: string // `${conversationType}:${targetId}`
|
clientConversationId: string // `${conversationType}:${targetId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 消息 IndexedDB 存储结构 */
|
||||||
export interface MessageDO extends Omit<Message, 'uploadProgress' | '_localFile' | '_ackMerging'> {
|
export interface MessageDO extends Omit<Message, 'uploadProgress' | '_localFile' | '_ackMerging'> {
|
||||||
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
|
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
|
||||||
conversationType: number // 会话类型,对齐 ImConversationType
|
conversationType: number // 会话类型,对齐 ImConversationType
|
||||||
clientConversationId: string // ConversationDO.clientConversationId
|
clientConversationId: string // ConversationDO.clientConversationId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置 IndexedDB 存储结构 */
|
||||||
export interface SettingDO<T = unknown> {
|
export interface SettingDO<T = unknown> {
|
||||||
key: string
|
key: string
|
||||||
value: T
|
value: T
|
||||||
|
|
@ -195,12 +200,18 @@ export interface Group {
|
||||||
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
||||||
members?: GroupMember[] // 群成员缓存(按需懒加载)
|
members?: GroupMember[] // 群成员缓存(按需懒加载)
|
||||||
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
|
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
|
||||||
|
activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化
|
||||||
|
activeCallExpired?: boolean // 群活跃通话探测是否已过期
|
||||||
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
|
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
|
||||||
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
|
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
|
||||||
memberCount?: number // 成员总数
|
memberCount?: number // 成员总数
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersLoaded' | 'membersExpired'>
|
/** 群 IndexedDB 存储结构 */
|
||||||
|
export type GroupDO = Omit<
|
||||||
|
Group,
|
||||||
|
'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersLoaded' | 'membersExpired'
|
||||||
|
>
|
||||||
|
|
||||||
// 群成员实体(前端内部结构)
|
// 群成员实体(前端内部结构)
|
||||||
export interface GroupMember {
|
export interface GroupMember {
|
||||||
|
|
@ -219,6 +230,7 @@ export interface GroupMember {
|
||||||
isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算)
|
isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 群成员 IndexedDB 存储结构 */
|
||||||
export type GroupMemberDO = GroupMember
|
export type GroupMemberDO = GroupMember
|
||||||
|
|
||||||
// ==================== 好友 ====================
|
// ==================== 好友 ====================
|
||||||
|
|
@ -242,6 +254,7 @@ export interface Friend {
|
||||||
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
|
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 好友 IndexedDB 存储结构 */
|
||||||
export type FriendDO = Friend
|
export type FriendDO = Friend
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -266,10 +279,13 @@ export interface FriendRequest {
|
||||||
toAvatar?: string // 接收方头像
|
toAvatar?: string // 接收方头像
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 好友申请 IndexedDB 存储结构 */
|
||||||
export type FriendRequestDO = FriendRequest
|
export type FriendRequestDO = FriendRequest
|
||||||
|
|
||||||
|
/** 加群申请 IndexedDB 存储结构 */
|
||||||
export type GroupRequestDO = import('@/api/im/group/request').ImGroupRequestRespVO
|
export type GroupRequestDO = import('@/api/im/group/request').ImGroupRequestRespVO
|
||||||
|
|
||||||
|
/** 频道 IndexedDB 存储结构 */
|
||||||
export type ChannelDO = import('@/api/im/manager/channel').ImManagerChannelVO
|
export type ChannelDO = import('@/api/im/manager/channel').ImManagerChannelVO
|
||||||
|
|
||||||
// ==================== 用户名片 ====================
|
// ==================== 用户名片 ====================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue