feat(im):优化已读上报、群详情缓存与 RTC 通话状态
- 已读上报增加本地读位置覆盖判断,避免切换会话和当前会话自动已读时重复调用 read 接口 - 标记会话已读时同步推进本地 read 游标并写入 IndexedDB,接口失败仅记录日志 - 缓存私聊对方 maxReadMessageId,并在状态补拉、回执更新和退出 IM 时维护缓存 - 增加群详情 infoLoaded 内存标记,减少切群时重复拉取群详情,手动刷新和关键通知仍强制刷新 - 同步 GROUP_INFO_UPDATE 的 joinApproval,避免群审批配置在前端缓存中陈旧 - 优化群通话胶囊条状态,记录 participantsLoaded,按需补齐参与者并在通话无人时移除胶囊 - RTC_CALL_START 生成群通话最小胶囊条,后续由参与者事件和 getActiveCall 补齐 - 退出 IM 时清理 RTC 状态和群通话缓存 - Vben antd/antd-next 调整媒体元素为函数 ref,修复 MediaStream 与元素挂载时序问题 - 修复 Vben 消息历史弹窗回调类型标注
This commit is contained in:
@@ -22,7 +22,7 @@ defineEmits<{
|
||||
toggleSpeaker: []
|
||||
}>()
|
||||
|
||||
const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
|
||||
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,7 +35,7 @@ const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localS
|
||||
<!-- 视频呼叫:自己摄像头预览铺底,对方头像悬浮顶部 -->
|
||||
<video
|
||||
v-if="isVideo && localStream"
|
||||
ref="localVideoRef"
|
||||
:ref="setLocalVideoRef"
|
||||
class="absolute inset-0 object-cover w-full h-full scale-x-[-1]"
|
||||
autoplay
|
||||
muted
|
||||
@@ -63,7 +63,7 @@ const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localS
|
||||
class="flex flex-col gap-2 items-center cursor-pointer select-none"
|
||||
@click="$emit('toggleMic')"
|
||||
>
|
||||
<!-- ant-design 系列里 mic 有 audio-muted-outlined 变体;speaker / camera 没有 muted 变体,off 态借 tabler:*-off 表达斜线 -->
|
||||
<!-- Iconify 里 mic 有静音变体;speaker / camera 没有同源静音变体,off 态借 tabler:*-off 表达斜线 -->
|
||||
<span
|
||||
class="flex justify-center items-center w-12 h-12 rounded-full"
|
||||
:class="micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
|
||||
|
||||
@@ -19,8 +19,8 @@ const props = defineProps<{
|
||||
speakerEnabled: boolean
|
||||
}>()
|
||||
|
||||
const videoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
|
||||
const audioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
|
||||
const setVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
|
||||
const setAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,7 +36,7 @@ const audioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant
|
||||
<!-- 视频可用:渲染 video;否则渲染头像或默认占位 -->
|
||||
<video
|
||||
v-if="participant.videoStream"
|
||||
ref="videoRef"
|
||||
:ref="setVideoRef"
|
||||
class="object-cover w-full h-full"
|
||||
autoplay
|
||||
playsinline
|
||||
@@ -55,7 +55,7 @@ const audioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant
|
||||
<!-- 远端音频;通过 audio 元素播放,本端静音避免回声;扬声器关闭时整体静音 -->
|
||||
<audio
|
||||
v-if="participant.audioStream && !participant.isLocal"
|
||||
ref="audioRef"
|
||||
:ref="setAudioRef"
|
||||
autoplay
|
||||
:muted="!speakerEnabled"
|
||||
></audio>
|
||||
|
||||
@@ -50,9 +50,9 @@ const gridColsClass = computed(() => {
|
||||
return 'grid-cols-3'
|
||||
})
|
||||
|
||||
const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
|
||||
const remoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream)
|
||||
const remoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream)
|
||||
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
|
||||
const setRemoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream)
|
||||
const setRemoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream)
|
||||
|
||||
/** 1v1 视频:是否有远端视频流 */
|
||||
const hasRemoteVideo = computed(() => !props.isGroup && !!props.remoteVideoStream)
|
||||
@@ -121,7 +121,7 @@ const formattedDuration = computed(() =>
|
||||
<template v-else-if="isVideo">
|
||||
<div v-show="hasRemoteVideo" class="absolute inset-0">
|
||||
<video
|
||||
ref="remoteVideoRef"
|
||||
:ref="setRemoteVideoRef"
|
||||
class="object-cover w-full h-full"
|
||||
autoplay
|
||||
playsinline
|
||||
@@ -143,7 +143,7 @@ const formattedDuration = computed(() =>
|
||||
class="absolute top-4 right-4 z-[2] overflow-hidden w-30 rounded-lg aspect-[9/16] bg-[#333]"
|
||||
>
|
||||
<video
|
||||
ref="localVideoRef"
|
||||
:ref="setLocalVideoRef"
|
||||
class="object-cover w-full h-full scale-x-[-1]"
|
||||
autoplay
|
||||
muted
|
||||
@@ -168,7 +168,7 @@ const formattedDuration = computed(() =>
|
||||
</template>
|
||||
<audio
|
||||
v-if="!isGroup && remoteAudioStream"
|
||||
ref="remoteAudioRef"
|
||||
:ref="setRemoteAudioRef"
|
||||
autoplay
|
||||
:muted="!speakerEnabled"
|
||||
></audio>
|
||||
|
||||
@@ -42,23 +42,26 @@ const pillText = computed(() => {
|
||||
watch(
|
||||
() => [props.groupId, activeCall.value?.room] as const,
|
||||
async ([groupId, room], oldValues) => {
|
||||
if (!groupId) {
|
||||
if (!groupId || !activeCall.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 决策是否需要拉取:切群 / room 切换必拉;同群同 room 且已加载 >= 2 人则跳过,避免参与者通知触发后重复请求
|
||||
// 决策是否需要拉取:仅补齐本地已有通话;没有本地通话时等待实时事件创建
|
||||
const groupChanged = !oldValues || oldValues[0] !== groupId
|
||||
const roomChanged = oldValues && oldValues[1] !== room
|
||||
const hydrated = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
|
||||
if (!groupChanged && !roomChanged && hydrated) {
|
||||
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
|
||||
if (
|
||||
rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
|
||||
(!groupChanged && !roomChanged && participantsLoaded)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话,移除本地缓存
|
||||
// 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话
|
||||
try {
|
||||
const data = await getActiveCall(groupId)
|
||||
if (data) {
|
||||
rtcStore.setGroupCall(data)
|
||||
rtcStore.setGroupCall(data, true)
|
||||
} else {
|
||||
rtcStore.removeGroupCall(groupId)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, type Ref, watch } from 'vue'
|
||||
import { ref, type VNodeRef, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* 把响应式 MediaStream 挂到 `<video>` / `<audio>` 元素的 srcObject 上;
|
||||
@@ -6,16 +6,22 @@ import { ref, type Ref, watch } from 'vue'
|
||||
*/
|
||||
export function useMediaStreamElement<T extends HTMLMediaElement>(
|
||||
streamSource: () => MediaStream | null | undefined
|
||||
): Ref<T | undefined> {
|
||||
): VNodeRef {
|
||||
const elRef = ref<T>()
|
||||
const syncStream = (stream = streamSource()) => {
|
||||
if (elRef.value) {
|
||||
elRef.value.srcObject = stream || null
|
||||
}
|
||||
}
|
||||
watch(
|
||||
streamSource,
|
||||
(stream) => {
|
||||
if (elRef.value) {
|
||||
elRef.value.srcObject = stream || null
|
||||
}
|
||||
syncStream(stream)
|
||||
},
|
||||
{ flush: 'post', immediate: true }
|
||||
)
|
||||
return elRef
|
||||
return (el) => {
|
||||
elRef.value = el instanceof HTMLMediaElement ? (el as T) : undefined
|
||||
syncStream()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useFriendStore } from '../store/friendStore'
|
||||
import { useGroupRequestStore } from '../store/groupRequestStore'
|
||||
import { useGroupStore } from '../store/groupStore'
|
||||
import { type PulledMessage, useMessageStore } from '../store/messageStore'
|
||||
import { useRtcStore } from '../store/rtcStore'
|
||||
import { useImWebSocketStore } from '../store/websocketStore'
|
||||
|
||||
/** 三类消息 pull 接口返回的原始 VO 联合类型;runMinIdPull 只需 id 推进游标,具体分发在 applyPage 内按类型 cast */
|
||||
@@ -55,6 +56,7 @@ export const useMessagePuller = () => {
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const rtcStore = useRtcStore()
|
||||
const currentUserId = getCurrentUserId()
|
||||
|
||||
/** 判断请求是否被主动取消 */
|
||||
@@ -285,7 +287,12 @@ export const useMessagePuller = () => {
|
||||
* 群成员不做全局增量同步,重连只标记本地群成员 cache 过期,进入群会话或成员列表时再按 groupId 刷新。
|
||||
*/
|
||||
const pullStateEvents = async (): Promise<void> => {
|
||||
// 1. 清理连接级缓存
|
||||
messageStore.clearPrivateReadMaxIdCache()
|
||||
rtcStore.clearGroupCallCache()
|
||||
groupStore.markAllGroupInfoExpired()
|
||||
groupStore.markAllGroupMembersExpired()
|
||||
// 2. 并发补偿远端状态
|
||||
const results = await Promise.allSettled([
|
||||
friendStore.pullFriends(),
|
||||
friendStore.pullFriendRequests(),
|
||||
@@ -403,6 +410,7 @@ export const useMessagePuller = () => {
|
||||
if (!isCurrentPull()) {
|
||||
return
|
||||
}
|
||||
messageStore.updatePrivateReadMaxId(active.targetId, maxReadId)
|
||||
if (maxReadId) {
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
|
||||
@@ -57,7 +57,7 @@ interface SendExtOptions {
|
||||
* 1. 私聊 / 群聊接口签名对称,按 conversation.type 分支调度,差异在分支内部消化
|
||||
* 2. 发送走「乐观更新」:先 insertMessage 写入 SENDING 占位,请求成功 ackMessage 更新为 NORMAL,失败更新为 FAILED
|
||||
* 3. 撤回不做乐观更新:服务端通过 WebSocket RECALL 事件回传,由 websocketStore 统一更新状态,避免网络失败后不可回退
|
||||
* 4. 已读上报:本端立刻清未读数;服务端回包成功后再做持久化
|
||||
* 4. 已读上报:本端立刻清未读数并记录本地读位置;接口失败仅记录日志
|
||||
*/
|
||||
export const useMessageSender = () => {
|
||||
const conversationStore = useConversationStore()
|
||||
@@ -223,7 +223,7 @@ export const useMessageSender = () => {
|
||||
|
||||
/**
|
||||
* 触发当前会话的已读上报(切会话 / 进入页面时调用)
|
||||
* 1. 本端立刻清未读数;服务端回包成功后再做持久化
|
||||
* 1. 本端立刻清未读数并推进读位置
|
||||
* 2. 已读位置取已加载消息和会话末条消息的最大服务端 id
|
||||
*/
|
||||
const readActive = async () => {
|
||||
@@ -240,15 +240,24 @@ export const useMessageSender = () => {
|
||||
}
|
||||
}
|
||||
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
maxMessageId
|
||||
)
|
||||
if (readCovered) {
|
||||
conversationStore.markConversationRead(conversation.type, conversation.targetId)
|
||||
return
|
||||
}
|
||||
const isPrivate = conversation.type === ImConversationType.PRIVATE
|
||||
const isGroup = conversation.type === ImConversationType.GROUP
|
||||
const isChannel = conversation.type === ImConversationType.CHANNEL
|
||||
// 本地标记已读:未读数清零(UI 立刻响应)
|
||||
conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId)
|
||||
if (!maxMessageId) {
|
||||
return
|
||||
}
|
||||
// 接口调用:按会话类型分发,并按对应已读开关控制;失败仅记录日志,不回退本地已读状态
|
||||
const isPrivate = conversation.type === ImConversationType.PRIVATE
|
||||
const isGroup = conversation.type === ImConversationType.GROUP
|
||||
const isChannel = conversation.type === ImConversationType.CHANNEL
|
||||
// 接口调用:按会话类型分发,并按对应已读开关控制
|
||||
if (!isPrivate && !isGroup && !isChannel) {
|
||||
return
|
||||
}
|
||||
@@ -290,9 +299,21 @@ export const useMessageSender = () => {
|
||||
if (!MESSAGE_PRIVATE_READ_ENABLED) {
|
||||
return
|
||||
}
|
||||
const cachedMaxReadId = messageStore.getPrivateReadMaxId(peerId)
|
||||
if (cachedMaxReadId !== undefined) {
|
||||
if (cachedMaxReadId > 0) {
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: peerId,
|
||||
privateReadMaxId: cachedMaxReadId
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 拉取对方已读到的最大消息 id
|
||||
const maxReadId = await apiGetPrivateMaxReadMessageId(peerId)
|
||||
messageStore.updatePrivateReadMaxId(peerId, maxReadId)
|
||||
if (!maxReadId) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ function onBeforeUnload() {
|
||||
}
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
|
||||
/** 离开 IM 主壳:取消在飞的 pull + 主动断 WebSocket + flush 草稿 + 清空表情缓存 + 解绑 unload + 停语音 */
|
||||
/** 离开 IM 主壳:取消 pull、断开 WebSocket、保存草稿、停止语音、解绑 unload,并结束当前 IM session */
|
||||
onUnmounted(() => {
|
||||
cancelPull()
|
||||
webSocketStore.disconnect()
|
||||
|
||||
@@ -510,7 +510,7 @@ function locateMessage(messageId: number) {
|
||||
width="640px"
|
||||
:footer="null"
|
||||
class="im-message-history__dialog"
|
||||
@after-open-change="(open) => open && onDialogOpen()"
|
||||
@after-open-change="(open: boolean) => open && onDialogOpen()"
|
||||
>
|
||||
<div class="flex flex-col gap-3 h-[520px]">
|
||||
<!-- 搜索区:activeFilter 存在时左侧出 chip × 可清,否则纯搜索框 -->
|
||||
|
||||
@@ -250,7 +250,7 @@ function reloadGroupData() {
|
||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
return
|
||||
}
|
||||
groupStore.fetchGroupInfo(conversation.targetId)
|
||||
groupStore.fetchGroupInfo(conversation.targetId, true)
|
||||
groupStore.fetchGroupMemberList(conversation.targetId, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -335,6 +335,15 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
return !!record && message.id <= record.messageId
|
||||
},
|
||||
|
||||
/** 判断会话读位置是否覆盖消息编号 */
|
||||
isReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
|
||||
if (!messageId) {
|
||||
return false
|
||||
}
|
||||
const record = this.getConversationRead(type, targetId)
|
||||
return !!record && record.messageId >= messageId
|
||||
},
|
||||
|
||||
/** 应用读位置到会话 */
|
||||
applyReadToConversation(conversation: Conversation, messageId: number): boolean {
|
||||
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
|
||||
@@ -604,11 +613,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
// 1. 清理会话级未读状态
|
||||
conversation.unreadCount = 0
|
||||
conversation.atMe = false
|
||||
conversation.atAll = false
|
||||
// 2. 懒加载消息并保存会话摘要
|
||||
// 懒加载消息并保存会话摘要
|
||||
void useMessageStore().ensureConversationMessageListLoaded(conversation)
|
||||
this.saveConversation(conversation)
|
||||
},
|
||||
@@ -705,12 +710,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
conversation.atMe = false
|
||||
conversation.atAll = false
|
||||
if (readMessageIdAdvanced) {
|
||||
const record = {
|
||||
conversationType: type,
|
||||
targetId,
|
||||
messageId,
|
||||
updateTime: Date.now()
|
||||
}
|
||||
const record = createConversationRead(type, targetId, messageId)
|
||||
this.conversationReads[key] = record
|
||||
void getDb()
|
||||
.transaction(['conversations', 'conversationReads'], 'readwrite', async (tx) => {
|
||||
|
||||
@@ -47,6 +47,7 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
|
||||
/** 构建群 IndexedDB 记录 */
|
||||
function buildGroupDO(group: Group): GroupDO {
|
||||
const {
|
||||
infoLoaded: _infoLoaded,
|
||||
members: _members,
|
||||
membersLoaded: _membersLoaded,
|
||||
membersExpired: _membersExpired,
|
||||
@@ -228,10 +229,11 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
this.groups = fresh.map((group) => {
|
||||
const existing = groupMap.get(group.id)
|
||||
if (!existing) {
|
||||
return group
|
||||
return { ...group, infoLoaded: true }
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
infoLoaded: true,
|
||||
members: existing.members,
|
||||
memberCount: existing.memberCount ?? group.memberCount,
|
||||
membersLoaded: existing.membersLoaded,
|
||||
@@ -251,6 +253,13 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
this.preloadMembersForEmptyAvatarGroups()
|
||||
},
|
||||
|
||||
/** 失效全部群详情缓存 */
|
||||
markAllGroupInfoExpired() {
|
||||
for (const group of this.groups) {
|
||||
group.infoLoaded = false
|
||||
}
|
||||
},
|
||||
|
||||
/** 预加载空群头像的成员列表,供 GroupAvatar 异步合成群头像 */
|
||||
preloadMembersForEmptyAvatarGroups() {
|
||||
for (const group of this.groups) {
|
||||
@@ -287,13 +296,17 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
},
|
||||
|
||||
/** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert,常用于 GROUP_UPDATE 推送后或手动 reload */
|
||||
async fetchGroupInfo(groupId: number) {
|
||||
async fetchGroupInfo(groupId: number, force = false) {
|
||||
const cached = this.getGroup(groupId)
|
||||
if (cached?.infoLoaded && !force) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await apiGetGroup(groupId)
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
this.upsertGroup(convertGroup(data))
|
||||
this.upsertGroup({ ...convertGroup(data), infoLoaded: true })
|
||||
} catch (error) {
|
||||
console.warn('[IM groupStore] fetchGroupInfo 失败', error)
|
||||
}
|
||||
@@ -725,7 +738,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
if (selfIsOperator && this.getGroup(groupId)) {
|
||||
return
|
||||
}
|
||||
await this.fetchGroupInfo(groupId)
|
||||
await this.fetchGroupInfo(groupId, true)
|
||||
},
|
||||
|
||||
/** 群名变更:按 newName 局部更新本地群名 */
|
||||
@@ -740,12 +753,15 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' })
|
||||
},
|
||||
|
||||
/** 群信息变更(NAME / NOTICE 之外字段,当前承载头像变更) */
|
||||
/** 群信息变更:同步头像、进群审批 */
|
||||
applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
const fields: Partial<Group> = {}
|
||||
if (payload.newAvatar) {
|
||||
fields.avatar = payload.newAvatar
|
||||
}
|
||||
if (payload.newJoinApproval != null) {
|
||||
fields.joinApproval = payload.newJoinApproval
|
||||
}
|
||||
if (Object.keys(fields).length > 0) {
|
||||
this.updateGroupFields(groupId, fields)
|
||||
}
|
||||
@@ -755,7 +771,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
async applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
// 自己刚被拉进来:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空
|
||||
if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) {
|
||||
await this.fetchGroupInfo(groupId)
|
||||
await this.fetchGroupInfo(groupId, true)
|
||||
}
|
||||
this.markGroupMembersExpired(groupId)
|
||||
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
|
||||
@@ -766,7 +782,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
const selfUserId = getCurrentUserId()
|
||||
// 自己自由进群:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空
|
||||
if (selfUserId && payload.entrantUserId === selfUserId && !this.getGroup(groupId)) {
|
||||
await this.fetchGroupInfo(groupId)
|
||||
await this.fetchGroupInfo(groupId, true)
|
||||
}
|
||||
this.markGroupMembersExpired(groupId)
|
||||
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
|
||||
|
||||
@@ -242,6 +242,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
state: () => ({
|
||||
messagesByConversation: {} as Record<string, Message[]>,
|
||||
loadedConversationKeys: [] as string[],
|
||||
privateReadMaxIds: {} as Partial<Record<number, number>>,
|
||||
privateMessageMaxId: 0,
|
||||
groupMessageMaxId: 0,
|
||||
channelMessageMaxId: 0
|
||||
@@ -266,6 +267,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
})
|
||||
this.messagesByConversation = {}
|
||||
this.loadedConversationKeys = []
|
||||
this.privateReadMaxIds = {}
|
||||
this.privateMessageMaxId = 0
|
||||
this.groupMessageMaxId = 0
|
||||
this.channelMessageMaxId = 0
|
||||
@@ -305,6 +307,30 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
}
|
||||
},
|
||||
|
||||
/** 获取私聊对方已读位置缓存 */
|
||||
getPrivateReadMaxId(peerId: number): number | undefined {
|
||||
return this.privateReadMaxIds[peerId]
|
||||
},
|
||||
|
||||
/** 更新私聊对方已读位置缓存 */
|
||||
updatePrivateReadMaxId(peerId: number, maxReadId: null | number = 0): number {
|
||||
if (!peerId) {
|
||||
return 0
|
||||
}
|
||||
const nextMaxReadId = maxReadId || 0
|
||||
const current = this.getPrivateReadMaxId(peerId)
|
||||
if (current !== undefined && nextMaxReadId <= current) {
|
||||
return current
|
||||
}
|
||||
this.privateReadMaxIds = { ...this.privateReadMaxIds, [peerId]: nextMaxReadId }
|
||||
return nextMaxReadId
|
||||
},
|
||||
|
||||
/** 清空私聊对方已读位置缓存 */
|
||||
clearPrivateReadMaxIdCache(): void {
|
||||
this.privateReadMaxIds = {}
|
||||
},
|
||||
|
||||
/** 标记会话近期使用 */
|
||||
touchConversationMessageCache(clientConversationId: string) {
|
||||
this.loadedConversationKeys = [
|
||||
@@ -797,6 +823,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
const changed: Message[] = []
|
||||
// 1. 私聊回执批量更新自己发送的消息
|
||||
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
|
||||
this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId)
|
||||
const privateReadMaxId = options.privateReadMaxId
|
||||
messages.forEach((message) => {
|
||||
if (
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
import { useFriendStore } from './friendStore'
|
||||
import { useGroupStore } from './groupStore'
|
||||
|
||||
type GroupActiveCallCache = {
|
||||
participantsLoaded?: boolean // 是否已拉取完整参与者列表
|
||||
} & ImRtcApi.RtcGroupCallRespVO
|
||||
|
||||
// RTC_CALL 通话信令载荷;按 status 区分子类型语义
|
||||
export interface ImRtcCallNotification {
|
||||
status: ImRtcParticipantStatusValue
|
||||
@@ -121,7 +125,7 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
}
|
||||
|
||||
/** 群活跃通话索引;groupId -> 群通话摘要;用于群聊顶部胶囊条 */
|
||||
const groupActiveCalls = ref<Map<number, ImRtcApi.RtcGroupCallRespVO>>(new Map())
|
||||
const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map())
|
||||
|
||||
/**
|
||||
* 已退出 / 已拒绝的用户编号集合;群通话场景内 pending 占位渲染时排除;
|
||||
@@ -252,20 +256,51 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
* 房内成员同步交给 LiveKit 客户端事件(ParticipantConnected / Disconnected);
|
||||
* 胶囊条不实时刷新 joinedUserIds / inviteeIds,展开 / 加入时再走 getActiveCall 接口拉最新
|
||||
*/
|
||||
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO) {
|
||||
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO, participantsLoaded?: boolean) {
|
||||
if (!payload?.groupId) {
|
||||
return
|
||||
}
|
||||
// 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
|
||||
const existing = groupActiveCalls.value.get(payload.groupId)
|
||||
if (existing && isSameGroupCall(existing, payload)) {
|
||||
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
|
||||
if (
|
||||
existing &&
|
||||
isSameGroupCall(existing, payload) &&
|
||||
!!existing.participantsLoaded === nextParticipantsLoaded
|
||||
) {
|
||||
return
|
||||
}
|
||||
const newGroupActiveCalls = new Map(groupActiveCalls.value)
|
||||
newGroupActiveCalls.set(payload.groupId, payload)
|
||||
newGroupActiveCalls.set(payload.groupId, {
|
||||
...payload,
|
||||
participantsLoaded: nextParticipantsLoaded
|
||||
})
|
||||
groupActiveCalls.value = newGroupActiveCalls
|
||||
}
|
||||
|
||||
/** 清空指定群的通话缓存 */
|
||||
function clearGroupCallCache(groupId?: number) {
|
||||
if (!groupId) {
|
||||
groupActiveCalls.value = new Map()
|
||||
return
|
||||
}
|
||||
const next = new Map(groupActiveCalls.value)
|
||||
next.delete(groupId)
|
||||
groupActiveCalls.value = next
|
||||
}
|
||||
|
||||
/** 判断群通话是否已补齐 */
|
||||
function isGroupCallParticipantsLoaded(groupId: number, room?: string): boolean {
|
||||
const call = groupActiveCalls.value.get(groupId)
|
||||
return (
|
||||
!!groupId &&
|
||||
!!room &&
|
||||
!!call &&
|
||||
call.room === room &&
|
||||
!!call.participantsLoaded
|
||||
)
|
||||
}
|
||||
|
||||
/** 两条群通话摘要内容相等(room / mediaType / inviterId / 两个 userId 数组逐项相等) */
|
||||
function isSameGroupCall(a: ImRtcApi.RtcGroupCallRespVO, b: ImRtcApi.RtcGroupCallRespVO): boolean {
|
||||
if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
|
||||
@@ -285,12 +320,7 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
|
||||
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
|
||||
function removeGroupCall(groupId: number) {
|
||||
if (!groupId || !groupActiveCalls.value.has(groupId)) {
|
||||
return
|
||||
}
|
||||
const newGroupActiveCalls = new Map(groupActiveCalls.value)
|
||||
newGroupActiveCalls.delete(groupId)
|
||||
groupActiveCalls.value = newGroupActiveCalls
|
||||
clearGroupCallCache(groupId)
|
||||
}
|
||||
|
||||
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */
|
||||
@@ -371,6 +401,10 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) {
|
||||
return
|
||||
}
|
||||
if (nextJoined.length === 0 && nextInvitee.length === 0) {
|
||||
removeGroupCall(groupId)
|
||||
return
|
||||
}
|
||||
setGroupCall({
|
||||
...existing,
|
||||
joinedUserIds: nextJoined,
|
||||
@@ -394,6 +428,8 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
markUserLeft,
|
||||
isUserLeft,
|
||||
setGroupCall,
|
||||
isGroupCallParticipantsLoaded,
|
||||
clearGroupCallCache,
|
||||
removeGroupCall,
|
||||
getGroupCall,
|
||||
applyParticipantConnected,
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from '../../utils/constants'
|
||||
import {
|
||||
getPrivateMessagePeerId,
|
||||
parseRtcCallPayload,
|
||||
playAudioTip,
|
||||
resolveCallEndReasonText
|
||||
} from '../../utils/message'
|
||||
@@ -462,14 +463,30 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
)
|
||||
if (isActive) {
|
||||
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
|
||||
conversationStore.markConversationRead(
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
ImConversationType.CHANNEL,
|
||||
websocketMessage.channelId,
|
||||
websocketMessage.id
|
||||
)
|
||||
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id).catch((error) => {
|
||||
console.warn('[IM WS] 频道自动已读上报失败', error)
|
||||
})
|
||||
conversationStore.markConversationRead(
|
||||
ImConversationType.CHANNEL,
|
||||
websocketMessage.channelId,
|
||||
readCovered ? undefined : websocketMessage.id
|
||||
)
|
||||
if (!readCovered) {
|
||||
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[IM WS] 频道自动已读上报失败',
|
||||
{
|
||||
conversationType: ImConversationType.CHANNEL,
|
||||
channelId: websocketMessage.channelId,
|
||||
messageId: websocketMessage.id
|
||||
},
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
|
||||
// 非当前会话且未免打扰:响一下提示音
|
||||
playAudioTip()
|
||||
@@ -567,7 +584,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
break
|
||||
}
|
||||
case ImContentType.RTC_CALL_START: {
|
||||
// 入库 + 渲染聊天 tip;胶囊条状态走 1602/1603,本帧不动 rtcStore,避免与首次填充竞争
|
||||
// 入库 + 渲染聊天 tip;同时用 START payload 先生成最小胶囊条,后续 getActiveCall / 参与者事件再补齐成员
|
||||
this.handleRtcCallStart(websocketMessage)
|
||||
ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
|
||||
break
|
||||
}
|
||||
@@ -660,15 +678,29 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
if (isActive) {
|
||||
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
|
||||
// 已读位置直接用刚到的消息 id(这条就是当前会话最大 id)
|
||||
conversationStore.markConversationRead(
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_PRIVATE_READ_ENABLED) {
|
||||
apiReadPrivateMessages(peerId, websocketMessage.id).catch((error) => {
|
||||
console.warn('[IM WS] 自动已读上报失败', error)
|
||||
})
|
||||
conversationStore.markConversationRead(
|
||||
ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
readCovered ? undefined : websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) {
|
||||
apiReadPrivateMessages(peerId, websocketMessage.id)
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[IM WS] 私聊自动已读上报失败',
|
||||
{
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
messageId: websocketMessage.id
|
||||
},
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
|
||||
// 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTip);FRIEND_* 等系统事件不响
|
||||
@@ -799,17 +831,29 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
conversationStore.activeConversation?.targetId === websocketMessage.groupId
|
||||
if (isActive) {
|
||||
// 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零
|
||||
conversationStore.markConversationRead(
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId,
|
||||
websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_GROUP_READ_ENABLED) {
|
||||
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch(
|
||||
(error) => {
|
||||
console.warn('[IM WS] 自动已读上报失败', error)
|
||||
}
|
||||
)
|
||||
conversationStore.markConversationRead(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId,
|
||||
readCovered ? undefined : websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) {
|
||||
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[IM WS] 群聊自动已读上报失败',
|
||||
{
|
||||
conversationType: ImConversationType.GROUP,
|
||||
groupId: websocketMessage.groupId,
|
||||
messageId: websocketMessage.id
|
||||
},
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
|
||||
// GROUP_* 群广播事件等系统消息不响提示音
|
||||
@@ -1129,6 +1173,27 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
}
|
||||
},
|
||||
|
||||
/** RTC_CALL_START 通话开始 */
|
||||
handleRtcCallStart(websocketMessage: ImGroupMessageNotification) {
|
||||
const payload = parseRtcCallPayload(websocketMessage.content)
|
||||
if (!payload?.room || !payload.mediaType || !payload.inviterUserId) {
|
||||
console.warn('[IM WS] RTC_CALL_START payload 不合法', {
|
||||
groupId: websocketMessage.groupId,
|
||||
messageId: websocketMessage.id,
|
||||
contentLength: websocketMessage.content?.length ?? 0
|
||||
})
|
||||
return
|
||||
}
|
||||
useRtcStore().setGroupCall({
|
||||
room: payload.room,
|
||||
groupId: websocketMessage.groupId,
|
||||
mediaType: payload.mediaType,
|
||||
inviterId: payload.inviterUserId,
|
||||
joinedUserIds: [payload.inviterUserId],
|
||||
inviteeIds: []
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* RTC_CALL_END 通话结束;私聊 + 群聊都走这一条;payload 携带 conversationType 区分
|
||||
* <p>
|
||||
|
||||
@@ -194,12 +194,13 @@ export interface Group {
|
||||
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
|
||||
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
||||
members?: GroupMember[] // 群成员缓存(按需懒加载)
|
||||
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
|
||||
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
|
||||
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
|
||||
memberCount?: number // 成员总数
|
||||
}
|
||||
|
||||
export type GroupDO = Omit<Group, 'members' | 'membersExpired' | 'membersLoaded'>
|
||||
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'>
|
||||
|
||||
// 群成员实体(前端内部结构)
|
||||
export interface GroupMember {
|
||||
|
||||
@@ -516,7 +516,8 @@ export async function stopRequests(): Promise<void> {
|
||||
{ useGroupStoreWithOut },
|
||||
{ useChannelStoreWithOut },
|
||||
{ useGroupRequestStoreWithOut },
|
||||
{ useFaceStoreWithOut }
|
||||
{ useFaceStoreWithOut },
|
||||
{ useRtcStore }
|
||||
] = await Promise.all([
|
||||
import('../home/store/messageStore'),
|
||||
import('../home/store/conversationStore'),
|
||||
@@ -524,7 +525,8 @@ export async function stopRequests(): Promise<void> {
|
||||
import('../home/store/groupStore'),
|
||||
import('../home/store/channelStore'),
|
||||
import('../home/store/groupRequestStore'),
|
||||
import('../home/store/faceStore')
|
||||
import('../home/store/faceStore'),
|
||||
import('../home/store/rtcStore')
|
||||
])
|
||||
useMessageStoreWithOut().clear()
|
||||
useConversationStoreWithOut().clear()
|
||||
@@ -533,5 +535,7 @@ export async function stopRequests(): Promise<void> {
|
||||
useChannelStoreWithOut().clear()
|
||||
useGroupRequestStoreWithOut().clear()
|
||||
useFaceStoreWithOut().clear()
|
||||
useRtcStore().reset()
|
||||
useRtcStore().clearGroupCallCache()
|
||||
closeDbConnection()
|
||||
}
|
||||
|
||||
@@ -700,10 +700,12 @@ export type GroupNotificationPayload = {
|
||||
mutedUserId?: number
|
||||
muteEndTime?: string
|
||||
newAvatar?: string
|
||||
newJoinApproval?: boolean
|
||||
newName?: string
|
||||
newNotice?: string
|
||||
newOwnerUserId?: number
|
||||
oldAvatar?: string
|
||||
oldJoinApproval?: boolean
|
||||
oldName?: string
|
||||
oldNotice?: string
|
||||
operatorUserId?: number
|
||||
|
||||
@@ -22,8 +22,7 @@ defineEmits<{
|
||||
toggleSpeaker: []
|
||||
}>()
|
||||
|
||||
const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
|
||||
void localVideoRef
|
||||
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,7 +35,7 @@ void localVideoRef
|
||||
<!-- 视频呼叫:自己摄像头预览铺底,对方头像悬浮顶部 -->
|
||||
<video
|
||||
v-if="isVideo && localStream"
|
||||
ref="localVideoRef"
|
||||
:ref="setLocalVideoRef"
|
||||
class="absolute inset-0 object-cover w-full h-full scale-x-[-1]"
|
||||
autoplay
|
||||
muted
|
||||
@@ -64,7 +63,7 @@ void localVideoRef
|
||||
class="flex flex-col gap-2 items-center cursor-pointer select-none"
|
||||
@click="$emit('toggleMic')"
|
||||
>
|
||||
<!-- ant-design 系列里 mic 有 audio-muted-outlined 变体;speaker / camera 没有 muted 变体,off 态借 tabler:*-off 表达斜线 -->
|
||||
<!-- Iconify 里 mic 有静音变体;speaker / camera 没有同源静音变体,off 态借 tabler:*-off 表达斜线 -->
|
||||
<span
|
||||
class="flex justify-center items-center w-12 h-12 rounded-full"
|
||||
:class="micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
|
||||
|
||||
@@ -19,10 +19,8 @@ const props = defineProps<{
|
||||
speakerEnabled: boolean
|
||||
}>()
|
||||
|
||||
const videoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
|
||||
const audioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
|
||||
void audioRef
|
||||
void videoRef
|
||||
const setVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
|
||||
const setAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -38,7 +36,7 @@ void videoRef
|
||||
<!-- 视频可用:渲染 video;否则渲染头像或默认占位 -->
|
||||
<video
|
||||
v-if="participant.videoStream"
|
||||
ref="videoRef"
|
||||
:ref="setVideoRef"
|
||||
class="object-cover w-full h-full"
|
||||
autoplay
|
||||
playsinline
|
||||
@@ -57,7 +55,7 @@ void videoRef
|
||||
<!-- 远端音频;通过 audio 元素播放,本端静音避免回声;扬声器关闭时整体静音 -->
|
||||
<audio
|
||||
v-if="participant.audioStream && !participant.isLocal"
|
||||
ref="audioRef"
|
||||
:ref="setAudioRef"
|
||||
autoplay
|
||||
:muted="!speakerEnabled"
|
||||
></audio>
|
||||
|
||||
@@ -50,12 +50,9 @@ const gridColsClass = computed(() => {
|
||||
return 'grid-cols-3'
|
||||
})
|
||||
|
||||
const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
|
||||
const remoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream)
|
||||
const remoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream)
|
||||
void localVideoRef
|
||||
void remoteAudioRef
|
||||
void remoteVideoRef
|
||||
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
|
||||
const setRemoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream)
|
||||
const setRemoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream)
|
||||
|
||||
/** 1v1 视频:是否有远端视频流 */
|
||||
const hasRemoteVideo = computed(() => !props.isGroup && !!props.remoteVideoStream)
|
||||
@@ -124,7 +121,7 @@ const formattedDuration = computed(() =>
|
||||
<template v-else-if="isVideo">
|
||||
<div v-show="hasRemoteVideo" class="absolute inset-0">
|
||||
<video
|
||||
ref="remoteVideoRef"
|
||||
:ref="setRemoteVideoRef"
|
||||
class="object-cover w-full h-full"
|
||||
autoplay
|
||||
playsinline
|
||||
@@ -146,7 +143,7 @@ const formattedDuration = computed(() =>
|
||||
class="absolute top-4 right-4 z-[2] overflow-hidden w-30 rounded-lg aspect-[9/16] bg-[#333]"
|
||||
>
|
||||
<video
|
||||
ref="localVideoRef"
|
||||
:ref="setLocalVideoRef"
|
||||
class="object-cover w-full h-full scale-x-[-1]"
|
||||
autoplay
|
||||
muted
|
||||
@@ -171,7 +168,7 @@ const formattedDuration = computed(() =>
|
||||
</template>
|
||||
<audio
|
||||
v-if="!isGroup && remoteAudioStream"
|
||||
ref="remoteAudioRef"
|
||||
:ref="setRemoteAudioRef"
|
||||
autoplay
|
||||
:muted="!speakerEnabled"
|
||||
></audio>
|
||||
|
||||
@@ -42,23 +42,26 @@ const pillText = computed(() => {
|
||||
watch(
|
||||
() => [props.groupId, activeCall.value?.room] as const,
|
||||
async ([groupId, room], oldValues) => {
|
||||
if (!groupId) {
|
||||
if (!groupId || !activeCall.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 决策是否需要拉取:切群 / room 切换必拉;同群同 room 且已加载 >= 2 人则跳过,避免参与者通知触发后重复请求
|
||||
// 决策是否需要拉取:仅补齐本地已有通话;没有本地通话时等待实时事件创建
|
||||
const groupChanged = !oldValues || oldValues[0] !== groupId
|
||||
const roomChanged = oldValues && oldValues[1] !== room
|
||||
const hydrated = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
|
||||
if (!groupChanged && !roomChanged && hydrated) {
|
||||
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
|
||||
if (
|
||||
rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
|
||||
(!groupChanged && !roomChanged && participantsLoaded)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话,移除本地缓存
|
||||
// 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话
|
||||
try {
|
||||
const data = await getActiveCall(groupId)
|
||||
if (data) {
|
||||
rtcStore.setGroupCall(data)
|
||||
rtcStore.setGroupCall(data, true)
|
||||
} else {
|
||||
rtcStore.removeGroupCall(groupId)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, type Ref, watch } from 'vue'
|
||||
import { ref, type VNodeRef, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* 把响应式 MediaStream 挂到 `<video>` / `<audio>` 元素的 srcObject 上;
|
||||
@@ -6,16 +6,22 @@ import { ref, type Ref, watch } from 'vue'
|
||||
*/
|
||||
export function useMediaStreamElement<T extends HTMLMediaElement>(
|
||||
streamSource: () => MediaStream | null | undefined
|
||||
): Ref<T | undefined> {
|
||||
): VNodeRef {
|
||||
const elRef = ref<T>()
|
||||
const syncStream = (stream = streamSource()) => {
|
||||
if (elRef.value) {
|
||||
elRef.value.srcObject = stream || null
|
||||
}
|
||||
}
|
||||
watch(
|
||||
streamSource,
|
||||
(stream) => {
|
||||
if (elRef.value) {
|
||||
elRef.value.srcObject = stream || null
|
||||
}
|
||||
syncStream(stream)
|
||||
},
|
||||
{ flush: 'post', immediate: true }
|
||||
)
|
||||
return elRef
|
||||
return (el) => {
|
||||
elRef.value = el instanceof HTMLMediaElement ? (el as T) : undefined
|
||||
syncStream()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useFriendStore } from '../store/friendStore'
|
||||
import { useGroupRequestStore } from '../store/groupRequestStore'
|
||||
import { useGroupStore } from '../store/groupStore'
|
||||
import { type PulledMessage, useMessageStore } from '../store/messageStore'
|
||||
import { useRtcStore } from '../store/rtcStore'
|
||||
import { useImWebSocketStore } from '../store/websocketStore'
|
||||
|
||||
/** 三类消息 pull 接口返回的原始 VO 联合类型;runMinIdPull 只需 id 推进游标,具体分发在 applyPage 内按类型 cast */
|
||||
@@ -55,6 +56,7 @@ export const useMessagePuller = () => {
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const rtcStore = useRtcStore()
|
||||
const currentUserId = getCurrentUserId()
|
||||
|
||||
/** 判断请求是否被主动取消 */
|
||||
@@ -285,7 +287,12 @@ export const useMessagePuller = () => {
|
||||
* 群成员不做全局增量同步,重连只标记本地群成员 cache 过期,进入群会话或成员列表时再按 groupId 刷新。
|
||||
*/
|
||||
const pullStateEvents = async (): Promise<void> => {
|
||||
// 1. 清理连接级缓存
|
||||
messageStore.clearPrivateReadMaxIdCache()
|
||||
rtcStore.clearGroupCallCache()
|
||||
groupStore.markAllGroupInfoExpired()
|
||||
groupStore.markAllGroupMembersExpired()
|
||||
// 2. 并发补偿远端状态
|
||||
const results = await Promise.allSettled([
|
||||
friendStore.pullFriends(),
|
||||
friendStore.pullFriendRequests(),
|
||||
@@ -403,6 +410,7 @@ export const useMessagePuller = () => {
|
||||
if (!isCurrentPull()) {
|
||||
return
|
||||
}
|
||||
messageStore.updatePrivateReadMaxId(active.targetId, maxReadId)
|
||||
if (maxReadId) {
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
|
||||
@@ -57,7 +57,7 @@ interface SendExtOptions {
|
||||
* 1. 私聊 / 群聊接口签名对称,按 conversation.type 分支调度,差异在分支内部消化
|
||||
* 2. 发送走「乐观更新」:先 insertMessage 写入 SENDING 占位,请求成功 ackMessage 更新为 NORMAL,失败更新为 FAILED
|
||||
* 3. 撤回不做乐观更新:服务端通过 WebSocket RECALL 事件回传,由 websocketStore 统一更新状态,避免网络失败后不可回退
|
||||
* 4. 已读上报:本端立刻清未读数;服务端回包成功后再做持久化
|
||||
* 4. 已读上报:本端立刻清未读数并记录本地读位置;接口失败仅记录日志
|
||||
*/
|
||||
export const useMessageSender = () => {
|
||||
const conversationStore = useConversationStore()
|
||||
@@ -223,7 +223,7 @@ export const useMessageSender = () => {
|
||||
|
||||
/**
|
||||
* 触发当前会话的已读上报(切会话 / 进入页面时调用)
|
||||
* 1. 本端立刻清未读数;服务端回包成功后再做持久化
|
||||
* 1. 本端立刻清未读数并推进读位置
|
||||
* 2. 已读位置取已加载消息和会话末条消息的最大服务端 id
|
||||
*/
|
||||
const readActive = async () => {
|
||||
@@ -240,15 +240,24 @@ export const useMessageSender = () => {
|
||||
}
|
||||
}
|
||||
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
maxMessageId
|
||||
)
|
||||
if (readCovered) {
|
||||
conversationStore.markConversationRead(conversation.type, conversation.targetId)
|
||||
return
|
||||
}
|
||||
const isPrivate = conversation.type === ImConversationType.PRIVATE
|
||||
const isGroup = conversation.type === ImConversationType.GROUP
|
||||
const isChannel = conversation.type === ImConversationType.CHANNEL
|
||||
// 本地标记已读:未读数清零(UI 立刻响应)
|
||||
conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId)
|
||||
if (!maxMessageId) {
|
||||
return
|
||||
}
|
||||
// 接口调用:按会话类型分发,并按对应已读开关控制;失败仅记录日志,不回退本地已读状态
|
||||
const isPrivate = conversation.type === ImConversationType.PRIVATE
|
||||
const isGroup = conversation.type === ImConversationType.GROUP
|
||||
const isChannel = conversation.type === ImConversationType.CHANNEL
|
||||
// 接口调用:按会话类型分发,并按对应已读开关控制
|
||||
if (!isPrivate && !isGroup && !isChannel) {
|
||||
return
|
||||
}
|
||||
@@ -290,9 +299,21 @@ export const useMessageSender = () => {
|
||||
if (!MESSAGE_PRIVATE_READ_ENABLED) {
|
||||
return
|
||||
}
|
||||
const cachedMaxReadId = messageStore.getPrivateReadMaxId(peerId)
|
||||
if (cachedMaxReadId !== undefined) {
|
||||
if (cachedMaxReadId > 0) {
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: peerId,
|
||||
privateReadMaxId: cachedMaxReadId
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 拉取对方已读到的最大消息 id
|
||||
const maxReadId = await apiGetPrivateMaxReadMessageId(peerId)
|
||||
messageStore.updatePrivateReadMaxId(peerId, maxReadId)
|
||||
if (!maxReadId) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ function onBeforeUnload() {
|
||||
}
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
|
||||
/** 离开 IM 主壳:取消在飞的 pull + 主动断 WebSocket + flush 草稿 + 清空表情缓存 + 解绑 unload + 停语音 */
|
||||
/** 离开 IM 主壳:取消 pull、断开 WebSocket、保存草稿、停止语音、解绑 unload,并结束当前 IM session */
|
||||
onUnmounted(() => {
|
||||
cancelPull()
|
||||
webSocketStore.disconnect()
|
||||
|
||||
@@ -250,7 +250,7 @@ function reloadGroupData() {
|
||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
return
|
||||
}
|
||||
groupStore.fetchGroupInfo(conversation.targetId)
|
||||
groupStore.fetchGroupInfo(conversation.targetId, true)
|
||||
groupStore.fetchGroupMemberList(conversation.targetId, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,20 @@ const pendingDraftConversations = new Set<Conversation>()
|
||||
|
||||
type LegacyConversationDO = ConversationDO & { readMessageId?: number }
|
||||
|
||||
/** 创建会话读位置记录 */
|
||||
function createConversationRead(
|
||||
type: number,
|
||||
targetId: number,
|
||||
messageId: number
|
||||
): ConversationRead {
|
||||
return {
|
||||
conversationType: type,
|
||||
targetId,
|
||||
messageId,
|
||||
updateTime: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建草稿保存防抖函数 */
|
||||
function createDraftDebounce(fn: () => void, wait: number) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -335,6 +349,15 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
return !!record && message.id <= record.messageId
|
||||
},
|
||||
|
||||
/** 判断会话读位置是否覆盖消息编号 */
|
||||
isReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
|
||||
if (!messageId) {
|
||||
return false
|
||||
}
|
||||
const record = this.getConversationRead(type, targetId)
|
||||
return !!record && record.messageId >= messageId
|
||||
},
|
||||
|
||||
/** 应用读位置到会话 */
|
||||
applyReadToConversation(conversation: Conversation, messageId: number): boolean {
|
||||
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
|
||||
@@ -604,11 +627,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
// 1. 清理会话级未读状态
|
||||
conversation.unreadCount = 0
|
||||
conversation.atMe = false
|
||||
conversation.atAll = false
|
||||
// 2. 懒加载消息并保存会话摘要
|
||||
// 懒加载消息并保存会话摘要
|
||||
void useMessageStore().ensureConversationMessageListLoaded(conversation)
|
||||
this.saveConversation(conversation)
|
||||
},
|
||||
@@ -685,7 +704,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
},
|
||||
|
||||
/** 标记会话已读 */
|
||||
markConversationRead(type: number, targetId: number, messageId?: number) {
|
||||
markConversationRead(type: number, targetId: number, messageId?: number): void {
|
||||
const conversation = this.getConversation(type, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
@@ -705,19 +724,25 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
conversation.atMe = false
|
||||
conversation.atAll = false
|
||||
if (readMessageIdAdvanced) {
|
||||
const record = {
|
||||
conversationType: type,
|
||||
targetId,
|
||||
messageId,
|
||||
updateTime: Date.now()
|
||||
}
|
||||
const record = createConversationRead(type, targetId, messageId)
|
||||
this.conversationReads[key] = record
|
||||
void getDb()
|
||||
.transaction(['conversations', 'conversationReads'], 'readwrite', async (tx) => {
|
||||
await this.saveConversationRecord(conversation, tx)
|
||||
await this.saveConversationReadRecord(record, tx)
|
||||
})
|
||||
.catch((error) => console.warn('[IM conversationStore] 会话已读写入失败', error))
|
||||
.catch((error) =>
|
||||
console.warn(
|
||||
'[IM conversationStore] 会话已读写入失败',
|
||||
{
|
||||
conversationType: type,
|
||||
targetId,
|
||||
messageId,
|
||||
conversationKey: key
|
||||
},
|
||||
error
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
this.saveConversation(conversation)
|
||||
|
||||
@@ -47,6 +47,7 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
|
||||
/** 构建群 IndexedDB 记录 */
|
||||
function buildGroupDO(group: Group): GroupDO {
|
||||
const {
|
||||
infoLoaded: _infoLoaded,
|
||||
members: _members,
|
||||
membersLoaded: _membersLoaded,
|
||||
membersExpired: _membersExpired,
|
||||
@@ -228,10 +229,11 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
this.groups = fresh.map((group) => {
|
||||
const existing = groupMap.get(group.id)
|
||||
if (!existing) {
|
||||
return group
|
||||
return { ...group, infoLoaded: true }
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
infoLoaded: true,
|
||||
members: existing.members,
|
||||
memberCount: existing.memberCount ?? group.memberCount,
|
||||
membersLoaded: existing.membersLoaded,
|
||||
@@ -251,6 +253,13 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
this.preloadMembersForEmptyAvatarGroups()
|
||||
},
|
||||
|
||||
/** 失效全部群详情缓存 */
|
||||
markAllGroupInfoExpired() {
|
||||
for (const group of this.groups) {
|
||||
group.infoLoaded = false
|
||||
}
|
||||
},
|
||||
|
||||
/** 预加载空群头像的成员列表,供 GroupAvatar 异步合成群头像 */
|
||||
preloadMembersForEmptyAvatarGroups() {
|
||||
for (const group of this.groups) {
|
||||
@@ -287,13 +296,17 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
},
|
||||
|
||||
/** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert,常用于 GROUP_UPDATE 推送后或手动 reload */
|
||||
async fetchGroupInfo(groupId: number) {
|
||||
async fetchGroupInfo(groupId: number, force = false) {
|
||||
const cached = this.getGroup(groupId)
|
||||
if (cached?.infoLoaded && !force) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await apiGetGroup(groupId)
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
this.upsertGroup(convertGroup(data))
|
||||
this.upsertGroup({ ...convertGroup(data), infoLoaded: true })
|
||||
} catch (error) {
|
||||
console.warn('[IM groupStore] fetchGroupInfo 失败', error)
|
||||
}
|
||||
@@ -725,7 +738,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
if (selfIsOperator && this.getGroup(groupId)) {
|
||||
return
|
||||
}
|
||||
await this.fetchGroupInfo(groupId)
|
||||
await this.fetchGroupInfo(groupId, true)
|
||||
},
|
||||
|
||||
/** 群名变更:按 newName 局部更新本地群名 */
|
||||
@@ -740,12 +753,15 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' })
|
||||
},
|
||||
|
||||
/** 群信息变更(NAME / NOTICE 之外字段,当前承载头像变更) */
|
||||
/** 群信息变更:同步头像、进群审批 */
|
||||
applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
const fields: Partial<Group> = {}
|
||||
if (payload.newAvatar) {
|
||||
fields.avatar = payload.newAvatar
|
||||
}
|
||||
if (payload.newJoinApproval != null) {
|
||||
fields.joinApproval = payload.newJoinApproval
|
||||
}
|
||||
if (Object.keys(fields).length > 0) {
|
||||
this.updateGroupFields(groupId, fields)
|
||||
}
|
||||
@@ -755,7 +771,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
async applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
// 自己刚被拉进来:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空
|
||||
if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) {
|
||||
await this.fetchGroupInfo(groupId)
|
||||
await this.fetchGroupInfo(groupId, true)
|
||||
}
|
||||
this.markGroupMembersExpired(groupId)
|
||||
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
|
||||
@@ -766,7 +782,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
const selfUserId = getCurrentUserId()
|
||||
// 自己自由进群:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空
|
||||
if (selfUserId && payload.entrantUserId === selfUserId && !this.getGroup(groupId)) {
|
||||
await this.fetchGroupInfo(groupId)
|
||||
await this.fetchGroupInfo(groupId, true)
|
||||
}
|
||||
this.markGroupMembersExpired(groupId)
|
||||
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
|
||||
|
||||
@@ -242,6 +242,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
state: () => ({
|
||||
messagesByConversation: {} as Record<string, Message[]>,
|
||||
loadedConversationKeys: [] as string[],
|
||||
privateReadMaxIds: {} as Partial<Record<number, number>>,
|
||||
privateMessageMaxId: 0,
|
||||
groupMessageMaxId: 0,
|
||||
channelMessageMaxId: 0
|
||||
@@ -266,6 +267,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
})
|
||||
this.messagesByConversation = {}
|
||||
this.loadedConversationKeys = []
|
||||
this.privateReadMaxIds = {}
|
||||
this.privateMessageMaxId = 0
|
||||
this.groupMessageMaxId = 0
|
||||
this.channelMessageMaxId = 0
|
||||
@@ -305,6 +307,30 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
}
|
||||
},
|
||||
|
||||
/** 获取私聊对方已读位置缓存 */
|
||||
getPrivateReadMaxId(peerId: number): number | undefined {
|
||||
return this.privateReadMaxIds[peerId]
|
||||
},
|
||||
|
||||
/** 更新私聊对方已读位置缓存 */
|
||||
updatePrivateReadMaxId(peerId: number, maxReadId: null | number = 0): number {
|
||||
if (!peerId) {
|
||||
return 0
|
||||
}
|
||||
const nextMaxReadId = maxReadId || 0
|
||||
const current = this.getPrivateReadMaxId(peerId)
|
||||
if (current !== undefined && nextMaxReadId <= current) {
|
||||
return current
|
||||
}
|
||||
this.privateReadMaxIds = { ...this.privateReadMaxIds, [peerId]: nextMaxReadId }
|
||||
return nextMaxReadId
|
||||
},
|
||||
|
||||
/** 清空私聊对方已读位置缓存 */
|
||||
clearPrivateReadMaxIdCache(): void {
|
||||
this.privateReadMaxIds = {}
|
||||
},
|
||||
|
||||
/** 标记会话近期使用 */
|
||||
touchConversationMessageCache(clientConversationId: string) {
|
||||
this.loadedConversationKeys = [
|
||||
@@ -797,6 +823,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
const changed: Message[] = []
|
||||
// 1. 私聊回执批量更新自己发送的消息
|
||||
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
|
||||
this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId)
|
||||
const privateReadMaxId = options.privateReadMaxId
|
||||
messages.forEach((message) => {
|
||||
if (
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
import { useFriendStore } from './friendStore'
|
||||
import { useGroupStore } from './groupStore'
|
||||
|
||||
type GroupActiveCallCache = {
|
||||
participantsLoaded?: boolean // 是否已拉取完整参与者列表
|
||||
} & ImRtcApi.RtcGroupCallRespVO
|
||||
|
||||
// RTC_CALL 通话信令载荷;按 status 区分子类型语义
|
||||
export interface ImRtcCallNotification {
|
||||
status: ImRtcParticipantStatusValue
|
||||
@@ -121,7 +125,7 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
}
|
||||
|
||||
/** 群活跃通话索引;groupId -> 群通话摘要;用于群聊顶部胶囊条 */
|
||||
const groupActiveCalls = ref<Map<number, ImRtcApi.RtcGroupCallRespVO>>(new Map())
|
||||
const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map())
|
||||
|
||||
/**
|
||||
* 已退出 / 已拒绝的用户编号集合;群通话场景内 pending 占位渲染时排除;
|
||||
@@ -252,20 +256,51 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
* 房内成员同步交给 LiveKit 客户端事件(ParticipantConnected / Disconnected);
|
||||
* 胶囊条不实时刷新 joinedUserIds / inviteeIds,展开 / 加入时再走 getActiveCall 接口拉最新
|
||||
*/
|
||||
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO) {
|
||||
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO, participantsLoaded?: boolean) {
|
||||
if (!payload?.groupId) {
|
||||
return
|
||||
}
|
||||
// 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
|
||||
const existing = groupActiveCalls.value.get(payload.groupId)
|
||||
if (existing && isSameGroupCall(existing, payload)) {
|
||||
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
|
||||
if (
|
||||
existing &&
|
||||
isSameGroupCall(existing, payload) &&
|
||||
!!existing.participantsLoaded === nextParticipantsLoaded
|
||||
) {
|
||||
return
|
||||
}
|
||||
const newGroupActiveCalls = new Map(groupActiveCalls.value)
|
||||
newGroupActiveCalls.set(payload.groupId, payload)
|
||||
newGroupActiveCalls.set(payload.groupId, {
|
||||
...payload,
|
||||
participantsLoaded: nextParticipantsLoaded
|
||||
})
|
||||
groupActiveCalls.value = newGroupActiveCalls
|
||||
}
|
||||
|
||||
/** 清空指定群的通话缓存 */
|
||||
function clearGroupCallCache(groupId?: number) {
|
||||
if (!groupId) {
|
||||
groupActiveCalls.value = new Map()
|
||||
return
|
||||
}
|
||||
const next = new Map(groupActiveCalls.value)
|
||||
next.delete(groupId)
|
||||
groupActiveCalls.value = next
|
||||
}
|
||||
|
||||
/** 判断群通话是否已补齐 */
|
||||
function isGroupCallParticipantsLoaded(groupId: number, room?: string): boolean {
|
||||
const call = groupActiveCalls.value.get(groupId)
|
||||
return (
|
||||
!!groupId &&
|
||||
!!room &&
|
||||
!!call &&
|
||||
call.room === room &&
|
||||
!!call.participantsLoaded
|
||||
)
|
||||
}
|
||||
|
||||
/** 两条群通话摘要内容相等(room / mediaType / inviterId / 两个 userId 数组逐项相等) */
|
||||
function isSameGroupCall(a: ImRtcApi.RtcGroupCallRespVO, b: ImRtcApi.RtcGroupCallRespVO): boolean {
|
||||
if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
|
||||
@@ -285,12 +320,7 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
|
||||
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
|
||||
function removeGroupCall(groupId: number) {
|
||||
if (!groupId || !groupActiveCalls.value.has(groupId)) {
|
||||
return
|
||||
}
|
||||
const newGroupActiveCalls = new Map(groupActiveCalls.value)
|
||||
newGroupActiveCalls.delete(groupId)
|
||||
groupActiveCalls.value = newGroupActiveCalls
|
||||
clearGroupCallCache(groupId)
|
||||
}
|
||||
|
||||
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */
|
||||
@@ -371,6 +401,10 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) {
|
||||
return
|
||||
}
|
||||
if (nextJoined.length === 0 && nextInvitee.length === 0) {
|
||||
removeGroupCall(groupId)
|
||||
return
|
||||
}
|
||||
setGroupCall({
|
||||
...existing,
|
||||
joinedUserIds: nextJoined,
|
||||
@@ -394,6 +428,8 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
markUserLeft,
|
||||
isUserLeft,
|
||||
setGroupCall,
|
||||
isGroupCallParticipantsLoaded,
|
||||
clearGroupCallCache,
|
||||
removeGroupCall,
|
||||
getGroupCall,
|
||||
applyParticipantConnected,
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from '../../utils/constants'
|
||||
import {
|
||||
getPrivateMessagePeerId,
|
||||
parseRtcCallPayload,
|
||||
playAudioTip,
|
||||
resolveCallEndReasonText
|
||||
} from '../../utils/message'
|
||||
@@ -462,14 +463,30 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
)
|
||||
if (isActive) {
|
||||
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
|
||||
conversationStore.markConversationRead(
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
ImConversationType.CHANNEL,
|
||||
websocketMessage.channelId,
|
||||
websocketMessage.id
|
||||
)
|
||||
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id).catch((error) => {
|
||||
console.warn('[IM WS] 频道自动已读上报失败', error)
|
||||
})
|
||||
conversationStore.markConversationRead(
|
||||
ImConversationType.CHANNEL,
|
||||
websocketMessage.channelId,
|
||||
readCovered ? undefined : websocketMessage.id
|
||||
)
|
||||
if (!readCovered) {
|
||||
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[IM WS] 频道自动已读上报失败',
|
||||
{
|
||||
conversationType: ImConversationType.CHANNEL,
|
||||
channelId: websocketMessage.channelId,
|
||||
messageId: websocketMessage.id
|
||||
},
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
|
||||
// 非当前会话且未免打扰:响一下提示音
|
||||
playAudioTip()
|
||||
@@ -567,7 +584,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
break
|
||||
}
|
||||
case ImContentType.RTC_CALL_START: {
|
||||
// 入库 + 渲染聊天 tip;胶囊条状态走 1602/1603,本帧不动 rtcStore,避免与首次填充竞争
|
||||
// 入库 + 渲染聊天 tip;同时用 START payload 先生成最小胶囊条,后续 getActiveCall / 参与者事件再补齐成员
|
||||
this.handleRtcCallStart(websocketMessage)
|
||||
ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
|
||||
break
|
||||
}
|
||||
@@ -660,15 +678,29 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
if (isActive) {
|
||||
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
|
||||
// 已读位置直接用刚到的消息 id(这条就是当前会话最大 id)
|
||||
conversationStore.markConversationRead(
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_PRIVATE_READ_ENABLED) {
|
||||
apiReadPrivateMessages(peerId, websocketMessage.id).catch((error) => {
|
||||
console.warn('[IM WS] 自动已读上报失败', error)
|
||||
})
|
||||
conversationStore.markConversationRead(
|
||||
ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
readCovered ? undefined : websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) {
|
||||
apiReadPrivateMessages(peerId, websocketMessage.id)
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[IM WS] 私聊自动已读上报失败',
|
||||
{
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
messageId: websocketMessage.id
|
||||
},
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
|
||||
// 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTip);FRIEND_* 等系统事件不响
|
||||
@@ -799,17 +831,29 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
conversationStore.activeConversation?.targetId === websocketMessage.groupId
|
||||
if (isActive) {
|
||||
// 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零
|
||||
conversationStore.markConversationRead(
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId,
|
||||
websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_GROUP_READ_ENABLED) {
|
||||
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch(
|
||||
(error) => {
|
||||
console.warn('[IM WS] 自动已读上报失败', error)
|
||||
}
|
||||
)
|
||||
conversationStore.markConversationRead(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId,
|
||||
readCovered ? undefined : websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) {
|
||||
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[IM WS] 群聊自动已读上报失败',
|
||||
{
|
||||
conversationType: ImConversationType.GROUP,
|
||||
groupId: websocketMessage.groupId,
|
||||
messageId: websocketMessage.id
|
||||
},
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
|
||||
// GROUP_* 群广播事件等系统消息不响提示音
|
||||
@@ -1129,6 +1173,27 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
}
|
||||
},
|
||||
|
||||
/** RTC_CALL_START 通话开始 */
|
||||
handleRtcCallStart(websocketMessage: ImGroupMessageNotification) {
|
||||
const payload = parseRtcCallPayload(websocketMessage.content)
|
||||
if (!payload?.room || !payload.mediaType || !payload.inviterUserId) {
|
||||
console.warn('[IM WS] RTC_CALL_START payload 不合法', {
|
||||
groupId: websocketMessage.groupId,
|
||||
messageId: websocketMessage.id,
|
||||
contentLength: websocketMessage.content?.length ?? 0
|
||||
})
|
||||
return
|
||||
}
|
||||
useRtcStore().setGroupCall({
|
||||
room: payload.room,
|
||||
groupId: websocketMessage.groupId,
|
||||
mediaType: payload.mediaType,
|
||||
inviterId: payload.inviterUserId,
|
||||
joinedUserIds: [payload.inviterUserId],
|
||||
inviteeIds: []
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* RTC_CALL_END 通话结束;私聊 + 群聊都走这一条;payload 携带 conversationType 区分
|
||||
* <p>
|
||||
|
||||
@@ -194,12 +194,13 @@ export interface Group {
|
||||
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
|
||||
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
||||
members?: GroupMember[] // 群成员缓存(按需懒加载)
|
||||
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
|
||||
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
|
||||
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
|
||||
memberCount?: number // 成员总数
|
||||
}
|
||||
|
||||
export type GroupDO = Omit<Group, 'members' | 'membersExpired' | 'membersLoaded'>
|
||||
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'>
|
||||
|
||||
// 群成员实体(前端内部结构)
|
||||
export interface GroupMember {
|
||||
|
||||
@@ -516,7 +516,8 @@ export async function stopRequests(): Promise<void> {
|
||||
{ useGroupStoreWithOut },
|
||||
{ useChannelStoreWithOut },
|
||||
{ useGroupRequestStoreWithOut },
|
||||
{ useFaceStoreWithOut }
|
||||
{ useFaceStoreWithOut },
|
||||
{ useRtcStore }
|
||||
] = await Promise.all([
|
||||
import('../home/store/messageStore'),
|
||||
import('../home/store/conversationStore'),
|
||||
@@ -524,7 +525,8 @@ export async function stopRequests(): Promise<void> {
|
||||
import('../home/store/groupStore'),
|
||||
import('../home/store/channelStore'),
|
||||
import('../home/store/groupRequestStore'),
|
||||
import('../home/store/faceStore')
|
||||
import('../home/store/faceStore'),
|
||||
import('../home/store/rtcStore')
|
||||
])
|
||||
useMessageStoreWithOut().clear()
|
||||
useConversationStoreWithOut().clear()
|
||||
@@ -533,5 +535,7 @@ export async function stopRequests(): Promise<void> {
|
||||
useChannelStoreWithOut().clear()
|
||||
useGroupRequestStoreWithOut().clear()
|
||||
useFaceStoreWithOut().clear()
|
||||
useRtcStore().reset()
|
||||
useRtcStore().clearGroupCallCache()
|
||||
closeDbConnection()
|
||||
}
|
||||
|
||||
@@ -700,10 +700,12 @@ export type GroupNotificationPayload = {
|
||||
mutedUserId?: number
|
||||
muteEndTime?: string
|
||||
newAvatar?: string
|
||||
newJoinApproval?: boolean
|
||||
newName?: string
|
||||
newNotice?: string
|
||||
newOwnerUserId?: number
|
||||
oldAvatar?: string
|
||||
oldJoinApproval?: boolean
|
||||
oldName?: string
|
||||
oldNotice?: string
|
||||
operatorUserId?: number
|
||||
|
||||
@@ -42,23 +42,26 @@ const pillText = computed(() => {
|
||||
watch(
|
||||
() => [props.groupId, activeCall.value?.room] as const,
|
||||
async ([groupId, room], oldValues) => {
|
||||
if (!groupId) {
|
||||
if (!groupId || !activeCall.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 决策是否需要拉取:切群 / room 切换必拉;同群同 room 且已加载 >= 2 人则跳过,避免参与者通知触发后重复请求
|
||||
// 决策是否需要拉取:仅补齐本地已有通话;没有本地通话时等待实时事件创建
|
||||
const groupChanged = !oldValues || oldValues[0] !== groupId
|
||||
const roomChanged = oldValues && oldValues[1] !== room
|
||||
const hydrated = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
|
||||
if (!groupChanged && !roomChanged && hydrated) {
|
||||
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
|
||||
if (
|
||||
rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
|
||||
(!groupChanged && !roomChanged && participantsLoaded)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话,移除本地缓存
|
||||
// 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话
|
||||
try {
|
||||
const data = await getActiveCall(groupId)
|
||||
if (data) {
|
||||
rtcStore.setGroupCall(data)
|
||||
rtcStore.setGroupCall(data, true)
|
||||
} else {
|
||||
rtcStore.removeGroupCall(groupId)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useFriendStore } from '../store/friendStore'
|
||||
import { useGroupRequestStore } from '../store/groupRequestStore'
|
||||
import { useGroupStore } from '../store/groupStore'
|
||||
import { type PulledMessage, useMessageStore } from '../store/messageStore'
|
||||
import { useRtcStore } from '../store/rtcStore'
|
||||
import { useImWebSocketStore } from '../store/websocketStore'
|
||||
|
||||
/** 三类消息 pull 接口返回的原始 VO 联合类型;runMinIdPull 只需 id 推进游标,具体分发在 applyPage 内按类型 cast */
|
||||
@@ -55,6 +56,7 @@ export const useMessagePuller = () => {
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const rtcStore = useRtcStore()
|
||||
const currentUserId = getCurrentUserId()
|
||||
|
||||
/** 判断请求是否被主动取消 */
|
||||
@@ -285,7 +287,12 @@ export const useMessagePuller = () => {
|
||||
* 群成员不做全局增量同步,重连只标记本地群成员 cache 过期,进入群会话或成员列表时再按 groupId 刷新。
|
||||
*/
|
||||
const pullStateEvents = async (): Promise<void> => {
|
||||
// 1. 清理连接级缓存
|
||||
messageStore.clearPrivateReadMaxIdCache()
|
||||
rtcStore.clearGroupCallCache()
|
||||
groupStore.markAllGroupInfoExpired()
|
||||
groupStore.markAllGroupMembersExpired()
|
||||
// 2. 并发补偿远端状态
|
||||
const results = await Promise.allSettled([
|
||||
friendStore.pullFriends(),
|
||||
friendStore.pullFriendRequests(),
|
||||
@@ -403,6 +410,7 @@ export const useMessagePuller = () => {
|
||||
if (!isCurrentPull()) {
|
||||
return
|
||||
}
|
||||
messageStore.updatePrivateReadMaxId(active.targetId, maxReadId)
|
||||
if (maxReadId) {
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
|
||||
@@ -57,7 +57,7 @@ interface SendExtOptions {
|
||||
* 1. 私聊 / 群聊接口签名对称,按 conversation.type 分支调度,差异在分支内部消化
|
||||
* 2. 发送走「乐观更新」:先 insertMessage 写入 SENDING 占位,请求成功 ackMessage 更新为 NORMAL,失败更新为 FAILED
|
||||
* 3. 撤回不做乐观更新:服务端通过 WebSocket RECALL 事件回传,由 websocketStore 统一更新状态,避免网络失败后不可回退
|
||||
* 4. 已读上报:本端立刻清未读数;服务端回包成功后再做持久化
|
||||
* 4. 已读上报:本端立刻清未读数并记录本地读位置;接口失败仅记录日志
|
||||
*/
|
||||
export const useMessageSender = () => {
|
||||
const conversationStore = useConversationStore()
|
||||
@@ -223,7 +223,7 @@ export const useMessageSender = () => {
|
||||
|
||||
/**
|
||||
* 触发当前会话的已读上报(切会话 / 进入页面时调用)
|
||||
* 1. 本端立刻清未读数;服务端回包成功后再做持久化
|
||||
* 1. 本端立刻清未读数并推进读位置
|
||||
* 2. 已读位置取已加载消息和会话末条消息的最大服务端 id
|
||||
*/
|
||||
const readActive = async () => {
|
||||
@@ -240,15 +240,24 @@ export const useMessageSender = () => {
|
||||
}
|
||||
}
|
||||
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
maxMessageId
|
||||
)
|
||||
if (readCovered) {
|
||||
conversationStore.markConversationRead(conversation.type, conversation.targetId)
|
||||
return
|
||||
}
|
||||
const isPrivate = conversation.type === ImConversationType.PRIVATE
|
||||
const isGroup = conversation.type === ImConversationType.GROUP
|
||||
const isChannel = conversation.type === ImConversationType.CHANNEL
|
||||
// 本地标记已读:未读数清零(UI 立刻响应)
|
||||
conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId)
|
||||
if (!maxMessageId) {
|
||||
return
|
||||
}
|
||||
// 接口调用:按会话类型分发,并按对应已读开关控制;失败仅记录日志,不回退本地已读状态
|
||||
const isPrivate = conversation.type === ImConversationType.PRIVATE
|
||||
const isGroup = conversation.type === ImConversationType.GROUP
|
||||
const isChannel = conversation.type === ImConversationType.CHANNEL
|
||||
// 接口调用:按会话类型分发,并按对应已读开关控制
|
||||
if (!isPrivate && !isGroup && !isChannel) {
|
||||
return
|
||||
}
|
||||
@@ -290,9 +299,21 @@ export const useMessageSender = () => {
|
||||
if (!MESSAGE_PRIVATE_READ_ENABLED) {
|
||||
return
|
||||
}
|
||||
const cachedMaxReadId = messageStore.getPrivateReadMaxId(peerId)
|
||||
if (cachedMaxReadId !== undefined) {
|
||||
if (cachedMaxReadId > 0) {
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: peerId,
|
||||
privateReadMaxId: cachedMaxReadId
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 拉取对方已读到的最大消息 id
|
||||
const maxReadId = await apiGetPrivateMaxReadMessageId(peerId)
|
||||
messageStore.updatePrivateReadMaxId(peerId, maxReadId)
|
||||
if (!maxReadId) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ function onBeforeUnload() {
|
||||
}
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
|
||||
/** 离开 IM 主壳:取消在飞的 pull + 主动断 WebSocket + flush 草稿 + 清空表情缓存 + 解绑 unload + 停语音 */
|
||||
/** 离开 IM 主壳:取消 pull、断开 WebSocket、保存草稿、停止语音、解绑 unload,并结束当前 IM session */
|
||||
onUnmounted(() => {
|
||||
cancelPull()
|
||||
webSocketStore.disconnect()
|
||||
|
||||
@@ -250,7 +250,7 @@ function reloadGroupData() {
|
||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
return
|
||||
}
|
||||
groupStore.fetchGroupInfo(conversation.targetId)
|
||||
groupStore.fetchGroupInfo(conversation.targetId, true)
|
||||
groupStore.fetchGroupMemberList(conversation.targetId, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,20 @@ const pendingDraftConversations = new Set<Conversation>()
|
||||
|
||||
type LegacyConversationDO = ConversationDO & { readMessageId?: number }
|
||||
|
||||
/** 创建会话读位置记录 */
|
||||
function createConversationRead(
|
||||
type: number,
|
||||
targetId: number,
|
||||
messageId: number
|
||||
): ConversationRead {
|
||||
return {
|
||||
conversationType: type,
|
||||
targetId,
|
||||
messageId,
|
||||
updateTime: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建草稿保存防抖函数 */
|
||||
function createDraftDebounce(fn: () => void, wait: number) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -335,6 +349,15 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
return !!record && message.id <= record.messageId
|
||||
},
|
||||
|
||||
/** 判断会话读位置是否覆盖消息编号 */
|
||||
isReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
|
||||
if (!messageId) {
|
||||
return false
|
||||
}
|
||||
const record = this.getConversationRead(type, targetId)
|
||||
return !!record && record.messageId >= messageId
|
||||
},
|
||||
|
||||
/** 应用读位置到会话 */
|
||||
applyReadToConversation(conversation: Conversation, messageId: number): boolean {
|
||||
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
|
||||
@@ -604,11 +627,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
// 1. 清理会话级未读状态
|
||||
conversation.unreadCount = 0
|
||||
conversation.atMe = false
|
||||
conversation.atAll = false
|
||||
// 2. 懒加载消息并保存会话摘要
|
||||
// 懒加载消息并保存会话摘要
|
||||
void useMessageStore().ensureConversationMessageListLoaded(conversation)
|
||||
this.saveConversation(conversation)
|
||||
},
|
||||
@@ -685,7 +704,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
},
|
||||
|
||||
/** 标记会话已读 */
|
||||
markConversationRead(type: number, targetId: number, messageId?: number) {
|
||||
markConversationRead(type: number, targetId: number, messageId?: number): void {
|
||||
const conversation = this.getConversation(type, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
@@ -705,19 +724,25 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
conversation.atMe = false
|
||||
conversation.atAll = false
|
||||
if (readMessageIdAdvanced) {
|
||||
const record = {
|
||||
conversationType: type,
|
||||
targetId,
|
||||
messageId,
|
||||
updateTime: Date.now()
|
||||
}
|
||||
const record = createConversationRead(type, targetId, messageId)
|
||||
this.conversationReads[key] = record
|
||||
void getDb()
|
||||
.transaction(['conversations', 'conversationReads'], 'readwrite', async (tx) => {
|
||||
await this.saveConversationRecord(conversation, tx)
|
||||
await this.saveConversationReadRecord(record, tx)
|
||||
})
|
||||
.catch((error) => console.warn('[IM conversationStore] 会话已读写入失败', error))
|
||||
.catch((error) =>
|
||||
console.warn(
|
||||
'[IM conversationStore] 会话已读写入失败',
|
||||
{
|
||||
conversationType: type,
|
||||
targetId,
|
||||
messageId,
|
||||
conversationKey: key
|
||||
},
|
||||
error
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
this.saveConversation(conversation)
|
||||
|
||||
@@ -47,6 +47,7 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
|
||||
/** 构建群 IndexedDB 记录 */
|
||||
function buildGroupDO(group: Group): GroupDO {
|
||||
const {
|
||||
infoLoaded: _infoLoaded,
|
||||
members: _members,
|
||||
membersLoaded: _membersLoaded,
|
||||
membersExpired: _membersExpired,
|
||||
@@ -228,10 +229,11 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
this.groups = fresh.map((group) => {
|
||||
const existing = groupMap.get(group.id)
|
||||
if (!existing) {
|
||||
return group
|
||||
return { ...group, infoLoaded: true }
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
infoLoaded: true,
|
||||
members: existing.members,
|
||||
memberCount: existing.memberCount ?? group.memberCount,
|
||||
membersLoaded: existing.membersLoaded,
|
||||
@@ -251,6 +253,13 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
this.preloadMembersForEmptyAvatarGroups()
|
||||
},
|
||||
|
||||
/** 失效全部群详情缓存 */
|
||||
markAllGroupInfoExpired() {
|
||||
for (const group of this.groups) {
|
||||
group.infoLoaded = false
|
||||
}
|
||||
},
|
||||
|
||||
/** 预加载空群头像的成员列表,供 GroupAvatar 异步合成群头像 */
|
||||
preloadMembersForEmptyAvatarGroups() {
|
||||
for (const group of this.groups) {
|
||||
@@ -287,13 +296,17 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
},
|
||||
|
||||
/** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert,常用于 GROUP_UPDATE 推送后或手动 reload */
|
||||
async fetchGroupInfo(groupId: number) {
|
||||
async fetchGroupInfo(groupId: number, force = false) {
|
||||
const cached = this.getGroup(groupId)
|
||||
if (cached?.infoLoaded && !force) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await apiGetGroup(groupId)
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
this.upsertGroup(convertGroup(data))
|
||||
this.upsertGroup({ ...convertGroup(data), infoLoaded: true })
|
||||
} catch (error) {
|
||||
console.warn('[IM groupStore] fetchGroupInfo 失败', error)
|
||||
}
|
||||
@@ -725,7 +738,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
if (selfIsOperator && this.getGroup(groupId)) {
|
||||
return
|
||||
}
|
||||
await this.fetchGroupInfo(groupId)
|
||||
await this.fetchGroupInfo(groupId, true)
|
||||
},
|
||||
|
||||
/** 群名变更:按 newName 局部更新本地群名 */
|
||||
@@ -740,12 +753,15 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' })
|
||||
},
|
||||
|
||||
/** 群信息变更(NAME / NOTICE 之外字段,当前承载头像变更) */
|
||||
/** 群信息变更:同步头像、进群审批 */
|
||||
applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
const fields: Partial<Group> = {}
|
||||
if (payload.newAvatar) {
|
||||
fields.avatar = payload.newAvatar
|
||||
}
|
||||
if (payload.newJoinApproval != null) {
|
||||
fields.joinApproval = payload.newJoinApproval
|
||||
}
|
||||
if (Object.keys(fields).length > 0) {
|
||||
this.updateGroupFields(groupId, fields)
|
||||
}
|
||||
@@ -755,7 +771,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
async applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
// 自己刚被拉进来:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空
|
||||
if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) {
|
||||
await this.fetchGroupInfo(groupId)
|
||||
await this.fetchGroupInfo(groupId, true)
|
||||
}
|
||||
this.markGroupMembersExpired(groupId)
|
||||
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
|
||||
@@ -766,7 +782,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||
const selfUserId = getCurrentUserId()
|
||||
// 自己自由进群:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空
|
||||
if (selfUserId && payload.entrantUserId === selfUserId && !this.getGroup(groupId)) {
|
||||
await this.fetchGroupInfo(groupId)
|
||||
await this.fetchGroupInfo(groupId, true)
|
||||
}
|
||||
this.markGroupMembersExpired(groupId)
|
||||
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
|
||||
|
||||
@@ -242,6 +242,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
state: () => ({
|
||||
messagesByConversation: {} as Record<string, Message[]>,
|
||||
loadedConversationKeys: [] as string[],
|
||||
privateReadMaxIds: {} as Partial<Record<number, number>>,
|
||||
privateMessageMaxId: 0,
|
||||
groupMessageMaxId: 0,
|
||||
channelMessageMaxId: 0
|
||||
@@ -266,6 +267,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
})
|
||||
this.messagesByConversation = {}
|
||||
this.loadedConversationKeys = []
|
||||
this.privateReadMaxIds = {}
|
||||
this.privateMessageMaxId = 0
|
||||
this.groupMessageMaxId = 0
|
||||
this.channelMessageMaxId = 0
|
||||
@@ -305,6 +307,30 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
}
|
||||
},
|
||||
|
||||
/** 获取私聊对方已读位置缓存 */
|
||||
getPrivateReadMaxId(peerId: number): number | undefined {
|
||||
return this.privateReadMaxIds[peerId]
|
||||
},
|
||||
|
||||
/** 更新私聊对方已读位置缓存 */
|
||||
updatePrivateReadMaxId(peerId: number, maxReadId: null | number = 0): number {
|
||||
if (!peerId) {
|
||||
return 0
|
||||
}
|
||||
const nextMaxReadId = maxReadId || 0
|
||||
const current = this.getPrivateReadMaxId(peerId)
|
||||
if (current !== undefined && nextMaxReadId <= current) {
|
||||
return current
|
||||
}
|
||||
this.privateReadMaxIds = { ...this.privateReadMaxIds, [peerId]: nextMaxReadId }
|
||||
return nextMaxReadId
|
||||
},
|
||||
|
||||
/** 清空私聊对方已读位置缓存 */
|
||||
clearPrivateReadMaxIdCache(): void {
|
||||
this.privateReadMaxIds = {}
|
||||
},
|
||||
|
||||
/** 标记会话近期使用 */
|
||||
touchConversationMessageCache(clientConversationId: string) {
|
||||
this.loadedConversationKeys = [
|
||||
@@ -797,6 +823,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
const changed: Message[] = []
|
||||
// 1. 私聊回执批量更新自己发送的消息
|
||||
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
|
||||
this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId)
|
||||
const privateReadMaxId = options.privateReadMaxId
|
||||
messages.forEach((message) => {
|
||||
if (
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
import { useFriendStore } from './friendStore'
|
||||
import { useGroupStore } from './groupStore'
|
||||
|
||||
type GroupActiveCallCache = {
|
||||
participantsLoaded?: boolean // 是否已拉取完整参与者列表
|
||||
} & ImRtcApi.RtcGroupCallRespVO
|
||||
|
||||
// RTC_CALL 通话信令载荷;按 status 区分子类型语义
|
||||
export interface ImRtcCallNotification {
|
||||
status: ImRtcParticipantStatusValue
|
||||
@@ -121,7 +125,7 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
}
|
||||
|
||||
/** 群活跃通话索引;groupId -> 群通话摘要;用于群聊顶部胶囊条 */
|
||||
const groupActiveCalls = ref<Map<number, ImRtcApi.RtcGroupCallRespVO>>(new Map())
|
||||
const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map())
|
||||
|
||||
/**
|
||||
* 已退出 / 已拒绝的用户编号集合;群通话场景内 pending 占位渲染时排除;
|
||||
@@ -252,20 +256,51 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
* 房内成员同步交给 LiveKit 客户端事件(ParticipantConnected / Disconnected);
|
||||
* 胶囊条不实时刷新 joinedUserIds / inviteeIds,展开 / 加入时再走 getActiveCall 接口拉最新
|
||||
*/
|
||||
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO) {
|
||||
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO, participantsLoaded?: boolean) {
|
||||
if (!payload?.groupId) {
|
||||
return
|
||||
}
|
||||
// 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
|
||||
const existing = groupActiveCalls.value.get(payload.groupId)
|
||||
if (existing && isSameGroupCall(existing, payload)) {
|
||||
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
|
||||
if (
|
||||
existing &&
|
||||
isSameGroupCall(existing, payload) &&
|
||||
!!existing.participantsLoaded === nextParticipantsLoaded
|
||||
) {
|
||||
return
|
||||
}
|
||||
const newGroupActiveCalls = new Map(groupActiveCalls.value)
|
||||
newGroupActiveCalls.set(payload.groupId, payload)
|
||||
newGroupActiveCalls.set(payload.groupId, {
|
||||
...payload,
|
||||
participantsLoaded: nextParticipantsLoaded
|
||||
})
|
||||
groupActiveCalls.value = newGroupActiveCalls
|
||||
}
|
||||
|
||||
/** 清空指定群的通话缓存 */
|
||||
function clearGroupCallCache(groupId?: number) {
|
||||
if (!groupId) {
|
||||
groupActiveCalls.value = new Map()
|
||||
return
|
||||
}
|
||||
const next = new Map(groupActiveCalls.value)
|
||||
next.delete(groupId)
|
||||
groupActiveCalls.value = next
|
||||
}
|
||||
|
||||
/** 判断群通话是否已补齐 */
|
||||
function isGroupCallParticipantsLoaded(groupId: number, room?: string): boolean {
|
||||
const call = groupActiveCalls.value.get(groupId)
|
||||
return (
|
||||
!!groupId &&
|
||||
!!room &&
|
||||
!!call &&
|
||||
call.room === room &&
|
||||
!!call.participantsLoaded
|
||||
)
|
||||
}
|
||||
|
||||
/** 两条群通话摘要内容相等(room / mediaType / inviterId / 两个 userId 数组逐项相等) */
|
||||
function isSameGroupCall(a: ImRtcApi.RtcGroupCallRespVO, b: ImRtcApi.RtcGroupCallRespVO): boolean {
|
||||
if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
|
||||
@@ -285,12 +320,7 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
|
||||
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
|
||||
function removeGroupCall(groupId: number) {
|
||||
if (!groupId || !groupActiveCalls.value.has(groupId)) {
|
||||
return
|
||||
}
|
||||
const newGroupActiveCalls = new Map(groupActiveCalls.value)
|
||||
newGroupActiveCalls.delete(groupId)
|
||||
groupActiveCalls.value = newGroupActiveCalls
|
||||
clearGroupCallCache(groupId)
|
||||
}
|
||||
|
||||
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */
|
||||
@@ -371,6 +401,10 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) {
|
||||
return
|
||||
}
|
||||
if (nextJoined.length === 0 && nextInvitee.length === 0) {
|
||||
removeGroupCall(groupId)
|
||||
return
|
||||
}
|
||||
setGroupCall({
|
||||
...existing,
|
||||
joinedUserIds: nextJoined,
|
||||
@@ -394,6 +428,8 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||
markUserLeft,
|
||||
isUserLeft,
|
||||
setGroupCall,
|
||||
isGroupCallParticipantsLoaded,
|
||||
clearGroupCallCache,
|
||||
removeGroupCall,
|
||||
getGroupCall,
|
||||
applyParticipantConnected,
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from '../../utils/constants'
|
||||
import {
|
||||
getPrivateMessagePeerId,
|
||||
parseRtcCallPayload,
|
||||
playAudioTip,
|
||||
resolveCallEndReasonText
|
||||
} from '../../utils/message'
|
||||
@@ -462,14 +463,30 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
)
|
||||
if (isActive) {
|
||||
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
|
||||
conversationStore.markConversationRead(
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
ImConversationType.CHANNEL,
|
||||
websocketMessage.channelId,
|
||||
websocketMessage.id
|
||||
)
|
||||
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id).catch((error) => {
|
||||
console.warn('[IM WS] 频道自动已读上报失败', error)
|
||||
})
|
||||
conversationStore.markConversationRead(
|
||||
ImConversationType.CHANNEL,
|
||||
websocketMessage.channelId,
|
||||
readCovered ? undefined : websocketMessage.id
|
||||
)
|
||||
if (!readCovered) {
|
||||
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[IM WS] 频道自动已读上报失败',
|
||||
{
|
||||
conversationType: ImConversationType.CHANNEL,
|
||||
channelId: websocketMessage.channelId,
|
||||
messageId: websocketMessage.id
|
||||
},
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
|
||||
// 非当前会话且未免打扰:响一下提示音
|
||||
playAudioTip()
|
||||
@@ -567,7 +584,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
break
|
||||
}
|
||||
case ImContentType.RTC_CALL_START: {
|
||||
// 入库 + 渲染聊天 tip;胶囊条状态走 1602/1603,本帧不动 rtcStore,避免与首次填充竞争
|
||||
// 入库 + 渲染聊天 tip;同时用 START payload 先生成最小胶囊条,后续 getActiveCall / 参与者事件再补齐成员
|
||||
this.handleRtcCallStart(websocketMessage)
|
||||
ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
|
||||
break
|
||||
}
|
||||
@@ -660,15 +678,29 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
if (isActive) {
|
||||
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
|
||||
// 已读位置直接用刚到的消息 id(这条就是当前会话最大 id)
|
||||
conversationStore.markConversationRead(
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_PRIVATE_READ_ENABLED) {
|
||||
apiReadPrivateMessages(peerId, websocketMessage.id).catch((error) => {
|
||||
console.warn('[IM WS] 自动已读上报失败', error)
|
||||
})
|
||||
conversationStore.markConversationRead(
|
||||
ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
readCovered ? undefined : websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) {
|
||||
apiReadPrivateMessages(peerId, websocketMessage.id)
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[IM WS] 私聊自动已读上报失败',
|
||||
{
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
messageId: websocketMessage.id
|
||||
},
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
|
||||
// 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTip);FRIEND_* 等系统事件不响
|
||||
@@ -799,17 +831,29 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
conversationStore.activeConversation?.targetId === websocketMessage.groupId
|
||||
if (isActive) {
|
||||
// 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零
|
||||
conversationStore.markConversationRead(
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId,
|
||||
websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_GROUP_READ_ENABLED) {
|
||||
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch(
|
||||
(error) => {
|
||||
console.warn('[IM WS] 自动已读上报失败', error)
|
||||
}
|
||||
)
|
||||
conversationStore.markConversationRead(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId,
|
||||
readCovered ? undefined : websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) {
|
||||
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[IM WS] 群聊自动已读上报失败',
|
||||
{
|
||||
conversationType: ImConversationType.GROUP,
|
||||
groupId: websocketMessage.groupId,
|
||||
messageId: websocketMessage.id
|
||||
},
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
|
||||
// GROUP_* 群广播事件等系统消息不响提示音
|
||||
@@ -1129,6 +1173,27 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
}
|
||||
},
|
||||
|
||||
/** RTC_CALL_START 通话开始 */
|
||||
handleRtcCallStart(websocketMessage: ImGroupMessageNotification) {
|
||||
const payload = parseRtcCallPayload(websocketMessage.content)
|
||||
if (!payload?.room || !payload.mediaType || !payload.inviterUserId) {
|
||||
console.warn('[IM WS] RTC_CALL_START payload 不合法', {
|
||||
groupId: websocketMessage.groupId,
|
||||
messageId: websocketMessage.id,
|
||||
contentLength: websocketMessage.content?.length ?? 0
|
||||
})
|
||||
return
|
||||
}
|
||||
useRtcStore().setGroupCall({
|
||||
room: payload.room,
|
||||
groupId: websocketMessage.groupId,
|
||||
mediaType: payload.mediaType,
|
||||
inviterId: payload.inviterUserId,
|
||||
joinedUserIds: [payload.inviterUserId],
|
||||
inviteeIds: []
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* RTC_CALL_END 通话结束;私聊 + 群聊都走这一条;payload 携带 conversationType 区分
|
||||
* <p>
|
||||
|
||||
@@ -194,12 +194,13 @@ export interface Group {
|
||||
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
|
||||
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
||||
members?: GroupMember[] // 群成员缓存(按需懒加载)
|
||||
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
|
||||
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
|
||||
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
|
||||
memberCount?: number // 成员总数
|
||||
}
|
||||
|
||||
export type GroupDO = Omit<Group, 'members' | 'membersExpired' | 'membersLoaded'>
|
||||
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'>
|
||||
|
||||
// 群成员实体(前端内部结构)
|
||||
export interface GroupMember {
|
||||
|
||||
@@ -516,7 +516,8 @@ export async function stopRequests(): Promise<void> {
|
||||
{ useGroupStoreWithOut },
|
||||
{ useChannelStoreWithOut },
|
||||
{ useGroupRequestStoreWithOut },
|
||||
{ useFaceStoreWithOut }
|
||||
{ useFaceStoreWithOut },
|
||||
{ useRtcStore }
|
||||
] = await Promise.all([
|
||||
import('../home/store/messageStore'),
|
||||
import('../home/store/conversationStore'),
|
||||
@@ -524,7 +525,8 @@ export async function stopRequests(): Promise<void> {
|
||||
import('../home/store/groupStore'),
|
||||
import('../home/store/channelStore'),
|
||||
import('../home/store/groupRequestStore'),
|
||||
import('../home/store/faceStore')
|
||||
import('../home/store/faceStore'),
|
||||
import('../home/store/rtcStore')
|
||||
])
|
||||
useMessageStoreWithOut().clear()
|
||||
useConversationStoreWithOut().clear()
|
||||
@@ -533,5 +535,7 @@ export async function stopRequests(): Promise<void> {
|
||||
useChannelStoreWithOut().clear()
|
||||
useGroupRequestStoreWithOut().clear()
|
||||
useFaceStoreWithOut().clear()
|
||||
useRtcStore().reset()
|
||||
useRtcStore().clearGroupCallCache()
|
||||
closeDbConnection()
|
||||
}
|
||||
|
||||
@@ -700,10 +700,12 @@ export type GroupNotificationPayload = {
|
||||
mutedUserId?: number
|
||||
muteEndTime?: string
|
||||
newAvatar?: string
|
||||
newJoinApproval?: boolean
|
||||
newName?: string
|
||||
newNotice?: string
|
||||
newOwnerUserId?: number
|
||||
oldAvatar?: string
|
||||
oldJoinApproval?: boolean
|
||||
oldName?: string
|
||||
oldNotice?: string
|
||||
operatorUserId?: number
|
||||
|
||||
Reference in New Issue
Block a user