补齐第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
+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}