| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from '../../../../components/group' |
| | | |
| | | import type { Conversation } from '#/views/im/home/types' |
| | | |
| | | import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | import { isOpenableUrl } from '#/packages/utils/src' |
| | | |
| | | import { Button, DropdownButton, Menu, message, Tooltip } from 'ant-design-vue' |
| | | |
| | | import { uploadFile } from '#/api/infra/file' |
| | | import { |
| | | ensureMediaSizeWithinLimit, |
| | | useMediaUploader |
| | | } from '#/views/im/home/composables/useMediaUploader' |
| | | import { useMessageSender } from '#/views/im/home/composables/useMessageSender' |
| | | import { useMuteOverlay } from '#/views/im/home/composables/useMuteOverlay' |
| | | import { useConversationStore } from '#/views/im/home/store/conversationStore' |
| | | import { useFriendStore } from '#/views/im/home/store/friendStore' |
| | | import { useGroupStore } from '#/views/im/home/store/groupStore' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | import { DANGEROUS_FILE_EXTENSIONS, MESSAGE_GROUP_READ_ENABLED } from '#/views/im/utils/config' |
| | | import { ImContentType, ImConversationType, ImGroupMemberRole } from '#/views/im/utils/constants' |
| | | import { getConversationKey } from '#/views/im/utils/conversation' |
| | | import { |
| | | type FaceMessage, |
| | | type QuoteMessage, |
| | | serializeMessage, |
| | | withQuotePayload |
| | | } from '#/views/im/utils/message' |
| | | import { getMemberDisplayName } from '#/views/im/utils/user' |
| | | |
| | | import { ReplyPreview } from '../message' |
| | | import FacePicker from './face-picker.vue' |
| | | import MentionPicker from './mention-picker.vue' |
| | | import VoiceRecorder from './voice-recorder.vue' |
| | | |
| | | defineOptions({ name: 'ImMessageInput' }) |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const groupStore = useGroupStore() |
| | | const friendStore = useFriendStore() |
| | | const { send, sendRaw } = useMessageSender() |
| | | const { |
| | | uploadAndSendMedia, |
| | | insertMediaPlaceholder, |
| | | markMediaFailed, |
| | | commitMediaPlaceholder, |
| | | createUploadProgressHandler, |
| | | verifyMediaUploadStillAllowed, |
| | | requireMediaHandler |
| | | } = useMediaUploader() |
| | | const muteOverlay = useMuteOverlay() // ç¦è¨ / å°ç¦è¦çå± |
| | | |
| | | const editorRef = useTemplateRef<HTMLDivElement>('editorRef') |
| | | const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef') |
| | | const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef') |
| | | const videoInputRef = useTemplateRef<HTMLInputElement>('videoInputRef') |
| | | const mentionRef = useTemplateRef<InstanceType<typeof MentionPicker>>('mentionRef') |
| | | |
| | | // ==================== ææ¬ / åé ==================== |
| | | const canSend = ref(false) // editor æ¯å¦æå¯åéå
容ï¼contenteditable 没 v-modelï¼é input äºä»¶ä¸»å¨åæ¥ |
| | | |
| | | /** ç»´æ¤ canSend + data-emptyï¼æèµ· placeholderï¼ï¼ä¸åè稿ï¼restoreDraftToEditor å¤ç¨é¿å
åæµ */ |
| | | function applyEditorUiState(editor: HTMLDivElement) { |
| | | const raw = editor.textContent || '' |
| | | // canSend æ trim å夿ï¼ç©ºæ ¼ / æ¢è¡ä¸ç®å¯åéå
容ï¼ï¼ç¦è¨æç´æ¥ç¦ç¨ |
| | | canSend.value = !!raw.trim() && !!conversationStore.activeConversation && !muteOverlay.value |
| | | // data-empty æåå§å
容夿ï¼ç¨æ·æ²ä¸ä¸ªç©ºæ ¼ä¹è¦è®© placeholder éèï¼é¿å
è§è§å å |
| | | // ç¨å±æ§"åå¨ / 缺失"èé 'true'/'false' å符串ï¼CSS [data-empty]::before å½ä¸å³å¯ï¼ |
| | | // æ¯ [data-empty='true'] ç´è§ï¼æµè§å¨å 空åç <br> â :empty ä¸å½ä¸ï¼æä»¥å¿
é¡» JS ç»´æ¤ |
| | | if (raw) { |
| | | delete editor.dataset.empty |
| | | } else { |
| | | editor.dataset.empty = '' |
| | | } |
| | | } |
| | | |
| | | /** ç¨æ·ç¼è¾å
¥å£çç»ä¸æ¶å°¾ï¼UI ç¶æåæ¥ + è稿åå storeï¼å表ç«å³åº [è稿] åç¼ï¼ */ |
| | | function syncEditorState() { |
| | | const editor = editorRef.value |
| | | if (!editor) { |
| | | return |
| | | } |
| | | applyEditorUiState(editor) |
| | | syncDraftToStore(editor) |
| | | } |
| | | |
| | | /** ç¦è¨ç¶æå忶忥åéæé® */ |
| | | watch(muteOverlay, () => { |
| | | const editor = editorRef.value |
| | | if (editor) { |
| | | applyEditorUiState(editor) |
| | | } |
| | | }) |
| | | |
| | | /** æ editor å½åå
容åå°ä¼è¯è稿ï¼plain ç± collectFromEditor æ¿ï¼ä¸åéæ¶åæºé¿å
å表ä¸å®åä¸ä¸è´ */ |
| | | function syncDraftToStore(editor: HTMLDivElement) { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | // collectFromEditor å·² trimï¼plain ä¸ºç©ºæ¶ store å
鍿 clearConversationDraft å¤ç |
| | | // reply éä¼ å½åå¿«ç
§ï¼setConversationDraft æ¯æ´å¯¹è±¡æ¿æ¢ï¼ä¸è¯»æ§ reply ä¼è®©ç¨æ·æ¯æ²ä¸ä¸ªé®å°±æå¼ç¨æ¡æ¦æ |
| | | const { text } = collectFromEditor(editor) |
| | | const existing = conversationStore.getConversationDraft(conversation) |
| | | conversationStore.setConversationDraft(conversation, { |
| | | html: editor.innerHTML, |
| | | plain: text, |
| | | reply: existing?.reply |
| | | }) |
| | | } |
| | | |
| | | /** åä¼è¯æ¶æ store éçè稿è¿åå° editorï¼åªæ´ UI ä¸ååè稿ï¼é¿å
storeâeditorâstore åæµ */ |
| | | function restoreDraftToEditor() { |
| | | const editor = editorRef.value |
| | | if (!editor) { |
| | | return |
| | | } |
| | | const conversation = conversationStore.activeConversation |
| | | const draft = conversation ? conversationStore.getConversationDraft(conversation) : undefined |
| | | editor.innerHTML = draft?.html || '' |
| | | applyEditorUiState(editor) |
| | | // æå
æ ç§»å°æ«å°¾ï¼è®©ç¨æ·æ¥çè¾å
¥ï¼ç©ºå
å®¹ç´æ¥ focus å³å¯ |
| | | if (draft?.html) { |
| | | placeCaretAtEnd(editor) |
| | | } |
| | | } |
| | | |
| | | /** æå
æ æ¾å° contenteditable å
ç´ çæ«å°¾ââååæè稿çä¼è¯æ¶å
æ èªç¶è½å¨å°¾é¨ï¼å¯¹é½å¾®ä¿¡ */ |
| | | function placeCaretAtEnd(el: HTMLElement) { |
| | | const range = document.createRange() |
| | | range.selectNodeContents(el) |
| | | range.collapse(false) |
| | | const sel = window.getSelection() |
| | | if (!sel) { |
| | | return |
| | | } |
| | | sel.removeAllRanges() |
| | | sel.addRange(range) |
| | | } |
| | | |
| | | /** |
| | | * èµ° DOM æ editor å
容æ¼å text + atUserIds |
| | | * |
| | | * èç¹åæ¯ï¼ |
| | | * 1. text èç¹ï¼ç´æ¥æ¼ textContentï¼è¿ç¨ä¸æ»¤æ ZWSPï¼token é¦ä½éç¹ç¨ï¼ä¸è¿åéå
å®¹ï¼ |
| | | * 2. brï¼æ¼ \nï¼Shift+Enter èµ° execCommand('insertLineBreak') 产ç©ï¼ |
| | | * 3. span[data-id]ï¼æ¼ token æ¾ç¤ºææ¬ + æ dataset.id æ¶å° atUserIdsï¼ä¸éå½ span å
é¨ï¼ |
| | | * 4. divï¼æµè§å¨å¨ contenteditable éé»è®¤æ¢è¡å®¹å¨ï¼åç½® \n åéå½åèç¹ |
| | | * 5. å
¶ä»å
ç´ ï¼éä¼ ï¼éå½åèç¹ |
| | | * |
| | | * atUserIds èµ° Set å»éï¼ç¨æ· @ å¼ ä¸ä¸¤æ¬¡æ¶ atUserIds åªåºç°ä¸æ¬¡ï¼trim æ«å°¾ç©ºç½ |
| | | */ |
| | | function collectFromEditor(root: HTMLElement): { atUserIds: number[]; text: string; } { |
| | | const userIds: number[] = [] |
| | | let text = '' |
| | | |
| | | function walk(node: Node) { |
| | | if (node.nodeType === Node.TEXT_NODE) { |
| | | text += (node.textContent || '').replaceAll('â', '') |
| | | return |
| | | } |
| | | if (node.nodeType !== Node.ELEMENT_NODE) { |
| | | return |
| | | } |
| | | const el = node as HTMLElement |
| | | const tag = el.tagName.toLowerCase() |
| | | if (tag === 'br') { |
| | | text += '\n' |
| | | return |
| | | } |
| | | if (tag === 'span' && el.dataset.id) { |
| | | text += el.textContent || '' |
| | | const id = Number(el.dataset.id) |
| | | if (!Number.isNaN(id)) { |
| | | userIds.push(id) |
| | | } |
| | | return |
| | | } |
| | | if (tag === 'div') { |
| | | if (text && !text.endsWith('\n')) { |
| | | text += '\n' |
| | | } |
| | | el.childNodes.forEach((child) => walk(child)) |
| | | return |
| | | } |
| | | el.childNodes.forEach((child) => walk(child)) |
| | | } |
| | | |
| | | // ç´æ¥ä» root.childNodes å¼å§ï¼é¿å
æ root æ¬èº«ä¹å½å
ç´ å¤çï¼è½ç¶ç®å没æç¹æ®æ ·å¼ï¼ä½ä»¥é²æªæ¥æ¹å¨ï¼ |
| | | root.childNodes.forEach((child) => walk(child)) |
| | | return { |
| | | text: text.trim(), |
| | | atUserIds: [...new Set(userIds)] |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * åéï¼ä» DOM æ¶ text + atUserIds â æ¸
空ç¼è¾å¨ â èµ° useMessageSender.send |
| | | * |
| | | * 1. é²å¾¡ï¼canSend æ¯ falseï¼trim å空 / æ²¡æ¿æ´»ä¼è¯ï¼æ editor 没 mount â ç´æ¥ return |
| | | * 2. æ¶éï¼DOM walk æ¿å°è¦åéçææ¬ + atUserIds |
| | | * 3. äºæ¬¡é²å¾¡ï¼collectFromEditor èµ° trimï¼å¯è½æ¯ syncEditorState æ´ä¸¥æ ¼ï¼ä¾å¦å
¨ ZWSPï¼ï¼ä»ç©ºå°± return |
| | | * 4. æ¸
空 + åæ¥ç¶æï¼å
æ¸
innerHTML å syncEditorState 让 placeholder / canSend ä¸èµ·åå½ |
| | | * ï¼é¡ºåºå¾éè¦ï¼å
æ¸
å syncï¼å¦å sync çå°æ§å
容ä¼è¯¯å¤ï¼ |
| | | * 5. ä¸éï¼atUserIds é空æä¼ ï¼é¿å
å空æ°ç»ï¼quote ç± clearConversationDraft åæåï¼ç¡®ä¿å¼ç¨æ¡ç«å³æ¶å¤± |
| | | */ |
| | | async function handleSend(options?: { receipt?: boolean }) { |
| | | const editor = editorRef.value |
| | | if (!canSend.value || !editor || muteOverlay.value) { |
| | | return |
| | | } |
| | | const { text, atUserIds } = collectFromEditor(editor) |
| | | if (!text) { |
| | | return |
| | | } |
| | | // 1. æ quote 忏
空 editor + å½åä¼è¯è稿ï¼å
å« replyï¼ï¼syncEditorState å / reply é½ä¸ºç©º |
| | | // store å
é¨ä¼èªå¨æ¸
çï¼ä½æ¾å¼ clearConversationDraft è½ç«å³åæ¥ãä¸ä¾èµ debounce æ¶åºï¼å表ä¸ç [è稿] ç«å³æ¶å¤± |
| | | const replyQuote = replyTarget.value |
| | | editor.innerHTML = '' |
| | | if (conversationStore.activeConversation) { |
| | | conversationStore.clearConversationDraft(conversationStore.activeConversation) |
| | | } |
| | | syncEditorState() |
| | | // 2. åé |
| | | await send(text, { |
| | | atUserIds: atUserIds.length > 0 ? atUserIds : undefined, |
| | | receipt: options?.receipt, |
| | | quote: replyQuote |
| | | }) |
| | | } |
| | | |
| | | /** åéæé®ä¸æèåæä½ */ |
| | | function handleSendMenuClick({ key }: { key: number | string }) { |
| | | if (key === 'receipt') { |
| | | handleSend({ receipt: true }) |
| | | } |
| | | } |
| | | |
| | | // ==================== éåº / æå
¥ ==================== |
| | | /** |
| | | * 䏿¬¡è½å¨ editor å
ç selectionï¼ç¦ç¹è¢«è¡¨æ
颿¿ç夺走æ¶ç¨æ¥åå°åæå
¥ç¹ï¼ |
| | | * |
| | | * çå¬ document.selectionchange æ¯ editor.@blur æ´å¯é ï¼blur æ¶ selection å·²ç»ç§»èµ° |
| | | */ |
| | | let savedRange: null | Range = null |
| | | |
| | | /** |
| | | * èµ° native execCommandï¼ä¿çæµè§å¨åç undo æ |
| | | * |
| | | * execCommand å¨ lib.dom é被æ @deprecatedï¼IDE æ¾ç¤ºå é¤çº¿ï¼ï¼ä½ 'insertText' / |
| | | * 'insertLineBreak' 没æçä»·ç W3C æ åæ¿ä»£ââRange/Selection èªå·±æ¼ DOM ä¼è®© Ctrl+Z 失æã |
| | | * éä¸å°è¿éè°ç¨ï¼è°ç¨ç¹ä¸å¿
æ£è½ ts-expect-errorï¼ä¸å¼å
¥ eslint disableï¼é¡¹ç®å½å |
| | | * @typescript-eslint v7 没æ no-deprecated è§åï¼å äºä¹æ æï¼åè让 lint æ¥"è§åä¸åå¨"ï¼ |
| | | */ |
| | | function nativeExec(command: 'insertLineBreak' | 'insertText', value?: string) { |
| | | document.execCommand(command, false, value) |
| | | } |
| | | |
| | | /** |
| | | * document selectionchange çå¬ï¼æè½å¨ editor å
ç selection ç¼åå° savedRangeï¼ |
| | | * insertText å¨ç¦ç¹è¢«å·èµ°åç¨å®æå
æ æ¢å¤å°åæå
¥ç¹ |
| | | */ |
| | | function onSelectionChange() { |
| | | const editor = editorRef.value |
| | | const sel = window.getSelection() |
| | | if (!editor || !sel || sel.rangeCount === 0) { |
| | | return |
| | | } |
| | | const range = sel.getRangeAt(0) |
| | | if (editor.contains(range.startContainer)) { |
| | | savedRange = range.cloneRange() |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * ç¹å» editor / picker å¤é¨æ¶å
³ææµ®å±ï¼é¿å
è¾å
¥ @keyword åç¨æ·ç¹å«å¤æµ®å±ä¸æ¶å¤± |
| | | * |
| | | * ç¨ mousedown èé clickï¼click å¨æäºæµè§å¨é picker å
ç´ æ¶å¤±ååä¸å°å targetï¼ä¼è¢«åæ |
| | | */ |
| | | function onDocMousedown(e: MouseEvent) { |
| | | if (!mentionVisible.value) { |
| | | return |
| | | } |
| | | const target = e.target as Node | null |
| | | if (!target) { |
| | | return |
| | | } |
| | | if (editorRef.value?.contains(target)) { |
| | | return |
| | | } |
| | | // picker æ¯ fixed å®ä½çå
å¼èç¹ï¼ä¸å¨ editor åæ éï¼ç¨ç±»åå®ä½ |
| | | const pickerEl = document.querySelector('.message-input__mention-picker') |
| | | if (pickerEl?.contains(target)) { |
| | | return |
| | | } |
| | | closeMention() |
| | | } |
| | | |
| | | onMounted(() => { |
| | | document.addEventListener('selectionchange', onSelectionChange) |
| | | document.addEventListener('mousedown', onDocMousedown) |
| | | restoreDraftToEditor() |
| | | }) |
| | | |
| | | onBeforeUnmount(() => { |
| | | document.removeEventListener('selectionchange', onSelectionChange) |
| | | document.removeEventListener('mousedown', onDocMousedown) |
| | | }) |
| | | |
| | | /** |
| | | * åä¼è¯æ¶è¿å对æ¹çèç¨¿å° editor |
| | | * |
| | | * 忥å
³ @ / 表æ
/ è¯é³æµ®å±å¹¶æ¸
savedRangeï¼ |
| | | * - mentionRange / savedRange æ§å¼ç¨è¿æåä¸ä¸ä¼è¯ç DOM èç¹ï¼ä¸æ¸
䏿¬¡æ token ä¼è½éä½ç½® |
| | | * - è¯é³å½å¶å¼¹çªä¿çæ¶ï¼å½å®è§¦åç onVoiceSend ä¼è¯»å½å activeConversationï¼æè¯é³åå°æ°ä¼è¯ |
| | | */ |
| | | watch( |
| | | () => |
| | | conversationStore.activeConversation |
| | | ? getConversationKey(conversationStore.activeConversation) |
| | | : null, |
| | | () => { |
| | | closeMention() |
| | | emojiVisible.value = false |
| | | voiceVisible.value = false |
| | | savedRange = null |
| | | restoreDraftToEditor() |
| | | } |
| | | ) |
| | | |
| | | /** |
| | | * æå符串æå
¥å
æ å¤ï¼emoji 颿¿çåºæ¯è°ç¨ï¼ |
| | | * |
| | | * 1. editor 没æç´æ¥è¿å |
| | | * 2. ç¦ç¹åå° editor + æ savedRange æ¢å¤æå½å selectionï¼emoji 颿¿å·ç¦ç¹åè¿è½ååä½ï¼ |
| | | * 3. nativeExec æææ¬ï¼ä¿çæµè§å¨åç undo æ |
| | | * 4. 忥 canSend / placeholder |
| | | */ |
| | | function insertText(str: string) { |
| | | const editor = editorRef.value |
| | | if (!editor) { |
| | | return |
| | | } |
| | | editor.focus() |
| | | if (savedRange) { |
| | | const sel = window.getSelection() |
| | | if (sel) { |
| | | sel.removeAllRanges() |
| | | sel.addRange(savedRange) |
| | | } |
| | | } |
| | | // 1. nativeExec æææ¬ï¼ä¿çæµè§å¨åç undo æ |
| | | nativeExec('insertText', str) |
| | | // 2. 忥 canSend / placeholder |
| | | syncEditorState() |
| | | } |
| | | |
| | | /** |
| | | * ç²è´´å¤ç |
| | | * |
| | | * 1. ä¼å
æ« clipboardData.items æ¾æä»¶ç±»åæ¡ç®ï¼æªå¾ãæå
¥çå¾ç / æä»¶çï¼ |
| | | * - image/* â èµ° IMAGE ä¸ä¼ åé |
| | | * - å
¶å® file â èµ° FILE ä¸ä¼ åé |
| | | * - 䏿¬¡ç²è´´åªå¤ç第ä¸ä¸ªæä»¶ï¼é¿å
䏿¬¡ç²è´´ååºå¤æ¡æ¶æ¯ |
| | | * 2. 没æä»¶åèµ° textï¼å¥æå¤é¨æ ·å¼ / èæ¬ï¼é¿å
å¤ç« inline style 污æ editor |
| | | * ï¼contenteditable é»è®¤ç²è´´ä¼å¸¦ HTMLï¼æä»¥æ¨¡æ¿ä¸ @paste.prevent æ¦æªï¼ |
| | | */ |
| | | function onPaste(e: ClipboardEvent) { |
| | | // 1. ä¼å
æ« clipboardData.items æ¾æä»¶ç±»åæ¡ç®ï¼æªå¾ãæå
¥çå¾ç / æä»¶çï¼ |
| | | const items = e.clipboardData?.items |
| | | if (items?.length) { |
| | | for (const item of items) { |
| | | if (!item) { |
| | | continue |
| | | } |
| | | if (item.kind !== 'file') { |
| | | continue |
| | | } |
| | | const file = item.getAsFile() |
| | | if (!file) { |
| | | continue |
| | | } |
| | | if (item.type.startsWith('image/')) { |
| | | void uploadAndSendImage(file) |
| | | } else { |
| | | void uploadAndSendFile(file) |
| | | } |
| | | return |
| | | } |
| | | } |
| | | // 2. 没æä»¶åèµ° textï¼å¥æå¤é¨æ ·å¼ / èæ¬ï¼é¿å
å¤ç« inline style 污æ editor |
| | | const text = e.clipboardData?.getData('text/plain') || '' |
| | | if (text) { |
| | | nativeExec('insertText', text) |
| | | // @paste.prevent é»æäºæµè§å¨é»è®¤ input äºä»¶ï¼éæå¨åæ¥è稿 / canSendï¼ä¸ insertText() è·¯å¾ä¸è´ |
| | | syncEditorState() |
| | | } |
| | | } |
| | | |
| | | /** ç¼è¾å¨å
容ååçç»ä¸å
¥å£ï¼å
忥 canSend / placeholderï¼åå¤ @ æµ®å±æ¯å¦è¦å±å¼ */ |
| | | function onInput() { |
| | | syncEditorState() |
| | | detectAtMention() |
| | | } |
| | | |
| | | // ==================== å¼ç¨ / åå¤ ==================== |
| | | |
| | | /** å½åä¼è¯çãæ£å¨åå¤ã对象ï¼ä»ä¼è¯è稿派ç */ |
| | | const replyTarget = computed<QuoteMessage | undefined>(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return undefined |
| | | } |
| | | return conversationStore.getConversationDraft(conversation)?.reply |
| | | }) |
| | | |
| | | /** æ¸
æå½å reply ä½ä¿çæ£æè稿ï¼ç¹ à å
³é / åéå³å°è¿è¡æ¶è° */ |
| | | function clearConversationReplyDraft() { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | conversationStore.clearConversationReplyDraft(conversation) |
| | | } |
| | | |
| | | /** åèµ°å½å reply å¿«ç
§ï¼æä¸æ¬¡æ¸
䏿¬¡ï¼ï¼åªä½ä¸ä¼ é¾è·¯å¨å¨æåç»ä¸è°å®æ¿ quote */ |
| | | function consumeReply(): QuoteMessage | undefined { |
| | | const quote = replyTarget.value |
| | | if (quote) { |
| | | clearConversationReplyDraft() |
| | | } |
| | | return quote |
| | | } |
| | | |
| | | // ==================== 表æ
==================== |
| | | const emojiVisible = ref(false) |
| | | /** åæ¢è¡¨æ
颿¿ï¼æå¼æ¶äºæ¥å
³æè¯é³é¢æ¿ */ |
| | | function toggleEmoji() { |
| | | emojiVisible.value = !emojiVisible.value |
| | | if (emojiVisible.value) { |
| | | voiceVisible.value = false |
| | | } |
| | | } |
| | | |
| | | /** éä¸è¡¨æ
è´´å¾ï¼æ¼ FaceMessage payload ç´æ¥èµ° sendRaw åéï¼quote å¤ç¨å½å reply å¿«ç
§ï¼ */ |
| | | async function onSelectFace(face: { height: number; name?: string; url: string; width: number; }) { |
| | | if (muteOverlay.value) { |
| | | return |
| | | } |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | const replyQuote = consumeReply() |
| | | const payload = withQuotePayload<FaceMessage>( |
| | | { url: face.url, width: face.width, height: face.height, name: face.name }, |
| | | replyQuote |
| | | ) |
| | | await sendRaw(ImContentType.FACE, serializeMessage(payload), { conversation }) |
| | | } |
| | | |
| | | // ==================== @ æåéæ©ï¼ç¾¤èï¼ ==================== |
| | | const isGroup = computed( |
| | | () => conversationStore.activeConversation?.type === ImConversationType.GROUP |
| | | ) |
| | | |
| | | /** ä» groupStore 读å½åæ¿æ´»ç¾¤çæåï¼åä¼è¯æ¶ç± MessagePanel é¢æï¼ */ |
| | | const groupMembers = computed<GroupMemberLite[]>(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return [] |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | return (group?.members || []).map((member) => { |
| | | const friend = friendStore.getFriend(member.userId) |
| | | return { |
| | | userId: member.userId, |
| | | showName: getMemberDisplayName(member, friend), |
| | | nickname: member.nickname, |
| | | avatar: member.avatar, |
| | | status: member.status |
| | | } |
| | | }) |
| | | }) |
| | | |
| | | /** å½åç¨æ·æ¯å¦è½ @ å
¨åï¼ç¾¤ä¸» + 管çåé½å
许ï¼å¯¹é½å¾®ä¿¡ PCï¼admin ä¹è½ @ ææäººï¼ */ |
| | | const canAtAll = computed<boolean>(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return false |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | if (!group) { |
| | | return false |
| | | } |
| | | const myId = getCurrentUserId() |
| | | if (!myId) { |
| | | return false |
| | | } |
| | | if (group.ownerUserId === myId) { |
| | | return true |
| | | } |
| | | return group.members?.find((member) => member.userId === myId)?.role === ImGroupMemberRole.ADMIN |
| | | }) |
| | | |
| | | const mentionVisible = ref(false) |
| | | const mentionSearchText = ref('') |
| | | /** æµ®å±å®ä½ï¼x æ¯å·¦è¾¹è·ï¼top / bottom äºéä¸ââ bottom éå®ï¼picker 䏿²¿è´´ @ï¼æ¯é»è®¤ï¼ |
| | | * 䏿¹æ¾ä¸ä¸æ¶éå为 top éå®ï¼picker 䏿²¿è´´ @ 䏿¹ï¼ */ |
| | | const mentionPosition = ref<{ bottom?: number; top?: number; x: number; }>({ x: 0, bottom: 0 }) |
| | | |
| | | /** MentionPicker ç容å¨å®½åº¦ï¼ä¸ç»ä»¶éç w-50 对é½ï¼ï¼ç¨äºè§å£å³æ²¿åå¼¹ï¼ |
| | | * é«åº¦ä¸åç¨å¸¸éç®ä½ç½®ââbottom éå®å picker å
容å¤å¯¡é½ä¸å½±å䏿²¿ä½ç½®ï¼èªç¶è´´ @ */ |
| | | const MENTION_WIDTH = 200 |
| | | const MENTION_MIN_FIT_ABOVE = 120 // 䏿¹å©ä½ç©ºé´è³å°è¿ä¹å¤ææ¾ä¸æ¹ï¼å¦åç¿»å°ä¸æ¹ï¼é¿å
picker 被è§å£é¡¶ / é¡¶é¨ chat header åæï¼ |
| | | |
| | | /** å½å @ å
³é®è¯å¨ editor éçèå´ï¼onMentionSelect ç¨å®å®ä½å é¤ + æå
¥ token */ |
| | | let mentionRange: null | Range = null |
| | | |
| | | /** å
³éæµ®å± + æ¸
æ rangeï¼é¿å
䏿¬¡æ®çç range 被ä¸ä¸æ¬¡ onMentionSelect è¯¯ç¨ */ |
| | | function closeMention() { |
| | | mentionVisible.value = false |
| | | mentionRange = null |
| | | } |
| | | |
| | | /** å¨å
æ å½åææ¬èç¹éååæ¾ @keywordï¼å½ä¸åå±å¼æµ®å± */ |
| | | function detectAtMention() { |
| | | if (!isGroup.value) { |
| | | closeMention() |
| | | return |
| | | } |
| | | const sel = window.getSelection() |
| | | if (!sel || sel.rangeCount === 0) { |
| | | closeMention() |
| | | return |
| | | } |
| | | const range = sel.getRangeAt(0) |
| | | if (!range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) { |
| | | closeMention() |
| | | return |
| | | } |
| | | const node = range.startContainer |
| | | const offset = range.startOffset |
| | | const before = (node.textContent || '').slice(0, offset) |
| | | // ç´æ¥æ¾æè¿ç @ï¼ä¸éå¶åç½®å符ï¼å¯¹é½å¾®ä¿¡"ä¸æç´§è´´ @ ä¹è½èæ³"ï¼ä½ 好@å¼ ä¸ãï¼@å¼ ä¸ é½è§¦åï¼ï¼ |
| | | // ä»£ä»·æ¯ email-like "test@example.com" ä¹ä¼è§¦åï¼ä½è天è¾å
¥éç² email çæ¦çä½ï¼ |
| | | // ä¸ç¨æ·æ Esc å³å¯å
³æµ®å±ï¼å
åºç± [^\s@] ä¿è¯ä¸æ¦è¾å
¥ç©ºæ ¼ / 第äºä¸ª @ å°±åä¸ |
| | | const match = before.match(/@([^\s@]*)$/) |
| | | if (!match) { |
| | | closeMention() |
| | | return |
| | | } |
| | | const keyword = match[1] ?? '' |
| | | const atOffset = offset - keyword.length - 1 |
| | | mentionRange = document.createRange() |
| | | mentionRange.setStart(node, atOffset) |
| | | mentionRange.setEnd(node, offset) |
| | | mentionSearchText.value = keyword |
| | | // éå®å¨ @ ç¬¦å·æ¬èº«ï¼èéå½å caretââå¦åç¨æ·æ¯å¤æ²ä¸ä¸ªåæµ®å±å°±è·çå³ç§»ï¼"é£"ï¼ |
| | | positionMention(node, atOffset) |
| | | mentionVisible.value = true |
| | | } |
| | | |
| | | /** |
| | | * æµ®å±ä½ç½®ï¼é»è®¤ bottom éå®ï¼picker 䏿²¿è´´ @ 䏿¹ 8pxï¼ï¼ä¸æ¹ä¸å¤æç¿»æ top éå® |
| | | * |
| | | * 1. è®¡ç® @ å符å±å¹åæ rect |
| | | * 2. 横åï¼picker å·¦è¾¹å¯¹é½ @ï¼è¶è¿è§å£å³æ²¿åå·¦æ¨ï¼è³å°ç 8px çç½ |
| | | * 3. 纵åï¼ä¸æ¹å©ä½ ⥠MENTION_MIN_FIT_ABOVE èµ° bottom éå®ï¼ä¸ä¾èµ picker å®é
é«åº¦ï¼ |
| | | * æ 论 1 é¡¹è¿æ¯ N 项 picker 䏿²¿é½è´´ @ï¼ï¼ä¸å¤åç¿»å° @ 䏿¹èµ° top éå® |
| | | */ |
| | | function positionMention(node: Node, atOffset: number) { |
| | | // 1. è®¡ç® @ å符å±å¹åæ rect |
| | | const anchor = document.createRange() |
| | | anchor.setStart(node, atOffset) |
| | | anchor.collapse(true) |
| | | const rect = anchor.getBoundingClientRect() |
| | | // 2. 横åï¼picker å·¦è¾¹å¯¹é½ @ï¼è¶è¿è§å£å³æ²¿åå·¦æ¨ï¼è³å°ç 8px çç½ |
| | | const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENTION_WIDTH - 8)) |
| | | // 3. 纵åï¼ä¸æ¹å©ä½ ⥠MENTION_MIN_FIT_ABOVE èµ° bottom éå® |
| | | mentionPosition.value = rect.top >= MENTION_MIN_FIT_ABOVE ? { x: left, bottom: window.innerHeight - rect.top + 8 } : { x: left, top: rect.bottom + 8 }; |
| | | } |
| | | |
| | | /** editor å
鍿»å¨æ¶åæ¥æµ®å±ä½ç½®ï¼å¤è¡ + è§¦åæ»å¨æ¡åºæ¯ï¼ */ |
| | | function onEditorScroll() { |
| | | if (!mentionVisible.value || !mentionRange) { |
| | | return |
| | | } |
| | | positionMention(mentionRange.startContainer, mentionRange.startOffset) |
| | | } |
| | | |
| | | function onMentionSelect(member: GroupMemberLite) { |
| | | const editor = editorRef.value |
| | | if (!editor || !mentionRange) { |
| | | return |
| | | } |
| | | // å @keywordï¼æå
¥ contenteditable=false ç tokenï¼ |
| | | // å 餿¶æ´æ®µæ¶é¤ + ä¸ä¼è¢«å
æ æç©¿ï¼data-id æ¯åç» collectFromEditor æ¶ atUserIds çé©å |
| | | // token ææ¬åºå®èµ°ç宿µç§°ï¼ç¾¤éæææåçå°çåé¢éä¸è´ï¼é¿å
æä¾§ç好å夿³¨ / 群æµç§°æ±¡æåéææ¬ |
| | | mentionRange.deleteContents() |
| | | const span = document.createElement('span') |
| | | span.className = 'mention-token' |
| | | span.dataset.id = String(member.userId) |
| | | span.contentEditable = 'false' |
| | | span.textContent = `@${member.nickname || member.showName}` |
| | | mentionRange.insertNode(span) |
| | | // token å¨ editor é¦ä½æ¶ï¼contenteditable=false è¾¹ç¼ä¼è®©å
æ æ æ³æªå° token å |
| | | // è¡¥ä¸ä¸ªé¶å®½ç©ºæ ¼å½éç¹ï¼DOM walk æ¶ä¼è¢«æ»¤æï¼ä¸è¿å
¥åéå
容 |
| | | const prev = span.previousSibling |
| | | if (!prev || (prev.nodeType === Node.TEXT_NODE && !prev.textContent)) { |
| | | span.parentNode?.insertBefore(document.createTextNode('\u200B'), span) |
| | | } |
| | | // å¨ token åè¡¥ä¸ä¸ª NBSPï¼è®©å
æ å¯ä»¥ç»§ç»è¾å
¥ï¼NBSP æ¯æ®éç©ºæ ¼æ´ç¨³ï¼é¿å
被æµè§å¨æå |
| | | const space = document.createTextNode('\u00A0') |
| | | span.parentNode?.insertBefore(space, span.nextSibling) |
| | | // å
æ ç§»å° NBSP ä¹å |
| | | const sel = window.getSelection() |
| | | if (sel) { |
| | | const newRange = document.createRange() |
| | | newRange.setStartAfter(space) |
| | | newRange.collapse(true) |
| | | sel.removeAllRanges() |
| | | sel.addRange(newRange) |
| | | } |
| | | closeMention() |
| | | editor.focus() |
| | | syncEditorState() |
| | | } |
| | | |
| | | /** |
| | | * é®çäºä»¶åå |
| | | * |
| | | * 1. mention æµ®å±æå¼æ¶ |
| | | * 1.1 â/â ç§»å¨é«äº® |
| | | * 1.2 Enter æåé â éä¸ï¼æ åé fall through å°ä¸é¢çåé忝ï¼é¿å
æ Enter 没ååº |
| | | * 1.3 Esc å
³æµ®å± |
| | | * 2. Shift+Enter æ¢è¡ï¼å¼ºå¶èµ° brï¼æµè§å¨é»è®¤ä¼æ divï¼DOM walk æ¼æ¥æ´å¤æï¼ |
| | | * 3. æ®é Enter åéï¼IME composition æé´ä¸è§¦åï¼é¿å
éè¯è¢«è¯¯åï¼ |
| | | */ |
| | | function onKeydown(e: KeyboardEvent) { |
| | | // 1. mention æµ®å±æå¼æ¶ |
| | | if (mentionVisible.value) { |
| | | if (e.key === 'ArrowUp') { |
| | | e.preventDefault() |
| | | mentionRef.value?.moveUp() |
| | | return |
| | | } |
| | | if (e.key === 'ArrowDown') { |
| | | e.preventDefault() |
| | | mentionRef.value?.moveDown() |
| | | return |
| | | } |
| | | if (e.key === 'Enter' && !e.isComposing && mentionRef.value?.hasCandidates()) { |
| | | e.preventDefault() |
| | | mentionRef.value?.pickActive() |
| | | return |
| | | } |
| | | if (e.key === 'Escape') { |
| | | closeMention() |
| | | return |
| | | } |
| | | } |
| | | // 2. Shift+Enter æ¢è¡ï¼å¼ºå¶èµ° brï¼æµè§å¨é»è®¤ä¼æ divï¼DOM walk æ¼æ¥æ´å¤æï¼ |
| | | if (e.key === 'Enter' && e.shiftKey && !e.isComposing) { |
| | | e.preventDefault() |
| | | nativeExec('insertLineBreak') |
| | | syncEditorState() |
| | | return |
| | | } |
| | | // 3. æ®é Enter åéï¼IME composition æé´ä¸è§¦åï¼é¿å
éè¯è¢«è¯¯åï¼ |
| | | if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.isComposing) { |
| | | e.preventDefault() |
| | | handleSend() |
| | | } |
| | | } |
| | | |
| | | // ==================== å¾ç / æä»¶ / è¯é³ ä¸ä¼ ==================== |
| | | |
| | | /** ä¸ä¼ åçç»ä¸æ¦æªï¼ç¦è¨æ / æ æ¿æ´»ä¼è¯ç´æ¥æ¾å¼ï¼è¿åå½å conversation ä¸æèµ°ç quote */ |
| | | function prepareMediaUpload(): undefined | { conversation: Conversation; quote?: QuoteMessage } { |
| | | if (muteOverlay.value) { |
| | | return undefined |
| | | } |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return undefined |
| | | } |
| | | return { conversation, quote: consumeReply() } |
| | | } |
| | | |
| | | /** ä¸ä¼ å¹¶åé IMAGE æ¶æ¯ï¼å ä½ + è¿åº¦ + çå® url ack ç± useMediaUploader å¤çï¼ */ |
| | | async function uploadAndSendImage(file: File) { |
| | | const context = prepareMediaUpload() |
| | | if (!context) { |
| | | return |
| | | } |
| | | await uploadAndSendMedia({ |
| | | file, |
| | | type: ImContentType.IMAGE, |
| | | quote: context.quote, |
| | | conversation: context.conversation |
| | | }) |
| | | } |
| | | |
| | | /** ä¸ä¼ å¹¶åé FILE æ¶æ¯ï¼payload ç± mediaTypeHandlers[FILE] èªå¨æ¼ url + name + size */ |
| | | async function uploadAndSendFile(file: File) { |
| | | // æä»¶ååæåä¸ä¸ª "." ä¹åçé¨åï¼æ åç¼ / 空åç¬¦ä¸²é½æ"æªç¥"æ¾è¡ |
| | | const extension = file.name.split('.').pop()?.toLowerCase() ?? '' |
| | | if (extension && DANGEROUS_FILE_EXTENSIONS.includes(extension)) { |
| | | message.warning(`ä¸å
许åé .${extension} ç±»åæä»¶`) |
| | | return |
| | | } |
| | | const context = prepareMediaUpload() |
| | | if (!context) { |
| | | return |
| | | } |
| | | await uploadAndSendMedia({ |
| | | file, |
| | | type: ImContentType.FILE, |
| | | quote: context.quote, |
| | | conversation: context.conversation |
| | | }) |
| | | } |
| | | |
| | | /** å¾çéå®å³ä¸ä¼ + åé IMAGE æ¶æ¯ï¼ä¸æ¾å
¥ editorï¼ç± useMediaUploader æ¥ç®¡å ä½ / è¿åº¦ / ackï¼ */ |
| | | async function onImagePicked(e: Event) { |
| | | const input = e.target as HTMLInputElement |
| | | const file = input.files?.[0] |
| | | input.value = '' |
| | | if (file) { |
| | | await uploadAndSendImage(file) |
| | | } |
| | | } |
| | | |
| | | /** æä»¶éå®å³ä¸ä¼ + åé FILE æ¶æ¯ï¼æºå¸¦åå§ name / size å
æ°æ®ï¼ */ |
| | | async function onFilePicked(e: Event) { |
| | | const input = e.target as HTMLInputElement |
| | | const file = input.files?.[0] |
| | | input.value = '' |
| | | if (file) { |
| | | await uploadAndSendFile(file) |
| | | } |
| | | } |
| | | |
| | | // ==================== è¯é³ ==================== |
| | | const voiceVisible = ref(false) |
| | | /** æå¼è¯é³å½å¶é¢æ¿ï¼äºæ¥å
³æè¡¨æ
颿¿ */ |
| | | function openVoice() { |
| | | voiceVisible.value = true |
| | | emojiVisible.value = false |
| | | } |
| | | /** åéå½é³æ¶æ¯ */ |
| | | async function onVoiceSend(payload: { |
| | | blob: Blob |
| | | duration: number |
| | | extension: string |
| | | mimeType: string |
| | | }) { |
| | | const context = prepareMediaUpload() |
| | | if (!context) { |
| | | return |
| | | } |
| | | const file = new File([payload.blob], `voice-${Date.now()}.${payload.extension}`, { |
| | | type: payload.mimeType || payload.blob.type |
| | | }) |
| | | await uploadAndSendMedia({ |
| | | file, |
| | | type: ImContentType.VOICE, |
| | | quote: context.quote, |
| | | conversation: context.conversation, |
| | | context: { voiceDuration: payload.duration } |
| | | }) |
| | | } |
| | | |
| | | // ==================== è§é¢ ==================== |
| | | type VideoProbe = { |
| | | cover?: Blob |
| | | duration?: number |
| | | height?: number |
| | | width?: number |
| | | } |
| | | |
| | | const VIDEO_COVER_MAX_DIM = 720 // å°é¢æé¿è¾¹ capï¼è天å表éçè§é¢å°é¢æ²¡å¿
è¦åè§é¢å辨çï¼4K å尺寸 jpeg 1-3MB 太浪费 |
| | | |
| | | /** |
| | | * å è½½è§é¢æ¬å°é¢è§ï¼ä¸æ¬¡æ§æ¿å° metadataï¼duration / 宽é«ï¼+ é¦å¸§å°é¢ blob |
| | | * |
| | | * ä¸ä¸ª video å
ç´ ä¸²ä¸¤ä»¶äºæ¯ä¸ºäºé¿å
éå¤ decodeï¼metadata è§£å®åç´æ¥ seek é¦å¸§åæªå¾ã |
| | | * æªå¾å¤±è´¥ä¸æå¼å¸¸ï¼åªè®© cover 缺失ï¼ä¿è¯ä¸»æµç¨ä»è½ä¸ä¼ è§é¢æ¬ä½ã |
| | | * |
| | | * finally 鿾弿å¼ç¨æ¯å 为ï¼ä»
revokeObjectURL ä¸è¶³ä»¥è®© video decoder ç«å³éæ¾ï¼ |
| | | * é¨åæµè§å¨çæ¬ä¸ 4K è§é¢è§£ç buffer 坿»çæ°å MB å ç§å°åå ç§ï¼è¿åå æ¡ä¼ç´¯è®¡æ¾å¤§ã |
| | | */ |
| | | async function probeVideoFile(file: File): Promise<VideoProbe> { |
| | | // 1. åå¤ç¦»å± video |
| | | // 1.1 muted + preload=metadataï¼åªä¸è½½æä»¶å¤´ï¼ä¸é¢å è½½æ´æ¡æµ |
| | | const objectUrl = URL.createObjectURL(file) |
| | | const video = document.createElement('video') |
| | | video.preload = 'metadata' |
| | | video.muted = true |
| | | video.src = objectUrl |
| | | try { |
| | | // 1.2 ç metadata å è½½ï¼è§£åº duration / 宽髿æ seek + æªå¾ç便® |
| | | await new Promise<void>((resolve, reject) => { |
| | | video.addEventListener('loadedmetadata', () => resolve(), { once: true }) |
| | | video.addEventListener('error', () => reject(new Error('video metadata load error')), { |
| | | once: true |
| | | }) |
| | | }) |
| | | // 1.3 æ½å
ä¿¡æ¯ï¼duration å¶æ NaNï¼æå°æ°æåæä»¶ï¼ï¼è½¯å¤ç为 undefined |
| | | const meta = { |
| | | duration: Number.isFinite(video.duration) ? Math.round(video.duration) : undefined, |
| | | width: video.videoWidth || undefined, |
| | | height: video.videoHeight || undefined |
| | | } |
| | | |
| | | // 2. æªé¦å¸§å°é¢ï¼ç¬ç« tryï¼å¤±è´¥ä»
é级 cover 为空ï¼ä¸å½±å metaï¼ |
| | | let cover: Blob | undefined |
| | | try { |
| | | // 2.1 ç® seek æ¶é´ï¼0.1s é¿å¼å¸¸è§ç纯é»é¦å¸§ï¼æ¶é¿ < 0.2s çæçè§é¢éå为 0 |
| | | const seekTo = video.duration > 0.2 ? 0.1 : 0 |
| | | // 2.2 seek + 3s è¶
æ¶ï¼currentTime 设为å½åå¼ï¼è¬å¦å·²ç»æ¯ 0 çæçè§é¢ï¼ |
| | | // é¨åæµè§å¨ä¸è§¦å onseekedï¼promise ä¼ä¸ç´ pending 塿»æ´æ¡é¾è·¯ |
| | | await new Promise<void>((resolve, reject) => { |
| | | const timer = setTimeout(() => reject(new Error('video seek timeout')), 3000) |
| | | video.addEventListener('seeked', () => { |
| | | clearTimeout(timer) |
| | | resolve() |
| | | }, { once: true }) |
| | | video.addEventListener( |
| | | 'error', |
| | | () => { |
| | | clearTimeout(timer) |
| | | reject(new Error('video seek error')) |
| | | }, |
| | | { once: true } |
| | | ) |
| | | video.currentTime = seekTo |
| | | }) |
| | | // 2.3 ç¦»å± canvas çæ¯ç¼©æ¾ï¼é¿è¾¹ cap 720ï¼VIDEO_COVER_MAX_DIMï¼ |
| | | const canvas = document.createElement('canvas') |
| | | const ratio = Math.min(1, VIDEO_COVER_MAX_DIM / Math.max(video.videoWidth, video.videoHeight)) |
| | | canvas.width = Math.round(video.videoWidth * ratio) |
| | | canvas.height = Math.round(video.videoHeight * ratio) |
| | | const context = canvas.getContext('2d') |
| | | if (context && canvas.width && canvas.height) { |
| | | // 2.4 å½å帧ç»å° canvas â toBlob æ¿ jpegï¼0.8 è´¨éæ¯è天å°é¢å¸¸ç¨çç¹ |
| | | context.drawImage(video, 0, 0, canvas.width, canvas.height) |
| | | cover = |
| | | (await new Promise<Blob | null>((resolve) => |
| | | canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.8) |
| | | )) ?? undefined |
| | | // 2.5 æåéæ¾ canvas backing storeï¼4K å尺寸 33MBï¼ï¼å«ç GC |
| | | canvas.width = 0 |
| | | canvas.height = 0 |
| | | } |
| | | } catch (error) { |
| | | console.warn('[IM] è§é¢å°é¢æªå失败', error) |
| | | } |
| | | return { ...meta, cover } |
| | | } finally { |
| | | // 3. æ¾å¼éæ¾ video èµæº |
| | | // 3.1 revoke æ¬å° objectUrl |
| | | URL.revokeObjectURL(objectUrl) |
| | | // 3.2 触å unloadï¼è®© decoder buffer ç«å³éæ¾ |
| | | video.removeAttribute('src') |
| | | video.load() |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * ä¸ä¼ å¹¶åé VIDEO æ¶æ¯ |
| | | * |
| | | * 1. ç«å³å ä½ï¼blob URL æ¢å½ video src ä¹å½ posterï¼æµè§å¨ä¼æ¿é¦å¸§ç»å¶ coverï¼status=SENDING + è¿åº¦æ¡ |
| | | * 2. probe ä¸è§é¢ä¸ä¼ åæ¥èµ·è·ï¼å°é¢ä¸ä¼ ç probe åº cover åä¸è§é¢ä¸ä¼ ç«é |
| | | * ï¼probe è§£ç + å°é¢ä¸ä¼ é常被è§é¢ä¸ä¼ æ¶é¿å®å
¨é®è½ï¼ä½æèçå ç¾ ms èµ·æ¥ï¼ |
| | | * 3. è§é¢æ¬ä½ä¸ä¼ å¿
é¡»æåï¼æ¿ä¸å° url å°±æå ä½ç½® FAILEDï¼å°é¢æ¯é¦ä¸æ·»è±ï¼å¤±è´¥ä»
æ¥å¿ |
| | | * 4. è§é¢é¾è·¯èæ¶é¿ï¼ä¸ä¼ æé´ç¨æ·åä¼è¯åæ¾å¼åéï¼é¿å
è½å°é误ä¼è¯éï¼ï¼åèµ°åå忥ä¸ç®ååï¼key ä»ç¸çï¼ |
| | | */ |
| | | async function uploadAndSendVideo(file: File) { |
| | | if (!ensureMediaSizeWithinLimit(file, ImContentType.VIDEO, message.warning)) { |
| | | return |
| | | } |
| | | const context = prepareMediaUpload() |
| | | if (!context) { |
| | | return |
| | | } |
| | | const { conversation } = context |
| | | const replyQuote = context.quote |
| | | const startKey = getConversationKey(conversation) |
| | | |
| | | // 1. ç«å³å ä½ï¼url èµ° blob 让 <video src> æé¦åèæ¸²æï¼coverUrl ä¸è®¾ blob |
| | | // ï¼<video poster> æå¾
å¾çèµæºï¼ä¼ video blob å¨é¨åæµè§å¨ä¼éåæé»åºï¼ä¸æ¯ç¨³å®è¡ä¸ºï¼ |
| | | // cover ç probe 弿¥åºçå® URL åç± commit é¶æ®µä¸èµ· patchï¼_localFile ç file ä¾å¤±è´¥éè¯ |
| | | // payload æ¼è£
èµ° mediaTypeHandlers[VIDEO].build ä¸ commit é¶æ®µå
±äº«åä¸ä»½é»è¾ |
| | | const videoHandler = requireMediaHandler(ImContentType.VIDEO) |
| | | const buildPlaceholderContent = (blobUrl: string): string => |
| | | serializeMessage(withQuotePayload(videoHandler.build(file, blobUrl, {}), replyQuote)) |
| | | const { clientMessageId } = insertMediaPlaceholder({ |
| | | file, |
| | | type: ImContentType.VIDEO, |
| | | conversation, |
| | | buildContent: buildPlaceholderContent |
| | | }) |
| | | |
| | | // 2. ä¸è·¯å¹¶è¡èµ·è·ï¼probe ä¸ä¸¤æ¡ä¸ä¼ æ ä¾èµï¼å°é¢ä¸ä¼ ç probe åº cover åç«å³æ¥åï¼ |
| | | // 2.1 è§é¢æ¬ä½ä¸ä¼ ï¼async IIFE å
ä¸å±è®© await æ¾å¼å¯è§ï¼lint ä¸åè¯¯å¤ floating promiseï¼ï¼ |
| | | // 失败å
åºä¸º url=undefinedï¼ç± step 3 æ¿ä¸å° url æ¶æ¶å°¾ |
| | | const videoUploadPromise: Promise<string | undefined> = (async () => { |
| | | try { |
| | | return await uploadFile( |
| | | { file }, |
| | | createUploadProgressHandler(conversation, clientMessageId) |
| | | ) |
| | | } catch (error) { |
| | | console.warn('[IM] è§é¢æ¬ä½ä¸ä¼ 失败', error) |
| | | return undefined |
| | | } |
| | | })() |
| | | // 2.2 probe æ¿å
ä¿¡æ¯ + å°é¢ blobï¼è§£ç 失败é级为空 probeï¼ä¸é»æè§é¢ä¸ä¼ |
| | | const probePromise = probeVideoFile(file).catch((error): VideoProbe => { |
| | | console.warn('[IM] è§é¢å
ä¿¡æ¯å 载失败ï¼é级为ä»
url + size', error) |
| | | return {} |
| | | }) |
| | | // 2.3 å°é¢ä¸ä¼ ï¼ç probe.cover åºæ¥åæ¥åèµ·è·ï¼ä¸è§é¢ä¸ä¼ ç«éï¼å¤±è´¥é级 coverUrl 为空 |
| | | const coverUploadPromise = probePromise.then(async (probe) => { |
| | | if (!probe.cover) { |
| | | return { probe, coverUrl: undefined as string | undefined } |
| | | } |
| | | try { |
| | | const coverUrl = await uploadFile({ |
| | | file: new File([probe.cover], `cover-${Date.now()}.jpg`, { type: 'image/jpeg' }) |
| | | }) |
| | | return { probe, coverUrl } |
| | | } catch (error) { |
| | | console.warn('[IM] è§é¢å°é¢ä¸ä¼ 失败', error) |
| | | return { probe, coverUrl: undefined as string | undefined } |
| | | } |
| | | }) |
| | | |
| | | // 3. æ¶å£æ ¡éª |
| | | // 3.1 ç两æ¡ä¸ä¼ é¾è·¯æ±å |
| | | const [videoRes, { probe, coverUrl }] = await Promise.all([ |
| | | videoUploadPromise, |
| | | coverUploadPromise |
| | | ]) |
| | | // 3.2 è§é¢æ¬ä½æ²¡ urlï¼å ä½ç½® FAILEDï¼è®©ç¨æ·å³å®éè¯ / å é¤ï¼_localFile å¨å
åéï¼ |
| | | const url = videoRes |
| | | if (!url) { |
| | | markMediaFailed(conversation.type, conversation.targetId, clientMessageId) |
| | | return |
| | | } |
| | | if (!isOpenableUrl(url)) { |
| | | console.warn('[IM] è§é¢ä¸ä¼ è¿åäºä¸æ¯ææå¼ç URL', { url }) |
| | | message.warning('ä¸ä¼ è¿åçè§é¢å°å䏿¯ææå¼') |
| | | markMediaFailed(conversation.type, conversation.targetId, clientMessageId) |
| | | return |
| | | } |
| | | const safeCoverUrl = coverUrl && isOpenableUrl(coverUrl) ? coverUrl : undefined |
| | | if (coverUrl && !safeCoverUrl) { |
| | | console.warn('[IM] è§é¢å°é¢ä¸ä¼ è¿åäºä¸æ¯ææå¼ç URL', { coverUrl }) |
| | | } |
| | | // 3.3 ä¸ä¼ åä¼è¯æ ¡éª + muteOverlay 夿¥ï¼ä¸ useMediaUploader.uploadAndSendMedia åä¸éï¼ |
| | | if ( |
| | | !verifyMediaUploadStillAllowed(conversation, startKey, ImContentType.VIDEO, clientMessageId) |
| | | ) { |
| | | return |
| | | } |
| | | |
| | | // 4. æ¼çå® VideoMessage payloadï¼patch è¿å ä½ + èµ° sendRaw å¤ç¨å ä½åé |
| | | const realContent = serializeMessage( |
| | | withQuotePayload( |
| | | videoHandler.build(file, url, { |
| | | videoProbe: { duration: probe.duration, width: probe.width, height: probe.height }, |
| | | videoCoverUrl: safeCoverUrl |
| | | }), |
| | | replyQuote |
| | | ) |
| | | ) |
| | | await commitMediaPlaceholder({ |
| | | type: ImContentType.VIDEO, |
| | | conversation, |
| | | clientMessageId, |
| | | realContent |
| | | }) |
| | | } |
| | | |
| | | /** è§é¢éå®å³ä¸ä¼ + åé VIDEO æ¶æ¯ï¼ä¸æ¾å
¥ editorï¼ç¬ç«é¾è·¯ï¼probe + åä¸ä¼ ï¼æç»èµ° commitMediaPlaceholder æ¶å°¾ï¼ */ |
| | | async function onVideoPicked(e: Event) { |
| | | const input = e.target as HTMLInputElement |
| | | const file = input.files?.[0] |
| | | input.value = '' |
| | | if (file) { |
| | | await uploadAndSendVideo(file) |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | å¤å±åºè²ä¸æ¶æ¯æµï¼bg-color-pageï¼ä¿æä¸è´ï¼è®©"æ¶æ¯ â è¾å
¥"æ è²å·®è¿æ¸¡ï¼ |
| | | padding ç»å
å±ç½å¡çå¼å¸ç©ºé´ï¼å¡çèªå¸¦è¾¹æ¡å°±å¤åºåè¾å
¥åºï¼ä¸åéè¦ä¸æ¡ border-t |
| | | --> |
| | | <div class="relative bg-[var(--ant-color-bg-layout)] px-3 pt-2 pb-3"> |
| | | <!-- ç¦è¨ / å°ç¦è¦çå±ï¼ä¼å
级 å°ç¦ > å
¨ç¾¤ç¦è¨ > æåç¦è¨ --> |
| | | <div |
| | | v-if="muteOverlay" |
| | | class="message-input__mute-overlay absolute top-2 right-3 bottom-3 left-3 z-20 flex items-center justify-center gap-2 rounded-lg border border-solid text-sm" |
| | | :class=" |
| | | muteOverlay.icon === 'ant-design:stop-outlined' |
| | | ? 'message-input__mute-overlay--error' |
| | | : 'message-input__mute-overlay--warning' |
| | | " |
| | | > |
| | | <Icon :icon="muteOverlay.icon" :size="18" /> |
| | | <span>{{ muteOverlay.text }}</span> |
| | | </div> |
| | | <!-- å
å±ç½è²åè§å¡ç = editor + å·¥å
·æ ï¼border + rounded 模æå¾®ä¿¡ãè¾å
¥æ¡ãè¾¹ç --> |
| | | <div |
| | | class="relative flex flex-col bg-[var(--ant-color-bg-container)] rounded-lg border border-solid border-[var(--im-border-color-lighter)]" |
| | | > |
| | | <!-- |
| | | è¾å
¥åºå¨ä¸ï¼contenteditable divï¼å代 textareaï¼å¯¹é½å¾®ä¿¡ PCï¼è¾å
¥åºå¨ä¸ï¼æä½å¨ä¸ï¼ |
| | | - 让 @ æµ®å±è½æ¿å°çå®å
æ rectï¼textarea æ¿ä¸å°ï¼ |
| | | - 让 @ æå以 <span data-id> token èç¹åå¨ï¼å token å³å idï¼é¿å
stale atUserIds |
| | | - placeholder éè¿ data-empty + ::before 模æï¼contenteditable 没æåç placeholderï¼ |
| | | --> |
| | | <div |
| | | ref="editorRef" |
| | | class="message-input__editor relative min-h-[120px] max-h-[200px] overflow-y-auto py-3.5 px-4 text-sm leading-normal outline-none whitespace-pre-wrap break-words" |
| | | contenteditable="true" |
| | | data-placeholder="æ Enter åéï¼Shift+Enter æ¢è¡" |
| | | data-empty="" |
| | | role="textbox" |
| | | @keydown="onKeydown" |
| | | @input="onInput" |
| | | @scroll.passive="onEditorScroll" |
| | | @paste.prevent="onPaste" |
| | | ></div> |
| | | |
| | | <!-- å¼ç¨é¢è§æ¡ --> |
| | | <ReplyPreview |
| | | v-if="replyTarget" |
| | | :quote="replyTarget" |
| | | closable |
| | | class="mx-3 mb-1.5" |
| | | @close="clearConversationReplyDraft" |
| | | /> |
| | | |
| | | <!-- |
| | | åºé¨å·¥å
·æ ï¼å·¦ä¾§æä½å¾æ + å³ä¾§åéæé®ï¼å¯¹é½å¾®ä¿¡ PCï¼æä½å¾æ ç»ä¸æ¾åºé¨ï¼ |
| | | - relative ç» FacePicker æä¾ absolute éç¹ï¼picker ç¨ bottom-full åä¸å¼¹åº |
| | | - 徿 ç»ä¸ 30Ã30 ç¹å»åºï¼18px icon + p-1.5ï¼ï¼gap-1 让é´è·è´´åå¾®ä¿¡è§æ |
| | | - border-t å¨ç¼è¾åºä¸å·¥å
·æ ä¹é´ç»ä¸æ¡ä¸ card è¾¹æ¡åè²çç»çº¿ |
| | | --> |
| | | <div |
| | | class="relative flex items-center justify-between gap-2 px-3 py-2 border-t border-t-solid border-[var(--im-border-color-lighter)]" |
| | | > |
| | | <div class="flex items-center gap-1"> |
| | | <!-- |
| | | ææ icon ç»ä¸èµ° Iconifyï¼ant-design outlined ç³»åï¼ï¼ |
| | | - è§è§é£æ ¼æ´æ¥è¿å¾®ä¿¡ PCï¼çº¿æ§ãåè§ï¼æ¯ Element Plus å
ç½®çæ´è½»éï¼ |
| | | - ç¬è¸ / å¾ç / æä»¶å¤¹ / 麦å
é£ åæºï¼é¿å
ä¸ä¸ªèµ° ep ä¸ä¸ªèµ° antd è§è§å²è£ |
| | | - å¤å± span å¤ç¨ .message-input__tool ç padding / hover æ ·å¼ï¼scoped CSS ç :deep(svg) ä»è½å½ä¸ |
| | | --> |
| | | <Tooltip title="表æ
" placement="top"> |
| | | <span |
| | | class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | @click.stop="toggleEmoji" |
| | | > |
| | | <Icon icon="ant-design:smile-outlined" :size="18" /> |
| | | </span> |
| | | </Tooltip> |
| | | <Tooltip title="åéå¾ç" placement="top"> |
| | | <span |
| | | class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | @click="imageInputRef?.click()" |
| | | > |
| | | <Icon icon="ant-design:picture-outlined" :size="18" /> |
| | | </span> |
| | | </Tooltip> |
| | | <Tooltip title="åéæä»¶" placement="top"> |
| | | <span |
| | | class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | @click="fileInputRef?.click()" |
| | | > |
| | | <Icon icon="ant-design:folder-outlined" :size="18" /> |
| | | </span> |
| | | </Tooltip> |
| | | <Tooltip title="è¯é³æ¶æ¯" placement="top"> |
| | | <span |
| | | class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | @click.stop="openVoice" |
| | | > |
| | | <Icon icon="ant-design:audio-outlined" :size="18" /> |
| | | </span> |
| | | </Tooltip> |
| | | <Tooltip title="åéè§é¢" placement="top"> |
| | | <span |
| | | class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | @click="videoInputRef?.click()" |
| | | > |
| | | <Icon icon="ant-design:video-camera-outlined" :size="18" /> |
| | | </span> |
| | | </Tooltip> |
| | | </div> |
| | | |
| | | <!-- 群è + 群已读å¼å¯ï¼åéæé® + â¼ ä¸æèåï¼ç¹ä¸»æé®æ®éåé / ç¹ â¼ éãåéåæ§æ¶æ¯ãï¼ï¼å¯¹é½å¾®ä¿¡ PC --> |
| | | <!-- 注æï¼ant-design-vue çåè£æé®æ¯ DropdownButtonï¼é Dropdown ç split-button 屿§ï¼é£æ¯ Element Plus åæ³ï¼ --> |
| | | <DropdownButton |
| | | v-if="isGroup && MESSAGE_GROUP_READ_ENABLED" |
| | | type="primary" |
| | | :disabled="!canSend" |
| | | @click="handleSend()" |
| | | > |
| | | å é |
| | | <template #overlay> |
| | | <Menu @click="handleSendMenuClick"> |
| | | <Menu.Item key="receipt">åéåæ§æ¶æ¯</Menu.Item> |
| | | </Menu> |
| | | </template> |
| | | </DropdownButton> |
| | | <!-- ç§èæç¾¤å·²è¯»å
³éï¼æ®éåéæé®ï¼æ ç¾¤åæ§å
¥å£ï¼ --> |
| | | <Button v-else type="primary" :disabled="!canSend" @click="handleSend()"> |
| | | å é |
| | | </Button> |
| | | |
| | | <!-- 表æ
颿¿ï¼bottom-full 让 picker 䏿²¿è´´å·¥å
·æ é¡¶é¨ï¼åä¸å¼¹åºï¼å¯¹é½å·¥å
·æ 左侧é¦å¾æ ï¼ --> |
| | | <FacePicker |
| | | v-model:visible="emojiVisible" |
| | | class="bottom-full left-3 mb-2" |
| | | @select-emoji="insertText" |
| | | @select-face="onSelectFace" |
| | | /> |
| | | |
| | | <!-- è¯é³å½å¶é¢æ¿ï¼ä¸è¡¨æ
颿¿åå¤å·¥å
·æ ï¼bottom-full åä¸å¼¹åºï¼é¿å
离触åç麦å
é£å¾æ è¿è¿ --> |
| | | <VoiceRecorder v-model="voiceVisible" class="bottom-full left-3 mb-2" @send="onVoiceSend" /> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- @ éæ©æµ®å±ï¼ç¾¤èæå¯ç¨ --> |
| | | <MentionPicker |
| | | ref="mentionRef" |
| | | v-model:visible="mentionVisible" |
| | | :position="mentionPosition" |
| | | :members="groupMembers" |
| | | :search-text="mentionSearchText" |
| | | :can-at-all="canAtAll" |
| | | @select="onMentionSelect" |
| | | /> |
| | | |
| | | <!-- éèçæä»¶éæ©å¨ --> |
| | | <input ref="imageInputRef" type="file" accept="image/*" hidden @change="onImagePicked" /> |
| | | <input ref="fileInputRef" type="file" hidden @change="onFilePicked" /> |
| | | <input ref="videoInputRef" type="file" accept="video/*" hidden @change="onVideoPicked" /> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* el-icon å
¨å±è§å .el-icon{color:var(--color,inherit); font-size:inherit; width:1em; height:1em} ä¼å
级æ´é«ï¼ |
| | | ç¨åé¢éæ©å¨ + !important éæ»ï¼é¢è²å Element Plus 主é¢åéï¼æè²èªå¨åå°æµ
ç° */ |
| | | .message-input__tool, |
| | | .message-input__tool:deep(svg) { |
| | | font-size: 18px !important; |
| | | color: var(--ant-color-text) !important; |
| | | fill: currentColor !important; |
| | | } |
| | | .message-input__tool:hover, |
| | | .message-input__tool:hover:deep(svg) { |
| | | color: var(--ant-color-primary) !important; |
| | | } |
| | | |
| | | .message-input__mute-overlay--warning { |
| | | color: #d48806; |
| | | background: #fff7e6; |
| | | border-color: #ffd591; |
| | | } |
| | | |
| | | .message-input__mute-overlay--error { |
| | | color: #cf1322; |
| | | background: #fff1f0; |
| | | border-color: #ffa39e; |
| | | } |
| | | |
| | | /* 注æï¼å¿
é¡»æ .dark ä¸ç®æ ç±»æ´ä½æ¾è¿ :global()ï¼å¦å Vue scoped ç¼è¯ä¼æ |
| | | `:global(.dark) .xxx` é误å¡ç¼©æè£¸ `.dark`ï¼å¯¼è´è¿äºé¢è²è¢«ç´æ¥å·å° <html> ä¸ |
| | | ï¼æè²ä¸æ´é¡µå红/åæé»ï¼ãBEM ç±»åå¯ä¸ï¼æ´ä½ global ä¸ä¼è¯¯ä¼¤å
¶å®å
ç´ ã */ |
| | | :global(.dark .message-input__mute-overlay--warning) { |
| | | color: #ffd666; |
| | | background: rgb(77 56 21 / 88%); |
| | | border-color: #ad6800; |
| | | } |
| | | |
| | | :global(.dark .message-input__mute-overlay--error) { |
| | | color: #ff7875; |
| | | background: rgb(91 33 33 / 88%); |
| | | border-color: #a8071a; |
| | | } |
| | | |
| | | /* ç¨ data-empty èé :emptyï¼æµè§å¨å¨å 空åä¼çä¸ <br>ï¼:empty ä¸å½ä¸ï¼data-empty ç± syncEditorState ç»´æ¤ */ |
| | | .message-input__editor[data-empty]::before { |
| | | content: attr(data-placeholder); |
| | | color: var(--ant-color-text-placeholder); |
| | | pointer-events: none; |
| | | position: absolute; |
| | | } |
| | | |
| | | /* @ token 走主è²é«äº®ï¼contenteditable=false 让 backspace æ´æ®µå è䏿¯éå符 */ |
| | | .message-input__editor :deep(.mention-token) { |
| | | color: var(--ant-color-primary); |
| | | } |
| | | </style> |