第三轮深度测试:修复7个并行agent发现的前端问题,含REASON_MESSAGES系统性补全
派了Ch1-2/3-4/5-6/7/8/9/10-11共7个agent对全部11章重新深度测试。后端问题已核实追加进
docs/IM-后端问题清单-2026-09-04.md(新增6-9条,发给用户);本次是前端问题的修复。
**REASON_MESSAGES系统性补全(最高优先级,多个agent独立发现同一个缺口)**
im-api.js的REASON_MESSAGES表新增22个条目,最重要的是PERMISSION_DENIED——后端
server/im_http.go里403响应统一用这个reason(30余处调用),但表里原来只有一个从未
被后端实际发送过的死条目FORBIDDEN,导致全站几乎每一个403从工程一开始就在展示原始
英文"permission denied"。一并补上VERSION_CONFLICT/MALFORMED_REQUEST(Ch9 agent实测
命中)、NOT_FOUND、VERIFICATION_RATE_LIMITED(动态文案,同TOO_MANY_ATTEMPTS模式)等。
新增translateReason()导出函数并用于send-message.jsx的sendFavorite——favorites/forward
等几个批量接口恒定200、真实成败包在items[].reason里,imRequest的自动翻译够不到这种
per-item场景,之前直接显示原始reason字符串。tinode-web.jsx同理补了CTRL_ERROR_TEXT_CN
(原生WS {ctrl}协议错误,同样绕开REASON_MESSAGES)并修了原表从未真正匹配过的bug——
CommError会把状态码拼进message变成"authentication failed (401)",原表按裸文本精确
匹配,看着正常纯粹是因为401/403兜底文案凑巧一样,一旦命中"unknown authentication
scheme"这类会被兜底文案误导、"policy violation"这类会直接漏出英文。uploadLargeFile/
createChatUploader/createFileDownloader同样绕开REASON_MESSAGES(走原生{ctrl}协议),
补了UPLOAD_CTRL_TEXT_CN小词典。已用真实账号端到端验证:favorites/forward到P2P目标
触发的PERMISSION_DENIED现在正确显示"没有权限执行此操作";draft接口版本冲突正确显示
"内容已被修改,请刷新后重试"。
**编辑消息时打开附件面板会静默覆盖被编辑消息(Ch3-4 agent发现,数据损坏级别)**
sendMessage对editing状态一律用head.replace,但onAttachFile/onAttachImage/onAttachAudio
原来只在forwardMessage时置空,编辑态没有同款门禁——编辑一条消息不改文字、点"+"选张图片
或名片,会直接把附件内容替换掉被编辑消息的原文,无任何确认。messages-view.jsx新增
editingMessage门禁,和forwardMessage一样把三个attach回调置空(等价于隐藏整个"+"按钮)。
已用真实浏览器验证:编辑态下"+"按钮消失,取消编辑后正常恢复。
**草稿防抖单一共享定时器丢字(Ch1-2、Ch3-4两个agent独立发现)**
handleComposerChange用单个this.draftSaveTimer,话题A打字触发600ms保存后300ms内切到
话题B,会把A的定时器clearTimeout掉,那次输入永久丢失;version也在定时器触发时才重新
读state,此时可能已经在别的话题上,退化成硬编码0——叠加后端draft接口对version=0"无条件
跳过乐观锁"的行为(已记入后端清单新增6),可能静默覆盖别的标签页写入的更新草稿。改为
按话题分开的定时器map,version在调用的当下(保证仍在该话题)同步捕获进闭包。
**呼叫状态机两处真实bug(Ch8 agent此前发现,本次重新核实live代码后确认+修复)**
1. 通话中导航离开messages-view.jsx整个组件树(切到工作台/个人设置,或移动端切回会话
列表)时,没有任何hangup清理——componentSetup已有的"切换话题时正确挂断"逻辑只在
componentDidUpdate里,组件真正卸载时不会触发。CallPanel自己的componentWillUnmount
只做本地清理,不通知对方也不重置tinode-web.jsx顶层callState,导致下次切回通话话题
时remount出一个callState卡在CALL_STATE_IN_PROGRESS的"僵尸"面板——start()走"我们刚
接听"分支直接return,从不调getUserMedia,本地媒体永久拿不到。补了对称的
componentWillUnmount挂断逻辑。
2. this.callTimeoutTimer声明后从未被真正setTimeout赋值——呼出无人接听或accept信令
(fire-and-forget的{note}包)被丢会永久卡在"呼叫中"。补了armCallRingTimeout(),
60秒未接通按跟手动取消完全一样的路径挂断(不发明新的通话结果语义),在收到对方
accept信号的两处分支都正确清timer。
**其余前端修复(按文件)**
- messages-view.jsx: @提醒mentionedUsers只在切话题时清空,同一话题内后续消息里字面
文本"@张三"会被静默解析成真实提醒(发送后清空);jumpToMessage点开已撤回/删除的
搜索结果原来只console.error静默失败,补了用户可见的错误提示;批量收藏对
favorites/batch共享Idempotency-Key导致的静默丢失(已知后端问题)不再照单全收
"收藏成功",改为发送后重新拉取真实收藏列表核对每条是否真的落地。
- audio-recorder.jsx: getUserMedia权限弹窗未决出结果时组件被卸载,补_unmounted
标记避免在死实例上继续跑mediaRecorder.start()。
- send-message.jsx: disabled变为true时(对方拉黑/被禁言等实时ACS变化)没有强制结束
正在进行的按住录音,可能remount出无用户手势的"自动录音";mouseup/touchend覆盖不到
拖出窗口/切换标签页/权限弹窗抢焦点的释放场景,补了blur/visibilitychange兜底。
- tinode-web.jsx: messages-menu视图和messages-home共用MessageCenterView实例无key,
从messages-home以非整体刷新方式切到messages-menu时菜单打不开,补key={view}。
- message-center-view.jsx: 实名认证驳回提醒的dismiss key只按用户+状态区分,同一状态
的两次独立驳回会互相误压制,改按submission_id区分。
- contact-management-view.jsx: Profile/Settings靠getUserProfile刷新拉黑状态,但该
接口对被拉黑对象直接403,导致"之前会话就已拉黑"的场景完全感知不到——叠加
getConversationContext兜底(该接口对拉黑目标正确返回200+relationship)。
- group-management-view.jsx: AdminList显示群主/管理员姓名漏了group_nickname兜底;
JoinRequests从聊天页横幅进入时返回按钮硬编码回group-settings(应回聊天本身),
从Governance进入时返回group-settings而非group-governance(与AdminList/Muted不一致)。
- workplace-collaboration-view.jsx: OrganizationDirectory/DeptNode的部门树加载失败
被无条件当成"暂无数据"渲染,补loadError区分+重试链接。
- personal-settings-view.jsx: CacheClear对navigator.storage/caches整个不存在的情况
只有react出了范围的.catch()覆盖不到的同步抛错,加了特性检测+try/catch;
YouthModeVerify触发TOO_MANY_ATTEMPTS锁定后确定按钮没有禁用/倒计时,会继续发出
注定失败的请求。
- security-support-view.jsx: 意见反馈category字段在config未加载完成前可为空但没有
提交前校验,会打到后端400。
- use-cached-data.js: bootstrap等首次拉取失败且无缓存兜底时永久停在undefined,加了
一次3秒后重试(仅在完全没有缓存值时触发,已有缓存值维持原"显示旧数据"行为不变)。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
505a6d7d56
commit
93b856aec8
@@ -338,6 +338,9 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
}).catch(_ => {});
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.state.rtcPanel) {
|
||||
this.handleCallHangup(this.state.topic, this.props.callSeq);
|
||||
}
|
||||
if (this.messagesScroller) {
|
||||
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
|
||||
}
|
||||
@@ -1139,12 +1142,13 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
if (!topicName) {
|
||||
return;
|
||||
}
|
||||
if (this.draftSaveTimer) {
|
||||
window.clearTimeout(this.draftSaveTimer);
|
||||
const version = this.state.draftVersion || 0;
|
||||
this.draftSaveTimers = this.draftSaveTimers || {};
|
||||
if (this.draftSaveTimers[topicName]) {
|
||||
window.clearTimeout(this.draftSaveTimers[topicName]);
|
||||
}
|
||||
this.draftSaveTimer = window.setTimeout(() => {
|
||||
this.draftSaveTimer = null;
|
||||
const version = this.state.topic == topicName ? this.state.draftVersion || 0 : 0;
|
||||
this.draftSaveTimers[topicName] = window.setTimeout(() => {
|
||||
delete this.draftSaveTimers[topicName];
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.updateTopicDraft)(topicName, text, [], version).then(d => {
|
||||
if (this.state.topic == topicName) {
|
||||
this.setState({
|
||||
@@ -1154,12 +1158,12 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
}).catch(_ => {});
|
||||
}, 600);
|
||||
}
|
||||
clearDraft(topicName) {
|
||||
if (this.draftSaveTimer) {
|
||||
window.clearTimeout(this.draftSaveTimer);
|
||||
this.draftSaveTimer = null;
|
||||
clearDraft(topicName, knownVersion) {
|
||||
if (this.draftSaveTimers && this.draftSaveTimers[topicName]) {
|
||||
window.clearTimeout(this.draftSaveTimers[topicName]);
|
||||
delete this.draftSaveTimers[topicName];
|
||||
}
|
||||
const version = this.state.topic == topicName ? this.state.draftVersion || 0 : 0;
|
||||
const version = knownVersion != null ? knownVersion : this.state.topic == topicName ? this.state.draftVersion || 0 : 0;
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.updateTopicDraft)(topicName, '', [], version).then(d => {
|
||||
if (this.state.topic == topicName) {
|
||||
this.setState({
|
||||
@@ -1177,7 +1181,9 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
}
|
||||
handleShowJoinRequests(e) {
|
||||
e.preventDefault();
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic));
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic);
|
||||
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(hash, 'from', 'chat');
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(hash);
|
||||
}
|
||||
handleSendFriendVerification(e) {
|
||||
e.preventDefault();
|
||||
@@ -1208,18 +1214,26 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
return;
|
||||
}
|
||||
const topicName = this.state.topic;
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.favoriteMessages)(this.props.selectedSeqs.map(seq => ({
|
||||
const seqs = this.props.selectedSeqs.slice();
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.favoriteMessages)(seqs.map(seq => ({
|
||||
topic: topicName,
|
||||
seq
|
||||
}))).then(result => {
|
||||
const failed = result && result.failed || [];
|
||||
if (failed.length && failed.length === this.props.selectedSeqs.length) {
|
||||
this.props.onError('收藏失败', 'err');
|
||||
} else if (failed.length) {
|
||||
this.showToast(`已收藏,${failed.length} 条失败`);
|
||||
} else {
|
||||
this.showToast('收藏成功');
|
||||
}
|
||||
}))).then(() => {
|
||||
return (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.getFavorites)({
|
||||
type: 'all',
|
||||
limit: 100
|
||||
}).then(data => {
|
||||
const items = data && data.items || [];
|
||||
const landed = new Set(items.filter(it => it.source && it.source.topic == topicName).map(it => String(it.source.seq)));
|
||||
const missing = seqs.filter(seq => !landed.has(String(seq)));
|
||||
if (missing.length === seqs.length) {
|
||||
this.props.onError('收藏失败', 'err');
|
||||
} else if (missing.length) {
|
||||
this.showToast(`已收藏 ${seqs.length - missing.length}/${seqs.length} 条`);
|
||||
} else {
|
||||
this.showToast('收藏成功');
|
||||
}
|
||||
});
|
||||
}).catch(err => {
|
||||
this.props.onError(err.message, 'err');
|
||||
});
|
||||
@@ -1279,6 +1293,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
this.applyMentionResolution(parsed);
|
||||
msg = parsed;
|
||||
}
|
||||
this.mentionedUsers = [];
|
||||
if (typeHint) {
|
||||
head = {
|
||||
...(head || {}),
|
||||
@@ -1286,10 +1301,11 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
};
|
||||
}
|
||||
const topicForDraft = this.state.topic;
|
||||
const draftVersionAtSend = this.state.draftVersion || 0;
|
||||
const doSend = () => {
|
||||
const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
|
||||
if (originalText != null && result && typeof result.then == 'function' && topicForDraft) {
|
||||
result.then(() => this.clearDraft(topicForDraft));
|
||||
result.then(() => this.clearDraft(topicForDraft, draftVersionAtSend));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -1682,6 +1698,9 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
}, 1000);
|
||||
} else {
|
||||
console.error("Unresolved message ref", replyToSeq);
|
||||
if (this.props.onError) {
|
||||
this.props.onError('该消息已被删除或撤回', 'err');
|
||||
}
|
||||
}
|
||||
}
|
||||
jumpToMessage(seq) {
|
||||
@@ -2040,6 +2059,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
const online = this.state.deleted || topic.isSelfType() ? null : this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
|
||||
const titleClass = 'panel-title' + (this.state.deleted ? ' deleted' : '');
|
||||
const darkModeClass = this.props.colorSchema == 'dark' ? 'dark' : '';
|
||||
const editingMessage = !!(this.state.reply && this.state.reply.editing);
|
||||
let messagesComponent = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_22__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_22__.Fragment, {
|
||||
children: [this.state.groupAnnouncement ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_22__.jsxDEV)("a", {
|
||||
href: "#",
|
||||
@@ -2200,9 +2220,9 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
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,
|
||||
onAttachFile: this.props.forwardMessage || editingMessage ? null : this.handleAttachFile,
|
||||
onAttachImage: this.props.forwardMessage || editingMessage ? null : this.handleAttachImageOrVideo,
|
||||
onAttachAudio: this.props.forwardMessage || editingMessage ? null : this.sendAudioAttachment,
|
||||
canStartCall: !this.props.forwardMessage && this.props.canStartCall,
|
||||
onStartAudioCall: this.props.onStartAudioCall,
|
||||
onStartVideoCall: this.props.onStartVideoCall,
|
||||
@@ -2486,20 +2506,21 @@ __webpack_require__.r(__webpack_exports__);
|
||||
|
||||
|
||||
const VOICE_PLAYED_KEY_PREFIX = 'im-voice-played:';
|
||||
function isVoicePlayed(topic, seq) {
|
||||
function isVoicePlayed(myUid, topic, seq) {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + topic) || '[]');
|
||||
const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + myUid + ':' + topic) || '[]');
|
||||
return Array.isArray(list) && list.includes(seq);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function markVoicePlayed(topic, seq) {
|
||||
function markVoicePlayed(myUid, topic, seq) {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + topic) || '[]');
|
||||
const key = VOICE_PLAYED_KEY_PREFIX + myUid + ':' + topic;
|
||||
const list = JSON.parse(localStorage.getItem(key) || '[]');
|
||||
const set = Array.isArray(list) ? list : [];
|
||||
if (!set.includes(seq)) {
|
||||
localStorage.setItem(VOICE_PLAYED_KEY_PREFIX + topic, JSON.stringify(set.concat(seq).slice(-500)));
|
||||
localStorage.setItem(key, JSON.stringify(set.concat(seq).slice(-500)));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -2508,7 +2529,7 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
|
||||
super(props);
|
||||
this.state = {
|
||||
progress: 0,
|
||||
voicePlayed: isVoicePlayed(props.topic, props.seq)
|
||||
voicePlayed: isVoicePlayed(props.tinode.getCurrentUserID(), props.topic, props.seq)
|
||||
};
|
||||
if (props.uploader) {
|
||||
props.uploader.onProgress = this.handleProgress.bind(this);
|
||||
@@ -2662,7 +2683,7 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
|
||||
if (this.state.voicePlayed) {
|
||||
return;
|
||||
}
|
||||
markVoicePlayed(this.props.topic, this.props.seq);
|
||||
markVoicePlayed(this.props.tinode.getCurrentUserID(), this.props.topic, this.props.seq);
|
||||
this.setState({
|
||||
voicePlayed: true
|
||||
});
|
||||
@@ -3531,6 +3552,7 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
this.startHoldRecording = this.startHoldRecording.bind(this);
|
||||
this.handleHoldMove = this.handleHoldMove.bind(this);
|
||||
this.endHoldRecording = this.endHoldRecording.bind(this);
|
||||
this.handleHoldVisibilityChange = this.handleHoldVisibilityChange.bind(this);
|
||||
this.toggleEmojiPanel = this.toggleEmojiPanel.bind(this);
|
||||
this.insertEmoji = this.insertEmoji.bind(this);
|
||||
this.toggleAttachPanel = this.toggleAttachPanel.bind(this);
|
||||
@@ -3562,6 +3584,8 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
window.removeEventListener('mouseup', this.endHoldRecording);
|
||||
window.removeEventListener('touchmove', this.handleHoldMove);
|
||||
window.removeEventListener('touchend', this.endHoldRecording);
|
||||
window.removeEventListener('blur', this.endHoldRecording);
|
||||
document.removeEventListener('visibilitychange', this.handleHoldVisibilityChange);
|
||||
}
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.messageEditArea) {
|
||||
@@ -3583,6 +3607,10 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
cardPicker: null,
|
||||
favPicker: null
|
||||
});
|
||||
} else if (!prevProps.disabled && this.props.disabled && this.state.audioRec) {
|
||||
this.setState({
|
||||
audioRec: false
|
||||
});
|
||||
} else if (prevProps.initMessage != this.props.initMessage) {
|
||||
const msg = this.props.initMessage || '';
|
||||
this.setState({
|
||||
@@ -3910,7 +3938,7 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_6__.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');
|
||||
this.props.onError('发送失败:' + ((0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_6__.translateReason)(failed[0].reason) || failed[0].reason || '未知原因'), 'err');
|
||||
}
|
||||
}).catch(err => {
|
||||
if (this.props.onError) {
|
||||
@@ -3936,6 +3964,13 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
passive: false
|
||||
});
|
||||
window.addEventListener('touchend', this.endHoldRecording);
|
||||
window.addEventListener('blur', this.endHoldRecording);
|
||||
document.addEventListener('visibilitychange', this.handleHoldVisibilityChange);
|
||||
}
|
||||
handleHoldVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
this.endHoldRecording();
|
||||
}
|
||||
}
|
||||
handleHoldMove(e) {
|
||||
const y = 'touches' in e ? e.touches[0] && e.touches[0].clientY : e.clientY;
|
||||
@@ -3954,6 +3989,8 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
window.removeEventListener('mouseup', this.endHoldRecording);
|
||||
window.removeEventListener('touchmove', this.handleHoldMove);
|
||||
window.removeEventListener('touchend', this.endHoldRecording);
|
||||
window.removeEventListener('blur', this.endHoldRecording);
|
||||
document.removeEventListener('visibilitychange', this.handleHoldVisibilityChange);
|
||||
this.holdStartY = null;
|
||||
const fakeEvent = {
|
||||
preventDefault: () => {}
|
||||
|
||||
Reference in New Issue
Block a user