refactor(im): 拆分会话和消息本地存储
- 新增 IM IndexedDB DB 封装、schema、key helper 和 session guard - 新增 messageStore,支持消息逐条持久化、分页加载、ack 合并、撤回和回执更新 - 调整 conversationStore 只持久化会话摘要,不再内嵌 messages 数组 - 切换发送、拉取、WebSocket、媒体上传和消息组件到 messageStore - 增加离开 IM 时的 store 清理和本地存储序列化保护pull/881/head
parent
e1b8370267
commit
811b93d9f1
|
|
@ -83,11 +83,7 @@
|
|||
</ConversationPickerPanel>
|
||||
|
||||
<!-- 好友视图:选好友建群后发送 -->
|
||||
<FriendPickerPanel
|
||||
v-else
|
||||
v-model:selected-ids="selectedFriendIds"
|
||||
:friends="friends"
|
||||
/>
|
||||
<FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" />
|
||||
</div>
|
||||
|
||||
<!-- 好友视图的 dialog footer:建群并发送 -->
|
||||
|
|
@ -119,11 +115,7 @@ import { useConversationStore } from '../../store/conversationStore'
|
|||
import { useFriendStore } from '../../store/friendStore'
|
||||
import { useGroupStore } from '../../store/groupStore'
|
||||
import { useMessageSender } from '../../composables/useMessageSender'
|
||||
import {
|
||||
ImConversationType,
|
||||
ImMessageType,
|
||||
isGroupConversation
|
||||
} from '../../../utils/constants'
|
||||
import { ImConversationType, ImMessageType, isGroupConversation } from '../../../utils/constants'
|
||||
import { getConversationKey } from '../../../utils/conversation'
|
||||
import { buildDefaultGroupName } from '../../../utils/group'
|
||||
import { serializeMessage, type CardTarget } from '../../../utils/message'
|
||||
|
|
@ -287,7 +279,6 @@ async function handleCreateGroupAndSend() {
|
|||
name: group.name || name,
|
||||
avatar: group.avatar || '',
|
||||
unreadCount: 0,
|
||||
messages: [],
|
||||
lastContent: '',
|
||||
lastSendTime: 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useMessage } from '@/hooks/web/useMessage'
|
|||
import { isOpenableUrl } from '@/utils/url'
|
||||
|
||||
import { useConversationStore } from '../store/conversationStore'
|
||||
import { useMessageStore } from '../store/messageStore'
|
||||
import { useMessageSender } from './useMessageSender'
|
||||
import { useMuteOverlay } from './useMuteOverlay'
|
||||
import { ImMessageStatus, ImMessageType } from '../../utils/constants'
|
||||
|
|
@ -67,8 +68,7 @@ export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
|
|||
},
|
||||
[ImMessageType.VOICE]: {
|
||||
kind: '语音',
|
||||
build: (_file, url, context) =>
|
||||
({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
|
||||
build: (_file, url, context) => ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
|
||||
extractResendContext: (oldContent) => {
|
||||
const old = parseMessage<AudioMessage>(oldContent)
|
||||
return { voiceDuration: old?.duration ?? 0 }
|
||||
|
|
@ -88,7 +88,8 @@ export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
|
|||
extractResendContext: (oldContent) => {
|
||||
const old = parseMessage<VideoMessage>(oldContent)
|
||||
// 旧 coverUrl 是 blob 说明上传期失败(cover 没传成功),不复用;真实 URL 直接复用,省一次封面上传
|
||||
const reuseCover = old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX) ? old.coverUrl : undefined
|
||||
const reuseCover =
|
||||
old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX) ? old.coverUrl : undefined
|
||||
return {
|
||||
videoProbe: { duration: old?.duration, width: old?.width, height: old?.height },
|
||||
videoCoverUrl: reuseCover
|
||||
|
|
@ -155,6 +156,7 @@ export function ensureMediaSizeWithinLimit(
|
|||
|
||||
export const useMediaUploader = () => {
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
const userStore = useUserStore()
|
||||
const muteOverlay = useMuteOverlay()
|
||||
const message = useMessage()
|
||||
|
|
@ -177,7 +179,7 @@ export const useMediaUploader = () => {
|
|||
const blobUrl = URL.createObjectURL(opts.file)
|
||||
const clientMessageId = opts.existingClientMessageId || generateClientMessageId()
|
||||
if (opts.existingClientMessageId) {
|
||||
conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
|
||||
messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
|
||||
content: opts.buildContent(blobUrl),
|
||||
status: ImMessageStatus.SENDING,
|
||||
uploadProgress: 0,
|
||||
|
|
@ -186,7 +188,6 @@ export const useMediaUploader = () => {
|
|||
return { clientMessageId, blobUrl }
|
||||
}
|
||||
const placeholder: Message = {
|
||||
id: 0,
|
||||
clientMessageId,
|
||||
type: opts.type,
|
||||
content: opts.buildContent(blobUrl),
|
||||
|
|
@ -198,7 +199,7 @@ export const useMediaUploader = () => {
|
|||
uploadProgress: 0,
|
||||
_localFile: opts.file
|
||||
}
|
||||
conversationStore.insertMessage(
|
||||
messageStore.insertMessage(
|
||||
{
|
||||
type: conversation.type,
|
||||
targetId: conversation.targetId,
|
||||
|
|
@ -221,7 +222,7 @@ export const useMediaUploader = () => {
|
|||
targetId: number,
|
||||
clientMessageId: string
|
||||
): void => {
|
||||
conversationStore.patchMessage(conversationType, targetId, clientMessageId, {
|
||||
messageStore.patchMessage(conversationType, targetId, clientMessageId, {
|
||||
status: ImMessageStatus.FAILED,
|
||||
uploadProgress: undefined
|
||||
})
|
||||
|
|
@ -244,7 +245,7 @@ export const useMediaUploader = () => {
|
|||
return
|
||||
}
|
||||
lastPercent = percent
|
||||
conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
|
||||
messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
|
||||
uploadProgress: percent
|
||||
})
|
||||
}
|
||||
|
|
@ -303,7 +304,7 @@ export const useMediaUploader = () => {
|
|||
clientMessageId: string
|
||||
realContent: string
|
||||
}): Promise<void> => {
|
||||
conversationStore.patchMessage(
|
||||
messageStore.patchMessage(
|
||||
opts.conversation.type,
|
||||
opts.conversation.targetId,
|
||||
opts.clientMessageId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { watch } from 'vue'
|
||||
import { useConversationStore } from '../store/conversationStore'
|
||||
import { useMessageStore, type PulledMessageBatchItem } from '../store/messageStore'
|
||||
import { useImWebSocketStore } from '../store/websocketStore'
|
||||
import { useFriendStore } from '../store/friendStore'
|
||||
import { getFriendDisplayName } from '../../utils/user'
|
||||
|
|
@ -30,7 +31,7 @@ import {
|
|||
MESSAGE_PRIVATE_READ_ENABLED
|
||||
} from '../../utils/config'
|
||||
import { buildChannelConversationStub } from '../../utils/channel'
|
||||
import { getPrivateMessagePeerId } from '../../utils/message'
|
||||
import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message'
|
||||
import { getCurrentUserId } from '../../utils/storage'
|
||||
import type { Message } from '../types'
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ import type { Message } from '../types'
|
|||
*/
|
||||
export const useMessagePuller = () => {
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
const wsStore = useImWebSocketStore()
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
|
@ -55,7 +57,11 @@ export const useMessagePuller = () => {
|
|||
/** 判断请求是否被主动取消 */
|
||||
const isAbortError = (e: unknown): boolean => {
|
||||
const error = e as { name?: string; code?: string; message?: string }
|
||||
return error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED' || error?.message === 'canceled'
|
||||
return (
|
||||
error?.name === 'CanceledError' ||
|
||||
error?.code === 'ERR_CANCELED' ||
|
||||
error?.message === 'canceled'
|
||||
)
|
||||
}
|
||||
|
||||
/** 私聊会话归属:自己发的算"发给 receiverId 的会话",否则算"发送方的会话";curry currentUserId 进闭包减少 3 处调用方的样板 */
|
||||
|
|
@ -66,7 +72,7 @@ export const useMessagePuller = () => {
|
|||
const convertPrivateMessage = (message: ImPrivateMessageRespVO): Message => {
|
||||
return {
|
||||
id: message.id,
|
||||
clientMessageId: message.clientMessageId || '',
|
||||
clientMessageId: message.clientMessageId || generateClientMessageId(),
|
||||
type: message.type,
|
||||
content: message.content,
|
||||
status: message.status,
|
||||
|
|
@ -81,7 +87,7 @@ export const useMessagePuller = () => {
|
|||
const convertGroupMessage = (message: ImGroupMessageRespVO): Message => {
|
||||
return {
|
||||
id: message.id,
|
||||
clientMessageId: message.clientMessageId || '',
|
||||
clientMessageId: message.clientMessageId || generateClientMessageId(),
|
||||
type: message.type,
|
||||
content: message.content,
|
||||
status: message.status,
|
||||
|
|
@ -100,7 +106,8 @@ export const useMessagePuller = () => {
|
|||
const convertChannelMessage = (message: ImChannelMessageRespVO): Message => {
|
||||
return {
|
||||
id: message.id,
|
||||
clientMessageId: '',
|
||||
// TODO @AI:是不是都需要使用 message 的 clientMessageId;注意,后端也需要有 clientMessageId;
|
||||
clientMessageId: generateClientMessageId(),
|
||||
type: message.type,
|
||||
content: message.content,
|
||||
status: message.status ?? ImMessageStatus.UNREAD,
|
||||
|
|
@ -161,7 +168,8 @@ export const useMessagePuller = () => {
|
|||
const isPrivate = conversationType === ImConversationType.PRIVATE
|
||||
const isChannel = conversationType === ImConversationType.CHANNEL
|
||||
const size = isPrivate ? MESSAGE_PRIVATE_PULL_SIZE : MESSAGE_GROUP_PULL_SIZE
|
||||
const isStillValid = () => !signal.aborted && pullEpoch === startEpoch && getCurrentUserId() === startUserId
|
||||
const isStillValid = () =>
|
||||
!signal.aborted && pullEpoch === startEpoch && getCurrentUserId() === startUserId
|
||||
while (true) {
|
||||
if (!isStillValid()) {
|
||||
return
|
||||
|
|
@ -182,26 +190,30 @@ export const useMessagePuller = () => {
|
|||
break
|
||||
}
|
||||
|
||||
// 逐条 dispatch:原消息走 insertMessage;RECALL 信号走 recallMessage 把同批内已 insert 的原消息更新为撤回提示。
|
||||
// TODO @AI:感觉这个 PulledMessageBatchItem 类名,batchItems 变量名,insertPulledBatch 方法名,不够能体现出 message;
|
||||
const batchItems: PulledMessageBatchItem[] = []
|
||||
// 逐条 dispatch:原消息走批量 insert;RECALL 信号走批量 recall 把同批内已 insert 的原消息更新为撤回提示。
|
||||
// 后端按 id 升序返回,且信号 id 一定 > 原消息 id(先更新 status 再插信号),所以原消息一定先到、recallMessage 找得到
|
||||
for (const raw of list) {
|
||||
if (isChannel) {
|
||||
const message = raw as ImChannelMessageRespVO
|
||||
conversationStore.insertMessage(
|
||||
convertChannelConversation(message),
|
||||
convertChannelMessage(message)
|
||||
)
|
||||
batchItems.push({
|
||||
kind: 'insert',
|
||||
conversationInfo: convertChannelConversation(message),
|
||||
message: convertChannelMessage(message)
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (isPrivate) {
|
||||
const message = raw as ImPrivateMessageRespVO
|
||||
// 特殊:撤回消息的处理
|
||||
if (message.type === ImMessageType.RECALL) {
|
||||
conversationStore.recallMessage(
|
||||
ImConversationType.PRIVATE,
|
||||
getPrivatePeerId(message),
|
||||
message.content
|
||||
)
|
||||
batchItems.push({
|
||||
kind: 'recall',
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: getPrivatePeerId(message),
|
||||
recallSignalContent: message.content
|
||||
})
|
||||
continue
|
||||
}
|
||||
// 特殊:离线 pull 期间入库的 FRIEND_* 帧(目前仅 FRIEND_ADD persistent=true)也要走好友数据分发,
|
||||
|
|
@ -214,35 +226,40 @@ export const useMessagePuller = () => {
|
|||
}
|
||||
}
|
||||
// 其它消息正常入会话消息列表
|
||||
conversationStore.insertMessage(
|
||||
convertPrivateConversation(message),
|
||||
convertPrivateMessage(message)
|
||||
)
|
||||
batchItems.push({
|
||||
kind: 'insert',
|
||||
conversationInfo: convertPrivateConversation(message),
|
||||
message: convertPrivateMessage(message)
|
||||
})
|
||||
} else {
|
||||
const message = raw as ImGroupMessageRespVO
|
||||
// 特殊:撤回消息的处理
|
||||
if (message.type === ImMessageType.RECALL) {
|
||||
conversationStore.recallMessage(
|
||||
ImConversationType.GROUP,
|
||||
message.groupId,
|
||||
message.content
|
||||
)
|
||||
batchItems.push({
|
||||
kind: 'recall',
|
||||
conversationType: ImConversationType.GROUP,
|
||||
targetId: message.groupId,
|
||||
recallSignalContent: message.content
|
||||
})
|
||||
continue
|
||||
}
|
||||
// 其它消息正常入会话消息列表
|
||||
conversationStore.insertMessage(
|
||||
convertGroupConversation(message),
|
||||
convertGroupMessage(message)
|
||||
)
|
||||
batchItems.push({
|
||||
kind: 'insert',
|
||||
conversationInfo: convertGroupConversation(message),
|
||||
message: convertGroupMessage(message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 游标推进到本批最大 id,与后端返回顺序无关;无有效 id 直接 break 避免死翻同一批
|
||||
const validIds = list.map((message) => message.id).filter((id): id is number => id != null)
|
||||
if (validIds.length === 0) {
|
||||
await messageStore.insertPulledBatch(batchItems, conversationType)
|
||||
break
|
||||
}
|
||||
const nextMinId = Math.max(...validIds)
|
||||
await messageStore.insertPulledBatch(batchItems, conversationType, nextMinId)
|
||||
// 游标没前进就停:当前后端契约是 id > minId,理论不会出现;防御后端契约变更或边界数据死翻
|
||||
if (nextMinId <= minId) {
|
||||
break
|
||||
|
|
@ -311,21 +328,21 @@ export const useMessagePuller = () => {
|
|||
await Promise.all([
|
||||
pullByType(
|
||||
ImConversationType.PRIVATE,
|
||||
conversationStore.privateMessageMaxId,
|
||||
messageStore.privateMessageMaxId,
|
||||
startEpoch,
|
||||
startUserId,
|
||||
abortController.signal
|
||||
),
|
||||
pullByType(
|
||||
ImConversationType.GROUP,
|
||||
conversationStore.groupMessageMaxId,
|
||||
messageStore.groupMessageMaxId,
|
||||
startEpoch,
|
||||
startUserId,
|
||||
abortController.signal
|
||||
),
|
||||
pullByType(
|
||||
ImConversationType.CHANNEL,
|
||||
conversationStore.channelMessageMaxId,
|
||||
messageStore.channelMessageMaxId,
|
||||
startEpoch,
|
||||
startUserId,
|
||||
abortController.signal
|
||||
|
|
@ -369,12 +386,15 @@ export const useMessagePuller = () => {
|
|||
const active = conversationStore.activeConversation
|
||||
if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) {
|
||||
try {
|
||||
const maxReadId = await apiGetPrivateMaxReadMessageId(active.targetId, abortController.signal)
|
||||
const maxReadId = await apiGetPrivateMaxReadMessageId(
|
||||
active.targetId,
|
||||
abortController.signal
|
||||
)
|
||||
if (!isCurrentPull()) {
|
||||
return
|
||||
}
|
||||
if (maxReadId) {
|
||||
conversationStore.applyReadReceipt({
|
||||
messageStore.applyReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: active.targetId,
|
||||
privateReadMaxId: maxReadId
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useConversationStore } from '../store/conversationStore'
|
||||
import { useMessageStore } from '../store/messageStore'
|
||||
import {
|
||||
sendPrivateMessage as apiSendPrivateMessage,
|
||||
readPrivateMessages as apiReadPrivateMessages,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
} from '../../utils/message'
|
||||
import { ImMessageType, ImMessageStatus, ImConversationType } from '../../utils/constants'
|
||||
import { MESSAGE_PRIVATE_READ_ENABLED, MESSAGE_GROUP_READ_ENABLED } from '../../utils/config'
|
||||
import { getClientConversationId } from '../../utils/db'
|
||||
import type { Conversation, Message } from '../types'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
|
|
@ -57,9 +59,10 @@ interface SendExtOptions {
|
|||
*/
|
||||
export const useMessageSender = () => {
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
/**构造本地乐观消息对象(id=0 表示尚未拿到服务端消息 id) */
|
||||
/** 构造本地乐观消息对象 */
|
||||
const buildLocalMessage = (opts: {
|
||||
clientMessageId: string
|
||||
content: string
|
||||
|
|
@ -68,7 +71,6 @@ export const useMessageSender = () => {
|
|||
atUserIds?: number[]
|
||||
}): Message => {
|
||||
return {
|
||||
id: 0,
|
||||
clientMessageId: opts.clientMessageId,
|
||||
type: opts.type,
|
||||
content: opts.content,
|
||||
|
|
@ -109,10 +111,10 @@ export const useMessageSender = () => {
|
|||
clientMessageId = options.existingClientMessageId
|
||||
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
|
||||
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
|
||||
const targetConversation = conversationStore.getConversation(conversation.type, realTarget)
|
||||
const stillExists = targetConversation?.messages.some(
|
||||
(m) => m.clientMessageId === clientMessageId
|
||||
)
|
||||
// TODO @AI:尽量不要 m 缩写,全称
|
||||
const stillExists = messageStore
|
||||
.getMessageList(conversation.type, realTarget)
|
||||
.some((m) => m.clientMessageId === clientMessageId && !m._ackMerging)
|
||||
if (!stillExists) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -131,7 +133,7 @@ export const useMessageSender = () => {
|
|||
name: conversation.name || String(realTarget),
|
||||
avatar: conversation.avatar || ''
|
||||
}
|
||||
conversationStore.insertMessage(conversationInfo, message)
|
||||
messageStore.insertMessage(conversationInfo, message)
|
||||
}
|
||||
|
||||
// 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 UNREAD,失败更新为 FAILED
|
||||
|
|
@ -143,7 +145,7 @@ export const useMessageSender = () => {
|
|||
type,
|
||||
content
|
||||
})
|
||||
conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, {
|
||||
void messageStore.ackMessage(conversation.type, realTarget, clientMessageId, {
|
||||
id: data.id,
|
||||
sendTime: new Date(data.sendTime).getTime(),
|
||||
status: data.status,
|
||||
|
|
@ -158,7 +160,7 @@ export const useMessageSender = () => {
|
|||
atUserIds: options?.atUserIds,
|
||||
receipt: options?.receipt
|
||||
})
|
||||
conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, {
|
||||
void messageStore.ackMessage(conversation.type, realTarget, clientMessageId, {
|
||||
id: data.id,
|
||||
sendTime: new Date(data.sendTime).getTime(),
|
||||
status: data.status,
|
||||
|
|
@ -170,7 +172,7 @@ export const useMessageSender = () => {
|
|||
return true
|
||||
} catch (e) {
|
||||
console.error('[IM] 消息发送失败', { type, realTarget, clientMessageId }, e)
|
||||
conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, {
|
||||
void messageStore.ackMessage(conversation.type, realTarget, clientMessageId, {
|
||||
status: ImMessageStatus.FAILED
|
||||
})
|
||||
return false
|
||||
|
|
@ -195,7 +197,7 @@ export const useMessageSender = () => {
|
|||
* 2. 此处不做乐观撤回,避免网络失败后状态不可回退
|
||||
*/
|
||||
const recall = async (message: Message) => {
|
||||
// 参数校验:本地占位消息(id=0)不能撤回
|
||||
// 参数校验:本地占位消息不能撤回
|
||||
if (!message.id) {
|
||||
return
|
||||
}
|
||||
|
|
@ -215,7 +217,7 @@ export const useMessageSender = () => {
|
|||
/**
|
||||
* 触发当前会话的已读上报(切会话 / 进入页面时调用)
|
||||
* 1. 本端立刻清未读数;服务端回包成功后再做持久化
|
||||
* 2. 已读位置取会话内最大真实消息 id(id=0 的本地发送中消息跳过)
|
||||
* 2. 已读位置取会话内最大真实消息 id(本地发送中消息跳过)
|
||||
*/
|
||||
const readActive = async () => {
|
||||
const conversation = conversationStore.activeConversation
|
||||
|
|
@ -223,11 +225,12 @@ export const useMessageSender = () => {
|
|||
return
|
||||
}
|
||||
// 本地标记已读:未读数清零 + 消息状态更新为 READ(UI 立刻响应)
|
||||
conversationStore.markActiveAsRead()
|
||||
const maxMessageId = conversationStore.getActiveMessages.reduce<number>(
|
||||
(max, m) => (m.id > max ? m.id : max),
|
||||
0
|
||||
)
|
||||
conversationStore.markConversationAsRead(conversation.type, conversation.targetId)
|
||||
messageStore.markConversationMessagesRead(conversation)
|
||||
// TODO @AI:message;不要用 m;
|
||||
const maxMessageId = messageStore
|
||||
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
|
||||
.reduce<number>((max, m) => (m.id && m.id > max ? m.id : max), 0)
|
||||
if (!maxMessageId) {
|
||||
return
|
||||
}
|
||||
|
|
@ -283,7 +286,7 @@ export const useMessageSender = () => {
|
|||
return
|
||||
}
|
||||
// applyReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ
|
||||
conversationStore.applyReadReceipt({
|
||||
messageStore.applyReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: peerId,
|
||||
privateReadMaxId: maxReadId
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export type MuteOverlayInfo = { text: string; icon: string }
|
|||
* 改成模块级共享后所有订阅者共用一份 setInterval,订阅数清零时也清掉 timer,避免内存与时钟漂移
|
||||
*/
|
||||
const sharedNow = ref(Date.now())
|
||||
let sharedTickTimer: ReturnType<typeof setInterval> | null = null
|
||||
let sharedTickTimer: number | null = null
|
||||
let subscriberCount = 0
|
||||
|
||||
function subscribeNowTick(): void {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { useRoute } from 'vue-router'
|
|||
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { useConversationStore } from './store/conversationStore'
|
||||
import { useMessageStore } from './store/messageStore'
|
||||
import { useImWebSocketStore } from './store/websocketStore'
|
||||
import { useFriendStore } from './store/friendStore'
|
||||
import { useGroupStore } from './store/groupStore'
|
||||
|
|
@ -46,6 +47,7 @@ import { useMessageSender } from './composables/useMessageSender'
|
|||
import { useVoicePlayer } from './composables/useVoicePlayer'
|
||||
import { ImConversationType } from '../utils/constants'
|
||||
import { StorageKeys } from '../utils/storage'
|
||||
import { initDb, stopRequests } from '../utils/db'
|
||||
import type { Conversation } from './types'
|
||||
import ToolBar from './components/ToolBar.vue'
|
||||
import UserInfoCard from './components/user/UserInfoCard.vue'
|
||||
|
|
@ -58,6 +60,7 @@ defineOptions({ name: 'ImIndex' })
|
|||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
const webSocketStore = useImWebSocketStore()
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
|
@ -81,9 +84,12 @@ onMounted(async () => {
|
|||
// 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
||||
conversationStore.loading = true
|
||||
try {
|
||||
// TODO @AI:这里要写个注释???!!!
|
||||
await initDb()
|
||||
// 1.2 五个 store 并发从 IDB 读取本地缓存(loadConversations / loadDrafts 返回 void;load{Friends,Groups,Channels} 返回是否命中缓存)
|
||||
const [, hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([
|
||||
const [, , hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([
|
||||
conversationStore.loadConversations(),
|
||||
messageStore.loadCursors(),
|
||||
friendStore.loadFriends(),
|
||||
groupStore.loadGroups(),
|
||||
draftStore.loadDrafts(),
|
||||
|
|
@ -162,6 +168,8 @@ onUnmounted(() => {
|
|||
// 模块级单例 audio 不会随视图卸载自动停,主动停掉避免切路由后语音继续响
|
||||
voicePlayer.stop()
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
// TODO @AI:写个注释?!
|
||||
void stopRequests()
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -157,13 +157,14 @@ const groups = computed<GroupLite[]>(() =>
|
|||
watch(
|
||||
friends,
|
||||
(list) => {
|
||||
if (selection.value?.type !== 'friend') {
|
||||
const selected = selection.value
|
||||
if (selected?.type !== 'friend') {
|
||||
return
|
||||
}
|
||||
const fresh = list.find((friend) => friend.id === selection.value!.friend.id)
|
||||
const fresh = list.find((friend) => friend.id === selected.friend.id)
|
||||
if (!fresh) {
|
||||
selection.value = null
|
||||
} else if (fresh !== selection.value.friend) {
|
||||
} else if (fresh !== selected.friend) {
|
||||
selection.value = { type: 'friend', friend: fresh }
|
||||
}
|
||||
},
|
||||
|
|
@ -172,13 +173,14 @@ watch(
|
|||
watch(
|
||||
groups,
|
||||
(list) => {
|
||||
if (selection.value?.type !== 'group') {
|
||||
const selected = selection.value
|
||||
if (selected?.type !== 'group') {
|
||||
return
|
||||
}
|
||||
const fresh = list.find((group) => group.id === selection.value!.group.id)
|
||||
const fresh = list.find((group) => group.id === selected.group.id)
|
||||
if (!fresh) {
|
||||
selection.value = null
|
||||
} else if (fresh !== selection.value.group) {
|
||||
} else if (fresh !== selected.group) {
|
||||
selection.value = { type: 'group', group: fresh }
|
||||
}
|
||||
},
|
||||
|
|
@ -187,13 +189,14 @@ watch(
|
|||
watch(
|
||||
friendRequests,
|
||||
(list) => {
|
||||
if (selection.value?.type !== 'request') {
|
||||
const selected = selection.value
|
||||
if (selected?.type !== 'request') {
|
||||
return
|
||||
}
|
||||
const fresh = list.find((request) => request.id === selection.value!.request.id)
|
||||
const fresh = list.find((request) => request.id === selected.request.id)
|
||||
if (!fresh) {
|
||||
selection.value = null
|
||||
} else if (fresh !== selection.value.request) {
|
||||
} else if (fresh !== selected.request) {
|
||||
selection.value = { type: 'request', request: fresh }
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -51,14 +51,17 @@ import Icon from '@/components/Icon/src/Icon.vue'
|
|||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
|
||||
import { useConversationStore } from '@/views/im/home/store/conversationStore'
|
||||
import { useMessageStore } from '@/views/im/home/store/messageStore'
|
||||
import { useMessageMultiSelect } from '@/views/im/home/composables/useMessageMultiSelect'
|
||||
import { ImForwardMode, isNormalMessage } from '@/views/im/utils/constants'
|
||||
import { getClientConversationId } from '@/views/im/utils/db'
|
||||
import type { Message } from '@/views/im/home/types'
|
||||
import { IM_FORWARD_DIALOG_KEY } from '../message/forward/keys'
|
||||
|
||||
defineOptions({ name: 'ImMessageMultiSelectBar' })
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
const message = useMessage()
|
||||
const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY)
|
||||
const multiSelect = useMessageMultiSelect()
|
||||
|
|
@ -66,16 +69,16 @@ const multiSelect = useMessageMultiSelect()
|
|||
/** 选中条数 */
|
||||
const selectedCount = computed(() => multiSelect.state.selectedClientMessageIds.length)
|
||||
|
||||
/** 当前会话内已选消息;conversation.messages 已按 sendTime 升序,filter 保序无需再 sort;isNormalMessage 过滤掉 RECALL / 系统事件,与 MessageItem.canForward 对齐 */
|
||||
/** 当前会话内已选消息 */
|
||||
function getSelectedMessages(): Message[] {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation) {
|
||||
return []
|
||||
}
|
||||
const ids = multiSelect.selectedIdSet.value
|
||||
return conversation.messages.filter(
|
||||
(message) => ids.has(message.clientMessageId) && isNormalMessage(message.type)
|
||||
)
|
||||
return messageStore
|
||||
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
|
||||
.filter((message) => ids.has(message.clientMessageId) && isNormalMessage(message.type))
|
||||
}
|
||||
|
||||
/** 逐条转发:开 ForwardDialog 单条模式 */
|
||||
|
|
@ -128,7 +131,7 @@ async function handleDelete() {
|
|||
return
|
||||
}
|
||||
for (const m of messages) {
|
||||
conversationStore.removeMessage(conversation.type, conversation.targetId, {
|
||||
messageStore.removeMessage(conversation.type, conversation.targetId, {
|
||||
id: m.id,
|
||||
clientMessageId: m.clientMessageId
|
||||
})
|
||||
|
|
@ -141,4 +144,3 @@ function handleCancel() {
|
|||
multiSelect.exit()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@
|
|||
class="flex items-center gap-1.5 w-[360px] px-3 py-1.5 rounded-[10px] text-13px text-[var(--el-text-color-primary)] bg-[var(--el-bg-color)] shadow-[0_1px_2px_rgba(0,0,0,0.04)] cursor-pointer hover:bg-[var(--el-fill-color-lighter)]"
|
||||
@click="handleTopClick"
|
||||
>
|
||||
<Icon icon="ant-design:pushpin-outlined" :size="14" class="flex-shrink-0 text-[var(--el-color-warning)]" />
|
||||
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)]">{{ getSenderName(latest) }}:</span>
|
||||
<Icon
|
||||
icon="ant-design:pushpin-outlined"
|
||||
:size="14"
|
||||
class="flex-shrink-0 text-[var(--el-color-warning)]"
|
||||
/>
|
||||
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)]"
|
||||
>{{ getSenderName(latest) }}:</span
|
||||
>
|
||||
<span class="flex-1 min-w-0 truncate">{{ getPreview(latest) }}</span>
|
||||
<!-- 单条:移除按钮;多条折叠:共 N 条;多条展开:收起箭头 -->
|
||||
<span
|
||||
|
|
@ -22,7 +28,9 @@
|
|||
移除
|
||||
</span>
|
||||
<template v-else-if="pinnedMessages.length > 1">
|
||||
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)] text-12px">共 {{ pinnedMessages.length }} 条</span>
|
||||
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)] text-12px">
|
||||
共 {{ pinnedMessages.length }} 条
|
||||
</span>
|
||||
<Icon
|
||||
:icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
|
||||
:size="11"
|
||||
|
|
@ -35,7 +43,7 @@
|
|||
<div
|
||||
v-if="pinnedMessages.length > 1 && expanded"
|
||||
class="im-group-pinned-message__list absolute top-full left-1.5 z-10 flex flex-col gap-2.5 w-[380px] p-3 rounded-xl bg-[var(--el-bg-color)] shadow-[0_6px_16px_rgba(0,0,0,0.12)]"
|
||||
style="margin-top: -1px;"
|
||||
style="margin-top: -1px"
|
||||
>
|
||||
<div
|
||||
v-for="msg in pinnedMessages"
|
||||
|
|
@ -43,8 +51,14 @@
|
|||
class="flex items-center gap-1.5 w-full px-3 py-1.5 rounded-[10px] text-13px text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)] cursor-pointer hover:bg-[var(--el-bg-color)]"
|
||||
@click="handleLocate(msg)"
|
||||
>
|
||||
<Icon icon="ant-design:pushpin-outlined" :size="14" class="flex-shrink-0 text-[var(--el-color-warning)]" />
|
||||
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)]">{{ getSenderName(msg) }}:</span>
|
||||
<Icon
|
||||
icon="ant-design:pushpin-outlined"
|
||||
:size="14"
|
||||
class="flex-shrink-0 text-[var(--el-color-warning)]"
|
||||
/>
|
||||
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)]">
|
||||
{{ getSenderName(msg) }}:
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 truncate">{{ getPreview(msg) }}</span>
|
||||
<span
|
||||
v-if="canManage"
|
||||
|
|
@ -126,23 +140,31 @@ function handleTopClick() {
|
|||
|
||||
/** 点击置顶消息行 → 触发跳转 + 收起弹出层 */
|
||||
function handleLocate(msg: Message) {
|
||||
if (!msg.id) {
|
||||
return
|
||||
}
|
||||
emit('locate', msg.id)
|
||||
expanded.value = false
|
||||
}
|
||||
|
||||
// TODO @AI:变量是不是都改成 message,不要 msg?!
|
||||
/** 置顶消息发送人显示名 */
|
||||
function getSenderName(msg: Message): string {
|
||||
return group.value ? getSenderDisplayName(msg.senderId, ImConversationType.GROUP, group.value.id) : ''
|
||||
return group.value
|
||||
? getSenderDisplayName(msg.senderId, ImConversationType.GROUP, group.value.id)
|
||||
: ''
|
||||
}
|
||||
|
||||
/** 置顶消息预览文本:复用会话最后一条摘要逻辑([图片] / [文件] / 文本等) */
|
||||
function getPreview(msg: Message): string {
|
||||
return group.value ? resolveConversationLastContent(msg, ImConversationType.GROUP, group.value.id) : ''
|
||||
return group.value
|
||||
? resolveConversationLastContent(msg, ImConversationType.GROUP, group.value.id)
|
||||
: ''
|
||||
}
|
||||
|
||||
/** 移除置顶:调后端 API,loading 期间禁止重复点;后端广播 GROUP_MESSAGE_UNPIN 由 dispatcher 自动同步本地 */
|
||||
async function handleRemove(msg: Message) {
|
||||
if (!group.value || removingId.value !== null) {
|
||||
if (!group.value || !msg.id || removingId.value !== null) {
|
||||
return
|
||||
}
|
||||
removingId.value = msg.id
|
||||
|
|
|
|||
|
|
@ -153,7 +153,9 @@
|
|||
v-else-if="isGroupNotification(message.type)"
|
||||
class="px-4 py-3 text-12px text-center italic text-[var(--el-text-color-secondary)] border-b border-b-solid border-[var(--el-border-color-lighter)]"
|
||||
>
|
||||
<TipSegments :segments="resolveGroupNotificationSegments(message, resolveGroupMemberName(message))" />
|
||||
<TipSegments
|
||||
:segments="resolveGroupNotificationSegments(message, resolveGroupMemberName(message))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 普通消息行 -->
|
||||
|
|
@ -178,11 +180,11 @@
|
|||
<span class="block text-right">{{ formatHistoryTime(message.sendTime) }}</span>
|
||||
<!-- 定位到聊天位置:absolute 浮在时间下方,行 hover 才显示,
|
||||
不参与右侧栏 flex 排版(避免隐藏时占位让"我"和内容之间留空);
|
||||
仅有真实 id 的消息才支持(本地占位消息 id=0 不行) -->
|
||||
仅有真实 id 的消息才支持(本地占位消息不行) -->
|
||||
<span
|
||||
v-if="message.id > 0"
|
||||
v-if="message.id && message.id > 0"
|
||||
class="im-message-history__locate"
|
||||
@click="locateMessage(message.id)"
|
||||
@click="locateMessage(message.id!)"
|
||||
>
|
||||
定位到聊天位置
|
||||
</span>
|
||||
|
|
@ -248,6 +250,7 @@ import { useUserStore } from '@/store/modules/user'
|
|||
import { getPrivateMessageList as apiGetPrivateMessageList } from '@/api/im/message/private'
|
||||
import { getGroupMessageList as apiGetGroupMessageList } from '@/api/im/message/group'
|
||||
import { useConversationStore } from '../../../../store/conversationStore'
|
||||
import { useMessageStore } from '../../../../store/messageStore'
|
||||
import { useGroupStore } from '../../../../store/groupStore'
|
||||
import { useFriendStore } from '../../../../store/friendStore'
|
||||
import { IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys'
|
||||
|
|
@ -285,6 +288,7 @@ import {
|
|||
type MergeMessage
|
||||
} from '@/views/im/utils/message'
|
||||
import type { Message } from '@/views/im/home/types'
|
||||
import { getClientConversationId } from '@/views/im/utils/db'
|
||||
import UserAvatar from '../../../../components/user/UserAvatar.vue'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||
|
|
@ -299,6 +303,7 @@ const emit = defineEmits<{
|
|||
|
||||
const userStore = useUserStore()
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
const groupStore = useGroupStore()
|
||||
const friendStore = useFriendStore()
|
||||
const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY)
|
||||
|
|
@ -316,7 +321,13 @@ defineExpose({
|
|||
|
||||
const conversation = computed(() => conversationStore.activeConversation)
|
||||
const isGroup = computed(() => conversation.value?.type === ImConversationType.GROUP)
|
||||
const allMessages = computed<Message[]>(() => conversation.value?.messages || [])
|
||||
const allMessages = computed<Message[]>(() =>
|
||||
conversation.value
|
||||
? messageStore.getMessages(
|
||||
getClientConversationId(conversation.value.type, conversation.value.targetId)
|
||||
)
|
||||
: []
|
||||
)
|
||||
|
||||
/** 单条消息的发送人显示名:渲染时按 conversation 上下文走 WeChat 优先级实时算 */
|
||||
function senderDisplayNameOf(message: Message): string {
|
||||
|
|
@ -329,8 +340,7 @@ function senderDisplayNameOf(message: Message): string {
|
|||
|
||||
/** 群广播事件 segments 的成员名解析器;按当前会话 targetId 走 getSenderDisplayName */
|
||||
function resolveGroupMemberName(message: Message): (userId: number) => string {
|
||||
return (id: number) =>
|
||||
getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0)
|
||||
return (id: number) => getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0)
|
||||
}
|
||||
|
||||
/** 单条消息的发送人真实昵称:给 UserAvatar 色卡 / alt 用,永远是 nickname 不掺备注 */
|
||||
|
|
@ -535,7 +545,7 @@ const hasMore = ref(true)
|
|||
*
|
||||
* - 未对接 list 接口的 type / keyword / sender 过滤参数:后端只支持 maxId + limit 游标分页,
|
||||
* tab 筛选在前端做(数据来回到本地后过滤)
|
||||
* - id=0(本地占位)跳过:后端没法按 messageId 查
|
||||
* - 本地占位跳过:后端没法按 messageId 查
|
||||
* - 返回数量 < limit 视为到顶
|
||||
*/
|
||||
async function loadEarlier() {
|
||||
|
|
@ -545,10 +555,7 @@ async function loadEarlier() {
|
|||
}
|
||||
// 仅 PRIVATE / GROUP 走分页接口;CHANNEL 单向广播、没有 list 接口,落到 else 会误调私聊接口(receiverId 传 channelId)
|
||||
const requestedType = conversation.value.type
|
||||
if (
|
||||
requestedType !== ImConversationType.PRIVATE &&
|
||||
requestedType !== ImConversationType.GROUP
|
||||
) {
|
||||
if (requestedType !== ImConversationType.PRIVATE && requestedType !== ImConversationType.GROUP) {
|
||||
return
|
||||
}
|
||||
// 快照当前会话主键:await 期间用户切走 / 关闭面板时丢弃响应,避免旧会话历史被 prepend 到新会话造成串号
|
||||
|
|
@ -559,11 +566,11 @@ async function loadEarlier() {
|
|||
loadingMore.value = true
|
||||
try {
|
||||
// 算 maxId(不含,作为后端游标):取当前会话本地缓存里最早一条服务端 id;
|
||||
// id=0 是本地乐观占位消息,没有服务端 id,要剔除
|
||||
// 本地乐观占位消息没有服务端 id,要剔除
|
||||
// 全是占位 / 列表为空时 reduce 不更新初值(POSITIVE_INFINITY),转成 undefined → 后端从最新拉
|
||||
const earliestId = allMessages.value
|
||||
.filter((message) => message.id > 0)
|
||||
.reduce((min, message) => Math.min(min, message.id), Number.POSITIVE_INFINITY)
|
||||
.filter((message) => !!message.id && message.id > 0)
|
||||
.reduce((min, message) => Math.min(min, message.id || min), Number.POSITIVE_INFINITY)
|
||||
const maxId = Number.isFinite(earliestId) ? earliestId : undefined
|
||||
|
||||
// 调后端 list 接口:私聊 / 群聊接口签名不同,分支调度;返回结果用 useMessagePuller
|
||||
|
|
@ -597,9 +604,9 @@ async function loadEarlier() {
|
|||
if (pageLength < HISTORY_PAGE_SIZE) {
|
||||
hasMore.value = false
|
||||
}
|
||||
// 合并到 conversationStore:prependMessages 内部去重 + 升序合并 + 落 IndexedDB;
|
||||
// 合并到 messageStore:prependMessages 内部去重 + 升序合并 + 落 IndexedDB;
|
||||
// 主聊天面板的 messages 是同一份引用,老消息也会一起出现在主面板里(符合预期)
|
||||
conversationStore.prependMessages(requestedType, requestedTargetId, earlier)
|
||||
messageStore.prependMessages(requestedType, requestedTargetId, earlier)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ import {
|
|||
parseRtcCallPayload
|
||||
} from '@/views/im/utils/message'
|
||||
import { useImUiStore } from '../../../../store/uiStore'
|
||||
import { useMessageStore } from '../../../../store/messageStore'
|
||||
import { useMessageSender } from '../../../../composables/useMessageSender'
|
||||
import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader'
|
||||
import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
|
||||
|
|
@ -289,6 +290,7 @@ const emit = defineEmits<{
|
|||
|
||||
const userStore = useUserStore()
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
const groupStore = useGroupStore()
|
||||
const friendStore = useFriendStore()
|
||||
const draftStore = useDraftStore()
|
||||
|
|
@ -600,7 +602,7 @@ type MenuKey = (typeof MENU_KEYS)[keyof typeof MENU_KEYS]
|
|||
|
||||
/**
|
||||
* 右键菜单项:
|
||||
* - 引用:已落库(id≠0)+ 未撤回的消息可引用,引用块写入 draftStore.reply
|
||||
* - 引用:已落库 + 未撤回的消息可引用,引用块写入 draftStore.reply
|
||||
* - 撤回 / 删除:互斥;自己发送 + 已落库 + 未撤回 + 2 分钟内显示「撤回」(推服务器),其它显示「删除」(仅本地清)
|
||||
*
|
||||
* 好友事件气泡态不弹菜单
|
||||
|
|
@ -661,7 +663,7 @@ async function handleContextMenu(e: MouseEvent) {
|
|||
icon: 'ant-design:copy-outlined'
|
||||
})
|
||||
}
|
||||
// 「引用」:已落库(id≠0)+ 未撤回 + 非合并转发;MERGE 内嵌快照在引用预览里无法降级展示
|
||||
// 「引用」:已落库 + 未撤回 + 非合并转发;MERGE 内嵌快照在引用预览里无法降级展示
|
||||
if (!!props.message.id && !isRecall.value && !isMerge.value) {
|
||||
items.push({
|
||||
key: MENU_KEYS.REPLY,
|
||||
|
|
@ -669,7 +671,7 @@ async function handleContextMenu(e: MouseEvent) {
|
|||
icon: 'bxs:quote-alt-left'
|
||||
})
|
||||
}
|
||||
// 「转发」「多选」:已落库(id≠0)+ 普通消息 + 未撤回;触发 ForwardDialog / 进入多选模式
|
||||
// 「转发」「多选」:已落库 + 普通消息 + 未撤回;触发 ForwardDialog / 进入多选模式
|
||||
if (canForward.value) {
|
||||
items.push({
|
||||
key: MENU_KEYS.FORWARD,
|
||||
|
|
@ -728,7 +730,7 @@ async function handleContextMenu(e: MouseEvent) {
|
|||
})
|
||||
}
|
||||
// 「撤回 / 删除」二选一:
|
||||
// - 自己发送 + 已落库(id≠0)+ 未撤回 + 在撤回窗口内 → 撤回(推服务器把消息态置 RECALL)
|
||||
// - 自己发送 + 已落库 + 未撤回 + 在撤回窗口内 → 撤回(推服务器把消息态置 RECALL)
|
||||
// - 其它(对方消息 / 已撤回 / 超出撤回窗口)→ 删除(仅本地清,不动后端)
|
||||
// divided 把这一项和上面的「引用」隔开,danger 显红对齐微信
|
||||
const canRecall =
|
||||
|
|
@ -822,7 +824,7 @@ const canManageSender = computed(() => {
|
|||
return myGroupRole.value < senderMember.role
|
||||
})
|
||||
|
||||
/** 是否允许转发 / 多选:普通消息 + 已落库(id≠0)+ 未撤回;本地占位 / 撤回 / 事件消息一律不可 */
|
||||
/** 是否允许转发 / 多选:普通消息 + 已落库 + 未撤回;本地占位 / 撤回 / 事件消息一律不可 */
|
||||
const canForward = computed(
|
||||
() => isNormalMessage(props.message.type) && !!props.message.id && !isRecall.value
|
||||
)
|
||||
|
|
@ -866,7 +868,7 @@ const canPin = computed(
|
|||
/** 置顶消息:二次确认 → 调后端 pin-message;后端广播 GROUP_MESSAGE_PIN,本端 dispatcher 拉最新 pinnedMessages */
|
||||
async function handlePin() {
|
||||
const group = currentGroup.value
|
||||
if (!group) {
|
||||
if (!group || !props.message.id) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
|
|
@ -976,7 +978,7 @@ async function handleResend() {
|
|||
}
|
||||
|
||||
// 文本类型 / 媒体类型但 _localFile 已丢:把 FAILED 占位回滚到 SENDING,复用 clientMessageId 让服务端按 cmid 幂等去重
|
||||
conversationStore.patchMessage(conversation.type, conversation.targetId, message.clientMessageId, {
|
||||
messageStore.patchMessage(conversation.type, conversation.targetId, message.clientMessageId, {
|
||||
status: ImMessageStatus.SENDING
|
||||
})
|
||||
await sendRaw(message.type, message.content, {
|
||||
|
|
@ -986,7 +988,7 @@ async function handleResend() {
|
|||
}
|
||||
|
||||
/**
|
||||
* 删除消息:本地软删,仅从 conversationStore.messages 移除,不调后端
|
||||
* 删除消息:本地软删,仅从 messageStore 移除,不调后端
|
||||
* 区别于"撤回":服务端没动,多端登录时其它客户端 / 群里其他人依然能看到这条
|
||||
*/
|
||||
/** 禁言:emit 给父组件打开时长选择弹窗(避免 MessageItem 过重) */
|
||||
|
|
@ -1033,7 +1035,7 @@ function handleDelete() {
|
|||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
conversationStore.removeMessage(conversation.type, conversation.targetId, {
|
||||
messageStore.removeMessage(conversation.type, conversation.targetId, {
|
||||
id: props.message.id,
|
||||
clientMessageId: props.message.clientMessageId
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
<div class="flex flex-1 flex-col min-w-0 bg-[var(--el-fill-color-light)]">
|
||||
<template v-if="conversationStore.activeConversation">
|
||||
<!-- 顶部 header:第一行群名 + 右侧图标,第二行嵌入置顶气泡(仅群聊 + 有置顶) -->
|
||||
<div class="flex flex-shrink-0 flex-col bg-[var(--el-fill-color-light)] border-b border-b-solid border-[var(--el-border-color-light)]">
|
||||
<div
|
||||
class="flex flex-shrink-0 flex-col bg-[var(--el-fill-color-light)] border-b border-b-solid border-[var(--el-border-color-light)]"
|
||||
>
|
||||
<div class="flex items-center justify-between h-14 px-5">
|
||||
<span class="flex flex-col min-w-0">
|
||||
<span class="flex items-baseline gap-1.5 min-w-0">
|
||||
|
|
@ -119,7 +121,9 @@
|
|||
class="inline-flex items-center gap-2 px-2.5 py-1 rounded-full text-13px cursor-pointer text-[var(--el-text-color-primary)] bg-[var(--el-color-warning-light-9)] transition-colors hover:bg-[var(--el-color-warning-light-8)]"
|
||||
@click="handleNotFriendClick"
|
||||
>
|
||||
<span class="inline-flex items-center justify-center w-4 h-4 rounded-full text-white bg-[var(--el-color-warning)] flex-shrink-0">
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-4 h-4 rounded-full text-white bg-[var(--el-color-warning)] flex-shrink-0"
|
||||
>
|
||||
<Icon icon="ant-design:user-outlined" :size="11" />
|
||||
</span>
|
||||
<span>对方还不是你的朋友</span>
|
||||
|
|
@ -141,7 +145,7 @@
|
|||
暂无消息
|
||||
</div>
|
||||
<!-- data-message-id 给 MessageHistory "定位到聊天位置" 用:父级通过 querySelector
|
||||
找到这层 wrapper,scrollIntoView + 加高亮 class;id=0 的本地占位消息跳过 -->
|
||||
找到这层 wrapper,scrollIntoView + 加高亮 class;本地占位消息跳过 -->
|
||||
<div
|
||||
v-for="(msg, index) in messages"
|
||||
:key="msg.id || msg.clientMessageId"
|
||||
|
|
@ -254,11 +258,14 @@ import RtcGroupCallBanner from '../../../../components/rtc/RtcGroupCallBanner.vu
|
|||
import { createCall } from '@/api/im/rtc'
|
||||
import { ImRtcCallMediaType, ImRtcCallStatus, ImConversationType } from '@/views/im/utils/constants'
|
||||
import { resolveCallEndReasonText } from '@/views/im/utils/message'
|
||||
import { getClientConversationId } from '@/views/im/utils/db'
|
||||
import { useRtcStore } from '../../../../store/rtcStore'
|
||||
import { useMessageStore } from '../../../../store/messageStore'
|
||||
|
||||
defineOptions({ name: 'ImMessagePanel' })
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
const friendStore = useFriendStore()
|
||||
const uiStore = useImUiStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
|
@ -298,7 +305,12 @@ watch(
|
|||
}
|
||||
)
|
||||
|
||||
const messages = computed(() => conversationStore.getActiveMessages)
|
||||
const messages = computed(() => {
|
||||
const conversation = conversationStore.activeConversation
|
||||
return conversation
|
||||
? messageStore.getMessages(getClientConversationId(conversation.type, conversation.targetId))
|
||||
: []
|
||||
})
|
||||
const isGroup = computed(
|
||||
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ import { getGroupReadUsers as apiGetGroupReadUsers } from '@/api/im/message/grou
|
|||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import { ImConversationType, ImGroupReceiptStatus } from '../../../../../utils/constants'
|
||||
import type { Message } from '../../../../types'
|
||||
import { useConversationStore } from '../../../../store/conversationStore'
|
||||
import { useMessageStore } from '../../../../store/messageStore'
|
||||
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||
import PagedScroller from '../../../../components/PagedScroller.vue'
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ const props = defineProps<{
|
|||
groupId: number
|
||||
}>()
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
|
||||
// popover 开关:show 时拉已读名单,关闭后保留 readUserIds 缓存(重开同一条消息不再请求)
|
||||
const popVisible = ref(false)
|
||||
|
|
@ -132,7 +132,7 @@ const unreadMembers = computed(() =>
|
|||
* 跳过:本地占位消息(id = 0,还没拿到服务端 id),后端没法按 messageId 查
|
||||
* 失败:仅控制台告警,readUserIds 保持空数组 → label 走 readCount 兜底,不阻塞 UI
|
||||
*
|
||||
* 拉到名单后顺手把 readCount / receiptStatus 回写到 conversationStore,让 popover 外面的
|
||||
* 拉到名单后顺手把 readCount / receiptStatus 回写到 messageStore,让 popover 外面的
|
||||
* label 也跟着走最新数:离线 / 漏收 RECEIPT 事件时本地 readCount 会偏旧,弹层里看到"已读 5"
|
||||
* 但外面仍是"未读"或旧人数;这里以服务端返回为准矫正回去
|
||||
*/
|
||||
|
|
@ -150,7 +150,7 @@ async function loadReadUsers() {
|
|||
// 全可见成员都已读 → flip 到 DONE,让外面 label 直接命中"全部已读"分支;
|
||||
// 否则只更新 readCount,receiptStatus 维持不变(PENDING / READING)
|
||||
const allRead = readCount > 0 && readCount >= visibleMembers.value.length
|
||||
conversationStore.applyReadReceipt({
|
||||
messageStore.applyReadReceipt({
|
||||
conversationType: ImConversationType.GROUP,
|
||||
targetId: props.groupId,
|
||||
groupMessageId: props.message.id,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
<div
|
||||
class="flex w-fit gap-1.5 items-center min-w-0 py-0.5 text-12px text-[var(--el-text-color-secondary)] rounded transition-colors"
|
||||
:class="[
|
||||
mirrored ? 'pl-1 pr-2 border-r-2 border-r-solid border-r-[var(--el-border-color)]' : 'pl-2 pr-1 border-l-2 border-l-solid border-l-[var(--el-border-color)]',
|
||||
mirrored
|
||||
? 'pl-1 pr-2 border-r-2 border-r-solid border-r-[var(--el-border-color)]'
|
||||
: 'pl-2 pr-1 border-l-2 border-l-solid border-l-[var(--el-border-color)]',
|
||||
{
|
||||
'cursor-pointer hover:text-[var(--el-text-color-primary)]': clickable && !isRecalled,
|
||||
'hover:bg-[var(--el-fill-color-light)]': (clickable && !isRecalled) || closable
|
||||
|
|
@ -32,12 +34,7 @@
|
|||
|
||||
<!-- 文件:icon + 文件名 + 大小 -->
|
||||
<template v-else-if="isFile">
|
||||
<Icon
|
||||
:icon="fileIcon.icon"
|
||||
:color="fileIcon.color"
|
||||
:size="14"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<Icon :icon="fileIcon.icon" :color="fileIcon.color" :size="14" class="flex-shrink-0" />
|
||||
<span v-if="parsedPayload?.name" class="min-w-0 line-clamp-2 break-words">
|
||||
{{ parsedPayload.name }}
|
||||
</span>
|
||||
|
|
@ -58,7 +55,11 @@
|
|||
</template>
|
||||
|
||||
<!-- 名片 -->
|
||||
<CardLineLabel v-else-if="isCard" :card="parsedPayload" class="min-w-0 line-clamp-2 break-words" />
|
||||
<CardLineLabel
|
||||
v-else-if="isCard"
|
||||
:card="parsedPayload"
|
||||
class="min-w-0 line-clamp-2 break-words"
|
||||
/>
|
||||
|
||||
<!-- 表情贴图:缩略图 + name(无 name 仅显示 [表情]) -->
|
||||
<template v-else-if="isFace">
|
||||
|
|
@ -103,8 +104,10 @@ import { formatSeconds } from '@/utils/formatTime'
|
|||
import { formatFileSize } from '@/utils/file'
|
||||
|
||||
import { useConversationStore } from '../../../../store/conversationStore'
|
||||
import { useMessageStore } from '../../../../store/messageStore'
|
||||
import { getSenderDisplayName } from '@/views/im/utils/user'
|
||||
import { ImMessageType } from '@/views/im/utils/constants'
|
||||
import { getClientConversationId } from '@/views/im/utils/db'
|
||||
import CardLineLabel from '@/views/im/home/components/card/CardLineLabel.vue'
|
||||
import {
|
||||
parseMessage,
|
||||
|
|
@ -148,6 +151,7 @@ const emit = defineEmits<{
|
|||
}>()
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
|
||||
/** 在当前会话消息列表里查找原消息,仅用于实时判断是否已撤回;摘要 / 缩略图都从 quote.content 直接派生 */
|
||||
const liveMessage = computed(() => {
|
||||
|
|
@ -155,7 +159,9 @@ const liveMessage = computed(() => {
|
|||
if (!conversation || !props.quote.messageId) {
|
||||
return undefined
|
||||
}
|
||||
return conversation.messages.find((message) => message.id === props.quote.messageId)
|
||||
return messageStore
|
||||
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
|
||||
.find((message) => message.id === props.quote.messageId)
|
||||
})
|
||||
|
||||
/** 命中本地缓存且 type === RECALL 才判定为已撤回;不在缓存的当快照仍有效 */
|
||||
|
|
@ -193,9 +199,7 @@ const isMaterial = computed(() => props.quote.type === ImMessageType.MATERIAL)
|
|||
/** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */
|
||||
const textPreview = computed(() => {
|
||||
const text = parsedPayload.value?.content ?? ''
|
||||
return text.length <= MAX_TEXT_PREVIEW_LEN
|
||||
? text
|
||||
: `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}…`
|
||||
return text.length <= MAX_TEXT_PREVIEW_LEN ? text : `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}…`
|
||||
})
|
||||
|
||||
/** 文件 icon:按扩展名挑色,跟主气泡渲染同源 */
|
||||
|
|
@ -227,4 +231,3 @@ function onClick() {
|
|||
emit('locate', props.quote.messageId)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -402,7 +402,6 @@ async function handleCreateGroupAndSend() {
|
|||
name: group.name || name,
|
||||
avatar: group.avatar || '',
|
||||
unreadCount: 0,
|
||||
messages: [],
|
||||
lastContent: '',
|
||||
lastSendTime: 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,12 @@ export const useChannelStore = defineStore('imChannelStore', {
|
|||
conversation.avatar = channel.avatar
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/** 清空频道内存 */
|
||||
clear() {
|
||||
this.channels = []
|
||||
this.loaded = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -98,4 +104,6 @@ if (import.meta.hot) {
|
|||
import.meta.hot.accept(acceptHMRUpdate(useChannelStore, import.meta.hot))
|
||||
}
|
||||
|
||||
export const useImChannelStore = () => useChannelStore(store)
|
||||
export const useChannelStoreWithOut = () => useChannelStore(store)
|
||||
// TODO @AI:这里,重名名,是不是没必要???(问问。)
|
||||
export const useImChannelStore = useChannelStoreWithOut
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -124,6 +124,13 @@ export const useDraftStore = defineStore('imDraft', {
|
|||
/** 立即落盘待写的草稿;beforeunload 时调,避免最后一次输入卡在 debounce 队列里丢失 */
|
||||
flushPersist(): void {
|
||||
persistBucket.flush()
|
||||
},
|
||||
|
||||
/** 清空草稿内存 */
|
||||
// TODO @AI:写草稿,是不是融合到 conversationStore 里。
|
||||
clear(): void {
|
||||
persistBucket.cancel()
|
||||
this.drafts = {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -514,7 +514,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
/**
|
||||
* 接收 GROUP_* 群广播事件,按 type 分发到对应私有 action
|
||||
*
|
||||
* WebSocket 实时收 + useMessagePuller 离线 pull 都走 conversationStore.insertMessage 旁路调用
|
||||
* WebSocket 实时收 + useMessagePuller 离线 pull 都走 messageStore.insertMessage 旁路调用
|
||||
* store 里没缓存的群静默忽略,等 fetchGroups 兜底
|
||||
*/
|
||||
applyGroupNotification(groupId: number, type: number, content?: string) {
|
||||
|
|
@ -567,7 +567,9 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.ADMIN)
|
||||
// 自己被加为管理员,原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
|
||||
if (isSelfInPayloadMembers(payload)) {
|
||||
useGroupRequestStore().fetchUnhandledList().catch(() => undefined)
|
||||
useGroupRequestStore()
|
||||
.fetchUnhandledList()
|
||||
.catch(() => undefined)
|
||||
}
|
||||
break
|
||||
case ImMessageType.GROUP_ADMIN_REMOVE:
|
||||
|
|
@ -701,7 +703,9 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
// 自己接管群主:原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
|
||||
const selfUserId = getCurrentUserId()
|
||||
if (selfUserId && payload.newOwnerUserId === selfUserId) {
|
||||
useGroupRequestStore().fetchUnhandledList().catch(() => undefined)
|
||||
useGroupRequestStore()
|
||||
.fetchUnhandledList()
|
||||
.catch(() => undefined)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -802,7 +806,9 @@ function convertGroup(group: ImGroupRespVO): Group {
|
|||
}
|
||||
|
||||
/** 后端 ImGroupMessageRespVO -> 前端 Message:补 targetId / selfSend / sendTime 等派生字段 */
|
||||
function convertGroupMessageVO(message: NonNullable<ImGroupRespVO['pinnedMessages']>[number]): Message {
|
||||
function convertGroupMessageVO(
|
||||
message: NonNullable<ImGroupRespVO['pinnedMessages']>[number]
|
||||
): Message {
|
||||
const currentUserId = getCurrentUserId()
|
||||
return {
|
||||
id: message.id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,839 @@
|
|||
import { acceptHMRUpdate, defineStore } from 'pinia'
|
||||
import { store } from '@/store'
|
||||
|
||||
import {
|
||||
IM_AT_ALL_USER_ID,
|
||||
ImConversationType,
|
||||
ImMessageStatus,
|
||||
ImMessageType,
|
||||
isGroupNotification,
|
||||
isNormalMessage
|
||||
} from '../../utils/constants'
|
||||
import {
|
||||
getClientConversationId,
|
||||
getClientMessageKey,
|
||||
getDb,
|
||||
getServerMessageKey,
|
||||
parseClientConversationId,
|
||||
setMessageMaxId,
|
||||
type DbTx
|
||||
} from '../../utils/db'
|
||||
import {
|
||||
generateClientMessageId,
|
||||
parseRecallMessageId,
|
||||
revokeBlobUrlsInContent
|
||||
} from '../../utils/message'
|
||||
import { resolveConversationLastContent } from '../../utils/conversation'
|
||||
import { getCurrentUserId } from '../../utils/storage'
|
||||
import { tryGetSenderDisplayName } from '../../utils/user'
|
||||
import { useGroupStore } from './groupStore'
|
||||
import { useConversationStore } from './conversationStore'
|
||||
import type { Conversation, Message, MessageDO } from '../types'
|
||||
|
||||
const MESSAGE_CACHE_CONVERSATION_LIMIT = 5
|
||||
const ackMergingPromises = new Map<string, Promise<void>>()
|
||||
|
||||
interface MessageConversationInfo {
|
||||
type: number
|
||||
targetId: number
|
||||
name: string
|
||||
avatar: string
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
// TODO @AI:叫这个 type 有点奇怪,可能需要再考虑下。
|
||||
export type PulledMessageBatchItem =
|
||||
| {
|
||||
kind: 'insert'
|
||||
conversationInfo: MessageConversationInfo
|
||||
message: Message
|
||||
}
|
||||
| {
|
||||
kind: 'recall'
|
||||
conversationType: number
|
||||
targetId: number
|
||||
recallSignalContent: string
|
||||
}
|
||||
|
||||
/** 获取会话的消息缓存 key */
|
||||
function getMessageCacheKey(type: number, targetId: number): string {
|
||||
return getClientConversationId(type, targetId)
|
||||
}
|
||||
|
||||
/** 生成消息本地主键 */
|
||||
function getMessageKey(
|
||||
message: Pick<Message, 'id' | 'clientMessageId'>,
|
||||
conversationType: number
|
||||
): string {
|
||||
return message.id
|
||||
? getServerMessageKey(conversationType, message.id)
|
||||
: getClientMessageKey(message.clientMessageId)
|
||||
}
|
||||
|
||||
/** 补齐客户端消息编号 */
|
||||
function ensureClientMessageId(message: Message): Message {
|
||||
if (!message.clientMessageId) {
|
||||
message.clientMessageId = generateClientMessageId()
|
||||
}
|
||||
if (!message.id) {
|
||||
message.id = undefined
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
/** 转换为 IndexedDB 消息记录 */
|
||||
// TODO @AI:buildXXX 更合理。
|
||||
function toMessageDO(message: Message, conversationType: number): MessageDO {
|
||||
const {
|
||||
uploadProgress: _uploadProgress,
|
||||
_localFile: _localFile,
|
||||
_ackMerging: _ackMerging,
|
||||
...rest
|
||||
} = message
|
||||
return {
|
||||
...rest,
|
||||
messageKey: getMessageKey(message, conversationType),
|
||||
conversationType,
|
||||
clientConversationId: getClientConversationId(conversationType, message.targetId)
|
||||
}
|
||||
}
|
||||
|
||||
/** IndexedDB 消息记录转前端消息 */
|
||||
// TODO @AI:buildXXX 更合理。
|
||||
function fromMessageDO(message: MessageDO): Message {
|
||||
const {
|
||||
messageKey: _messageKey,
|
||||
conversationType: _conversationType,
|
||||
clientConversationId: _clientConversationId,
|
||||
...rest
|
||||
} = message
|
||||
return rest
|
||||
}
|
||||
|
||||
/** 算出末条消息的发送人快照 */
|
||||
// TODO @AI:里面的代码注释;最好写下;
|
||||
function deriveLastSenderDisplayName(
|
||||
conversation: Conversation,
|
||||
senderId: number
|
||||
): string | undefined {
|
||||
const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
|
||||
if (liveSenderName) {
|
||||
return liveSenderName
|
||||
}
|
||||
if (conversation.type === ImConversationType.GROUP) {
|
||||
const groupStore = useGroupStore()
|
||||
const group = groupStore.getGroup(conversation.targetId)
|
||||
const fetchPromise = group?.membersLoaded
|
||||
? groupStore.fetchGroupMember(conversation.targetId, senderId)
|
||||
: groupStore.fetchGroupMembers(conversation.targetId)
|
||||
fetchPromise.catch((e) =>
|
||||
console.warn(
|
||||
'[IM messageStore] 兜底拉群成员失败',
|
||||
{ groupId: conversation.targetId, senderId, fullFetch: !group?.membersLoaded },
|
||||
e
|
||||
)
|
||||
)
|
||||
}
|
||||
return conversation.lastSenderId === senderId ? conversation.lastSenderDisplayName : undefined
|
||||
}
|
||||
|
||||
/** 按消息更新会话摘要 */
|
||||
function applyConversationSummary(conversation: Conversation, message: Message): void {
|
||||
const senderDisplayName = deriveLastSenderDisplayName(conversation, message.senderId)
|
||||
conversation.lastContent = resolveConversationLastContent(
|
||||
message,
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
senderDisplayName
|
||||
)
|
||||
conversation.lastSendTime = message.sendTime || Date.now()
|
||||
conversation.lastSenderId = message.senderId
|
||||
conversation.lastMessageType = message.type
|
||||
conversation.lastMessageId = message.id
|
||||
conversation.lastClientMessageId = message.clientMessageId
|
||||
conversation.lastMessageStatus = message.status
|
||||
conversation.lastReceiptStatus = message.receiptStatus
|
||||
conversation.lastSelfSend = message.selfSend
|
||||
conversation.lastSenderDisplayName = senderDisplayName
|
||||
}
|
||||
|
||||
/** 按末条消息重算会话摘要 */
|
||||
function recomputeConversationLast(conversation: Conversation, messages: Message[]): void {
|
||||
const last = messages[messages.length - 1]
|
||||
if (last) {
|
||||
applyConversationSummary(conversation, last)
|
||||
return
|
||||
}
|
||||
conversation.lastContent = ''
|
||||
conversation.lastSendTime = 0
|
||||
conversation.lastSenderId = undefined
|
||||
conversation.lastMessageType = undefined
|
||||
conversation.lastMessageId = undefined
|
||||
conversation.lastClientMessageId = undefined
|
||||
conversation.lastMessageStatus = undefined
|
||||
conversation.lastReceiptStatus = undefined
|
||||
conversation.lastSelfSend = undefined
|
||||
conversation.lastSenderDisplayName = undefined
|
||||
}
|
||||
|
||||
/** 同步群 @ 状态 */
|
||||
function syncConversationAtFlags(conversation: Conversation, message: Message): void {
|
||||
if (
|
||||
message.selfSend ||
|
||||
conversation.type !== ImConversationType.GROUP ||
|
||||
!message.atUserIds ||
|
||||
message.atUserIds.length === 0 ||
|
||||
message.status === ImMessageStatus.READ
|
||||
) {
|
||||
return
|
||||
}
|
||||
const currentUserId = getCurrentUserId()
|
||||
if (currentUserId && message.atUserIds.includes(currentUserId)) {
|
||||
conversation.atMe = true
|
||||
}
|
||||
if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) {
|
||||
conversation.atAll = true
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用服务端消息更新 */
|
||||
function applyServerMessageUpdate(message: Message, updates: Partial<Message>): void {
|
||||
if (updates.content && updates.content !== message.content) {
|
||||
revokeBlobUrlsInContent(message.content)
|
||||
}
|
||||
Object.assign(message, updates)
|
||||
if (updates.id === 0) {
|
||||
message.id = undefined
|
||||
}
|
||||
if (updates.status !== undefined && updates.status !== ImMessageStatus.SENDING) {
|
||||
message.uploadProgress = undefined
|
||||
if (updates.status !== ImMessageStatus.FAILED) {
|
||||
message._localFile = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断是否为同一条消息 */
|
||||
function isSameMessage(left: Message, right: Message): boolean {
|
||||
if (left.id && right.id && left.id === right.id) {
|
||||
return true
|
||||
}
|
||||
return !!left.clientMessageId && left.clientMessageId === right.clientMessageId
|
||||
}
|
||||
|
||||
export const useMessageStore = defineStore('imMessageStore', {
|
||||
state: () => ({
|
||||
messagesByConversation: {} as Record<string, Message[]>,
|
||||
loadedConversationKeys: [] as string[],
|
||||
privateMessageMaxId: 0,
|
||||
groupMessageMaxId: 0,
|
||||
channelMessageMaxId: 0
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/** 获取会话已加载消息 */
|
||||
getMessages:
|
||||
(state) =>
|
||||
(clientConversationId: string): Message[] =>
|
||||
state.messagesByConversation[clientConversationId] || []
|
||||
},
|
||||
|
||||
actions: {
|
||||
/** 清空消息内存 */
|
||||
clear() {
|
||||
Object.values(this.messagesByConversation).forEach((messages) => {
|
||||
messages.forEach((message) => {
|
||||
revokeBlobUrlsInContent(message.content)
|
||||
message._localFile = undefined
|
||||
})
|
||||
})
|
||||
this.messagesByConversation = {}
|
||||
this.loadedConversationKeys = []
|
||||
this.privateMessageMaxId = 0
|
||||
this.groupMessageMaxId = 0
|
||||
this.channelMessageMaxId = 0
|
||||
ackMergingPromises.clear()
|
||||
},
|
||||
|
||||
/** 从 settings 加载消息游标 */
|
||||
async loadCursors() {
|
||||
const db = getDb()
|
||||
// TODO @AI:可以通过 message 表去算么?不通过这个。
|
||||
const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([
|
||||
db.getSetting<number>('privateMessageMaxId'),
|
||||
db.getSetting<number>('groupMessageMaxId'),
|
||||
db.getSetting<number>('channelMessageMaxId')
|
||||
])
|
||||
this.privateMessageMaxId = privateMaxId || 0
|
||||
this.groupMessageMaxId = groupMaxId || 0
|
||||
this.channelMessageMaxId = channelMaxId || 0
|
||||
},
|
||||
|
||||
/** 更新内存游标 */
|
||||
updateMaxId(conversationType: number, messageId?: number) {
|
||||
if (!messageId) {
|
||||
return
|
||||
}
|
||||
if (conversationType === ImConversationType.PRIVATE && messageId > this.privateMessageMaxId) {
|
||||
this.privateMessageMaxId = messageId
|
||||
} else if (
|
||||
conversationType === ImConversationType.GROUP &&
|
||||
messageId > this.groupMessageMaxId
|
||||
) {
|
||||
this.groupMessageMaxId = messageId
|
||||
} else if (
|
||||
conversationType === ImConversationType.CHANNEL &&
|
||||
messageId > this.channelMessageMaxId
|
||||
) {
|
||||
this.channelMessageMaxId = messageId
|
||||
}
|
||||
},
|
||||
|
||||
/** 标记会话近期使用 */
|
||||
touchConversation(clientConversationId: string) {
|
||||
this.loadedConversationKeys = [
|
||||
clientConversationId,
|
||||
...this.loadedConversationKeys.filter((key) => key !== clientConversationId)
|
||||
]
|
||||
// 保留当前活跃会话 + 最近打开过的 5 个会话。
|
||||
const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_CONVERSATION_LIMIT + 1)
|
||||
const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_CONVERSATION_LIMIT + 1)
|
||||
this.loadedConversationKeys = retained
|
||||
removed.forEach((key) => {
|
||||
delete this.messagesByConversation[key]
|
||||
})
|
||||
},
|
||||
|
||||
/** 加载当前会话最近消息 */
|
||||
async loadMore(
|
||||
clientConversationId: string,
|
||||
beforeSendTime?: number,
|
||||
limit = 50
|
||||
): Promise<Message[]> {
|
||||
// TODO @AI:代码段的注释;
|
||||
const list = await getDb().getMessagesByConversation(clientConversationId, {
|
||||
beforeSendTime,
|
||||
limit
|
||||
})
|
||||
const parsed = parseClientConversationId(clientConversationId)
|
||||
if (!parsed) {
|
||||
return []
|
||||
}
|
||||
const messages = list.map(fromMessageDO)
|
||||
const existing = this.messagesByConversation[clientConversationId] || []
|
||||
const existingKeys = new Set(existing.map((message) => getMessageKey(message, parsed.type)))
|
||||
const fresh = messages.filter(
|
||||
(message) => !existingKeys.has(getMessageKey(message, parsed.type))
|
||||
)
|
||||
// TODO @AI:messageA、messageB;
|
||||
this.messagesByConversation[clientConversationId] = [...fresh, ...existing].sort(
|
||||
(a, b) => (a.sendTime || 0) - (b.sendTime || 0)
|
||||
)
|
||||
this.touchConversation(clientConversationId)
|
||||
return fresh
|
||||
},
|
||||
|
||||
/** 确保会话消息已加载 */
|
||||
async ensureLoaded(conversation: Conversation) {
|
||||
// TODO @AI:代码段的注释;
|
||||
const key = getMessageCacheKey(conversation.type, conversation.targetId)
|
||||
if (this.messagesByConversation[key]) {
|
||||
this.touchConversation(key)
|
||||
return
|
||||
}
|
||||
await this.loadMore(key)
|
||||
},
|
||||
|
||||
/** 获取内存消息数组 */
|
||||
getMessageList(conversationType: number, targetId: number): Message[] {
|
||||
// TODO @AI:代码段的注释;
|
||||
const key = getMessageCacheKey(conversationType, targetId)
|
||||
if (!this.messagesByConversation[key]) {
|
||||
this.messagesByConversation[key] = []
|
||||
}
|
||||
this.touchConversation(key)
|
||||
return this.messagesByConversation[key]
|
||||
},
|
||||
|
||||
/** 持久化单条消息 */
|
||||
async persistMessage(message: Message, conversationType: number, tx?: DbTx) {
|
||||
// TODO @AI:代码段的注释;
|
||||
const db = getDb()
|
||||
const next = toMessageDO(message, conversationType)
|
||||
if (message.id && message.clientMessageId) {
|
||||
const existing = await db.getByIndex<MessageDO>(
|
||||
'messages',
|
||||
'clientMessageId',
|
||||
message.clientMessageId,
|
||||
tx
|
||||
)
|
||||
if (existing && existing.messageKey !== next.messageKey) {
|
||||
await db.delete('messages', existing.messageKey, tx)
|
||||
}
|
||||
}
|
||||
await db.put('messages', next, tx)
|
||||
},
|
||||
|
||||
/** 持久化消息游标 */
|
||||
async persistMaxId(conversationType: number, messageId?: number, tx?: DbTx) {
|
||||
this.updateMaxId(conversationType, messageId)
|
||||
await setMessageMaxId(conversationType, messageId, tx)
|
||||
},
|
||||
|
||||
/** 应用撤回到内存 */
|
||||
applyRecallInMemory(conversationType: number, targetId: number, recallSignalContent: string) {
|
||||
// TODO @AI:代码段的注释;
|
||||
const messageId = parseRecallMessageId(recallSignalContent)
|
||||
if (!messageId) {
|
||||
return null
|
||||
}
|
||||
const conversationStore = useConversationStore()
|
||||
const conversation = conversationStore.getConversation(conversationType, targetId)
|
||||
if (!conversation) {
|
||||
return null
|
||||
}
|
||||
const messages = this.getMessageList(conversationType, targetId)
|
||||
const message = messages.find((item) => item.id === messageId)
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
message.type = ImMessageType.RECALL
|
||||
message.status = ImMessageStatus.RECALL
|
||||
message.content = ''
|
||||
if (messages[messages.length - 1]?.id === messageId) {
|
||||
recomputeConversationLast(conversation, messages)
|
||||
}
|
||||
return { conversation, message }
|
||||
},
|
||||
|
||||
/** 批量写入拉取消息 */
|
||||
async insertPulledBatch(
|
||||
items: PulledMessageBatchItem[],
|
||||
conversationType: number,
|
||||
maxMessageId?: number
|
||||
) {
|
||||
// TODO @AI:代码段的注释;
|
||||
if (items.length === 0) {
|
||||
await this.persistMaxId(conversationType, maxMessageId)
|
||||
return
|
||||
}
|
||||
const conversationStore = useConversationStore()
|
||||
const persistedMessages = new Map<string, { message: Message; conversationType: number }>()
|
||||
const changedConversations = new Map<string, Conversation>()
|
||||
|
||||
const addChanged = (conversation: Conversation, message: Message) => {
|
||||
const clientConversationId = getClientConversationId(
|
||||
conversation.type,
|
||||
conversation.targetId
|
||||
)
|
||||
changedConversations.set(clientConversationId, conversation)
|
||||
persistedMessages.set(getMessageKey(message, conversation.type), {
|
||||
message,
|
||||
conversationType: conversation.type
|
||||
})
|
||||
}
|
||||
|
||||
// TODO @AI:是不是最好 mesages?
|
||||
for (const item of items) {
|
||||
if (item.kind === 'recall') {
|
||||
const changed = this.applyRecallInMemory(
|
||||
item.conversationType,
|
||||
item.targetId,
|
||||
item.recallSignalContent
|
||||
)
|
||||
if (changed) {
|
||||
addChanged(changed.conversation, changed.message)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const { conversationInfo } = item
|
||||
const message = ensureClientMessageId(item.message)
|
||||
if (
|
||||
conversationInfo.type === ImConversationType.GROUP &&
|
||||
isGroupNotification(message.type)
|
||||
) {
|
||||
useGroupStore().applyGroupNotification(
|
||||
conversationInfo.targetId,
|
||||
message.type,
|
||||
message.content
|
||||
)
|
||||
}
|
||||
|
||||
const conversation = conversationStore.ensureConversation(conversationInfo)
|
||||
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
|
||||
const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
|
||||
if (existingIndex >= 0) {
|
||||
applyServerMessageUpdate(messages[existingIndex], message)
|
||||
if (existingIndex === messages.length - 1) {
|
||||
recomputeConversationLast(conversation, messages)
|
||||
syncConversationAtFlags(conversation, message)
|
||||
}
|
||||
this.updateMaxId(conversationInfo.type, message.id)
|
||||
addChanged(conversation, messages[existingIndex])
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO @AI:applyConversationSummary 要 await 么?不然会有报错;
|
||||
applyConversationSummary(conversation, message)
|
||||
syncConversationAtFlags(conversation, message)
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type === conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId === conversationInfo.targetId
|
||||
if (
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.READ &&
|
||||
message.status !== ImMessageStatus.RECALL
|
||||
) {
|
||||
conversation.unreadCount++
|
||||
}
|
||||
|
||||
let insertIndex = messages.length
|
||||
if (message.id) {
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
const existing = messages[index]
|
||||
if (existing.id && message.id < existing.id) {
|
||||
insertIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
messages.splice(insertIndex, 0, message)
|
||||
this.updateMaxId(conversationInfo.type, message.id)
|
||||
addChanged(conversation, message)
|
||||
}
|
||||
|
||||
this.updateMaxId(conversationType, maxMessageId)
|
||||
await getDb()
|
||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||
for (const item of persistedMessages.values()) {
|
||||
await this.persistMessage(item.message, item.conversationType, tx)
|
||||
}
|
||||
await conversationStore.persistConversations([...changedConversations.values()], tx)
|
||||
await setMessageMaxId(conversationType, maxMessageId, tx)
|
||||
})
|
||||
.catch((e) => console.error('[IM messageStore] 批量消息写入失败', e))
|
||||
},
|
||||
|
||||
/** 插入消息 */
|
||||
insertMessage(
|
||||
conversationInfo: MessageConversationInfo,
|
||||
messageInfo: Message,
|
||||
options?: { persistMaxId?: boolean }
|
||||
) {
|
||||
// TODO @AI:代码段的注释;类似上面的问题;;;
|
||||
const conversationStore = useConversationStore()
|
||||
const message = ensureClientMessageId(messageInfo)
|
||||
if (conversationInfo.type === ImConversationType.GROUP && isGroupNotification(message.type)) {
|
||||
useGroupStore().applyGroupNotification(
|
||||
conversationInfo.targetId,
|
||||
message.type,
|
||||
message.content
|
||||
)
|
||||
}
|
||||
|
||||
const conversation = conversationStore.ensureConversation(conversationInfo)
|
||||
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
|
||||
const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
|
||||
if (existingIndex >= 0) {
|
||||
applyServerMessageUpdate(messages[existingIndex], message)
|
||||
if (existingIndex === messages.length - 1) {
|
||||
recomputeConversationLast(conversation, messages)
|
||||
syncConversationAtFlags(conversation, message)
|
||||
}
|
||||
this.updateMaxId(conversationInfo.type, message.id)
|
||||
void getDb()
|
||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||
await this.persistMessage(messages[existingIndex], conversationInfo.type, tx)
|
||||
await conversationStore.persistConversations(conversation, tx)
|
||||
if (options?.persistMaxId !== false) {
|
||||
await setMessageMaxId(conversationInfo.type, message.id, tx)
|
||||
}
|
||||
})
|
||||
.catch((e) => console.error('[IM messageStore] 消息写入失败', e))
|
||||
return
|
||||
}
|
||||
|
||||
applyConversationSummary(conversation, message)
|
||||
syncConversationAtFlags(conversation, message)
|
||||
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type === conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId === conversationInfo.targetId
|
||||
if (
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.READ &&
|
||||
message.status !== ImMessageStatus.RECALL
|
||||
) {
|
||||
conversation.unreadCount++
|
||||
}
|
||||
|
||||
let insertIndex = messages.length
|
||||
if (message.id) {
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
const existing = messages[index]
|
||||
if (existing.id && message.id < existing.id) {
|
||||
insertIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
messages.splice(insertIndex, 0, message)
|
||||
this.updateMaxId(conversationInfo.type, message.id)
|
||||
void getDb()
|
||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||
await this.persistMessage(message, conversationInfo.type, tx)
|
||||
await conversationStore.persistConversations(conversation, tx)
|
||||
if (options?.persistMaxId !== false) {
|
||||
await setMessageMaxId(conversationInfo.type, message.id, tx)
|
||||
}
|
||||
})
|
||||
.catch((e) => console.error('[IM messageStore] 消息写入失败', e))
|
||||
},
|
||||
|
||||
/** ack 合并 */
|
||||
ackMessage(
|
||||
conversationType: number,
|
||||
targetId: number,
|
||||
clientMessageId: string,
|
||||
updates: Partial<Message>
|
||||
) {
|
||||
const mergeKey = `${conversationType}:${targetId}:${clientMessageId}`
|
||||
const existingPromise = ackMergingPromises.get(mergeKey)
|
||||
if (existingPromise) {
|
||||
return existingPromise
|
||||
}
|
||||
const promise = this.doAckMessage(
|
||||
conversationType,
|
||||
targetId,
|
||||
clientMessageId,
|
||||
updates
|
||||
).finally(() => {
|
||||
ackMergingPromises.delete(mergeKey)
|
||||
})
|
||||
ackMergingPromises.set(mergeKey, promise)
|
||||
return promise
|
||||
},
|
||||
|
||||
/** 执行 ack 合并 */
|
||||
async doAckMessage(
|
||||
conversationType: number,
|
||||
targetId: number,
|
||||
clientMessageId: string,
|
||||
updates: Partial<Message>
|
||||
) {
|
||||
const conversationStore = useConversationStore()
|
||||
const conversation = conversationStore.getConversation(conversationType, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
const messages = this.getMessageList(conversationType, targetId)
|
||||
const message = messages.find((item) => item.clientMessageId === clientMessageId)
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
message._ackMerging = true
|
||||
try {
|
||||
applyServerMessageUpdate(message, updates)
|
||||
if (messages[messages.length - 1] === message) {
|
||||
recomputeConversationLast(conversation, messages)
|
||||
}
|
||||
this.updateMaxId(conversationType, message.id)
|
||||
await getDb()
|
||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||
await this.persistMessage(message, conversationType, tx)
|
||||
await conversationStore.persistConversations(conversation, tx)
|
||||
await setMessageMaxId(conversationType, message.id, tx)
|
||||
})
|
||||
.catch((e) => console.error('[IM messageStore] ack 写入失败', e))
|
||||
} finally {
|
||||
message._ackMerging = false
|
||||
}
|
||||
},
|
||||
|
||||
/** 局部更新消息 */
|
||||
patchMessage(
|
||||
conversationType: number,
|
||||
targetId: number,
|
||||
clientMessageId: string,
|
||||
patch: Partial<Message>
|
||||
) {
|
||||
// TODO @AI:代码段的注释;
|
||||
const message = this.getMessageList(conversationType, targetId).find(
|
||||
(item) => item.clientMessageId === clientMessageId
|
||||
)
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
let changed = false
|
||||
for (const key in patch) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(patch, key) &&
|
||||
(patch as Record<string, unknown>)[key] !==
|
||||
(message as unknown as Record<string, unknown>)[key]
|
||||
) {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
applyServerMessageUpdate(message, patch)
|
||||
}
|
||||
},
|
||||
|
||||
/** 撤回消息 */
|
||||
recallMessage(conversationType: number, targetId: number, recallSignalContent: string) {
|
||||
const conversationStore = useConversationStore()
|
||||
const changed = this.applyRecallInMemory(conversationType, targetId, recallSignalContent)
|
||||
if (!changed) {
|
||||
return
|
||||
}
|
||||
this.persistMessage(changed.message, conversationType).catch((e) =>
|
||||
console.error('[IM messageStore] 撤回消息写入失败', e)
|
||||
)
|
||||
conversationStore.saveConversations(changed.conversation)
|
||||
},
|
||||
|
||||
/** 应用已读回执 */
|
||||
applyReadReceipt(options: {
|
||||
conversationType: number
|
||||
targetId: number
|
||||
privateReadMaxId?: number
|
||||
groupMessageId?: number
|
||||
readCount?: number
|
||||
receiptStatus?: number
|
||||
}) {
|
||||
// TODO @AI:代码段的注释;
|
||||
const messages = this.getMessageList(options.conversationType, options.targetId)
|
||||
const changed: Message[] = []
|
||||
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
|
||||
messages.forEach((message) => {
|
||||
if (
|
||||
message.selfSend &&
|
||||
message.id &&
|
||||
message.id <= options.privateReadMaxId! &&
|
||||
message.status !== ImMessageStatus.RECALL
|
||||
) {
|
||||
message.status = ImMessageStatus.READ
|
||||
changed.push(message)
|
||||
}
|
||||
})
|
||||
} else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
|
||||
const message = messages.find((item) => item.id === options.groupMessageId)
|
||||
if (message) {
|
||||
if (options.readCount !== undefined) {
|
||||
message.readCount = options.readCount
|
||||
}
|
||||
if (options.receiptStatus !== undefined) {
|
||||
message.receiptStatus = options.receiptStatus
|
||||
}
|
||||
changed.push(message)
|
||||
}
|
||||
}
|
||||
changed.forEach((message) => {
|
||||
this.persistMessage(message, options.conversationType).catch((e) =>
|
||||
console.warn('[IM messageStore] 回执写入失败', e)
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
/** 前置历史消息 */
|
||||
prependMessages(conversationType: number, targetId: number, earlierMessages: Message[]) {
|
||||
// TODO @AI:代码段的注释;
|
||||
if (earlierMessages.length === 0) {
|
||||
return
|
||||
}
|
||||
const messages = this.getMessageList(conversationType, targetId)
|
||||
const existingIds = new Set(messages.map((message) => message.id).filter(Boolean))
|
||||
const fresh = earlierMessages
|
||||
.map(ensureClientMessageId)
|
||||
.filter((message) => message.id && !existingIds.has(message.id))
|
||||
.sort((a, b) => (a.id || 0) - (b.id || 0))
|
||||
if (fresh.length === 0) {
|
||||
return
|
||||
}
|
||||
const key = getMessageCacheKey(conversationType, targetId)
|
||||
this.messagesByConversation[key] = [...fresh, ...messages]
|
||||
fresh.forEach((message) => {
|
||||
this.persistMessage(message, conversationType).catch((e) =>
|
||||
console.warn('[IM messageStore] 历史消息写入失败', e)
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
/** 删除单条消息 */
|
||||
removeMessage(
|
||||
conversationType: number,
|
||||
targetId: number,
|
||||
key: { id?: number; clientMessageId?: string }
|
||||
) {
|
||||
// TODO @AI:代码段的注释;
|
||||
const conversationStore = useConversationStore()
|
||||
const conversation = conversationStore.getConversation(conversationType, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
const messages = this.getMessageList(conversationType, targetId)
|
||||
const index = messages.findIndex((message) => {
|
||||
if (key.id && message.id && message.id === key.id) {
|
||||
return true
|
||||
}
|
||||
return !!key.clientMessageId && message.clientMessageId === key.clientMessageId
|
||||
})
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
const [removed] = messages.splice(index, 1)
|
||||
revokeBlobUrlsInContent(removed.content)
|
||||
if (index === messages.length) {
|
||||
recomputeConversationLast(conversation, messages)
|
||||
}
|
||||
getDb()
|
||||
.delete('messages', getMessageKey(removed, conversationType))
|
||||
.catch((e) => console.warn('[IM messageStore] 消息删除失败', e))
|
||||
conversationStore.saveConversations()
|
||||
},
|
||||
|
||||
/** 当前会话标记已读 */
|
||||
markConversationMessagesRead(conversation: Conversation) {
|
||||
// TODO @AI:代码段的注释;
|
||||
const messages = this.getMessageList(conversation.type, conversation.targetId)
|
||||
messages.forEach((message) => {
|
||||
if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
|
||||
message.status = ImMessageStatus.READ
|
||||
this.persistMessage(message, conversation.type).catch((e) =>
|
||||
console.warn('[IM messageStore] 已读状态写入失败', e)
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/** 删除会话全部消息 */
|
||||
deleteConversationMessages(conversationType: number, targetId: number) {
|
||||
// TODO @AI:代码段的注释;
|
||||
const clientConversationId = getClientConversationId(conversationType, targetId)
|
||||
const messages = this.messagesByConversation[clientConversationId] || []
|
||||
messages.forEach((message) => {
|
||||
revokeBlobUrlsInContent(message.content)
|
||||
message._localFile = undefined
|
||||
})
|
||||
delete this.messagesByConversation[clientConversationId]
|
||||
this.loadedConversationKeys = this.loadedConversationKeys.filter(
|
||||
(key) => key !== clientConversationId
|
||||
)
|
||||
getDb()
|
||||
.deleteByIndex('messages', 'clientConversationId', clientConversationId)
|
||||
.catch((e) => console.warn('[IM messageStore] 会话消息删除失败', e))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const useMessageStoreWithOut = () => useMessageStore(store)
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useMessageStore, import.meta.hot))
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
WS_RECONNECT_JITTER_MS
|
||||
} from '../../utils/config'
|
||||
import { useConversationStore } from './conversationStore'
|
||||
import { useMessageStore } from './messageStore'
|
||||
import { useFriendStore, type FriendNotificationPayload } from './friendStore'
|
||||
import { getFriendDisplayName } from '../../utils/user'
|
||||
import { useGroupStore } from './groupStore'
|
||||
|
|
@ -317,10 +318,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
|
||||
/**
|
||||
* 频道消息实时入会话;频道消息单向 + 无状态机,直接 insertMessage 即可
|
||||
* pull 与 WS 拿到同一条 id 时,conversationStore.insertMessage 内部按 id 去重,不会重复
|
||||
* pull 与 WS 拿到同一条 id 时,messageStore.insertMessage 内部按 id 去重,不会重复
|
||||
*/
|
||||
handleChannelMessage(websocketMessage: ImChannelMessageRespVO) {
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
// 离线加载期间先缓冲,等 pull 完成后再统一回放,避免重复或顺序错乱
|
||||
if (conversationStore.loading) {
|
||||
this.messageBuffer.push({
|
||||
|
|
@ -333,7 +335,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
typeof websocketMessage.sendTime === 'number'
|
||||
? websocketMessage.sendTime
|
||||
: new Date(websocketMessage.sendTime).getTime()
|
||||
conversationStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), {
|
||||
messageStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), {
|
||||
id: websocketMessage.id,
|
||||
clientMessageId: '',
|
||||
type: websocketMessage.type,
|
||||
|
|
@ -513,7 +515,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage)
|
||||
// 这里拦截下来改走 recallMessage(把原消息更新为 RECALL 态),不让它作为新消息进列表
|
||||
if (websocketMessage.type === ImMessageType.RECALL) {
|
||||
conversationStore.recallMessage(
|
||||
useMessageStore().recallMessage(
|
||||
ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
websocketMessage.content
|
||||
|
|
@ -523,7 +525,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
|
||||
// 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段
|
||||
const message = convertPrivateMessage(websocketMessage, currentUserId)
|
||||
conversationStore.insertMessage(
|
||||
useMessageStore().insertMessage(
|
||||
{
|
||||
type: ImConversationType.PRIVATE,
|
||||
targetId: peerId,
|
||||
|
|
@ -543,7 +545,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
if (isActive) {
|
||||
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
|
||||
// 已读位置直接用刚到的消息 id(这条就是当前会话最大 id)
|
||||
conversationStore.markActiveAsRead()
|
||||
conversationStore.markConversationAsRead(ImConversationType.PRIVATE, peerId)
|
||||
if (conversation) {
|
||||
useMessageStore().markConversationMessagesRead(conversation)
|
||||
}
|
||||
if (MESSAGE_PRIVATE_READ_ENABLED) {
|
||||
apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => {
|
||||
console.warn('[IM WS] 自动已读上报失败', e)
|
||||
|
|
@ -580,8 +585,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
if (!websocketMessage.id) {
|
||||
return
|
||||
}
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.applyReadReceipt({
|
||||
useMessageStore().applyReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: websocketMessage.senderId,
|
||||
privateReadMaxId: websocketMessage.id
|
||||
|
|
@ -637,7 +641,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`
|
||||
// 这里拦截下来改走 recallMessage(把原消息更新为 RECALL 态)
|
||||
if (websocketMessage.type === ImMessageType.RECALL) {
|
||||
conversationStore.recallMessage(
|
||||
useMessageStore().recallMessage(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId,
|
||||
websocketMessage.content
|
||||
|
|
@ -647,7 +651,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
|
||||
// 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段
|
||||
const message = convertGroupMessage(websocketMessage, currentUserId)
|
||||
conversationStore.insertMessage(
|
||||
useMessageStore().insertMessage(
|
||||
{
|
||||
type: ImConversationType.GROUP,
|
||||
targetId: websocketMessage.groupId,
|
||||
|
|
@ -669,7 +673,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
conversationStore.activeConversation?.targetId === websocketMessage.groupId
|
||||
if (isActive) {
|
||||
// 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零
|
||||
conversationStore.markActiveAsRead()
|
||||
conversationStore.markConversationAsRead(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId
|
||||
)
|
||||
if (conversation) {
|
||||
useMessageStore().markConversationMessagesRead(conversation)
|
||||
}
|
||||
if (MESSAGE_GROUP_READ_ENABLED) {
|
||||
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => {
|
||||
console.warn('[IM WS] 自动已读上报失败', e)
|
||||
|
|
@ -698,8 +708,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
if (!MESSAGE_GROUP_READ_ENABLED) {
|
||||
return
|
||||
}
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.applyReadReceipt({
|
||||
useMessageStore().applyReadReceipt({
|
||||
conversationType: ImConversationType.GROUP,
|
||||
targetId: websocketMessage.groupId,
|
||||
groupMessageId: websocketMessage.id,
|
||||
|
|
|
|||
|
|
@ -47,13 +47,16 @@ export interface Conversation {
|
|||
name: string // 展示名称(私聊=好友昵称;群聊=群名)
|
||||
avatar: string // 头像
|
||||
unreadCount: number // 未读数
|
||||
messages: Message[] // 消息列表
|
||||
|
||||
// ========== 最后一条消息事实索引 ==========
|
||||
lastContent: string // 会话列表展示的最后一条消息摘要
|
||||
lastSendTime: number // 最后一条消息时间,用于排序
|
||||
lastSenderId?: number // 发送人编号
|
||||
lastMessageType?: number // 消息类型,对齐 ImMessageType
|
||||
lastMessageId?: number // 最后一条服务端消息编号
|
||||
lastClientMessageId?: string // 最后一条客户端消息编号
|
||||
lastMessageStatus?: number // 最后一条消息状态
|
||||
lastReceiptStatus?: number // 最后一条群回执状态
|
||||
lastSelfSend?: boolean // 是否自己发的
|
||||
lastSenderDisplayName?: string // 发送人显示名快照——仅作 utils/user.getSenderDisplayName 实时算不出真名时的 fallback
|
||||
|
||||
|
|
@ -68,7 +71,8 @@ export interface Conversation {
|
|||
// 消息数据结构
|
||||
export interface Message {
|
||||
// ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO) ==========
|
||||
id: number // 服务端消息编号,发送中为 0
|
||||
// TODO @AI:全局的 id 占位 0,是不是枚举下!!!
|
||||
id?: number // 服务端消息编号,发送中为空
|
||||
clientMessageId: string // 客户端消息编号,本地生成用于合并去重
|
||||
type: number // 消息类型,对齐 ImMessageType
|
||||
content: string // 消息内容,JSON 字符串
|
||||
|
|
@ -90,23 +94,25 @@ export interface Message {
|
|||
// 媒体消息内存中保留的原始 File;下划线前缀表示不进 JSON / 不持久化(IDB 恢复后必为 undefined)
|
||||
// 失败重试时按它重走上传;页面刷新后该字段丢失,恢复阶段直接 drop 整条消息
|
||||
_localFile?: File
|
||||
_ackMerging?: boolean // ack 合并中标记,不持久化
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话索引项:基于 Conversation 派生,但剥离 messages 字段(消息按会话独立存到 messages key)
|
||||
*
|
||||
* Omit<T, K> 是 TS 内置工具类型:从类型 T 中剔除 K 指定的字段,得到剩余字段组成的新类型。
|
||||
* 这里 `Omit<Conversation, 'messages'>` 等价于"Conversation 去掉 messages 字段后的版本",
|
||||
* 与"Conversation 派生但少一个 messages 字段"的语义一致,不需要再手写一份重复结构。
|
||||
*/
|
||||
export type ConversationMeta = Omit<Conversation, 'messages'>
|
||||
// ==================== IndexedDB 本地存储结构 ====================
|
||||
|
||||
// 持久化的会话索引:游标 + 会话元数据列表,按用户 ID 分桶
|
||||
export interface ConversationStoreMeta {
|
||||
privateMessageMaxId: number // 私聊消息最大编号
|
||||
groupMessageMaxId: number // 群聊消息最大编号
|
||||
channelMessageMaxId?: number // 频道消息最大编号
|
||||
conversations: ConversationMeta[] // 会话索引(不含 messages)
|
||||
export interface ConversationDO extends Conversation {
|
||||
clientConversationId: string // `${type}:${targetId}`
|
||||
}
|
||||
|
||||
export interface MessageDO extends Omit<Message, 'uploadProgress' | '_localFile' | '_ackMerging'> {
|
||||
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
|
||||
conversationType: number // 会话类型,对齐 ImConversationType
|
||||
clientConversationId: string // ConversationDO.clientConversationId
|
||||
}
|
||||
|
||||
export interface SettingDO<T = unknown> {
|
||||
key: string
|
||||
value: T
|
||||
updateTime?: number
|
||||
}
|
||||
|
||||
// ==================== 群 / 群成员 ====================
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
|||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import * as ManagerFacePackItemApi from '@/api/im/manager/face/item'
|
||||
import { probeImageSize } from '@/views/im/utils/image'
|
||||
import type { FormRules } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'ImManagerFacePackItemForm' })
|
||||
|
||||
|
|
@ -99,7 +100,7 @@ const formData = ref({
|
|||
sort: 0,
|
||||
status: CommonStatusEnum.ENABLE
|
||||
})
|
||||
const formRules = reactive({
|
||||
const formRules = reactive<FormRules>({
|
||||
url: [{ required: true, message: '表情图不能为空', trigger: 'change' }],
|
||||
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
|
||||
// 宽高自动探测后允许手改,但提交前必须落 1-2048 范围;防止用户清空后 submit 让后端报错
|
||||
|
|
|
|||
|
|
@ -0,0 +1,482 @@
|
|||
import { toRaw } from 'vue'
|
||||
|
||||
import { getCurrentUserId } from './storage'
|
||||
import { ImConversationType } from './constants'
|
||||
import type { MessageDO, SettingDO } from '../home/types'
|
||||
|
||||
export const DB_SCHEMA_VERSION = 1
|
||||
|
||||
export type DbStoreName =
|
||||
| 'conversations'
|
||||
| 'messages'
|
||||
| 'friends'
|
||||
| 'friendRequests'
|
||||
| 'groups'
|
||||
| 'groupMembers'
|
||||
| 'groupRequests'
|
||||
| 'channels'
|
||||
| 'settings'
|
||||
|
||||
// TODO @AI:是不是继续使用 IDBTransaction,不用新的类型定义;
|
||||
export type DbTx = IDBTransaction
|
||||
|
||||
let currentDb: IDBDatabase | null = null
|
||||
let currentUserId: number | null = null
|
||||
let currentSession = 0
|
||||
|
||||
/** 校验当前 IM IndexedDB session 仍有效 */
|
||||
export function isCurrentDbSession(session: number): boolean {
|
||||
return session === currentSession
|
||||
}
|
||||
|
||||
/** 获取当前 IM IndexedDB session */
|
||||
export function getDbSession(): number {
|
||||
return currentSession
|
||||
}
|
||||
|
||||
/** 拼接当前用户 IM DB 名称 */
|
||||
function getDbName(userId: number): string {
|
||||
return `im:${userId}`
|
||||
}
|
||||
|
||||
/** 包装 IndexedDB request */
|
||||
function requestToPromise<T = unknown>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
/** 等待事务完成 */
|
||||
function transactionDone(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(transaction.error)
|
||||
transaction.onabort = () => reject(transaction.error)
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建索引 */
|
||||
function createIndex(
|
||||
store: IDBObjectStore,
|
||||
name: string,
|
||||
keyPath: string | string[],
|
||||
options?: IDBIndexParameters
|
||||
) {
|
||||
if (!store.indexNames.contains(name)) {
|
||||
store.createIndex(name, keyPath, options)
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 schema */
|
||||
function upgradeSchema(db: IDBDatabase) {
|
||||
if (!db.objectStoreNames.contains('conversations')) {
|
||||
const store = db.createObjectStore('conversations', { keyPath: 'clientConversationId' })
|
||||
createIndex(store, 'lastSendTime', 'lastSendTime')
|
||||
}
|
||||
if (!db.objectStoreNames.contains('messages')) {
|
||||
const store = db.createObjectStore('messages', { keyPath: 'messageKey' })
|
||||
createIndex(store, 'clientConversationId', 'clientConversationId')
|
||||
createIndex(store, 'clientConversationId+sendTime', ['clientConversationId', 'sendTime'])
|
||||
createIndex(store, 'clientMessageId', 'clientMessageId', { unique: true })
|
||||
}
|
||||
if (!db.objectStoreNames.contains('friends')) {
|
||||
const store = db.createObjectStore('friends', { keyPath: 'id' })
|
||||
createIndex(store, 'friendUserId', 'friendUserId', { unique: true })
|
||||
createIndex(store, 'status', 'status')
|
||||
}
|
||||
if (!db.objectStoreNames.contains('friendRequests')) {
|
||||
const store = db.createObjectStore('friendRequests', { keyPath: 'id' })
|
||||
createIndex(store, 'status', 'status')
|
||||
createIndex(store, 'createTime', 'createTime')
|
||||
}
|
||||
if (!db.objectStoreNames.contains('groups')) {
|
||||
const store = db.createObjectStore('groups', { keyPath: 'id' })
|
||||
createIndex(store, 'name', 'name')
|
||||
createIndex(store, 'status', 'status')
|
||||
}
|
||||
if (!db.objectStoreNames.contains('groupMembers')) {
|
||||
const store = db.createObjectStore('groupMembers', { keyPath: 'id' })
|
||||
createIndex(store, 'groupId', 'groupId')
|
||||
createIndex(store, 'groupId+userId', ['groupId', 'userId'], { unique: true })
|
||||
}
|
||||
if (!db.objectStoreNames.contains('groupRequests')) {
|
||||
const store = db.createObjectStore('groupRequests', { keyPath: 'id' })
|
||||
createIndex(store, 'status', 'status')
|
||||
createIndex(store, 'createTime', 'createTime')
|
||||
}
|
||||
if (!db.objectStoreNames.contains('channels')) {
|
||||
const store = db.createObjectStore('channels', { keyPath: 'id' })
|
||||
createIndex(store, 'status', 'status')
|
||||
createIndex(store, 'sort', 'sort')
|
||||
}
|
||||
if (!db.objectStoreNames.contains('settings')) {
|
||||
db.createObjectStore('settings', { keyPath: 'key' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开 IM IndexedDB */
|
||||
// TODO @AI:是不是方法里,代码段的注释,是不是要增加下?
|
||||
function openDb(name: string): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(name, DB_SCHEMA_VERSION)
|
||||
request.onupgradeneeded = () => upgradeSchema(request.result)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
/** 初始化当前用户 IM DB */
|
||||
// TODO @AI:是不是方法里,代码段的注释,是不是要增加下?
|
||||
export async function initDb(): Promise<void> {
|
||||
const userId = getCurrentUserId()
|
||||
if (!Number.isFinite(userId) || userId <= 0) {
|
||||
throw new Error('当前用户不存在,无法初始化 IM DB')
|
||||
}
|
||||
if (currentDb && currentUserId === userId) {
|
||||
return
|
||||
}
|
||||
currentDb?.close()
|
||||
currentSession++
|
||||
currentUserId = userId
|
||||
currentDb = await openDb(getDbName(userId))
|
||||
}
|
||||
|
||||
/** 关闭当前 IM DB session */
|
||||
// TODO @AI:是不是需要被调用下???
|
||||
export function closeDbSession() {
|
||||
currentSession++
|
||||
currentDb?.close()
|
||||
currentDb = null
|
||||
currentUserId = null
|
||||
}
|
||||
|
||||
/** 获取当前 IM DB */
|
||||
function getRawDb(): IDBDatabase {
|
||||
if (!currentDb) {
|
||||
throw new Error('IM DB 未初始化')
|
||||
}
|
||||
return currentDb
|
||||
}
|
||||
|
||||
/** 校验单次写入 session */
|
||||
function guardSession(session: number) {
|
||||
if (!isCurrentDbSession(session)) {
|
||||
throw new Error('IM DB session 已失效')
|
||||
}
|
||||
}
|
||||
|
||||
/** 克隆可入库对象 */
|
||||
function toDbValue<T>(value: T): T {
|
||||
return toRaw(value) as T
|
||||
}
|
||||
|
||||
// TODO @AI:我们讨论下,DbWrapper 会不会有点怪?
|
||||
// TODO @AI:是不是改成 selectOne、selectAll、selectList、insert、update、delete、save 这种。
|
||||
class DbWrapper {
|
||||
/** 获取单条记录 */
|
||||
// TODO @AI:是不是不用缩写,tx 改成 transaction 更好理解;
|
||||
async get<T>(storeName: DbStoreName, key: IDBValidKey, tx?: DbTx): Promise<T | undefined> {
|
||||
if (tx) {
|
||||
return requestToPromise<T | undefined>(tx.objectStore(storeName).get(key))
|
||||
}
|
||||
return this.transaction<T | undefined>([storeName], 'readonly', (innerTx) =>
|
||||
this.get<T>(storeName, key, innerTx)
|
||||
)
|
||||
}
|
||||
|
||||
/** 获取 store 全量记录 */
|
||||
async getAll<T>(storeName: DbStoreName, tx?: DbTx): Promise<T[]> {
|
||||
if (tx) {
|
||||
return requestToPromise<T[]>(tx.objectStore(storeName).getAll())
|
||||
}
|
||||
return this.transaction<T[]>([storeName], 'readonly', (innerTx) =>
|
||||
this.getAll<T>(storeName, innerTx)
|
||||
)
|
||||
}
|
||||
|
||||
/** 按唯一索引获取单条记录 */
|
||||
async getByIndex<T>(
|
||||
storeName: DbStoreName,
|
||||
indexName: string,
|
||||
query: IDBValidKey | IDBKeyRange,
|
||||
tx?: DbTx
|
||||
): Promise<T | undefined> {
|
||||
if (tx) {
|
||||
return requestToPromise<T | undefined>(tx.objectStore(storeName).index(indexName).get(query))
|
||||
}
|
||||
return this.transaction<T | undefined>([storeName], 'readonly', (innerTx) =>
|
||||
this.getByIndex<T>(storeName, indexName, query, innerTx)
|
||||
)
|
||||
}
|
||||
|
||||
/** 按索引获取记录列表 */
|
||||
async getAllByIndex<T>(
|
||||
storeName: DbStoreName,
|
||||
indexName: string,
|
||||
query?: IDBValidKey | IDBKeyRange,
|
||||
tx?: DbTx
|
||||
): Promise<T[]> {
|
||||
if (tx) {
|
||||
return requestToPromise<T[]>(tx.objectStore(storeName).index(indexName).getAll(query))
|
||||
}
|
||||
return this.transaction<T[]>([storeName], 'readonly', (innerTx) =>
|
||||
this.getAllByIndex<T>(storeName, indexName, query, innerTx)
|
||||
)
|
||||
}
|
||||
|
||||
/** 写入记录 */
|
||||
async put<T>(storeName: DbStoreName, value: T, tx?: DbTx): Promise<void> {
|
||||
if (tx) {
|
||||
await requestToPromise(tx.objectStore(storeName).put(toDbValue(value)))
|
||||
return
|
||||
}
|
||||
await this.transaction([storeName], 'readwrite', (innerTx) =>
|
||||
this.put(storeName, value, innerTx)
|
||||
)
|
||||
}
|
||||
|
||||
/** 删除记录 */
|
||||
async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTx): Promise<void> {
|
||||
if (tx) {
|
||||
await requestToPromise(tx.objectStore(storeName).delete(key))
|
||||
return
|
||||
}
|
||||
await this.transaction([storeName], 'readwrite', (innerTx) =>
|
||||
this.delete(storeName, key, innerTx)
|
||||
)
|
||||
}
|
||||
|
||||
/** 按索引删除记录 */
|
||||
async deleteByIndex(
|
||||
storeName: DbStoreName,
|
||||
indexName: string,
|
||||
query: IDBValidKey | IDBKeyRange,
|
||||
tx?: DbTx
|
||||
): Promise<void> {
|
||||
if (!tx) {
|
||||
await this.transaction([storeName], 'readwrite', (innerTx) =>
|
||||
this.deleteByIndex(storeName, indexName, query, innerTx)
|
||||
)
|
||||
return
|
||||
}
|
||||
const index = tx.objectStore(storeName).index(indexName)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = index.openCursor(query)
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result
|
||||
if (!cursor) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
cursor.delete()
|
||||
cursor.continue()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 执行事务 */
|
||||
// TODO @AI:方法里的方法段的注释,需要写么?
|
||||
async transaction<T>(
|
||||
storeNames: DbStoreName[],
|
||||
mode: IDBTransactionMode,
|
||||
runner: (tx: DbTx) => Promise<T>
|
||||
): Promise<T> {
|
||||
const session = getDbSession()
|
||||
guardSession(session)
|
||||
const tx = getRawDb().transaction(storeNames, mode)
|
||||
const done = transactionDone(tx)
|
||||
let result: T
|
||||
try {
|
||||
result = await runner(tx)
|
||||
} catch (e) {
|
||||
// TODO @AI:这种 logger error 要打印么?
|
||||
try {
|
||||
tx.abort()
|
||||
} catch {}
|
||||
await done.catch(() => undefined)
|
||||
throw e
|
||||
}
|
||||
await done
|
||||
guardSession(session)
|
||||
return result
|
||||
}
|
||||
|
||||
/** 按会话分页获取消息 */
|
||||
// TODO @AI:这个方法里,代码段的注释,是不是要增加下?比如说,分页的逻辑,游标的逻辑,等等;
|
||||
// TODO @AI:项目里,一般方法名,是使用 getListByXXXX;
|
||||
async getMessagesByConversation(
|
||||
clientConversationId: string,
|
||||
options?: { beforeSendTime?: number; limit?: number },
|
||||
tx?: DbTx
|
||||
): Promise<MessageDO[]> {
|
||||
const limit = options?.limit ?? 50
|
||||
const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER
|
||||
const range = IDBKeyRange.bound(
|
||||
[clientConversationId, 0],
|
||||
[clientConversationId, upper],
|
||||
false,
|
||||
true
|
||||
)
|
||||
const read = async (innerTx: DbTx): Promise<MessageDO[]> => {
|
||||
const index = innerTx.objectStore('messages').index('clientConversationId+sendTime')
|
||||
const out: MessageDO[] = []
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = index.openCursor(range, 'prev')
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result
|
||||
if (!cursor || out.length >= limit) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
out.push(cursor.value as MessageDO)
|
||||
cursor.continue()
|
||||
}
|
||||
})
|
||||
return out.reverse()
|
||||
}
|
||||
if (tx) {
|
||||
return read(tx)
|
||||
}
|
||||
return this.transaction<MessageDO[]>(['messages'], 'readonly', read)
|
||||
}
|
||||
|
||||
/** 读取设置 */
|
||||
async getSetting<T>(key: string, tx?: DbTx): Promise<T | undefined> {
|
||||
const item = await this.get<SettingDO<T>>('settings', key, tx)
|
||||
return item?.value
|
||||
}
|
||||
|
||||
/** 写入设置 */
|
||||
async setSetting<T>(key: string, value: T, tx?: DbTx): Promise<void> {
|
||||
await this.put<SettingDO<T>>('settings', { key, value, updateTime: Date.now() }, tx)
|
||||
}
|
||||
}
|
||||
|
||||
const dbWrapper = new DbWrapper()
|
||||
|
||||
/** 获取当前 IM DB wrapper */
|
||||
export function getDb(): DbWrapper {
|
||||
return dbWrapper
|
||||
}
|
||||
|
||||
/** 当前用户会话主键 */
|
||||
export function getClientConversationId(type: number, targetId: number): string {
|
||||
return `${type}:${targetId}`
|
||||
}
|
||||
|
||||
/** 解析当前用户会话主键 */
|
||||
export function parseClientConversationId(
|
||||
clientConversationId: string
|
||||
): { type: number; targetId: number } | null {
|
||||
const [typeText, targetIdText] = clientConversationId.split(':')
|
||||
const type = Number(typeText)
|
||||
const targetId = Number(targetIdText)
|
||||
if (!Number.isFinite(type) || !Number.isFinite(targetId) || targetId <= 0) {
|
||||
// TODO @AI:logger info?
|
||||
return null
|
||||
}
|
||||
return { type, targetId }
|
||||
}
|
||||
|
||||
/** 服务端消息主键 */
|
||||
export function getServerMessageKey(conversationType: number, id: number): string {
|
||||
return `${conversationType}:${id}`
|
||||
}
|
||||
|
||||
/** 客户端临时消息主键 */
|
||||
export function getClientMessageKey(clientMessageId: string): string {
|
||||
return `client:${clientMessageId}`
|
||||
}
|
||||
|
||||
/** 解析本地消息主键 */
|
||||
// TODO @AI:这个方法,貌似没调用;
|
||||
export function parseMessageKey(
|
||||
messageKey: string
|
||||
):
|
||||
| { kind: 'client'; clientMessageId: string }
|
||||
| { kind: 'server'; conversationType: number; id: number }
|
||||
| null {
|
||||
if (!messageKey) {
|
||||
return null
|
||||
}
|
||||
if (messageKey.startsWith('client:')) {
|
||||
const clientMessageId = messageKey.slice('client:'.length)
|
||||
return clientMessageId ? { kind: 'client', clientMessageId } : null
|
||||
}
|
||||
const [conversationTypeText, idText] = messageKey.split(':')
|
||||
const conversationType = Number(conversationTypeText)
|
||||
const id = Number(idText)
|
||||
if (!Number.isFinite(conversationType) || !Number.isFinite(id) || id <= 0) {
|
||||
return null
|
||||
}
|
||||
return { kind: 'server', conversationType, id }
|
||||
}
|
||||
|
||||
/** 更新消息拉取游标 */
|
||||
export async function setMessageMaxId(
|
||||
conversationType: number,
|
||||
maxId: number | undefined,
|
||||
tx?: DbTx
|
||||
): Promise<void> {
|
||||
if (!maxId) {
|
||||
return
|
||||
}
|
||||
let key: string
|
||||
switch (conversationType) {
|
||||
case ImConversationType.PRIVATE:
|
||||
key = 'privateMessageMaxId'
|
||||
break
|
||||
case ImConversationType.GROUP:
|
||||
key = 'groupMessageMaxId'
|
||||
break
|
||||
case ImConversationType.CHANNEL:
|
||||
key = 'channelMessageMaxId'
|
||||
break
|
||||
default:
|
||||
throw new Error(`未知 IM 会话类型:${conversationType}`)
|
||||
}
|
||||
const db = getDb()
|
||||
const current = (await db.getSetting<number>(key, tx)) || 0
|
||||
if (maxId > current) {
|
||||
await db.setSetting(key, maxId, tx)
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止当前 IM DB session */
|
||||
// TODO @AI:这里的注释,要写下;方法注释;
|
||||
export async function stopRequests(): Promise<void> {
|
||||
const [
|
||||
{ useMessageStoreWithOut },
|
||||
{ useConversationStoreWithOut },
|
||||
{ useFriendStoreWithOut },
|
||||
{ useGroupStoreWithOut },
|
||||
{ useChannelStoreWithOut },
|
||||
{ useGroupRequestStoreWithOut },
|
||||
{ useDraftStoreWithOut },
|
||||
{ useFaceStoreWithOut }
|
||||
] = await Promise.all([
|
||||
import('../home/store/messageStore'),
|
||||
import('../home/store/conversationStore'),
|
||||
import('../home/store/friendStore'),
|
||||
import('../home/store/groupStore'),
|
||||
import('../home/store/channelStore'),
|
||||
import('../home/store/groupRequestStore'),
|
||||
import('../home/store/draftStore'),
|
||||
import('../home/store/faceStore')
|
||||
])
|
||||
currentSession++
|
||||
useMessageStoreWithOut().clear()
|
||||
useConversationStoreWithOut().clear()
|
||||
useFriendStoreWithOut().clear()
|
||||
useGroupStoreWithOut().clear()
|
||||
useChannelStoreWithOut().clear()
|
||||
useGroupRequestStoreWithOut().reset()
|
||||
useDraftStoreWithOut().clear()
|
||||
useFaceStoreWithOut().reset()
|
||||
currentDb?.close()
|
||||
currentDb = null
|
||||
currentUserId = null
|
||||
}
|
||||
|
|
@ -414,7 +414,7 @@ function mapMessageToMergeItem(
|
|||
): MergeMessageItem {
|
||||
const snapshot = senderSnapshots.get(message.senderId)
|
||||
return {
|
||||
messageId: message.id,
|
||||
messageId: message.id || 0,
|
||||
senderId: message.senderId,
|
||||
senderNickname: snapshot?.nickname ?? String(message.senderId),
|
||||
senderAvatar: snapshot?.avatar ?? '',
|
||||
|
|
@ -555,7 +555,7 @@ export const removeQuotePayload = (content: string): string => {
|
|||
/** 由 Message 派生 QuoteMessage 用于乐观渲染;ack 后会被服务端权威版本覆盖 */
|
||||
export const buildQuoteFromMessage = (message: Message): QuoteMessage => {
|
||||
return {
|
||||
messageId: message.id,
|
||||
messageId: message.id || 0,
|
||||
senderId: message.senderId,
|
||||
type: message.type,
|
||||
content: removeQuotePayload(message.content)
|
||||
|
|
@ -882,7 +882,10 @@ export function resolveRtcCallTipSegments(message: {
|
|||
}
|
||||
if (message.type === ImMessageType.RTC_CALL_START) {
|
||||
return payload.inviterUserId
|
||||
? [tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)), tipText(' 发起了语音通话')]
|
||||
? [
|
||||
tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)),
|
||||
tipText(' 发起了语音通话')
|
||||
]
|
||||
: []
|
||||
}
|
||||
if (message.type === ImMessageType.RTC_CALL_END) {
|
||||
|
|
|
|||
|
|
@ -4,47 +4,23 @@ import { toRaw } from 'vue'
|
|||
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
|
||||
|
||||
/**
|
||||
* IM 模块的 IndexedDB 实例(localforage 优先 IndexedDB,自动降级到 WebSQL / localStorage)
|
||||
*
|
||||
* 为什么不用 localStorage 直接存:
|
||||
* 1. 配额:localStorage 整体上限 5~10MB,多会话长历史很容易撑爆
|
||||
* 2. 写放大:localStorage 必须按 key 整体写入,单次写就是 MB 级序列化阻塞主线程
|
||||
*
|
||||
* 配套策略:会话与消息按 key 分桶(见 StorageKeys),让单次变更只重写最小粒度的 key;
|
||||
* IndexedDB 默认配额一般是浏览器可用空间的 ~50%,远大于 localStorage,配合分桶才发挥效果
|
||||
* IM 模块本地缓存实例(localforage 优先 IndexedDB,自动降级到 WebSQL / localStorage)
|
||||
*/
|
||||
export const imStorage = localforage.createInstance({
|
||||
name: 'im',
|
||||
storeName: 'conversation',
|
||||
description: 'IM 会话索引与消息缓存'
|
||||
description: 'IM 本地缓存'
|
||||
})
|
||||
|
||||
/**
|
||||
* 存储 key 统一在此生成
|
||||
*
|
||||
* - 会话 / 好友 / 群相关业务数据走 imStorage(IndexedDB),key 都按 userId 分桶
|
||||
* - 好友 / 群相关业务数据走 imStorage(IndexedDB),key 都按 userId 分桶
|
||||
* - 轻量 UI 状态(侧边栏宽度)仍走 localStorage:体积小、跨 Tab 同步天然,没必要走 IndexedDB
|
||||
*
|
||||
* 所有业务 key 都注入 userId:多账号切换按用户隔离避免数据互串;账号切换时只清 in-memory、IDB 数据保留——回切旧账号能秒开,不浪费已下载好友 / 群 / 成员快照
|
||||
*/
|
||||
export const StorageKeys = {
|
||||
/**
|
||||
* 会话索引:游标 + 会话元数据(不含 messages),对应 ConversationStoreMeta
|
||||
*
|
||||
* 任何会话级元数据变更(top / silent / unread / 列表增删 / 排序)都会重写这一个 key;由于 messages 已剥离到独立 key,单次写体积稳定(仅元数据,量级 KB 级)
|
||||
*/
|
||||
conversationMeta: (userId: number | string) => `conversation:meta:${userId}`,
|
||||
/**
|
||||
* 单会话消息:按 (type, targetId) 分桶,存 Message[]
|
||||
*
|
||||
* - type:私聊 / 群聊(对齐 ImConversationType)
|
||||
* - targetId:私聊的对方 userId / 群聊的 groupId
|
||||
*
|
||||
* 每条消息变更只重写当前会话这一个 key,避免老方案"全量写所有会话所有消息"的写放大;软删除会话时由 conversationStore.removeConversationMessages 物理删除该 key,避免 orphan 残留
|
||||
*/
|
||||
conversationMessages: (userId: number | string, type: number, targetId: number) =>
|
||||
`conversation:messages:${userId}:${type}:${targetId}`,
|
||||
|
||||
/**
|
||||
* 输入框草稿整桶:Record<`${type}:${targetId}`, DraftSnapshot>
|
||||
*
|
||||
|
|
@ -59,8 +35,7 @@ export const StorageKeys = {
|
|||
/** 频道列表整桶;频道量级很小,整桶整写够用 */
|
||||
channels: (userId: number | string) => `channels:${userId}`,
|
||||
/** 单群成员,按 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) =>
|
||||
|
|
@ -81,11 +56,40 @@ export function getCurrentUserId(): number {
|
|||
|
||||
/** IDB 写入:fire-and-forget */
|
||||
export function setQuietly(key: string, value: unknown, errorLabel: string): void {
|
||||
// toRaw 拆 Vue / Pinia reactive Proxy——structuredClone 不接 Proxy 会抛 DataCloneError 静默丢盘
|
||||
const raw = value && typeof value === 'object' ? toRaw(value) : value
|
||||
const raw = toStorageValue(value)
|
||||
void imStorage.setItem(key, raw).catch((e) => console.warn(errorLabel, e))
|
||||
}
|
||||
|
||||
export function removeQuietly(key: string, errorLabel: string): void {
|
||||
void imStorage.removeItem(key).catch((e) => console.warn(errorLabel, e))
|
||||
}
|
||||
|
||||
/** 转换为 IndexedDB 可存储的数据 */
|
||||
// TODO @AI:后续,是不是可以删除掉?尽量使用 db.ts 对不对哈?
|
||||
function toStorageValue<T>(value: T, seen = new WeakMap<object, unknown>()): T {
|
||||
const raw = value && typeof value === 'object' ? toRaw(value) : value
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return raw
|
||||
}
|
||||
if (raw instanceof Date || raw instanceof Blob || raw instanceof ArrayBuffer) {
|
||||
return raw
|
||||
}
|
||||
if (seen.has(raw)) {
|
||||
return seen.get(raw) as T
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
const array: unknown[] = []
|
||||
seen.set(raw, array)
|
||||
raw.forEach((item) => array.push(toStorageValue(item, seen)))
|
||||
return array as T
|
||||
}
|
||||
const out: Record<string, unknown> = {}
|
||||
seen.set(raw, out)
|
||||
Object.entries(raw).forEach(([key, item]) => {
|
||||
if (typeof item === 'function' || typeof item === 'symbol') {
|
||||
return
|
||||
}
|
||||
out[key] = toStorageValue(item, seen)
|
||||
})
|
||||
return out as T
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue