| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { Conversation, Message } from '../types' |
| | | |
| | | import type { AxiosProgressEvent } from '#/api/infra/file' |
| | | |
| | | import { isOpenableUrl } from '#/packages/utils/src' |
| | | |
| | | import { message } from 'ant-design-vue' |
| | | |
| | | import { uploadFile } from '#/api/infra/file' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { |
| | | MESSAGE_FILE_MAX_MB, |
| | | MESSAGE_IMAGE_MAX_MB, |
| | | MESSAGE_VIDEO_MAX_MB, |
| | | MESSAGE_VOICE_MAX_MB |
| | | } from '../../utils/config' |
| | | import { ImContentType, ImMessageStatus } from '../../utils/constants' |
| | | import { getConversationKey } from '../../utils/conversation' |
| | | import { |
| | | type AudioMessage, |
| | | BLOB_URL_PREFIX, |
| | | type FileMessage, |
| | | generateClientMessageId, |
| | | type ImageMessage, |
| | | parseMessage, |
| | | type QuoteMessage, |
| | | serializeMessage, |
| | | type VideoMessage, |
| | | withQuotePayload |
| | | } from '../../utils/message' |
| | | import { useConversationStore } from '../store/conversationStore' |
| | | import { useMessageStore } from '../store/messageStore' |
| | | import { useMessageSender } from './useMessageSender' |
| | | import { useMuteOverlay } from './useMuteOverlay' |
| | | |
| | | type UploadProgressEvent = Parameters<NonNullable<AxiosProgressEvent>>[0] |
| | | |
| | | /** åæ¡åªä½ payload èåï¼è¦ç IMAGE / FILE / VOICE / VIDEO åç§ï¼ */ |
| | | export type MediaPayload = AudioMessage | FileMessage | ImageMessage | VideoMessage |
| | | |
| | | /** |
| | | * åªä½ç¹å®çå
æ°æ®ä¸ä¸æï¼é¦å / éä¼ å
±ç¨å
¥åï¼ä¸å type å
³å¿ä¸ååæ®µ |
| | | * |
| | | * - voiceDurationï¼è¯é³æ¶é¿ï¼ç§ï¼ï¼é¦åç± VoiceRecorder ç»ï¼éä¼ ä»æ§ AudioMessage.duration å |
| | | * - videoProbeï¼è§é¢å
ä¿¡æ¯ï¼é¦åç± probeVideoFile è§£åºï¼éä¼ ä»æ§ VideoMessage ç´æ¥æ·åæ®µï¼ |
| | | * - videoCoverUrlï¼è§é¢å°é¢çå® URLï¼å ä½é¶æ®µä¸è®¾ï¼é¿å
ä¼ blob å½ poster å¨é¨åæµè§å¨éåï¼ï¼commit é¶æ®µç± cover ä¸ä¼ ç»æå¡«å
¥ï¼éä¼ æ¶ä»æ§ VideoMessage.coverUrl å¤ç¨ï¼æ§å¼è¥æ¯ blob ä¼è¢«è·³è¿ |
| | | */ |
| | | export interface MediaTypeContext { |
| | | voiceDuration?: number |
| | | videoProbe?: { duration?: number; height?: number; width?: number; } |
| | | videoCoverUrl?: string |
| | | } |
| | | |
| | | export 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>> = { |
| | | [ImContentType.IMAGE]: { |
| | | kind: 'å¾ç', |
| | | build: (_file, url) => ({ url }) as ImageMessage, |
| | | extractResendContext: () => ({}) |
| | | }, |
| | | [ImContentType.FILE]: { |
| | | kind: 'æä»¶', |
| | | build: (file, url) => ({ url, name: file.name, size: file.size }) as FileMessage, |
| | | extractResendContext: () => ({}) |
| | | }, |
| | | [ImContentType.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 } |
| | | } |
| | | }, |
| | | [ImContentType.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_URL_PREFIX) ? 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 |
| | | /** å¯¹é½ ImContentTypeï¼mediaTypeHandlers å¿
é¡»æå¯¹åºé¡¹ */ |
| | | type: number |
| | | /** åªä½ç¹å®çå
æ°æ®ï¼å¦è¯é³æ¶é¿ / è§é¢å
ä¿¡æ¯ï¼ï¼ä¸ä¼ æç©ºå¯¹è±¡å¤ç */ |
| | | context?: MediaTypeContext |
| | | /** å¼ç¨æ¶æ¯ï¼è¥æï¼ï¼åè¿ payload.quote */ |
| | | quote?: QuoteMessage |
| | | /** éå®èµ·å§ä¼è¯ï¼ä¸ä¼ æé´ä¼è¯åèµ°åæ¾å¼åé */ |
| | | conversation: Conversation |
| | | /** éè¯å·²æå 使¶æ¯æ¶å¤ç¨ç客æ·ç«¯æ¶æ¯ç¼å· */ |
| | | existingClientMessageId?: string |
| | | } |
| | | |
| | | /** |
| | | * åªä½ä¸ä¼ + åé composableï¼image / file / voice / video å
±ç¨åºå± helperï¼ |
| | | * |
| | | * ä¸ useMessageSender.sendRaw çãå
å请æ±å ackãä¸åï¼åªä½é¾è·¯å¿
é¡»ãå
å ä½åä¸ä¼ ãï¼ |
| | | * 1. ç«å³ insertMessage åå
¥å 使¶æ¯ï¼status=SENDINGãcontent ç¨ blob URLã_localFile å
åç Fileï¼ |
| | | * 2. uploadFile ä¸ä¼ ï¼onUploadProgress åè° patchMessage æ´æ° uploadProgressï¼UI 宿¶æ¾ç¤ºè¿åº¦æ¡ |
| | | * 3. ä¸ä¼ æååç¨çå® url éç contentï¼patchMessage æ¿æ¢ï¼æ§ blob URL ç± store èªå¨ revoke |
| | | * 4. èµ° sendRaw(existingClientMessageId) å¤ç¨å ä½åé请æ±ï¼é¿å
é夿å
¥ä¸¤æ¡ |
| | | * |
| | | * ä»»æå¤±è´¥ææ¶æ¯ç¶æç½® FAILEDï¼MessageItem ä¸ç¹éè¯åèµ°ä¸æ¬¡æ¬å½æ°ï¼_localFile è¿å¨å
åå°±è¡ï¼ |
| | | */ |
| | | /** æå
å®¹ç±»åæ å°ä½ç§¯ä¸éï¼MBï¼ï¼æªè¯å«ç±»åè¿å 0 表示ä¸é */ |
| | | function resolveMediaMaxMb(type: number): number { |
| | | switch (type) { |
| | | case ImContentType.FILE: { |
| | | return MESSAGE_FILE_MAX_MB |
| | | } |
| | | case ImContentType.IMAGE: { |
| | | return MESSAGE_IMAGE_MAX_MB |
| | | } |
| | | case ImContentType.VIDEO: { |
| | | return MESSAGE_VIDEO_MAX_MB |
| | | } |
| | | case ImContentType.VOICE: { |
| | | return MESSAGE_VOICE_MAX_MB |
| | | } |
| | | default: { |
| | | return 0 |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** æ ¡éªåªä½æä»¶å¤§å°æ¯å¦è¶
è¿å
容类åä¸éï¼è¶
é触å warn å¹¶è¿å falseï¼è°ç¨æ¹ä¸åºè¿å
¥å ä½ / ä¸ä¼ é¾è·¯ */ |
| | | export function ensureMediaSizeWithinLimit( |
| | | file: File, |
| | | type: number, |
| | | warn: (text: string) => void |
| | | ): boolean { |
| | | const maxMb = resolveMediaMaxMb(type) |
| | | if (maxMb && file.size > maxMb * 1024 * 1024) { |
| | | warn(`æä»¶å¤§å°è¶
è¿ä¸é ${maxMb}MBï¼è¯·å缩ååå`) |
| | | return false |
| | | } |
| | | return true |
| | | } |
| | | |
| | | export const useMediaUploader = () => { |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | const muteOverlay = useMuteOverlay() |
| | | const { sendRaw } = useMessageSender() |
| | | |
| | | /** |
| | | * ç«å³åå
¥åªä½å 使¶æ¯ï¼ä½å± helperï¼image/file/voice èµ° uploadAndSendMedia å
è£
ï¼video ç´æ¥ç¨æ¬å½æ°ï¼ |
| | | * |
| | | * ç¨ createObjectURL(file) çæä¸´æ¶ blob URL åç» buildContentï¼å ä½ status=SENDING + uploadProgress=0ï¼ |
| | | * file æå¨ _localFile ä¸ä¾å¤±è´¥éè¯æ¶éèµ°ä¸ä¼ |
| | | */ |
| | | const insertMediaPlaceholder = (opts: { |
| | | buildContent: (blobUrl: string) => string |
| | | conversation: Conversation |
| | | existingClientMessageId?: string |
| | | file: File |
| | | type: number |
| | | }): { blobUrl: string; clientMessageId: string; } => { |
| | | const { conversation } = opts |
| | | const blobUrl = URL.createObjectURL(opts.file) |
| | | const clientMessageId = opts.existingClientMessageId || generateClientMessageId() |
| | | if (opts.existingClientMessageId) { |
| | | messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, { |
| | | content: opts.buildContent(blobUrl), |
| | | status: ImMessageStatus.SENDING, |
| | | uploadProgress: 0, |
| | | _localFile: opts.file |
| | | }) |
| | | return { clientMessageId, blobUrl } |
| | | } |
| | | const placeholder: Message = { |
| | | clientMessageId, |
| | | type: opts.type, |
| | | content: opts.buildContent(blobUrl), |
| | | status: ImMessageStatus.SENDING, |
| | | sendTime: Date.now(), |
| | | senderId: getCurrentUserId(), |
| | | targetId: conversation.targetId, |
| | | selfSend: true, |
| | | uploadProgress: 0, |
| | | _localFile: opts.file |
| | | } |
| | | void messageStore.insertMessage( |
| | | { |
| | | type: conversation.type, |
| | | targetId: conversation.targetId, |
| | | name: conversation.name || String(conversation.targetId), |
| | | avatar: conversation.avatar || '' |
| | | }, |
| | | placeholder |
| | | ).catch(() => undefined) |
| | | return { clientMessageId, blobUrl } |
| | | } |
| | | |
| | | /** |
| | | * æå 使¶æ¯ç½®ä¸º FAILEDï¼ä¸ä¼ 失败 / ä¼è¯åèµ° / ç¦è¨æå°ç¹ çåºæ¯ç»ä¸æ¶å°¾ï¼ |
| | | * |
| | | * åæ¶æ¸
æ uploadProgress ââ 失败å MessageItem ç isUploading ä¸åå½ä¸ï¼è¿åº¦é®ç½© / æä»¶ç¹å»ç¦ç¨ / è¯é³ loading æå¶ é½åæ¥è§£é¤ï¼ |
| | | * _localFile ä¿çï¼è®©ç¨æ·ç¹éè¯å¯ä»¥èµ° uploadAndSendMedia éä¼ |
| | | */ |
| | | const markMediaFailed = ( |
| | | conversationType: number, |
| | | targetId: number, |
| | | clientMessageId: string |
| | | ): void => { |
| | | messageStore.patchMessage(conversationType, targetId, clientMessageId, { |
| | | status: ImMessageStatus.FAILED, |
| | | uploadProgress: undefined |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * çæ 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: UploadProgressEvent): void => { |
| | | if (!event.total) { |
| | | return |
| | | } |
| | | const percent = Math.round((event.loaded / event.total) * 100) |
| | | if (percent === lastPercent) { |
| | | return |
| | | } |
| | | lastPercent = percent |
| | | messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, { |
| | | uploadProgress: percent |
| | | }) |
| | | } |
| | | } |
| | | |
| | | /** ååªä½ç±»å䏿åï¼ä»
æ¥å¿ç¨ï¼ï¼æªæ³¨å type éå为éç¨ãåªä½ã */ |
| | | const getMediaKind = (type: number): string => mediaTypeHandlers[type]?.kind ?? 'åªä½' |
| | | |
| | | /** |
| | | * æ type å handlerï¼ç¼ºåæéï¼ç¨åºé误éä¸å¨è¿ä¸å¤ï¼ |
| | | * |
| | | * è°ç¨æ¹æ¿å°çæ¯ `MediaTypeHandler`ï¼é undefinedï¼ï¼ä¸åéè¦ `!` æè¨ã |
| | | * ä»
ç»ãç¡®å® type å¨è¡¨éãçè°ç¨æ¹ç¨ ââ image/file/voice/video åç±»å
¥å£ï¼éç¨ dispatcher ä»å¯ç¨ `mediaTypeHandlers[type]?.` optional chain |
| | | */ |
| | | const requireMediaHandler = (type: number): MediaTypeHandler => { |
| | | const handler = mediaTypeHandlers[type] |
| | | if (!handler) { |
| | | throw new Error(`[IM] æªæ³¨åçåªä½ç±»å ${type}`) |
| | | } |
| | | return handler |
| | | } |
| | | |
| | | /** |
| | | * ä¸ä¼ 宿åçæ¶å£æ ¡éªï¼ä¼è¯ä»æ¯å 使¶éå®çé£ä¸ª + å½åæªè¢«ç¦è¨ï¼ä»»ä¸ä¸æ»¡è¶³ 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 宿åé |
| | | * |
| | | * ä¸ä¼ æå â patch content â sendRaw å¤ç¨ existingClientMessageIdï¼store å
é¨ revoke æ§ blob URL |
| | | */ |
| | | const commitMediaPlaceholder = async (opts: { |
| | | clientMessageId: string |
| | | conversation: Conversation |
| | | realContent: string |
| | | type: number |
| | | }): Promise<void> => { |
| | | messageStore.patchMessage( |
| | | opts.conversation.type, |
| | | opts.conversation.targetId, |
| | | opts.clientMessageId, |
| | | { content: opts.realContent } |
| | | ) |
| | | // æ¾å¼ä¼ conversation èéä¾èµ sendRaw å
é¨å activeï¼ |
| | | // verifyMediaUploadStillAllowed ä¸ sendRaw ä¹é´åå¨å¾®ç§çªå£ï¼æé´ç¨æ·åä¼è¯ä¹è½ä¿è¯åå°åä¼è¯ |
| | | await sendRaw(opts.type, opts.realContent, { |
| | | existingClientMessageId: opts.clientMessageId, |
| | | targetId: opts.conversation.targetId, |
| | | conversation: opts.conversation |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * ä¸ä¼ åªä½æä»¶å¹¶åéæ¶æ¯ï¼é«å±å
¥å£ï¼image / file / voice ç¨ï¼video èµ°ä½å± helper èªè¡ç»è£
ï¼ |
| | | * |
| | | * @returns å 使¶æ¯ç clientMessageIdï¼è°ç¨æ¹æéç¨äºåç» patch / ç§»é¤ï¼ä¸ä¼ 失败æ¶å ä½ä»ä¿ç为 FAILED æï¼ |
| | | */ |
| | | 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 '' |
| | | } |
| | | // ä½ç§¯ä¸éæ¦æªï¼å¤§æä»¶æµè§å¨å
æªå¸§ / è§£ç å¯è´ OOMï¼è¶
éç´æ¥ warningï¼ä¸è¿å
¥å ä½ / ä¸ä¼ é¾è·¯ |
| | | if (!ensureMediaSizeWithinLimit(opts.file, opts.type, message.warning)) { |
| | | 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, |
| | | existingClientMessageId: opts.existingClientMessageId |
| | | }) |
| | | |
| | | // 2. ä¸ä¼ ï¼è¿åº¦åè° patch uploadProgressï¼å¤±è´¥ä¿ç _localFile ä¾éè¯ |
| | | let url: string | undefined |
| | | try { |
| | | url = await uploadFile( |
| | | { file: opts.file }, |
| | | createUploadProgressHandler(conversation, clientMessageId) |
| | | ) |
| | | } catch (error) { |
| | | console.error(`[IM] ${handler.kind}ä¸ä¼ 失败`, error) |
| | | } |
| | | if (!url) { |
| | | markMediaFailed(conversation.type, conversation.targetId, clientMessageId) |
| | | return clientMessageId |
| | | } |
| | | if (!isOpenableUrl(url)) { |
| | | console.warn(`[IM] ${handler.kind}ä¸ä¼ è¿åäºä¸æ¯ææå¼ç URL`, { url }) |
| | | message.warning('ä¸ä¼ è¿åçæä»¶å°å䏿¯ææå¼') |
| | | markMediaFailed(conversation.type, conversation.targetId, clientMessageId) |
| | | return clientMessageId |
| | | } |
| | | |
| | | // 3. ä¸ä¼ æé´ä¼è¯åæ¢ / ç¨æ·ç»åº / 被ç¦è¨ï¼ä»»ä¸æ
åµé½æ¾å¼åéï¼å ä½ç½® FAILED |
| | | if (!verifyMediaUploadStillAllowed(conversation, startKey, opts.type, clientMessageId)) { |
| | | return clientMessageId |
| | | } |
| | | |
| | | // 4. patch content + sendRaw æ¶å°¾ |
| | | await commitMediaPlaceholder({ |
| | | type: opts.type, |
| | | conversation, |
| | | clientMessageId, |
| | | realContent: buildContent(url) |
| | | }) |
| | | return clientMessageId |
| | | } |
| | | |
| | | return { |
| | | uploadAndSendMedia, |
| | | insertMediaPlaceholder, |
| | | markMediaFailed, |
| | | commitMediaPlaceholder, |
| | | createUploadProgressHandler, |
| | | verifyMediaUploadStillAllowed, |
| | | getMediaKind, |
| | | requireMediaHandler |
| | | } |
| | | } |