第四轮深度测试:修复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
+18
-1
@@ -21,6 +21,12 @@ export class ImApiError extends Error {
|
||||
// error is constructed fixes all of them at once, rather than needing a matching change at every
|
||||
// individual catch block scattered across the app.
|
||||
const REASON_MESSAGES = {
|
||||
// Real per-account risk is low (this app always sends a hardcoded key, so hitting these means a
|
||||
// deployment-wide config problem, not something an individual user did) -- but exactly this "the
|
||||
// whole site is silently misconfigured" scenario has genuinely happened once already (a stale API
|
||||
// key got redeployed to production), so it's worth a real message instead of raw English site-wide.
|
||||
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
|
||||
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
|
||||
TENANT_UNAVAILABLE: '企业码不存在或已停用',
|
||||
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
|
||||
AUTH_FAILED: '账号或密码错误',
|
||||
@@ -603,6 +609,17 @@ export function publicFileBase() {
|
||||
return fileBase();
|
||||
}
|
||||
|
||||
// Just the URL string -- fileBase() (the tenant's real, possibly cross-origin file domain, e.g.
|
||||
// "https://api.118im.com/v0/file") + the server-relative path a Drafty ref/upload response carries.
|
||||
// No fetch. Split out from getFileUrl() below for DownloadPage (content-library-view.jsx): that
|
||||
// caller needs a correctly-qualified URL to *display and hand to the download button*, not to
|
||||
// eagerly pull the whole file into a blob the moment the page opens -- getFileUrl()'s eager fetch,
|
||||
// used unmodified there, silently downloaded every out-of-band attachment in full before the user
|
||||
// ever pressed "download", defeating the ready/downloading/done UI entirely.
|
||||
export function resolveFileUrl(fileId) {
|
||||
return fileId ? fileBase() + '/s/' + encodeURIComponent(fileId) : null;
|
||||
}
|
||||
|
||||
// File serving (GET /v0/file/s/{id}) is auth-gated -- the upload endpoints never return a directly
|
||||
// servable URL (publicUploadPayload only has file_id/purpose/mime_type/size/expires_at), so a plain
|
||||
// <img src> can't be pointed at it (browsers don't attach custom headers to img/video/audio requests).
|
||||
@@ -625,7 +642,7 @@ export function getFileUrl(fileId) {
|
||||
if (token) {
|
||||
headers.Authorization = 'Token ' + token;
|
||||
}
|
||||
const promise = fetch(fileBase() + '/s/' + encodeURIComponent(fileId), {headers}).then(res => {
|
||||
const promise = fetch(resolveFileUrl(fileId), {headers}).then(res => {
|
||||
if (!res.ok) {
|
||||
throw new ImApiError('文件加载失败', 'FILE_FETCH_FAILED', res.status);
|
||||
}
|
||||
|
||||
@@ -467,7 +467,7 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Toggle({checked, onChange}) { return <button type="button" role="switch" aria-checked={checked} className={'people-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; }
|
||||
function Toggle({checked, onChange, disabled}) { return <button type="button" role="switch" aria-checked={checked} disabled={disabled} className={'people-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; }
|
||||
|
||||
function Settings({uid, initialBlocked, pinBlocked, onToast}) {
|
||||
// Same seed-from-what-we-already-know as Profile/Verification -- getUserProfile can 403 even for a
|
||||
@@ -529,12 +529,16 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
|
||||
<span>查看聊天记录</span><Icon>chevron_right</Icon>
|
||||
</button>
|
||||
<div><span>消息传输方式</span><small>已启动加密传输</small></div></section>
|
||||
<section><div><span>置顶聊天</span><Toggle checked={pinned} onChange={value => {
|
||||
<section><div><span>置顶聊天</span><Toggle checked={pinned} disabled={settingsVersion === null} onChange={value => {
|
||||
// settingsVersion starts null until getConversationSettings' GET resolves (below); a toggle
|
||||
// clicked inside that window sent version:null over the wire, which the server 400s on as
|
||||
// MALFORMED_REQUEST -- disabling the switch during that (usually brief, but real on a slow
|
||||
// connection) gap avoids that avoidable round trip and its "设置失败" toast entirely.
|
||||
const prev = pinned; setPinned(value);
|
||||
updateConversationSettings(uid, {pinned: value, version: settingsVersion}).then(s => setSettingsVersion(s.version))
|
||||
.catch(err => { setPinned(prev); onToast(err.message || '设置失败'); });
|
||||
}} /></div>
|
||||
<div><span>消息免打扰</span><Toggle checked={muted} onChange={value => {
|
||||
<div><span>消息免打扰</span><Toggle checked={muted} disabled={settingsVersion === null} onChange={value => {
|
||||
const prev = muted; setMuted(value);
|
||||
updateConversationSettings(uid, {mute_notifications: value, version: settingsVersion}).then(s => setSettingsVersion(s.version))
|
||||
.catch(err => { setMuted(prev); onToast(err.message || '设置失败'); });
|
||||
|
||||
@@ -8,7 +8,7 @@ import HashNavigation from '../lib/navigation.js';
|
||||
import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
|
||||
import {
|
||||
getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader,
|
||||
searchTopicMessages, getMessageContext, getTopicMedia, checkURL, getFileUrl, getGroup, getUserProfile
|
||||
searchTopicMessages, getMessageContext, getTopicMedia, checkURL, getFileUrl, resolveFileUrl, getGroup, getUserProfile
|
||||
} from '../lib/im-api.js';
|
||||
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
|
||||
import { topicDisplayName } from '../lib/utils.js';
|
||||
@@ -341,7 +341,22 @@ function DownloadPage({hashParams, tinode, onToast}) {
|
||||
const apply = content => {
|
||||
const att = firstAttachment(content);
|
||||
if (!att) { onToast('文件信息加载失败'); setLoadError(true); return; }
|
||||
setMeta({name: att.name, mime: att.mime, size: Drafty.getEntitySize(att), url: Drafty.getDownloadUrl(att)});
|
||||
// Drafty.getDownloadUrl's raw return value is only actually usable as-is for a small inband
|
||||
// attachment (an already-local blob: URL built from inline base64 data). For an out-of-band
|
||||
// one -- everything above the ~3MB inline threshold, i.e. essentially every real photo/video/
|
||||
// document -- it's the bare server-relative .ref path (e.g. "/v0/file/s/xxx"), which the
|
||||
// browser would resolve against *this page's own* origin rather than the tenant's real
|
||||
// (often cross-origin) file domain. resolveAttachmentUrl() elsewhere in this file already
|
||||
// gets this right by routing ref-backed attachments through getFileUrl() -- but that eagerly
|
||||
// fetches the whole file into a blob immediately, which is wrong here: this page hasn't had
|
||||
// the user press "立即下载" yet, and doing that on mount would silently pull the entire file in
|
||||
// the background and make the ready/downloading/done states below meaningless.
|
||||
// resolveFileUrl() is the same domain-qualification with no fetch, exactly what's needed to
|
||||
// just populate `meta.url` for later use by the real download button.
|
||||
const direct = Drafty.getDownloadUrl(att);
|
||||
const url = (direct && (direct.startsWith('blob:') || direct.startsWith('data:'))) ?
|
||||
direct : resolveFileUrl(direct && direct.slice(direct.lastIndexOf('/') + 1));
|
||||
setMeta({name: att.name, mime: att.mime, size: Drafty.getEntitySize(att), url});
|
||||
};
|
||||
if (favoriteId) {
|
||||
getFavorite(favoriteId).then(fav => apply(fav.snapshot && fav.snapshot.content)).catch(err => { onToast(err.message || '加载失败'); setLoadError(true); });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1050,6 +1050,16 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
params.topicName = this.state.topic;
|
||||
let menuItems = messageSpecificMenuItems || [];
|
||||
// Picking "回复" on some other message while already editing one (this.state.reply.editing)
|
||||
// silently abandons the edit: handlePickReply overwrites this.state.reply wholesale but never
|
||||
// touches contentToEdit, so the composer keeps showing the message being edited while the
|
||||
// header/send-icon quietly switch to reply mode -- sending then posts that text as a brand new
|
||||
// reply to the other message, and the original edit is lost with no confirmation. Simplest fix
|
||||
// is the same one already used for menu_item_edit's window check: just don't offer the option
|
||||
// that would cause it, rather than trying to reconcile the two states after the fact.
|
||||
if (this.state.reply && this.state.reply.editing) {
|
||||
menuItems = menuItems.filter(item => item !== 'menu_item_reply');
|
||||
}
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
if (topic) {
|
||||
// Edit (Ch.4): chat-message.jsx already gated 'menu_item_edit' on writer + non-attachment
|
||||
@@ -1974,7 +1984,15 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
|
||||
isDragEnabled() {
|
||||
return this.state.isWriter && !this.state.unconfirmed && !this.props.forwardMessage && !this.state.peerMessagingDisabled;
|
||||
// Same editing-state guard as the attach-panel props in render() (see the `editingMessage`
|
||||
// local there) -- drag&drop is a second, independent path into handleFileDrop below, which
|
||||
// calls handleAttachImageOrVideo/handleAttachFile directly rather than through those props, so
|
||||
// gating only the props left this route as a full bypass of that guard: dropping a file while
|
||||
// editing a message (without touching the "+" button at all) still replaced the edited
|
||||
// message's content via sendMessage's head.replace, with no confirmation.
|
||||
const editingMessage = !!(this.state.reply && this.state.reply.editing);
|
||||
return this.state.isWriter && !this.state.unconfirmed && !this.props.forwardMessage &&
|
||||
!this.state.peerMessagingDisabled && !editingMessage;
|
||||
}
|
||||
|
||||
handleDragStart(e) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import { uploadLargeFile, getAbuseReasons, submitAbuseReport, getFeedbackConfig, submitFeedback } from '../lib/im-api.js';
|
||||
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
|
||||
import { readCachedData, useCachedData } from '../lib/use-cached-data.js';
|
||||
|
||||
const navigate = (view, params) => {
|
||||
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
|
||||
@@ -181,7 +181,13 @@ function UserFeedback({tinode, onToast}) {
|
||||
// cached object avoids a flash where the category picker renders (gated on `config` alone) with
|
||||
// nothing selected yet.
|
||||
const cachedFeedbackConfig = readCachedData('feedback-config');
|
||||
const [config, setConfig] = React.useState(() => cachedFeedbackConfig || null);
|
||||
// Was its own useState + one-shot useEffect with no retry -- a failed first fetch with nothing
|
||||
// cached yet left `config` stuck at null for the rest of the page's life (the category picker
|
||||
// below never renders since it's gated on `config`, but content/submit stay visible the whole
|
||||
// time), and the only way out was a full page reload. Routing through useCachedData gets the same
|
||||
// "one retry after 3s if there's truly nothing cached yet" behavior bootstrap and friends already
|
||||
// have, for free.
|
||||
const [config] = useCachedData('feedback-config', getFeedbackConfig, []);
|
||||
const [category, setCategory] = React.useState(() => (cachedFeedbackConfig && cachedFeedbackConfig.categories && cachedFeedbackConfig.categories[0]) || '');
|
||||
const [content, setContent] = React.useState('');
|
||||
const [images, setImages] = React.useState([]);
|
||||
@@ -193,12 +199,14 @@ function UserFeedback({tinode, onToast}) {
|
||||
const maxFiles = (config && config.attachment_policy && config.attachment_policy.max_files) || 5;
|
||||
const maxLength = (config && config.max_length) || 300;
|
||||
|
||||
// Only fills in the default once, the first time a fetch (initial or the useCachedData retry)
|
||||
// actually resolves without one already picked synchronously above from a warm cache -- doesn't
|
||||
// clobber a category the user already changed.
|
||||
React.useEffect(() => {
|
||||
getFeedbackConfig().then(data => {
|
||||
writeCachedData('feedback-config', data);
|
||||
setConfig(data); setCategory((data.categories && data.categories[0]) || 'other');
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
if (config && config.categories && config.categories.length && !category) {
|
||||
setCategory(config.categories[0]);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const CATEGORY_LABELS = {bug: '功能异常', suggestion: '产品建议', complaint: '服务投诉', other: '其他'};
|
||||
|
||||
|
||||
+26
-10
@@ -2373,15 +2373,25 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
|
||||
// this.callTimeoutTimer was declared (and already read/cleared in handleCallClose) but never
|
||||
// actually assigned anywhere -- an outgoing call the other side never answers (or whose 'accept'
|
||||
// {note} gets silently dropped, since every call-signaling step after the initial invite is a
|
||||
// fire-and-forget {note}, not a {pub} with a server ack) left the caller's UI stuck on "呼叫中"
|
||||
// forever, with manual hang-up the only way out. This arms a plain no-answer timeout for the
|
||||
// outgoing-ring wait only, mirroring a normal phone call -- it reuses the exact same hang-up path
|
||||
// as clicking the cancel button by hand (real 'hang-up' note to the peer + the usual top-level
|
||||
// state reset), so it doesn't invent any new call-outcome semantics. Deliberately scoped to just
|
||||
// this one wait state: adding similar timeouts around mid-call offer/ice-candidate/answer signaling
|
||||
// would risk false-positive hangups on connections that are just slow, not actually dropped.
|
||||
// actually assigned anywhere -- whose 'accept' {note} gets silently dropped, since every
|
||||
// call-signaling step after the initial invite is a fire-and-forget {note}, not a {pub} with a
|
||||
// server ack, left the caller's UI stuck on "呼叫中" forever, with manual hang-up the only way out.
|
||||
// This arms a 60s backstop that reuses the exact same hang-up path as clicking cancel by hand
|
||||
// (real 'hang-up' note to the peer + the usual top-level state reset), so it doesn't invent any
|
||||
// new call-outcome semantics.
|
||||
//
|
||||
// Note this is a secondary safety net, not the primary defense against a plain unanswered call --
|
||||
// the backend already has its own call_establishment_timeout (30s, server/main.go in the im_be
|
||||
// reference repo) that ends a genuinely-unanswered call server-side and notifies both parties via
|
||||
// {info hang-up}, which handleInfoMessage's 'hang-up' case below already handles independently of
|
||||
// this timer. For that common case this 60s timer mostly just no-ops (state has already moved to
|
||||
// CALL_STATE_NONE by the time it fires). What it actually guards against is a narrower case: the
|
||||
// {info hang-up} notification carrying that server-side timeout is itself lost -- the same class
|
||||
// of signaling drop this whole mechanism exists for, just at a different stage. Kept comfortably
|
||||
// above the server's own 30s so it never races or duplicates that primary path. Deliberately scoped
|
||||
// to just the outgoing-ring wait: adding similar timeouts around mid-call offer/ice-candidate/
|
||||
// answer signaling would risk false-positive hangups on connections that are just slow, not
|
||||
// actually dropped.
|
||||
armCallRingTimeout() {
|
||||
clearTimeout(this.callTimeoutTimer);
|
||||
this.callTimeoutTimer = setTimeout(() => {
|
||||
@@ -2480,7 +2490,13 @@ class TinodeWeb extends React.Component {
|
||||
this.setState({
|
||||
callTopic: undefined,
|
||||
callState: CALL_STATE_NONE,
|
||||
callAudioOnly: undefined
|
||||
callAudioOnly: undefined,
|
||||
// callSeq is otherwise only ever set once handleCallInvite's {pub} ack comes back (see its
|
||||
// .then() below), never cleared -- without resetting it here, a call ended before that ack
|
||||
// arrives (or armCallRingTimeout's hang-up firing before it) leaves this call's callSeq
|
||||
// pointing at whatever the *previous* call's seq was, and the next outgoing call could read
|
||||
// that stale value for the brief window before its own ack lands.
|
||||
callSeq: undefined
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ export default class CallMessage extends React.PureComponent {
|
||||
duration = <FormattedMessage id="call_disconnected" defaultMessage="disconnected" description="Label for disconnected call" />;
|
||||
break;
|
||||
}
|
||||
} else if (['accepted', 'started'].includes(this.props.callState) && !this.props.duration) {
|
||||
// Mirrors call-status.jsx's identical branch (the conversation-list preview for the same
|
||||
// message) -- this chat-bubble sibling was missing it, so a call left running (the product
|
||||
// supports messaging while a call is minimized/in progress) showed "0:00" here instead of
|
||||
// "in progress" until the call actually ended and the message got its final duration.
|
||||
duration = <FormattedMessage id="call_in_progress" defaultMessage="in progress" description="Label for call in progress" />;
|
||||
} else {
|
||||
duration = <span>{secondsToTime(this.props.duration / 1000)}</span>;
|
||||
}
|
||||
|
||||
@@ -192,6 +192,7 @@ class CallPanel extends React.PureComponent {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
topic.onInfo = this.previousOnInfo;
|
||||
this.stop();
|
||||
@@ -277,6 +278,16 @@ class CallPanel extends React.PureComponent {
|
||||
// Start local video.
|
||||
navigator.mediaDevices.getUserMedia(this.localStreamConstraints)
|
||||
.then(stream => {
|
||||
// The permission prompt this getUserMedia call triggers can still be pending when the call
|
||||
// is torn down from elsewhere (the ring-timeout hangup, a hangup note from the peer, or the
|
||||
// messages-view.jsx componentWillUnmount hangup added this round) -- resolving after that
|
||||
// touches this.localRef.current (already null post-unmount, so the srcObject assignment
|
||||
// below would throw) and would otherwise go on to announce an invite for a call nothing is
|
||||
// showing anymore.
|
||||
if (this._unmounted) {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
return;
|
||||
}
|
||||
if (!this.localStreamConstraints.video) {
|
||||
// Starting an audio-only call. Create a dummy video track
|
||||
// (so video can be enabled during the call if the user desires).
|
||||
@@ -670,11 +681,18 @@ class CallPanel extends React.PureComponent {
|
||||
.then(stream => {
|
||||
const track = stream.getVideoTracks()[0];
|
||||
stream.removeTrack(track);
|
||||
// Same permission-prompt-outlives-the-call race as start() above -- replaceLocalVideoTrack
|
||||
// touches this.localRef.current/this.state.localStream, both invalid once unmounted.
|
||||
if (this._unmounted) {
|
||||
track.stop();
|
||||
return;
|
||||
}
|
||||
return this.replaceLocalVideoTrack(track);
|
||||
})
|
||||
.then(_ => { this.sendOnDataChannel(VIDEO_UNMUTED_EVENT); })
|
||||
.catch(err => this.handleGetUserMediaError(err))
|
||||
.finally(_ => { this.setState({videoToggleInProgress: false}); }); // Make sure we redraw the mute/unmute icons (e.g. camera -> camera_off).
|
||||
// Make sure we redraw the mute/unmute icons (e.g. camera -> camera_off).
|
||||
.finally(_ => { if (!this._unmounted) { this.setState({videoToggleInProgress: false}); } });
|
||||
}
|
||||
|
||||
// Data channel exists as soon as the peer connection is created, but isn't actually usable
|
||||
@@ -710,10 +728,15 @@ class CallPanel extends React.PureComponent {
|
||||
}
|
||||
const track = stream.getVideoTracks()[0];
|
||||
stream.removeTrack(track);
|
||||
// Same permission/device-enumeration-outlives-the-call race as start()/unmuteVideo().
|
||||
if (this._unmounted) {
|
||||
track.stop();
|
||||
return null;
|
||||
}
|
||||
return this.replaceLocalVideoTrack(track);
|
||||
})
|
||||
.catch(err => this.handleGetUserMediaError(err))
|
||||
.finally(_ => { this.setState({videoToggleInProgress: false}); });
|
||||
.finally(_ => { if (!this._unmounted) { this.setState({videoToggleInProgress: false}); } });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,15 @@ class SendMessage extends React.PureComponent {
|
||||
// sits there with `disabled` true and no surviving UI able to end it, and if `disabled` later
|
||||
// reverts to false in the same topic, this stale `true` would remount a fresh <AudioRecorder>
|
||||
// with no user gesture at all -- silently starting to record again on its own.
|
||||
//
|
||||
// By the time this runs, React has already committed the `disabled` render and unmounted
|
||||
// <AudioRecorder> (its ref callback nulled this.audioRecorderInstance), so endHoldRecording()'s
|
||||
// own handleDone/handleDelete call is a no-op here -- it's called anyway for the six
|
||||
// mousemove/mouseup/touchmove/touchend/blur/visibilitychange listeners startHoldRecording
|
||||
// registered on window/document, which endHoldRecording always removes unconditionally
|
||||
// regardless of whether the recorder instance is still around. Skipping this left them
|
||||
// dangling until the next real mouseup/touchend/blur/tab-switch happened to fire.
|
||||
this.endHoldRecording();
|
||||
this.setState({audioRec: false});
|
||||
} else if (prevProps.initMessage != this.props.initMessage) {
|
||||
const msg = this.props.initMessage || '';
|
||||
|
||||
Reference in New Issue
Block a user