第四轮深度测试:修复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
|
// error is constructed fixes all of them at once, rather than needing a matching change at every
|
||||||
// individual catch block scattered across the app.
|
// individual catch block scattered across the app.
|
||||||
const REASON_MESSAGES = {
|
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_UNAVAILABLE: '企业码不存在或已停用',
|
||||||
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
|
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
|
||||||
AUTH_FAILED: '账号或密码错误',
|
AUTH_FAILED: '账号或密码错误',
|
||||||
@@ -603,6 +609,17 @@ export function publicFileBase() {
|
|||||||
return fileBase();
|
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
|
// 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
|
// 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).
|
// <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) {
|
if (token) {
|
||||||
headers.Authorization = 'Token ' + 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) {
|
if (!res.ok) {
|
||||||
throw new ImApiError('文件加载失败', 'FILE_FETCH_FAILED', res.status);
|
throw new ImApiError('文件加载失败', 'FILE_FETCH_FAILED', res.status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
|
|||||||
</div>;
|
</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}) {
|
function Settings({uid, initialBlocked, pinBlocked, onToast}) {
|
||||||
// Same seed-from-what-we-already-know as Profile/Verification -- getUserProfile can 403 even for a
|
// 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>
|
<span>查看聊天记录</span><Icon>chevron_right</Icon>
|
||||||
</button>
|
</button>
|
||||||
<div><span>消息传输方式</span><small>已启动加密传输</small></div></section>
|
<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);
|
const prev = pinned; setPinned(value);
|
||||||
updateConversationSettings(uid, {pinned: value, version: settingsVersion}).then(s => setSettingsVersion(s.version))
|
updateConversationSettings(uid, {pinned: value, version: settingsVersion}).then(s => setSettingsVersion(s.version))
|
||||||
.catch(err => { setPinned(prev); onToast(err.message || '设置失败'); });
|
.catch(err => { setPinned(prev); onToast(err.message || '设置失败'); });
|
||||||
}} /></div>
|
}} /></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);
|
const prev = muted; setMuted(value);
|
||||||
updateConversationSettings(uid, {mute_notifications: value, version: settingsVersion}).then(s => setSettingsVersion(s.version))
|
updateConversationSettings(uid, {mute_notifications: value, version: settingsVersion}).then(s => setSettingsVersion(s.version))
|
||||||
.catch(err => { setMuted(prev); onToast(err.message || '设置失败'); });
|
.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 { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
|
||||||
import {
|
import {
|
||||||
getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader,
|
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';
|
} from '../lib/im-api.js';
|
||||||
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
|
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
|
||||||
import { topicDisplayName } from '../lib/utils.js';
|
import { topicDisplayName } from '../lib/utils.js';
|
||||||
@@ -341,7 +341,22 @@ function DownloadPage({hashParams, tinode, onToast}) {
|
|||||||
const apply = content => {
|
const apply = content => {
|
||||||
const att = firstAttachment(content);
|
const att = firstAttachment(content);
|
||||||
if (!att) { onToast('文件信息加载失败'); setLoadError(true); return; }
|
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) {
|
if (favoriteId) {
|
||||||
getFavorite(favoriteId).then(fav => apply(fav.snapshot && fav.snapshot.content)).catch(err => { onToast(err.message || '加载失败'); setLoadError(true); });
|
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 { Tinode } from 'tinode-sdk';
|
||||||
|
|
||||||
import HashNavigation from '../lib/navigation.js';
|
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 { makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
import { useCachedData } from '../lib/use-cached-data.js';
|
import { useCachedData } from '../lib/use-cached-data.js';
|
||||||
import { topicDisplayName } from '../lib/utils.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 [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
|
||||||
const offline = view === 'messages-offline' || (!!myUserId && connected === false);
|
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 filtered = conversations.filter(item => `${item.title}${item.summary}`.toLowerCase().includes(query.trim().toLowerCase()));
|
||||||
const frequent = frequentContacts(chatList);
|
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);
|
const [reminderDismissed, setReminderDismissed] = React.useState(false);
|
||||||
// Keyed by submission_id (a new one is minted per resubmission, server/im_http.go's
|
// 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
|
// realNamePayload/serveIMRealNameSubmissions), not just status -- two separate rejections both
|
||||||
// report status:"rejected", and without something that actually changes between them, dismissing
|
// 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
|
// 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
|
// again within the same snooze window. Falls back to status for the states that never had a
|
||||||
// submission (e.g. never-submitted "unverified").
|
// submission (e.g. never-submitted "unverified", where there's only ever one such state anyway).
|
||||||
const dismissKey = (myUserId && realname) ?
|
const dismissKey = (myUserId && realnameStatus) ?
|
||||||
`realname-reminder-dismissed:${myUserId}:${realname.submission_id || realname.status}` : null;
|
`realname-reminder-dismissed:${myUserId}:${realnameStatus.submission_id || realnameStatus.status}` : null;
|
||||||
let lastDismissedAt = 0;
|
let lastDismissedAt = 0;
|
||||||
try { lastDismissedAt = dismissKey ? Number(localStorage.getItem(dismissKey) || 0) : 0; } catch (_) { /* ignore */ }
|
try { lastDismissedAt = dismissKey ? Number(localStorage.getItem(dismissKey) || 0) : 0; } catch (_) { /* ignore */ }
|
||||||
const withinSnooze = lastDismissedAt > 0 && (Date.now() - lastDismissedAt) < parseRemindAfter(realname && realname.remind_after);
|
const withinSnooze = lastDismissedAt > 0 && (Date.now() - lastDismissedAt) < parseRemindAfter(realnameMeta && realnameMeta.remind_after);
|
||||||
const showRealnameReminder = !!realname && !reminderDismissed && !withinSnooze &&
|
const showRealnameReminder = !!realnameStatus && !reminderDismissed && !withinSnooze &&
|
||||||
(realname.status === 'unverified' || realname.status === 'rejected');
|
(realnameStatus.status === 'unverified' || realnameStatus.status === 'rejected');
|
||||||
const dismissRealnameReminder = () => {
|
const dismissRealnameReminder = () => {
|
||||||
if (dismissKey) { try { localStorage.setItem(dismissKey, String(Date.now())); } catch (_) { /* ignore */ } }
|
if (dismissKey) { try { localStorage.setItem(dismissKey, String(Date.now())); } catch (_) { /* ignore */ } }
|
||||||
setReminderDismissed(true);
|
setReminderDismissed(true);
|
||||||
@@ -313,7 +331,7 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOp
|
|||||||
<BottomNavigation />
|
<BottomNavigation />
|
||||||
{menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} permissions={bootstrap && bootstrap.permissions} /> : null}
|
{menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} permissions={bootstrap && bootstrap.permissions} /> : null}
|
||||||
<Toast message={toast} onDone={() => setToast('')} />
|
<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>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,9 +394,12 @@ const NOTIFICATION_TEXT = {
|
|||||||
'group.settings.updated': '群设置有更新',
|
'group.settings.updated': '群设置有更新',
|
||||||
accepted: '对方已通过你的好友申请',
|
accepted: '对方已通过你的好友申请',
|
||||||
rejected: '对方拒绝了你的好友申请',
|
rejected: '对方拒绝了你的好友申请',
|
||||||
// 新样本:security.risk.continue 事件的 summary 直接是 decision 字面量 "continue"
|
// 新样本:security.risk.continue/cancel 事件的 summary 直接是 decision 字面量
|
||||||
// (风险确认审计记录,见 im-api.js 的 confirmRiskChallenge),同样是机器码。
|
// "continue"/"cancel"(风险确认审计记录,见 im-api.js 的 confirmRiskChallenge),同样是机器码——
|
||||||
continue: '你确认继续了一次安全风险提示'
|
// 两个 decision 枚举值只有一个"continue"曾被收录,"cancel"(第11章发消息前风险提示点"取消发送"
|
||||||
|
// 产生)此前会直接把英文单词"cancel"展示在系统消息列表里。
|
||||||
|
continue: '你确认继续了一次安全风险提示',
|
||||||
|
cancel: '你取消了一次风险操作确认'
|
||||||
};
|
};
|
||||||
|
|
||||||
function systemNotificationText(item) {
|
function systemNotificationText(item) {
|
||||||
|
|||||||
@@ -1050,6 +1050,16 @@ class MessagesView extends React.Component {
|
|||||||
}
|
}
|
||||||
params.topicName = this.state.topic;
|
params.topicName = this.state.topic;
|
||||||
let menuItems = messageSpecificMenuItems || [];
|
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);
|
const topic = this.props.tinode.getTopic(params.topicName);
|
||||||
if (topic) {
|
if (topic) {
|
||||||
// Edit (Ch.4): chat-message.jsx already gated 'menu_item_edit' on writer + non-attachment
|
// 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() {
|
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) {
|
handleDragStart(e) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import HashNavigation from '../lib/navigation.js';
|
import HashNavigation from '../lib/navigation.js';
|
||||||
import { uploadLargeFile, getAbuseReasons, submitAbuseReport, getFeedbackConfig, submitFeedback } from '../lib/im-api.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) => {
|
const navigate = (view, params) => {
|
||||||
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
|
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
|
// cached object avoids a flash where the category picker renders (gated on `config` alone) with
|
||||||
// nothing selected yet.
|
// nothing selected yet.
|
||||||
const cachedFeedbackConfig = readCachedData('feedback-config');
|
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 [category, setCategory] = React.useState(() => (cachedFeedbackConfig && cachedFeedbackConfig.categories && cachedFeedbackConfig.categories[0]) || '');
|
||||||
const [content, setContent] = React.useState('');
|
const [content, setContent] = React.useState('');
|
||||||
const [images, setImages] = 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 maxFiles = (config && config.attachment_policy && config.attachment_policy.max_files) || 5;
|
||||||
const maxLength = (config && config.max_length) || 300;
|
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(() => {
|
React.useEffect(() => {
|
||||||
getFeedbackConfig().then(data => {
|
if (config && config.categories && config.categories.length && !category) {
|
||||||
writeCachedData('feedback-config', data);
|
setCategory(config.categories[0]);
|
||||||
setConfig(data); setCategory((data.categories && data.categories[0]) || 'other');
|
}
|
||||||
}).catch(() => {});
|
}, [config]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const CATEGORY_LABELS = {bug: '功能异常', suggestion: '产品建议', complaint: '服务投诉', other: '其他'};
|
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
|
// 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'
|
// actually assigned anywhere -- whose 'accept' {note} gets silently dropped, since every
|
||||||
// {note} gets silently dropped, since every call-signaling step after the initial invite is a
|
// call-signaling step after the initial invite is a fire-and-forget {note}, not a {pub} with a
|
||||||
// fire-and-forget {note}, not a {pub} with a server ack) left the caller's UI stuck on "呼叫中"
|
// server ack, left the caller's UI stuck on "呼叫中" forever, with manual hang-up the only way out.
|
||||||
// forever, with manual hang-up the only way out. This arms a plain no-answer timeout for the
|
// This arms a 60s backstop that reuses the exact same hang-up path as clicking cancel by hand
|
||||||
// outgoing-ring wait only, mirroring a normal phone call -- it reuses the exact same hang-up path
|
// (real 'hang-up' note to the peer + the usual top-level state reset), so it doesn't invent any
|
||||||
// as clicking the cancel button by hand (real 'hang-up' note to the peer + the usual top-level
|
// new call-outcome semantics.
|
||||||
// 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
|
// Note this is a secondary safety net, not the primary defense against a plain unanswered call --
|
||||||
// would risk false-positive hangups on connections that are just slow, not actually dropped.
|
// 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() {
|
armCallRingTimeout() {
|
||||||
clearTimeout(this.callTimeoutTimer);
|
clearTimeout(this.callTimeoutTimer);
|
||||||
this.callTimeoutTimer = setTimeout(() => {
|
this.callTimeoutTimer = setTimeout(() => {
|
||||||
@@ -2480,7 +2490,13 @@ class TinodeWeb extends React.Component {
|
|||||||
this.setState({
|
this.setState({
|
||||||
callTopic: undefined,
|
callTopic: undefined,
|
||||||
callState: CALL_STATE_NONE,
|
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" />;
|
duration = <FormattedMessage id="call_disconnected" defaultMessage="disconnected" description="Label for disconnected call" />;
|
||||||
break;
|
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 {
|
} else {
|
||||||
duration = <span>{secondsToTime(this.props.duration / 1000)}</span>;
|
duration = <span>{secondsToTime(this.props.duration / 1000)}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ class CallPanel extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
this._unmounted = true;
|
||||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||||
topic.onInfo = this.previousOnInfo;
|
topic.onInfo = this.previousOnInfo;
|
||||||
this.stop();
|
this.stop();
|
||||||
@@ -277,6 +278,16 @@ class CallPanel extends React.PureComponent {
|
|||||||
// Start local video.
|
// Start local video.
|
||||||
navigator.mediaDevices.getUserMedia(this.localStreamConstraints)
|
navigator.mediaDevices.getUserMedia(this.localStreamConstraints)
|
||||||
.then(stream => {
|
.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) {
|
if (!this.localStreamConstraints.video) {
|
||||||
// Starting an audio-only call. Create a dummy video track
|
// Starting an audio-only call. Create a dummy video track
|
||||||
// (so video can be enabled during the call if the user desires).
|
// (so video can be enabled during the call if the user desires).
|
||||||
@@ -670,11 +681,18 @@ class CallPanel extends React.PureComponent {
|
|||||||
.then(stream => {
|
.then(stream => {
|
||||||
const track = stream.getVideoTracks()[0];
|
const track = stream.getVideoTracks()[0];
|
||||||
stream.removeTrack(track);
|
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);
|
return this.replaceLocalVideoTrack(track);
|
||||||
})
|
})
|
||||||
.then(_ => { this.sendOnDataChannel(VIDEO_UNMUTED_EVENT); })
|
.then(_ => { this.sendOnDataChannel(VIDEO_UNMUTED_EVENT); })
|
||||||
.catch(err => this.handleGetUserMediaError(err))
|
.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
|
// 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];
|
const track = stream.getVideoTracks()[0];
|
||||||
stream.removeTrack(track);
|
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);
|
return this.replaceLocalVideoTrack(track);
|
||||||
})
|
})
|
||||||
.catch(err => this.handleGetUserMediaError(err))
|
.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
|
// 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>
|
// 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.
|
// 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});
|
this.setState({audioRec: false});
|
||||||
} else if (prevProps.initMessage != this.props.initMessage) {
|
} else if (prevProps.initMessage != this.props.initMessage) {
|
||||||
const msg = this.props.initMessage || '';
|
const msg = this.props.initMessage || '';
|
||||||
|
|||||||
+38
-18
@@ -3456,6 +3456,7 @@ __webpack_require__.r(__webpack_exports__);
|
|||||||
/* harmony export */ rejectGroupJoinRequest: function() { return /* binding */ rejectGroupJoinRequest; },
|
/* harmony export */ rejectGroupJoinRequest: function() { return /* binding */ rejectGroupJoinRequest; },
|
||||||
/* harmony export */ reopenMonthlyReport: function() { return /* binding */ reopenMonthlyReport; },
|
/* harmony export */ reopenMonthlyReport: function() { return /* binding */ reopenMonthlyReport; },
|
||||||
/* harmony export */ requestVerificationCode: function() { return /* binding */ requestVerificationCode; },
|
/* harmony export */ requestVerificationCode: function() { return /* binding */ requestVerificationCode; },
|
||||||
|
/* harmony export */ resolveFileUrl: function() { return /* binding */ resolveFileUrl; },
|
||||||
/* harmony export */ resolveQRCode: function() { return /* binding */ resolveQRCode; },
|
/* harmony export */ resolveQRCode: function() { return /* binding */ resolveQRCode; },
|
||||||
/* harmony export */ resolveTenant: function() { return /* binding */ resolveTenant; },
|
/* harmony export */ resolveTenant: function() { return /* binding */ resolveTenant; },
|
||||||
/* harmony export */ respondMeeting: function() { return /* binding */ respondMeeting; },
|
/* harmony export */ respondMeeting: function() { return /* binding */ respondMeeting; },
|
||||||
@@ -3517,6 +3518,8 @@ class ImApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const REASON_MESSAGES = {
|
const REASON_MESSAGES = {
|
||||||
|
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
|
||||||
|
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
|
||||||
TENANT_UNAVAILABLE: '企业码不存在或已停用',
|
TENANT_UNAVAILABLE: '企业码不存在或已停用',
|
||||||
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
|
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
|
||||||
AUTH_FAILED: '账号或密码错误',
|
AUTH_FAILED: '账号或密码错误',
|
||||||
@@ -4175,6 +4178,9 @@ async function fetchFileState(fileId, range = '') {
|
|||||||
function publicFileBase() {
|
function publicFileBase() {
|
||||||
return fileBase();
|
return fileBase();
|
||||||
}
|
}
|
||||||
|
function resolveFileUrl(fileId) {
|
||||||
|
return fileId ? fileBase() + '/s/' + encodeURIComponent(fileId) : null;
|
||||||
|
}
|
||||||
const fileUrlCache = new Map();
|
const fileUrlCache = new Map();
|
||||||
function getFileUrl(fileId) {
|
function getFileUrl(fileId) {
|
||||||
if (!fileId) {
|
if (!fileId) {
|
||||||
@@ -4193,7 +4199,7 @@ function getFileUrl(fileId) {
|
|||||||
if (token) {
|
if (token) {
|
||||||
headers.Authorization = 'Token ' + token;
|
headers.Authorization = 'Token ' + token;
|
||||||
}
|
}
|
||||||
const promise = fetch(fileBase() + '/s/' + encodeURIComponent(fileId), {
|
const promise = fetch(resolveFileUrl(fileId), {
|
||||||
headers
|
headers
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -8298,12 +8304,14 @@ function Profile({
|
|||||||
}
|
}
|
||||||
function Toggle({
|
function Toggle({
|
||||||
checked,
|
checked,
|
||||||
onChange
|
onChange,
|
||||||
|
disabled
|
||||||
}) {
|
}) {
|
||||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
|
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
|
||||||
type: "button",
|
type: "button",
|
||||||
role: "switch",
|
role: "switch",
|
||||||
"aria-checked": checked,
|
"aria-checked": checked,
|
||||||
|
disabled: disabled,
|
||||||
className: 'people-switch' + (checked ? ' checked' : ''),
|
className: 'people-switch' + (checked ? ' checked' : ''),
|
||||||
onClick: () => onChange(!checked),
|
onClick: () => onChange(!checked),
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {}, void 0, false)
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {}, void 0, false)
|
||||||
@@ -8393,6 +8401,7 @@ function Settings({
|
|||||||
children: "置顶聊天"
|
children: "置顶聊天"
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Toggle, {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Toggle, {
|
||||||
checked: pinned,
|
checked: pinned,
|
||||||
|
disabled: settingsVersion === null,
|
||||||
onChange: value => {
|
onChange: value => {
|
||||||
const prev = pinned;
|
const prev = pinned;
|
||||||
setPinned(value);
|
setPinned(value);
|
||||||
@@ -8410,6 +8419,7 @@ function Settings({
|
|||||||
children: "消息免打扰"
|
children: "消息免打扰"
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Toggle, {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Toggle, {
|
||||||
checked: muted,
|
checked: muted,
|
||||||
|
disabled: settingsVersion === null,
|
||||||
onChange: value => {
|
onChange: value => {
|
||||||
const prev = muted;
|
const prev = muted;
|
||||||
setMuted(value);
|
setMuted(value);
|
||||||
@@ -9443,11 +9453,13 @@ function DownloadPage({
|
|||||||
setLoadError(true);
|
setLoadError(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const direct = tinode_sdk__WEBPACK_IMPORTED_MODULE_1__.Drafty.getDownloadUrl(att);
|
||||||
|
const url = direct && (direct.startsWith('blob:') || direct.startsWith('data:')) ? direct : (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_5__.resolveFileUrl)(direct && direct.slice(direct.lastIndexOf('/') + 1));
|
||||||
setMeta({
|
setMeta({
|
||||||
name: att.name,
|
name: att.name,
|
||||||
mime: att.mime,
|
mime: att.mime,
|
||||||
size: tinode_sdk__WEBPACK_IMPORTED_MODULE_1__.Drafty.getEntitySize(att),
|
size: tinode_sdk__WEBPACK_IMPORTED_MODULE_1__.Drafty.getEntitySize(att),
|
||||||
url: tinode_sdk__WEBPACK_IMPORTED_MODULE_1__.Drafty.getDownloadUrl(att)
|
url
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
if (favoriteId) {
|
if (favoriteId) {
|
||||||
@@ -13200,18 +13212,20 @@ function MessageHome({
|
|||||||
const [toast, setToast] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
|
const [toast, setToast] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
|
||||||
const [bootstrap] = (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.useCachedData)(myUserId ? 'bootstrap:' + myUserId : null, _lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getBootstrap, [myUserId]);
|
const [bootstrap] = (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.useCachedData)(myUserId ? 'bootstrap:' + myUserId : null, _lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getBootstrap, [myUserId]);
|
||||||
const offline = view === 'messages-offline' || !!myUserId && connected === false;
|
const offline = view === 'messages-offline' || !!myUserId && connected === false;
|
||||||
const conversations = entryConversations(bootstrap).concat(normalizeConversations(chatList, myUserId));
|
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 filtered = conversations.filter(item => `${item.title}${item.summary}`.toLowerCase().includes(query.trim().toLowerCase()));
|
||||||
const frequent = frequentContacts(chatList);
|
const frequent = frequentContacts(chatList);
|
||||||
const realname = bootstrap && bootstrap.security && bootstrap.security.realname;
|
const realnameMeta = bootstrap && bootstrap.security && bootstrap.security.realname;
|
||||||
|
const [realnameStatus] = (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.useCachedData)(myUserId ? 'realname-status:' + myUserId : null, _lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getRealnameStatus, [myUserId]);
|
||||||
const [reminderDismissed, setReminderDismissed] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
|
const [reminderDismissed, setReminderDismissed] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
|
||||||
const dismissKey = myUserId && realname ? `realname-reminder-dismissed:${myUserId}:${realname.submission_id || realname.status}` : null;
|
const dismissKey = myUserId && realnameStatus ? `realname-reminder-dismissed:${myUserId}:${realnameStatus.submission_id || realnameStatus.status}` : null;
|
||||||
let lastDismissedAt = 0;
|
let lastDismissedAt = 0;
|
||||||
try {
|
try {
|
||||||
lastDismissedAt = dismissKey ? Number(localStorage.getItem(dismissKey) || 0) : 0;
|
lastDismissedAt = dismissKey ? Number(localStorage.getItem(dismissKey) || 0) : 0;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
const withinSnooze = lastDismissedAt > 0 && Date.now() - lastDismissedAt < parseRemindAfter(realname && realname.remind_after);
|
const withinSnooze = lastDismissedAt > 0 && Date.now() - lastDismissedAt < parseRemindAfter(realnameMeta && realnameMeta.remind_after);
|
||||||
const showRealnameReminder = !!realname && !reminderDismissed && !withinSnooze && (realname.status === 'unverified' || realname.status === 'rejected');
|
const showRealnameReminder = !!realnameStatus && !reminderDismissed && !withinSnooze && (realnameStatus.status === 'unverified' || realnameStatus.status === 'rejected');
|
||||||
const dismissRealnameReminder = () => {
|
const dismissRealnameReminder = () => {
|
||||||
if (dismissKey) {
|
if (dismissKey) {
|
||||||
try {
|
try {
|
||||||
@@ -13331,7 +13345,7 @@ function MessageHome({
|
|||||||
message: toast,
|
message: toast,
|
||||||
onDone: () => setToast('')
|
onDone: () => setToast('')
|
||||||
}, void 0, false), showRealnameReminder ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)(RealnameReminderModal, {
|
}, void 0, false), showRealnameReminder ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)(RealnameReminderModal, {
|
||||||
allowSkip: !!realname.allow_skip,
|
allowSkip: !!(realnameMeta && realnameMeta.allow_skip),
|
||||||
onDismiss: dismissRealnameReminder
|
onDismiss: dismissRealnameReminder
|
||||||
}, void 0, false) : null]
|
}, void 0, false) : null]
|
||||||
}, void 0, true);
|
}, void 0, true);
|
||||||
@@ -13450,7 +13464,8 @@ const NOTIFICATION_TEXT = {
|
|||||||
'group.settings.updated': '群设置有更新',
|
'group.settings.updated': '群设置有更新',
|
||||||
accepted: '对方已通过你的好友申请',
|
accepted: '对方已通过你的好友申请',
|
||||||
rejected: '对方拒绝了你的好友申请',
|
rejected: '对方拒绝了你的好友申请',
|
||||||
continue: '你确认继续了一次安全风险提示'
|
continue: '你确认继续了一次安全风险提示',
|
||||||
|
cancel: '你取消了一次风险操作确认'
|
||||||
};
|
};
|
||||||
function systemNotificationText(item) {
|
function systemNotificationText(item) {
|
||||||
const summary = (item.summary || '').trim();
|
const summary = (item.summary || '').trim();
|
||||||
@@ -16562,7 +16577,7 @@ function UserFeedback({
|
|||||||
onToast
|
onToast
|
||||||
}) {
|
}) {
|
||||||
const cachedFeedbackConfig = (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_3__.readCachedData)('feedback-config');
|
const cachedFeedbackConfig = (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_3__.readCachedData)('feedback-config');
|
||||||
const [config, setConfig] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(() => cachedFeedbackConfig || null);
|
const [config] = (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_3__.useCachedData)('feedback-config', _lib_im_api_js__WEBPACK_IMPORTED_MODULE_2__.getFeedbackConfig, []);
|
||||||
const [category, setCategory] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(() => cachedFeedbackConfig && cachedFeedbackConfig.categories && cachedFeedbackConfig.categories[0] || '');
|
const [category, setCategory] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(() => cachedFeedbackConfig && cachedFeedbackConfig.categories && cachedFeedbackConfig.categories[0] || '');
|
||||||
const [content, setContent] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
|
const [content, setContent] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
|
||||||
const [images, setImages] = react__WEBPACK_IMPORTED_MODULE_0___default().useState([]);
|
const [images, setImages] = react__WEBPACK_IMPORTED_MODULE_0___default().useState([]);
|
||||||
@@ -16574,12 +16589,10 @@ function UserFeedback({
|
|||||||
const maxFiles = config && config.attachment_policy && config.attachment_policy.max_files || 5;
|
const maxFiles = config && config.attachment_policy && config.attachment_policy.max_files || 5;
|
||||||
const maxLength = config && config.max_length || 300;
|
const maxLength = config && config.max_length || 300;
|
||||||
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
|
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
|
||||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_2__.getFeedbackConfig)().then(data => {
|
if (config && config.categories && config.categories.length && !category) {
|
||||||
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_3__.writeCachedData)('feedback-config', data);
|
setCategory(config.categories[0]);
|
||||||
setConfig(data);
|
}
|
||||||
setCategory(data.categories && data.categories[0] || 'other');
|
}, [config]);
|
||||||
}).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
const CATEGORY_LABELS = {
|
const CATEGORY_LABELS = {
|
||||||
bug: '功能异常',
|
bug: '功能异常',
|
||||||
suggestion: '产品建议',
|
suggestion: '产品建议',
|
||||||
@@ -19539,7 +19552,8 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
|||||||
this.setState({
|
this.setState({
|
||||||
callTopic: undefined,
|
callTopic: undefined,
|
||||||
callState: _constants_js__WEBPACK_IMPORTED_MODULE_13__.CALL_STATE_NONE,
|
callState: _constants_js__WEBPACK_IMPORTED_MODULE_13__.CALL_STATE_NONE,
|
||||||
callAudioOnly: undefined
|
callAudioOnly: undefined,
|
||||||
|
callSeq: undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
handleCallAccept(topicName, setCallTopic, callSeq) {
|
handleCallAccept(topicName, setCallTopic, callSeq) {
|
||||||
@@ -23318,6 +23332,12 @@ class CallMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
|||||||
}, void 0, false);
|
}, void 0, false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
} else if (['accepted', 'started'].includes(this.props.callState) && !this.props.duration) {
|
||||||
|
duration = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
|
id: "call_in_progress",
|
||||||
|
defaultMessage: "in progress",
|
||||||
|
description: "Label for call in progress"
|
||||||
|
}, void 0, false);
|
||||||
} else {
|
} else {
|
||||||
duration = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("span", {
|
duration = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("span", {
|
||||||
children: (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_2__.secondsToTime)(this.props.duration / 1000)
|
children: (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_2__.secondsToTime)(this.props.duration / 1000)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1011,6 +1011,9 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
|||||||
}
|
}
|
||||||
params.topicName = this.state.topic;
|
params.topicName = this.state.topic;
|
||||||
let menuItems = messageSpecificMenuItems || [];
|
let menuItems = messageSpecificMenuItems || [];
|
||||||
|
if (this.state.reply && this.state.reply.editing) {
|
||||||
|
menuItems = menuItems.filter(item => item !== 'menu_item_reply');
|
||||||
|
}
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
const topic = this.props.tinode.getTopic(params.topicName);
|
||||||
if (topic) {
|
if (topic) {
|
||||||
const editWindow = this.state.messagePolicy ? this.state.messagePolicy.edit_window_seconds : 0;
|
const editWindow = this.state.messagePolicy ? this.state.messagePolicy.edit_window_seconds : 0;
|
||||||
@@ -1721,7 +1724,8 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
|||||||
topic.pinMessage(seq, false);
|
topic.pinMessage(seq, false);
|
||||||
}
|
}
|
||||||
isDragEnabled() {
|
isDragEnabled() {
|
||||||
return this.state.isWriter && !this.state.unconfirmed && !this.props.forwardMessage && !this.state.peerMessagingDisabled;
|
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) {
|
handleDragStart(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -3608,6 +3612,7 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
|||||||
favPicker: null
|
favPicker: null
|
||||||
});
|
});
|
||||||
} else if (!prevProps.disabled && this.props.disabled && this.state.audioRec) {
|
} else if (!prevProps.disabled && this.props.disabled && this.state.audioRec) {
|
||||||
|
this.endHoldRecording();
|
||||||
this.setState({
|
this.setState({
|
||||||
audioRec: false
|
audioRec: false
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -168,6 +168,7 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
this._unmounted = true;
|
||||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||||
topic.onInfo = this.previousOnInfo;
|
topic.onInfo = this.previousOnInfo;
|
||||||
this.stop();
|
this.stop();
|
||||||
@@ -241,6 +242,10 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigator.mediaDevices.getUserMedia(this.localStreamConstraints).then(stream => {
|
navigator.mediaDevices.getUserMedia(this.localStreamConstraints).then(stream => {
|
||||||
|
if (this._unmounted) {
|
||||||
|
stream.getTracks().forEach(t => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this.localStreamConstraints.video) {
|
if (!this.localStreamConstraints.video) {
|
||||||
stream.addTrack(this.emptyVideoTrack());
|
stream.addTrack(this.emptyVideoTrack());
|
||||||
}
|
}
|
||||||
@@ -582,13 +587,19 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
|
|||||||
}).then(stream => {
|
}).then(stream => {
|
||||||
const track = stream.getVideoTracks()[0];
|
const track = stream.getVideoTracks()[0];
|
||||||
stream.removeTrack(track);
|
stream.removeTrack(track);
|
||||||
|
if (this._unmounted) {
|
||||||
|
track.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
return this.replaceLocalVideoTrack(track);
|
return this.replaceLocalVideoTrack(track);
|
||||||
}).then(_ => {
|
}).then(_ => {
|
||||||
this.sendOnDataChannel(VIDEO_UNMUTED_EVENT);
|
this.sendOnDataChannel(VIDEO_UNMUTED_EVENT);
|
||||||
}).catch(err => this.handleGetUserMediaError(err)).finally(_ => {
|
}).catch(err => this.handleGetUserMediaError(err)).finally(_ => {
|
||||||
this.setState({
|
if (!this._unmounted) {
|
||||||
videoToggleInProgress: false
|
this.setState({
|
||||||
});
|
videoToggleInProgress: false
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
sendOnDataChannel(message) {
|
sendOnDataChannel(message) {
|
||||||
@@ -625,11 +636,17 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
|
|||||||
}
|
}
|
||||||
const track = stream.getVideoTracks()[0];
|
const track = stream.getVideoTracks()[0];
|
||||||
stream.removeTrack(track);
|
stream.removeTrack(track);
|
||||||
|
if (this._unmounted) {
|
||||||
|
track.stop();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return this.replaceLocalVideoTrack(track);
|
return this.replaceLocalVideoTrack(track);
|
||||||
}).catch(err => this.handleGetUserMediaError(err)).finally(_ => {
|
}).catch(err => this.handleGetUserMediaError(err)).finally(_ => {
|
||||||
this.setState({
|
if (!this._unmounted) {
|
||||||
videoToggleInProgress: false
|
this.setState({
|
||||||
});
|
videoToggleInProgress: false
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user