第四轮深度测试:修复7个agent发现的问题,重点是编辑态附件覆盖的完整旁路和通话面板崩溃防护
再次派7个agent覆盖全部11章,验证第三轮修复+找新问题。本次委托agent专门交叉核对 上一轮每一项修复是否真的生效,多数确认有效,但抓出2处"看似修好、实则有旁路"的情况 和若干新问题。 **编辑消息+拖放文件仍可静默覆盖被编辑消息(上一轮修复的完整旁路,Ch3-4 agent发现)** 上一轮给`onAttachFile`/`onAttachImage`/`onAttachAudio`三个prop加了editingMessage门禁, 但`isDragEnabled()`(messages-view.jsx)没有检查这个状态——拖放文件到聊天窗口走的是 `handleFileDrop`直接调用`handleAttachImageOrVideo`/`handleAttachFile`类方法本身,完全 绕开了被置null的props。编辑态下不点"+"号、直接把文件拖进聊天窗口,依然会静默替换掉 被编辑消息的原文。已给`isDragEnabled()`补上同款判断,这是`handleDrop`和渲染层拖放提示 两处调用的唯一门禁函数,一次性堵住。 同一个agent还发现编辑态下点击"回复"另一条消息会静默放弃当前编辑(`handlePickReply` 覆盖`this.state.reply`但不清`contentToEdit`,发送时值走了`reply`分支而不是`editing` 分支,编辑框里的原文被当成对另一条消息的新回复发出去,原消息未被修改)——已在 `handleShowMessageContextMenu`里比照`menu_item_edit`的年龄过滤模式,编辑态下直接不 提供"回复"这个菜单项。 **实名认证驳回提醒的dismissKey修复上一轮实际没生效(Ch1-2 agent发现,已重新修复)** 上一轮把dismissKey从纯status改成"submission_id或status",但live验证发现 `bootstrap.security.realname`这个数据源根本不包含submission_id字段(后端serveIMBootstrap 的实现只有status/required/allow_skip/remind_after四个硬编码字段)——所以这个修复在 真实环境下永远退化回status,等于没有变化,两次独立驳回依然会互相误压制提醒。这次改为 额外调用专用的`GET /security/realname`接口(该接口的实现确实会返回submission_id), 用它的数据驱动dismissKey和提醒触发条件,bootstrap的字段只保留用于remind_after/ allow_skip这些它独有的字段。已用真实账号验证dismissKey现在正确从这个新数据源生成。 **呼叫面板未卸载防护补全到call-panel.jsx(Ch8 agent发现,同类问题上一轮只修了 audio-recorder.jsx)** `start()`/`unmuteVideo()`/`handleSwitchCameraClick()`三处的getUserMedia/ enumerateDevices异步链,结尾都会touch `this.localRef.current`/`this.state.localStream`, 但都没检查组件是否已经卸载——权限弹窗未决出结果时通话结束(呼出超时/收到挂断/对方 主动挂断),这些promise resolve后会在死实例上抛TypeError,被各自的catch吞掉后又会展示 一句过期的原始英文报错。三处都补了`this._unmounted`检查,拿到的媒体流/轨道直接停止 而不是继续使用。 **其余修复**:`call-message.jsx`补上和`call-status.jsx`一致的"进行中"状态文案(原来 通话进行中且消息还没有最终时长时显示"0:00"而不是"进行中",已用真实渲染验证过两个 组件行为不一致);`tinode-web.jsx`的`handleCallClose`补上重置`callSeq`(避免跨通话 残留旧值)+ 更新`armCallRingTimeout`注释准确描述它是后端30秒建立超时的二级兜底而非 主要防线(后端`call_establishment_timeout`默认30秒,已有独立机制处理"直接不接"的 常见场景);`REASON_MESSAGES`补充`API_KEY_REQUIRED`/`METHOD_NOT_ALLOWED`(此前记录过 一次真实的部署期API Key配置漂移事故,属于这两个reason的真实触发场景); `message-center-view.jsx`的`NOTIFICATION_TEXT`补充风险确认"取消"结果的文案(发消息 前风险提示点"取消发送"会产生这个系统通知,此前只收录了"继续");会话列表"置顶"从 只变背景色改为真正排到列表最前(原来只有CSS着色,没有任何排序逻辑,已用真实群聊 置顶验证过排序生效且系统消息/文件助手两个固定入口仍保持在最前);好友聊天设置页 置顶/免打扰开关在初始GET未完成前禁用(避免`version:null`打到后端400); `content-library-view.jsx`的`DownloadPage`修复带外附件下载使用未做域名归一化的裸 相对路径(生产环境webapp和文件域名跨域时会必现下载失败,已拆出`resolveFileUrl` 辅助函数只做URL拼装不做预先fetch,跟`getFileUrl`的实际下载区分开); `security-support-view.jsx`的意见反馈分类配置改用`useCachedData`获得重试能力(原来 独立实现的边车缓存逻辑没享受到本轮`use-cached-data.js`的重试修复)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
93b856aec8
commit
4a30cfa6b3
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { Tinode } from 'tinode-sdk';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import { getBootstrap, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';
|
||||
import { getBootstrap, getRealnameStatus, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';
|
||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { useCachedData } from '../lib/use-cached-data.js';
|
||||
import { topicDisplayName } from '../lib/utils.js';
|
||||
@@ -237,25 +237,43 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOp
|
||||
const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
|
||||
const offline = view === 'messages-offline' || (!!myUserId && connected === false);
|
||||
|
||||
const conversations = entryConversations(bootstrap).concat(normalizeConversations(chatList, myUserId));
|
||||
// "置顶" only ever changed a background tint (see the `pinned` comment inside
|
||||
// normalizeConversations) -- every other IM this product's users have used floats a pinned chat to
|
||||
// the top of the list and keeps it there, so a tint-only "pin" reads as broken even where the
|
||||
// underlying flag is genuinely correct (group chats). Array.prototype.sort is stable (guaranteed
|
||||
// since ES2019), so this only reorders pinned-vs-not and otherwise preserves chatList's own
|
||||
// ordering. Applied after normalizing, not to the concatenated list, so the two fixed system
|
||||
// entries (system_messages/file_assistant) -- which have no `pinned` concept at all -- stay
|
||||
// pinned to the very top the way they always have, ahead of even a pinned real conversation.
|
||||
const realConversations = normalizeConversations(chatList, myUserId)
|
||||
.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
|
||||
const conversations = entryConversations(bootstrap).concat(realConversations);
|
||||
const filtered = conversations.filter(item => `${item.title}${item.summary}`.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
const frequent = frequentContacts(chatList);
|
||||
|
||||
const realname = bootstrap && bootstrap.security && bootstrap.security.realname;
|
||||
// bootstrap.security.realname carries required/allow_skip/remind_after, but -- confirmed live --
|
||||
// its handler (serveIMBootstrap) builds that block from a 4-field literal that never includes
|
||||
// submission_id, unlike the dedicated GET /security/realname endpoint (realNamePayload). The
|
||||
// dismissKey fix below depends on submission_id to tell two separate rejections apart; sourcing
|
||||
// `status` from bootstrap alone made that fix a no-op in practice (it always fell back to `status`,
|
||||
// reproducing the exact bug it was meant to fix). Fetching this separately, the same
|
||||
// useCachedData-with-retry way bootstrap itself is fetched, gets the real submission_id.
|
||||
const realnameMeta = bootstrap && bootstrap.security && bootstrap.security.realname;
|
||||
const [realnameStatus] = useCachedData(myUserId ? 'realname-status:' + myUserId : null, getRealnameStatus, [myUserId]);
|
||||
const [reminderDismissed, setReminderDismissed] = React.useState(false);
|
||||
// Keyed by submission_id (a new one is minted per resubmission, server/im_http.go's
|
||||
// realNamePayload/serveIMRealNameSubmissions), not just status -- two separate rejections both
|
||||
// report status:"rejected", and without something that actually changes between them, dismissing
|
||||
// the first would keep suppressing the reminder for a brand new rejection submitted and rejected
|
||||
// again within the same snooze window. Falls back to status for the states that never had a
|
||||
// submission (e.g. never-submitted "unverified").
|
||||
const dismissKey = (myUserId && realname) ?
|
||||
`realname-reminder-dismissed:${myUserId}:${realname.submission_id || realname.status}` : null;
|
||||
// submission (e.g. never-submitted "unverified", where there's only ever one such state anyway).
|
||||
const dismissKey = (myUserId && realnameStatus) ?
|
||||
`realname-reminder-dismissed:${myUserId}:${realnameStatus.submission_id || realnameStatus.status}` : null;
|
||||
let lastDismissedAt = 0;
|
||||
try { lastDismissedAt = dismissKey ? Number(localStorage.getItem(dismissKey) || 0) : 0; } catch (_) { /* ignore */ }
|
||||
const withinSnooze = lastDismissedAt > 0 && (Date.now() - lastDismissedAt) < parseRemindAfter(realname && realname.remind_after);
|
||||
const showRealnameReminder = !!realname && !reminderDismissed && !withinSnooze &&
|
||||
(realname.status === 'unverified' || realname.status === 'rejected');
|
||||
const withinSnooze = lastDismissedAt > 0 && (Date.now() - lastDismissedAt) < parseRemindAfter(realnameMeta && realnameMeta.remind_after);
|
||||
const showRealnameReminder = !!realnameStatus && !reminderDismissed && !withinSnooze &&
|
||||
(realnameStatus.status === 'unverified' || realnameStatus.status === 'rejected');
|
||||
const dismissRealnameReminder = () => {
|
||||
if (dismissKey) { try { localStorage.setItem(dismissKey, String(Date.now())); } catch (_) { /* ignore */ } }
|
||||
setReminderDismissed(true);
|
||||
@@ -313,7 +331,7 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOp
|
||||
<BottomNavigation />
|
||||
{menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} permissions={bootstrap && bootstrap.permissions} /> : null}
|
||||
<Toast message={toast} onDone={() => setToast('')} />
|
||||
{showRealnameReminder ? <RealnameReminderModal allowSkip={!!realname.allow_skip} onDismiss={dismissRealnameReminder} /> : null}
|
||||
{showRealnameReminder ? <RealnameReminderModal allowSkip={!!(realnameMeta && realnameMeta.allow_skip)} onDismiss={dismissRealnameReminder} /> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -376,9 +394,12 @@ const NOTIFICATION_TEXT = {
|
||||
'group.settings.updated': '群设置有更新',
|
||||
accepted: '对方已通过你的好友申请',
|
||||
rejected: '对方拒绝了你的好友申请',
|
||||
// 新样本:security.risk.continue 事件的 summary 直接是 decision 字面量 "continue"
|
||||
// (风险确认审计记录,见 im-api.js 的 confirmRiskChallenge),同样是机器码。
|
||||
continue: '你确认继续了一次安全风险提示'
|
||||
// 新样本:security.risk.continue/cancel 事件的 summary 直接是 decision 字面量
|
||||
// "continue"/"cancel"(风险确认审计记录,见 im-api.js 的 confirmRiskChallenge),同样是机器码——
|
||||
// 两个 decision 枚举值只有一个"continue"曾被收录,"cancel"(第11章发消息前风险提示点"取消发送"
|
||||
// 产生)此前会直接把英文单词"cancel"展示在系统消息列表里。
|
||||
continue: '你确认继续了一次安全风险提示',
|
||||
cancel: '你取消了一次风险操作确认'
|
||||
};
|
||||
|
||||
function systemNotificationText(item) {
|
||||
|
||||
Reference in New Issue
Block a user