修复全页面走查发现的 P0 前端问题(第一批)

八个 agent 对 11 章逐页对照设计稿+后端源码后,先修其中"功能不可用/崩溃/谎报成功"级别的:

崩溃与数据错误
- content-library-view: Highlight 按 [start,len] 数组解构后端返回的 {start,end} 对象,
  对象不可迭代会直接抛 TypeError,任何文本搜索命中都让整个结果区渲染崩掉。服务端偏移还叠加
  两个问题(Go 字节下标 vs JS UTF-16;偏移基准是全文但 snippet 会被开窗截断),改为在展示
  文本上本地匹配关键词。
- content-library-view: firstAttachment 用 Drafty.attachments,SDK 里硬过滤 tp=='EX'
  只认文件附件,图片(IM)/视频(VD)/语音(AU) 一个都取不到——收藏缩略图、语音播放源、聊天记录
  图片视频宫格全部拿不到资源。改用 Drafty.entities 按类型分派(chat-message.jsx 已有先例)。
- message-center-view: 系统通知的机器码兜底会把真实数据吞掉(日报提醒的 summary 就是日期
  字符串 "2026-08-20",会议通知的 summary 是标题),改为查表命中才替换。
- message-center-view: 会话列表漏了 avatar 字段,每行都只能落到兜底图标;同文件
  frequentContacts() 本来就是对的,照它补上。

功能不可用
- auth-onboarding-view: 首次开启青少年模式是死路——设完密码丢弃了服务端返回的
  enable_action_token,从不调 enableYouthMode,用户走完短信验证+设密码后模式仍是关的。
  现在拿着这个 token 把最后一步做完。
- group-management-view: "投诉/举报"是个 onToast('暂未开放') 死桩,而 target_type='group'
  这条链路早就通了。接上真实举报页。
- tinode-web: 刷新/深链会被弹回首页。两个原因叠加:初始化白名单还是上游 Tinode 的
  ['cred','reset','register'];handleLoginSuccessful 无条件把 hash 改写成 workbench,
  刷新触发的自动登录也走这条。抽出 SIDE_PANEL_VIEWS 常量复用,并区分登录流程与自动登录。
- widgets/meta-message: deleted 分支从未实现,撤回一条消息后时间线上什么都不剩。

谎报成功 / 判定错误
- tinode-web + personal-settings-view: 改密码不返回 Promise,服务端拒绝时照样弹"密码已更新"。
- group-management-view: 禁言批量读 result.succeeded,但该接口返回的是 {topic, items},
  succeeded 恒为 undefined,不管成没成都弹"设置成功"。两个接口结构不同,分开判定。
- workplace-collaboration-view: 会议取消状态写成 'cancelled',后端是 'canceled' 单 l,
  会议取消后"取消会议"按钮还挂着。

样式与显示
- base.css: 删掉旧版 .organization-department 的 padding-left !important,它压过按层级算出的
  内联缩进,导致部门行全平、只有人员行缩进。
- base.css: 补 .youth-password-gate(原来 0 条规则),PIN 框缺 position:relative 和居中。
- security-support-view: shield_lock 不在经典 Material Icons 里(只有 Symbols),会显示成
  字面文本;.risk-info-page 也是 0 条规则,整页渲染成裸项目符号列表。改用 verified_user 并
  补上 .risk-dialog。
- contact-management-view: 好友资料页性别直接输出后端枚举,显示成 "male"。
- workplace-collaboration-view: 层级标注两条路径两种格式(树形深三层变"3级"、搜索硬传"三"),
  统一成一个 helper。

已记录不在前端修的后端缺口:recallMessage 的 version 只能传 1,因为所有消息读取接口
(messagePayload/messageSearchPayload/mediaMessagePayload/favoriteSnapshot)都不下发 version,
冲突错误里也不回传真实值,被编辑过的消息撤回必然失败。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-03 18:31:26 +08:00
co-authored by Claude Opus 5
parent c4189646f4
commit 5a07bb4e3b
17 changed files with 365 additions and 138 deletions
+25 -15
View File
@@ -653,21 +653,11 @@
color: #8b929b;
}
.organization-root > .material-icons:first-child {
color: #2f73d2;
}
.organization-department {
padding-left: 1.75rem !important;
}
.organization-department > .material-icons:first-child {
color: #e79a32;
}
.organization-members button {
padding-left: 2.5rem;
}
/* 旧版组织架构的 .organization-root / .organization-department / .organization-members 三条规则
已删除:前两个类名 JSX 里早就没人用了,而 .organization-department 那条
`padding-left: 1.75rem !important` 会压过新版按层级算出来的内联 paddingLeft,
导致一级/二级/三级部门行左边距完全一样、树形层级看不出来(人员行没有 !important,缩进正常,
结果是"部门全平、人员缩进",比不缩进更乱)。新版完整规则在下面 .organization-department 那一块。 */
.organization-members img {
width: 2.5rem;
@@ -6440,6 +6430,26 @@ div.toast.show {
font-size: 1.8rem;
}
/* 青少年模式的 PIN 页复用了下面的 .auth-pin-boxes / .auth-pin-input / .auth-primary,
但它自己的容器类 .youth-password-gate 原来在整个 base.css 里一条规则都没有。缺的正是
.auth-pin-content 提供的两件事:position:relative(.auth-pin-input 是 position:absolute
+ top:11rem,没有定位祖先就会跑到更外层去,点 PIN 框重新聚焦会点空)和 align-items:center
(.auth-pin-boxes 是 grid,父级的 text-align:center 对它无效,不会居中)。 */
.youth-password-gate {
display: flex;
position: relative;
flex-direction: column;
align-items: center;
width: min(100%, 32rem);
margin: 0 auto;
padding: 3rem 2rem 2rem;
}
.youth-password-gate .auth-primary {
width: min(100%, 24rem);
margin-top: 3rem;
}
.auth-pin-boxes {
display: grid;
grid-template-columns: repeat(4, 4rem);
+5
View File
@@ -449,6 +449,11 @@ export const getConversationSettings = topic => imGet('/conversations/' + encode
export const updateConversationSettings = (topic, body) => imPatch('/conversations/' + encodeURIComponent(topic) + '/settings', body);
// -- Messages (Ch.4/7). --
// version 只能默认传 1:服务端做的是乐观锁校验(tenant_adapter.go:1963 要求 msg.Version 与传入值
// 完全相等,否则 ErrDuplicate),但**没有任何消息读取接口下发过 version 字段**——messagePayload /
// messageSearchPayload / mediaMessagePayload / favoriteSnapshot 全都没有,冲突时的错误里也不回传
// 真实值。所以前端拿不到正确的版本号,被编辑过(version>1)的消息撤回必然失败。这是后端缺口,
// 已记入《IM-后端问题清单.md》,不在前端绕。
export const recallMessage = (topic, seq, version = 1, reason = 'mistake') =>
imPost('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/recall', {version, reason});
export const reviseMessage = (topic, seq, content, version = 1) =>
+25 -3
View File
@@ -4,7 +4,8 @@ import HashNavigation from '../lib/navigation.js';
import {
resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile,
requestVerificationCode, verifyRegisterCode, verifyPasswordResetCode, confirmPasswordReset,
createSecurityChallenge, verifySecurityChallenge, getRealnameStatus, submitRealname, setIndependentPassword
createSecurityChallenge, verifySecurityChallenge, getRealnameStatus, submitRealname, setIndependentPassword,
getYouthMode, enableYouthMode
} from '../lib/im-api.js';
const USERNAME_RE = /^[a-z0-9]{1,10}$/;
@@ -674,9 +675,30 @@ function IndependentPassword({authenticated}) {
if (!actionToken) { setError('身份验证已过期,请返回重新验证'); return; }
setBusy(true);
try {
await setIndependentPassword(pin, 'youth_mode', actionToken);
const result = await setIndependentPassword(pin, 'youth_mode', actionToken);
sessionStorage.removeItem('im-action-token:' + IDENTITY_CHECK_SCENE);
setError(''); setToast('独立密码设置成功'); setPin('');
setPin('');
// 设密码只是"首次开启青少年模式"这条路的中间一步,不是终点。服务端在这一步就把下一步
// 要用的凭证一起发回来了(enable_action_token + next_action:"enable_youth_mode",
// 见 server/im_http.go:6178-6189)。原来这里把它整个丢掉、只弹一句"设置成功"就停在
// PIN 页——用户走完短信验证+设密码,青少年模式其实还是关的,得自己摸回设置页再输一遍。
// 现在拿着这个 token 把最后一步做完。
const enableToken = result && result.enable_action_token;
if (enableToken) {
try {
const current = await getYouthMode();
await enableYouthMode(current.policy_version, enableToken);
setError(''); setToast('青少年模式已开启');
window.setTimeout(() => navigate('youth-mode-on'), 600);
return;
} catch (enableErr) {
// 密码确实设成功了,只是自动开启这一步没成,如实说明并把人送回青少年模式页手动开
setError('独立密码已设置,但自动开启未成功:' + (enableErr.message || '请返回重试'));
window.setTimeout(() => navigate('youth-mode-off'), 1200);
return;
}
}
setError(''); setToast('独立密码设置成功');
} catch (err) {
setError(err.message || '设置失败,请返回重新验证身份');
} finally {
+6 -1
View File
@@ -191,6 +191,9 @@ function FriendRequests({onToast}) {
</main></div>;
}
// 与 personal-settings-view.jsx 的 GENDER_OPTIONS 一一对应,两处写的是同一套后端枚举
const GENDER_LABELS = {male: '男', female: '女', other: '其他'};
// `GET /users/{id}/profile` (getUserProfile) 403s for anyone who isn't already a friend, by design
// (server's default `profile_visibility` is "friends"-only, server/im_http.go's imPrivacyAllowsProfile).
// But the two places that hand you a *stranger* to look at -- an account search hit and a scanned
@@ -357,7 +360,9 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
<section><Avatar person={profile} /><div><h2>{profile.display_name}</h2>
{profile.public_account ? <p>用户名:{profile.public_account}</p> : null}
{profile.region ? <p>地区:{profile.region}</p> : null}</div><Icon>qr_code</Icon></section>
<dl>{profile.gender ? <div><dt>性别</dt><dd>{profile.gender}</dd></div> : null}
{/* 后端存的是 male/female/other 枚举(personal-settings-view.jsx:203 的 GENDER_OPTIONS 写进去的),
原来直接原样输出,资料页上显示的是英文 "male"。 */}
<dl>{profile.gender ? <div><dt>性别</dt><dd>{GENDER_LABELS[profile.gender] || profile.gender}</dd></div> : null}
<div><dt>个性签名</dt><dd className={profile.signature ? '' : 'muted'}>{profile.signature || '此人很懒什么都没留下~'}</dd></div>
</dl>
{isFriend ? <div className="people-profile-actions">
+37 -10
View File
@@ -62,11 +62,23 @@ function Search({value, onChange, placeholder = '搜索'}) { return <label class
function draftyContentOf(content) {
return Drafty.isValid(content) ? content : null;
}
// 必须用 Drafty.entities 而不是 Drafty.attachments:后者在 SDK 里硬过滤 `ent.tp == 'EX'`
// (tinode.dev.js:2538),只认「文件附件」这一种实体。而本应用发图片/视频/语音走的分别是
// insertImage(IM)/insertVideo(VD)/appendAudio(AU),在 attachments 下一个都取不到——收藏的图片视频
// 缩略图、语音收藏的播放源、聊天记录的图片视频宫格因此全部拿不到资源。entities 的回调第三参给出
// tp,可以按类型分派,chat-message.jsx:139 用的就是这个。
const MEDIA_ENTITY_TYPES = ['IM', 'VD', 'AU', 'EX'];
function firstAttachment(content) {
const drafty = draftyContentOf(content);
if (!drafty) return null;
let found = null;
Drafty.attachments(drafty, att => { if (!found) found = att; });
Drafty.entities(drafty, (data, _index, tp) => {
if (!found && data && MEDIA_ENTITY_TYPES.includes(tp)) {
found = data;
return true; // 命中第一个就停,entities 的回调返回 true 表示中断遍历
}
return false;
});
return found;
}
// Drafty.getDownloadUrl returns either an already-usable blob: URL (small inband attachment, .val) or
@@ -388,14 +400,29 @@ function DownloadPage({hashParams, tinode, onToast}) {
</div>;
}
function Highlight({text, ranges}) {
if (!ranges || !ranges.length) return text;
const parts = []; let last = 0;
ranges.forEach(([start, len], i) => {
parts.push(text.slice(last, start), <mark key={i}>{text.slice(start, start + len)}</mark>);
last = start + len;
});
parts.push(text.slice(last));
// 不用服务端返回的 matches 偏移,改为在展示文本上本地匹配关键词。服务端那份偏移有三层问题,
// 全都对不上前端要高亮的这段文字:
// 1. 结构是 [{start,end}] 对象数组(im_http.go:9151),按 [start,len] 数组解构会直接抛
// TypeError(对象不可迭代),整个搜索结果渲染崩掉;
// 2. start/end 是 Go 的字节下标(strings.Index + len(query)),中文一字三字节,跟 JS 的
// UTF-16 下标永远错位;
// 3. 偏移基准是完整原文,但 snippet 在超过 120 字时会被开窗截断(im_http.go:9128-9139),
// 两者基准不一致——而且那段开窗自己也混用了字节下标和 rune 切片。
// 本地匹配没有这些歧义:所见即所高亮。
function Highlight({text, query}) {
const needle = (query || '').trim();
if (!needle || !text) return text;
const parts = [];
const haystack = text.toLowerCase();
const lowered = needle.toLowerCase();
let last = 0;
for (let at = haystack.indexOf(lowered); at !== -1; at = haystack.indexOf(lowered, at + lowered.length)) {
if (at > last) parts.push(text.slice(last, at));
parts.push(<mark key={at}>{text.slice(at, at + needle.length)}</mark>);
last = at + needle.length;
}
if (!parts.length) return text;
if (last < text.length) parts.push(text.slice(last));
return parts;
}
@@ -494,7 +521,7 @@ function History({view, hashParams, myUserId, onToast}) {
view === 'history-search' ? <section className="library-message-results">
{list.map(item => <button type="button" key={item.seq} onClick={() => jump(item.seq)}>
<Avatar item={{}} /><span><strong>{nameOf(item.from)}</strong><p>{item.type === 'text' ?
<Highlight text={item.snippet || ''} ranges={item.matches} /> : (TYPE_LABEL[item.type] || item.snippet || '')}</p>
<Highlight text={item.snippet || ''} query={query} /> : (TYPE_LABEL[item.type] || item.snippet || '')}</p>
<small>{shortDateFormat(new Date(item.ts))}</small></span><Icon>chevron_right</Icon>
</button>)}
</section> :
+28 -5
View File
@@ -396,7 +396,17 @@ function GroupSettings({topic, myUserId, tinode, onToast}) {
const t = tinode ? tinode.getTopic(topic) : null;
(t ? t.delMessagesAll(false) : Promise.resolve()).then(() => onToast('聊天记录已清除')).catch(err => onToast(err.message || '操作失败'));
}}><span>清空聊天记录</span><Icon>chevron_right</Icon></button>
<button type="button" onClick={() => onToast('举报入口暂未开放')}><span>投诉/举报</span><Icon>chevron_right</Icon></button>
{/* target_type='group' 这条链路早就是通的(tinode-web.jsx 的 handleReportTopic 就在用),
这里原来一直是个"暂未开放"的死桩。跟 contact-management-view.jsx 的好友举报入口一样,
举报页要的是 target_type/target_id/target_name 三个参数,本文件的 navigate 辅助函数
只会写 topic,所以直接拼 hash。 */}
<button type="button" onClick={() => {
let hash = HashNavigation.setUrlSidePanel('', 'abuse-report-reasons');
hash = HashNavigation.addUrlParam(hash, 'target_type', 'group');
hash = HashNavigation.addUrlParam(hash, 'target_id', topic);
hash = HashNavigation.addUrlParam(hash, 'target_name', (group && group.name) || topic);
HashNavigation.navigateTo(hash);
}}><span>投诉/举报</span><Icon>chevron_right</Icon></button>
</section>
<button type="button" className="group-danger" onClick={leave}>退出群组</button>
</main>
@@ -659,8 +669,19 @@ function MemberPicker({type, topic, onToast}) {
setBusy(true);
const call = mute ? muteGroupMembers(topic, selected.map(user_id => ({user_id}))) : addGroupAdmins(topic, selected);
call.then(result => {
const failed = result && result.succeeded ? result.succeeded.filter(s => !s.ok) : [];
setToast(failed.length ? `成功设置 ${selected.length - failed.length} 人,${failed.length} 人设置失败` : '设置成功');
// 两个批量接口的响应结构不一样,不能用同一套判定:
// - 添加管理员 POST .../admins/batch 返回 succeeded[],每项带 ok/reason(im_http.go:5377-5392)
// - 添加禁言 POST .../mutes 返回 {topic, items},只有成功的才进 items,
// 非法 uid 会被静默 continue(im_http.go:5486-5488),根本没有 succeeded 字段
// 原来两条路都读 succeeded,禁言那条恒为 undefined → failed 恒为空 → 不管实际成没成都弹"设置成功"。
let failedCount;
if (mute) {
const done = (result && Array.isArray(result.items)) ? result.items.length : selected.length;
failedCount = Math.max(selected.length - done, 0);
} else {
failedCount = (result && Array.isArray(result.succeeded)) ? result.succeeded.filter(s => !s.ok).length : 0;
}
setToast(failedCount ? `成功设置 ${selected.length - failedCount} 人,${failedCount} 人设置失败` : '设置成功');
window.setTimeout(() => navigate(back, topic), 900);
}).catch(err => onToast(err.message || '设置失败')).finally(() => setBusy(false));
};
@@ -867,8 +888,10 @@ function GroupDescriptionEditor({topic, onToast}) {
return <div className="group-page">
<Header title="群简介" back="group-profile" backTopic={topic} right="发布" onRight={publish} />
<main className="group-edit-text">
<textarea autoFocus value={value} maxLength={225} placeholder="请输入群简介内容" onChange={e => setValue(e.target.value)} />
<small>{value.length}/225</small>
{/* 上限跟后端硬限对齐(im_http.go:4667 超过 200 rune 直接 400 field=description)。
参考图标的是 0/225,但那个数字后端不接受——按 225 放行会让用户写满再被服务端打回。 */}
<textarea autoFocus value={value} maxLength={200} placeholder="请输入群简介内容" onChange={e => setValue(e.target.value)} />
<small>{value.length}/200</small>
</main>
</div>;
}
+10 -7
View File
@@ -147,6 +147,9 @@ function normalizeConversations(chatList, myUserId) {
summary,
time: formatConversationTime(item.touched),
unread: Math.max(Number(item.unread) || 0, 0),
// 真实头像:跟同文件 frequentContacts() 一样从 public.photo 取,原来漏了这一项,
// 导致会话列表每一行都只能落到下面的兜底图标,永远显示不出头像。
avatar: makeImageUrl(item.public && item.public.photo),
// 没有真实头像时的兜底图标要区分群聊和单聊,否则群聊也画成单人像,列表里分不出哪条是群
icon: isGroup ? 'group' : 'person',
// 在线状态只对单聊有意义:群不存在"在线",传 undefined 让 Avatar 不渲染那个状态点
@@ -310,15 +313,15 @@ const NOTIFICATION_TEXT = {
rejected: '对方拒绝了你的好友申请'
};
// 机器码判定:纯 ASCII 且只由小写字母/数字/点/下划线/连字符组成("accepted"、
// "group.settings.updated" 这类),正常中文文案不会命中。
const MACHINE_CODE = /^[a-z0-9._-]+$/;
function systemNotificationText(item) {
const summary = (item.summary || '').trim();
// 后端给了真正的文案就直接用;只有当它是机器码时才走兜底,避免把未来后端补上的正常文案盖掉。
if (summary && !MACHINE_CODE.test(summary)) return summary;
return NOTIFICATION_TEXT[summary] || NOTIFICATION_TEXT[item.event_type] || '您有一条新的系统通知。';
// 只在查表命中时才替换。后端的 summary 有些确实是机器码(好友申请通过时是 "accepted"),
// 但也有些就是真实数据——日报提醒的 summary 是纯日期字符串
// (db/mysql/tenant_adapter.go:4917 传 date.Format("2006-01-02")),会议通知的 summary 是会议标题。
// 一旦按"看起来像机器码就吞掉"来判断,这些真实内容会被换成一句无意义的兜底文案。
const mapped = NOTIFICATION_TEXT[summary] || NOTIFICATION_TEXT[item.event_type];
if (mapped) return mapped;
return summary || '您有一条新的系统通知。';
}
function SystemMessages({authenticated}) {
+7
View File
@@ -42,6 +42,12 @@ const SHOW_GO_TO_LAST_DIST = 100;
const FETCH_PAGE_TRIGGER = 40;
const messages = defineMessages({
// 已存在于 src/i18n/*.json(zh: "消息已删除"),这里只是注册引用,不是新增待翻译串
message_deleted: {
id: 'message_deleted',
defaultMessage: 'message deleted',
description: 'Placeholder shown in the timeline where a message was recalled or deleted',
},
online_now: {
id: 'online_now',
defaultMessage: 'online now',
@@ -1997,6 +2003,7 @@ class MessagesView extends React.Component {
messageNodes.push(
<MetaMessage
deleted={true}
deletedText={this.props.intl.formatMessage(messages.message_deleted)}
key={msg.seq} />
);
} else {
+12 -3
View File
@@ -383,13 +383,22 @@ function ChangePassword({onUpdatePassword, onToast}) {
const [oldPwd, setOldPwd] = React.useState('');
const [newPwd, setNewPwd] = React.useState('');
const [confirmPwd, setConfirmPwd] = React.useState('');
const [busy, setBusy] = React.useState(false);
const submit = () => {
if (!oldPwd || newPwd.length < 6) { onToast('请输入原密码,新密码至少6位'); return; }
if (newPwd !== confirmPwd) { onToast('两次输入的新密码不一致'); return; }
if (!onUpdatePassword) { onToast('当前环境不支持修改密码'); return; }
onUpdatePassword(newPwd);
onToast('密码已更新');
window.setTimeout(() => navigate('android-settings'), 500);
if (busy) return;
setBusy(true);
// 等真实结果再报成功。原来是发出去就立刻弹"密码已更新"并跳走,服务端拒绝(密码策略不符等)
// 时用户看到的仍然是成功提示,密码其实没变。
Promise.resolve(onUpdatePassword(newPwd)).then(() => {
onToast('密码已更新');
window.setTimeout(() => navigate('android-settings'), 500);
}).catch(err => {
onToast((err && err.message) || '修改失败,请重试');
setBusy(false);
});
};
return <div className="personal-page profile-editor-page"><Header title="修改密码" back="android-settings" action="完成" onAction={submit} /><main>
<label><span>原密码</span><input type="password" value={oldPwd} onChange={e => setOldPwd(e.target.value)} placeholder="请输入当前密码" /></label>
+7 -2
View File
@@ -43,8 +43,13 @@ function Toast({message, onDone}) {
// counterpart: the pre-send URL risk check below (RiskConfirmDialog), which DOES have a real backend
// (POST /security/urls/check + POST /security/risk-challenges/{id}/confirm).
function FraudAwareness({secondary}) {
return <div className="support-page risk-warning-page"><Header title="安全提醒" back={secondary ? 'fraud-risk-warning' : 'personal-center'} /><main className="risk-info-page">
<span className="risk-symbol"><Icon>{secondary ? 'warning_amber' : 'shield_lock'}</Icon></span>
// 这里必须带上 risk-dialog:本页的 ul/li/aside/.risk-emphasis/button.primary 全部样式都挂在
// base.css 的 .risk-dialog 下(14 条规则),而 .risk-info-page 在 base.css 里一条规则都没有——
// 只写 risk-info-page 的话整页会渲染成带原生项目符号的裸列表。
// 图标名也修正了:shield_lock 只存在于 Material Symbols,index.html 加载的是经典 Material Icons,
// 那套字体里没有这个字形,会原样显示 "shield_lock" 这串文本。改用仓库已在用的 verified_user。
return <div className="support-page risk-warning-page"><Header title="安全提醒" back={secondary ? 'fraud-risk-warning' : 'personal-center'} /><main className="risk-info-page risk-dialog">
<span className="risk-symbol"><Icon>{secondary ? 'warning_amber' : 'verified_user'}</Icon></span>
{secondary ? <>
<p className="risk-emphasis">您发送的内容可能涉及诈骗风险,请注意甄别。</p>
<p>切勿轻信陌生人的投资理财、兼职刷单、网络贷款等信息,谨防上当受骗。</p>
+74 -48
View File
@@ -101,6 +101,59 @@ const messages = defineMessages({
}
});
// 全部合法的侧栏视图名。抽成模块级常量而不是内联在 handleHashRoute 里,是因为初始化时的
// "要不要保留 hash" 判断也需要同一份名单——那里原来写的是上游 Tinode 遗留的
// ['cred','reset','register'] 三个名字,本工程新增的上百个路由一个都不在里面,导致带任何新路由的
// 地址刷新/深链进入都会被 navigateTo('') 清空 hash 弹回首页。
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-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-list','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'];
class TinodeWeb extends React.Component {
constructor(props) {
super(props);
@@ -431,7 +484,7 @@ class TinodeWeb extends React.Component {
// Parse the hash navigation params.
const parsedNav = HashNavigation.parseUrlHash(window.location.hash);
// Maybe navigate to home screen.
if (!['cred', 'reset', 'register'].includes(parsedNav.path[0])) {
if (!SIDE_PANEL_VIEWS.includes(parsedNav.path[0]) || !parsedNav.path[0]) {
// Save possible topic name.
this.setState({requestedTopic: parsedNav.path[1]});
const path = parsedNav.params && parsedNav.params.cred_done ?
@@ -650,49 +703,7 @@ class TinodeWeb extends React.Component {
if (hash.path && hash.path.length > 0) {
// Left-side panel selector.
if (['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-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-list','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',
''].includes(hash.path[0])) {
if (SIDE_PANEL_VIEWS.includes(hash.path[0])) {
newState.sidePanelSelected = hash.path[0];
newState.sidePanelParams = hash.params;
} else {
@@ -1011,7 +1022,15 @@ class TinodeWeb extends React.Component {
// The new bottom nav only has "workbench" and "messages" -- there's no standalone contacts
// entry point anymore, so landing a fresh login there orphaned the user outside the nav they can
// actually see. Land on the workbench instead (matches the "工作台优先" home screen in the mocks).
let urlHash = HashNavigation.setUrlSidePanel(window.location.hash, 'workbench');
//
// 但只在"真的刚走完登录流程"时才这么跳。这个方法在页面刷新触发自动登录时也会执行,原来无条件
// 改写 hash,等于把用户刷新前所在的页面冲掉——不管停在个人中心、群设置还是收藏,刷新后一律
// 被弹到工作台。现在先看当前 hash 是不是一个有效的、非登录流程的视图,是就原样保留。
const currentView = HashNavigation.parseUrlHash(window.location.hash).path[0];
const cameFromAuthFlow = !currentView || AUTH_FLOW_VIEWS.includes(currentView) ||
!SIDE_PANEL_VIEWS.includes(currentView);
let urlHash = cameFromAuthFlow ?
HashNavigation.setUrlSidePanel(window.location.hash, 'workbench') : window.location.hash;
if (goToTopic) {
urlHash = HashNavigation.setUrlTopic(urlHash, goToTopic);
}
@@ -1739,10 +1758,17 @@ class TinodeWeb extends React.Component {
handleUpdatePasswordRequest(password) {
this.handleError();
if (password) {
this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password)
.catch(err => this.handleError(err.message, 'err'));
if (!password) {
return Promise.reject(new Error('密码不能为空'));
}
// 必须把 Promise 返回出去:调用方(personal-settings-view 的修改密码页)要靠它判断成败。
// 原来这里只在内部 catch 掉、什么都不返回,调用方拿到 undefined,于是密码策略被服务端拒绝时
// 用户照样看到"密码已更新"并被跳走。跟之前修过的 handleDeleteAccount 是同一类问题。
return this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password)
.catch(err => {
this.handleError(err.message, 'err');
throw err;
});
}
handleChangePermissions(topicName, mode, uid) {
+11 -4
View File
@@ -183,12 +183,17 @@ function ImgFromFile({fileId, className}) {
// hollow diamond / (expanded) = filled blue diamond, level 3+ (a person, not a sub-department) =
// hollow circle. The backend never returns a depth field (confirmed against organizationDepartmentPayload),
// so depth is purely how far this node is from the root in the client's own recursion.
// 层级标注统一用中文数字。原来树形分支只手写到"二级"、更深就退化成"3级",
// 而搜索分支又硬传字符串 "三",同一个标注两条路径两种格式。
const LEVEL_NUMERALS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
const levelLabel = n => LEVEL_NUMERALS[n - 1] || String(n);
function DeptRow({dept, depth, expanded, onToggle}) {
const icon = depth === 0 ? 'business' : 'diamond';
return <button type="button" className={`organization-department depth-${depth}${expanded ? ' expanded' : ''}`}
style={{paddingLeft: `${1 + depth * 1.1}rem`}} onClick={onToggle}>
<Icon>{icon}</Icon>
<span><strong>{dept.name}{typeof dept.member_count === 'number' ? `${dept.member_count}人` : ''}({depth === 0 ? '一级' : depth === 1 ? '二级' : `${depth + 1}级`})</strong></span>
<span><strong>{dept.name}{typeof dept.member_count === 'number' ? `${dept.member_count}人` : ''}({levelLabel(depth + 1)}级)</strong></span>
<Icon>{expanded ? 'expand_more' : 'chevron_right'}</Icon>
</button>;
}
@@ -206,7 +211,7 @@ function PersonRow({person, depth, isMe, level}) {
return <button type="button" className={`organization-person${isMe ? ' me' : ''}`}
style={{paddingLeft: `${1 + depth * 1.1}rem`}} onClick={() => navigate('group-member-profile', {uid: person.user_id})}>
<Icon>radio_button_unchecked</Icon>
<span><strong>{person.display_name}({level}级)</strong></span>
<span><strong>{person.display_name}({levelLabel(level)}级)</strong></span>
<Icon>chevron_right</Icon>
</button>;
}
@@ -390,7 +395,9 @@ function MeetingDetail({hashParams, onToast}) {
<div><span><b>{counts.accepted}</b><small>已接受</small></span><span><b>{counts.pending}</b><small>待响应</small></span><span><b>{counts.declined}</b><small>已拒绝</small></span></div>
<footer>
{meeting.permissions && meeting.permissions.can_remind ? <button type="button" disabled={busy} onClick={remind}><Icon>notifications</Icon>提醒未响应</button> : null}
{meeting.permissions && meeting.permissions.can_cancel && meeting.state !== 'cancelled' ? <button type="button" className="danger" disabled={busy} onClick={cancel}>取消会议</button> : null}
{/* 后端常量是 "canceled" 单 l(store/types/meetings.go:7),原来写成 "cancelled" 永远不相等,
会议取消之后"取消会议"按钮还挂在那儿 */}
{meeting.permissions && meeting.permissions.can_cancel && meeting.state !== 'canceled' ? <button type="button" className="danger" disabled={busy} onClick={cancel}>取消会议</button> : null}
</footer>
</section> : null}
</main>
@@ -503,7 +510,7 @@ function OrganizationDirectory({myUserId}) {
{searchResults.length === 0 ? <p className="organization-loading">未找到匹配的成员</p> :
searchResults.map((item, idx) => item.type === 'department' ?
<SearchDeptRow key={'d' + (item.department && item.department.department_id || idx)} dept={item.department} /> :
<PersonRow key={'u' + (item.user && item.user.user_id || idx)} person={item.user} depth={0} level="三"
<PersonRow key={'u' + (item.user && item.user.user_id || idx)} person={item.user} depth={0} level={3}
isMe={item.user && (item.user.user_id === myUserId || 'usr' + item.user.user_id === myUserId)} />)}
</section> :
<section className="organization-tree">
+6
View File
@@ -14,6 +14,12 @@ export default class MetaMessage extends React.PureComponent {
// A date badge.
content = <>{this.props.date}</>
bubbleClass += ' date';
} else if (this.props.deleted) {
// 撤回/删除占位。messages-view.jsx 对 msg.hi 的消息一直在传 deleted={true},但这里从来
// 没有处理过这个分支,于是整个组件走到下面的 `return null` —— 撤回一条消息之后时间线上
// 什么都不剩,看不出这里曾经有过一条消息。文案复用已有的 message_deleted("消息已删除")。
content = <i className="gray">{this.props.deletedText || '消息已删除'}</i>;
bubbleClass += ' date';
}
if (!content) {
+97 -37
View File
@@ -6638,11 +6638,26 @@ function IndependentPassword({
}
setBusy(true);
try {
await (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_2__.setIndependentPassword)(pin, 'youth_mode', actionToken);
const result = await (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_2__.setIndependentPassword)(pin, 'youth_mode', actionToken);
sessionStorage.removeItem('im-action-token:' + IDENTITY_CHECK_SCENE);
setPin('');
const enableToken = result && result.enable_action_token;
if (enableToken) {
try {
const current = await (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_2__.getYouthMode)();
await (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_2__.enableYouthMode)(current.policy_version, enableToken);
setError('');
setToast('青少年模式已开启');
window.setTimeout(() => navigate('youth-mode-on'), 600);
return;
} catch (enableErr) {
setError('独立密码已设置,但自动开启未成功:' + (enableErr.message || '请返回重试'));
window.setTimeout(() => navigate('youth-mode-off'), 1200);
return;
}
}
setError('');
setToast('独立密码设置成功');
setPin('');
} catch (err) {
setError(err.message || '设置失败,请返回重新验证身份');
} finally {
@@ -7690,6 +7705,11 @@ function FriendRequests({
}, void 0, true)]
}, void 0, true);
}
const GENDER_LABELS = {
male: '男',
female: '女',
other: '其他'
};
function seedProfile(item) {
if (!item || !item.user_id) return;
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)('profile-seed:' + item.user_id, {
@@ -8035,7 +8055,7 @@ function Profile({
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("dt", {
children: "性别"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("dd", {
children: profile.gender
children: GENDER_LABELS[profile.gender] || profile.gender
}, void 0, false)]
}, void 0, true) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("dt", {
@@ -8684,12 +8704,17 @@ function Search({
function draftyContentOf(content) {
return tinode_sdk__WEBPACK_IMPORTED_MODULE_1__.Drafty.isValid(content) ? content : null;
}
const MEDIA_ENTITY_TYPES = ['IM', 'VD', 'AU', 'EX'];
function firstAttachment(content) {
const drafty = draftyContentOf(content);
if (!drafty) return null;
let found = null;
tinode_sdk__WEBPACK_IMPORTED_MODULE_1__.Drafty.attachments(drafty, att => {
if (!found) found = att;
tinode_sdk__WEBPACK_IMPORTED_MODULE_1__.Drafty.entities(drafty, (data, _index, tp) => {
if (!found && data && MEDIA_ENTITY_TYPES.includes(tp)) {
found = data;
return true;
}
return false;
});
return found;
}
@@ -9306,18 +9331,23 @@ function DownloadPage({
}
function Highlight({
text,
ranges
query
}) {
if (!ranges || !ranges.length) return text;
const needle = (query || '').trim();
if (!needle || !text) return text;
const parts = [];
const haystack = text.toLowerCase();
const lowered = needle.toLowerCase();
let last = 0;
ranges.forEach(([start, len], i) => {
parts.push(text.slice(last, start), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("mark", {
children: text.slice(start, start + len)
}, i, false));
last = start + len;
});
parts.push(text.slice(last));
for (let at = haystack.indexOf(lowered); at !== -1; at = haystack.indexOf(lowered, at + lowered.length)) {
if (at > last) parts.push(text.slice(last, at));
parts.push((0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("mark", {
children: text.slice(at, at + needle.length)
}, at, false));
last = at + needle.length;
}
if (!parts.length) return text;
if (last < text.length) parts.push(text.slice(last));
return parts;
}
function HistoryNav({
@@ -9468,7 +9498,7 @@ function History({
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("p", {
children: item.type === 'text' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(Highlight, {
text: item.snippet || '',
ranges: item.matches
query: query
}, void 0, false) : TYPE_LABEL[item.type] || item.snippet || ''
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("small", {
children: (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_4__.shortDateFormat)(new Date(item.ts))
@@ -11148,7 +11178,13 @@ function GroupSettings({
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
onClick: () => onToast('举报入口暂未开放'),
onClick: () => {
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel('', 'abuse-report-reasons');
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'target_type', 'group');
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'target_id', topic);
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'target_name', group && group.name || topic);
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(hash);
},
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
children: "投诉/举报"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Icon, {
@@ -11696,8 +11732,14 @@ function MemberPicker({
user_id
}))) : (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.addGroupAdmins)(topic, selected);
call.then(result => {
const failed = result && result.succeeded ? result.succeeded.filter(s => !s.ok) : [];
setToast(failed.length ? `成功设置 ${selected.length - failed.length} 人,${failed.length} 人设置失败` : '设置成功');
let failedCount;
if (mute) {
const done = result && Array.isArray(result.items) ? result.items.length : selected.length;
failedCount = Math.max(selected.length - done, 0);
} else {
failedCount = result && Array.isArray(result.succeeded) ? result.succeeded.filter(s => !s.ok).length : 0;
}
setToast(failedCount ? `成功设置 ${selected.length - failedCount} 人,${failedCount} 人设置失败` : '设置成功');
window.setTimeout(() => navigate(back, topic), 900);
}).catch(err => onToast(err.message || '设置失败')).finally(() => setBusy(false));
};
@@ -12176,11 +12218,11 @@ function GroupDescriptionEditor({
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("textarea", {
autoFocus: true,
value: value,
maxLength: 225,
maxLength: 200,
placeholder: "请输入群简介内容",
onChange: e => setValue(e.target.value)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", {
children: [value.length, "/225"]
children: [value.length, "/200"]
}, void 0, true)]
}, void 0, true)]
}, void 0, true);
@@ -12660,6 +12702,7 @@ function normalizeConversations(chatList, myUserId) {
summary,
time: formatConversationTime(item.touched),
unread: Math.max(Number(item.unread) || 0, 0),
avatar: (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_4__.makeImageUrl)(item.public && item.public.photo),
icon: isGroup ? 'group' : 'person',
online: isGroup ? undefined : !!item.online,
muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()),
@@ -12983,11 +13026,11 @@ const NOTIFICATION_TEXT = {
accepted: '对方已通过你的好友申请',
rejected: '对方拒绝了你的好友申请'
};
const MACHINE_CODE = /^[a-z0-9._-]+$/;
function systemNotificationText(item) {
const summary = (item.summary || '').trim();
if (summary && !MACHINE_CODE.test(summary)) return summary;
return NOTIFICATION_TEXT[summary] || NOTIFICATION_TEXT[item.event_type] || '您有一条新的系统通知。';
const mapped = NOTIFICATION_TEXT[summary] || NOTIFICATION_TEXT[item.event_type];
if (mapped) return mapped;
return summary || '您有一条新的系统通知。';
}
function SystemMessages({
authenticated
@@ -15009,6 +15052,7 @@ function ChangePassword({
const [oldPwd, setOldPwd] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
const [newPwd, setNewPwd] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
const [confirmPwd, setConfirmPwd] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
const [busy, setBusy] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
const submit = () => {
if (!oldPwd || newPwd.length < 6) {
onToast('请输入原密码,新密码至少6位');
@@ -15022,9 +15066,15 @@ function ChangePassword({
onToast('当前环境不支持修改密码');
return;
}
onUpdatePassword(newPwd);
onToast('密码已更新');
window.setTimeout(() => navigate('android-settings'), 500);
if (busy) return;
setBusy(true);
Promise.resolve(onUpdatePassword(newPwd)).then(() => {
onToast('密码已更新');
window.setTimeout(() => navigate('android-settings'), 500);
}).catch(err => {
onToast(err && err.message || '修改失败,请重试');
setBusy(false);
});
};
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
className: "personal-page profile-editor-page",
@@ -15717,11 +15767,11 @@ function FraudAwareness({
title: "安全提醒",
back: secondary ? 'fraud-risk-warning' : 'personal-center'
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("main", {
className: "risk-info-page",
className: "risk-info-page risk-dialog",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("span", {
className: "risk-symbol",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)(Icon, {
children: secondary ? 'warning_amber' : 'shield_lock'
children: secondary ? 'warning_amber' : 'verified_user'
}, void 0, false)
}, void 0, false), secondary ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.Fragment, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("p", {
@@ -17035,6 +17085,8 @@ const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
description: 'Placeholder for the country selector'
}
});
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-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-list', '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'];
class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component) {
constructor(props) {
super(props);
@@ -17283,7 +17335,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
});
}
const parsedNav = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash);
if (!['cred', 'reset', 'register'].includes(parsedNav.path[0])) {
if (!SIDE_PANEL_VIEWS.includes(parsedNav.path[0]) || !parsedNav.path[0]) {
this.setState({
requestedTopic: parsedNav.path[1]
});
@@ -17494,7 +17546,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
newTopicTabSelected: hash.params.tab
};
if (hash.path && hash.path.length > 0) {
if (['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-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-list', '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', ''].includes(hash.path[0])) {
if (SIDE_PANEL_VIEWS.includes(hash.path[0])) {
newState.sidePanelSelected = hash.path[0];
newState.sidePanelParams = hash.params;
} else {
@@ -17766,7 +17818,9 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
loadSpinnerVisible: false
});
});
let urlHash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, 'workbench');
const currentView = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash).path[0];
const cameFromAuthFlow = !currentView || AUTH_FLOW_VIEWS.includes(currentView) || !SIDE_PANEL_VIEWS.includes(currentView);
let urlHash = cameFromAuthFlow ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, 'workbench') : window.location.hash;
if (goToTopic) {
urlHash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(urlHash, goToTopic);
}
@@ -18413,9 +18467,13 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
}
handleUpdatePasswordRequest(password) {
this.handleError();
if (password) {
this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password).catch(err => this.handleError(err.message, 'err'));
if (!password) {
return Promise.reject(new Error('密码不能为空'));
}
return this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password).catch(err => {
this.handleError(err.message, 'err');
throw err;
});
}
handleChangePermissions(topicName, mode, uid) {
const topic = this.tinode.getTopic(topicName);
@@ -19855,6 +19913,8 @@ function ImgFromFile({
className: className
}, void 0, false);
}
const LEVEL_NUMERALS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
const levelLabel = n => LEVEL_NUMERALS[n - 1] || String(n);
function DeptRow({
dept,
depth,
@@ -19873,7 +19933,7 @@ function DeptRow({
children: icon
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("span", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("strong", {
children: [dept.name, typeof dept.member_count === 'number' ? `${dept.member_count}人` : '', "(", depth === 0 ? '一级' : depth === 1 ? '二级' : `${depth + 1}级`, ")"]
children: [dept.name, typeof dept.member_count === 'number' ? `${dept.member_count}人` : '', "(", levelLabel(depth + 1), "级)"]
}, void 0, true)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(Icon, {
children: expanded ? 'expand_more' : 'chevron_right'
@@ -19916,7 +19976,7 @@ function PersonRow({
children: "radio_button_unchecked"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("span", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("strong", {
children: [person.display_name, "(", level, "级)"]
children: [person.display_name, "(", levelLabel(level), "级)"]
}, void 0, true)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(Icon, {
children: "chevron_right"
@@ -20280,7 +20340,7 @@ function MeetingDetail({
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(Icon, {
children: "notifications"
}, void 0, false), "提醒未响应"]
}, void 0, true) : null, meeting.permissions && meeting.permissions.can_cancel && meeting.state !== 'cancelled' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("button", {
}, void 0, true) : null, meeting.permissions && meeting.permissions.can_cancel && meeting.state !== 'canceled' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("button", {
type: "button",
className: "danger",
disabled: busy,
@@ -20511,7 +20571,7 @@ function OrganizationDirectory({
}, 'd' + (item.department && item.department.department_id || idx), false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(PersonRow, {
person: item.user,
depth: 0,
level: "三",
level: 3,
isMe: item.user && (item.user.user_id === myUserId || 'usr' + item.user.user_id === myUserId)
}, 'u' + (item.user && item.user.user_id || idx), false))
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("section", {
+1 -1
View File
File diff suppressed because one or more lines are too long
+13 -1
View File
@@ -125,6 +125,11 @@ const NOTIFICATION_EXEC_INTERVAL = 300;
const SHOW_GO_TO_LAST_DIST = 100;
const FETCH_PAGE_TRIGGER = 40;
const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
message_deleted: {
id: 'message_deleted',
defaultMessage: 'message deleted',
description: 'Placeholder shown in the timeline where a message was recalled or deleted'
},
online_now: {
id: 'online_now',
defaultMessage: 'online now',
@@ -1840,7 +1845,8 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
}
if (msg.hi) {
messageNodes.push((0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_22__.jsxDEV)(_widgets_meta_message_jsx__WEBPACK_IMPORTED_MODULE_11__["default"], {
deleted: true
deleted: true,
deletedText: this.props.intl.formatMessage(messages.message_deleted)
}, msg.seq, false));
} else {
const thisDate = new Date(msg.ts);
@@ -2837,6 +2843,12 @@ class MetaMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
children: this.props.date
}, void 0, false);
bubbleClass += ' date';
} else if (this.props.deleted) {
content = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)("i", {
className: "gray",
children: this.props.deletedText || '消息已删除'
}, void 0, false);
bubbleClass += ' date';
}
if (!content) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.Fragment, {
File diff suppressed because one or more lines are too long