补齐第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
+5
View File
@@ -71,6 +71,11 @@ export function fullFormatter(style, data, values, key, stack) {
attr.duration = data.duration > 0 ? (data.duration | 0) : undefined;
attr.preview = data.preview;
attr.loading = 'lazy';
// 4.9 语音未读红点:chat-message.jsx 的 onVoicePlayed 记录"这条语音真的被点开听过",
// 跟消息本身有没有被标记已读是两件事。favorites/history 等别处复用同一个 formatter
// context 没有这个字段时,attr.onPlay 就是 undefined,AudioPlayer 那边本来就把它当
// 可选 prop 处理,不会因此出问题。
attr.onPlay = this.onVoicePlayed;
}
el = AudioPlayer;
// Audio element cannot have content.
+7
View File
@@ -510,6 +510,13 @@ export const confirmRiskChallenge = (riskChallengeId, decision = 'continue') =>
imPost('/security/risk-challenges/' + encodeURIComponent(riskChallengeId) + '/confirm', {decision});
export const getMentionableGroupMembers = (topic, query = '', cursor = '') =>
imGet('/groups/' + encodeURIComponent(topic) + '/members' + qs({mentionable: true, query, cursor}));
// 4.1 跨话题草稿:GET 对不存在的草稿返回 {content:'',entities:[],version:0} 而不是 404,PUT 用
// version 做乐观锁(对接文档描述的样子)。真实验证过用原生话题名(P2P 是对方 user_id,不是
// REST 层内部的 p2pXXX id)可以正常读写——这个接口内部会先查 Subs.Get 校验会话存在/有权限,
// 跟 favorites 那条从不做话题归一化、直接 NOT_FOUND 的坏路径不是同一个问题。
export const getTopicDraft = topic => imGet('/topics/' + encodeURIComponent(topic) + '/draft');
export const updateTopicDraft = (topic, content, entities, version) =>
imPut('/topics/' + encodeURIComponent(topic) + '/draft', {content, entities, version});
export async function fetchFileState(fileId, range = '') {
const headers = {
+137 -4
View File
@@ -30,7 +30,8 @@ import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constan
import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js';
import { topicDisplayName } from '../lib/utils.js';
import { checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap, getConversationContext,
getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, recallMessage } from '../lib/im-api.js';
getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, getTopicDraft, recallMessage,
updateTopicDraft } from '../lib/im-api.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
@@ -156,6 +157,7 @@ class MessagesView extends React.Component {
this.state.messagePolicy = null;
this.state.toast = null;
this.state.riskDialog = null;
this.mentionedUsers = [];
this.componentSetup = this.componentSetup.bind(this);
this.leave = this.leave.bind(this);
@@ -189,6 +191,11 @@ class MessagesView extends React.Component {
this.handleEnablePeer = this.handleEnablePeer.bind(this);
this.loadRelationshipContext = this.loadRelationshipContext.bind(this);
this.loadGroupContext = this.loadGroupContext.bind(this);
this.loadDraft = this.loadDraft.bind(this);
this.handleComposerChange = this.handleComposerChange.bind(this);
this.clearDraft = this.clearDraft.bind(this);
this.mentionSearch = this.mentionSearch.bind(this);
this.mentionSelected = this.mentionSelected.bind(this);
this.handleShowAnnouncement = this.handleShowAnnouncement.bind(this);
this.handleShowJoinRequests = this.handleShowJoinRequests.bind(this);
this.handleSendFriendVerification = this.handleSendFriendVerification.bind(this);
@@ -350,8 +357,10 @@ class MessagesView extends React.Component {
topic.onAuxUpdated = this.handleAuxUpdate;
}
this.mentionedUsers = [];
this.loadRelationshipContext(topic);
this.loadGroupContext(topic);
this.loadDraft(topic);
}
if (topic) {
@@ -413,7 +422,9 @@ class MessagesView extends React.Component {
groupRoles: null,
myGroupRole: null,
groupAnnouncement: null,
pendingJoinRequests: 0
pendingJoinRequests: 0,
draftText: null,
draftVersion: 0
};
} else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic);
@@ -440,6 +451,8 @@ class MessagesView extends React.Component {
myGroupRole: null,
groupAnnouncement: null,
pendingJoinRequests: 0,
draftText: null,
draftVersion: 0,
// MessagesView isn't remounted on topic switch (no `key={topic}` at the call site in
// tinode-web.jsx), so a pending risk-check dialog from the conversation just left would
// otherwise survive the switch -- confirming it would then send the old topic's message
@@ -1139,6 +1152,81 @@ class MessagesView extends React.Component {
}).catch(_ => {});
}
// 4.2-4.3 @提醒:只对群话题开放(私聊提@没有意义,也没有可选的"其他成员")。返回给
// send-message.jsx 的选择器渲染,真正的 uid 解析在 mentionSelected 里记下来,供发送时
// applyMentionResolution 使用。
mentionSearch(query) {
const topic = this.props.tinode ? this.props.tinode.getTopic(this.state.topic) : null;
if (!topic || !topic.isGroupType()) {
return Promise.resolve([]);
}
return getGroupMembers(this.state.topic, {mentionable: true, query}).then(data => data.items || []);
}
mentionSelected(uid, name) {
this.mentionedUsers = (this.mentionedUsers || []).filter(m => m.name !== name);
this.mentionedUsers.push({uid, name});
}
// 4.1 跨话题草稿:话题一切换就去问服务端这个会话有没有存过草稿。原生话题名(P2P 用对方
// user_id)直接可用,接口内部自己会做订阅存在性校验,不需要额外归一化。
loadDraft(topic) {
if (!topic) {
return;
}
const topicName = topic.name;
getTopicDraft(topicName).then(data => {
if (this.state.topic != topicName) {
return;
}
this.setState({draftText: data.content || '', draftVersion: data.version || 0});
}).catch(_ => {});
}
// 输入框每次变化都会调这个(不在这里节流——防抖延迟统一放这一处,跟日报/月报草稿已有的
// 600ms 防抖是同一个数值,方便以后统一调整)。编辑已有消息、转发预填内容这两种情况下
// SendMessage 的文本框也会触发 onChange,但那些不是"新写的草稿",不应该覆盖真正的草稿。
handleComposerChange(text) {
if (this.state.reply && this.state.reply.editing) {
return;
}
if (this.props.forwardMessage) {
return;
}
const topicName = this.state.topic;
if (!topicName) {
return;
}
if (this.draftSaveTimer) {
window.clearTimeout(this.draftSaveTimer);
}
this.draftSaveTimer = window.setTimeout(() => {
this.draftSaveTimer = null;
const version = (this.state.topic == topicName) ? (this.state.draftVersion || 0) : 0;
updateTopicDraft(topicName, text, [], version).then(d => {
if (this.state.topic == topicName) {
this.setState({draftVersion: d.version});
}
}).catch(_ => {});
}, 600);
}
// 消息真的发出去之后,把服务端存的草稿清空——不然重新打开这个会话会看到已经发送过的旧文字。
// 单纯等下一次防抖自然把空字符串存上去也能达到同样效果,但那样在"发送后立刻切走"这个真实
// 会发生的场景里会有一个几百毫秒的窗口,草稿还是旧内容,不如发送成功后立刻清。
clearDraft(topicName) {
if (this.draftSaveTimer) {
window.clearTimeout(this.draftSaveTimer);
this.draftSaveTimer = null;
}
const version = (this.state.topic == topicName) ? (this.state.draftVersion || 0) : 0;
updateTopicDraft(topicName, '', [], version).then(d => {
if (this.state.topic == topicName) {
this.setState({draftText: '', draftVersion: d.version});
}
}).catch(_ => {});
}
// Note: setUrlSidePanel only overwrites path[0] -- called with window.location.hash while a topic is
// open (path[1] is the open topic), it leaves that segment in place, producing e.g.
// "#group-announcement-detail/grpXXX?topic=grpXXX". handleHashRoute then re-parses path[1] as a topic
@@ -1234,6 +1322,25 @@ class MessagesView extends React.Component {
// Drafty entity array. Since every attachment this app actually sends uses the standard ent[]
// format (Drafty.attachFile/insertImage/insertVideo), none of them carry a flat file_id, so without
// this hint every image/video/file/voice message silently classifies as plain "text" server-side.
// Mutates a parsed Drafty document in place, upgrading any auto-detected "@name" MN entity whose
// raw matched text equals a name the user actually picked from the mention dropdown into a real,
// uid-bearing mention. Entities that don't match anything tracked (a literal "@word" the user just
// typed, or an edited/mismatched name) are left with their raw text value -- Tinode's own renderer
// already falls back to displaying that gracefully, this only upgrades the ones it safely can.
applyMentionResolution(parsed) {
if (!parsed || !Array.isArray(parsed.ent) || !this.mentionedUsers || !this.mentionedUsers.length) {
return;
}
parsed.ent.forEach(e => {
if (e.tp === 'MN' && e.data && typeof e.data.val === 'string') {
const match = this.mentionedUsers.find(m => m.name === e.data.val);
if (match) {
e.data.val = match.uid;
}
}
});
}
sendMessage(msg, uploadCompletionPromise, uploader, typeHint) {
// Captured before the reply/edit branches below possibly rewrite `msg` into a Drafty object
// (Drafty.append(...) for a reply-with-quote) -- the URL risk check further down keys off
@@ -1261,16 +1368,36 @@ class MessagesView extends React.Component {
head = {reply: '' + this.state.reply.seq};
if (typeof msg == 'string') {
msg = Drafty.parse(msg);
this.applyMentionResolution(msg);
}
msg = Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg);
}
this.handleCancelReply();
} else if (typeof msg == 'string' && this.mentionedUsers && this.mentionedUsers.length) {
// 4.2-4.3 @提醒:Drafty.parse 本来就会把纯文本里的"@word"自动识别成 MN 实体,但
// data.val 存的是匹配到的原始文字(tinode.dev.js 的 ENTITY_TYPES 表,pack: val =>
// ({val: val.slice(1)})),不是 uid——那是纯文本解析器,不可能凭空知道 uid。真正的 uid
// 只在用户从选择器里点选时才知道,记在 this.mentionedUsers 里;这里按显示名回填,把
// 自动识别出的占位符换成真的 uid。没有任何选过的提醒时完全不碰 msg,维持字符串原样,
// 交给 SDK 自己在更底层 parse(跟这次改动之前完全一样的行为,不引入任何差异)。
const parsed = Drafty.parse(msg);
this.applyMentionResolution(parsed);
msg = parsed;
}
if (typeHint) {
head = {...(head || {}), 'x-im-type': typeHint};
}
const doSend = () => this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
const topicForDraft = this.state.topic;
const doSend = () => {
const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
// Only a plain-text compose (not a reply/edit/forward, all of which pass a Drafty object or
// reuse contentToEdit rather than the persisted draft) has a server-side draft worth clearing.
if (originalText != null && result && typeof result.then == 'function' && topicForDraft) {
result.then(() => this.clearDraft(topicForDraft));
}
return result;
};
// Pre-send URL risk check (POST /security/urls/check) -- the only Ch.11 "anti-fraud" mechanism
// with a real backend behind it. Only plain-text composed messages are checked (not forwards,
@@ -2186,14 +2313,20 @@ class MessagesView extends React.Component {
// the real Tinode ACS-level restrictions and are unaffected by this.
disabled={!this.state.isWriter || this.state.deleted}
reply={this.state.reply}
initMessage={this.state.contentToEdit}
initMessage={this.state.contentToEdit != null ? this.state.contentToEdit : this.state.draftText}
sendOnEnter={this.props.sendOnEnter}
onKeyPress={this.sendKeyPress}
onRecordingProgress={this.sendKeyPress}
onMessageChange={this.props.forwardMessage ? null : this.handleComposerChange}
onMentionSearch={(this.props.forwardMessage || !groupTopic) ? null : this.mentionSearch}
onMentionSelected={this.mentionSelected}
onSendMessage={this.sendMessage}
onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile}
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImageOrVideo}
onAttachAudio={this.props.forwardMessage ? null : this.sendAudioAttachment}
canStartCall={!this.props.forwardMessage && this.props.canStartCall}
onStartAudioCall={this.props.onStartAudioCall}
onStartVideoCall={this.props.onStartVideoCall}
onError={this.props.onError}
onQuoteClick={this.handleQuoteClick}
onCancelReply={this.handleCancelReply} />}
+24 -9
View File
@@ -1995,6 +1995,22 @@ class TinodeWeb extends React.Component {
});
}
// Extracted out of defaultTopicContextMenu so the new 4.9-4.14 attachment-panel call shortcuts
// (send-message.jsx) can gate on exactly the same real-world availability check as the existing
// "..." menu items, instead of re-deriving (and risking drifting from) the same three conditions.
canStartCall(topicName) {
const topic = this.tinode.getTopic(topicName);
if (!topic || topic._deleted || !Tinode.isP2PTopicName(topicName)) {
return false;
}
const acs = topic.getAccessMode();
const writer = !!(acs && acs.isWriter());
const cachedBootstrap = this.state.myUserId ? readCachedData('bootstrap:' + this.state.myUserId) : null;
const callFeatureOk = !cachedBootstrap || !cachedBootstrap.features ||
(cachedBootstrap.features.audio_call !== false && cachedBootstrap.features.video_call !== false);
return writer && callFeatureOk && !!this.tinode.getServerParam('iceServers');
}
defaultTopicContextMenu(topicName) {
const topic = this.tinode.getTopic(topicName);
@@ -2006,7 +2022,7 @@ class TinodeWeb extends React.Component {
const me = this.tinode.getMeTopic();
let muted = false, blocked = false, self_blocked = false, subscribed = false, deleter = false,
archived = false, pinned = false, webrtc = false, writer = false, p2p = false, self = false;
archived = false, pinned = false, webrtc = false, p2p = false, self = false;
if (topic) {
subscribed = topic.isSubscribed();
archived = topic.isArchived();
@@ -2018,20 +2034,16 @@ class TinodeWeb extends React.Component {
blocked = !acs.isJoiner();
self_blocked = !acs.isJoiner('want');
deleter = acs.isDeleter();
writer = acs.isWriter();
}
}
// 后端今天新增了一个全局(非按租户)的通话总开关:{pub head:webrtc:...} 现在会先过
// im_client_feature 这个开关,关闭时整条消息直接被服务端拒绝。这个开关缺失/查询失败时是
// fail-closed(当成关闭),比 Tinode 协议层的 iceServers 配置更容易出现"某天突然打不通"的
// 情况。这里读一下各页面已经在共享的 bootstrap 缓存,能读到就按 features 门控;读不到就按
// 原来的行为放行——按钮可见性只是一层预判,真正失败时上面 handleCallInvite 已经会报错,
// 这里不做的话用户点了按钮才发现打不通,做了至少能提前少显示一个注定失败的入口。
const cachedBootstrap = this.state.myUserId ? readCachedData('bootstrap:' + this.state.myUserId) : null;
const callFeatureOk = !cachedBootstrap || !cachedBootstrap.features ||
(cachedBootstrap.features.audio_call !== false && cachedBootstrap.features.video_call !== false);
webrtc = writer && callFeatureOk && !!this.tinode.getServerParam('iceServers');
// 情况。canStartCall 统一做这几条判断(也是 4.9-4.14 附件面板通话入口在用的同一份逻辑),
// 按钮可见性只是一层预判,真正失败时上面 handleCallInvite 已经会报错,这里不做的话用户点了
// 按钮才发现打不通,做了至少能提前少显示一个注定失败的入口。
webrtc = this.canStartCall(topicName);
p2p = Tinode.isP2PTopicName(topicName);
self = Tinode.isSelfTopicName(topicName);
@@ -2782,6 +2794,9 @@ class TinodeWeb extends React.Component {
onCallSendOffer={this.handleCallSendOffer}
onCallIceCandidate={this.handleCallIceCandidate}
onCallSendAnswer={this.handleCallSendAnswer}
canStartCall={this.state.topicSelected ? this.canStartCall(this.state.topicSelected) : false}
onStartAudioCall={this.handleStartAudioCall}
onStartVideoCall={this.handleStartVideoCall}
errorText={this.state.errorText}
errorLevel={this.state.errorLevel}
+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>
);
}