修复回复/编辑消息触发风控弹窗时,"正在回复"预览条在弹窗结果出来前就被清空

现象:对一条历史消息点"回复",输入含链接的文本点发送,风控弹窗(安全提醒/警示级)
刚弹出的同时,composer 里"正在回复 XX"的预览条就已经消失了;如果这时候点"取消发送",
这条回复就被永久放弃——原来引用的消息内容和回复关系不再存在,也没有任何提示,唯一的
恢复办法是回到那条历史消息重新点一次"回复"。编辑消息(修改一条已发消息的内容后再次
包含链接)是同一根因的同款问题,只是编辑可以靠重新点"编辑"找回原文,没有回复那么彻底
不可逆。

根因:sendMessage() 原来在根据 this.state.reply / this.props.forwardMessage 组装好
msg/head 之后,同步、无条件地立刻调用了 this.handleCancelReply()(清空 reply/编辑
状态),这一步发生在下面的 URL 风控检查(POST /security/urls/check)之前——但风控
弹窗要等用户点"继续发送"或"取消发送"才会真正决定要不要走 doSend(),这中间的等待期里
UI 状态已经被提前清空了。

修法:把 handleCancelReply() 从三处提前调用点移到 doSend() 内部、真正调用
this.props.sendMessage(...) 之前。这样它只在真的要发送时才执行——不含链接的普通消息
里 doSend() 本来就是同步立即执行,时机与原来完全一样;命中风控的消息里,"继续发送"
时才会触发(此时清空回复状态是正确的,消息确实发出去了),"取消发送"时 doSend()
根本不会被调用,回复/编辑的预览条和引用内容都会完整保留,可以直接修改文本重试或
自己手动取消。转发场景不受影响:转发消息不会触发这条风控检查(originalText 恒为
null),移动调用时机对它是纯粹的时序等价替换。

验证:真实浏览器操作,对一条消息点"回复",输入含 https://example.com/a?token=abc123
的文本发送——警示弹窗弹出的同时预览条仍然可见;点"取消发送"后弹窗消失、预览条依旧
完整保留、composer 文本不变;再次点发送、这次点"我已知晓,继续发送",消息正确以
带引用卡片的回复形式发出,预览条在发送成功后才清空。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-06 13:32:59 +08:00
co-authored by Claude Sonnet 5
parent cdca509183
commit a964d51ba5
+13 -2
View File
@@ -1525,7 +1525,6 @@ class MessagesView extends React.Component {
// We are forwarding a message. // We are forwarding a message.
msg = this.props.forwardMessage.msg; msg = this.props.forwardMessage.msg;
head = this.props.forwardMessage.head; head = this.props.forwardMessage.head;
this.handleCancelReply();
} else if (this.state.reply) { } else if (this.state.reply) {
if (this.state.reply.editing) { if (this.state.reply.editing) {
if (msg == this.state.contentToEdit) { if (msg == this.state.contentToEdit) {
@@ -1545,7 +1544,6 @@ class MessagesView extends React.Component {
} }
msg = Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg); msg = Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg);
} }
this.handleCancelReply();
} else if (typeof msg == 'string' && this.mentionedUsers && this.mentionedUsers.length) { } else if (typeof msg == 'string' && this.mentionedUsers && this.mentionedUsers.length) {
// 4.2-4.3 @提醒:Drafty.parse 本来就会把纯文本里的"@word"自动识别成 MN 实体,但 // 4.2-4.3 @提醒:Drafty.parse 本来就会把纯文本里的"@word"自动识别成 MN 实体,但
// data.val 存的是匹配到的原始文字(tinode.dev.js 的 ENTITY_TYPES 表,pack: val => // data.val 存的是匹配到的原始文字(tinode.dev.js 的 ENTITY_TYPES 表,pack: val =>
@@ -1584,6 +1582,19 @@ class MessagesView extends React.Component {
// the same backend version=0-skips-the-lock issue explained in handleComposerChange above. // the same backend version=0-skips-the-lock issue explained in handleComposerChange above.
const draftVersionAtSend = this.state.draftVersion || 0; const draftVersionAtSend = this.state.draftVersion || 0;
const doSend = () => { const doSend = () => {
// Was called eagerly above (right after building `msg`/`head` from `this.state.reply`/
// `this.props.forwardMessage`), synchronously and unconditionally -- including on a message
// that turns out to need the URL risk dialog below, which only resolves *after* this function
// already returned. That meant clicking send on a reply/edit containing a link made the
// "回复"/编辑 preview bar vanish immediately, before the user had even seen the risk prompt;
// clicking "取消发送" then left the reply/edit permanently abandoned -- the quoted message was
// gone from the composer with no way back except manually replying again, even though the user
// only meant to cancel sending the flagged text, not the reply itself. Moving the call here
// means it only fires at the point a send is actually going to happen: immediately for the
// common non-URL case (this function runs synchronously right away, same timing as before),
// or deferred until "继续发送" for a flagged one -- and never at all if the dialog is cancelled,
// so the reply/edit state and its preview bar correctly survive a cancelled risk prompt.
this.handleCancelReply();
const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head); 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 // 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. // reuse contentToEdit rather than the persisted draft) has a server-side draft worth clearing.