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>
|
</ConversationPickerPanel>
|
||||||
|
|
||||||
<!-- 好友视图:选好友建群后发送 -->
|
<!-- 好友视图:选好友建群后发送 -->
|
||||||
<FriendPickerPanel
|
<FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" />
|
||||||
v-else
|
|
||||||
v-model:selected-ids="selectedFriendIds"
|
|
||||||
:friends="friends"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 好友视图的 dialog footer:建群并发送 -->
|
<!-- 好友视图的 dialog footer:建群并发送 -->
|
||||||
|
|
@ -119,11 +115,7 @@ import { useConversationStore } from '../../store/conversationStore'
|
||||||
import { useFriendStore } from '../../store/friendStore'
|
import { useFriendStore } from '../../store/friendStore'
|
||||||
import { useGroupStore } from '../../store/groupStore'
|
import { useGroupStore } from '../../store/groupStore'
|
||||||
import { useMessageSender } from '../../composables/useMessageSender'
|
import { useMessageSender } from '../../composables/useMessageSender'
|
||||||
import {
|
import { ImConversationType, ImMessageType, isGroupConversation } from '../../../utils/constants'
|
||||||
ImConversationType,
|
|
||||||
ImMessageType,
|
|
||||||
isGroupConversation
|
|
||||||
} from '../../../utils/constants'
|
|
||||||
import { getConversationKey } from '../../../utils/conversation'
|
import { getConversationKey } from '../../../utils/conversation'
|
||||||
import { buildDefaultGroupName } from '../../../utils/group'
|
import { buildDefaultGroupName } from '../../../utils/group'
|
||||||
import { serializeMessage, type CardTarget } from '../../../utils/message'
|
import { serializeMessage, type CardTarget } from '../../../utils/message'
|
||||||
|
|
@ -287,7 +279,6 @@ async function handleCreateGroupAndSend() {
|
||||||
name: group.name || name,
|
name: group.name || name,
|
||||||
avatar: group.avatar || '',
|
avatar: group.avatar || '',
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
messages: [],
|
|
||||||
lastContent: '',
|
lastContent: '',
|
||||||
lastSendTime: 0
|
lastSendTime: 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useMessage } from '@/hooks/web/useMessage'
|
||||||
import { isOpenableUrl } from '@/utils/url'
|
import { isOpenableUrl } from '@/utils/url'
|
||||||
|
|
||||||
import { useConversationStore } from '../store/conversationStore'
|
import { useConversationStore } from '../store/conversationStore'
|
||||||
|
import { useMessageStore } from '../store/messageStore'
|
||||||
import { useMessageSender } from './useMessageSender'
|
import { useMessageSender } from './useMessageSender'
|
||||||
import { useMuteOverlay } from './useMuteOverlay'
|
import { useMuteOverlay } from './useMuteOverlay'
|
||||||
import { ImMessageStatus, ImMessageType } from '../../utils/constants'
|
import { ImMessageStatus, ImMessageType } from '../../utils/constants'
|
||||||
|
|
@ -67,8 +68,7 @@ export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
|
||||||
},
|
},
|
||||||
[ImMessageType.VOICE]: {
|
[ImMessageType.VOICE]: {
|
||||||
kind: '语音',
|
kind: '语音',
|
||||||
build: (_file, url, context) =>
|
build: (_file, url, context) => ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
|
||||||
({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
|
|
||||||
extractResendContext: (oldContent) => {
|
extractResendContext: (oldContent) => {
|
||||||
const old = parseMessage<AudioMessage>(oldContent)
|
const old = parseMessage<AudioMessage>(oldContent)
|
||||||
return { voiceDuration: old?.duration ?? 0 }
|
return { voiceDuration: old?.duration ?? 0 }
|
||||||
|
|
@ -88,7 +88,8 @@ export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
|
||||||
extractResendContext: (oldContent) => {
|
extractResendContext: (oldContent) => {
|
||||||
const old = parseMessage<VideoMessage>(oldContent)
|
const old = parseMessage<VideoMessage>(oldContent)
|
||||||
// 旧 coverUrl 是 blob 说明上传期失败(cover 没传成功),不复用;真实 URL 直接复用,省一次封面上传
|
// 旧 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 {
|
return {
|
||||||
videoProbe: { duration: old?.duration, width: old?.width, height: old?.height },
|
videoProbe: { duration: old?.duration, width: old?.width, height: old?.height },
|
||||||
videoCoverUrl: reuseCover
|
videoCoverUrl: reuseCover
|
||||||
|
|
@ -155,6 +156,7 @@ export function ensureMediaSizeWithinLimit(
|
||||||
|
|
||||||
export const useMediaUploader = () => {
|
export const useMediaUploader = () => {
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const muteOverlay = useMuteOverlay()
|
const muteOverlay = useMuteOverlay()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
@ -177,7 +179,7 @@ export const useMediaUploader = () => {
|
||||||
const blobUrl = URL.createObjectURL(opts.file)
|
const blobUrl = URL.createObjectURL(opts.file)
|
||||||
const clientMessageId = opts.existingClientMessageId || generateClientMessageId()
|
const clientMessageId = opts.existingClientMessageId || generateClientMessageId()
|
||||||
if (opts.existingClientMessageId) {
|
if (opts.existingClientMessageId) {
|
||||||
conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
|
messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
|
||||||
content: opts.buildContent(blobUrl),
|
content: opts.buildContent(blobUrl),
|
||||||
status: ImMessageStatus.SENDING,
|
status: ImMessageStatus.SENDING,
|
||||||
uploadProgress: 0,
|
uploadProgress: 0,
|
||||||
|
|
@ -186,7 +188,6 @@ export const useMediaUploader = () => {
|
||||||
return { clientMessageId, blobUrl }
|
return { clientMessageId, blobUrl }
|
||||||
}
|
}
|
||||||
const placeholder: Message = {
|
const placeholder: Message = {
|
||||||
id: 0,
|
|
||||||
clientMessageId,
|
clientMessageId,
|
||||||
type: opts.type,
|
type: opts.type,
|
||||||
content: opts.buildContent(blobUrl),
|
content: opts.buildContent(blobUrl),
|
||||||
|
|
@ -198,7 +199,7 @@ export const useMediaUploader = () => {
|
||||||
uploadProgress: 0,
|
uploadProgress: 0,
|
||||||
_localFile: opts.file
|
_localFile: opts.file
|
||||||
}
|
}
|
||||||
conversationStore.insertMessage(
|
messageStore.insertMessage(
|
||||||
{
|
{
|
||||||
type: conversation.type,
|
type: conversation.type,
|
||||||
targetId: conversation.targetId,
|
targetId: conversation.targetId,
|
||||||
|
|
@ -221,7 +222,7 @@ export const useMediaUploader = () => {
|
||||||
targetId: number,
|
targetId: number,
|
||||||
clientMessageId: string
|
clientMessageId: string
|
||||||
): void => {
|
): void => {
|
||||||
conversationStore.patchMessage(conversationType, targetId, clientMessageId, {
|
messageStore.patchMessage(conversationType, targetId, clientMessageId, {
|
||||||
status: ImMessageStatus.FAILED,
|
status: ImMessageStatus.FAILED,
|
||||||
uploadProgress: undefined
|
uploadProgress: undefined
|
||||||
})
|
})
|
||||||
|
|
@ -244,7 +245,7 @@ export const useMediaUploader = () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastPercent = percent
|
lastPercent = percent
|
||||||
conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
|
messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
|
||||||
uploadProgress: percent
|
uploadProgress: percent
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -303,7 +304,7 @@ export const useMediaUploader = () => {
|
||||||
clientMessageId: string
|
clientMessageId: string
|
||||||
realContent: string
|
realContent: string
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
conversationStore.patchMessage(
|
messageStore.patchMessage(
|
||||||
opts.conversation.type,
|
opts.conversation.type,
|
||||||
opts.conversation.targetId,
|
opts.conversation.targetId,
|
||||||
opts.clientMessageId,
|
opts.clientMessageId,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
import { useConversationStore } from '../store/conversationStore'
|
import { useConversationStore } from '../store/conversationStore'
|
||||||
|
import { useMessageStore, type PulledMessageBatchItem } from '../store/messageStore'
|
||||||
import { useImWebSocketStore } from '../store/websocketStore'
|
import { useImWebSocketStore } from '../store/websocketStore'
|
||||||
import { useFriendStore } from '../store/friendStore'
|
import { useFriendStore } from '../store/friendStore'
|
||||||
import { getFriendDisplayName } from '../../utils/user'
|
import { getFriendDisplayName } from '../../utils/user'
|
||||||
|
|
@ -30,7 +31,7 @@ import {
|
||||||
MESSAGE_PRIVATE_READ_ENABLED
|
MESSAGE_PRIVATE_READ_ENABLED
|
||||||
} from '../../utils/config'
|
} from '../../utils/config'
|
||||||
import { buildChannelConversationStub } from '../../utils/channel'
|
import { buildChannelConversationStub } from '../../utils/channel'
|
||||||
import { getPrivateMessagePeerId } from '../../utils/message'
|
import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message'
|
||||||
import { getCurrentUserId } from '../../utils/storage'
|
import { getCurrentUserId } from '../../utils/storage'
|
||||||
import type { Message } from '../types'
|
import type { Message } from '../types'
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@ import type { Message } from '../types'
|
||||||
*/
|
*/
|
||||||
export const useMessagePuller = () => {
|
export const useMessagePuller = () => {
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
const wsStore = useImWebSocketStore()
|
const wsStore = useImWebSocketStore()
|
||||||
const friendStore = useFriendStore()
|
const friendStore = useFriendStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
|
@ -55,7 +57,11 @@ export const useMessagePuller = () => {
|
||||||
/** 判断请求是否被主动取消 */
|
/** 判断请求是否被主动取消 */
|
||||||
const isAbortError = (e: unknown): boolean => {
|
const isAbortError = (e: unknown): boolean => {
|
||||||
const error = e as { name?: string; code?: string; message?: string }
|
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 处调用方的样板 */
|
/** 私聊会话归属:自己发的算"发给 receiverId 的会话",否则算"发送方的会话";curry currentUserId 进闭包减少 3 处调用方的样板 */
|
||||||
|
|
@ -66,7 +72,7 @@ export const useMessagePuller = () => {
|
||||||
const convertPrivateMessage = (message: ImPrivateMessageRespVO): Message => {
|
const convertPrivateMessage = (message: ImPrivateMessageRespVO): Message => {
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
clientMessageId: message.clientMessageId || '',
|
clientMessageId: message.clientMessageId || generateClientMessageId(),
|
||||||
type: message.type,
|
type: message.type,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
status: message.status,
|
status: message.status,
|
||||||
|
|
@ -81,7 +87,7 @@ export const useMessagePuller = () => {
|
||||||
const convertGroupMessage = (message: ImGroupMessageRespVO): Message => {
|
const convertGroupMessage = (message: ImGroupMessageRespVO): Message => {
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
clientMessageId: message.clientMessageId || '',
|
clientMessageId: message.clientMessageId || generateClientMessageId(),
|
||||||
type: message.type,
|
type: message.type,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
status: message.status,
|
status: message.status,
|
||||||
|
|
@ -100,7 +106,8 @@ export const useMessagePuller = () => {
|
||||||
const convertChannelMessage = (message: ImChannelMessageRespVO): Message => {
|
const convertChannelMessage = (message: ImChannelMessageRespVO): Message => {
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
clientMessageId: '',
|
// TODO @AI:是不是都需要使用 message 的 clientMessageId;注意,后端也需要有 clientMessageId;
|
||||||
|
clientMessageId: generateClientMessageId(),
|
||||||
type: message.type,
|
type: message.type,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
status: message.status ?? ImMessageStatus.UNREAD,
|
status: message.status ?? ImMessageStatus.UNREAD,
|
||||||
|
|
@ -161,7 +168,8 @@ export const useMessagePuller = () => {
|
||||||
const isPrivate = conversationType === ImConversationType.PRIVATE
|
const isPrivate = conversationType === ImConversationType.PRIVATE
|
||||||
const isChannel = conversationType === ImConversationType.CHANNEL
|
const isChannel = conversationType === ImConversationType.CHANNEL
|
||||||
const size = isPrivate ? MESSAGE_PRIVATE_PULL_SIZE : MESSAGE_GROUP_PULL_SIZE
|
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) {
|
while (true) {
|
||||||
if (!isStillValid()) {
|
if (!isStillValid()) {
|
||||||
return
|
return
|
||||||
|
|
@ -182,26 +190,30 @@ export const useMessagePuller = () => {
|
||||||
break
|
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 找得到
|
// 后端按 id 升序返回,且信号 id 一定 > 原消息 id(先更新 status 再插信号),所以原消息一定先到、recallMessage 找得到
|
||||||
for (const raw of list) {
|
for (const raw of list) {
|
||||||
if (isChannel) {
|
if (isChannel) {
|
||||||
const message = raw as ImChannelMessageRespVO
|
const message = raw as ImChannelMessageRespVO
|
||||||
conversationStore.insertMessage(
|
batchItems.push({
|
||||||
convertChannelConversation(message),
|
kind: 'insert',
|
||||||
convertChannelMessage(message)
|
conversationInfo: convertChannelConversation(message),
|
||||||
)
|
message: convertChannelMessage(message)
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (isPrivate) {
|
if (isPrivate) {
|
||||||
const message = raw as ImPrivateMessageRespVO
|
const message = raw as ImPrivateMessageRespVO
|
||||||
// 特殊:撤回消息的处理
|
// 特殊:撤回消息的处理
|
||||||
if (message.type === ImMessageType.RECALL) {
|
if (message.type === ImMessageType.RECALL) {
|
||||||
conversationStore.recallMessage(
|
batchItems.push({
|
||||||
ImConversationType.PRIVATE,
|
kind: 'recall',
|
||||||
getPrivatePeerId(message),
|
conversationType: ImConversationType.PRIVATE,
|
||||||
message.content
|
targetId: getPrivatePeerId(message),
|
||||||
)
|
recallSignalContent: message.content
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 特殊:离线 pull 期间入库的 FRIEND_* 帧(目前仅 FRIEND_ADD persistent=true)也要走好友数据分发,
|
// 特殊:离线 pull 期间入库的 FRIEND_* 帧(目前仅 FRIEND_ADD persistent=true)也要走好友数据分发,
|
||||||
|
|
@ -214,35 +226,40 @@ export const useMessagePuller = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 其它消息正常入会话消息列表
|
// 其它消息正常入会话消息列表
|
||||||
conversationStore.insertMessage(
|
batchItems.push({
|
||||||
convertPrivateConversation(message),
|
kind: 'insert',
|
||||||
convertPrivateMessage(message)
|
conversationInfo: convertPrivateConversation(message),
|
||||||
)
|
message: convertPrivateMessage(message)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
const message = raw as ImGroupMessageRespVO
|
const message = raw as ImGroupMessageRespVO
|
||||||
// 特殊:撤回消息的处理
|
// 特殊:撤回消息的处理
|
||||||
if (message.type === ImMessageType.RECALL) {
|
if (message.type === ImMessageType.RECALL) {
|
||||||
conversationStore.recallMessage(
|
batchItems.push({
|
||||||
ImConversationType.GROUP,
|
kind: 'recall',
|
||||||
message.groupId,
|
conversationType: ImConversationType.GROUP,
|
||||||
message.content
|
targetId: message.groupId,
|
||||||
)
|
recallSignalContent: message.content
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 其它消息正常入会话消息列表
|
// 其它消息正常入会话消息列表
|
||||||
conversationStore.insertMessage(
|
batchItems.push({
|
||||||
convertGroupConversation(message),
|
kind: 'insert',
|
||||||
convertGroupMessage(message)
|
conversationInfo: convertGroupConversation(message),
|
||||||
)
|
message: convertGroupMessage(message)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 游标推进到本批最大 id,与后端返回顺序无关;无有效 id 直接 break 避免死翻同一批
|
// 游标推进到本批最大 id,与后端返回顺序无关;无有效 id 直接 break 避免死翻同一批
|
||||||
const validIds = list.map((message) => message.id).filter((id): id is number => id != null)
|
const validIds = list.map((message) => message.id).filter((id): id is number => id != null)
|
||||||
if (validIds.length === 0) {
|
if (validIds.length === 0) {
|
||||||
|
await messageStore.insertPulledBatch(batchItems, conversationType)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const nextMinId = Math.max(...validIds)
|
const nextMinId = Math.max(...validIds)
|
||||||
|
await messageStore.insertPulledBatch(batchItems, conversationType, nextMinId)
|
||||||
// 游标没前进就停:当前后端契约是 id > minId,理论不会出现;防御后端契约变更或边界数据死翻
|
// 游标没前进就停:当前后端契约是 id > minId,理论不会出现;防御后端契约变更或边界数据死翻
|
||||||
if (nextMinId <= minId) {
|
if (nextMinId <= minId) {
|
||||||
break
|
break
|
||||||
|
|
@ -311,21 +328,21 @@ export const useMessagePuller = () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
pullByType(
|
pullByType(
|
||||||
ImConversationType.PRIVATE,
|
ImConversationType.PRIVATE,
|
||||||
conversationStore.privateMessageMaxId,
|
messageStore.privateMessageMaxId,
|
||||||
startEpoch,
|
startEpoch,
|
||||||
startUserId,
|
startUserId,
|
||||||
abortController.signal
|
abortController.signal
|
||||||
),
|
),
|
||||||
pullByType(
|
pullByType(
|
||||||
ImConversationType.GROUP,
|
ImConversationType.GROUP,
|
||||||
conversationStore.groupMessageMaxId,
|
messageStore.groupMessageMaxId,
|
||||||
startEpoch,
|
startEpoch,
|
||||||
startUserId,
|
startUserId,
|
||||||
abortController.signal
|
abortController.signal
|
||||||
),
|
),
|
||||||
pullByType(
|
pullByType(
|
||||||
ImConversationType.CHANNEL,
|
ImConversationType.CHANNEL,
|
||||||
conversationStore.channelMessageMaxId,
|
messageStore.channelMessageMaxId,
|
||||||
startEpoch,
|
startEpoch,
|
||||||
startUserId,
|
startUserId,
|
||||||
abortController.signal
|
abortController.signal
|
||||||
|
|
@ -369,12 +386,15 @@ export const useMessagePuller = () => {
|
||||||
const active = conversationStore.activeConversation
|
const active = conversationStore.activeConversation
|
||||||
if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) {
|
if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) {
|
||||||
try {
|
try {
|
||||||
const maxReadId = await apiGetPrivateMaxReadMessageId(active.targetId, abortController.signal)
|
const maxReadId = await apiGetPrivateMaxReadMessageId(
|
||||||
|
active.targetId,
|
||||||
|
abortController.signal
|
||||||
|
)
|
||||||
if (!isCurrentPull()) {
|
if (!isCurrentPull()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (maxReadId) {
|
if (maxReadId) {
|
||||||
conversationStore.applyReadReceipt({
|
messageStore.applyReadReceipt({
|
||||||
conversationType: ImConversationType.PRIVATE,
|
conversationType: ImConversationType.PRIVATE,
|
||||||
targetId: active.targetId,
|
targetId: active.targetId,
|
||||||
privateReadMaxId: maxReadId
|
privateReadMaxId: maxReadId
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useConversationStore } from '../store/conversationStore'
|
import { useConversationStore } from '../store/conversationStore'
|
||||||
|
import { useMessageStore } from '../store/messageStore'
|
||||||
import {
|
import {
|
||||||
sendPrivateMessage as apiSendPrivateMessage,
|
sendPrivateMessage as apiSendPrivateMessage,
|
||||||
readPrivateMessages as apiReadPrivateMessages,
|
readPrivateMessages as apiReadPrivateMessages,
|
||||||
|
|
@ -20,6 +21,7 @@ import {
|
||||||
} from '../../utils/message'
|
} from '../../utils/message'
|
||||||
import { ImMessageType, ImMessageStatus, ImConversationType } from '../../utils/constants'
|
import { ImMessageType, ImMessageStatus, ImConversationType } from '../../utils/constants'
|
||||||
import { MESSAGE_PRIVATE_READ_ENABLED, MESSAGE_GROUP_READ_ENABLED } from '../../utils/config'
|
import { MESSAGE_PRIVATE_READ_ENABLED, MESSAGE_GROUP_READ_ENABLED } from '../../utils/config'
|
||||||
|
import { getClientConversationId } from '../../utils/db'
|
||||||
import type { Conversation, Message } from '../types'
|
import type { Conversation, Message } from '../types'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
|
|
||||||
|
|
@ -57,9 +59,10 @@ interface SendExtOptions {
|
||||||
*/
|
*/
|
||||||
export const useMessageSender = () => {
|
export const useMessageSender = () => {
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
/**构造本地乐观消息对象(id=0 表示尚未拿到服务端消息 id) */
|
/** 构造本地乐观消息对象 */
|
||||||
const buildLocalMessage = (opts: {
|
const buildLocalMessage = (opts: {
|
||||||
clientMessageId: string
|
clientMessageId: string
|
||||||
content: string
|
content: string
|
||||||
|
|
@ -68,7 +71,6 @@ export const useMessageSender = () => {
|
||||||
atUserIds?: number[]
|
atUserIds?: number[]
|
||||||
}): Message => {
|
}): Message => {
|
||||||
return {
|
return {
|
||||||
id: 0,
|
|
||||||
clientMessageId: opts.clientMessageId,
|
clientMessageId: opts.clientMessageId,
|
||||||
type: opts.type,
|
type: opts.type,
|
||||||
content: opts.content,
|
content: opts.content,
|
||||||
|
|
@ -109,10 +111,10 @@ export const useMessageSender = () => {
|
||||||
clientMessageId = options.existingClientMessageId
|
clientMessageId = options.existingClientMessageId
|
||||||
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
|
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
|
||||||
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
|
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
|
||||||
const targetConversation = conversationStore.getConversation(conversation.type, realTarget)
|
// TODO @AI:尽量不要 m 缩写,全称
|
||||||
const stillExists = targetConversation?.messages.some(
|
const stillExists = messageStore
|
||||||
(m) => m.clientMessageId === clientMessageId
|
.getMessageList(conversation.type, realTarget)
|
||||||
)
|
.some((m) => m.clientMessageId === clientMessageId && !m._ackMerging)
|
||||||
if (!stillExists) {
|
if (!stillExists) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +133,7 @@ export const useMessageSender = () => {
|
||||||
name: conversation.name || String(realTarget),
|
name: conversation.name || String(realTarget),
|
||||||
avatar: conversation.avatar || ''
|
avatar: conversation.avatar || ''
|
||||||
}
|
}
|
||||||
conversationStore.insertMessage(conversationInfo, message)
|
messageStore.insertMessage(conversationInfo, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 UNREAD,失败更新为 FAILED
|
// 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 UNREAD,失败更新为 FAILED
|
||||||
|
|
@ -143,7 +145,7 @@ export const useMessageSender = () => {
|
||||||
type,
|
type,
|
||||||
content
|
content
|
||||||
})
|
})
|
||||||
conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, {
|
void messageStore.ackMessage(conversation.type, realTarget, clientMessageId, {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
sendTime: new Date(data.sendTime).getTime(),
|
sendTime: new Date(data.sendTime).getTime(),
|
||||||
status: data.status,
|
status: data.status,
|
||||||
|
|
@ -158,7 +160,7 @@ export const useMessageSender = () => {
|
||||||
atUserIds: options?.atUserIds,
|
atUserIds: options?.atUserIds,
|
||||||
receipt: options?.receipt
|
receipt: options?.receipt
|
||||||
})
|
})
|
||||||
conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, {
|
void messageStore.ackMessage(conversation.type, realTarget, clientMessageId, {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
sendTime: new Date(data.sendTime).getTime(),
|
sendTime: new Date(data.sendTime).getTime(),
|
||||||
status: data.status,
|
status: data.status,
|
||||||
|
|
@ -170,7 +172,7 @@ export const useMessageSender = () => {
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[IM] 消息发送失败', { type, realTarget, clientMessageId }, e)
|
console.error('[IM] 消息发送失败', { type, realTarget, clientMessageId }, e)
|
||||||
conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, {
|
void messageStore.ackMessage(conversation.type, realTarget, clientMessageId, {
|
||||||
status: ImMessageStatus.FAILED
|
status: ImMessageStatus.FAILED
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
|
|
@ -195,7 +197,7 @@ export const useMessageSender = () => {
|
||||||
* 2. 此处不做乐观撤回,避免网络失败后状态不可回退
|
* 2. 此处不做乐观撤回,避免网络失败后状态不可回退
|
||||||
*/
|
*/
|
||||||
const recall = async (message: Message) => {
|
const recall = async (message: Message) => {
|
||||||
// 参数校验:本地占位消息(id=0)不能撤回
|
// 参数校验:本地占位消息不能撤回
|
||||||
if (!message.id) {
|
if (!message.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +217,7 @@ export const useMessageSender = () => {
|
||||||
/**
|
/**
|
||||||
* 触发当前会话的已读上报(切会话 / 进入页面时调用)
|
* 触发当前会话的已读上报(切会话 / 进入页面时调用)
|
||||||
* 1. 本端立刻清未读数;服务端回包成功后再做持久化
|
* 1. 本端立刻清未读数;服务端回包成功后再做持久化
|
||||||
* 2. 已读位置取会话内最大真实消息 id(id=0 的本地发送中消息跳过)
|
* 2. 已读位置取会话内最大真实消息 id(本地发送中消息跳过)
|
||||||
*/
|
*/
|
||||||
const readActive = async () => {
|
const readActive = async () => {
|
||||||
const conversation = conversationStore.activeConversation
|
const conversation = conversationStore.activeConversation
|
||||||
|
|
@ -223,11 +225,12 @@ export const useMessageSender = () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 本地标记已读:未读数清零 + 消息状态更新为 READ(UI 立刻响应)
|
// 本地标记已读:未读数清零 + 消息状态更新为 READ(UI 立刻响应)
|
||||||
conversationStore.markActiveAsRead()
|
conversationStore.markConversationAsRead(conversation.type, conversation.targetId)
|
||||||
const maxMessageId = conversationStore.getActiveMessages.reduce<number>(
|
messageStore.markConversationMessagesRead(conversation)
|
||||||
(max, m) => (m.id > max ? m.id : max),
|
// TODO @AI:message;不要用 m;
|
||||||
0
|
const maxMessageId = messageStore
|
||||||
)
|
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
|
||||||
|
.reduce<number>((max, m) => (m.id && m.id > max ? m.id : max), 0)
|
||||||
if (!maxMessageId) {
|
if (!maxMessageId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -283,7 +286,7 @@ export const useMessageSender = () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// applyReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ
|
// applyReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ
|
||||||
conversationStore.applyReadReceipt({
|
messageStore.applyReadReceipt({
|
||||||
conversationType: ImConversationType.PRIVATE,
|
conversationType: ImConversationType.PRIVATE,
|
||||||
targetId: peerId,
|
targetId: peerId,
|
||||||
privateReadMaxId: maxReadId
|
privateReadMaxId: maxReadId
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export type MuteOverlayInfo = { text: string; icon: string }
|
||||||
* 改成模块级共享后所有订阅者共用一份 setInterval,订阅数清零时也清掉 timer,避免内存与时钟漂移
|
* 改成模块级共享后所有订阅者共用一份 setInterval,订阅数清零时也清掉 timer,避免内存与时钟漂移
|
||||||
*/
|
*/
|
||||||
const sharedNow = ref(Date.now())
|
const sharedNow = ref(Date.now())
|
||||||
let sharedTickTimer: ReturnType<typeof setInterval> | null = null
|
let sharedTickTimer: number | null = null
|
||||||
let subscriberCount = 0
|
let subscriberCount = 0
|
||||||
|
|
||||||
function subscribeNowTick(): void {
|
function subscribeNowTick(): void {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
import { useAppStore } from '@/store/modules/app'
|
||||||
import { useConversationStore } from './store/conversationStore'
|
import { useConversationStore } from './store/conversationStore'
|
||||||
|
import { useMessageStore } from './store/messageStore'
|
||||||
import { useImWebSocketStore } from './store/websocketStore'
|
import { useImWebSocketStore } from './store/websocketStore'
|
||||||
import { useFriendStore } from './store/friendStore'
|
import { useFriendStore } from './store/friendStore'
|
||||||
import { useGroupStore } from './store/groupStore'
|
import { useGroupStore } from './store/groupStore'
|
||||||
|
|
@ -46,6 +47,7 @@ import { useMessageSender } from './composables/useMessageSender'
|
||||||
import { useVoicePlayer } from './composables/useVoicePlayer'
|
import { useVoicePlayer } from './composables/useVoicePlayer'
|
||||||
import { ImConversationType } from '../utils/constants'
|
import { ImConversationType } from '../utils/constants'
|
||||||
import { StorageKeys } from '../utils/storage'
|
import { StorageKeys } from '../utils/storage'
|
||||||
|
import { initDb, stopRequests } from '../utils/db'
|
||||||
import type { Conversation } from './types'
|
import type { Conversation } from './types'
|
||||||
import ToolBar from './components/ToolBar.vue'
|
import ToolBar from './components/ToolBar.vue'
|
||||||
import UserInfoCard from './components/user/UserInfoCard.vue'
|
import UserInfoCard from './components/user/UserInfoCard.vue'
|
||||||
|
|
@ -58,6 +60,7 @@ defineOptions({ name: 'ImIndex' })
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
const webSocketStore = useImWebSocketStore()
|
const webSocketStore = useImWebSocketStore()
|
||||||
const friendStore = useFriendStore()
|
const friendStore = useFriendStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
|
@ -81,9 +84,12 @@ onMounted(async () => {
|
||||||
// 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
// 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
||||||
conversationStore.loading = true
|
conversationStore.loading = true
|
||||||
try {
|
try {
|
||||||
|
// TODO @AI:这里要写个注释???!!!
|
||||||
|
await initDb()
|
||||||
// 1.2 五个 store 并发从 IDB 读取本地缓存(loadConversations / loadDrafts 返回 void;load{Friends,Groups,Channels} 返回是否命中缓存)
|
// 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(),
|
conversationStore.loadConversations(),
|
||||||
|
messageStore.loadCursors(),
|
||||||
friendStore.loadFriends(),
|
friendStore.loadFriends(),
|
||||||
groupStore.loadGroups(),
|
groupStore.loadGroups(),
|
||||||
draftStore.loadDrafts(),
|
draftStore.loadDrafts(),
|
||||||
|
|
@ -162,6 +168,8 @@ onUnmounted(() => {
|
||||||
// 模块级单例 audio 不会随视图卸载自动停,主动停掉避免切路由后语音继续响
|
// 模块级单例 audio 不会随视图卸载自动停,主动停掉避免切路由后语音继续响
|
||||||
voicePlayer.stop()
|
voicePlayer.stop()
|
||||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||||
|
// TODO @AI:写个注释?!
|
||||||
|
void stopRequests()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -157,13 +157,14 @@ const groups = computed<GroupLite[]>(() =>
|
||||||
watch(
|
watch(
|
||||||
friends,
|
friends,
|
||||||
(list) => {
|
(list) => {
|
||||||
if (selection.value?.type !== 'friend') {
|
const selected = selection.value
|
||||||
|
if (selected?.type !== 'friend') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const fresh = list.find((friend) => friend.id === selection.value!.friend.id)
|
const fresh = list.find((friend) => friend.id === selected.friend.id)
|
||||||
if (!fresh) {
|
if (!fresh) {
|
||||||
selection.value = null
|
selection.value = null
|
||||||
} else if (fresh !== selection.value.friend) {
|
} else if (fresh !== selected.friend) {
|
||||||
selection.value = { type: 'friend', friend: fresh }
|
selection.value = { type: 'friend', friend: fresh }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -172,13 +173,14 @@ watch(
|
||||||
watch(
|
watch(
|
||||||
groups,
|
groups,
|
||||||
(list) => {
|
(list) => {
|
||||||
if (selection.value?.type !== 'group') {
|
const selected = selection.value
|
||||||
|
if (selected?.type !== 'group') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const fresh = list.find((group) => group.id === selection.value!.group.id)
|
const fresh = list.find((group) => group.id === selected.group.id)
|
||||||
if (!fresh) {
|
if (!fresh) {
|
||||||
selection.value = null
|
selection.value = null
|
||||||
} else if (fresh !== selection.value.group) {
|
} else if (fresh !== selected.group) {
|
||||||
selection.value = { type: 'group', group: fresh }
|
selection.value = { type: 'group', group: fresh }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -187,13 +189,14 @@ watch(
|
||||||
watch(
|
watch(
|
||||||
friendRequests,
|
friendRequests,
|
||||||
(list) => {
|
(list) => {
|
||||||
if (selection.value?.type !== 'request') {
|
const selected = selection.value
|
||||||
|
if (selected?.type !== 'request') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const fresh = list.find((request) => request.id === selection.value!.request.id)
|
const fresh = list.find((request) => request.id === selected.request.id)
|
||||||
if (!fresh) {
|
if (!fresh) {
|
||||||
selection.value = null
|
selection.value = null
|
||||||
} else if (fresh !== selection.value.request) {
|
} else if (fresh !== selected.request) {
|
||||||
selection.value = { type: 'request', request: fresh }
|
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 { useMessage } from '@/hooks/web/useMessage'
|
||||||
|
|
||||||
import { useConversationStore } from '@/views/im/home/store/conversationStore'
|
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 { useMessageMultiSelect } from '@/views/im/home/composables/useMessageMultiSelect'
|
||||||
import { ImForwardMode, isNormalMessage } from '@/views/im/utils/constants'
|
import { ImForwardMode, isNormalMessage } from '@/views/im/utils/constants'
|
||||||
|
import { getClientConversationId } from '@/views/im/utils/db'
|
||||||
import type { Message } from '@/views/im/home/types'
|
import type { Message } from '@/views/im/home/types'
|
||||||
import { IM_FORWARD_DIALOG_KEY } from '../message/forward/keys'
|
import { IM_FORWARD_DIALOG_KEY } from '../message/forward/keys'
|
||||||
|
|
||||||
defineOptions({ name: 'ImMessageMultiSelectBar' })
|
defineOptions({ name: 'ImMessageMultiSelectBar' })
|
||||||
|
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY)
|
const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY)
|
||||||
const multiSelect = useMessageMultiSelect()
|
const multiSelect = useMessageMultiSelect()
|
||||||
|
|
@ -66,16 +69,16 @@ const multiSelect = useMessageMultiSelect()
|
||||||
/** 选中条数 */
|
/** 选中条数 */
|
||||||
const selectedCount = computed(() => multiSelect.state.selectedClientMessageIds.length)
|
const selectedCount = computed(() => multiSelect.state.selectedClientMessageIds.length)
|
||||||
|
|
||||||
/** 当前会话内已选消息;conversation.messages 已按 sendTime 升序,filter 保序无需再 sort;isNormalMessage 过滤掉 RECALL / 系统事件,与 MessageItem.canForward 对齐 */
|
/** 当前会话内已选消息 */
|
||||||
function getSelectedMessages(): Message[] {
|
function getSelectedMessages(): Message[] {
|
||||||
const conversation = conversationStore.activeConversation
|
const conversation = conversationStore.activeConversation
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const ids = multiSelect.selectedIdSet.value
|
const ids = multiSelect.selectedIdSet.value
|
||||||
return conversation.messages.filter(
|
return messageStore
|
||||||
(message) => ids.has(message.clientMessageId) && isNormalMessage(message.type)
|
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
|
||||||
)
|
.filter((message) => ids.has(message.clientMessageId) && isNormalMessage(message.type))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 逐条转发:开 ForwardDialog 单条模式 */
|
/** 逐条转发:开 ForwardDialog 单条模式 */
|
||||||
|
|
@ -128,7 +131,7 @@ async function handleDelete() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
conversationStore.removeMessage(conversation.type, conversation.targetId, {
|
messageStore.removeMessage(conversation.type, conversation.targetId, {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
clientMessageId: m.clientMessageId
|
clientMessageId: m.clientMessageId
|
||||||
})
|
})
|
||||||
|
|
@ -141,4 +144,3 @@ function handleCancel() {
|
||||||
multiSelect.exit()
|
multiSelect.exit()
|
||||||
}
|
}
|
||||||
</script>
|
</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)]"
|
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"
|
@click="handleTopClick"
|
||||||
>
|
>
|
||||||
<Icon icon="ant-design:pushpin-outlined" :size="14" class="flex-shrink-0 text-[var(--el-color-warning)]" />
|
<Icon
|
||||||
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)]">{{ getSenderName(latest) }}:</span>
|
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>
|
<span class="flex-1 min-w-0 truncate">{{ getPreview(latest) }}</span>
|
||||||
<!-- 单条:移除按钮;多条折叠:共 N 条;多条展开:收起箭头 -->
|
<!-- 单条:移除按钮;多条折叠:共 N 条;多条展开:收起箭头 -->
|
||||||
<span
|
<span
|
||||||
|
|
@ -22,7 +28,9 @@
|
||||||
移除
|
移除
|
||||||
</span>
|
</span>
|
||||||
<template v-else-if="pinnedMessages.length > 1">
|
<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
|
||||||
:icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
|
:icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
|
||||||
:size="11"
|
:size="11"
|
||||||
|
|
@ -35,7 +43,7 @@
|
||||||
<div
|
<div
|
||||||
v-if="pinnedMessages.length > 1 && expanded"
|
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)]"
|
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
|
<div
|
||||||
v-for="msg in pinnedMessages"
|
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)]"
|
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)"
|
@click="handleLocate(msg)"
|
||||||
>
|
>
|
||||||
<Icon icon="ant-design:pushpin-outlined" :size="14" class="flex-shrink-0 text-[var(--el-color-warning)]" />
|
<Icon
|
||||||
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)]">{{ getSenderName(msg) }}:</span>
|
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 class="flex-1 min-w-0 truncate">{{ getPreview(msg) }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="canManage"
|
v-if="canManage"
|
||||||
|
|
@ -126,23 +140,31 @@ function handleTopClick() {
|
||||||
|
|
||||||
/** 点击置顶消息行 → 触发跳转 + 收起弹出层 */
|
/** 点击置顶消息行 → 触发跳转 + 收起弹出层 */
|
||||||
function handleLocate(msg: Message) {
|
function handleLocate(msg: Message) {
|
||||||
|
if (!msg.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
emit('locate', msg.id)
|
emit('locate', msg.id)
|
||||||
expanded.value = false
|
expanded.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO @AI:变量是不是都改成 message,不要 msg?!
|
||||||
/** 置顶消息发送人显示名 */
|
/** 置顶消息发送人显示名 */
|
||||||
function getSenderName(msg: Message): string {
|
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 {
|
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 自动同步本地 */
|
/** 移除置顶:调后端 API,loading 期间禁止重复点;后端广播 GROUP_MESSAGE_UNPIN 由 dispatcher 自动同步本地 */
|
||||||
async function handleRemove(msg: Message) {
|
async function handleRemove(msg: Message) {
|
||||||
if (!group.value || removingId.value !== null) {
|
if (!group.value || !msg.id || removingId.value !== null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
removingId.value = msg.id
|
removingId.value = msg.id
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,9 @@
|
||||||
v-else-if="isGroupNotification(message.type)"
|
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)]"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- 普通消息行 -->
|
<!-- 普通消息行 -->
|
||||||
|
|
@ -178,11 +180,11 @@
|
||||||
<span class="block text-right">{{ formatHistoryTime(message.sendTime) }}</span>
|
<span class="block text-right">{{ formatHistoryTime(message.sendTime) }}</span>
|
||||||
<!-- 定位到聊天位置:absolute 浮在时间下方,行 hover 才显示,
|
<!-- 定位到聊天位置:absolute 浮在时间下方,行 hover 才显示,
|
||||||
不参与右侧栏 flex 排版(避免隐藏时占位让"我"和内容之间留空);
|
不参与右侧栏 flex 排版(避免隐藏时占位让"我"和内容之间留空);
|
||||||
仅有真实 id 的消息才支持(本地占位消息 id=0 不行) -->
|
仅有真实 id 的消息才支持(本地占位消息不行) -->
|
||||||
<span
|
<span
|
||||||
v-if="message.id > 0"
|
v-if="message.id && message.id > 0"
|
||||||
class="im-message-history__locate"
|
class="im-message-history__locate"
|
||||||
@click="locateMessage(message.id)"
|
@click="locateMessage(message.id!)"
|
||||||
>
|
>
|
||||||
定位到聊天位置
|
定位到聊天位置
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -248,6 +250,7 @@ import { useUserStore } from '@/store/modules/user'
|
||||||
import { getPrivateMessageList as apiGetPrivateMessageList } from '@/api/im/message/private'
|
import { getPrivateMessageList as apiGetPrivateMessageList } from '@/api/im/message/private'
|
||||||
import { getGroupMessageList as apiGetGroupMessageList } from '@/api/im/message/group'
|
import { getGroupMessageList as apiGetGroupMessageList } from '@/api/im/message/group'
|
||||||
import { useConversationStore } from '../../../../store/conversationStore'
|
import { useConversationStore } from '../../../../store/conversationStore'
|
||||||
|
import { useMessageStore } from '../../../../store/messageStore'
|
||||||
import { useGroupStore } from '../../../../store/groupStore'
|
import { useGroupStore } from '../../../../store/groupStore'
|
||||||
import { useFriendStore } from '../../../../store/friendStore'
|
import { useFriendStore } from '../../../../store/friendStore'
|
||||||
import { IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys'
|
import { IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys'
|
||||||
|
|
@ -285,6 +288,7 @@ import {
|
||||||
type MergeMessage
|
type MergeMessage
|
||||||
} from '@/views/im/utils/message'
|
} from '@/views/im/utils/message'
|
||||||
import type { Message } from '@/views/im/home/types'
|
import type { Message } from '@/views/im/home/types'
|
||||||
|
import { getClientConversationId } from '@/views/im/utils/db'
|
||||||
import UserAvatar from '../../../../components/user/UserAvatar.vue'
|
import UserAvatar from '../../../../components/user/UserAvatar.vue'
|
||||||
import MessageBubble from './MessageBubble.vue'
|
import MessageBubble from './MessageBubble.vue'
|
||||||
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||||
|
|
@ -299,6 +303,7 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const friendStore = useFriendStore()
|
const friendStore = useFriendStore()
|
||||||
const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY)
|
const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY)
|
||||||
|
|
@ -316,7 +321,13 @@ defineExpose({
|
||||||
|
|
||||||
const conversation = computed(() => conversationStore.activeConversation)
|
const conversation = computed(() => conversationStore.activeConversation)
|
||||||
const isGroup = computed(() => conversation.value?.type === ImConversationType.GROUP)
|
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 优先级实时算 */
|
/** 单条消息的发送人显示名:渲染时按 conversation 上下文走 WeChat 优先级实时算 */
|
||||||
function senderDisplayNameOf(message: Message): string {
|
function senderDisplayNameOf(message: Message): string {
|
||||||
|
|
@ -329,8 +340,7 @@ function senderDisplayNameOf(message: Message): string {
|
||||||
|
|
||||||
/** 群广播事件 segments 的成员名解析器;按当前会话 targetId 走 getSenderDisplayName */
|
/** 群广播事件 segments 的成员名解析器;按当前会话 targetId 走 getSenderDisplayName */
|
||||||
function resolveGroupMemberName(message: Message): (userId: number) => string {
|
function resolveGroupMemberName(message: Message): (userId: number) => string {
|
||||||
return (id: number) =>
|
return (id: number) => getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0)
|
||||||
getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单条消息的发送人真实昵称:给 UserAvatar 色卡 / alt 用,永远是 nickname 不掺备注 */
|
/** 单条消息的发送人真实昵称:给 UserAvatar 色卡 / alt 用,永远是 nickname 不掺备注 */
|
||||||
|
|
@ -535,7 +545,7 @@ const hasMore = ref(true)
|
||||||
*
|
*
|
||||||
* - 未对接 list 接口的 type / keyword / sender 过滤参数:后端只支持 maxId + limit 游标分页,
|
* - 未对接 list 接口的 type / keyword / sender 过滤参数:后端只支持 maxId + limit 游标分页,
|
||||||
* tab 筛选在前端做(数据来回到本地后过滤)
|
* tab 筛选在前端做(数据来回到本地后过滤)
|
||||||
* - id=0(本地占位)跳过:后端没法按 messageId 查
|
* - 本地占位跳过:后端没法按 messageId 查
|
||||||
* - 返回数量 < limit 视为到顶
|
* - 返回数量 < limit 视为到顶
|
||||||
*/
|
*/
|
||||||
async function loadEarlier() {
|
async function loadEarlier() {
|
||||||
|
|
@ -545,10 +555,7 @@ async function loadEarlier() {
|
||||||
}
|
}
|
||||||
// 仅 PRIVATE / GROUP 走分页接口;CHANNEL 单向广播、没有 list 接口,落到 else 会误调私聊接口(receiverId 传 channelId)
|
// 仅 PRIVATE / GROUP 走分页接口;CHANNEL 单向广播、没有 list 接口,落到 else 会误调私聊接口(receiverId 传 channelId)
|
||||||
const requestedType = conversation.value.type
|
const requestedType = conversation.value.type
|
||||||
if (
|
if (requestedType !== ImConversationType.PRIVATE && requestedType !== ImConversationType.GROUP) {
|
||||||
requestedType !== ImConversationType.PRIVATE &&
|
|
||||||
requestedType !== ImConversationType.GROUP
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 快照当前会话主键:await 期间用户切走 / 关闭面板时丢弃响应,避免旧会话历史被 prepend 到新会话造成串号
|
// 快照当前会话主键:await 期间用户切走 / 关闭面板时丢弃响应,避免旧会话历史被 prepend 到新会话造成串号
|
||||||
|
|
@ -559,11 +566,11 @@ async function loadEarlier() {
|
||||||
loadingMore.value = true
|
loadingMore.value = true
|
||||||
try {
|
try {
|
||||||
// 算 maxId(不含,作为后端游标):取当前会话本地缓存里最早一条服务端 id;
|
// 算 maxId(不含,作为后端游标):取当前会话本地缓存里最早一条服务端 id;
|
||||||
// id=0 是本地乐观占位消息,没有服务端 id,要剔除
|
// 本地乐观占位消息没有服务端 id,要剔除
|
||||||
// 全是占位 / 列表为空时 reduce 不更新初值(POSITIVE_INFINITY),转成 undefined → 后端从最新拉
|
// 全是占位 / 列表为空时 reduce 不更新初值(POSITIVE_INFINITY),转成 undefined → 后端从最新拉
|
||||||
const earliestId = allMessages.value
|
const earliestId = allMessages.value
|
||||||
.filter((message) => message.id > 0)
|
.filter((message) => !!message.id && message.id > 0)
|
||||||
.reduce((min, message) => Math.min(min, message.id), Number.POSITIVE_INFINITY)
|
.reduce((min, message) => Math.min(min, message.id || min), Number.POSITIVE_INFINITY)
|
||||||
const maxId = Number.isFinite(earliestId) ? earliestId : undefined
|
const maxId = Number.isFinite(earliestId) ? earliestId : undefined
|
||||||
|
|
||||||
// 调后端 list 接口:私聊 / 群聊接口签名不同,分支调度;返回结果用 useMessagePuller
|
// 调后端 list 接口:私聊 / 群聊接口签名不同,分支调度;返回结果用 useMessagePuller
|
||||||
|
|
@ -597,9 +604,9 @@ async function loadEarlier() {
|
||||||
if (pageLength < HISTORY_PAGE_SIZE) {
|
if (pageLength < HISTORY_PAGE_SIZE) {
|
||||||
hasMore.value = false
|
hasMore.value = false
|
||||||
}
|
}
|
||||||
// 合并到 conversationStore:prependMessages 内部去重 + 升序合并 + 落 IndexedDB;
|
// 合并到 messageStore:prependMessages 内部去重 + 升序合并 + 落 IndexedDB;
|
||||||
// 主聊天面板的 messages 是同一份引用,老消息也会一起出现在主面板里(符合预期)
|
// 主聊天面板的 messages 是同一份引用,老消息也会一起出现在主面板里(符合预期)
|
||||||
conversationStore.prependMessages(requestedType, requestedTargetId, earlier)
|
messageStore.prependMessages(requestedType, requestedTargetId, earlier)
|
||||||
} finally {
|
} finally {
|
||||||
loadingMore.value = false
|
loadingMore.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,7 @@ import {
|
||||||
parseRtcCallPayload
|
parseRtcCallPayload
|
||||||
} from '@/views/im/utils/message'
|
} from '@/views/im/utils/message'
|
||||||
import { useImUiStore } from '../../../../store/uiStore'
|
import { useImUiStore } from '../../../../store/uiStore'
|
||||||
|
import { useMessageStore } from '../../../../store/messageStore'
|
||||||
import { useMessageSender } from '../../../../composables/useMessageSender'
|
import { useMessageSender } from '../../../../composables/useMessageSender'
|
||||||
import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader'
|
import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader'
|
||||||
import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
|
import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
|
||||||
|
|
@ -289,6 +290,7 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const friendStore = useFriendStore()
|
const friendStore = useFriendStore()
|
||||||
const draftStore = useDraftStore()
|
const draftStore = useDraftStore()
|
||||||
|
|
@ -600,7 +602,7 @@ type MenuKey = (typeof MENU_KEYS)[keyof typeof MENU_KEYS]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 右键菜单项:
|
* 右键菜单项:
|
||||||
* - 引用:已落库(id≠0)+ 未撤回的消息可引用,引用块写入 draftStore.reply
|
* - 引用:已落库 + 未撤回的消息可引用,引用块写入 draftStore.reply
|
||||||
* - 撤回 / 删除:互斥;自己发送 + 已落库 + 未撤回 + 2 分钟内显示「撤回」(推服务器),其它显示「删除」(仅本地清)
|
* - 撤回 / 删除:互斥;自己发送 + 已落库 + 未撤回 + 2 分钟内显示「撤回」(推服务器),其它显示「删除」(仅本地清)
|
||||||
*
|
*
|
||||||
* 好友事件气泡态不弹菜单
|
* 好友事件气泡态不弹菜单
|
||||||
|
|
@ -661,7 +663,7 @@ async function handleContextMenu(e: MouseEvent) {
|
||||||
icon: 'ant-design:copy-outlined'
|
icon: 'ant-design:copy-outlined'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 「引用」:已落库(id≠0)+ 未撤回 + 非合并转发;MERGE 内嵌快照在引用预览里无法降级展示
|
// 「引用」:已落库 + 未撤回 + 非合并转发;MERGE 内嵌快照在引用预览里无法降级展示
|
||||||
if (!!props.message.id && !isRecall.value && !isMerge.value) {
|
if (!!props.message.id && !isRecall.value && !isMerge.value) {
|
||||||
items.push({
|
items.push({
|
||||||
key: MENU_KEYS.REPLY,
|
key: MENU_KEYS.REPLY,
|
||||||
|
|
@ -669,7 +671,7 @@ async function handleContextMenu(e: MouseEvent) {
|
||||||
icon: 'bxs:quote-alt-left'
|
icon: 'bxs:quote-alt-left'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 「转发」「多选」:已落库(id≠0)+ 普通消息 + 未撤回;触发 ForwardDialog / 进入多选模式
|
// 「转发」「多选」:已落库 + 普通消息 + 未撤回;触发 ForwardDialog / 进入多选模式
|
||||||
if (canForward.value) {
|
if (canForward.value) {
|
||||||
items.push({
|
items.push({
|
||||||
key: MENU_KEYS.FORWARD,
|
key: MENU_KEYS.FORWARD,
|
||||||
|
|
@ -728,7 +730,7 @@ async function handleContextMenu(e: MouseEvent) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 「撤回 / 删除」二选一:
|
// 「撤回 / 删除」二选一:
|
||||||
// - 自己发送 + 已落库(id≠0)+ 未撤回 + 在撤回窗口内 → 撤回(推服务器把消息态置 RECALL)
|
// - 自己发送 + 已落库 + 未撤回 + 在撤回窗口内 → 撤回(推服务器把消息态置 RECALL)
|
||||||
// - 其它(对方消息 / 已撤回 / 超出撤回窗口)→ 删除(仅本地清,不动后端)
|
// - 其它(对方消息 / 已撤回 / 超出撤回窗口)→ 删除(仅本地清,不动后端)
|
||||||
// divided 把这一项和上面的「引用」隔开,danger 显红对齐微信
|
// divided 把这一项和上面的「引用」隔开,danger 显红对齐微信
|
||||||
const canRecall =
|
const canRecall =
|
||||||
|
|
@ -822,7 +824,7 @@ const canManageSender = computed(() => {
|
||||||
return myGroupRole.value < senderMember.role
|
return myGroupRole.value < senderMember.role
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 是否允许转发 / 多选:普通消息 + 已落库(id≠0)+ 未撤回;本地占位 / 撤回 / 事件消息一律不可 */
|
/** 是否允许转发 / 多选:普通消息 + 已落库 + 未撤回;本地占位 / 撤回 / 事件消息一律不可 */
|
||||||
const canForward = computed(
|
const canForward = computed(
|
||||||
() => isNormalMessage(props.message.type) && !!props.message.id && !isRecall.value
|
() => isNormalMessage(props.message.type) && !!props.message.id && !isRecall.value
|
||||||
)
|
)
|
||||||
|
|
@ -866,7 +868,7 @@ const canPin = computed(
|
||||||
/** 置顶消息:二次确认 → 调后端 pin-message;后端广播 GROUP_MESSAGE_PIN,本端 dispatcher 拉最新 pinnedMessages */
|
/** 置顶消息:二次确认 → 调后端 pin-message;后端广播 GROUP_MESSAGE_PIN,本端 dispatcher 拉最新 pinnedMessages */
|
||||||
async function handlePin() {
|
async function handlePin() {
|
||||||
const group = currentGroup.value
|
const group = currentGroup.value
|
||||||
if (!group) {
|
if (!group || !props.message.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -976,7 +978,7 @@ async function handleResend() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文本类型 / 媒体类型但 _localFile 已丢:把 FAILED 占位回滚到 SENDING,复用 clientMessageId 让服务端按 cmid 幂等去重
|
// 文本类型 / 媒体类型但 _localFile 已丢:把 FAILED 占位回滚到 SENDING,复用 clientMessageId 让服务端按 cmid 幂等去重
|
||||||
conversationStore.patchMessage(conversation.type, conversation.targetId, message.clientMessageId, {
|
messageStore.patchMessage(conversation.type, conversation.targetId, message.clientMessageId, {
|
||||||
status: ImMessageStatus.SENDING
|
status: ImMessageStatus.SENDING
|
||||||
})
|
})
|
||||||
await sendRaw(message.type, message.content, {
|
await sendRaw(message.type, message.content, {
|
||||||
|
|
@ -986,7 +988,7 @@ async function handleResend() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除消息:本地软删,仅从 conversationStore.messages 移除,不调后端
|
* 删除消息:本地软删,仅从 messageStore 移除,不调后端
|
||||||
* 区别于"撤回":服务端没动,多端登录时其它客户端 / 群里其他人依然能看到这条
|
* 区别于"撤回":服务端没动,多端登录时其它客户端 / 群里其他人依然能看到这条
|
||||||
*/
|
*/
|
||||||
/** 禁言:emit 给父组件打开时长选择弹窗(避免 MessageItem 过重) */
|
/** 禁言:emit 给父组件打开时长选择弹窗(避免 MessageItem 过重) */
|
||||||
|
|
@ -1033,7 +1035,7 @@ function handleDelete() {
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
conversationStore.removeMessage(conversation.type, conversation.targetId, {
|
messageStore.removeMessage(conversation.type, conversation.targetId, {
|
||||||
id: props.message.id,
|
id: props.message.id,
|
||||||
clientMessageId: props.message.clientMessageId
|
clientMessageId: props.message.clientMessageId
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
<div class="flex flex-1 flex-col min-w-0 bg-[var(--el-fill-color-light)]">
|
<div class="flex flex-1 flex-col min-w-0 bg-[var(--el-fill-color-light)]">
|
||||||
<template v-if="conversationStore.activeConversation">
|
<template v-if="conversationStore.activeConversation">
|
||||||
<!-- 顶部 header:第一行群名 + 右侧图标,第二行嵌入置顶气泡(仅群聊 + 有置顶) -->
|
<!-- 顶部 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">
|
<div class="flex items-center justify-between h-14 px-5">
|
||||||
<span class="flex flex-col min-w-0">
|
<span class="flex flex-col min-w-0">
|
||||||
<span class="flex items-baseline gap-1.5 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)]"
|
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"
|
@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" />
|
<Icon icon="ant-design:user-outlined" :size="11" />
|
||||||
</span>
|
</span>
|
||||||
<span>对方还不是你的朋友</span>
|
<span>对方还不是你的朋友</span>
|
||||||
|
|
@ -141,7 +145,7 @@
|
||||||
暂无消息
|
暂无消息
|
||||||
</div>
|
</div>
|
||||||
<!-- data-message-id 给 MessageHistory "定位到聊天位置" 用:父级通过 querySelector
|
<!-- data-message-id 给 MessageHistory "定位到聊天位置" 用:父级通过 querySelector
|
||||||
找到这层 wrapper,scrollIntoView + 加高亮 class;id=0 的本地占位消息跳过 -->
|
找到这层 wrapper,scrollIntoView + 加高亮 class;本地占位消息跳过 -->
|
||||||
<div
|
<div
|
||||||
v-for="(msg, index) in messages"
|
v-for="(msg, index) in messages"
|
||||||
:key="msg.id || msg.clientMessageId"
|
:key="msg.id || msg.clientMessageId"
|
||||||
|
|
@ -254,11 +258,14 @@ import RtcGroupCallBanner from '../../../../components/rtc/RtcGroupCallBanner.vu
|
||||||
import { createCall } from '@/api/im/rtc'
|
import { createCall } from '@/api/im/rtc'
|
||||||
import { ImRtcCallMediaType, ImRtcCallStatus, ImConversationType } from '@/views/im/utils/constants'
|
import { ImRtcCallMediaType, ImRtcCallStatus, ImConversationType } from '@/views/im/utils/constants'
|
||||||
import { resolveCallEndReasonText } from '@/views/im/utils/message'
|
import { resolveCallEndReasonText } from '@/views/im/utils/message'
|
||||||
|
import { getClientConversationId } from '@/views/im/utils/db'
|
||||||
import { useRtcStore } from '../../../../store/rtcStore'
|
import { useRtcStore } from '../../../../store/rtcStore'
|
||||||
|
import { useMessageStore } from '../../../../store/messageStore'
|
||||||
|
|
||||||
defineOptions({ name: 'ImMessagePanel' })
|
defineOptions({ name: 'ImMessagePanel' })
|
||||||
|
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
const friendStore = useFriendStore()
|
const friendStore = useFriendStore()
|
||||||
const uiStore = useImUiStore()
|
const uiStore = useImUiStore()
|
||||||
const groupStore = useGroupStore()
|
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(
|
const isGroup = computed(
|
||||||
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
|
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ import { getGroupReadUsers as apiGetGroupReadUsers } from '@/api/im/message/grou
|
||||||
import { CommonStatusEnum } from '@/utils/constants'
|
import { CommonStatusEnum } from '@/utils/constants'
|
||||||
import { ImConversationType, ImGroupReceiptStatus } from '../../../../../utils/constants'
|
import { ImConversationType, ImGroupReceiptStatus } from '../../../../../utils/constants'
|
||||||
import type { Message } from '../../../../types'
|
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 GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||||
import PagedScroller from '../../../../components/PagedScroller.vue'
|
import PagedScroller from '../../../../components/PagedScroller.vue'
|
||||||
|
|
||||||
|
|
@ -73,7 +73,7 @@ const props = defineProps<{
|
||||||
groupId: number
|
groupId: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const conversationStore = useConversationStore()
|
const messageStore = useMessageStore()
|
||||||
|
|
||||||
// popover 开关:show 时拉已读名单,关闭后保留 readUserIds 缓存(重开同一条消息不再请求)
|
// popover 开关:show 时拉已读名单,关闭后保留 readUserIds 缓存(重开同一条消息不再请求)
|
||||||
const popVisible = ref(false)
|
const popVisible = ref(false)
|
||||||
|
|
@ -132,7 +132,7 @@ const unreadMembers = computed(() =>
|
||||||
* 跳过:本地占位消息(id = 0,还没拿到服务端 id),后端没法按 messageId 查
|
* 跳过:本地占位消息(id = 0,还没拿到服务端 id),后端没法按 messageId 查
|
||||||
* 失败:仅控制台告警,readUserIds 保持空数组 → label 走 readCount 兜底,不阻塞 UI
|
* 失败:仅控制台告警,readUserIds 保持空数组 → label 走 readCount 兜底,不阻塞 UI
|
||||||
*
|
*
|
||||||
* 拉到名单后顺手把 readCount / receiptStatus 回写到 conversationStore,让 popover 外面的
|
* 拉到名单后顺手把 readCount / receiptStatus 回写到 messageStore,让 popover 外面的
|
||||||
* label 也跟着走最新数:离线 / 漏收 RECEIPT 事件时本地 readCount 会偏旧,弹层里看到"已读 5"
|
* label 也跟着走最新数:离线 / 漏收 RECEIPT 事件时本地 readCount 会偏旧,弹层里看到"已读 5"
|
||||||
* 但外面仍是"未读"或旧人数;这里以服务端返回为准矫正回去
|
* 但外面仍是"未读"或旧人数;这里以服务端返回为准矫正回去
|
||||||
*/
|
*/
|
||||||
|
|
@ -150,7 +150,7 @@ async function loadReadUsers() {
|
||||||
// 全可见成员都已读 → flip 到 DONE,让外面 label 直接命中"全部已读"分支;
|
// 全可见成员都已读 → flip 到 DONE,让外面 label 直接命中"全部已读"分支;
|
||||||
// 否则只更新 readCount,receiptStatus 维持不变(PENDING / READING)
|
// 否则只更新 readCount,receiptStatus 维持不变(PENDING / READING)
|
||||||
const allRead = readCount > 0 && readCount >= visibleMembers.value.length
|
const allRead = readCount > 0 && readCount >= visibleMembers.value.length
|
||||||
conversationStore.applyReadReceipt({
|
messageStore.applyReadReceipt({
|
||||||
conversationType: ImConversationType.GROUP,
|
conversationType: ImConversationType.GROUP,
|
||||||
targetId: props.groupId,
|
targetId: props.groupId,
|
||||||
groupMessageId: props.message.id,
|
groupMessageId: props.message.id,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@
|
||||||
<div
|
<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="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="[
|
: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,
|
'cursor-pointer hover:text-[var(--el-text-color-primary)]': clickable && !isRecalled,
|
||||||
'hover:bg-[var(--el-fill-color-light)]': (clickable && !isRecalled) || closable
|
'hover:bg-[var(--el-fill-color-light)]': (clickable && !isRecalled) || closable
|
||||||
|
|
@ -32,12 +34,7 @@
|
||||||
|
|
||||||
<!-- 文件:icon + 文件名 + 大小 -->
|
<!-- 文件:icon + 文件名 + 大小 -->
|
||||||
<template v-else-if="isFile">
|
<template v-else-if="isFile">
|
||||||
<Icon
|
<Icon :icon="fileIcon.icon" :color="fileIcon.color" :size="14" class="flex-shrink-0" />
|
||||||
: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">
|
<span v-if="parsedPayload?.name" class="min-w-0 line-clamp-2 break-words">
|
||||||
{{ parsedPayload.name }}
|
{{ parsedPayload.name }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -58,7 +55,11 @@
|
||||||
</template>
|
</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 仅显示 [表情]) -->
|
<!-- 表情贴图:缩略图 + name(无 name 仅显示 [表情]) -->
|
||||||
<template v-else-if="isFace">
|
<template v-else-if="isFace">
|
||||||
|
|
@ -103,8 +104,10 @@ import { formatSeconds } from '@/utils/formatTime'
|
||||||
import { formatFileSize } from '@/utils/file'
|
import { formatFileSize } from '@/utils/file'
|
||||||
|
|
||||||
import { useConversationStore } from '../../../../store/conversationStore'
|
import { useConversationStore } from '../../../../store/conversationStore'
|
||||||
|
import { useMessageStore } from '../../../../store/messageStore'
|
||||||
import { getSenderDisplayName } from '@/views/im/utils/user'
|
import { getSenderDisplayName } from '@/views/im/utils/user'
|
||||||
import { ImMessageType } from '@/views/im/utils/constants'
|
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 CardLineLabel from '@/views/im/home/components/card/CardLineLabel.vue'
|
||||||
import {
|
import {
|
||||||
parseMessage,
|
parseMessage,
|
||||||
|
|
@ -148,6 +151,7 @@ const emit = defineEmits<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
|
|
||||||
/** 在当前会话消息列表里查找原消息,仅用于实时判断是否已撤回;摘要 / 缩略图都从 quote.content 直接派生 */
|
/** 在当前会话消息列表里查找原消息,仅用于实时判断是否已撤回;摘要 / 缩略图都从 quote.content 直接派生 */
|
||||||
const liveMessage = computed(() => {
|
const liveMessage = computed(() => {
|
||||||
|
|
@ -155,7 +159,9 @@ const liveMessage = computed(() => {
|
||||||
if (!conversation || !props.quote.messageId) {
|
if (!conversation || !props.quote.messageId) {
|
||||||
return undefined
|
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 才判定为已撤回;不在缓存的当快照仍有效 */
|
/** 命中本地缓存且 type === RECALL 才判定为已撤回;不在缓存的当快照仍有效 */
|
||||||
|
|
@ -193,9 +199,7 @@ const isMaterial = computed(() => props.quote.type === ImMessageType.MATERIAL)
|
||||||
/** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */
|
/** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */
|
||||||
const textPreview = computed(() => {
|
const textPreview = computed(() => {
|
||||||
const text = parsedPayload.value?.content ?? ''
|
const text = parsedPayload.value?.content ?? ''
|
||||||
return text.length <= MAX_TEXT_PREVIEW_LEN
|
return text.length <= MAX_TEXT_PREVIEW_LEN ? text : `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}…`
|
||||||
? text
|
|
||||||
: `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}…`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 文件 icon:按扩展名挑色,跟主气泡渲染同源 */
|
/** 文件 icon:按扩展名挑色,跟主气泡渲染同源 */
|
||||||
|
|
@ -227,4 +231,3 @@ function onClick() {
|
||||||
emit('locate', props.quote.messageId)
|
emit('locate', props.quote.messageId)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -402,7 +402,6 @@ async function handleCreateGroupAndSend() {
|
||||||
name: group.name || name,
|
name: group.name || name,
|
||||||
avatar: group.avatar || '',
|
avatar: group.avatar || '',
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
messages: [],
|
|
||||||
lastContent: '',
|
lastContent: '',
|
||||||
lastSendTime: 0
|
lastSendTime: 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,12 @@ export const useChannelStore = defineStore('imChannelStore', {
|
||||||
conversation.avatar = channel.avatar
|
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))
|
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 队列里丢失 */
|
/** 立即落盘待写的草稿;beforeunload 时调,避免最后一次输入卡在 debounce 队列里丢失 */
|
||||||
flushPersist(): void {
|
flushPersist(): void {
|
||||||
persistBucket.flush()
|
persistBucket.flush()
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 清空草稿内存 */
|
||||||
|
// TODO @AI:写草稿,是不是融合到 conversationStore 里。
|
||||||
|
clear(): void {
|
||||||
|
persistBucket.cancel()
|
||||||
|
this.drafts = {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -514,7 +514,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
/**
|
/**
|
||||||
* 接收 GROUP_* 群广播事件,按 type 分发到对应私有 action
|
* 接收 GROUP_* 群广播事件,按 type 分发到对应私有 action
|
||||||
*
|
*
|
||||||
* WebSocket 实时收 + useMessagePuller 离线 pull 都走 conversationStore.insertMessage 旁路调用
|
* WebSocket 实时收 + useMessagePuller 离线 pull 都走 messageStore.insertMessage 旁路调用
|
||||||
* store 里没缓存的群静默忽略,等 fetchGroups 兜底
|
* store 里没缓存的群静默忽略,等 fetchGroups 兜底
|
||||||
*/
|
*/
|
||||||
applyGroupNotification(groupId: number, type: number, content?: string) {
|
applyGroupNotification(groupId: number, type: number, content?: string) {
|
||||||
|
|
@ -567,7 +567,9 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.ADMIN)
|
this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.ADMIN)
|
||||||
// 自己被加为管理员,原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
|
// 自己被加为管理员,原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
|
||||||
if (isSelfInPayloadMembers(payload)) {
|
if (isSelfInPayloadMembers(payload)) {
|
||||||
useGroupRequestStore().fetchUnhandledList().catch(() => undefined)
|
useGroupRequestStore()
|
||||||
|
.fetchUnhandledList()
|
||||||
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case ImMessageType.GROUP_ADMIN_REMOVE:
|
case ImMessageType.GROUP_ADMIN_REMOVE:
|
||||||
|
|
@ -701,7 +703,9 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
// 自己接管群主:原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
|
// 自己接管群主:原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
|
||||||
const selfUserId = getCurrentUserId()
|
const selfUserId = getCurrentUserId()
|
||||||
if (selfUserId && payload.newOwnerUserId === selfUserId) {
|
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 等派生字段 */
|
/** 后端 ImGroupMessageRespVO -> 前端 Message:补 targetId / selfSend / sendTime 等派生字段 */
|
||||||
function convertGroupMessageVO(message: NonNullable<ImGroupRespVO['pinnedMessages']>[number]): Message {
|
function convertGroupMessageVO(
|
||||||
|
message: NonNullable<ImGroupRespVO['pinnedMessages']>[number]
|
||||||
|
): Message {
|
||||||
const currentUserId = getCurrentUserId()
|
const currentUserId = getCurrentUserId()
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
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
|
WS_RECONNECT_JITTER_MS
|
||||||
} from '../../utils/config'
|
} from '../../utils/config'
|
||||||
import { useConversationStore } from './conversationStore'
|
import { useConversationStore } from './conversationStore'
|
||||||
|
import { useMessageStore } from './messageStore'
|
||||||
import { useFriendStore, type FriendNotificationPayload } from './friendStore'
|
import { useFriendStore, type FriendNotificationPayload } from './friendStore'
|
||||||
import { getFriendDisplayName } from '../../utils/user'
|
import { getFriendDisplayName } from '../../utils/user'
|
||||||
import { useGroupStore } from './groupStore'
|
import { useGroupStore } from './groupStore'
|
||||||
|
|
@ -317,10 +318,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 频道消息实时入会话;频道消息单向 + 无状态机,直接 insertMessage 即可
|
* 频道消息实时入会话;频道消息单向 + 无状态机,直接 insertMessage 即可
|
||||||
* pull 与 WS 拿到同一条 id 时,conversationStore.insertMessage 内部按 id 去重,不会重复
|
* pull 与 WS 拿到同一条 id 时,messageStore.insertMessage 内部按 id 去重,不会重复
|
||||||
*/
|
*/
|
||||||
handleChannelMessage(websocketMessage: ImChannelMessageRespVO) {
|
handleChannelMessage(websocketMessage: ImChannelMessageRespVO) {
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
// 离线加载期间先缓冲,等 pull 完成后再统一回放,避免重复或顺序错乱
|
// 离线加载期间先缓冲,等 pull 完成后再统一回放,避免重复或顺序错乱
|
||||||
if (conversationStore.loading) {
|
if (conversationStore.loading) {
|
||||||
this.messageBuffer.push({
|
this.messageBuffer.push({
|
||||||
|
|
@ -333,7 +335,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
typeof websocketMessage.sendTime === 'number'
|
typeof websocketMessage.sendTime === 'number'
|
||||||
? websocketMessage.sendTime
|
? websocketMessage.sendTime
|
||||||
: new Date(websocketMessage.sendTime).getTime()
|
: new Date(websocketMessage.sendTime).getTime()
|
||||||
conversationStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), {
|
messageStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), {
|
||||||
id: websocketMessage.id,
|
id: websocketMessage.id,
|
||||||
clientMessageId: '',
|
clientMessageId: '',
|
||||||
type: websocketMessage.type,
|
type: websocketMessage.type,
|
||||||
|
|
@ -513,7 +515,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage)
|
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage)
|
||||||
// 这里拦截下来改走 recallMessage(把原消息更新为 RECALL 态),不让它作为新消息进列表
|
// 这里拦截下来改走 recallMessage(把原消息更新为 RECALL 态),不让它作为新消息进列表
|
||||||
if (websocketMessage.type === ImMessageType.RECALL) {
|
if (websocketMessage.type === ImMessageType.RECALL) {
|
||||||
conversationStore.recallMessage(
|
useMessageStore().recallMessage(
|
||||||
ImConversationType.PRIVATE,
|
ImConversationType.PRIVATE,
|
||||||
peerId,
|
peerId,
|
||||||
websocketMessage.content
|
websocketMessage.content
|
||||||
|
|
@ -523,7 +525,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
|
|
||||||
// 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段
|
// 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段
|
||||||
const message = convertPrivateMessage(websocketMessage, currentUserId)
|
const message = convertPrivateMessage(websocketMessage, currentUserId)
|
||||||
conversationStore.insertMessage(
|
useMessageStore().insertMessage(
|
||||||
{
|
{
|
||||||
type: ImConversationType.PRIVATE,
|
type: ImConversationType.PRIVATE,
|
||||||
targetId: peerId,
|
targetId: peerId,
|
||||||
|
|
@ -543,7 +545,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
|
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
|
||||||
// 已读位置直接用刚到的消息 id(这条就是当前会话最大 id)
|
// 已读位置直接用刚到的消息 id(这条就是当前会话最大 id)
|
||||||
conversationStore.markActiveAsRead()
|
conversationStore.markConversationAsRead(ImConversationType.PRIVATE, peerId)
|
||||||
|
if (conversation) {
|
||||||
|
useMessageStore().markConversationMessagesRead(conversation)
|
||||||
|
}
|
||||||
if (MESSAGE_PRIVATE_READ_ENABLED) {
|
if (MESSAGE_PRIVATE_READ_ENABLED) {
|
||||||
apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => {
|
apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => {
|
||||||
console.warn('[IM WS] 自动已读上报失败', e)
|
console.warn('[IM WS] 自动已读上报失败', e)
|
||||||
|
|
@ -580,8 +585,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
if (!websocketMessage.id) {
|
if (!websocketMessage.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const conversationStore = useConversationStore()
|
useMessageStore().applyReadReceipt({
|
||||||
conversationStore.applyReadReceipt({
|
|
||||||
conversationType: ImConversationType.PRIVATE,
|
conversationType: ImConversationType.PRIVATE,
|
||||||
targetId: websocketMessage.senderId,
|
targetId: websocketMessage.senderId,
|
||||||
privateReadMaxId: websocketMessage.id
|
privateReadMaxId: websocketMessage.id
|
||||||
|
|
@ -637,7 +641,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`
|
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`
|
||||||
// 这里拦截下来改走 recallMessage(把原消息更新为 RECALL 态)
|
// 这里拦截下来改走 recallMessage(把原消息更新为 RECALL 态)
|
||||||
if (websocketMessage.type === ImMessageType.RECALL) {
|
if (websocketMessage.type === ImMessageType.RECALL) {
|
||||||
conversationStore.recallMessage(
|
useMessageStore().recallMessage(
|
||||||
ImConversationType.GROUP,
|
ImConversationType.GROUP,
|
||||||
websocketMessage.groupId,
|
websocketMessage.groupId,
|
||||||
websocketMessage.content
|
websocketMessage.content
|
||||||
|
|
@ -647,7 +651,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
|
|
||||||
// 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段
|
// 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段
|
||||||
const message = convertGroupMessage(websocketMessage, currentUserId)
|
const message = convertGroupMessage(websocketMessage, currentUserId)
|
||||||
conversationStore.insertMessage(
|
useMessageStore().insertMessage(
|
||||||
{
|
{
|
||||||
type: ImConversationType.GROUP,
|
type: ImConversationType.GROUP,
|
||||||
targetId: websocketMessage.groupId,
|
targetId: websocketMessage.groupId,
|
||||||
|
|
@ -669,7 +673,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
conversationStore.activeConversation?.targetId === websocketMessage.groupId
|
conversationStore.activeConversation?.targetId === websocketMessage.groupId
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
// 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零
|
// 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零
|
||||||
conversationStore.markActiveAsRead()
|
conversationStore.markConversationAsRead(
|
||||||
|
ImConversationType.GROUP,
|
||||||
|
websocketMessage.groupId
|
||||||
|
)
|
||||||
|
if (conversation) {
|
||||||
|
useMessageStore().markConversationMessagesRead(conversation)
|
||||||
|
}
|
||||||
if (MESSAGE_GROUP_READ_ENABLED) {
|
if (MESSAGE_GROUP_READ_ENABLED) {
|
||||||
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => {
|
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => {
|
||||||
console.warn('[IM WS] 自动已读上报失败', e)
|
console.warn('[IM WS] 自动已读上报失败', e)
|
||||||
|
|
@ -698,8 +708,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
if (!MESSAGE_GROUP_READ_ENABLED) {
|
if (!MESSAGE_GROUP_READ_ENABLED) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const conversationStore = useConversationStore()
|
useMessageStore().applyReadReceipt({
|
||||||
conversationStore.applyReadReceipt({
|
|
||||||
conversationType: ImConversationType.GROUP,
|
conversationType: ImConversationType.GROUP,
|
||||||
targetId: websocketMessage.groupId,
|
targetId: websocketMessage.groupId,
|
||||||
groupMessageId: websocketMessage.id,
|
groupMessageId: websocketMessage.id,
|
||||||
|
|
|
||||||
|
|
@ -47,13 +47,16 @@ export interface Conversation {
|
||||||
name: string // 展示名称(私聊=好友昵称;群聊=群名)
|
name: string // 展示名称(私聊=好友昵称;群聊=群名)
|
||||||
avatar: string // 头像
|
avatar: string // 头像
|
||||||
unreadCount: number // 未读数
|
unreadCount: number // 未读数
|
||||||
messages: Message[] // 消息列表
|
|
||||||
|
|
||||||
// ========== 最后一条消息事实索引 ==========
|
// ========== 最后一条消息事实索引 ==========
|
||||||
lastContent: string // 会话列表展示的最后一条消息摘要
|
lastContent: string // 会话列表展示的最后一条消息摘要
|
||||||
lastSendTime: number // 最后一条消息时间,用于排序
|
lastSendTime: number // 最后一条消息时间,用于排序
|
||||||
lastSenderId?: number // 发送人编号
|
lastSenderId?: number // 发送人编号
|
||||||
lastMessageType?: number // 消息类型,对齐 ImMessageType
|
lastMessageType?: number // 消息类型,对齐 ImMessageType
|
||||||
|
lastMessageId?: number // 最后一条服务端消息编号
|
||||||
|
lastClientMessageId?: string // 最后一条客户端消息编号
|
||||||
|
lastMessageStatus?: number // 最后一条消息状态
|
||||||
|
lastReceiptStatus?: number // 最后一条群回执状态
|
||||||
lastSelfSend?: boolean // 是否自己发的
|
lastSelfSend?: boolean // 是否自己发的
|
||||||
lastSenderDisplayName?: string // 发送人显示名快照——仅作 utils/user.getSenderDisplayName 实时算不出真名时的 fallback
|
lastSenderDisplayName?: string // 发送人显示名快照——仅作 utils/user.getSenderDisplayName 实时算不出真名时的 fallback
|
||||||
|
|
||||||
|
|
@ -68,7 +71,8 @@ export interface Conversation {
|
||||||
// 消息数据结构
|
// 消息数据结构
|
||||||
export interface Message {
|
export interface Message {
|
||||||
// ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO) ==========
|
// ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO) ==========
|
||||||
id: number // 服务端消息编号,发送中为 0
|
// TODO @AI:全局的 id 占位 0,是不是枚举下!!!
|
||||||
|
id?: number // 服务端消息编号,发送中为空
|
||||||
clientMessageId: string // 客户端消息编号,本地生成用于合并去重
|
clientMessageId: string // 客户端消息编号,本地生成用于合并去重
|
||||||
type: number // 消息类型,对齐 ImMessageType
|
type: number // 消息类型,对齐 ImMessageType
|
||||||
content: string // 消息内容,JSON 字符串
|
content: string // 消息内容,JSON 字符串
|
||||||
|
|
@ -90,23 +94,25 @@ export interface Message {
|
||||||
// 媒体消息内存中保留的原始 File;下划线前缀表示不进 JSON / 不持久化(IDB 恢复后必为 undefined)
|
// 媒体消息内存中保留的原始 File;下划线前缀表示不进 JSON / 不持久化(IDB 恢复后必为 undefined)
|
||||||
// 失败重试时按它重走上传;页面刷新后该字段丢失,恢复阶段直接 drop 整条消息
|
// 失败重试时按它重走上传;页面刷新后该字段丢失,恢复阶段直接 drop 整条消息
|
||||||
_localFile?: File
|
_localFile?: File
|
||||||
|
_ackMerging?: boolean // ack 合并中标记,不持久化
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ==================== IndexedDB 本地存储结构 ====================
|
||||||
* 会话索引项:基于 Conversation 派生,但剥离 messages 字段(消息按会话独立存到 messages key)
|
|
||||||
*
|
|
||||||
* Omit<T, K> 是 TS 内置工具类型:从类型 T 中剔除 K 指定的字段,得到剩余字段组成的新类型。
|
|
||||||
* 这里 `Omit<Conversation, 'messages'>` 等价于"Conversation 去掉 messages 字段后的版本",
|
|
||||||
* 与"Conversation 派生但少一个 messages 字段"的语义一致,不需要再手写一份重复结构。
|
|
||||||
*/
|
|
||||||
export type ConversationMeta = Omit<Conversation, 'messages'>
|
|
||||||
|
|
||||||
// 持久化的会话索引:游标 + 会话元数据列表,按用户 ID 分桶
|
export interface ConversationDO extends Conversation {
|
||||||
export interface ConversationStoreMeta {
|
clientConversationId: string // `${type}:${targetId}`
|
||||||
privateMessageMaxId: number // 私聊消息最大编号
|
}
|
||||||
groupMessageMaxId: number // 群聊消息最大编号
|
|
||||||
channelMessageMaxId?: number // 频道消息最大编号
|
export interface MessageDO extends Omit<Message, 'uploadProgress' | '_localFile' | '_ackMerging'> {
|
||||||
conversations: ConversationMeta[] // 会话索引(不含 messages)
|
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 { CommonStatusEnum } from '@/utils/constants'
|
||||||
import * as ManagerFacePackItemApi from '@/api/im/manager/face/item'
|
import * as ManagerFacePackItemApi from '@/api/im/manager/face/item'
|
||||||
import { probeImageSize } from '@/views/im/utils/image'
|
import { probeImageSize } from '@/views/im/utils/image'
|
||||||
|
import type { FormRules } from 'element-plus'
|
||||||
|
|
||||||
defineOptions({ name: 'ImManagerFacePackItemForm' })
|
defineOptions({ name: 'ImManagerFacePackItemForm' })
|
||||||
|
|
||||||
|
|
@ -99,7 +100,7 @@ const formData = ref({
|
||||||
sort: 0,
|
sort: 0,
|
||||||
status: CommonStatusEnum.ENABLE
|
status: CommonStatusEnum.ENABLE
|
||||||
})
|
})
|
||||||
const formRules = reactive({
|
const formRules = reactive<FormRules>({
|
||||||
url: [{ required: true, message: '表情图不能为空', trigger: 'change' }],
|
url: [{ required: true, message: '表情图不能为空', trigger: 'change' }],
|
||||||
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
|
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
|
||||||
// 宽高自动探测后允许手改,但提交前必须落 1-2048 范围;防止用户清空后 submit 让后端报错
|
// 宽高自动探测后允许手改,但提交前必须落 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 {
|
): MergeMessageItem {
|
||||||
const snapshot = senderSnapshots.get(message.senderId)
|
const snapshot = senderSnapshots.get(message.senderId)
|
||||||
return {
|
return {
|
||||||
messageId: message.id,
|
messageId: message.id || 0,
|
||||||
senderId: message.senderId,
|
senderId: message.senderId,
|
||||||
senderNickname: snapshot?.nickname ?? String(message.senderId),
|
senderNickname: snapshot?.nickname ?? String(message.senderId),
|
||||||
senderAvatar: snapshot?.avatar ?? '',
|
senderAvatar: snapshot?.avatar ?? '',
|
||||||
|
|
@ -555,7 +555,7 @@ export const removeQuotePayload = (content: string): string => {
|
||||||
/** 由 Message 派生 QuoteMessage 用于乐观渲染;ack 后会被服务端权威版本覆盖 */
|
/** 由 Message 派生 QuoteMessage 用于乐观渲染;ack 后会被服务端权威版本覆盖 */
|
||||||
export const buildQuoteFromMessage = (message: Message): QuoteMessage => {
|
export const buildQuoteFromMessage = (message: Message): QuoteMessage => {
|
||||||
return {
|
return {
|
||||||
messageId: message.id,
|
messageId: message.id || 0,
|
||||||
senderId: message.senderId,
|
senderId: message.senderId,
|
||||||
type: message.type,
|
type: message.type,
|
||||||
content: removeQuotePayload(message.content)
|
content: removeQuotePayload(message.content)
|
||||||
|
|
@ -882,7 +882,10 @@ export function resolveRtcCallTipSegments(message: {
|
||||||
}
|
}
|
||||||
if (message.type === ImMessageType.RTC_CALL_START) {
|
if (message.type === ImMessageType.RTC_CALL_START) {
|
||||||
return payload.inviterUserId
|
return payload.inviterUserId
|
||||||
? [tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)), tipText(' 发起了语音通话')]
|
? [
|
||||||
|
tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)),
|
||||||
|
tipText(' 发起了语音通话')
|
||||||
|
]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
if (message.type === ImMessageType.RTC_CALL_END) {
|
if (message.type === ImMessageType.RTC_CALL_END) {
|
||||||
|
|
|
||||||
|
|
@ -4,47 +4,23 @@ import { toRaw } from 'vue'
|
||||||
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
|
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IM 模块的 IndexedDB 实例(localforage 优先 IndexedDB,自动降级到 WebSQL / localStorage)
|
* IM 模块本地缓存实例(localforage 优先 IndexedDB,自动降级到 WebSQL / localStorage)
|
||||||
*
|
|
||||||
* 为什么不用 localStorage 直接存:
|
|
||||||
* 1. 配额:localStorage 整体上限 5~10MB,多会话长历史很容易撑爆
|
|
||||||
* 2. 写放大:localStorage 必须按 key 整体写入,单次写就是 MB 级序列化阻塞主线程
|
|
||||||
*
|
|
||||||
* 配套策略:会话与消息按 key 分桶(见 StorageKeys),让单次变更只重写最小粒度的 key;
|
|
||||||
* IndexedDB 默认配额一般是浏览器可用空间的 ~50%,远大于 localStorage,配合分桶才发挥效果
|
|
||||||
*/
|
*/
|
||||||
export const imStorage = localforage.createInstance({
|
export const imStorage = localforage.createInstance({
|
||||||
name: 'im',
|
name: 'im',
|
||||||
storeName: 'conversation',
|
storeName: 'conversation',
|
||||||
description: 'IM 会话索引与消息缓存'
|
description: 'IM 本地缓存'
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 存储 key 统一在此生成
|
* 存储 key 统一在此生成
|
||||||
*
|
*
|
||||||
* - 会话 / 好友 / 群相关业务数据走 imStorage(IndexedDB),key 都按 userId 分桶
|
* - 好友 / 群相关业务数据走 imStorage(IndexedDB),key 都按 userId 分桶
|
||||||
* - 轻量 UI 状态(侧边栏宽度)仍走 localStorage:体积小、跨 Tab 同步天然,没必要走 IndexedDB
|
* - 轻量 UI 状态(侧边栏宽度)仍走 localStorage:体积小、跨 Tab 同步天然,没必要走 IndexedDB
|
||||||
*
|
*
|
||||||
* 所有业务 key 都注入 userId:多账号切换按用户隔离避免数据互串;账号切换时只清 in-memory、IDB 数据保留——回切旧账号能秒开,不浪费已下载好友 / 群 / 成员快照
|
* 所有业务 key 都注入 userId:多账号切换按用户隔离避免数据互串;账号切换时只清 in-memory、IDB 数据保留——回切旧账号能秒开,不浪费已下载好友 / 群 / 成员快照
|
||||||
*/
|
*/
|
||||||
export const StorageKeys = {
|
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>
|
* 输入框草稿整桶:Record<`${type}:${targetId}`, DraftSnapshot>
|
||||||
*
|
*
|
||||||
|
|
@ -59,8 +35,7 @@ export const StorageKeys = {
|
||||||
/** 频道列表整桶;频道量级很小,整桶整写够用 */
|
/** 频道列表整桶;频道量级很小,整桶整写够用 */
|
||||||
channels: (userId: number | string) => `channels:${userId}`,
|
channels: (userId: number | string) => `channels:${userId}`,
|
||||||
/** 单群成员,按 groupId 分桶——单群可上百-千级,跟懒加载粒度对齐;群解散时物理删 */
|
/** 单群成员,按 groupId 分桶——单群可上百-千级,跟懒加载粒度对齐;群解散时物理删 */
|
||||||
groupMembers: (userId: number | string, groupId: number) =>
|
groupMembers: (userId: number | string, groupId: number) => `groupMembers:${userId}:${groupId}`,
|
||||||
`groupMembers:${userId}:${groupId}`,
|
|
||||||
|
|
||||||
/** 最近转发会话 key 列表(按 userId 分桶);ConversationPickerPanel 左栏顶部头像区使用 */
|
/** 最近转发会话 key 列表(按 userId 分桶);ConversationPickerPanel 左栏顶部头像区使用 */
|
||||||
recentForwardConversationKeys: (userId: number | string) =>
|
recentForwardConversationKeys: (userId: number | string) =>
|
||||||
|
|
@ -81,11 +56,40 @@ export function getCurrentUserId(): number {
|
||||||
|
|
||||||
/** IDB 写入:fire-and-forget */
|
/** IDB 写入:fire-and-forget */
|
||||||
export function setQuietly(key: string, value: unknown, errorLabel: string): void {
|
export function setQuietly(key: string, value: unknown, errorLabel: string): void {
|
||||||
// toRaw 拆 Vue / Pinia reactive Proxy——structuredClone 不接 Proxy 会抛 DataCloneError 静默丢盘
|
const raw = toStorageValue(value)
|
||||||
const raw = value && typeof value === 'object' ? toRaw(value) : value
|
|
||||||
void imStorage.setItem(key, raw).catch((e) => console.warn(errorLabel, e))
|
void imStorage.setItem(key, raw).catch((e) => console.warn(errorLabel, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeQuietly(key: string, errorLabel: string): void {
|
export function removeQuietly(key: string, errorLabel: string): void {
|
||||||
void imStorage.removeItem(key).catch((e) => console.warn(errorLabel, e))
|
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