componentDidUpdate 的 initMessage 分支直接访问 this.messageEditArea 未判空,而输入框在 noInput(转发中)或 disabled(ACS未解析完)时会 被卸载——此时 initMessage(草稿/编辑内容)一变化就会解引用 null, 无 error boundary 兜底,整个应用白屏。 第十轮最小粒度测试 Ch3-4(实测复现2次)与 Ch8(同一处代码路径独立 命中)各自发现同一个bug。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
820 lines
39 KiB
React
820 lines
39 KiB
React
// 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 <AudioRecorder> 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 <AudioRecorder>
|
||
// 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
|
||
// <AudioRecorder> (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 ?
|
||
(<div id="reply-quote-preview">
|
||
<div className="cancel">
|
||
<a href="#" onClick={e => {e.preventDefault(); this.props.onCancelReply();}}><i className="material-icons gray">close</i></a>
|
||
</div>
|
||
{this.state.quote}
|
||
</div>) : null;
|
||
const audioEnabled = this.state.audioAvailable && this.props.onAttachAudio;
|
||
const mentionPanel = this.state.mentionQuery != null ?
|
||
(<div id="mention-picker">
|
||
{this.state.mentionResults.length === 0 ?
|
||
<div className="mention-picker-empty">无匹配成员</div> :
|
||
this.state.mentionResults.map((item, i) => (
|
||
<div key={item.user_id} className={'mention-picker-row' + (i === this.state.mentionHighlight ? ' active' : '')}
|
||
onMouseDown={e => { e.preventDefault(); this.selectMention(item); }}
|
||
onMouseEnter={() => this.setState({mentionHighlight: i})}>
|
||
{item.group_nickname || item.display_name || item.user_id}
|
||
</div>
|
||
))}
|
||
</div>) : null;
|
||
return (
|
||
<div id="send-message-wrapper">
|
||
{!this.props.noInput ? quote : null}
|
||
{mentionPanel}
|
||
<div id="send-message-panel">
|
||
{!this.props.disabled ?
|
||
<>
|
||
{this.props.noInput ?
|
||
(quote || <div className="hr thin" />) :
|
||
(this.state.audioRec ?
|
||
(<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
|
||
description="Message shown when component is loading"/></div>}>
|
||
<AudioRecorder
|
||
ref={ref => {this.audioRecorderInstance = ref;}}
|
||
holdMode={true}
|
||
cancelling={this.state.audioCancelling}
|
||
onRecordingProgress={_ => this.props.onKeyPress(true)}
|
||
onDeleted={_ => this.setState({audioRec: false})}
|
||
onFinished={this.handleAttachAudio}/>
|
||
</Suspense>) :
|
||
<textarea id="send-message-input" placeholder={prompt}
|
||
value={this.state.message} onChange={this.handleMessageTyping}
|
||
onKeyDown={this.handleKeyPress}
|
||
ref={ref => {this.messageEditArea = ref;}} />)}
|
||
{/* 表情/附件两个入口固定在输入框右侧,哪个面板展开了对应图标就变⌨(点了收起换回键盘),
|
||
跟前面 toggleEmojiPanel 的规则说明是同一条约定,附件那侧照抄。 */}
|
||
{!this.props.noInput && !this.state.audioRec ?
|
||
<a href="#" onClick={this.toggleEmojiPanel} title="表情">
|
||
<i className="material-icons secondary">{this.state.emojiOpen ? 'keyboard' : 'insert_emoticon'}</i>
|
||
</a> : null}
|
||
{this.props.onAttachFile && !this.state.audioRec ?
|
||
<a href="#" onClick={this.toggleAttachPanel} title={formatMessage(messages.icon_title_attach_file)}>
|
||
<i className="material-icons secondary">{this.state.attachOpen ? 'keyboard' : 'add_circle_outline'}</i>
|
||
</a> : null}
|
||
{this.state.message || !audioEnabled ?
|
||
<a href="#" onClick={this.handleSend} title={formatMessage(messages.icon_title_send)}>
|
||
<i className="material-icons">{sendIcon}</i>
|
||
</a> :
|
||
!this.state.audioRec ?
|
||
<a href="#" onMouseDown={this.startHoldRecording} onTouchStart={this.startHoldRecording}
|
||
onClick={e => e.preventDefault()} title={formatMessage(messages.icon_title_record_voice)}>
|
||
<i className="material-icons">mic</i>
|
||
</a> :
|
||
null
|
||
}
|
||
<input type="file" ref={ref => {this.attachFile = ref}}
|
||
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
||
<input type="file" ref={ref => {this.attachImage = ref}} accept="image/*, video/*"
|
||
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
||
<input type="file" ref={ref => {this.attachCamera = ref}} accept="image/*" capture="environment"
|
||
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
||
</>
|
||
:
|
||
<div id="writing-disabled">{prompt}</div>
|
||
}
|
||
</div>
|
||
{this.state.emojiOpen ?
|
||
<div id="emoji-panel">
|
||
<div id="emoji-panel-tabs">
|
||
<a href="#" className={this.state.emojiTab === 'emoji' ? 'active' : ''}
|
||
onClick={e => {e.preventDefault(); this.setState({emojiTab: 'emoji'});}}>😊</a>
|
||
<a href="#" className={this.state.emojiTab === 'recent' ? 'active' : ''}
|
||
onClick={e => {e.preventDefault(); this.setState({emojiTab: 'recent'});}}>♡</a>
|
||
</div>
|
||
<div id="emoji-panel-grid">
|
||
{(this.state.emojiTab === 'emoji' ? EMOJI_LIST : this.state.recentEmoji).length === 0 ?
|
||
<div className="emoji-panel-empty">还没有常用表情</div> :
|
||
(this.state.emojiTab === 'emoji' ? EMOJI_LIST : this.state.recentEmoji).map((emoji, i) => (
|
||
<a href="#" key={emoji + i} onClick={e => {e.preventDefault(); this.insertEmoji(emoji);}}>{emoji}</a>
|
||
))}
|
||
</div>
|
||
{/* 参考图表情网格下方那一整行退格/发送专属控件——原来完全没做,"只点表情不碰键盘"
|
||
这条路径下用户还得把视线移出面板去点外面的发送图标。复用已有的 sendIcon/handleSend,
|
||
不重新发明发送逻辑。 */}
|
||
<div id="emoji-panel-footer">
|
||
<a href="#" onClick={e => {e.preventDefault(); this.deleteLastChar();}} title="删除">
|
||
<i className="material-icons">backspace</i>
|
||
</a>
|
||
<a href="#" className={'emoji-panel-send' + (this.state.message ? ' active' : '')}
|
||
onClick={e => {e.preventDefault(); this.handleSend(e);}} title={formatMessage(messages.icon_title_send)}>
|
||
<i className="material-icons">{sendIcon}</i>
|
||
</a>
|
||
</div>
|
||
</div> : null}
|
||
{this.state.attachOpen ?
|
||
<div id="attach-panel">
|
||
<a href="#" onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); this.attachImage.click();}}>
|
||
<i className="material-icons">photo</i><span>照片</span>
|
||
</a>
|
||
<a href="#" onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); this.attachCamera.click();}}>
|
||
<i className="material-icons">photo_camera</i><span>拍摄</span>
|
||
</a>
|
||
<a href="#" className={this.props.canStartCall ? '' : 'disabled'}
|
||
onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); if (this.props.canStartCall && this.props.onStartAudioCall) this.props.onStartAudioCall();}}>
|
||
<i className="material-icons">call</i><span>语音通话</span>
|
||
</a>
|
||
<a href="#" onClick={this.openCardPicker}>
|
||
<i className="material-icons">badge</i><span>名片</span>
|
||
</a>
|
||
<a href="#" onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); this.attachFile.click();}}>
|
||
<i className="material-icons">attach_file</i><span>文件</span>
|
||
</a>
|
||
<a href="#" onClick={this.openFavPicker}>
|
||
<i className="material-icons">star_border</i><span>收藏</span>
|
||
</a>
|
||
<a href="#" className={this.props.canStartCall ? '' : 'disabled'}
|
||
onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); if (this.props.canStartCall && this.props.onStartVideoCall) this.props.onStartVideoCall();}}>
|
||
<i className="material-icons">videocam</i><span>视频通话</span>
|
||
</a>
|
||
</div> : null}
|
||
{this.state.cardPicker ?
|
||
<div className="send-message-picker-backdrop" onClick={() => this.setState({cardPicker: null})}>
|
||
<section onClick={e => e.stopPropagation()}>
|
||
<header>选择联系人<a href="#" onClick={e => {e.preventDefault(); this.setState({cardPicker: null});}}><i className="material-icons">close</i></a></header>
|
||
<div className="send-message-picker-list">
|
||
{this.state.cardPicker.loading ? <p className="send-message-picker-empty">加载中…</p> :
|
||
this.state.cardPicker.items.length === 0 ? <p className="send-message-picker-empty">{this.state.cardPicker.error ? '加载失败' : '暂无好友'}</p> :
|
||
this.state.cardPicker.items.map(item => (
|
||
<a href="#" key={item.user_id} onClick={e => {e.preventDefault(); this.sendCard(item);}}>{item.display_name || item.user_id}</a>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div> : null}
|
||
{this.state.favPicker ?
|
||
<div className="send-message-picker-backdrop" onClick={() => this.setState({favPicker: null})}>
|
||
<section onClick={e => e.stopPropagation()}>
|
||
<header>我的收藏<a href="#" onClick={e => {e.preventDefault(); this.setState({favPicker: null});}}><i className="material-icons">close</i></a></header>
|
||
<div className="send-message-picker-list">
|
||
{this.state.favPicker.loading ? <p className="send-message-picker-empty">加载中…</p> :
|
||
this.state.favPicker.items.length === 0 ? <p className="send-message-picker-empty">{this.state.favPicker.error ? '加载失败' : '暂无收藏'}</p> :
|
||
this.state.favPicker.items.map(item => (
|
||
<a href="#" key={item.favorite_id} onClick={e => {e.preventDefault(); this.sendFavorite(item);}}>
|
||
{favoriteSummary(item)}
|
||
</a>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
};
|
||
|
||
export default injectIntl(SendMessage);
|