✨ feat(im): 优化发送中的能力 v0.2:简化各种 kind、复用各种逻辑

pull/881/head
YunaiV 2026-05-06 08:46:33 +08:00
parent 957a63f8f4
commit b17f7a57e5
5 changed files with 258 additions and 165 deletions

View File

@ -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
}
}

View File

@ -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
)
)

View File

@ -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 重发

View File

@ -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)

View File

@ -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 ====================