补齐第3-4章composer功能缺口:跨话题草稿/@提醒/表情面板/附件面板/名片/收藏发送/按住说话/语音未读红点

第3-4章复测确认这批原型图要求的功能之前完全没做(真实聊天引擎 messages-view.jsx/
send-message.jsx 从Ch1开始一直是最基础的版本:两个附件图标+文本框+发送/麦克风,
没有@、没有表情面板、没有7宫格附件面板、没有名片/收藏发送、语音是点一下进入录音模式
不是按住说话)。这次一次性把8项都接上,除了新功能外,过程中顺带发现并修了4个真bug。

**跨话题草稿**(im-api.js 新增 getTopicDraft/updateTopicDraft,messages-view.jsx)
话题切换时拉取 GET /topics/{topic}/draft(真实验证过P2P用原生话题名——对方user_id——
可以正常读写,接口内部自己会查Subs.Get校验,不需要额外归一化),输入框内容600ms防抖
PUT回去;真正发送成功后立即清空服务端草稿,不等下一次防抖。真实验证:输入文字→切走
再切回→草稿还在;刷新整个页面(新WS会话)→草稿依然从服务端正确恢复。

**@提醒**(send-message.jsx 新增 mention 相关状态和UI,messages-view.jsx 新增
mentionSearch/mentionedUsers/applyMentionResolution)
只在群聊开放,检测"光标前最近一个未闭合的@"触发面板,调 getGroupMembers(topic,
{mentionable:true, query}) 做实时搜索。协议层面:Tinode原生Drafty.parse本来就会把
纯文本里的"@word"自动识别成MN实体,但data.val存的是匹配到的原始文字,不是uid——
真正的uid只有用户从选择器里点选时才知道,记在 messages-view.jsx 的
this.mentionedUsers,发送前重新parse一遍把匹配上的占位符换成真uid。真实验证:选
"kcg051307"发送后直接查后端存储,MN实体的data.val确认是真实uid
"usrYVeV0yZs8tw",不是原始文字。

**表情面板**(send-message.jsx)
2个tab(😊表情/♡常用),常用表情本地localStorage维护。图片没有拍到第二个tab具体
内容,判断"常用"是这类两tab设计里最常见、也是本项目里唯一有本地存储依据能撑起来的
语义。真实验证:点表情正确插入文本框,用过的表情正确出现在"常用"tab。

**7宫格附件面板**(send-message.jsx,替换原来常驻的照片/文件两个图标)
照片/拍摄/语音通话/名片/文件/收藏/视频通话。语音通话、视频通话复用第8章已有的
handleStartAudioCall/handleStartVideoCall(从 tinode-web.jsx 新抽出 canStartCall()
方法,同时替换掉"..."菜单里原来的内联判断,避免同一个判断逻辑两处维护),仅P2P且
真实通话功能可用时才不置灰——真实验证过群聊里这两项正确显示为禁用状态。

**名片消息**(send-message.jsx openCardPicker/sendCard,复用原生TheCard/
Drafty.appendTheCard)
选好友生成名片,uid必须用TheCard.setTinodeID写进vCard的comm数组(不是随便塞进某个
字段——widgets/the-card-mini.jsx渲染时用TheCard.getFirstTinodeID读这个协议项来判断
"发消息"按钮指向谁)。真实验证:选中好友后发出的名片消息真实带"聊天"按钮,点击可用。

**收藏发送到当前会话**(send-message.jsx openFavPicker/sendFavorite,复用Ch7的
forwardFavorites接口)
真实联调中发现并修复一个新bug:这个接口和其它几个批量接口一样,HTTP层恒定200,
真实成败要看items[].status——原来的写法完全没检查这个字段,等于假装总是成功。已改成
检查失败项并报错。同时真实测试还发现该接口对P2P目标会返回200+items[0].status:
"failed", reason:"PERMISSION_DENIED"(哪怕是真实好友、这个会话本身发消息完全正常),
看起来是后端这个接口自己的写权限判断有问题,不是前端能修的,已记录。

**按住说话+上滑取消录音**(send-message.jsx新增手势处理,audio-recorder.jsx新增
holdMode展示态)
mousedown/touchstart直接挂载AudioRecorder(挂载即开始录音,复用原有机制不动),
移动超过80px判定为"松开取消",松手时通过ref直接调组件内部的handleDone/handleDelete。
过程中发现并修复3个真bug:① injectIntl包裹AudioRecorder默认不支持ref转发
("Function components cannot be given refs"警告,ref静默失效),加
{forwardRef:true}修好;② handleDelete从来没有像handleDone那样判空mediaRecorder,
录音还没真正开始(getUserMedia尚未resolve)就点删除会直接抛TypeError,这是原有代码
一直存在的问题,这次因为按住松手可能在录音刚开始的瞬间发生而被真实触发;③
cleanUp()同理没判空this.stream。补上判空后,"从未真正开始录音就结束"这个场景会
直接当作取消处理并正确调用onDeleted,不会再把UI卡在录音态出不来(真实验证:这个
环境的浏览器面板本身会拦截麦克风访问,用这个"getUserMedia永远拒绝"的真实场景反而
完整测出了正常/取消两条路径在录音真正开始前结束时都不再卡死或报错)。

**语音消息未读红点**(chat-message.jsx/audio-player.jsx/formatters.js)
纯客户端概念,原生Tinode的recv/read只到"消息本身有没有被看到",跟"这条语音有没有
真的被点开听过"是两回事。audio-player.jsx的播放按钮新增可选onPlay回调(不传不影响
现有调用方),formatters.js的AU分支透传给它,chat-message.jsx按话题维护一份
localStorage"已播放seq集合",对收到的(非自己发的)语音消息,没播放过就在气泡右上角
显示红点。真实验证:用原生SDK的_routeData往话题本地缓存注入一条真实结构的语音消息
(浏览器面板拦截麦克风,没有真实语音消息可用,用这个方式验证渲染/播放回调/持久化
三段逻辑),红点正确出现;直接触发播放回调后红点消失且localStorage正确写入对应seq。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-04 21:59:09 +08:00
co-authored by Claude Opus 5
parent abef778c7f
commit 505a6d7d56
16 changed files with 1714 additions and 104 deletions
+6
View File
@@ -223,6 +223,12 @@ class AudioPlayer extends React.PureComponent {
} else if (this.audioPlayer.readyState >= 2) {
this.audioPlayer.play();
this.setState({playing: true}, this.visualize);
// 4.9 语音未读红点:唯一一个知道"用户真的点了播放"这一刻的地方——上层只知道会话/消息本身
// 有没有被标记已读(原生 Tinode recv/read),跟"这条语音有没有被真的点开听过"是两件事。
// 可选 prop,不传不影响任何现有调用点(收藏页/聊天记录检索也在用这个组件,不需要这个信号)。
if (this.props.onPlay) {
this.props.onPlay();
}
}
}
+45 -3
View File
@@ -237,9 +237,24 @@ class AudioRecorder extends React.PureComponent {
e.preventDefault();
this.durationMillis = 0;
this.startedOn = null;
this.mediaRecorder.stop();
// getUserMedia hasn't necessarily resolved yet -- a real, reachable race even outside the new
// 4.7-4.9 press-and-hold gesture (send-message.jsx can now call this within moments of mount):
// tap record then immediately tap delete, before the permission prompt (or this promise) settles,
// and mediaRecorder/stream are still null. handleDone already guarded its own `.stop()` call for
// exactly this reason; this one never did, so it 500'd with "Cannot read properties of null
// (reading 'stop')" instead of just cancelling a recording that never really started.
if (this.mediaRecorder) {
this.mediaRecorder.stop();
}
this.cleanUp();
this.setState({recording: false});
// onDeleted/onFinished normally only fire from mediaRecorder.onstop (below) -- if there's no
// mediaRecorder, that event can never happen, so the parent (send-message.jsx's audioRec state,
// driving whether this component is even mounted) would never hear back and stay stuck showing
// the recorder UI forever. Nothing was actually captured in this case, so call it directly.
if (!this.mediaRecorder && this.props.onDeleted) {
this.props.onDeleted();
}
}
handleDone(e) {
@@ -252,6 +267,11 @@ class AudioRecorder extends React.PureComponent {
// Stop recording and return data.
if (this.mediaRecorder) {
this.mediaRecorder.stop();
} else if (this.props.onDeleted) {
// Same never-started race as handleDelete -- nothing was recorded, so there's nothing to send.
// Treat it as a cancel (onDeleted) rather than silently leaving the parent's audioRec state
// (and this component) stuck open with no way forward.
this.props.onDeleted();
}
}
@@ -379,12 +399,29 @@ class AudioRecorder extends React.PureComponent {
if (this.audioInput) {
this.audioInput.disconnect();
}
this.stream.getTracks().forEach(track => track.stop());
// Same never-actually-started race as handleDelete's mediaRecorder guard above -- this.stream is
// still null until getUserMedia resolves, and componentWillUnmount already knew to check before
// calling this; this call site (handleDelete) didn't.
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
}
render() {
const {formatMessage} = this.props.intl;
const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
// 4.7-4.9 按住说话:send-message.jsx 现在用一个 ref 直接调这个组件的 handleDone/handleDelete
// 来驱动"松开发送/上滑取消",不再需要点这几个按钮——holdMode 时换成更贴近参考图的极简样子
// (波形+计时+提示文案),录音本身(MediaRecorder/波形可视化)完全不动,只是换了一层展示。
if (this.props.holdMode) {
return (
<div className={'audio audio-hold' + (this.props.cancelling ? ' cancelling' : '')}>
{this.state.recording ? <canvas ref={this.canvasRef} /> : null}
<div className="duration">{this.state.duration}</div>
<div className="audio-hold-hint">{this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'}</div>
</div>
);
}
return (
<div className="audio">
<a href="#" onClick={this.handleDelete} title={formatMessage(messages.icon_title_delete)}>
@@ -416,4 +453,9 @@ class AudioRecorder extends React.PureComponent {
}
}
export default injectIntl(AudioRecorder);
// forwardRef:true so send-message.jsx's ref (needed to drive handleDone/handleDelete externally
// for the 4.7-4.9 press-and-hold gesture) actually reaches the class instance instead of the
// injectIntl wrapper -- without this option injectIntl's wrapper is a function component and a
// plain ref on it silently resolves to null (React warns "Function components cannot be given
// refs"), which was live-caught leaving the recorder UI stuck open with no way to finish the hold.
export default injectIntl(AudioRecorder, {forwardRef: true});
+50 -2
View File
@@ -13,12 +13,37 @@ import { fullFormatter } from '../lib/formatters.js';
import { sanitizeUrl } from '../lib/utils.js';
import HashNavigation from '../lib/navigation.js';
// 4.9 语音未读红点:纯客户端概念,没有对应的后端字段——原生 Tinode 的 recv/read 只标记到
// "这条消息本身有没有被看到",跟"这条语音有没有被真的点开听过"是两回事(文字消息看一眼就算
// 读过,语音消息可能只是划过去、根本没点开播放)。按话题分别记一份"已播放过的 seq 集合"在
// localStorage,跟"最近使用表情"是同一类不需要后端参与的本地小状态。
const VOICE_PLAYED_KEY_PREFIX = 'im-voice-played:';
function isVoicePlayed(topic, seq) {
try {
const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + topic) || '[]');
return Array.isArray(list) && list.includes(seq);
} catch (_) {
return false;
}
}
function markVoicePlayed(topic, seq) {
try {
const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + topic) || '[]');
const set = Array.isArray(list) ? list : [];
if (!set.includes(seq)) {
// Cap so a very long-lived topic doesn't grow this without bound.
localStorage.setItem(VOICE_PLAYED_KEY_PREFIX + topic, JSON.stringify(set.concat(seq).slice(-500)));
}
} catch (_) { /* ignore */ }
}
class BaseChatMessage extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
progress: 0
progress: 0,
voicePlayed: isVoicePlayed(props.topic, props.seq)
};
if (props.uploader) {
@@ -32,13 +57,18 @@ class BaseChatMessage extends React.PureComponent {
this.handleContextClick = this.handleContextClick.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.handleDraftyClick = this.handleDraftyClick.bind(this);
this.handleVoicePlayed = this.handleVoicePlayed.bind(this);
this.formatterContext = {
formatMessage: props.intl.formatMessage.bind(props.intl),
viewportWidth: props.viewportWidth,
authorizeURL: props.tinode.authorizeURL.bind(props.tinode),
onHandleClick: this.handleDraftyClick
onHandleClick: this.handleDraftyClick,
// formatters.js's 'AU' case wires this to AudioPlayer's onPlay -- see handleVoicePlayed below
// for why this needs to live on the instance (closes over this specific message's seq/topic)
// rather than being a static, prop-derived value like the other formatterContext entries.
onVoicePlayed: this.handleVoicePlayed
};
}
@@ -180,6 +210,14 @@ class BaseChatMessage extends React.PureComponent {
this.props.onCancelUpload(this.props.seq, this.props.uploader);
}
handleVoicePlayed() {
if (this.state.voicePlayed) {
return;
}
markVoicePlayed(this.props.topic, this.props.seq);
this.setState({voicePlayed: true});
}
handleQuoteClick(e) {
e.preventDefault();
e.stopPropagation();
@@ -215,6 +253,15 @@ class BaseChatMessage extends React.PureComponent {
const isFailed = !this.props.response &&
(this.props.received == Tinode.MESSAGE_STATUS_FAILED || this.props.received == Tinode.MESSAGE_STATUS_FATAL);
const failReason = this.renderFailReason();
// 4.9 语音未读红点:只对收到的(response=true)语音消息生效——自己发的语音不需要提醒自己听。
let isVoiceMessage = false;
if (this.props.response && this.props.mimeType == Drafty.getContentType() && Drafty.isValid(this.props.content)) {
Drafty.entities(this.props.content, (_0, _1, tp) => {
if (tp === 'AU') { isVoiceMessage = true; }
return isVoiceMessage;
});
}
const showVoiceUnreadDot = isVoiceMessage && !this.state.voicePlayed;
let textSizeClass = 'message-content';
const fullDisplay = (this.props.isGroup && this.props.response &&
(this.props.sequence == 'single' || this.props.sequence == 'last'));
@@ -295,6 +342,7 @@ class BaseChatMessage extends React.PureComponent {
<i className="material-icons red message-fail-icon">error</i> : null}
<div>
<div className={bubbleClass}>
{showVoiceUnreadDot ? <i className="voice-unread-dot" /> : null}
<div className="content-meta">
<div className={textSizeClass}>
{bundleCard || content}
+399 -14
View File
@@ -1,7 +1,7 @@
// Send message form.
import React, { Suspense } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import { Drafty, TheCard } from 'tinode-sdk';
// Lazy-loading AudioRecorder because it's quite large due to
// a dependency on webm-duration-fix.
@@ -10,6 +10,47 @@ 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 } from '../lib/im-api.js';
// 4.5-4.6 表情面板:参考图确认了"只有2个tab"(😊表情 / ♡)和"发送按钮浅蓝禁用、深蓝启用"这两点,
// 但没有任何一张图能看清第二个tab具体展示什么内容——判断为"常用"(这是这类两tab设计里最常见的
// 语义,也是本项目其它地方唯一有本地存储依据能撑起来的一种:不存在一个真实的"收藏表情"后端概念)。
// 表情本身的具体清单同样没有图片依据,选了一组常见、覆盖面广的 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: {
@@ -68,6 +109,18 @@ class SendMessage extends React.PureComponent {
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.
@@ -83,6 +136,17 @@ class SendMessage extends React.PureComponent {
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.toggleEmojiPanel = this.toggleEmojiPanel.bind(this);
this.insertEmoji = this.insertEmoji.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);
}
@@ -103,6 +167,13 @@ class SendMessage extends React.PureComponent {
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);
}
componentDidUpdate(prevProps) {
@@ -118,7 +189,8 @@ class SendMessage extends React.PureComponent {
}
if (prevProps.topicName != this.props.topicName) {
this.setState({message: this.props.initMessage || '', audioRec: false, quote: null});
this.setState({message: this.props.initMessage || '', audioRec: false, quote: null, mentionQuery: null,
mentionResults: [], emojiOpen: false, attachOpen: false, cardPicker: null, favPicker: null});
} else if (prevProps.initMessage != this.props.initMessage) {
const msg = this.props.initMessage || '';
this.setState({message: msg}, _ => {
@@ -211,6 +283,27 @@ class SendMessage extends React.PureComponent {
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') {
@@ -234,6 +327,10 @@ class SendMessage extends React.PureComponent {
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) {
@@ -243,6 +340,68 @@ class SendMessage extends React.PureComponent {
}
}
// 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();
@@ -252,6 +411,144 @@ class SendMessage extends React.PureComponent {
}
}
// 组合面板图标切换规律(核对图片确认):表情/附件两个入口固定在输入框右侧;哪个面板当前
// 展开,对应的那个图标就变成"⌨"(点击收起换回键盘),另一个保持原样不变——不是两个一起变。
// 这里只做了表情这一侧,附件面板(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);
}
});
}
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);
this.props.onSendMessage(Drafty.appendTheCard(null, 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('发送失败:' + (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);
}
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);
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 ?
@@ -271,29 +568,34 @@ class SendMessage extends React.PureComponent {
{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.onAttachFile && !this.state.audioRec ?
<>
<a href="#" onClick={e => {e.preventDefault(); this.attachImage.click();}} title={formatMessage(messages.icon_title_add_image)}>
<i className="material-icons secondary">photo</i>
</a>
<a href="#" onClick={e => {e.preventDefault(); this.attachFile.click();}} title={formatMessage(messages.icon_title_attach_file)}>
<i className="material-icons secondary">attach_file</i>
</a>
</>
:
null}
{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}/>
@@ -302,12 +604,23 @@ class SendMessage extends React.PureComponent {
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="#" onClick={e => {e.preventDefault(); this.setState({audioRec: true})}} title={formatMessage(messages.icon_title_record_voice)}>
<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
@@ -316,11 +629,83 @@ class SendMessage extends React.PureComponent {
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>
</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>
);
}