gaoluyang
2 天以前 b64a0deae5b5d33f9e20671a68936b27f0b9b00b
src/views/im/home/pages/conversation/components/input/message-input.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1216 @@
<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>