refactor(im): 拆分会话消息存储并合并草稿
- 新增 IM IndexedDB DB client,按当前用户初始化本地库 - 将会话与消息拆成 conversations / messages 逐条存储 - 将草稿合并进 Conversation.draft,删除 draftStore - 优化 pull 批量写入,消息、会话摘要和游标同事务落库 - 统一 store action 命名,清理旧 localStorage key 和 TODO - 保留 maxId settings 游标,避免本地消息回收后游标回退im
parent
811b93d9f1
commit
664904bd06
|
|
@ -2,6 +2,7 @@ import request from '@/config/axios'
|
||||||
|
|
||||||
export interface ImChannelMessageRespVO {
|
export interface ImChannelMessageRespVO {
|
||||||
id: number
|
id: number
|
||||||
|
clientMessageId?: string
|
||||||
channelId: number
|
channelId: number
|
||||||
materialId: number
|
materialId: number
|
||||||
type: number
|
type: number
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
import { useConversationStore } from '../store/conversationStore'
|
import { useConversationStore } from '../store/conversationStore'
|
||||||
import { useMessageStore, type PulledMessageBatchItem } from '../store/messageStore'
|
import { useMessageStore, type PulledMessage } from '../store/messageStore'
|
||||||
import { useImWebSocketStore } from '../store/websocketStore'
|
import { useImWebSocketStore } from '../store/websocketStore'
|
||||||
import { useFriendStore } from '../store/friendStore'
|
import { useFriendStore } from '../store/friendStore'
|
||||||
import { getFriendDisplayName } from '../../utils/user'
|
import { getFriendDisplayName } from '../../utils/user'
|
||||||
|
|
@ -106,8 +106,7 @@ export const useMessagePuller = () => {
|
||||||
const convertChannelMessage = (message: ImChannelMessageRespVO): Message => {
|
const convertChannelMessage = (message: ImChannelMessageRespVO): Message => {
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
// TODO @AI:是不是都需要使用 message 的 clientMessageId;注意,后端也需要有 clientMessageId;
|
clientMessageId: message.clientMessageId || generateClientMessageId(),
|
||||||
clientMessageId: generateClientMessageId(),
|
|
||||||
type: message.type,
|
type: message.type,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
status: message.status ?? ImMessageStatus.UNREAD,
|
status: message.status ?? ImMessageStatus.UNREAD,
|
||||||
|
|
@ -190,14 +189,13 @@ export const useMessagePuller = () => {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:感觉这个 PulledMessageBatchItem 类名,batchItems 变量名,insertPulledBatch 方法名,不够能体现出 message;
|
const pulledMessages: PulledMessage[] = []
|
||||||
const batchItems: PulledMessageBatchItem[] = []
|
|
||||||
// 逐条 dispatch:原消息走批量 insert;RECALL 信号走批量 recall 把同批内已 insert 的原消息更新为撤回提示。
|
// 逐条 dispatch:原消息走批量 insert;RECALL 信号走批量 recall 把同批内已 insert 的原消息更新为撤回提示。
|
||||||
// 后端按 id 升序返回,且信号 id 一定 > 原消息 id(先更新 status 再插信号),所以原消息一定先到、recallMessage 找得到
|
// 后端按 id 升序返回,且信号 id 一定 > 原消息 id(先更新 status 再插信号),所以原消息一定先到、recallMessage 找得到
|
||||||
for (const raw of list) {
|
for (const raw of list) {
|
||||||
if (isChannel) {
|
if (isChannel) {
|
||||||
const message = raw as ImChannelMessageRespVO
|
const message = raw as ImChannelMessageRespVO
|
||||||
batchItems.push({
|
pulledMessages.push({
|
||||||
kind: 'insert',
|
kind: 'insert',
|
||||||
conversationInfo: convertChannelConversation(message),
|
conversationInfo: convertChannelConversation(message),
|
||||||
message: convertChannelMessage(message)
|
message: convertChannelMessage(message)
|
||||||
|
|
@ -208,7 +206,7 @@ export const useMessagePuller = () => {
|
||||||
const message = raw as ImPrivateMessageRespVO
|
const message = raw as ImPrivateMessageRespVO
|
||||||
// 特殊:撤回消息的处理
|
// 特殊:撤回消息的处理
|
||||||
if (message.type === ImMessageType.RECALL) {
|
if (message.type === ImMessageType.RECALL) {
|
||||||
batchItems.push({
|
pulledMessages.push({
|
||||||
kind: 'recall',
|
kind: 'recall',
|
||||||
conversationType: ImConversationType.PRIVATE,
|
conversationType: ImConversationType.PRIVATE,
|
||||||
targetId: getPrivatePeerId(message),
|
targetId: getPrivatePeerId(message),
|
||||||
|
|
@ -226,7 +224,7 @@ export const useMessagePuller = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 其它消息正常入会话消息列表
|
// 其它消息正常入会话消息列表
|
||||||
batchItems.push({
|
pulledMessages.push({
|
||||||
kind: 'insert',
|
kind: 'insert',
|
||||||
conversationInfo: convertPrivateConversation(message),
|
conversationInfo: convertPrivateConversation(message),
|
||||||
message: convertPrivateMessage(message)
|
message: convertPrivateMessage(message)
|
||||||
|
|
@ -235,7 +233,7 @@ export const useMessagePuller = () => {
|
||||||
const message = raw as ImGroupMessageRespVO
|
const message = raw as ImGroupMessageRespVO
|
||||||
// 特殊:撤回消息的处理
|
// 特殊:撤回消息的处理
|
||||||
if (message.type === ImMessageType.RECALL) {
|
if (message.type === ImMessageType.RECALL) {
|
||||||
batchItems.push({
|
pulledMessages.push({
|
||||||
kind: 'recall',
|
kind: 'recall',
|
||||||
conversationType: ImConversationType.GROUP,
|
conversationType: ImConversationType.GROUP,
|
||||||
targetId: message.groupId,
|
targetId: message.groupId,
|
||||||
|
|
@ -244,7 +242,7 @@ export const useMessagePuller = () => {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 其它消息正常入会话消息列表
|
// 其它消息正常入会话消息列表
|
||||||
batchItems.push({
|
pulledMessages.push({
|
||||||
kind: 'insert',
|
kind: 'insert',
|
||||||
conversationInfo: convertGroupConversation(message),
|
conversationInfo: convertGroupConversation(message),
|
||||||
message: convertGroupMessage(message)
|
message: convertGroupMessage(message)
|
||||||
|
|
@ -255,11 +253,11 @@ export const useMessagePuller = () => {
|
||||||
// 游标推进到本批最大 id,与后端返回顺序无关;无有效 id 直接 break 避免死翻同一批
|
// 游标推进到本批最大 id,与后端返回顺序无关;无有效 id 直接 break 避免死翻同一批
|
||||||
const validIds = list.map((message) => message.id).filter((id): id is number => id != null)
|
const validIds = list.map((message) => message.id).filter((id): id is number => id != null)
|
||||||
if (validIds.length === 0) {
|
if (validIds.length === 0) {
|
||||||
await messageStore.insertPulledBatch(batchItems, conversationType)
|
await messageStore.applyPulledMessageList(pulledMessages, conversationType)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const nextMinId = Math.max(...validIds)
|
const nextMinId = Math.max(...validIds)
|
||||||
await messageStore.insertPulledBatch(batchItems, conversationType, nextMinId)
|
await messageStore.applyPulledMessageList(pulledMessages, conversationType, nextMinId)
|
||||||
// 游标没前进就停:当前后端契约是 id > minId,理论不会出现;防御后端契约变更或边界数据死翻
|
// 游标没前进就停:当前后端契约是 id > minId,理论不会出现;防御后端契约变更或边界数据死翻
|
||||||
if (nextMinId <= minId) {
|
if (nextMinId <= minId) {
|
||||||
break
|
break
|
||||||
|
|
@ -378,7 +376,7 @@ export const useMessagePuller = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// pull + replay 都完成后再排序,避免回放消息打乱顺序
|
// pull + replay 都完成后再排序,避免回放消息打乱顺序
|
||||||
conversationStore.sortConversations()
|
conversationStore.sortConversationList()
|
||||||
|
|
||||||
// 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」
|
// 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」
|
||||||
// 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 Index.vue 的 watch 触发
|
// 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 Index.vue 的 watch 触发
|
||||||
|
|
@ -394,7 +392,7 @@ export const useMessagePuller = () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (maxReadId) {
|
if (maxReadId) {
|
||||||
messageStore.applyReadReceipt({
|
messageStore.applyMessageReadReceipt({
|
||||||
conversationType: ImConversationType.PRIVATE,
|
conversationType: ImConversationType.PRIVATE,
|
||||||
targetId: active.targetId,
|
targetId: active.targetId,
|
||||||
privateReadMaxId: maxReadId
|
privateReadMaxId: maxReadId
|
||||||
|
|
|
||||||
|
|
@ -111,10 +111,9 @@ export const useMessageSender = () => {
|
||||||
clientMessageId = options.existingClientMessageId
|
clientMessageId = options.existingClientMessageId
|
||||||
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
|
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
|
||||||
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
|
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
|
||||||
// TODO @AI:尽量不要 m 缩写,全称
|
|
||||||
const stillExists = messageStore
|
const stillExists = messageStore
|
||||||
.getMessageList(conversation.type, realTarget)
|
.getMessageList(conversation.type, realTarget)
|
||||||
.some((m) => m.clientMessageId === clientMessageId && !m._ackMerging)
|
.some((message) => message.clientMessageId === clientMessageId && !message._ackMerging)
|
||||||
if (!stillExists) {
|
if (!stillExists) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -227,10 +226,13 @@ export const useMessageSender = () => {
|
||||||
// 本地标记已读:未读数清零 + 消息状态更新为 READ(UI 立刻响应)
|
// 本地标记已读:未读数清零 + 消息状态更新为 READ(UI 立刻响应)
|
||||||
conversationStore.markConversationAsRead(conversation.type, conversation.targetId)
|
conversationStore.markConversationAsRead(conversation.type, conversation.targetId)
|
||||||
messageStore.markConversationMessagesRead(conversation)
|
messageStore.markConversationMessagesRead(conversation)
|
||||||
// TODO @AI:message;不要用 m;
|
|
||||||
const maxMessageId = messageStore
|
const maxMessageId = messageStore
|
||||||
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
|
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
|
||||||
.reduce<number>((max, m) => (m.id && m.id > max ? m.id : max), 0)
|
.reduce<number>(
|
||||||
|
(maxMessageId, message) =>
|
||||||
|
message.id && message.id > maxMessageId ? message.id : maxMessageId,
|
||||||
|
0
|
||||||
|
)
|
||||||
if (!maxMessageId) {
|
if (!maxMessageId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -285,8 +287,8 @@ export const useMessageSender = () => {
|
||||||
if (!maxReadId) {
|
if (!maxReadId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// applyReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ
|
// applyMessageReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ
|
||||||
messageStore.applyReadReceipt({
|
messageStore.applyMessageReadReceipt({
|
||||||
conversationType: ImConversationType.PRIVATE,
|
conversationType: ImConversationType.PRIVATE,
|
||||||
targetId: peerId,
|
targetId: peerId,
|
||||||
privateReadMaxId: maxReadId
|
privateReadMaxId: maxReadId
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ import { useImWebSocketStore } from './store/websocketStore'
|
||||||
import { useFriendStore } from './store/friendStore'
|
import { useFriendStore } from './store/friendStore'
|
||||||
import { useGroupStore } from './store/groupStore'
|
import { useGroupStore } from './store/groupStore'
|
||||||
import { useGroupRequestStore } from './store/groupRequestStore'
|
import { useGroupRequestStore } from './store/groupRequestStore'
|
||||||
import { useDraftStore } from './store/draftStore'
|
|
||||||
import { useFaceStore } from './store/faceStore'
|
import { useFaceStore } from './store/faceStore'
|
||||||
import { useChannelStore } from './store/channelStore'
|
import { useChannelStore } from './store/channelStore'
|
||||||
import { useMessagePuller } from './composables/useMessagePuller'
|
import { useMessagePuller } from './composables/useMessagePuller'
|
||||||
|
|
@ -65,7 +64,6 @@ const webSocketStore = useImWebSocketStore()
|
||||||
const friendStore = useFriendStore()
|
const friendStore = useFriendStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const groupRequestStore = useGroupRequestStore()
|
const groupRequestStore = useGroupRequestStore()
|
||||||
const draftStore = useDraftStore()
|
|
||||||
const faceStore = useFaceStore()
|
const faceStore = useFaceStore()
|
||||||
const channelStore = useChannelStore()
|
const channelStore = useChannelStore()
|
||||||
const { pullOnce, cancelPull } = useMessagePuller()
|
const { pullOnce, cancelPull } = useMessagePuller()
|
||||||
|
|
@ -81,18 +79,17 @@ onMounted(async () => {
|
||||||
.fetchUnhandledList()
|
.fetchUnhandledList()
|
||||||
.catch((e) => console.warn('[IM] 拉取未处理加群申请失败', e))
|
.catch((e) => console.warn('[IM] 拉取未处理加群申请失败', e))
|
||||||
|
|
||||||
// 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
// 1.1 整段 loading=true 阻断会话列表抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
||||||
conversationStore.loading = true
|
conversationStore.loading = true
|
||||||
try {
|
try {
|
||||||
// TODO @AI:这里要写个注释???!!!
|
// 1.2 打开当前用户 IM DB
|
||||||
await initDb()
|
await initDb()
|
||||||
// 1.2 五个 store 并发从 IDB 读取本地缓存(loadConversations / loadDrafts 返回 void;load{Friends,Groups,Channels} 返回是否命中缓存)
|
// 1.3 五个 store 并发从 IDB 读取本地缓存(loadConversations / loadMessageCursors 返回 void;load{Friends,Groups,Channels} 返回是否命中缓存)
|
||||||
const [, , hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([
|
const [, , hasCachedFriends, hasCachedGroups, hasCachedChannels] = await Promise.all([
|
||||||
conversationStore.loadConversations(),
|
conversationStore.loadConversations(),
|
||||||
messageStore.loadCursors(),
|
messageStore.loadMessageCursors(),
|
||||||
friendStore.loadFriends(),
|
friendStore.loadFriends(),
|
||||||
groupStore.loadGroups(),
|
groupStore.loadGroups(),
|
||||||
draftStore.loadDrafts(),
|
|
||||||
channelStore.loadChannels()
|
channelStore.loadChannels()
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -130,7 +127,7 @@ onMounted(async () => {
|
||||||
conversationStore.setActiveConversation(firstVisible)
|
conversationStore.setActiveConversation(firstVisible)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 1. 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续 saveConversations 全被早 return 阻断
|
// 1. 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续会话列表写入全被早 return 阻断
|
||||||
// 2. WebSocket 不在这里 disconnect——路由离开会走 onUnmounted 自然清理,用户也可以刷新重试
|
// 2. WebSocket 不在这里 disconnect——路由离开会走 onUnmounted 自然清理,用户也可以刷新重试
|
||||||
conversationStore.loading = false
|
conversationStore.loading = false
|
||||||
console.error('[IM] 初始化失败', e)
|
console.error('[IM] 初始化失败', e)
|
||||||
|
|
@ -155,7 +152,7 @@ function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | un
|
||||||
|
|
||||||
/** 标签关闭前 flush 草稿队列;debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */
|
/** 标签关闭前 flush 草稿队列;debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */
|
||||||
function onBeforeUnload() {
|
function onBeforeUnload() {
|
||||||
draftStore.flushPersist()
|
conversationStore.flushDraftSave()
|
||||||
}
|
}
|
||||||
window.addEventListener('beforeunload', onBeforeUnload)
|
window.addEventListener('beforeunload', onBeforeUnload)
|
||||||
|
|
||||||
|
|
@ -163,12 +160,12 @@ window.addEventListener('beforeunload', onBeforeUnload)
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
cancelPull()
|
cancelPull()
|
||||||
webSocketStore.disconnect()
|
webSocketStore.disconnect()
|
||||||
draftStore.flushPersist()
|
conversationStore.flushDraftSave()
|
||||||
faceStore.reset()
|
faceStore.reset()
|
||||||
// 模块级单例 audio 不会随视图卸载自动停,主动停掉避免切路由后语音继续响
|
// 模块级单例 audio 不会随视图卸载自动停,主动停掉避免切路由后语音继续响
|
||||||
voicePlayer.stop()
|
voicePlayer.stop()
|
||||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||||
// TODO @AI:写个注释?!
|
// 停止当前 IM session 并清理各 store 内存
|
||||||
void stopRequests()
|
void stopRequests()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,7 @@
|
||||||
<!-- 进群审批:仅群主可操作;开启后普通成员的「申请」「邀请」路径都需群主 / 管理员同意;群主 / 管理员邀请直进 -->
|
<!-- 进群审批:仅群主可操作;开启后普通成员的「申请」「邀请」路径都需群主 / 管理员同意;群主 / 管理员邀请直进 -->
|
||||||
<div v-if="isOwner" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
|
<div v-if="isOwner" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
|
||||||
<span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">进群需要群主 / 群管理确认</span>
|
<span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">进群需要群主 / 群管理确认</span>
|
||||||
<el-switch :model-value="!!group.joinApproval" @change="onJoinApprovalChange" />
|
<el-switch :model-value="!!group.joinApproval" @change="handleJoinApprovalChange" />
|
||||||
</div>
|
</div>
|
||||||
<!-- 进群申请子项:仅当开启审批 + 当前用户是 owner / admin 时出现;点击进列表 dialog -->
|
<!-- 进群申请子项:仅当开启审批 + 当前用户是 owner / admin 时出现;点击进列表 dialog -->
|
||||||
<div
|
<div
|
||||||
|
|
@ -567,8 +567,7 @@ async function saveNotice() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 群主:切换「进群审批」开关;开启后所有「申请」「邀请」路径都需群主 / 管理员同意 */
|
/** 群主:切换「进群审批」开关;开启后所有「申请」「邀请」路径都需群主 / 管理员同意 */
|
||||||
// TODO @AI:应该是 handleXXX;别的方法也看看,是不是统一调整过来。
|
async function handleJoinApprovalChange(value: boolean | string | number) {
|
||||||
async function onJoinApprovalChange(value: boolean | string | number) {
|
|
||||||
if (!props.group) {
|
if (!props.group) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,6 @@ import { useFriendStore } from '../../../../store/friendStore'
|
||||||
import { useGroupStore } from '../../../../store/groupStore'
|
import { useGroupStore } from '../../../../store/groupStore'
|
||||||
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
|
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
|
||||||
import { useImUiStore } from '../../../../store/uiStore'
|
import { useImUiStore } from '../../../../store/uiStore'
|
||||||
import { useDraftStore } from '../../../../store/draftStore'
|
|
||||||
import { ImConversationType, ImMessageType, isNormalMessage } from '../../../../../utils/constants'
|
import { ImConversationType, ImMessageType, isNormalMessage } from '../../../../../utils/constants'
|
||||||
import { getSenderDisplayName } from '@/views/im/utils/user'
|
import { getSenderDisplayName } from '@/views/im/utils/user'
|
||||||
import { buildRecallTip } from '@/views/im/utils/conversation'
|
import { buildRecallTip } from '@/views/im/utils/conversation'
|
||||||
|
|
@ -128,7 +127,6 @@ const friendStore = useFriendStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const groupRequestStore = useGroupRequestStore()
|
const groupRequestStore = useGroupRequestStore()
|
||||||
const uiStore = useImUiStore()
|
const uiStore = useImUiStore()
|
||||||
const draftStore = useDraftStore()
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const isActive = computed(
|
const isActive = computed(
|
||||||
|
|
@ -145,7 +143,7 @@ const draft = computed(() => {
|
||||||
if (isActive.value) {
|
if (isActive.value) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return draftStore.getDraft(props.conversation)
|
return conversationStore.getDraft(props.conversation)
|
||||||
})
|
})
|
||||||
|
|
||||||
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
|
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
:quote="replyTarget"
|
:quote="replyTarget"
|
||||||
closable
|
closable
|
||||||
class="mx-3 mb-1.5"
|
class="mx-3 mb-1.5"
|
||||||
@close="clearReply"
|
@close="clearReplyDraft"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
@ -167,7 +167,6 @@ import { updateFile } from '@/api/infra/file'
|
||||||
import { useConversationStore } from '@/views/im/home/store/conversationStore'
|
import { useConversationStore } from '@/views/im/home/store/conversationStore'
|
||||||
import { useGroupStore } from '@/views/im/home/store/groupStore'
|
import { useGroupStore } from '@/views/im/home/store/groupStore'
|
||||||
import { useFriendStore } from '@/views/im/home/store/friendStore'
|
import { useFriendStore } from '@/views/im/home/store/friendStore'
|
||||||
import { useDraftStore } from '@/views/im/home/store/draftStore'
|
|
||||||
import { getMemberDisplayName } from '@/views/im/utils/user'
|
import { getMemberDisplayName } from '@/views/im/utils/user'
|
||||||
import { useMessage } from '@/hooks/web/useMessage'
|
import { useMessage } from '@/hooks/web/useMessage'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
|
|
@ -200,7 +199,6 @@ defineOptions({ name: 'ImMessageInput' })
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const friendStore = useFriendStore()
|
const friendStore = useFriendStore()
|
||||||
const draftStore = useDraftStore()
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const { send, sendRaw } = useMessageSender()
|
const { send, sendRaw } = useMessageSender()
|
||||||
|
|
@ -257,7 +255,7 @@ watch(muteOverlay, () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 把 editor 当前内容写到 draftStore;plain 由 collectFromEditor 拿,与发送时同源避免列表与实发不一致 */
|
/** 把 editor 当前内容写到会话草稿;plain 由 collectFromEditor 拿,与发送时同源避免列表与实发不一致 */
|
||||||
function syncDraftToStore(editor: HTMLDivElement) {
|
function syncDraftToStore(editor: HTMLDivElement) {
|
||||||
const conversation = conversationStore.activeConversation
|
const conversation = conversationStore.activeConversation
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
|
|
@ -266,8 +264,8 @@ function syncDraftToStore(editor: HTMLDivElement) {
|
||||||
// collectFromEditor 已 trim,plain 为空时 store 内部按 clearDraft 处理
|
// collectFromEditor 已 trim,plain 为空时 store 内部按 clearDraft 处理
|
||||||
// reply 透传当前快照:setDraft 是整对象替换,不读旧 reply 会让用户每敲一个键就把引用条擦掉
|
// reply 透传当前快照:setDraft 是整对象替换,不读旧 reply 会让用户每敲一个键就把引用条擦掉
|
||||||
const { text } = collectFromEditor(editor)
|
const { text } = collectFromEditor(editor)
|
||||||
const existing = draftStore.getDraft(conversation)
|
const existing = conversationStore.getDraft(conversation)
|
||||||
draftStore.setDraft(conversation, {
|
conversationStore.setDraft(conversation, {
|
||||||
html: editor.innerHTML,
|
html: editor.innerHTML,
|
||||||
plain: text,
|
plain: text,
|
||||||
reply: existing?.reply
|
reply: existing?.reply
|
||||||
|
|
@ -281,7 +279,7 @@ function restoreDraftToEditor() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const conversation = conversationStore.activeConversation
|
const conversation = conversationStore.activeConversation
|
||||||
const draft = conversation ? draftStore.getDraft(conversation) : undefined
|
const draft = conversation ? conversationStore.getDraft(conversation) : undefined
|
||||||
editor.innerHTML = draft?.html || ''
|
editor.innerHTML = draft?.html || ''
|
||||||
applyEditorUiState(editor)
|
applyEditorUiState(editor)
|
||||||
// 把光标移到末尾,让用户接着输入;空内容直接 focus 即可
|
// 把光标移到末尾,让用户接着输入;空内容直接 focus 即可
|
||||||
|
|
@ -383,7 +381,7 @@ async function handleSend(options?: { receipt?: boolean }) {
|
||||||
const replyQuote = replyTarget.value
|
const replyQuote = replyTarget.value
|
||||||
editor.innerHTML = ''
|
editor.innerHTML = ''
|
||||||
if (conversationStore.activeConversation) {
|
if (conversationStore.activeConversation) {
|
||||||
draftStore.clearDraft(conversationStore.activeConversation)
|
conversationStore.clearDraft(conversationStore.activeConversation)
|
||||||
}
|
}
|
||||||
syncEditorState()
|
syncEditorState()
|
||||||
// 2. 发送
|
// 2. 发送
|
||||||
|
|
@ -568,29 +566,29 @@ function onInput() {
|
||||||
|
|
||||||
// ==================== 引用 / 回复 ====================
|
// ==================== 引用 / 回复 ====================
|
||||||
|
|
||||||
/** 当前会话的「正在回复」对象,从 draftStore 派生(MessageItem 写、MessageInput 读) */
|
/** 当前会话的「正在回复」对象,从会话草稿派生 */
|
||||||
const replyTarget = computed<QuoteMessage | undefined>(() => {
|
const replyTarget = computed<QuoteMessage | undefined>(() => {
|
||||||
const conversation = conversationStore.activeConversation
|
const conversation = conversationStore.activeConversation
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return draftStore.getDraft(conversation)?.reply
|
return conversationStore.getDraft(conversation)?.reply
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 清掉当前 reply 但保留正文草稿:点 × 关闭 / 发送即将进行时调 */
|
/** 清掉当前 reply 但保留正文草稿:点 × 关闭 / 发送即将进行时调 */
|
||||||
function clearReply() {
|
function clearReplyDraft() {
|
||||||
const conversation = conversationStore.activeConversation
|
const conversation = conversationStore.activeConversation
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
draftStore.clearReply(conversation)
|
conversationStore.clearReplyDraft(conversation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取走当前 reply 快照(抓一次清一次),媒体上传链路在动手前统一调它拿 quote */
|
/** 取走当前 reply 快照(抓一次清一次),媒体上传链路在动手前统一调它拿 quote */
|
||||||
function consumeReply(): QuoteMessage | undefined {
|
function consumeReply(): QuoteMessage | undefined {
|
||||||
const quote = replyTarget.value
|
const quote = replyTarget.value
|
||||||
if (quote) {
|
if (quote) {
|
||||||
clearReply()
|
clearReplyDraft()
|
||||||
}
|
}
|
||||||
return quote
|
return quote
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,37 +139,36 @@ function handleTopClick() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 点击置顶消息行 → 触发跳转 + 收起弹出层 */
|
/** 点击置顶消息行 → 触发跳转 + 收起弹出层 */
|
||||||
function handleLocate(msg: Message) {
|
function handleLocate(message: Message) {
|
||||||
if (!msg.id) {
|
if (!message.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
emit('locate', msg.id)
|
emit('locate', message.id)
|
||||||
expanded.value = false
|
expanded.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:变量是不是都改成 message,不要 msg?!
|
|
||||||
/** 置顶消息发送人显示名 */
|
/** 置顶消息发送人显示名 */
|
||||||
function getSenderName(msg: Message): string {
|
function getSenderName(message: Message): string {
|
||||||
return group.value
|
return group.value
|
||||||
? getSenderDisplayName(msg.senderId, ImConversationType.GROUP, group.value.id)
|
? getSenderDisplayName(message.senderId, ImConversationType.GROUP, group.value.id)
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 置顶消息预览文本:复用会话最后一条摘要逻辑([图片] / [文件] / 文本等) */
|
/** 置顶消息预览文本:复用会话最后一条摘要逻辑([图片] / [文件] / 文本等) */
|
||||||
function getPreview(msg: Message): string {
|
function getPreview(message: Message): string {
|
||||||
return group.value
|
return group.value
|
||||||
? resolveConversationLastContent(msg, ImConversationType.GROUP, group.value.id)
|
? resolveConversationLastContent(message, ImConversationType.GROUP, group.value.id)
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 移除置顶:调后端 API,loading 期间禁止重复点;后端广播 GROUP_MESSAGE_UNPIN 由 dispatcher 自动同步本地 */
|
/** 移除置顶:调后端 API,loading 期间禁止重复点;后端广播 GROUP_MESSAGE_UNPIN 由 dispatcher 自动同步本地 */
|
||||||
async function handleRemove(msg: Message) {
|
async function handleRemove(pinnedMessage: Message) {
|
||||||
if (!group.value || !msg.id || removingId.value !== null) {
|
if (!group.value || !pinnedMessage.id || removingId.value !== null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
removingId.value = msg.id
|
removingId.value = pinnedMessage.id
|
||||||
try {
|
try {
|
||||||
await apiUnpinGroupMessage({ id: group.value.id, messageId: msg.id })
|
await apiUnpinGroupMessage({ id: group.value.id, messageId: pinnedMessage.id })
|
||||||
message.success('已取消置顶')
|
message.success('已取消置顶')
|
||||||
} finally {
|
} finally {
|
||||||
removingId.value = null
|
removingId.value = null
|
||||||
|
|
|
||||||
|
|
@ -604,9 +604,9 @@ async function loadEarlier() {
|
||||||
if (pageLength < HISTORY_PAGE_SIZE) {
|
if (pageLength < HISTORY_PAGE_SIZE) {
|
||||||
hasMore.value = false
|
hasMore.value = false
|
||||||
}
|
}
|
||||||
// 合并到 messageStore:prependMessages 内部去重 + 升序合并 + 落 IndexedDB;
|
// 合并到 messageStore:prependMessageList 内部去重 + 升序合并 + 落 IndexedDB;
|
||||||
// 主聊天面板的 messages 是同一份引用,老消息也会一起出现在主面板里(符合预期)
|
// 主聊天面板的 messages 是同一份引用,老消息也会一起出现在主面板里(符合预期)
|
||||||
messageStore.prependMessages(requestedType, requestedTargetId, earlier)
|
messageStore.prependMessageList(requestedType, requestedTargetId, earlier)
|
||||||
} finally {
|
} finally {
|
||||||
loadingMore.value = false
|
loadingMore.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,6 @@ import { useUserStore } from '@/store/modules/user'
|
||||||
import { useConversationStore } from '../../../../store/conversationStore'
|
import { useConversationStore } from '../../../../store/conversationStore'
|
||||||
import { useGroupStore } from '../../../../store/groupStore'
|
import { useGroupStore } from '../../../../store/groupStore'
|
||||||
import { useFriendStore } from '../../../../store/friendStore'
|
import { useFriendStore } from '../../../../store/friendStore'
|
||||||
import { useDraftStore } from '../../../../store/draftStore'
|
|
||||||
import { useFaceStore } from '../../../../store/faceStore'
|
import { useFaceStore } from '../../../../store/faceStore'
|
||||||
import {
|
import {
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
|
|
@ -293,7 +292,6 @@ const conversationStore = useConversationStore()
|
||||||
const messageStore = useMessageStore()
|
const messageStore = useMessageStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const friendStore = useFriendStore()
|
const friendStore = useFriendStore()
|
||||||
const draftStore = useDraftStore()
|
|
||||||
const faceStore = useFaceStore()
|
const faceStore = useFaceStore()
|
||||||
const uiStore = useImUiStore()
|
const uiStore = useImUiStore()
|
||||||
const { recall, sendRaw } = useMessageSender()
|
const { recall, sendRaw } = useMessageSender()
|
||||||
|
|
@ -602,7 +600,7 @@ type MenuKey = (typeof MENU_KEYS)[keyof typeof MENU_KEYS]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 右键菜单项:
|
* 右键菜单项:
|
||||||
* - 引用:已落库 + 未撤回的消息可引用,引用块写入 draftStore.reply
|
* - 引用:已落库 + 未撤回的消息可引用,引用块写入会话草稿
|
||||||
* - 撤回 / 删除:互斥;自己发送 + 已落库 + 未撤回 + 2 分钟内显示「撤回」(推服务器),其它显示「删除」(仅本地清)
|
* - 撤回 / 删除:互斥;自己发送 + 已落库 + 未撤回 + 2 分钟内显示「撤回」(推服务器),其它显示「删除」(仅本地清)
|
||||||
*
|
*
|
||||||
* 好友事件气泡态不弹菜单
|
* 好友事件气泡态不弹菜单
|
||||||
|
|
@ -892,13 +890,13 @@ async function handleCopy() {
|
||||||
successMessage('内容已复制到剪贴板')
|
successMessage('内容已复制到剪贴板')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 进入引用模式:把当前消息构造成 QuoteMessage 写入 draftStore,MessageInput 顶部引用条响应式出现 */
|
/** 进入引用模式:把当前消息构造成 QuoteMessage 写入会话草稿 */
|
||||||
function handleReply() {
|
function handleReply() {
|
||||||
const conversation = conversationStore.activeConversation
|
const conversation = conversationStore.activeConversation
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
draftStore.setReply(conversation, buildQuoteFromMessage(props.message))
|
conversationStore.setReplyDraft(conversation, buildQuoteFromMessage(props.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 转发当前消息:打开 ForwardDialog(单条模式;mode=single 即原样转) */
|
/** 转发当前消息:打开 ForwardDialog(单条模式;mode=single 即原样转) */
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ async function loadReadUsers() {
|
||||||
// 全可见成员都已读 → flip 到 DONE,让外面 label 直接命中"全部已读"分支;
|
// 全可见成员都已读 → flip 到 DONE,让外面 label 直接命中"全部已读"分支;
|
||||||
// 否则只更新 readCount,receiptStatus 维持不变(PENDING / READING)
|
// 否则只更新 readCount,receiptStatus 维持不变(PENDING / READING)
|
||||||
const allRead = readCount > 0 && readCount >= visibleMembers.value.length
|
const allRead = readCount > 0 && readCount >= visibleMembers.value.length
|
||||||
messageStore.applyReadReceipt({
|
messageStore.applyMessageReadReceipt({
|
||||||
conversationType: ImConversationType.GROUP,
|
conversationType: ImConversationType.GROUP,
|
||||||
targetId: props.groupId,
|
targetId: props.groupId,
|
||||||
groupMessageId: props.message.id,
|
groupMessageId: props.message.id,
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,8 @@ export const useChannelStore = defineStore('imChannelStore', {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 整桶持久化频道列表(量级小,不维护增量) */
|
/** 保存频道列表 */
|
||||||
saveChannels(): void {
|
saveChannelList(): void {
|
||||||
const userId = getCurrentUserId()
|
const userId = getCurrentUserId()
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return
|
return
|
||||||
|
|
@ -64,15 +64,15 @@ export const useChannelStore = defineStore('imChannelStore', {
|
||||||
try {
|
try {
|
||||||
this.channels = (await getSimpleChannelList()) || []
|
this.channels = (await getSimpleChannelList()) || []
|
||||||
this.loaded = true
|
this.loaded = true
|
||||||
this.syncConversationMetadata()
|
this.syncChannelConversationMetadata()
|
||||||
this.saveChannels()
|
this.saveChannelList()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[IM channelStore] fetchChannels 失败', e)
|
console.warn('[IM channelStore] fetchChannels 失败', e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 用最新的频道信息覆盖已有 CHANNEL 会话的 name / avatar;conversationStore 持久化的旧占位被刷掉 */
|
/** 用最新的频道信息覆盖已有 CHANNEL 会话的 name / avatar;conversationStore 持久化的旧占位被刷掉 */
|
||||||
syncConversationMetadata() {
|
syncChannelConversationMetadata() {
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const indexed = new Map(this.channels.map((c) => [c.id, c]))
|
const indexed = new Map(this.channels.map((c) => [c.id, c]))
|
||||||
conversationStore.conversations.forEach((conversation) => {
|
conversationStore.conversations.forEach((conversation) => {
|
||||||
|
|
@ -105,5 +105,3 @@ if (import.meta.hot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChannelStoreWithOut = () => useChannelStore(store)
|
export const useChannelStoreWithOut = () => useChannelStore(store)
|
||||||
// TODO @AI:这里,重名名,是不是没必要???(问问。)
|
|
||||||
export const useImChannelStore = useChannelStoreWithOut
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,55 @@
|
||||||
import { acceptHMRUpdate, defineStore } from 'pinia'
|
import { acceptHMRUpdate, defineStore } from 'pinia'
|
||||||
|
import { debounce } from 'lodash-es'
|
||||||
import { store } from '@/store'
|
import { store } from '@/store'
|
||||||
|
|
||||||
import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config'
|
import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config'
|
||||||
import { ImConversationType } from '../../utils/constants'
|
import { ImConversationType } from '../../utils/constants'
|
||||||
import { getClientConversationId, getDb, type DbTx } from '../../utils/db'
|
import { getClientConversationId, getDb, type DbTransaction } from '../../utils/db'
|
||||||
import { getCurrentUserId } from '../../utils/storage'
|
import { getCurrentUserId } from '../../utils/storage'
|
||||||
import { useDraftStore } from './draftStore'
|
|
||||||
import { useMessageStore } from './messageStore'
|
import { useMessageStore } from './messageStore'
|
||||||
import type { Conversation, ConversationDO } from '../types'
|
import type { Conversation, ConversationDO } from '../types'
|
||||||
|
|
||||||
|
const PERSIST_DRAFT_DEBOUNCE_MS = 500
|
||||||
|
const pendingDraftConversations = new Set<Conversation>()
|
||||||
|
|
||||||
/** 会话转 IndexedDB 记录 */
|
/** 会话转 IndexedDB 记录 */
|
||||||
function toConversationDO(conversation: Conversation): ConversationDO {
|
function toConversationDO(conversation: Conversation): ConversationDO {
|
||||||
|
const draft = conversation.draft
|
||||||
return {
|
return {
|
||||||
...conversation,
|
targetId: conversation.targetId,
|
||||||
|
type: conversation.type,
|
||||||
|
name: conversation.name,
|
||||||
|
avatar: conversation.avatar,
|
||||||
|
unreadCount: conversation.unreadCount,
|
||||||
|
lastContent: conversation.lastContent,
|
||||||
|
lastSendTime: conversation.lastSendTime,
|
||||||
|
lastSenderId: conversation.lastSenderId,
|
||||||
|
lastMessageType: conversation.lastMessageType,
|
||||||
|
lastMessageId: conversation.lastMessageId,
|
||||||
|
lastClientMessageId: conversation.lastClientMessageId,
|
||||||
|
lastMessageStatus: conversation.lastMessageStatus,
|
||||||
|
lastReceiptStatus: conversation.lastReceiptStatus,
|
||||||
|
lastSelfSend: conversation.lastSelfSend,
|
||||||
|
lastSenderDisplayName: conversation.lastSenderDisplayName,
|
||||||
|
deleted: conversation.deleted,
|
||||||
|
top: conversation.top,
|
||||||
|
silent: conversation.silent,
|
||||||
|
atMe: conversation.atMe,
|
||||||
|
atAll: conversation.atAll,
|
||||||
|
draft: draft
|
||||||
|
? {
|
||||||
|
html: draft.html,
|
||||||
|
plain: draft.plain,
|
||||||
|
reply: draft.reply
|
||||||
|
? {
|
||||||
|
messageId: draft.reply.messageId,
|
||||||
|
senderId: draft.reply.senderId,
|
||||||
|
type: draft.reply.type,
|
||||||
|
content: draft.reply.content
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
clientConversationId: getClientConversationId(conversation.type, conversation.targetId)
|
clientConversationId: getClientConversationId(conversation.type, conversation.targetId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -64,8 +101,8 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
/** 加载会话 */
|
/** 加载会话 */
|
||||||
// TODO @AI:方法里的代码段注释,写一下。
|
|
||||||
async loadConversations() {
|
async loadConversations() {
|
||||||
|
// 1. 清理旧账号内存
|
||||||
const userId = getCurrentUserId()
|
const userId = getCurrentUserId()
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
this.clear()
|
this.clear()
|
||||||
|
|
@ -75,6 +112,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
? getClientConversationId(this.activeConversation.type, this.activeConversation.targetId)
|
? getClientConversationId(this.activeConversation.type, this.activeConversation.targetId)
|
||||||
: null
|
: null
|
||||||
this.clear()
|
this.clear()
|
||||||
|
// 2. 从 IndexedDB 读取会话和轻量设置
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const [conversations, recent] = await Promise.all([
|
const [conversations, recent] = await Promise.all([
|
||||||
db.getAll<ConversationDO>('conversations'),
|
db.getAll<ConversationDO>('conversations'),
|
||||||
|
|
@ -84,6 +122,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
if (Array.isArray(recent)) {
|
if (Array.isArray(recent)) {
|
||||||
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
|
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
|
||||||
}
|
}
|
||||||
|
// 3. 恢复当前激活会话
|
||||||
if (previousActiveKey) {
|
if (previousActiveKey) {
|
||||||
this.activeConversation =
|
this.activeConversation =
|
||||||
this.conversations.find(
|
this.conversations.find(
|
||||||
|
|
@ -97,47 +136,59 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
|
|
||||||
/** 清空会话内存 */
|
/** 清空会话内存 */
|
||||||
clear() {
|
clear() {
|
||||||
|
saveDraftConversationsDebounced.cancel()
|
||||||
|
pendingDraftConversations.clear()
|
||||||
this.conversations = []
|
this.conversations = []
|
||||||
this.activeConversation = null
|
this.activeConversation = null
|
||||||
this.recentForwardConversationKeys = []
|
this.recentForwardConversationKeys = []
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 执行会话持久化 */
|
/** 执行会话记录持久化 */
|
||||||
// TODO @AI:想了下,DbTx 改成 DbTransaction 吧,变量可以叫 tx;
|
async persistConversationRecords(
|
||||||
// TODO @AI:方法里的代码段注释,写一下。
|
target: Conversation | Conversation[] | null | undefined,
|
||||||
async persistConversations(
|
tx?: DbTransaction
|
||||||
target?: Conversation | Conversation[] | null,
|
|
||||||
tx?: DbTx
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const conversations = (
|
const conversations = (Array.isArray(target) ? target : target ? [target] : []).map(
|
||||||
Array.isArray(target) ? target : target ? [target] : this.conversations
|
toConversationDO
|
||||||
).map(toConversationDO)
|
)
|
||||||
|
if (conversations.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (tx) {
|
if (tx) {
|
||||||
for (const conversation of conversations) {
|
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 (innerTx) => {
|
await db.transaction(['conversations'], 'readwrite', async (tx) => {
|
||||||
for (const conversation of conversations) {
|
for (const conversation of conversations) {
|
||||||
await db.put('conversations', conversation, innerTx)
|
await db.put('conversations', conversation, tx)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 持久化会话 */
|
/** 持久化单个会话 */
|
||||||
saveConversations(target?: Conversation | Conversation[] | null, tx?: DbTx): void {
|
saveConversation(conversation: Conversation | null | undefined, tx?: DbTransaction): void {
|
||||||
|
if (!conversation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void this.persistConversationRecords(conversation, tx).catch((e) =>
|
||||||
|
console.warn('[IM conversationStore] 会话写入失败', e)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 持久化会话列表 */
|
||||||
|
saveConversationList(conversations?: Conversation[] | null, tx?: DbTransaction): void {
|
||||||
if (this.loading && !tx) {
|
if (this.loading && !tx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void this.persistConversations(target, tx).catch((e) =>
|
void this.persistConversationRecords(conversations || this.conversations, tx).catch((e) =>
|
||||||
console.warn('[IM conversationStore] 会话写入失败', e)
|
console.warn('[IM conversationStore] 会话写入失败', e)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 确保会话存在 */
|
/** 确保会话存在 */
|
||||||
// TODO @AI:方法里的代码段注释,写一下。
|
|
||||||
ensureConversation(info: {
|
ensureConversation(info: {
|
||||||
type: number
|
type: number
|
||||||
targetId: number
|
targetId: number
|
||||||
|
|
@ -145,6 +196,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
avatar: string
|
avatar: string
|
||||||
silent?: boolean
|
silent?: boolean
|
||||||
}): Conversation {
|
}): Conversation {
|
||||||
|
// 1. 创建不存在的会话
|
||||||
let conversation = this.getConversation(info.type, info.targetId)
|
let conversation = this.getConversation(info.type, info.targetId)
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
conversation = this.createEmptyConversation(
|
conversation = this.createEmptyConversation(
|
||||||
|
|
@ -156,6 +208,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
)
|
)
|
||||||
this.conversations.unshift(conversation)
|
this.conversations.unshift(conversation)
|
||||||
} else if (conversation.deleted) {
|
} else if (conversation.deleted) {
|
||||||
|
// 2. 恢复软删除会话
|
||||||
conversation.deleted = false
|
conversation.deleted = false
|
||||||
conversation.name = info.name || conversation.name
|
conversation.name = info.name || conversation.name
|
||||||
conversation.avatar = info.avatar || conversation.avatar
|
conversation.avatar = info.avatar || conversation.avatar
|
||||||
|
|
@ -163,6 +216,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
conversation.silent = info.silent
|
conversation.silent = info.silent
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 3. 同步会话展示元数据
|
||||||
if (info.name) {
|
if (info.name) {
|
||||||
conversation.name = info.name
|
conversation.name = info.name
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +231,6 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 打开或创建会话 */
|
/** 打开或创建会话 */
|
||||||
// TODO @AI:方法里的代码段注释,写一下。
|
|
||||||
openConversation(
|
openConversation(
|
||||||
targetId: number,
|
targetId: number,
|
||||||
type: number,
|
type: number,
|
||||||
|
|
@ -185,6 +238,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
avatar: string,
|
avatar: string,
|
||||||
options?: { silent?: boolean }
|
options?: { silent?: boolean }
|
||||||
): Conversation {
|
): Conversation {
|
||||||
|
// 1. 确保会话在列表中
|
||||||
const conversation = this.ensureConversation({
|
const conversation = this.ensureConversation({
|
||||||
type,
|
type,
|
||||||
targetId,
|
targetId,
|
||||||
|
|
@ -192,23 +246,25 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
avatar,
|
avatar,
|
||||||
silent: options?.silent
|
silent: options?.silent
|
||||||
})
|
})
|
||||||
|
// 2. 激活会话并保存
|
||||||
this.setActiveConversation(conversation)
|
this.setActiveConversation(conversation)
|
||||||
this.saveConversations(conversation)
|
this.saveConversation(conversation)
|
||||||
return conversation
|
return conversation
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 设置当前会话 */
|
/** 设置当前会话 */
|
||||||
// TODO @AI:方法里的代码段注释,写一下。
|
|
||||||
setActiveConversation(conversation: Conversation | null) {
|
setActiveConversation(conversation: Conversation | null) {
|
||||||
this.activeConversation = conversation
|
this.activeConversation = conversation
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 1. 清理会话级未读状态
|
||||||
conversation.unreadCount = 0
|
conversation.unreadCount = 0
|
||||||
conversation.atMe = false
|
conversation.atMe = false
|
||||||
conversation.atAll = false
|
conversation.atAll = false
|
||||||
void useMessageStore().ensureLoaded(conversation)
|
// 2. 懒加载消息并保存会话摘要
|
||||||
this.saveConversations(conversation)
|
void useMessageStore().ensureConversationMessagesLoaded(conversation)
|
||||||
|
this.saveConversation(conversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 创建空会话 */
|
/** 创建空会话 */
|
||||||
|
|
@ -242,7 +298,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
conversation.top = top
|
conversation.top = top
|
||||||
this.saveConversations(conversation)
|
this.saveConversation(conversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 设置免打扰 */
|
/** 设置免打扰 */
|
||||||
|
|
@ -252,13 +308,12 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
conversation.silent = silent
|
conversation.silent = silent
|
||||||
// TODO @AI:saveConversations 拆成 saveConversationList、saveConversation 两个方法;
|
this.saveConversation(conversation)
|
||||||
this.saveConversations(conversation)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 删除会话 */
|
/** 删除会话 */
|
||||||
// TODO @AI:方法里的代码段注释,写一下。
|
|
||||||
removeConversation(type: number, targetId: number) {
|
removeConversation(type: number, targetId: number) {
|
||||||
|
// 1. 标记会话删除
|
||||||
const conversation = this.getConversation(type, targetId)
|
const conversation = this.getConversation(type, targetId)
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return
|
return
|
||||||
|
|
@ -267,9 +322,10 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
this.activeConversation = null
|
this.activeConversation = null
|
||||||
}
|
}
|
||||||
conversation.deleted = true
|
conversation.deleted = true
|
||||||
|
// 2. 删除会话关联的消息和草稿
|
||||||
useMessageStore().deleteConversationMessages(type, targetId)
|
useMessageStore().deleteConversationMessages(type, targetId)
|
||||||
useDraftStore().clearDraft({ type, targetId })
|
this.clearDraft(conversation)
|
||||||
this.saveConversations(conversation)
|
this.saveConversation(conversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 删除私聊会话 */
|
/** 删除私聊会话 */
|
||||||
|
|
@ -294,10 +350,10 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
conversation.unreadCount = 0
|
conversation.unreadCount = 0
|
||||||
conversation.atMe = false
|
conversation.atMe = false
|
||||||
conversation.atAll = false
|
conversation.atAll = false
|
||||||
this.saveConversations(conversation)
|
this.saveConversation(conversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
// TODO @AI:把最近转发 ==== 拆分下???
|
// ==================== 最近转发 ====================
|
||||||
|
|
||||||
/** 推送最近转发会话 */
|
/** 推送最近转发会话 */
|
||||||
pushRecentForwardConversationKeys(keys: string[]) {
|
pushRecentForwardConversationKeys(keys: string[]) {
|
||||||
|
|
@ -309,7 +365,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
0,
|
0,
|
||||||
CONVERSATION_RECENT_FORWARD_MAX
|
CONVERSATION_RECENT_FORWARD_MAX
|
||||||
)
|
)
|
||||||
this.persistRecentForwardConversationKeys()
|
this.saveRecentForwardConversationKeys()
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 移除最近转发会话 */
|
/** 移除最近转发会话 */
|
||||||
|
|
@ -319,11 +375,11 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.recentForwardConversationKeys.splice(index, 1)
|
this.recentForwardConversationKeys.splice(index, 1)
|
||||||
this.persistRecentForwardConversationKeys()
|
this.saveRecentForwardConversationKeys()
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 持久化最近转发会话 */
|
/** 保存最近转发会话 */
|
||||||
persistRecentForwardConversationKeys() {
|
saveRecentForwardConversationKeys() {
|
||||||
void getDb()
|
void getDb()
|
||||||
.setSetting(
|
.setSetting(
|
||||||
'recentForwardConversationKeys',
|
'recentForwardConversationKeys',
|
||||||
|
|
@ -332,10 +388,12 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
.catch((e) => console.warn('[IM conversationStore] 最近转发列表写入失败', e))
|
.catch((e) => console.warn('[IM conversationStore] 最近转发列表写入失败', e))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ==================== 会话维护 ====================
|
||||||
|
|
||||||
/** 重排会话 */
|
/** 重排会话 */
|
||||||
sortConversations() {
|
sortConversationList() {
|
||||||
this.conversations.sort((a, b) => (b.lastSendTime || 0) - (a.lastSendTime || 0))
|
this.conversations.sort((a, b) => (b.lastSendTime || 0) - (a.lastSendTime || 0))
|
||||||
this.saveConversations(this.conversations)
|
this.saveConversationList(this.conversations)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 同步会话展示元数据 */
|
/** 同步会话展示元数据 */
|
||||||
|
|
@ -362,14 +420,96 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if (changed) {
|
if (changed) {
|
||||||
this.saveConversations(conversation)
|
this.saveConversation(conversation)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 草稿 ====================
|
||||||
|
|
||||||
|
/** 获取草稿 */
|
||||||
|
getDraft(conversation: { type: number; targetId: number }): Conversation['draft'] | undefined {
|
||||||
|
return this.getConversation(conversation.type, conversation.targetId)?.draft
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 设置草稿 */
|
||||||
|
setDraft(
|
||||||
|
conversation: { type: number; targetId: number },
|
||||||
|
snapshot: NonNullable<Conversation['draft']>
|
||||||
|
): void {
|
||||||
|
if (!snapshot.plain.trim() && !snapshot.reply) {
|
||||||
|
this.clearDraft(conversation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const target = this.getConversation(conversation.type, conversation.targetId)
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.draft = snapshot
|
||||||
|
this.scheduleDraftSave(target)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 清除草稿 */
|
||||||
|
clearDraft(conversation: { type: number; targetId: number }): void {
|
||||||
|
const target = this.getConversation(conversation.type, conversation.targetId)
|
||||||
|
if (!target?.draft) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.draft = undefined
|
||||||
|
this.scheduleDraftSave(target)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 设置回复草稿 */
|
||||||
|
setReplyDraft(
|
||||||
|
conversation: { type: number; targetId: number },
|
||||||
|
quote: NonNullable<Conversation['draft']>['reply']
|
||||||
|
) {
|
||||||
|
if (!quote) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const existing = this.getDraft(conversation)
|
||||||
|
this.setDraft(conversation, {
|
||||||
|
html: existing?.html ?? '',
|
||||||
|
plain: existing?.plain ?? '',
|
||||||
|
reply: quote
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 清除回复草稿 */
|
||||||
|
clearReplyDraft(conversation: { type: number; targetId: number }): void {
|
||||||
|
const existing = this.getDraft(conversation)
|
||||||
|
if (!existing?.reply) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setDraft(conversation, { ...existing, reply: undefined })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 调度草稿保存 */
|
||||||
|
scheduleDraftSave(conversation: Conversation): void {
|
||||||
|
pendingDraftConversations.add(conversation)
|
||||||
|
saveDraftConversationsDebounced()
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 立即保存草稿 */
|
||||||
|
flushDraftSave(): void {
|
||||||
|
saveDraftConversationsDebounced.flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const useConversationStoreWithOut = () => useConversationStore(store)
|
export const useConversationStoreWithOut = () => useConversationStore(store)
|
||||||
|
|
||||||
|
/** 合并草稿写入 */
|
||||||
|
const saveDraftConversationsDebounced = debounce(() => {
|
||||||
|
const conversations = Array.from(pendingDraftConversations)
|
||||||
|
pendingDraftConversations.clear()
|
||||||
|
if (conversations.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void useConversationStoreWithOut()
|
||||||
|
.persistConversationRecords(conversations)
|
||||||
|
.catch((e) => console.warn('[IM conversationStore] 草稿写入失败', e))
|
||||||
|
}, PERSIST_DRAFT_DEBOUNCE_MS)
|
||||||
|
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
import.meta.hot.accept(acceptHMRUpdate(useConversationStore, import.meta.hot))
|
import.meta.hot.accept(acceptHMRUpdate(useConversationStore, import.meta.hot))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
|
||||||
import { debounce } from 'lodash-es'
|
|
||||||
|
|
||||||
import { store } from '@/store'
|
|
||||||
|
|
||||||
import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage'
|
|
||||||
import { getConversationKey } from '../../utils/conversation'
|
|
||||||
import type { QuoteMessage } from '../../utils/message'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 草稿快照
|
|
||||||
* - html:editor 是 contenteditable,含 @-token / <br>,回填编辑器用整段 innerHTML 才能还原
|
|
||||||
* - plain:纯文本预览,给会话列表 [草稿] 文案展示用,避免列表渲染时再 strip HTML
|
|
||||||
* - reply:当前会话的「正在引用」对象,跟随草稿走;切会话保留,发送后清空
|
|
||||||
*/
|
|
||||||
export interface DraftSnapshot {
|
|
||||||
html: string
|
|
||||||
plain: string
|
|
||||||
reply?: QuoteMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 草稿持久化整桶结构:Record<会话 key, 快照>;草稿量级小(每会话至多几百字节),整桶整写够用 */
|
|
||||||
type DraftBucket = Record<string, DraftSnapshot>
|
|
||||||
|
|
||||||
/** 写盘 debounce:用户连续敲键盘时合并写入,避免高频 IDB 写放大 */
|
|
||||||
const PERSIST_DEBOUNCE_MS = 500
|
|
||||||
|
|
||||||
/** 合并连续输入的 IDB 写;setQuietly 内部 toRaw 拆 reactive proxy,避免 structuredClone 抛错 */
|
|
||||||
const persistBucket = debounce((userId: number, bucket: DraftBucket) => {
|
|
||||||
setQuietly(StorageKeys.drafts(userId), bucket, '[IM draftStore] persist 失败')
|
|
||||||
}, PERSIST_DEBOUNCE_MS)
|
|
||||||
|
|
||||||
export const useDraftStore = defineStore('imDraft', {
|
|
||||||
state: () => ({
|
|
||||||
/** 内存草稿表:key = getConversationKey */
|
|
||||||
drafts: {} as DraftBucket
|
|
||||||
}),
|
|
||||||
actions: {
|
|
||||||
/**
|
|
||||||
* 启动时从 IDB 加载已有草稿;失败仅打日志(草稿丢失可以接受,不阻断 IM 初始化)
|
|
||||||
*
|
|
||||||
* 进入即清空内存:账号切换 / 重进 IM 时 store 实例可能保留上一次的 drafts,
|
|
||||||
* 同 targetId 的不同账号会话会串显示 [草稿]
|
|
||||||
*/
|
|
||||||
async loadDrafts(): Promise<void> {
|
|
||||||
this.drafts = {}
|
|
||||||
const userId = getCurrentUserId()
|
|
||||||
if (!userId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const bucket = await imStorage.getItem<DraftBucket>(StorageKeys.drafts(userId))
|
|
||||||
if (bucket && typeof bucket === 'object') {
|
|
||||||
this.drafts = bucket
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[IM draftStore] loadDrafts 失败', e)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 取草稿;返回 undefined 表示该会话无草稿 */
|
|
||||||
getDraft(conversation: { type: number; targetId: number }): DraftSnapshot | undefined {
|
|
||||||
return this.drafts[getConversationKey(conversation)]
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写草稿 + debounce 落盘
|
|
||||||
*
|
|
||||||
* plain 空且 reply 也空才按 clear 处理:用户只点了回复未输入正文时,切会话回来引用条仍要在
|
|
||||||
*/
|
|
||||||
setDraft(
|
|
||||||
conversation: { type: number; targetId: number },
|
|
||||||
snapshot: DraftSnapshot
|
|
||||||
): void {
|
|
||||||
if (!snapshot.plain.trim() && !snapshot.reply) {
|
|
||||||
this.clearDraft(conversation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.drafts[getConversationKey(conversation)] = snapshot
|
|
||||||
this.schedulePersist()
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 清草稿:发送成功 / 编辑器清空 / 会话被软删除时调用 */
|
|
||||||
clearDraft(conversation: { type: number; targetId: number }): void {
|
|
||||||
const key = getConversationKey(conversation)
|
|
||||||
if (!(key in this.drafts)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delete this.drafts[key]
|
|
||||||
this.schedulePersist()
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 进入回复模式:把 quote 写到当前草稿,正文 html / plain 保留 */
|
|
||||||
setReply(
|
|
||||||
conversation: { type: number; targetId: number },
|
|
||||||
quote: QuoteMessage
|
|
||||||
): void {
|
|
||||||
const existing = this.getDraft(conversation)
|
|
||||||
this.setDraft(conversation, {
|
|
||||||
html: existing?.html ?? '',
|
|
||||||
plain: existing?.plain ?? '',
|
|
||||||
reply: quote
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 退出回复模式:仅清掉 reply,正文草稿保留;无 reply 时直接返回 */
|
|
||||||
clearReply(conversation: { type: number; targetId: number }): void {
|
|
||||||
const existing = this.getDraft(conversation)
|
|
||||||
if (!existing?.reply) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.setDraft(conversation, { ...existing, reply: undefined })
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 调度 debounce 写盘;未登录时直接跳过(无主 key 不写) */
|
|
||||||
schedulePersist(): void {
|
|
||||||
const userId = getCurrentUserId()
|
|
||||||
if (!userId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
persistBucket(userId, this.drafts)
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 立即落盘待写的草稿;beforeunload 时调,避免最后一次输入卡在 debounce 队列里丢失 */
|
|
||||||
flushPersist(): void {
|
|
||||||
persistBucket.flush()
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 清空草稿内存 */
|
|
||||||
// TODO @AI:写草稿,是不是融合到 conversationStore 里。
|
|
||||||
clear(): void {
|
|
||||||
persistBucket.cancel()
|
|
||||||
this.drafts = {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useDraftStoreWithOut = () => {
|
|
||||||
return useDraftStore(store)
|
|
||||||
}
|
|
||||||
|
|
||||||
// dev: 让 Pinia 的 actions / state 改动支持 HMR,避免每次改 store 都得硬刷
|
|
||||||
if (import.meta.hot) {
|
|
||||||
import.meta.hot.accept(acceptHMRUpdate(useDraftStore, import.meta.hot))
|
|
||||||
}
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
getServerMessageKey,
|
getServerMessageKey,
|
||||||
parseClientConversationId,
|
parseClientConversationId,
|
||||||
setMessageMaxId,
|
setMessageMaxId,
|
||||||
type DbTx
|
type DbTransaction
|
||||||
} from '../../utils/db'
|
} from '../../utils/db'
|
||||||
import {
|
import {
|
||||||
generateClientMessageId,
|
generateClientMessageId,
|
||||||
|
|
@ -30,7 +30,8 @@ import { useGroupStore } from './groupStore'
|
||||||
import { useConversationStore } from './conversationStore'
|
import { useConversationStore } from './conversationStore'
|
||||||
import type { Conversation, Message, MessageDO } from '../types'
|
import type { Conversation, Message, MessageDO } from '../types'
|
||||||
|
|
||||||
const MESSAGE_CACHE_CONVERSATION_LIMIT = 5
|
const MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT = 5
|
||||||
|
const MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT = MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT + 1
|
||||||
const ackMergingPromises = new Map<string, Promise<void>>()
|
const ackMergingPromises = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
interface MessageConversationInfo {
|
interface MessageConversationInfo {
|
||||||
|
|
@ -41,8 +42,8 @@ interface MessageConversationInfo {
|
||||||
silent?: boolean
|
silent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:叫这个 type 有点奇怪,可能需要再考虑下。
|
/** 拉取消息批量处理项 */
|
||||||
export type PulledMessageBatchItem =
|
export type PulledMessage =
|
||||||
| {
|
| {
|
||||||
kind: 'insert'
|
kind: 'insert'
|
||||||
conversationInfo: MessageConversationInfo
|
conversationInfo: MessageConversationInfo
|
||||||
|
|
@ -82,16 +83,22 @@ function ensureClientMessageId(message: Message): Message {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 转换为 IndexedDB 消息记录 */
|
/** 转换为 IndexedDB 消息记录 */
|
||||||
// TODO @AI:buildXXX 更合理。
|
function buildMessageDO(message: Message, conversationType: number): MessageDO {
|
||||||
function toMessageDO(message: Message, conversationType: number): MessageDO {
|
|
||||||
const {
|
|
||||||
uploadProgress: _uploadProgress,
|
|
||||||
_localFile: _localFile,
|
|
||||||
_ackMerging: _ackMerging,
|
|
||||||
...rest
|
|
||||||
} = message
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
id: message.id,
|
||||||
|
clientMessageId: message.clientMessageId,
|
||||||
|
type: message.type,
|
||||||
|
content: message.content,
|
||||||
|
status: message.status,
|
||||||
|
sendTime: message.sendTime,
|
||||||
|
senderId: message.senderId,
|
||||||
|
atUserIds: message.atUserIds ? [...message.atUserIds] : undefined,
|
||||||
|
receiverUserIds: message.receiverUserIds ? [...message.receiverUserIds] : undefined,
|
||||||
|
receiptStatus: message.receiptStatus,
|
||||||
|
readCount: message.readCount,
|
||||||
|
materialId: message.materialId,
|
||||||
|
targetId: message.targetId,
|
||||||
|
selfSend: message.selfSend,
|
||||||
messageKey: getMessageKey(message, conversationType),
|
messageKey: getMessageKey(message, conversationType),
|
||||||
conversationType,
|
conversationType,
|
||||||
clientConversationId: getClientConversationId(conversationType, message.targetId)
|
clientConversationId: getClientConversationId(conversationType, message.targetId)
|
||||||
|
|
@ -99,8 +106,7 @@ function toMessageDO(message: Message, conversationType: number): MessageDO {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IndexedDB 消息记录转前端消息 */
|
/** IndexedDB 消息记录转前端消息 */
|
||||||
// TODO @AI:buildXXX 更合理。
|
function buildMessageFromDO(message: MessageDO): Message {
|
||||||
function fromMessageDO(message: MessageDO): Message {
|
|
||||||
const {
|
const {
|
||||||
messageKey: _messageKey,
|
messageKey: _messageKey,
|
||||||
conversationType: _conversationType,
|
conversationType: _conversationType,
|
||||||
|
|
@ -111,15 +117,16 @@ function fromMessageDO(message: MessageDO): Message {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 算出末条消息的发送人快照 */
|
/** 算出末条消息的发送人快照 */
|
||||||
// TODO @AI:里面的代码注释;最好写下;
|
|
||||||
function deriveLastSenderDisplayName(
|
function deriveLastSenderDisplayName(
|
||||||
conversation: Conversation,
|
conversation: Conversation,
|
||||||
senderId: number
|
senderId: number
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
// 1. 优先使用当前内存中的好友 / 群成员信息
|
||||||
const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
|
const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
|
||||||
if (liveSenderName) {
|
if (liveSenderName) {
|
||||||
return liveSenderName
|
return liveSenderName
|
||||||
}
|
}
|
||||||
|
// 2. 群成员缓存缺失时异步补齐
|
||||||
if (conversation.type === ImConversationType.GROUP) {
|
if (conversation.type === ImConversationType.GROUP) {
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const group = groupStore.getGroup(conversation.targetId)
|
const group = groupStore.getGroup(conversation.targetId)
|
||||||
|
|
@ -256,9 +263,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 从 settings 加载消息游标 */
|
/** 从 settings 加载消息游标 */
|
||||||
async loadCursors() {
|
async loadMessageCursors() {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
// TODO @AI:可以通过 message 表去算么?不通过这个。
|
|
||||||
const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([
|
const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([
|
||||||
db.getSetting<number>('privateMessageMaxId'),
|
db.getSetting<number>('privateMessageMaxId'),
|
||||||
db.getSetting<number>('groupMessageMaxId'),
|
db.getSetting<number>('groupMessageMaxId'),
|
||||||
|
|
@ -270,7 +276,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 更新内存游标 */
|
/** 更新内存游标 */
|
||||||
updateMaxId(conversationType: number, messageId?: number) {
|
updateMessageMaxId(conversationType: number, messageId?: number) {
|
||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -290,14 +296,14 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 标记会话近期使用 */
|
/** 标记会话近期使用 */
|
||||||
touchConversation(clientConversationId: string) {
|
touchConversationMessageCache(clientConversationId: string) {
|
||||||
this.loadedConversationKeys = [
|
this.loadedConversationKeys = [
|
||||||
clientConversationId,
|
clientConversationId,
|
||||||
...this.loadedConversationKeys.filter((key) => key !== clientConversationId)
|
...this.loadedConversationKeys.filter((key) => key !== clientConversationId)
|
||||||
]
|
]
|
||||||
// 保留当前活跃会话 + 最近打开过的 5 个会话。
|
// 保留当前活跃会话 + 最近打开过的会话
|
||||||
const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_CONVERSATION_LIMIT + 1)
|
const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT)
|
||||||
const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_CONVERSATION_LIMIT + 1)
|
const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT)
|
||||||
this.loadedConversationKeys = retained
|
this.loadedConversationKeys = retained
|
||||||
removed.forEach((key) => {
|
removed.forEach((key) => {
|
||||||
delete this.messagesByConversation[key]
|
delete this.messagesByConversation[key]
|
||||||
|
|
@ -305,61 +311,59 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 加载当前会话最近消息 */
|
/** 加载当前会话最近消息 */
|
||||||
async loadMore(
|
async loadMoreMessages(
|
||||||
clientConversationId: string,
|
clientConversationId: string,
|
||||||
beforeSendTime?: number,
|
beforeSendTime?: number,
|
||||||
limit = 50
|
limit = 50
|
||||||
): Promise<Message[]> {
|
): Promise<Message[]> {
|
||||||
// TODO @AI:代码段的注释;
|
// 1. 从 IndexedDB 倒序读取一页,返回前已按时间升序排列
|
||||||
const list = await getDb().getMessagesByConversation(clientConversationId, {
|
const list = await getDb().getMessageListByConversation(clientConversationId, {
|
||||||
beforeSendTime,
|
beforeSendTime,
|
||||||
limit
|
limit
|
||||||
})
|
})
|
||||||
|
// 2. 合并到内存缓存,过滤已存在的消息
|
||||||
const parsed = parseClientConversationId(clientConversationId)
|
const parsed = parseClientConversationId(clientConversationId)
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const messages = list.map(fromMessageDO)
|
const messages = list.map(buildMessageFromDO)
|
||||||
const existing = this.messagesByConversation[clientConversationId] || []
|
const existing = this.messagesByConversation[clientConversationId] || []
|
||||||
const existingKeys = new Set(existing.map((message) => getMessageKey(message, parsed.type)))
|
const existingKeys = new Set(existing.map((message) => getMessageKey(message, parsed.type)))
|
||||||
const fresh = messages.filter(
|
const fresh = messages.filter(
|
||||||
(message) => !existingKeys.has(getMessageKey(message, parsed.type))
|
(message) => !existingKeys.has(getMessageKey(message, parsed.type))
|
||||||
)
|
)
|
||||||
// TODO @AI:messageA、messageB;
|
|
||||||
this.messagesByConversation[clientConversationId] = [...fresh, ...existing].sort(
|
this.messagesByConversation[clientConversationId] = [...fresh, ...existing].sort(
|
||||||
(a, b) => (a.sendTime || 0) - (b.sendTime || 0)
|
(messageA, messageB) => (messageA.sendTime || 0) - (messageB.sendTime || 0)
|
||||||
)
|
)
|
||||||
this.touchConversation(clientConversationId)
|
this.touchConversationMessageCache(clientConversationId)
|
||||||
return fresh
|
return fresh
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 确保会话消息已加载 */
|
/** 确保会话消息已加载 */
|
||||||
async ensureLoaded(conversation: Conversation) {
|
async ensureConversationMessagesLoaded(conversation: Conversation) {
|
||||||
// TODO @AI:代码段的注释;
|
|
||||||
const key = getMessageCacheKey(conversation.type, conversation.targetId)
|
const key = getMessageCacheKey(conversation.type, conversation.targetId)
|
||||||
if (this.messagesByConversation[key]) {
|
if (this.messagesByConversation[key]) {
|
||||||
this.touchConversation(key)
|
this.touchConversationMessageCache(key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await this.loadMore(key)
|
await this.loadMoreMessages(key)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 获取内存消息数组 */
|
/** 获取内存消息数组 */
|
||||||
getMessageList(conversationType: number, targetId: number): Message[] {
|
getMessageList(conversationType: number, targetId: number): Message[] {
|
||||||
// TODO @AI:代码段的注释;
|
|
||||||
const key = getMessageCacheKey(conversationType, targetId)
|
const key = getMessageCacheKey(conversationType, targetId)
|
||||||
if (!this.messagesByConversation[key]) {
|
if (!this.messagesByConversation[key]) {
|
||||||
this.messagesByConversation[key] = []
|
this.messagesByConversation[key] = []
|
||||||
}
|
}
|
||||||
this.touchConversation(key)
|
this.touchConversationMessageCache(key)
|
||||||
return this.messagesByConversation[key]
|
return this.messagesByConversation[key]
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 持久化单条消息 */
|
/** 持久化消息记录 */
|
||||||
async persistMessage(message: Message, conversationType: number, tx?: DbTx) {
|
async persistMessageRecord(message: Message, conversationType: number, tx?: DbTransaction) {
|
||||||
// TODO @AI:代码段的注释;
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const next = toMessageDO(message, conversationType)
|
const next = buildMessageDO(message, conversationType)
|
||||||
|
// ack 后服务端 key 替换 client key
|
||||||
if (message.id && message.clientMessageId) {
|
if (message.id && message.clientMessageId) {
|
||||||
const existing = await db.getByIndex<MessageDO>(
|
const existing = await db.getByIndex<MessageDO>(
|
||||||
'messages',
|
'messages',
|
||||||
|
|
@ -374,15 +378,19 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
await db.put('messages', next, tx)
|
await db.put('messages', next, tx)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 持久化消息游标 */
|
/** 保存消息游标 */
|
||||||
async persistMaxId(conversationType: number, messageId?: number, tx?: DbTx) {
|
async saveMessageMaxId(conversationType: number, messageId?: number, tx?: DbTransaction) {
|
||||||
this.updateMaxId(conversationType, messageId)
|
this.updateMessageMaxId(conversationType, messageId)
|
||||||
await setMessageMaxId(conversationType, messageId, tx)
|
await setMessageMaxId(conversationType, messageId, tx)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 应用撤回到内存 */
|
/** 应用撤回到内存 */
|
||||||
applyRecallInMemory(conversationType: number, targetId: number, recallSignalContent: string) {
|
applyRecallMessageInMemory(
|
||||||
// TODO @AI:代码段的注释;
|
conversationType: number,
|
||||||
|
targetId: number,
|
||||||
|
recallSignalContent: string
|
||||||
|
) {
|
||||||
|
// 1. 定位被撤回的原消息
|
||||||
const messageId = parseRecallMessageId(recallSignalContent)
|
const messageId = parseRecallMessageId(recallSignalContent)
|
||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
return null
|
return null
|
||||||
|
|
@ -397,6 +405,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
// 2. 更新消息和会话摘要
|
||||||
message.type = ImMessageType.RECALL
|
message.type = ImMessageType.RECALL
|
||||||
message.status = ImMessageStatus.RECALL
|
message.status = ImMessageStatus.RECALL
|
||||||
message.content = ''
|
message.content = ''
|
||||||
|
|
@ -407,14 +416,14 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 批量写入拉取消息 */
|
/** 批量写入拉取消息 */
|
||||||
async insertPulledBatch(
|
async applyPulledMessageList(
|
||||||
items: PulledMessageBatchItem[],
|
pulledMessages: PulledMessage[],
|
||||||
conversationType: number,
|
conversationType: number,
|
||||||
maxMessageId?: number
|
maxMessageId?: number
|
||||||
) {
|
) {
|
||||||
// TODO @AI:代码段的注释;
|
if (pulledMessages.length === 0) {
|
||||||
if (items.length === 0) {
|
// 1. 空批次只推进游标
|
||||||
await this.persistMaxId(conversationType, maxMessageId)
|
await this.saveMessageMaxId(conversationType, maxMessageId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
|
|
@ -433,13 +442,14 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:是不是最好 mesages?
|
// 1. 先更新内存,收集需要持久化的消息和会话
|
||||||
for (const item of items) {
|
for (const pulledMessage of pulledMessages) {
|
||||||
if (item.kind === 'recall') {
|
if (pulledMessage.kind === 'recall') {
|
||||||
const changed = this.applyRecallInMemory(
|
// 1.1 撤回信号更新原消息
|
||||||
item.conversationType,
|
const changed = this.applyRecallMessageInMemory(
|
||||||
item.targetId,
|
pulledMessage.conversationType,
|
||||||
item.recallSignalContent
|
pulledMessage.targetId,
|
||||||
|
pulledMessage.recallSignalContent
|
||||||
)
|
)
|
||||||
if (changed) {
|
if (changed) {
|
||||||
addChanged(changed.conversation, changed.message)
|
addChanged(changed.conversation, changed.message)
|
||||||
|
|
@ -447,8 +457,9 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const { conversationInfo } = item
|
const { conversationInfo } = pulledMessage
|
||||||
const message = ensureClientMessageId(item.message)
|
const message = ensureClientMessageId(pulledMessage.message)
|
||||||
|
// 1.2 群通知先同步群资料
|
||||||
if (
|
if (
|
||||||
conversationInfo.type === ImConversationType.GROUP &&
|
conversationInfo.type === ImConversationType.GROUP &&
|
||||||
isGroupNotification(message.type)
|
isGroupNotification(message.type)
|
||||||
|
|
@ -460,21 +471,23 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1.3 确保会话和消息缓存存在
|
||||||
const conversation = conversationStore.ensureConversation(conversationInfo)
|
const conversation = conversationStore.ensureConversation(conversationInfo)
|
||||||
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
|
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
|
||||||
const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
|
const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
|
// 1.4 已存在消息合并服务端状态
|
||||||
applyServerMessageUpdate(messages[existingIndex], message)
|
applyServerMessageUpdate(messages[existingIndex], message)
|
||||||
if (existingIndex === messages.length - 1) {
|
if (existingIndex === messages.length - 1) {
|
||||||
recomputeConversationLast(conversation, messages)
|
recomputeConversationLast(conversation, messages)
|
||||||
syncConversationAtFlags(conversation, message)
|
syncConversationAtFlags(conversation, message)
|
||||||
}
|
}
|
||||||
this.updateMaxId(conversationInfo.type, message.id)
|
this.updateMessageMaxId(conversationInfo.type, message.id)
|
||||||
addChanged(conversation, messages[existingIndex])
|
addChanged(conversation, messages[existingIndex])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:applyConversationSummary 要 await 么?不然会有报错;
|
// 1.5 新消息更新会话摘要和未读状态
|
||||||
applyConversationSummary(conversation, message)
|
applyConversationSummary(conversation, message)
|
||||||
syncConversationAtFlags(conversation, message)
|
syncConversationAtFlags(conversation, message)
|
||||||
const isActive =
|
const isActive =
|
||||||
|
|
@ -490,6 +503,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
conversation.unreadCount++
|
conversation.unreadCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1.6 新消息按服务端 id 插入内存列表
|
||||||
let insertIndex = messages.length
|
let insertIndex = messages.length
|
||||||
if (message.id) {
|
if (message.id) {
|
||||||
for (let index = 0; index < messages.length; index++) {
|
for (let index = 0; index < messages.length; index++) {
|
||||||
|
|
@ -501,17 +515,22 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
messages.splice(insertIndex, 0, message)
|
messages.splice(insertIndex, 0, message)
|
||||||
this.updateMaxId(conversationInfo.type, message.id)
|
this.updateMessageMaxId(conversationInfo.type, message.id)
|
||||||
addChanged(conversation, message)
|
addChanged(conversation, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateMaxId(conversationType, maxMessageId)
|
// 2. 更新内存游标
|
||||||
|
this.updateMessageMaxId(conversationType, maxMessageId)
|
||||||
|
// 3. 单事务写入消息、会话摘要和游标
|
||||||
await getDb()
|
await getDb()
|
||||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||||
|
// 3.1 写入本批变更消息
|
||||||
for (const item of persistedMessages.values()) {
|
for (const item of persistedMessages.values()) {
|
||||||
await this.persistMessage(item.message, item.conversationType, tx)
|
await this.persistMessageRecord(item.message, item.conversationType, tx)
|
||||||
}
|
}
|
||||||
await conversationStore.persistConversations([...changedConversations.values()], tx)
|
// 3.2 写入本批变更会话
|
||||||
|
await conversationStore.persistConversationRecords([...changedConversations.values()], tx)
|
||||||
|
// 3.3 写入本批游标
|
||||||
await setMessageMaxId(conversationType, maxMessageId, tx)
|
await setMessageMaxId(conversationType, maxMessageId, tx)
|
||||||
})
|
})
|
||||||
.catch((e) => console.error('[IM messageStore] 批量消息写入失败', e))
|
.catch((e) => console.error('[IM messageStore] 批量消息写入失败', e))
|
||||||
|
|
@ -521,11 +540,11 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
insertMessage(
|
insertMessage(
|
||||||
conversationInfo: MessageConversationInfo,
|
conversationInfo: MessageConversationInfo,
|
||||||
messageInfo: Message,
|
messageInfo: Message,
|
||||||
options?: { persistMaxId?: boolean }
|
options?: { saveMaxId?: boolean }
|
||||||
) {
|
) {
|
||||||
// TODO @AI:代码段的注释;类似上面的问题;;;
|
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const message = ensureClientMessageId(messageInfo)
|
const message = ensureClientMessageId(messageInfo)
|
||||||
|
// 1. 先处理消息带来的群资料变更
|
||||||
if (conversationInfo.type === ImConversationType.GROUP && isGroupNotification(message.type)) {
|
if (conversationInfo.type === ImConversationType.GROUP && isGroupNotification(message.type)) {
|
||||||
useGroupStore().applyGroupNotification(
|
useGroupStore().applyGroupNotification(
|
||||||
conversationInfo.targetId,
|
conversationInfo.targetId,
|
||||||
|
|
@ -534,21 +553,23 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 确保会话和消息缓存存在
|
||||||
const conversation = conversationStore.ensureConversation(conversationInfo)
|
const conversation = conversationStore.ensureConversation(conversationInfo)
|
||||||
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
|
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
|
||||||
const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
|
const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
|
||||||
|
// 3. 已存在消息走覆盖更新
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
applyServerMessageUpdate(messages[existingIndex], message)
|
applyServerMessageUpdate(messages[existingIndex], message)
|
||||||
if (existingIndex === messages.length - 1) {
|
if (existingIndex === messages.length - 1) {
|
||||||
recomputeConversationLast(conversation, messages)
|
recomputeConversationLast(conversation, messages)
|
||||||
syncConversationAtFlags(conversation, message)
|
syncConversationAtFlags(conversation, message)
|
||||||
}
|
}
|
||||||
this.updateMaxId(conversationInfo.type, message.id)
|
this.updateMessageMaxId(conversationInfo.type, message.id)
|
||||||
void getDb()
|
void getDb()
|
||||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||||
await this.persistMessage(messages[existingIndex], conversationInfo.type, tx)
|
await this.persistMessageRecord(messages[existingIndex], conversationInfo.type, tx)
|
||||||
await conversationStore.persistConversations(conversation, tx)
|
await conversationStore.persistConversationRecords(conversation, tx)
|
||||||
if (options?.persistMaxId !== false) {
|
if (options?.saveMaxId !== false) {
|
||||||
await setMessageMaxId(conversationInfo.type, message.id, tx)
|
await setMessageMaxId(conversationInfo.type, message.id, tx)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -556,6 +577,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 新消息更新会话摘要和未读状态
|
||||||
applyConversationSummary(conversation, message)
|
applyConversationSummary(conversation, message)
|
||||||
syncConversationAtFlags(conversation, message)
|
syncConversationAtFlags(conversation, message)
|
||||||
|
|
||||||
|
|
@ -572,6 +594,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
conversation.unreadCount++
|
conversation.unreadCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. 新消息按 id 插入到内存数组
|
||||||
let insertIndex = messages.length
|
let insertIndex = messages.length
|
||||||
if (message.id) {
|
if (message.id) {
|
||||||
for (let index = 0; index < messages.length; index++) {
|
for (let index = 0; index < messages.length; index++) {
|
||||||
|
|
@ -583,12 +606,13 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
messages.splice(insertIndex, 0, message)
|
messages.splice(insertIndex, 0, message)
|
||||||
this.updateMaxId(conversationInfo.type, message.id)
|
this.updateMessageMaxId(conversationInfo.type, message.id)
|
||||||
|
// 6. 单事务写入消息、会话摘要和游标
|
||||||
void getDb()
|
void getDb()
|
||||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||||
await this.persistMessage(message, conversationInfo.type, tx)
|
await this.persistMessageRecord(message, conversationInfo.type, tx)
|
||||||
await conversationStore.persistConversations(conversation, tx)
|
await conversationStore.persistConversationRecords(conversation, tx)
|
||||||
if (options?.persistMaxId !== false) {
|
if (options?.saveMaxId !== false) {
|
||||||
await setMessageMaxId(conversationInfo.type, message.id, tx)
|
await setMessageMaxId(conversationInfo.type, message.id, tx)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -626,6 +650,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
clientMessageId: string,
|
clientMessageId: string,
|
||||||
updates: Partial<Message>
|
updates: Partial<Message>
|
||||||
) {
|
) {
|
||||||
|
// 1. 定位待合并消息
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const conversation = conversationStore.getConversation(conversationType, targetId)
|
const conversation = conversationStore.getConversation(conversationType, targetId)
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
|
|
@ -638,19 +663,22 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
}
|
}
|
||||||
message._ackMerging = true
|
message._ackMerging = true
|
||||||
try {
|
try {
|
||||||
|
// 2. 合并服务端 ack 到内存
|
||||||
applyServerMessageUpdate(message, updates)
|
applyServerMessageUpdate(message, updates)
|
||||||
if (messages[messages.length - 1] === message) {
|
if (messages[messages.length - 1] === message) {
|
||||||
recomputeConversationLast(conversation, messages)
|
recomputeConversationLast(conversation, messages)
|
||||||
}
|
}
|
||||||
this.updateMaxId(conversationType, message.id)
|
this.updateMessageMaxId(conversationType, message.id)
|
||||||
|
// 3. 单事务写入消息、会话摘要和游标
|
||||||
await getDb()
|
await getDb()
|
||||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||||
await this.persistMessage(message, conversationType, tx)
|
await this.persistMessageRecord(message, conversationType, tx)
|
||||||
await conversationStore.persistConversations(conversation, tx)
|
await conversationStore.persistConversationRecords(conversation, tx)
|
||||||
await setMessageMaxId(conversationType, message.id, tx)
|
await setMessageMaxId(conversationType, message.id, tx)
|
||||||
})
|
})
|
||||||
.catch((e) => console.error('[IM messageStore] ack 写入失败', e))
|
.catch((e) => console.error('[IM messageStore] ack 写入失败', e))
|
||||||
} finally {
|
} finally {
|
||||||
|
// 4. 清理合并标记
|
||||||
message._ackMerging = false
|
message._ackMerging = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -662,7 +690,6 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
clientMessageId: string,
|
clientMessageId: string,
|
||||||
patch: Partial<Message>
|
patch: Partial<Message>
|
||||||
) {
|
) {
|
||||||
// TODO @AI:代码段的注释;
|
|
||||||
const message = this.getMessageList(conversationType, targetId).find(
|
const message = this.getMessageList(conversationType, targetId).find(
|
||||||
(item) => item.clientMessageId === clientMessageId
|
(item) => item.clientMessageId === clientMessageId
|
||||||
)
|
)
|
||||||
|
|
@ -688,18 +715,22 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
/** 撤回消息 */
|
/** 撤回消息 */
|
||||||
recallMessage(conversationType: number, targetId: number, recallSignalContent: string) {
|
recallMessage(conversationType: number, targetId: number, recallSignalContent: string) {
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const changed = this.applyRecallInMemory(conversationType, targetId, recallSignalContent)
|
const changed = this.applyRecallMessageInMemory(
|
||||||
|
conversationType,
|
||||||
|
targetId,
|
||||||
|
recallSignalContent
|
||||||
|
)
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.persistMessage(changed.message, conversationType).catch((e) =>
|
this.persistMessageRecord(changed.message, conversationType).catch((e) =>
|
||||||
console.error('[IM messageStore] 撤回消息写入失败', e)
|
console.error('[IM messageStore] 撤回消息写入失败', e)
|
||||||
)
|
)
|
||||||
conversationStore.saveConversations(changed.conversation)
|
conversationStore.saveConversation(changed.conversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 应用已读回执 */
|
/** 应用已读回执 */
|
||||||
applyReadReceipt(options: {
|
applyMessageReadReceipt(options: {
|
||||||
conversationType: number
|
conversationType: number
|
||||||
targetId: number
|
targetId: number
|
||||||
privateReadMaxId?: number
|
privateReadMaxId?: number
|
||||||
|
|
@ -707,9 +738,9 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
readCount?: number
|
readCount?: number
|
||||||
receiptStatus?: number
|
receiptStatus?: number
|
||||||
}) {
|
}) {
|
||||||
// TODO @AI:代码段的注释;
|
|
||||||
const messages = this.getMessageList(options.conversationType, options.targetId)
|
const messages = this.getMessageList(options.conversationType, options.targetId)
|
||||||
const changed: Message[] = []
|
const changed: Message[] = []
|
||||||
|
// 1. 私聊回执批量更新自己发送的消息
|
||||||
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
|
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
|
||||||
messages.forEach((message) => {
|
messages.forEach((message) => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -723,6 +754,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
|
} else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
|
||||||
|
// 2. 群聊回执更新单条消息
|
||||||
const message = messages.find((item) => item.id === options.groupMessageId)
|
const message = messages.find((item) => item.id === options.groupMessageId)
|
||||||
if (message) {
|
if (message) {
|
||||||
if (options.readCount !== undefined) {
|
if (options.readCount !== undefined) {
|
||||||
|
|
@ -734,16 +766,21 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
changed.push(message)
|
changed.push(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
changed.forEach((message) => {
|
if (changed.length === 0) {
|
||||||
this.persistMessage(message, options.conversationType).catch((e) =>
|
return
|
||||||
console.warn('[IM messageStore] 回执写入失败', e)
|
}
|
||||||
)
|
// 3. 单事务写入变更消息
|
||||||
})
|
void getDb()
|
||||||
|
.transaction(['messages'], 'readwrite', async (tx) => {
|
||||||
|
for (const message of changed) {
|
||||||
|
await this.persistMessageRecord(message, options.conversationType, tx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn('[IM messageStore] 回执写入失败', e))
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 前置历史消息 */
|
/** 前置历史消息 */
|
||||||
prependMessages(conversationType: number, targetId: number, earlierMessages: Message[]) {
|
prependMessageList(conversationType: number, targetId: number, earlierMessages: Message[]) {
|
||||||
// TODO @AI:代码段的注释;
|
|
||||||
if (earlierMessages.length === 0) {
|
if (earlierMessages.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -758,11 +795,13 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
}
|
}
|
||||||
const key = getMessageCacheKey(conversationType, targetId)
|
const key = getMessageCacheKey(conversationType, targetId)
|
||||||
this.messagesByConversation[key] = [...fresh, ...messages]
|
this.messagesByConversation[key] = [...fresh, ...messages]
|
||||||
fresh.forEach((message) => {
|
void getDb()
|
||||||
this.persistMessage(message, conversationType).catch((e) =>
|
.transaction(['messages'], 'readwrite', async (tx) => {
|
||||||
console.warn('[IM messageStore] 历史消息写入失败', e)
|
for (const message of fresh) {
|
||||||
)
|
await this.persistMessageRecord(message, conversationType, tx)
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn('[IM messageStore] 历史消息写入失败', e))
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 删除单条消息 */
|
/** 删除单条消息 */
|
||||||
|
|
@ -771,7 +810,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
targetId: number,
|
targetId: number,
|
||||||
key: { id?: number; clientMessageId?: string }
|
key: { id?: number; clientMessageId?: string }
|
||||||
) {
|
) {
|
||||||
// TODO @AI:代码段的注释;
|
// 1. 定位会话和消息
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const conversation = conversationStore.getConversation(conversationType, targetId)
|
const conversation = conversationStore.getConversation(conversationType, targetId)
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
|
|
@ -787,25 +826,26 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 2. 从内存移除消息
|
||||||
const [removed] = messages.splice(index, 1)
|
const [removed] = messages.splice(index, 1)
|
||||||
revokeBlobUrlsInContent(removed.content)
|
revokeBlobUrlsInContent(removed.content)
|
||||||
if (index === messages.length) {
|
if (index === messages.length) {
|
||||||
recomputeConversationLast(conversation, messages)
|
recomputeConversationLast(conversation, messages)
|
||||||
}
|
}
|
||||||
|
// 3. 删除本地记录并保存会话摘要
|
||||||
getDb()
|
getDb()
|
||||||
.delete('messages', getMessageKey(removed, conversationType))
|
.delete('messages', getMessageKey(removed, conversationType))
|
||||||
.catch((e) => console.warn('[IM messageStore] 消息删除失败', e))
|
.catch((e) => console.warn('[IM messageStore] 消息删除失败', e))
|
||||||
conversationStore.saveConversations()
|
conversationStore.saveConversation(conversation)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 当前会话标记已读 */
|
/** 当前会话标记已读 */
|
||||||
markConversationMessagesRead(conversation: Conversation) {
|
markConversationMessagesRead(conversation: Conversation) {
|
||||||
// TODO @AI:代码段的注释;
|
|
||||||
const messages = this.getMessageList(conversation.type, conversation.targetId)
|
const messages = this.getMessageList(conversation.type, conversation.targetId)
|
||||||
messages.forEach((message) => {
|
messages.forEach((message) => {
|
||||||
if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
|
if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
|
||||||
message.status = ImMessageStatus.READ
|
message.status = ImMessageStatus.READ
|
||||||
this.persistMessage(message, conversation.type).catch((e) =>
|
this.persistMessageRecord(message, conversation.type).catch((e) =>
|
||||||
console.warn('[IM messageStore] 已读状态写入失败', e)
|
console.warn('[IM messageStore] 已读状态写入失败', e)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -814,7 +854,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
|
|
||||||
/** 删除会话全部消息 */
|
/** 删除会话全部消息 */
|
||||||
deleteConversationMessages(conversationType: number, targetId: number) {
|
deleteConversationMessages(conversationType: number, targetId: number) {
|
||||||
// TODO @AI:代码段的注释;
|
// 1. 清理内存消息和媒体资源
|
||||||
const clientConversationId = getClientConversationId(conversationType, targetId)
|
const clientConversationId = getClientConversationId(conversationType, targetId)
|
||||||
const messages = this.messagesByConversation[clientConversationId] || []
|
const messages = this.messagesByConversation[clientConversationId] || []
|
||||||
messages.forEach((message) => {
|
messages.forEach((message) => {
|
||||||
|
|
@ -825,6 +865,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
this.loadedConversationKeys = this.loadedConversationKeys.filter(
|
this.loadedConversationKeys = this.loadedConversationKeys.filter(
|
||||||
(key) => key !== clientConversationId
|
(key) => key !== clientConversationId
|
||||||
)
|
)
|
||||||
|
// 2. 删除 IndexedDB 消息
|
||||||
getDb()
|
getDb()
|
||||||
.deleteByIndex('messages', 'clientConversationId', clientConversationId)
|
.deleteByIndex('messages', 'clientConversationId', clientConversationId)
|
||||||
.catch((e) => console.warn('[IM messageStore] 会话消息删除失败', e))
|
.catch((e) => console.warn('[IM messageStore] 会话消息删除失败', e))
|
||||||
|
|
|
||||||
|
|
@ -312,8 +312,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
)
|
)
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
conversation.unreadCount = 0
|
conversation.unreadCount = 0
|
||||||
|
conversationStore.saveConversation(conversation)
|
||||||
}
|
}
|
||||||
conversationStore.saveConversations()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -416,7 +416,6 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
}
|
}
|
||||||
} else if (isGroupRequestNotification(websocketMessage.type)) {
|
} else if (isGroupRequestNotification(websocketMessage.type)) {
|
||||||
// 加群申请通知(1503 / 1505 / 1506)走私聊通道,与好友通知同段位但分开 dispatcher
|
// 加群申请通知(1503 / 1505 / 1506)走私聊通道,与好友通知同段位但分开 dispatcher
|
||||||
// TODO @AI:改成走群聊通道。不然消息不好拉到!!!
|
|
||||||
this.handleGroupRequestNotification(websocketMessage)
|
this.handleGroupRequestNotification(websocketMessage)
|
||||||
} else {
|
} else {
|
||||||
// TEXT / IMAGE / FILE / VOICE / VIDEO 等普通消息
|
// TEXT / IMAGE / FILE / VOICE / VIDEO 等普通消息
|
||||||
|
|
@ -585,7 +584,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
if (!websocketMessage.id) {
|
if (!websocketMessage.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
useMessageStore().applyReadReceipt({
|
useMessageStore().applyMessageReadReceipt({
|
||||||
conversationType: ImConversationType.PRIVATE,
|
conversationType: ImConversationType.PRIVATE,
|
||||||
targetId: websocketMessage.senderId,
|
targetId: websocketMessage.senderId,
|
||||||
privateReadMaxId: websocketMessage.id
|
privateReadMaxId: websocketMessage.id
|
||||||
|
|
@ -708,7 +707,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
if (!MESSAGE_GROUP_READ_ENABLED) {
|
if (!MESSAGE_GROUP_READ_ENABLED) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
useMessageStore().applyReadReceipt({
|
useMessageStore().applyMessageReadReceipt({
|
||||||
conversationType: ImConversationType.GROUP,
|
conversationType: ImConversationType.GROUP,
|
||||||
targetId: websocketMessage.groupId,
|
targetId: websocketMessage.groupId,
|
||||||
groupMessageId: websocketMessage.id,
|
groupMessageId: websocketMessage.id,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,14 @@ export interface ImGroupMessageDTO {
|
||||||
|
|
||||||
// ==================== 本地会话 / 消息结构 ====================
|
// ==================== 本地会话 / 消息结构 ====================
|
||||||
|
|
||||||
|
/** 引用消息 */
|
||||||
|
export interface QuoteMessage {
|
||||||
|
messageId: number // 引用消息编号
|
||||||
|
senderId: number // 引用消息发送人编号
|
||||||
|
type: number // 引用消息类型
|
||||||
|
content: string // 引用消息内容
|
||||||
|
}
|
||||||
|
|
||||||
// 会话数据结构(前端自有结构,后端无对应实体)
|
// 会话数据结构(前端自有结构,后端无对应实体)
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
// ========== 核心标识 ==========
|
// ========== 核心标识 ==========
|
||||||
|
|
@ -66,12 +74,16 @@ export interface Conversation {
|
||||||
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
||||||
atMe?: boolean // 群聊:是否有人 @我
|
atMe?: boolean // 群聊:是否有人 @我
|
||||||
atAll?: boolean // 群聊:是否有人 @全体成员
|
atAll?: boolean // 群聊:是否有人 @全体成员
|
||||||
|
draft?: {
|
||||||
|
html: string // 输入框 HTML
|
||||||
|
plain: string // 输入框纯文本
|
||||||
|
reply?: QuoteMessage // 引用消息
|
||||||
|
} // 输入框草稿
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息数据结构
|
// 消息数据结构
|
||||||
export interface Message {
|
export interface Message {
|
||||||
// ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO) ==========
|
// ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO) ==========
|
||||||
// TODO @AI:全局的 id 占位 0,是不是枚举下!!!
|
|
||||||
id?: number // 服务端消息编号,发送中为空
|
id?: number // 服务端消息编号,发送中为空
|
||||||
clientMessageId: string // 客户端消息编号,本地生成用于合并去重
|
clientMessageId: string // 客户端消息编号,本地生成用于合并去重
|
||||||
type: number // 消息类型,对齐 ImMessageType
|
type: number // 消息类型,对齐 ImMessageType
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,7 @@ export type DbStoreName =
|
||||||
| 'channels'
|
| 'channels'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
|
|
||||||
// TODO @AI:是不是继续使用 IDBTransaction,不用新的类型定义;
|
export type DbTransaction = IDBTransaction
|
||||||
export type DbTx = IDBTransaction
|
|
||||||
|
|
||||||
let currentDb: IDBDatabase | null = null
|
let currentDb: IDBDatabase | null = null
|
||||||
let currentUserId: number | null = null
|
let currentUserId: number | null = null
|
||||||
|
|
@ -116,18 +115,18 @@ function upgradeSchema(db: IDBDatabase) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 打开 IM IndexedDB */
|
/** 打开 IM IndexedDB */
|
||||||
// TODO @AI:是不是方法里,代码段的注释,是不是要增加下?
|
|
||||||
function openDb(name: string): Promise<IDBDatabase> {
|
function openDb(name: string): Promise<IDBDatabase> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(name, DB_SCHEMA_VERSION)
|
const request = indexedDB.open(name, DB_SCHEMA_VERSION)
|
||||||
|
// 创建或升级对象仓库
|
||||||
request.onupgradeneeded = () => upgradeSchema(request.result)
|
request.onupgradeneeded = () => upgradeSchema(request.result)
|
||||||
|
// 返回可复用连接
|
||||||
request.onsuccess = () => resolve(request.result)
|
request.onsuccess = () => resolve(request.result)
|
||||||
request.onerror = () => reject(request.error)
|
request.onerror = () => reject(request.error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 初始化当前用户 IM DB */
|
/** 初始化当前用户 IM DB */
|
||||||
// TODO @AI:是不是方法里,代码段的注释,是不是要增加下?
|
|
||||||
export async function initDb(): Promise<void> {
|
export async function initDb(): Promise<void> {
|
||||||
const userId = getCurrentUserId()
|
const userId = getCurrentUserId()
|
||||||
if (!Number.isFinite(userId) || userId <= 0) {
|
if (!Number.isFinite(userId) || userId <= 0) {
|
||||||
|
|
@ -142,10 +141,8 @@ export async function initDb(): Promise<void> {
|
||||||
currentDb = await openDb(getDbName(userId))
|
currentDb = await openDb(getDbName(userId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关闭当前 IM DB session */
|
/** 关闭当前 IM DB 连接 */
|
||||||
// TODO @AI:是不是需要被调用下???
|
function closeDbConnection() {
|
||||||
export function closeDbSession() {
|
|
||||||
currentSession++
|
|
||||||
currentDb?.close()
|
currentDb?.close()
|
||||||
currentDb = null
|
currentDb = null
|
||||||
currentUserId = null
|
currentUserId = null
|
||||||
|
|
@ -171,27 +168,28 @@ function toDbValue<T>(value: T): T {
|
||||||
return toRaw(value) as T
|
return toRaw(value) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:我们讨论下,DbWrapper 会不会有点怪?
|
class DbClient {
|
||||||
// TODO @AI:是不是改成 selectOne、selectAll、selectList、insert、update、delete、save 这种。
|
|
||||||
class DbWrapper {
|
|
||||||
/** 获取单条记录 */
|
/** 获取单条记录 */
|
||||||
// TODO @AI:是不是不用缩写,tx 改成 transaction 更好理解;
|
async get<T>(
|
||||||
async get<T>(storeName: DbStoreName, key: IDBValidKey, tx?: DbTx): Promise<T | undefined> {
|
storeName: DbStoreName,
|
||||||
|
key: IDBValidKey,
|
||||||
|
tx?: DbTransaction
|
||||||
|
): Promise<T | undefined> {
|
||||||
if (tx) {
|
if (tx) {
|
||||||
return requestToPromise<T | undefined>(tx.objectStore(storeName).get(key))
|
return requestToPromise<T | undefined>(tx.objectStore(storeName).get(key))
|
||||||
}
|
}
|
||||||
return this.transaction<T | undefined>([storeName], 'readonly', (innerTx) =>
|
return this.transaction<T | undefined>([storeName], 'readonly', (tx) =>
|
||||||
this.get<T>(storeName, key, innerTx)
|
this.get<T>(storeName, key, tx)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取 store 全量记录 */
|
/** 获取 store 全量记录 */
|
||||||
async getAll<T>(storeName: DbStoreName, tx?: DbTx): Promise<T[]> {
|
async getAll<T>(storeName: DbStoreName, tx?: DbTransaction): Promise<T[]> {
|
||||||
if (tx) {
|
if (tx) {
|
||||||
return requestToPromise<T[]>(tx.objectStore(storeName).getAll())
|
return requestToPromise<T[]>(tx.objectStore(storeName).getAll())
|
||||||
}
|
}
|
||||||
return this.transaction<T[]>([storeName], 'readonly', (innerTx) =>
|
return this.transaction<T[]>([storeName], 'readonly', (tx) =>
|
||||||
this.getAll<T>(storeName, innerTx)
|
this.getAll<T>(storeName, tx)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,13 +198,13 @@ class DbWrapper {
|
||||||
storeName: DbStoreName,
|
storeName: DbStoreName,
|
||||||
indexName: string,
|
indexName: string,
|
||||||
query: IDBValidKey | IDBKeyRange,
|
query: IDBValidKey | IDBKeyRange,
|
||||||
tx?: DbTx
|
tx?: DbTransaction
|
||||||
): Promise<T | undefined> {
|
): Promise<T | undefined> {
|
||||||
if (tx) {
|
if (tx) {
|
||||||
return requestToPromise<T | undefined>(tx.objectStore(storeName).index(indexName).get(query))
|
return requestToPromise<T | undefined>(tx.objectStore(storeName).index(indexName).get(query))
|
||||||
}
|
}
|
||||||
return this.transaction<T | undefined>([storeName], 'readonly', (innerTx) =>
|
return this.transaction<T | undefined>([storeName], 'readonly', (tx) =>
|
||||||
this.getByIndex<T>(storeName, indexName, query, innerTx)
|
this.getByIndex<T>(storeName, indexName, query, tx)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,35 +213,33 @@ class DbWrapper {
|
||||||
storeName: DbStoreName,
|
storeName: DbStoreName,
|
||||||
indexName: string,
|
indexName: string,
|
||||||
query?: IDBValidKey | IDBKeyRange,
|
query?: IDBValidKey | IDBKeyRange,
|
||||||
tx?: DbTx
|
tx?: DbTransaction
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
if (tx) {
|
if (tx) {
|
||||||
return requestToPromise<T[]>(tx.objectStore(storeName).index(indexName).getAll(query))
|
return requestToPromise<T[]>(tx.objectStore(storeName).index(indexName).getAll(query))
|
||||||
}
|
}
|
||||||
return this.transaction<T[]>([storeName], 'readonly', (innerTx) =>
|
return this.transaction<T[]>([storeName], 'readonly', (tx) =>
|
||||||
this.getAllByIndex<T>(storeName, indexName, query, innerTx)
|
this.getAllByIndex<T>(storeName, indexName, query, tx)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写入记录 */
|
/** 写入记录 */
|
||||||
async put<T>(storeName: DbStoreName, value: T, tx?: DbTx): Promise<void> {
|
async put<T>(storeName: DbStoreName, value: T, tx?: DbTransaction): Promise<void> {
|
||||||
if (tx) {
|
if (tx) {
|
||||||
await requestToPromise(tx.objectStore(storeName).put(toDbValue(value)))
|
await requestToPromise(tx.objectStore(storeName).put(toDbValue(value)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await this.transaction([storeName], 'readwrite', (innerTx) =>
|
await this.transaction([storeName], 'readwrite', (tx) => this.put(storeName, value, tx))
|
||||||
this.put(storeName, value, innerTx)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除记录 */
|
/** 删除记录 */
|
||||||
async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTx): Promise<void> {
|
async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTransaction): Promise<void> {
|
||||||
if (tx) {
|
if (tx) {
|
||||||
await requestToPromise(tx.objectStore(storeName).delete(key))
|
await requestToPromise(tx.objectStore(storeName).delete(key))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await this.transaction([storeName], 'readwrite', (innerTx) =>
|
await this.transaction([storeName], 'readwrite', (tx) =>
|
||||||
this.delete(storeName, key, innerTx)
|
this.delete(storeName, key, tx)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,11 +248,11 @@ class DbWrapper {
|
||||||
storeName: DbStoreName,
|
storeName: DbStoreName,
|
||||||
indexName: string,
|
indexName: string,
|
||||||
query: IDBValidKey | IDBKeyRange,
|
query: IDBValidKey | IDBKeyRange,
|
||||||
tx?: DbTx
|
tx?: DbTransaction
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!tx) {
|
if (!tx) {
|
||||||
await this.transaction([storeName], 'readwrite', (innerTx) =>
|
await this.transaction([storeName], 'readwrite', (tx) =>
|
||||||
this.deleteByIndex(storeName, indexName, query, innerTx)
|
this.deleteByIndex(storeName, indexName, query, tx)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -277,39 +273,38 @@ class DbWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 执行事务 */
|
/** 执行事务 */
|
||||||
// TODO @AI:方法里的方法段的注释,需要写么?
|
|
||||||
async transaction<T>(
|
async transaction<T>(
|
||||||
storeNames: DbStoreName[],
|
storeNames: DbStoreName[],
|
||||||
mode: IDBTransactionMode,
|
mode: IDBTransactionMode,
|
||||||
runner: (tx: DbTx) => Promise<T>
|
runner: (tx: DbTransaction) => Promise<T>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
// 开启事务前校验 session
|
||||||
const session = getDbSession()
|
const session = getDbSession()
|
||||||
guardSession(session)
|
guardSession(session)
|
||||||
const tx = getRawDb().transaction(storeNames, mode)
|
const tx = getRawDb().transaction(storeNames, mode)
|
||||||
const done = transactionDone(tx)
|
const done = transactionDone(tx)
|
||||||
let result: T
|
let result: T
|
||||||
try {
|
try {
|
||||||
|
// 事务内只执行 IndexedDB request 链
|
||||||
result = await runner(tx)
|
result = await runner(tx)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// TODO @AI:这种 logger error 要打印么?
|
|
||||||
try {
|
try {
|
||||||
tx.abort()
|
tx.abort()
|
||||||
} catch {}
|
} catch {}
|
||||||
await done.catch(() => undefined)
|
await done.catch(() => undefined)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
// commit 后再次校验 session
|
||||||
await done
|
await done
|
||||||
guardSession(session)
|
guardSession(session)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按会话分页获取消息 */
|
/** 按会话分页获取消息 */
|
||||||
// TODO @AI:这个方法里,代码段的注释,是不是要增加下?比如说,分页的逻辑,游标的逻辑,等等;
|
async getMessageListByConversation(
|
||||||
// TODO @AI:项目里,一般方法名,是使用 getListByXXXX;
|
|
||||||
async getMessagesByConversation(
|
|
||||||
clientConversationId: string,
|
clientConversationId: string,
|
||||||
options?: { beforeSendTime?: number; limit?: number },
|
options?: { beforeSendTime?: number; limit?: number },
|
||||||
tx?: DbTx
|
tx?: DbTransaction
|
||||||
): Promise<MessageDO[]> {
|
): Promise<MessageDO[]> {
|
||||||
const limit = options?.limit ?? 50
|
const limit = options?.limit ?? 50
|
||||||
const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER
|
const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER
|
||||||
|
|
@ -319,10 +314,11 @@ class DbWrapper {
|
||||||
false,
|
false,
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
const read = async (innerTx: DbTx): Promise<MessageDO[]> => {
|
const read = async (tx: DbTransaction): Promise<MessageDO[]> => {
|
||||||
const index = innerTx.objectStore('messages').index('clientConversationId+sendTime')
|
const index = tx.objectStore('messages').index('clientConversationId+sendTime')
|
||||||
const out: MessageDO[] = []
|
const out: MessageDO[] = []
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
// 从新到旧读取一页
|
||||||
const request = index.openCursor(range, 'prev')
|
const request = index.openCursor(range, 'prev')
|
||||||
request.onerror = () => reject(request.error)
|
request.onerror = () => reject(request.error)
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
|
|
@ -335,6 +331,7 @@ class DbWrapper {
|
||||||
cursor.continue()
|
cursor.continue()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// 气泡渲染需要按时间升序
|
||||||
return out.reverse()
|
return out.reverse()
|
||||||
}
|
}
|
||||||
if (tx) {
|
if (tx) {
|
||||||
|
|
@ -344,22 +341,22 @@ class DbWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取设置 */
|
/** 读取设置 */
|
||||||
async getSetting<T>(key: string, tx?: DbTx): Promise<T | undefined> {
|
async getSetting<T>(key: string, tx?: DbTransaction): Promise<T | undefined> {
|
||||||
const item = await this.get<SettingDO<T>>('settings', key, tx)
|
const item = await this.get<SettingDO<T>>('settings', key, tx)
|
||||||
return item?.value
|
return item?.value
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写入设置 */
|
/** 写入设置 */
|
||||||
async setSetting<T>(key: string, value: T, tx?: DbTx): Promise<void> {
|
async setSetting<T>(key: string, value: T, tx?: DbTransaction): Promise<void> {
|
||||||
await this.put<SettingDO<T>>('settings', { key, value, updateTime: Date.now() }, tx)
|
await this.put<SettingDO<T>>('settings', { key, value, updateTime: Date.now() }, tx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbWrapper = new DbWrapper()
|
const dbClient = new DbClient()
|
||||||
|
|
||||||
/** 获取当前 IM DB wrapper */
|
/** 获取当前 IM DB client */
|
||||||
export function getDb(): DbWrapper {
|
export function getDb(): DbClient {
|
||||||
return dbWrapper
|
return dbClient
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前用户会话主键 */
|
/** 当前用户会话主键 */
|
||||||
|
|
@ -375,7 +372,6 @@ export function parseClientConversationId(
|
||||||
const type = Number(typeText)
|
const type = Number(typeText)
|
||||||
const targetId = Number(targetIdText)
|
const targetId = Number(targetIdText)
|
||||||
if (!Number.isFinite(type) || !Number.isFinite(targetId) || targetId <= 0) {
|
if (!Number.isFinite(type) || !Number.isFinite(targetId) || targetId <= 0) {
|
||||||
// TODO @AI:logger info?
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return { type, targetId }
|
return { type, targetId }
|
||||||
|
|
@ -392,7 +388,6 @@ export function getClientMessageKey(clientMessageId: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解析本地消息主键 */
|
/** 解析本地消息主键 */
|
||||||
// TODO @AI:这个方法,貌似没调用;
|
|
||||||
export function parseMessageKey(
|
export function parseMessageKey(
|
||||||
messageKey: string
|
messageKey: string
|
||||||
):
|
):
|
||||||
|
|
@ -419,7 +414,7 @@ export function parseMessageKey(
|
||||||
export async function setMessageMaxId(
|
export async function setMessageMaxId(
|
||||||
conversationType: number,
|
conversationType: number,
|
||||||
maxId: number | undefined,
|
maxId: number | undefined,
|
||||||
tx?: DbTx
|
tx?: DbTransaction
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!maxId) {
|
if (!maxId) {
|
||||||
return
|
return
|
||||||
|
|
@ -446,7 +441,6 @@ export async function setMessageMaxId(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 停止当前 IM DB session */
|
/** 停止当前 IM DB session */
|
||||||
// TODO @AI:这里的注释,要写下;方法注释;
|
|
||||||
export async function stopRequests(): Promise<void> {
|
export async function stopRequests(): Promise<void> {
|
||||||
const [
|
const [
|
||||||
{ useMessageStoreWithOut },
|
{ useMessageStoreWithOut },
|
||||||
|
|
@ -455,7 +449,6 @@ export async function stopRequests(): Promise<void> {
|
||||||
{ useGroupStoreWithOut },
|
{ useGroupStoreWithOut },
|
||||||
{ useChannelStoreWithOut },
|
{ useChannelStoreWithOut },
|
||||||
{ useGroupRequestStoreWithOut },
|
{ useGroupRequestStoreWithOut },
|
||||||
{ useDraftStoreWithOut },
|
|
||||||
{ useFaceStoreWithOut }
|
{ useFaceStoreWithOut }
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
import('../home/store/messageStore'),
|
import('../home/store/messageStore'),
|
||||||
|
|
@ -464,7 +457,6 @@ export async function stopRequests(): Promise<void> {
|
||||||
import('../home/store/groupStore'),
|
import('../home/store/groupStore'),
|
||||||
import('../home/store/channelStore'),
|
import('../home/store/channelStore'),
|
||||||
import('../home/store/groupRequestStore'),
|
import('../home/store/groupRequestStore'),
|
||||||
import('../home/store/draftStore'),
|
|
||||||
import('../home/store/faceStore')
|
import('../home/store/faceStore')
|
||||||
])
|
])
|
||||||
currentSession++
|
currentSession++
|
||||||
|
|
@ -474,9 +466,6 @@ export async function stopRequests(): Promise<void> {
|
||||||
useGroupStoreWithOut().clear()
|
useGroupStoreWithOut().clear()
|
||||||
useChannelStoreWithOut().clear()
|
useChannelStoreWithOut().clear()
|
||||||
useGroupRequestStoreWithOut().reset()
|
useGroupRequestStoreWithOut().reset()
|
||||||
useDraftStoreWithOut().clear()
|
|
||||||
useFaceStoreWithOut().reset()
|
useFaceStoreWithOut().reset()
|
||||||
currentDb?.close()
|
closeDbConnection()
|
||||||
currentDb = null
|
|
||||||
currentUserId = null
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ import { getCurrentUserId } from './storage'
|
||||||
import { formatCallDuration } from './time'
|
import { formatCallDuration } from './time'
|
||||||
import { useFriendStore } from '../home/store/friendStore'
|
import { useFriendStore } from '../home/store/friendStore'
|
||||||
import { useGroupStore } from '../home/store/groupStore'
|
import { useGroupStore } from '../home/store/groupStore'
|
||||||
import type { Conversation, Message, User, GroupLite } from '../home/types'
|
import type { Conversation, Message, User, GroupLite, QuoteMessage } from '../home/types'
|
||||||
|
|
||||||
|
export type { QuoteMessage } from '../home/types'
|
||||||
|
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
// IM 消息 content 编解码 & 展示工具
|
// IM 消息 content 编解码 & 展示工具
|
||||||
|
|
@ -177,14 +179,6 @@ export function parseTextSegments(text: string, mentions: MentionCandidate[] = [
|
||||||
|
|
||||||
// ==================== 引用消息 ====================
|
// ==================== 引用消息 ====================
|
||||||
|
|
||||||
/** 引用消息 payload(对齐后端 QuoteMessage) */
|
|
||||||
export interface QuoteMessage {
|
|
||||||
messageId: number
|
|
||||||
senderId: number
|
|
||||||
type: number
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 引用容器:5 种普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO)都可携带 quote */
|
/** 引用容器:5 种普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO)都可携带 quote */
|
||||||
interface Quotable {
|
interface Quotable {
|
||||||
quote?: QuoteMessage
|
quote?: QuoteMessage
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,6 @@ export const imStorage = localforage.createInstance({
|
||||||
* 所有业务 key 都注入 userId:多账号切换按用户隔离避免数据互串;账号切换时只清 in-memory、IDB 数据保留——回切旧账号能秒开,不浪费已下载好友 / 群 / 成员快照
|
* 所有业务 key 都注入 userId:多账号切换按用户隔离避免数据互串;账号切换时只清 in-memory、IDB 数据保留——回切旧账号能秒开,不浪费已下载好友 / 群 / 成员快照
|
||||||
*/
|
*/
|
||||||
export const StorageKeys = {
|
export const StorageKeys = {
|
||||||
/**
|
|
||||||
* 输入框草稿整桶:Record<`${type}:${targetId}`, DraftSnapshot>
|
|
||||||
*
|
|
||||||
* 草稿端本地、量级小(每会话至多几百字节),整桶整写够用;持久化按 userId 分桶与其它业务一致
|
|
||||||
*/
|
|
||||||
drafts: (userId: number | string) => `drafts:${userId}`,
|
|
||||||
|
|
||||||
/** 好友列表整桶(含 DISABLE 软删记录);好友量级有限,不维护增量 */
|
/** 好友列表整桶(含 DISABLE 软删记录);好友量级有限,不维护增量 */
|
||||||
friends: (userId: number | string) => `friends:${userId}`,
|
friends: (userId: number | string) => `friends:${userId}`,
|
||||||
/** 群列表整桶(不含 members,剥离到独立 key),保证整桶写不带成员爆量 */
|
/** 群列表整桶(不含 members,剥离到独立 key),保证整桶写不带成员爆量 */
|
||||||
|
|
@ -37,10 +30,6 @@ export const StorageKeys = {
|
||||||
/** 单群成员,按 groupId 分桶——单群可上百-千级,跟懒加载粒度对齐;群解散时物理删 */
|
/** 单群成员,按 groupId 分桶——单群可上百-千级,跟懒加载粒度对齐;群解散时物理删 */
|
||||||
groupMembers: (userId: number | string, groupId: number) => `groupMembers:${userId}:${groupId}`,
|
groupMembers: (userId: number | string, groupId: number) => `groupMembers:${userId}:${groupId}`,
|
||||||
|
|
||||||
/** 最近转发会话 key 列表(按 userId 分桶);ConversationPickerPanel 左栏顶部头像区使用 */
|
|
||||||
recentForwardConversationKeys: (userId: number | string) =>
|
|
||||||
`recentForwardConversationKeys:${userId}`,
|
|
||||||
|
|
||||||
/** 侧边栏宽度(localStorage);三个 Tab 共用一份记忆,对齐微信(拖一次到处一致)。 */
|
/** 侧边栏宽度(localStorage);三个 Tab 共用一份记忆,对齐微信(拖一次到处一致)。 */
|
||||||
asideWidth: 'im:aside',
|
asideWidth: 'im:aside',
|
||||||
/** 会话列表置顶折叠展开态(localStorage);轻量 UI 偏好。 */
|
/** 会话列表置顶折叠展开态(localStorage);轻量 UI 偏好。 */
|
||||||
|
|
@ -65,7 +54,6 @@ export function removeQuietly(key: string, errorLabel: string): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 转换为 IndexedDB 可存储的数据 */
|
/** 转换为 IndexedDB 可存储的数据 */
|
||||||
// TODO @AI:后续,是不是可以删除掉?尽量使用 db.ts 对不对哈?
|
|
||||||
function toStorageValue<T>(value: T, seen = new WeakMap<object, unknown>()): T {
|
function toStorageValue<T>(value: T, seen = new WeakMap<object, unknown>()): T {
|
||||||
const raw = value && typeof value === 'object' ? toRaw(value) : value
|
const raw = value && typeof value === 'object' ? toRaw(value) : value
|
||||||
if (!raw || typeof raw !== 'object') {
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue