✨ feat(im): 优化发送中的能力 v0.2:简化各种 kind、复用各种逻辑
parent
957a63f8f4
commit
b17f7a57e5
|
|
@ -4,23 +4,97 @@ import { useUserStore } from '@/store/modules/user'
|
|||
import { useConversationStore } from '../store/conversationStore'
|
||||
import { useMessageSender } from './useMessageSender'
|
||||
import { useMuteOverlay } from './useMuteOverlay'
|
||||
import { ImMessageStatus } from '../../utils/constants'
|
||||
import { ImMessageStatus, ImMessageType } from '../../utils/constants'
|
||||
import { getConversationKey } from '../../utils/conversation'
|
||||
import {
|
||||
generateClientMessageId,
|
||||
parseMessage,
|
||||
serializeMessage,
|
||||
withQuotePayload,
|
||||
type QuoteMessage
|
||||
type AudioMessage,
|
||||
type FileMessage,
|
||||
type ImageMessage,
|
||||
type QuoteMessage,
|
||||
type VideoMessage
|
||||
} from '../../utils/message'
|
||||
import type { Conversation, Message } from '../types'
|
||||
|
||||
/** 单次媒体上传的入参(image / file / voice 共用;video 走低层 helper 自行组装) */
|
||||
export interface UploadAndSendMediaOptions<P extends { quote?: QuoteMessage }> {
|
||||
/** 单条媒体 payload 联合(覆盖 IMAGE / FILE / VOICE / VIDEO 四种) */
|
||||
export type MediaPayload = ImageMessage | FileMessage | AudioMessage | VideoMessage
|
||||
|
||||
/**
|
||||
* 媒体特定的元数据上下文:首发 / 重传共用入参;不同 type 关心不同字段
|
||||
*
|
||||
* - voiceDuration:语音时长(秒),首发由 VoiceRecorder 给,重传从旧 AudioMessage.duration 取
|
||||
* - videoProbe:视频元信息(首发由 probeVideoFile 解出,重传从旧 VideoMessage 直接拷字段)
|
||||
* - videoCoverUrl:视频封面真实 URL;占位阶段用 blob,commit 用真实 URL,重传时旧值若是 blob 会被跳过
|
||||
*/
|
||||
export interface MediaTypeContext {
|
||||
voiceDuration?: number
|
||||
videoProbe?: { duration?: number; width?: number; height?: number }
|
||||
videoCoverUrl?: string
|
||||
}
|
||||
|
||||
interface MediaTypeHandler {
|
||||
/** 中文名,仅日志用(替代之前散落 9 处的 kind 字符串) */
|
||||
kind: string
|
||||
/** 由 file + url + context 生成 payload;占位时 url 是 blob URL,commit 时是真实 url */
|
||||
build: (file: File, url: string, context: MediaTypeContext) => MediaPayload
|
||||
/** 重传场景:从旧 content 提取 context(让重传不需要重做 probe / 重录语音) */
|
||||
extractResendContext: (oldContent: string) => MediaTypeContext
|
||||
}
|
||||
|
||||
/** 媒体类型注册表:image / file / voice / video 各自的 kind + 首发 / 重传共用的 build / extract */
|
||||
export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
|
||||
[ImMessageType.IMAGE]: {
|
||||
kind: '图片',
|
||||
build: (_file, url) => ({ url }) as ImageMessage,
|
||||
extractResendContext: () => ({})
|
||||
},
|
||||
[ImMessageType.FILE]: {
|
||||
kind: '文件',
|
||||
build: (file, url) => ({ url, name: file.name, size: file.size }) as FileMessage,
|
||||
extractResendContext: () => ({})
|
||||
},
|
||||
[ImMessageType.VOICE]: {
|
||||
kind: '语音',
|
||||
build: (_file, url, context) =>
|
||||
({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
|
||||
extractResendContext: (oldContent) => {
|
||||
const old = parseMessage<AudioMessage>(oldContent)
|
||||
return { voiceDuration: old?.duration ?? 0 }
|
||||
}
|
||||
},
|
||||
[ImMessageType.VIDEO]: {
|
||||
kind: '视频',
|
||||
build: (file, url, context) =>
|
||||
({
|
||||
url,
|
||||
coverUrl: context.videoCoverUrl,
|
||||
duration: context.videoProbe?.duration,
|
||||
width: context.videoProbe?.width,
|
||||
height: context.videoProbe?.height,
|
||||
size: file.size
|
||||
}) as VideoMessage,
|
||||
extractResendContext: (oldContent) => {
|
||||
const old = parseMessage<VideoMessage>(oldContent)
|
||||
// 旧 coverUrl 是 blob 说明上传期失败(cover 没传成功),不复用;真实 URL 直接复用,省一次封面上传
|
||||
const reuseCover = old?.coverUrl && !old.coverUrl.startsWith('blob:') ? old.coverUrl : undefined
|
||||
return {
|
||||
videoProbe: { duration: old?.duration, width: old?.width, height: old?.height },
|
||||
videoCoverUrl: reuseCover
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 单次媒体上传的入参(image / file / voice 走 uploadAndSendMedia;video 走低层 helper 自行组装) */
|
||||
export interface UploadAndSendMediaOptions {
|
||||
file: File
|
||||
type: number // 对齐 ImMessageType
|
||||
kind: string // 文案:「图片」/「文件」/「语音」,仅日志用
|
||||
/** 由 url 生成消息 payload;占位阶段传 blob URL,上传成功后再用真实 url 重生成 */
|
||||
buildPayload: (url: string) => P
|
||||
/** 对齐 ImMessageType;mediaTypeHandlers 必须有对应项 */
|
||||
type: number
|
||||
/** 媒体特定的元数据(如语音时长 / 视频元信息);不传按空对象处理 */
|
||||
context?: MediaTypeContext
|
||||
/** 引用消息(若有),写进 payload.quote */
|
||||
quote?: QuoteMessage
|
||||
/** 锁定起始会话,上传期间会话切走则放弃发送 */
|
||||
|
|
@ -101,6 +175,57 @@ export const useMediaUploader = () => {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 axios `onUploadProgress` 回调:用 closure 缓存上次百分比,未变化直接 return 不进 store
|
||||
*
|
||||
* XHR onProgress 大文件下每秒触发 10-50 次,但 Math.round 后百分比有大量重复(一秒内可能十几次同一个数字);
|
||||
* 在源头去重,能省掉 store 的 find + Object.assign + Vue reactivity 触发链
|
||||
*/
|
||||
const createUploadProgressHandler = (conversation: Conversation, clientMessageId: string) => {
|
||||
let lastPercent = -1
|
||||
return (event: ProgressEvent): void => {
|
||||
if (!event.total) {
|
||||
return
|
||||
}
|
||||
const percent = Math.round((event.loaded / event.total) * 100)
|
||||
if (percent === lastPercent) {
|
||||
return
|
||||
}
|
||||
lastPercent = percent
|
||||
conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
|
||||
uploadProgress: percent
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 取媒体类型中文名(仅日志用);未注册 type 退化为通用「媒体」 */
|
||||
const getMediaKind = (type: number): string => mediaTypeHandlers[type]?.kind ?? '媒体'
|
||||
|
||||
/**
|
||||
* 上传完成后的收口校验:会话仍是占位时锁定的那个 + 当前未被禁言;任一不满足 markMediaFailed + 返回 false
|
||||
*
|
||||
* image / file / voice / video 链路都要在「拿到真实 url 后、调 sendRaw 之前」过一遍这两道
|
||||
*/
|
||||
const verifyMediaUploadStillAllowed = (
|
||||
conversation: Conversation,
|
||||
startKey: string,
|
||||
type: number,
|
||||
clientMessageId: string
|
||||
): boolean => {
|
||||
const activeConversation = conversationStore.activeConversation
|
||||
if (!activeConversation || getConversationKey(activeConversation) !== startKey) {
|
||||
console.warn(`[IM] ${getMediaKind(type)}上传期间切换了会话,放弃发送`, { startKey })
|
||||
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||
return false
|
||||
}
|
||||
if (muteOverlay.value) {
|
||||
console.warn(`[IM] ${getMediaKind(type)}上传期间被禁言,放弃发送`, { startKey })
|
||||
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 占位完成后用真实 url 替换 content,再走 sendRaw 完成发送
|
||||
*
|
||||
|
|
@ -118,30 +243,38 @@ export const useMediaUploader = () => {
|
|||
opts.clientMessageId,
|
||||
{ content: opts.realContent }
|
||||
)
|
||||
// 显式传 conversation 而非依赖 sendRaw 内部取 active:
|
||||
// verifyMediaUploadStillAllowed 与 sendRaw 之间存在微秒窗口,期间用户切会话也能保证发到原会话
|
||||
await sendRaw(opts.type, opts.realContent, {
|
||||
existingClientMessageId: opts.clientMessageId,
|
||||
targetId: opts.conversation.targetId
|
||||
targetId: opts.conversation.targetId,
|
||||
conversation: opts.conversation
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传媒体文件并发送消息(高层入口;image / file / voice 用)
|
||||
* 上传媒体文件并发送消息(高层入口;image / file / voice 用,video 走低层 helper 自行组装)
|
||||
*
|
||||
* @returns 占位消息的 clientMessageId(调用方按需用于后续 patch / 移除;上传失败时占位仍保留为 FAILED 态)
|
||||
*/
|
||||
const uploadAndSendMedia = async <P extends { quote?: QuoteMessage }>(
|
||||
opts: UploadAndSendMediaOptions<P>
|
||||
): Promise<string> => {
|
||||
const uploadAndSendMedia = async (opts: UploadAndSendMediaOptions): Promise<string> => {
|
||||
const { conversation } = opts
|
||||
const handler = mediaTypeHandlers[opts.type]
|
||||
if (!handler) {
|
||||
console.warn('[IM] uploadAndSendMedia 收到未注册的媒体类型', { type: opts.type })
|
||||
return ''
|
||||
}
|
||||
const startKey = getConversationKey(conversation)
|
||||
const context = opts.context ?? {}
|
||||
const buildContent = (url: string): string =>
|
||||
serializeMessage(withQuotePayload(handler.build(opts.file, url, context), opts.quote))
|
||||
|
||||
// 1. 立即占位
|
||||
const { clientMessageId } = insertMediaPlaceholder({
|
||||
file: opts.file,
|
||||
type: opts.type,
|
||||
conversation,
|
||||
buildContent: (blobUrl) =>
|
||||
serializeMessage(withQuotePayload<P>(opts.buildPayload(blobUrl), opts.quote))
|
||||
buildContent
|
||||
})
|
||||
|
||||
// 2. 上传:进度回调 patch uploadProgress;失败保留 _localFile 供重试
|
||||
|
|
@ -149,18 +282,13 @@ export const useMediaUploader = () => {
|
|||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', opts.file)
|
||||
const res = (await updateFile(form, (event: ProgressEvent) => {
|
||||
if (!event.total) {
|
||||
return
|
||||
}
|
||||
const percent = Math.round((event.loaded / event.total) * 100)
|
||||
conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
|
||||
uploadProgress: percent
|
||||
})
|
||||
})) as { data?: string }
|
||||
const res = (await updateFile(
|
||||
form,
|
||||
createUploadProgressHandler(conversation, clientMessageId)
|
||||
)) as { data?: string }
|
||||
url = res?.data
|
||||
} catch (e) {
|
||||
console.error(`[IM] ${opts.kind}上传失败`, e)
|
||||
console.error(`[IM] ${handler.kind}上传失败`, e)
|
||||
}
|
||||
if (!url) {
|
||||
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||
|
|
@ -168,30 +296,27 @@ export const useMediaUploader = () => {
|
|||
}
|
||||
|
||||
// 3. 上传期间会话切换 / 用户登出 / 被禁言:任一情况都放弃发送,占位置 FAILED
|
||||
const activeConversation = conversationStore.activeConversation
|
||||
if (!activeConversation || getConversationKey(activeConversation) !== startKey) {
|
||||
console.warn(`[IM] ${opts.kind}上传期间切换了会话,放弃发送`, { startKey })
|
||||
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||
return clientMessageId
|
||||
}
|
||||
if (muteOverlay.value) {
|
||||
console.warn(`[IM] ${opts.kind}上传期间被禁言,放弃发送`, { startKey })
|
||||
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||
if (!verifyMediaUploadStillAllowed(conversation, startKey, opts.type, clientMessageId)) {
|
||||
return clientMessageId
|
||||
}
|
||||
|
||||
// 4. patch content + sendRaw 收尾
|
||||
const realContent = serializeMessage(
|
||||
withQuotePayload<P>(opts.buildPayload(url), opts.quote)
|
||||
)
|
||||
await commitMediaPlaceholder({
|
||||
type: opts.type,
|
||||
conversation,
|
||||
clientMessageId,
|
||||
realContent
|
||||
realContent: buildContent(url)
|
||||
})
|
||||
return clientMessageId
|
||||
}
|
||||
|
||||
return { uploadAndSendMedia, insertMediaPlaceholder, markMediaFailed, commitMediaPlaceholder }
|
||||
return {
|
||||
uploadAndSendMedia,
|
||||
insertMediaPlaceholder,
|
||||
markMediaFailed,
|
||||
commitMediaPlaceholder,
|
||||
createUploadProgressHandler,
|
||||
verifyMediaUploadStillAllowed,
|
||||
getMediaKind
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,16 +170,15 @@ import { useFriendStore } from '@/views/im/home/store/friendStore'
|
|||
import { useDraftStore } from '@/views/im/home/store/draftStore'
|
||||
import { getMemberDisplayName } from '@/views/im/utils/user'
|
||||
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
|
||||
import { useMediaUploader } from '@/views/im/home/composables/useMediaUploader'
|
||||
import {
|
||||
mediaTypeHandlers,
|
||||
useMediaUploader
|
||||
} from '@/views/im/home/composables/useMediaUploader'
|
||||
import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay'
|
||||
import { getConversationKey } from '@/views/im/utils/conversation'
|
||||
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
|
||||
import {
|
||||
serializeMessage,
|
||||
type ImageMessage,
|
||||
type FileMessage,
|
||||
type AudioMessage,
|
||||
type VideoMessage,
|
||||
type QuoteMessage,
|
||||
withQuotePayload
|
||||
} from '@/views/im/utils/message'
|
||||
|
|
@ -198,8 +197,14 @@ const groupStore = useGroupStore()
|
|||
const friendStore = useFriendStore()
|
||||
const draftStore = useDraftStore()
|
||||
const { send } = useMessageSender()
|
||||
const { uploadAndSendMedia, insertMediaPlaceholder, markMediaFailed, commitMediaPlaceholder } =
|
||||
useMediaUploader()
|
||||
const {
|
||||
uploadAndSendMedia,
|
||||
insertMediaPlaceholder,
|
||||
markMediaFailed,
|
||||
commitMediaPlaceholder,
|
||||
createUploadProgressHandler,
|
||||
verifyMediaUploadStillAllowed
|
||||
} = useMediaUploader()
|
||||
|
||||
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
|
||||
const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
|
||||
|
|
@ -573,16 +578,6 @@ function consumeReply(): QuoteMessage | undefined {
|
|||
return quote
|
||||
}
|
||||
|
||||
/** 校验当前激活会话仍是 startKey;切走了记日志 + 返回 false,调用方放弃发送 */
|
||||
function isStillSameConversation(startKey: string, kind: string): boolean {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation || getConversationKey(conversation) !== startKey) {
|
||||
console.warn(`[IM] ${kind}上传期间切换了会话,放弃发送`, { startKey })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ==================== 表情 ====================
|
||||
const emojiVisible = ref(false)
|
||||
/** 切换表情面板;打开时互斥关掉语音面板 */
|
||||
|
|
@ -826,29 +821,25 @@ async function uploadAndSendImage(file: File) {
|
|||
if (!context) {
|
||||
return
|
||||
}
|
||||
await uploadAndSendMedia<ImageMessage>({
|
||||
await uploadAndSendMedia({
|
||||
file,
|
||||
type: ImMessageType.IMAGE,
|
||||
kind: '图片',
|
||||
quote: context.quote,
|
||||
conversation: context.conversation,
|
||||
buildPayload: (url) => ({ url })
|
||||
conversation: context.conversation
|
||||
})
|
||||
}
|
||||
|
||||
/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
|
||||
/** 上传并发送 FILE 消息;payload 由 mediaTypeHandlers[FILE] 自动拼 url + name + size */
|
||||
async function uploadAndSendFile(file: File) {
|
||||
const context = prepareMediaUpload()
|
||||
if (!context) {
|
||||
return
|
||||
}
|
||||
await uploadAndSendMedia<FileMessage>({
|
||||
await uploadAndSendMedia({
|
||||
file,
|
||||
type: ImMessageType.FILE,
|
||||
kind: '文件',
|
||||
quote: context.quote,
|
||||
conversation: context.conversation,
|
||||
buildPayload: (url) => ({ url, name: file.name, size: file.size })
|
||||
conversation: context.conversation
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -879,20 +870,19 @@ function openVoice() {
|
|||
voiceVisible.value = true
|
||||
emojiVisible.value = false
|
||||
}
|
||||
/** VoiceRecorder 录完回传 blob,包成 webm File 后走通用 uploadAndSendMedia */
|
||||
/** VoiceRecorder 录完回传 blob,包成 webm File 后走通用 uploadAndSendMedia;duration 走 context */
|
||||
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
|
||||
const context = prepareMediaUpload()
|
||||
if (!context) {
|
||||
return
|
||||
}
|
||||
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
|
||||
await uploadAndSendMedia<AudioMessage>({
|
||||
await uploadAndSendMedia({
|
||||
file,
|
||||
type: ImMessageType.VOICE,
|
||||
kind: '语音',
|
||||
quote: context.quote,
|
||||
conversation: context.conversation,
|
||||
buildPayload: (url) => ({ url, duration: payload.duration })
|
||||
context: { voiceDuration: payload.duration }
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1008,12 +998,11 @@ async function uploadAndSendVideo(file: File) {
|
|||
const startKey = getConversationKey(conversation)
|
||||
|
||||
// 1. 立即占位:blob URL 同时作 url + coverUrl 让 <video> 渲染首帧;_localFile 留 file 供失败重试
|
||||
// payload 拼装走 mediaTypeHandlers[VIDEO].build 与 commit 阶段共享同一份逻辑
|
||||
const videoHandler = mediaTypeHandlers[ImMessageType.VIDEO]!
|
||||
const buildPlaceholderContent = (blobUrl: string): string =>
|
||||
serializeMessage(
|
||||
withQuotePayload<VideoMessage>(
|
||||
{ url: blobUrl, coverUrl: blobUrl, size: file.size },
|
||||
replyQuote
|
||||
)
|
||||
withQuotePayload(videoHandler.build(file, blobUrl, { videoCoverUrl: blobUrl }), replyQuote)
|
||||
)
|
||||
const { clientMessageId } = insertMediaPlaceholder({
|
||||
file,
|
||||
|
|
@ -1023,23 +1012,21 @@ async function uploadAndSendVideo(file: File) {
|
|||
})
|
||||
|
||||
// 2. 三路并行起跑(probe 与两条上传无依赖,封面上传等 probe 出 cover 后立即接力)
|
||||
// 2.1 视频本体上传:进度回调 patch uploadProgress;立即 catch 兜底为 url=undefined,由 step 3 拿不到 url 时收尾
|
||||
// 2.1 视频本体上传:async IIFE 包一层让 await 显式可见(lint 不再误判 floating promise),
|
||||
// 失败兜底为 url=undefined,由 step 3 拿不到 url 时收尾
|
||||
const videoForm = new FormData()
|
||||
videoForm.append('file', file)
|
||||
const videoUploadPromise = (
|
||||
updateFile(videoForm, (event: ProgressEvent) => {
|
||||
if (!event.total) {
|
||||
return
|
||||
}
|
||||
const percent = Math.round((event.loaded / event.total) * 100)
|
||||
conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
|
||||
uploadProgress: percent
|
||||
})
|
||||
}) as Promise<{ data?: string }>
|
||||
).catch((e) => {
|
||||
console.warn('[IM] 视频本体上传失败', e)
|
||||
return { data: undefined as string | undefined }
|
||||
})
|
||||
const videoUploadPromise: Promise<{ data?: string }> = (async () => {
|
||||
try {
|
||||
return (await updateFile(
|
||||
videoForm,
|
||||
createUploadProgressHandler(conversation, clientMessageId)
|
||||
)) as { data?: string }
|
||||
} catch (e) {
|
||||
console.warn('[IM] 视频本体上传失败', e)
|
||||
return { data: undefined }
|
||||
}
|
||||
})()
|
||||
// 2.2 probe 拿元信息 + 封面 blob:解码失败降级为空 probe,不阻断视频上传
|
||||
const probePromise = probeVideoFile(file).catch((e): VideoProbe => {
|
||||
console.warn('[IM] 视频元信息加载失败,降级为仅 url + size', e)
|
||||
|
|
@ -1076,28 +1063,18 @@ async function uploadAndSendVideo(file: File) {
|
|||
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||
return
|
||||
}
|
||||
// 3.3 校验会话仍是发送时锁定的那个(视频链路耗时长,这个窗口很实际)
|
||||
if (!isStillSameConversation(startKey, '视频')) {
|
||||
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||
return
|
||||
}
|
||||
// 3.4 视频上传期间被禁言也要拦:链路最长,最容易踩到 muteOverlay 期间触发
|
||||
if (muteOverlay.value) {
|
||||
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||
// 3.3 上传后会话校验 + muteOverlay 复查(与 useMediaUploader.uploadAndSendMedia 同一道)
|
||||
if (!verifyMediaUploadStillAllowed(conversation, startKey, ImMessageType.VIDEO, clientMessageId)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 拼真实 VideoMessage payload,patch 进占位 + 走 sendRaw 复用占位发送
|
||||
const realContent = serializeMessage(
|
||||
withQuotePayload<VideoMessage>(
|
||||
{
|
||||
url,
|
||||
coverUrl,
|
||||
duration: probe.duration,
|
||||
width: probe.width,
|
||||
height: probe.height,
|
||||
size: file.size
|
||||
},
|
||||
withQuotePayload(
|
||||
videoHandler.build(file, url, {
|
||||
videoProbe: { duration: probe.duration, width: probe.width, height: probe.height },
|
||||
videoCoverUrl: coverUrl
|
||||
}),
|
||||
replyQuote
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -131,10 +131,7 @@
|
|||
<div
|
||||
v-else-if="isVoice && voicePayload"
|
||||
class="relative flex gap-2 items-center min-w-[120px] px-3.5 py-2.5 rounded-lg cursor-pointer"
|
||||
:class="[
|
||||
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
|
||||
message.selfSend ? 'bg-[#95ec69]' : 'bg-[var(--el-fill-color-light)]'
|
||||
]"
|
||||
:class="bubbleClass('voice')"
|
||||
@click="handleVoiceClick"
|
||||
>
|
||||
<Icon
|
||||
|
|
@ -317,7 +314,7 @@ import {
|
|||
} from '@/views/im/utils/user'
|
||||
import { useImUiStore } from '../../../../store/uiStore'
|
||||
import { useMessageSender } from '../../../../composables/useMessageSender'
|
||||
import { useMediaUploader } from '../../../../composables/useMediaUploader'
|
||||
import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader'
|
||||
import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
|
||||
import type { Message } from '../../../../types'
|
||||
import MessageReadStatus from './MessageReadStatus.vue'
|
||||
|
|
@ -873,62 +870,25 @@ async function handleResend() {
|
|||
const message = props.message
|
||||
const file = message._localFile
|
||||
|
||||
// 媒体类型 + _localFile 在 → 重走 uploadAndSendMedia;按 type 分发 buildPayload,旧元数据从 content 解出复用
|
||||
// 媒体类型 + _localFile 在 → 重走 uploadAndSendMedia;type 分发 + 旧元数据复用统一在 mediaTypeHandlers 表里
|
||||
if (isMediaMessageType(message.type) && file) {
|
||||
const oldQuote = getQuoteFromMessage(message.content) ?? undefined
|
||||
conversationStore.removeMessage(conversation.type, conversation.targetId, {
|
||||
id: message.id,
|
||||
clientMessageId: message.clientMessageId
|
||||
})
|
||||
if (message.type === ImMessageType.IMAGE) {
|
||||
await uploadAndSendMedia<ImageMessage>({
|
||||
const handler = mediaTypeHandlers[message.type]
|
||||
if (handler) {
|
||||
const oldQuote = getQuoteFromMessage(message.content) ?? undefined
|
||||
const context = handler.extractResendContext(message.content)
|
||||
conversationStore.removeMessage(conversation.type, conversation.targetId, {
|
||||
id: message.id,
|
||||
clientMessageId: message.clientMessageId
|
||||
})
|
||||
await uploadAndSendMedia({
|
||||
file,
|
||||
type: ImMessageType.IMAGE,
|
||||
kind: '图片',
|
||||
type: message.type,
|
||||
quote: oldQuote,
|
||||
conversation,
|
||||
buildPayload: (url) => ({ url })
|
||||
})
|
||||
} else if (message.type === ImMessageType.FILE) {
|
||||
await uploadAndSendMedia<FileMessage>({
|
||||
file,
|
||||
type: ImMessageType.FILE,
|
||||
kind: '文件',
|
||||
quote: oldQuote,
|
||||
conversation,
|
||||
buildPayload: (url) => ({ url, name: file.name, size: file.size })
|
||||
})
|
||||
} else if (message.type === ImMessageType.VOICE) {
|
||||
const oldVoice = parseMessage<AudioMessage>(message.content)
|
||||
await uploadAndSendMedia<AudioMessage>({
|
||||
file,
|
||||
type: ImMessageType.VOICE,
|
||||
kind: '语音',
|
||||
quote: oldQuote,
|
||||
conversation,
|
||||
buildPayload: (url) => ({ url, duration: oldVoice?.duration ?? 0 })
|
||||
})
|
||||
} else if (message.type === ImMessageType.VIDEO) {
|
||||
const oldVideo = parseMessage<VideoMessage>(message.content)
|
||||
// 视频不重 probe + 重传 cover,沿用旧 coverUrl;旧 coverUrl 是 blob 时(占位失败)丢掉,让接收端无 poster 但仍可播
|
||||
const reuseCover = oldVideo?.coverUrl?.startsWith('blob:') ? undefined : oldVideo?.coverUrl
|
||||
await uploadAndSendMedia<VideoMessage>({
|
||||
file,
|
||||
type: ImMessageType.VIDEO,
|
||||
kind: '视频',
|
||||
quote: oldQuote,
|
||||
conversation,
|
||||
buildPayload: (url) => ({
|
||||
url,
|
||||
coverUrl: reuseCover,
|
||||
duration: oldVideo?.duration,
|
||||
width: oldVideo?.width,
|
||||
height: oldVideo?.height,
|
||||
size: file.size
|
||||
})
|
||||
context
|
||||
})
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 文本类型 / 媒体类型但 _localFile 已丢:原 content 走 sendRaw 重发
|
||||
|
|
|
|||
|
|
@ -605,6 +605,21 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
if (!message) {
|
||||
return
|
||||
}
|
||||
// 值未变就早返回:onUploadProgress 高频回调里同 percent 重复 patch 时直接跳过响应式更新链
|
||||
// (createUploadProgressHandler 在源头已去重;这里是最后兜底,对 patch.uploadProgress / status 等字段都生效)
|
||||
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) {
|
||||
return
|
||||
}
|
||||
// 替换 content 时 revoke 旧 blob URL,与 ackMessage 同语义
|
||||
if (patch.content && patch.content !== message.content) {
|
||||
revokeBlobUrlsInContent(message.content)
|
||||
|
|
|
|||
|
|
@ -106,19 +106,35 @@ export const parseMessage = <T>(content: string): T | null => {
|
|||
export const serializeMessage = <T>(payload: T): string => JSON.stringify(payload)
|
||||
|
||||
/**
|
||||
* 释放 content 中所有 blob: URL 的内存映射
|
||||
* 媒体 payload 里可能包含 blob URL 的字段(图片/文件/视频/语音都对齐这套 url 字段命名)
|
||||
*
|
||||
* 媒体上传链路占位时用 URL.createObjectURL(file) 当临时 url 写进 content;
|
||||
* ack / 重发 / 删除消息时调本函数把映射释放,避免 File 对象在浏览器内存里悬空(视频几百 MB 很伤)
|
||||
* 跟随 ImageMessage / VideoMessage / FileMessage / AudioMessage interface 定义同步:
|
||||
* - url:主体资源(占位时是 blob URL,ack 后是真实 URL)
|
||||
* - coverUrl:视频封面(占位时跟 url 同 blob,cover 上传成功后是真实 URL)
|
||||
* - thumbnailUrl:图片缩略图(当前未占位时使用 blob,预留)
|
||||
*/
|
||||
const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const
|
||||
|
||||
/**
|
||||
* 释放 content 中媒体 payload 字段上的 blob URL 内存映射
|
||||
*
|
||||
* 仅扫描 url / coverUrl / thumbnailUrl 三个已知字段,避免 regex 全文 grep 误伤 quote.content 里嵌套的同名 blob URL。
|
||||
* 仅对当前 document 内创建的 blob URL 有效;IndexedDB 恢复出来的旧 blob URL 已随旧 document 失效,调它无害但无意义
|
||||
*/
|
||||
export const revokeBlobUrlsInContent = (content: string): void => {
|
||||
if (!content || !content.includes('blob:')) {
|
||||
return
|
||||
}
|
||||
const matches = content.match(/blob:[^"'\s)]+/g)
|
||||
matches?.forEach((url) => URL.revokeObjectURL(url))
|
||||
const payload = parseMessage<Record<string, unknown>>(content)
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
for (const field of MEDIA_BLOB_URL_FIELDS) {
|
||||
const value = payload[field]
|
||||
if (typeof value === 'string' && value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 引用消息 helper ====================
|
||||
|
|
|
|||
Loading…
Reference in New Issue