第三轮深度测试:修复7个并行agent发现的前端问题,含REASON_MESSAGES系统性补全

派了Ch1-2/3-4/5-6/7/8/9/10-11共7个agent对全部11章重新深度测试。后端问题已核实追加进
docs/IM-后端问题清单-2026-09-04.md(新增6-9条,发给用户);本次是前端问题的修复。

**REASON_MESSAGES系统性补全(最高优先级,多个agent独立发现同一个缺口)**
im-api.js的REASON_MESSAGES表新增22个条目,最重要的是PERMISSION_DENIED——后端
server/im_http.go里403响应统一用这个reason(30余处调用),但表里原来只有一个从未
被后端实际发送过的死条目FORBIDDEN,导致全站几乎每一个403从工程一开始就在展示原始
英文"permission denied"。一并补上VERSION_CONFLICT/MALFORMED_REQUEST(Ch9 agent实测
命中)、NOT_FOUND、VERIFICATION_RATE_LIMITED(动态文案,同TOO_MANY_ATTEMPTS模式)等。
新增translateReason()导出函数并用于send-message.jsx的sendFavorite——favorites/forward
等几个批量接口恒定200、真实成败包在items[].reason里,imRequest的自动翻译够不到这种
per-item场景,之前直接显示原始reason字符串。tinode-web.jsx同理补了CTRL_ERROR_TEXT_CN
(原生WS {ctrl}协议错误,同样绕开REASON_MESSAGES)并修了原表从未真正匹配过的bug——
CommError会把状态码拼进message变成"authentication failed (401)",原表按裸文本精确
匹配,看着正常纯粹是因为401/403兜底文案凑巧一样,一旦命中"unknown authentication
scheme"这类会被兜底文案误导、"policy violation"这类会直接漏出英文。uploadLargeFile/
createChatUploader/createFileDownloader同样绕开REASON_MESSAGES(走原生{ctrl}协议),
补了UPLOAD_CTRL_TEXT_CN小词典。已用真实账号端到端验证:favorites/forward到P2P目标
触发的PERMISSION_DENIED现在正确显示"没有权限执行此操作";draft接口版本冲突正确显示
"内容已被修改,请刷新后重试"。

**编辑消息时打开附件面板会静默覆盖被编辑消息(Ch3-4 agent发现,数据损坏级别)**
sendMessage对editing状态一律用head.replace,但onAttachFile/onAttachImage/onAttachAudio
原来只在forwardMessage时置空,编辑态没有同款门禁——编辑一条消息不改文字、点"+"选张图片
或名片,会直接把附件内容替换掉被编辑消息的原文,无任何确认。messages-view.jsx新增
editingMessage门禁,和forwardMessage一样把三个attach回调置空(等价于隐藏整个"+"按钮)。
已用真实浏览器验证:编辑态下"+"按钮消失,取消编辑后正常恢复。

**草稿防抖单一共享定时器丢字(Ch1-2、Ch3-4两个agent独立发现)**
handleComposerChange用单个this.draftSaveTimer,话题A打字触发600ms保存后300ms内切到
话题B,会把A的定时器clearTimeout掉,那次输入永久丢失;version也在定时器触发时才重新
读state,此时可能已经在别的话题上,退化成硬编码0——叠加后端draft接口对version=0"无条件
跳过乐观锁"的行为(已记入后端清单新增6),可能静默覆盖别的标签页写入的更新草稿。改为
按话题分开的定时器map,version在调用的当下(保证仍在该话题)同步捕获进闭包。

**呼叫状态机两处真实bug(Ch8 agent此前发现,本次重新核实live代码后确认+修复)**
1. 通话中导航离开messages-view.jsx整个组件树(切到工作台/个人设置,或移动端切回会话
   列表)时,没有任何hangup清理——componentSetup已有的"切换话题时正确挂断"逻辑只在
   componentDidUpdate里,组件真正卸载时不会触发。CallPanel自己的componentWillUnmount
   只做本地清理,不通知对方也不重置tinode-web.jsx顶层callState,导致下次切回通话话题
   时remount出一个callState卡在CALL_STATE_IN_PROGRESS的"僵尸"面板——start()走"我们刚
   接听"分支直接return,从不调getUserMedia,本地媒体永久拿不到。补了对称的
   componentWillUnmount挂断逻辑。
2. this.callTimeoutTimer声明后从未被真正setTimeout赋值——呼出无人接听或accept信令
   (fire-and-forget的{note}包)被丢会永久卡在"呼叫中"。补了armCallRingTimeout(),
   60秒未接通按跟手动取消完全一样的路径挂断(不发明新的通话结果语义),在收到对方
   accept信号的两处分支都正确清timer。

**其余前端修复(按文件)**
- messages-view.jsx: @提醒mentionedUsers只在切话题时清空,同一话题内后续消息里字面
  文本"@张三"会被静默解析成真实提醒(发送后清空);jumpToMessage点开已撤回/删除的
  搜索结果原来只console.error静默失败,补了用户可见的错误提示;批量收藏对
  favorites/batch共享Idempotency-Key导致的静默丢失(已知后端问题)不再照单全收
  "收藏成功",改为发送后重新拉取真实收藏列表核对每条是否真的落地。
- audio-recorder.jsx: getUserMedia权限弹窗未决出结果时组件被卸载,补_unmounted
  标记避免在死实例上继续跑mediaRecorder.start()。
- send-message.jsx: disabled变为true时(对方拉黑/被禁言等实时ACS变化)没有强制结束
  正在进行的按住录音,可能remount出无用户手势的"自动录音";mouseup/touchend覆盖不到
  拖出窗口/切换标签页/权限弹窗抢焦点的释放场景,补了blur/visibilitychange兜底。
- tinode-web.jsx: messages-menu视图和messages-home共用MessageCenterView实例无key,
  从messages-home以非整体刷新方式切到messages-menu时菜单打不开,补key={view}。
- message-center-view.jsx: 实名认证驳回提醒的dismiss key只按用户+状态区分,同一状态
  的两次独立驳回会互相误压制,改按submission_id区分。
- contact-management-view.jsx: Profile/Settings靠getUserProfile刷新拉黑状态,但该
  接口对被拉黑对象直接403,导致"之前会话就已拉黑"的场景完全感知不到——叠加
  getConversationContext兜底(该接口对拉黑目标正确返回200+relationship)。
- group-management-view.jsx: AdminList显示群主/管理员姓名漏了group_nickname兜底;
  JoinRequests从聊天页横幅进入时返回按钮硬编码回group-settings(应回聊天本身),
  从Governance进入时返回group-settings而非group-governance(与AdminList/Muted不一致)。
- workplace-collaboration-view.jsx: OrganizationDirectory/DeptNode的部门树加载失败
  被无条件当成"暂无数据"渲染,补loadError区分+重试链接。
- personal-settings-view.jsx: CacheClear对navigator.storage/caches整个不存在的情况
  只有react出了范围的.catch()覆盖不到的同步抛错,加了特性检测+try/catch;
  YouthModeVerify触发TOO_MANY_ATTEMPTS锁定后确定按钮没有禁用/倒计时,会继续发出
  注定失败的请求。
- security-support-view.jsx: 意见反馈category字段在config未加载完成前可为空但没有
  提交前校验,会打到后端400。
- use-cached-data.js: bootstrap等首次拉取失败且无缓存兜底时永久停在undefined,加了
  一次3秒后重试(仅在完全没有缓存值时触发,已有缓存值维持原"显示旧数据"行为不变)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-04 23:29:52 +08:00
co-authored by Claude Sonnet 5
parent 505a6d7d56
commit 93b856aec8
20 changed files with 662 additions and 140 deletions
+58 -7
View File
@@ -30,7 +30,32 @@ const REASON_MESSAGES = {
VALIDATION_FAILED: '提交的内容不符合要求', VALIDATION_FAILED: '提交的内容不符合要求',
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试', IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
RESOURCE_NOT_FOUND: '内容不存在或已被删除', RESOURCE_NOT_FOUND: '内容不存在或已被删除',
FORBIDDEN: '没有权限执行此操作', // 后端 server/im_http.go 里 30+ 处 403 用的都是 imReasonPermissionDenied="PERMISSION_DENIED",
// 从未发送过字面量 "FORBIDDEN"——原来的 FORBIDDEN 条目是死代码,全站 403 一直透传英文原文。
PERMISSION_DENIED: '没有权限执行此操作',
NOT_FOUND: '内容不存在或已被删除',
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
AUTH_REQUIRED: '请先登录',
ACTION_TOKEN_INVALID: '操作凭证已失效,请重新操作',
SCHEMA_UNSUPPORTED: '当前版本过旧,请刷新页面后重试',
IDEMPOTENCY_IN_PROGRESS: '上一次操作正在处理中,请稍后再试',
INTERNAL_ERROR: '服务器繁忙,请稍后重试',
UNSUPPORTED_MEDIA_TYPE: '不支持的文件类型',
CHALLENGE_EXPIRED: '验证码已过期,请重新获取',
SMS_DELIVERY_FAILED: '短信发送失败,请稍后重试',
PASSWORD_POLICY_VIOLATION: '密码不符合安全要求',
VERIFICATION_RATE_LIMITED: details => details && details.retry_after_seconds
? `操作过于频繁,请 ${details.retry_after_seconds} 秒后重试`
: '操作过于频繁,请稍后再试',
ALREADY_GROUP_MEMBER: '对方已经是群成员',
DESCRIPTION_REQUIRED: '请填写简介内容',
DESCRIPTION_TOO_LONG: '简介内容过长',
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
INVALID_EMAIL: '邮箱格式不正确',
MAILBOX_UNAVAILABLE: '总经理信箱暂不可用',
TOO_MANY_FILES: '文件数量超出限制',
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友', FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息', MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
GROUP_MUTED: '你已被禁言,无法发言', GROUP_MUTED: '你已被禁言,无法发言',
@@ -130,14 +155,24 @@ export async function imRequest(path, {method = 'GET', body, headers, auth = tru
// (not silently replaced with a generic string) since this backend's messages have consistently // (not silently replaced with a generic string) since this backend's messages have consistently
// been reasonable, specific, business-appropriate text in real testing, not raw internals -- only // been reasonable, specific, business-appropriate text in real testing, not raw internals -- only
// reasons this app doesn't otherwise know how to react to fall through to it. // reasons this app doesn't otherwise know how to react to fall through to it.
const entry = payload.reason && REASON_MESSAGES[payload.reason]; const known = translateReason(payload.reason, payload.details);
const known = typeof entry === 'function' ? entry(payload.details) : entry;
throw new ImApiError(known || payload.message || payload.reason || response.statusText || '请求失败', throw new ImApiError(known || payload.message || payload.reason || response.statusText || '请求失败',
payload.reason, response.status, payload.details); payload.reason, response.status, payload.details);
} }
return normalizeResponse(payload); return normalizeResponse(payload);
} }
// Several batch endpoints (favorites/batch, favorites/forward, messages/forward, ...) always return
// HTTP 200 and bury the real per-item outcome in a nested `items[]`/`failed[]` array instead of
// throwing -- imRequest's translation above never runs for those, since it only fires on a non-2xx/
// non-zero-code response. Exported so call sites reading one of those per-item `reason` strings
// directly (e.g. send-message.jsx's sendFavorite) can reuse the exact same table instead of either
// duplicating it or -- as was actually happening -- showing the raw untranslated reason string.
export function translateReason(reason, details) {
const entry = reason && REASON_MESSAGES[reason];
return (typeof entry === 'function' ? entry(details) : entry) || null;
}
const idem = () => ({'Idempotency-Key': requestId()}); const idem = () => ({'Idempotency-Key': requestId()});
export const imGet = (path, opts) => imRequest(path, opts); export const imGet = (path, opts) => imRequest(path, opts);
@@ -199,6 +234,15 @@ export async function uploadPublicFile(file, purpose, onProgress) {
// Tinode's native {ctrl:{code,text,params:{url,expires}}} envelope, not the {code:0,data} REST envelope -- // Tinode's native {ctrl:{code,text,params:{url,expires}}} envelope, not the {code:0,data} REST envelope --
// there's no separate file_id field; the id is the last path segment of `url`, kept verbatim (including its // there's no separate file_id field; the id is the last path segment of `url`, kept verbatim (including its
// extension suffix) since getFileUrl() already round-trips that exact format unmodified. // extension suffix) since getFileUrl() already round-trips that exact format unmodified.
// This upload talks to Tinode's native {ctrl} protocol (see the comment above), not the REST layer,
// so REASON_MESSAGES above never applies to it -- confirmed against server/datamodel.go's Err*
// constructors for the literal ctrl.text values a failure here can actually carry.
const UPLOAD_CTRL_TEXT_CN = {
'too large': '文件大小超出限制',
'malformed': '请求内容有误,请稍后重试',
'permission denied': '没有权限执行此操作',
'gone': '内容不存在或已被删除'
};
export function uploadLargeFile(tinode, file, onProgress) { export function uploadLargeFile(tinode, file, onProgress) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken(); const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) { if (!authToken) {
@@ -223,7 +267,8 @@ export function uploadLargeFile(tinode, file, onProgress) {
const url = ctrl.params.url; const url = ctrl.params.url;
resolve({fileId: url.slice(url.lastIndexOf('/') + 1), url, expires: ctrl.params.expires}); resolve({fileId: url.slice(url.lastIndexOf('/') + 1), url, expires: ctrl.params.expires});
} else { } else {
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status)); const known = ctrl.text && UPLOAD_CTRL_TEXT_CN[ctrl.text.trim().toLowerCase()];
reject(new ImApiError(known || ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
} }
}; };
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0)); xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
@@ -273,7 +318,11 @@ export function createChatUploader(tinode) {
if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) { if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) {
resolve(ctrl.params.url); resolve(ctrl.params.url);
} else { } else {
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status)); // Same native-{ctrl}-protocol translation as uploadLargeFile above -- REASON_MESSAGES
// never applies here, and a too-large photo/video/file picked from the attachment panel
// is a realistic, easy way for a real user to hit this.
const known = ctrl.text && UPLOAD_CTRL_TEXT_CN[ctrl.text.trim().toLowerCase()];
reject(new ImApiError(known || ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
} }
}; };
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0)); xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
@@ -327,7 +376,9 @@ export function createFileDownloader(tinode) {
let err; let err;
try { try {
const pkt = JSON.parse(this.result); const pkt = JSON.parse(this.result);
err = new ImApiError((pkt.ctrl && pkt.ctrl.text) || '下载失败', 'DOWNLOAD_FAILED', xhr.status); const text = pkt.ctrl && pkt.ctrl.text;
const known = text && UPLOAD_CTRL_TEXT_CN[text.trim().toLowerCase()];
err = new ImApiError(known || text || '下载失败', 'DOWNLOAD_FAILED', xhr.status);
} catch (e) { } catch (e) {
err = new ImApiError('下载失败', 'DOWNLOAD_FAILED', xhr.status); err = new ImApiError('下载失败', 'DOWNLOAD_FAILED', xhr.status);
} }
@@ -499,7 +550,7 @@ export const forwardMessages = (mode, sources, targetTopics, comment = '') =>
imPost('/messages/forward', {mode, sources, target_topics: targetTopics, comment}); imPost('/messages/forward', {mode, sources, target_topics: targetTopics, comment});
export const getForwardBundle = (bundleId, cursor = '') => imGet('/forward-bundles/' + encodeURIComponent(bundleId) + qs({cursor})); export const getForwardBundle = (bundleId, cursor = '') => imGet('/forward-bundles/' + encodeURIComponent(bundleId) + qs({cursor}));
export const favoriteMessages = messages => imPost('/favorites/batch', {messages: messages.map(m => ({...m, seq: String(m.seq)}))}); export const favoriteMessages = messages => imPost('/favorites/batch', {messages: messages.map(m => ({...m, seq: String(m.seq)}))});
export const getFavorites = ({type = 'all', query = '', cursor = ''} = {}) => imGet('/favorites' + qs({type, q: query, cursor})); export const getFavorites = ({type = 'all', query = '', cursor = '', limit} = {}) => imGet('/favorites' + qs({type, q: query, cursor, limit}));
export const getFavorite = favoriteId => imGet('/favorites/' + encodeURIComponent(favoriteId)); export const getFavorite = favoriteId => imGet('/favorites/' + encodeURIComponent(favoriteId));
export const deleteFavorite = favoriteId => imDelete('/favorites/' + encodeURIComponent(favoriteId)); export const deleteFavorite = favoriteId => imDelete('/favorites/' + encodeURIComponent(favoriteId));
export const batchDeleteFavorites = favoriteIds => imPost('/favorites/batch-delete', {favorite_ids: favoriteIds}); export const batchDeleteFavorites = favoriteIds => imPost('/favorites/batch-delete', {favorite_ids: favoriteIds});
+17 -2
View File
@@ -23,19 +23,34 @@ export function useCachedData(key, fetcher, deps) {
React.useEffect(() => { React.useEffect(() => {
if (!key) return; if (!key) return;
let live = true; let live = true;
let retried = false;
// Don't flip back to a loading state just because deps changed while we already have a cached // Don't flip back to a loading state just because deps changed while we already have a cached
// value for the new key -- only the truly-never-fetched case should show a loading affordance. // value for the new key -- only the truly-never-fetched case should show a loading affordance.
setLoading(!cache.has(key)); setLoading(!cache.has(key));
fetcher().then(result => { const attempt = () => fetcher().then(result => {
if (!live) return; if (!live) return;
cache.set(key, result); cache.set(key, result);
setData(result); setData(result);
setLoading(false); setLoading(false);
}).catch(() => { }).catch(() => {
if (!live) return;
// A transient failure on the very *first* fetch for this key -- nothing cached yet to fall
// back on -- used to leave `data` stuck at undefined for the rest of the session, since
// nothing else ever re-triggers this effect unless `key`/`deps` change. One retry after a
// short delay covers the common case (a network blip during initial page load) without
// retry-storming a persistently failing endpoint. A key that already has *some* cached value
// keeps the original behavior (show the stale value, don't retry) -- that case is already
// showing the user something, so it's lower urgency than the stuck-at-nothing case.
if (!retried && !cache.has(key)) {
retried = true;
window.setTimeout(() => { if (live) attempt(); }, 3000);
return;
}
// Keep whatever stale/cached value was already showing rather than clearing it on a transient // Keep whatever stale/cached value was already showing rather than clearing it on a transient
// failure -- a blank screen on error is worse than a slightly-out-of-date one. // failure -- a blank screen on error is worse than a slightly-out-of-date one.
if (live) setLoading(false); setLoading(false);
}); });
attempt();
return () => { live = false; }; return () => { live = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, ...(deps || [])]); }, [key, ...(deps || [])]);
+25 -3
View File
@@ -6,8 +6,8 @@ import HashNavigation from '../lib/navigation.js';
import { import {
getFriends, getFriendRequests, createFriendRequest, acceptFriendRequest, rejectFriendRequest, getFriends, getFriendRequests, createFriendRequest, acceptFriendRequest, rejectFriendRequest,
deleteFriend, updateFriendRemark, searchUsers, getUserProfile, createMyQRCode, getBlocks, deleteFriend, updateFriendRemark, searchUsers, getUserProfile, createMyQRCode, getBlocks,
blockUser, unblockUser, getFileUrl, getBootstrap, getConversationSettings, updateConversationSettings, blockUser, unblockUser, getFileUrl, getBootstrap, getConversationContext, getConversationSettings,
createUserQRCode updateConversationSettings, createUserQRCode
} from '../lib/im-api.js'; } from '../lib/im-api.js';
import { readCachedData, writeCachedData, useCachedData } from '../lib/use-cached-data.js'; import { readCachedData, writeCachedData, useCachedData } from '../lib/use-cached-data.js';
@@ -398,6 +398,18 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
return current; return current;
}); });
}); });
// getUserProfile 403s outright for someone you've blocked (server-side privacy check) --
// whatever relationship was cached from before the block (typically "friend") then just stays
// stuck, which used to make this page keep showing "发消息/音视频通话" for someone actually
// blocked, entirely missing the blocked_by_me/blocked_by_peer branches cannotAddReason already
// handles below. getConversationContext resolves for a P2P conversation regardless of block
// status and correctly reports the real relationship, so it's used here purely to refresh that
// one field on top of whatever getUserProfile/the seed cache already provided for the rest.
getConversationContext(uid).then(ctx => {
if (ctx && ctx.relationship) {
setProfile(current => current ? {...current, relationship: ctx.relationship} : current);
}
}).catch(() => {});
}, [uid]); }, [uid]);
// group-management-view.jsx 的 navigateToProfile 在跳转之后异步用 getGroupMemberProfile 把 // group-management-view.jsx 的 navigateToProfile 在跳转之后异步用 getGroupMemberProfile 把
// capabilities/relationship 补进种子缓存(那两个字段 getUserProfile 对非好友会 403 拿不到, // capabilities/relationship 补进种子缓存(那两个字段 getUserProfile 对非好友会 403 拿不到,
@@ -483,7 +495,17 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
// 这时不能让这次拉取回来的真实关系(还是 friend)把它改回去——原来会导致弹窗一弹出来, // 这时不能让这次拉取回来的真实关系(还是 friend)把它改回去——原来会导致弹窗一弹出来,
// 背后的拉黑开关自己跳回"未拉黑",同时"删除好友"红条又冒出来,跟参考图不符也跟弹窗 // 背后的拉黑开关自己跳回"未拉黑",同时"删除好友"红条又冒出来,跟参考图不符也跟弹窗
// 正在确认的这件事自相矛盾。 // 正在确认的这件事自相矛盾。
getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); if (!pinBlocked) setBlocked(p.relationship === 'blocked_by_me'); }).catch(() => {}); getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); if (!pinBlocked) setBlocked(p.relationship === 'blocked_by_me'); }).catch(() => {
// Same 403-on-blocked-contact gap as Profile above -- this used to leave `blocked` stuck at
// whatever initialBlocked was (usually false, e.g. arriving fresh from the address book)
// whenever the block didn't just happen in this same page session, showing an off "拉黑"
// toggle and the "删除好友" button for someone actually already blocked.
if (!pinBlocked) {
getConversationContext(uid).then(ctx => {
if (ctx && ctx.relationship) { setBlocked(ctx.relationship === 'blocked_by_me'); }
}).catch(() => {});
}
});
// GET /users/{id}/profile never exposes the real remark (backend's userProfilePayload always // GET /users/{id}/profile never exposes the real remark (backend's userProfilePayload always
// calls userSummaryPayload(user, "") -- confirmed against im_be/server/im_http.go:7844 -- // calls userSummaryPayload(user, "") -- confirmed against im_be/server/im_http.go:7844 --
// folding an empty remark into display_name instead of returning a separate field), only the // folding an empty remark into display_name instead of returning a separate field), only the
+12 -5
View File
@@ -257,11 +257,17 @@ function GroupChat({mode, onToast}) {
return <div className="group-page"><ChatHeader />{['group-new-request', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice" onClick={() => navigate('group-join-requests')}><Icon>person_add_alt</Icon><span><strong>新的进群申请</strong><small>2 条申请等待处理</small></span><Icon>chevron_right</Icon></button> : null}{['group-announcement', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice announcement" onClick={() => navigate('group-announcement-detail')}><Icon>campaign</Icon><span><strong>群公告</strong><small>下午三点进行版本评审,请准时参加</small></span><Icon>chevron_right</Icon></button> : null}<main className="group-chat-timeline"><p className="chat-time">今天 10:24</p>{mode === 'group-invite' ? <article className="group-invite-card"><div><Avatar item={GROUPS_DEMO.owned[0]} /><span><strong>产品研发群</strong><small>安妮邀请你加入群聊</small></span></div><p>一起参与新版产品的设计与研发讨论</p><button type="button" disabled={joined} onClick={() => {setJoined(true); onToast('已加入产品研发群');}}>{joined ? '已加入' : '立即加入'}</button></article> : <><SystemEvent>安妮邀请栗子来了加入群聊</SystemEvent><SystemEvent>管理员通过了王珊珊的进群申请</SystemEvent><div className="group-chat-message"><Avatar item={MEMBERS[0]} /><div><span>安妮 <em>管理员</em></span><p>下午评审前请大家把问题同步到文档里。</p></div></div><div className="group-chat-message owner"><Avatar item={{...MEMBERS[3], avatar: 'img/bkg/p05.jpg'}} /><div><span>陈晨 <em>群主</em></span><p>收到,我会在两点前整理一版。</p></div></div></>}</main><div className="group-chat-composer"><button type="button"><Icon>record_voice_over</Icon></button><input placeholder="输入消息" /><button type="button"><Icon>sentiment_satisfied_alt</Icon></button><button type="button"><Icon>add_circle_outline</Icon></button></div></div>; return <div className="group-page"><ChatHeader />{['group-new-request', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice" onClick={() => navigate('group-join-requests')}><Icon>person_add_alt</Icon><span><strong>新的进群申请</strong><small>2 条申请等待处理</small></span><Icon>chevron_right</Icon></button> : null}{['group-announcement', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice announcement" onClick={() => navigate('group-announcement-detail')}><Icon>campaign</Icon><span><strong>群公告</strong><small>下午三点进行版本评审,请准时参加</small></span><Icon>chevron_right</Icon></button> : null}<main className="group-chat-timeline"><p className="chat-time">今天 10:24</p>{mode === 'group-invite' ? <article className="group-invite-card"><div><Avatar item={GROUPS_DEMO.owned[0]} /><span><strong>产品研发群</strong><small>安妮邀请你加入群聊</small></span></div><p>一起参与新版产品的设计与研发讨论</p><button type="button" disabled={joined} onClick={() => {setJoined(true); onToast('已加入产品研发群');}}>{joined ? '已加入' : '立即加入'}</button></article> : <><SystemEvent>安妮邀请栗子来了加入群聊</SystemEvent><SystemEvent>管理员通过了王珊珊的进群申请</SystemEvent><div className="group-chat-message"><Avatar item={MEMBERS[0]} /><div><span>安妮 <em>管理员</em></span><p>下午评审前请大家把问题同步到文档里。</p></div></div><div className="group-chat-message owner"><Avatar item={{...MEMBERS[3], avatar: 'img/bkg/p05.jpg'}} /><div><span>陈晨 <em>群主</em></span><p>收到,我会在两点前整理一版。</p></div></div></>}</main><div className="group-chat-composer"><button type="button"><Icon>record_voice_over</Icon></button><input placeholder="输入消息" /><button type="button"><Icon>sentiment_satisfied_alt</Icon></button><button type="button"><Icon>add_circle_outline</Icon></button></div></div>;
} }
function JoinRequests({topic, onToast}) { function JoinRequests({topic, fromChat, onToast}) {
// A persistent in-chat banner (messages-view.jsx's handleShowAnnouncement/handleShowJoinRequests) // A persistent in-chat banner (messages-view.jsx's handleShowAnnouncement/handleShowJoinRequests)
// is a second entry point besides group-settings, and this whole feature remounts on every // is a second entry point besides group-settings, and this whole feature remounts on every
// navigation step (see GroupManagementView's `key={view}` note) -- so an admin actively working // navigation step (see GroupManagementView's `key={view}` note) -- so an admin actively working
// through requests re-triggers this blank "加载中" flash on essentially every accept/reject. // through requests re-triggers this blank "加载中" flash on essentially every accept/reject.
// `fromChat`/`backToChat` mirror Announcement's handling of the exact same two-entry-point
// ambiguity above -- without it, returning from a click on the in-chat banner always landed on
// "group-settings" (closing the open conversation) instead of back to the chat itself.
const backToChat = React.useCallback(() => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
}, [topic]);
const [items, setItems] = React.useState(() => (topic && readCachedData('group-join-requests:' + topic)) || null); const [items, setItems] = React.useState(() => (topic && readCachedData('group-join-requests:' + topic)) || null);
const [profiles, setProfiles] = React.useState({}); const [profiles, setProfiles] = React.useState({});
const [loadError, setLoadError] = React.useState(false); const [loadError, setLoadError] = React.useState(false);
@@ -298,7 +304,8 @@ function JoinRequests({topic, onToast}) {
// to the raw status string for anything unrecognized so a mismatch is visible instead of silently wrong. // to the raw status string for anything unrecognized so a mismatch is visible instead of silently wrong.
const statusLabel = {accepted: '已确定', approved: '已确定', rejected: '已拒绝', expired: '已过期'}; const statusLabel = {accepted: '已确定', approved: '已确定', rejected: '已拒绝', expired: '已过期'};
return <div className="group-page"> return <div className="group-page">
<Header title="邀请进群申请" back="group-settings" backTopic={topic} /> <Header title="邀请进群申请" back={fromChat ? undefined : 'group-governance'} backTopic={topic}
onBack={fromChat ? backToChat : undefined} />
<main className="group-request-list"> <main className="group-request-list">
{items === null ? <p className="group-inline-empty">{loadError ? '申请列表加载失败' : '加载中…'}</p> : {items === null ? <p className="group-inline-empty">{loadError ? '申请列表加载失败' : '加载中…'}</p> :
items.length ? items.map(item => items.length ? items.map(item =>
@@ -701,11 +708,11 @@ function AdminList({topic, onToast}) {
<Header title="设置群管理员" back="group-governance" backTopic={topic} right="添加" onRight={() => navigate('group-admin-add', topic)} /> <Header title="设置群管理员" back="group-governance" backTopic={topic} right="添加" onRight={() => navigate('group-admin-add', topic)} />
<main className="group-admin-page"> <main className="group-admin-page">
<aside><Icon>info_outline</Icon><p>管理员可协助审批进群申请、管理公告和群成员。最多设置 10 名管理员。</p></aside> <aside><Icon>info_outline</Icon><p>管理员可协助审批进群申请、管理公告和群成员。最多设置 10 名管理员。</p></aside>
{owner ? <><h2>群主</h2><article><Avatar item={owner} /><span><strong>{owner.display_name}</strong><small>群主</small></span></article></> : null} {owner ? <><h2>群主</h2><article><Avatar item={owner} /><span><strong>{owner.group_nickname || owner.display_name}</strong><small>群主</small></span></article></> : null}
<h2>管理员 {admins ? admins.length : 0}/10</h2> <h2>管理员 {admins ? admins.length : 0}/10</h2>
{admins === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> : {admins === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> :
admins.length ? admins.map(item => <article key={item.user_id}> admins.length ? admins.map(item => <article key={item.user_id}>
<Avatar item={item} /><span><strong>{item.display_name}</strong><small>管理员</small></span> <Avatar item={item} /><span><strong>{item.group_nickname || item.display_name}</strong><small>管理员</small></span>
<button type="button" onClick={() => remove(item.user_id)}>移除</button> <button type="button" onClick={() => remove(item.user_id)}>移除</button>
</article>) : <div className="group-inline-empty">暂无管理员</div>} </article>) : <div className="group-inline-empty">暂无管理员</div>}
</main> </main>
@@ -1062,7 +1069,7 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode,
let content; let content;
if (view === 'group-list' || view === 'group-empty') content = <GroupList tinode={tinode} onToast={setToast} />; if (view === 'group-list' || view === 'group-empty') content = <GroupList tinode={tinode} onToast={setToast} />;
else if (['group-invite', 'group-events', 'group-new-request', 'group-announcement', 'group-summary', 'group-role-message'].includes(view)) content = <GroupChat mode={view} onToast={setToast} />; else if (['group-invite', 'group-events', 'group-new-request', 'group-announcement', 'group-summary', 'group-role-message'].includes(view)) content = <GroupChat mode={view} onToast={setToast} />;
else if (view === 'group-join-requests') content = <JoinRequests topic={topic} onToast={setToast} />; else if (view === 'group-join-requests') content = <JoinRequests topic={topic} fromChat={hashParams && hashParams.from === 'chat'} onToast={setToast} />;
else if (view === 'group-announcement-detail') content = <Announcement topic={topic} myRole={myRole} fromChat={hashParams && hashParams.from === 'chat'} onToast={setToast} />; else if (view === 'group-announcement-detail') content = <Announcement topic={topic} myRole={myRole} fromChat={hashParams && hashParams.from === 'chat'} onToast={setToast} />;
else if (view === 'group-settings') content = <GroupSettings topic={topic} myUserId={myUserId} tinode={tinode} onToast={setToast} />; else if (view === 'group-settings') content = <GroupSettings topic={topic} myUserId={myUserId} tinode={tinode} onToast={setToast} />;
else if (view === 'group-profile') content = <GroupProfile topic={topic} myUserId={myUserId} onToast={setToast} />; else if (view === 'group-profile') content = <GroupProfile topic={topic} myUserId={myUserId} onToast={setToast} />;
+8 -1
View File
@@ -243,7 +243,14 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOp
const realname = bootstrap && bootstrap.security && bootstrap.security.realname; const realname = bootstrap && bootstrap.security && bootstrap.security.realname;
const [reminderDismissed, setReminderDismissed] = React.useState(false); const [reminderDismissed, setReminderDismissed] = React.useState(false);
const dismissKey = myUserId ? 'realname-reminder-dismissed:' + myUserId : null; // 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;
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(realname && realname.remind_after);
+94 -26
View File
@@ -30,7 +30,7 @@ import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constan
import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js'; import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js';
import { topicDisplayName } from '../lib/utils.js'; import { topicDisplayName } from '../lib/utils.js';
import { checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap, getConversationContext, import { checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap, getConversationContext,
getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, getTopicDraft, recallMessage, getFavorites, getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, getTopicDraft, recallMessage,
updateTopicDraft } from '../lib/im-api.js'; updateTopicDraft } from '../lib/im-api.js';
import HashNavigation from '../lib/navigation.js'; import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js'; import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
@@ -288,6 +288,18 @@ class MessagesView extends React.Component {
} }
componentWillUnmount() { componentWillUnmount() {
// componentSetup below has an identical guard for switching from one topic to another during an
// active call -- but componentDidUpdate/componentSetup never runs on an actual unmount (leaving
// the messaging section entirely for another chapter, or on mobile switching to the conversation
// list while displayMobile). Without this, CallPanel's own componentWillUnmount only tears down
// local media: it never notifies the peer or resets tinode-web.jsx's top-level callState/
// callTopic, which then stay stuck at CALL_STATE_IN_PROGRESS. Navigating back to the call's topic
// later remounts a "zombie" CallPanel -- its start() sees that stale CALL_STATE_IN_PROGRESS and
// takes the "we just accepted" branch, which never calls getUserMedia(): no local media, every
// control disabled, only hang-up works.
if (this.state.rtcPanel) {
this.handleCallHangup(this.state.topic, this.props.callSeq);
}
if (this.messagesScroller) { if (this.messagesScroller) {
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent); this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
} }
@@ -1186,6 +1198,18 @@ class MessagesView extends React.Component {
// 输入框每次变化都会调这个(不在这里节流——防抖延迟统一放这一处,跟日报/月报草稿已有的 // 输入框每次变化都会调这个(不在这里节流——防抖延迟统一放这一处,跟日报/月报草稿已有的
// 600ms 防抖是同一个数值,方便以后统一调整)。编辑已有消息、转发预填内容这两种情况下 // 600ms 防抖是同一个数值,方便以后统一调整)。编辑已有消息、转发预填内容这两种情况下
// SendMessage 的文本框也会触发 onChange,但那些不是"新写的草稿",不应该覆盖真正的草稿。 // SendMessage 的文本框也会触发 onChange,但那些不是"新写的草稿",不应该覆盖真正的草稿。
//
// 定时器按话题分开存(this.draftSaveTimers 是个 map),不能像原来那样用单个共享句柄——
// 那样话题A打字触发的600ms保存,会在300ms后切到话题B打字时被 clearTimeout 直接取消掉,
// A刚打的字就再也不会发给服务器了。
//
// version 在这里(调用的当下,this.state.topic 必然还是 topicName,因为 onMessageChange
// 只有当前激活话题的 SendMessage 实例会触发)同步捕获进闭包,不能等定时器触发时才去读
// this.state.draftVersion——那时用户很可能已经切到别的话题,届时重新读到的要么是无关话题
// 的版本号,要么在判断不匹配时退化成硬编码的 0。后端 PUT .../draft 对 version=0 是"无条件
// 跳过乐观锁校验直接覆盖"而不是"仅当草稿真的不存在时才创建"(见
// docs/IM-后端问题清单-2026-09-04.md),这个 0 一旦被真的发出去,可能无声覆盖掉这段时间里
// 别的标签页/设备写入的更新草稿。
handleComposerChange(text) { handleComposerChange(text) {
if (this.state.reply && this.state.reply.editing) { if (this.state.reply && this.state.reply.editing) {
return; return;
@@ -1197,12 +1221,13 @@ class MessagesView extends React.Component {
if (!topicName) { if (!topicName) {
return; return;
} }
if (this.draftSaveTimer) { const version = this.state.draftVersion || 0;
window.clearTimeout(this.draftSaveTimer); this.draftSaveTimers = this.draftSaveTimers || {};
if (this.draftSaveTimers[topicName]) {
window.clearTimeout(this.draftSaveTimers[topicName]);
} }
this.draftSaveTimer = window.setTimeout(() => { this.draftSaveTimers[topicName] = window.setTimeout(() => {
this.draftSaveTimer = null; delete this.draftSaveTimers[topicName];
const version = (this.state.topic == topicName) ? (this.state.draftVersion || 0) : 0;
updateTopicDraft(topicName, text, [], version).then(d => { updateTopicDraft(topicName, text, [], version).then(d => {
if (this.state.topic == topicName) { if (this.state.topic == topicName) {
this.setState({draftVersion: d.version}); this.setState({draftVersion: d.version});
@@ -1214,12 +1239,15 @@ class MessagesView extends React.Component {
// 消息真的发出去之后,把服务端存的草稿清空——不然重新打开这个会话会看到已经发送过的旧文字。 // 消息真的发出去之后,把服务端存的草稿清空——不然重新打开这个会话会看到已经发送过的旧文字。
// 单纯等下一次防抖自然把空字符串存上去也能达到同样效果,但那样在"发送后立刻切走"这个真实 // 单纯等下一次防抖自然把空字符串存上去也能达到同样效果,但那样在"发送后立刻切走"这个真实
// 会发生的场景里会有一个几百毫秒的窗口,草稿还是旧内容,不如发送成功后立刻清。 // 会发生的场景里会有一个几百毫秒的窗口,草稿还是旧内容,不如发送成功后立刻清。
clearDraft(topicName) { // knownVersion 由调用方(sendMessage)在发送发起的那一刻同步捕获传入,原因同上——避免在
if (this.draftSaveTimer) { // 发送成功回调触发时(用户很可能已经切换话题)重新派生出不可靠的版本号。
window.clearTimeout(this.draftSaveTimer); clearDraft(topicName, knownVersion) {
this.draftSaveTimer = null; if (this.draftSaveTimers && this.draftSaveTimers[topicName]) {
window.clearTimeout(this.draftSaveTimers[topicName]);
delete this.draftSaveTimers[topicName];
} }
const version = (this.state.topic == topicName) ? (this.state.draftVersion || 0) : 0; const version = knownVersion != null ? knownVersion :
(this.state.topic == topicName) ? (this.state.draftVersion || 0) : 0;
updateTopicDraft(topicName, '', [], version).then(d => { updateTopicDraft(topicName, '', [], version).then(d => {
if (this.state.topic == topicName) { if (this.state.topic == topicName) {
this.setState({draftText: '', draftVersion: d.version}); this.setState({draftText: '', draftVersion: d.version});
@@ -1244,8 +1272,11 @@ class MessagesView extends React.Component {
handleShowJoinRequests(e) { handleShowJoinRequests(e) {
e.preventDefault(); e.preventDefault();
HashNavigation.navigateTo(HashNavigation.addUrlParam( // `from=chat` mirrors handleShowAnnouncement above -- lets JoinRequests (group-management-view.jsx)
HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic)); // return to this chat instead of always landing on group-governance.
let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic);
hash = HashNavigation.addUrlParam(hash, 'from', 'chat');
HashNavigation.navigateTo(hash);
} }
handleSendFriendVerification(e) { handleSendFriendVerification(e) {
@@ -1285,15 +1316,28 @@ class MessagesView extends React.Component {
return; return;
} }
const topicName = this.state.topic; const topicName = this.state.topic;
favoriteMessages(this.props.selectedSeqs.map(seq => ({topic: topicName, seq}))).then(result => { const seqs = this.props.selectedSeqs.slice();
const failed = (result && result.failed) || []; favoriteMessages(seqs.map(seq => ({topic: topicName, seq}))).then(() => {
if (failed.length && failed.length === this.props.selectedSeqs.length) { // `result.failed` can't be trusted here: the batch-create endpoint reuses one request-level
this.props.onError('收藏失败', 'err'); // Idempotency-Key across every message in the batch (docs/IM-后端问题清单-2026-09-04.md,
} else if (failed.length) { // 新增1), so every item after the first silently no-ops via ON DUPLICATE KEY UPDATE and gets
this.showToast(`已收藏,${failed.length} 条失败`); // folded into `existing_count` -- indistinguishable, from this response alone, from a message
} else { // that really was already favorited. Re-reading the actual favorites list and checking which
this.showToast('收藏成功'); // of the messages we just asked for really landed sidesteps that ambiguity entirely.
} return getFavorites({type: 'all', limit: 100}).then(data => {
const items = (data && data.items) || [];
const landed = new Set(items
.filter(it => it.source && it.source.topic == topicName)
.map(it => String(it.source.seq)));
const missing = seqs.filter(seq => !landed.has(String(seq)));
if (missing.length === seqs.length) {
this.props.onError('收藏失败', 'err');
} else if (missing.length) {
this.showToast(`已收藏 ${seqs.length - missing.length}/${seqs.length} 条`);
} else {
this.showToast('收藏成功');
}
});
}).catch(err => { }).catch(err => {
this.props.onError(err.message, 'err'); this.props.onError(err.message, 'err');
}); });
@@ -1384,17 +1428,28 @@ class MessagesView extends React.Component {
this.applyMentionResolution(parsed); this.applyMentionResolution(parsed);
msg = parsed; msg = parsed;
} }
// Was only cleared on topic switch -- picking someone from the @提醒 picker once left them
// resolvable for every later message in the same topic too, so literal text "@张三" typed by
// hand, pasted, or quoted from someone else in a LATER, unrelated message would silently turn
// into a real mention of whoever was last picked under that name. A retry of a failed send
// (retrySend) reuses the already-resolved Drafty content stored on the failed pub, not this
// array, so clearing it here doesn't affect retries.
this.mentionedUsers = [];
if (typeHint) { if (typeHint) {
head = {...(head || {}), 'x-im-type': typeHint}; head = {...(head || {}), 'x-im-type': typeHint};
} }
const topicForDraft = this.state.topic; const topicForDraft = this.state.topic;
// Captured now, not re-read inside the .then() below -- by the time send actually resolves
// the user may have switched topics, and clearDraft falling back to a hardcoded 0 runs into
// the same backend version=0-skips-the-lock issue explained in handleComposerChange above.
const draftVersionAtSend = this.state.draftVersion || 0;
const doSend = () => { const doSend = () => {
const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head); const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
// Only a plain-text compose (not a reply/edit/forward, all of which pass a Drafty object or // Only a plain-text compose (not a reply/edit/forward, all of which pass a Drafty object or
// reuse contentToEdit rather than the persisted draft) has a server-side draft worth clearing. // reuse contentToEdit rather than the persisted draft) has a server-side draft worth clearing.
if (originalText != null && result && typeof result.then == 'function' && topicForDraft) { if (originalText != null && result && typeof result.then == 'function' && topicForDraft) {
result.then(() => this.clearDraft(topicForDraft)); result.then(() => this.clearDraft(topicForDraft, draftVersionAtSend));
} }
return result; return result;
}; };
@@ -1882,7 +1937,13 @@ class MessagesView extends React.Component {
ref.current.classList.add('flash'); ref.current.classList.add('flash');
setTimeout(_ => {ref.current.classList.remove('flash')} , 1000); setTimeout(_ => {ref.current.classList.remove('flash')} , 1000);
} else { } else {
// Most commonly hit when the target message was deleted/recalled after being found by
// Ch.7 chat-history search but before this click -- <MetaMessage deleted> never gets a ref
// (see jumpToMessage below), so without this the click did nothing visible at all.
console.error("Unresolved message ref", replyToSeq); console.error("Unresolved message ref", replyToSeq);
if (this.props.onError) {
this.props.onError('该消息已被删除或撤回', 'err');
}
} }
} }
@@ -2219,6 +2280,13 @@ class MessagesView extends React.Component {
const titleClass = 'panel-title' + (this.state.deleted ? ' deleted' : ''); const titleClass = 'panel-title' + (this.state.deleted ? ' deleted' : '');
const darkModeClass = this.props.colorSchema == 'dark' ? 'dark' : ''; const darkModeClass = this.props.colorSchema == 'dark' ? 'dark' : '';
// sendMessage() unconditionally sends whatever content it's given with head={replace:...}
// while this.state.reply.editing is set (see sendMessage above) -- it has no way to tell
// "the user is still editing the original text" apart from "the user just picked a photo/
// file/card/favorite from the attachment panel while that edit was still open". Gating the
// attach entry points closed here (same mechanism already used for forwardMessage below) is
// simpler and safer than trying to teach sendMessage to distinguish those cases after the fact.
const editingMessage = !!(this.state.reply && this.state.reply.editing);
let messagesComponent = ( let messagesComponent = (
<> <>
@@ -2321,9 +2389,9 @@ class MessagesView extends React.Component {
onMentionSearch={(this.props.forwardMessage || !groupTopic) ? null : this.mentionSearch} onMentionSearch={(this.props.forwardMessage || !groupTopic) ? null : this.mentionSearch}
onMentionSelected={this.mentionSelected} onMentionSelected={this.mentionSelected}
onSendMessage={this.sendMessage} onSendMessage={this.sendMessage}
onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile} onAttachFile={(this.props.forwardMessage || editingMessage) ? null : this.handleAttachFile}
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImageOrVideo} onAttachImage={(this.props.forwardMessage || editingMessage) ? null : this.handleAttachImageOrVideo}
onAttachAudio={this.props.forwardMessage ? null : this.sendAudioAttachment} onAttachAudio={(this.props.forwardMessage || editingMessage) ? null : this.sendAudioAttachment}
canStartCall={!this.props.forwardMessage && this.props.canStartCall} canStartCall={!this.props.forwardMessage && this.props.canStartCall}
onStartAudioCall={this.props.onStartAudioCall} onStartAudioCall={this.props.onStartAudioCall}
onStartVideoCall={this.props.onStartVideoCall} onStartVideoCall={this.props.onStartVideoCall}
+40 -6
View File
@@ -341,12 +341,23 @@ function CacheClear({onToast}) {
const confirm = async () => { const confirm = async () => {
setBusy(true); setBusy(true);
try { try {
const before = (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0; // navigator.storage/caches can be entirely absent (private browsing in some browsers, older
const keys = await caches.keys(); // WebViews) rather than just rejecting -- referencing a missing global throws synchronously,
await Promise.all(keys.map(k => caches.delete(k))); // which a bare .catch() on the call below can't catch (it never gets that far), so without
const after = (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0; // this check the whole confirm() throws, `done` never becomes true, and the "已完成" state is
// never reached even though the button correctly stops spinning via `finally`.
const hasStorageEstimate = typeof navigator !== 'undefined' && navigator.storage && navigator.storage.estimate;
const hasCaches = typeof caches !== 'undefined';
const before = hasStorageEstimate ? (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0 : 0;
if (hasCaches) {
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
}
const after = hasStorageEstimate ? (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0 : 0;
setFreed(Math.max(0, before - after)); setFreed(Math.max(0, before - after));
setDone(true); setDone(true);
} catch (err) {
onToast(err.message || '清理失败,请重试');
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -615,11 +626,22 @@ function YouthModeVerify({hashParams, onToast}) {
const [password, setPassword] = React.useState(''); const [password, setPassword] = React.useState('');
const [error, setError] = React.useState(''); const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false); const [busy, setBusy] = React.useState(false);
const [lockedUntil, setLockedUntil] = React.useState(0);
const [now, setNow] = React.useState(Date.now());
const remainingLockSeconds = lockedUntil ? Math.max(0, Math.ceil((lockedUntil - now) / 1000)) : 0;
// Self-rescheduling tick, not setInterval -- naturally stops once the countdown reaches 0 instead
// of needing separate start/stop bookkeeping.
React.useEffect(() => {
if (remainingLockSeconds <= 0) return;
const id = window.setTimeout(() => setNow(Date.now()), 1000);
return () => window.clearTimeout(id);
}, [remainingLockSeconds]);
React.useEffect(() => { getYouthMode().then(setSettings).catch(() => {}); }, []); React.useEffect(() => { getYouthMode().then(setSettings).catch(() => {}); }, []);
const submit = () => { const submit = () => {
if (password.length !== 4) { setError('请输入4位独立密码'); return; } if (password.length !== 4) { setError('请输入4位独立密码'); return; }
if (remainingLockSeconds > 0) { return; }
setBusy(true); setBusy(true);
setError(''); setError('');
// Server's serveIMYouthModeEnable consumes the token with a strict action='enable_youth_mode' // Server's serveIMYouthModeEnable consumes the token with a strict action='enable_youth_mode'
@@ -639,7 +661,17 @@ function YouthModeVerify({hashParams, onToast}) {
if (settings) writeCachedData('youth-mode', {...settings, enabled: mode === 'enable'}); if (settings) writeCachedData('youth-mode', {...settings, enabled: mode === 'enable'});
onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭'); onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭');
window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400); window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
}).catch(err => setError(err.message || '验证失败')).finally(() => setBusy(false)); }).catch(err => {
// Server locks out further attempts for retry_after_seconds after too many wrong passwords
// (any request during that window 429s regardless of whether THIS password is correct) --
// without tracking that window here, the button stayed enabled the whole time, letting the
// user fire off more requests that were guaranteed to fail before the lock even lifts.
if (err.reason === 'TOO_MANY_ATTEMPTS' && err.details && err.details.retry_after_seconds) {
setLockedUntil(Date.now() + err.details.retry_after_seconds * 1000);
setNow(Date.now());
}
setError(err.message || '验证失败');
}).finally(() => setBusy(false));
}; };
return <div className="personal-page youth-mode-page"> return <div className="personal-page youth-mode-page">
@@ -649,7 +681,9 @@ function YouthModeVerify({hashParams, onToast}) {
<input className="auth-pin-input" type="text" inputMode="numeric" autoComplete="off" autoFocus <input className="auth-pin-input" type="text" inputMode="numeric" autoComplete="off" autoFocus
value={password} onChange={e => setPassword(e.target.value.replace(/\D/g, '').slice(0, 4))} aria-label="独立密码" /> value={password} onChange={e => setPassword(e.target.value.replace(/\D/g, '').slice(0, 4))} aria-label="独立密码" />
{error ? <div className="auth-field-error">{error}</div> : null} {error ? <div className="auth-field-error">{error}</div> : null}
<button type="button" className="auth-primary" disabled={password.length !== 4 || busy} onClick={submit}>{busy ? '验证中…' : '确定'}</button> <button type="button" className="auth-primary" disabled={password.length !== 4 || busy || remainingLockSeconds > 0} onClick={submit}>
{busy ? '验证中…' : remainingLockSeconds > 0 ? `请 ${remainingLockSeconds} 秒后重试` : '确定'}
</button>
</main> </main>
</div>; </div>;
} }
+5
View File
@@ -214,6 +214,11 @@ function UserFeedback({tinode, onToast}) {
const submit = () => { const submit = () => {
if (!content.trim()) { onToast('请填写反馈内容'); return; } if (!content.trim()) { onToast('请填写反馈内容'); return; }
// category is a required backend field (empty string 400s as MALFORMED_REQUEST) but the picker
// that sets it only renders once `config` has loaded -- if getFeedbackConfig() is still in
// flight or failed, `category` can still be '' here while content/submit stay visible the whole
// time, so this needs its own explicit guard rather than relying on the picker having run.
if (!category) { onToast('请选择反馈类型'); return; }
setSubmitting(true); setSubmitting(true);
submitFeedback({category, content: content.trim(), attachment_file_ids: images.map(i => i.id), include_diagnostics: diagnostics}) submitFeedback({category, content: content.trim(), attachment_file_ids: images.map(i => i.id), include_diagnostics: diagnostics})
.then(result => setSubmitted(result)) .then(result => setSubmitted(result))
+1
View File
@@ -157,6 +157,7 @@ class SidepanelView extends React.PureComponent {
return <div id="sidepanel" className={'message-center-panel' + (fullPage ? ' full-page' : '')}> return <div id="sidepanel" className={'message-center-panel' + (fullPage ? ' full-page' : '')}>
<MessageCenterView <MessageCenterView
view={view} view={view}
key={view}
connected={this.props.connected} connected={this.props.connected}
chatList={this.props.chatList} chatList={this.props.chatList}
myUserId={this.props.myUserId} myUserId={this.props.myUserId}
+59 -5
View File
@@ -160,20 +160,42 @@ const AUTH_FLOW_VIEWS = ['tenant', 'login', 'register', 'reset', 'cred',
// ctrl.params 里也没有像 REST 层那样稳定的 reason 字段可用(已实测确认)。 // ctrl.params 里也没有像 REST 层那样稳定的 reason 字段可用(已实测确认)。
// 原来这里直接把 err.message 原样显示,登录密码打错用户看到的是英文 "authentication failed", // 原来这里直接把 err.message 原样显示,登录密码打错用户看到的是英文 "authentication failed",
// 不是参考图要求的"账号或密码错误"。 // 不是参考图要求的"账号或密码错误"。
const LOGIN_ERROR_TEXT_CN = { //
// 这张表原来按 err.message 精确匹配裸文案——但 tinode-sdk 的 CommError 构造时会把状态码拼进
// message 里("authentication failed (401)"),导致这张表实际上从未真正命中过:'authentication
// failed' 之所以看起来工作正常,纯粹是因为紧接着的 401/403 兜底分支凑巧给出了同一句译文,掩盖
// 了匹配从未成立这件事;一旦命中 'unknown authentication scheme'(401,跟密码错误共用同一个状态
// 码,会被兜底分支误判成"账号或密码错误")或 'policy violation'(422,不在兜底范围内)这两种更
//少见的场景,用户就会看到带着 "(状态码)" 尾巴的原始英文。translateCtrlError 先去掉这段状态码
// 后缀再查表,两个问题一并解决。
//
// 同一张表也被 handleDisconnect 复用,用来翻译 {hi} 阶段(登录请求发出之前)的连接失败——那个
// 场景下 permission denied 最常见的真实原因是租户被停用,不是"账号或密码错误",所以只在这里放
// 一份不带 401/403 兜底的裸翻译(loginErrorMessage 自己的兜底只应用在真正的登录尝试上)。
const CTRL_ERROR_TEXT_CN = {
'authentication failed': '账号或密码错误', 'authentication failed': '账号或密码错误',
'authentication required': '请先登录', 'authentication required': '请先登录',
'unknown authentication scheme': '登录方式不受支持,请刷新页面重试', 'unknown authentication scheme': '登录方式不受支持,请刷新页面重试',
'policy violation': '当前操作不符合安全策略要求' 'policy violation': '当前操作不符合安全策略要求',
'permission denied': '没有权限执行此操作,请重新登录'
}; };
function translateCtrlError(err) {
const raw = err && err.message;
if (!raw) return null;
const normalized = raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase();
return CTRL_ERROR_TEXT_CN[normalized] || null;
}
function loginErrorMessage(err) { function loginErrorMessage(err) {
const known = err && err.message && LOGIN_ERROR_TEXT_CN[err.message.trim().toLowerCase()]; const known = translateCtrlError(err);
if (known) return known; if (known) return known;
// 未知文案时按状态码兜底,401/403 绝大多数场景就是账号或密码错误 // 未知文案时按状态码兜底,401/403 绝大多数场景就是账号或密码错误
if (err && (err.code === 401 || err.code === 403)) return '账号或密码错误'; if (err && (err.code === 401 || err.code === 403)) return '账号或密码错误';
return (err && err.message) || '登录失败,请重试'; return (err && err.message) || '登录失败,请重试';
} }
// See armCallRingTimeout below.
const CALL_RING_TIMEOUT_MS = 60000;
class TinodeWeb extends React.Component { class TinodeWeb extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -908,13 +930,17 @@ class TinodeWeb extends React.Component {
}, 1000); }, 1000);
} }
// Connection lost // Connection lost. Also covers a {hi} handshake rejected before login ever gets a chance to run
// (e.g. the tenant being deactivated mid-session) -- that path calls onDisconnect, not the {login}
// catch block down in doLogin, so without translateCtrlError here it showed the raw English
// ctrl.text (e.g. "permission denied (403)") instead of any Chinese message at all.
handleDisconnect(err) { handleDisconnect(err) {
const known = translateCtrlError(err);
this.setState({ this.setState({
connected: false, connected: false,
ready: false, ready: false,
topicSelectedOnline: false, topicSelectedOnline: false,
errorText: err && err.message ? err.message : "Disconnected", errorText: known || (err && err.message ? err.message : "Disconnected"),
errorLevel: err && err.message ? 'err' : 'warn', errorLevel: err && err.message ? 'err' : 'warn',
loginDisabled: false, loginDisabled: false,
contextMenuVisible: false, contextMenuVisible: false,
@@ -2329,6 +2355,7 @@ class TinodeWeb extends React.Component {
} }
handleStartVideoCall() { handleStartVideoCall() {
this.armCallRingTimeout();
this.setState({ this.setState({
callTopic: this.state.topicSelected, callTopic: this.state.topicSelected,
callState: CALL_STATE_OUTGOING_INITATED, callState: CALL_STATE_OUTGOING_INITATED,
@@ -2337,6 +2364,7 @@ class TinodeWeb extends React.Component {
} }
handleStartAudioCall() { handleStartAudioCall() {
this.armCallRingTimeout();
this.setState({ this.setState({
callTopic: this.state.topicSelected, callTopic: this.state.topicSelected,
callState: CALL_STATE_OUTGOING_INITATED, callState: CALL_STATE_OUTGOING_INITATED,
@@ -2344,6 +2372,28 @@ 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.
armCallRingTimeout() {
clearTimeout(this.callTimeoutTimer);
this.callTimeoutTimer = setTimeout(() => {
this.callTimeoutTimer = null;
if (this.state.callState != CALL_STATE_OUTGOING_INITATED) {
return;
}
this.handleCallHangup(this.state.callTopic, this.state.callSeq);
this.handleCallClose();
}, CALL_RING_TIMEOUT_MS);
}
handleCallInvite(callTopic, callSeq, callState, audioOnly) { handleCallInvite(callTopic, callSeq, callState, audioOnly) {
switch (callState) { switch (callState) {
case CALL_STATE_OUTGOING_INITATED: case CALL_STATE_OUTGOING_INITATED:
@@ -2435,6 +2485,7 @@ class TinodeWeb extends React.Component {
} }
handleCallAccept(topicName, setCallTopic, callSeq) { handleCallAccept(topicName, setCallTopic, callSeq) {
clearTimeout(this.callTimeoutTimer);
const topic = this.tinode.getTopic(topicName); const topic = this.tinode.getTopic(topicName);
if (!topic) { if (!topic) {
return; return;
@@ -2463,6 +2514,9 @@ class TinodeWeb extends React.Component {
} }
switch (info.event) { switch (info.event) {
case 'accept': case 'accept':
// Either branch below means the outgoing ring wait is over one way or another --
// see armCallRingTimeout.
clearTimeout(this.callTimeoutTimer);
// If another my session has accepted the call. // If another my session has accepted the call.
if (Tinode.isMeTopicName(info.topic) && this.tinode.isMe(info.from)) { if (Tinode.isMeTopicName(info.topic) && this.tinode.isMe(info.from)) {
this.setState({ this.setState({
+29 -5
View File
@@ -223,14 +223,21 @@ function DeptNode({dept, depth, myUserId}) {
const [children, setChildren] = React.useState(null); const [children, setChildren] = React.useState(null);
const [members, setMembers] = React.useState(null); const [members, setMembers] = React.useState(null);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
const load = () => { const load = () => {
if (children != null) return; if (children != null) return;
setLoading(true); setLoading(true);
setLoadError(false);
Promise.all([ Promise.all([
getOrganizationDepartments(dept.department_id).then(d => d.departments || d.items || []), getOrganizationDepartments(dept.department_id).then(d => d.departments || d.items || []),
dept.member_count ? getOrganizationDepartmentMembers(dept.department_id).then(d => d.members || d.items || []) : Promise.resolve([]) dept.member_count ? getOrganizationDepartmentMembers(dept.department_id).then(d => d.members || d.items || []) : Promise.resolve([])
]).then(([depts, mems]) => { setChildren(depts); setMembers(mems); }).finally(() => setLoading(false)); ]).then(([depts, mems]) => { setChildren(depts); setMembers(mems); })
// Without this, a rejected Promise.all left children/members at null forever -- rendered as
// an empty (but not loading) node, indistinguishable from "this department genuinely has no
// sub-departments or members". loadError lets the retry link below tell the two apart.
.catch(() => setLoadError(true))
.finally(() => setLoading(false));
}; };
const toggle = () => { const next = !expanded; setExpanded(next); if (next) load(); }; const toggle = () => { const next = !expanded; setExpanded(next); if (next) load(); };
@@ -239,7 +246,9 @@ function DeptNode({dept, depth, myUserId}) {
return <> return <>
<DeptRow dept={dept} depth={depth} expanded={expanded} onToggle={toggle} /> <DeptRow dept={dept} depth={depth} expanded={expanded} onToggle={toggle} />
{expanded && loading ? <p className="organization-loading">加载中…</p> : null} {expanded && loading ? <p className="organization-loading">加载中…</p> : null}
{expanded && !loading ? <> {expanded && !loading && loadError ?
<p className="organization-loading">加载失败 <a href="#" onClick={e => { e.preventDefault(); load(); }}>重试</a></p> : null}
{expanded && !loading && !loadError ? <>
{(children || []).map(child => <DeptNode key={child.department_id} dept={child} depth={depth + 1} myUserId={myUserId} />)} {(children || []).map(child => <DeptNode key={child.department_id} dept={child} depth={depth + 1} myUserId={myUserId} />)}
{(members || []).map(person => <PersonRow key={person.user_id} person={person} depth={depth + 1} {(members || []).map(person => <PersonRow key={person.user_id} person={person} depth={depth + 1}
level={depth + 2} isMe={person.user_id === myUserId || 'usr' + person.user_id === myUserId} />)} level={depth + 2} isMe={person.user_id === myUserId || 'usr' + person.user_id === myUserId} />)}
@@ -484,18 +493,31 @@ function OrganizationDirectory({myUserId}) {
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const [searchResults, setSearchResults] = React.useState(null); const [searchResults, setSearchResults] = React.useState(null);
const [roots, setRoots] = React.useState(() => readCachedData('org-roots') || null); const [roots, setRoots] = React.useState(() => readCachedData('org-roots') || null);
const [rootsError, setRootsError] = React.useState(false);
const [tenantName, setTenantName] = React.useState(''); const [tenantName, setTenantName] = React.useState('');
React.useEffect(() => { const loadRoots = React.useCallback(() => {
setRootsError(false);
getOrganizationDepartments('root').then(d => { getOrganizationDepartments('root').then(d => {
const list = d.departments || d.items || []; const list = d.departments || d.items || [];
writeCachedData('org-roots', list); writeCachedData('org-roots', list);
setRoots(list); setRoots(list);
}).catch(() => setRoots(prev => prev || [])); }).catch(() => {
// Was collapsing a genuine load failure into the exact same `roots: []` state as "this
// tenant truly has no departments" -- rootsError lets the render below tell them apart
// instead of showing "暂无组织架构数据" for what might just be a network blip.
setRoots(prev => prev || []);
setRootsError(true);
});
}, []);
React.useEffect(() => {
loadRoots();
try { try {
const tenant = JSON.parse(window.localStorage.getItem('im-tenant') || '{}'); const tenant = JSON.parse(window.localStorage.getItem('im-tenant') || '{}');
setTenantName(tenant.name || ''); setTenantName(tenant.name || '');
} catch (_) { /* ignore */ } } catch (_) { /* ignore */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Was calling getOrganizationMembers({query}) -- besides that function's own now-fixed q/query // Was calling getOrganizationMembers({query}) -- besides that function's own now-fixed q/query
@@ -526,7 +548,9 @@ function OrganizationDirectory({myUserId}) {
</section> : </section> :
<section className="organization-tree"> <section className="organization-tree">
{roots == null ? <p className="organization-loading">加载中…</p> : {roots == null ? <p className="organization-loading">加载中…</p> :
roots.length === 0 ? <p className="organization-loading">暂无组织架构数据</p> : roots.length === 0 ? <p className="organization-loading">
{rootsError ? <>加载失败 <a href="#" onClick={e => { e.preventDefault(); loadRoots(); }}>重试</a></> : '暂无组织架构数据'}
</p> :
roots.map(dept => <DeptNode key={dept.department_id} dept={dept} depth={0} myUserId={myUserId} />)} roots.map(dept => <DeptNode key={dept.department_id} dept={dept} depth={0} myUserId={myUserId} />)}
</section>} </section>}
</main> </main>
+10
View File
@@ -117,6 +117,7 @@ class AudioRecorder extends React.PureComponent {
} }
componentWillUnmount() { componentWillUnmount() {
this._unmounted = true;
this.startedOn = null; this.startedOn = null;
if (this.stream) { if (this.stream) {
this.cleanUp(); this.cleanUp();
@@ -288,6 +289,15 @@ class AudioRecorder extends React.PureComponent {
} }
initMediaRecording(stream) { initMediaRecording(stream) {
if (this._unmounted) {
// getUserMedia()'s permission prompt can still be pending when this component gets torn
// down (e.g. the composer became disabled mid-request). Without this guard, the promise
// resolving afterwards would go on to call mediaRecorder.start()/visualize() (the latter
// reads this.canvasRef, already null post-unmount) against a component nothing can stop
// anymore -- the mic would keep running with no UI able to end it.
stream.getTracks().forEach(t => t.stop());
return;
}
this.stream = stream; this.stream = stream;
AUDIO_MIME_TYPES.some(mimeType => { AUDIO_MIME_TYPES.some(mimeType => {
if (MediaRecorder.isTypeSupported(mimeType)) { if (MediaRecorder.isTypeSupported(mimeType)) {
+11 -7
View File
@@ -17,22 +17,26 @@ import HashNavigation from '../lib/navigation.js';
// "这条消息本身有没有被看到",跟"这条语音有没有被真的点开听过"是两回事(文字消息看一眼就算 // "这条消息本身有没有被看到",跟"这条语音有没有被真的点开听过"是两回事(文字消息看一眼就算
// 读过,语音消息可能只是划过去、根本没点开播放)。按话题分别记一份"已播放过的 seq 集合"在 // 读过,语音消息可能只是划过去、根本没点开播放)。按话题分别记一份"已播放过的 seq 集合"在
// localStorage,跟"最近使用表情"是同一类不需要后端参与的本地小状态。 // localStorage,跟"最近使用表情"是同一类不需要后端参与的本地小状态。
// Key还要按登录账号分——P2P话题名本身就是对方user_id,跟"我是谁"无关;这个应用已知同一浏览器
// origin下多个账号共享localStorage(多标签页各自登录不同账号),不带uid会导致账号A点开播放过
// 的语音,在账号B自己与同一个人/同一个群的独立会话里也显示成"已读"。
const VOICE_PLAYED_KEY_PREFIX = 'im-voice-played:'; const VOICE_PLAYED_KEY_PREFIX = 'im-voice-played:';
function isVoicePlayed(topic, seq) { function isVoicePlayed(myUid, topic, seq) {
try { try {
const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + topic) || '[]'); const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + myUid + ':' + topic) || '[]');
return Array.isArray(list) && list.includes(seq); return Array.isArray(list) && list.includes(seq);
} catch (_) { } catch (_) {
return false; return false;
} }
} }
function markVoicePlayed(topic, seq) { function markVoicePlayed(myUid, topic, seq) {
try { try {
const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + topic) || '[]'); const key = VOICE_PLAYED_KEY_PREFIX + myUid + ':' + topic;
const list = JSON.parse(localStorage.getItem(key) || '[]');
const set = Array.isArray(list) ? list : []; const set = Array.isArray(list) ? list : [];
if (!set.includes(seq)) { if (!set.includes(seq)) {
// Cap so a very long-lived topic doesn't grow this without bound. // Cap so a very long-lived topic doesn't grow this without bound.
localStorage.setItem(VOICE_PLAYED_KEY_PREFIX + topic, JSON.stringify(set.concat(seq).slice(-500))); localStorage.setItem(key, JSON.stringify(set.concat(seq).slice(-500)));
} }
} catch (_) { /* ignore */ } } catch (_) { /* ignore */ }
} }
@@ -43,7 +47,7 @@ class BaseChatMessage extends React.PureComponent {
this.state = { this.state = {
progress: 0, progress: 0,
voicePlayed: isVoicePlayed(props.topic, props.seq) voicePlayed: isVoicePlayed(props.tinode.getCurrentUserID(), props.topic, props.seq)
}; };
if (props.uploader) { if (props.uploader) {
@@ -214,7 +218,7 @@ class BaseChatMessage extends React.PureComponent {
if (this.state.voicePlayed) { if (this.state.voicePlayed) {
return; return;
} }
markVoicePlayed(this.props.topic, this.props.seq); markVoicePlayed(this.props.tinode.getCurrentUserID(), this.props.topic, this.props.seq);
this.setState({voicePlayed: true}); this.setState({voicePlayed: true});
} }
+31 -2
View File
@@ -10,7 +10,7 @@ const AudioRecorder = React.lazy(_ => import('./audio-recorder.jsx'));
import { KEYPRESS_DELAY } from '../config.js'; import { KEYPRESS_DELAY } from '../config.js';
import { filePasted } from '../lib/blob-helpers.js'; import { filePasted } from '../lib/blob-helpers.js';
import { replyFormatter } from '../lib/formatters.js'; import { replyFormatter } from '../lib/formatters.js';
import { forwardFavorites, getFavorites, getFileUrl, getFriends } from '../lib/im-api.js'; import { forwardFavorites, getFavorites, getFileUrl, getFriends, translateReason } from '../lib/im-api.js';
// 4.5-4.6 表情面板:参考图确认了"只有2个tab"(😊表情 / ♡)和"发送按钮浅蓝禁用、深蓝启用"这两点, // 4.5-4.6 表情面板:参考图确认了"只有2个tab"(😊表情 / ♡)和"发送按钮浅蓝禁用、深蓝启用"这两点,
// 但没有任何一张图能看清第二个tab具体展示什么内容——判断为"常用"(这是这类两tab设计里最常见的 // 但没有任何一张图能看清第二个tab具体展示什么内容——判断为"常用"(这是这类两tab设计里最常见的
@@ -140,6 +140,7 @@ class SendMessage extends React.PureComponent {
this.startHoldRecording = this.startHoldRecording.bind(this); this.startHoldRecording = this.startHoldRecording.bind(this);
this.handleHoldMove = this.handleHoldMove.bind(this); this.handleHoldMove = this.handleHoldMove.bind(this);
this.endHoldRecording = this.endHoldRecording.bind(this); this.endHoldRecording = this.endHoldRecording.bind(this);
this.handleHoldVisibilityChange = this.handleHoldVisibilityChange.bind(this);
this.toggleEmojiPanel = this.toggleEmojiPanel.bind(this); this.toggleEmojiPanel = this.toggleEmojiPanel.bind(this);
this.insertEmoji = this.insertEmoji.bind(this); this.insertEmoji = this.insertEmoji.bind(this);
this.toggleAttachPanel = this.toggleAttachPanel.bind(this); this.toggleAttachPanel = this.toggleAttachPanel.bind(this);
@@ -174,6 +175,8 @@ class SendMessage extends React.PureComponent {
window.removeEventListener('mouseup', this.endHoldRecording); window.removeEventListener('mouseup', this.endHoldRecording);
window.removeEventListener('touchmove', this.handleHoldMove); window.removeEventListener('touchmove', this.handleHoldMove);
window.removeEventListener('touchend', this.endHoldRecording); window.removeEventListener('touchend', this.endHoldRecording);
window.removeEventListener('blur', this.endHoldRecording);
document.removeEventListener('visibilitychange', this.handleHoldVisibilityChange);
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@@ -191,6 +194,16 @@ class SendMessage extends React.PureComponent {
if (prevProps.topicName != this.props.topicName) { if (prevProps.topicName != this.props.topicName) {
this.setState({message: this.props.initMessage || '', audioRec: false, quote: null, mentionQuery: null, this.setState({message: this.props.initMessage || '', audioRec: false, quote: null, mentionQuery: null,
mentionResults: [], emojiOpen: false, attachOpen: false, cardPicker: null, favPicker: null}); mentionResults: [], emojiOpen: false, attachOpen: false, cardPicker: null, favPicker: null});
} else if (!prevProps.disabled && this.props.disabled && this.state.audioRec) {
// isWriter/etc are real-time ACS bits (messages-view.jsx re-derives them from {pres}/{meta}),
// so `disabled` can flip to true mid-recording -- the other party blocking you, being removed
// from a group, or getting muted, all without the user releasing the hold-to-talk button first.
// Unmounting <AudioRecorder> here (rather than leaving it mounted under a now-hidden branch)
// is what actually stops the mic via its componentWillUnmount; without this the recording just
// 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.
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 || '';
this.setState({message: msg}, _ => { this.setState({message: msg}, _ => {
@@ -494,7 +507,7 @@ class SendMessage extends React.PureComponent {
forwardFavorites([item.favorite_id], [this.props.topicName]).then(result => { forwardFavorites([item.favorite_id], [this.props.topicName]).then(result => {
const failed = (result && result.items || []).filter(r => r.status === 'failed'); const failed = (result && result.items || []).filter(r => r.status === 'failed');
if (failed.length && this.props.onError) { if (failed.length && this.props.onError) {
this.props.onError('发送失败:' + (failed[0].reason || '未知原因'), 'err'); this.props.onError('发送失败:' + (translateReason(failed[0].reason) || failed[0].reason || '未知原因'), 'err');
} }
}).catch(err => { }).catch(err => {
if (this.props.onError) { if (this.props.onError) {
@@ -520,6 +533,20 @@ class SendMessage extends React.PureComponent {
window.addEventListener('mouseup', this.endHoldRecording); window.addEventListener('mouseup', this.endHoldRecording);
window.addEventListener('touchmove', this.handleHoldMove, {passive: false}); window.addEventListener('touchmove', this.handleHoldMove, {passive: false});
window.addEventListener('touchend', this.endHoldRecording); window.addEventListener('touchend', this.endHoldRecording);
// mouseup/touchend only fire for a release point still inside this window/tab -- dragging the
// pointer out of the browser entirely, switching tabs/apps, or a permission prompt stealing
// focus (e.g. the very getUserMedia prompt this recording itself triggered, on a second call)
// all leave the mic running with no further event this listener pair will ever see. Ending the
// recording (same finish-or-cancel logic as a normal release, per the cancel-zone check) on
// either signal is a safer default than waiting indefinitely for a mouseup that may never come.
window.addEventListener('blur', this.endHoldRecording);
document.addEventListener('visibilitychange', this.handleHoldVisibilityChange);
}
handleHoldVisibilityChange() {
if (document.hidden) {
this.endHoldRecording();
}
} }
handleHoldMove(e) { handleHoldMove(e) {
@@ -538,6 +565,8 @@ class SendMessage extends React.PureComponent {
window.removeEventListener('mouseup', this.endHoldRecording); window.removeEventListener('mouseup', this.endHoldRecording);
window.removeEventListener('touchmove', this.handleHoldMove); window.removeEventListener('touchmove', this.handleHoldMove);
window.removeEventListener('touchend', this.endHoldRecording); window.removeEventListener('touchend', this.endHoldRecording);
window.removeEventListener('blur', this.endHoldRecording);
document.removeEventListener('visibilitychange', this.handleHoldVisibilityChange);
this.holdStartY = null; this.holdStartY = null;
const fakeEvent = {preventDefault: () => {}}; const fakeEvent = {preventDefault: () => {}};
if (this.audioRecorderInstance) { if (this.audioRecorderInstance) {
+184 -35
View File
@@ -3476,6 +3476,7 @@ __webpack_require__.r(__webpack_exports__);
/* harmony export */ submitTenantApplication: function() { return /* binding */ submitTenantApplication; }, /* harmony export */ submitTenantApplication: function() { return /* binding */ submitTenantApplication; },
/* harmony export */ submitTenantEntryFeedback: function() { return /* binding */ submitTenantEntryFeedback; }, /* harmony export */ submitTenantEntryFeedback: function() { return /* binding */ submitTenantEntryFeedback; },
/* harmony export */ transferGroupOwner: function() { return /* binding */ transferGroupOwner; }, /* harmony export */ transferGroupOwner: function() { return /* binding */ transferGroupOwner; },
/* harmony export */ translateReason: function() { return /* binding */ translateReason; },
/* harmony export */ unblockUser: function() { return /* binding */ unblockUser; }, /* harmony export */ unblockUser: function() { return /* binding */ unblockUser; },
/* harmony export */ unmuteGroupMember: function() { return /* binding */ unmuteGroupMember; }, /* harmony export */ unmuteGroupMember: function() { return /* binding */ unmuteGroupMember; },
/* harmony export */ updateConversationSettings: function() { return /* binding */ updateConversationSettings; }, /* harmony export */ updateConversationSettings: function() { return /* binding */ updateConversationSettings; },
@@ -3525,7 +3526,28 @@ const REASON_MESSAGES = {
VALIDATION_FAILED: '提交的内容不符合要求', VALIDATION_FAILED: '提交的内容不符合要求',
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试', IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
RESOURCE_NOT_FOUND: '内容不存在或已被删除', RESOURCE_NOT_FOUND: '内容不存在或已被删除',
FORBIDDEN: '没有权限执行此操作', PERMISSION_DENIED: '没有权限执行此操作',
NOT_FOUND: '内容不存在或已被删除',
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
AUTH_REQUIRED: '请先登录',
ACTION_TOKEN_INVALID: '操作凭证已失效,请重新操作',
SCHEMA_UNSUPPORTED: '当前版本过旧,请刷新页面后重试',
IDEMPOTENCY_IN_PROGRESS: '上一次操作正在处理中,请稍后再试',
INTERNAL_ERROR: '服务器繁忙,请稍后重试',
UNSUPPORTED_MEDIA_TYPE: '不支持的文件类型',
CHALLENGE_EXPIRED: '验证码已过期,请重新获取',
SMS_DELIVERY_FAILED: '短信发送失败,请稍后重试',
PASSWORD_POLICY_VIOLATION: '密码不符合安全要求',
VERIFICATION_RATE_LIMITED: details => details && details.retry_after_seconds ? `操作过于频繁,请 ${details.retry_after_seconds} 秒后重试` : '操作过于频繁,请稍后再试',
ALREADY_GROUP_MEMBER: '对方已经是群成员',
DESCRIPTION_REQUIRED: '请填写简介内容',
DESCRIPTION_TOO_LONG: '简介内容过长',
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
INVALID_EMAIL: '邮箱格式不正确',
MAILBOX_UNAVAILABLE: '总经理信箱暂不可用',
TOO_MANY_FILES: '文件数量超出限制',
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友', FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息', MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
GROUP_MUTED: '你已被禁言,无法发言', GROUP_MUTED: '你已被禁言,无法发言',
@@ -3603,12 +3625,15 @@ async function imRequest(path, {
} }
} }
if (!response.ok || payload && payload.code && payload.code !== 0) { if (!response.ok || payload && payload.code && payload.code !== 0) {
const entry = payload.reason && REASON_MESSAGES[payload.reason]; const known = translateReason(payload.reason, payload.details);
const known = typeof entry === 'function' ? entry(payload.details) : entry;
throw new ImApiError(known || payload.message || payload.reason || response.statusText || '请求失败', payload.reason, response.status, payload.details); throw new ImApiError(known || payload.message || payload.reason || response.statusText || '请求失败', payload.reason, response.status, payload.details);
} }
return normalizeResponse(payload); return normalizeResponse(payload);
} }
function translateReason(reason, details) {
const entry = reason && REASON_MESSAGES[reason];
return (typeof entry === 'function' ? entry(details) : entry) || null;
}
const idem = () => ({ const idem = () => ({
'Idempotency-Key': requestId() 'Idempotency-Key': requestId()
}); });
@@ -3675,6 +3700,12 @@ async function uploadPublicFile(file, purpose, onProgress) {
xhr.send(form); xhr.send(form);
}); });
} }
const UPLOAD_CTRL_TEXT_CN = {
'too large': '文件大小超出限制',
'malformed': '请求内容有误,请稍后重试',
'permission denied': '没有权限执行此操作',
'gone': '内容不存在或已被删除'
};
function uploadLargeFile(tinode, file, onProgress) { function uploadLargeFile(tinode, file, onProgress) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken(); const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) { if (!authToken) {
@@ -3705,7 +3736,8 @@ function uploadLargeFile(tinode, file, onProgress) {
expires: ctrl.params.expires expires: ctrl.params.expires
}); });
} else { } else {
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status)); const known = ctrl.text && UPLOAD_CTRL_TEXT_CN[ctrl.text.trim().toLowerCase()];
reject(new ImApiError(known || ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
} }
}; };
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0)); xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
@@ -3745,7 +3777,8 @@ function createChatUploader(tinode) {
if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) { if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) {
resolve(ctrl.params.url); resolve(ctrl.params.url);
} else { } else {
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status)); const known = ctrl.text && UPLOAD_CTRL_TEXT_CN[ctrl.text.trim().toLowerCase()];
reject(new ImApiError(known || ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
} }
}; };
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0)); xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
@@ -3804,7 +3837,9 @@ function createFileDownloader(tinode) {
let err; let err;
try { try {
const pkt = JSON.parse(this.result); const pkt = JSON.parse(this.result);
err = new ImApiError(pkt.ctrl && pkt.ctrl.text || '下载失败', 'DOWNLOAD_FAILED', xhr.status); const text = pkt.ctrl && pkt.ctrl.text;
const known = text && UPLOAD_CTRL_TEXT_CN[text.trim().toLowerCase()];
err = new ImApiError(known || text || '下载失败', 'DOWNLOAD_FAILED', xhr.status);
} catch (e) { } catch (e) {
err = new ImApiError('下载失败', 'DOWNLOAD_FAILED', xhr.status); err = new ImApiError('下载失败', 'DOWNLOAD_FAILED', xhr.status);
} }
@@ -4075,11 +4110,13 @@ const favoriteMessages = messages => imPost('/favorites/batch', {
const getFavorites = ({ const getFavorites = ({
type = 'all', type = 'all',
query = '', query = '',
cursor = '' cursor = '',
limit
} = {}) => imGet('/favorites' + qs({ } = {}) => imGet('/favorites' + qs({
type, type,
q: query, q: query,
cursor cursor,
limit
})); }));
const getFavorite = favoriteId => imGet('/favorites/' + encodeURIComponent(favoriteId)); const getFavorite = favoriteId => imGet('/favorites/' + encodeURIComponent(favoriteId));
const deleteFavorite = favoriteId => imDelete('/favorites/' + encodeURIComponent(favoriteId)); const deleteFavorite = favoriteId => imDelete('/favorites/' + encodeURIComponent(favoriteId));
@@ -4734,15 +4771,25 @@ function useCachedData(key, fetcher, deps) {
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => { react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
if (!key) return; if (!key) return;
let live = true; let live = true;
let retried = false;
setLoading(!cache.has(key)); setLoading(!cache.has(key));
fetcher().then(result => { const attempt = () => fetcher().then(result => {
if (!live) return; if (!live) return;
cache.set(key, result); cache.set(key, result);
setData(result); setData(result);
setLoading(false); setLoading(false);
}).catch(() => { }).catch(() => {
if (live) setLoading(false); if (!live) return;
if (!retried && !cache.has(key)) {
retried = true;
window.setTimeout(() => {
if (live) attempt();
}, 3000);
return;
}
setLoading(false);
}); });
attempt();
return () => { return () => {
live = false; live = false;
}; };
@@ -8137,6 +8184,14 @@ function Profile({
return current; return current;
}); });
}); });
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getConversationContext)(uid).then(ctx => {
if (ctx && ctx.relationship) {
setProfile(current => current ? {
...current,
relationship: ctx.relationship
} : current);
}
}).catch(() => {});
}, [uid]); }, [uid]);
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => { react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
if (!uid) return undefined; if (!uid) return undefined;
@@ -8279,7 +8334,15 @@ function Settings({
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)('profile-seed:' + uid, p); (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)('profile-seed:' + uid, p);
setProfile(p); setProfile(p);
if (!pinBlocked) setBlocked(p.relationship === 'blocked_by_me'); if (!pinBlocked) setBlocked(p.relationship === 'blocked_by_me');
}).catch(() => {}); }).catch(() => {
if (!pinBlocked) {
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getConversationContext)(uid).then(ctx => {
if (ctx && ctx.relationship) {
setBlocked(ctx.relationship === 'blocked_by_me');
}
}).catch(() => {});
}
});
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getFriends)().then(data => { (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getFriends)().then(data => {
const entry = (data.items || []).find(item => item.user_id === uid); const entry = (data.items || []).find(item => item.user_id === uid);
if (entry) setRemark(entry.remark || ''); if (entry) setRemark(entry.remark || '');
@@ -11043,8 +11106,12 @@ function GroupChat({
} }
function JoinRequests({ function JoinRequests({
topic, topic,
fromChat,
onToast onToast
}) { }) {
const backToChat = react__WEBPACK_IMPORTED_MODULE_0___default().useCallback(() => {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel('', ''), topic));
}, [topic]);
const [items, setItems] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(() => topic && (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.readCachedData)('group-join-requests:' + topic) || null); const [items, setItems] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(() => topic && (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.readCachedData)('group-join-requests:' + topic) || null);
const [profiles, setProfiles] = react__WEBPACK_IMPORTED_MODULE_0___default().useState({}); const [profiles, setProfiles] = react__WEBPACK_IMPORTED_MODULE_0___default().useState({});
const [loadError, setLoadError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false); const [loadError, setLoadError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
@@ -11098,8 +11165,9 @@ function JoinRequests({
className: "group-page", className: "group-page",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Header, { children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Header, {
title: "邀请进群申请", title: "邀请进群申请",
back: "group-settings", back: fromChat ? undefined : 'group-governance',
backTopic: topic backTopic: topic,
onBack: fromChat ? backToChat : undefined
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("main", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("main", {
className: "group-request-list", className: "group-request-list",
children: items === null ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("p", { children: items === null ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("p", {
@@ -11896,7 +11964,7 @@ function AdminList({
item: owner item: owner
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("strong", { children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("strong", {
children: owner.display_name children: owner.group_nickname || owner.display_name
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", {
children: "群主" children: "群主"
}, void 0, false)] }, void 0, false)]
@@ -11912,7 +11980,7 @@ function AdminList({
item: item item: item
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("strong", { children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("strong", {
children: item.display_name children: item.group_nickname || item.display_name
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", {
children: "管理员" children: "管理员"
}, void 0, false)] }, void 0, false)]
@@ -12592,6 +12660,7 @@ function GroupManagementView({
onToast: setToast onToast: setToast
}, void 0, false);else if (view === 'group-join-requests') content = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(JoinRequests, { }, void 0, false);else if (view === 'group-join-requests') content = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(JoinRequests, {
topic: topic, topic: topic,
fromChat: hashParams && hashParams.from === 'chat',
onToast: setToast onToast: setToast
}, void 0, false);else if (view === 'group-announcement-detail') content = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Announcement, { }, void 0, false);else if (view === 'group-announcement-detail') content = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Announcement, {
topic: topic, topic: topic,
@@ -13136,7 +13205,7 @@ function MessageHome({
const frequent = frequentContacts(chatList); const frequent = frequentContacts(chatList);
const realname = bootstrap && bootstrap.security && bootstrap.security.realname; const realname = bootstrap && bootstrap.security && bootstrap.security.realname;
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-reminder-dismissed:' + myUserId : null; const dismissKey = myUserId && realname ? `realname-reminder-dismissed:${myUserId}:${realname.submission_id || realname.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;
@@ -15347,16 +15416,22 @@ function CacheClear({
const confirm = async () => { const confirm = async () => {
setBusy(true); setBusy(true);
try { try {
const before = (await navigator.storage.estimate().catch(() => ({ const hasStorageEstimate = typeof navigator !== 'undefined' && navigator.storage && navigator.storage.estimate;
const hasCaches = typeof caches !== 'undefined';
const before = hasStorageEstimate ? (await navigator.storage.estimate().catch(() => ({
usage: 0 usage: 0
}))).usage || 0; }))).usage || 0 : 0;
const keys = await caches.keys(); if (hasCaches) {
await Promise.all(keys.map(k => caches.delete(k))); const keys = await caches.keys();
const after = (await navigator.storage.estimate().catch(() => ({ await Promise.all(keys.map(k => caches.delete(k)));
}
const after = hasStorageEstimate ? (await navigator.storage.estimate().catch(() => ({
usage: 0 usage: 0
}))).usage || 0; }))).usage || 0 : 0;
setFreed(Math.max(0, before - after)); setFreed(Math.max(0, before - after));
setDone(true); setDone(true);
} catch (err) {
onToast(err.message || '清理失败,请重试');
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -15832,6 +15907,14 @@ function YouthModeVerify({
const [password, setPassword] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(''); const [password, setPassword] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
const [error, setError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(''); const [error, setError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
const [busy, setBusy] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false); const [busy, setBusy] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
const [lockedUntil, setLockedUntil] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(0);
const [now, setNow] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(Date.now());
const remainingLockSeconds = lockedUntil ? Math.max(0, Math.ceil((lockedUntil - now) / 1000)) : 0;
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
if (remainingLockSeconds <= 0) return;
const id = window.setTimeout(() => setNow(Date.now()), 1000);
return () => window.clearTimeout(id);
}, [remainingLockSeconds]);
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => { react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_6__.getYouthMode)().then(setSettings).catch(() => {}); (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_6__.getYouthMode)().then(setSettings).catch(() => {});
}, []); }, []);
@@ -15840,6 +15923,9 @@ function YouthModeVerify({
setError('请输入4位独立密码'); setError('请输入4位独立密码');
return; return;
} }
if (remainingLockSeconds > 0) {
return;
}
setBusy(true); setBusy(true);
setError(''); setError('');
const action = mode === 'enable' ? 'enable_youth_mode' : 'close_youth_mode'; const action = mode === 'enable' ? 'enable_youth_mode' : 'close_youth_mode';
@@ -15855,7 +15941,13 @@ function YouthModeVerify({
}); });
onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭'); onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭');
window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400); window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
}).catch(err => setError(err.message || '验证失败')).finally(() => setBusy(false)); }).catch(err => {
if (err.reason === 'TOO_MANY_ATTEMPTS' && err.details && err.details.retry_after_seconds) {
setLockedUntil(Date.now() + err.details.retry_after_seconds * 1000);
setNow(Date.now());
}
setError(err.message || '验证失败');
}).finally(() => setBusy(false));
}; };
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", { return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
className: "personal-page youth-mode-page", className: "personal-page youth-mode-page",
@@ -15885,9 +15977,9 @@ function YouthModeVerify({
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("button", { }, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("button", {
type: "button", type: "button",
className: "auth-primary", className: "auth-primary",
disabled: password.length !== 4 || busy, disabled: password.length !== 4 || busy || remainingLockSeconds > 0,
onClick: submit, onClick: submit,
children: busy ? '验证中…' : '确定' children: busy ? '验证中…' : remainingLockSeconds > 0 ? `请 ${remainingLockSeconds} 秒后重试` : '确定'
}, void 0, false)] }, void 0, false)]
}, void 0, true)] }, void 0, true)]
}, void 0, true); }, void 0, true);
@@ -16514,6 +16606,10 @@ function UserFeedback({
onToast('请填写反馈内容'); onToast('请填写反馈内容');
return; return;
} }
if (!category) {
onToast('请选择反馈类型');
return;
}
setSubmitting(true); setSubmitting(true);
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_2__.submitFeedback)({ (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_2__.submitFeedback)({
category, category,
@@ -17013,7 +17109,7 @@ class SidepanelView extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
myUserId: this.props.myUserId, myUserId: this.props.myUserId,
onTopicSelected: this.props.onTopicSelected, onTopicSelected: this.props.onTopicSelected,
onOpenScanner: this.props.onOpenScanner onOpenScanner: this.props.onOpenScanner
}, void 0, false) }, view, false)
}, void 0, false); }, void 0, false);
} }
const chapterThreeViews = ['chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request', 'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard', 'chat-attachments', 'chat-mixed', 'chat-contact-card']; const chapterThreeViews = ['chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request', 'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard', 'chat-attachments', 'chat-mixed', 'chat-contact-card'];
@@ -17450,18 +17546,26 @@ const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
}); });
const SIDE_PANEL_VIEWS = ['acc_general', 'archive', 'blocked', 'contacts', 'cred', 'crop', 'edit', 'general', 'newtpk', 'notif', 'register', 'reset', 'security', 'settings', 'support', 'wallpapers', 'tenant', 'login', 'enterprise-register', 'enterprise-feedback', 'identity-check', 'realname', 'realname-verified', 'realname-reminder', 'independent-password', 'messages-home', 'messages-offline', 'messages-menu', 'message-actions', 'system-messages', 'chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request', 'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard', 'chat-attachments', 'chat-mixed', 'chat-contact-card', 'compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message', 'emoji-panel', 'emoji-message', 'voice-ready', 'voice-hold', 'voice-recording', 'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message', 'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple', 'forward-individual', 'forward-bundle', 'address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-qr', 'friend-search-result', 'friend-verification', 'friend-profile', 'group-member-profile', 'friend-settings', 'friend-settings-blocked', 'friend-delete-confirm', 'block-list', 'block-confirm', 'clear-chat-confirm', 'group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests', 'group-new-request', 'group-announcement', 'group-summary', 'group-announcement-detail', 'group-settings', 'group-profile', 'group-avatar', 'group-nickname', 'group-description', 'group-my-nickname', 'group-role-message', 'group-qr', 'group-governance', 'group-admins', 'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed', 'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files', 'group-file-upload', 'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice', 'favorites-links', 'favorites-select', 'favorite-share', 'file-download', 'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links', 'history-images', 'history-videos', 'call-history', 'call-incoming', 'audio-call-ringing', 'audio-call-active', 'audio-call-muted', 'audio-call-speaker', 'video-call-ringing', 'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off', 'call-minimized', 'workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail', 'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail', 'monthly-report-editor', 'monthly-report-mine', 'monthly-report-team', 'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice', 'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings', 'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm', 'app-version', 'privacy-settings', 'friend-discovery-settings', 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify', 'notification-permission', 'permission-settings-reminder', 'camera-permission-info', 'microphone-permission-info', 'fraud-risk-warning', 'fraud-risk-confirmation', 'abuse-report-reasons', 'abuse-report-details', 'user-feedback', '']; const SIDE_PANEL_VIEWS = ['acc_general', 'archive', 'blocked', 'contacts', 'cred', 'crop', 'edit', 'general', 'newtpk', 'notif', 'register', 'reset', 'security', 'settings', 'support', 'wallpapers', 'tenant', 'login', 'enterprise-register', 'enterprise-feedback', 'identity-check', 'realname', 'realname-verified', 'realname-reminder', 'independent-password', 'messages-home', 'messages-offline', 'messages-menu', 'message-actions', 'system-messages', 'chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request', 'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard', 'chat-attachments', 'chat-mixed', 'chat-contact-card', 'compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message', 'emoji-panel', 'emoji-message', 'voice-ready', 'voice-hold', 'voice-recording', 'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message', 'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple', 'forward-individual', 'forward-bundle', 'address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-qr', 'friend-search-result', 'friend-verification', 'friend-profile', 'group-member-profile', 'friend-settings', 'friend-settings-blocked', 'friend-delete-confirm', 'block-list', 'block-confirm', 'clear-chat-confirm', 'group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests', 'group-new-request', 'group-announcement', 'group-summary', 'group-announcement-detail', 'group-settings', 'group-profile', 'group-avatar', 'group-nickname', 'group-description', 'group-my-nickname', 'group-role-message', 'group-qr', 'group-governance', 'group-admins', 'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed', 'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files', 'group-file-upload', 'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice', 'favorites-links', 'favorites-select', 'favorite-share', 'file-download', 'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links', 'history-images', 'history-videos', 'call-history', 'call-incoming', 'audio-call-ringing', 'audio-call-active', 'audio-call-muted', 'audio-call-speaker', 'video-call-ringing', 'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off', 'call-minimized', 'workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail', 'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail', 'monthly-report-editor', 'monthly-report-mine', 'monthly-report-team', 'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice', 'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings', 'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm', 'app-version', 'privacy-settings', 'friend-discovery-settings', 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify', 'notification-permission', 'permission-settings-reminder', 'camera-permission-info', 'microphone-permission-info', 'fraud-risk-warning', 'fraud-risk-confirmation', 'abuse-report-reasons', 'abuse-report-details', 'user-feedback', ''];
const AUTH_FLOW_VIEWS = ['tenant', 'login', 'register', 'reset', 'cred', 'enterprise-register', 'enterprise-feedback']; const AUTH_FLOW_VIEWS = ['tenant', 'login', 'register', 'reset', 'cred', 'enterprise-register', 'enterprise-feedback'];
const LOGIN_ERROR_TEXT_CN = { const CTRL_ERROR_TEXT_CN = {
'authentication failed': '账号或密码错误', 'authentication failed': '账号或密码错误',
'authentication required': '请先登录', 'authentication required': '请先登录',
'unknown authentication scheme': '登录方式不受支持,请刷新页面重试', 'unknown authentication scheme': '登录方式不受支持,请刷新页面重试',
'policy violation': '当前操作不符合安全策略要求' 'policy violation': '当前操作不符合安全策略要求',
'permission denied': '没有权限执行此操作,请重新登录'
}; };
function translateCtrlError(err) {
const raw = err && err.message;
if (!raw) return null;
const normalized = raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase();
return CTRL_ERROR_TEXT_CN[normalized] || null;
}
function loginErrorMessage(err) { function loginErrorMessage(err) {
const known = err && err.message && LOGIN_ERROR_TEXT_CN[err.message.trim().toLowerCase()]; const known = translateCtrlError(err);
if (known) return known; if (known) return known;
if (err && (err.code === 401 || err.code === 403)) return '账号或密码错误'; if (err && (err.code === 401 || err.code === 403)) return '账号或密码错误';
return err && err.message || '登录失败,请重试'; return err && err.message || '登录失败,请重试';
} }
const CALL_RING_TIMEOUT_MS = 60000;
class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component) { class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component) {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -18086,11 +18190,12 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
}, 1000); }, 1000);
} }
handleDisconnect(err) { handleDisconnect(err) {
const known = translateCtrlError(err);
this.setState({ this.setState({
connected: false, connected: false,
ready: false, ready: false,
topicSelectedOnline: false, topicSelectedOnline: false,
errorText: err && err.message ? err.message : "Disconnected", errorText: known || (err && err.message ? err.message : "Disconnected"),
errorLevel: err && err.message ? 'err' : 'warn', errorLevel: err && err.message ? 'err' : 'warn',
loginDisabled: false, loginDisabled: false,
contextMenuVisible: false, contextMenuVisible: false,
@@ -19336,6 +19441,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
}, void 0, false), null, null, _ => {}, null); }, void 0, false), null, null, _ => {}, null);
} }
handleStartVideoCall() { handleStartVideoCall() {
this.armCallRingTimeout();
this.setState({ this.setState({
callTopic: this.state.topicSelected, callTopic: this.state.topicSelected,
callState: _constants_js__WEBPACK_IMPORTED_MODULE_13__.CALL_STATE_OUTGOING_INITATED, callState: _constants_js__WEBPACK_IMPORTED_MODULE_13__.CALL_STATE_OUTGOING_INITATED,
@@ -19343,12 +19449,24 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
}); });
} }
handleStartAudioCall() { handleStartAudioCall() {
this.armCallRingTimeout();
this.setState({ this.setState({
callTopic: this.state.topicSelected, callTopic: this.state.topicSelected,
callState: _constants_js__WEBPACK_IMPORTED_MODULE_13__.CALL_STATE_OUTGOING_INITATED, callState: _constants_js__WEBPACK_IMPORTED_MODULE_13__.CALL_STATE_OUTGOING_INITATED,
callAudioOnly: true callAudioOnly: true
}); });
} }
armCallRingTimeout() {
clearTimeout(this.callTimeoutTimer);
this.callTimeoutTimer = setTimeout(() => {
this.callTimeoutTimer = null;
if (this.state.callState != _constants_js__WEBPACK_IMPORTED_MODULE_13__.CALL_STATE_OUTGOING_INITATED) {
return;
}
this.handleCallHangup(this.state.callTopic, this.state.callSeq);
this.handleCallClose();
}, CALL_RING_TIMEOUT_MS);
}
handleCallInvite(callTopic, callSeq, callState, audioOnly) { handleCallInvite(callTopic, callSeq, callState, audioOnly) {
switch (callState) { switch (callState) {
case _constants_js__WEBPACK_IMPORTED_MODULE_13__.CALL_STATE_OUTGOING_INITATED: case _constants_js__WEBPACK_IMPORTED_MODULE_13__.CALL_STATE_OUTGOING_INITATED:
@@ -19425,6 +19543,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
}); });
} }
handleCallAccept(topicName, setCallTopic, callSeq) { handleCallAccept(topicName, setCallTopic, callSeq) {
clearTimeout(this.callTimeoutTimer);
const topic = this.tinode.getTopic(topicName); const topic = this.tinode.getTopic(topicName);
if (!topic) { if (!topic) {
return; return;
@@ -19451,6 +19570,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
} }
switch (info.event) { switch (info.event) {
case 'accept': case 'accept':
clearTimeout(this.callTimeoutTimer);
if (tinode_sdk__WEBPACK_IMPORTED_MODULE_4__.Tinode.isMeTopicName(info.topic) && this.tinode.isMe(info.from)) { if (tinode_sdk__WEBPACK_IMPORTED_MODULE_4__.Tinode.isMeTopicName(info.topic) && this.tinode.isMe(info.from)) {
this.setState({ this.setState({
callTopic: null, callTopic: null,
@@ -20383,13 +20503,15 @@ function DeptNode({
const [children, setChildren] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(null); const [children, setChildren] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(null);
const [members, setMembers] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(null); const [members, setMembers] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(null);
const [loading, setLoading] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false); const [loading, setLoading] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
const [loadError, setLoadError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
const load = () => { const load = () => {
if (children != null) return; if (children != null) return;
setLoading(true); setLoading(true);
setLoadError(false);
Promise.all([(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getOrganizationDepartments)(dept.department_id).then(d => d.departments || d.items || []), dept.member_count ? (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getOrganizationDepartmentMembers)(dept.department_id).then(d => d.members || d.items || []) : Promise.resolve([])]).then(([depts, mems]) => { Promise.all([(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getOrganizationDepartments)(dept.department_id).then(d => d.departments || d.items || []), dept.member_count ? (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getOrganizationDepartmentMembers)(dept.department_id).then(d => d.members || d.items || []) : Promise.resolve([])]).then(([depts, mems]) => {
setChildren(depts); setChildren(depts);
setMembers(mems); setMembers(mems);
}).finally(() => setLoading(false)); }).catch(() => setLoadError(true)).finally(() => setLoading(false));
}; };
const toggle = () => { const toggle = () => {
const next = !expanded; const next = !expanded;
@@ -20408,7 +20530,17 @@ function DeptNode({
}, void 0, false), expanded && loading ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("p", { }, void 0, false), expanded && loading ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("p", {
className: "organization-loading", className: "organization-loading",
children: "加载中…" children: "加载中…"
}, void 0, false) : null, expanded && !loading ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, { }, void 0, false) : null, expanded && !loading && loadError ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("p", {
className: "organization-loading",
children: ["加载失败 ", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("a", {
href: "#",
onClick: e => {
e.preventDefault();
load();
},
children: "重试"
}, void 0, false)]
}, void 0, true) : null, expanded && !loading && !loadError ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, {
children: [(children || []).map(child => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(DeptNode, { children: [(children || []).map(child => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(DeptNode, {
dept: child, dept: child,
depth: depth + 1, depth: depth + 1,
@@ -20921,13 +21053,21 @@ function OrganizationDirectory({
const [query, setQuery] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(''); const [query, setQuery] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
const [searchResults, setSearchResults] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(null); const [searchResults, setSearchResults] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(null);
const [roots, setRoots] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(() => (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_4__.readCachedData)('org-roots') || null); const [roots, setRoots] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(() => (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_4__.readCachedData)('org-roots') || null);
const [rootsError, setRootsError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
const [tenantName, setTenantName] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(''); const [tenantName, setTenantName] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => { const loadRoots = react__WEBPACK_IMPORTED_MODULE_0___default().useCallback(() => {
setRootsError(false);
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getOrganizationDepartments)('root').then(d => { (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getOrganizationDepartments)('root').then(d => {
const list = d.departments || d.items || []; const list = d.departments || d.items || [];
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_4__.writeCachedData)('org-roots', list); (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_4__.writeCachedData)('org-roots', list);
setRoots(list); setRoots(list);
}).catch(() => setRoots(prev => prev || [])); }).catch(() => {
setRoots(prev => prev || []);
setRootsError(true);
});
}, []);
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
loadRoots();
try { try {
const tenant = JSON.parse(window.localStorage.getItem('im-tenant') || '{}'); const tenant = JSON.parse(window.localStorage.getItem('im-tenant') || '{}');
setTenantName(tenant.name || ''); setTenantName(tenant.name || '');
@@ -20979,7 +21119,16 @@ function OrganizationDirectory({
children: "加载中…" children: "加载中…"
}, void 0, false) : roots.length === 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("p", { }, void 0, false) : roots.length === 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("p", {
className: "organization-loading", className: "organization-loading",
children: "暂无组织架构数据" children: rootsError ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, {
children: ["加载失败 ", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("a", {
href: "#",
onClick: e => {
e.preventDefault();
loadRoots();
},
children: "重试"
}, void 0, false)]
}, void 0, true) : '暂无组织架构数据'
}, void 0, false) : roots.map(dept => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(DeptNode, { }, void 0, false) : roots.map(dept => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(DeptNode, {
dept: dept, dept: dept,
depth: 0, depth: 0,
+1 -1
View File
File diff suppressed because one or more lines are too long
+70 -33
View File
@@ -338,6 +338,9 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
}).catch(_ => {}); }).catch(_ => {});
} }
componentWillUnmount() { componentWillUnmount() {
if (this.state.rtcPanel) {
this.handleCallHangup(this.state.topic, this.props.callSeq);
}
if (this.messagesScroller) { if (this.messagesScroller) {
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent); this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
} }
@@ -1139,12 +1142,13 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
if (!topicName) { if (!topicName) {
return; return;
} }
if (this.draftSaveTimer) { const version = this.state.draftVersion || 0;
window.clearTimeout(this.draftSaveTimer); this.draftSaveTimers = this.draftSaveTimers || {};
if (this.draftSaveTimers[topicName]) {
window.clearTimeout(this.draftSaveTimers[topicName]);
} }
this.draftSaveTimer = window.setTimeout(() => { this.draftSaveTimers[topicName] = window.setTimeout(() => {
this.draftSaveTimer = null; delete this.draftSaveTimers[topicName];
const version = this.state.topic == topicName ? this.state.draftVersion || 0 : 0;
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.updateTopicDraft)(topicName, text, [], version).then(d => { (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.updateTopicDraft)(topicName, text, [], version).then(d => {
if (this.state.topic == topicName) { if (this.state.topic == topicName) {
this.setState({ this.setState({
@@ -1154,12 +1158,12 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
}).catch(_ => {}); }).catch(_ => {});
}, 600); }, 600);
} }
clearDraft(topicName) { clearDraft(topicName, knownVersion) {
if (this.draftSaveTimer) { if (this.draftSaveTimers && this.draftSaveTimers[topicName]) {
window.clearTimeout(this.draftSaveTimer); window.clearTimeout(this.draftSaveTimers[topicName]);
this.draftSaveTimer = null; delete this.draftSaveTimers[topicName];
} }
const version = this.state.topic == topicName ? this.state.draftVersion || 0 : 0; const version = knownVersion != null ? knownVersion : this.state.topic == topicName ? this.state.draftVersion || 0 : 0;
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.updateTopicDraft)(topicName, '', [], version).then(d => { (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.updateTopicDraft)(topicName, '', [], version).then(d => {
if (this.state.topic == topicName) { if (this.state.topic == topicName) {
this.setState({ this.setState({
@@ -1177,7 +1181,9 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
} }
handleShowJoinRequests(e) { handleShowJoinRequests(e) {
e.preventDefault(); e.preventDefault();
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic)); let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic);
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(hash, 'from', 'chat');
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(hash);
} }
handleSendFriendVerification(e) { handleSendFriendVerification(e) {
e.preventDefault(); e.preventDefault();
@@ -1208,18 +1214,26 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
return; return;
} }
const topicName = this.state.topic; const topicName = this.state.topic;
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.favoriteMessages)(this.props.selectedSeqs.map(seq => ({ const seqs = this.props.selectedSeqs.slice();
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.favoriteMessages)(seqs.map(seq => ({
topic: topicName, topic: topicName,
seq seq
}))).then(result => { }))).then(() => {
const failed = result && result.failed || []; return (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.getFavorites)({
if (failed.length && failed.length === this.props.selectedSeqs.length) { type: 'all',
this.props.onError('收藏失败', 'err'); limit: 100
} else if (failed.length) { }).then(data => {
this.showToast(`已收藏,${failed.length} 条失败`); const items = data && data.items || [];
} else { const landed = new Set(items.filter(it => it.source && it.source.topic == topicName).map(it => String(it.source.seq)));
this.showToast('收藏成功'); const missing = seqs.filter(seq => !landed.has(String(seq)));
} if (missing.length === seqs.length) {
this.props.onError('收藏失败', 'err');
} else if (missing.length) {
this.showToast(`已收藏 ${seqs.length - missing.length}/${seqs.length} 条`);
} else {
this.showToast('收藏成功');
}
});
}).catch(err => { }).catch(err => {
this.props.onError(err.message, 'err'); this.props.onError(err.message, 'err');
}); });
@@ -1279,6 +1293,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
this.applyMentionResolution(parsed); this.applyMentionResolution(parsed);
msg = parsed; msg = parsed;
} }
this.mentionedUsers = [];
if (typeHint) { if (typeHint) {
head = { head = {
...(head || {}), ...(head || {}),
@@ -1286,10 +1301,11 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
}; };
} }
const topicForDraft = this.state.topic; const topicForDraft = this.state.topic;
const draftVersionAtSend = this.state.draftVersion || 0;
const doSend = () => { const doSend = () => {
const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head); const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
if (originalText != null && result && typeof result.then == 'function' && topicForDraft) { if (originalText != null && result && typeof result.then == 'function' && topicForDraft) {
result.then(() => this.clearDraft(topicForDraft)); result.then(() => this.clearDraft(topicForDraft, draftVersionAtSend));
} }
return result; return result;
}; };
@@ -1682,6 +1698,9 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
}, 1000); }, 1000);
} else { } else {
console.error("Unresolved message ref", replyToSeq); console.error("Unresolved message ref", replyToSeq);
if (this.props.onError) {
this.props.onError('该消息已被删除或撤回', 'err');
}
} }
} }
jumpToMessage(seq) { jumpToMessage(seq) {
@@ -2040,6 +2059,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
const online = this.state.deleted || topic.isSelfType() ? null : this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline'; const online = this.state.deleted || topic.isSelfType() ? null : this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
const titleClass = 'panel-title' + (this.state.deleted ? ' deleted' : ''); const titleClass = 'panel-title' + (this.state.deleted ? ' deleted' : '');
const darkModeClass = this.props.colorSchema == 'dark' ? 'dark' : ''; const darkModeClass = this.props.colorSchema == 'dark' ? 'dark' : '';
const editingMessage = !!(this.state.reply && this.state.reply.editing);
let messagesComponent = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_22__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_22__.Fragment, { let messagesComponent = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_22__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_22__.Fragment, {
children: [this.state.groupAnnouncement ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_22__.jsxDEV)("a", { children: [this.state.groupAnnouncement ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_22__.jsxDEV)("a", {
href: "#", href: "#",
@@ -2200,9 +2220,9 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
onMentionSearch: this.props.forwardMessage || !groupTopic ? null : this.mentionSearch, onMentionSearch: this.props.forwardMessage || !groupTopic ? null : this.mentionSearch,
onMentionSelected: this.mentionSelected, onMentionSelected: this.mentionSelected,
onSendMessage: this.sendMessage, onSendMessage: this.sendMessage,
onAttachFile: this.props.forwardMessage ? null : this.handleAttachFile, onAttachFile: this.props.forwardMessage || editingMessage ? null : this.handleAttachFile,
onAttachImage: this.props.forwardMessage ? null : this.handleAttachImageOrVideo, onAttachImage: this.props.forwardMessage || editingMessage ? null : this.handleAttachImageOrVideo,
onAttachAudio: this.props.forwardMessage ? null : this.sendAudioAttachment, onAttachAudio: this.props.forwardMessage || editingMessage ? null : this.sendAudioAttachment,
canStartCall: !this.props.forwardMessage && this.props.canStartCall, canStartCall: !this.props.forwardMessage && this.props.canStartCall,
onStartAudioCall: this.props.onStartAudioCall, onStartAudioCall: this.props.onStartAudioCall,
onStartVideoCall: this.props.onStartVideoCall, onStartVideoCall: this.props.onStartVideoCall,
@@ -2486,20 +2506,21 @@ __webpack_require__.r(__webpack_exports__);
const VOICE_PLAYED_KEY_PREFIX = 'im-voice-played:'; const VOICE_PLAYED_KEY_PREFIX = 'im-voice-played:';
function isVoicePlayed(topic, seq) { function isVoicePlayed(myUid, topic, seq) {
try { try {
const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + topic) || '[]'); const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + myUid + ':' + topic) || '[]');
return Array.isArray(list) && list.includes(seq); return Array.isArray(list) && list.includes(seq);
} catch (_) { } catch (_) {
return false; return false;
} }
} }
function markVoicePlayed(topic, seq) { function markVoicePlayed(myUid, topic, seq) {
try { try {
const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + topic) || '[]'); const key = VOICE_PLAYED_KEY_PREFIX + myUid + ':' + topic;
const list = JSON.parse(localStorage.getItem(key) || '[]');
const set = Array.isArray(list) ? list : []; const set = Array.isArray(list) ? list : [];
if (!set.includes(seq)) { if (!set.includes(seq)) {
localStorage.setItem(VOICE_PLAYED_KEY_PREFIX + topic, JSON.stringify(set.concat(seq).slice(-500))); localStorage.setItem(key, JSON.stringify(set.concat(seq).slice(-500)));
} }
} catch (_) {} } catch (_) {}
} }
@@ -2508,7 +2529,7 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
super(props); super(props);
this.state = { this.state = {
progress: 0, progress: 0,
voicePlayed: isVoicePlayed(props.topic, props.seq) voicePlayed: isVoicePlayed(props.tinode.getCurrentUserID(), props.topic, props.seq)
}; };
if (props.uploader) { if (props.uploader) {
props.uploader.onProgress = this.handleProgress.bind(this); props.uploader.onProgress = this.handleProgress.bind(this);
@@ -2662,7 +2683,7 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
if (this.state.voicePlayed) { if (this.state.voicePlayed) {
return; return;
} }
markVoicePlayed(this.props.topic, this.props.seq); markVoicePlayed(this.props.tinode.getCurrentUserID(), this.props.topic, this.props.seq);
this.setState({ this.setState({
voicePlayed: true voicePlayed: true
}); });
@@ -3531,6 +3552,7 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
this.startHoldRecording = this.startHoldRecording.bind(this); this.startHoldRecording = this.startHoldRecording.bind(this);
this.handleHoldMove = this.handleHoldMove.bind(this); this.handleHoldMove = this.handleHoldMove.bind(this);
this.endHoldRecording = this.endHoldRecording.bind(this); this.endHoldRecording = this.endHoldRecording.bind(this);
this.handleHoldVisibilityChange = this.handleHoldVisibilityChange.bind(this);
this.toggleEmojiPanel = this.toggleEmojiPanel.bind(this); this.toggleEmojiPanel = this.toggleEmojiPanel.bind(this);
this.insertEmoji = this.insertEmoji.bind(this); this.insertEmoji = this.insertEmoji.bind(this);
this.toggleAttachPanel = this.toggleAttachPanel.bind(this); this.toggleAttachPanel = this.toggleAttachPanel.bind(this);
@@ -3562,6 +3584,8 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
window.removeEventListener('mouseup', this.endHoldRecording); window.removeEventListener('mouseup', this.endHoldRecording);
window.removeEventListener('touchmove', this.handleHoldMove); window.removeEventListener('touchmove', this.handleHoldMove);
window.removeEventListener('touchend', this.endHoldRecording); window.removeEventListener('touchend', this.endHoldRecording);
window.removeEventListener('blur', this.endHoldRecording);
document.removeEventListener('visibilitychange', this.handleHoldVisibilityChange);
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.messageEditArea) { if (this.messageEditArea) {
@@ -3583,6 +3607,10 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
cardPicker: null, cardPicker: null,
favPicker: null favPicker: null
}); });
} else if (!prevProps.disabled && this.props.disabled && this.state.audioRec) {
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 || '';
this.setState({ this.setState({
@@ -3910,7 +3938,7 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_6__.forwardFavorites)([item.favorite_id], [this.props.topicName]).then(result => { (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_6__.forwardFavorites)([item.favorite_id], [this.props.topicName]).then(result => {
const failed = (result && result.items || []).filter(r => r.status === 'failed'); const failed = (result && result.items || []).filter(r => r.status === 'failed');
if (failed.length && this.props.onError) { if (failed.length && this.props.onError) {
this.props.onError('发送失败:' + (failed[0].reason || '未知原因'), 'err'); this.props.onError('发送失败:' + ((0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_6__.translateReason)(failed[0].reason) || failed[0].reason || '未知原因'), 'err');
} }
}).catch(err => { }).catch(err => {
if (this.props.onError) { if (this.props.onError) {
@@ -3936,6 +3964,13 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
passive: false passive: false
}); });
window.addEventListener('touchend', this.endHoldRecording); window.addEventListener('touchend', this.endHoldRecording);
window.addEventListener('blur', this.endHoldRecording);
document.addEventListener('visibilitychange', this.handleHoldVisibilityChange);
}
handleHoldVisibilityChange() {
if (document.hidden) {
this.endHoldRecording();
}
} }
handleHoldMove(e) { handleHoldMove(e) {
const y = 'touches' in e ? e.touches[0] && e.touches[0].clientY : e.clientY; const y = 'touches' in e ? e.touches[0] && e.touches[0].clientY : e.clientY;
@@ -3954,6 +3989,8 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
window.removeEventListener('mouseup', this.endHoldRecording); window.removeEventListener('mouseup', this.endHoldRecording);
window.removeEventListener('touchmove', this.handleHoldMove); window.removeEventListener('touchmove', this.handleHoldMove);
window.removeEventListener('touchend', this.endHoldRecording); window.removeEventListener('touchend', this.endHoldRecording);
window.removeEventListener('blur', this.endHoldRecording);
document.removeEventListener('visibilitychange', this.handleHoldVisibilityChange);
this.holdStartY = null; this.holdStartY = null;
const fakeEvent = { const fakeEvent = {
preventDefault: () => {} preventDefault: () => {}
File diff suppressed because one or more lines are too long
@@ -110,6 +110,7 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
} }
} }
componentWillUnmount() { componentWillUnmount() {
this._unmounted = true;
this.startedOn = null; this.startedOn = null;
if (this.stream) { if (this.stream) {
this.cleanUp(); this.cleanUp();
@@ -241,6 +242,10 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
this.canvasHeight = this.canvasRef.current.height; this.canvasHeight = this.canvasRef.current.height;
} }
initMediaRecording(stream) { initMediaRecording(stream) {
if (this._unmounted) {
stream.getTracks().forEach(t => t.stop());
return;
}
this.stream = stream; this.stream = stream;
AUDIO_MIME_TYPES.some(mimeType => { AUDIO_MIME_TYPES.some(mimeType => {
if (MediaRecorder.isTypeSupported(mimeType)) { if (MediaRecorder.isTypeSupported(mimeType)) {
File diff suppressed because one or more lines are too long