// Send message form. import React, { Suspense } from 'react'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { Drafty, TheCard } from 'tinode-sdk'; // Lazy-loading AudioRecorder because it's quite large due to // a dependency on webm-duration-fix. const AudioRecorder = React.lazy(_ => import('./audio-recorder.jsx')); import { KEYPRESS_DELAY } from '../config.js'; import { filePasted } from '../lib/blob-helpers.js'; import { replyFormatter } from '../lib/formatters.js'; import { forwardFavorites, getFavorites, getFileUrl, getFriends, translateReason } from '../lib/im-api.js'; // 4.5-4.6 表情面板:参考图确认了"只有2个tab"(😊表情 / ♡)和"发送按钮浅蓝禁用、深蓝启用"这两点, // 但没有任何一张图能看清第二个tab具体展示什么内容——判断为"常用"(这是这类两tab设计里最常见的 // 语义,也是本项目其它地方唯一有本地存储依据能撑起来的一种:不存在一个真实的"收藏表情"后端概念)。 // 表情本身的具体清单——纠正一处之前的误判:参考图`表情包展开(发送按钮默认状态).png`/`表情包 // 发送页面.png`其实清楚画出了一套具体的经典"黄脸"贴图(微信/QQ经典表情那种视觉风格),不是没有 // 图片依据。只是这个项目没有对应的贴图素材资源,做不到跟截图完全一致的视觉,退而求其次选了一组 // 常见、覆盖面广的系统 Unicode 表情——这是素材缺口下的折中方案,不是"没有依据、随便选的"。 const EMOJI_LIST = ['😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊', '😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗', '🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮', '🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜', '😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '☹️', '🙁', '😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩', '🤯', '😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '😡', '😠', '🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🥳', '🥴', '🥺', '🤫', '🤭', '🧐', '🤓', '😈', '👿', '👍', '👎', '👏', '🙌', '🤝', '🙏', '💪', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '💕', '💯', '🔥', '🎉', '🎂', '🌹', '☀️', '🌙', '⭐']; // 跟 content-library-view.jsx 的 TYPE_LABEL 是同一套值(那边是 view 层,这边是 widget,不方便 // 互相 import,就近各自维护一份小常量)。收藏摘要要优先读 snapshot.content.txt 这个结构化字段, // 不能像最初那版一样直接展示 snapshot.text——那是整条 content 原样 JSON.stringify 的结果 // (例如 {"txt":"..."}),不是给人看的,之前联调时真被这个坑绊了一次才发现。 const FAVORITE_TYPE_LABEL = {image: '[图片]', video: '[视频]', voice: '[语音]', file: '[文件]', link: '[链接]', text: '[文本]'}; function favoriteSummary(item) { // image/video/voice favorites' snapshot.content.txt is a placeholder single space in this // project's convention (confirmed via a real GET /favorites response) -- reading it as the // summary showed a blank line instead of a type label for these three types. if (item.type === 'image' || item.type === 'video' || item.type === 'voice') { return FAVORITE_TYPE_LABEL[item.type]; } const content = item.snapshot && item.snapshot.content; // A text favorite's snapshot.content is a plain string, not a {txt:...} Drafty object -- // content.txt on it is undefined, which used to fall through all the way to the internal // item.type value ("text") being shown to the user verbatim. if (typeof content === 'string' && content.trim()) return content; if (content && typeof content.txt === 'string' && content.txt.trim()) return content.txt; return FAVORITE_TYPE_LABEL[item.type] || item.type || item.favorite_id; } const RECENT_EMOJI_KEY = 'im-recent-emoji'; const MAX_RECENT_EMOJI = 30; function loadRecentEmoji() { try { const raw = JSON.parse(localStorage.getItem(RECENT_EMOJI_KEY) || '[]'); return Array.isArray(raw) ? raw : []; } catch (_) { return []; } } function pushRecentEmoji(emoji) { try { const list = [emoji].concat(loadRecentEmoji().filter(e => e !== emoji)).slice(0, MAX_RECENT_EMOJI); localStorage.setItem(RECENT_EMOJI_KEY, JSON.stringify(list)); } catch (_) { /* ignore */ } } const messages = defineMessages({ messaging_disabled: { id: 'messaging_disabled_prompt', defaultMessage: 'Messaging disabled', description: 'Prompt in SendMessage in read-only topic' }, type_new_message: { id: 'new_message_prompt', defaultMessage: 'New message', description: 'Prompt in send message field' }, add_image_caption: { id: 'image_caption_prompt', defaultMessage: 'Image caption', description: 'Prompt in SendMessage for attached image' }, file_attachment_too_large: { id: 'file_attachment_too_large', defaultMessage: 'The file size {size} exceeds the {limit} limit.', description: 'Error message when attachment is too large' }, cannot_initiate_upload: { id: 'cannot_initiate_file_upload', defaultMessage: 'Cannot initiate file upload.', description: 'Generic error messagewhen attachment fails' }, icon_title_record_voice: { id: 'icon_title_record_voice', defaultMessage: 'Record voice message', description: 'Icon tool tip for recording a voice message' }, icon_title_attach_file: { id: 'icon_title_attach_file', defaultMessage: 'Attach file', description: 'Icon tool tip for attaching a file' }, icon_title_add_image: { id: 'icon_title_add_image', defaultMessage: 'Add image', description: 'Icon tool tip for attaching an image' }, icon_title_send: { id: 'icon_title_send', defaultMessage: 'Send message', description: 'Icon tool tip for sending a message' }, }); class SendMessage extends React.PureComponent { constructor(props) { super(props); this.state = { quote: null, message: '', audioRec: false, audioAvailable: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia), // @提醒选择器:mentionQuery 非 null 时显示面板;mentionStart 是触发这次匹配的"@"在 // message 里的字符下标(不是 fmt/grapheme 下标,纯 JS 字符串下标,跟 slice 配对用)。 mentionQuery: null, mentionResults: [], mentionHighlight: 0, emojiOpen: false, emojiTab: 'emoji', recentEmoji: loadRecentEmoji(), attachOpen: false, cardPicker: null, favPicker: null, audioCancelling: false, }; // Timestamp when the previous key press was sent to the server. this.keypressTimestamp = 0; this.handlePasteEvent = this.handlePasteEvent.bind(this); this.handleAttachImage = this.handleAttachImage.bind(this); this.handleAttachFile = this.handleAttachFile.bind(this); this.handleAttachAudio = this.handleAttachAudio.bind(this); this.handleSend = this.handleSend.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this); this.handleMessageTyping = this.handleMessageTyping.bind(this); this.handleDropAttach = this.handleDropAttach.bind(this); this.handleQuoteClick = this.handleQuoteClick.bind(this); this.selectMention = this.selectMention.bind(this); this.startHoldRecording = this.startHoldRecording.bind(this); this.handleHoldMove = this.handleHoldMove.bind(this); this.endHoldRecording = this.endHoldRecording.bind(this); this.handleHoldVisibilityChange = this.handleHoldVisibilityChange.bind(this); this.toggleEmojiPanel = this.toggleEmojiPanel.bind(this); this.insertEmoji = this.insertEmoji.bind(this); this.deleteLastChar = this.deleteLastChar.bind(this); this.toggleAttachPanel = this.toggleAttachPanel.bind(this); this.openCardPicker = this.openCardPicker.bind(this); this.openFavPicker = this.openFavPicker.bind(this); this.sendCard = this.sendCard.bind(this); this.sendFavorite = this.sendFavorite.bind(this); this.formatReply = this.formatReply.bind(this); } componentDidMount() { if (this.messageEditArea) { this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false); if (window.getComputedStyle(this.messageEditArea).getPropertyValue('transition-property') == 'all') { // Set focus on desktop, but not on mobile: focus causes soft keyboard to pop up. this.messageEditArea.focus(); } } this.setState({quote: this.formatReply()}); } componentWillUnmount() { if (this.messageEditArea) { this.messageEditArea.removeEventListener('paste', this.handlePasteEvent, false); } if (this.mentionSearchTimer) { window.clearTimeout(this.mentionSearchTimer); } window.removeEventListener('mousemove', this.handleHoldMove); window.removeEventListener('mouseup', this.endHoldRecording); window.removeEventListener('touchmove', this.handleHoldMove); window.removeEventListener('touchend', this.endHoldRecording); window.removeEventListener('blur', this.endHoldRecording); document.removeEventListener('visibilitychange', this.handleHoldVisibilityChange); } componentDidUpdate(prevProps) { if (this.messageEditArea) { if (window.getComputedStyle(this.messageEditArea).getPropertyValue('transition-property') == 'all') { // Set focus on desktop, but not on mobile: focus causes soft keyboard to pop up. this.messageEditArea.focus(); } // Adjust height of the message area for the amount of text. this.messageEditArea.style.height = '0px'; this.messageEditArea.style.height = this.messageEditArea.scrollHeight + 'px'; } if (prevProps.topicName != this.props.topicName) { this.setState({message: this.props.initMessage || '', audioRec: false, quote: null, mentionQuery: null, mentionResults: [], emojiOpen: false, attachOpen: false, cardPicker: null, favPicker: null}); } else if (!prevProps.disabled && this.props.disabled && this.state.audioRec) { // isWriter/etc are real-time ACS bits (messages-view.jsx re-derives them from {pres}/{meta}), // so `disabled` can flip to true mid-recording -- the other party blocking you, being removed // from a group, or getting muted, all without the user releasing the hold-to-talk button first. // Unmounting here (rather than leaving it mounted under a now-hidden branch) // is what actually stops the mic via its componentWillUnmount; without this the recording just // sits there with `disabled` true and no surviving UI able to end it, and if `disabled` later // reverts to false in the same topic, this stale `true` would remount a fresh // with no user gesture at all -- silently starting to record again on its own. // // By the time this runs, React has already committed the `disabled` render and unmounted // (its ref callback nulled this.audioRecorderInstance), so endHoldRecording()'s // own handleDone/handleDelete call is a no-op here -- it's called anyway for the six // mousemove/mouseup/touchmove/touchend/blur/visibilitychange listeners startHoldRecording // registered on window/document, which endHoldRecording always removes unconditionally // regardless of whether the recorder instance is still around. Skipping this left them // dangling until the next real mouseup/touchend/blur/tab-switch happened to fire. this.endHoldRecording(); this.setState({audioRec: false}); } else if (prevProps.initMessage != this.props.initMessage) { const msg = this.props.initMessage || ''; this.setState({message: msg}, _ => { // messageEditArea can be null here: initMessage (draft/edit content) can change while the // textarea itself is unmounted, e.g. noInput during an active forward, or disabled while // ACS is still resolving right after a topic switch. if (this.messageEditArea) { // If there is text, scroll to bottom and set caret to the end. this.messageEditArea.scrollTop = this.messageEditArea.scrollHeight; this.messageEditArea.setSelectionRange(msg.length, msg.length); } }); } if (prevProps.reply != this.props.reply) { this.setState({quote: this.formatReply()}); } } formatReply() { return this.props.reply ? Drafty.format(this.props.reply.content, replyFormatter, { formatMessage: this.props.intl.formatMessage.bind(this.props.intl), authorizeURL: this.props.tinode.authorizeURL.bind(this.props.tinode) }) : null; } handlePasteEvent(e) { if (this.props.disabled) { return; } // onAttachImage/onAttachFile are explicitly null while forwarding a message // (messages-view.jsx) -- pasting a file in that state used to throw // "onAttachImage is not a function" instead of just not attaching anything. // FIXME: handle large files too. if (filePasted(e, file => { if (this.props.onAttachImage) { this.props.onAttachImage(file); } }, file => { if (this.props.onAttachFile) { this.props.onAttachFile(file); } }, this.props.onError)) { // If a file was pasted, don't paste base64 data into input field. e.preventDefault(); } } handleAttachImage(e) { if (e.target.files && e.target.files.length > 0 && this.props.onAttachImage) { this.props.onAttachImage(e.target.files[0]); } // Clear the value so the same file can be uploaded again. e.target.value = ''; } handleAttachFile(e) { if (e.target.files && e.target.files.length > 0 && this.props.onAttachFile) { this.props.onAttachFile(e.target.files[0]); } // Clear the value so the same file can be uploaded again. e.target.value = ''; } handleDropAttach(files) { if (files && files.length > 0 && this.props.onAttachFile) { this.props.onAttachFile(files[0]); } } handleAttachAudio(url, preview, duration) { this.setState({audioRec: false}); if (this.props.onAttachAudio) { this.props.onAttachAudio(url, preview, duration); } } handleSend(e) { e.preventDefault(); const message = this.state.message.trim(); if (message || this.props.acceptBlank || this.props.noInput) { const result = this.props.onSendMessage(message); // onSendMessage may return a promise resolving to `false` when a pre-send risk check blocked // or the user cancelled it -- in that case keep the composer text so nothing typed is lost. if (result && typeof result.then == 'function') { result.then(sent => { if (sent !== false) this.setState({message: ''}); }); } else { this.setState({message: ''}); } } } /* Send on Enter key */ handleKeyPress(e) { if (this.state.audioRec) { // Ignore key presses while audio is being recorded. e.preventDefault(); e.stopPropagation(); return; } if (this.state.mentionQuery != null) { if (e.key === 'Escape') { e.preventDefault(); this.setState({mentionQuery: null, mentionResults: []}); return; } if (this.state.mentionResults.length > 0 && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { e.preventDefault(); const delta = e.key === 'ArrowDown' ? 1 : -1; const count = this.state.mentionResults.length; this.setState({mentionHighlight: (this.state.mentionHighlight + delta + count) % count}); return; } if (e.key === 'Enter' && this.state.mentionResults.length > 0) { e.preventDefault(); e.stopPropagation(); this.selectMention(this.state.mentionResults[this.state.mentionHighlight]); return; } } // Enter pressed: either send, or enter a new line. if (e.key === 'Enter') { if (this.props.sendOnEnter == 'plain') { // Have Shift-Enter insert a line break instead if (!e.shiftKey) { e.preventDefault(); e.stopPropagation(); this.handleSend(e); } } else { if (e.ctrlKey || e.metaKey) { e.preventDefault(); e.stopPropagation(); this.handleSend(e); } } } } handleMessageTyping(e) { this.setState({message: e.target.value}); this.updateMentionState(e.target.value, e.target.selectionStart); if (this.props.onMessageChange) { this.props.onMessageChange(e.target.value); } if (this.props.onKeyPress) { const now = new Date().getTime(); if (now - this.keypressTimestamp > KEYPRESS_DELAY) { this.props.onKeyPress(); this.keypressTimestamp = now; } } } // 4.2-4.3 @提醒:只在群聊里挂 onMentionSearch(messages-view.jsx 只给群话题传这个 prop)。 // 触发条件是"光标前最近一个'@','@'前面是空白或整段开头,'@'和光标之间没有空白"——这样 // "联系xx@xx.com"这种邮箱场景里的@不会被误当成提醒触发。 updateMentionState(text, caret) { if (!this.props.onMentionSearch || caret == null) { return; } const before = text.slice(0, caret); const match = /(?:^|\s)@([^\s@]*)$/.exec(before); if (!match) { if (this.state.mentionQuery != null) { this.setState({mentionQuery: null, mentionResults: []}); } return; } const query = match[1]; const start = caret - query.length - 1; this.setState({mentionQuery: query, mentionStart: start, mentionHighlight: 0}); if (this.mentionSearchTimer) { window.clearTimeout(this.mentionSearchTimer); } this.mentionSearchTimer = window.setTimeout(() => { this.props.onMentionSearch(query).then(results => { // mentionQuery may have moved on (user kept typing, or closed the picker) by the time this // resolves -- only apply it if we're still showing a picker at all, matching the same // stale-response guard used everywhere else responses race state. if (this.state.mentionQuery != null) { this.setState({mentionResults: results || []}); } }).catch(() => {}); }, 200); } selectMention(item) { const {message, mentionStart, mentionQuery} = this.state; if (mentionStart == null || mentionQuery == null) { return; } const name = item.group_nickname || item.display_name || item.user_id; const insertion = '@' + name + ' '; const end = mentionStart + 1 + mentionQuery.length; const newMessage = message.slice(0, mentionStart) + insertion + message.slice(end); const caretPos = mentionStart + insertion.length; this.setState({message: newMessage, mentionQuery: null, mentionResults: []}); if (this.props.onMentionSelected) { this.props.onMentionSelected(item.user_id, name); } if (this.props.onMessageChange) { this.props.onMessageChange(newMessage); } // Textarea's own DOM value needs the same update -- setState alone re-renders with the right // `value` prop, but the caret placement below needs the element's real value/selection to // already match, and doing it in the same tick (rather than waiting on the render) avoids a // visible flash of the stale text with the caret in the wrong place. requestAnimationFrame(() => { if (this.messageEditArea) { this.messageEditArea.focus(); this.messageEditArea.setSelectionRange(caretPos, caretPos); } }); } handleQuoteClick(e) { e.preventDefault(); e.stopPropagation(); if (this.props.reply && this.props.onQuoteClick) { const replyToSeq = this.props.reply.seq; this.props.onQuoteClick(replyToSeq); } } // 组合面板图标切换规律(核对图片确认):表情/附件两个入口固定在输入框右侧;哪个面板当前 // 展开,对应的那个图标就变成"⌨"(点击收起换回键盘),另一个保持原样不变——不是两个一起变。 // 这里只做了表情这一侧,附件面板(4.13/4.9-4.14)另外接线时要遵守同一条规则,不要各自发明。 toggleEmojiPanel(e) { e.preventDefault(); this.setState(s => ({emojiOpen: !s.emojiOpen, attachOpen: false, audioRec: false})); } insertEmoji(emoji) { const ta = this.messageEditArea; const start = ta ? ta.selectionStart : this.state.message.length; const end = ta ? ta.selectionEnd : this.state.message.length; const newMessage = this.state.message.slice(0, start) + emoji + this.state.message.slice(end); pushRecentEmoji(emoji); this.setState({message: newMessage, recentEmoji: loadRecentEmoji()}); if (this.props.onMessageChange) { this.props.onMessageChange(newMessage); } const caretPosEmoji = start + emoji.length; requestAnimationFrame(() => { if (this.messageEditArea) { this.messageEditArea.focus(); this.messageEditArea.setSelectionRange(caretPosEmoji, caretPosEmoji); } }); } // 参考图的表情面板自带一个退格键,让"只点表情不碰键盘"这条路径也能删掉打错的字符——用 // Array.from 而不是 slice(0,-1) 切最后一个 Unicode 码点,避免把一个占两个UTF-16码元的emoji // (比如复合肤色/家庭类emoji)齐腰斩断,删出半个乱码字符。 deleteLastChar() { const ta = this.messageEditArea; const start = ta ? ta.selectionStart : this.state.message.length; const end = ta ? ta.selectionEnd : this.state.message.length; let newMessage, caretPos; if (start !== end) { newMessage = this.state.message.slice(0, start) + this.state.message.slice(end); caretPos = start; } else if (start > 0) { const before = Array.from(this.state.message.slice(0, start)); before.pop(); newMessage = before.join('') + this.state.message.slice(start); caretPos = before.join('').length; } else { return; } this.setState({message: newMessage}); if (this.props.onMessageChange) { this.props.onMessageChange(newMessage); } requestAnimationFrame(() => { if (this.messageEditArea) { this.messageEditArea.focus(); this.messageEditArea.setSelectionRange(caretPos, caretPos); } }); } toggleAttachPanel(e) { e.preventDefault(); this.setState(s => ({attachOpen: !s.attachOpen, emojiOpen: false})); } // 3.12/4.9-4.14 发名片:选一个好友,生成一张只含姓名+头像+该好友 uid 的名片消息发到当前会话。 // uid 必须用 TheCard.setTinodeID 写进 comm 数组(vCard 的"tinode"协议项),不是随便塞进某个 // 字段——widgets/the-card-mini.jsx 点开这张卡片时用 TheCard.getFirstTinodeID(card) 读这个 // 协议项来判断"发消息"按钮该指向谁,写错地方那个按钮会永远不出现。 openCardPicker(e) { e.preventDefault(); this.setState({attachOpen: false, cardPicker: {loading: true}}); getFriends().then(data => { this.setState({cardPicker: {loading: false, items: data.items || []}}); }).catch(() => this.setState({cardPicker: {loading: false, items: [], error: true}})); } sendCard(item) { this.setState({cardPicker: null}); const build = avatarUrl => { const card = new TheCard(item.display_name || item.user_id, avatarUrl); TheCard.setTinodeID(card, item.user_id); // Every other attachment sender (image/video/file/voice) threads a typeHint through to // messages-view.jsx's sendMessage(msg, uploadCompletionPromise, uploader, typeHint) so the // backend's imMessageType() classifier -- which only ever looks at head['x-im-type'] or a flat // content.file_id/url, never at real Drafty entities -- doesn't fall through to its "text" // default. This call was only ever passing the first argument, so every card message has been // silently misclassified as plain text (confirmed live: search/history summarize it as raw // Drafty JSON instead of a name). this.props.onSendMessage(Drafty.appendTheCard(null, card), undefined, undefined, 'card'); }; if (item.avatar_file_id) { getFileUrl(item.avatar_file_id).then(build).catch(() => build(undefined)); } else { build(undefined); } } // 收藏发送:走的是 Ch.7 已有的 forwardFavorites REST 接口(转发收藏的同一个接口,目标只给 // 当前会话一个),不经过 onSendMessage/Drafty——收藏本来就是后端已经存好快照的独立资源, // 不是本地新拼一条消息。 openFavPicker(e) { e.preventDefault(); this.setState({attachOpen: false, favPicker: {loading: true}}); getFavorites({type: 'all'}).then(data => { this.setState({favPicker: {loading: false, items: data.items || []}}); }).catch(() => this.setState({favPicker: {loading: false, items: [], error: true}})); } sendFavorite(item) { this.setState({favPicker: null}); if (!this.props.topicName) { return; } // 这个接口恒定 200,真实成败要看 items[].status(跟批量收藏/批量转发是同一套设计,全局代码 // 审查那一轮已经在其它几处调用点踩过同一个坑并修过——这是新写的这处,同样的坑要一起避开, // 不能只 .catch 网络层错误就当万事大吉。联调时真的用这条路径测出过 200 OK 但 // items[0].status:"failed"、reason:"PERMISSION_DENIED" 的情况(对方是真实好友、这个会话 // 本身正常能发消息),看起来是后端这个接口自己的权限判断有问题,不是本地能修的,如实报错 // 而不是假装发送成功。 forwardFavorites([item.favorite_id], [this.props.topicName]).then(result => { const failed = (result && result.items || []).filter(r => r.status === 'failed'); if (failed.length && this.props.onError) { this.props.onError('发送失败:' + (translateReason(failed[0].reason) || failed[0].reason || '未知原因'), 'err'); } }).catch(err => { if (this.props.onError) { this.props.onError((err && err.message) || '发送失败', 'err'); } }); } // 4.7-4.9 按住说话 + 上滑取消:mousedown/touchstart 直接挂载 AudioRecorder(挂载即开始录音, // 这一步跟原来点一下进入录音模式完全一样,没有改 AudioRecorder 自己的录音逻辑),松手时通过 // ref 直接调它的 handleDone/handleDelete——这两个方法本来是绑给它自己按钮的 onClick,要求一个 // 带 preventDefault 的事件对象,这里传一个最小的假事件满足这个要求,除此之外没有更深地伸手进 // 它的内部状态。上滑距离阈值 80px 是参照大多数同类应用(微信等)的常见取值,没有对应的可验证 // 数值来源,明确记录不是抄的哪个具体规格。 startHoldRecording(e) { e.preventDefault(); if (this.state.audioRec) { return; } this.holdStartY = 'touches' in e ? (e.touches[0] && e.touches[0].clientY) : e.clientY; this.setState({audioRec: true, audioCancelling: false, emojiOpen: false, attachOpen: false}); window.addEventListener('mousemove', this.handleHoldMove); window.addEventListener('mouseup', this.endHoldRecording); window.addEventListener('touchmove', this.handleHoldMove, {passive: false}); window.addEventListener('touchend', this.endHoldRecording); // mouseup/touchend only fire for a release point still inside this window/tab -- dragging the // pointer out of the browser entirely, switching tabs/apps, or a permission prompt stealing // focus (e.g. the very getUserMedia prompt this recording itself triggered, on a second call) // all leave the mic running with no further event this listener pair will ever see. Ending the // recording (same finish-or-cancel logic as a normal release, per the cancel-zone check) on // either signal is a safer default than waiting indefinitely for a mouseup that may never come. window.addEventListener('blur', this.endHoldRecording); document.addEventListener('visibilitychange', this.handleHoldVisibilityChange); } handleHoldVisibilityChange() { if (document.hidden) { this.endHoldRecording(); } } handleHoldMove(e) { const y = 'touches' in e ? (e.touches[0] && e.touches[0].clientY) : e.clientY; if (y == null || this.holdStartY == null) { return; } const cancelling = (this.holdStartY - y) > 80; if (cancelling !== this.state.audioCancelling) { this.setState({audioCancelling: cancelling}); } } endHoldRecording() { window.removeEventListener('mousemove', this.handleHoldMove); window.removeEventListener('mouseup', this.endHoldRecording); window.removeEventListener('touchmove', this.handleHoldMove); window.removeEventListener('touchend', this.endHoldRecording); window.removeEventListener('blur', this.endHoldRecording); document.removeEventListener('visibilitychange', this.handleHoldVisibilityChange); this.holdStartY = null; const fakeEvent = {preventDefault: () => {}}; if (this.audioRecorderInstance) { if (this.state.audioCancelling) { this.audioRecorderInstance.handleDelete(fakeEvent); } else { this.audioRecorderInstance.handleDone(fakeEvent); } } } render() { const { formatMessage } = this.props.intl; const prompt = this.props.disabled ? formatMessage(messages.messaging_disabled) : (this.props.messagePrompt ? formatMessage(messages[this.props.messagePrompt]) : formatMessage(messages.type_new_message)); const sendIcon = (this.props.reply && this.props.reply.editing) ? 'check_circle' : 'send'; const quote = this.state.quote ? () : null; const audioEnabled = this.state.audioAvailable && this.props.onAttachAudio; const mentionPanel = this.state.mentionQuery != null ? (
{this.state.mentionResults.length === 0 ?
无匹配成员
: this.state.mentionResults.map((item, i) => (
{ e.preventDefault(); this.selectMention(item); }} onMouseEnter={() => this.setState({mentionHighlight: i})}> {item.group_nickname || item.display_name || item.user_id}
))}
) : null; return (
{!this.props.noInput ? quote : null} {mentionPanel}
{!this.props.disabled ? <> {this.props.noInput ? (quote ||
) : (this.state.audioRec ? (
}> {this.audioRecorderInstance = ref;}} holdMode={true} cancelling={this.state.audioCancelling} onRecordingProgress={_ => this.props.onKeyPress(true)} onDeleted={_ => this.setState({audioRec: false})} onFinished={this.handleAttachAudio}/> ) :