Files
im_webapp/src/widgets/send-message.jsx
T
Your NameandClaude Sonnet 5 9d2db5a648 修复转发/切换话题时应用整体白屏崩溃
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>
2026-09-06 00:46:37 +08:00

820 lines
39 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);