diff --git a/apps/web-antd/src/views/im/home/components/rtc/rtc-call-container.vue b/apps/web-antd/src/views/im/home/components/rtc/rtc-call-container.vue
index 490bfeb31..8e09d2fe2 100644
--- a/apps/web-antd/src/views/im/home/components/rtc/rtc-call-container.vue
+++ b/apps/web-antd/src/views/im/home/components/rtc/rtc-call-container.vue
@@ -1,11 +1,13 @@
+
diff --git a/apps/web-antd/src/views/im/home/store/channelStore.ts b/apps/web-antd/src/views/im/home/store/channelStore.ts
index 628f57f38..cf6b90bfe 100644
--- a/apps/web-antd/src/views/im/home/store/channelStore.ts
+++ b/apps/web-antd/src/views/im/home/store/channelStore.ts
@@ -1,15 +1,21 @@
-import type { ImManagerChannelApi } from '#/api/im/manager/channel';
+import type { ChannelDO } from '../types'
-import { acceptHMRUpdate, defineStore } from 'pinia';
+import type { ImManagerChannelApi } from '#/api/im/manager/channel'
-import { getSimpleChannelList } from '#/api/im/manager/channel';
-import { getCurrentUserId } from '#/views/im/utils/auth';
+import { acceptHMRUpdate, defineStore } from 'pinia'
-import { ImConversationType } from '../../utils/constants';
-import { getDb } from '../../utils/db';
-import { useConversationStore } from './conversationStore';
+import { getSimpleChannelList } from '#/api/im/manager/channel'
-let storeEpoch = 0; // clear 时递增;旧账号请求返回后不得写入新账号状态
+import { ImConversationType } from '../../utils/constants'
+import { type DbClient, getDb, initDb } from '../../utils/db'
+import {
+ ResourceRequestKey,
+ ResourceRequestMode,
+ runResourceRequest
+} from '../../utils/resourceRequest'
+import { useConversationStore } from './conversationStore'
+
+type ImManagerChannelVO = ImManagerChannelApi.Channel
/**
* IM 频道 Store
@@ -19,14 +25,14 @@ let storeEpoch = 0; // clear 时递增;旧账号请求返回后不得写入新
*/
export const useChannelStore = defineStore('imChannelStore', {
state: () => ({
- channels: [] as ImManagerChannelApi.Channel[],
- loaded: false,
+ channels: [] as ImManagerChannelVO[],
+ loaded: false
}),
getters: {
- getChannel(state): (id: number) => ImManagerChannelApi.Channel | undefined {
- return (id: number) => state.channels.find((c) => c.id === id);
- },
+ getChannel(state): (id: number) => ImManagerChannelVO | undefined {
+ return (id: number) => state.channels.find((c) => c.id === id)
+ }
},
actions: {
@@ -34,54 +40,27 @@ export const useChannelStore = defineStore('imChannelStore', {
/** 从 IndexedDB 恢复频道列表 */
async loadChannelList(): Promise {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
try {
- const cached =
- await getDb().getAll('channels');
- if (
- requestEpoch !== storeEpoch ||
- getCurrentUserId() !== requestUserId
- ) {
- return false;
- }
+ const cached = await getDb().getAll('channels')
if (!cached || cached.length === 0) {
- return false;
+ return false
}
- this.channels = cached;
- return true;
- } catch (error) {
- if (
- requestEpoch === storeEpoch &&
- getCurrentUserId() === requestUserId
- ) {
- console.warn('[IM channelStore] 本地频道缓存读取失败', error);
- }
- return false;
+ this.channels = cached
+ return true
+ } catch (e) {
+ console.warn('[IM channelStore] 本地频道缓存读取失败', e)
+ return false
}
},
/** 保存频道列表 */
- saveChannelList(): void {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- const channels = [...this.channels];
- void getDb()
- .transaction(['channels'], 'readwrite', async (tx) => {
- const db = getDb();
- await db.clearStore('channels', tx);
- for (const channel of channels) {
- await db.put('channels', channel, tx);
- }
- })
- .catch((error) => {
- if (
- requestEpoch === storeEpoch &&
- getCurrentUserId() === requestUserId
- ) {
- console.warn('[IM channelStore] 本地频道缓存写入失败', error);
- }
- });
+ async saveChannelList(channels: ImManagerChannelVO[], db: DbClient = getDb()): Promise {
+ await db.transaction(['channels'], 'readwrite', async (tx) => {
+ await db.clearStore('channels', tx)
+ for (const channel of channels) {
+ await db.put('channels', channel, tx)
+ }
+ })
},
// ==================== 远端拉取 ====================
@@ -89,66 +68,57 @@ export const useChannelStore = defineStore('imChannelStore', {
/** 拉取启用的频道精简列表;成功后回填会话列表已有的频道 name / avatar,覆盖 IDB 旧占位 */
async fetchChannelList(force = false) {
if (this.loaded && !force) {
- return;
- }
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- try {
- const channels = (await getSimpleChannelList()) || [];
- if (
- requestEpoch !== storeEpoch ||
- getCurrentUserId() !== requestUserId
- ) {
- return;
- }
- this.channels = channels;
- this.loaded = true;
- this.syncChannelConversationMetadata();
- this.saveChannelList();
- } catch (error) {
- if (
- requestEpoch === storeEpoch &&
- getCurrentUserId() === requestUserId
- ) {
- console.warn('[IM channelStore] fetchChannelList 失败', error);
- }
+ return this.channels
}
+ return runResourceRequest(
+ ResourceRequestKey.CHANNEL_LIST,
+ async () => {
+ const db = await initDb()
+ const channels = (await getSimpleChannelList()) || []
+ this.channels = channels
+ this.loaded = true
+ this.syncChannelConversationMetadata(db)
+ await this.saveChannelList(channels, db).catch((e) =>
+ console.warn('[IM channelStore] 本地频道缓存写入失败', e)
+ )
+ return channels
+ },
+ { mode: ResourceRequestMode.SINGLE_FLIGHT, refreshAfterPending: force }
+ )
},
/** 用最新的频道信息覆盖已有 CHANNEL 会话的 name / avatar */
- syncChannelConversationMetadata() {
- const conversationStore = useConversationStore();
- const indexed = new Map(this.channels.map((c) => [c.id, c]));
+ syncChannelConversationMetadata(db: DbClient = getDb()) {
+ const conversationStore = useConversationStore()
+ const indexed = new Map(this.channels.map((c) => [c.id, c]))
conversationStore.conversations.forEach((conversation) => {
if (conversation.type !== ImConversationType.CHANNEL) {
- return;
+ return
}
- const channel = indexed.get(conversation.targetId);
+ const channel = indexed.get(conversation.targetId)
if (!channel) {
- return;
+ return
}
conversationStore.updateConversation(
ImConversationType.CHANNEL,
conversation.targetId,
{
name: channel.name,
- avatar: channel.avatar,
+ avatar: channel.avatar
},
- );
- });
+ db
+ )
+ })
},
/** 清空频道内存 */
clear() {
- storeEpoch++;
- this.channels = [];
- this.loaded = false;
- },
- },
-});
+ this.channels = []
+ this.loaded = false
+ }
+ }
+})
if (import.meta.hot) {
- import.meta.hot.accept(acceptHMRUpdate(useChannelStore, import.meta.hot));
+ import.meta.hot.accept(acceptHMRUpdate(useChannelStore, import.meta.hot))
}
-
-export const useChannelStoreWithOut = () => useChannelStore();
diff --git a/apps/web-antd/src/views/im/home/store/conversationStore.ts b/apps/web-antd/src/views/im/home/store/conversationStore.ts
index 6264dee1b..83379beaa 100644
--- a/apps/web-antd/src/views/im/home/store/conversationStore.ts
+++ b/apps/web-antd/src/views/im/home/store/conversationStore.ts
@@ -1,78 +1,70 @@
-import type { DbTransaction } from '../../utils/db';
import type {
Conversation,
ConversationDO,
ConversationRead,
ConversationReadDO,
- MessageDO,
-} from '../types';
+ Message,
+ MessageDO
+} from '../types'
-import type { ImConversationReadApi } from '#/api/im/conversation/read';
+import type { ImConversationReadApi } from '#/api/im/conversation/read'
-import { acceptHMRUpdate, defineStore } from 'pinia';
+import { debounce } from '@vben/utils'
-import { pullMyConversationReadList as apiPullMyConversationReadList } from '#/api/im/conversation/read';
-import { getCurrentUserId } from '#/views/im/utils/auth';
+import { acceptHMRUpdate, defineStore } from 'pinia'
-import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config';
+import {
+ pullMyConversationReadList as apiPullMyConversationReadList
+} from '#/api/im/conversation/read'
+
+import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config'
import {
IM_AT_ALL_USER_ID,
ImConversationType,
ImMessageReceiptStatus,
ImMessageStatus,
- isNormalMessage,
-} from '../../utils/constants';
-import { getClientConversationId, getDb, StorageKeys } from '../../utils/db';
-import { runIncrementalPull } from '../../utils/pull';
-import { useMessageStore } from './messageStore';
+ isNormalMessage
+} from '../../utils/constants'
+import {
+ type DbClient,
+ type DbTransaction,
+ getClientConversationId,
+ getDb,
+ initDb,
+ StorageKeys
+} from '../../utils/db'
+import {
+ enqueueConversationBarrier,
+ enqueueConversationWrite,
+ enqueueConversationWrites,
+ isRelationTerminated
+} from '../../utils/messageSync'
+import { runIncrementalPull } from '../../utils/pull'
+import { useMessageStore } from './messageStore'
-const PERSIST_DRAFT_DEBOUNCE_MS = 500;
-const pendingDraftConversations = new Set();
+type ImConversationReadRespVO = ImConversationReadApi.ConversationReadRespVO
+
+const PERSIST_DRAFT_DEBOUNCE_MS = 500
+const pendingDraftConversations = new Map()
+const conversationProjectionBases = new WeakMap() // 记录投影构建基线,仅保留构建后发生的并发字段变更
/** 创建会话读位置记录 */
function createConversationRead(
type: number,
targetId: number,
- messageId: number,
+ messageId: number
): ConversationRead {
return {
conversationType: type,
targetId,
messageId,
- updateTime: Date.now(),
- };
-}
-
-/** 创建草稿保存防抖函数 */
-function createDraftDebounce(fn: () => void, wait: number) {
- let timer: ReturnType | undefined;
-
- const run = () => {
- if (timer) {
- clearTimeout(timer);
- timer = undefined;
- }
- fn();
- };
- const debounced = () => {
- if (timer) {
- clearTimeout(timer);
- }
- timer = setTimeout(run, wait);
- };
- debounced.cancel = () => {
- if (timer) {
- clearTimeout(timer);
- timer = undefined;
- }
- };
- debounced.flush = run;
- return debounced;
+ updateTime: Date.now()
+ }
}
/** 会话转 IndexedDB 记录 */
function toConversationDO(conversation: Conversation): ConversationDO {
- const draft = conversation.draft;
+ const draft = conversation.draft
return {
targetId: conversation.targetId,
type: conversation.type,
@@ -97,20 +89,15 @@ function toConversationDO(conversation: Conversation): ConversationDO {
atAll: conversation.atAll,
atMessageId: conversation.atMessageId,
atAllMessageId: conversation.atAllMessageId,
- draft: draft
- ? { ...draft, reply: draft.reply ? { ...draft.reply } : undefined }
- : undefined,
- clientConversationId: getClientConversationId(
- conversation.type,
- conversation.targetId,
- ),
- };
+ draft: draft ? { ...draft, reply: draft.reply ? { ...draft.reply } : undefined } : undefined,
+ clientConversationId: getClientConversationId(conversation.type, conversation.targetId)
+ }
}
/** IndexedDB 记录转会话 */
function fromConversationDO(conversation: ConversationDO): Conversation {
- const { clientConversationId: _clientConversationId, ...rest } = conversation;
- return rest;
+ const { clientConversationId: _clientConversationId, ...rest } = conversation
+ return rest
}
/** 会话读位置转 IndexedDB 记录 */
@@ -120,24 +107,19 @@ function toConversationReadDO(record: ConversationRead): ConversationReadDO {
targetId: record.targetId,
messageId: record.messageId,
updateTime: record.updateTime,
- clientConversationId: getClientConversationId(
- record.conversationType,
- record.targetId,
- ),
- };
+ clientConversationId: getClientConversationId(record.conversationType, record.targetId)
+ }
}
/** IndexedDB 记录转会话读位置 */
function fromConversationReadDO(record: ConversationReadDO): ConversationRead {
- const { clientConversationId: _clientConversationId, ...rest } = record;
- return rest;
+ const { clientConversationId: _clientConversationId, ...rest } = record
+ return rest
}
/** 是否为有效会话读位置 */
-function isValidConversationReadRecord(
- record: ImConversationReadApi.ConversationReadRespVO,
-): boolean {
- return !!record.conversationType && !!record.targetId && !!record.messageId;
+function isValidConversationReadRecord(record: ImConversationReadRespVO): boolean {
+ return !!record.conversationType && !!record.targetId && !!record.messageId
}
/** 按读位置重算会话未读与 @ 状态 */
@@ -145,11 +127,11 @@ function applyConversationUnreadState(
conversation: Conversation,
messages: MessageDO[],
readMessageId: number,
+ userId: number
): boolean {
- const currentUserId = getCurrentUserId();
- let unreadCount = 0;
- let atMessageId: number | undefined;
- let atAllMessageId: number | undefined;
+ let unreadCount = 0
+ let atMessageId: number | undefined
+ let atAllMessageId: number | undefined
for (const message of messages) {
if (
!message.id ||
@@ -158,21 +140,14 @@ function applyConversationUnreadState(
!isNormalMessage(message.type) ||
message.status === ImMessageStatus.RECALL
) {
- continue;
+ continue
}
- unreadCount++;
- if (
- currentUserId &&
- message.atUserIds?.includes(currentUserId) &&
- message.id > (atMessageId || 0)
- ) {
- atMessageId = message.id;
+ unreadCount++
+ if (message.atUserIds?.includes(userId) && message.id > (atMessageId || 0)) {
+ atMessageId = message.id
}
- if (
- message.atUserIds?.includes(IM_AT_ALL_USER_ID) &&
- message.id > (atAllMessageId || 0)
- ) {
- atAllMessageId = message.id;
+ if (message.atUserIds?.includes(IM_AT_ALL_USER_ID) && message.id > (atAllMessageId || 0)) {
+ atAllMessageId = message.id
}
}
const changed =
@@ -180,13 +155,13 @@ function applyConversationUnreadState(
conversation.atMe !== !!atMessageId ||
conversation.atAll !== !!atAllMessageId ||
conversation.atMessageId !== atMessageId ||
- conversation.atAllMessageId !== atAllMessageId;
- conversation.unreadCount = unreadCount;
- conversation.atMe = !!atMessageId;
- conversation.atAll = !!atAllMessageId;
- conversation.atMessageId = atMessageId;
- conversation.atAllMessageId = atAllMessageId;
- return changed;
+ conversation.atAllMessageId !== atAllMessageId
+ conversation.unreadCount = unreadCount
+ conversation.atMe = !!atMessageId
+ conversation.atAll = !!atAllMessageId
+ conversation.atMessageId = atMessageId
+ conversation.atAllMessageId = atAllMessageId
+ return changed
}
/** 无读位置时按原未读窗口应用撤回状态 */
@@ -194,51 +169,39 @@ function applyConversationRecallStateWithoutRead(
conversation: Conversation,
messages: MessageDO[],
originalMessage: MessageDO,
+ userId: number
): boolean {
- const previousUnreadCount = conversation.unreadCount;
+ const previousUnreadCount = conversation.unreadCount
const originalWasIncomingNormal =
!!originalMessage.id &&
!originalMessage.selfSend &&
isNormalMessage(originalMessage.type) &&
- originalMessage.status !== ImMessageStatus.RECALL;
+ originalMessage.status !== ImMessageStatus.RECALL
const incomingNormalMessages = messages
.filter(
(message) =>
!!message.id &&
!message.selfSend &&
isNormalMessage(message.type) &&
- message.status !== ImMessageStatus.RECALL,
+ message.status !== ImMessageStatus.RECALL
)
- .toSorted((left, right) => (right.id || 0) - (left.id || 0));
+ .sort((left, right) => (right.id || 0) - (left.id || 0))
const previousUnreadMessages = [
- ...incomingNormalMessages.filter(
- (message) => message.id !== originalMessage.id,
- ),
- ...(originalWasIncomingNormal ? [originalMessage] : []),
+ ...incomingNormalMessages.filter((message) => message.id !== originalMessage.id),
+ ...(originalWasIncomingNormal ? [originalMessage] : [])
]
- .toSorted((left, right) => (right.id || 0) - (left.id || 0))
- .slice(0, previousUnreadCount);
- const recalledUnread = previousUnreadMessages.some(
- (message) => message.id === originalMessage.id,
- );
- const unreadCount = Math.max(
- 0,
- previousUnreadCount - (recalledUnread ? 1 : 0),
- );
- const unreadMessages = incomingNormalMessages.slice(0, unreadCount);
- const currentUserId = getCurrentUserId();
- let atMessageId = conversation.atMessageId;
- let atAllMessageId = conversation.atAllMessageId;
+ .sort((left, right) => (right.id || 0) - (left.id || 0))
+ .slice(0, previousUnreadCount)
+ const recalledUnread = previousUnreadMessages.some((message) => message.id === originalMessage.id)
+ const unreadCount = Math.max(0, previousUnreadCount - (recalledUnread ? 1 : 0))
+ const unreadMessages = incomingNormalMessages.slice(0, unreadCount)
+ let atMessageId = conversation.atMessageId
+ let atAllMessageId = conversation.atAllMessageId
if (
conversation.atMessageId === originalMessage.id ||
- (!conversation.atMessageId &&
- conversation.atMe &&
- !!currentUserId &&
- originalMessage.atUserIds?.includes(currentUserId))
+ (!conversation.atMessageId && conversation.atMe && originalMessage.atUserIds?.includes(userId))
) {
- atMessageId = unreadMessages.find((message) =>
- message.atUserIds?.includes(currentUserId),
- )?.id;
+ atMessageId = unreadMessages.find((message) => message.atUserIds?.includes(userId))?.id
}
if (
conversation.atAllMessageId === originalMessage.id ||
@@ -247,59 +210,54 @@ function applyConversationRecallStateWithoutRead(
originalMessage.atUserIds?.includes(IM_AT_ALL_USER_ID))
) {
atAllMessageId = unreadMessages.find((message) =>
- message.atUserIds?.includes(IM_AT_ALL_USER_ID),
- )?.id;
+ message.atUserIds?.includes(IM_AT_ALL_USER_ID)
+ )?.id
}
const changed =
conversation.unreadCount !== unreadCount ||
conversation.atMe !== !!atMessageId ||
conversation.atAll !== !!atAllMessageId ||
conversation.atMessageId !== atMessageId ||
- conversation.atAllMessageId !== atAllMessageId;
- conversation.unreadCount = unreadCount;
- conversation.atMe = !!atMessageId;
- conversation.atAll = !!atAllMessageId;
- conversation.atMessageId = atMessageId;
- conversation.atAllMessageId = atAllMessageId;
- return changed;
+ conversation.atAllMessageId !== atAllMessageId
+ conversation.unreadCount = unreadCount
+ conversation.atMe = !!atMessageId
+ conversation.atAll = !!atAllMessageId
+ conversation.atMessageId = atMessageId
+ conversation.atAllMessageId = atAllMessageId
+ return changed
}
/** 为旧会话回填未读 @ 消息编号 */
function backfillConversationMentionIds(
conversation: Conversation,
messages: MessageDO[],
+ userId: number
): boolean {
- const currentUserId = getCurrentUserId();
const unreadMessages = messages
.filter(
(message) =>
!!message.id &&
!message.selfSend &&
isNormalMessage(message.type) &&
- message.status !== ImMessageStatus.RECALL,
+ message.status !== ImMessageStatus.RECALL
)
- .toSorted((left, right) => (right.id || 0) - (left.id || 0))
- .slice(0, conversation.unreadCount);
+ .sort((left, right) => (right.id || 0) - (left.id || 0))
+ .slice(0, conversation.unreadCount)
const atMessageId =
conversation.atMessageId ||
- (conversation.atMe && currentUserId
- ? unreadMessages.find((message) =>
- message.atUserIds?.includes(currentUserId),
- )?.id
- : undefined);
+ (conversation.atMe
+ ? unreadMessages.find((message) => message.atUserIds?.includes(userId))?.id
+ : undefined)
const atAllMessageId =
conversation.atAllMessageId ||
(conversation.atAll
- ? unreadMessages.find((message) =>
- message.atUserIds?.includes(IM_AT_ALL_USER_ID),
- )?.id
- : undefined);
+ ? unreadMessages.find((message) => message.atUserIds?.includes(IM_AT_ALL_USER_ID))?.id
+ : undefined)
const changed =
- conversation.atMessageId !== atMessageId ||
- conversation.atAllMessageId !== atAllMessageId;
- conversation.atMessageId = atMessageId;
- conversation.atAllMessageId = atAllMessageId;
- return changed;
+ conversation.atMessageId !== atMessageId || conversation.atAllMessageId !== atAllMessageId
+ conversation.atMessageId = atMessageId
+ conversation.atAllMessageId = atAllMessageId
+ return changed
}
export const useConversationStore = defineStore('imConversationStore', {
@@ -309,32 +267,29 @@ export const useConversationStore = defineStore('imConversationStore', {
activeConversation: null as Conversation | null, // 当前激活的会话
activeMentionMessageId: undefined as number | undefined, // 当前会话待定位的未读 @ 消息编号
loading: false, // 是否正在批量加载
- recentForwardConversationKeys: [] as string[], // 最近转发会话 key 列表
+ recentForwardConversationKeys: [] as string[] // 最近转发会话 key 列表
}),
getters: {
/** 排序后的会话列表 */
getSortedConversationList(state): Conversation[] {
- return state.conversations
+ return [...state.conversations]
.filter((conversation) => !conversation.deleted)
- .toSorted((a, b) => {
- const aTop = a.top ? 1 : 0;
- const bTop = b.top ? 1 : 0;
+ .sort((a, b) => {
+ const aTop = a.top ? 1 : 0
+ const bTop = b.top ? 1 : 0
if (aTop !== bTop) {
- return bTop - aTop;
+ return bTop - aTop
}
- return (b.lastSendTime || 0) - (a.lastSendTime || 0);
- });
+ return (b.lastSendTime || 0) - (a.lastSendTime || 0)
+ })
},
/** 未读总数 */
getTotalUnreadCount(state): number {
return state.conversations
.filter((conversation) => !conversation.deleted && !conversation.silent)
- .reduce(
- (sum, conversation) => sum + (conversation.unreadCount || 0),
- 0,
- );
+ .reduce((sum, conversation) => sum + (conversation.unreadCount || 0), 0)
},
/** 查找会话 */
@@ -342,190 +297,145 @@ export const useConversationStore = defineStore('imConversationStore', {
(state) =>
(type: number, targetId: number): Conversation | undefined =>
state.conversations.find(
- (conversation) =>
- conversation.type === type && conversation.targetId === targetId,
+ (conversation) => conversation.type === type && conversation.targetId === targetId
),
/** 查找会话读位置 */
getConversationRead:
(state) =>
(type: number, targetId: number): ConversationRead | undefined =>
- state.conversationReads[getClientConversationId(type, targetId)],
+ state.conversationReads[getClientConversationId(type, targetId)]
},
actions: {
/** 加载会话 */
async loadConversationList() {
// 1. 清理旧账号内存
- const userId = getCurrentUserId();
- if (!userId) {
- this.clear();
- return;
- }
const previousActiveKey = this.activeConversation
- ? getClientConversationId(
- this.activeConversation.type,
- this.activeConversation.targetId,
- )
- : null;
- this.clear();
- // 2. 从 IndexedDB 读取会话和轻量设置
- const db = getDb();
- const [conversations, conversationReads, recent] = await Promise.all([
- db.getAll('conversations'),
- db.getAll('conversationReads'),
- db.getSetting(
- StorageKeys.settings.recentForwardConversationKeys,
- ),
- ]);
- const nextConversationReads: Record = {};
- for (const record of conversationReads) {
- const item = fromConversationReadDO(record);
- nextConversationReads[
- getClientConversationId(item.conversationType, item.targetId)
- ] = item;
- }
- const nextConversations = conversations.map((conversation) =>
- fromConversationDO(conversation),
- );
- this.conversationReads = nextConversationReads;
- await this.applyLocalConversationReads(nextConversations);
- this.conversations = nextConversations;
- if (Array.isArray(recent)) {
- this.recentForwardConversationKeys = recent.slice(
- 0,
- CONVERSATION_RECENT_FORWARD_MAX,
- );
- }
- // 3. 恢复当前激活会话
- if (previousActiveKey) {
- this.activeConversation =
- this.conversations.find(
- (conversation) =>
- !conversation.deleted &&
- getClientConversationId(
- conversation.type,
- conversation.targetId,
- ) === previousActiveKey,
- ) ?? null;
- }
+ ? getClientConversationId(this.activeConversation.type, this.activeConversation.targetId)
+ : null
+ await enqueueConversationBarrier(async () => {
+ const loading = this.loading
+ this.clear()
+ this.loading = loading
+ // 2. 从 IndexedDB 读取会话和轻量设置
+ const db = getDb()
+ const [conversations, conversationReads, recent] = await Promise.all([
+ db.getAll('conversations'),
+ db.getAll('conversationReads'),
+ db.getSetting(StorageKeys.settings.recentForwardConversationKeys)
+ ])
+ const nextConversationReads: Record = {}
+ for (const record of conversationReads) {
+ const item = fromConversationReadDO(record)
+ nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] =
+ item
+ }
+ const nextConversations = conversations.map(fromConversationDO)
+ this.conversationReads = nextConversationReads
+ await this.applyLocalConversationReads(nextConversations, db)
+ this.conversations = nextConversations
+ if (Array.isArray(recent)) {
+ this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
+ }
+ // 3. 恢复当前激活会话
+ if (previousActiveKey) {
+ this.activeConversation =
+ this.conversations.find(
+ (conversation) =>
+ !conversation.deleted &&
+ getClientConversationId(conversation.type, conversation.targetId) ===
+ previousActiveKey
+ ) ?? null
+ }
+ })
},
/** 清空会话内存 */
clear() {
- saveDraftConversationListDebounced.cancel();
- pendingDraftConversations.clear();
- this.conversations = [];
- this.conversationReads = {};
- this.activeConversation = null;
- this.activeMentionMessageId = undefined;
- this.recentForwardConversationKeys = [];
+ saveDraftConversationListDebounced.cancel()
+ pendingDraftConversations.clear()
+ this.conversations = []
+ this.conversationReads = {}
+ this.activeConversation = null
+ this.activeMentionMessageId = undefined
+ this.recentForwardConversationKeys = []
+ this.loading = false
},
/** 持久化会话读位置 */
async saveConversationReadRecord(
target: ConversationRead | ConversationRead[] | null | undefined,
tx?: DbTransaction,
+ db: DbClient = getDb()
): Promise {
- let targets: ConversationRead[] = [];
- if (Array.isArray(target)) {
- targets = target;
- } else if (target) {
- targets = [target];
- }
- const records = targets.map((record) => toConversationReadDO(record));
+ const records = (Array.isArray(target) ? target : target ? [target] : []).map(
+ toConversationReadDO
+ )
if (records.length === 0) {
- return;
+ return
}
- const db = getDb();
if (tx) {
for (const record of records) {
- await db.put('conversationReads', record, tx);
+ await db.put('conversationReads', record, tx)
}
- return;
+ return
}
await db.transaction(['conversationReads'], 'readwrite', async (tx) => {
for (const record of records) {
- await db.put('conversationReads', record, tx);
+ await db.put('conversationReads', record, tx)
}
- });
+ })
},
/** 应用本地会话读位置 */
- async applyLocalConversationReads(conversations?: Conversation[]) {
- const targetConversations = conversations || this.conversations;
- const changedConversations: Conversation[] = [];
+ async applyLocalConversationReads(conversations?: Conversation[], db: DbClient = getDb()) {
+ const targetConversations = conversations || this.conversations
+ const changedConversations: Conversation[] = []
for (const conversation of targetConversations) {
- const record = this.getConversationRead(
- conversation.type,
- conversation.targetId,
- );
+ const record = this.getConversationRead(conversation.type, conversation.targetId)
const needsMentionBackfill =
(conversation.atMe && !conversation.atMessageId) ||
- (conversation.atAll && !conversation.atAllMessageId);
+ (conversation.atAll && !conversation.atAllMessageId)
if (!record && !needsMentionBackfill) {
- continue;
+ continue
}
- const messages = await getDb().getAllByIndex(
+ const messages = await db.getAllByIndex(
'messages',
'clientConversationId',
- getClientConversationId(conversation.type, conversation.targetId),
- );
+ getClientConversationId(conversation.type, conversation.targetId)
+ )
const changed = record
- ? this.applyReadToConversation(
- conversation,
- record.messageId,
- messages,
- )
- : backfillConversationMentionIds(conversation, messages);
+ ? this.applyReadToConversation(conversation, record.messageId, messages, db.userId)
+ : backfillConversationMentionIds(conversation, messages, db.userId)
if (changed) {
- changedConversations.push(conversation);
+ changedConversations.push(conversation)
}
}
if (changedConversations.length > 0) {
- await this.saveConversationRecord(changedConversations);
+ await this.saveConversationRecord(changedConversations, undefined, db)
}
},
/** 判断消息是否已被会话读位置覆盖 */
isMessageCoveredByReadPosition(
conversation: Pick,
- message?: null | { id?: number },
+ message?: null | { id?: number }
): boolean {
if (!message?.id) {
- return false;
+ return false
}
- const record = this.getConversationRead(
- conversation.type,
- conversation.targetId,
- );
- 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;
+ const record = this.getConversationRead(conversation.type, conversation.targetId)
+ return !!record && message.id <= record.messageId
},
/** 判断服务端已读位置是否覆盖消息编号 */
- isReportedReadPositionCovered(
- type: number,
- targetId: number,
- messageId?: number,
- ): boolean {
+ isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
if (!messageId) {
- return false;
+ return false
}
- const conversation = this.getConversation(type, targetId);
- return (conversation?.reportedReadMessageId || 0) >= messageId;
+ const conversation = this.getConversation(type, targetId)
+ return (conversation?.reportedReadMessageId || 0) >= messageId
},
/** 应用读位置到会话 */
@@ -533,8 +443,9 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation: Conversation,
messageId: number,
messages: MessageDO[],
+ userId: number
): boolean {
- return applyConversationUnreadState(conversation, messages, messageId);
+ return applyConversationUnreadState(conversation, messages, messageId, userId)
},
/** 应用撤回后的会话未读与 @ 状态 */
@@ -542,79 +453,80 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation: Conversation,
messages: MessageDO[],
originalMessage: MessageDO,
+ userId: number
): boolean {
- const read = this.getConversationRead(
- conversation.type,
- conversation.targetId,
- );
+ const read = this.getConversationRead(conversation.type, conversation.targetId)
return read
- ? applyConversationUnreadState(conversation, messages, read.messageId)
- : applyConversationRecallStateWithoutRead(
- conversation,
- messages,
- originalMessage,
- );
+ ? applyConversationUnreadState(conversation, messages, read.messageId, userId)
+ : applyConversationRecallStateWithoutRead(conversation, messages, originalMessage, userId)
},
/** 应用会话读位置 */
async applyConversationReadList(
- records: ImConversationReadApi.ConversationReadRespVO[],
- isActive?: () => boolean,
+ records: ImConversationReadRespVO[],
+ db: DbClient = getDb()
+ ): Promise {
+ const conversationIds = records
+ .filter(isValidConversationReadRecord)
+ .map((record) => getClientConversationId(record.conversationType, record.targetId))
+ await enqueueConversationWrites(conversationIds, () =>
+ this.applyConversationReadListNow(records, db)
+ )
+ },
+
+ /** 实际应用会话读位置;调用方必须持有涉及会话的写 lane */
+ async applyConversationReadListNow(
+ records: ImConversationReadRespVO[],
+ db: DbClient
): Promise {
if (records.length === 0) {
- return;
+ return
}
- const changedReads = new Map();
- const changedConversations = new Map();
- const changedMessages = new Map();
- const db = getDb();
- const messageStore = useMessageStore();
+ const changedReads = new Map()
+ const changedConversations = new Map()
+ const changedMessages = new Map()
+ const changedMemoryMessages = new Map()
+ const messageStore = useMessageStore()
// 1. 按读位置更新会话未读和频道已读态
for (const record of records) {
- if (isActive && !isActive()) {
- return;
- }
if (!isValidConversationReadRecord(record)) {
- continue;
+ continue
}
const clientConversationId = getClientConversationId(
record.conversationType,
- record.targetId,
- );
- let storedMessages: MessageDO[] | undefined;
+ record.targetId
+ )
+ let storedMessages: MessageDO[] | undefined
const getStoredMessages = async () => {
if (!storedMessages) {
storedMessages = await db.getAllByIndex(
'messages',
'clientConversationId',
- clientConversationId,
- );
+ clientConversationId
+ )
}
- return storedMessages;
- };
- const current = this.conversationReads[clientConversationId];
- const messageId = Math.max(record.messageId, current?.messageId || 0);
- const conversation = this.getConversation(
- record.conversationType,
- record.targetId,
- );
- if (
- conversation &&
- record.messageId > (conversation.reportedReadMessageId || 0)
- ) {
- conversation.reportedReadMessageId = record.messageId;
- changedConversations.set(clientConversationId, conversation);
+ return storedMessages
+ }
+ const current =
+ changedReads.get(clientConversationId) || this.conversationReads[clientConversationId]
+ const messageId = Math.max(record.messageId, current?.messageId || 0)
+ const currentConversation = this.getConversation(record.conversationType, record.targetId)
+ const conversation =
+ changedConversations.get(clientConversationId) ||
+ (currentConversation ? { ...currentConversation } : undefined)
+ if (conversation && record.messageId > (conversation.reportedReadMessageId || 0)) {
+ conversation.reportedReadMessageId = record.messageId
+ changedConversations.set(clientConversationId, conversation)
}
if (!current || messageId > current.messageId) {
const next = {
conversationType: record.conversationType,
targetId: record.targetId,
messageId,
- updateTime: record.updateTime,
- };
- this.conversationReads[clientConversationId] = next;
- changedReads.set(clientConversationId, next);
+ updateTime: record.updateTime
+ }
+ changedReads.set(clientConversationId, next)
}
if (
@@ -623,21 +535,25 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation,
messageId,
await getStoredMessages(),
+ db.userId
)
) {
- changedConversations.set(clientConversationId, conversation);
+ changedConversations.set(clientConversationId, conversation)
}
if (record.conversationType !== ImConversationType.CHANNEL) {
- continue;
+ continue
}
- const memoryMessages = messageStore.getMessages(clientConversationId);
+ const memoryMessages = messageStore.getMessages(clientConversationId)
for (const message of memoryMessages) {
if (
message.id &&
message.id <= messageId &&
message.receiptStatus !== ImMessageReceiptStatus.DONE
) {
- message.receiptStatus = ImMessageReceiptStatus.DONE;
+ changedMemoryMessages.set(message, {
+ ...message,
+ receiptStatus: ImMessageReceiptStatus.DONE
+ })
}
}
for (const message of await getStoredMessages()) {
@@ -646,8 +562,8 @@ export const useConversationStore = defineStore('imConversationStore', {
message.id <= messageId &&
message.receiptStatus !== ImMessageReceiptStatus.DONE
) {
- message.receiptStatus = ImMessageReceiptStatus.DONE;
- changedMessages.set(message.messageKey, message);
+ message.receiptStatus = ImMessageReceiptStatus.DONE
+ changedMessages.set(message.messageKey, message)
}
}
}
@@ -656,155 +572,199 @@ export const useConversationStore = defineStore('imConversationStore', {
if (
changedReads.size === 0 &&
changedConversations.size === 0 &&
- changedMessages.size === 0
+ changedMessages.size === 0 &&
+ changedMemoryMessages.size === 0
) {
- return;
+ return
}
- if (isActive && !isActive()) {
- return;
- }
- const stores: Array<'conversationReads' | 'conversations' | 'messages'> =
- [];
+ const stores: Array<'conversationReads' | 'conversations' | 'messages'> = []
if (changedReads.size > 0) {
- stores.push('conversationReads');
+ stores.push('conversationReads')
}
if (changedConversations.size > 0) {
- stores.push('conversations');
+ stores.push('conversations')
}
if (changedMessages.size > 0) {
- stores.push('messages');
+ stores.push('messages')
}
- await db.transaction(stores, 'readwrite', async (tx) => {
- if (changedReads.size > 0) {
- await this.saveConversationReadRecord([...changedReads.values()], tx);
- }
- if (changedConversations.size > 0) {
- await this.saveConversationRecord(
- [...changedConversations.values()],
- tx,
- );
- }
- for (const message of changedMessages.values()) {
- await db.put('messages', message, tx);
- }
- });
+ if (stores.length > 0) {
+ await db.transaction(stores, 'readwrite', async (tx) => {
+ if (changedReads.size > 0) {
+ await this.saveConversationReadRecord([...changedReads.values()], tx, db)
+ }
+ if (changedConversations.size > 0) {
+ await this.saveConversationRecord([...changedConversations.values()], tx, db)
+ }
+ for (const message of changedMessages.values()) {
+ await db.put('messages', message, tx)
+ }
+ })
+ }
+ changedReads.forEach((read, key) => {
+ this.conversationReads[key] = read
+ })
+ changedConversations.forEach((conversation) => {
+ this.publishConversationProjection(conversation, true)
+ })
+ changedMemoryMessages.forEach((next, current) => Object.assign(current, next))
},
/** 增量拉取会话读位置 */
- async pullConversationReads(isActive?: () => boolean): Promise {
+ async pullConversationReads(): Promise {
+ const db = await initDb()
await runIncrementalPull(
+ db,
StorageKeys.settings.conversationReadPullCursor,
apiPullMyConversationReadList,
async (records) => {
- if (isActive && !isActive()) {
- return false;
- }
- await this.applyConversationReadList(records, isActive);
- if (isActive && !isActive()) {
- return false;
- }
- return true;
- },
- isActive,
- );
+ await this.applyConversationReadList(records, db)
+ return true
+ }
+ )
},
/** 执行会话记录持久化 */
async saveConversationRecord(
target: Conversation | Conversation[] | null | undefined,
tx?: DbTransaction,
+ db: DbClient = getDb()
): Promise {
- const db = getDb();
- const conversations =
- // oxlint-disable-next-line unicorn/no-nested-ternary
- (Array.isArray(target) ? target : target ? [target] : []).map(
- (conversation) => toConversationDO(conversation),
- );
+ const conversations = (Array.isArray(target) ? target : target ? [target] : []).map(
+ toConversationDO
+ )
if (conversations.length === 0) {
- return;
+ return
}
if (tx) {
for (const conversation of conversations) {
- await db.put('conversations', conversation, tx);
+ await db.put('conversations', conversation, tx)
}
- return;
+ return
}
await db.transaction(['conversations'], 'readwrite', async (tx) => {
for (const conversation of conversations) {
- await db.put('conversations', conversation, tx);
+ await db.put('conversations', conversation, tx)
}
- });
+ })
},
/** 持久化单个会话 */
saveConversation(
conversation: Conversation | null | undefined,
tx?: DbTransaction,
+ db: DbClient = getDb()
): void {
if (!conversation) {
- return;
+ return
}
- void this.saveConversationRecord(conversation, tx).catch((error) =>
- console.warn('[IM conversationStore] 会话写入失败', error),
- );
+ if (tx) {
+ void this.saveConversationRecord(conversation, tx, db).catch((e) =>
+ console.warn('[IM conversationStore] 会话写入失败', e)
+ )
+ return
+ }
+ void enqueueConversationWrite(
+ getClientConversationId(conversation.type, conversation.targetId),
+ () => this.saveConversationRecord(conversation, undefined, db)
+ ).catch((e) => console.warn('[IM conversationStore] 会话写入失败', e))
},
/** 持久化会话列表 */
saveConversationList(
conversations?: Conversation[] | null,
tx?: DbTransaction,
- ): void {
- if (this.loading && !tx) {
- return;
+ db: DbClient = getDb()
+ ): Promise {
+ const targets = conversations || this.conversations
+ if (tx) {
+ return this.saveConversationRecord(targets, tx, db).catch((e) =>
+ console.warn('[IM conversationStore] 会话列表写入失败', e)
+ )
}
- void this.saveConversationRecord(
- conversations || this.conversations,
- tx,
- ).catch((error) =>
- console.warn('[IM conversationStore] 会话写入失败', error),
- );
+ return enqueueConversationWrites(
+ targets.map((item) => getClientConversationId(item.type, item.targetId)),
+ () => this.saveConversationRecord(targets, undefined, db)
+ )
+ .then(() => undefined)
+ .catch((e) => {
+ console.warn('[IM conversationStore] 会话写入失败', e)
+ })
+ },
+
+ /** 构建会话的下一份投影,不提前修改响应式状态 */
+ buildConversationProjection(info: {
+ avatar: string
+ name: string
+ silent?: boolean
+ targetId: number
+ type: number
+ }): Conversation {
+ const clientConversationId = getClientConversationId(info.type, info.targetId)
+ const relationTerminated =
+ info.type === ImConversationType.GROUP && isRelationTerminated(clientConversationId)
+ const current = this.getConversation(info.type, info.targetId)
+ const conversation = current
+ ? { ...current }
+ : this.createEmptyConversation(
+ info.type,
+ info.targetId,
+ info.name,
+ info.avatar,
+ info.silent
+ )
+ if (conversation.deleted && !relationTerminated) {
+ conversation.deleted = false
+ }
+ if (info.name) {
+ conversation.name = info.name
+ }
+ if (info.avatar) {
+ conversation.avatar = info.avatar
+ }
+ if (info.silent !== undefined) {
+ conversation.silent = info.silent
+ }
+ if (relationTerminated) {
+ conversation.deleted = true
+ }
+ if (current) {
+ conversationProjectionBases.set(conversation, { ...current })
+ }
+ return conversation
+ },
+
+ /** 发布已成功持久化的会话投影 */
+ publishConversationProjection(
+ projection: Conversation,
+ preserveConcurrentFields = false
+ ): Conversation {
+ const current = this.getConversation(projection.type, projection.targetId)
+ if (current) {
+ const concurrentFields: Partial = {}
+ const base = conversationProjectionBases.get(projection)
+ if (preserveConcurrentFields) {
+ if (!base || current.name !== base.name) concurrentFields.name = current.name
+ if (!base || current.avatar !== base.avatar) concurrentFields.avatar = current.avatar
+ if (!base || current.top !== base.top) concurrentFields.top = current.top
+ if (!base || current.silent !== base.silent) concurrentFields.silent = current.silent
+ if (!base || current.draft !== base.draft) concurrentFields.draft = current.draft
+ }
+ Object.assign(current, projection, concurrentFields)
+ return current
+ }
+ this.conversations.unshift(projection)
+ return projection
},
/** 确保会话存在 */
ensureConversation(info: {
- avatar: string;
- name: string;
- silent?: boolean;
- targetId: number;
- type: number;
+ avatar: string
+ name: string
+ silent?: boolean
+ targetId: number
+ type: number
}): Conversation {
- // 1. 创建不存在的会话
- let conversation = this.getConversation(info.type, info.targetId);
- if (!conversation) {
- conversation = this.createEmptyConversation(
- info.type,
- info.targetId,
- info.name,
- info.avatar,
- info.silent,
- );
- this.conversations.unshift(conversation);
- } else if (conversation.deleted) {
- // 2. 恢复软删除会话
- conversation.deleted = false;
- conversation.name = info.name || conversation.name;
- conversation.avatar = info.avatar || conversation.avatar;
- if (info.silent !== undefined) {
- conversation.silent = info.silent;
- }
- } else {
- // 3. 同步会话展示元数据
- if (info.name) {
- conversation.name = info.name;
- }
- if (info.avatar) {
- conversation.avatar = info.avatar;
- }
- if (info.silent !== undefined) {
- conversation.silent = info.silent;
- }
- }
- return conversation;
+ return this.publishConversationProjection(this.buildConversationProjection(info))
},
/** 打开或创建会话 */
@@ -813,7 +773,7 @@ export const useConversationStore = defineStore('imConversationStore', {
type: number,
name: string,
avatar: string,
- options?: { silent?: boolean },
+ options?: { silent?: boolean }
): Conversation {
// 1. 确保会话在列表中
const conversation = this.ensureConversation({
@@ -821,32 +781,33 @@ export const useConversationStore = defineStore('imConversationStore', {
targetId,
name,
avatar,
- silent: options?.silent,
- });
+ silent: options?.silent
+ })
// 2. 激活会话并保存
- this.setActiveConversation(conversation);
- this.saveConversation(conversation);
- return conversation;
+ this.setActiveConversation(conversation)
+ this.saveConversation(conversation)
+ return conversation
},
/** 设置当前会话 */
setActiveConversation(conversation: Conversation | null) {
- this.activeMentionMessageId =
- conversation?.atMessageId || conversation?.atAllMessageId;
- this.activeConversation = conversation;
+ this.activeMentionMessageId = conversation?.atMessageId || conversation?.atAllMessageId
+ this.activeConversation = conversation
if (!conversation) {
- return;
+ return
}
// 懒加载消息并保存会话摘要
- void useMessageStore().ensureConversationMessageListLoaded(conversation);
- this.saveConversation(conversation);
+ void useMessageStore()
+ .ensureConversationMessageListLoaded(conversation)
+ .catch((error) => console.warn('[IM conversationStore] 会话消息加载失败', error))
+ this.saveConversation(conversation)
},
/** 消费当前会话待定位的未读 @ 消息编号 */
consumeActiveMentionMessageId(): number | undefined {
- const messageId = this.activeMentionMessageId;
- this.activeMentionMessageId = undefined;
- return messageId;
+ const messageId = this.activeMentionMessageId
+ this.activeMentionMessageId = undefined
+ return messageId
},
/** 创建空会话 */
@@ -855,7 +816,7 @@ export const useConversationStore = defineStore('imConversationStore', {
targetId: number,
name: string,
avatar: string,
- silent = false,
+ silent = false
): Conversation {
return {
targetId,
@@ -869,56 +830,98 @@ export const useConversationStore = defineStore('imConversationStore', {
top: false,
silent,
atMe: false,
- atAll: false,
- };
+ atAll: false
+ }
},
/** 设置置顶 */
setConversationTop(type: number, targetId: number, top: boolean) {
- const conversation = this.getConversation(type, targetId);
+ const conversation = this.getConversation(type, targetId)
if (!conversation) {
- return;
+ return
}
- conversation.top = top;
- this.saveConversation(conversation);
+ conversation.top = top
+ this.saveConversation(conversation)
},
/** 设置免打扰 */
setConversationSilent(type: number, targetId: number, silent: boolean) {
- const conversation = this.getConversation(type, targetId);
+ const conversation = this.getConversation(type, targetId)
if (!conversation) {
- return;
+ return
}
- conversation.silent = silent;
- this.saveConversation(conversation);
+ conversation.silent = silent
+ this.saveConversation(conversation)
},
/** 删除会话 */
- removeConversation(type: number, targetId: number) {
- // 1. 标记会话删除
- const conversation = this.getConversation(type, targetId);
+ async removeConversation(type: number, targetId: number) {
+ const db = getDb()
+ await enqueueConversationWrite(getClientConversationId(type, targetId), async () => {
+ await this.removeConversationNow(type, targetId, db)
+ })
+ },
+
+ /** 实际删除会话;调用方必须持有当前会话写 lane */
+ async removeConversationNow(type: number, targetId: number, db: DbClient) {
+ if (!this.getConversation(type, targetId)) {
+ return
+ }
+ // 1. 先持久化消息 clear watermark 并清理消息
+ await useMessageStore().deleteConversationMessageListNow(type, targetId, db)
+ // 2. 保存删除终态,再发布响应式投影
+ await this.hideConversationNow(type, targetId, db)
+ },
+
+ /** 保存隐藏会话投影但不发布 Store;用于和其它终态写入共用事务 */
+ async saveHiddenConversationRecord(
+ type: number,
+ targetId: number,
+ tx?: DbTransaction,
+ db: DbClient = getDb()
+ ): Promise {
+ const clientConversationId = getClientConversationId(type, targetId)
+ const current = this.getConversation(type, targetId)
+ const stored = current
+ ? undefined
+ : await db.get('conversations', clientConversationId, tx)
+ const conversation = current || (stored ? fromConversationDO(stored) : undefined)
if (!conversation) {
- return;
+ return undefined
}
+ const projection = { ...conversation, deleted: true, draft: undefined }
+ await this.saveConversationRecord(projection, tx, db)
+ return projection
+ },
+
+ /** 发布已成功持久化的隐藏会话投影 */
+ publishHiddenConversationProjection(projection: Conversation): void {
+ const conversation = this.getConversation(projection.type, projection.targetId)
+ if (!conversation) {
+ return
+ }
+ pendingDraftConversations.delete(conversation)
if (this.activeConversation === conversation) {
- this.activeConversation = null;
- this.activeMentionMessageId = undefined;
+ this.activeConversation = null
+ this.activeMentionMessageId = undefined
+ }
+ Object.assign(conversation, projection)
+ },
+
+ /** 隐藏会话但保留消息;用于退群、被踢和群解散终态 */
+ async hideConversationNow(type: number, targetId: number, db: DbClient) {
+ const projection = await this.saveHiddenConversationRecord(type, targetId, undefined, db)
+ if (projection) {
+ this.publishHiddenConversationProjection(projection)
}
- conversation.deleted = true;
- // 2. 删除会话关联的消息和草稿
- useMessageStore().deleteConversationMessageList(type, targetId);
- this.clearConversationDraft(conversation);
- this.saveConversation(conversation);
},
/** 删除私聊会话 */
- removePrivateConversation(friendId: number) {
- this.removeConversation(ImConversationType.PRIVATE, friendId);
- },
-
- /** 删除群聊会话 */
- removeGroupConversation(groupId: number) {
- this.removeConversation(ImConversationType.GROUP, groupId);
+ removePrivateConversation(friendId: number, db: DbClient = getDb()) {
+ return enqueueConversationWrite(
+ getClientConversationId(ImConversationType.PRIVATE, friendId),
+ () => this.removeConversationNow(ImConversationType.PRIVATE, friendId, db)
+ )
},
/** 标记会话已读 */
@@ -926,55 +929,55 @@ export const useConversationStore = defineStore('imConversationStore', {
type: number,
targetId: number,
messageId?: number,
+ db: DbClient = getDb()
): void {
- const conversation = this.getConversation(type, targetId);
+ void enqueueConversationWrite(getClientConversationId(type, targetId), () =>
+ this.markConversationReadNow(type, targetId, messageId, db)
+ ).catch((e) => console.warn('[IM conversationStore] 会话已读写入失败', e))
+ },
+
+ /** 实际标记会话已读;调用方必须持有当前会话写 lane */
+ async markConversationReadNow(
+ type: number,
+ targetId: number,
+ messageId: number | undefined,
+ db: DbClient
+ ) {
+ const conversation = this.getConversation(type, targetId)
if (!conversation) {
- return;
+ return
}
- const key = getClientConversationId(type, targetId);
- const current = this.conversationReads[key];
- const readMessageIdAdvanced =
- !!messageId && messageId > (current?.messageId || 0);
+ const key = getClientConversationId(type, targetId)
+ const current = this.conversationReads[key]
+ const readMessageIdAdvanced = !!messageId && messageId > (current?.messageId || 0)
if (
conversation.unreadCount === 0 &&
!conversation.atMe &&
!conversation.atAll &&
!readMessageIdAdvanced
) {
- return;
+ return
+ }
+ const nextConversation = {
+ ...conversation,
+ unreadCount: 0,
+ atMe: false,
+ atAll: false,
+ atMessageId: undefined,
+ atAllMessageId: undefined
}
- conversation.unreadCount = 0;
- conversation.atMe = false;
- conversation.atAll = false;
- conversation.atMessageId = undefined;
- conversation.atAllMessageId = undefined;
if (readMessageIdAdvanced) {
- const record = createConversationRead(type, targetId, messageId);
- this.conversationReads[key] = record;
- void getDb()
- .transaction(
- ['conversations', 'conversationReads'],
- 'readwrite',
- async (tx) => {
- await this.saveConversationRecord(conversation, tx);
- await this.saveConversationReadRecord(record, tx);
- },
- )
- .catch((error) =>
- console.warn(
- '[IM conversationStore] 会话已读写入失败',
- {
- conversationType: type,
- targetId,
- messageId,
- conversationKey: key,
- },
- error,
- ),
- );
- return;
+ const record = createConversationRead(type, targetId, messageId)
+ await db.transaction(['conversations', 'conversationReads'], 'readwrite', async (tx) => {
+ await this.saveConversationRecord(nextConversation, tx, db)
+ await this.saveConversationReadRecord(record, tx, db)
+ })
+ this.publishConversationProjection(nextConversation, true)
+ this.conversationReads[key] = record
+ return
}
- this.saveConversation(conversation);
+ await this.saveConversationRecord(nextConversation, undefined, db)
+ this.publishConversationProjection(nextConversation, true)
},
/** 标记会话已上报服务端读位置 */
@@ -982,19 +985,30 @@ export const useConversationStore = defineStore('imConversationStore', {
type: number,
targetId: number,
messageId?: number,
+ db: DbClient = getDb()
): void {
if (!messageId) {
- return;
+ return
}
- const conversation = this.getConversation(type, targetId);
- if (
- !conversation ||
- messageId <= (conversation.reportedReadMessageId || 0)
- ) {
- return;
+ void enqueueConversationWrite(getClientConversationId(type, targetId), () =>
+ this.markConversationReadReportedNow(type, targetId, messageId, db)
+ ).catch((e) => console.warn('[IM conversationStore] 已上报读位置写入失败', e))
+ },
+
+ /** 实际记录已上报读位置;调用方必须持有当前会话写 lane */
+ async markConversationReadReportedNow(
+ type: number,
+ targetId: number,
+ messageId: number,
+ db: DbClient
+ ) {
+ const conversation = this.getConversation(type, targetId)
+ if (!conversation || messageId <= (conversation.reportedReadMessageId || 0)) {
+ return
}
- conversation.reportedReadMessageId = messageId;
- this.saveConversation(conversation);
+ const nextConversation = { ...conversation, reportedReadMessageId: messageId }
+ await this.saveConversationRecord(nextConversation, undefined, db)
+ this.publishConversationProjection(nextConversation, true)
},
// ==================== 最近转发 ====================
@@ -1002,24 +1016,24 @@ export const useConversationStore = defineStore('imConversationStore', {
/** 推送最近转发会话 */
pushRecentForwardConversationKeyList(keys: string[]) {
if (!keys || keys.length === 0) {
- return;
+ return
}
- const merged = [...keys, ...this.recentForwardConversationKeys];
- this.recentForwardConversationKeys = [...new Set(merged)].slice(
+ const merged = [...keys, ...this.recentForwardConversationKeys]
+ this.recentForwardConversationKeys = Array.from(new Set(merged)).slice(
0,
- CONVERSATION_RECENT_FORWARD_MAX,
- );
- this.saveRecentForwardConversationKeyList();
+ CONVERSATION_RECENT_FORWARD_MAX
+ )
+ this.saveRecentForwardConversationKeyList()
},
/** 移除最近转发会话 */
removeRecentForwardConversationKey(key: string) {
- const index = this.recentForwardConversationKeys.indexOf(key);
- if (index === -1) {
- return;
+ const index = this.recentForwardConversationKeys.indexOf(key)
+ if (index < 0) {
+ return
}
- this.recentForwardConversationKeys.splice(index, 1);
- this.saveRecentForwardConversationKeyList();
+ this.recentForwardConversationKeys.splice(index, 1)
+ this.saveRecentForwardConversationKeyList()
},
/** 保存最近转发会话 */
@@ -1027,24 +1041,19 @@ export const useConversationStore = defineStore('imConversationStore', {
void getDb()
.setSetting(
StorageKeys.settings.recentForwardConversationKeys,
- this.recentForwardConversationKeys.slice(
- 0,
- CONVERSATION_RECENT_FORWARD_MAX,
- ),
+ this.recentForwardConversationKeys.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
)
- .catch((error) =>
- console.warn('[IM conversationStore] 最近转发列表写入失败', error),
- );
+ .catch((e) => console.warn('[IM conversationStore] 最近转发列表写入失败', e))
},
// ==================== 会话维护 ====================
/** 重排会话 */
sortConversationList() {
- this.conversations.sort(
- (a, b) => (b.lastSendTime || 0) - (a.lastSendTime || 0),
- );
- this.saveConversationList(this.conversations);
+ this.conversations.sort((a, b) => (b.lastSendTime || 0) - (a.lastSendTime || 0))
+ if (!this.loading) {
+ void this.saveConversationList(this.conversations)
+ }
},
/** 同步会话展示元数据 */
@@ -1052,26 +1061,27 @@ export const useConversationStore = defineStore('imConversationStore', {
type: number,
targetId: number,
info: { avatar?: string; name?: string; silent?: boolean },
+ db: DbClient = getDb()
) {
- const conversation = this.getConversation(type, targetId);
+ const conversation = this.getConversation(type, targetId)
if (!conversation) {
- return;
+ return
}
- let changed = false;
+ let changed = false
if (info.name && conversation.name !== info.name) {
- conversation.name = info.name;
- changed = true;
+ conversation.name = info.name
+ changed = true
}
if (info.avatar !== undefined && conversation.avatar !== info.avatar) {
- conversation.avatar = info.avatar || '';
- changed = true;
+ conversation.avatar = info.avatar || ''
+ changed = true
}
if (info.silent !== undefined && conversation.silent !== info.silent) {
- conversation.silent = info.silent;
- changed = true;
+ conversation.silent = info.silent
+ changed = true
}
if (changed) {
- this.saveConversation(conversation);
+ this.saveConversation(conversation, undefined, db)
}
},
@@ -1079,111 +1089,92 @@ export const useConversationStore = defineStore('imConversationStore', {
/** 获取草稿 */
getConversationDraft(conversation: {
- targetId: number;
- type: number;
+ targetId: number
+ type: number
}): Conversation['draft'] | undefined {
- return this.getConversation(conversation.type, conversation.targetId)
- ?.draft;
+ return this.getConversation(conversation.type, conversation.targetId)?.draft
},
/** 设置草稿 */
setConversationDraft(
- conversation: { targetId: number; type: number },
- snapshot: NonNullable,
+ conversation: { targetId: number; type: number; },
+ snapshot: NonNullable
): void {
if (!snapshot.plain.trim() && !snapshot.reply) {
- this.clearConversationDraft(conversation);
- return;
+ this.clearConversationDraft(conversation)
+ return
}
- const target = this.getConversation(
- conversation.type,
- conversation.targetId,
- );
+ const target = this.getConversation(conversation.type, conversation.targetId)
if (!target) {
- return;
+ return
}
- target.draft = snapshot;
- this.scheduleConversationDraftSave(target);
+ target.draft = snapshot
+ this.scheduleConversationDraftSave(target)
},
/** 清除草稿 */
- clearConversationDraft(conversation: {
- targetId: number;
- type: number;
- }): void {
- const target = this.getConversation(
- conversation.type,
- conversation.targetId,
- );
+ clearConversationDraft(conversation: { targetId: number; type: number; }): void {
+ const target = this.getConversation(conversation.type, conversation.targetId)
if (!target?.draft) {
- return;
+ return
}
- target.draft = undefined;
- this.scheduleConversationDraftSave(target);
+ target.draft = undefined
+ this.scheduleConversationDraftSave(target)
},
/** 设置回复草稿 */
setConversationReplyDraft(
- conversation: { targetId: number; type: number },
- quote: NonNullable['reply'],
+ conversation: { targetId: number; type: number; },
+ quote: NonNullable['reply']
) {
if (!quote) {
- return;
+ return
}
- const existing = this.getConversationDraft(conversation);
+ const existing = this.getConversationDraft(conversation)
this.setConversationDraft(conversation, {
html: existing?.html ?? '',
plain: existing?.plain ?? '',
- reply: quote,
- });
+ reply: quote
+ })
},
/** 清除回复草稿 */
- clearConversationReplyDraft(conversation: {
- targetId: number;
- type: number;
- }): void {
- const existing = this.getConversationDraft(conversation);
+ clearConversationReplyDraft(conversation: { targetId: number; type: number; }): void {
+ const existing = this.getConversationDraft(conversation)
if (!existing?.reply) {
- return;
+ return
}
- this.setConversationDraft(conversation, {
- ...existing,
- reply: undefined,
- });
+ this.setConversationDraft(conversation, { ...existing, reply: undefined })
},
/** 调度草稿保存 */
scheduleConversationDraftSave(conversation: Conversation): void {
- pendingDraftConversations.add(conversation);
- saveDraftConversationListDebounced();
+ pendingDraftConversations.set(conversation, getDb())
+ saveDraftConversationListDebounced()
},
/** 立即保存草稿 */
- flushConversationDraftSave(): void {
- saveDraftConversationListDebounced.flush();
- },
- },
-});
-
-export const useConversationStoreWithOut = () => useConversationStore();
+ flushConversationDraftSave(): Promise {
+ return saveDraftConversationListDebounced.flush() ?? Promise.resolve()
+ }
+ }
+})
/** 合并草稿写入 */
-const saveDraftConversationListDebounced = createDraftDebounce(() => {
- const conversations = [...pendingDraftConversations];
- pendingDraftConversations.clear();
+const saveDraftConversationListDebounced = debounce(async (): Promise => {
+ const conversations = Array.from(pendingDraftConversations.entries())
+ pendingDraftConversations.clear()
if (conversations.length === 0) {
- return;
+ return
}
- void useConversationStoreWithOut()
- .saveConversationRecord(conversations)
- .catch((error) =>
- console.warn('[IM conversationStore] 草稿写入失败', error),
- );
-}, PERSIST_DRAFT_DEBOUNCE_MS);
+ const conversationStore = useConversationStore()
+ await Promise.all(
+ conversations.map(([conversation, db]) =>
+ conversationStore.saveConversationList([conversation], undefined, db)
+ )
+ )
+}, PERSIST_DRAFT_DEBOUNCE_MS)
if (import.meta.hot) {
- import.meta.hot.accept(
- acceptHMRUpdate(useConversationStore, import.meta.hot),
- );
+ import.meta.hot.accept(acceptHMRUpdate(useConversationStore, import.meta.hot))
}
diff --git a/apps/web-antd/src/views/im/home/store/faceStore.ts b/apps/web-antd/src/views/im/home/store/faceStore.ts
index b27040a56..fd8ffa149 100644
--- a/apps/web-antd/src/views/im/home/store/faceStore.ts
+++ b/apps/web-antd/src/views/im/home/store/faceStore.ts
@@ -1,16 +1,26 @@
-import type { ImFacePackApi } from '#/api/im/face/pack';
-import type { ImFaceUserItemApi } from '#/api/im/face/userItem';
+import type { ImFacePackApi } from '#/api/im/face/pack'
+import type { ImFaceUserItemApi } from '#/api/im/face/userItem'
-import { ref } from 'vue';
+import { ref } from 'vue'
-import { acceptHMRUpdate, defineStore } from 'pinia';
+import { acceptHMRUpdate, defineStore } from 'pinia'
-import { getFacePackList as apiGetFacePackList } from '#/api/im/face/pack';
+import { getFacePackList as apiGetFacePackList } from '#/api/im/face/pack'
import {
createFaceUserItem as apiCreateFaceUserItem,
deleteFaceUserItem as apiDeleteFaceUserItem,
- getFaceUserItemList as apiGetFaceUserItemList,
-} from '#/api/im/face/userItem';
+ getFaceUserItemList as apiGetFaceUserItemList
+} from '#/api/im/face/userItem'
+
+import {
+ ResourceRequestKey,
+ ResourceRequestMode,
+ runResourceRequest
+} from '../../utils/resourceRequest'
+
+type ImFacePackUserVO = ImFacePackApi.FacePackUser
+type ImFaceUserItemVO = ImFaceUserItemApi.FaceUserItem
+type ImFaceUserItemSaveReqVO = ImFaceUserItemApi.FaceUserItemSaveReqVO
/**
* IM 表情面板数据 store(系统表情包 + 个人表情)
@@ -21,64 +31,32 @@ import {
*/
export const useFaceStore = defineStore('imFace', () => {
/** 系统表情包列表(含每个包的 items);运营管理后台维护 */
- const facePacks = ref([]);
+ const facePacks = ref([])
/** 个人表情包列表(用户长按「添加到表情」/ 上传产生) */
- const faceUserItems = ref([]);
+ const faceUserItems = ref([])
- /** clear() 时递增;旧账号请求返回后不写入新账号内存 */
- let storeEpoch = 0;
-
- /**
- * 系统表情包拉取 promise;ensureFacePackList 内 cache:
- * - null = 还没拉过,下次调用真发请求
- * - resolve 后保留对象 = 后续调用 await 立即返回,不再发请求
- * - reject 后置回 null,让调用方下次重试
- */
- let facePacksPromise: null | Promise = null;
/** 按需拉取系统表情包(已拉过则直接复用 cached promise) */
async function ensureFacePackList(): Promise {
- if (!facePacksPromise) {
- const requestEpoch = storeEpoch;
- facePacksPromise = apiGetFacePackList()
- .then((data) => {
- if (requestEpoch !== storeEpoch) {
- return;
- }
- facePacks.value = data || [];
- })
- .catch((error) => {
- console.warn('[IM] 拉取表情包失败', error);
- if (requestEpoch === storeEpoch) {
- facePacksPromise = null;
- }
- throw error;
- });
- }
- return facePacksPromise;
+ await runResourceRequest(
+ ResourceRequestKey.FACE_PACKS,
+ async () => {
+ const data = await apiGetFacePackList()
+ facePacks.value = data || []
+ },
+ { mode: ResourceRequestMode.CACHE_SUCCESS }
+ )
}
- /** 个人表情拉取 promise;语义同上 */
- let faceUserItemsPromise: null | Promise = null;
/** 按需拉取个人表情(已拉过则直接复用 cached promise) */
async function ensureFaceUserItemList(): Promise {
- if (!faceUserItemsPromise) {
- const requestEpoch = storeEpoch;
- faceUserItemsPromise = apiGetFaceUserItemList()
- .then((data) => {
- if (requestEpoch !== storeEpoch) {
- return;
- }
- faceUserItems.value = data || [];
- })
- .catch((error) => {
- console.warn('[IM] 拉取个人表情失败', error);
- if (requestEpoch === storeEpoch) {
- faceUserItemsPromise = null;
- }
- throw error;
- });
- }
- return faceUserItemsPromise;
+ await runResourceRequest(
+ ResourceRequestKey.FACE_USER_ITEMS,
+ async () => {
+ const data = await apiGetFaceUserItemList()
+ faceUserItems.value = data || []
+ },
+ { mode: ResourceRequestMode.CACHE_SUCCESS }
+ )
}
/**
@@ -86,57 +64,45 @@ export const useFaceStore = defineStore('imFace', () => {
*
* 来源:1. 用户在表情面板「+」上传图片 2. 长按消息「添加到表情」
*/
- async function addFaceUserItem(
- reqVO: ImFaceUserItemApi.FaceUserItemSaveReqVO,
- ): Promise {
- const requestEpoch = storeEpoch;
- const id = await apiCreateFaceUserItem(reqVO);
+ async function addFaceUserItem(reqVO: ImFaceUserItemSaveReqVO): Promise {
+ await ensureFaceUserItemList().catch((error) => {
+ console.warn('[IM] 个人表情列表初始化失败,继续添加', error)
+ })
+ const id = await apiCreateFaceUserItem(reqVO)
if (!id) {
- return false;
+ return false
}
- // 已切账号时跳过旧请求结果
- if (requestEpoch !== storeEpoch) {
- return false;
- }
- // id 不在缓存里才插入;服务端唯一约束兜底了 race,本地理论上不会拿到重复 id
if (!faceUserItems.value.some((item) => item.id === id)) {
faceUserItems.value.unshift({
id,
url: reqVO.url,
name: reqVO.name,
width: reqVO.width,
- height: reqVO.height,
- });
+ height: reqVO.height
+ })
}
- return true;
+ return true
}
/** 删除个人表情;本地立即移除 */
async function removeFaceUserItem(id: number): Promise {
- const requestEpoch = storeEpoch;
+ await ensureFaceUserItemList().catch((error) => {
+ console.warn('[IM] 个人表情列表初始化失败,继续删除', error)
+ })
try {
- await apiDeleteFaceUserItem(id);
- // 已切账号时跳过旧请求结果
- if (requestEpoch !== storeEpoch) {
- return false;
- }
- faceUserItems.value = faceUserItems.value.filter(
- (item) => item.id !== id,
- );
- return true;
- } catch (error) {
- console.warn('[IM] 删除个人表情失败', { id }, error);
- return false;
+ await apiDeleteFaceUserItem(id)
+ faceUserItems.value = faceUserItems.value.filter((item) => item.id !== id)
+ return true
+ } catch (e) {
+ console.warn('[IM] 删除个人表情失败', { id }, e)
+ return false
}
}
/** 清空表情缓存 */
function clear(): void {
- facePacks.value = [];
- faceUserItems.value = [];
- facePacksPromise = null;
- faceUserItemsPromise = null;
- storeEpoch++;
+ facePacks.value = []
+ faceUserItems.value = []
}
return {
@@ -146,13 +112,10 @@ export const useFaceStore = defineStore('imFace', () => {
ensureFaceUserItemList,
addFaceUserItem,
removeFaceUserItem,
- clear,
- };
-});
-
-/** 在 setup 外(路由守卫等)取 store 实例的工具方法 */
-export const useFaceStoreWithOut = () => useFaceStore();
+ clear
+ }
+})
if (import.meta.hot) {
- import.meta.hot.accept(acceptHMRUpdate(useFaceStore, import.meta.hot));
+ import.meta.hot.accept(acceptHMRUpdate(useFaceStore, import.meta.hot))
}
diff --git a/apps/web-antd/src/views/im/home/store/friendStore.ts b/apps/web-antd/src/views/im/home/store/friendStore.ts
index dd02b2158..e8fd1e4d4 100644
--- a/apps/web-antd/src/views/im/home/store/friendStore.ts
+++ b/apps/web-antd/src/views/im/home/store/friendStore.ts
@@ -1,11 +1,11 @@
-import type { Friend, FriendLite, FriendRequest } from '../types';
+import type { Friend, FriendDO, FriendLite, FriendRequest, FriendRequestDO } from '../types'
-import type { ImFriendApi } from '#/api/im/friend';
-import type { ImFriendRequestApi } from '#/api/im/friend/request';
+import type { ImFriendApi } from '#/api/im/friend'
+import type { ImFriendRequestApi } from '#/api/im/friend/request'
-import { CommonStatusEnum } from '@vben/constants';
+import { CommonStatusEnum } from '@vben/constants'
-import { acceptHMRUpdate, defineStore } from 'pinia';
+import { acceptHMRUpdate, defineStore } from 'pinia'
import {
blockFriend as apiBlockFriend,
@@ -14,65 +14,65 @@ import {
getMyFriendList as apiGetMyFriendList,
pullMyFriendList as apiPullMyFriendList,
unblockFriend as apiUnblockFriend,
- updateFriend as apiUpdateFriend,
-} from '#/api/im/friend';
+ updateFriend as apiUpdateFriend
+} from '#/api/im/friend'
import {
agreeFriendRequest as apiAgreeFriendRequest,
applyFriendRequest as apiApplyFriendRequest,
getMyFriendRequest as apiGetMyFriendRequest,
getMyFriendRequestList as apiGetMyFriendRequestList,
pullMyFriendRequestList as apiPullMyFriendRequestList,
- refuseFriendRequest as apiRefuseFriendRequest,
-} from '#/api/im/friend/request';
-import { getCurrentUserId } from '#/views/im/utils/auth';
+ refuseFriendRequest as apiRefuseFriendRequest
+} from '#/api/im/friend/request'
+import { getCurrentUserId } from '#/views/im/utils/auth'
-import { FRIEND_REQUEST_PAGE_SIZE } from '../../utils/config';
+import { FRIEND_REQUEST_PAGE_SIZE } from '../../utils/config'
+import { ImConversationType, ImFriendRequestHandleResult } from '../../utils/constants'
+import { type DbClient, getDb, initDb, StorageKeys } from '../../utils/db'
+import { runIncrementalPull } from '../../utils/pull'
import {
- ImConversationType,
- ImFriendRequestHandleResult,
-} from '../../utils/constants';
-import { getDb, StorageKeys } from '../../utils/db';
-import { runIncrementalPull } from '../../utils/pull';
-import { getFriendDisplayName } from '../../utils/user';
-import { useConversationStore } from './conversationStore';
+ isResourceRequestPending,
+ ResourceRequestKey,
+ ResourceRequestMode,
+ runResourceRequest
+} from '../../utils/resourceRequest'
+import { getFriendDisplayName } from '../../utils/user'
+import { useConversationStore } from './conversationStore'
-type PendingRequest = { epoch: number; promise: Promise; userId: number };
+type ImFriendRespVO = ImFriendApi.FriendRespVO
+type ImFriendRequestApplyReqVO = ImFriendRequestApi.FriendRequestApplyReqVO
+type ImFriendRequestRespVO = ImFriendRequestApi.FriendRequestRespVO
-/** 当前正在进行的好友列表拉取;多 dispatcher 同时触发时复用同一 Promise,避免雪崩重拉 */
-let pendingFetchFriends: null | PendingRequest = null;
-/** 当前正在进行的好友申请列表拉取;多端连续多条申请到达时复用同一 Promise,避免雪崩重拉 */
-let pendingFetchRequests: null | PendingRequest = null;
-/** 当前正在进行的「加载更多申请」请求 */
-let pendingLoadMoreRequests: null | PendingRequest = null;
-/** 当前正在进行的好友详情请求 */
-const pendingFetchFriendInfos = new Map>();
-
-/** clear() 时递增;旧账号那次还没返回的请求 resolve 后比对一致才写 store,防跨账号数据泄漏 */
-let storeEpoch = 0;
-
-/** 构建好友详情请求去重 key */
-function getPendingFriendInfoKey(userId: number, friendUserId: number): string {
- return `${userId}:${friendUserId}`;
+/** 在好友列表拉取期间合并一次关系事件尾随刷新 */
+function queueFriendListRefreshAfterPending(fetch: () => Promise): void {
+ if (isResourceRequestPending(ResourceRequestKey.FRIEND_LIST)) {
+ void fetch().catch(() => undefined)
+ }
}
+/** 当前好友申请分页任务;首页和加载更多互斥执行 */
+let requestTask: null | Promise = null
+/** 当前正在进行的好友详情请求 */
+const pendingFetchFriendInfos = new Map>()
+
/** 好友通知 payload(对齐后端 BaseFriendNotification + 子类裁减后的字段) */
export interface FriendNotificationPayload {
- operatorUserId: number;
- friendUserId: number;
+ operatorUserId: number
+ friendUserId: number
// FRIEND_REQUEST_* 系列:申请记录的核心字段(避免 payload 携带完整 DO)
- requestId?: number;
- applyContent?: string;
- handleContent?: string;
- addSource?: number;
+ requestId?: number
+ applyContent?: string
+ handleContent?: string
+ addSource?: number
// FRIEND_REQUEST_RECEIVED:申请方聚合字段,供前端直推 push 进列表,无需回拉
- fromNickname?: string;
- fromAvatar?: string;
+ fromNickname?: string
+ fromAvatar?: string
// FRIEND_UPDATE:单边属性变更
- displayName?: string;
- silent?: boolean;
- pinned?: boolean;
+ displayName?: string
+ silent?: boolean
+ pinned?: boolean
// FRIEND_DELETE:是否级联清理本端相关数据(如私聊会话)
- clear?: boolean;
+ clear?: boolean
}
/**
@@ -91,7 +91,7 @@ export const useFriendStore = defineStore('imFriendStore', {
/** 我相关的好友申请列表(含我发起的 + 别人加我的;后端按 id 倒序游标分页) */
friendRequests: [] as FriendRequest[],
/** 是否还有更早的申请记录可加载;返回不满 page size 即置 false */
- hasMoreFriendRequests: true,
+ hasMoreFriendRequests: true
}),
getters: {
@@ -102,21 +102,19 @@ export const useFriendStore = defineStore('imFriendStore', {
* 直接 find 时 N 条消息 × M 好友 = O(N×M);建索引后单次读 O(1),重建只在写好友(fetchFriendList / upsertFriend 等)时发生
*/
getFriendMap: (state): Map => {
- const map = new Map();
+ const map = new Map()
for (const friend of state.friends) {
- map.set(friend.friendUserId, friend);
+ map.set(friend.friendUserId, friend)
}
- return map;
+ return map
},
/** 按 friendUserId 找好友(含已软删的 DISABLE 记录,调用方自行判定) */
getFriend(): (friendUserId: number) => Friend | undefined {
- return (friendUserId: number) => this.getFriendMap.get(friendUserId);
+ return (friendUserId: number) => this.getFriendMap.get(friendUserId)
},
/** 当前生效的好友列表(过滤掉 DISABLE 软删记录) */
getActiveFriendList: (state): Friend[] => {
- return state.friends.filter(
- (friend) => friend.status !== CommonStatusEnum.DISABLE,
- );
+ return state.friends.filter((friend) => friend.status !== CommonStatusEnum.DISABLE)
},
/** 当前生效好友的 Lite 视图(PickerPanel / 选人弹窗共用,自带拼音字段供分桶 / 搜索) */
getActiveFriendLiteList(): FriendLite[] {
@@ -126,32 +124,25 @@ export const useFriendStore = defineStore('imFriendStore', {
nicknamePinyin: friend.nicknamePinyin,
avatar: friend.avatar,
displayName: friend.displayName,
- displayNamePinyin: friend.displayNamePinyin,
- }));
+ displayNamePinyin: friend.displayNamePinyin
+ }))
},
/** 判断对方是否是当前用户的有效好友(存在 + 非 DISABLE) */
isActiveFriend() {
return (friendUserId: number): boolean => {
- const entry = this.getFriend(friendUserId);
- return !!entry && entry.status !== CommonStatusEnum.DISABLE;
- };
- },
- /** 我的黑名单(blocked=true 且 ENABLE) */
- getBlockedFriendList: (state): Friend[] => {
- return state.friends.filter(
- (friend) =>
- friend.status !== CommonStatusEnum.DISABLE && friend.blocked === true,
- );
+ const entry = this.getFriend(friendUserId)
+ return !!entry && entry.status !== CommonStatusEnum.DISABLE
+ }
},
/** 未处理申请数(接收方=我)—— 实时派生,「新的朋友」红点用 */
getUnhandledRequestCount: (state): number => {
- const currentUserId = getCurrentUserId();
+ const currentUserId = getCurrentUserId()
return state.friendRequests.filter(
(request) =>
request.handleResult === ImFriendRequestHandleResult.UNHANDLED &&
- request.toUserId === currentUserId,
- ).length;
- },
+ request.toUserId === currentUserId
+ ).length
+ }
},
actions: {
@@ -161,88 +152,81 @@ export const useFriendStore = defineStore('imFriendStore', {
async loadFriendData(): Promise {
try {
const [friends, friendRequests] = await Promise.all([
- getDb().getAll('friends'),
- getDb().getAll('friendRequests'),
- ]);
+ getDb().getAll('friends'),
+ getDb().getAll('friendRequests')
+ ])
if (friends.length > 0) {
- this.friends = friends;
+ this.friends = friends
}
if (friendRequests.length > 0) {
- this.friendRequests = friendRequests.toSorted(
- (requestA, requestB) => requestB.id - requestA.id,
- );
- this.hasMoreFriendRequests =
- friendRequests.length >= FRIEND_REQUEST_PAGE_SIZE;
+ this.friendRequests = friendRequests.sort(
+ (requestA, requestB) => requestB.id - requestA.id
+ )
+ this.hasMoreFriendRequests = friendRequests.length >= FRIEND_REQUEST_PAGE_SIZE
}
- return friends.length > 0;
- } catch (error) {
- console.warn('[IM friendStore] 本地好友缓存读取失败', error);
- return false;
+ return friends.length > 0
+ } catch (e) {
+ console.warn('[IM friendStore] 本地好友缓存读取失败', e)
+ return false
}
},
/** 保存好友列表 */
- saveFriendList(): void {
- void getDb()
- .transaction(['friends'], 'readwrite', async (tx) => {
- const db = getDb();
- await db.clearStore('friends', tx);
- for (const friend of this.friends) {
- if (friend.id) {
- await db.put('friends', friend, tx);
- }
+ async saveFriendList(friends: Friend[], db: DbClient = getDb()): Promise {
+ await db.transaction(['friends'], 'readwrite', async (tx) => {
+ await db.clearStore('friends', tx)
+ for (const friend of friends) {
+ if (friend.id) {
+ await db.put('friends', friend, tx)
}
- })
- .catch((error) =>
- console.warn('[IM friendStore] 本地好友缓存写入失败', error),
- );
+ }
+ })
},
/** 保存单个好友 */
- async saveFriendRecord(friend: Friend | undefined): Promise {
+ async saveFriendRecord(friend: Friend | undefined, db: DbClient = getDb()): Promise {
if (!friend?.id) {
- return;
+ return
}
- await getDb().put('friends', friend);
+ await db.put('friends', friend)
},
/** 保存单个好友 */
- saveFriend(friend: Friend | undefined): void {
- void this.saveFriendRecord(friend).catch((error) =>
- console.warn('[IM friendStore] 本地好友写入失败', error),
- );
+ saveFriend(friend: Friend | undefined, db: DbClient = getDb()): void {
+ void this.saveFriendRecord(friend, db).catch((e) =>
+ console.warn('[IM friendStore] 本地好友写入失败', e)
+ )
},
/** 保存好友申请列表 */
- saveFriendRequestList(): void {
- void getDb()
+ saveFriendRequestList(requests?: FriendRequest[], db: DbClient = getDb()): void {
+ const snapshot = requests ?? [...this.friendRequests]
+ void db
.transaction(['friendRequests'], 'readwrite', async (tx) => {
- const db = getDb();
- await db.clearStore('friendRequests', tx);
- for (const request of this.friendRequests) {
- await db.put('friendRequests', request, tx);
+ await db.clearStore('friendRequests', tx)
+ for (const request of snapshot) {
+ await db.put('friendRequests', request, tx)
}
})
- .catch((error) =>
- console.warn('[IM friendStore] 本地好友申请缓存写入失败', error),
- );
+ .catch((e) => console.warn('[IM friendStore] 本地好友申请缓存写入失败', e))
},
/** 保存单条好友申请 */
async saveFriendRequestRecord(
request: FriendRequest | undefined,
+ db: DbClient = getDb()
): Promise {
if (!request) {
- return;
+ return
}
- await getDb().put('friendRequests', request);
+ await db.put('friendRequests', request)
},
/** 保存单条好友申请 */
- saveFriendRequest(request: FriendRequest | undefined): void {
- void this.saveFriendRequestRecord(request).catch((error) =>
- console.warn('[IM friendStore] 本地好友申请写入失败', error),
- );
+ saveFriendRequest(request: FriendRequest | undefined, db: DbClient = getDb()): void {
+ void this.saveFriendRequestRecord(request, db).catch((e) =>
+ console.warn('[IM friendStore] 本地好友申请写入失败', e)
+ )
},
// ==================== 远端拉取 ====================
@@ -250,31 +234,19 @@ export const useFriendStore = defineStore('imFriendStore', {
/** 从后端拉取并覆盖本地列表(含 DISABLE 历史好友给已删对话兜底);只同步 ENABLE 的会话信息,DISABLE 的不动 —— cascade 清会话由 WS dispatcher 按 payload.clear 处理,避免 fetchFriendList 覆盖用户「不清空聊天记录」的选择 */
async fetchFriendList(force = false) {
if (this.loaded && !force) {
- return;
+ return this.friends
}
- // 快照 epoch;clear() 之后到 .then 之间触发的 epoch++ 表示账号已切,旧结果不能写入新 store
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- if (
- pendingFetchFriends?.epoch === requestEpoch &&
- pendingFetchFriends.userId === requestUserId
- ) {
- return pendingFetchFriends.promise;
- }
- const promise = apiGetMyFriendList()
- .then((list) => {
- if (
- requestEpoch !== storeEpoch ||
- getCurrentUserId() !== requestUserId
- ) {
- return;
- }
- this.friends = (list || []).map((friend) => convertFriend(friend));
- this.loaded = true;
- const conversationStore = useConversationStore();
+ return runResourceRequest(
+ ResourceRequestKey.FRIEND_LIST,
+ async () => {
+ const db = await initDb()
+ const friends = ((await apiGetMyFriendList()) || []).map(convertFriend)
+ this.friends = friends
+ this.loaded = true
+ const conversationStore = useConversationStore()
for (const friend of this.friends) {
if (friend.status === CommonStatusEnum.DISABLE) {
- continue;
+ continue
}
conversationStore.updateConversation(
ImConversationType.PRIVATE,
@@ -282,26 +254,18 @@ export const useFriendStore = defineStore('imFriendStore', {
{
name: getFriendDisplayName(friend),
avatar: friend.avatar,
- silent: friend.silent,
+ silent: friend.silent
},
- );
+ db
+ )
}
- this.saveFriendList();
- })
- .finally(() => {
- if (
- pendingFetchFriends?.epoch === requestEpoch &&
- pendingFetchFriends.userId === requestUserId
- ) {
- pendingFetchFriends = null;
- }
- });
- pendingFetchFriends = {
- epoch: requestEpoch,
- userId: requestUserId,
- promise,
- };
- return promise;
+ await this.saveFriendList(friends, db).catch((e) =>
+ console.warn('[IM friendStore] 本地好友缓存写入失败', e)
+ )
+ return friends
+ },
+ { mode: ResourceRequestMode.SINGLE_FLIGHT, refreshAfterPending: force }
+ )
},
/**
@@ -310,95 +274,70 @@ export const useFriendStore = defineStore('imFriendStore', {
* 含已删除好友,按 status 走 upsert
*/
async pullFriends() {
- // 快照 epoch;账号在拉取途中切换(clear() → epoch++)时丢弃旧账号那几页结果,防跨账号数据泄漏
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- const isActive = () =>
- requestEpoch === storeEpoch && getCurrentUserId() === requestUserId;
+ const db = await initDb()
await runIncrementalPull(
+ db,
StorageKeys.settings.friendPullCursor,
apiPullMyFriendList,
async (records) => {
- if (!isActive()) {
- return false;
- }
- await Promise.all(
- records.map((vo) => this.upsertFriendForPull(convertFriend(vo))),
- );
- return true;
- },
- isActive,
- );
+ await Promise.all(records.map((vo) => this.upsertFriendForPull(convertFriend(vo), db)))
+ return true
+ }
+ )
// 置 loaded,供通讯录页 fetchFriendList(force=false) 复用缓存而非重复全量拉
- if (isActive()) {
- this.loaded = true;
- }
+ this.loaded = true
},
/** 按 friendUserId 获取详情并合并到本地(保证 nickname / avatar 最新) */
async fetchFriendInfo(friendUserId: number) {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- if (!requestUserId) {
- return;
- }
- const key = getPendingFriendInfoKey(requestUserId, friendUserId);
- const inflight = pendingFetchFriendInfos.get(key);
+ const inflight = pendingFetchFriendInfos.get(friendUserId)
if (inflight) {
- return inflight;
+ return inflight
}
const promise = (async () => {
try {
- const data = await apiGetFriend(friendUserId);
+ const db = await initDb()
+ const data = await apiGetFriend(friendUserId)
if (!data) {
- return;
+ return
}
- // clear() 已切账号:旧请求的好友详情不能再 upsert 进新账号的 friends
- if (
- requestEpoch !== storeEpoch ||
- getCurrentUserId() !== requestUserId
- ) {
- return;
- }
- this.upsertFriend(convertFriend(data));
- } catch (error) {
- console.warn('[IM friendStore] fetchFriendInfo 失败', error);
+ await this.upsertFriendForPull(convertFriend(data), db)
+ } catch (e) {
+ console.warn('[IM friendStore] fetchFriendInfo 失败', e)
}
})().finally(() => {
- if (pendingFetchFriendInfos.get(key) === promise) {
- pendingFetchFriendInfos.delete(key);
+ if (pendingFetchFriendInfos.get(friendUserId) === promise) {
+ pendingFetchFriendInfos.delete(friendUserId)
}
- });
- pendingFetchFriendInfos.set(key, promise);
- return promise;
+ })
+ pendingFetchFriendInfos.set(friendUserId, promise)
+ return promise
},
// ==================== 申请-审批 ====================
/** 发起好友申请:成功后等待对方同意(不直接落地为好友) */
- async applyFriendRequest(
- reqVO: ImFriendRequestApi.FriendRequestApplyReqVO,
- ): Promise {
- return await apiApplyFriendRequest(reqVO);
+ async applyFriendRequest(reqVO: ImFriendRequestApplyReqVO): Promise {
+ return await apiApplyFriendRequest(reqVO)
},
/** 同意一条好友申请;后端会双向落库 + 推 FRIEND_ADD,本端等通知到达再 upsertFriend */
async agreeFriendRequest(requestId: number) {
- await apiAgreeFriendRequest(requestId);
- await this.applyHandleResult(
- requestId,
- ImFriendRequestHandleResult.AGREED,
- );
+ const db = await initDb()
+ await apiAgreeFriendRequest(requestId)
+ await this.applyHandleResult(requestId, ImFriendRequestHandleResult.AGREED, undefined, db)
},
/** 拒绝一条好友申请 */
async refuseFriendRequest(requestId: number, handleContent?: string) {
- await apiRefuseFriendRequest(requestId, handleContent);
+ const db = await initDb()
+ await apiRefuseFriendRequest(requestId, handleContent)
await this.applyHandleResult(
requestId,
ImFriendRequestHandleResult.REFUSED,
handleContent,
- );
+ db
+ )
},
/** 把 handleResult 应用到本地申请记录;找不到就按 id 单查兜底 upsert,避免破坏 id 倒序 */
@@ -406,368 +345,259 @@ export const useFriendStore = defineStore('imFriendStore', {
requestId: number,
result: number,
handleContent?: string,
+ db: DbClient = getDb()
): Promise {
- const request = this.getFriendRequest(requestId);
+ const request = this.getFriendRequest(requestId)
if (request) {
- request.handleResult = result;
+ request.handleResult = result
if (handleContent !== undefined) {
- request.handleContent = handleContent;
+ request.handleContent = handleContent
}
- request.handleTime = Date.now();
- this.saveFriendRequest(request);
- return;
+ request.handleTime = Date.now()
+ this.saveFriendRequest(request, db)
+ return
}
- await this.fetchFriendRequest(requestId);
+ await this.fetchFriendRequest(requestId, db)
},
- /** 拉取「我相关」的好友申请列表首页(页面打开 / 收到 FRIEND_REQUEST_RECEIVED 时刷新);pending 期间复用同一 Promise */
+ /** 拉取「我相关」的好友申请列表首页;pending 期间复用同一 Promise */
async fetchFriendRequestList() {
- if (pendingFetchRequests) {
- const currentUserId = getCurrentUserId();
- if (
- pendingFetchRequests.epoch === storeEpoch &&
- pendingFetchRequests.userId === currentUserId
- ) {
- return pendingFetchRequests.promise;
- }
+ if (requestTask) {
+ return requestTask
}
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- const promise = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE)
- .then((list) => {
- if (
- requestEpoch !== storeEpoch ||
- getCurrentUserId() !== requestUserId
- ) {
- return;
- }
- const items = (list || []).map((request) =>
- convertFriendRequest(request),
- );
- this.friendRequests = items;
- // 不足一页即没有更多;满页可能还有,等 loadMore 拉到 0 条再确定
- this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE;
- this.saveFriendRequestList();
- })
- .finally(() => {
- if (
- pendingFetchRequests?.epoch === requestEpoch &&
- pendingFetchRequests.userId === requestUserId
- ) {
- pendingFetchRequests = null;
- }
- });
- pendingFetchRequests = {
- epoch: requestEpoch,
- userId: requestUserId,
- promise,
- };
- return promise;
+ const promise = (async () => {
+ const db = await initDb()
+ const list = await apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE)
+ const items = (list || []).map(convertFriendRequest)
+ this.friendRequests = items.sort((left, right) => right.id - left.id)
+ // 不足一页即没有更多;满页可能还有,等 loadMore 拉到 0 条再确定
+ this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE
+ this.saveFriendRequestList(undefined, db)
+ })().finally(() => {
+ if (requestTask === promise) {
+ requestTask = null
+ }
+ })
+ requestTask = promise
+ return promise
},
/** 加载更多申请(按本地最旧 requestId 游标分页);无更多 / pending 中直接返回 */
async loadMoreFriendRequestList() {
- const requestUserId = getCurrentUserId();
- const hasSameFetchPending =
- pendingFetchRequests?.epoch === storeEpoch &&
- pendingFetchRequests.userId === requestUserId;
- if (!this.hasMoreFriendRequests || hasSameFetchPending) {
- return;
+ if (!this.hasMoreFriendRequests) {
+ return
}
- if (
- pendingLoadMoreRequests?.epoch === storeEpoch &&
- pendingLoadMoreRequests.userId === requestUserId
- ) {
- return pendingLoadMoreRequests.promise;
+ if (requestTask) {
+ return requestTask
}
- const oldest = this.friendRequests[this.friendRequests.length - 1];
+ const oldest = this.friendRequests[this.friendRequests.length - 1]
if (!oldest) {
- return this.fetchFriendRequestList();
+ return this.fetchFriendRequestList()
}
- const requestEpoch = storeEpoch;
- const promise = apiGetMyFriendRequestList(
- FRIEND_REQUEST_PAGE_SIZE,
- oldest.id,
- )
- .then((list) => {
- if (
- requestEpoch !== storeEpoch ||
- getCurrentUserId() !== requestUserId
- ) {
- return;
- }
- const items = (list || []).map((request) =>
- convertFriendRequest(request),
- );
- this.friendRequests.push(...items);
- this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE;
- this.saveFriendRequestList();
- })
- .finally(() => {
- if (
- pendingLoadMoreRequests?.epoch === requestEpoch &&
- pendingLoadMoreRequests.userId === requestUserId
- ) {
- pendingLoadMoreRequests = null;
- }
- });
- pendingLoadMoreRequests = {
- epoch: requestEpoch,
- userId: requestUserId,
- promise,
- };
- return promise;
+ const promise = (async () => {
+ const db = await initDb()
+ const list = await apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE, oldest.id)
+ const items = (list || []).map(convertFriendRequest)
+ const currentIds = new Set(this.friendRequests.map((request) => request.id))
+ const additions = items.filter((request) => !currentIds.has(request.id))
+ this.friendRequests.push(...additions)
+ this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE
+ this.saveFriendRequestList(undefined, db)
+ })().finally(() => {
+ if (requestTask === promise) {
+ requestTask = null
+ }
+ })
+ requestTask = promise
+ return promise
},
/** 按 id 查申请记录;列表是按 id 倒序的小列表,O(n) find 即可,不再维护 Map 索引 */
getFriendRequest(requestId: number): FriendRequest | undefined {
- return this.friendRequests.find((request) => request.id === requestId);
+ return this.friendRequests.find((request) => request.id === requestId)
},
/** 按 id 从后端单查并 upsert 到本地(dispatcher 兜底用,避免全量重拉);后端带越权过滤 */
- async fetchFriendRequest(requestId: number) {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- const data = await apiGetMyFriendRequest(requestId);
+ async fetchFriendRequest(requestId: number, db: DbClient = getDb()) {
+ const data = await apiGetMyFriendRequest(requestId)
if (!data) {
- return;
+ return
}
- // clear() 已切账号:旧请求的申请记录不能再写进新账号的 friendRequests
- if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
- return;
- }
- this.upsertFriendRequest(convertFriendRequest(data));
- },
-
- /** 合并单条好友申请:已有则按 id 覆盖;新记录按 id 倒序插入(比本地最旧还老则跳过,留给 loadMore 带回) */
- upsertFriendRequest(next: FriendRequest) {
- void this.upsertFriendRequestForPull(next).catch((error) =>
- console.warn('[IM friendStore] 本地好友申请写入失败', error),
- );
+ await this.upsertFriendRequestForPull(convertFriendRequest(data), db)
},
/** 合并单条好友申请 */
- async upsertFriendRequestForPull(next: FriendRequest): Promise {
- const existing = this.getFriendRequest(next.id);
+ async upsertFriendRequestForPull(next: FriendRequest, db: DbClient = getDb()): Promise {
+ const existing = this.getFriendRequest(next.id)
if (existing) {
- Object.assign(existing, next);
- await this.saveFriendRequestRecord(existing);
- return;
+ Object.assign(existing, next)
+ await this.saveFriendRequestRecord(existing, db)
+ return
}
// 比本地最旧 id 还老:不入列表,让 loadMore 自然带回,避免破坏 id 倒序 / 后续 loadMore 重复 push
- const oldest = this.friendRequests[this.friendRequests.length - 1];
+ const oldest = this.friendRequests[this.friendRequests.length - 1]
if (oldest && next.id < oldest.id) {
- return;
+ return
}
// 按 id 倒序找首个比自己小的位置插入;找不到则追加末尾
- const insertIndex = this.friendRequests.findIndex(
- (request) => request.id < next.id,
- );
- if (insertIndex === -1) {
- this.friendRequests.push(next);
+ const insertIndex = this.friendRequests.findIndex((request) => request.id < next.id)
+ if (insertIndex < 0) {
+ this.friendRequests.push(next)
} else {
- this.friendRequests.splice(insertIndex, 0, next);
+ this.friendRequests.splice(insertIndex, 0, next)
}
- await this.saveFriendRequestRecord(next);
+ await this.saveFriendRequestRecord(next, db)
},
/** 增量拉取好友申请变更并合并(重连 / 离线补偿);按 update_time + id 游标,已处理的按 handleResult 覆盖 */
async pullFriendRequests() {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- const isActive = () =>
- requestEpoch === storeEpoch && getCurrentUserId() === requestUserId;
+ const db = await initDb()
await runIncrementalPull(
+ db,
StorageKeys.settings.friendRequestPullCursor,
- apiPullMyFriendRequestList,
+ (params) => apiPullMyFriendRequestList(params),
async (records) => {
- if (!isActive()) {
- return false;
- }
await Promise.all(
- records.map((vo) =>
- this.upsertFriendRequestForPull(convertFriendRequest(vo)),
- ),
- );
- return true;
- },
- isActive,
- );
+ records.map((vo) => this.upsertFriendRequestForPull(convertFriendRequest(vo), db))
+ )
+ return true
+ }
+ )
},
// ==================== 好友关系操作 ====================
/** 删除好友(单向软删,本端置 DISABLE);clear=true 时级联清理本地相关数据(如私聊会话),并透传后端给多端同步 */
async deleteFriend(friendUserId: number, clear: boolean = true) {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- await apiDeleteFriend(friendUserId, clear);
- if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
- return;
- }
- this.removeFriend(friendUserId, clear);
+ const db = await initDb()
+ await apiDeleteFriend(friendUserId, clear)
+ this.removeFriend(friendUserId, clear, db)
},
/** 切换免打扰:同步会话的 silent 字段,避免会话列表 silent 图标等 1210 推到才更新 */
async setFriendSilent(friendUserId: number, silent: boolean) {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- await apiUpdateFriend({ friendUserId, silent });
- if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
- return;
- }
- const friend = this.getFriend(friendUserId);
+ const db = await initDb()
+ await apiUpdateFriend({ friendUserId, silent })
+ const friend = this.getFriend(friendUserId)
if (friend) {
- friend.silent = silent;
- const conversationStore = useConversationStore();
+ friend.silent = silent
+ const conversationStore = useConversationStore()
conversationStore.updateConversation(
ImConversationType.PRIVATE,
friendUserId,
{ silent },
- );
- this.saveFriend(friend);
- }
- },
-
- /** 切换联系人置顶 */
- async setFriendPinned(friendUserId: number, pinned: boolean) {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- await apiUpdateFriend({ friendUserId, pinned });
- if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
- return;
- }
- const friend = this.getFriend(friendUserId);
- if (friend) {
- friend.pinned = pinned;
- this.saveFriend(friend);
+ db
+ )
+ this.saveFriend(friend, db)
}
},
/** 拉黑好友:本端乐观更新 + 调接口;后端 FRIEND_BLOCK 推到时由 dispatcher 兜底同步多端 */
async blockFriend(friendUserId: number) {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- await apiBlockFriend(friendUserId);
- if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
- return;
- }
- const friend = this.getFriend(friendUserId);
+ const db = await initDb()
+ await apiBlockFriend(friendUserId)
+ const friend = this.getFriend(friendUserId)
if (friend) {
- friend.blocked = true;
- this.saveFriend(friend);
+ friend.blocked = true
+ this.saveFriend(friend, db)
}
},
/** 移出黑名单:本端乐观更新 + 调接口;后端 FRIEND_UNBLOCK 推到时由 dispatcher 兜底同步多端 */
async unblockFriend(friendUserId: number) {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- await apiUnblockFriend(friendUserId);
- if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
- return;
- }
- const friend = this.getFriend(friendUserId);
+ const db = await initDb()
+ await apiUnblockFriend(friendUserId)
+ const friend = this.getFriend(friendUserId)
if (friend) {
- friend.blocked = false;
- this.saveFriend(friend);
+ friend.blocked = false
+ this.saveFriend(friend, db)
}
},
/** 修改好友展示备注(仅自己可见) */
async setFriendDisplayName(friendUserId: number, displayName: string) {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- const value = displayName.trim();
+ const value = displayName.trim()
// 后端 displayName 语义:null/undefined = 不改,"" = 清空,所以这里直接传 value(可能是空串)
- await apiUpdateFriend({ friendUserId, displayName: value });
- if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
- return;
- }
- const friend = this.getFriend(friendUserId);
+ const db = await initDb()
+ await apiUpdateFriend({ friendUserId, displayName: value })
+ const friend = this.getFriend(friendUserId)
if (friend) {
- friend.displayName = value;
- const conversationStore = useConversationStore();
+ friend.displayName = value
+ const conversationStore = useConversationStore()
conversationStore.updateConversation(
ImConversationType.PRIVATE,
friendUserId,
- {
- name: getFriendDisplayName(friend),
- },
- );
- this.saveFriend(friend);
+ { name: getFriendDisplayName(friend) },
+ db
+ )
+ this.saveFriend(friend, db)
}
},
/** 本地合并 / 新增某个好友(WebSocket 事件 & 手动刷新都用) */
upsertFriend(friend: Friend) {
- void this.upsertFriendForPull(friend).catch((error) =>
- console.warn('[IM friendStore] 本地好友写入失败', error),
- );
+ void this.upsertFriendForPull(friend).catch((e) =>
+ console.warn('[IM friendStore] 本地好友写入失败', e)
+ )
},
/** 本地合并 / 新增某个好友 */
- async upsertFriendForPull(friend: Friend): Promise {
+ async upsertFriendForPull(friend: Friend, db: DbClient = getDb()): Promise {
const index = this.friends.findIndex(
- (existing) => existing.friendUserId === friend.friendUserId,
- );
- if (index === -1) {
- this.friends.push({
- ...friend,
- status: friend.status ?? CommonStatusEnum.ENABLE,
- });
- } else {
+ (existing) => existing.friendUserId === friend.friendUserId
+ )
+ if (index >= 0) {
this.friends[index] = {
...this.friends[index],
...friend,
- status: friend.status ?? CommonStatusEnum.ENABLE,
- };
+ status: friend.status ?? CommonStatusEnum.ENABLE
+ }
+ } else {
+ this.friends.push({
+ ...friend,
+ status: friend.status ?? CommonStatusEnum.ENABLE
+ })
}
- const conversationStore = useConversationStore();
- const merged = this.getFriend(friend.friendUserId);
+ const conversationStore = useConversationStore()
+ const merged = this.getFriend(friend.friendUserId)
conversationStore.updateConversation(
ImConversationType.PRIVATE,
friend.friendUserId,
{
name: merged ? getFriendDisplayName(merged) : friend.nickname,
avatar: friend.avatar,
- silent: friend.silent,
+ silent: friend.silent
},
- );
- await this.saveFriendRecord(merged);
+ db
+ )
+ await this.saveFriendRecord(merged, db)
},
/** 本地标记删除(WebSocket FRIEND_DELETE 事件触发;clear=true 时级联清相关数据如私聊会话) */
- removeFriend(friendUserId: number, clear: boolean = true) {
- const friend = this.getFriend(friendUserId);
+ removeFriend(friendUserId: number, clear: boolean = true, db: DbClient = getDb()) {
+ const friend = this.getFriend(friendUserId)
if (friend) {
// blocked 不动,跟后端 deleteFriend0「删好友期间保留拉黑状态」对齐
- friend.status = CommonStatusEnum.DISABLE;
- friend.deleteTime = Date.now();
+ friend.status = CommonStatusEnum.DISABLE
+ friend.deleteTime = Date.now()
}
if (clear) {
- const conversationStore = useConversationStore();
- conversationStore.removePrivateConversation(friendUserId);
+ const conversationStore = useConversationStore()
+ void conversationStore
+ .removePrivateConversation(friendUserId, db)
+ .catch((e) => console.warn('[IM friendStore] 私聊会话删除失败', e))
}
- this.saveFriend(friend);
+ this.saveFriend(friend, db)
},
// ==================== WebSocket 事件 dispatcher(1201-1210 段) ====================
/** FRIEND_REQUEST_RECEIVED(1203):收到新申请;payload 已带申请方昵称 / 头像,按 requestId 直推 push 进列表 */
applyFriendRequestReceivedNotification(payload: FriendNotificationPayload) {
- if (!payload.requestId) {
- return;
- }
- const currentUserId = getCurrentUserId();
- const existingIndex = this.friendRequests.findIndex(
- (item) => item.id === payload.requestId,
- );
- if (existingIndex !== -1) {
- const existing = this.friendRequests.splice(existingIndex, 1)[0];
- if (!existing) {
- return;
- }
+ const currentUserId = getCurrentUserId()
+ const existingIndex = this.friendRequests.findIndex((item) => item.id === payload.requestId)
+ if (existingIndex >= 0) {
+ const existing = this.friendRequests.splice(existingIndex, 1)[0]!
const next = {
...existing,
fromUserId: payload.operatorUserId,
@@ -777,14 +607,14 @@ export const useFriendStore = defineStore('imFriendStore', {
addSource: payload.addSource,
createTime: Date.now(),
fromNickname: payload.fromNickname,
- fromAvatar: payload.fromAvatar,
- };
- this.friendRequests.unshift(next);
- this.saveFriendRequest(next);
- return;
+ fromAvatar: payload.fromAvatar
+ }
+ this.friendRequests.unshift(next)
+ this.saveFriendRequest(next)
+ return
}
const next = {
- id: payload.requestId,
+ id: payload.requestId!,
fromUserId: payload.operatorUserId,
toUserId: currentUserId,
handleResult: ImFriendRequestHandleResult.UNHANDLED,
@@ -792,33 +622,26 @@ export const useFriendStore = defineStore('imFriendStore', {
addSource: payload.addSource,
createTime: Date.now(),
fromNickname: payload.fromNickname,
- fromAvatar: payload.fromAvatar,
- };
- this.friendRequests.unshift(next);
- this.saveFriendRequest(next);
+ fromAvatar: payload.fromAvatar
+ }
+ this.friendRequests.unshift(next)
+ this.saveFriendRequest(next)
},
/** FRIEND_REQUEST_APPROVED(1201):我的申请被同意;按 requestId 更新状态(FRIEND_ADD 会另外推) */
applyFriendRequestApprovedNotification(payload: FriendNotificationPayload) {
- if (!payload.requestId) {
- return;
- }
- void this.applyHandleResult(
- payload.requestId,
- ImFriendRequestHandleResult.AGREED,
- );
+ void this.applyHandleResult(payload.requestId!, ImFriendRequestHandleResult.AGREED).catch(
+ (e) => console.warn('[IM friendStore] 好友申请同意状态写入失败', e)
+ )
},
/** FRIEND_REQUEST_REJECTED(1202):我的申请被拒绝;按 requestId 更新状态 */
applyFriendRequestRejectedNotification(payload: FriendNotificationPayload) {
- if (!payload.requestId) {
- return;
- }
void this.applyHandleResult(
- payload.requestId,
+ payload.requestId!,
ImFriendRequestHandleResult.REFUSED,
- payload.handleContent,
- );
+ payload.handleContent
+ ).catch((e) => console.warn('[IM friendStore] 好友申请拒绝状态写入失败', e))
},
/**
@@ -826,93 +649,88 @@ export const useFriendStore = defineStore('imFriendStore', {
* peerUserId 由 websocketStore 按帧 sender / receiver 算好传入:becomeFriends 单条入库后双方收到同一份 payload,
* 本端真正的「对端」是帧上的另一个用户,不是 payload.friendUserId(payload 里固定是 toUserId)。
*/
- applyFriendAddNotification(
- _payload: FriendNotificationPayload,
- peerUserId: number,
- ) {
+ applyFriendAddNotification(_payload: FriendNotificationPayload, peerUserId: number) {
+ queueFriendListRefreshAfterPending(() => this.fetchFriendList(true))
if (this.isActiveFriend(peerUserId)) {
- return;
+ return
}
- void this.fetchFriendInfo(peerUserId);
+ void this.fetchFriendInfo(peerUserId).catch((e) =>
+ console.warn('[IM friendStore] 好友详情补拉失败', e)
+ )
},
/**
* FRIEND_DELETE(1205):好友被删除;本端清理 + 按 payload.clear 决定是否级联清会话(多端跟主操作端一致)
* peerUserId 由 websocketStore 按帧 sender / receiver 算好传入;与 FRIEND_ADD 保持一致的 peer 推断
*/
- applyFriendDeleteNotification(
- payload: FriendNotificationPayload,
- peerUserId: number,
- ) {
- this.removeFriend(peerUserId, payload.clear !== false);
+ applyFriendDeleteNotification(payload: FriendNotificationPayload, peerUserId: number) {
+ queueFriendListRefreshAfterPending(() => this.fetchFriendList(true))
+ this.removeFriend(peerUserId, payload.clear !== false)
},
/** FRIEND_BLOCK(1207):拉黑;多端同步 */
applyFriendBlockNotification(payload: FriendNotificationPayload) {
- const friend = this.getFriend(payload.friendUserId);
+ queueFriendListRefreshAfterPending(() => this.fetchFriendList(true))
+ const friend = this.getFriend(payload.friendUserId)
if (friend) {
- friend.blocked = true;
- this.saveFriend(friend);
+ friend.blocked = true
+ this.saveFriend(friend)
}
},
/** FRIEND_UNBLOCK(1208):移出黑名单;多端同步 */
applyFriendUnblockNotification(payload: FriendNotificationPayload) {
- const friend = this.getFriend(payload.friendUserId);
+ queueFriendListRefreshAfterPending(() => this.fetchFriendList(true))
+ const friend = this.getFriend(payload.friendUserId)
if (friend) {
- friend.blocked = false;
- this.saveFriend(friend);
+ friend.blocked = false
+ this.saveFriend(friend)
}
},
/** FRIEND_INFO_UPDATED(1209):好友资料变更(昵称 / 头像);重拉详情 */
applyFriendInfoUpdatedNotification(payload: FriendNotificationPayload) {
- void this.fetchFriendInfo(payload.friendUserId);
+ queueFriendListRefreshAfterPending(() => this.fetchFriendList(true))
+ void this.fetchFriendInfo(payload.friendUserId)
},
/** FRIEND_UPDATE(1210):批量更新(备注 / 免打扰 / 联系人置顶);多端同步 */
applyFriendUpdateNotification(payload: FriendNotificationPayload) {
- const friend = this.getFriend(payload.friendUserId);
+ queueFriendListRefreshAfterPending(() => this.fetchFriendList(true))
+ const friend = this.getFriend(payload.friendUserId)
if (!friend) {
- return;
+ return
}
- if (payload.displayName !== null) {
- friend.displayName = payload.displayName;
+ if (payload.displayName != null) {
+ friend.displayName = payload.displayName
}
- if (payload.silent !== null) {
- friend.silent = payload.silent;
+ if (payload.silent != null) {
+ friend.silent = payload.silent
}
- if (payload.pinned !== null) {
- friend.pinned = payload.pinned;
+ if (payload.pinned != null) {
+ friend.pinned = payload.pinned
}
- const conversationStore = useConversationStore();
- conversationStore.updateConversation(
- ImConversationType.PRIVATE,
- payload.friendUserId,
- {
- name: getFriendDisplayName(friend),
- silent: friend.silent,
- },
- );
- this.saveFriend(friend);
+ const conversationStore = useConversationStore()
+ conversationStore.updateConversation(ImConversationType.PRIVATE, payload.friendUserId, {
+ name: getFriendDisplayName(friend),
+ silent: friend.silent
+ })
+ this.saveFriend(friend)
},
- /** 清空好友内存状态,并废弃未返回请求(pending Promise 置空 + storeEpoch++) */
+ /** 清空好友内存状态,并废弃未返回请求 */
clear() {
- this.friends = [];
- this.friendRequests = [];
- this.loaded = false;
- this.hasMoreFriendRequests = true;
- pendingFetchFriends = null;
- pendingFetchRequests = null;
- pendingLoadMoreRequests = null;
- pendingFetchFriendInfos.clear();
- storeEpoch++;
- },
- },
-});
+ this.friends = []
+ this.friendRequests = []
+ this.loaded = false
+ this.hasMoreFriendRequests = true
+ requestTask = null
+ pendingFetchFriendInfos.clear()
+ }
+ }
+})
-function convertFriend(vo: ImFriendApi.FriendRespVO): Friend {
+function convertFriend(vo: ImFriendRespVO): Friend {
return {
id: vo.id,
friendUserId: vo.friendUserId,
@@ -927,13 +745,11 @@ function convertFriend(vo: ImFriendApi.FriendRespVO): Friend {
blocked: !!vo.blocked,
status: vo.status,
addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined,
- deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined,
- };
+ deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined
+ }
}
-function convertFriendRequest(
- vo: ImFriendRequestApi.FriendRequestRespVO,
-): FriendRequest {
+function convertFriendRequest(vo: ImFriendRequestRespVO): FriendRequest {
return {
id: vo.id,
fromUserId: vo.fromUserId,
@@ -947,13 +763,11 @@ function convertFriendRequest(
fromNickname: vo.fromNickname,
fromAvatar: vo.fromAvatar,
toNickname: vo.toNickname,
- toAvatar: vo.toAvatar,
- };
+ toAvatar: vo.toAvatar
+ }
}
-export const useFriendStoreWithOut = () => useFriendStore();
-
// dev: 让 Pinia 的 actions / state 改动支持 HMR,避免每次改 store 都得硬刷
if (import.meta.hot) {
- import.meta.hot.accept(acceptHMRUpdate(useFriendStore, import.meta.hot));
+ import.meta.hot.accept(acceptHMRUpdate(useFriendStore, import.meta.hot))
}
diff --git a/apps/web-antd/src/views/im/home/store/groupRequestStore.ts b/apps/web-antd/src/views/im/home/store/groupRequestStore.ts
index 6c4f1fbb8..4bb668c4e 100644
--- a/apps/web-antd/src/views/im/home/store/groupRequestStore.ts
+++ b/apps/web-antd/src/views/im/home/store/groupRequestStore.ts
@@ -1,31 +1,33 @@
-import type { ImGroupRequestApi } from '#/api/im/group/request';
+import type { GroupRequestDO } from '../types'
-import { acceptHMRUpdate, defineStore } from 'pinia';
+import type { ImGroupRequestApi } from '#/api/im/group/request'
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
import {
agreeGroupRequest as apiAgreeGroupRequest,
getMyGroupRequest as apiGetMyGroupRequest,
getUnhandledRequestList as apiGetUnhandledRequestList,
pullMyGroupRequestList as apiPullMyGroupRequestList,
- refuseGroupRequest as apiRefuseGroupRequest,
-} from '#/api/im/group/request';
-import { getCurrentUserId } from '#/views/im/utils/auth';
-import { ImGroupRequestHandleResult } from '#/views/im/utils/constants';
+ refuseGroupRequest as apiRefuseGroupRequest
+} from '#/api/im/group/request'
+import { ImGroupRequestHandleResult } from '#/views/im/utils/constants'
-import { getDb, StorageKeys } from '../../utils/db';
-import { runIncrementalPull } from '../../utils/pull';
+import { type DbClient, getDb, initDb, StorageKeys } from '../../utils/db'
+import { runIncrementalPull } from '../../utils/pull'
+import {
+ ResourceRequestKey,
+ ResourceRequestMode,
+ runResourceRequest
+} from '../../utils/resourceRequest'
-type PendingRequest = { epoch: number; promise: Promise; userId: number };
-
-/** clear() 时递增;旧账号 in-flight 的 pullGroupRequests 结果 resolve 后比对一致才写 store,防跨账号红点污染(与 friendStore 同口径) */
-let storeEpoch = 0;
-let pendingUnhandledFetch: null | PendingRequest = null;
+type ImGroupRequestRespVO = ImGroupRequestApi.GroupRequestRespVO
/**
* IM 加群申请 Store
*
* 仅维护「我管理的所有群」下未处理的申请列表(unhandledList);
- * 横幅 / Drawer 都从这里派生 count 和分组列表,避免给 ImGroupApi.GroupRespVO 挂 pendingRequestCount 字段
+ * 横幅 / Drawer 都从这里派生 count 和分组列表,避免给 ImGroupRespVO 挂 pendingRequestCount 字段
*
* 数据生命周期:
* - 进 IM 后调一次 fetchUnhandledGroupRequestList 拉首页全量
@@ -37,9 +39,7 @@ let pendingUnhandledFetch: null | PendingRequest = null;
export const useGroupRequestStore = defineStore('imGroupRequestStore', {
state: () => ({
/** 我管理的所有群下未处理申请列表(按 id 倒序) */
- unhandledList: [] as ImGroupRequestApi.GroupRequestRespVO[],
- /** fetchUnhandledGroupRequestList 是否成功执行过;避免横幅显示 0 然后跳数字的闪烁 */
- loaded: false,
+ unhandledList: [] as ImGroupRequestRespVO[]
}),
getters: {
@@ -47,115 +47,53 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', {
* 各群下未处理申请数的 Map;O(N) 扫一次缓存供 ConversationItem 等 N 处复用,避免 N×M 重复 filter
*/
getUnhandledGroupRequestCountMap(state): Map {
- const map = new Map();
+ const map = new Map()
for (const request of state.unhandledList) {
- map.set(request.groupId, (map.get(request.groupId) ?? 0) + 1);
+ map.set(request.groupId, (map.get(request.groupId) ?? 0) + 1)
}
- return map;
+ return map
},
/** 指定群下的未处理申请数 */
getUnhandledGroupRequestCount(): (groupId: number) => number {
- return (groupId: number) =>
- this.getUnhandledGroupRequestCountMap.get(groupId) ?? 0;
- },
- /** 指定群下的未处理申请列表 */
- getUnhandledGroupRequestListByGroupId:
- (state) =>
- (groupId: number): ImGroupRequestApi.GroupRequestRespVO[] =>
- state.unhandledList.filter((r) => r.groupId === groupId),
+ return (groupId: number) => this.getUnhandledGroupRequestCountMap.get(groupId) ?? 0
+ }
},
actions: {
/** 从 IndexedDB 恢复加群申请 */
- async loadGroupRequestList(): Promise {
+ async loadGroupRequestList(): Promise {
try {
- const cached =
- await getDb().getAll(
- 'groupRequests',
- );
- if (!cached || cached.length === 0) {
- return false;
+ const cached = await getDb().getAll('groupRequests')
+ if (cached.length === 0) {
+ return
}
this.unhandledList = cached
- .filter(
- (request) =>
- request.handleResult === ImGroupRequestHandleResult.UNHANDLED,
- )
- .toSorted((requestA, requestB) => requestB.id - requestA.id);
- return true;
- } catch (error) {
- console.warn('[IM groupRequestStore] 本地加群申请缓存读取失败', error);
- return false;
+ .filter((request) => request.handleResult === ImGroupRequestHandleResult.UNHANDLED)
+ .sort((requestA, requestB) => requestB.id - requestA.id)
+ } catch (e) {
+ console.warn('[IM groupRequestStore] 本地加群申请缓存读取失败', e)
}
},
- /** 保存加群申请列表 */
- saveGroupRequestList(): void {
- void getDb()
- .transaction(['groupRequests'], 'readwrite', async (tx) => {
- const db = getDb();
- await db.clearStore('groupRequests', tx);
- for (const request of this.unhandledList) {
- await db.put('groupRequests', request, tx);
- }
- })
- .catch((error) =>
- console.warn(
- '[IM groupRequestStore] 本地加群申请缓存写入失败',
- error,
- ),
- );
- },
-
- /** 保存单条加群申请 */
- async saveGroupRequestRecord(
- request: ImGroupRequestApi.GroupRequestRespVO,
- ): Promise {
- await getDb().put('groupRequests', request);
- },
-
- /** 保存单条加群申请 */
- saveGroupRequest(request: ImGroupRequestApi.GroupRequestRespVO): void {
- void this.saveGroupRequestRecord(request).catch((error) =>
- console.warn('[IM groupRequestStore] 本地加群申请写入失败', error),
- );
- },
-
/** 拉取我管理的所有群下未处理申请;进 IM 后 / 升级 admin 后 / WS 推送有冲突时调用 */
async fetchUnhandledGroupRequestList() {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- if (
- pendingUnhandledFetch?.epoch === requestEpoch &&
- pendingUnhandledFetch.userId === requestUserId
- ) {
- return pendingUnhandledFetch.promise;
- }
- const promise = (async () => {
- const list = await apiGetUnhandledRequestList();
- if (
- requestEpoch !== storeEpoch ||
- getCurrentUserId() !== requestUserId
- ) {
- return;
- }
- this.unhandledList = list || [];
- this.loaded = true;
- this.saveGroupRequestList();
- })().finally(() => {
- if (
- pendingUnhandledFetch?.epoch === requestEpoch &&
- pendingUnhandledFetch.userId === requestUserId
- ) {
- pendingUnhandledFetch = null;
- }
- });
- pendingUnhandledFetch = {
- epoch: requestEpoch,
- userId: requestUserId,
- promise,
- };
- return promise;
+ return runResourceRequest(
+ ResourceRequestKey.GROUP_REQUEST_UNHANDLED,
+ async () => {
+ const db = await initDb()
+ this.unhandledList = await apiGetUnhandledRequestList()
+ const snapshot = [...this.unhandledList]
+ void db
+ .transaction(['groupRequests'], 'readwrite', async (tx) => {
+ await db.clearStore('groupRequests', tx)
+ for (const request of snapshot) {
+ await db.put('groupRequests', request, tx)
+ }
+ })
+ .catch((e) => console.warn('[IM groupRequestStore] 本地加群申请缓存写入失败', e))
+ },
+ { mode: ResourceRequestMode.SINGLE_FLIGHT }
+ )
},
/**
@@ -164,42 +102,25 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', {
* 同一对 group_id, user_id 复用记录时 requestId 不变但 applyContent / inviterUserId 会刷新,所以无条件 fetch + 排到头部
*/
async addGroupRequestById(requestId: number) {
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- const request = await apiGetMyGroupRequest(requestId);
+ const db = await initDb()
+ const request = await apiGetMyGroupRequest(requestId)
if (!request) {
- return;
+ return
}
- if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
- return;
- }
- this.upsertGroupRequest(request);
- },
-
- /**
- * 本地合并 / 新增单条加群申请(WS 推送 & 增量拉取共用)
- *
- * 未处理的按 id 去重后置顶;已处理的从未处理列表移除,避免补偿时把已同意 / 拒绝的记录塞回红点
- */
- upsertGroupRequest(request: ImGroupRequestApi.GroupRequestRespVO) {
- void this.upsertGroupRequestForPull(request).catch((error) =>
- console.warn('[IM groupRequestStore] 本地加群申请写入失败', error),
- );
+ await this.upsertGroupRequestForPull(request, db)
},
/** 本地合并 / 新增单条加群申请 */
async upsertGroupRequestForPull(
- request: ImGroupRequestApi.GroupRequestRespVO,
+ request: ImGroupRequestRespVO,
+ db: DbClient = getDb()
): Promise {
if (request.handleResult !== ImGroupRequestHandleResult.UNHANDLED) {
- await this.removeGroupRequestByIdForPull(request.id);
- return;
+ await this.removeGroupRequestByIdForPull(request.id, db)
+ return
}
- this.unhandledList = [
- request,
- ...this.unhandledList.filter((r) => r.id !== request.id),
- ];
- await this.saveGroupRequestRecord(request);
+ this.unhandledList = [request, ...this.unhandledList.filter((r) => r.id !== request.id)]
+ await db.put('groupRequests', request)
},
/**
@@ -209,73 +130,53 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', {
* 故首次重连时游标为空 = 一次性全量走一遍(已处理记录命中 removeGroupRequestById 为 no-op,红点不受影响),之后增量。
*/
async pullGroupRequests() {
- // 快照 epoch;账号在拉取途中切换(clear() → epoch++)时丢弃旧账号那几页结果,防跨账号红点污染
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- const isActive = () =>
- requestEpoch === storeEpoch && getCurrentUserId() === requestUserId;
+ const db = await initDb()
await runIncrementalPull(
+ db,
StorageKeys.settings.groupRequestPullCursor,
- apiPullMyGroupRequestList,
+ (params) => apiPullMyGroupRequestList(params),
async (records) => {
- if (!isActive()) {
- return false;
- }
- await Promise.all(
- records.map((vo) => this.upsertGroupRequestForPull(vo)),
- );
- return true;
- },
- isActive,
- );
- if (isActive()) {
- this.loaded = true;
- }
+ await Promise.all(records.map((vo) => this.upsertGroupRequestForPull(vo, db)))
+ return true
+ }
+ )
},
/** WS 收到 1505 / 1506 或本端处理完一条:按 requestId 从列表移除 */
- removeGroupRequestById(requestId: number) {
- this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId);
- void getDb()
+ removeGroupRequestById(requestId: number, db: DbClient = getDb()) {
+ this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId)
+ void db
.delete('groupRequests', requestId)
- .catch((error) =>
- console.warn('[IM groupRequestStore] 本地加群申请删除失败', error),
- );
+ .catch((e) => console.warn('[IM groupRequestStore] 本地加群申请删除失败', e))
},
/** 删除单条加群申请 */
- async removeGroupRequestByIdForPull(requestId: number): Promise {
- this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId);
- await getDb().delete('groupRequests', requestId);
+ async removeGroupRequestByIdForPull(requestId: number, db: DbClient = getDb()): Promise {
+ this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId)
+ await db.delete('groupRequests', requestId)
},
/** 同意申请;本端处理后立即从列表移除,避免被反复点击 */
async agreeGroupRequest(requestId: number) {
- await apiAgreeGroupRequest(requestId);
- this.removeGroupRequestById(requestId);
+ const db = await initDb()
+ await apiAgreeGroupRequest(requestId)
+ this.removeGroupRequestById(requestId, db)
},
/** 拒绝申请 */
async refuseGroupRequest(requestId: number, handleContent?: string) {
- await apiRefuseGroupRequest(requestId, handleContent);
- this.removeGroupRequestById(requestId);
+ const db = await initDb()
+ await apiRefuseGroupRequest(requestId, handleContent)
+ this.removeGroupRequestById(requestId, db)
},
/** 清空加群申请内存 */
clear() {
- this.unhandledList = [];
- this.loaded = false;
- // 账号切换:递增 epoch 废弃旧账号 in-flight 的 pullGroupRequests 结果,避免写进新账号红点列表
- storeEpoch++;
- pendingUnhandledFetch = null;
- },
- },
-});
-
-export const useGroupRequestStoreWithOut = () => useGroupRequestStore();
+ this.unhandledList = []
+ }
+ }
+})
if (import.meta.hot) {
- import.meta.hot.accept(
- acceptHMRUpdate(useGroupRequestStore, import.meta.hot),
- );
+ import.meta.hot.accept(acceptHMRUpdate(useGroupRequestStore, import.meta.hot))
}
diff --git a/apps/web-antd/src/views/im/home/store/groupStore.ts b/apps/web-antd/src/views/im/home/store/groupStore.ts
index cc0af8ec3..783ba9fb6 100644
--- a/apps/web-antd/src/views/im/home/store/groupStore.ts
+++ b/apps/web-antd/src/views/im/home/store/groupStore.ts
@@ -1,62 +1,91 @@
-import type { GroupNotificationPayload } from '../../utils/message';
-import type { Group, GroupDO, GroupMember, Message } from '../types';
+import type { Group, GroupDO, GroupMember, GroupMemberDO, Message } from '../types'
-import type { ImGroupApi } from '#/api/im/group';
-import type { ImGroupMemberApi } from '#/api/im/group/member';
+import type { ImGroupApi } from '#/api/im/group'
+import type { ImGroupMemberApi } from '#/api/im/group/member'
-import { CommonStatusEnum } from '@vben/constants';
+import { CommonStatusEnum } from '@vben/constants'
-import { acceptHMRUpdate, defineStore } from 'pinia';
+import { acceptHMRUpdate, defineStore } from 'pinia'
import {
+ dissolveGroup as apiDissolveGroup,
getGroup as apiGetGroup,
- getMyGroupList as apiGetMyGroupList,
-} from '#/api/im/group';
+ getMyGroupList as apiGetMyGroupList
+} from '#/api/im/group'
import {
getGroupMember as apiGetGroupMember,
getGroupMemberList as apiGetGroupMemberList,
- updateGroupMember as apiUpdateGroupMember,
-} from '#/api/im/group/member';
-import { getCurrentUserId } from '#/views/im/utils/auth';
+ quitGroup as apiQuitGroup,
+ updateGroupMember as apiUpdateGroupMember
+} from '#/api/im/group/member'
import {
ImContentType,
ImConversationType,
ImGroupMemberRole,
- ImMessageStatus,
-} from '../../utils/constants';
-import { getDb } from '../../utils/db';
-import { getGroupDisplayName } from '../../utils/user';
-import { useConversationStore } from './conversationStore';
-import { useGroupRequestStore } from './groupRequestStore';
+ ImMessageStatus
+} from '../../utils/constants'
+import { type DbClient, getClientConversationId, getDb, initDb } from '../../utils/db'
+import { type GroupNotificationPayload } from '../../utils/message'
+import {
+ enqueueConversationWrite,
+ enqueueConversationWrites,
+ isRelationTerminated,
+ markRelationTerminated,
+ reopenRelation
+} from '../../utils/messageSync'
+import {
+ isResourceRequestPending,
+ ResourceRequestKey,
+ ResourceRequestMode,
+ runResourceRequest
+} from '../../utils/resourceRequest'
+import { getGroupDisplayName } from '../../utils/user'
+import { useConversationStore } from './conversationStore'
+import { useGroupRequestStore } from './groupRequestStore'
-/** clear() 时递增;旧账号 in-flight 的成员请求返回后比对一致才写 store */
-let storeEpoch = 0;
+type ImGroupRespVO = ImGroupApi.GroupRespVO
+type ImGroupMemberRespVO = ImGroupMemberApi.GroupMemberRespVO
-/**
- * fetchGroupMemberList 并发去重表:同 groupId 同时进的请求共用一个 Promise
- *
- * key 必须带 userId——账号切换时 A 的请求不能被 B 复用,否则 IIFE 内部的 saveGroupMemberList 会把 A 的成员数据写进 B 的 IDB 桶
- */
-const pendingMemberFetches = new Map>();
-const pendingMemberKey = (userId: number, groupId: number) =>
- `${userId}:${groupId}`;
+/** 在群列表拉取期间合并一次群事件尾随刷新 */
+function queueGroupListRefreshAfterPending(fetch: () => Promise): void {
+ if (isResourceRequestPending(ResourceRequestKey.GROUP_LIST)) {
+ void fetch().catch(() => undefined)
+ }
+}
+
+let groupRelationVersionSequence = 0
+const groupRelationVersions = new Map()
+
+/** 获取当前群关系代际,首次访问时创建 */
+function ensureGroupRelationVersion(groupId: number): number {
+ const current = groupRelationVersions.get(groupId)
+ if (current !== undefined) {
+ return current
+ }
+ const next = ++groupRelationVersionSequence
+ groupRelationVersions.set(groupId, next)
+ return next
+}
+
+const pendingGroupInfoFetches = new Map>()
+const queuedGroupInfoRefreshes = new Set()
+
+/** fetchGroupMemberList 并发去重表:同一群关系代际共用一个 Promise */
+const pendingMemberFetches = new Map>()
+const queuedMemberRefreshes = new Set()
+const pendingMemberKey = (groupId: number, relationVersion: number) =>
+ `${groupId}:${relationVersion}`
/**
* fetchGroupMember 单成员并发去重表:同 (groupId, memberUserId) 同时进的请求共用一个 Promise
*
* 跟整群表分开:单成员 fetch 跟整群 fetch 语义不同(单成员不回填 me 的 silent),不能互相代替
*/
-const pendingSingleMemberFetches = new Map<
- string,
- Promise
->();
+const pendingSingleMemberFetches = new Map>()
-const pendingSingleMemberKey = (
- userId: number,
- groupId: number,
- memberUserId: number,
-) => `${userId}:${groupId}:${memberUserId}`;
+const pendingSingleMemberKey = (groupId: number, relationVersion: number, memberUserId: number) =>
+ `${groupId}:${relationVersion}:${memberUserId}`
/** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO {
@@ -68,21 +97,20 @@ function buildGroupDO(group: Group): GroupDO {
membersLoaded: _membersLoaded,
membersExpired: _membersExpired,
...record
- } = group;
- return record;
+ } = group
+ return record
}
/** 判断当前用户是否在 payload.memberUserIds 里(GROUP_CREATE / INVITE / KICK 自判用) */
-function isSelfInPayloadMembers(payload: GroupNotificationPayload): boolean {
- const selfUserId = getCurrentUserId();
- return !!selfUserId && (payload.memberUserIds || []).includes(selfUserId);
+function isSelfInPayloadMembers(payload: GroupNotificationPayload, userId: number): boolean {
+ return (payload.memberUserIds || []).includes(userId)
}
/** 刷新我管理的群申请红点 */
function refreshUnhandledGroupRequests(): void {
useGroupRequestStore()
.fetchUnhandledGroupRequestList()
- .catch(() => undefined);
+ .catch(() => undefined)
}
/**
@@ -97,22 +125,15 @@ export const useGroupStore = defineStore('imGroupStore', {
state: () => ({
groups: [] as Group[],
loaded: false, // 仅 fetchGroupList 成功后置位;loadGroupList(IDB)不置位,否则后台 SWR 刷新会被缓存命中跳过
- groupMembersExpired: false, // 进入 IM / 重连后置位;IDB 里的成员桶延迟加载到内存时,也要按过期处理
+ groupMembersExpired: false // 进入 IM / 重连后置位;IDB 里的成员桶延迟加载到内存时,也要按过期处理
}),
getters: {
getGroup:
(state) =>
(id: number): Group | undefined => {
- return state.groups.find((g) => g.id === id);
- },
- /** 群成员 userId → GroupMember 索引;调用方按 userId 反查昵称 / 头像等元信息 */
- getGroupMemberMap:
- (state) =>
- (id: number): Map => {
- const group = state.groups.find((g) => g.id === id);
- return new Map((group?.members || []).map((m) => [m.userId, m]));
- },
+ return state.groups.find((g) => g.id === id)
+ }
},
actions: {
@@ -121,117 +142,145 @@ export const useGroupStore = defineStore('imGroupStore', {
/** 从 IndexedDB 恢复群列表 */
async loadGroupList(): Promise {
try {
- const cached = await getDb().getAll('groups');
+ const cached = await getDb().getAll('groups')
if (!cached || cached.length === 0) {
- return false;
+ return false
}
- this.groups = cached;
- return true;
- } catch (error) {
- console.warn('[IM groupStore] 本地群缓存读取失败', error);
- return false;
+ const conversationIds = cached.map((group) =>
+ getClientConversationId(ImConversationType.GROUP, group.id)
+ )
+ return await enqueueConversationWrites(conversationIds, async () => {
+ const activeGroups = cached.filter(
+ (group) =>
+ !isRelationTerminated(getClientConversationId(ImConversationType.GROUP, group.id))
+ )
+ this.groups = activeGroups
+ return activeGroups.length > 0
+ })
+ } catch (e) {
+ console.warn('[IM groupStore] 本地群缓存读取失败', e)
+ return false
}
},
/** 保存群列表 */
- saveGroupList(): void {
- void getDb()
- .transaction(['groups'], 'readwrite', async (tx) => {
- const db = getDb();
- await db.clearStore('groups', tx);
- for (const group of this.groups) {
- await db.put('groups', buildGroupDO(group), tx);
- }
- })
- .catch((error) =>
- console.warn('[IM groupStore] 本地群缓存写入失败', error),
- );
+ async saveGroupList(groups: Group[], db: DbClient = getDb()): Promise {
+ await db.transaction(['groups'], 'readwrite', async (tx) => {
+ await db.clearStore('groups', tx)
+ for (const group of groups) {
+ await db.put('groups', buildGroupDO(group), tx)
+ }
+ })
},
/** 保存单个群 */
- async saveGroupRecord(group: Group | undefined): Promise {
+ async saveGroupRecord(group: Group | undefined, db: DbClient = getDb()): Promise {
if (!group) {
- return;
+ return
}
- await getDb().put('groups', buildGroupDO(group));
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, group.id)
+ if (isRelationTerminated(clientConversationId)) {
+ return
+ }
+ await db.put('groups', buildGroupDO(group))
},
/** 保存单个群 */
- saveGroup(group: Group | undefined): void {
- void this.saveGroupRecord(group).catch((error) =>
- console.warn('[IM groupStore] 本地群写入失败', error),
- );
+ saveGroup(group: Group | undefined, db: DbClient = getDb()): void {
+ if (!group) {
+ return
+ }
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, group.id)
+ const snapshot = { ...group }
+ void enqueueConversationWrite(clientConversationId, () =>
+ this.saveGroupRecord(snapshot, db)
+ ).catch((e) => console.warn('[IM groupStore] 本地群写入失败', e))
},
/** 从 IndexedDB 恢复指定群成员 */
async loadGroupMemberList(groupId: number): Promise {
// in-memory 已"完整"加载(fetchGroupMemberList 跑过或上次冷启动从 IDB 整桶恢复过):直接复用;
// 单成员补齐(fetchGroupMember)写进的 partial members 不在此返回缓存——其 membersLoaded=false
- const cachedGroup = this.getGroup(groupId);
+ const cachedGroup = this.getGroup(groupId)
if (cachedGroup?.members && cachedGroup.membersLoaded) {
- return cachedGroup.members;
+ return cachedGroup.members
}
try {
- const cached = await getDb().getAllByIndex(
+ const relationVersion = ensureGroupRelationVersion(groupId)
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId)
+ const cached = await getDb().getAllByIndex(
'groupMembers',
'groupId',
- groupId,
- );
+ groupId
+ )
if (!cached || cached.length === 0) {
- return null;
+ return null
}
- // 把 IDB 拿到的成员落到对应 group
- const group = this.getGroup(groupId);
- if (group) {
- group.members = cached;
- group.memberCount = cached.length;
- group.membersLoaded = true;
- group.membersExpired = this.groupMembersExpired;
- } else {
- // group 还没就位:仅 in-memory 占位(name='' 表示未知),不调 upsertGroup —— 避免把假名灌进 conversation.name + groups IDB 桶;
- // 后续,等 fetchGroupList 浅合并时,被真名覆盖
- this.groups.push({
- id: groupId,
- name: '',
- members: cached,
- memberCount: cached.length,
- membersLoaded: true,
- membersExpired: this.groupMembersExpired,
- });
- }
- return cached;
- } catch (error) {
- console.warn(
- '[IM groupStore] 本地群成员缓存读取失败',
- { groupId },
- error,
- );
- return null;
+ return await enqueueConversationWrite(clientConversationId, async () => {
+ if (
+ groupRelationVersions.get(groupId) !== relationVersion ||
+ isRelationTerminated(clientConversationId)
+ ) {
+ return null
+ }
+ // 把 IDB 拿到的成员落到对应 group
+ const group = this.getGroup(groupId)
+ if (!group) {
+ // group 还没就位:仅 in-memory 占位(name='' 表示未知),不调 upsertGroup —— 避免把假名灌进 conversation.name + groups IDB 桶;
+ // 后续,等 fetchGroupList 浅合并时,被真名覆盖
+ this.groups.push({
+ id: groupId,
+ name: '',
+ members: cached,
+ memberCount: cached.length,
+ membersLoaded: true,
+ membersExpired: this.groupMembersExpired
+ })
+ } else {
+ group.members = cached
+ group.memberCount = cached.length
+ group.membersLoaded = true
+ group.membersExpired = this.groupMembersExpired
+ }
+ return cached
+ })
+ } catch (e) {
+ console.warn('[IM groupStore] 本地群成员缓存读取失败', { groupId }, e)
+ return null
}
},
- /** 保存指定群成员 */
- saveGroupMemberList(groupId: number): void {
- const members = this.getGroup(groupId)?.members;
- if (!members) {
- return;
+ /** 保存指定群成员;调用方必须持有群会话写 lane */
+ async saveGroupMemberListRecord(
+ groupId: number,
+ members: GroupMemberDO[],
+ db: DbClient = getDb()
+ ): Promise {
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId)
+ if (isRelationTerminated(clientConversationId)) {
+ return
}
- void getDb()
- .transaction(['groupMembers'], 'readwrite', async (tx) => {
- const db = getDb();
- await db.deleteByIndex('groupMembers', 'groupId', groupId, tx);
- for (const member of members) {
- if (member.id) {
- await db.put('groupMembers', member, tx);
- }
+ await db.transaction(['groupMembers'], 'readwrite', async (tx) => {
+ await db.deleteByIndex('groupMembers', 'groupId', groupId, tx)
+ for (const member of members) {
+ if (member.id) {
+ await db.put('groupMembers', member, tx)
}
- })
- .catch((error) =>
- console.warn(
- `[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})`,
- error,
- ),
- );
+ }
+ })
+ },
+
+ /** 串行保存指定群成员 */
+ saveGroupMemberList(groupId: number, db: DbClient = getDb()): void {
+ const members = this.getGroup(groupId)?.members
+ if (!members) {
+ return
+ }
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId)
+ const records = JSON.parse(JSON.stringify(members)) as GroupMemberDO[]
+ void enqueueConversationWrite(clientConversationId, () =>
+ this.saveGroupMemberListRecord(groupId, records, db)
+ ).catch((e) => console.warn(`[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})`, e))
},
// ==================== 远端拉取 ====================
@@ -239,90 +288,100 @@ export const useGroupStore = defineStore('imGroupStore', {
/** 拉取群列表;同步刷新对应群聊会话的展示名 / 头像 + 落 IDB */
async fetchGroupList(force = false) {
if (this.loaded && !force) {
- return;
+ return this.groups
}
- const requestEpoch = storeEpoch;
- const requestUserId = getCurrentUserId();
- // 拉取当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMemberList)
- const list = await apiGetMyGroupList();
- if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
- return;
- }
- const fresh = (list || []).map((group) => convertGroup(group));
- // 合并而非全量替换:成员缓存只在成员列表接口维护,群个人设置以群列表接口为准
- const groupMap = new Map(this.groups.map((group) => [group.id, group]));
- this.groups = fresh.map((group) => {
- const existing = groupMap.get(group.id);
- if (!existing) {
- return { ...group, activeCallExpired: true, infoLoaded: true };
- }
- return {
- ...group,
- infoLoaded: true,
- activeCallExpired: existing.activeCallExpired,
- activeCallLoaded: existing.activeCallLoaded,
- members: existing.members,
- memberCount: existing.memberCount ?? group.memberCount,
- membersLoaded: existing.membersLoaded,
- membersExpired: existing.membersExpired,
- };
- });
- this.loaded = true;
- const conversationStore = useConversationStore();
+ return runResourceRequest(
+ ResourceRequestKey.GROUP_LIST,
+ async () => {
+ const db = await initDb()
+ const fresh = ((await apiGetMyGroupList()) || []).map((group) =>
+ convertGroup(group, db.userId)
+ )
+ const conversationIds = Array.from(
+ new Set(
+ [...this.groups, ...fresh].map((group) =>
+ getClientConversationId(ImConversationType.GROUP, group.id)
+ )
+ )
+ )
+ const committed = await enqueueConversationWrites(conversationIds, async () => {
+ const visibleGroups = fresh.filter(
+ (group) =>
+ !isRelationTerminated(getClientConversationId(ImConversationType.GROUP, group.id))
+ )
+ const groupMap = new Map(this.groups.map((group) => [group.id, group]))
+ this.groups = visibleGroups.map((group) => {
+ const existing = groupMap.get(group.id)
+ if (!existing) {
+ return { ...group, activeCallExpired: true, infoLoaded: true }
+ }
+ return {
+ ...group,
+ infoLoaded: true,
+ activeCallExpired: existing.activeCallExpired,
+ activeCallLoaded: existing.activeCallLoaded,
+ members: existing.members,
+ memberCount: existing.memberCount ?? group.memberCount,
+ membersLoaded: existing.membersLoaded,
+ membersExpired: existing.membersExpired
+ }
+ })
+ this.loaded = true
+ const conversationStore = useConversationStore()
+ for (const group of this.groups) {
+ conversationStore.updateConversation(
+ ImConversationType.GROUP,
+ group.id,
+ {
+ name: getGroupDisplayName(group),
+ avatar: group.avatar,
+ silent: group.silent
+ },
+ db
+ )
+ }
+ await this.saveGroupList([...this.groups], db).catch((e) =>
+ console.warn('[IM groupStore] 本地群缓存写入失败', e)
+ )
+ return this.groups
+ })
+ this.preloadMembersForEmptyAvatarGroups(db)
+ return committed
+ },
+ { mode: ResourceRequestMode.SINGLE_FLIGHT, refreshAfterPending: force }
+ )
+ },
+
+ /** 预加载空群头像的成员列表,供 GroupAvatar 异步合成群头像 */
+ preloadMembersForEmptyAvatarGroups(db: DbClient = getDb()) {
for (const group of this.groups) {
- conversationStore.updateConversation(
- ImConversationType.GROUP,
- group.id,
- {
- name: getGroupDisplayName(group),
- avatar: group.avatar,
- silent: group.silent,
- },
- );
+ if (
+ group.avatar ||
+ group.joinStatus === CommonStatusEnum.DISABLE ||
+ (group.membersLoaded && !group.membersExpired && group.members?.length)
+ ) {
+ continue
+ }
+ const force = !!group.membersLoaded && !group.membersExpired && !group.members?.length
+ this.fetchGroupMemberList(group.id, force, db).catch((error) => {
+ console.warn('[IM groupStore] 预加载群头像成员失败', { groupId: group.id }, error)
+ })
}
- this.saveGroupList();
- this.preloadMembersForEmptyAvatarGroups();
},
/** 失效全部群详情缓存 */
markAllGroupInfoExpired() {
for (const group of this.groups) {
- group.infoLoaded = false;
- }
- },
-
- /** 预加载空群头像的成员列表,供 GroupAvatar 异步合成群头像 */
- preloadMembersForEmptyAvatarGroups() {
- for (const group of this.groups) {
- if (
- group.avatar ||
- group.joinStatus === CommonStatusEnum.DISABLE ||
- (group.membersLoaded &&
- !group.membersExpired &&
- group.members?.length)
- ) {
- continue;
- }
- const force =
- !!group.membersLoaded &&
- !group.membersExpired &&
- !group.members?.length;
- this.fetchGroupMemberList(group.id, force).catch((error) => {
- console.warn(
- '[IM groupStore] 预加载群头像成员失败',
- { groupId: group.id },
- error,
- );
- });
+ group.infoLoaded = false
}
},
/** 失效全部群成员缓存 */
markAllGroupMembersExpired() {
- this.groupMembersExpired = true;
+ this.groupMembersExpired = true
for (const group of this.groups) {
if (group.membersLoaded) {
- group.membersExpired = true;
+ group.membersExpired = true
}
}
},
@@ -330,149 +389,203 @@ export const useGroupStore = defineStore('imGroupStore', {
/** 失效全部群通话探测缓存 */
markAllGroupActiveCallsExpired() {
for (const group of this.groups) {
- group.activeCallExpired = true;
+ group.activeCallExpired = true
}
},
/** 标记群通话探测已加载 */
markGroupActiveCallLoaded(groupId: number) {
- const group = this.getGroup(groupId);
+ const group = this.getGroup(groupId)
if (!group) {
- return;
+ return
}
- group.activeCallLoaded = true;
- group.activeCallExpired = false;
+ group.activeCallLoaded = true
+ group.activeCallExpired = false
},
/** 判断群通话是否需要重新探测 */
isGroupActiveCallExpired(groupId: number): boolean {
- const group = this.getGroup(groupId);
- return !group?.activeCallLoaded || !!group.activeCallExpired;
+ const group = this.getGroup(groupId)
+ return !group?.activeCallLoaded || !!group.activeCallExpired
},
/** 失效指定群成员缓存 */
markGroupMembersExpired(groupId: number) {
- const group = this.getGroup(groupId);
+ const group = this.getGroup(groupId)
if (group?.membersLoaded) {
- group.membersExpired = true;
+ group.membersExpired = true
}
},
/** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert,常用于 GROUP_UPDATE 推送后或手动 reload */
- async fetchGroupInfo(groupId: number, force = false) {
- const cached = this.getGroup(groupId);
+ fetchGroupInfo(
+ groupId: number,
+ force = false,
+ db: DbClient = getDb()
+ ): Promise {
+ const relationVersion = ensureGroupRelationVersion(groupId)
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId)
+ if (isRelationTerminated(clientConversationId)) {
+ return Promise.resolve(undefined)
+ }
+ const cached = this.getGroup(groupId)
if (cached?.infoLoaded && !force) {
- return;
+ return Promise.resolve(cached)
}
- try {
- const data = await apiGetGroup(groupId);
- if (!data) {
- return;
+ const key = `${groupId}:${relationVersion}`
+ const pending = pendingGroupInfoFetches.get(key)
+ if (pending) {
+ if (force) {
+ queuedGroupInfoRefreshes.add(key)
}
- this.upsertGroup({ ...convertGroup(data), infoLoaded: true });
- } catch (error) {
- console.warn('[IM groupStore] fetchGroupInfo 失败', error);
+ return pending
}
+ const isRelationCurrent = () =>
+ groupRelationVersions.get(groupId) === relationVersion &&
+ !isRelationTerminated(clientConversationId)
+ const promise = (async () => {
+ try {
+ const data = await apiGetGroup(groupId)
+ if (!data) {
+ return undefined
+ }
+ return await enqueueConversationWrite(clientConversationId, async () => {
+ if (!isRelationCurrent()) {
+ return undefined
+ }
+ await this.upsertGroupAndSave(
+ { ...convertGroup(data, db.userId), infoLoaded: true },
+ db
+ )
+ return this.getGroup(groupId)
+ })
+ } catch (e) {
+ console.warn('[IM groupStore] fetchGroupInfo 失败', e)
+ return undefined
+ }
+ })().finally(() => {
+ if (pendingGroupInfoFetches.get(key) !== promise) {
+ return
+ }
+ const shouldRefresh = queuedGroupInfoRefreshes.has(key) && isRelationCurrent()
+ pendingGroupInfoFetches.delete(key)
+ queuedGroupInfoRefreshes.delete(key)
+ if (shouldRefresh) {
+ void this.fetchGroupInfo(groupId, true, db)
+ }
+ })
+ pendingGroupInfoFetches.set(key, promise)
+ return promise
},
/** 按群拉取成员(in-memory 缓存 + 并发去重,force=true 强刷)+ 落 IDB */
fetchGroupMemberList(
groupId: number,
force = false,
+ db: DbClient = getDb()
): Promise {
// in-memory "完整"加载过才命中——单成员补齐写入的 partial members 不在此返回(membersLoaded=false)
- const cached = this.getGroup(groupId);
- if (
- cached &&
- cached.members &&
- cached.membersLoaded &&
- !cached.membersExpired &&
- !force
- ) {
- return Promise.resolve(cached.members);
+ const cached = this.getGroup(groupId)
+ if (cached && cached.members && cached.membersLoaded && !cached.membersExpired && !force) {
+ return Promise.resolve(cached.members)
}
- // 未登录:不发起请求也不登记 in-flight,避免污染单飞表
- const requestUserId = getCurrentUserId();
- if (!requestUserId) {
- return Promise.resolve([]);
- }
- const requestEpoch = storeEpoch;
- // 同 (userId, groupId) 已经有正在飞的请求:直接复用,避免重复打接口
- const key = pendingMemberKey(requestUserId, groupId);
- const inflight = pendingMemberFetches.get(key);
+ const relationVersion = ensureGroupRelationVersion(groupId)
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId)
+ const key = pendingMemberKey(groupId, relationVersion)
+ const inflight = pendingMemberFetches.get(key)
if (inflight) {
- return inflight;
+ if (force) {
+ queuedMemberRefreshes.add(key)
+ }
+ return inflight
}
const promise = (async () => {
+ const client = db
+ const userId = db.userId
// 拉接口 + 单 pass 转换:同时捕获 me 的原始 VO,给下面回填 user-per-group 字段(silent / groupRemark)用
- const list = await apiGetGroupMemberList(groupId);
- if (
- requestEpoch !== storeEpoch ||
- getCurrentUserId() !== requestUserId
- ) {
- return [];
- }
- let meRaw: ImGroupMemberApi.GroupMemberRespVO | undefined;
+ const list = await apiGetGroupMemberList(groupId)
+ let meRaw: ImGroupMemberRespVO | undefined
const members = (list || []).map((member) => {
- if (member.userId === requestUserId) {
- meRaw = member;
+ if (member.userId === userId) {
+ meRaw = member
}
- return convertGroupMember(member, groupId);
- });
- const silent = !!meRaw?.silent;
- const groupRemark = meRaw?.groupRemark || '';
+ return convertGroupMember(member, groupId)
+ })
+ const silent = !!meRaw?.silent
+ const groupRemark = meRaw?.groupRemark || ''
- // 必须 await 之后重新 getGroup,避免 fetchGroupList 已并发写入真实 group 的 race
- const group = this.getGroup(groupId);
- const isPlaceholder = !group;
- let groupFieldsChanged = false;
- if (group) {
- group.members = members;
- group.memberCount = members.length;
- group.membersLoaded = true;
- group.membersExpired = false;
- // silent / groupRemark 任一变化才同步到 conversation 和 IDB;groupRemark 变化要顺带刷会话名
- if (group.silent !== silent || group.groupRemark !== groupRemark) {
- group.silent = silent;
- group.groupRemark = groupRemark;
- groupFieldsChanged = true;
- const conversationStore = useConversationStore();
- conversationStore.updateConversation(
- ImConversationType.GROUP,
- groupId,
- {
- name: getGroupDisplayName(group),
- silent,
- },
- );
+ return await enqueueConversationWrite(clientConversationId, async () => {
+ if (
+ groupRelationVersions.get(groupId) !== relationVersion ||
+ isRelationTerminated(clientConversationId)
+ ) {
+ return []
+ }
+ // 必须进入 lane 后重新 getGroup,避免 fetchGroupList 已并发写入真实 group 的 race
+ const group = this.getGroup(groupId)
+ const isPlaceholder = !group
+ let groupFieldsChanged = false
+ if (!group) {
+ // group 还没就位:仅 in-memory push 占位(name='' 表示未知),不调 upsertGroup——避免把假名灌进 conversation.name + groups IDB 桶;
+ // 后续,等 fetchGroupList 浅合并时,被真名覆盖
+ this.groups.push({
+ id: groupId,
+ name: '',
+ members,
+ memberCount: members.length,
+ silent,
+ groupRemark,
+ membersLoaded: true,
+ membersExpired: false
+ })
+ } else {
+ group.members = members
+ group.memberCount = members.length
+ group.membersLoaded = true
+ group.membersExpired = false
+ // silent / groupRemark 任一变化才同步到 conversation 和 IDB;groupRemark 变化要顺带刷会话名
+ if (group.silent !== silent || group.groupRemark !== groupRemark) {
+ group.silent = silent
+ group.groupRemark = groupRemark
+ groupFieldsChanged = true
+ const conversationStore = useConversationStore()
+ conversationStore.updateConversation(
+ ImConversationType.GROUP,
+ groupId,
+ {
+ name: getGroupDisplayName(group),
+ silent
+ },
+ client
+ )
+ }
}
- } else {
- // group 还没就位:仅 in-memory push 占位(name='' 表示未知),不调 upsertGroup——避免把假名灌进 conversation.name + groups IDB 桶;
- // 后续,等 fetchGroupList 浅合并时,被真名覆盖
- this.groups.push({
- id: groupId,
- name: '',
- members,
- memberCount: members.length,
- silent,
- groupRemark,
- membersLoaded: true,
- membersExpired: false,
- });
- }
- // groups 桶仅在 user-per-group 字段实际变化时写——避免一次批量进群引发多次整桶重写
- this.saveGroupMemberList(groupId);
- if (!isPlaceholder && groupFieldsChanged) {
- this.saveGroup(group);
- }
- return members;
+ const records = JSON.parse(JSON.stringify(members)) as GroupMemberDO[]
+ await this.saveGroupMemberListRecord(groupId, records, client)
+ if (!isPlaceholder && groupFieldsChanged) {
+ await this.saveGroupRecord(group, client)
+ }
+ return members
+ })
+ })().finally(() => {
// 无论成功 / 失败都要从单飞表清掉,否则后续同 group 请求永远拿到这个 stale Promise
- })().finally(() => pendingMemberFetches.delete(key));
+ if (pendingMemberFetches.get(key) === promise) {
+ const shouldRefresh =
+ queuedMemberRefreshes.has(key) &&
+ groupRelationVersions.get(groupId) === relationVersion &&
+ !isRelationTerminated(clientConversationId)
+ pendingMemberFetches.delete(key)
+ queuedMemberRefreshes.delete(key)
+ if (shouldRefresh) {
+ void this.fetchGroupMemberList(groupId, true, db).catch(() => undefined)
+ }
+ }
+ })
- // 把 Promise 登记进单飞表,让此后短时间内的同 (userId, groupId) 请求复用
- pendingMemberFetches.set(key, promise);
- return promise;
+ // 把 Promise 登记进单飞表,让同一群关系代际的请求复用
+ pendingMemberFetches.set(key, promise)
+ return promise
},
/**
@@ -481,125 +594,182 @@ export const useGroupStore = defineStore('imGroupStore', {
* 跟 fetchGroupMemberList 区别:只拉这一个成员,不动 me 的 silent / groupRemark(不是 me 的话拿不到);
* 命中时把成员 upsert 进 group.members 数组并落 IDB,让后续渲染能用 displayUserName
*/
- fetchGroupMember(
- groupId: number,
- memberUserId: number,
- ): Promise {
+ fetchGroupMember(groupId: number, memberUserId: number): Promise {
// in-memory 命中直接返回,不打接口
- const cached = this.getGroup(groupId)?.members?.find(
- (m) => m.userId === memberUserId,
- );
+ const cached = this.getGroup(groupId)?.members?.find((m) => m.userId === memberUserId)
if (cached) {
- return Promise.resolve(cached);
+ return Promise.resolve(cached)
}
- // 未登录:不发起请求也不登记 in-flight,避免污染单飞表
- const requestUserId = getCurrentUserId();
- if (!requestUserId) {
- return Promise.resolve(null);
- }
- const requestEpoch = storeEpoch;
- // 同 (userId, groupId, memberUserId) 已经有正在飞的请求:直接复用
- const key = pendingSingleMemberKey(requestUserId, groupId, memberUserId);
- const inflight = pendingSingleMemberFetches.get(key);
+ const relationVersion = ensureGroupRelationVersion(groupId)
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId)
+ const key = pendingSingleMemberKey(groupId, relationVersion, memberUserId)
+ const inflight = pendingSingleMemberFetches.get(key)
if (inflight) {
- return inflight;
+ return inflight
}
const promise = (async () => {
- const data = await apiGetGroupMember(groupId, memberUserId);
+ const data = await apiGetGroupMember(groupId, memberUserId)
if (!data) {
- return null;
+ return null
}
- if (
- requestEpoch !== storeEpoch ||
- getCurrentUserId() !== requestUserId
- ) {
- return null;
- }
- const member = convertGroupMember(data, groupId);
- // 把这一条 upsert 进 group.members 仅供 in-memory 渲染兜底;group 还没就位则用 placeholder
- // 注意:不写 IDB——成员桶语义是"全量",存"1 人桶"会污染下次冷启动的 loadGroupMemberList
- const group = this.getGroup(groupId);
- if (group) {
- const memberList = group.members ?? [];
- const index = memberList.findIndex((m) => m.userId === memberUserId);
- if (index === -1) {
- memberList.push(member);
- } else {
- memberList[index] = member;
+ const member = convertGroupMember(data, groupId)
+
+ return await enqueueConversationWrite(clientConversationId, async () => {
+ if (
+ groupRelationVersions.get(groupId) !== relationVersion ||
+ isRelationTerminated(clientConversationId)
+ ) {
+ return null
}
- group.members = memberList;
- } else {
- // memberCount 不设:后续 fetchGroupList 合并 `existing.memberCount ?? fresh.memberCount` 时,
- // 占位值会顶替真实值(fresh 不带 memberCount),等 fetchGroupMemberList 跑过才能拿到真实数
- this.groups.push({
- id: groupId,
- name: '',
- members: [member],
- });
+ // 把这一条 upsert 进 group.members 仅供 in-memory 渲染兜底;group 还没就位则用 placeholder
+ // 注意:不写 IDB——成员桶语义是"全量",存"1 人桶"会污染下次冷启动的 loadGroupMemberList
+ const group = this.getGroup(groupId)
+ if (!group) {
+ // memberCount 不设:后续 fetchGroupList 合并 `existing.memberCount ?? fresh.memberCount` 时,
+ // 占位值会顶替真实值(fresh 不带 memberCount),等 fetchGroupMemberList 跑过才能拿到真实数
+ this.groups.push({
+ id: groupId,
+ name: '',
+ members: [member]
+ })
+ } else {
+ const memberList = group.members ?? []
+ const index = memberList.findIndex((m) => m.userId === memberUserId)
+ if (index >= 0) {
+ memberList[index] = member
+ } else {
+ memberList.push(member)
+ }
+ group.members = memberList
+ }
+ return member
+ })
+ })().finally(() => {
+ if (pendingSingleMemberFetches.get(key) === promise) {
+ pendingSingleMemberFetches.delete(key)
}
- return member;
- })().finally(() => pendingSingleMemberFetches.delete(key));
- pendingSingleMemberFetches.set(key, promise);
- return promise;
+ })
+ pendingSingleMemberFetches.set(key, promise)
+ return promise
},
/** 按 id 插入或合并群(命中则浅合并保留旧字段,未命中则追加),同步把展示名 / 头像 / 免打扰推到对应会话 */
- upsertGroup(group: Group) {
- void this.upsertGroupAndSave(group).catch((error) =>
- console.warn('[IM groupStore] 本地群写入失败', error),
- );
+ upsertGroup(group: Group, db: DbClient = getDb()) {
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, group.id)
+ void enqueueConversationWrite(clientConversationId, () =>
+ this.upsertGroupAndSave(group, db)
+ ).catch((e) => console.warn('[IM groupStore] 本地群写入失败', e))
},
/** 按 id 插入或合并群 */
- async upsertGroupAndSave(group: Group): Promise {
- const index = this.groups.findIndex((g) => g.id === group.id);
- if (index === -1) {
- this.groups.push(group);
+ async upsertGroupAndSave(group: Group, db: DbClient = getDb()): Promise {
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, group.id)
+ if (isRelationTerminated(clientConversationId)) {
+ return
+ }
+ const index = this.groups.findIndex((g) => g.id === group.id)
+ if (index >= 0) {
+ this.groups[index] = { ...this.groups[index], ...group }
} else {
- this.groups[index] = { ...this.groups[index], ...group };
+ this.groups.push(group)
}
// 同步推到 conversation:群名 / 头像 / 免打扰是会话列表展示用的,必须紧随 group 变更
- const merged = this.getGroup(group.id) ?? group;
- const conversationStore = useConversationStore();
- conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
- name: getGroupDisplayName(merged),
- avatar: merged.avatar,
- silent: merged.silent,
- });
+ const merged = this.getGroup(group.id) ?? group
+ const conversationStore = useConversationStore()
+ conversationStore.updateConversation(
+ ImConversationType.GROUP,
+ group.id,
+ {
+ name: getGroupDisplayName(merged),
+ avatar: merged.avatar,
+ silent: merged.silent
+ },
+ db
+ )
// 持久化到 IDB(fire-and-forget)
- await this.saveGroupRecord(merged);
+ await this.saveGroupRecord(merged, db)
},
/** 本地移除群缓存和群会话;群解散(GROUP_DEL)、退群、被踢都复用 */
- removeGroup(id: number) {
- // 本地硬删(区别于好友删除的软删保留记录);级联清群聊会话避免列表里留死群
- this.groups = this.groups.filter((g) => g.id !== id);
- const conversationStore = useConversationStore();
- conversationStore.removeGroupConversation(id);
- void getDb()
- .transaction(['groups', 'groupMembers'], 'readwrite', async (tx) => {
- const db = getDb();
- await db.delete('groups', id, tx);
- await db.deleteByIndex('groupMembers', 'groupId', id, tx);
- })
- .catch((error) =>
- console.warn(`[IM groupStore] 群缓存删除失败 (groupId=${id})`, error),
- );
+ removeGroup(
+ id: number,
+ expectedRelationVersion = ensureGroupRelationVersion(id),
+ db: DbClient = getDb()
+ ) {
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, id)
+ return enqueueConversationWrite(clientConversationId, async () => {
+ if (groupRelationVersions.get(id) !== expectedRelationVersion) {
+ return
+ }
+ await this.removeGroupNow(id, undefined, db)
+ })
+ },
+
+ /** 退出群聊并清理本地数据 */
+ async quitGroup(id: number): Promise {
+ const relationVersion = ensureGroupRelationVersion(id)
+ const db = getDb()
+ await apiQuitGroup(id)
+ await this.removeGroup(id, relationVersion, db)
+ },
+
+ /** 解散群聊并清理本地数据 */
+ async dissolveGroup(id: number): Promise {
+ const relationVersion = ensureGroupRelationVersion(id)
+ const db = getDb()
+ await apiDissolveGroup(id)
+ await this.removeGroup(id, relationVersion, db)
+ },
+
+ /** 实际移除群关系;调用方必须持有群会话写 lane */
+ async removeGroupNow(id: number, messageId: number | undefined, db: DbClient): Promise {
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, id)
+ const conversationStore = useConversationStore()
+ if (!markRelationTerminated(clientConversationId, messageId)) {
+ return
+ }
+ groupRelationVersions.set(id, ++groupRelationVersionSequence)
+ const currentGroups = this.groups
+ const currentConversation = conversationStore.getConversation(ImConversationType.GROUP, id)
+ // 群、成员和会话终态必须一次提交,避免重启后只恢复其中一侧
+ const hiddenConversation = await db.transaction(
+ ['groups', 'groupMembers', 'conversations'],
+ 'readwrite',
+ async (tx) => {
+ await db.delete('groups', id, tx)
+ await db.deleteByIndex('groupMembers', 'groupId', id, tx)
+ return conversationStore.saveHiddenConversationRecord(
+ ImConversationType.GROUP,
+ id,
+ tx,
+ db
+ )
+ }
+ )
+ // 事务期间若新 Store 投影已接管,则只保留已提交的旧 DB 终态
+ if (this.groups === currentGroups) {
+ this.groups = currentGroups.filter((group) => group.id !== id)
+ }
+ if (
+ hiddenConversation &&
+ conversationStore.getConversation(ImConversationType.GROUP, id) === currentConversation
+ ) {
+ conversationStore.publishHiddenConversationProjection(hiddenConversation)
+ }
},
/** 切换免打扰:推后端 + 落本地 + 同步会话列表的 silent,避免 silent 图标 / 总未读 / 提示音判断与设置漂移;和 friendStore.setFriendSilent 对齐 */
async setGroupSilent(id: number, silent: boolean) {
- await apiUpdateGroupMember({ groupId: id, silent });
- const group = this.getGroup(id);
+ const db = await initDb()
+ await apiUpdateGroupMember({ groupId: id, silent })
+ const group = this.getGroup(id)
if (!group) {
- return;
+ return
}
- group.silent = silent;
- const conversationStore = useConversationStore();
- conversationStore.updateConversation(ImConversationType.GROUP, id, {
- silent,
- });
- this.saveGroup(group);
+ group.silent = silent
+ const conversationStore = useConversationStore()
+ conversationStore.updateConversation(ImConversationType.GROUP, id, { silent }, db)
+ this.saveGroup(group, db)
},
/** 批量更新群成员角色;本地不命中则忽略,等 fetchGroupMemberList 兜底 */
@@ -607,25 +777,26 @@ export const useGroupStore = defineStore('imGroupStore', {
groupId: number,
userIds: number[],
role: number,
+ db: DbClient = getDb()
) {
- const group = this.getGroup(groupId);
+ const group = this.getGroup(groupId)
if (!group?.members?.length) {
- return;
+ return
}
// 命中目标且角色已变化才标记 changed,避免无变化时整数组重建触发响应式
- const idSet = new Set(userIds);
- let changed = false;
+ const idSet = new Set(userIds)
+ let changed = false
const newMembers = group.members.map((member) => {
if (!idSet.has(member.userId) || member.role === role) {
- return member;
+ return member
}
- changed = true;
- return { ...member, role };
- });
+ changed = true
+ return { ...member, role }
+ })
// 有变化才整组替换,让响应式只在真有更新时通知下游
if (changed) {
- group.members = newMembers;
- this.saveGroupMemberList(groupId);
+ group.members = newMembers
+ this.saveGroupMemberList(groupId, db)
}
},
@@ -634,52 +805,34 @@ export const useGroupStore = defineStore('imGroupStore', {
groupId: number,
oldOwnerId: number,
newOwnerId: number,
+ db: DbClient = getDb()
) {
- const group = this.getGroup(groupId);
+ const group = this.getGroup(groupId)
if (!group) {
- return;
+ return
}
if (group.ownerUserId !== newOwnerId) {
- group.ownerUserId = newOwnerId;
+ group.ownerUserId = newOwnerId
}
- this.updateGroupMemberRoleList(
- groupId,
- [oldOwnerId],
- ImGroupMemberRole.NORMAL,
- );
- this.updateGroupMemberRoleList(
- groupId,
- [newOwnerId],
- ImGroupMemberRole.OWNER,
- );
- this.saveGroup(group);
+ this.updateGroupMemberRoleList(groupId, [oldOwnerId], ImGroupMemberRole.NORMAL, db)
+ this.updateGroupMemberRoleList(groupId, [newOwnerId], ImGroupMemberRole.OWNER, db)
+ this.saveGroup(group, db)
},
/** 本地剔除群成员(GROUP_MEMBER_QUIT / KICK 事件);不命中则等 fetchGroupMemberList 兜底 */
- removeLocalGroupMemberList(groupId: number, userIds: number[]) {
- const group = this.getGroup(groupId);
- if (!group?.members?.length || userIds.length === 0) {
- return;
+ removeLocalGroupMemberList(groupId: number, userIds: number[], db: DbClient = getDb()) {
+ const group = this.getGroup(groupId)
+ if (!group?.members?.length || !userIds.length) {
+ return
}
- const idSet = new Set(userIds);
- const next = group.members.filter((member) => !idSet.has(member.userId));
+ const idSet = new Set(userIds)
+ const next = group.members.filter((member) => !idSet.has(member.userId))
if (next.length === group.members.length) {
- return;
+ return
}
- group.members = next;
- group.memberCount = next.length;
- this.saveGroupMemberList(groupId);
- },
-
- /** 本地更新群成员的 status(自己退群 / 被踢的本地预置;让 isMember 立即收敛到 stranger,不依赖 removeGroup 的整群移除) */
- updateGroupMemberStatus(groupId: number, userId: number, status: number) {
- const group = this.getGroup(groupId);
- const member = group?.members?.find((m) => m.userId === userId);
- if (!member || member.status === status) {
- return;
- }
- member.status = status;
- this.saveGroupMemberList(groupId);
+ group.members = next
+ group.memberCount = next.length
+ this.saveGroupMemberList(groupId, db)
},
/** 本地更新群成员的 displayUserName(GROUP_MEMBER_NICKNAME_UPDATE 事件);不命中则等 fetchGroupMemberList 兜底 */
@@ -687,36 +840,40 @@ export const useGroupStore = defineStore('imGroupStore', {
groupId: number,
userId: number,
displayUserName: string,
+ db: DbClient = getDb()
) {
- const group = this.getGroup(groupId);
- const member = group?.members?.find((m) => m.userId === userId);
+ const group = this.getGroup(groupId)
+ const member = group?.members?.find((m) => m.userId === userId)
if (!member || member.displayUserName === displayUserName) {
- return;
+ return
}
- member.displayUserName = displayUserName;
- this.saveGroupMemberList(groupId);
+ member.displayUserName = displayUserName
+ this.saveGroupMemberList(groupId, db)
},
/** 局部更新群字段(name / notice / avatar 等);未命中本地缓存时静默忽略,等 fetchGroupList 兜底;新值跟旧值都相同时跳过响应式 + IDB 写 */
- updateGroupFields(groupId: number, fields: Partial) {
- const group = this.getGroup(groupId);
+ updateGroupFields(groupId: number, fields: Partial, db: DbClient = getDb()) {
+ const group = this.getGroup(groupId)
if (!group) {
- return;
+ return
}
- const changed = (Object.keys(fields) as (keyof Group)[]).some(
- (k) => group[k] !== fields[k],
- );
+ const changed = (Object.keys(fields) as (keyof Group)[]).some((k) => group[k] !== fields[k])
if (!changed) {
- return;
+ return
}
- Object.assign(group, fields);
- const conversationStore = useConversationStore();
- conversationStore.updateConversation(ImConversationType.GROUP, groupId, {
- name: getGroupDisplayName(group),
- avatar: group.avatar,
- silent: group.silent,
- });
- this.saveGroup(group);
+ Object.assign(group, fields)
+ const conversationStore = useConversationStore()
+ conversationStore.updateConversation(
+ ImConversationType.GROUP,
+ groupId,
+ {
+ name: getGroupDisplayName(group),
+ avatar: group.avatar,
+ silent: group.silent
+ },
+ db
+ )
+ this.saveGroup(group, db)
},
/**
@@ -725,146 +882,174 @@ export const useGroupStore = defineStore('imGroupStore', {
* WebSocket 实时收走 messageStore.insertMessage 旁路调用
* store 里没缓存的群静默忽略,等 fetchGroupList 兜底
*/
- applyGroupNotification(groupId: number, type: number, content?: string) {
+ async applyGroupNotification(
+ groupId: number,
+ type: number,
+ content?: string,
+ messageId?: number,
+ db: DbClient = getDb()
+ ) {
if (!groupId) {
- return;
+ return
}
- let payload: GroupNotificationPayload;
+ await enqueueConversationWrite(
+ getClientConversationId(ImConversationType.GROUP, groupId),
+ () => this.applyGroupNotificationNow(groupId, type, content, messageId, db)
+ )
+ },
+
+ /** 实际应用群广播;调用方必须持有群会话写 lane */
+ async applyGroupNotificationNow(
+ groupId: number,
+ type: number,
+ content: string | undefined,
+ messageId: number | undefined,
+ db: DbClient
+ ) {
+ let payload: GroupNotificationPayload
try {
- payload = content ? JSON.parse(content) : {};
+ payload = content ? JSON.parse(content) : {}
} catch (error) {
console.warn(
'[IM groupStore] applyGroupNotification 解析 content 失败',
{ groupId, type, contentLength: content?.length ?? 0 },
- error,
- );
- return;
+ error
+ )
+ return
}
+ queueGroupListRefreshAfterPending(() => this.fetchGroupList(true))
switch (type) {
- case ImContentType.GROUP_ADMIN_ADD: {
+ case ImContentType.GROUP_ADMIN_ADD:
this.updateGroupMemberRoleList(
groupId,
payload.memberUserIds || [],
ImGroupMemberRole.ADMIN,
- );
- this.markGroupMembersExpired(groupId);
+ db
+ )
+ this.markGroupMembersExpired(groupId)
// 自己被加为管理员,原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
- if (isSelfInPayloadMembers(payload)) {
- refreshUnhandledGroupRequests();
+ if (isSelfInPayloadMembers(payload, db.userId)) {
+ refreshUnhandledGroupRequests()
}
- break;
- }
- case ImContentType.GROUP_ADMIN_REMOVE: {
+ break
+ case ImContentType.GROUP_ADMIN_REMOVE:
this.updateGroupMemberRoleList(
groupId,
payload.memberUserIds || [],
ImGroupMemberRole.NORMAL,
- );
- this.markGroupMembersExpired(groupId);
- if (isSelfInPayloadMembers(payload)) {
- refreshUnhandledGroupRequests();
+ db
+ )
+ this.markGroupMembersExpired(groupId)
+ if (isSelfInPayloadMembers(payload, db.userId)) {
+ refreshUnhandledGroupRequests()
}
- break;
- }
- case ImContentType.GROUP_BANNED: {
- this.updateGroupFields(groupId, { banned: !!payload.banned });
- break;
- }
- case ImContentType.GROUP_CANCEL_MUTED: {
- this.updateGroupFields(groupId, { mutedAll: false });
- break;
- }
- case ImContentType.GROUP_CREATE: {
- this.applyGroupCreateNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_DISSOLVE: {
- this.removeGroup(groupId);
- break;
- }
- case ImContentType.GROUP_INFO_UPDATE: {
- this.applyGroupInfoUpdateNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_MEMBER_CANCEL_MUTED: {
- this.applyGroupMemberCancelMutedNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_MEMBER_ENTER: {
- this.applyGroupMemberEnterNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_MEMBER_INVITE: {
- this.applyGroupMemberInviteNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_MEMBER_KICK: {
- this.applyGroupMemberKickNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_MEMBER_MUTED: {
- this.applyGroupMemberMutedNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: {
- this.applyGroupMemberNicknameUpdateNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_MEMBER_QUIT: {
- this.applyGroupMemberQuitNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_MESSAGE_PIN: {
- this.applyGroupMessagePinNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_MESSAGE_UNPIN: {
- this.applyGroupMessageUnpinNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_MUTED: {
- this.updateGroupFields(groupId, { mutedAll: true });
- break;
- }
- case ImContentType.GROUP_NAME_UPDATE: {
- this.applyGroupNameUpdateNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_NOTICE_UPDATE: {
- this.applyGroupNoticeUpdateNotification(groupId, payload);
- break;
- }
- case ImContentType.GROUP_OWNER_TRANSFER: {
- this.applyGroupOwnerTransferNotification(groupId, payload);
- break;
- }
+ break
+ case ImContentType.GROUP_BANNED:
+ this.updateGroupFields(groupId, { banned: !!payload.banned }, db)
+ break
+ case ImContentType.GROUP_CANCEL_MUTED:
+ this.updateGroupFields(groupId, { mutedAll: false }, db)
+ break
+ case ImContentType.GROUP_CREATE:
+ await this.applyGroupCreateNotificationNow(groupId, payload, messageId, db)
+ break
+ case ImContentType.GROUP_DISSOLVE:
+ await this.removeGroupNow(groupId, messageId, db)
+ break
+ case ImContentType.GROUP_INFO_UPDATE:
+ this.applyGroupInfoUpdateNotification(groupId, payload, db)
+ break
+ case ImContentType.GROUP_MEMBER_CANCEL_MUTED:
+ this.applyGroupMemberCancelMutedNotification(groupId, payload, db)
+ break
+ case ImContentType.GROUP_MEMBER_ENTER:
+ await this.applyGroupMemberEnterNotificationNow(groupId, payload, messageId, db)
+ break
+ case ImContentType.GROUP_MEMBER_INVITE:
+ await this.applyGroupMemberInviteNotificationNow(groupId, payload, messageId, db)
+ break
+ case ImContentType.GROUP_MEMBER_KICK:
+ await this.applyGroupMemberKickNotificationNow(groupId, payload, messageId, db)
+ break
+ case ImContentType.GROUP_MEMBER_MUTED:
+ this.applyGroupMemberMutedNotification(groupId, payload, db)
+ break
+ case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE:
+ this.applyGroupMemberNicknameUpdateNotification(groupId, payload, db)
+ break
+ case ImContentType.GROUP_MEMBER_QUIT:
+ await this.applyGroupMemberQuitNotificationNow(groupId, payload, messageId, db)
+ break
+ case ImContentType.GROUP_MESSAGE_PIN:
+ this.applyGroupMessagePinNotification(groupId, payload, db)
+ break
+ case ImContentType.GROUP_MESSAGE_UNPIN:
+ this.applyGroupMessageUnpinNotification(groupId, payload, db)
+ break
+ case ImContentType.GROUP_MUTED:
+ this.updateGroupFields(groupId, { mutedAll: true }, db)
+ break
+ case ImContentType.GROUP_NAME_UPDATE:
+ this.applyGroupNameUpdateNotification(groupId, payload, db)
+ break
+ case ImContentType.GROUP_NOTICE_UPDATE:
+ this.applyGroupNoticeUpdateNotification(groupId, payload, db)
+ break
+ case ImContentType.GROUP_OWNER_TRANSFER:
+ this.applyGroupOwnerTransferNotification(groupId, payload, db.userId, db)
+ break
}
},
+ /** 实际恢复群关系;调用方必须持有群会话写 lane */
+ async reopenGroupRelationNow(
+ groupId: number,
+ messageId: number | undefined,
+ db: DbClient
+ ): Promise {
+ const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId)
+ if (!reopenRelation(clientConversationId, messageId)) {
+ return false
+ }
+ groupRelationVersions.set(groupId, ++groupRelationVersionSequence)
+ const conversation = useConversationStore().getConversation(ImConversationType.GROUP, groupId)
+ if (!conversation?.deleted) {
+ return true
+ }
+ const nextConversation = { ...conversation, deleted: false }
+ await useConversationStore().saveConversationRecord(nextConversation, undefined, db)
+ useConversationStore().publishConversationProjection(nextConversation, true)
+ return true
+ },
+
/** 创建群广播:群未就位时拉群详情 */
- async applyGroupCreateNotification(
+ async applyGroupCreateNotificationNow(
groupId: number,
payload: GroupNotificationPayload,
+ messageId: number | undefined,
+ db: DbClient = getDb()
) {
- if (!isSelfInPayloadMembers(payload)) {
- return;
+ if (!isSelfInPayloadMembers(payload, db.userId)) {
+ return
}
- const selfUserId = getCurrentUserId();
- const selfIsOperator =
- !!selfUserId && payload.operatorUserId === selfUserId;
+ if (!(await this.reopenGroupRelationNow(groupId, messageId, db))) {
+ return
+ }
+ const selfIsOperator = payload.operatorUserId === db.userId
if (selfIsOperator && this.getGroup(groupId)) {
- return;
+ return
}
- await this.fetchGroupInfo(groupId, true);
+ void this.fetchGroupInfo(groupId, true, db)
},
/** 群名变更:按 newName 局部更新本地群名 */
applyGroupNameUpdateNotification(
groupId: number,
payload: GroupNotificationPayload,
+ db: DbClient = getDb()
) {
if (payload.newName) {
- this.updateGroupFields(groupId, { name: payload.newName });
+ this.updateGroupFields(groupId, { name: payload.newName }, db)
}
},
@@ -872,96 +1057,113 @@ export const useGroupStore = defineStore('imGroupStore', {
applyGroupNoticeUpdateNotification(
groupId: number,
payload: GroupNotificationPayload,
+ db: DbClient = getDb()
) {
- this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' });
+ this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' }, db)
},
/** 群信息变更:同步头像、进群审批 */
applyGroupInfoUpdateNotification(
groupId: number,
payload: GroupNotificationPayload,
+ db: DbClient = getDb()
) {
- const fields: Partial = {};
+ const fields: Partial = {}
if (payload.newAvatar) {
- fields.avatar = payload.newAvatar;
+ fields.avatar = payload.newAvatar
}
- if (payload.newJoinApproval !== null) {
- fields.joinApproval = payload.newJoinApproval;
+ if (payload.newJoinApproval != null) {
+ fields.joinApproval = payload.newJoinApproval
}
if (Object.keys(fields).length > 0) {
- this.updateGroupFields(groupId, fields);
+ this.updateGroupFields(groupId, fields, db)
}
},
/** 成员加入:被邀请者本端 group 未就位先 fetchGroupInfo 初次拉取;所有人都刷成员列表(新成员 nickname / avatar 不在 payload) */
- async applyGroupMemberInviteNotification(
+ async applyGroupMemberInviteNotificationNow(
groupId: number,
payload: GroupNotificationPayload,
+ messageId: number | undefined,
+ db: DbClient = getDb()
) {
- // 自己刚被拉进来:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空
- if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) {
- await this.fetchGroupInfo(groupId, true);
+ const selfJoined = isSelfInPayloadMembers(payload, db.userId)
+ if (selfJoined) {
+ if (!(await this.reopenGroupRelationNow(groupId, messageId, db))) {
+ return
+ }
}
- this.markGroupMembersExpired(groupId);
- this.fetchGroupMemberList(groupId, true).catch(() => undefined);
+ // 当前 lane 结束后再补群详情,避免 fetchGroupInfo 重入同一会话 lane
+ if (selfJoined && !this.getGroup(groupId)) {
+ void this.fetchGroupInfo(groupId, true, db).then((group) => {
+ if (!group) {
+ return
+ }
+ this.markGroupMembersExpired(groupId)
+ void this.fetchGroupMemberList(groupId, true, db).catch(() => undefined)
+ })
+ return
+ }
+ this.markGroupMembersExpired(groupId)
+ void this.fetchGroupMemberList(groupId, true, db).catch(() => undefined)
},
/** 自由进群:进群者本端 group 未就位先 fetchGroupInfo 初次拉取;所有人都刷成员列表 */
- async applyGroupMemberEnterNotification(
+ async applyGroupMemberEnterNotificationNow(
groupId: number,
payload: GroupNotificationPayload,
+ messageId: number | undefined,
+ db: DbClient = getDb()
) {
- const selfUserId = getCurrentUserId();
- // 自己自由进群:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空
- if (
- selfUserId &&
- payload.entrantUserId === selfUserId &&
- !this.getGroup(groupId)
- ) {
- await this.fetchGroupInfo(groupId, true);
+ const selfJoined = payload.entrantUserId === db.userId
+ if (selfJoined) {
+ if (!(await this.reopenGroupRelationNow(groupId, messageId, db))) {
+ return
+ }
}
- this.markGroupMembersExpired(groupId);
- this.fetchGroupMemberList(groupId, true).catch(() => undefined);
+ // 当前 lane 结束后再补群详情,避免 fetchGroupInfo 重入同一会话 lane
+ if (selfJoined && !this.getGroup(groupId)) {
+ void this.fetchGroupInfo(groupId, true, db).then((group) => {
+ if (!group) {
+ return
+ }
+ this.markGroupMembersExpired(groupId)
+ void this.fetchGroupMemberList(groupId, true, db).catch(() => undefined)
+ })
+ return
+ }
+ this.markGroupMembersExpired(groupId)
+ void this.fetchGroupMemberList(groupId, true, db).catch(() => undefined)
},
/** 成员退群:退群者本人先把 self.status 置 DISABLE 再 removeGroup(保留状态语义 + 维持 groups 列表干净);其他成员从本地列表移除 quitter */
- applyGroupMemberQuitNotification(
+ async applyGroupMemberQuitNotificationNow(
groupId: number,
payload: GroupNotificationPayload,
+ messageId: number | undefined,
+ db: DbClient = getDb()
) {
- const selfUserId = getCurrentUserId();
- if (selfUserId && payload.operatorUserId === selfUserId) {
- this.updateGroupMemberStatus(
- groupId,
- selfUserId,
- CommonStatusEnum.DISABLE,
- );
- this.removeGroup(groupId);
+ if (payload.operatorUserId === db.userId) {
+ await this.removeGroupNow(groupId, messageId, db)
} else if (payload.operatorUserId) {
- this.removeLocalGroupMemberList(groupId, [payload.operatorUserId]);
- this.markGroupMembersExpired(groupId);
+ this.removeLocalGroupMemberList(groupId, [payload.operatorUserId], db)
+ this.markGroupMembersExpired(groupId)
}
},
/** 成员被移出:被踢者本人先把 self.status 置 DISABLE 再 removeGroup;其他成员从本地列表移除被踢者 */
- applyGroupMemberKickNotification(
+ async applyGroupMemberKickNotificationNow(
groupId: number,
payload: GroupNotificationPayload,
+ messageId: number | undefined,
+ db: DbClient = getDb()
) {
- const memberIds = payload.memberUserIds || [];
- const selfUserId = getCurrentUserId();
- if (isSelfInPayloadMembers(payload)) {
- if (selfUserId) {
- this.updateGroupMemberStatus(
- groupId,
- selfUserId,
- CommonStatusEnum.DISABLE,
- );
- }
- this.removeGroup(groupId);
- } else if (memberIds.length > 0) {
- this.removeLocalGroupMemberList(groupId, memberIds);
- this.markGroupMembersExpired(groupId);
+ const memberIds = payload.memberUserIds || []
+ if (isSelfInPayloadMembers(payload, db.userId)) {
+ await this.removeGroupNow(groupId, messageId, db)
+ } else if (memberIds.length) {
+ this.removeLocalGroupMemberList(groupId, memberIds, db)
+ this.markGroupMembersExpired(groupId)
}
},
@@ -969,14 +1171,16 @@ export const useGroupStore = defineStore('imGroupStore', {
applyGroupMemberNicknameUpdateNotification(
groupId: number,
payload: GroupNotificationPayload,
+ db: DbClient = getDb()
) {
if (payload.operatorUserId) {
this.updateGroupMemberDisplayUserName(
groupId,
payload.operatorUserId,
payload.displayUserName ?? '',
- );
- this.markGroupMembersExpired(groupId);
+ db
+ )
+ this.markGroupMembersExpired(groupId)
}
},
@@ -984,21 +1188,18 @@ export const useGroupStore = defineStore('imGroupStore', {
applyGroupOwnerTransferNotification(
groupId: number,
payload: GroupNotificationPayload,
+ userId: number,
+ db: DbClient = getDb()
) {
if (payload.operatorUserId && payload.newOwnerUserId) {
- this.transferGroupOwner(
- groupId,
- payload.operatorUserId,
- payload.newOwnerUserId,
- );
- this.markGroupMembersExpired(groupId);
+ this.transferGroupOwner(groupId, payload.operatorUserId, payload.newOwnerUserId, db)
+ this.markGroupMembersExpired(groupId)
}
// 自己接管群主:原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
- const selfUserId = getCurrentUserId();
- if (selfUserId && payload.newOwnerUserId === selfUserId) {
- refreshUnhandledGroupRequests();
- } else if (selfUserId && payload.operatorUserId === selfUserId) {
- refreshUnhandledGroupRequests();
+ if (payload.newOwnerUserId === userId) {
+ refreshUnhandledGroupRequests()
+ } else if (payload.operatorUserId === userId) {
+ refreshUnhandledGroupRequests()
}
},
@@ -1006,19 +1207,20 @@ export const useGroupStore = defineStore('imGroupStore', {
applyGroupMessagePinNotification(
groupId: number,
payload: GroupNotificationPayload,
+ db: DbClient = getDb()
) {
- const message = payload.message;
+ const message = payload.message
if (!message) {
- return;
+ return
}
- const group = this.getGroup(groupId);
+ const group = this.getGroup(groupId)
if (!group) {
- return;
+ return
}
// 幂等:已存在同 messageId 不重复 push
- const existing = group.pinnedMessages || [];
+ const existing = group.pinnedMessages || []
if (existing.some((msg) => msg.id === message.id)) {
- return;
+ return
}
group.pinnedMessages = [
...existing,
@@ -1031,51 +1233,47 @@ export const useGroupStore = defineStore('imGroupStore', {
status: ImMessageStatus.NORMAL,
sendTime: new Date(message.sendTime).getTime(),
targetId: message.groupId || groupId,
- selfSend: message.senderId === getCurrentUserId(),
+ selfSend: message.senderId === db.userId,
atUserIds: message.atUserIds ? [...message.atUserIds] : [],
- receiverUserIds: message.receiverUserIds
- ? [...message.receiverUserIds]
- : [],
- },
- ];
- this.saveGroup(group);
+ receiverUserIds: message.receiverUserIds ? [...message.receiverUserIds] : []
+ }
+ ]
+ this.saveGroup(group, db)
},
/** 群消息取消置顶:按 messageId 从本地置顶列表中移除 */
applyGroupMessageUnpinNotification(
groupId: number,
payload: GroupNotificationPayload,
+ db: DbClient = getDb()
) {
if (!payload.messageId) {
- return;
+ return
}
- const group = this.getGroup(groupId);
+ const group = this.getGroup(groupId)
if (!group?.pinnedMessages?.length) {
- return;
+ return
}
- const newPinnedMessages = group.pinnedMessages.filter(
- (m) => m.id !== payload.messageId,
- );
+ const newPinnedMessages = group.pinnedMessages.filter((m) => m.id !== payload.messageId)
if (newPinnedMessages.length === group.pinnedMessages.length) {
- return;
+ return
}
- group.pinnedMessages = newPinnedMessages;
- this.saveGroup(group);
+ group.pinnedMessages = newPinnedMessages
+ this.saveGroup(group, db)
},
/** 单成员禁言:更新目标成员的 muteEndTime */
applyGroupMemberMutedNotification(
groupId: number,
payload: GroupNotificationPayload,
+ db: DbClient = getDb()
) {
- const group = this.getGroup(groupId);
- const member = group?.members?.find(
- (m) => m.userId === payload.mutedUserId,
- );
+ const group = this.getGroup(groupId)
+ const member = group?.members?.find((m) => m.userId === payload.mutedUserId)
if (member && payload.muteEndTime) {
- member.muteEndTime = payload.muteEndTime;
- this.saveGroupMemberList(groupId);
- this.markGroupMembersExpired(groupId);
+ member.muteEndTime = payload.muteEndTime
+ this.saveGroupMemberList(groupId, db)
+ this.markGroupMembersExpired(groupId)
}
},
@@ -1083,33 +1281,34 @@ export const useGroupStore = defineStore('imGroupStore', {
applyGroupMemberCancelMutedNotification(
groupId: number,
payload: GroupNotificationPayload,
+ db: DbClient = getDb()
) {
- const group = this.getGroup(groupId);
- const member = group?.members?.find(
- (m) => m.userId === payload.mutedUserId,
- );
+ const group = this.getGroup(groupId)
+ const member = group?.members?.find((m) => m.userId === payload.mutedUserId)
if (member) {
- member.muteEndTime = undefined;
- this.saveGroupMemberList(groupId);
- this.markGroupMembersExpired(groupId);
+ member.muteEndTime = undefined
+ this.saveGroupMemberList(groupId, db)
+ this.markGroupMembersExpired(groupId)
}
},
/** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */
clear() {
- this.groups = [];
- this.loaded = false;
- this.groupMembersExpired = false;
- // 账号切换:递增 epoch 废弃旧账号 in-flight 的成员请求
- storeEpoch++;
+ this.groups = []
+ this.loaded = false
+ this.groupMembersExpired = false
// 单飞表跟 in-memory state 一起重置;旧账号 in-flight 的请求 finally 也会自己 delete key,提前清空只是更干脆
- pendingMemberFetches.clear();
- pendingSingleMemberFetches.clear();
- },
- },
-});
+ pendingMemberFetches.clear()
+ queuedMemberRefreshes.clear()
+ pendingSingleMemberFetches.clear()
+ pendingGroupInfoFetches.clear()
+ queuedGroupInfoRefreshes.clear()
+ groupRelationVersions.clear()
+ }
+ }
+})
-function convertGroup(group: ImGroupApi.GroupRespVO): Group {
+function convertGroup(group: ImGroupRespVO, currentUserId: number): Group {
return {
id: group.id,
name: group.name,
@@ -1117,22 +1316,22 @@ function convertGroup(group: ImGroupApi.GroupRespVO): Group {
notice: group.notice,
ownerUserId: group.ownerUserId,
pinnedMessages: group.pinnedMessages?.map((message) =>
- convertGroupMessageVO(message),
+ convertGroupMessageVO(message, currentUserId)
),
mutedAll: group.mutedAll,
banned: group.banned,
joinApproval: group.joinApproval,
joinStatus: group.joinStatus,
groupRemark: group.groupRemark,
- silent: group.silent,
- };
+ silent: group.silent
+ }
}
-/** 后端 ImGroupMessageApi.GroupMessageRespVO -> 前端 Message:补 targetId / selfSend / sendTime 等派生字段 */
+/** 后端 ImGroupMessageRespVO -> 前端 Message:补 targetId / selfSend / sendTime 等派生字段 */
function convertGroupMessageVO(
- message: NonNullable[number],
+ message: NonNullable[number],
+ currentUserId: number
): Message {
- const currentUserId = getCurrentUserId();
return {
id: message.id,
clientMessageId: message.clientMessageId || '',
@@ -1146,14 +1345,11 @@ function convertGroupMessageVO(
atUserIds: message.atUserIds || [],
receiverUserIds: message.receiverUserIds || [],
receiptStatus: message.receiptStatus,
- readCount: message.readCount,
- };
+ readCount: message.readCount
+ }
}
-function convertGroupMember(
- member: ImGroupMemberApi.GroupMemberRespVO,
- groupId: number,
-): GroupMember {
+function convertGroupMember(member: ImGroupMemberRespVO, groupId: number): GroupMember {
return {
id: member.id,
userId: member.userId,
@@ -1163,13 +1359,11 @@ function convertGroupMember(
displayUserName: member.displayUserName,
status: member.status,
role: member.role,
- muteEndTime: member.muteEndTime,
- };
+ muteEndTime: member.muteEndTime
+ }
}
-export const useGroupStoreWithOut = () => useGroupStore();
-
// dev: 让 Pinia 的 actions 改动支持 HMR,免去每次改 store 都要硬刷
if (import.meta.hot) {
- import.meta.hot.accept(acceptHMRUpdate(useGroupStore, import.meta.hot));
+ import.meta.hot.accept(acceptHMRUpdate(useGroupStore, import.meta.hot))
}
diff --git a/apps/web-antd/src/views/im/home/store/messageStore.ts b/apps/web-antd/src/views/im/home/store/messageStore.ts
index a9a424936..268b56aaa 100644
--- a/apps/web-antd/src/views/im/home/store/messageStore.ts
+++ b/apps/web-antd/src/views/im/home/store/messageStore.ts
@@ -1,12 +1,6 @@
-import type {
- DbTransaction,
- MessageDOPageCursor,
-} from '../../utils/db';
-import type { Conversation, Message, MessageDO } from '../types';
+import type { Conversation, ConversationDO, Message, MessageDO } from '../types'
-import { acceptHMRUpdate, defineStore } from 'pinia';
-
-import { getCurrentUserId } from '#/views/im/utils/auth';
+import { acceptHMRUpdate, defineStore } from 'pinia'
import {
IM_AT_ALL_USER_ID,
@@ -15,108 +9,210 @@ import {
ImMessageReceiptStatus,
ImMessageStatus,
isGroupNotification,
- isNormalMessage,
-} from '../../utils/constants';
-import { resolveConversationLastContent } from '../../utils/conversation';
+ isNormalMessage
+} from '../../utils/constants'
+import { resolveConversationLastContent } from '../../utils/conversation'
import {
+ type DbClient,
+ type DbTransaction,
getClientConversationId,
getClientMessageKey,
getDb,
getServerMessageKey,
+ type MessageDOPageCursor,
parseClientConversationId,
setMessageMaxId,
- StorageKeys,
-} from '../../utils/db';
+ StorageKeys
+} from '../../utils/db'
import {
generateClientMessageId,
+ parseMessage,
parseRecallMessageId,
revokeBlobUrlsInContent,
-} from '../../utils/message';
-import { isGroupQuit, tryGetSenderDisplayName } from '../../utils/user';
-import { useConversationStore } from './conversationStore';
-import { useGroupStore } from './groupStore';
+ serializeMessage
+} from '../../utils/message'
+import {
+ enqueueConversationWrite,
+ enqueueConversationWrites,
+ isRelationTerminated,
+ MessageTerminalPriority,
+ reduceMessageState
+} from '../../utils/messageSync'
+import { isGroupQuit, tryGetSenderDisplayName } from '../../utils/user'
+import { useConversationStore } from './conversationStore'
+import { useGroupStore } from './groupStore'
-const MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT = 5;
-const MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT =
- MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT + 1;
-const ackMergingPromises = new Map>();
+const MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT = 5
+const MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT = MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT + 1
+const ackMergingPromises = new Map>()
interface MessageConversationInfo {
- type: number;
- targetId: number;
- name: string;
- avatar: string;
- silent?: boolean;
+ type: number
+ targetId: number
+ name: string
+ avatar: string
+ silent?: boolean
}
interface PersistMessageRecordOptions {
- mergeClientRecord?: boolean;
+ mergeClientRecord?: boolean
}
interface MessagePageResult {
- messages: Message[];
- hasMore: boolean;
+ messages: Message[]
+ hasMore: boolean
+}
+
+interface ConversationMessageTerminal {
+ clearBefore: number
+ deletedKeys: Set
+ recalledKeys: Set
+}
+
+interface RecallMessageProjection {
+ conversation: Conversation
+ message: Message
+ cachedMessage?: Message
}
/** 拉取消息批量处理项 */
export type PulledMessage =
| {
- conversationInfo: MessageConversationInfo;
- kind: 'insert';
- message: Message;
+ conversationInfo: MessageConversationInfo
+ kind: 'insert'
+ message: Message
}
| {
- conversationType: number;
- kind: 'recall';
- recallSignalContent: string;
- targetId: number;
- };
-
-/** 获取会话的消息缓存 key */
-function getMessageCacheKey(type: number, targetId: number): string {
- return getClientConversationId(type, targetId);
-}
+ conversationType: number
+ kind: 'recall'
+ recallSignalContent: string
+ targetId: number
+ }
/** 生成消息本地主键 */
function getMessageKey(
message: Pick,
- conversationType: number,
+ conversationType: number
): string {
return message.id
? getServerMessageKey(conversationType, message.id)
- : getClientMessageKey(message.clientMessageId);
+ : getClientMessageKey(message.clientMessageId)
+}
+
+/** 判断消息是否已被当前设备清理或删除 */
+function isMessageTerminated(message: Message, terminal: ConversationMessageTerminal): boolean {
+ return (
+ (!!message.id && message.id <= terminal.clearBefore) ||
+ (!!message.id && terminal.deletedKeys.has(`id:${message.id}`)) ||
+ terminal.deletedKeys.has(`client:${message.clientMessageId}`)
+ )
+}
+
+/** 把已持久化的撤回 marker 应用到迟到的普通消息 */
+function applyPersistedRecall(message: Message, terminal: ConversationMessageTerminal): Message {
+ if (!message.id || !terminal.recalledKeys.has(`id:${message.id}`)) {
+ return message
+ }
+ return {
+ ...message,
+ type: ImContentType.RECALL,
+ content: '',
+ status: ImMessageStatus.RECALL
+ }
+}
+
+/** 读取会话持久化终态 */
+async function getConversationMessageTerminal(
+ clientConversationId: string,
+ db: DbClient
+): Promise {
+ const [clearBefore, deletedKeys, recalledKeys] = await Promise.all([
+ db.getSetting(
+ `${StorageKeys.settings.conversationClearBeforePrefix}${clientConversationId}`
+ ),
+ db.getSetting(
+ `${StorageKeys.settings.conversationDeletedMessagesPrefix}${clientConversationId}`
+ ),
+ db.getSetting(
+ `${StorageKeys.settings.conversationRecalledMessagesPrefix}${clientConversationId}`
+ )
+ ])
+ return {
+ clearBefore: clearBefore || 0,
+ deletedKeys: new Set(deletedKeys || []),
+ recalledKeys: new Set(recalledKeys || [])
+ }
+}
+
+/** 获取普通消息与撤回终态的归约优先级 */
+function getMessageTerminalPriority(message: Pick) {
+ if (message.type === ImContentType.RECALL || message.status === ImMessageStatus.RECALL) {
+ return MessageTerminalPriority.RECALL
+ }
+ return message.status === ImMessageStatus.NORMAL && !!message.id
+ ? MessageTerminalPriority.CONFIRMED
+ : MessageTerminalPriority.NORMAL
}
/** 获取数据库分页结果的最早游标 */
function getMessageDOPageCursor(message?: MessageDO): MessageDOPageCursor | undefined {
return message
? {
- messageKey: message.messageKey,
sendTime: message.sendTime,
+ messageKey: message.messageKey
}
- : undefined;
+ : undefined
}
/** 判断两个消息分页游标是否一致 */
function isSameMessageDOPageCursor(
left?: MessageDOPageCursor,
- right?: MessageDOPageCursor,
+ right?: MessageDOPageCursor
): boolean {
if (!left || !right) {
- return left === right;
+ return left === right
}
- return left.sendTime === right.sendTime && left.messageKey === right.messageKey;
+ return left.sendTime === right.sendTime && left.messageKey === right.messageKey
}
/** 补齐客户端消息编号 */
function ensureClientMessageId(message: Message): Message {
if (!message.clientMessageId) {
- message.clientMessageId = generateClientMessageId();
+ message.clientMessageId = generateClientMessageId()
}
if (!message.id) {
- message.id = undefined;
+ message.id = undefined
+ }
+ return message
+}
+
+/** 媒体占位只持久化恢复标记,不把本次运行的 blob URL 写成可重发内容 */
+function getPersistentMessageContent(message: Message): string {
+ if (message.status !== ImMessageStatus.SENDING || !message._localFile) {
+ return message.content
+ }
+ const payload = parseMessage>(message.content)
+ if (!payload) {
+ return message.content
+ }
+ const { url: _localUrl, coverUrl: _localCoverUrl, ...persistedPayload } = payload
+ return serializeMessage({ ...persistedPayload, _uploadPending: true })
+}
+
+/** 将重启前未完成的本地消息降级为可识别的失败态 */
+function recoverPendingMessage(message: MessageDO): MessageDO {
+ if (message.status !== ImMessageStatus.SENDING) {
+ return message
+ }
+ const payload = parseMessage>(message.content)
+ if (!payload?._uploadPending) {
+ return { ...message, status: ImMessageStatus.FAILED }
+ }
+ return {
+ ...message,
+ content: serializeMessage({ ...payload, _uploadPending: false, _uploadFailed: true }),
+ status: ImMessageStatus.FAILED
}
- return message;
}
/** 转换为 IndexedDB 消息记录 */
@@ -125,14 +221,12 @@ function buildMessageDO(message: Message, conversationType: number): MessageDO {
id: message.id,
clientMessageId: message.clientMessageId,
type: message.type,
- content: message.content,
+ content: getPersistentMessageContent(message),
status: message.status,
sendTime: message.sendTime,
senderId: message.senderId,
atUserIds: message.atUserIds ? [...message.atUserIds] : undefined,
- receiverUserIds: message.receiverUserIds
- ? [...message.receiverUserIds]
- : undefined,
+ receiverUserIds: message.receiverUserIds ? [...message.receiverUserIds] : undefined,
receiptStatus: message.receiptStatus,
readCount: message.readCount,
materialId: message.materialId,
@@ -140,11 +234,8 @@ function buildMessageDO(message: Message, conversationType: number): MessageDO {
selfSend: message.selfSend,
messageKey: getMessageKey(message, conversationType),
conversationType,
- clientConversationId: getClientConversationId(
- conversationType,
- message.targetId,
- ),
- };
+ clientConversationId: getClientConversationId(conversationType, message.targetId)
+ }
}
/** IndexedDB 消息记录转前端消息 */
@@ -154,106 +245,127 @@ function buildMessageFromDO(message: MessageDO): Message {
conversationType: _conversationType,
clientConversationId: _clientConversationId,
...rest
- } = message;
- return rest;
+ } = message
+ return rest
+}
+
+/** IndexedDB 会话记录转前端会话 */
+function buildConversationFromDO(conversation: ConversationDO): Conversation {
+ const { clientConversationId: _clientConversationId, ...rest } = conversation
+ return rest
}
/** 算出末条消息的发送人快照 */
function deriveLastSenderDisplayName(
conversation: Conversation,
senderId: number,
+ db: DbClient
): string | undefined {
// 1. 优先使用当前内存中的好友 / 群成员信息
- const liveSenderName = tryGetSenderDisplayName(
- senderId,
- conversation.type,
- conversation.targetId,
- );
+ const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
if (liveSenderName) {
- return liveSenderName;
+ return liveSenderName
}
// 2. 群成员缓存缺失时异步补齐
if (conversation.type === ImConversationType.GROUP) {
- const groupStore = useGroupStore();
- const group = groupStore.getGroup(conversation.targetId);
+ const groupStore = useGroupStore()
+ const group = groupStore.getGroup(conversation.targetId)
if (!group || isGroupQuit(group)) {
- return conversation.lastSenderId === senderId
- ? conversation.lastSenderDisplayName
- : undefined;
+ return conversation.lastSenderId === senderId ? conversation.lastSenderDisplayName : undefined
}
const fetchPromise =
group?.membersLoaded && !group.membersExpired
? groupStore.fetchGroupMember(conversation.targetId, senderId)
- : groupStore.fetchGroupMemberList(conversation.targetId);
- fetchPromise.catch((error) =>
+ : groupStore.fetchGroupMemberList(conversation.targetId, false, db)
+ fetchPromise.catch((e) =>
console.warn(
'[IM messageStore] 兜底拉群成员失败',
- {
- groupId: conversation.targetId,
- senderId,
- fullFetch: !group?.membersLoaded,
- },
- error,
- ),
- );
+ { groupId: conversation.targetId, senderId, fullFetch: !group?.membersLoaded },
+ e
+ )
+ )
}
- return conversation.lastSenderId === senderId
- ? conversation.lastSenderDisplayName
- : undefined;
+ return conversation.lastSenderId === senderId ? conversation.lastSenderDisplayName : undefined
}
/** 按消息更新会话摘要 */
function applyConversationSummary(
conversation: Conversation,
message: Message,
+ db: DbClient
): void {
- const senderDisplayName = deriveLastSenderDisplayName(
- conversation,
- message.senderId,
- );
+ const senderDisplayName = deriveLastSenderDisplayName(conversation, message.senderId, db)
conversation.lastContent = resolveConversationLastContent(
message,
conversation.type,
conversation.targetId,
- senderDisplayName,
- );
- conversation.lastSendTime = message.sendTime || Date.now();
- conversation.lastSenderId = message.senderId;
- conversation.lastMessageType = message.type;
- conversation.lastMessageId = message.id;
- conversation.lastClientMessageId = message.clientMessageId;
- conversation.lastMessageStatus = message.status;
- conversation.lastReceiptStatus = message.receiptStatus;
- conversation.lastSelfSend = message.selfSend;
- conversation.lastSenderDisplayName = senderDisplayName;
+ senderDisplayName
+ )
+ conversation.lastSendTime = message.sendTime || Date.now()
+ conversation.lastSenderId = message.senderId
+ conversation.lastMessageType = message.type
+ conversation.lastMessageId = message.id
+ conversation.lastClientMessageId = message.clientMessageId
+ conversation.lastMessageStatus = message.status
+ conversation.lastReceiptStatus = message.receiptStatus
+ conversation.lastSelfSend = message.selfSend
+ conversation.lastSenderDisplayName = senderDisplayName
+}
+
+/** 判断消息是否可以推进当前会话摘要 */
+function shouldUpdateConversationSummary(conversation: Conversation, message: Message): boolean {
+ if (message.id !== undefined && conversation.lastMessageId !== undefined) {
+ return message.id >= conversation.lastMessageId
+ }
+ return (
+ (!!message.clientMessageId && message.clientMessageId === conversation.lastClientMessageId) ||
+ (message.sendTime || 0) > (conversation.lastSendTime || 0)
+ )
+}
+
+/** 会话摘要顺序:服务端消息按 id,混合本地消息时按发送时间 */
+function compareConversationSummaryOrder(
+ left: Pick,
+ right: Pick
+): number {
+ if (left.id && right.id) {
+ return left.id - right.id
+ }
+ return (
+ (left.sendTime || 0) - (right.sendTime || 0) ||
+ Number(!!left.id) - Number(!!right.id) ||
+ left.clientMessageId.localeCompare(right.clientMessageId)
+ )
}
/** 按末条消息重算会话摘要 */
function recomputeConversationLast(
conversation: Conversation,
messages: Message[],
+ db: DbClient
): void {
- const last = messages[messages.length - 1];
+ const last = messages[messages.length - 1]
if (last) {
- applyConversationSummary(conversation, last);
- return;
+ applyConversationSummary(conversation, last, db)
+ return
}
- conversation.lastContent = '';
- conversation.lastSendTime = 0;
- conversation.lastSenderId = undefined;
- conversation.lastMessageType = undefined;
- conversation.lastMessageId = undefined;
- conversation.lastClientMessageId = undefined;
- conversation.lastMessageStatus = undefined;
- conversation.lastReceiptStatus = undefined;
- conversation.lastSelfSend = undefined;
- conversation.lastSenderDisplayName = undefined;
+ conversation.lastContent = ''
+ conversation.lastSendTime = 0
+ conversation.lastSenderId = undefined
+ conversation.lastMessageType = undefined
+ conversation.lastMessageId = undefined
+ conversation.lastClientMessageId = undefined
+ conversation.lastMessageStatus = undefined
+ conversation.lastReceiptStatus = undefined
+ conversation.lastSelfSend = undefined
+ conversation.lastSenderDisplayName = undefined
}
/** 同步群 @ 状态 */
function syncConversationAtFlags(
conversation: Conversation,
message: Message,
+ userId: number
): void {
if (
message.selfSend ||
@@ -261,68 +373,75 @@ function syncConversationAtFlags(
!message.atUserIds ||
message.atUserIds.length === 0
) {
- return;
+ return
}
- const currentUserId = getCurrentUserId();
- if (currentUserId && message.atUserIds.includes(currentUserId)) {
- conversation.atMe = true;
+ if (message.atUserIds.includes(userId)) {
+ conversation.atMe = true
if (message.id && message.id > (conversation.atMessageId || 0)) {
- conversation.atMessageId = message.id;
+ conversation.atMessageId = message.id
}
}
if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) {
- conversation.atAll = true;
+ conversation.atAll = true
if (message.id && message.id > (conversation.atAllMessageId || 0)) {
- conversation.atAllMessageId = message.id;
+ conversation.atAllMessageId = message.id
}
}
}
-/** 应用服务端消息更新 */
-function applyServerMessageUpdate(
- message: Message,
- updates: Partial,
-): void {
- if (updates.content && updates.content !== message.content) {
- revokeBlobUrlsInContent(message.content);
+/** 构建服务端消息更新后的下一份投影 */
+function buildServerMessageProjection(message: Message, updates: Partial): Message {
+ const next = { ...message, ...updates }
+ if (message.receiptStatus !== undefined) {
+ next.receiptStatus =
+ updates.receiptStatus === undefined
+ ? message.receiptStatus
+ : Math.max(message.receiptStatus, updates.receiptStatus)
+ }
+ if (message.readCount !== undefined) {
+ next.readCount =
+ updates.readCount === undefined
+ ? message.readCount
+ : Math.max(message.readCount, updates.readCount)
}
- Object.assign(message, updates);
if (updates.id === 0) {
- message.id = undefined;
+ next.id = undefined
}
- if (
- updates.status !== undefined &&
- updates.status !== ImMessageStatus.SENDING
- ) {
- message.uploadProgress = undefined;
+ if (updates.status !== undefined && updates.status !== ImMessageStatus.SENDING) {
+ next.uploadProgress = undefined
if (updates.status !== ImMessageStatus.FAILED) {
- message._localFile = undefined;
+ next._localFile = undefined
}
}
+ return next
+}
+
+/** 发布已成功持久化的服务端消息更新 */
+function applyServerMessageUpdate(message: Message, updates: Partial): void {
+ if (updates.content && updates.content !== message.content) {
+ revokeBlobUrlsInContent(message.content)
+ }
+ Object.assign(message, buildServerMessageProjection(message, updates))
}
/** 判断是否为同一条消息 */
function isSameMessage(left: Message, right: Message): boolean {
if (left.id && right.id && left.id === right.id) {
- return true;
+ return true
}
- return (
- !!left.clientMessageId && left.clientMessageId === right.clientMessageId
- );
+ return !!left.clientMessageId && left.clientMessageId === right.clientMessageId
}
export const useMessageStore = defineStore('imMessageStore', {
state: () => ({
messagesByConversation: {} as Record,
- messageDOPageCursors: {} as Record<
- string,
- MessageDOPageCursor | undefined
- >,
+ messageDOPageCursors: {} as Record,
loadedConversationKeys: [] as string[],
+ recoveredConversationKeys: [] as string[],
privateReadMaxIds: {} as Partial>,
privateMessageMaxId: 0,
groupMessageMaxId: 0,
- channelMessageMaxId: 0,
+ channelMessageMaxId: 0
}),
getters: {
@@ -330,7 +449,7 @@ export const useMessageStore = defineStore('imMessageStore', {
getMessages:
(state) =>
(clientConversationId: string): Message[] =>
- state.messagesByConversation[clientConversationId] || [],
+ state.messagesByConversation[clientConversationId] || []
},
actions: {
@@ -338,212 +457,228 @@ export const useMessageStore = defineStore('imMessageStore', {
clear() {
Object.values(this.messagesByConversation).forEach((messages) => {
messages.forEach((message) => {
- revokeBlobUrlsInContent(message.content);
- message._localFile = undefined;
- });
- });
- this.messagesByConversation = {};
- this.messageDOPageCursors = {};
- this.loadedConversationKeys = [];
- this.privateReadMaxIds = {};
- this.privateMessageMaxId = 0;
- this.groupMessageMaxId = 0;
- this.channelMessageMaxId = 0;
- ackMergingPromises.clear();
+ revokeBlobUrlsInContent(message.content)
+ message._localFile = undefined
+ })
+ })
+ this.messagesByConversation = {}
+ this.messageDOPageCursors = {}
+ this.loadedConversationKeys = []
+ this.recoveredConversationKeys = []
+ this.privateReadMaxIds = {}
+ this.privateMessageMaxId = 0
+ this.groupMessageMaxId = 0
+ this.channelMessageMaxId = 0
+ ackMergingPromises.clear()
},
/** 从 settings 加载消息游标 */
async loadMessageCursorList() {
- const db = getDb();
+ const db = getDb()
const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([
db.getSetting(StorageKeys.settings.privateMessageMaxId),
db.getSetting(StorageKeys.settings.groupMessageMaxId),
- db.getSetting(StorageKeys.settings.channelMessageMaxId),
- ]);
- this.privateMessageMaxId = privateMaxId || 0;
- this.groupMessageMaxId = groupMaxId || 0;
- this.channelMessageMaxId = channelMaxId || 0;
+ db.getSetting(StorageKeys.settings.channelMessageMaxId)
+ ])
+ this.privateMessageMaxId = privateMaxId || 0
+ this.groupMessageMaxId = groupMaxId || 0
+ this.channelMessageMaxId = channelMaxId || 0
},
/** 更新内存游标 */
updateMessageCursor(conversationType: number, messageId?: number) {
if (!messageId) {
- return;
+ return
}
- if (
- conversationType === ImConversationType.PRIVATE &&
- messageId > this.privateMessageMaxId
- ) {
- this.privateMessageMaxId = messageId;
+ if (conversationType === ImConversationType.PRIVATE && messageId > this.privateMessageMaxId) {
+ this.privateMessageMaxId = messageId
} else if (
conversationType === ImConversationType.GROUP &&
messageId > this.groupMessageMaxId
) {
- this.groupMessageMaxId = messageId;
+ this.groupMessageMaxId = messageId
} else if (
conversationType === ImConversationType.CHANNEL &&
messageId > this.channelMessageMaxId
) {
- this.channelMessageMaxId = messageId;
+ this.channelMessageMaxId = messageId
}
},
/** 获取私聊对方已读位置缓存 */
getPrivateReadMaxId(peerId: number): number | undefined {
- return this.privateReadMaxIds[peerId];
+ return this.privateReadMaxIds[peerId]
},
/** 更新私聊对方已读位置缓存 */
- updatePrivateReadMaxId(
- peerId: number,
- maxReadId: null | number = 0,
- ): number {
+ updatePrivateReadMaxId(peerId: number, maxReadId?: null | number): number {
if (!peerId) {
- return 0;
+ return 0
}
- const nextMaxReadId = maxReadId || 0;
- const current = this.getPrivateReadMaxId(peerId);
+ const nextMaxReadId = maxReadId || 0
+ const current = this.getPrivateReadMaxId(peerId)
if (current !== undefined && nextMaxReadId <= current) {
- return current;
+ return current
}
- this.privateReadMaxIds = {
- ...this.privateReadMaxIds,
- [peerId]: nextMaxReadId,
- };
- return nextMaxReadId;
+ this.privateReadMaxIds = { ...this.privateReadMaxIds, [peerId]: nextMaxReadId }
+ return nextMaxReadId
},
/** 清空私聊对方已读位置缓存 */
clearPrivateReadMaxIdCache(): void {
- this.privateReadMaxIds = {};
+ this.privateReadMaxIds = {}
},
/** 标记会话近期使用 */
touchConversationMessageCache(clientConversationId: string) {
this.loadedConversationKeys = [
clientConversationId,
- ...this.loadedConversationKeys.filter(
- (key) => key !== clientConversationId,
- ),
- ];
+ ...this.loadedConversationKeys.filter((key) => key !== clientConversationId)
+ ]
// 保留当前活跃会话 + 最近打开过的会话
- const retained = this.loadedConversationKeys.slice(
- 0,
- MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT,
- );
- const removed = this.loadedConversationKeys.slice(
- MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT,
- );
- this.loadedConversationKeys = retained;
+ const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT)
+ const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT)
+ this.loadedConversationKeys = retained
removed.forEach((key) => {
- Reflect.deleteProperty(this.messagesByConversation, key);
- Reflect.deleteProperty(this.messageDOPageCursors, key);
- });
+ delete this.messagesByConversation[key]
+ delete this.messageDOPageCursors[key]
+ })
},
/** 加载当前会话最近消息 */
async loadMoreMessageList(
clientConversationId: string,
limit = 50,
+ db: DbClient = getDb()
): Promise {
- const parsed = parseClientConversationId(clientConversationId);
+ return enqueueConversationWrite(clientConversationId, () =>
+ this.loadMoreMessageListNow(clientConversationId, limit, db)
+ )
+ },
+
+ /** 实际加载会话消息;调用方必须持有当前会话写 lane */
+ async loadMoreMessageListNow(
+ clientConversationId: string,
+ limit: number,
+ db: DbClient
+ ): Promise {
+ const parsed = parseClientConversationId(clientConversationId)
if (!parsed) {
- return { messages: [], hasMore: false };
+ return { messages: [], hasMore: false }
+ }
+ const before = this.messageDOPageCursors[clientConversationId]
+ const terminal = await getConversationMessageTerminal(clientConversationId, db)
+ if (!before && !this.recoveredConversationKeys.includes(clientConversationId)) {
+ await this.recoverPendingMessageListNow(clientConversationId, terminal, db)
}
- const before = this.messageDOPageCursors[clientConversationId];
// 1. 从 IndexedDB 倒序读取一页,返回前已按时间升序排列
- const page = await getDb().getMessageListByConversation(
- clientConversationId,
- {
- before,
- limit,
- },
- );
- if (
- isSameMessageDOPageCursor(
- this.messageDOPageCursors[clientConversationId],
- before,
- )
- ) {
- const nextCursor = getMessageDOPageCursor(page.list[0]);
+ const page = await db.getMessageListByConversation(clientConversationId, {
+ before,
+ limit
+ })
+ if (isSameMessageDOPageCursor(this.messageDOPageCursors[clientConversationId], before)) {
+ const nextCursor = getMessageDOPageCursor(page.list[0])
if (nextCursor) {
- this.messageDOPageCursors[clientConversationId] = nextCursor;
+ this.messageDOPageCursors[clientConversationId] = nextCursor
}
}
// 2. 合并到内存缓存,过滤已存在的消息
- const messages = page.list.map((message) =>
- buildMessageFromDO(message),
- );
- const existing = this.messagesByConversation[clientConversationId] || [];
- const existingKeys = new Set(
- existing.map((message) => getMessageKey(message, parsed.type)),
- );
+ const messages = page.list
+ .map(buildMessageFromDO)
+ .filter((message) => !isMessageTerminated(message, terminal))
+ .map((message) => applyPersistedRecall(message, terminal))
+ const existing = this.messagesByConversation[clientConversationId] || []
+ const existingKeys = new Set(existing.map((message) => getMessageKey(message, parsed.type)))
const fresh = messages.filter(
- (message) => !existingKeys.has(getMessageKey(message, parsed.type)),
- );
- this.messagesByConversation[clientConversationId] = [
- ...fresh,
- ...existing,
- ].toSorted(
- (messageA, messageB) =>
- (messageA.sendTime || 0) - (messageB.sendTime || 0),
- );
- this.touchConversationMessageCache(clientConversationId);
- return { messages: fresh, hasMore: page.hasMore };
+ (message) => !existingKeys.has(getMessageKey(message, parsed.type))
+ )
+ this.messagesByConversation[clientConversationId] = [...fresh, ...existing].sort(
+ (messageA, messageB) => (messageA.sendTime || 0) - (messageB.sendTime || 0)
+ )
+ if (!before && !this.recoveredConversationKeys.includes(clientConversationId)) {
+ this.recoveredConversationKeys.push(clientConversationId)
+ }
+ this.touchConversationMessageCache(clientConversationId)
+ return { messages: fresh, hasMore: page.hasMore }
+ },
+
+ /** 恢复当前会话未完成消息;读取、去重和写回均处于同一写 lane */
+ async recoverPendingMessageListNow(
+ clientConversationId: string,
+ terminal: ConversationMessageTerminal,
+ db: DbClient
+ ): Promise {
+ await db.transaction(['messages'], 'readwrite', async (tx) => {
+ const records = await db.getAllByIndex(
+ 'messages',
+ 'clientConversationId',
+ clientConversationId,
+ tx
+ )
+ const serverClientMessageIds = new Set(
+ records.filter((message) => !!message.id).map((message) => message.clientMessageId)
+ )
+ for (const record of records) {
+ const message = buildMessageFromDO(record)
+ if (
+ isMessageTerminated(message, terminal) ||
+ (!record.id && serverClientMessageIds.has(record.clientMessageId))
+ ) {
+ await db.delete('messages', record.messageKey, tx)
+ continue
+ }
+ const recovered = recoverPendingMessage(record)
+ const recoveredMessage = buildMessageFromDO(recovered)
+ const recalled = applyPersistedRecall(recoveredMessage, terminal)
+ if (recovered !== record || recalled !== recoveredMessage) {
+ await db.put('messages', buildMessageDO(recalled, record.conversationType), tx)
+ }
+ }
+ })
},
/** 确保会话消息已加载 */
async ensureConversationMessageListLoaded(conversation: Conversation) {
- const key = getMessageCacheKey(conversation.type, conversation.targetId);
- if (this.messagesByConversation[key]) {
- this.touchConversationMessageCache(key);
- return;
+ const key = getClientConversationId(conversation.type, conversation.targetId)
+ if (this.messagesByConversation[key] && this.recoveredConversationKeys.includes(key)) {
+ this.touchConversationMessageCache(key)
+ return
}
- await this.loadMoreMessageList(key);
+ await this.loadMoreMessageList(key)
},
/** 获取内存消息数组 */
getMessageList(conversationType: number, targetId: number): Message[] {
- const key = getMessageCacheKey(conversationType, targetId);
+ const key = getClientConversationId(conversationType, targetId)
if (!this.messagesByConversation[key]) {
- this.messagesByConversation[key] = [];
+ this.messagesByConversation[key] = []
}
- this.touchConversationMessageCache(key);
- return this.messagesByConversation[key];
+ this.touchConversationMessageCache(key)
+ return this.messagesByConversation[key]
},
/** 持久化消息记录 */
async saveMessageRecord(
message: Message,
conversationType: number,
- tx?: DbTransaction,
- options?: PersistMessageRecordOptions,
+ tx: DbTransaction | undefined,
+ options: PersistMessageRecordOptions | undefined,
+ db: DbClient
) {
- const db = getDb();
- const next = buildMessageDO(message, conversationType);
+ const next = buildMessageDO(message, conversationType)
// 服务端 key 替换 client key
if (options?.mergeClientRecord && message.id && message.clientMessageId) {
const existing = await db.getByIndex(
'messages',
'clientMessageId',
message.clientMessageId,
- tx,
- );
+ tx
+ )
if (existing && existing.messageKey !== next.messageKey) {
- await db.delete('messages', existing.messageKey, tx);
+ await db.delete('messages', existing.messageKey, tx)
}
}
- await db.put('messages', next, tx);
- },
-
- /** 保存消息游标 */
- async saveMessageCursor(
- conversationType: number,
- messageId?: number,
- tx?: DbTransaction,
- ) {
- await setMessageMaxId(conversationType, messageId, tx);
- this.updateMessageCursor(conversationType, messageId);
+ await db.put('messages', next, tx)
},
/** 应用撤回到本地消息与会话状态 */
@@ -552,360 +687,544 @@ export const useMessageStore = defineStore('imMessageStore', {
targetId: number,
recallSignalContent: string,
tx: DbTransaction,
+ staged: undefined | { conversation: Conversation; messages: Message[] },
+ db: DbClient
) {
// 1. 定位被撤回的原消息和会话
- const messageId = parseRecallMessageId(recallSignalContent);
+ const messageId = parseRecallMessageId(recallSignalContent)
if (!messageId) {
- return null;
+ return null
}
- const conversationStore = useConversationStore();
- const conversation = conversationStore.getConversation(
- conversationType,
- targetId,
- );
- if (!conversation) {
- return null;
+ const clientConversationId = getClientConversationId(conversationType, targetId)
+ const recallSettingKey = `${StorageKeys.settings.conversationRecalledMessagesPrefix}${clientConversationId}`
+ const recalledKeys = (await db.getSetting(recallSettingKey, tx)) || []
+ await db.setSetting(
+ recallSettingKey,
+ Array.from(new Set([...recalledKeys, `id:${messageId}`])),
+ tx
+ )
+ const conversationStore = useConversationStore()
+ const currentConversation =
+ staged?.conversation || conversationStore.getConversation(conversationType, targetId)
+ if (!currentConversation) {
+ return null
}
- const clientConversationId = getClientConversationId(
- conversationType,
- targetId,
- );
- const cachedMessage = this.messagesByConversation[
- clientConversationId
- ]?.find((item) => item.id === messageId);
- const storedMessage = await getDb().get(
+ const cachedMessage = (
+ staged?.messages || this.messagesByConversation[clientConversationId]
+ )?.find((item) => item.id === messageId)
+ const storedMessage = await db.get(
'messages',
getServerMessageKey(conversationType, messageId),
- tx,
- );
+ tx
+ )
const originalMessage = cachedMessage
? buildMessageDO(cachedMessage, conversationType)
- : storedMessage;
+ : storedMessage
if (!originalMessage) {
- return null;
+ return null
}
- // 2. 更新内存消息与数据库记录
- const recalledMessage =
- cachedMessage || buildMessageFromDO(originalMessage);
- revokeBlobUrlsInContent(recalledMessage.content);
- recalledMessage.type = ImContentType.RECALL;
- recalledMessage.status = ImMessageStatus.RECALL;
- recalledMessage.content = '';
- await this.saveMessageRecord(recalledMessage, conversationType, tx);
+ // 2. 构建撤回后的消息和会话投影,再写数据库
+ const recalledMessage = {
+ ...(cachedMessage || buildMessageFromDO(originalMessage)),
+ type: ImContentType.RECALL,
+ status: ImMessageStatus.RECALL,
+ content: ''
+ }
+ const conversation = { ...currentConversation }
+ await this.saveMessageRecord(recalledMessage, conversationType, tx, undefined, db)
// 3. 按本地完整消息和读位置重算未读与 @ 状态
- const storedMessages = await getDb().getAllByIndex(
+ const storedMessages = await db.getAllByIndex(
'messages',
'clientConversationId',
clientConversationId,
- tx,
- );
+ tx
+ )
conversationStore.applyRecallToConversation(
conversation,
storedMessages,
originalMessage,
- );
+ db.userId
+ )
if (conversation.lastMessageId === messageId) {
- applyConversationSummary(conversation, recalledMessage);
+ applyConversationSummary(conversation, recalledMessage, db)
}
- return { conversation, message: recalledMessage };
+ return { conversation, message: recalledMessage, cachedMessage } as RecallMessageProjection
},
- /** 批量写入拉取消息 */
+ /** 把拉取批次投入涉及会话的串行写 lane */
async applyPulledMessageList(
pulledMessages: PulledMessage[],
conversationType: number,
- maxMessageId?: number,
+ maxMessageId: number | undefined,
+ db: DbClient
+ ) {
+ const conversationIds = pulledMessages.map((item) =>
+ item.kind === 'insert'
+ ? getClientConversationId(item.conversationInfo.type, item.conversationInfo.targetId)
+ : getClientConversationId(item.conversationType, item.targetId)
+ )
+ await enqueueConversationWrites(conversationIds, () =>
+ this.applyPulledMessageListNow(pulledMessages, conversationType, maxMessageId, db)
+ )
+ },
+
+ /** 批量写入拉取消息 */
+ async applyPulledMessageListNow(
+ pulledMessages: PulledMessage[],
+ conversationType: number,
+ maxMessageId: number | undefined,
+ db: DbClient
) {
if (pulledMessages.length === 0) {
// 1. 空批次只推进游标
- await this.saveMessageCursor(conversationType, maxMessageId);
- return;
+ await setMessageMaxId(conversationType, maxMessageId, undefined, db)
+ this.updateMessageCursor(conversationType, maxMessageId)
+ return
}
- const conversationStore = useConversationStore();
+ const conversationStore = useConversationStore()
+ const groupStore = useGroupStore()
const persistedMessages = new Map<
+ string,
+ { conversationType: number; mergeClientRecord?: boolean; message: Message; }
+ >()
+ const changedConversations = new Map()
+ const conversationProjections = new Map<
string,
{
- conversationType: number;
- mergeClientRecord?: boolean;
- message: Message;
+ conversation: Conversation
+ currentMessages: Message[]
+ nextMessages: Message[]
}
- >();
- const changedConversations = new Map();
- const recallMessages: Extract<
- PulledMessage,
- { kind: 'recall' }
- >[] = [];
+ >()
+ const recallMessages: Extract[] = []
+ const terminalStates = new Map()
+
+ const getTerminal = async (clientConversationId: string) => {
+ const cached = terminalStates.get(clientConversationId)
+ if (cached) {
+ return cached
+ }
+ const [clearBefore, deletedKeys, recalledKeys] = await Promise.all([
+ db.getSetting(
+ `${StorageKeys.settings.conversationClearBeforePrefix}${clientConversationId}`
+ ),
+ db.getSetting(
+ `${StorageKeys.settings.conversationDeletedMessagesPrefix}${clientConversationId}`
+ ),
+ db.getSetting(
+ `${StorageKeys.settings.conversationRecalledMessagesPrefix}${clientConversationId}`
+ )
+ ])
+ const terminal = {
+ clearBefore: clearBefore || 0,
+ deletedKeys: new Set(deletedKeys || []),
+ recalledKeys: new Set(recalledKeys || [])
+ }
+ terminalStates.set(clientConversationId, terminal)
+ return terminal
+ }
const addChanged = (
conversation: Conversation,
message: Message,
- options?: PersistMessageRecordOptions,
+ options?: PersistMessageRecordOptions
) => {
const clientConversationId = getClientConversationId(
conversation.type,
- conversation.targetId,
- );
- changedConversations.set(clientConversationId, conversation);
+ conversation.targetId
+ )
+ changedConversations.set(clientConversationId, conversation)
persistedMessages.set(getMessageKey(message, conversation.type), {
message,
conversationType: conversation.type,
- mergeClientRecord: options?.mergeClientRecord,
- });
- };
+ mergeClientRecord: options?.mergeClientRecord
+ })
+ }
- // 1. 先更新内存,收集需要持久化的消息和会话
+ // 1. 按消息顺序构建待提交投影
for (const pulledMessage of pulledMessages) {
if (pulledMessage.kind === 'recall') {
// 1.1 撤回信号在事务内读取原消息后统一处理
- recallMessages.push(pulledMessage);
- continue;
+ recallMessages.push(pulledMessage)
+ continue
}
- const { conversationInfo } = pulledMessage;
- const hasServerClientMessageId =
- !!pulledMessage.message.clientMessageId;
- const message = ensureClientMessageId(pulledMessage.message);
- // 1.2 确保会话和消息缓存存在
- const conversation =
- conversationStore.ensureConversation(conversationInfo);
- const messages = this.getMessageList(
+ const { conversationInfo } = pulledMessage
+ const hasServerClientMessageId = !!pulledMessage.message.clientMessageId
+ let message = ensureClientMessageId(pulledMessage.message)
+ const clientConversationId = getClientConversationId(
conversationInfo.type,
- conversationInfo.targetId,
- );
+ conversationInfo.targetId
+ )
+ if (conversationInfo.type === ImConversationType.GROUP) {
+ const groupNotification = isGroupNotification(message.type)
+ if (groupNotification) {
+ await groupStore.applyGroupNotificationNow(
+ conversationInfo.targetId,
+ message.type,
+ message.content,
+ message.id,
+ db
+ )
+ }
+ const staged = conversationProjections.get(clientConversationId)
+ if (staged) {
+ if (isRelationTerminated(clientConversationId)) {
+ staged.conversation.deleted = true
+ staged.conversation.draft = undefined
+ } else if (groupNotification) {
+ staged.conversation.deleted = false
+ }
+ }
+ }
+ const terminal = await getTerminal(clientConversationId)
+ if (isMessageTerminated(message, terminal)) {
+ continue
+ }
+ message = applyPersistedRecall(message, terminal)
+ // 1.2 获取或创建当前批次的待提交投影
+ let projection = conversationProjections.get(clientConversationId)
+ if (!projection) {
+ const currentMessages = this.messagesByConversation[clientConversationId] || []
+ projection = {
+ conversation: conversationStore.buildConversationProjection(conversationInfo),
+ currentMessages,
+ nextMessages: [...currentMessages]
+ }
+ conversationProjections.set(clientConversationId, projection)
+ }
+ const conversation = projection.conversation
+ const messages = projection.nextMessages
const isActive =
- conversationStore.activeConversation?.type ===
- conversationInfo.type &&
- conversationStore.activeConversation?.targetId ===
- conversationInfo.targetId;
+ conversationStore.activeConversation?.type === conversationInfo.type &&
+ conversationStore.activeConversation?.targetId === conversationInfo.targetId
const isUnread =
!message.selfSend &&
!isActive &&
- !conversationStore.isMessageCoveredByReadPosition(
- conversation,
- message,
- ) &&
+ !conversationStore.isMessageCoveredByReadPosition(conversation, message) &&
isNormalMessage(message.type) &&
- message.status !== ImMessageStatus.RECALL;
- const existingIndex = messages.findIndex((existing) =>
- isSameMessage(existing, message),
- );
- if (existingIndex !== -1) {
- const existing = messages[existingIndex];
- if (!existing) {
- continue;
+ message.status !== ImMessageStatus.RECALL
+ let existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
+ if (existingIndex < 0 && message.id) {
+ const messageId = message.id
+ const storedMessage = await db.get(
+ 'messages',
+ getServerMessageKey(conversationInfo.type, messageId)
+ )
+ if (storedMessage) {
+ existingIndex = messages.findIndex((existing) => existing.id && messageId < existing.id)
+ if (existingIndex < 0) {
+ existingIndex = messages.length
+ }
+ messages.splice(existingIndex, 0, buildMessageFromDO(storedMessage))
}
+ }
+ if (existingIndex >= 0) {
// 1.3 已存在消息合并服务端状态
- applyServerMessageUpdate(existing, message);
- if (existingIndex === messages.length - 1) {
- recomputeConversationLast(conversation, messages);
+ const existing = messages[existingIndex]!
+ const reduced = reduceMessageState(
+ { priority: getMessageTerminalPriority(existing), value: existing },
+ { priority: getMessageTerminalPriority(message), value: message }
+ )
+ if (reduced.value === message) {
+ messages[existingIndex] = buildServerMessageProjection(existing, message)
+ }
+ const mergedMessage = messages[existingIndex]!
+ if (
+ existingIndex === messages.length - 1 &&
+ shouldUpdateConversationSummary(conversation, mergedMessage)
+ ) {
+ recomputeConversationLast(conversation, messages, db)
}
if (isUnread) {
- syncConversationAtFlags(conversation, message);
+ syncConversationAtFlags(conversation, message, db.userId)
}
- addChanged(conversation, existing, {
- mergeClientRecord: hasServerClientMessageId,
- });
- continue;
+ addChanged(conversation, messages[existingIndex]!, {
+ mergeClientRecord: hasServerClientMessageId
+ })
+ continue
}
// 1.4 新消息更新会话摘要和未读状态
- applyConversationSummary(conversation, message);
+ if (shouldUpdateConversationSummary(conversation, message)) {
+ applyConversationSummary(conversation, message, db)
+ }
if (isUnread) {
- syncConversationAtFlags(conversation, message);
- conversation.unreadCount++;
+ syncConversationAtFlags(conversation, message, db.userId)
+ conversation.unreadCount++
}
- // 1.5 新消息按服务端 id 插入内存列表
- let insertIndex = messages.length;
+ // 1.5 新消息按服务端 id 插入待提交列表
+ let insertIndex = messages.length
if (message.id) {
- for (const [index, existing] of messages.entries()) {
+ for (let index = 0; index < messages.length; index++) {
+ const existing = messages[index]!
if (existing.id && message.id < existing.id) {
- insertIndex = index;
- break;
+ insertIndex = index
+ break
}
}
}
- messages.splice(insertIndex, 0, message);
+ messages.splice(insertIndex, 0, message)
addChanged(conversation, message, {
- mergeClientRecord: hasServerClientMessageId && !!message.id,
- });
+ mergeClientRecord: hasServerClientMessageId && !!message.id
+ })
}
// 2. 单事务写入消息、会话摘要和游标
- await getDb().transaction(
- ['messages', 'conversations', 'settings'],
- 'readwrite',
- async (tx) => {
- // 2.1 写入本批变更消息
- for (const item of persistedMessages.values()) {
- await this.saveMessageRecord(
- item.message,
- item.conversationType,
- tx,
- {
- mergeClientRecord: item.mergeClientRecord,
- },
- );
- }
- // 2.2 应用本批撤回信号
- for (const recallMessage of recallMessages) {
- const changed = await this.applyRecallMessageRecord(
- recallMessage.conversationType,
- recallMessage.targetId,
- recallMessage.recallSignalContent,
- tx,
- );
- if (changed) {
- changedConversations.set(
- getClientConversationId(
- changed.conversation.type,
- changed.conversation.targetId,
- ),
- changed.conversation,
- );
- }
- }
- // 2.3 写入本批变更会话
- await conversationStore.saveConversationRecord(
- [...changedConversations.values()],
+ await db.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
+ // 2.1 写入本批变更消息
+ for (const item of persistedMessages.values()) {
+ await this.saveMessageRecord(
+ item.message,
+ item.conversationType,
tx,
- );
- // 2.4 写入本批游标
- await setMessageMaxId(conversationType, maxMessageId, tx);
- },
- );
+ {
+ mergeClientRecord: item.mergeClientRecord
+ },
+ db
+ )
+ }
+ // 2.2 应用本批撤回信号
+ for (const recallMessage of recallMessages) {
+ const staged = conversationProjections.get(
+ getClientConversationId(recallMessage.conversationType, recallMessage.targetId)
+ )
+ const changed = await this.applyRecallMessageRecord(
+ recallMessage.conversationType,
+ recallMessage.targetId,
+ recallMessage.recallSignalContent,
+ tx,
+ staged
+ ? { conversation: staged.conversation, messages: staged.nextMessages }
+ : undefined,
+ db
+ )
+ if (changed) {
+ const clientConversationId = getClientConversationId(
+ changed.conversation.type,
+ changed.conversation.targetId
+ )
+ let projection = conversationProjections.get(clientConversationId)
+ if (!projection) {
+ const currentMessages = this.messagesByConversation[clientConversationId] || []
+ projection = {
+ conversation: changed.conversation,
+ currentMessages,
+ nextMessages: [...currentMessages]
+ }
+ conversationProjections.set(clientConversationId, projection)
+ } else {
+ projection.conversation = changed.conversation
+ }
+ const recalledIndex = projection.nextMessages.findIndex(
+ (message) => message.id === changed.message.id
+ )
+ if (recalledIndex >= 0) {
+ projection.nextMessages[recalledIndex] = changed.message
+ }
+ changedConversations.set(clientConversationId, changed.conversation)
+ }
+ }
+ // 2.3 写入本批变更会话
+ await conversationStore.saveConversationRecord([...changedConversations.values()], tx, db)
+ // 2.4 写入本批游标
+ await setMessageMaxId(conversationType, maxMessageId, tx, db)
+ })
// 3. 持久化成功后推进内存游标
- this.updateMessageCursor(conversationType, maxMessageId);
- for (const item of persistedMessages.values()) {
- this.updateMessageCursor(item.conversationType, item.message.id);
+ for (const [clientConversationId, projection] of conversationProjections) {
+ for (const current of projection.currentMessages) {
+ const next = projection.nextMessages.find((message) => isSameMessage(message, current))
+ if (next && next.content !== current.content) {
+ revokeBlobUrlsInContent(current.content)
+ }
+ }
+ this.messagesByConversation[clientConversationId] = projection.nextMessages
+ this.touchConversationMessageCache(clientConversationId)
+ conversationStore.publishConversationProjection(projection.conversation, true)
}
+ this.updateMessageCursor(conversationType, maxMessageId)
},
- /** 插入消息 */
- insertMessage(
+ /** 把实时或本地消息投入会话串行写 lane */
+ async insertMessage(
conversationInfo: MessageConversationInfo,
messageInfo: Message,
- options?: { saveMaxId?: boolean },
+ db: DbClient = getDb()
+ ): Promise {
+ const clientConversationId = getClientConversationId(
+ conversationInfo.type,
+ conversationInfo.targetId
+ )
+ await enqueueConversationWrite(clientConversationId, () =>
+ this.insertMessageNow(conversationInfo, messageInfo, db)
+ )
+ return true
+ },
+
+ /** 实际插入消息;调用方必须持有当前会话写 lane */
+ async insertMessageNow(
+ conversationInfo: MessageConversationInfo,
+ messageInfo: Message,
+ db: DbClient
): Promise {
- const conversationStore = useConversationStore();
- const hasIncomingClientMessageId = !!messageInfo.clientMessageId;
- const message = ensureClientMessageId(messageInfo);
+ const conversationStore = useConversationStore()
+ const hasIncomingClientMessageId = !!messageInfo.clientMessageId
+ let message = ensureClientMessageId(messageInfo)
+ const clientConversationId = getClientConversationId(
+ conversationInfo.type,
+ conversationInfo.targetId
+ )
+ const [clearBefore, deletedKeys, recalledKeys] = await Promise.all([
+ db.getSetting(
+ `${StorageKeys.settings.conversationClearBeforePrefix}${clientConversationId}`
+ ),
+ db.getSetting(
+ `${StorageKeys.settings.conversationDeletedMessagesPrefix}${clientConversationId}`
+ ),
+ db.getSetting(
+ `${StorageKeys.settings.conversationRecalledMessagesPrefix}${clientConversationId}`
+ )
+ ])
+ const terminal = {
+ clearBefore: clearBefore || 0,
+ deletedKeys: new Set(deletedKeys || []),
+ recalledKeys: new Set(recalledKeys || [])
+ }
+ if (isMessageTerminated(message, terminal)) {
+ return
+ }
+ message = applyPersistedRecall(message, terminal)
// 1. 先处理消息带来的群资料变更
- if (
- conversationInfo.type === ImConversationType.GROUP &&
- isGroupNotification(message.type)
- ) {
- useGroupStore().applyGroupNotification(
- conversationInfo.targetId,
- message.type,
- message.content,
- );
+ if (conversationInfo.type === ImConversationType.GROUP) {
+ if (isGroupNotification(message.type)) {
+ await useGroupStore().applyGroupNotificationNow(
+ conversationInfo.targetId,
+ message.type,
+ message.content,
+ message.id,
+ db
+ )
+ }
}
- // 2. 确保会话和消息缓存存在
- const conversation =
- conversationStore.ensureConversation(conversationInfo);
- const messages = this.getMessageList(
- conversationInfo.type,
- conversationInfo.targetId,
- );
+ // 2. 构建会话和消息的下一份投影,不提前修改响应式状态
+ const conversation = conversationStore.buildConversationProjection(conversationInfo)
+ const currentMessages = this.messagesByConversation[clientConversationId] || []
+ const messages = [...currentMessages]
+ let existingIndex = messages.findIndex((item) => isSameMessage(item, message))
+ if (existingIndex < 0 && message.id) {
+ const messageId = message.id
+ const storedMessage = await db.get(
+ 'messages',
+ getServerMessageKey(conversationInfo.type, messageId)
+ )
+ if (storedMessage) {
+ existingIndex = messages.findIndex((existing) => existing.id && messageId < existing.id)
+ if (existingIndex < 0) {
+ existingIndex = messages.length
+ }
+ messages.splice(existingIndex, 0, buildMessageFromDO(storedMessage))
+ }
+ }
const isActive =
conversationStore.activeConversation?.type === conversationInfo.type &&
- conversationStore.activeConversation?.targetId ===
- conversationInfo.targetId;
+ conversationStore.activeConversation?.targetId === conversationInfo.targetId
const isUnread =
+ existingIndex < 0 &&
!message.selfSend &&
!isActive &&
- !conversationStore.isMessageCoveredByReadPosition(
- conversation,
- message,
- ) &&
+ !conversationStore.isMessageCoveredByReadPosition(conversation, message) &&
isNormalMessage(message.type) &&
- message.status !== ImMessageStatus.RECALL;
- const existingIndex = messages.findIndex((item) =>
- isSameMessage(item, message),
- );
+ message.status !== ImMessageStatus.RECALL
// 3. 已存在消息走覆盖更新
- if (existingIndex !== -1) {
- const existing = messages[existingIndex];
- if (!existing) {
- return Promise.resolve();
+ if (existingIndex >= 0) {
+ const existing = messages[existingIndex]!
+ const reduced = reduceMessageState(
+ { priority: getMessageTerminalPriority(existing), value: existing },
+ { priority: getMessageTerminalPriority(message), value: message }
+ )
+ if (reduced.value === message) {
+ messages[existingIndex] = buildServerMessageProjection(existing, message)
}
- applyServerMessageUpdate(existing, message);
- if (existingIndex === messages.length - 1) {
- recomputeConversationLast(conversation, messages);
+ if (
+ existingIndex === messages.length - 1 &&
+ shouldUpdateConversationSummary(conversation, messages[existingIndex]!)
+ ) {
+ recomputeConversationLast(conversation, messages, db)
}
if (isUnread) {
- syncConversationAtFlags(conversation, message);
+ syncConversationAtFlags(conversation, message, db.userId)
}
- return getDb()
- .transaction(
- ['messages', 'conversations', 'settings'],
- 'readwrite',
- async (tx) => {
- await this.saveMessageRecord(
- existing,
- conversationInfo.type,
- tx,
- {
- mergeClientRecord: hasIncomingClientMessageId,
- },
- );
- await conversationStore.saveConversationRecord(conversation, tx);
- if (options?.saveMaxId !== false) {
- await setMessageMaxId(conversationInfo.type, message.id, tx);
- }
- },
- )
- .catch((error) => {
- console.error('[IM messageStore] 消息写入失败', error);
- throw error;
+ await db
+ .transaction(['messages', 'conversations'], 'readwrite', async (tx) => {
+ await this.saveMessageRecord(
+ messages[existingIndex]!,
+ conversationInfo.type,
+ tx,
+ {
+ mergeClientRecord: hasIncomingClientMessageId
+ },
+ db
+ )
+ await conversationStore.saveConversationRecord(conversation, tx, db)
})
- .then(() => {
- this.updateMessageCursor(conversationInfo.type, message.id);
- });
+ .catch((e) => {
+ console.error('[IM messageStore] 消息写入失败', e)
+ throw e
+ })
+ const currentMessage = currentMessages.find((item) => isSameMessage(item, message))
+ if (currentMessage && currentMessage.content !== messages[existingIndex]!.content) {
+ revokeBlobUrlsInContent(currentMessage.content)
+ }
+ this.messagesByConversation[clientConversationId] = messages
+ this.touchConversationMessageCache(clientConversationId)
+ conversationStore.publishConversationProjection(conversation, true)
+ return
}
// 4. 新消息更新会话摘要和未读状态
- applyConversationSummary(conversation, message);
+ if (shouldUpdateConversationSummary(conversation, message)) {
+ applyConversationSummary(conversation, message, db)
+ }
if (isUnread) {
- syncConversationAtFlags(conversation, message);
- conversation.unreadCount++;
+ syncConversationAtFlags(conversation, message, db.userId)
+ conversation.unreadCount++
}
- // 5. 新消息按 id 插入到内存数组
- let insertIndex = messages.length;
+ // 5. 新消息按 id 插入到待发布数组
+ let insertIndex = messages.length
if (message.id) {
- for (const [index, existing] of messages.entries()) {
+ for (let index = 0; index < messages.length; index++) {
+ const existing = messages[index]!
if (existing.id && message.id < existing.id) {
- insertIndex = index;
- break;
+ insertIndex = index
+ break
}
}
}
- messages.splice(insertIndex, 0, message);
- // 6. 单事务写入消息、会话摘要和游标
- return getDb()
- .transaction(
- ['messages', 'conversations', 'settings'],
- 'readwrite',
- async (tx) => {
- await this.saveMessageRecord(message, conversationInfo.type, tx, {
- mergeClientRecord: hasIncomingClientMessageId && !!message.id,
- });
- await conversationStore.saveConversationRecord(conversation, tx);
- if (options?.saveMaxId !== false) {
- await setMessageMaxId(conversationInfo.type, message.id, tx);
- }
- },
- )
- .catch((error) => {
- console.error('[IM messageStore] 消息写入失败', error);
- throw error;
+ messages.splice(insertIndex, 0, message)
+ // 6. 单事务写入消息和会话摘要
+ await db
+ .transaction(['messages', 'conversations'], 'readwrite', async (tx) => {
+ await this.saveMessageRecord(
+ message,
+ conversationInfo.type,
+ tx,
+ {
+ mergeClientRecord: hasIncomingClientMessageId && !!message.id
+ },
+ db
+ )
+ await conversationStore.saveConversationRecord(conversation, tx, db)
})
- .then(() => {
- this.updateMessageCursor(conversationInfo.type, message.id);
- });
+ .catch((e) => {
+ console.error('[IM messageStore] 消息写入失败', e)
+ throw e
+ })
+ this.messagesByConversation[clientConversationId] = messages
+ this.touchConversationMessageCache(clientConversationId)
+ conversationStore.publishConversationProjection(conversation, true)
},
/** ack 合并 */
@@ -914,22 +1233,24 @@ export const useMessageStore = defineStore('imMessageStore', {
targetId: number,
clientMessageId: string,
updates: Partial,
+ db: DbClient = getDb()
) {
- const mergeKey = `${conversationType}:${targetId}:${clientMessageId}`;
- const existingPromise = ackMergingPromises.get(mergeKey);
+ const mergeKey = `${conversationType}:${targetId}:${clientMessageId}`
+ const existingPromise = ackMergingPromises.get(mergeKey)
if (existingPromise) {
- return existingPromise;
+ return existingPromise
}
const promise = this.doAckMessage(
conversationType,
targetId,
clientMessageId,
updates,
+ db
).finally(() => {
- ackMergingPromises.delete(mergeKey);
- });
- ackMergingPromises.set(mergeKey, promise);
- return promise;
+ ackMergingPromises.delete(mergeKey)
+ })
+ ackMergingPromises.set(mergeKey, promise)
+ return promise
},
/** 执行 ack 合并 */
@@ -938,51 +1259,107 @@ export const useMessageStore = defineStore('imMessageStore', {
targetId: number,
clientMessageId: string,
updates: Partial,
+ db: DbClient = getDb()
) {
- // 1. 定位待合并消息
- const conversationStore = useConversationStore();
- const conversation = conversationStore.getConversation(
- conversationType,
- targetId,
- );
- if (!conversation) {
- return;
+ await enqueueConversationWrite(getClientConversationId(conversationType, targetId), () =>
+ this.doAckMessageNow(conversationType, targetId, clientMessageId, updates, db)
+ )
+ },
+
+ /** 实际执行 ack 合并;调用方必须持有当前会话写 lane */
+ async doAckMessageNow(
+ conversationType: number,
+ targetId: number,
+ clientMessageId: string,
+ updates: Partial,
+ db: DbClient
+ ) {
+ // 1. 从任务绑定的 DB 读取待合并消息和会话,避免内存窗口淘汰后丢 ACK
+ const conversationStore = useConversationStore()
+ const clientConversationId = getClientConversationId(conversationType, targetId)
+ const currentConversation = conversationStore.getConversation(conversationType, targetId)
+ const currentMessages = this.messagesByConversation[clientConversationId]
+ const currentMessage = currentMessages?.find(
+ (item) => item.clientMessageId === clientMessageId
+ )
+ const [storedMessage, storedConversation] = await Promise.all([
+ db.getByIndex('messages', 'clientMessageId', clientMessageId),
+ db.get('conversations', clientConversationId)
+ ])
+ if (!storedMessage) {
+ return
}
- const messages = this.getMessageList(conversationType, targetId);
- const message = messages.find(
- (item) => item.clientMessageId === clientMessageId,
- );
- if (!message) {
- return;
+ const message = buildMessageFromDO(storedMessage)
+ if (currentMessage) {
+ currentMessage._ackMerging = true
}
- message._ackMerging = true;
try {
- // 2. 合并服务端 ack 到内存
- applyServerMessageUpdate(message, updates);
- if (messages[messages.length - 1] === message) {
- recomputeConversationLast(conversation, messages);
+ // 2. 构建服务端 ack 的下一份投影
+ const incoming = { ...message, ...updates }
+ const reduced = reduceMessageState(
+ { priority: getMessageTerminalPriority(message), value: message },
+ { priority: getMessageTerminalPriority(incoming), value: incoming }
+ )
+ let nextMessage: Message
+ if (reduced.value === incoming) {
+ nextMessage = buildServerMessageProjection(message, updates)
+ } else {
+ const nonTerminalUpdates = { ...updates }
+ delete nonTerminalUpdates.type
+ delete nonTerminalUpdates.status
+ delete nonTerminalUpdates.content
+ nextMessage = buildServerMessageProjection(message, nonTerminalUpdates)
+ }
+ nextMessage._ackMerging = undefined
+ const nextConversation = storedConversation
+ ? buildConversationFromDO(storedConversation)
+ : undefined
+ if (
+ nextConversation &&
+ (nextConversation.lastClientMessageId === clientMessageId ||
+ (!!message.id && nextConversation.lastMessageId === message.id))
+ ) {
+ applyConversationSummary(nextConversation, nextMessage, db)
+ }
+ // 3. 单事务写入消息和会话摘要;ACK 不推进 pull cursor
+ await db
+ .transaction(['messages', 'conversations'], 'readwrite', async (tx) => {
+ await this.saveMessageRecord(
+ nextMessage,
+ conversationType,
+ tx,
+ {
+ mergeClientRecord: true
+ },
+ db
+ )
+ if (nextConversation) {
+ await conversationStore.saveConversationRecord(nextConversation, tx, db)
+ }
+ })
+ .catch((e) => {
+ console.error('[IM messageStore] ack 写入失败', e)
+ throw e
+ })
+ // 4. 仅更新仍由当前 Store 持有的旧投影;DB-only ACK 不重新撑开 LRU 窗口
+ if (
+ currentMessage &&
+ currentMessages &&
+ this.messagesByConversation[clientConversationId] === currentMessages
+ ) {
+ applyServerMessageUpdate(currentMessage, nextMessage)
+ }
+ if (
+ nextConversation &&
+ currentConversation &&
+ conversationStore.getConversation(conversationType, targetId) === currentConversation
+ ) {
+ conversationStore.publishConversationProjection(nextConversation, true)
}
- // 3. 单事务写入消息、会话摘要和游标
- await getDb()
- .transaction(
- ['messages', 'conversations', 'settings'],
- 'readwrite',
- async (tx) => {
- await this.saveMessageRecord(message, conversationType, tx, {
- mergeClientRecord: true,
- });
- await conversationStore.saveConversationRecord(conversation, tx);
- await setMessageMaxId(conversationType, message.id, tx);
- },
- )
- .catch((error) => {
- console.error('[IM messageStore] ack 写入失败', error);
- throw error;
- });
- this.updateMessageCursor(conversationType, message.id);
} finally {
- // 4. 清理合并标记
- message._ackMerging = false;
+ if (currentMessage) {
+ currentMessage._ackMerging = false
+ }
}
},
@@ -991,27 +1368,27 @@ export const useMessageStore = defineStore('imMessageStore', {
conversationType: number,
targetId: number,
clientMessageId: string,
- patch: Partial,
+ patch: Partial
) {
const message = this.getMessageList(conversationType, targetId).find(
- (item) => item.clientMessageId === clientMessageId,
- );
+ (item) => item.clientMessageId === clientMessageId
+ )
if (!message) {
- return;
+ return
}
- let changed = false;
+ let changed = false
for (const key in patch) {
if (
Object.prototype.hasOwnProperty.call(patch, key) &&
(patch as Record)[key] !==
(message as unknown as Record)[key]
) {
- changed = true;
- break;
+ changed = true
+ break
}
}
if (changed) {
- applyServerMessageUpdate(message, patch);
+ applyServerMessageUpdate(message, patch)
}
},
@@ -1020,205 +1397,388 @@ export const useMessageStore = defineStore('imMessageStore', {
conversationType: number,
targetId: number,
recallSignalContent: string,
+ db: DbClient = getDb()
): Promise {
- const conversationStore = useConversationStore();
- await getDb()
- .transaction(['messages', 'conversations'], 'readwrite', async (tx) => {
+ await enqueueConversationWrite(getClientConversationId(conversationType, targetId), () =>
+ this.recallMessageNow(conversationType, targetId, recallSignalContent, db)
+ )
+ },
+
+ /** 实际应用撤回终态;调用方必须持有当前会话写 lane */
+ async recallMessageNow(
+ conversationType: number,
+ targetId: number,
+ recallSignalContent: string,
+ db: DbClient
+ ): Promise {
+ const conversationStore = useConversationStore()
+ const changed = await db
+ .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
const changed = await this.applyRecallMessageRecord(
conversationType,
targetId,
recallSignalContent,
tx,
- );
+ undefined,
+ db
+ )
if (!changed) {
- return;
+ return null
}
- await conversationStore.saveConversationRecord(
- changed.conversation,
- tx,
- );
+ await conversationStore.saveConversationRecord(changed.conversation, tx, db)
+ return changed
})
- .catch((error) => {
- console.error('[IM messageStore] 撤回消息写入失败', error);
- throw error;
- });
+ .catch((e) => {
+ console.error('[IM messageStore] 撤回消息写入失败', e)
+ throw e
+ })
+ if (!changed) {
+ return
+ }
+ if (changed.cachedMessage) {
+ revokeBlobUrlsInContent(changed.cachedMessage.content)
+ Object.assign(changed.cachedMessage, changed.message)
+ }
+ conversationStore.publishConversationProjection(changed.conversation, true)
},
/** 应用已读回执 */
- applyMessageReadReceipt(options: {
- conversationType: number;
- groupMessageId?: number;
- privateReadMaxId?: number;
- readCount?: number;
- receiptStatus?: number;
- targetId: number;
- }) {
- const messages = this.getMessageList(
+ async applyMessageReadReceipt(
+ options: {
+ conversationType: number
+ groupMessageId?: number
+ privateReadMaxId?: number
+ readCount?: number
+ receiptStatus?: number
+ targetId: number
+ },
+ db: DbClient = getDb()
+ ) {
+ await enqueueConversationWrite(
+ getClientConversationId(options.conversationType, options.targetId),
+ () => this.applyMessageReadReceiptNow(options, db)
+ )
+ },
+
+ /** 实际应用消息回执;调用方必须持有当前会话写 lane */
+ async applyMessageReadReceiptNow(
+ options: {
+ conversationType: number
+ groupMessageId?: number
+ privateReadMaxId?: number
+ readCount?: number
+ receiptStatus?: number
+ targetId: number
+ },
+ db: DbClient
+ ) {
+ const clientConversationId = getClientConversationId(
options.conversationType,
- options.targetId,
- );
- const changed: Message[] = [];
+ options.targetId
+ )
+ const messages = this.messagesByConversation[clientConversationId] || []
+ const changed: Array<{ current: Message; next: Message }> = []
+ const durableChanges = new Map()
// 1. 私聊回执批量更新自己发送的消息
- if (
- options.conversationType === ImConversationType.PRIVATE &&
- options.privateReadMaxId
- ) {
- this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId);
- const privateReadMaxId = options.privateReadMaxId;
+ if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
messages.forEach((message) => {
if (
message.selfSend &&
message.id &&
- message.id <= privateReadMaxId &&
+ message.id <= options.privateReadMaxId! &&
message.receiptStatus === ImMessageReceiptStatus.PENDING
) {
- message.receiptStatus = ImMessageReceiptStatus.DONE;
- changed.push(message);
- }
- });
- } else if (
- options.conversationType === ImConversationType.GROUP &&
- options.groupMessageId
- ) {
- // 2. 群聊回执更新单条消息
- const message = messages.find(
- (item) => item.id === options.groupMessageId,
- );
- if (message) {
- if (options.readCount !== undefined) {
- message.readCount = options.readCount;
- }
- if (options.receiptStatus !== undefined) {
- message.receiptStatus = options.receiptStatus;
- }
- changed.push(message);
- }
- }
- if (changed.length === 0) {
- return;
- }
- // 3. 单事务写入变更消息
- void getDb()
- .transaction(['messages'], 'readwrite', async (tx) => {
- for (const message of changed) {
- await this.saveMessageRecord(message, options.conversationType, tx);
+ changed.push({
+ current: message,
+ next: { ...message, receiptStatus: ImMessageReceiptStatus.DONE }
+ })
}
})
- .catch((error) =>
- console.warn('[IM messageStore] 回执写入失败', error),
- );
+ const storedMessages = await db.getAllByIndex(
+ 'messages',
+ 'clientConversationId',
+ clientConversationId
+ )
+ const storedByKey = new Map(storedMessages.map((message) => [message.messageKey, message]))
+ storedMessages
+ .filter(
+ (message) =>
+ message.selfSend &&
+ !!message.id &&
+ message.id <= options.privateReadMaxId! &&
+ message.receiptStatus === ImMessageReceiptStatus.PENDING
+ )
+ .forEach((message) => {
+ const next = { ...message, receiptStatus: ImMessageReceiptStatus.DONE }
+ durableChanges.set(next.messageKey, next)
+ })
+ changed.forEach(({ next }) => {
+ const record = buildMessageDO(next, options.conversationType)
+ if (!durableChanges.has(record.messageKey)) {
+ durableChanges.set(record.messageKey, {
+ ...storedByKey.get(record.messageKey),
+ ...record
+ })
+ }
+ })
+ } else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
+ // 2. 群聊回执更新单条消息
+ const message = messages.find((item) => item.id === options.groupMessageId)
+ const storedMessage = await db.get(
+ 'messages',
+ getServerMessageKey(options.conversationType, options.groupMessageId)
+ )
+ const nextReadCount =
+ message?.readCount === undefined &&
+ storedMessage?.readCount === undefined &&
+ options.readCount === undefined
+ ? undefined
+ : Math.max(
+ message?.readCount ?? 0,
+ storedMessage?.readCount ?? 0,
+ options.readCount ?? 0
+ )
+ const nextReceiptStatus =
+ message?.receiptStatus === undefined &&
+ storedMessage?.receiptStatus === undefined &&
+ options.receiptStatus === undefined
+ ? undefined
+ : Math.max(
+ message?.receiptStatus ?? 0,
+ storedMessage?.receiptStatus ?? 0,
+ options.receiptStatus ?? 0
+ )
+ if (message) {
+ const next = {
+ ...message,
+ readCount: nextReadCount,
+ receiptStatus: nextReceiptStatus
+ }
+ if (
+ next.readCount !== message.readCount ||
+ next.receiptStatus !== message.receiptStatus
+ ) {
+ changed.push({ current: message, next })
+ }
+ }
+ if (storedMessage) {
+ const next = {
+ ...storedMessage,
+ readCount: nextReadCount,
+ receiptStatus: nextReceiptStatus
+ }
+ if (
+ next.readCount !== storedMessage.readCount ||
+ next.receiptStatus !== storedMessage.receiptStatus
+ ) {
+ durableChanges.set(next.messageKey, next)
+ }
+ } else if (message && changed.length > 0) {
+ const record = buildMessageDO(changed[0]!.next, options.conversationType)
+ durableChanges.set(record.messageKey, record)
+ }
+ }
+ if (changed.length === 0 && durableChanges.size === 0) {
+ if (options.conversationType === ImConversationType.PRIVATE) {
+ this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId)
+ }
+ return
+ }
+ // 3. 单事务写入变更消息
+ await db
+ .transaction(['messages'], 'readwrite', async (tx) => {
+ for (const message of durableChanges.values()) {
+ await db.put('messages', message, tx)
+ }
+ })
+ .catch((e) => {
+ console.warn('[IM messageStore] 回执写入失败', e)
+ throw e
+ })
+ changed.forEach((item) => Object.assign(item.current, item.next))
+ if (options.conversationType === ImConversationType.PRIVATE) {
+ this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId)
+ }
},
/** 前置历史消息 */
- prependMessageList(
+ async prependMessageList(
conversationType: number,
targetId: number,
earlierMessages: Message[],
+ db: DbClient = getDb()
+ ): Promise {
+ await enqueueConversationWrite(getClientConversationId(conversationType, targetId), () =>
+ this.prependMessageListNow(conversationType, targetId, earlierMessages, db)
+ )
+ },
+
+ /** 实际前置历史消息;调用方必须持有当前会话写 lane */
+ async prependMessageListNow(
+ conversationType: number,
+ targetId: number,
+ earlierMessages: Message[],
+ db: DbClient
) {
if (earlierMessages.length === 0) {
- return;
+ return
}
- const messages = this.getMessageList(conversationType, targetId);
- const existingIds = new Set(
- messages.map((message) => message.id).filter(Boolean),
- );
+ const clientConversationId = getClientConversationId(conversationType, targetId)
+ const terminal = await getConversationMessageTerminal(clientConversationId, db)
+ const messages = this.messagesByConversation[clientConversationId] || []
+ const existingIds = new Set(messages.map((message) => message.id).filter(Boolean))
const fresh = earlierMessages
- .map((message) => ensureClientMessageId(message))
- .filter((message) => message.id && !existingIds.has(message.id))
- .toSorted(
- (messageA, messageB) => (messageA.id || 0) - (messageB.id || 0),
- );
+ .map(ensureClientMessageId)
+ .filter(
+ (message) =>
+ message.id && !existingIds.has(message.id) && !isMessageTerminated(message, terminal)
+ )
+ .map((message) => applyPersistedRecall(message, terminal))
+ .sort((messageA, messageB) => (messageA.id || 0) - (messageB.id || 0))
if (fresh.length === 0) {
- return;
+ return
}
- const key = getMessageCacheKey(conversationType, targetId);
- this.messagesByConversation[key] = [...fresh, ...messages];
- void getDb()
+ const key = getClientConversationId(conversationType, targetId)
+ const nextMessages = [...fresh, ...messages]
+ await db
.transaction(['messages'], 'readwrite', async (tx) => {
for (const message of fresh) {
- await this.saveMessageRecord(message, conversationType, tx);
+ await this.saveMessageRecord(message, conversationType, tx, undefined, db)
}
})
- .catch((error) =>
- console.warn('[IM messageStore] 历史消息写入失败', error),
- );
+ .catch((e) => {
+ console.warn('[IM messageStore] 历史消息写入失败', e)
+ throw e
+ })
+ this.messagesByConversation[key] = nextMessages
+ this.touchConversationMessageCache(key)
},
/** 删除单条消息 */
- removeMessage(
+ async removeMessage(
conversationType: number,
targetId: number,
- key: { clientMessageId?: string; id?: number },
+ key: { clientMessageId?: string; id?: number; },
+ db: DbClient = getDb()
+ ) {
+ await enqueueConversationWrite(getClientConversationId(conversationType, targetId), () =>
+ this.removeMessageNow(conversationType, targetId, key, db)
+ )
+ },
+
+ /** 实际持久删除单条消息;调用方必须持有当前会话写 lane */
+ async removeMessageNow(
+ conversationType: number,
+ targetId: number,
+ key: { clientMessageId?: string; id?: number; },
+ db: DbClient
) {
// 1. 定位会话和消息
- const conversationStore = useConversationStore();
- const conversation = conversationStore.getConversation(
- conversationType,
- targetId,
- );
+ const conversationStore = useConversationStore()
+ const conversation = conversationStore.getConversation(conversationType, targetId)
if (!conversation) {
- return;
+ return
}
- const messages = this.getMessageList(conversationType, targetId);
+ const messages = this.getMessageList(conversationType, targetId)
const index = messages.findIndex((message) => {
if (key.id && message.id && message.id === key.id) {
- return true;
+ return true
}
- return (
- !!key.clientMessageId &&
- message.clientMessageId === key.clientMessageId
- );
- });
- if (index === -1) {
- return;
+ return !!key.clientMessageId && message.clientMessageId === key.clientMessageId
+ })
+ if (index < 0) {
+ return
}
- // 2. 从内存移除消息
- const [removed] = messages.splice(index, 1);
- if (!removed) {
- return;
- }
- revokeBlobUrlsInContent(removed.content);
- if (index === messages.length) {
- recomputeConversationLast(conversation, messages);
- }
- // 3. 删除本地记录并保存会话摘要
- getDb()
- .delete('messages', getMessageKey(removed, conversationType))
- .catch((error) =>
- console.warn('[IM messageStore] 消息删除失败', error),
- );
- conversationStore.saveConversation(conversation);
+ const removed = messages[index]!
+ const clientConversationId = getClientConversationId(conversationType, targetId)
+ let nextMessages = messages.filter((_, messageIndex) => messageIndex !== index)
+ const nextConversation = { ...conversation }
+ // 2. 先持久化 delete key,再删除消息并保存会话摘要
+ await db.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
+ const settingKey = `${StorageKeys.settings.conversationDeletedMessagesPrefix}${clientConversationId}`
+ const oldKeys = (await db.getSetting(settingKey, tx)) || []
+ const deletedKeys = [
+ ...(removed.id ? [`id:${removed.id}`] : []),
+ ...(removed.clientMessageId ? [`client:${removed.clientMessageId}`] : [])
+ ]
+ await db.setSetting(settingKey, Array.from(new Set([...oldKeys, ...deletedKeys])), tx)
+ await db.delete('messages', getMessageKey(removed, conversationType), tx)
+ if (index === nextMessages.length) {
+ if (nextMessages.length === 0) {
+ const storedMessages = await db.getAllByIndex(
+ 'messages',
+ 'clientConversationId',
+ clientConversationId,
+ tx
+ )
+ const latest = storedMessages.sort(compareConversationSummaryOrder).at(-1)
+ nextMessages = latest ? [buildMessageFromDO(latest)] : []
+ }
+ recomputeConversationLast(nextConversation, nextMessages, db)
+ }
+ await conversationStore.saveConversationRecord(nextConversation, tx, db)
+ })
+ // 3. 事务提交后发布内存投影
+ revokeBlobUrlsInContent(removed.content)
+ this.messagesByConversation[clientConversationId] = nextMessages
+ conversationStore.publishConversationProjection(nextConversation, true)
},
- /** 删除会话全部消息 */
- deleteConversationMessageList(conversationType: number, targetId: number) {
- // 1. 清理内存消息和媒体资源
- const clientConversationId = getClientConversationId(
- conversationType,
- targetId,
- );
- const messages = this.messagesByConversation[clientConversationId] || [];
+ /** 实际清空会话消息;调用方必须持有当前会话写 lane */
+ async deleteConversationMessageListNow(
+ conversationType: number,
+ targetId: number,
+ db: DbClient
+ ) {
+ const clientConversationId = getClientConversationId(conversationType, targetId)
+ const messages = this.messagesByConversation[clientConversationId] || []
+ const conversation = useConversationStore().getConversation(conversationType, targetId)
+ // 1. 先持久化 clear watermark,再删除当前会话消息
+ await db.transaction(['messages', 'settings'], 'readwrite', async (tx) => {
+ const storedMessages = await db.getAllByIndex(
+ 'messages',
+ 'clientConversationId',
+ clientConversationId,
+ tx
+ )
+ const settingKey = `${StorageKeys.settings.conversationClearBeforePrefix}${clientConversationId}`
+ const oldClearBefore = (await db.getSetting(settingKey, tx)) || 0
+ const deletedSettingKey = `${StorageKeys.settings.conversationDeletedMessagesPrefix}${clientConversationId}`
+ const oldDeletedKeys = (await db.getSetting(deletedSettingKey, tx)) || []
+ const pendingClientKeys = new Set(
+ [...storedMessages, ...messages]
+ .filter((message) => !message.id && !!message.clientMessageId)
+ .map((message) => `client:${message.clientMessageId}`)
+ )
+ const clearBefore = Math.max(
+ oldClearBefore,
+ conversation?.lastMessageId || 0,
+ ...storedMessages.map((message) => message.id || 0)
+ )
+ await db.setSetting(settingKey, clearBefore, tx)
+ if (pendingClientKeys.size > 0) {
+ await db.setSetting(
+ deletedSettingKey,
+ Array.from(new Set([...oldDeletedKeys, ...pendingClientKeys])),
+ tx
+ )
+ }
+ await db.deleteByIndex('messages', 'clientConversationId', clientConversationId, tx)
+ })
+ // 2. 事务提交后清理内存消息和媒体资源
messages.forEach((message) => {
- revokeBlobUrlsInContent(message.content);
- message._localFile = undefined;
- });
- Reflect.deleteProperty(this.messagesByConversation, clientConversationId);
- Reflect.deleteProperty(this.messageDOPageCursors, clientConversationId);
+ revokeBlobUrlsInContent(message.content)
+ message._localFile = undefined
+ })
+ delete this.messagesByConversation[clientConversationId]
+ delete this.messageDOPageCursors[clientConversationId]
this.loadedConversationKeys = this.loadedConversationKeys.filter(
- (key) => key !== clientConversationId,
- );
- // 2. 删除 IndexedDB 消息
- getDb()
- .deleteByIndex('messages', 'clientConversationId', clientConversationId)
- .catch((error) =>
- console.warn('[IM messageStore] 会话消息删除失败', error),
- );
- },
- },
-});
-
-export const useMessageStoreWithOut = () => useMessageStore();
+ (key) => key !== clientConversationId
+ )
+ }
+ }
+})
if (import.meta.hot) {
- import.meta.hot.accept(acceptHMRUpdate(useMessageStore, import.meta.hot));
+ import.meta.hot.accept(acceptHMRUpdate(useMessageStore, import.meta.hot))
}
diff --git a/apps/web-antd/src/views/im/home/store/rtcStore.ts b/apps/web-antd/src/views/im/home/store/rtcStore.ts
index 1137cf906..92439b7dc 100644
--- a/apps/web-antd/src/views/im/home/store/rtcStore.ts
+++ b/apps/web-antd/src/views/im/home/store/rtcStore.ts
@@ -1,94 +1,96 @@
-import type {
- ImRtcCallEndReasonValue,
- ImRtcCallStageValue,
- ImRtcParticipantStatusValue,
-} from '../../utils/constants';
+import type { ImRtcApi } from '#/api/im/rtc'
-import type { ImRtcApi } from '#/api/im/rtc';
+import { computed, ref } from 'vue'
-import { computed, ref } from 'vue';
+import { isEqual } from '@vben/utils'
-import { defineStore } from 'pinia';
+import { defineStore } from 'pinia'
-import { getCurrentUserId } from '#/views/im/utils/auth';
+import { getCurrentUserId } from '#/views/im/utils/auth'
import {
ImConversationType,
+ type ImRtcCallEndReasonValue,
ImRtcCallStage,
+ type ImRtcCallStageValue,
ImRtcCallStatus,
-} from '../../utils/constants';
-import { useFriendStore } from './friendStore';
-import { useGroupStore } from './groupStore';
+ type ImRtcParticipantStatusValue
+} from '../../utils/constants'
+import { useFriendStore } from './friendStore'
+import { useGroupStore } from './groupStore'
+
+type ImRtcCallRespVO = ImRtcApi.RtcCallRespVO
+type ImRtcGroupCallRespVO = ImRtcApi.RtcGroupCallRespVO
type GroupActiveCallCache = {
- participantsLoaded?: boolean; // 是否已拉取完整参与者列表
-} & ImRtcApi.RtcGroupCallRespVO;
+ participantsLoaded?: boolean // 是否已拉取完整参与者列表
+} & ImRtcGroupCallRespVO
// RTC_CALL 通话信令载荷;按 status 区分子类型语义
export interface ImRtcCallNotification {
- status: ImRtcParticipantStatusValue;
- room: string;
- conversationType: number;
- mediaType: number;
- groupId?: number;
+ status: ImRtcParticipantStatusValue
+ room: string
+ conversationType: number
+ mediaType: number
+ groupId?: number
// INVITE 专属:被叫接通需要的 LiveKit 连接参数 + 主叫展示信息
- livekitUrl?: string;
- token?: string;
- inviterUserId?: number;
- inviterNickname?: string;
- inviterAvatar?: string;
+ livekitUrl?: string
+ token?: string
+ inviterUserId?: number
+ inviterNickname?: string
+ inviterAvatar?: string
// INVITE 专属:本次被邀请人列表;包含收件人自身,前端来电小条按需过滤展示「邀请的其他人」
- inviteeIds?: number[];
+ inviteeIds?: number[]
// REJECT 专属:操作者展示信息(其它子类型走 RTC_CALL_END)
- operatorUserId?: number;
- operatorNickname?: string;
- operatorAvatar?: string;
+ operatorUserId?: number
+ operatorNickname?: string
+ operatorAvatar?: string
}
// RTC_PARTICIPANT_CONNECTED 通话参与者加入载荷(LiveKit webhook 转推)
export interface ImRtcParticipantConnectedNotification {
- room: string;
- userId: number;
- conversationType: number;
- groupId?: number;
+ room: string
+ userId: number
+ conversationType: number
+ groupId?: number
// 群聊场景非邀请成员首次填充胶囊条用
- mediaType?: number;
- inviterUserId?: number;
+ mediaType?: number
+ inviterUserId?: number
}
// RTC_PARTICIPANT_DISCONNECTED 通话参与者离开载荷(LiveKit webhook 转推)
export interface ImRtcParticipantDisconnectedNotification {
- room: string;
- userId: number;
- conversationType: number;
- groupId?: number;
+ room: string
+ userId: number
+ conversationType: number
+ groupId?: number
}
// RTC_CALL_END 通话结束载荷(入消息流;私聊渲染消息气泡,群聊渲染系统提示行)
export interface ImRtcCallEndNotification {
- room: string;
- conversationType: number;
- mediaType: number;
- endReason: ImRtcCallEndReasonValue;
- durationSeconds?: number;
+ room: string
+ conversationType: number
+ mediaType: number
+ endReason: ImRtcCallEndReasonValue
+ durationSeconds?: number
// 操作者聚合字段:HANGUP/CANCEL/REJECT 触发人;webhook 兜底为 null
- operatorUserId?: number;
- operatorNickname?: string;
- operatorAvatar?: string;
+ operatorUserId?: number
+ operatorNickname?: string
+ operatorAvatar?: string
}
export const useRtcStore = defineStore('imRtc', () => {
/** 当前阶段 */
- const stage = ref(ImRtcCallStage.IDLE);
+ const stage = ref(ImRtcCallStage.IDLE)
/** 当前通话;invite / accept / refreshToken 拿到的完整信息 */
- const call = ref(null);
+ const call = ref(null)
/** 来电载荷;仅 INCOMING 阶段使用;status 固定 INVITING,其它字段 INVITE 专属 */
- const incomingPayload = ref(null);
+ const incomingPayload = ref(null)
/** 进入 RUNNING 的时间戳;用于通话时长展示;reset 时清零 */
- const startedAt = ref(0);
+ const startedAt = ref(0)
/** 是否处于通话相关阶段 */
- const isActive = computed(() => stage.value !== ImRtcCallStage.IDLE);
+ const isActive = computed(() => stage.value !== ImRtcCallStage.IDLE)
/**
* 对端展示名;按阶段 + 会话类型分支:
@@ -96,61 +98,57 @@ export const useRtcStore = defineStore('imRtc', () => {
*/
const peerNickname = computed(() => {
if (stage.value === ImRtcCallStage.INCOMING) {
- return incomingPayload.value?.inviterNickname || '';
+ return incomingPayload.value?.inviterNickname || ''
}
- const c = call.value;
- if (!c) return '';
+ const c = call.value
+ if (!c) return ''
if (c.conversationType === ImConversationType.GROUP) {
- return useGroupStore().getGroup(c.groupId ?? 0)?.name || '';
+ return useGroupStore().getGroup(c.groupId ?? 0)?.name || ''
}
- const peerUserId = resolvePrivatePeerUserId(c);
- return (
- (peerUserId && useFriendStore().getFriend(peerUserId)?.nickname) || ''
- );
- });
+ const peerUserId = resolvePrivatePeerUserId(c)
+ return (peerUserId && useFriendStore().getFriend(peerUserId)?.nickname) || ''
+ })
/** 对端头像;策略同 peerNickname */
const peerAvatar = computed(() => {
if (stage.value === ImRtcCallStage.INCOMING) {
- return incomingPayload.value?.inviterAvatar || '';
+ return incomingPayload.value?.inviterAvatar || ''
}
- const c = call.value;
- if (!c) return '';
+ const c = call.value
+ if (!c) return ''
if (c.conversationType === ImConversationType.GROUP) {
- return useGroupStore().getGroup(c.groupId ?? 0)?.avatar || '';
+ return useGroupStore().getGroup(c.groupId ?? 0)?.avatar || ''
}
- const peerUserId = resolvePrivatePeerUserId(c);
- return (peerUserId && useFriendStore().getFriend(peerUserId)?.avatar) || '';
- });
+ const peerUserId = resolvePrivatePeerUserId(c)
+ return (peerUserId && useFriendStore().getFriend(peerUserId)?.avatar) || ''
+ })
/** 私聊场景对端 userId:自己是主叫则取首个 invitee,否则取 inviter */
- function resolvePrivatePeerUserId(
- c: ImRtcApi.RtcCallRespVO,
- ): number | undefined {
- const myId = getCurrentUserId();
- return c.inviterId === myId ? c.inviteeIds?.[0] : c.inviterId;
+ function resolvePrivatePeerUserId(c: ImRtcCallRespVO): number | undefined {
+ const myId = getCurrentUserId()
+ return c.inviterId === myId ? c.inviteeIds?.[0] : c.inviterId
}
/** 群活跃通话索引;groupId -> 群通话摘要;用于群聊顶部胶囊条 */
- const groupActiveCalls = ref