修复风控弹窗取消后再次发送同一条回复,引用内容与正文被永久追加重复

现象:对一条历史消息点"回复",输入含链接的文本触发风控弹窗后点"取消发送"
(预览条正确保留,这是既有修复的正常行为),不改动文本再次点发送、这次
在弹窗里点"我已知晓,继续发送"——最终发出的消息里,正文被写了两遍(引用
块本身只有一份),例如输入"排查根因4 xxx",实际发出"[引用] 排查根因4
xxx排查根因4 xxx"。第三次发送同一条文本会变成正文三遍,以此类推。

根因:sendMessage()给回复拼接引用内容时写的是
`Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg)`。
Drafty.sanitizeEntities和Drafty.append(umd/tinode.dev.js,第三方SDK,
未改动)都会原地修改第一个参数并把同一个引用返回,从不做深拷贝——
sanitizeEntities内部直接改content.ent[i]再return content,append直接
first.txt+=、first.fmt.push()再return first。这里把this.state.reply.content
(React state里"正在回复"预览条依赖的同一个对象)原样传进去,等于每次
点发送都会把当次输入的正文永久焊死在这条回复引用的原始内容上。

平时这个副作用不可见:一次成功发送后handleCancelReply()马上清掉
state.reply,被污染的对象跟着一起扔掉。但URL风控检查会把真正调用
doSend()(进而调用这行代码)的时机推迟到弹窗resolve之后,"取消发送"
按现有修复(a964d51b)刻意不清空state.reply以保留预览条——这就让
被污染的引用内容留了下来,等下一次点发送时,同一个已经被污染的对象
再被追加一次,正文因此重复。

修复:拼接前对this.state.reply.content做一次深拷贝,让两个SDK方法的
原地修改只作用于用完即扔的副本,不管sendMessage对同一条还没清空的
回复调用多少次都不影响原始状态。

验证:真实浏览器操作,对消息点回复,输入带链接文本发送触发风控弹窗,
点"取消发送"(预览条与文本保留正常),不改文本再次发送并这次确认
"继续发送"——发出的消息引用块和正文都只出现一次,不再重复。

第14+轮最小粒度测试 Ch4 发现(回复+风控弹窗联动场景,紧邻已知修复
a964d51b验证时发现)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-06 16:24:30 +08:00
co-authored by Claude Sonnet 5
parent 53e28e3bc6
commit 30e6d5e300
+18 -1
View File
@@ -1594,7 +1594,24 @@ class MessagesView extends React.Component {
msg = Drafty.parse(msg);
this.applyMentionResolution(msg);
}
msg = Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg);
// Both tinode.dev.js helpers below mutate their first argument in place and return that
// same reference (Drafty.sanitizeEntities: `return content` after patching content.ent[i]
// itself; Drafty.append: `first.txt += ...`, `first.fmt.push(...)`, then `return first`) --
// neither ever clones. Passing `this.state.reply.content` straight through used to hand this
// live React-state object to both, so every call here permanently welded whatever `msg` this
// particular attempt carried onto the quoted content the *reply itself* points to. Invisible
// on an ordinary send since handleCancelReply() clears state.reply right after, discarding the
// now-mutated copy along with it -- but the URL risk dialog above defers doSend (and this
// append) until *after* the check resolves, and cancelling it deliberately leaves state.reply
// alone so the reply survives (see doSend's own comment). Confirmed live: reply to a message,
// type a flagged link, send, click "取消发送", send the exact same text again, confirm through
// the dialog this time -- the composed message quoted the reply once but then repeated the
// typed text twice, because the first (cancelled) attempt had already appended it into
// state.reply.content, and the second attempt appended onto *that*. A deep clone here keeps
// both helpers' in-place mutations confined to a throwaway copy every time, however many times
// sendMessage runs for the same still-open reply.
const replyContentCopy = JSON.parse(JSON.stringify(this.state.reply.content));
msg = Drafty.append(Drafty.sanitizeEntities(replyContentCopy), msg);
}
} else if (typeof msg == 'string' && this.mentionedUsers && this.mentionedUsers.length) {
// 4.2-4.3 @提醒:Drafty.parse 本来就会把纯文本里的"@word"自动识别成 MN 实体,但