Files
im_webapp/src/widgets/send-message.jsx
T
Your NameandClaude Sonnet 5 f42b8536e5 第六轮深度测试:7个并行agent覆盖全部11章,修复21处前端问题
Ch1-2: 用户名注册邀请码错误改为友好文案(原为原始TypeError泄露给用户);
  企业注册页扫描图标补上点击反馈。
Ch3-4: 发名片消息补传typeHint(此前被后端误分类成text,搜索/历史摘要显示成JSON);
  文件卡片按mime大类区分图标颜色(此前所有文件都是同一个灰色通用图标);
  表情面板补上专属退格/发送按钮(浅蓝禁用/深蓝启用两态);订正一处与截图不符的代码注释。
Ch5-6: 群聊设置页"群成员"补上可点击的完整成员列表页(复用MemberPicker字母分组);
  群文件页去掉无依据的硬编码"/10GB"容量上限显示。
Ch7: 收藏点击图片/视频缩略图补上预览;"查找聊天记录-链接"tab补上定位回原消息;
  文件下载页返回入口按来源tab纠正;批量转发成功计数改为按目标会话去重
  (原公式混用"收藏条数"和"目标会话数"两个维度,混选多条收藏转发时可能显示负数);
  收藏/视频tab切换补上URL路由同步。
Ch8: topic.onInfo在通话期间补上事件转发(此前通话中"对方正在输入"和已读回执静默失效);
  主叫等待接听页补上"正在等待对方接受邀请…"文案;语音通话最小化胶囊的还原按钮移出
  拖拽区域(原来嵌在draggable内部,指针捕获可能吞掉点击);修复中等窗宽(641-750px)下
  本地摄像头预览遮挡"切换摄像头"按钮的CSS重叠。
Ch9: 部门树展开时跳过child_count=0的注定空请求;月报字数上限从硬编码1000改为读取
  后端真实的10000;发起会议补上"结束时间需晚于开始时间"的本地校验和更具体的报错;
  日报/月报统计接口补上department_id透传(此前包装函数注释断言后端会忽略,实际已支持)。
Ch10-11: URL风险检测补上g标志(原来一条消息只检查第一个链接,第二个链接哪怕是高危
  也不会被拦截);补上"安全教育"设置入口(原功能完整实现但零入口,需手改hash才能打开);
  修改密码按钮补上busy态反馈;青少年模式PIN页补上"忘记独立密码"入口(后端已支持重置,
  前端未接);"关于我们"补上"联系我们"行。

7个agent同时对全部11章存量后端问题条目逐一复核,结论详见待整理的后端问题清单更新
(Ch1-2发现一条P0级后端问题:验证码固定值"123456",已记录未修复,不在此commit修改
im_be仓库范围内)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 03:48:28 +08:00

807 lines
38 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: '[链接]'};
function favoriteSummary(item) {
const content = item.snapshot && item.snapshot.content;
if (content && typeof content.txt === 'string' && content.txt) {
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}, _ => {
// 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);