Convert im_webapp from static mock prototypes to real backend integration

Implements all 11 product chapters (auth, messaging, contacts, groups,
files/favorites, calls, workplace/reports, settings, security) against the
real /v1/im REST API and native Tinode WS protocol, replacing the hardcoded
mock data the ba8f48ee prototype commit shipped with. Adds src/lib/im-api.js
as the REST client, plus supporting fixes across messages-view.jsx and the
attachment/upload/download pipeline (wrong SDK auth headers, group topic
naming, seq/timezone edge cases) found via live end-to-end testing against
the real backend.

Backup checkpoint before reconciling with origin/dev's parallel rewrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-03 11:40:53 +08:00
co-authored by Claude Sonnet 5
parent ba8f48ee83
commit 251c9de87c
36 changed files with 52812 additions and 5199 deletions
+394 -105
View File
@@ -1,6 +1,11 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import {
resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile,
requestVerificationCode, verifyRegisterCode, verifyPasswordResetCode, confirmPasswordReset,
createSecurityChallenge, verifySecurityChallenge, getRealnameStatus, submitRealname, setIndependentPassword
} from '../lib/im-api.js';
const USERNAME_RE = /^[a-z0-9]{1,10}$/;
const PHONE_RE = /^\d{11}$/;
@@ -23,7 +28,7 @@ function AppMark({tenantName}) {
function Header({title, back = 'tenant'}) {
return <header className="auth-header">
<button type="button" className="auth-icon-button" onClick={() => navigate(back)} aria-label="返回">
<button type="button" className="auth-icon-button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回">
<Icon>arrow_back_ios_new</Icon>
</button>
<h1>{title}</h1>
@@ -65,21 +70,42 @@ function Toast({message, onDone}) {
return message ? <div className="auth-toast" role="status"><Icon>check_circle</Icon>{message}</div> : null;
}
function TenantEntry({onTenantSelected}) {
// Shared alert for errors coming back from a real API/WS call (as opposed to local form validation,
// which each form already reports inline per-field).
function FormAlert({text}) {
return text ? <div className="auth-form-alert"><Icon>error_outline</Icon>{text}</div> : null;
}
function TenantEntry({onTenantResolved}) {
const [code, setCode] = React.useState(sessionStorage.getItem('im-tenant-code') || '');
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
const submit = e => {
const submit = async e => {
e.preventDefault();
const value = code.trim();
if (!value || value.length > 64) {
setError(value ? '企业码不能超过 64 个字符' : '请输入企业码');
return;
}
sessionStorage.setItem('im-tenant-code', value);
sessionStorage.setItem('im-tenant-name', value === 'acme' ? '示例企业' : value);
onTenantSelected(value);
navigate('login');
setBusy(true);
setError('');
try {
const data = await resolveTenant(value);
// Kept for the mock UI's own display of the tenant name while on this screen; the durable
// context (read by every subsequent /v1/im/* call and by the {hi} tenant field) is persisted
// by onTenantResolved.
sessionStorage.setItem('im-tenant-code', data.tenant.code || value);
sessionStorage.setItem('im-tenant-name', data.tenant.name || data.tenant.code || value);
onTenantResolved(data, value);
navigate('login');
} catch (err) {
// Per the product spec, tenant-resolve failures are shown as one generic message
// regardless of reason (not found vs. disabled vs. rate limited).
setError('企业码不存在或已停用,请核实后重试');
} finally {
setBusy(false);
}
};
return <div className="auth-landing">
@@ -88,46 +114,57 @@ function TenantEntry({onTenantSelected}) {
<AppMark />
<Field value={code} onChange={e => { setCode(e.target.value); setError(''); }}
placeholder="请输入企业码" autoComplete="organization" maxLength="64" error={error} autoFocus />
<button className="auth-primary" type="submit" disabled={!code.trim()}>进入企业</button>
<button className="auth-primary" type="submit" disabled={!code.trim() || busy}>{busy ? '进入中…' : '进入企业'}</button>
</form>
<button className="auth-bottom-link" type="button" onClick={() => navigate('enterprise-feedback')}>遇到问题?</button>
</div>;
}
function Login({tenantName}) {
const [account, setAccount] = React.useState('');
function Login({tenantName, login, loginDisabled, persist, errorText, errorLevel, onLoginRequest, onPersistenceChange, onError}) {
const [account, setAccount] = React.useState(login || '');
const [password, setPassword] = React.useState('');
const [agreed, setAgreed] = React.useState(false);
const [error, setError] = React.useState('');
const [toast, setToast] = React.useState('');
// The prototype (docs/images/登录页面.png) has no "stay logged in" checkbox -- a consumer IM app
// just keeps you signed in. Opt in silently instead of exposing the old debug-era toggle.
React.useEffect(() => {
if (!persist) {
onPersistenceChange(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Real login failures (wrong password, disabled account, ...) clear the password and keep the account,
// matching the product spec.
React.useEffect(() => {
if (errorLevel === 'err' && errorText) {
setPassword('');
}
}, [errorText, errorLevel]);
const submit = e => {
e.preventDefault();
if (!account.trim() || !password || !agreed) return;
if (password === 'error') {
setPassword('');
setError('账号或密码错误');
} else {
setToast('登录成功');
window.setTimeout(() => navigate('messages-home'), 500);
}
if (!account.trim() || !password || !agreed || loginDisabled) return;
onLoginRequest(account.trim(), password);
};
return <div className="auth-landing auth-login-page">
<button type="button" className="auth-back-link" onClick={() => navigate('tenant')}><Icon>arrow_back_ios_new</Icon>切换企业</button>
<button type="button" className="auth-back-link"
onClick={() => { onError && onError(); navigate('tenant'); }}><Icon>arrow_back_ios_new</Icon>切换企业</button>
<form className="auth-landing-form" onSubmit={submit}>
<AppMark tenantName={tenantName} />
{error ? <div className="auth-form-alert"><Icon>error_outline</Icon>{error}</div> : null}
<Field value={account} onChange={e => { setAccount(e.target.value); setError(''); }}
<FormAlert text={errorLevel === 'err' ? errorText : ''} />
<Field value={account} onChange={e => setAccount(e.target.value)}
placeholder="请输入用户名/手机号" autoComplete="username" />
<PasswordField value={password} onChange={e => { setPassword(e.target.value); setError(''); }}
<PasswordField value={password} onChange={e => setPassword(e.target.value)}
placeholder="请输入密码" autoComplete="current-password" />
<div className="auth-row auth-row-end"><button type="button" className="auth-text-button" onClick={() => navigate('reset')}>忘记密码</button></div>
<button className="auth-primary" type="submit" disabled={!account.trim() || !password || !agreed}>登录</button>
<button className="auth-primary" type="submit" disabled={!account.trim() || !password || !agreed || loginDisabled}>
{loginDisabled ? '登录中…' : '登录'}
</button>
<p className="auth-switch">没有账户? <button type="button" onClick={() => navigate('register')}>马上注册</button></p>
</form>
<Agreement checked={agreed} onChange={setAgreed} />
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
@@ -136,7 +173,9 @@ function EnterpriseRegister() {
const [email, setEmail] = React.useState('');
const [file, setFile] = React.useState(null);
const [errors, setErrors] = React.useState({});
const [submitted, setSubmitted] = React.useState(sessionStorage.getItem('im-enterprise-application-status') === 'received');
const [submitting, setSubmitting] = React.useState(false);
const [submitted, setSubmitted] = React.useState(false);
const [reviewDays, setReviewDays] = React.useState(1);
const [toast, setToast] = React.useState('');
React.useEffect(() => () => file && URL.revokeObjectURL(file.url), [file]);
@@ -149,31 +188,52 @@ function EnterpriseRegister() {
return;
}
if (file) URL.revokeObjectURL(file.url);
setFile({name: selected.name, type: selected.type, url: URL.createObjectURL(selected)});
setFile({name: selected.name, type: selected.type, url: URL.createObjectURL(selected), blob: selected});
setErrors({...errors, file: ''});
};
const submit = e => {
const submit = async e => {
e.preventDefault();
const next = {};
if (!CREDIT_CODE_RE.test(creditCode)) next.creditCode = '请输入 18 位统一社会信用代码';
if (email && !EMAIL_RE.test(email)) next.email = '请输入有效的企业邮箱';
setErrors(next);
if (Object.keys(next).length) return;
setSubmitted(true);
sessionStorage.setItem('im-enterprise-application-status', 'received');
setToast('提交成功,我们会尽快联系你');
setSubmitting(true);
try {
let licenseFileId = '';
if (file) {
const uploaded = await uploadPublicFile(file.blob, 'enterprise_license');
licenseFileId = uploaded.file_id;
}
const data = await submitTenantApplication({
credit_code: creditCode,
enterprise_email: email,
license_file_id: licenseFileId,
tenant_code_hint: sessionStorage.getItem('im-tenant-code') || ''
});
// Prototype (docs/images/企业注册-填写注册信息.png) states this in business days, not hours.
setReviewDays(Math.max(1, Math.round((data.estimated_review_hours || 24) / 24)));
setSubmitted(true);
setToast('提交成功,我们会尽快联系你');
} catch (err) {
setErrors({...next, form: err.message || '提交失败,请稍后重试'});
} finally {
setSubmitting(false);
}
};
return <div className="auth-page">
<Header title="企业注册" />
<main className="auth-content auth-content-narrow">
<ol className="auth-steps">
<li className="active"><span>1</span><b>填写企业信息</b></li>
<li className={submitted ? '' : 'active'}><span>1</span><b>填写企业信息</b></li>
<li><span>2</span><b>确定信息</b></li>
<li><span>3</span><b>提交审核</b></li>
<li className={submitted ? 'active' : ''}><span>3</span><b>提交审核</b></li>
</ol>
<form className="auth-form" onSubmit={submit}>
<FormAlert text={errors.form} />
<label className="auth-label">统一社会信用代码</label>
<Field value={creditCode} onChange={e => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
placeholder="请输入18位统一社会信用代码" maxLength="18" icon="qr_code_scanner" error={errors.creditCode} />
@@ -193,187 +253,397 @@ function EnterpriseRegister() {
<Field icon="mail_outline" value={email} onChange={e => setEmail(e.target.value)}
placeholder="请输入企业邮箱" type="email" error={errors.email} />
<p className="auth-help">用于接收审核结果及企业通知</p>
<button className="auth-primary" type="submit" disabled={submitted || creditCode.length !== 18}>{submitted ? '已提交' : '提交资料'}</button>
<p className="auth-review-time"><Icon>schedule</Icon>资料提交后,预计 1 个工作日完成审核</p>
<button className="auth-primary" type="submit" disabled={submitted || submitting || creditCode.length !== 18}>
{submitted ? '已提交' : submitting ? '提交中…' : '提交资料'}
</button>
<p className="auth-review-time"><Icon>schedule</Icon>资料提交后,预计 {reviewDays} 个工作日完成审核</p>
</form>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
// Per docs/images/企业注册-问题反馈.png: titled "意见反馈", 300-char textarea with the instructions
// as its placeholder (not a separate label), "图片(N/9)" counter-style label, and no contact field.
function EnterpriseFeedback() {
const [description, setDescription] = React.useState('');
const [contact, setContact] = React.useState('');
const [files, setFiles] = React.useState([]);
const [error, setError] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
const [toast, setToast] = React.useState('');
const addFiles = e => {
const selected = Array.from(e.target.files || []).slice(0, 9 - files.length);
const valid = selected.filter(file => file.size <= 10 * 1024 * 1024 && ['image/jpeg', 'image/png', 'image/webp'].includes(file.type));
setFiles(files.concat(valid.map(file => ({name: file.name, url: URL.createObjectURL(file)}))));
setFiles(files.concat(valid.map(file => ({name: file.name, url: URL.createObjectURL(file), blob: file}))));
};
const submit = e => {
const submit = async e => {
e.preventDefault();
const length = description.trim().length;
if (length < 10 || length > 1000) {
setError('问题描述需为 10 至 1000 个字符');
if (length < 10 || length > 300) {
setError('问题描述需为 10 至 300 个字符');
return;
}
setToast('已收到反馈,我们会尽快处理');
files.forEach(item => URL.revokeObjectURL(item.url));
setDescription(''); setContact(''); setFiles([]);
setSubmitting(true);
try {
const screenshotFileIds = [];
for (const item of files) {
const uploaded = await uploadPublicFile(item.blob, 'tenant_entry_feedback');
screenshotFileIds.push(uploaded.file_id);
}
await submitTenantEntryFeedback({
description: description.trim(),
tenant_code_hint: sessionStorage.getItem('im-tenant-code') || '',
screenshot_file_ids: screenshotFileIds
});
setToast('已收到反馈,我们会尽快处理');
files.forEach(item => URL.revokeObjectURL(item.url));
setDescription(''); setFiles([]); setError('');
} catch (err) {
setError(err.message || '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
};
return <div className="auth-page auth-soft-bg">
<Header title="问题反馈" />
<Header title="意见反馈" />
<main className="auth-content auth-content-narrow">
<form className="auth-form" onSubmit={submit}>
<label className="auth-label">问题描述</label>
<textarea value={description} maxLength="1000" onChange={e => { setDescription(e.target.value); setError(''); }}
placeholder="请详细描述遇到的问题,至少输入10个字" className={error ? 'invalid' : ''} />
<div className="auth-counter">{description.length}/1000</div>
<label className="auth-label">问题反馈</label>
<textarea value={description} maxLength="300" onChange={e => { setDescription(e.target.value); setError(''); }}
placeholder="为了更好的帮您解决问题,请在提交问题时上传截图信息,方便我们更快速的帮助您解决问题。" className={error ? 'invalid' : ''} />
<div className="auth-counter">{description.length}/300</div>
{error ? <div className="auth-field-error">{error}</div> : null}
<label className="auth-label">上传截图 <span>最多 9 张</span></label>
<label className="auth-label">图片({files.length}/9)</label>
<div className="auth-screenshot-grid">
{files.map((file, index) => <div className="auth-screenshot" key={file.url}>
<img src={file.url} alt={file.name} />
<button type="button" onClick={() => { URL.revokeObjectURL(file.url); setFiles(files.filter((_, i) => i !== index)); }} aria-label="删除截图"><Icon>close</Icon></button>
</div>)}
{files.length < 9 ? <label className="auth-add-screenshot"><input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={addFiles} /><Icon>add</Icon></label> : null}
{files.length < 9 ? <label className="auth-add-screenshot"><input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={addFiles} /><Icon>photo_camera</Icon><span>点击上传</span></label> : null}
</div>
<label className="auth-label">联系方式 <span>选填</span></label>
<Field value={contact} onChange={e => setContact(e.target.value)} placeholder="手机号或邮箱,便于我们联系您" />
<button className="auth-primary" type="submit" disabled={description.trim().length < 10}>提交反馈</button>
<button className="auth-primary" type="submit" disabled={description.trim().length < 10 || submitting}>{submitting ? '提交中…' : '提交'}</button>
</form>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
function VerificationButton({phone, scene = 'register'}) {
// Requests a phone verification code (scene: 'register' | 'reset_password' | 'security') and reports the
// resulting challenge_id upward -- the caller needs it to verify the code the user types in.
function VerificationButton({phone, scene = 'register', onChallenge, onError}) {
const [seconds, setSeconds] = React.useState(0);
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
if (!seconds) return undefined;
const timer = window.setTimeout(() => setSeconds(seconds - 1), 1000);
const timer = window.setTimeout(() => setSeconds(s => s - 1), 1000);
return () => window.clearTimeout(timer);
}, [seconds]);
return <button type="button" className="auth-code-button" disabled={seconds > 0 || !PHONE_RE.test(phone)}
onClick={() => { sessionStorage.setItem('im-verification-scene', scene); setSeconds(60); }}>
{seconds > 0 ? `${seconds}s` : '获取验证码'}
const click = async () => {
if (busy || seconds > 0 || !PHONE_RE.test(phone)) return;
setBusy(true);
try {
const data = await requestVerificationCode(scene, phone);
onChallenge(data.challenge_id);
setSeconds(data.resend_after_seconds || 60);
} catch (err) {
onError && onError(err.message || '获取验证码失败');
if (err.details && err.details.retry_after_seconds) {
setSeconds(err.details.retry_after_seconds);
}
if (err.details && err.details.challenge_id) {
// Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it.
onChallenge(err.details.challenge_id);
}
} finally {
setBusy(false);
}
};
return <button type="button" className="auth-code-button" disabled={busy || seconds > 0 || !PHONE_RE.test(phone)} onClick={click}>
{seconds > 0 ? `${seconds}s` : busy ? '发送中…' : '获取验证码'}
</button>;
}
function Register() {
// Per the actual prototype screenshots (docs/images/注册页面.png), all fields are shown together --
// no phone-vs-username mode toggle. "Phone takes precedence" (per the spec text) is validation-only:
// if a phone is filled it must be verified; the invite code is only required for a username-only
// signup (matching the mock's original submit-time validation, now with real backend calls behind it).
function Register({errorText, errorLevel, onImRegisterRequest}) {
const [form, setForm] = React.useState({username: '', phone: '', code: '', password: '', invite: ''});
const [challengeId, setChallengeId] = React.useState('');
const [agreed, setAgreed] = React.useState(false);
const [errors, setErrors] = React.useState({});
const [toast, setToast] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
const change = key => e => setForm({...form, [key]: e.target.value});
const submit = e => {
const usingPhone = !!form.phone;
// onImRegisterRequest (tinode-web.jsx's handleImRegisterRequest -> a WS connect+createAccountBasic
// chain) isn't awaited and doesn't return a promise -- on the username/invite-code path (no
// verifyRegisterCode await ahead of it) `submit` returns and its `finally` clears `submitting`
// within milliseconds, long before that WS round-trip finishes, leaving the button re-clickable
// and able to fire a second concurrent registration. The parent's only failure signal is these
// props changing, so hold `submitting` until either that happens or the phone-path's own catch
// already handled it (success unmounts this component regardless).
React.useEffect(() => {
if (errorLevel === 'err' && errorText) setSubmitting(false);
}, [errorText, errorLevel]);
const submit = async e => {
e.preventDefault();
const next = {};
if (!form.username && !form.phone) next.username = '用户名和手机号至少填写一项';
if (form.username && !USERNAME_RE.test(form.username)) next.username = '用户名仅支持 1-10 位小写字母或数字';
if (form.phone && !PHONE_RE.test(form.phone)) next.phone = '请输入 11 位手机号';
if (!form.phone && !INVITE_RE.test(form.invite)) next.invite = '用户名注册需填写 5 位字母邀请码';
if (form.invite && !INVITE_RE.test(form.invite)) next.invite = '邀请码为 5 位大小写字母';
if (usingPhone) {
if (!PHONE_RE.test(form.phone)) next.phone = '请输入 11 位手机号';
if (!challengeId) next.code = '请先获取验证码';
else if (!/^\d{6}$/.test(form.code)) next.code = '请输入 6 位验证码';
} else if (!INVITE_RE.test(form.invite)) {
next.invite = '用户名注册需填写 5 位字母邀请码';
}
if (form.password.length < 8) next.password = '密码至少 8 位';
setErrors(next);
if (Object.keys(next).length) return;
setToast(form.phone ? '注册信息已准备,接口将在联调阶段启用' : '未绑定手机号,忘记密码需联系企业管理员');
setSubmitting(true);
try {
if (usingPhone) {
const verified = await verifyRegisterCode(challengeId, form.code);
// The username field is optional even on the phone path -- filling in both used to silently
// drop the typed username (always sent '' and always logged in as the phone), because the
// server only auto-assigns a random handle when username is actually empty
// (server/user.go resolveRegistrationAccount: username!="" && phone!="" => account=username,
// secretAccount=username; only username=="" falls back to the phone-derived random handle).
// Per the protocol spec, the phone number itself must never become the display name -- leave
// it blank when no username was typed either (server assigns a random handle; the user can
// set a nickname afterwards).
const secretAccount = form.username || ('+86' + form.phone);
onImRegisterRequest(secretAccount, form.password, form.username || '',
{username: form.username || '', phone: form.phone, invite_code: ''},
{meth: 'tel', val: form.phone, resp: verified.verification_token, params: {challenge_id: challengeId}});
} else {
onImRegisterRequest(form.username, form.password, form.username,
{username: form.username, phone: '', invite_code: form.invite}, null);
}
// On success the parent (tinode-web.jsx handleImRegisterRequest -> handleLoginSuccessful)
// navigates away (unmounting this component, so `submitting` no longer matters); on failure
// it surfaces errorText/errorLevel, which the effect above clears `submitting` on. No
// `finally` here on purpose -- onImRegisterRequest isn't awaited (it's not a promise), so a
// `finally` would clear `submitting` within milliseconds on the username/invite-code path,
// long before that request's own WS round-trip resolves, defeating the double-submit guard.
} catch (err) {
setErrors({...next, code: err.message || '验证码错误或已过期'});
setSubmitting(false);
}
};
return <div className="auth-landing auth-register-page">
<button type="button" className="auth-back-link" onClick={() => navigate('login')}><Icon>arrow_back_ios_new</Icon>返回登录</button>
<form className="auth-landing-form auth-register-form" onSubmit={submit}>
<AppMark tenantName={sessionStorage.getItem('im-tenant-name') || '讯工捷迅'} />
<FormAlert text={errorLevel === 'err' ? errorText : ''} />
<Field value={form.username} onChange={e => setForm({...form, username: e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10)})}
placeholder="请输入用户名" error={errors.username} autoComplete="username" />
<Field value={form.phone} onChange={e => setForm({...form, phone: e.target.value.replace(/\D/g, '').slice(0, 11)})}
placeholder="请输入手机号" error={errors.phone} inputMode="numeric" />
<Field value={form.code} onChange={change('code')} placeholder="请输入短信验证码" inputMode="numeric"
action={<VerificationButton phone={form.phone} />} />
<Field value={form.code} onChange={e => setForm({...form, code: e.target.value.replace(/\D/g, '').slice(0, 6)})}
placeholder="请输入短信验证码" inputMode="numeric" error={errors.code}
action={<VerificationButton phone={form.phone} scene="register" onChallenge={setChallengeId}
onError={msg => setErrors(e => ({...e, code: msg}))} />} />
<PasswordField value={form.password} onChange={change('password')} placeholder="请输入密码" autoComplete="new-password" error={errors.password} />
<Field value={form.invite} onChange={e => setForm({...form, invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)})}
placeholder="请输入邀请码(非必填)" error={errors.invite} />
<button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed}>注册</button>
<button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed || submitting}>
{submitting ? '注册中…' : '注册'}
</button>
<p className="auth-switch">已有讯工捷迅账号? <button type="button" onClick={() => navigate('login')}>登录</button></p>
</form>
<Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同捷讯" />
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
function PasswordReset() {
const [form, setForm] = React.useState({phone: '', code: '', password: '', confirm: ''});
const [challengeId, setChallengeId] = React.useState('');
const [error, setError] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
const [toast, setToast] = React.useState('');
const change = key => e => setForm({...form, [key]: e.target.value});
const submit = e => {
const submit = async e => {
e.preventDefault();
if (!PHONE_RE.test(form.phone)) return setError('请输入 11 位手机号');
if (form.password.length < 8 || form.password.length > 20) return setError('密码需为 8-20 位字母或数字组合');
if (!challengeId || !/^\d{6}$/.test(form.code)) return setError('请先获取并输入 6 位验证码');
// Matches Register's rule (>= 8, no upper bound -- the backend's own checkPasswordPolicy only
// enforces a minimum length). This page used to also reject anything over 20 characters, a limit
// invented here with no backend counterpart: a password set during registration could exceed
// it, then resetting it back to that same value through this flow would be rejected -- a
// self-inflicted lockout for password reset's own sake.
if (form.password.length < 8) return setError('密码至少 8 位');
if (form.password !== form.confirm) return setError('两次输入的密码不一致');
setError(''); setToast('密码修改成功,请重新登录');
window.setTimeout(() => navigate('login'), 1200);
setSubmitting(true);
setError('');
try {
const verified = await verifyPasswordResetCode(challengeId, form.code);
await confirmPasswordReset(verified.reset_token, form.password);
setToast('密码修改成功,请重新登录');
window.setTimeout(() => navigate('login'), 1200);
} catch (err) {
setError(err.message || '验证码错误或已过期');
setSubmitting(false);
}
};
return <div className="auth-page auth-soft-bg">
<Header title="忘记密码" back="login" />
<main className="auth-content auth-content-narrow auth-reset-content">
<form className="auth-list-form" onSubmit={submit}>
<label><span>手机号</span><input value={form.phone} onChange={e => setForm({...form, phone: e.target.value.replace(/\D/g, '').slice(0, 11)})} placeholder="请输入手机号" inputMode="numeric" /></label>
<label><span>验证码</span><input value={form.code} onChange={change('code')} placeholder="请输入验证码" inputMode="numeric" /><VerificationButton phone={form.phone} scene="reset_password" /></label>
<label><span>验证码</span><input value={form.code} onChange={e => setForm({...form, code: e.target.value.replace(/\D/g, '').slice(0, 6)})} placeholder="请输入验证码" inputMode="numeric" />
<VerificationButton phone={form.phone} scene="reset_password" onChallenge={setChallengeId} onError={setError} /></label>
<div className="auth-list-gap" />
<label><span>设置密码</span><input type="password" value={form.password} onChange={change('password')} placeholder="8-20位字母或数字组合" /></label>
<label><span>设置密码</span><input type="password" value={form.password} onChange={change('password')} placeholder="至少 8 位" /></label>
<label><span>再次输入</span><input type="password" value={form.confirm} onChange={change('confirm')} placeholder="请再次确定密码" /></label>
{error ? <div className="auth-form-alert"><Icon>error_outline</Icon>{error}</div> : null}
<button className="auth-primary auth-sticky-action" type="submit" disabled={!form.phone || !form.code || !form.password || !form.confirm}>修改密码</button>
<button className="auth-primary auth-sticky-action" type="submit"
disabled={!form.phone || !form.code || !form.password || !form.confirm || submitting}>
{submitting ? '提交中…' : '修改密码'}
</button>
</form>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
// Precedes 1.11 (setting the independent password): resolves the account's own verified phone
// server-side (POST /v1/im/security/challenges never takes a phone number from the client) and, on
// successful verify, stashes the resulting action_token for the next screen to spend.
const IDENTITY_CHECK_SCENE = 'set_independent_password';
function IdentityCheck({authenticated}) {
const [phone] = React.useState('138****8899');
const [phoneMask, setPhoneMask] = React.useState('');
const [challengeId, setChallengeId] = React.useState('');
const [code, setCode] = React.useState('');
const [toast, setToast] = React.useState('');
return <div className="auth-page auth-soft-bg">
const [seconds, setSeconds] = React.useState(0);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState('');
React.useEffect(() => {
if (!seconds) return undefined;
const timer = window.setTimeout(() => setSeconds(s => s - 1), 1000);
return () => window.clearTimeout(timer);
}, [seconds]);
const requestCode = React.useCallback(async () => {
setBusy(true);
setError('');
try {
const data = await createSecurityChallenge(IDENTITY_CHECK_SCENE);
setChallengeId(data.challenge_id);
setPhoneMask(data.phone_mask);
setSeconds(data.resend_after_seconds || 60);
} catch (err) {
setError(err.reason === 'REALNAME_REQUIRED' ? '需要先完成实名认证并绑定手机号' : (err.message || '获取验证码失败'));
} finally {
setBusy(false);
}
}, []);
React.useEffect(() => { requestCode(); }, [requestCode]);
const submit = async e => {
e.preventDefault();
if (!challengeId || code.length !== 6 || busy) return;
setBusy(true);
try {
const data = await verifySecurityChallenge(challengeId, code);
sessionStorage.setItem('im-action-token:' + IDENTITY_CHECK_SCENE, JSON.stringify(data));
navigate('independent-password');
} catch (err) {
setError(err.message || '验证码错误');
} finally {
setBusy(false);
}
};
return <div className="auth-page auth-soft-bg auth-identity-check-page">
<Header title="身份验证" back={authenticated ? 'security' : 'login'} />
<main className="auth-content auth-content-narrow auth-reset-content">
<form className="auth-list-form" onSubmit={e => { e.preventDefault(); setToast('身份验证成功'); }}>
<label><span>手机号</span><input value={phone} readOnly /></label>
<label><span>验证码</span><input value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="请输入验证码" inputMode="numeric" /><VerificationButton phone="13800008899" scene="security" /></label>
<button className="auth-primary auth-sticky-action" type="submit" disabled={code.length !== 6}>确定</button>
<form className="auth-list-form" onSubmit={submit}>
<label><span>手机号</span><input value={phoneMask} readOnly placeholder="获取中…" /></label>
<label><span>验证码</span>
<input value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="请输入验证码" inputMode="numeric" />
<button type="button" className="auth-code-button" disabled={busy || seconds > 0} onClick={requestCode}>
{seconds > 0 ? `${seconds}s` : '获取验证码'}
</button>
</label>
{error ? <div className="auth-field-error">{error}</div> : null}
<button className="auth-primary auth-sticky-action" type="submit" disabled={code.length !== 6 || busy}>确定</button>
</form>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
function Realname({verified = false, authenticated = false}) {
const [name, setName] = React.useState(verified ? '蒋欣' : '');
const [idNumber, setIdNumber] = React.useState(verified ? '43993838581999355454' : '');
const [status, setStatus] = React.useState(verified ? 'verified' : 'unverified');
function Realname({authenticated = false}) {
// Real status (unverified/pending/verified/rejected) drives the view -- the old mock's `verified`
// prop toggle is gone, both 'realname' and 'realname-verified' routes render this and let the
// GET /v1/im/security/realname response decide what to show.
const [status, setStatus] = React.useState('loading');
const [maskedName, setMaskedName] = React.useState('');
const [maskedIdNumber, setMaskedIdNumber] = React.useState('');
const [name, setName] = React.useState('');
const [idNumber, setIdNumber] = React.useState('');
const [error, setError] = React.useState('');
const submit = e => {
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
getRealnameStatus().then(data => {
setStatus(data.status);
setMaskedName(data.masked_name || '');
setMaskedIdNumber(data.masked_id_number || '');
}).catch(err => { setError(err.message || '加载实名状态失败'); setStatus('load_error'); });
}, []);
const editable = status === 'unverified' || status === 'rejected';
const submit = async e => {
e.preventDefault();
if (name.trim().length < 2 || !/^\d{17}[\dXx]$/.test(idNumber)) {
setError('请输入有效姓名和 18 位身份证号码'); return;
}
setStatus('pending'); setError('');
setBusy(true);
try {
// Backend's validIMRealName only accepts the literal 'cn_id' -- 'id_card' was rejected outright
// with a 400 every time, so real-name submission never actually worked.
const data = await submitRealname({name: name.trim(), id_type: 'cn_id', id_number: idNumber.toUpperCase()});
setStatus(data.status || 'pending');
setError('');
} catch (err) {
setError(err.message || '提交失败,请稍后重试');
} finally {
setBusy(false);
}
};
if (status === 'loading' || status === 'load_error') {
return <div className="auth-page auth-realname-page"><Header title="实名认证" back={authenticated ? 'security' : 'login'} />
<p className="auth-privacy-note" style={{margin: '2rem 1rem'}}>{status === 'load_error' ? (error || '加载失败,请重试') : '加载中…'}</p>
</div>;
}
return <div className="auth-page auth-realname-page">
<Header title="实名认证" back={authenticated ? 'security' : 'login'} />
<div className="auth-realname-hero"><div><strong>实名认证</strong><span>更加真实,更受欢迎</span></div><Icon>verified_user</Icon></div>
<main className="auth-realname-content">
<h2>{status === 'verified' ? '认证信息' : '请您使用有效身份证信息认证'}</h2>
<form onSubmit={submit}>
<label><span>姓名</span><input value={status === 'verified' ? name[0] + '*' : name} onChange={e => setName(e.target.value)} readOnly={status !== 'unverified'} placeholder="请输入真实姓名" /></label>
<label><span>证件号码</span><input value={status === 'verified' ? idNumber.slice(0, 3) + '***********' + idNumber.slice(-4) : idNumber} onChange={e => setIdNumber(e.target.value)} readOnly={status !== 'unverified'} placeholder="请输入证件号码" /></label>
<label><span>姓名</span><input value={editable ? name : maskedName} onChange={e => setName(e.target.value)} readOnly={!editable} placeholder="请输入真实姓名" /></label>
<label><span>证件号码</span><input value={editable ? idNumber : maskedIdNumber} onChange={e => setIdNumber(e.target.value)} readOnly={!editable} placeholder="请输入证件号码" /></label>
{error ? <div className="auth-field-error">{error}</div> : null}
<button className="auth-primary" type="submit" disabled={status !== 'unverified'}>{status === 'verified' ? '已实名' : status === 'pending' ? '审核中' : '提交'}</button>
<button className="auth-primary" type="submit" disabled={!editable || busy}>
{status === 'verified' ? '已实名' : status === 'pending' ? '审核中' : busy ? '提交中…' : '提交'}
</button>
</form>
<p className="auth-privacy-note"><Icon>info</Icon>我们将获取您的姓名、证件号用于认证,信息会被严格保护</p>
</main>
@@ -384,7 +654,7 @@ function RealnameReminder() {
return <div className="auth-page auth-reminder-demo">
<div className="auth-demo-chat"><h1>消息</h1><div className="auth-demo-search"><Icon>search</Icon>搜索</div>{['系统消息', '文件助手', '项目协作群', '陈经理'].map((item, i) => <div className="auth-demo-row" key={item}><span>{item[0]}</span><div><b>{item}</b><small>{i ? '最近一条消息内容' : '安全通知与账号动态'}</small></div></div>)}</div>
<div className="auth-modal-backdrop">
<div className="auth-modal"><h2>实名认证提醒</h2><p>根据国家法律法规要求,您需先进行实名认证才能使用该应用</p><button className="auth-primary" onClick={() => navigate('realname')}>去实名认证</button><button className="auth-text-button" onClick={() => navigate('tenant')}>稍后处理</button></div>
<div className="auth-modal"><h2>实名认证提醒</h2><p>根据国家法律法规要求,您需先进行实名认证才能使用该项 APP</p><button className="auth-primary" onClick={() => navigate('realname')}>去实名认证</button></div>
</div>
</div>;
}
@@ -393,11 +663,25 @@ function IndependentPassword({authenticated}) {
const [pin, setPin] = React.useState('');
const [error, setError] = React.useState('');
const [toast, setToast] = React.useState('');
const [busy, setBusy] = React.useState(false);
// Mirrors server/im_http.go validIMIndependentPassword: 4 digits, not all-same, not a run.
const invalid = ['0000','1111','2222','3333','4444','5555','6666','7777','8888','9999','0123','1234','2345','3456','4567','5678','6789','9876','8765','7654','6543','5432','4321','3210'];
const submit = e => {
const submit = async e => {
e.preventDefault();
if (invalid.includes(pin)) { setError('请勿使用连续或重复数字'); return; }
setError(''); setToast('独立密码设置成功'); setPin('');
const stored = sessionStorage.getItem('im-action-token:' + IDENTITY_CHECK_SCENE);
const actionToken = stored && JSON.parse(stored).action_token;
if (!actionToken) { setError('身份验证已过期,请返回重新验证'); return; }
setBusy(true);
try {
await setIndependentPassword(pin, 'youth_mode', actionToken);
sessionStorage.removeItem('im-action-token:' + IDENTITY_CHECK_SCENE);
setError(''); setToast('独立密码设置成功'); setPin('');
} catch (err) {
setError(err.message || '设置失败,请返回重新验证身份');
} finally {
setBusy(false);
}
};
return <div className="auth-page">
<Header title="" back={authenticated ? 'security' : 'login'} />
@@ -406,26 +690,31 @@ function IndependentPassword({authenticated}) {
<div className="auth-pin-boxes" aria-hidden="true">{[0,1,2,3].map(i => <span key={i}>{pin[i] ? '●' : ''}</span>)}</div>
<input className="auth-pin-input" type="text" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))} inputMode="numeric" autoComplete="off" aria-label="四位独立密码" autoFocus />
{error ? <div className="auth-field-error">{error}</div> : null}
<button className="auth-primary" disabled={pin.length !== 4} onClick={submit}>确定</button>
<button className="auth-primary" disabled={pin.length !== 4 || busy} onClick={submit}>{busy ? '提交中…' : '确定'}</button>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
export default function AuthOnboardingView({view, onTenantSelected, authenticated = false}) {
export default function AuthOnboardingView({
view, onTenantResolved, authenticated = false,
login, loginDisabled, persist, errorText, errorLevel,
onLoginRequest, onPersistenceChange, onImRegisterRequest, onError
}) {
const tenantName = sessionStorage.getItem('im-tenant-name') || '';
switch (view) {
case 'tenant': return <TenantEntry onTenantSelected={onTenantSelected || (() => {})} />;
case 'login': return <Login tenantName={tenantName} />;
case 'tenant': return <TenantEntry onTenantResolved={onTenantResolved || (() => {})} />;
case 'login': return <Login tenantName={tenantName} login={login} loginDisabled={loginDisabled} persist={persist}
errorText={errorText} errorLevel={errorLevel} onLoginRequest={onLoginRequest} onPersistenceChange={onPersistenceChange} onError={onError} />;
case 'enterprise-register': return <EnterpriseRegister />;
case 'enterprise-feedback': return <EnterpriseFeedback />;
case 'register': return <Register />;
case 'register': return <Register errorText={errorText} errorLevel={errorLevel} onImRegisterRequest={onImRegisterRequest} />;
case 'reset': return <PasswordReset />;
case 'identity-check': return <IdentityCheck authenticated={authenticated} />;
case 'realname': return <Realname authenticated={authenticated} />;
case 'realname-verified': return <Realname verified authenticated={authenticated} />;
case 'realname-verified': return <Realname authenticated={authenticated} />;
case 'realname-reminder': return <RealnameReminder />;
case 'independent-password': return <IndependentPassword authenticated={authenticated} />;
default: return <TenantEntry onTenantSelected={onTenantSelected || (() => {})} />;
default: return <TenantEntry onTenantResolved={onTenantResolved || (() => {})} />;
}
}
+427 -62
View File
@@ -1,9 +1,22 @@
import React from 'react';
import QRCode from 'qrcodejs';
import { pinyin } from 'pinyin-pro';
import HashNavigation from '../lib/navigation.js';
import {
getFriends, getFriendRequests, createFriendRequest, acceptFriendRequest, rejectFriendRequest,
deleteFriend, updateFriendRemark, searchUsers, getUserProfile, createMyQRCode, getBlocks,
blockUser, unblockUser, getFileUrl, getBootstrap
} from '../lib/im-api.js';
import { readCachedData, writeCachedData, useCachedData } from '../lib/use-cached-data.js';
const navigate = view => HashNavigation.navigateTo(view);
// Carries an optional `uid` (and anything else already in the hash) across chapter-5 navigation --
// e.g. address-book -> friend-profile -> friend-settings all need to know which person this is.
const navigate = (view, uid) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
hash = uid ? HashNavigation.addUrlParam(hash, 'uid', uid) : HashNavigation.removeUrlParam(hash, 'uid');
HashNavigation.navigateTo(hash);
};
export const CONTACT_MANAGEMENT_VIEWS = [
'address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-search-result',
@@ -12,27 +25,39 @@ export const CONTACT_MANAGEMENT_VIEWS = [
'clear-chat-confirm'
];
const FRIENDS = [
{id: 'annie', name: '安妮', remark: '安妮 · 产品', initial: 'A', avatar: 'img/bkg/p09.jpg', account: 'AN1024'},
{id: 'chen', name: '陈晨', initial: 'C', avatar: 'img/bkg/p05.jpg', account: 'CHEN88'},
{id: 'lizi', name: '栗子来了', remark: '栗子', initial: 'L', avatar: 'img/bkg/p15.jpg', account: 'LIZI66'},
{id: 'wang', name: '王珊珊', initial: 'W', avatar: 'img/bkg/p14.jpg', account: 'WSS2026'}
];
const REQUESTS = [
{id: 'r1', name: '祈万事安好', message: '我是设计交流群里的小祈', time: '10:24', status: 'pending', avatar: 'img/bkg/p03.jpg'},
{id: 'r2', name: '一颗栗子', message: '你好,想认识一下', time: '昨天', status: 'accepted', avatar: 'img/bkg/p15.jpg'},
{id: 'r3', name: '林间风', message: '通过手机号搜索到你', time: '星期一', status: 'rejected', avatar: 'img/bkg/p18.jpg'}
];
// Server's own `initial` (bootstrap.go displayInitial) is just the first Unicode code point of the
// display name -- for Chinese names that's a hanzi, not a Latin letter, so it can't drive the A/B/C
// section headers docs/images/通讯录.png actually shows. Group client-side by first-pinyin-letter.
function alphaInitial(name) {
const ch = (name || '').trim()[0] || '#';
if (/[A-Za-z]/.test(ch)) {
return ch.toUpperCase();
}
if (/[一-龥]/.test(ch)) {
const letter = pinyin(ch, {pattern: 'first', toneType: 'none'});
return /[a-z]/i.test(letter) ? letter.toUpperCase() : '#';
}
return '#';
}
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'messages-home', right, onRight}) {
return <header className="people-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>;
function Header({title, back = 'messages-home', backUid, right, onRight}) {
return <header className="people-header"><button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backUid); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>;
}
// `person.avatar_file_id` (real API field) is loaded lazily via a cached authenticated fetch --
// file serving is auth-gated, there's no public URL a plain <img src> could point at directly.
function Avatar({person, icon}) {
return <span className="people-avatar">{person && person.avatar ? <img src={person.avatar} alt="" /> : <Icon>{icon || 'person'}</Icon>}</span>;
const fileId = person && person.avatar_file_id;
const [url, setUrl] = React.useState(null);
React.useEffect(() => {
if (!fileId) { setUrl(null); return undefined; }
let live = true;
getFileUrl(fileId).then(u => { if (live) setUrl(u); }).catch(() => {});
return () => { live = false; };
}, [fileId]);
return <span className="people-avatar">{url ? <img src={url} alt="" /> : <Icon>{icon || 'person'}</Icon>}</span>;
}
function Toast({message, onDone}) {
@@ -44,94 +69,434 @@ function Toast({message, onDone}) {
return message ? <div className="chat-toast" role="status">{message}</div> : null;
}
function Search({value, onChange, placeholder}) {
return <label className="people-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} /></label>;
function Search({value, onChange, placeholder, onSubmit}) {
return <label className="people-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)}
onKeyDown={event => { if (event.key === 'Enter' && onSubmit) onSubmit(); }} placeholder={placeholder} /></label>;
}
function QrCard({group = false}) {
// `payload` is the real `qr_payload` string from POST /me/qrcodes (already shaped like
// `im://qr?token=...&purpose=add_friend` -- same scheme the demo literal used, no format change).
function QrCard({payload, profile, loading}) {
const ref = React.useRef(null);
React.useEffect(() => {
if (!ref.current) return;
if (!ref.current || !payload) return;
ref.current.innerHTML = '';
new QRCode(ref.current, {text: group ? 'im://qr?token=demo-group-2026&purpose=join_group' : 'im://qr?token=demo-user-2026&purpose=add_friend', width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M});
}, [group]);
return <section className="people-qr-card"><div className="people-qr-profile"><Avatar person={group ? {avatar: 'img/bkg/p22.jpg'} : FRIENDS[1]} /><span><strong>{group ? '产品研发群' : '陈晨'}</strong><small>{group ? '18 位成员' : '账号 CHEN88'}</small></span></div><div className="people-qrcode" ref={ref} /><p>{group ? '扫码申请加入群聊' : '扫一扫上面的二维码,加我为好友'}</p><small>二维码 24 小时内有效</small></section>;
new QRCode(ref.current, {text: payload, width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M});
}, [payload]);
return <section className="people-qr-card">
<div className="people-qr-profile"><Avatar person={profile} /><span><strong>{(profile && profile.display_name) || ''}</strong>
<small>{profile && profile.public_account ? '账号 ' + profile.public_account : ''}</small></span></div>
<div className="people-qrcode" ref={ref} />
{loading ? <p>正在生成二维码…</p> : <p>扫一扫上面的二维码,加我为好友</p>}
<small>二维码 24 小时内有效</small>
</section>;
}
function AddressBook() {
function AddressBook({myUserId, onTopicSelected}) {
const [query, setQuery] = React.useState('');
const visible = FRIENDS.filter(item => `${item.name}${item.remark || ''}`.includes(query));
const groups = visible.reduce((result, item) => ({...result, [item.initial]: (result[item.initial] || []).concat(item)}), {});
// Seeded from cache so re-opening the address book (very much a back-and-forth flow: check
// contacts, open a chat, come back) doesn't drop straight back to "加载中…" for a friend list that
// hasn't actually changed.
// Keyed by myUserId (same convention as the 'bootstrap:'+myUserId cache just below) -- a bare
// 'friends' key meant logging out and into a *different* account in the same tab would briefly
// paint the previous account's cached friend list on mount, before the fresh getFriends() call
// overwrote it moments later.
const friendsCacheKey = myUserId ? 'friends:' + myUserId : null;
const [friends, setFriends] = React.useState(() => (friendsCacheKey && readCachedData(friendsCacheKey)) || []);
const [pendingCount, setPendingCount] = React.useState(0);
const [loading, setLoading] = React.useState(() => !(friendsCacheKey && readCachedData(friendsCacheKey)));
// Same bootstrap the message center already fetches (and caches under the same key) -- only need
// it here for the file assistant's real topic, so a plain read of the shared cache is enough.
const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
const fileAssistantTopic = bootstrap && bootstrap.entries && bootstrap.entries.file_assistant
&& bootstrap.entries.file_assistant.topic;
React.useEffect(() => {
getFriends().then(data => {
const list = data.items || [];
if (friendsCacheKey) { writeCachedData(friendsCacheKey, list); }
setFriends(list);
}).catch(() => {}).finally(() => setLoading(false));
getFriendRequests('inbound', 'pending').then(data => setPendingCount((data.items || []).length)).catch(() => {});
}, []);
const visible = friends.filter(item => `${item.display_name}${item.remark || ''}`.includes(query));
const groups = visible.reduce((result, item) => {
const key = alphaInitial(item.remark || item.display_name);
return {...result, [key]: (result[key] || []).concat(item)};
}, {});
const sortedKeys = Object.keys(groups).sort();
const entries = [
{icon: 'person_add_alt', label: '新朋友', route: 'friend-requests', badge: 1, tone: 'blue'},
{icon: 'group', label: '群聊', route: 'group-list', tone: 'green'},
{icon: 'folder_special', label: '文件助手', route: 'chat-standard', tone: 'orange'},
{icon: 'person_add_alt', label: '新朋友', route: 'friend-requests', badge: pendingCount || null, tone: 'blue'},
{icon: 'group', label: '群组', route: 'group-list', tone: 'green'},
{icon: 'folder_special', label: '文件助手', route: 'chat-standard', tone: 'orange', fileAssistant: true},
{icon: 'notifications', label: '系统消息', route: 'system-messages', tone: 'red'}
];
return <div className="people-page"><Header title="通讯录" right="添加" onRight={() => navigate('add-friend')} /><Search value={query} onChange={setQuery} placeholder="搜索联系人" /><main className="people-scroll"><section className="people-entry-list">{entries.map(item => <button type="button" key={item.label} onClick={() => navigate(item.route)}><Avatar icon={item.icon} /><strong>{item.label}</strong>{item.badge ? <em>{item.badge}</em> : null}<Icon>chevron_right</Icon></button>)}</section>{Object.keys(groups).map(initial => <section className="people-group" key={initial}><h2>{initial}</h2>{groups[initial].map(person => <button type="button" key={person.id} onClick={() => navigate('friend-profile')}><Avatar person={person} /><span><strong>{person.remark || person.name}</strong>{person.remark ? <small>{person.name}</small> : null}</span></button>)}</section>)}</main><nav className="people-index">{Object.keys(groups).map(value => <a href={`#people-${value}`} key={value}>{value}</a>)}</nav></div>;
const scrollTo = key => {
const el = document.getElementById('people-group-' + key);
if (el) el.scrollIntoView({behavior: 'smooth', block: 'start'});
};
// The file assistant is a real topic (from bootstrap), not a route -- same bug/fix pattern as the
// "常联系的人" shortcuts in message-center-view.jsx. Falls back to the old demo route only if
// bootstrap hasn't resolved a real topic yet, so the entry never becomes a dead click.
const openEntry = item => {
if (item.fileAssistant && fileAssistantTopic && onTopicSelected) onTopicSelected(fileAssistantTopic);
else navigate(item.route);
};
return <div className="people-page"><Header title="通讯录" right="添加" onRight={() => navigate('add-friend')} /><Search value={query} onChange={setQuery} placeholder="搜索联系人" />
<main className="people-scroll">
<section className="people-entry-list">{entries.map(item => <button type="button" key={item.label} onClick={() => openEntry(item)}><Avatar icon={item.icon} /><strong>{item.label}</strong>{item.badge ? <em>{item.badge}</em> : null}<Icon>chevron_right</Icon></button>)}</section>
<h3 className="people-section-title">好友通讯录</h3>
{loading ? <p className="people-empty-hint">加载中…</p> : sortedKeys.length === 0 ? <p className="people-empty-hint">暂无好友</p> : null}
{sortedKeys.map(initial => <section className="people-group" id={'people-group-' + initial} key={initial}><h2>{initial}</h2>
{groups[initial].map(person => <button type="button" key={person.user_id} onClick={() => { seedProfile(person); navigate('friend-profile', person.user_id); }}>
<Avatar person={person} /><span><strong>{person.remark || person.display_name}</strong>{person.remark ? <small>{person.display_name}</small> : null}</span>
</button>)}
</section>)}
</main>
<nav className="people-index">{sortedKeys.map(value => <a href="#" key={value} onClick={e => { e.preventDefault(); scrollTo(value); }}>{value}</a>)}</nav>
</div>;
}
function requestStatusLabel(item) {
if (item.status === 'pending') return item.direction === 'outbound' ? '已发送' : null;
if (item.status === 'accepted') return '已添加';
if (item.status === 'rejected') return '已拒绝';
if (item.status === 'expired') return '已过期';
return null;
}
function FriendRequests({onToast}) {
const [items, setItems] = React.useState(REQUESTS);
const handle = (id, status) => {setItems(values => values.map(item => item.id === id ? {...item, status} : item)); onToast(status === 'accepted' ? '已通过好友申请' : '已拒绝好友申请');};
return <div className="people-page"><Header title="新朋友" back="address-book" /><main className="people-request-list">{items.map(item => <article key={item.id}><Avatar person={item} /><div><strong>{item.name}</strong><p>{item.message}</p><small>{item.time}</small></div>{item.status === 'pending' ? <span><button type="button" onClick={() => handle(item.id, 'rejected')}>拒绝</button><button type="button" className="primary" onClick={() => handle(item.id, 'accepted')}>通过</button></span> : <em>{item.status === 'accepted' ? '已通过' : '已拒绝'}</em>}</article>)}</main></div>;
const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
getFriendRequests('all', 'all').then(data => setItems(data.items || [])).catch(() => {}).finally(() => setLoading(false));
}, []);
const handle = (item, accept) => {
const nextStatus = accept ? 'accepted' : 'rejected';
setItems(values => values.map(v => v.request_id === item.request_id ? {...v, status: nextStatus} : v));
const call = accept ? acceptFriendRequest(item.request_id) : rejectFriendRequest(item.request_id);
call.then(() => onToast(accept ? '已通过好友申请' : '已拒绝好友申请')).catch(err => {
setItems(values => values.map(v => v.request_id === item.request_id ? {...v, status: item.status} : v));
onToast(err.message || '操作失败');
});
};
return <div className="people-page"><Header title="新朋友" back="address-book" /><main className="people-request-list">
{loading ? <p className="people-empty-hint">加载中…</p> : items.length === 0 ? <p className="people-empty-hint">暂无新朋友申请</p> : null}
{items.map(item => <article key={item.request_id}>
<Avatar person={item.peer} />
<div><strong>{(item.peer && item.peer.display_name) || item.applicant_id}</strong><p>{item.message || '请求添加您为好友'}</p><small>{new Date(item.created_at).toLocaleString()}</small></div>
{item.status === 'pending' && item.direction === 'inbound' ?
<span><button type="button" onClick={() => handle(item, false)}>拒绝</button><button type="button" className="primary" onClick={() => handle(item, true)}>通过验证</button></span> :
<em>{requestStatusLabel(item)}</em>}
</article>)}
</main></div>;
}
function AddFriend({result = false}) {
const [query, setQuery] = React.useState(result ? 'AN1024' : '');
return <div className="people-page"><Header title="添加好友" back="address-book" /><Search value={query} onChange={setQuery} placeholder="输入账号或手机号" /><main>{query ? <section className="people-search-result"><h2>搜索结果</h2><button type="button" onClick={() => navigate('group-member-profile')}><Avatar person={FRIENDS[0]} /><span><strong>安妮</strong><small>账号 AN1024 · 产品设计组</small></span><em>添加</em></button></section> : <section className="people-add-actions"><button type="button"><Icon>qr_code_scanner</Icon><span><strong>扫一扫</strong><small>扫描好友或群二维码</small></span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('my-friend-qr')}><Icon>qr_code_2</Icon><span><strong>我的二维码</strong><small>让朋友扫一扫添加我</small></span><Icon>chevron_right</Icon></button><button type="button"><Icon>share</Icon><span><strong>分享好友</strong><small>发送我的个人名片</small></span><Icon>chevron_right</Icon></button></section>}<p className="people-my-account">我的账号:CHEN88</p></main></div>;
// `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
// QR code -- already returned the exact same fields (display_name/avatar_file_id/gender/region/
// public_account/signature, both go through userSearchItemPayload/copyOptionalProfileFields) *before*
// that gate, unconditionally. Profile used to throw that object away and re-fetch via the gated
// endpoint, which 403s and leaves the page stuck on "加载中…" forever with no way forward -- this
// stashes what we already have so Profile can render immediately, and only uses getUserProfile as a
// best-effort background refresh (for when the two really are/become friends, or the peer's privacy
// setting is "everyone").
function seedProfile(item) {
if (!item || !item.user_id) return;
writeCachedData('profile-seed:' + item.user_id, {
...item,
capabilities: {can_add_friend: !!item.can_add}
});
}
function AddFriend({result = false, onToast, onOpenScanner}) {
const [query, setQuery] = React.useState('');
const [searching, setSearching] = React.useState(false);
const [searched, setSearched] = React.useState(result);
const [results, setResults] = React.useState([]);
const runSearch = q => {
if (!q.trim()) { setSearched(false); setResults([]); return; }
setSearching(true); setSearched(true);
searchUsers(q.trim(), 'exact_account').then(data => setResults(data.items || [])).catch(() => setResults([])).finally(() => setSearching(false));
};
const shareCard = () => {
createMyQRCode().then(data => {
const text = `加我为好友:${data.qr_payload}`;
if (navigator.share) {
navigator.share({title: '添加好友', text}).catch(() => {});
} else if (navigator.clipboard) {
navigator.clipboard.writeText(text);
}
}).catch(() => {});
};
const actionLabel = item => item.relationship === 'friend' ? '已是好友' :
item.relationship === 'pending_outbound' ? '已申请' : item.relationship === 'pending_inbound' ? '待通过' : '添加';
return <div className="people-page"><Header title="添加好友" back="address-book" />
<Search value={query} onChange={v => { setQuery(v); if (!v.trim()) { setSearched(false); setResults([]); } }} placeholder="查找账号" onSubmit={() => runSearch(query)} />
<main>
{searched ? <section className="people-search-result"><h2>查找人</h2>
{searching ? <p className="people-empty-hint">搜索中…</p> : results.length === 0 ? <p className="people-empty-hint">未找到相关用户</p> :
results.map(item => <button type="button" key={item.user_id}
onClick={() => { seedProfile(item); item.relationship === 'friend' ? navigate('friend-profile', item.user_id) : navigate('group-member-profile', item.user_id); }}>
<Avatar person={item} /><span><strong>{item.display_name}</strong></span>
<em className={item.relationship === 'none' ? 'people-add-cta' : ''}>{actionLabel(item)}</em>
</button>)}
</section> : <section className="people-add-actions">
<button type="button" onClick={() => navigate('my-friend-qr')}><Icon>qr_code</Icon><span><strong>我的二维码</strong><small>让朋友扫一扫添加我</small></span><Icon>chevron_right</Icon></button>
<button type="button" onClick={() => onOpenScanner ? onOpenScanner() : onToast('暂不支持扫码,请使用账号搜索添加好友')}><Icon>qr_code_scanner</Icon><span><strong>扫一扫</strong><small>扫描好友或群二维码</small></span><Icon>chevron_right</Icon></button>
<button type="button" onClick={shareCard}><Icon>share</Icon><span><strong>分享给好友</strong><small>分享链接给好友,一起来玩吧</small></span><Icon>chevron_right</Icon></button>
</section>}
</main>
</div>;
}
function QrPage({onToast}) {
return <div className="people-page people-qr-page"><Header title="我的二维码" back="add-friend" /><main><QrCard /><div className="people-qr-actions"><button type="button" onClick={() => onToast('二维码名片已分享')}><Icon>share</Icon><span>分享好友</span></button><button type="button" onClick={() => onToast('二维码已保存到相册')}><Icon>download</Icon><span>保存到相册</span></button></div></main></div>;
const [payload, setPayload] = React.useState(null);
const [profile, setProfile] = React.useState(null);
React.useEffect(() => {
createMyQRCode().then(data => { setPayload(data.qr_payload); setProfile(data.display_profile); }).catch(() => onToast('二维码生成失败'));
}, []);
const share = () => {
if (navigator.share && payload) {
navigator.share({title: '添加好友', text: payload}).then(() => onToast('二维码名片已分享')).catch(() => {});
} else if (navigator.clipboard && payload) {
navigator.clipboard.writeText(payload);
onToast('链接已复制');
}
};
const save = () => {
const canvas = document.querySelector('.people-qrcode canvas');
if (!canvas) { onToast('二维码尚未生成'); return; }
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = '我的二维码.png';
a.click();
onToast('二维码已保存到相册');
};
return <div className="people-page people-qr-page"><Header title="我的二维码" back="add-friend" /><main><QrCard payload={payload} profile={profile} loading={!payload} />
<div className="people-qr-actions"><button type="button" onClick={share}><Icon>share</Icon><span>分享好友</span></button><button type="button" onClick={save}><Icon>download</Icon><span>保存到相册</span></button></div>
</main></div>;
}
function Verification({onToast}) {
const [message, setMessage] = React.useState('我是产品研发群里的陈晨');
return <div className="people-page"><Header title="好友验证" back="group-member-profile" /><main className="people-verify"><Avatar person={FRIENDS[0]} /><h2>安妮</h2><label><span>验证留言</span><textarea value={message} maxLength="50" onChange={event => setMessage(event.target.value)} /><small>{message.length}/50</small></label><button type="button" className="people-primary" disabled={!message.trim()} onClick={() => {onToast('好友申请已发送'); window.setTimeout(() => navigate('group-member-profile'), 800);}}>发送申请</button></main></div>;
function Verification({uid, onToast}) {
// Same seed-then-best-effort-refresh as Profile -- reached right after it (via its "添加好友"
// button), so the cache this reads is the one Profile itself just kept warm.
const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null));
const [message, setMessage] = React.useState('');
const [sending, setSending] = React.useState(false);
const [sent, setSent] = React.useState(false);
React.useEffect(() => {
if (!uid) return;
getUserProfile(uid).then(data => { writeCachedData('profile-seed:' + uid, data); setProfile(data); }).catch(() => {});
}, [uid]);
const alreadyPending = profile && (profile.relationship === 'pending_outbound' || profile.relationship === 'pending_inbound');
const alreadyFriend = profile && profile.relationship === 'friend';
const send = () => {
setSending(true);
createFriendRequest(uid, message.trim(), 'search').then(() => {
onToast('好友申请已发送'); setSent(true);
// Patch the seed Profile will re-read on remount -- its own background getUserProfile refresh
// can't do this for us, since a stranger-turned-pending peer still fails that gated endpoint
// the exact same way a stranger does (server only exempts an established "friend").
writeCachedData('profile-seed:' + uid, {...(readCachedData('profile-seed:' + uid) || {}), relationship: 'pending_outbound'});
window.setTimeout(() => navigate('group-member-profile', uid), 800);
}).catch(err => onToast(err.message || '发送失败')).finally(() => setSending(false));
};
return <div className="people-page"><Header title="好友验证" back="group-member-profile" backUid={uid} /><main className="people-verify">
<Avatar person={profile} /><h2>{(profile && profile.display_name) || ''}</h2>
{alreadyFriend ? <p className="people-empty-hint">你们已经是好友了</p> :
alreadyPending || sent ? <p className="people-empty-hint">好友申请正在等待验证</p> :
<label><span>验证留言</span><textarea value={message} maxLength="50" placeholder="我是祈万事安好" onChange={event => setMessage(event.target.value)} /><small>{message.length}/50</small></label>}
{alreadyFriend || alreadyPending || sent ? null : <div className="people-verify-actions">
<button type="button" onClick={() => navigate('group-member-profile', uid)}>取消</button>
<button type="button" className="people-primary" disabled={sending} onClick={send}>{sending ? '发送中…' : '发送'}</button>
</div>}
</main></div>;
}
function Profile({friend, onToast}) {
const person = friend ? FRIENDS[0] : FRIENDS[2];
return <div className="people-page"><Header title="个人资料" back={friend ? 'address-book' : 'chat-standard'} right={friend ? '设置' : null} onRight={() => navigate('friend-settings')} /><main className="people-profile"><section><Avatar person={person} /><div><h2>{person.remark || person.name}</h2><p>{person.remark ? `昵称:${person.name}` : '产品研发群成员'}</p><p>账号:{person.account}</p></div><Icon>qr_code_2</Icon></section><dl><div><dt>性别</dt><dd>女</dd></div><div><dt>地区</dt><dd>上海 浦东新区</dd></div><div><dt>个性签名</dt><dd>保持好奇,认真生活。</dd></div></dl>{friend ? <div className="people-profile-actions"><button type="button" className="people-primary" onClick={() => navigate('chat-standard')}><Icon>chat_bubble_outline</Icon>发消息</button><button type="button" onClick={() => navigate('call-history')}><Icon>call</Icon>音视频通话</button></div> : <button type="button" className="people-primary" onClick={() => navigate('friend-verification')}><Icon>person_add_alt</Icon>添加好友</button>}</main></div>;
function Profile({uid, friend, onTopicSelected, onToast}) {
const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null));
// A failed fetch with no seed to fall back on previously left this stuck on "加载中…" forever --
// tracked separately from `profile` itself (which legitimately stays null while genuinely still
// loading) so the render below can tell the two apart, same fix as DownloadPage's `loadError`.
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!uid) return;
getUserProfile(uid).then(data => {
writeCachedData('profile-seed:' + uid, data);
setProfile(data);
}).catch(() => {
// Expected for anyone who isn't a friend yet (server-side privacy default) -- if a search/QR
// seed already got us a usable preview, keep showing that instead of erroring the page out.
// Only a total stranger with no seed data at all (e.g. a stale/direct deep link) has nothing to
// show, and that's the one case worth telling the user about.
setProfile(current => {
if (!current) { onToast('资料加载失败'); setLoadError(true); }
return current;
});
});
}, [uid]);
if (!profile) return <div className="people-page"><Header title="个人资料" back={friend ? 'address-book' : 'chat-standard'} /><main className="people-profile"><p className="people-empty-hint">{loadError ? '资料加载失败,请重试' : '加载中…'}</p></main></div>;
const canAdd = profile.capabilities && profile.capabilities.can_add_friend;
const isFriend = profile.relationship === 'friend';
// canAdd===false covers several real, distinct relationships (found live: searching a friend who
// had blocked me showed this exact page claiming a request was "pending verification" when nothing
// had been sent and nothing ever could be -- they'd blocked me). Only pending_outbound actually
// matches that wording; the others need their own accurate text instead of reusing it as a catch-all.
const cannotAddReason = profile.relationship === 'blocked_by_peer' ? '对方已将你拉黑,无法添加为好友' :
profile.relationship === 'blocked_by_me' ? '你已拉黑对方,需先取消拉黑才能添加为好友' :
profile.relationship === 'pending_inbound' ? '对方已向你发送好友申请,请前往"新朋友"处理' :
'好友申请正在等待验证';
// Real native Tinode p2p topic name (from my side) is just the peer's own user_id ("usrXXXX") --
// NOT `profile.topic` (a separate "p2pXXXX" identifier the custom REST layer uses internally,
// which Tinode.topicType() doesn't recognize, so selecting it left the message pane blank).
const openChat = () => onTopicSelected(uid);
return <div className="people-page"><Header title="个人资料" back={friend ? 'address-book' : 'chat-standard'}
right={isFriend ? '设置' : null} onRight={() => navigate('friend-settings', uid)} />
<main className="people-profile">
<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}
<div><dt>个性签名</dt><dd className={profile.signature ? '' : 'muted'}>{profile.signature || '此人很懒什么都没留下~'}</dd></div>
</dl>
{isFriend ? <div className="people-profile-actions">
<button type="button" className="people-primary" onClick={openChat}><Icon>chat_bubble_outline</Icon>发消息</button>
<button type="button" onClick={openChat}><Icon>call</Icon>音视频通话</button>
</div> :
canAdd ? <button type="button" className="people-primary" onClick={() => navigate('friend-verification', uid)}><Icon>person_add_alt</Icon>添加好友</button> :
<p className="people-empty-hint">{cannotAddReason}</p>}
</main>
</div>;
}
function Toggle({checked, onChange}) { return <button type="button" role="switch" aria-checked={checked} className={'people-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; }
function Settings({initialBlocked, onToast}) {
function Settings({uid, initialBlocked, onToast}) {
// Same seed-from-what-we-already-know as Profile/Verification -- getUserProfile can 403 even for a
// real, current friend (see seedProfile's own comment), which used to leave this page's header
// blank instead of just stuck like Profile, but no less wrong.
const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null));
const [blocked, setBlocked] = React.useState(initialBlocked);
const [pinned, setPinned] = React.useState(false);
const [muted, setMuted] = React.useState(false);
const [blocked, setBlocked] = React.useState(initialBlocked);
return <div className="people-page"><Header title="聊天设置" back="friend-profile" /><main className="people-settings"><section className="people-settings-profile"><Avatar person={FRIENDS[0]} /><strong>安妮 · 产品</strong></section><section><button type="button" onClick={() => navigate('history-search')}><span>查找聊天记录</span><Icon>chevron_right</Icon></button><button type="button"><span>消息保存方式</span><small>永久</small><Icon>chevron_right</Icon></button></section><section><div><span>置顶聊天</span><Toggle checked={pinned} onChange={setPinned} /></div><div><span>消息免打扰</span><Toggle checked={muted} onChange={setMuted} /></div><div><span>加入黑名单</span><Toggle checked={blocked} onChange={value => value ? navigate('block-confirm') : (setBlocked(false), onToast('已移出黑名单'))} /></div></section><section><button type="button"><span>设置备注</span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('abuse-report-reasons')}><span>投诉与举报</span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('clear-chat-confirm')}><span>清除聊天记录</span><Icon>chevron_right</Icon></button></section><button type="button" className="people-danger" onClick={() => navigate('friend-delete-confirm')}>删除好友</button></main></div>;
const [editingRemark, setEditingRemark] = React.useState(false);
const [remark, setRemark] = React.useState('');
const [remarkDraft, setRemarkDraft] = React.useState('');
React.useEffect(() => {
if (!uid) return;
getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); setBlocked(p.relationship === 'blocked_by_me'); }).catch(() => {});
// 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 --
// folding an empty remark into display_name instead of returning a separate field), only the
// friends list endpoint's friendListItemPayload does. Look this friend up there instead.
getFriends().then(data => {
const entry = (data.items || []).find(item => item.user_id === uid);
if (entry) setRemark(entry.remark || '');
}).catch(() => {});
}, [uid]);
const saveRemark = () => {
const value = remarkDraft.trim();
updateFriendRemark(uid, value).then(() => {
setRemark(value);
setEditingRemark(false); onToast('备注已更新');
}).catch(err => onToast(err.message || '备注更新失败'));
};
return <div className="people-page"><Header title="聊天设置" back="friend-profile" backUid={uid} /><main className="people-settings">
<section className="people-settings-profile"><Avatar person={profile} /><strong>{(profile && profile.display_name) || ''}</strong></section>
<section><button type="button" onClick={() => HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'history-search'), 'topic', uid))}>
<span>查看聊天记录</span><Icon>chevron_right</Icon>
</button>
<div><span>消息传输方式</span><small>已启动加密传输</small></div></section>
<section><div><span>置顶聊天</span><Toggle checked={pinned} onChange={setPinned} /></div>
<div><span>消息免打扰</span><Toggle checked={muted} onChange={setMuted} /></div>
<button type="button" onClick={() => navigate('clear-chat-confirm', uid)}><span>清空聊天记录</span><Icon>chevron_right</Icon></button>
{editingRemark ?
<div className="people-remark-edit"><input value={remarkDraft} maxLength="20" onChange={e => setRemarkDraft(e.target.value)} autoFocus /><button type="button" onClick={saveRemark}>保存</button></div> :
<button type="button" onClick={() => { setRemarkDraft(remark); setEditingRemark(true); }}><span>备注</span>{remark ? <small>{remark}</small> : null}<Icon>chevron_right</Icon></button>}
<div><span>{blocked ? '取消拉黑' : '拉黑'}</span><Toggle checked={!!blocked} onChange={value => {
if (value) { navigate('block-confirm', uid); return; }
unblockUser(uid).then(() => { setBlocked(false); onToast('已移出黑名单'); }).catch(err => onToast(err.message || '操作失败'));
}} /></div>
<button type="button" onClick={() => {
// The local navigate(view, uid) helper only carries a single `uid` param -- the report
// flow (security-support-view.jsx's ReportReasons/ReportDetails) needs target_type/
// target_id/target_name, so build the hash directly here rather than through it.
let hash = HashNavigation.setUrlSidePanel(window.location.hash, 'abuse-report-reasons');
hash = HashNavigation.addUrlParam(hash, 'target_type', 'user');
hash = HashNavigation.addUrlParam(hash, 'target_id', uid);
hash = HashNavigation.addUrlParam(hash, 'target_name', (profile && profile.display_name) || uid);
HashNavigation.navigateTo(hash);
}}><span>投诉与举报</span><Icon>chevron_right</Icon></button>
</section>
{blocked ? null : <button type="button" className="people-danger" onClick={() => navigate('friend-delete-confirm', uid)}>删除好友</button>}
</main></div>;
}
function BlockList({onToast}) {
const [people, setPeople] = React.useState([FRIENDS[3], FRIENDS[2]]);
return <div className="people-page"><Header title="黑名单" back="friend-settings" /><main className="people-block-list">{people.length ? people.map(person => <article key={person.id}><Avatar person={person} /><span><strong>{person.name}</strong><small>已阻止消息和通话</small></span><button type="button" onClick={() => {setPeople(items => items.filter(item => item.id !== person.id)); onToast('已移出黑名单');}}>移出</button></article>) : <div className="people-empty"><Icon>person_off</Icon><strong>黑名单为空</strong></div>}</main></div>;
const [people, setPeople] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => { getBlocks().then(data => setPeople(data.items || [])).catch(() => {}).finally(() => setLoading(false)); }, []);
const remove = userId => {
unblockUser(userId).then(() => { setPeople(items => items.filter(item => item.user_id !== userId)); onToast('已移出黑名单'); })
.catch(err => onToast(err.message || '操作失败'));
};
// The only entry point into this view is personal-settings-view.jsx's "设置" list
// ('android-settings'), not friend-settings -- this page has no friend uid in scope at all.
return <div className="people-page"><Header title="黑名单" back="android-settings" /><main className="people-block-list">
{loading ? <p className="people-empty-hint">加载中…</p> : people.length ?
people.map(item => <article key={item.user_id}><Avatar person={item.user} /><span><strong>{(item.user && item.user.display_name) || item.user_id}</strong><small>已阻止消息和通话</small></span><button type="button" onClick={() => remove(item.user_id)}>移出</button></article>) :
<div className="people-empty"><Icon>person_off</Icon><strong>黑名单为空</strong></div>}
</main></div>;
}
function ConfirmSheet({type, onToast}) {
function ConfirmSheet({type, uid, tinode, onToast}) {
const [busy, setBusy] = React.useState(false);
const config = {
delete: {title: '删除好友', text: '删除后将解除好友关系,对方将无法继续向你发送消息。聊天记录仍会保留。', action: '确认删除', route: 'address-book', toast: '好友已删除'},
block: {title: '加入黑名单', text: '加入黑名单后,你将不再收到对方的消息和通话。', action: '确认拉黑', route: 'friend-settings-blocked', toast: '已加入黑名单'},
clear: {title: '清除聊天记录', text: '仅清除你自己的聊天记录,不会影响对方,也不会删除已收藏内容。', action: '确认清除', route: 'friend-settings', toast: '聊天记录已清除'}
delete: {title: '删除好友', text: '删除后将解除好友关系,对方将无法继续向你发送消息。聊天记录仍会保留。', action: '确认删除', route: 'address-book', toast: '好友已删除', keepUid: false},
block: {title: '黑名单', text: '拉黑后将不会收到对方的消息,可在"设置"—"黑名单"查看人员名单', action: '确定', route: 'friend-settings-blocked', toast: '已加入黑名单', keepUid: true},
clear: {title: '清除聊天记录', text: '清空后不可找回,确定要清空当前会话的历史记录吗?', action: '确定', route: 'friend-settings', toast: '聊天记录已清除', keepUid: true}
}[type];
return <div className="people-page"><Settings initialBlocked={type === 'block'} onToast={onToast} /><div className="people-sheet-backdrop"><section className="people-confirm-sheet" role="dialog" aria-modal="true"><i /><h2>{config.title}</h2><p>{config.text}</p><button type="button" className="danger" onClick={() => {onToast(config.toast); window.setTimeout(() => navigate(config.route), 700);}}>{config.action}</button><button type="button" onClick={() => navigate('friend-settings')}>取消</button></section></div></div>;
const confirm = () => {
setBusy(true);
let call;
if (type === 'delete') call = deleteFriend(uid);
else if (type === 'block') call = blockUser(uid);
else {
// No dedicated "clear my view of history" REST endpoint exists yet -- this is the same native
// Tinode soft-delete-all-for-me the real message-actions menu already uses (context-menu.jsx).
// Native p2p topic name (from my side) is just the peer's own user_id -- NOT the relationship
// context's `topic` field, which is a separate "p2pXXXX" id the REST layer uses internally and
// Tinode.topicType() doesn't recognize (see Profile.openChat for the full story).
const topic = tinode ? tinode.getTopic(uid) : null;
call = topic ? topic.delMessagesAll(false) : Promise.resolve();
}
call.then(() => { onToast(config.toast); navigate(config.route, config.keepUid ? uid : undefined); })
.catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false));
};
return <div className="people-page"><Settings uid={uid} initialBlocked={type === 'block'} onToast={onToast} />
<div className="people-sheet-backdrop"><section className="people-confirm-sheet" role="dialog" aria-modal="true"><i /><h2>{config.title}</h2><p>{config.text}</p>
<button type="button" className="danger" disabled={busy} onClick={confirm}>{config.action}</button>
<button type="button" onClick={() => navigate('friend-settings', uid)}>取消</button>
</section></div>
</div>;
}
export default function ContactManagementView({view}) {
export default function ContactManagementView({view, hashParams, myUserId, tinode, onTopicSelected, onOpenScanner}) {
const [toast, setToast] = React.useState('');
const uid = hashParams && hashParams.uid;
let content;
if (view === 'address-book') content = <AddressBook />;
if (view === 'address-book') content = <AddressBook myUserId={myUserId} onTopicSelected={onTopicSelected} />;
else if (view === 'friend-requests') content = <FriendRequests onToast={setToast} />;
else if (view === 'add-friend' || view === 'friend-search-result') content = <AddFriend result={view === 'friend-search-result'} />;
else if (view === 'add-friend' || view === 'friend-search-result') content = <AddFriend result={view === 'friend-search-result'} onToast={setToast} onOpenScanner={onOpenScanner} />;
else if (view === 'my-friend-qr') content = <QrPage onToast={setToast} />;
else if (view === 'friend-verification') content = <Verification onToast={setToast} />;
else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile friend={view === 'friend-profile'} onToast={setToast} />;
else if (view === 'friend-settings' || view === 'friend-settings-blocked') content = <Settings initialBlocked={view === 'friend-settings-blocked'} onToast={setToast} />;
else if (view === 'friend-verification') content = <Verification uid={uid} onToast={setToast} />;
else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile uid={uid} friend={view === 'friend-profile'} onTopicSelected={onTopicSelected} onToast={setToast} />;
else if (view === 'friend-settings' || view === 'friend-settings-blocked') content = <Settings uid={uid} initialBlocked={view === 'friend-settings-blocked'} onToast={setToast} />;
else if (view === 'block-list') content = <BlockList onToast={setToast} />;
else if (view === 'friend-delete-confirm') content = <ConfirmSheet type="delete" onToast={setToast} />;
else if (view === 'block-confirm') content = <ConfirmSheet type="block" onToast={setToast} />;
else content = <ConfirmSheet type="clear" onToast={setToast} />;
else if (view === 'friend-delete-confirm') content = <ConfirmSheet type="delete" uid={uid} tinode={tinode} onToast={setToast} />;
else if (view === 'block-confirm') content = <ConfirmSheet type="block" uid={uid} tinode={tinode} onToast={setToast} />;
else content = <ConfirmSheet type="clear" uid={uid} tinode={tinode} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
}
+477 -81
View File
@@ -1,8 +1,41 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import { Drafty, Tinode } from 'tinode-sdk';
const navigate = view => HashNavigation.navigateTo(view);
import AudioPlayer from '../widgets/audio-player.jsx';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
import {
getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader,
searchTopicMessages, getMessageContext, getTopicMedia, checkURL, getFileUrl, getGroup, getUserProfile
} from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import { topicDisplayName } from '../lib/utils.js';
// favoritePayload's source.topic is the raw conversation topic id (grpXXX/usrXXX) -- resolve it to a
// display name the same way every other list in this app does, cached since many favorites usually
// share a handful of source conversations.
const sourceNameCache = new Map();
function resolveSourceName(topic, onDone) {
if (!topic) return;
if (sourceNameCache.has(topic)) { onDone(sourceNameCache.get(topic)); return; }
if (Tinode.isSelfTopicName(topic)) { sourceNameCache.set(topic, '已保存的消息'); onDone('已保存的消息'); return; }
const call = topic.startsWith('grp') ? getGroup(topic).then(g => g.name) : getUserProfile(topic).then(p => p.display_name);
call.then(name => { sourceNameCache.set(topic, name); onDone(name); }).catch(() => {});
}
// Generic multi-param navigate -- content-library-view.jsx's original helper was a bare
// `HashNavigation.navigateTo(view)`, replacing the whole hash and dropping any existing params on
// every internal tab/link click; nothing in this file could carry a topic/favoriteId through its own
// navigation even if a caller threaded one in from outside.
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
};
export const CONTENT_LIBRARY_VIEWS = [
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
@@ -11,121 +44,484 @@ export const CONTENT_LIBRARY_VIEWS = [
'history-links', 'history-images', 'history-videos'
];
const FAVORITES = [
{id: 'text', type: 'text', text: '评审结论已经整理好了,下午三点一起确认新版流程。', source: '产品研发群', time: '今天 10:32'},
{id: 'file', type: 'file', name: '产品需求说明书.pdf', size: '2.4 MB', source: '产品研发群', time: '昨天 18:40'},
{id: 'voice', type: 'voice', duration: 12, source: '安妮', time: '星期一 09:20'},
{id: 'link', type: 'link', title: '即时通讯产品体验设计指南', summary: '从消息模型、会话体验到文件安全的完整设计方法。', url: 'design.example.com', source: '设计共创小组', time: '8月20日'},
{id: 'image', type: 'image', source: '产品研发群', time: '8月18日', image: 'img/bkg/p22.jpg'},
{id: 'video', type: 'video', source: '新版发布协作群', time: '8月16日', image: 'img/bkg/p23.jpg', duration: '00:24'}
];
const TARGETS = [
{id: 'assistant', name: '文件助手', note: '仅自己可见', icon: 'folder_special'},
{id: 'product', name: '产品研发群', note: '18人', image: 'img/bkg/p22.jpg'},
{id: 'annie', name: '安妮', note: '在线', image: 'img/bkg/p09.jpg'},
{id: 'lizi', name: '栗子来了', note: '刚刚活跃', image: 'img/bkg/p15.jpg'}
];
const FILE_RECORDS = [
{id: 'f1', name: '产品需求说明书.pdf', size: '2.4 MB', sender: '陈晨', time: '今天 10:20', state: 'available'},
{id: 'f2', name: '交互原型最终版.fig', size: '18.6 MB', sender: '安妮', time: '昨天 18:42', state: 'available'},
{id: 'f3', name: '旧版本接口说明.docx', size: '760 KB', sender: '栗子来了', time: '8月18日', state: 'removed'}
];
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'messages-home', right, onRight}) { return <header className="library-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>; }
function Avatar({item}) { return <span className="library-avatar">{item.image ? <img src={item.image} alt="" /> : <Icon>{item.icon || 'person'}</Icon>}</span>; }
function Toast({message, onDone}) { React.useEffect(() => {if (!message) return undefined; const timer = window.setTimeout(onDone, 2000); return () => window.clearTimeout(timer);}, [message, onDone]); return message ? <div className="chat-toast" role="status">{message}</div> : null; }
function Header({title, back = 'messages-home', backParams, right, onRight}) {
return <header className="library-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backParams); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}
</header>;
}
function Avatar({item}) { return <span className="library-avatar">{item && item.image ? <img src={item.image} alt="" /> : <Icon>{(item && item.icon) || 'person'}</Icon>}</span>; }
function Toast({message, onDone}) { React.useEffect(() => { if (!message) return undefined; const timer = window.setTimeout(onDone, 2000); return () => window.clearTimeout(timer); }, [message, onDone]); return message ? <div className="chat-toast" role="status">{message}</div> : null; }
function Search({value, onChange, placeholder = '搜索'}) { return <label className="library-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} /></label>; }
function FileCard({item, onOpen}) {
const removed = item.state === 'removed';
return <button type="button" className={'library-file-card' + (removed ? ' removed' : '')} onClick={removed ? undefined : onOpen}><span>PDF</span><div><strong>{item.name}</strong><small>{item.size}{item.sender ? ` · ${item.sender}` : ''}</small></div>{removed ? <em>文件已失效</em> : <Icon>chevron_right</Icon>}</button>;
// -- Drafty extraction helpers: a favorite's snapshot.content (and a history item's flattened content
// fields) are literally the original message's own content, not a Ch.7-specific shape -- reuse the same
// Drafty API chat-message.jsx/context-menu.jsx already use for live messages instead of re-deriving it.
function draftyContentOf(content) {
return Drafty.isValid(content) ? content : null;
}
function firstAttachment(content) {
const drafty = draftyContentOf(content);
if (!drafty) return null;
let found = null;
Drafty.attachments(drafty, att => { if (!found) found = att; });
return found;
}
// Drafty.getDownloadUrl returns either an already-usable blob: URL (small inband attachment, .val) or
// the raw .ref relative path (out-of-band, needs an authenticated fetch) -- getFileUrl() takes a bare
// file_id and builds that path itself, so pull just the id back out of .ref rather than double it up.
function resolveAttachmentUrl(att) {
if (!att) return Promise.resolve(null);
const direct = Drafty.getDownloadUrl(att);
if (!direct) return Promise.resolve(null);
if (direct.startsWith('blob:') || direct.startsWith('data:')) return Promise.resolve(direct);
return getFileUrl(direct.slice(direct.lastIndexOf('/') + 1)).catch(() => null);
}
// searchTopicMessages/getTopicMedia results spread the original Drafty content's own fields directly
// onto each item (mediaMessagePayload/messageSearchPayload flatten `content`, they don't nest it) --
// reconstruct the Drafty shape from whatever ent/fmt/txt survived so the same attachment-extraction
// path works here as everywhere else, rather than guessing at flat field names that may not exist.
function attachmentFromFlat(item) {
if (item && (item.ent || item.fmt || item.txt !== undefined)) {
return firstAttachment({txt: item.txt, fmt: item.fmt, ent: item.ent});
}
return null;
}
function plainTextOf(content, fallbackText) {
if (typeof content === 'string') return content;
const drafty = draftyContentOf(content);
if (drafty) return Drafty.toPlainText(drafty);
return fallbackText || '';
}
// file_type -> {icon, color} matching Ch.6's group-file convention (a colored square + letter/icon).
const FILE_TYPE_META = {image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'], document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']};
function extFromName(name) {
const m = /\.([a-zA-Z0-9]+)$/.exec(name || '');
return m ? m[1].toUpperCase() : '';
}
function VoiceCard({item, playing, onPlay}) {
return <button type="button" className={'library-voice-card' + (playing ? ' playing' : '')} onClick={onPlay}><Icon>{playing ? 'pause_circle' : 'play_circle'}</Icon><span className="library-wave" /><strong>{item.duration}″</strong></button>;
function FileCard({name, size, mime, sender, removed, onOpen}) {
const ext = extFromName(name);
return <button type="button" className={'library-file-card' + (removed ? ' removed' : '')} onClick={removed ? undefined : onOpen}>
<span>{ext || (mime || '').split('/')[0].toUpperCase() || '?'}</span>
<div><strong>{name || '未命名文件'}</strong><small>{typeof size === 'number' ? bytesToHumanSize(size) : (size || '未知大小')}{sender ? ` · ${sender}` : ''}</small></div>
{removed ? <em>文件已失效</em> : <Icon>chevron_right</Icon>}
</button>;
}
function LinkCard({item, onOpen}) {
return <button type="button" className="library-link-card" onClick={onOpen}><img src="img/bkg/a03.jpg" alt="链接缩略图" /><div><strong>{item.title}</strong><p>{item.summary}</p><small><Icon>link</Icon>{item.url}</small></div></button>;
// Real playback via the same widgets/audio-player.jsx used for live voice messages, rather than the
// mock's plain state-toggle with no actual <audio> element -- resolves its own blob URL like Avatar
// does, so callers don't need their own per-item URL-map bookkeeping just for voice favorites.
function VoiceCard({att, head}) {
const [src, setSrc] = React.useState(null);
React.useEffect(() => { resolveAttachmentUrl(att).then(setSrc); }, [att]);
const duration = (att && att.duration) || (head && head.duration) || 0;
return <div className="library-voice-card">
{src ? <AudioPlayer short src={src} duration={duration} preview={att && att.preview} /> : <Icon>hourglass_empty</Icon>}
</div>;
}
function FavoriteItem({item, playing, onPlay, onOpen, selecting, selected, onSelect}) {
// No OG-metadata extraction exists anywhere in the backend (favoriteSnapshot/mediaMessagePayload just
// copy the raw message content -- no title/thumbnail/summary field is ever populated), matching what
// every one of the reference screenshots for both 我的收藏-链接 and 查找聊天记录-链接 actually show: a
// plain URL string, not a card. Rendering a rich card here would be inventing data that doesn't exist.
function LinkText({url, onOpen}) {
return <button type="button" className="library-link-text" onClick={() => onOpen && onOpen(url)}><Icon>link</Icon><span>{url}</span></button>;
}
function MediaThumb({url, video, duration, onOpen}) {
return <button type="button" className="library-media-thumb" onClick={onOpen}>
{url ? <img src={url} alt={video ? '视频缩略图' : '图片缩略图'} /> : <Icon>{video ? 'videocam' : 'image'}</Icon>}
{video ? <><Icon>play_circle</Icon>{duration ? <small>{secondsToTime(duration)}</small> : null}</> : null}
</button>;
}
function FavoriteItem({item, onOpen, selecting, selected, onSelect}) {
const att = firstAttachment(item.snapshot && item.snapshot.content);
const source = item.source_display || (item.source && item.source.topic) || '';
return <article className={'library-favorite-item' + (selecting ? ' selecting' : '')}>
{selecting ? <button type="button" className={'library-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择收藏"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
<div className="library-favorite-content">
{item.type === 'text' ? <p className="library-text-content">{item.text}</p> : null}
{item.type === 'file' ? <FileCard item={item} onOpen={onOpen} /> : null}
{item.type === 'voice' ? <VoiceCard item={item} playing={playing} onPlay={onPlay} /> : null}
{item.type === 'link' ? <LinkCard item={item} onOpen={onOpen} /> : null}
{item.type === 'image' || item.type === 'video' ? <button type="button" className="library-media-thumb" onClick={onOpen}><img src={item.image} alt={item.type === 'image' ? '收藏图片' : '收藏视频'} />{item.type === 'video' ? <><Icon>play_circle</Icon><small>{item.duration}</small></> : null}</button> : null}
<footer><span>来自 {item.source}</span><time>{item.time}</time><button type="button" aria-label="更多操作"><Icon>more_horiz</Icon></button></footer>
{item.type === 'text' ? <p className="library-text-content">{plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}</p> : null}
{item.type === 'file' ? <FileCard name={att && att.name} size={att && Drafty.getEntitySize(att)} mime={att && att.mime} onOpen={onOpen} /> : null}
{item.type === 'voice' ? <VoiceCard att={att} head={item.snapshot && item.snapshot.head} /> : null}
{item.type === 'link' ? <LinkText url={(item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)} onOpen={onOpen} /> : null}
{item.type === 'image' || item.type === 'video' ? <MediaThumb url={item._thumbUrl} video={item.type === 'video'} onOpen={onOpen} /> : null}
<footer><span>来自 {source}</span><time>{shortDateFormat(new Date(item.collected_at))}</time></footer>
</div>
</article>;
}
const TYPE_ROUTE = {all: 'favorites-mixed', file: 'favorites-files', text: 'favorites-text', voice: 'favorites-voice', link: 'favorites-links'};
const TYPE_LABELS = [['all', '全部'], ['text', '文本'], ['image', '图片'], ['video', '视频'], ['voice', '语音'], ['file', '文件'], ['link', '链接']];
const TYPE_LABELS = [['all', '全部'], ['image', '图片'], ['video', '视频'], ['file', '文件'], ['voice', '语音'], ['text', '文本'], ['link', '链接']];
function Favorites({view, onToast}) {
function Favorites({view, tinode, onToast}) {
const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : 'all';
const selecting = view === 'favorites-select';
const [type, setType] = React.useState(selecting ? 'file' : initialType);
const [query, setQuery] = React.useState('');
const [playing, setPlaying] = React.useState(null);
const [selected, setSelected] = React.useState(selecting ? ['file'] : []);
const [items, setItems] = React.useState(FAVORITES);
const visible = items.filter(item => (type === 'all' || item.type === type) && JSON.stringify(item).toLowerCase().includes(query.toLowerCase()));
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id));
const deleteSelected = () => {setItems(values => values.filter(item => !selected.includes(item.id))); setSelected([]); onToast('已删除所选收藏');};
return <div className="library-page"><Header title={selecting ? `已选择 ${selected.length} 项` : '我的收藏'} back={selecting ? 'favorites-files' : 'messages-home'} right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? 'favorites-files' : 'favorites-select')} /><Search value={query} onChange={setQuery} placeholder="搜索收藏内容" /><nav className="library-type-tabs">{TYPE_LABELS.map(([id, label]) => <button type="button" key={id} className={type === id ? 'active' : ''} onClick={() => {setType(id); if (TYPE_ROUTE[id] && !selecting) navigate(TYPE_ROUTE[id]);}}>{label}</button>)}</nav><main className="library-favorite-list">{visible.map(item => <FavoriteItem key={item.id} item={item} selecting={selecting} selected={selected.includes(item.id)} onSelect={() => toggle(item.id)} playing={playing === item.id} onPlay={() => setPlaying(playing === item.id ? null : item.id)} onOpen={() => item.type === 'file' ? navigate('file-download') : item.type === 'link' ? onToast('链接安全检查通过,可继续访问') : onToast('已打开收藏详情')} />)}{!visible.length ? <div className="library-empty"><Icon>bookmark_border</Icon><strong>暂无此类收藏</strong></div> : null}</main>{selecting ? <nav className="library-batch-bar"><button type="button" disabled={!selected.length} onClick={() => navigate('favorite-share')}><Icon>share</Icon><span>转发</span></button><button type="button" disabled={!selected.length} onClick={deleteSelected}><Icon>delete_outline</Icon><span>删除</span></button></nav> : null}</div>;
const [selected, setSelected] = React.useState([]);
// Seeded per-type -- one Favorites instance backs every favorites* tab/route (confirmed multiple
// entry points: personal-settings' "我的收藏" row, direct-message-view.jsx's attachment-panel
// "favorite" action), so an unparameterized key would flash a stale, wrong-type list under a
// freshly-switched tab. `query` is deliberately excluded -- it always resets to '' on a tab switch,
// so caching a search result under the same key would show it under a plain (non-search) revisit.
const [items, setItems] = React.useState(() => readCachedData('favorites:' + type) || null);
const [cursor, setCursor] = React.useState('');
const [hasMore, setHasMore] = React.useState(false);
const [fileUrls, setFileUrls] = React.useState({});
const [sourceNames, setSourceNames] = React.useState({});
const load = React.useCallback((append) => {
getFavorites({type, query, cursor: append ? cursor : ''}).then(data => {
setItems(prev => {
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
if (!query) writeCachedData('favorites:' + type, list);
return list;
});
setCursor(data.next_cursor || '');
setHasMore(!!data.next_cursor);
}).catch(err => onToast(err.message || '加载失败'));
}, [type, query, cursor]);
React.useEffect(() => {
setItems((!query && readCachedData('favorites:' + type)) || null);
load(false);
}, [type, query]);
// Image/video favorites need an authenticated blob URL, same as any other file_id-backed thumbnail.
React.useEffect(() => {
(items || []).forEach(item => {
if ((item.type !== 'image' && item.type !== 'video') || fileUrls[item.favorite_id]) return;
const att = firstAttachment(item.snapshot && item.snapshot.content);
resolveAttachmentUrl(att).then(url => { if (url) setFileUrls(v => ({...v, [item.favorite_id]: url})); });
});
}, [items]);
React.useEffect(() => {
(items || []).forEach(item => {
const topic = item.source && item.source.topic;
if (!topic || sourceNames[topic]) return;
resolveSourceName(topic, name => name && setSourceNames(v => ({...v, [topic]: name})));
});
}, [items]);
const toggle = id => setSelected(values => values.includes(id) ? values.filter(v => v !== id) : values.concat(id));
const deleteSelected = () => {
if (!window.confirm(`确定删除已选择的 ${selected.length} 项收藏吗?`)) return;
batchDeleteFavorites(selected).then(result => {
const okIds = new Set(((result && result.items) || []).filter(r => r.ok).map(r => r.favorite_id));
setItems(values => {
const next = values.filter(item => !okIds.has(item.favorite_id));
if (!query) writeCachedData('favorites:' + type, next);
return next;
});
setSelected(values => values.filter(id => !okIds.has(id)));
onToast(okIds.size === selected.length ? '已删除所选收藏' : `已删除 ${okIds.size}/${selected.length} 项,部分删除失败`);
}).catch(err => onToast(err.message || '删除失败'));
};
const openItem = item => {
if (item.type === 'file') { navigate('file-download', {favoriteId: item.favorite_id}); return; }
if (item.type === 'link') {
const url = (item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text);
checkURL(url, 'favorite').then(res => {
if (res.decision === 'allow') window.open(url, '_blank', 'noopener,noreferrer');
else onToast(res.decision === 'block' ? '该链接存在风险,已被拦截' : '该链接可能存在风险,请谨慎访问');
}).catch(() => onToast('链接安全校验失败'));
return;
}
onToast('暂不支持预览此类收藏');
};
const list = items || [];
return <div className="library-page">
<Header title={selecting ? `已选择 ${selected.length} 项` : '我的收藏'} back={selecting ? 'favorites-files' : 'messages-home'}
right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? 'favorites-files' : 'favorites-select')} />
<Search value={query} onChange={setQuery} placeholder="搜索收藏内容" />
<nav className="library-type-tabs">{TYPE_LABELS.map(([id, label]) => <button type="button" key={id} className={type === id ? 'active' : ''}
onClick={() => { setType(id); if (TYPE_ROUTE[id] && !selecting) navigate(TYPE_ROUTE[id]); }}>{label}</button>)}</nav>
<main className="library-favorite-list">
{items === null ? <p className="library-empty-hint">加载中…</p> :
list.length ? <>{list.map(item => <FavoriteItem key={item.favorite_id} item={{...item, _thumbUrl: fileUrls[item.favorite_id],
source_display: sourceNames[item.source && item.source.topic]}}
selecting={selecting} selected={selected.includes(item.favorite_id)} onSelect={() => toggle(item.favorite_id)}
onOpen={() => openItem(item)} />)}
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}</> :
<div className="library-empty"><Icon>bookmark_border</Icon><strong>暂无此类收藏</strong></div>}
</main>
{selecting ? <nav className="library-batch-bar">
<button type="button" disabled={!selected.length} onClick={() => navigate('favorite-share', {ids: selected.join(',')})}><Icon>share</Icon><span>转发</span></button>
<button type="button" disabled={!selected.length} onClick={deleteSelected}><Icon>delete_outline</Icon><span>删除</span></button>
</nav> : null}
</div>;
}
function FavoriteShare({onToast}) {
const [query, setQuery] = React.useState(''); const [selected, setSelected] = React.useState([]); const [confirming, setConfirming] = React.useState(false); const [comment, setComment] = React.useState('');
const visible = TARGETS.filter(item => `${item.name}${item.note}`.includes(query));
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id).slice(0, 9));
return <div className="library-page"><Header title="分享收藏文件" back="favorites-select" />{!confirming ? <><Search value={query} onChange={setQuery} placeholder="搜索联系人或群聊" /><p className="library-section-title">最近会话</p><main className="library-target-list">{visible.map(item => <button type="button" key={item.id} onClick={() => toggle(item.id)}><Avatar item={item} /><span><strong>{item.name}</strong><small>{item.note}</small></span><i className={'library-check' + (selected.includes(item.id) ? ' checked' : '')}><Icon>{selected.includes(item.id) ? 'check' : ''}</Icon></i></button>)}</main><footer className="library-next-bar"><span>已选择 {selected.length}/9</span><button type="button" disabled={!selected.length} onClick={() => setConfirming(true)}>下一步</button></footer></> : <main className="library-share-confirm"><h2>发送给</h2><div className="library-target-summary">{TARGETS.filter(item => selected.includes(item.id)).map(item => <span key={item.id}><Avatar item={item} /><small>{item.name}</small></span>)}</div><section><FileCard item={FAVORITES[1]} /><p>收藏原件会继续保留,并在目标会话中生成一条新文件消息。</p></section><textarea value={comment} onChange={event => setComment(event.target.value)} placeholder="给朋友留言" /><button type="button" onClick={() => {onToast(`已分享给 ${selected.length} 个会话`); window.setTimeout(() => navigate('favorites-files'), 700);}}>发送</button></main>}</div>;
// Reuses the exact same real-contact-list source as Ch.4's batch-forward-dialog.jsx (the app's own
// already-subscribed chatList, filtered locally) rather than the pre-built but never-actually-wired
// getForwardTargets() -- that endpoint's field shape was never verified against a real caller anywhere
// in this codebase, while this pattern is proven live since Chapter 4.
function FavoriteShare({hashParams, myUserId, chatList, onToast}) {
const ids = (hashParams && hashParams.ids ? hashParams.ids.split(',') : []).filter(Boolean);
const [query, setQuery] = React.useState('');
const [selected, setSelected] = React.useState([]);
const [confirming, setConfirming] = React.useState(false);
const [comment, setComment] = React.useState('');
const [sending, setSending] = React.useState(false);
const [preview, setPreview] = React.useState(null);
React.useEffect(() => { if (ids[0]) getFavorite(ids[0]).then(setPreview).catch(() => {}); }, []);
const toggle = id => setSelected(values => values.includes(id) ? values.filter(v => v !== id) : values.concat(id).slice(0, 9));
const send = () => {
if (sending) return;
setSending(true);
forwardFavorites(ids, selected, comment.trim()).then(result => {
const items = (result && result.items) || [];
const failedCount = items.filter(r => r.status !== 'sent').length;
onToast(failedCount ? `${selected.length - failedCount}/${selected.length} 个会话分享成功` : `已分享给 ${selected.length} 个会话`);
window.setTimeout(() => navigate('favorites-files'), 700);
}).catch(err => onToast(err.message || '分享失败')).finally(() => setSending(false));
};
const previewAtt = preview && firstAttachment(preview.snapshot && preview.snapshot.content);
const contacts = (chatList || []).filter(c => c.acs && c.acs.isJoiner() && c.acs.isWriter() &&
(!query || topicDisplayName(c.name, c.public, c.name || '').toLowerCase().includes(query.toLowerCase())));
const nameOf = uid => { const t = contacts.find(c => c.name === uid); return (t && topicDisplayName(t.name, t.public, uid)) || uid; };
return <div className="library-page">
<Header title="分享" back="favorites-select" />
{!confirming ? <>
<Search value={query} onChange={setQuery} placeholder="搜索联系人或群聊" />
<p className="library-section-title">最近聊天</p>
<main className="library-target-list">{contacts.map(item => <button type="button" key={item.name} onClick={() => toggle(item.name)}>
<Avatar item={{}} /><span><strong>{topicDisplayName(item.name, item.public, item.name)}</strong></span>
<i className={'library-check' + (selected.includes(item.name) ? ' checked' : '')}><Icon>{selected.includes(item.name) ? 'check' : ''}</Icon></i>
</button>)}</main>
<footer className="library-next-bar"><span>已选择 {selected.length}/9</span><button type="button" disabled={!selected.length} onClick={() => setConfirming(true)}>确定</button></footer>
</> : <main className="library-share-confirm">
<h2>发送给</h2>
<div className="library-target-summary">{selected.map(uid => <span key={uid}><Avatar item={{}} /><small>{nameOf(uid)}</small></span>)}</div>
{preview ? <section>{previewAtt ? <FileCard name={previewAtt.name} size={Drafty.getEntitySize(previewAtt)} onOpen={() => {}} /> :
<p className="library-text-content">{plainTextOf(preview.snapshot && preview.snapshot.content, preview.snapshot && preview.snapshot.text)}</p>}
<p>收藏原件会继续保留,并在目标会话中生成一条新消息。</p></section> : null}
<textarea value={comment} onChange={event => setComment(event.target.value)} placeholder="给朋友留言" />
<button type="button" disabled={sending} onClick={send}>发送</button>
</main>}
</div>;
}
function DownloadPage({view, onToast}) {
const initial = view === 'file-downloaded' ? 100 : view === 'file-downloading' ? 36 : 0;
const [progress, setProgress] = React.useState(initial); const [paused, setPaused] = React.useState(false);
React.useEffect(() => {if (view !== 'file-downloading' || paused || progress >= 100) return undefined; const timer = window.setTimeout(() => setProgress(value => Math.min(100, value + 4)), 280); return () => window.clearTimeout(timer);}, [view, paused, progress]);
React.useEffect(() => {if (progress === 100 && view === 'file-downloading') navigate('file-downloaded');}, [progress, view]);
const state = progress === 100 ? 'done' : progress > 0 ? 'loading' : 'ready';
return <div className="library-page"><Header title="文件详情" back="favorites-files" /><main className="library-download"><span className="library-download-icon">PDF</span><h2>产品需求说明书.pdf</h2><p>2.4 MB · PDF 文档</p><dl><div><dt>来源</dt><dd>产品研发群</dd></div><div><dt>上传者</dt><dd>陈晨</dd></div><div><dt>收藏时间</dt><dd>昨天 18:40</dd></div></dl>{state === 'loading' ? <section className="library-download-progress"><div><span>正在下载</span><strong>{progress}%</strong></div><progress value={progress} max="100" /><small>{(2.4 * progress / 100).toFixed(1)} MB / 2.4 MB</small></section> : null}{state === 'ready' ? <button type="button" className="library-primary" onClick={() => navigate('file-downloading')}><Icon>download</Icon>下载文件</button> : state === 'loading' ? <div className="library-download-actions"><button type="button" onClick={() => setPaused(!paused)}><Icon>{paused ? 'play_arrow' : 'pause'}</Icon>{paused ? '继续' : '暂停'}</button><button type="button" onClick={() => navigate('file-download')}><Icon>close</Icon>取消</button></div> : <><div className="library-complete"><Icon>check_circle</Icon><span><strong>下载完成</strong><small>文件已保存到受控目录</small></span></div><div className="library-download-actions"><button type="button" className="primary" onClick={() => onToast('已调用系统文件查看器')}><Icon>open_in_new</Icon>打开文件</button><button type="button" onClick={() => onToast('已打开系统分享面板')}><Icon>ios_share</Icon>其他应用</button></div></>}</main></div>;
function DownloadPage({hashParams, tinode, onToast}) {
const [meta, setMeta] = React.useState(null);
// A failed fetch previously left this stuck on "加载中…" forever -- the toast fired once and faded,
// but `meta` never left null so the loading branch below never had anything else to show. Tracked
// separately from `meta` itself since a real "still loading" state also has `meta === null`.
const [loadError, setLoadError] = React.useState(false);
const [state, setState] = React.useState('ready');
const [progress, setProgress] = React.useState(0);
const downloaderRef = React.useRef(null);
const {favoriteId, topic, seq} = hashParams || {};
// Two distinct entry points share this route: Favorites (favoriteId set, Ch.7.1) and History's
// file tab (topic+seq set, Ch.7.12) -- "back" must return to whichever one actually opened this
// page, not unconditionally to favorites-files (which History never came from and has no topic
// param for anyway).
const backView = favoriteId ? 'favorites-files' : 'history-files';
const backParams = favoriteId ? undefined : {topic};
React.useEffect(() => {
const apply = content => {
const att = firstAttachment(content);
if (!att) { onToast('文件信息加载失败'); setLoadError(true); return; }
setMeta({name: att.name, mime: att.mime, size: Drafty.getEntitySize(att), url: Drafty.getDownloadUrl(att)});
};
if (favoriteId) {
getFavorite(favoriteId).then(fav => apply(fav.snapshot && fav.snapshot.content)).catch(err => { onToast(err.message || '加载失败'); setLoadError(true); });
} else if (topic && seq) {
getMessageContext(topic, seq, 0, 0).then(data => {
const item = (data.items || []).find(m => String(m.seq) === String(seq)) || data.items[0];
apply(item && item.content);
}).catch(err => { onToast(err.message || '加载失败'); setLoadError(true); });
}
}, [hashParams]);
const start = () => {
if (!meta || !tinode) return;
// A small inband attachment's "url" (from Drafty.getDownloadUrl) is already a ready-to-use
// blob: URL built from its inline base64 data -- there's nothing to fetch from the server, so
// getLargeFileHelper().download() would be the wrong tool (it requires a *relative* HTTP path and
// returns bare `undefined`, not a rejected promise, for anything else -- confirmed by triggering
// this exact case: chaining .then() off that return value threw "Cannot read properties of
// undefined"). Save the already-local blob directly instead.
if (meta.url && (meta.url.startsWith('blob:') || meta.url.startsWith('data:'))) {
const link = document.createElement('a');
link.href = meta.url; link.download = meta.name || 'file';
document.body.appendChild(link); link.click(); document.body.removeChild(link);
setState('done');
return;
}
// tinode.getLargeFileHelper().download() sends the same two wrong headers as its upload() sibling
// (X-Tinode-APIKey/X-Tinode-Auth, neither recognized by this backend) -- every real out-of-band
// download was silently failing. createFileDownloader() is a fixed-headers drop-in with the exact
// same download()/cancel() interface.
const downloader = createFileDownloader(tinode);
if (!downloader) { onToast('当前环境不支持下载'); return; }
downloaderRef.current = downloader;
setState('loading'); setProgress(0);
// download() resolves once the browser's own save dialog/native download has been triggered --
// there's no server-side download-completion record to poll (confirmed absent on the backend), so
// "downloaded" here just means the transfer finished, tracked only for this page's own lifetime.
const result = downloader.download(meta.url, meta.name, meta.mime,
loaded => setProgress(meta.size ? Math.min(100, Math.round(loaded / meta.size * 100)) : 0),
err => { setState('ready'); onToast((err && err.message) || '下载失败'); });
if (result && result.then) {
result.then(() => setState('done')).catch(() => {}); // onError above already surfaced the failure.
}
};
const cancel = () => { if (downloaderRef.current) downloaderRef.current.cancel(); setState('ready'); setProgress(0); };
if (!meta) return <div className="library-page"><Header title="文件" back={backView} backParams={backParams} /><main className="library-download">
<p className="library-empty-hint">{loadError ? '文件不存在或已被删除' : '加载中…'}</p>
</main></div>;
return <div className="library-page">
<Header title="文件" back={backView} backParams={backParams} />
<main className="library-download">
<span className="library-download-icon">{extFromName(meta.name) || '?'}</span>
<h2>{meta.name}</h2>
<p>该文件不支持在线预览</p>
{state === 'loading' ? <section className="library-download-progress">
<div><span>下载中...</span><strong>{progress}%</strong></div>
<progress value={progress} max="100" />
<small>{bytesToHumanSize(meta.size * progress / 100)} / {bytesToHumanSize(meta.size)}</small>
</section> : null}
{state === 'ready' ? <button type="button" className="library-primary" onClick={start}><Icon>download</Icon>立即下载({bytesToHumanSize(meta.size)})</button> :
state === 'loading' ? <div className="library-download-actions"><button type="button" onClick={cancel}><Icon>close</Icon>取消</button></div> :
<div className="library-download-actions"><button type="button" className="primary" onClick={() => onToast('已保存,可在浏览器下载列表中查看')}><Icon>open_in_new</Icon>用其他应用打开</button></div>}
</main>
</div>;
}
function Highlight({text, query}) {
if (!query) return text;
const index = text.indexOf(query); if (index < 0) return text;
return <>{text.slice(0, index)}<mark>{query}</mark>{text.slice(index + query.length)}</>;
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));
return parts;
}
function HistoryNav({active}) {
const tabs = [['history-search', '消息'], ['history-files', '文件'], ['history-links', '链接'], ['history-images', '图片'], ['history-videos', '视频']];
const tabs = [['history-search', '聊天记录'], ['history-images', '图片'], ['history-videos', '视频'], ['history-files', '文件'], ['history-links', '链接']];
return <nav className="library-history-tabs">{tabs.map(item => <button type="button" key={item[0]} className={active === item[0] ? 'active' : ''} onClick={() => navigate(item[0])}>{item[1]}</button>)}</nav>;
}
function History({view, onToast}) {
const [query, setQuery] = React.useState(view === 'history-search' ? '评审' : '');
const messages = [{id: 1, sender: '安妮', text: '下午三点进行版本评审,请大家提前准备。', time: '今天 09:30'}, {id: 2, sender: '陈晨', text: '评审结论已经整理到产品需求文档。', time: '今天 16:42'}];
return <div className="library-page"><Header title="查找聊天记录" back="friend-settings" /><Search value={query} onChange={setQuery} placeholder="搜索当前会话" /><HistoryNav active={view} /><main className="library-history-content">{view === 'history-search' ? <section className="library-message-results">{messages.filter(item => item.text.includes(query)).map(item => <button type="button" key={item.id} onClick={() => {onToast('已定位到原消息'); window.setTimeout(() => navigate('chat-standard'), 600);}}><Avatar item={{image: item.sender === '安妮' ? 'img/bkg/p09.jpg' : 'img/bkg/p05.jpg'}} /><span><strong>{item.sender}</strong><p><Highlight text={item.text} query={query} /></p><small>{item.time}</small></span><Icon>chevron_right</Icon></button>)}</section> : null}{view === 'history-files' ? <section className="library-record-files">{FILE_RECORDS.filter(item => item.name.includes(query)).map(item => <FileCard key={item.id} item={item} onOpen={() => navigate('file-download')} />)}</section> : null}{view === 'history-links' ? <section className="library-record-links">{FAVORITES.filter(item => item.type === 'link').map(item => <LinkCard key={item.id} item={item} onOpen={() => onToast('链接安全检查通过')} />)}</section> : null}{view === 'history-images' || view === 'history-videos' ? <MediaGrid video={view === 'history-videos'} onToast={onToast} /> : null}</main></div>;
const HISTORY_TITLES = {'history-search': '查找聊天记录', 'history-files': '文件', 'history-links': '链接', 'history-images': '图片', 'history-videos': '视频'};
const HISTORY_TYPE = {'history-search': 'all', 'history-files': 'file', 'history-links': 'link', 'history-images': 'image', 'history-videos': 'video'};
// Same bracket-label convention as message-center-view.jsx's draftyPreview() -- messageText() on the
// backend has no attachment-aware summary, so a non-text search hit's own "snippet" field is often just
// the raw serialized Drafty content; a fixed label per type reads far better than that.
const TYPE_LABEL = {image: '[图片]', video: '[视频]', voice: '[语音]', file: '[文件]', link: '[链接]'};
function History({view, hashParams, myUserId, onToast}) {
const topic = hashParams && hashParams.topic;
// Keyed by both topic and view/tab -- reached from one chat's own settings, and a user plausibly
// leaves to tweak a setting and comes back to keep browsing/searching the same conversation's
// records (or checks a different chat's history later the same session), so dropping either half
// of the key would bleed one chat's or one tab's data into another's.
const cacheKey = topic ? `history:${topic}:${view}` : null;
const [query, setQuery] = React.useState('');
const [items, setItems] = React.useState(() => (cacheKey && readCachedData(cacheKey)) || null);
const [cursor, setCursor] = React.useState('');
const [hasMore, setHasMore] = React.useState(false);
const [mediaUrls, setMediaUrls] = React.useState({});
const [senderNames, setSenderNames] = React.useState({});
const load = React.useCallback((append) => {
if (!topic) return;
const type = HISTORY_TYPE[view];
const call = view === 'history-search' ? searchTopicMessages(topic, {query, type, cursor: append ? cursor : ''}) :
getTopicMedia(topic, type, append ? cursor : '');
call.then(data => {
setItems(prev => {
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
if (!query && cacheKey) writeCachedData(cacheKey, list);
return list;
});
setCursor(data.next_cursor || ''); setHasMore(!!data.next_cursor);
}).catch(err => onToast(err.message || '加载失败'));
}, [topic, view, query, cursor]);
React.useEffect(() => {
setItems((!query && cacheKey && readCachedData(cacheKey)) || null);
load(false);
}, [topic, view, query]);
React.useEffect(() => {
if (view !== 'history-images' && view !== 'history-videos') return;
(items || []).forEach(item => {
if (mediaUrls[item.seq]) return;
resolveAttachmentUrl(attachmentFromFlat(item)).then(url => { if (url) setMediaUrls(v => ({...v, [item.seq]: url})); });
});
}, [items, view]);
// item.from (like snapshot.from on a favorite) is a bare uid with no "usr" prefix -- every other uid
// in this app (topic names, hash params, etc.) is fully-prefixed, so it has to be added back before
// this can be looked up like any other user id.
React.useEffect(() => {
(items || []).forEach(item => {
const from = item.from;
if (!from || senderNames[from] || from === myUserId || ('usr' + from) === myUserId) return;
getUserProfile(from.startsWith('usr') ? from : 'usr' + from).then(p => {
setSenderNames(v => ({...v, [from]: p.display_name}));
}).catch(() => {});
});
}, [items]);
// Mirrors tinode-web.jsx's own handleTopicSelected exactly (HashNavigation.setUrlTopic('', topicName)
// -- an empty base hash, not the current one, per its "Clear newTopicParams after use" comment) so
// this lands the same way a normal chat-list click does, then layers the jump target on as a param;
// navigate()'s own helper only ever touches path[0]+params, never path[1] (the actual topic slot).
const jump = seq => HashNavigation.navigateTo(HashNavigation.addUrlParam(HashNavigation.setUrlTopic('', topic), 'jumpseq', seq));
const nameOf = from => {
if (!from) return '';
if (from === myUserId || ('usr' + from) === myUserId) return '我';
return senderNames[from] || from;
};
const openLink = url => {
checkURL(url, 'message').then(res => {
if (res.decision === 'allow') window.open(url, '_blank', 'noopener,noreferrer');
else onToast(res.decision === 'block' ? '该链接存在风险,已被拦截' : '该链接可能存在风险,请谨慎访问');
}).catch(() => onToast('链接安全校验失败'));
};
const list = items || [];
return <div className="library-page">
{/* Reached from either a friend's chat settings (usrXXX topic, reads hashParams.uid) or a
group's (grpXXX topic, reads hashParams.topic) -- the topic's own prefix says which settings
page to return to and which param name it expects; passing the wrong one is the same
blank-page failure as History's other entry points had before this was a switch. */}
<Header title={HISTORY_TITLES[view]}
back={topic && topic.startsWith('grp') ? 'group-settings' : 'friend-settings'}
backParams={topic && topic.startsWith('grp') ? {topic} : {uid: topic}} />
{view === 'history-search' ? <Search value={query} onChange={setQuery} placeholder="搜索当前会话" /> : null}
<HistoryNav active={view} />
<main className="library-history-content">
{items === null ? <p className="library-empty-hint">加载中…</p> : !list.length ?
<div className="library-empty"><Icon>{view === 'history-search' ? 'search_off' : 'inbox'}</Icon><strong>暂无内容</strong></div> :
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>
<small>{shortDateFormat(new Date(item.ts))}</small></span><Icon>chevron_right</Icon>
</button>)}
</section> :
view === 'history-files' ? <section className="library-record-files">{list.map(item => {
const att = attachmentFromFlat(item);
return <React.Fragment key={item.seq}>
<div className="library-record-header"><strong>{nameOf(item.from)}</strong><small>{shortDateFormat(new Date(item.ts))}</small></div>
<FileCard name={att ? att.name : item.name} size={att ? Drafty.getEntitySize(att) : item.size} mime={att && att.mime}
removed={item.availability === 'removed'} onOpen={() => navigate('file-download', {topic, seq: item.seq})} />
</React.Fragment>;
})}</section> :
view === 'history-links' ? <section className="library-record-links">{list.map(item => <React.Fragment key={item.seq}>
<div className="library-record-header"><strong>{nameOf(item.from)}</strong><small>{shortDateFormat(new Date(item.ts))}</small></div>
<LinkText url={item.url || plainTextOf(item.txt !== undefined ? {txt: item.txt, fmt: item.fmt, ent: item.ent} : undefined, item.text)} onOpen={openLink} />
</React.Fragment>)}</section> :
<section className="library-media-grid">{list.map(item => <MediaThumb key={item.seq} url={mediaUrls[item.seq]} video={view === 'history-videos'}
duration={item.duration} onOpen={() => jump(item.seq)} />)}</section>}
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}
</main>
</div>;
}
function MediaGrid({video, onToast}) {
const images = video ? ['p23.jpg', 'p21.jpg', 'p19.jpg', 'p24.jpg'] : ['p22.jpg', 'p10.jpg', 'p17.jpg', 'p20.jpg', 'p06.jpg', 'p13.jpg'];
return <><h2 className="library-date-title">2026年8月</h2><section className="library-media-grid">{images.map((image, index) => <button type="button" key={image} onClick={() => onToast(video ? '已打开视频播放器' : '已打开大图预览')}><img src={`img/bkg/${image}`} alt={video ? '聊天视频缩略图' : '聊天图片缩略图'} />{video ? <><Icon>play_circle</Icon><small>00:{18 + index * 5}</small></> : null}</button>)}</section></>;
}
export default function ContentLibraryView({view}) {
export default function ContentLibraryView({view, hashParams, tinode, myUserId, onTopicSelected, chatList}) {
const [toast, setToast] = React.useState(''); let content;
if (view.startsWith('favorites')) content = <Favorites view={view} onToast={setToast} />;
else if (view === 'favorite-share') content = <FavoriteShare onToast={setToast} />;
else if (view.startsWith('file-download')) content = <DownloadPage view={view} onToast={setToast} />;
else content = <History view={view} onToast={setToast} />;
if (view.startsWith('favorites')) content = <Favorites view={view} tinode={tinode} onToast={setToast} />;
else if (view === 'favorite-share') content = <FavoriteShare hashParams={hashParams} myUserId={myUserId} chatList={chatList} onToast={setToast} />;
else if (view.startsWith('file-download')) content = <DownloadPage hashParams={hashParams} tinode={tinode} onToast={setToast} />;
else content = <History view={view} hashParams={hashParams} myUserId={myUserId} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
}
File diff suppressed because it is too large Load Diff
+162 -65
View File
@@ -1,55 +1,54 @@
import React from 'react';
import { Tinode } from 'tinode-sdk';
import HashNavigation from '../lib/navigation.js';
import { getBootstrap, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { useCachedData } from '../lib/use-cached-data.js';
import { topicDisplayName } from '../lib/utils.js';
const navigate = view => HashNavigation.navigateTo(view);
// Must go through setUrlSidePanel (not a bare navigateTo(view), which replaces the whole hash) so
// switching panels from "messages-home" doesn't silently drop path[1] -- the currently open
// conversation topic, which coexists with this panel in the split view once a chat is selected.
const navigate = view => HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, view));
const DEMO_CONVERSATIONS = [
{id: 'system', title: '系统消息', summary: '您有一条新的安全通知', time: '昨天 21:03', icon: 'notifications', tone: 'orange', system: true},
{id: 'sun', title: '孙光洪', summary: '最近忙什么', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p03.jpg', online: true},
{id: 'files', title: '文件助手', summary: '你上传了 1 份文件', time: '昨天 21:03', icon: 'folder', tone: 'green', fileAssistant: true},
{id: 'zhao', title: '赵吾行', summary: '好久不见,怎样了,都挺好的', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p07.jpg'},
{id: 'wang', title: '王厚哲', summary: '恭喜你呀', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p12.jpg', online: true},
{id: 'sunmu', title: '孙牧森', summary: '真有意思', time: '昨天 21:03', muted: true, avatar: 'img/bkg/p18.jpg'}
];
const FREQUENT_CONTACTS = [
{id: 'a', name: '吃火锅', avatar: 'img/bkg/p02.jpg', online: true},
{id: 'b', name: '爱上草莓', avatar: 'img/bkg/p05.jpg', online: true},
{id: 'c', name: '好久鼻尖', avatar: 'img/bkg/p09.jpg'},
{id: 'd', name: '太想念你', avatar: 'img/bkg/p14.jpg', online: true},
{id: 'e', name: '夏纳', avatar: 'img/bkg/p21.jpg'}
];
const SYSTEM_NOTIFICATIONS = [
{
id: 'device-1', date: '6月28日 10:04', title: '新设备登录成功',
loginAt: '2026/08/24 14:17', device: 'Chrome · macOS', ip: '220.198.***.212', read: false
},
{
id: 'device-2', date: '6月30日 13:04', title: '新设备登录成功',
loginAt: '2026/08/20 09:42', device: 'iPhone 15', ip: '116.25.***.86', read: true
}
];
const QUICK_ACTIONS = [
{id: 'friends', icon: 'group', label: '朋友'},
{id: 'profile', icon: 'person_outline', label: '我的'},
{id: 'create-group', icon: 'add_comment', label: '创建群组'},
{id: 'add-friend', icon: 'person_add_alt', label: '添加好友'},
{id: 'scan', icon: 'qr_code_scanner', label: '扫一扫'}
];
// Was a hardcoded FREQUENT_CONTACTS mock (5 fake names, always clicking through to the same
// direct-message-view.jsx demo regardless of which avatar was tapped -- confirmed independently by
// a user screenshot showing every contact opening the identical "接语彤" script). Real "frequently
// contacted" data doesn't exist as its own bootstrap field, so this derives it from the same
// `chatList` the conversation list below already uses: P2P topics only (a "people you talk to" rail
// isn't the place for groups), most-recently-touched first.
function frequentContacts(chatList) {
if (!chatList || chatList.length === 0) return [];
return chatList
.filter(item => item.topic && Tinode.isP2PTopicName(item.topic))
.slice()
.sort((a, b) => new Date(b.touched || 0).getTime() - new Date(a.touched || 0).getTime())
.slice(0, 8)
.map(item => ({
id: item.topic,
name: (item.public && item.public.fn) || '未命名',
avatar: makeImageUrl(item.public && item.public.photo),
online: !!item.online
}));
}
// Matches docs/images/消息页面—长按弹出.png exactly (9 items, in this order -- no "编辑" despite
// bootstrap.message_policy.supported_actions listing it; the mock predates that field and the
// prototype just doesn't surface an edit action here). 'recall' is gated against supported_actions
// (server/im_http.go serveIMBootstrap); favorite/forward/translate/select are pure client affordances.
const MESSAGE_ACTIONS = [
{id: 'quote', icon: 'format_quote', label: '引用'},
{id: 'copy', icon: 'content_copy', label: '复制'},
{id: 'favorite', icon: 'inventory_2', label: '收藏'},
{id: 'translate', icon: 'translate', label: '翻译'},
{id: 'forward', icon: 'reply', label: '转发'},
{id: 'recall', icon: 'undo', label: '撤回'},
{id: 'delete', icon: 'delete_outline', label: '删除'},
{id: 'recall', icon: 'undo', label: '撤回', gated: true},
{id: 'delete', icon: 'delete_outline', label: '删除', gated: true},
{id: 'select', icon: 'select_all', label: '多选'},
{id: 'receipts', icon: 'published_with_changes', label: '群回执'}
// Not in bootstrap.message_policy.supported_actions at all (that list only covers 'recall' for the
// sender's own message) -- left ungated like the other pure client affordances above.
{id: 'group_recall', icon: 'restart_alt', label: '群撤回'}
];
function Icon({children}) {
@@ -66,25 +65,77 @@ function Toast({message, onDone}) {
}
function Avatar({item, small = false}) {
const className = 'messages-avatar ' + (small ? 'small ' : '') + (item.tone || 'blue');
// The tone modifier is namespaced ('tone-orange', not 'orange') because base.css's original
// Tinode styles already define bare `.orange`/`.green`/`.blue` as generic `color: ... !important`
// text-color utility classes -- an unprefixed tone class collides with those and, since
// `!important` beats any selector this component could use to fight back, silently overrides the
// intended white icon-on-tint background to whatever that generic utility's text color is (this is
// exactly why the default/no-tone case rendered an invisible blue-on-blue icon: 'blue' is one of
// the collision names).
const className = 'messages-avatar ' + (small ? 'small ' : '') + 'tone-' + (item.tone || 'blue');
return <div className={className}>
{item.avatar ? <img src={item.avatar} alt="" /> : <Icon>{item.icon || 'person'}</Icon>}
{item.online !== undefined ? <span className={item.online ? 'online' : 'offline'} /> : null}
</div>;
}
// A chat list row needs a one-line summary even when the latest message isn't plain text. This is a
// plain-Chinese equivalent of lib/formatters.js's previewFormatter (which needs react-intl wired in,
// inconsistent with this file's all-hardcoded-Chinese mock strings) -- just enough to label the
// message by Drafty entity type for a conversation list preview.
function draftyPreview(content) {
if (typeof content === 'string' || content == null) return content || '';
if (Array.isArray(content.ent) && content.ent.length) {
const label = {IM: '[图片]', VD: '[视频]', AU: '[语音]', EX: '[文件]', TC: '[名片]', VC: '[通话]'}[content.ent[0].tp];
if (label) return label;
}
return content.txt || '[消息]';
}
function formatConversationTime(ts) {
if (!ts) return '';
const date = new Date(ts);
const now = new Date();
const startOfDay = d => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.round((startOfDay(now) - startOfDay(date)) / 86400000);
if (days === 0) return date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
if (days === 1) return '昨天';
if (days < 7) return ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][date.getDay()];
return date.toLocaleDateString('zh-CN', {month: 'numeric', day: 'numeric'});
}
// Entries (system messages / file assistant) come from bootstrap.entries and are always shown --
// unlike the old mock, they don't disappear once the user has real conversations (that was a bug).
function entryConversations(bootstrap) {
if (!bootstrap || !bootstrap.entries) return [];
const list = [];
const sys = bootstrap.entries.system_messages;
if (sys && sys.enabled) {
// Matches docs/images/消息.png ("最新公告"). bootstrap doesn't carry a preview of the latest
// notification, so this is a static label like the rest of that screenshot's demo content.
list.push({id: 'system', title: '系统消息', summary: '最新公告', icon: 'notifications', tone: 'orange',
system: true, unread: sys.unread_count || 0});
}
const files = bootstrap.entries.file_assistant;
if (files && files.enabled) {
list.push({id: 'files', title: '文件助手', summary: '你上传1份文件', icon: 'folder', tone: 'green',
fileAssistant: true, topic: files.topic, unread: files.unread_count || 0});
}
return list;
}
function normalizeConversations(chatList, myUserId) {
if (!chatList || chatList.length === 0) return DEMO_CONVERSATIONS;
if (!chatList || chatList.length === 0) return [];
return chatList.map((item, index) => {
const latest = typeof item.latestMessage === 'function' ? item.latestMessage() : null;
let summary = item.private && item.private.comment ? item.private.comment : '暂无消息';
if (latest && typeof latest.content === 'string') summary = latest.content;
if (latest) summary = draftyPreview(latest.content);
if (latest && latest.from === myUserId) summary = '我:' + summary;
return {
id: item.topic || item.user || String(index),
title: item.public && item.public.fn ? item.public.fn : '未命名会话',
title: topicDisplayName(item.topic, item.public, '未命名会话'),
summary,
time: item.touched ? new Date(item.touched).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'}) : '',
time: formatConversationTime(item.touched),
unread: Math.max(Number(item.unread) || 0, 0),
online: !!item.online,
muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()),
@@ -100,27 +151,41 @@ function BottomNavigation() {
</nav>;
}
function QuickMenu({onClose, onAction}) {
function QuickMenu({onClose, onAction, permissions}) {
const actions = [
{id: 'friends', icon: 'group', label: '朋友'},
{id: 'profile', icon: 'person_outline', label: '我的'},
{id: 'create-group', icon: 'add_comment', label: '创建群组', permission: 'create_group'},
{id: 'add-friend', icon: 'person_add_alt', label: '添加好友', permission: 'user_search'},
{id: 'scan', icon: 'qr_code_scanner', label: '扫一扫', permission: 'scan_qr'}
].filter(item => !item.permission || !permissions || permissions[item.permission] !== false);
return <div className="messages-menu-backdrop" onMouseDown={onClose}>
<div className="messages-quick-menu" onMouseDown={e => e.stopPropagation()} role="menu">
{QUICK_ACTIONS.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => onAction(item)}>
{actions.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => onAction(item)}>
<Icon>{item.icon}</Icon><span>{item.label}</span>
</button>)}
</div>
</div>;
}
function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOpenScanner}) {
const [query, setQuery] = React.useState('');
const [menuOpen, setMenuOpen] = React.useState(view === 'messages-menu');
const [toast, setToast] = React.useState('');
// Was `useState(null)` + its own effect -- refetched from scratch (blank entries/badges for a
// beat) every time this component remounted, e.g. after a trip to another sidepanel and back.
const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
const offline = view === 'messages-offline' || (!!myUserId && connected === false);
const conversations = normalizeConversations(chatList, myUserId);
const conversations = entryConversations(bootstrap).concat(normalizeConversations(chatList, myUserId));
const filtered = conversations.filter(item => `${item.title}${item.summary}`.toLowerCase().includes(query.trim().toLowerCase()));
const frequent = frequentContacts(chatList);
const selectConversation = item => {
if (item.system || item.id === 'system') {
if (item.system) {
navigate('system-messages');
} else if (item.fileAssistant && item.topic && onTopicSelected) {
onTopicSelected(item.topic);
} else if (chatList && chatList.length && onTopicSelected) {
onTopicSelected(item.id);
} else {
@@ -134,7 +199,8 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
if (item.id === 'profile') return navigate('personal-center');
if (item.id === 'create-group') return navigate('group-list');
if (item.id === 'add-friend') return navigate('add-friend');
setToast(item.id === 'scan' ? '扫码功能将在客户端能力章节提供' : `${item.label}页面将在对应章节中提供`);
if (item.id === 'scan' && onOpenScanner) return onOpenScanner();
setToast(item.id === 'scan' ? '扫码功能暂不可用' : `${item.label}页面将在对应章节中提供`);
};
return <div className="messages-product-page">
@@ -144,14 +210,14 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
</header>
<main className="messages-home-content">
<label className="messages-search"><Icon>search</Icon><input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索" /></label>
<section className="messages-frequent">
{frequent.length > 0 ? <section className="messages-frequent">
<h2>常联系的人</h2>
<div className="messages-frequent-scroll">
{FREQUENT_CONTACTS.map(item => <button type="button" key={item.id} onClick={() => navigate('chat-standard')}>
{frequent.map(item => <button type="button" key={item.id} onClick={() => onTopicSelected && onTopicSelected(item.id)}>
<Avatar item={item} small /><span>{item.name}</span>
</button>)}
</div>
</section>
</section> : null}
<section className="messages-conversations" aria-label="会话列表">
{filtered.map(item => <button type="button" className={'messages-conversation' + (item.pinned ? ' pinned' : '')}
key={item.id} onClick={() => selectConversation(item)}>
@@ -165,7 +231,7 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
</section>
</main>
<BottomNavigation />
{menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} /> : null}
{menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} permissions={bootstrap && bootstrap.permissions} /> : null}
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
@@ -174,6 +240,17 @@ function MessageActions() {
const [visible, setVisible] = React.useState(true);
const [toast, setToast] = React.useState('');
const [selected, setSelected] = React.useState('');
const [supportedActions, setSupportedActions] = React.useState(null);
React.useEffect(() => {
getBootstrap().then(data => setSupportedActions(data.message_policy && data.message_policy.supported_actions)).catch(() => {});
}, []);
// This screen is a static illustration of the long-press menu (not attached to a real message), so
// there's no real (topic, seq) to act on -- the real wiring belongs in messages-view.jsx (chapter 4)
// where an actual message exists. Here we only let the server's declared capabilities decide which
// gated actions (recall/edit/delete) are worth showing at all.
const actions = MESSAGE_ACTIONS.filter(item => !item.gated || !supportedActions || supportedActions.includes(item.id));
const runAction = item => {
setSelected(item.id);
@@ -195,7 +272,7 @@ function MessageActions() {
<footer className="message-demo-composer"><Icon>mic_none</Icon><span>输入消息</span><Icon>sentiment_satisfied_alt</Icon><Icon>add_circle_outline</Icon></footer>
{visible ? <div className="message-action-backdrop" onMouseDown={() => setVisible(false)}>
<div className="message-action-menu" onMouseDown={e => e.stopPropagation()} role="menu">
{MESSAGE_ACTIONS.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => runAction(item)}>
{actions.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => runAction(item)}>
<span>{item.label}</span><Icon>{item.icon}</Icon>
</button>)}
</div>
@@ -205,20 +282,40 @@ function MessageActions() {
}
function SystemMessages({authenticated}) {
const [items, setItems] = React.useState(SYSTEM_NOTIFICATIONS);
const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState('');
const [toast, setToast] = React.useState('');
const markRead = id => setItems(items.map(item => item.id === id ? {...item, read: true} : item));
React.useEffect(() => {
getSystemNotifications().then(data => setItems(data.items || []))
.catch(err => setError(err.message || '加载失败'))
.finally(() => setLoading(false));
}, []);
const markRead = item => {
if (item.read_at) return;
setItems(list => list.map(i => i.id === item.id ? {...i, read_at: new Date().toISOString()} : i));
markSystemNotificationRead(item.id).catch(() => {
// Roll the optimistic update back on failure -- otherwise the "new" badge silently vanishes
// even though the server never actually recorded this notification as read.
setItems(list => list.map(i => i.id === item.id ? {...i, read_at: null} : i));
});
};
return <div className="system-messages-page">
<header className="messages-sub-header"><button onClick={() => navigate('messages-home')} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
<header className="messages-sub-header"><button onClick={() => { if (!HashNavigation.goBack()) navigate('messages-home'); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
<main className="system-notification-list">
{items.map(item => <article key={item.id} className={item.read ? 'read' : ''} onClick={() => markRead(item.id)}>
<time>{item.date}</time>
{loading ? <p className="messages-empty"><Icon>hourglass_empty</Icon>加载中…</p> : null}
{error ? <p className="messages-empty"><Icon>error_outline</Icon>{error}</p> : null}
{!loading && !error && items.length === 0 ? <p className="messages-empty"><Icon>notifications_none</Icon>暂无系统消息</p> : null}
{items.map(item => <article key={item.id} className={item.read_at ? 'read' : ''} onClick={() => markRead(item)}>
<time>{new Date(item.created_at).toLocaleString('zh-CN')}</time>
<div className="system-notification-card">
<header><h2>{item.title}</h2>{!item.read ? <span>新</span> : null}</header>
<p>亲爱的用户,您的账号于 <button type="button" onClick={() => setToast('登录时间:' + item.loginAt)}>{item.loginAt}</button> 在新设备上登录成功。</p>
<dl><div><dt>设备:</dt><dd>{item.device}</dd></div><div><dt>IP地址:</dt><dd>{item.ip}</dd></div></dl>
<p>如果不是本人操作,请及时修改密码并检查登录设备。</p>
<button type="button" className="system-security-link" onClick={() => navigate(authenticated ? 'security' : 'identity-check')}>前往安全设置 <Icon>chevron_right</Icon></button>
<header><h2>{item.title}</h2>{!item.read_at ? <span>新</span> : null}</header>
<p>{item.summary}</p>
{item.action && item.action.device ? <dl><div><dt>设备:</dt><dd>{item.action.device}</dd></div>{item.action.ip ? <div><dt>IP地址:</dt><dd>{item.action.ip}</dd></div> : null}</dl> : null}
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate(authenticated ? 'security' : 'identity-check'); }}>前往安全设置 <Icon>chevron_right</Icon></button>
<footer>保持警惕<br />即时通讯安全团队</footer>
</div>
</article>)}
+372 -28
View File
@@ -18,6 +18,7 @@ import LoadSpinner from '../widgets/load-spinner.jsx';
import LogoView from './logo-view.jsx';
import MetaMessage from '../widgets/meta-message.jsx';
import PinnedMessages from '../widgets/pinned-messages.jsx';
import { RiskConfirmDialog } from './security-support-view.jsx';
import SendMessage from '../widgets/send-message.jsx';
const TheCardPreview = React.lazy(_ => import('../widgets/the-card-preview.jsx'));
const VideoPreview = React.lazy(_ => import('../widgets/video-preview.jsx'));
@@ -27,6 +28,9 @@ import { DEFAULT_P2P_ACCESS_MODE, EDIT_PREVIEW_LENGTH, IMAGE_PREVIEW_DIM, IMMEDI
MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY, QUOTED_REPLY_LENGTH, VIDEO_PREVIEW_DIM } from '../config.js';
import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constants.js';
import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js';
import { topicDisplayName } from '../lib/utils.js';
import { checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap, getConversationContext,
getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, recallMessage } from '../lib/im-api.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
@@ -141,10 +145,17 @@ class MessagesView extends React.Component {
super(props);
this.state = MessagesView.getDerivedStateFromProps(props, {});
// Not topic-specific, fetched once in componentDidMount -- unlike the rest of state this
// is intentionally not touched by getDerivedStateFromProps on topic change.
this.state.messagePolicy = null;
this.state.toast = null;
this.state.riskDialog = null;
this.componentSetup = this.componentSetup.bind(this);
this.leave = this.leave.bind(this);
this.sendMessage = this.sendMessage.bind(this);
this.handleRiskConfirm = this.handleRiskConfirm.bind(this);
this.handleRiskCancel = this.handleRiskCancel.bind(this);
this.retrySend = this.retrySend.bind(this);
this.sendImageAttachment = this.sendImageAttachment.bind(this);
this.sendVideoAttachment = this.sendVideoAttachment.bind(this);
@@ -170,6 +181,13 @@ class MessagesView extends React.Component {
this.handleShowMessageContextMenu = this.handleShowMessageContextMenu.bind(this);
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
this.handleEnablePeer = this.handleEnablePeer.bind(this);
this.loadRelationshipContext = this.loadRelationshipContext.bind(this);
this.loadGroupContext = this.loadGroupContext.bind(this);
this.handleShowAnnouncement = this.handleShowAnnouncement.bind(this);
this.handleShowJoinRequests = this.handleShowJoinRequests.bind(this);
this.handleSendFriendVerification = this.handleSendFriendVerification.bind(this);
this.handleBatchDelete = this.handleBatchDelete.bind(this);
this.handleBatchFavorite = this.handleBatchFavorite.bind(this);
this.handleAttachFile = this.handleAttachFile.bind(this);
this.handleAttachImageOrVideo = this.handleAttachImageOrVideo.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
@@ -182,6 +200,7 @@ class MessagesView extends React.Component {
this.handleEditMessage = this.handleEditMessage.bind(this);
this.handleCancelReply = this.handleCancelReply.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.jumpToMessage = this.jumpToMessage.bind(this);
this.handleUnpinMessage = this.handleUnpinMessage.bind(this);
this.handleCallHangup = this.handleCallHangup.bind(this);
@@ -249,12 +268,17 @@ class MessagesView extends React.Component {
this.mountDnDEvents(this.dndRef);
this.componentSetup({}, {});
getBootstrap().then(data => {
this.setState({messagePolicy: data.message_policy || null});
}).catch(_ => {});
}
componentWillUnmount() {
if (this.messagesScroller) {
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
}
window.clearTimeout(this.toastTimer);
// Flush all notifications.
this.clearNotificationQueue();
@@ -290,6 +314,13 @@ class MessagesView extends React.Component {
this.postReadNotification(0);
}
// Ch.7: jump to a message found via chat-history search (content-library-view.jsx's History),
// passed in as a ?jumpseq= hash param rather than a normal prop since the navigation that gets us
// here also has to change the *topic*, which content-library-view.jsx has no direct way to do.
if (this.props.jumpToSeq && this.props.jumpToSeq != prevProps.jumpToSeq) {
this.jumpToMessage(parseInt(this.props.jumpToSeq, 10));
}
this.componentSetup(prevProps, prevState);
}
@@ -312,6 +343,9 @@ class MessagesView extends React.Component {
topic.onPres = this.handleSubsUpdated;
topic.onAuxUpdated = this.handleAuxUpdate;
}
this.loadRelationshipContext(topic);
this.loadGroupContext(topic);
}
if (topic) {
@@ -368,7 +402,12 @@ class MessagesView extends React.Component {
pins: [],
pinsLoaded: false,
selectedPin: 0,
subsVersion: 0
subsVersion: 0,
relationshipContext: null,
groupRoles: null,
myGroupRole: null,
groupAnnouncement: null,
pendingJoinRequests: 0
};
} else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic);
@@ -389,7 +428,18 @@ class MessagesView extends React.Component {
showGoToLastButton: false,
contentToEdit: null,
dragging: false,
selectedPin: 0
selectedPin: 0,
relationshipContext: null,
groupRoles: null,
myGroupRole: null,
groupAnnouncement: null,
pendingJoinRequests: 0,
// MessagesView isn't remounted on topic switch (no `key={topic}` at the call site in
// tinode-web.jsx), so a pending risk-check dialog from the conversation just left would
// otherwise survive the switch -- confirming it would then send the old topic's message
// into whichever topic is now open, since doSend()'s `this.props.sendMessage` resolves the
// topic to send to at call time, not at the time the dialog was raised.
riskDialog: null
};
if (nextProps.forwardMessage) {
@@ -435,7 +485,7 @@ class MessagesView extends React.Component {
});
} else if (topic.public) {
Object.assign(nextState, {
title: topic.public.fn,
title: topicDisplayName(topic.name, topic.public, ''),
avatar: makeImageUrl(topic.public.photo)
});
} else {
@@ -517,7 +567,7 @@ class MessagesView extends React.Component {
nextState.isAdmin = false;
}
if (!prevState.readingBlocked) {
prevState.readingBlocked = true;
nextState.readingBlocked = true;
}
if (prevState.isSharer) {
nextState.isSharer = false;
@@ -692,7 +742,7 @@ class MessagesView extends React.Component {
});
} else if (desc.public) {
this.setState({
title: desc.public.fn,
title: topicDisplayName(this.props.topic, desc.public, ''),
avatar: makeImageUrl(desc.public.photo)
});
} else {
@@ -968,9 +1018,28 @@ class MessagesView extends React.Component {
params.userName = this.state.title;
}
params.topicName = this.state.topic;
const menuItems = messageSpecificMenuItems || [];
let menuItems = messageSpecificMenuItems || [];
const topic = this.props.tinode.getTopic(params.topicName);
if (topic) {
// Edit (Ch.4): chat-message.jsx already gated 'menu_item_edit' on writer + non-attachment
// content, but has no access to the server-configured edit window (that's session-level state
// living here, not a per-message prop) -- unlike recall just below, this was never actually
// filtered by age, so an edit attempt on a message well past the window would only fail at
// send time instead of never showing the option. Same window-check shape as recall's, just
// removing the item after the fact instead of adding it, since base eligibility already ran.
const editWindow = this.state.messagePolicy ? this.state.messagePolicy.edit_window_seconds : 0;
if (menuItems.includes('menu_item_edit') && !(editWindow > 0 && params.timestamp &&
params.timestamp.getTime() > (new Date().getTime() - editWindow * 1000))) {
menuItems = menuItems.filter(item => item !== 'menu_item_edit');
}
menuItems.push('menu_item_copy', 'menu_item_favorite');
// Recall (Ch.4): own message, real seq, and still inside the server-configured window.
// Distinct from delete -- see context-menu.jsx recallOwnMessage for why both exist.
const recallWindow = this.state.messagePolicy ? this.state.messagePolicy.recall_window_seconds : 0;
if (params.userFrom == this.props.myUserId && params.seq > 0 && recallWindow > 0 && params.timestamp &&
params.timestamp.getTime() > (new Date().getTime() - recallWindow * 1000)) {
menuItems.push('menu_item_recall');
}
// Self -> (1) always hard-delete with [Delete] title.
// P2P -> (1) hard-delete if allowed and not too old, (2) always soft-delete.
// Group -> (1) hard-delete if allowed and not too old or owner, (2) always soft-delete.
@@ -1002,6 +1071,7 @@ class MessagesView extends React.Component {
}
}
}
menuItems.push('menu_item_select');
this.props.showContextMenu(params, menuItems);
}
@@ -1014,19 +1084,156 @@ class MessagesView extends React.Component {
this.props.onChangePermissions(this.state.topic, DEFAULT_P2P_ACCESS_MODE, this.state.topic);
}
// Fetch the real friend/block/pending relationship state for a 1:1 topic so the UI can explain
// (instead of silently disabling) why sending is restricted. Best-effort: a failed fetch just
// leaves relationshipContext null and the existing isWriter-based disable stays in effect.
loadRelationshipContext(topic) {
if (!topic || !topic.isP2PType()) {
return;
}
const topicName = topic.name;
getConversationContext(topicName).then(data => {
if (this.state.topic == topicName) {
this.setState({relationshipContext: data});
}
}).catch(_ => {});
}
// Ch.6: role badges (docs/images/群标识.png) and the announcement/join-request banners at the top of a
// group chat (docs/images/群公告状态.png, 新邀请进群申请.png) all need data no P2P topic has -- fetch it
// once per group-topic-load, same best-effort pattern as loadRelationshipContext above. Every `topic`
// field these Ch.6 endpoints return is a real grp-prefixed native Tinode name (verified against the Go
// source), so there's no Ch.5-style translation needed anywhere here.
loadGroupContext(topic) {
if (!topic || !topic.isGroupType() || topic.isChannelType()) {
return;
}
const topicName = topic.name;
getGroupMembers(topicName).then(data => {
if (this.state.topic != topicName) {
return;
}
const roles = {};
(data.items || []).forEach(m => { roles[m.user_id] = m.role; });
this.setState({groupRoles: roles, myGroupRole: roles[this.props.myUserId] || null});
// Pending join requests can only be resolved by an admin/owner -- only they can act on the banner,
// and only they pass requireIMGroupMember's IsAdmin() check on the list endpoint itself.
if (roles[this.props.myUserId] == 'owner' || roles[this.props.myUserId] == 'admin') {
getGroupJoinRequests(topicName, 'pending').then(reqData => {
if (this.state.topic == topicName) {
this.setState({pendingJoinRequests: (reqData.items || []).length});
}
}).catch(_ => {});
}
}).catch(_ => {});
getLatestGroupAnnouncement(topicName).then(data => {
if (this.state.topic == topicName) {
this.setState({groupAnnouncement: (data && data.content) ? data : null});
}
}).catch(_ => {});
}
// Note: setUrlSidePanel only overwrites path[0] -- called with window.location.hash while a topic is
// open (path[1] is the open topic), it leaves that segment in place, producing e.g.
// "#group-announcement-detail/grpXXX?topic=grpXXX". handleHashRoute then re-parses path[1] as a topic
// selector on every hashchange, fighting with the sidepanel that's trying to open and leaving the
// whole view blank. Build the target hash from an empty path instead of the current one.
handleShowAnnouncement(e) {
e.preventDefault();
// `from=chat` lets Announcement (group-management-view.jsx) tell this entry point apart from
// the one via group-settings -- both land on the identical path[0]/topic hash otherwise, so
// without it the "back" button can't know whether to return to group-settings or to this chat.
let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-announcement-detail'), 'topic', this.state.topic);
hash = HashNavigation.addUrlParam(hash, 'from', 'chat');
HashNavigation.navigateTo(hash);
}
handleShowJoinRequests(e) {
e.preventDefault();
HashNavigation.navigateTo(HashNavigation.addUrlParam(
HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic));
}
handleSendFriendVerification(e) {
e.preventDefault();
// Same stray-path[1] hazard as handleShowAnnouncement above (build from an empty base, not the
// current hash, since a topic is open) -- this one additionally never set `uid`, which the
// friend-verification page needs (contact-management-view.jsx's Verification reads hashParams.uid)
// to know who the verification is for; for a P2P topic that's simply the topic name itself.
HashNavigation.navigateTo(HashNavigation.addUrlParam(
HashNavigation.setUrlSidePanel('', 'friend-verification'), 'uid', this.state.topic));
}
showToast(text) {
this.setState({toast: text});
window.clearTimeout(this.toastTimer);
// Matches div.toast.show's own fadein(0.5s) + hold(2.5s) + fadeout(0.5s) animation length,
// so the class is removed right as the CSS fadeout finishes instead of cutting it short.
this.toastTimer = window.setTimeout(() => this.setState({toast: null}), 3500);
}
handleBatchDelete() {
const topic = this.props.tinode.getTopic(this.state.topic);
if (!topic || !this.props.selectedSeqs.length) {
return;
}
topic.delMessagesList(this.props.selectedSeqs.slice(), true).then(_ => {
this.props.onExitSelectMode();
}).catch(err => {
this.props.onError(err.message, 'err');
});
}
// Per docs/images/多选-选中对话内容-收藏成功.png the selection stays visible behind the toast --
// unlike delete, favoriting doesn't remove the messages, so there's no reason to exit select mode.
handleBatchFavorite() {
if (!this.props.selectedSeqs.length) {
return;
}
const topicName = this.state.topic;
favoriteMessages(this.props.selectedSeqs.map(seq => ({topic: topicName, seq}))).then(result => {
const failed = (result && result.failed) || [];
if (failed.length && failed.length === this.props.selectedSeqs.length) {
this.props.onError('收藏失败', 'err');
} else if (failed.length) {
this.showToast(`已收藏,${failed.length} 条失败`);
} else {
this.showToast('收藏成功');
}
}).catch(err => {
this.props.onError(err.message, 'err');
});
}
sendKeyPress(audio) {
const topic = this.props.tinode.getTopic(this.state.topic);
if (topic.isSubscribed()) {
if (audio) {
topic.noteRecording(true);
} else {
topic.noteKeyPress();
}
if (!topic.isSubscribed()) {
return;
}
if (audio) {
// Doc's default for the "user is recording voice" indicator is OFF unless product opts in
// (a non-persisted {note what:"kpa"} broadcast to every other group member) -- this was firing
// unconditionally on every recording, contradicting that documented default. No product signal
// to turn it on yet, so this is a deliberate no-op rather than a removed feature: re-enable by
// restoring `topic.noteRecording(true);` here if/when that's confirmed wanted.
return;
}
topic.noteKeyPress();
}
// sendMessage sends the message with an optional subscription to topic first.
sendMessage(msg, uploadCompletionPromise, uploader) {
// typeHint (Ch.7): the backend's imMessageType() classifier -- which drives every type-filtered list
// in this chapter (favorites tabs, chat-history search/media tabs) -- only recognizes a flat
// content.file_id/content.url key or an explicit head['x-im-type'] hint; it never walks a real
// Drafty entity array. Since every attachment this app actually sends uses the standard ent[]
// format (Drafty.attachFile/insertImage/insertVideo), none of them carry a flat file_id, so without
// this hint every image/video/file/voice message silently classifies as plain "text" server-side.
sendMessage(msg, uploadCompletionPromise, uploader, typeHint) {
// Captured before the reply/edit branches below possibly rewrite `msg` into a Drafty object
// (Drafty.append(...) for a reply-with-quote) -- the URL risk check further down keys off
// `typeof msg == 'string'`, so without this a plain-text reply containing a link would silently
// skip the check entirely (a fresh, non-reply message with the same text does get checked).
const originalText = typeof msg === 'string' ? msg : null;
let head;
if (!msg && this.props.forwardMessage) {
// We are forwarding a message.
@@ -1053,7 +1260,59 @@ class MessagesView extends React.Component {
}
this.handleCancelReply();
}
this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
if (typeHint) {
head = {...(head || {}), 'x-im-type': typeHint};
}
const doSend = () => this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
// Pre-send URL risk check (POST /security/urls/check) -- the only Ch.11 "anti-fraud" mechanism
// with a real backend behind it. Only plain-text composed messages are checked (not forwards,
// uploads, or already-Drafty content) since that's the one path a URL could plausibly be typed in.
// Returns a promise resolving to `false` when the message was NOT sent (blocked/cancelled) so
// send-message.jsx can leave the composer text intact instead of optimistically clearing it --
// without this, a blocked message's text would just vanish with no failed-message trace to retry.
const urlMatch = originalText && originalText.match(/https?:\/\/\S+/);
if (urlMatch) {
return checkURL(urlMatch[0], 'message_compose').then(result => {
if (result.decision === 'allow') {
return doSend();
}
return new Promise(resolve => {
// riskDialog is a single slot -- if an earlier risk check is still awaiting the user's
// decision (two messages sent in quick succession, both flagged), overwriting it here
// would leave that first promise permanently unresolved and its message silently dropped
// with send-message.jsx never told to stop holding it as unsent. Resolving it `false` first
// means it's treated the same as the user cancelling it -- lost, but not hung forever.
if (this.state.riskDialog) this.state.riskDialog.resolve(false);
this.setState({riskDialog: {blocked: result.decision === 'block', challengeId: result.risk_challenge_id, send: doSend, resolve}});
});
}).catch(_ => doSend()); // Don't let a risk-check failure block sending altogether.
}
return doSend();
}
handleRiskConfirm() {
const dialog = this.state.riskDialog;
if (!dialog) return;
this.setState({riskDialog: null});
const finish = () => dialog.resolve(dialog.send());
if (dialog.challengeId) {
confirmRiskChallenge(dialog.challengeId, 'continue').then(finish).catch(finish);
} else {
finish();
}
}
handleRiskCancel() {
const dialog = this.state.riskDialog;
if (!dialog) return;
this.setState({riskDialog: null});
if (dialog.challengeId && !dialog.blocked) {
confirmRiskChallenge(dialog.challengeId, 'cancel').catch(_ => {});
}
dialog.resolve(false);
}
// Retry sending a message.
@@ -1090,7 +1349,7 @@ class MessagesView extends React.Component {
if (file.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
const uploader = createChatUploader(this.props.tinode);
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
@@ -1103,7 +1362,7 @@ class MessagesView extends React.Component {
urlPromise: uploadCompletionPromise
});
// Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader);
this.sendMessage(msg, uploadCompletionPromise, uploader, 'file');
} else {
// Small enough to send inband.
fileToBase64(file)
@@ -1112,7 +1371,7 @@ class MessagesView extends React.Component {
data: b64.bits,
filename: b64.name,
size: file.size
})))
}), undefined, undefined, 'file'))
.catch(err => this.props.onError(err.message, 'err'));
}
}
@@ -1185,7 +1444,7 @@ class MessagesView extends React.Component {
if (blob.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
const uploader = createChatUploader(this.props.tinode);
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
@@ -1212,7 +1471,7 @@ class MessagesView extends React.Component {
msg = Drafty.append(msg, Drafty.parse(caption));
}
// Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader);
this.sendMessage(msg, uploadCompletionPromise, uploader, 'image');
})
.catch(err => this.props.onError(err, 'err'));
return;
@@ -1234,7 +1493,7 @@ class MessagesView extends React.Component {
msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.parse(caption));
}
this.sendMessage(msg);
this.sendMessage(msg, undefined, undefined, 'image');
});
}
@@ -1251,7 +1510,7 @@ class MessagesView extends React.Component {
let uploader;
if ((videoBlob.size + previewBlob.size) > maxInbandAttachmentSize) {
// One or both are too large to send inband. Uploading out of band and sending as a link.
uploader = this.props.tinode.getLargeFileHelper();
uploader = createChatUploader(this.props.tinode);
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
@@ -1281,7 +1540,7 @@ class MessagesView extends React.Component {
msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.parse(caption));
}
this.sendMessage(msg);
this.sendMessage(msg, undefined, undefined, 'video');
});
return;
}
@@ -1320,7 +1579,7 @@ class MessagesView extends React.Component {
msg = Drafty.append(msg, Drafty.parse(caption));
}
// Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader);
this.sendMessage(msg, uploadCompletionPromise, uploader, 'video');
})
.catch(err => this.props.onError(err.message, 'err'));
}
@@ -1379,7 +1638,7 @@ class MessagesView extends React.Component {
const maxInbandAttachmentSize = this.props.tinode.getServerParam('maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024;
if (blob.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
const uploader = createChatUploader(this.props.tinode);
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
@@ -1393,7 +1652,7 @@ class MessagesView extends React.Component {
urlPromise: uploadCompletionPromise
});
// Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader);
this.sendMessage(msg, uploadCompletionPromise, uploader, 'voice');
} else {
// Small enough to send inband.
blobToBase64(blob)
@@ -1404,7 +1663,7 @@ class MessagesView extends React.Component {
size: blob.size,
duration: duration,
preview: preview,
}))
}), undefined, undefined, 'voice')
})
}
})
@@ -1494,6 +1753,27 @@ class MessagesView extends React.Component {
}
}
// Ch.7 chat-history search result -> jump to the message in the real conversation. handleQuoteClick
// only scrolls to a message already present in this topic's locally-cached message list (used
// elsewhere for quoted-reply/pinned-message jumps, where that's always true); a search result can
// point at a message far outside whatever window happens to be loaded, so fetch a window around it
// first via the native SDK (not the REST search API -- handleQuoteClick reads refs off the SDK's own
// local topic cache, so the data has to land there, which only getMeta does).
jumpToMessage(seq) {
const topic = this.props.tinode && this.props.topic ? this.props.tinode.getTopic(this.props.topic) : null;
if (!topic || !seq || isNaN(seq)) {
return;
}
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'jumpseq'));
const jump = () => window.setTimeout(() => this.handleQuoteClick(seq), 150);
if (topic.findMessage(seq)) {
jump();
} else {
topic.getMeta(topic.startMetaQuery().withData(Math.max(1, seq - 20), seq + 21, 41).build())
.then(jump).catch(err => this.props.onError(err.message, 'err'));
}
}
handleUnpinMessage(seq) {
const topic = this.props.tinode.getTopic(this.state.topic);
topic.pinMessage(seq, false);
@@ -1568,6 +1848,10 @@ class MessagesView extends React.Component {
onSendAnswer={this.props.onCallSendAnswer} />
</Suspense>;
const riskDialog = this.state.riskDialog &&
<RiskConfirmDialog blocked={this.state.riskDialog.blocked}
onConfirm={this.handleRiskConfirm} onCancel={this.handleRiskCancel} />;
let component;
if (!this.state.topic) {
component = <LogoView
@@ -1733,6 +2017,7 @@ class MessagesView extends React.Component {
topic={this.state.topic}
content={msg.content}
mimeType={msg.head && msg.head.mime}
head={msg.head}
replyToSeq={replyToSeq}
edited={msg.head && !msg.head.webrtc && msg.head.replace}
timestamp={msg.ts}
@@ -1743,11 +2028,17 @@ class MessagesView extends React.Component {
userFrom={userFrom}
userName={userName}
userAvatar={userAvatar}
senderRole={groupTopic && this.state.groupRoles ? this.state.groupRoles[thisFrom] : null}
sequence={sequence}
received={deliveryStatus}
uploader={msg._uploader}
userIsWriter={this.state.isWriter}
userIsAdmin={this.state.isAdmin}
relationship={this.state.relationshipContext ? this.state.relationshipContext.relationship : null}
onSendFriendVerification={this.handleSendFriendVerification}
selectMode={this.props.selectMode}
selected={this.props.selectedSeqs && this.props.selectedSeqs.includes(msg.seq)}
onToggleSelect={this.props.onToggleMessageSelect}
pinned={this.state.pins.includes(msg.seq)}
viewportWidth={this.props.viewportWidth} // Used by `formatter`.
showContextMenu={this.handleShowMessageContextMenu}
@@ -1797,6 +2088,18 @@ class MessagesView extends React.Component {
let messagesComponent = (
<>
{this.state.groupAnnouncement ?
<a href="#" id="group-announcement-banner" onClick={this.handleShowAnnouncement}>
<i className="material-icons">campaign</i>
<span>{this.state.groupAnnouncement.content}</span>
<i className="material-icons">chevron_right</i>
</a> : null}
{this.state.pendingJoinRequests > 0 ?
<a href="#" id="group-join-request-banner" onClick={this.handleShowJoinRequests}>
<i className="material-icons">person_add_alt</i>
<span>新邀请进群申请({this.state.pendingJoinRequests})</span>
<i className="material-icons">chevron_right</i>
</a> : null}
<div id="messages-container" className={darkModeClass}>
<button id="go-to-latest" className={'action-button' + (this.state.showGoToLastButton ? '' : ' hidden')}
onClick={this.goToLatestMessage}>
@@ -1817,6 +2120,9 @@ class MessagesView extends React.Component {
: null }
</div>
: null }
<div className={'toast' + (this.state.toast ? ' show' : '')}>
<i className="material-icons">check</i>&nbsp;{this.state.toast}
</div>
</div>
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
<div id="peer-messaging-disabled-note">
@@ -1826,13 +2132,51 @@ class MessagesView extends React.Component {
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
</div> : null}
{this.state.unconfirmed ?
{!this.state.unconfirmed && this.state.relationshipContext &&
this.state.relationshipContext.relationship == 'pending_inbound' ?
<div id="relationship-status-note">以上是打招呼的消息</div> : null}
{!this.state.unconfirmed && this.state.relationshipContext &&
this.state.relationshipContext.relationship == 'pending_outbound' ?
<div id="relationship-status-note">好友申请正在等待处理</div> : null}
{!this.state.unconfirmed && this.state.relationshipContext &&
this.state.relationshipContext.relationship == 'none' ?
<div id="relationship-status-note">你们还不是好友,发送消息前需先验证 <a href="#"
onClick={this.handleSendFriendVerification}>发送朋友验证</a></div> : null}
{this.props.selectMode ?
<div id="batch-action-bar">
<div id="batch-action-count">已选 {this.props.selectedSeqs.length} 个</div>
<div id="batch-action-buttons">
<a href="#" onClick={e => { e.preventDefault(); this.props.selectedSeqs.length && this.props.onShowBatchForward(); }}
className={this.props.selectedSeqs.length ? '' : 'disabled'}>
<i className="material-icons">forward</i>转发
</a>
<a href="#" onClick={e => { e.preventDefault(); this.handleBatchFavorite(); }}
className={this.props.selectedSeqs.length ? '' : 'disabled'}>
<i className="material-icons">star_border</i>收藏
</a>
<a href="#" onClick={e => { e.preventDefault(); this.handleBatchDelete(); }}
className={this.props.selectedSeqs.length ? '' : 'disabled'}>
<i className="material-icons">delete_outline</i>删除
</a>
</div>
<a href="#" id="batch-action-cancel" onClick={e => { e.preventDefault(); this.props.onExitSelectMode(); }}>取消</a>
</div>
: this.state.unconfirmed ?
<Invitation onAction={this.handleNewChatAcceptance} />
:
<SendMessage
tinode={this.props.tinode}
topicName={this.state.topic}
noInput={!!this.props.forwardMessage}
// relationshipContext.can_send is deliberately NOT part of this -- per the reference
// screenshots (个人对话-拉黑状态/已读状态.png etc.), every non-friend relationship
// state (pending/none/blocked) keeps the composer typable; the restriction shows up as
// a per-message failure reason (chat-message.jsx, keyed off `relationship`) after a
// real send attempt, not as a preemptively grayed-out input. can_send has no other
// reader in the codebase -- this was the only place it was gating anything, and doing
// so here made every failure-reason branch for those relationships unreachable, since
// the composer never let you attempt a send in the first place. isWriter/deleted are
// the real Tinode ACS-level restrictions and are unaffected by this.
disabled={!this.state.isWriter || this.state.deleted}
reply={this.state.reply}
initMessage={this.state.contentToEdit}
@@ -1927,7 +2271,7 @@ class MessagesView extends React.Component {
</>
);
}
component = <div id="topic-view" ref={this.mountDnDEvents}>{component2}{overlay}</div>
component = <div id="topic-view" ref={this.mountDnDEvents}>{component2}{overlay}{riskDialog}</div>
}
return component;
}
+610 -74
View File
@@ -2,31 +2,41 @@ import React from 'react';
import QRCode from 'qrcodejs';
import HashNavigation from '../lib/navigation.js';
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import { BASE_APP_NAME } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
import {
getMeProfile, updateMeProfile, getFileUrl, uploadLargeFile,
getMePrivacy, updateMePrivacy, getYouthMode, enableYouthMode, disableYouthMode, verifyIndependentPassword,
createMyQRCode, getRealnameStatus
} from '../lib/im-api.js';
const navigate = view => HashNavigation.navigateTo(view);
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
};
export const PERSONAL_SETTINGS_VIEWS = [
'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date',
'profile-signature', 'personal-qrcode', 'android-settings', 'cache-clear-confirm',
'app-version', 'app-update-available', 'privacy-settings', 'friend-discovery-settings',
'youth-mode-off', 'youth-mode-confirm', 'youth-mode-on', 'youth-mode-disable',
'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'
];
const PROFILE_KEY = 'im-demo-personal-profile';
const PRIVACY_KEY = 'im-demo-privacy-settings';
const YOUTH_KEY = 'im-demo-youth-mode';
const DEFAULT_PROFILE = {nickname: '陈晨', birthDate: '1998-06-18', signature: '保持好奇,认真生活。'};
function readObject(key, fallback) {
try { return {...fallback, ...JSON.parse(window.localStorage.getItem(key))}; } catch (_) { return fallback; }
}
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'personal-center', action, actionLabel, onAction}) {
return <header className="personal-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{action ? <button type="button" className="text" onClick={onAction}>{actionLabel || action}</button> : <span />}</header>;
function Header({title, back = 'personal-center', action, actionLabel, onAction, actionDisabled}) {
return <header className="personal-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{action ? <button type="button" className="text" onClick={onAction} disabled={actionDisabled}>{actionLabel || action}</button> : <span />}
</header>;
}
function Toast({message, onDone}) {
@@ -39,123 +49,649 @@ function Toast({message, onDone}) {
}
function Toggle({checked, disabled, onChange}) {
return <button type="button" role="switch" aria-checked={checked} disabled={disabled} className={'personal-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>;
return <button type="button" role="switch" aria-checked={checked} disabled={disabled}
className={'personal-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>;
}
function MenuRow({icon, title, value, route, onClick, danger}) {
return <button type="button" className={'personal-menu-row' + (danger ? ' danger' : '')} onClick={onClick || (() => route && navigate(route))}>{icon ? <Icon>{icon}</Icon> : null}<span><strong>{title}</strong>{value ? <small>{value}</small> : null}</span>{route || onClick ? <Icon>chevron_right</Icon> : null}</button>;
return <button type="button" className={'personal-menu-row' + (danger ? ' danger' : '')}
onClick={onClick || (() => route && navigate(route))}>
{icon ? <Icon>{icon}</Icon> : null}
<span><strong>{title}</strong>{value ? <small>{value}</small> : null}</span>
{route || onClick ? <Icon>chevron_right</Icon> : null}
</button>;
}
// docs/images/我的.png 的"企业名称"字段实际上是租户名,不是组织架构的部门 -- 复用 Ch.9 workbench
// 已经验证过的读取方式(登录时 tenants/resolve 缓存进 localStorage['im-tenant'])。
function tenantName() {
try { return JSON.parse(window.localStorage.getItem('im-tenant') || '{}').name || ''; } catch (_) { return ''; }
}
function ImgFromFile({fileId, className}) {
const [url, setUrl] = React.useState(null);
React.useEffect(() => { let live = true; getFileUrl(fileId).then(u => live && setUrl(u)).catch(() => {}); return () => { live = false; }; }, [fileId]);
return url ? <img className={className} src={url} alt="" /> : <span className={className} />;
}
function PersonalCenter() {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
return <div className="personal-page personal-center-page"><Header title="我的" back="messages-home" /><main><button type="button" className="personal-identity" onClick={() => navigate('personal-profile')}><img src="img/bkg/p05.jpg" alt="" /><span><strong>{profile.nickname}</strong><small>产品研发部 · 产品经理</small><small>账号:CHEN88</small></span><Icon>chevron_right</Icon></button><section><MenuRow icon="qr_code_2" title="我的二维码" route="personal-qrcode" /></section><section><MenuRow icon="inventory_2" title="我的收藏" route="favorites" /><MenuRow icon="feedback" title="意见反馈" route="user-feedback" /><MenuRow icon="settings" title="设置" route="android-settings" /></section></main></div>;
// Was `useState(null)` + its own effect -- remounting this (e.g. tabbing back from 设置) blanked
// the name/avatar back to the loading placeholder for a beat even though nothing had changed,
// same "switching pages feels like it reloads every time" complaint this cache already fixed for
// the workbench/message list/address book.
const [profile] = useCachedData('me-profile', getMeProfile, []);
return <div className="personal-page personal-center-page"><Header title="我的" back="messages-home" /><main>
<button type="button" className="personal-identity" onClick={() => navigate('personal-profile')}>
{profile && profile.avatar_file_id ? <ImgFromFile fileId={profile.avatar_file_id} /> : <span className="personal-avatar-fallback"><Icon>person</Icon></span>}
<span><strong>{(profile && (profile.nickname || profile.public_account)) || '…'}</strong>
<small>企业名称:{tenantName() || '…'}</small>
<small>ID: {(profile && profile.public_account) || '…'}</small></span>
<Icon>chevron_right</Icon>
</button>
<section><MenuRow icon="qr_code" title="我的二维码" route="personal-qrcode" /></section>
<section>
<MenuRow icon="bookmark_border" title="我的收藏" route="favorites" />
<MenuRow icon="report_problem" title="关于我们" route="app-version" />
<MenuRow icon="feedback" title="意见反馈" route="user-feedback" />
<MenuRow icon="settings" title="设置" route="android-settings" />
</section>
</main></div>;
}
function PersonalProfile({onToast}) {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
return <div className="personal-page"><Header title="个人信息" /><main className="personal-profile-list"><button type="button" onClick={() => onToast('头像选择器已打开')}><strong>头像</strong><img src="img/bkg/p05.jpg" alt="" /><Icon>chevron_right</Icon></button><MenuRow title="昵称" value={profile.nickname} route="profile-nickname" /><MenuRow title="出生日期" value={profile.birthDate} route="profile-birth-date" /><MenuRow title="二维码名片" value="" route="personal-qrcode" /><MenuRow title="账号" value="CHEN88" /><MenuRow title="职位" value="产品经理" /><MenuRow title="公司" value="星海科技有限公司" /><MenuRow title="个性签名" value={profile.signature || '未填写'} route="profile-signature" /></main></div>;
// docs/images/个人信息.png 两张截图字段不完全一样(一张有用户名/职位/公司,另一张有ID号/地区)--
// 代码调研确认 /me/profile 真实字段只有 avatar/nickname/gender/birth_date/region/signature/email;
// "职位/公司"属于第9章组织架构数据源(这里没有反查接口,不展示);"ID号"、"用户名"是原型臆造字段,
// 后端没有对应概念(用户id恒为usrXXX格式),不做。
function PersonalProfile({onToast, tinode}) {
// Shares the 'me-profile' key with PersonalCenter's already-fixed cache -- this is the hub every
// field editor below navigates back to on save, so it's revisited constantly.
const [profile, setProfile] = React.useState(() => readCachedData('me-profile') || null);
const fileInputRef = React.useRef(null);
const [uploading, setUploading] = React.useState(false);
const load = () => getMeProfile().then(p => { writeCachedData('me-profile', p); setProfile(p); }).catch(() => {});
React.useEffect(() => { load(); }, []);
const pickAvatar = () => fileInputRef.current && fileInputRef.current.click();
const handleAvatarFile = event => {
const file = event.target.files && event.target.files[0];
event.target.value = '';
if (!file || !tinode) return;
setUploading(true);
uploadLargeFile(tinode, file).then(({fileId}) => updateMeProfile({avatar_file_id: fileId, version: profile.version}))
.then(load).then(() => onToast('头像已更新'))
.catch(err => onToast(err.message || '头像上传失败')).finally(() => setUploading(false));
};
const genderLabel = {male: '男', female: '女', other: '其他'}[profile && profile.gender] || '未设置';
return <div className="personal-page"><Header title="个人信息" /><main className="personal-profile-list">
<button type="button" onClick={pickAvatar} disabled={uploading}>
<strong>头像</strong>
{profile && profile.avatar_file_id ? <ImgFromFile fileId={profile.avatar_file_id} /> : <Icon>person</Icon>}
<Icon>chevron_right</Icon>
</button>
<input ref={fileInputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={handleAvatarFile} />
<MenuRow title="昵称" value={profile && (profile.nickname || '未设置')} route="profile-nickname" />
<MenuRow title="性别" value={genderLabel} route="profile-gender" />
<MenuRow title="出生日期" value={(profile && profile.birth_date) || '未设置'} route="profile-birth-date" />
<MenuRow title="二维码名片" route="personal-qrcode" />
<MenuRow title="地区" value={(profile && profile.region) || '未设置'} route="profile-region" />
<MenuRow title="个性签名" value={(profile && profile.signature) || '此人很懒什么都没留下~'} route="profile-signature" />
</main></div>;
}
function TextEditor({field, multiline, title, maxLength, onToast}) {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
const [value, setValue] = React.useState(profile[field] || '');
// Real field_rules confirmed against server/im_http.go: nickname max_length=32, signature
// max_length=120 -- the reference screenshots show 120/20 respectively (swapped and wrong),
// mock code already had the right numbers by luck; kept here but sourced from a constant that
// matches the server contract rather than the screenshots.
const FIELD_LIMITS = {nickname: 32, signature: 120};
function TextEditor({field, multiline, title, onToast}) {
const [profile, setProfile] = React.useState(null);
const [value, setValue] = React.useState('');
const [saving, setSaving] = React.useState(false);
React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p[field] || ''); }).catch(() => {}); }, [field]);
const save = () => {
const normalized = value.trim();
if (field === 'nickname' && !normalized) return onToast('昵称不能为空');
const next = {...profile, [field]: field === 'signature' ? value.trim() : normalized};
window.localStorage.setItem(PROFILE_KEY, JSON.stringify(next));
onToast('资料已保存');
window.setTimeout(() => navigate('personal-profile'), 650);
if (field === 'nickname' && !normalized) { onToast('昵称不能为空'); return; }
setSaving(true);
updateMeProfile({[field]: normalized, version: profile.version}).then(_ => {
onToast('资料已保存');
window.setTimeout(() => navigate('personal-profile'), 500);
}).catch(err => onToast(err.message || '保存失败')).finally(() => setSaving(false));
};
return <div className="personal-page profile-editor-page"><Header title={title} back="personal-profile" action="完成" onAction={save} /><main><label>{multiline ? <textarea value={value} maxLength={maxLength} autoFocus onChange={event => setValue(event.target.value)} /> : <input value={value} maxLength={maxLength} autoFocus onChange={event => setValue(event.target.value)} />}<small>{value.length}/{maxLength}</small>{value ? <button type="button" onClick={() => setValue('')} aria-label="清空"><Icon>cancel</Icon></button> : null}</label><p>{field === 'nickname' ? '昵称将展示在联系人资料和聊天页面中' : '签名将展示在你的个人资料页'}</p></main></div>;
const maxLength = FIELD_LIMITS[field];
return <div className="personal-page profile-editor-page">
<Header title={title} back="personal-profile" action="完成" onAction={save} actionDisabled={!profile || saving} />
<main>
<label>
{multiline ?
<textarea value={value} maxLength={maxLength} autoFocus placeholder="请输入……" onChange={event => setValue(event.target.value)} /> :
<input value={value} maxLength={maxLength} autoFocus onChange={event => setValue(event.target.value)} />}
<small>{value.length}/{maxLength}</small>
{value ? <button type="button" onClick={() => setValue('')} aria-label="清空"><Icon>cancel</Icon></button> : null}
</label>
<p>{field === 'nickname' ? '昵称将展示在联系人资料和聊天页面中' : '签名将展示在你的个人资料页'}</p>
</main>
</div>;
}
function BirthDateEditor({onToast}) {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
const [value, setValue] = React.useState(profile.birthDate);
const save = () => {window.localStorage.setItem(PROFILE_KEY, JSON.stringify({...profile, birthDate: value})); onToast('出生日期已保存'); window.setTimeout(() => navigate('personal-profile'), 650);};
return <div className="personal-page profile-editor-page"><Header title="出生日期" back="personal-profile" action="完成" onAction={save} /><main className="birth-date-editor"><Icon>cake</Icon><label><span>选择出生日期</span><input type="date" value={value} max="2026-08-25" min="1900-01-01" onChange={event => setValue(event.target.value)} /></label><p>出生日期的可见范围由隐私设置决定</p></main></div>;
const [profile, setProfile] = React.useState(null);
const [value, setValue] = React.useState('');
React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p.birth_date || ''); }).catch(() => {}); }, []);
const save = () => {
updateMeProfile({birth_date: value, version: profile.version}).then(_ => {
onToast('出生日期已保存');
window.setTimeout(() => navigate('personal-profile'), 500);
}).catch(err => onToast(err.message || '保存失败'));
};
const today = new Date().toISOString().slice(0, 10);
return <div className="personal-page profile-editor-page">
<Header title="出生日期" back="personal-profile" action="完成" onAction={save} actionDisabled={!profile} />
<main className="birth-date-editor">
<Icon>cake</Icon>
<label><span>选择出生日期</span><input type="date" value={value} max={today} min="1900-01-01" onChange={event => setValue(event.target.value)} /></label>
<p>出生日期的可见范围由隐私设置决定</p>
</main>
</div>;
}
const GENDER_OPTIONS = [{value: 'male', label: '男'}, {value: 'female', label: '女'}, {value: 'other', label: '其他'}];
function GenderEditor({onToast}) {
const [profile, setProfile] = React.useState(null);
React.useEffect(() => { getMeProfile().then(setProfile).catch(() => {}); }, []);
const choose = value => {
updateMeProfile({gender: value, version: profile.version}).then(_ => {
onToast('性别已更新');
window.setTimeout(() => navigate('personal-profile'), 400);
}).catch(err => onToast(err.message || '保存失败'));
};
return <div className="personal-page"><Header title="性别" back="personal-profile" /><main className="gender-option-list">
{GENDER_OPTIONS.map(opt => <button type="button" key={opt.value} onClick={() => choose(opt.value)} disabled={!profile}>
<strong>{opt.label}</strong>{profile && profile.gender === opt.value ? <Icon>check</Icon> : null}
</button>)}
</main></div>;
}
function RegionEditor({onToast}) {
const [profile, setProfile] = React.useState(null);
const [value, setValue] = React.useState('');
React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p.region || ''); }).catch(() => {}); }, []);
const save = () => {
updateMeProfile({region: value.trim(), version: profile.version}).then(_ => {
onToast('地区已保存');
window.setTimeout(() => navigate('personal-profile'), 500);
}).catch(err => onToast(err.message || '保存失败'));
};
return <div className="personal-page profile-editor-page">
<Header title="地区" back="personal-profile" action="完成" onAction={save} actionDisabled={!profile} />
<main><label><input value={value} maxLength={60} autoFocus placeholder="省份/城市" onChange={event => setValue(event.target.value)} /></label></main>
</div>;
}
function PersonalQr({onToast}) {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
const [token, setToken] = React.useState(Date.now());
// Same shared 'me-profile' key as PersonalCenter/PersonalProfile -- this only needs it for the
// avatar/name display next to the code. `qr` itself is deliberately NOT cached: createMyQRCode()
// mints/renews a signed code each call (POST semantics, not a GET), so seeding a stale payload
// risks showing an already-superseded code that fails to scan -- worse than a brief spinner.
const [profile, setProfile] = React.useState(() => readCachedData('me-profile') || null);
const [qr, setQr] = React.useState(null);
const qrRef = React.useRef(null);
const load = () => {
createMyQRCode().then(setQr).catch(err => onToast(err.message || '二维码生成失败'));
};
React.useEffect(() => {
getMeProfile().then(p => { writeCachedData('me-profile', p); setProfile(p); }).catch(() => {});
load();
}, []);
React.useEffect(() => {
if (!qr || !qrRef.current) return;
qrRef.current.innerHTML = '';
new QRCode(qrRef.current, {text: `im://profile-card?token=demo-${token}&purpose=add_friend`, width: 210, height: 210, correctLevel: QRCode.CorrectLevel.M});
}, [token]);
return <div className="personal-page personal-qr-page"><Header title="我的二维码" /><main><section><div className="personal-qr-profile"><img src="img/bkg/p05.jpg" alt="" /><span><strong>{profile.nickname}</strong><small>星海科技有限公司</small></span></div><div className="personal-qrcode" ref={qrRef} /><p>扫一扫上面的二维码,加我为好友</p><small>二维码凭证有效期为 24 小时</small></section><div className="personal-qr-actions"><button type="button" onClick={() => {setToken(Date.now()); onToast('二维码已刷新');}}><Icon>refresh</Icon><span>刷新</span></button><button type="button" onClick={() => onToast('二维码名片已分享')}><Icon>share</Icon><span>分享</span></button></div></main></div>;
new QRCode(qrRef.current, {text: qr.qr_payload || qr.qr_token, width: 210, height: 210, correctLevel: QRCode.CorrectLevel.M});
}, [qr]);
return <div className="personal-page personal-qr-page"><Header title="我的二维码" /><main>
<section>
<div className="personal-qr-profile">
{profile && profile.avatar_file_id ? <ImgFromFile fileId={profile.avatar_file_id} /> : <Icon>person</Icon>}
<span><strong>{(profile && profile.nickname) || (profile && profile.public_account)}</strong><small>{tenantName()}</small></span>
</div>
<div className="personal-qrcode" ref={qrRef} />
<p>扫一扫上面的二维码,加我为好友</p>
{qr && qr.expires_at ? <small>二维码将于 {new Date(qr.expires_at).toLocaleString()} 失效</small> : null}
</section>
<div className="personal-qr-actions">
<button type="button" onClick={() => { load(); onToast('二维码已刷新'); }}><Icon>refresh</Icon><span>刷新</span></button>
<button type="button" onClick={() => {
if (navigator.share && qr) {
navigator.share({title: '我的名片', text: qr.qr_payload || qr.qr_token}).catch(() => {});
} else {
onToast('当前环境不支持分享');
}
}}><Icon>share</Icon><span>分享</span></button>
</div>
</main></div>;
}
function AndroidSettings({onToast}) {
return <div className="personal-page"><Header title="设置" /><main className="android-settings-list"><section><MenuRow icon="shield" title="账号与安全" route="security" /><MenuRow icon="notifications_none" title="消息通知" route="notification-permission" /><MenuRow icon="lock_outline" title="隐私设置" route="privacy-settings" /></section><section><MenuRow icon="photo_camera" title="相机权限说明" route="camera-permission-info" /><MenuRow icon="mic_none" title="麦克风权限说明" route="microphone-permission-info" /></section><section><MenuRow icon="delete_sweep" title="清空缓存" value="126 MB" route="cache-clear-confirm" /><MenuRow icon="child_care" title="青少年模式" value={window.localStorage.getItem(YOUTH_KEY) === 'enabled' ? '已开启' : '未开启'} route={window.localStorage.getItem(YOUTH_KEY) === 'enabled' ? 'youth-mode-on' : 'youth-mode-off'} /><MenuRow icon="system_update" title="版本更新" value="1.2.4" route="app-version" /></section><section><MenuRow icon="block" title="黑名单" route="block-list" /><MenuRow icon="wallpaper" title="聊天背景" route="wallpapers" /><MenuRow icon="info_outline" title="关于应用" route="app-version" /></section><button type="button" className="personal-logout" onClick={() => onToast('退出登录需在后端对接阶段完成会话撤销')}>退出当前登录</button></main></div>;
// docs/images/设置(iOS).png 只有7项,docs/images/设置(安卓).png 多了"青少年模式"/"实名认证"两行状态
// 展示 -- 两个平台业务能力理应一致(文字需求也这么写),采用更完整的安卓版列表,不再区分iOS/安卓两份。
function SettingsList({onToast, onLogout}) {
// 'youth-mode' is shared with the YouthMode page's own fetch of the same endpoint; 'me-realname'
// has no other reader today but gets the same treatment for consistency -- both status badges here
// otherwise flash a plausible-but-possibly-wrong value (realnameLabel's own fallback below shows
// "未认证" the instant this remounts, even while the still-unauthenticated-looking fetch is in
// flight) rather than a neutral loading placeholder, so caching avoids a *wrong* badge, not just a
// blank one.
const [youthSettings, setYouthSettings] = React.useState(() => readCachedData('youth-mode') || null);
const [realname, setRealname] = React.useState(() => readCachedData('me-realname') || null);
React.useEffect(() => {
getYouthMode().then(d => { writeCachedData('youth-mode', d); setYouthSettings(d); }).catch(() => {});
getRealnameStatus().then(d => { writeCachedData('me-realname', d); setRealname(d); }).catch(() => {});
}, []);
const youthEnabled = youthSettings ? youthSettings.enabled : null;
const realnameStatus = realname ? realname.status : null;
const realnameLabel = {verified: '已认证', pending: '审核中', rejected: '未通过'}[realnameStatus] || '未认证';
const logout = () => {
if (window.confirm('确定要退出当前登录吗?')) {
onLogout ? onLogout() : onToast('当前环境不支持退出登录');
}
};
return <div className="personal-page"><Header title="设置" /><main className="android-settings-list">
<section>
<MenuRow icon="delete_sweep" title="清除缓存" route="cache-clear-confirm" />
<MenuRow icon="mark_chat_read" title="清除所有聊天记录" route="clear-history-confirm" />
<MenuRow icon="password" title="修改密码" route="change-password" />
</section>
<section>
<MenuRow icon="lock_outline" title="隐私设置" route="privacy-settings" />
<MenuRow icon="block" title="黑名单" route="block-list" />
<MenuRow icon="child_care" title="青少年模式" value={youthEnabled == null ? '…' : (youthEnabled ? '已开启' : '未开启')}
route={youthEnabled ? 'youth-mode-on' : 'youth-mode-off'} />
<MenuRow icon="verified_user" title="实名认证" value={realnameLabel} route="realname" />
</section>
<section><MenuRow icon="no_accounts" title="注销账号" route="delete-account-confirm" danger /></section>
<button type="button" className="personal-logout" onClick={logout}>退出当前登录</button>
</main></div>;
}
function ConfirmSheet({icon, title, body, confirmLabel = '确认', confirmDanger = true, busy, onConfirm, onCancel, children}) {
return <div className="personal-sheet-backdrop"><section className="personal-confirm-sheet" role="dialog" aria-modal="true">
<Icon>{icon}</Icon><h2>{title}</h2><p>{body}</p>
{children}
<button type="button" className={confirmDanger ? 'danger' : 'primary'} disabled={busy} onClick={onConfirm}>{busy ? '处理中…' : confirmLabel}</button>
<button type="button" onClick={onCancel} disabled={busy}>取消</button>
</section></div>;
}
// No backend endpoint clears cache/history -- both are pure client-side operations (confirmed:
// server/main.go's ~74 routes have nothing for either). Cache clearing empties Cache Storage/IDB;
// clearing all history calls the native Tinode delMessagesAll(false) per open conversation, same
// method contact-management-view.jsx/group-management-view.jsx already use for single-topic clears.
function CacheClear({onToast}) {
const [cleared, setCleared] = React.useState(false);
return <div className="personal-page"><AndroidSettings onToast={onToast} /><div className="personal-sheet-backdrop"><section className="personal-confirm-sheet" role="dialog" aria-modal="true"><Icon>{cleared ? 'check_circle' : 'delete_sweep'}</Icon><h2>{cleared ? '缓存已清理' : '清空缓存'}</h2><p>{cleared ? '已释放 126 MB 存储空间。' : '将清除缩略图、临时文件和可重新下载的数据,不会删除聊天记录、收藏或登录信息。'}</p>{cleared ? <button type="button" className="primary" onClick={() => navigate('android-settings')}>完成</button> : <><button type="button" className="danger" onClick={() => {setCleared(true); onToast('缓存清理完成');}}>确认清理</button><button type="button" onClick={() => navigate('android-settings')}>取消</button></>}</section></div></div>;
const [busy, setBusy] = React.useState(false);
const [done, setDone] = React.useState(false);
const [freed, setFreed] = React.useState(0);
const confirm = async () => {
setBusy(true);
try {
const before = (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0;
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
const after = (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0;
setFreed(Math.max(0, before - after));
setDone(true);
} finally {
setBusy(false);
}
};
return <ConfirmSheet icon={done ? 'check_circle' : 'delete_sweep'} title={done ? '缓存已清理' : '清空缓存'}
body={done ? `已释放约 ${(freed / (1024 * 1024)).toFixed(1)} MB 存储空间。` : '确定清空本地的缓存数据?将清除缩略图、临时文件和可重新下载的数据,不会删除聊天记录、收藏或登录信息。'}
confirmLabel={done ? '完成' : '确定'} confirmDanger={!done} busy={busy}
onConfirm={done ? () => navigate('android-settings') : confirm} onCancel={() => navigate('android-settings')} />;
}
function AppVersion({updatePrompt, onToast}) {
const [checking, setChecking] = React.useState(false);
const check = () => {setChecking(true); window.setTimeout(() => {setChecking(false); navigate('app-update-available');}, 700);};
return <div className="personal-page app-version-page"><Header title="关于与更新" back="android-settings" /><main><section className="app-brand"><span><Icon>forum</Icon></span><h2>光讯通讯</h2><p>Android 版本 1.2.4</p></section><section className="personal-menu-section"><MenuRow title="用户条款" route="app-version" /><MenuRow title="隐私协议" route="app-version" /><MenuRow title="检测新版本" value={checking ? '检测中…' : '有新版本'} onClick={check} /><MenuRow title="联系我们" value="support@im.example" /></section></main>{updatePrompt ? <div className="update-backdrop"><section className="update-dialog" role="dialog" aria-modal="true"><span><Icon>rocket_launch</Icon></span><h2>发现新版本</h2><strong>版本 1.3.0</strong><ul><li>优化消息与工作台加载速度</li><li>完善隐私设置和 Android 权限引导</li><li>修复已知问题</li></ul><button type="button" onClick={() => onToast('更新任务已交给 Android 下载管理器')}>立即更新</button><button type="button" onClick={() => navigate('app-version')}>稍后提醒</button></section></div> : null}</div>;
function ClearHistoryConfirm({tinode, chatList, onToast}) {
const [busy, setBusy] = React.useState(false);
const confirm = () => {
if (!tinode || !chatList || !chatList.length) { onToast('没有可清除的会话'); navigate('android-settings'); return; }
setBusy(true);
Promise.all(chatList.map(c => {
const topic = tinode.getTopic(c.name);
return topic ? topic.delMessagesAll(false).catch(() => {}) : Promise.resolve();
})).then(() => {
onToast('聊天记录已清空');
navigate('android-settings');
}).finally(() => setBusy(false));
};
return <ConfirmSheet icon="mark_chat_read" title="清除所有聊天记录"
body="确定清除所有聊天记录?此操作不可恢复,但不会影响好友/群聊关系和收藏内容。" busy={busy}
onConfirm={confirm} onCancel={() => navigate('android-settings')} />;
}
// Changing password has no REST endpoint -- it's native Tinode {acc} protocol
// (Tinode.updateAccountBasic), already wired end-to-end via tinode-web.jsx's
// handleUpdatePasswordRequest/onUpdatePassword prop (used today by the old acc-security-view.jsx).
// Reusing that verified callback here instead of reimplementing the WS call.
function ChangePassword({onUpdatePassword, onToast}) {
const [oldPwd, setOldPwd] = React.useState('');
const [newPwd, setNewPwd] = React.useState('');
const [confirmPwd, setConfirmPwd] = React.useState('');
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);
};
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>
<label><span>新密码</span><input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} placeholder="至少6位" /></label>
<label><span>确认新密码</span><input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} placeholder="再次输入新密码" /></label>
<p>Tinode 协议本身不会在服务端校验"原密码",这里的原密码输入是前端自己加的一道二次确认,防止误触。</p>
</main></div>;
}
// Account deletion is also native Tinode ({del what="user" hard=true} -> delCurrentUser(true)),
// already wired via tinode-web.jsx's handleDeleteAccount/onDeleteAccount prop. Server-side there's
// no cooldown/soft-delete and no password re-check (confirmed against server/user.go) -- adding a
// password confirmation step here is a deliberate, conservative frontend-only safeguard given how
// irreversible and consequential this action is, not something the backend requires.
function DeleteAccountConfirm({onDeleteAccount, onToast}) {
const [step, setStep] = React.useState(1);
const [password, setPassword] = React.useState('');
const [busy, setBusy] = React.useState(false);
const proceed = () => {
if (!password) { onToast('请输入密码以确认'); return; }
if (!onDeleteAccount) { onToast('当前环境不支持注销账号'); return; }
setBusy(true);
Promise.resolve(onDeleteAccount()).catch(err => { onToast(err.message || '注销失败'); setBusy(false); });
};
if (step === 1) {
return <ConfirmSheet icon="warning" title="注销账号"
body="注销后账号数据将被永久删除且无法恢复,包括聊天记录、好友关系和收藏内容。确定要继续吗?"
confirmLabel="继续" onConfirm={() => setStep(2)} onCancel={() => navigate('android-settings')} />;
}
return <ConfirmSheet icon="no_accounts" title="输入密码确认注销" body="请输入登录密码以确认注销账号(该操作立即生效,不可撤销)。"
busy={busy} onConfirm={proceed} onCancel={() => navigate('android-settings')}>
<input type="password" autoFocus value={password} onChange={e => setPassword(e.target.value)} placeholder="登录密码" />
</ConfirmSheet>;
}
// docs/images/版本更新页面.png 其实是"关于我们"页(app图标+版本号+用户条款/隐私协议/检测新版本/联系我们),
// 品牌名图片里出现了5个不同版本(捷信密使/光讯通讯/笔墨讯使/光迅通讯...),判定为设计稿混用了多版本草稿,
// 统一用代码里已确立的 BASE_APP_NAME(纯品牌名,不带版本号后缀),不采信任何一个图片品牌名。
// "检测新版本"(及跳转应用商店的更新弹窗)是原型给移动端设计的,web版没有应用商店可跳,按要求隐藏。
function AppVersion({onToast}) {
const currentVersion = PACKAGE_VERSION;
return <div className="personal-page app-version-page"><Header title="关于我们" back="android-settings" /><main>
<section className="app-brand"><span><Icon>forum</Icon></span><h2>{BASE_APP_NAME}</h2><p>版本 {currentVersion}</p></section>
<section className="personal-menu-section">
<MenuRow title="用户条款" onClick={() => onToast('用户条款')} />
<MenuRow title="隐私协议" onClick={() => onToast('隐私协议')} />
</section>
</main>
</div>;
}
// docs/images/隐私设置.png 只画了"添加我的方式"+"邀我入群需要验证"两项,但 UserPrivacySettings
// 结构体真实还有 profile_visibility/last_seen_visibility/read_receipts 三个字段 -- 图片大概率没拍全,
// 这三项是真实、有意义的字段,保留;"邀我入群需要验证"反过来是图片有但后端完全没有对应字段(代码调研
// 确认 UserPrivacySettings 里没有这个概念,群邀请入群逻辑本身也不检查任何隐私开关),故不做这一项,
// 记录为后端缺口而不是造一个点了没有效果的假开关。
function PrivacySettings({onToast}) {
const initial = readObject(PRIVACY_KEY, {groupVerification: true, readReceipts: true, lastSeen: true});
const [settings, setSettings] = React.useState(initial);
const update = (key, value) => {const next = {...settings, [key]: value}; setSettings(next); window.localStorage.setItem(PRIVACY_KEY, JSON.stringify(next)); onToast('隐私设置已保存');};
return <div className="personal-page"><Header title="隐私设置" back="android-settings" /><main className="privacy-list"><section><MenuRow title="添加我的方式" value="全部允许" route="friend-discovery-settings" /></section><section><div><span><strong>邀请我入群需要验证</strong><small>关闭后仍受企业安全策略保护</small></span><Toggle checked={settings.groupVerification} onChange={value => update('groupVerification', value)} /></div><div><span><strong>显示消息已读状态</strong><small>关闭后你也无法查看对方的已读状态</small></span><Toggle checked={settings.readReceipts} onChange={value => update('readReceipts', value)} /></div><div><span><strong>展示最后在线时间</strong><small>仅向联系人显示</small></span><Toggle checked={settings.lastSeen} onChange={value => update('lastSeen', value)} /></div></section><section><MenuRow title="黑名单" route="block-list" /></section></main></div>;
// Shares 'me-privacy' with FriendDiscoverySettings' identical getMePrivacy() call -- a toggle page,
// not a text edit form, so a background refetch self-corrects any stale toggle state harmlessly
// (no risk of clobbering in-progress typing the way a free-text field would).
const [privacy, setPrivacy] = React.useState(() => readCachedData('me-privacy') || null);
React.useEffect(() => { getMePrivacy().then(p => { writeCachedData('me-privacy', p); setPrivacy(p); }).catch(() => {}); }, []);
const update = (key, value) => {
const prev = privacy;
setPrivacy({...privacy, [key]: value});
updateMePrivacy({[key]: value, version: privacy.version}).then(p => { writeCachedData('me-privacy', p); setPrivacy(p); })
.catch(err => { onToast(err.message || '保存失败'); setPrivacy(prev); writeCachedData('me-privacy', prev); });
};
const methodSummary = () => {
if (!privacy) return '';
const labels = {account: '账号', phone: '手机号', qr: '二维码', group: '群聊'};
const on = Object.entries(privacy.add_me_methods || {}).filter(([, v]) => v).map(([k]) => labels[k] || k);
if (on.length === 4) return '全部允许';
return on.length ? on.join('、') : '全部关闭';
};
return <div className="personal-page"><Header title="隐私设置" back="android-settings" /><main className="privacy-list">
<section><MenuRow title="添加我的方式" value={methodSummary()} route="friend-discovery-settings" /></section>
{privacy ? <section>
<div><span><strong>显示消息已读状态</strong><small>关闭后你也无法查看对方的已读状态</small></span>
<Toggle checked={!!privacy.read_receipts} onChange={v => update('read_receipts', v)} /></div>
<div><span><strong>展示最后在线时间</strong><small>仅向联系人显示</small></span>
{/* Backend's validVisibility only accepts everyone/friends/nobody -- 'contacts'/'hidden' were
rejected by the PATCH validator outright (every toggle attempt 400'd and silently reverted),
and even a lenient backend would enforce the opposite of what turning this "on" should do
(imCanViewLastSeen's default case treats any unrecognized value as hidden). */}
<Toggle checked={privacy.last_seen_visibility !== 'nobody'} onChange={v => update('last_seen_visibility', v ? 'friends' : 'nobody')} /></div>
<div><span><strong>允许好友查看我的资料</strong><small>关闭后仅你自己可见</small></span>
<Toggle checked={privacy.profile_visibility !== 'nobody'} onChange={v => update('profile_visibility', v ? 'friends' : 'nobody')} /></div>
</section> : null}
</main></div>;
}
function FriendDiscoverySettings({onToast}) {
const initial = readObject(PRIVACY_KEY, {account: true, phone: true, qr: true, group: true});
const [methods, setMethods] = React.useState(initial);
const update = (key, value) => {const next = {...methods, [key]: value}; setMethods(next); window.localStorage.setItem(PRIVACY_KEY, JSON.stringify(next)); onToast('添加方式已更新');};
// Same shared 'me-privacy' key as PrivacySettings -- identical resource, so whichever of the two
// screens is opened first warms the cache for the other.
const [privacy, setPrivacy] = React.useState(() => readCachedData('me-privacy') || null);
React.useEffect(() => { getMePrivacy().then(p => { writeCachedData('me-privacy', p); setPrivacy(p); }).catch(() => {}); }, []);
const update = (key, value) => {
const prev = privacy;
const nextMethods = {...(privacy.add_me_methods || {}), [key]: value};
setPrivacy({...privacy, add_me_methods: nextMethods});
updateMePrivacy({add_me_methods: nextMethods, version: privacy.version}).then(p => { writeCachedData('me-privacy', p); setPrivacy(p); })
.catch(err => { onToast(err.message || '保存失败'); setPrivacy(prev); writeCachedData('me-privacy', prev); });
};
const rows = [{key: 'account', title: '账号'}, {key: 'phone', title: '手机号'}, {key: 'qr', title: '二维码'}, {key: 'group', title: '群聊'}];
return <div className="personal-page"><Header title="添加我的方式" back="privacy-settings" /><main className="privacy-list"><p className="privacy-note">关闭某种方式后,其他人将无法通过该渠道搜索或添加你。</p><section>{rows.map(row => <div key={row.key}><span><strong>可通过{row.title}添加我</strong></span><Toggle checked={methods[row.key]} onChange={value => update(row.key, value)} /></div>)}</section></main></div>;
return <div className="personal-page"><Header title="添加我的方式" back="privacy-settings" /><main className="privacy-list">
<p className="privacy-note">关闭某种方式后,其他人将无法通过该渠道搜索或添加你。</p>
{privacy ? <section>{rows.map(row => <div key={row.key}>
<span><strong>可通过{row.title}添加我</strong></span>
<Toggle checked={!!(privacy.add_me_methods || {})[row.key]} onChange={v => update(row.key, v)} />
</div>)}</section> : null}
</main></div>;
}
const YOUTH_RULES = ['每日使用时长不超过 40 分钟', '限制主动或被动添加好友、加入群聊的次数', '隐藏不适合未成年人的内容与功能'];
// docs/images/青少年模式未开启(安卓).png 的"每日不超过40分钟"/"加好友群聊每日各4次"是纯前端文案,
// 代码调研确认 YouthModeSettings.Restrictions 后端真实内容是三个类别码
// (night_quiet/content_filter/payment_guard),没有任何分钟数/次数字段 -- 这两个具体数字只作为说明
// 文案展示,不冒充是从后端配置读出来的。使用时长同理没有后端字段,这里做的是纯本地、按自然日重置的
// 近似计时(刷新/换设备会丢,不追求精确,比完全不显示更贴近原型但明确知道它不是权威数据)。
const YOUTH_RULES = [
`为呵护未成年人健康成长,${BASE_APP_NAME}推出青少年模式。该模式下部分功能将受限制使用`,
'每天开启后使用时长不超过 40 分钟',
'限制加好友/群聊次数(主动/被动加好友或群聊每日最多各 4 次)'
];
const YOUTH_USAGE_KEY = 'im-youth-mode-usage';
function YouthMode({state, onToast}) {
const enabled = state === 'on' || state === 'disable';
function useYouthUsageSeconds(active) {
const [seconds, setSeconds] = React.useState(0);
React.useEffect(() => {
const today = new Date().toISOString().slice(0, 10);
let stored = {};
try { stored = JSON.parse(window.localStorage.getItem(YOUTH_USAGE_KEY) || '{}'); } catch (_) { /* ignore */ }
if (stored.date !== today) stored = {date: today, seconds: 0};
setSeconds(stored.seconds || 0);
if (!active) return undefined;
const timer = window.setInterval(() => {
setSeconds(s => {
const next = s + 1;
window.localStorage.setItem(YOUTH_USAGE_KEY, JSON.stringify({date: today, seconds: next}));
return next;
});
}, 1000);
return () => window.clearInterval(timer);
}, [active]);
return seconds;
}
function formatHMS(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
const pad = n => String(n).padStart(2, '0');
return `${pad(h)}:${pad(m)}:${pad(s)}`;
}
function YouthMode({onToast}) {
// Shares 'youth-mode' with SettingsList's badge fetch (see comment there) and gets patched directly
// by YouthModeVerify right after a successful enable/disable, so navigating back here shows the new
// state immediately instead of the stale cached one for a moment before self-correcting.
const [settings, setSettings] = React.useState(() => readCachedData('youth-mode') || null);
const usage = useYouthUsageSeconds(settings && settings.enabled);
React.useEffect(() => {
setSettings(readCachedData('youth-mode') || null);
getYouthMode().then(d => { writeCachedData('youth-mode', d); setSettings(d); }).catch(err => onToast(err.message || '加载失败'));
}, []);
const startEnable = () => {
if (!settings) return;
if (!settings.password_set) {
onToast('请先设置独立密码');
window.setTimeout(() => navigate('identity-check'), 400);
return;
}
navigate('youth-mode-verify', {ym: 'enable'});
};
if (!settings) return <div className="personal-page youth-mode-page"><Header title="青少年模式" back="android-settings" /></div>;
return <div className="personal-page youth-mode-page">
<Header title="青少年模式" back="android-settings" />
<main>
<Icon>{settings.enabled ? 'verified_user' : 'child_care'}</Icon>
<h2>{settings.enabled ? '青少年模式已开启' : '青少年模式未开启'}</h2>
<ul>{YOUTH_RULES.map(rule => <li key={rule}>{rule}</li>)}</ul>
{settings.enabled ? <p className="youth-usage">今日已使用:{formatHMS(usage)}</p> : null}
</main>
<footer>
<button type="button" className={settings.enabled ? 'outline' : ''}
onClick={() => settings.enabled ? navigate('youth-mode-verify', {ym: 'disable'}) : startEnable()}>
{settings.enabled ? '关闭青少年模式' : '开启青少年模式'}
</button>
<small>可在设置中查看青少年模式状态</small>
</footer>
</div>;
}
// docs/images/青少年模式提示(安卓).png 实际内容是"今日已达40分钟拦截层"(文件名误导),其中的
// "输入密码继续使用"选项代码调研确认后端没有任何"临时解锁但不关闭"的操作可对应 -- serveIMYouthModeEnable
// /Disable 只有开/关两种终态,没有"本次会话继续"的中间态。不做这个假按钮,只保留真实可行的"关闭"路径。
//
// 开关流程统一:已设置密码时,输入密码 -> POST .../independent-password/verify 换 action_token
// (action 按 enable/disable 传 enable_youth_mode/close_youth_mode)-> 调 enable/disable。
// 未设置密码时开启前必须先走 identity-check(短信验证码)设置密码,这一步不消费于开启本身。
function YouthModeVerify({hashParams, onToast}) {
const mode = (hashParams && hashParams.ym) || 'disable';
const [settings, setSettings] = React.useState(null);
const [password, setPassword] = React.useState('');
const enable = () => {window.localStorage.setItem(YOUTH_KEY, 'enabled'); onToast('青少年模式已开启'); window.setTimeout(() => navigate('youth-mode-on'), 650);};
const disable = () => {if (password.length < 6) return onToast('请输入 6 位独立密码'); window.localStorage.removeItem(YOUTH_KEY); setPassword(''); onToast('青少年模式已关闭'); window.setTimeout(() => navigate('youth-mode-off'), 650);};
return <div className="personal-page youth-mode-page"><Header title="青少年模式" back="android-settings" /><main><Icon>{enabled ? 'verified_user' : 'child_care'}</Icon><h2>{enabled ? '青少年模式已开启' : '青少年模式未开启'}</h2><ul>{YOUTH_RULES.map(rule => <li key={rule}>{rule}</li>)}</ul>{enabled ? <p className="youth-usage">今日已使用:00:24:05</p> : null}</main><footer><button type="button" className={enabled ? 'outline' : ''} onClick={() => navigate(enabled ? 'youth-mode-disable' : 'youth-mode-confirm')}>{enabled ? '关闭青少年模式' : '开启青少年模式'}</button><small>可在设置中查看青少年模式状态</small></footer>{state === 'confirm' ? <div className="personal-sheet-backdrop"><section className="personal-confirm-sheet youth-confirm" role="dialog" aria-modal="true"><Icon>family_restroom</Icon><h2>开启青少年模式</h2><p>开启后将按照当前企业的青少年保护策略限制使用时长、社交次数和部分内容。独立密码用于关闭模式。</p><button type="button" className="primary" onClick={enable}>确认并开启</button><button type="button" onClick={() => navigate('youth-mode-off')}>取消</button></section></div> : null}{state === 'disable' ? <div className="personal-sheet-backdrop"><section className="personal-confirm-sheet youth-confirm" role="dialog" aria-modal="true"><Icon>password</Icon><h2>关闭青少年模式</h2><p>请输入开启青少年模式时设置的 6 位独立密码。</p><input type="password" inputMode="numeric" maxLength="6" value={password} autoFocus onChange={event => setPassword(event.target.value.replace(/\D/g, ''))} placeholder="独立密码" /><button type="button" className="primary" onClick={disable}>验证并关闭</button><button type="button" onClick={() => navigate('youth-mode-on')}>取消</button></section></div> : null}</div>;
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
React.useEffect(() => { getYouthMode().then(setSettings).catch(() => {}); }, []);
const submit = () => {
if (password.length !== 4) { setError('请输入4位独立密码'); return; }
setBusy(true);
setError('');
// Server's serveIMYouthModeEnable consumes the token with a strict action='enable_youth_mode'
// match (ConsumeActionToken does an exact WHERE action=? lookup) -- minting it tagged
// 'set_independent_password' meant the lookup could never find it, so every enable attempt
// 403'd with ACTION_TOKEN_INVALID regardless of a correct password.
const action = mode === 'enable' ? 'enable_youth_mode' : 'close_youth_mode';
verifyIndependentPassword(password, 'youth_mode', action).then(({action_token}) => {
const call = mode === 'enable' ?
enableYouthMode(settings.policy_version, action_token) :
disableYouthMode(action_token);
return call;
}).then(_ => {
// Patch the shared cache with the now-known new state before navigating back to YouthMode/
// SettingsList -- without this they'd briefly show the pre-toggle cached value again and then
// flip, which is a more confusing flash than the plain loading state they had before caching.
if (settings) writeCachedData('youth-mode', {...settings, enabled: mode === 'enable'});
onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭');
window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
}).catch(err => setError(err.message || '验证失败')).finally(() => setBusy(false));
};
return <div className="personal-page youth-mode-page">
<Header title={mode === 'enable' ? '开启青少年模式' : '关闭青少年模式'} back={mode === 'enable' ? 'youth-mode-off' : 'youth-mode-on'} />
<main className="youth-password-gate">
<div className="auth-pin-boxes" aria-hidden="true">{[0, 1, 2, 3].map(i => <span key={i}>{password[i] ? '●' : ''}</span>)}</div>
<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="独立密码" />
{error ? <div className="auth-field-error">{error}</div> : null}
<button type="button" className="auth-primary" disabled={password.length !== 4 || busy} onClick={submit}>{busy ? '验证中…' : '确定'}</button>
</main>
</div>;
}
// 10.18-10.21:纯静态权限说明卡片,浏览器原生权限API(Notification.requestPermission()/
// getUserMedia())本身没有"用途说明"这个中间步骤,所以这一层完全是前端自己在真正触发系统权限
// 弹窗前插入的说明UI,不需要任何后端接口 -- 与代码调研确认的"清缓存/清聊天记录"是同一类判断。
const PERMISSIONS = {
notification: {icon: 'notifications_active', title: '获取通知权限', text: '用于及时送达新消息、会议提醒和安全通知。', action: '开启通知', route: 'permission-settings-reminder'},
reminder: {icon: 'settings', title: '消息推送暂未开启', text: '请前往 Android 系统设置,在通知管理中允许光讯通讯发送通知。', action: '前往设置', route: 'android-settings'},
camera: {icon: 'photo_camera', title: '相机权限使用说明', text: '用于拍照、扫码和视频通话。拒绝后仍可从相册选择图片。', action: '继续授权', route: 'messages-home'},
notification: {icon: 'notifications_active', title: '获取推送权限', text: '向您及时送达重要通知、活动提醒、个性化内容更新等信息。', action: '开启通知', route: 'permission-settings-reminder'},
reminder: {icon: 'settings', title: '消息推送暂未开启', text: `前往【设置 - 通知管理】一键开启吧!`, action: '好的', secondaryAction: '暂不开启', route: 'android-settings'},
camera: {icon: 'photo_camera', title: '相机权限使用说明', text: '调用相机拍摄/扫码,为您提供拍照、扫码识别等服务。', action: '继续授权', route: 'messages-home'},
microphone: {icon: 'mic', title: '麦克风权限使用说明', text: '用于语音消息、语音输入和音视频通话,未授权时不会创建录音文件。', action: '继续授权', route: 'messages-home'}
};
function PermissionGuide({type, onToast}) {
const config = PERMISSIONS[type];
const proceed = () => {onToast(type === 'reminder' ? '已打开 Android 应用设置' : '等待 Android 系统授权结果'); window.setTimeout(() => navigate(config.route), 650);};
return <div className="personal-page permission-guide-page"><div className="permission-preview"><header><h1>消息</h1><Icon>menu</Icon></header><label><Icon>search</Icon><span>搜索</span></label>{['系统消息', '产品研发群', '文件助手', '安妮'].map((name, index) => <div key={name}><span className={'permission-avatar tone-' + index}><Icon>{index === 0 ? 'notifications' : index === 2 ? 'folder' : 'person'}</Icon></span><span><strong>{name}</strong><small>{index ? '最近有一条新消息' : '账号安全提醒'}</small></span></div>)}</div><div className="permission-overlay"><section role="dialog" aria-modal="true"><span><Icon>{config.icon}</Icon></span><h2>{config.title}</h2><p>{config.text}</p><button type="button" onClick={proceed}>{config.action}</button><button type="button" onClick={() => navigate('android-settings')}>暂不开启</button></section></div></div>;
const proceed = () => {
if (type === 'notification' && 'Notification' in window) {
Notification.requestPermission().then(() => navigate(config.route));
return;
}
onToast(type === 'reminder' ? '请前往系统设置开启通知' : '等待系统授权结果');
window.setTimeout(() => navigate(config.route), 500);
};
return <div className="personal-page permission-guide-page">
<div className="permission-preview"><header><h1>消息</h1><Icon>menu</Icon></header></div>
<div className="permission-overlay"><section role="dialog" aria-modal="true">
<span><Icon>{config.icon}</Icon></span>
<h2>{config.title}</h2>
<p>{config.text}</p>
<button type="button" onClick={proceed}>{config.action}</button>
{config.secondaryAction ? <button type="button" onClick={() => navigate('messages-home')}>{config.secondaryAction}</button> : null}
</section></div>
</div>;
}
export default function PersonalSettingsView({view}) {
export default function PersonalSettingsView({view, hashParams, myUserId, tinode, chatList, onUpdatePassword, onDeleteAccount, onLogout}) {
const [toast, setToast] = React.useState('');
let content;
if (view === 'personal-center') content = <PersonalCenter />;
else if (view === 'personal-profile') content = <PersonalProfile onToast={setToast} />;
else if (view === 'profile-nickname') content = <TextEditor field="nickname" title="设置昵称" maxLength={32} onToast={setToast} />;
else if (view === 'profile-signature') content = <TextEditor field="signature" title="个性签名" maxLength={120} multiline onToast={setToast} />;
else if (view === 'personal-profile') content = <PersonalProfile onToast={setToast} tinode={tinode} />;
else if (view === 'profile-nickname') content = <TextEditor field="nickname" title="设置昵称" onToast={setToast} />;
else if (view === 'profile-signature') content = <TextEditor field="signature" title="个性签名" multiline onToast={setToast} />;
else if (view === 'profile-birth-date') content = <BirthDateEditor onToast={setToast} />;
else if (view === 'profile-gender') content = <GenderEditor onToast={setToast} />;
else if (view === 'profile-region') content = <RegionEditor onToast={setToast} />;
else if (view === 'personal-qrcode') content = <PersonalQr onToast={setToast} />;
else if (view === 'android-settings') content = <AndroidSettings onToast={setToast} />;
else if (view === 'android-settings') content = <SettingsList onToast={setToast} onLogout={onLogout} />;
else if (view === 'cache-clear-confirm') content = <CacheClear onToast={setToast} />;
else if (view === 'app-version' || view === 'app-update-available') content = <AppVersion updatePrompt={view === 'app-update-available'} onToast={setToast} />;
else if (view === 'clear-history-confirm') content = <ClearHistoryConfirm onToast={setToast} tinode={tinode} chatList={chatList} />;
else if (view === 'change-password') content = <ChangePassword onToast={setToast} onUpdatePassword={onUpdatePassword} />;
else if (view === 'delete-account-confirm') content = <DeleteAccountConfirm onToast={setToast} onDeleteAccount={onDeleteAccount} />;
else if (view === 'app-version') content = <AppVersion onToast={setToast} />;
else if (view === 'privacy-settings') content = <PrivacySettings onToast={setToast} />;
else if (view === 'friend-discovery-settings') content = <FriendDiscoverySettings onToast={setToast} />;
else if (view.startsWith('youth-mode')) content = <YouthMode state={view.replace('youth-mode-', '')} onToast={setToast} />;
else if (view === 'youth-mode-off' || view === 'youth-mode-on') content = <YouthMode onToast={setToast} />;
else if (view === 'youth-mode-verify') content = <YouthModeVerify hashParams={hashParams} onToast={setToast} />;
else content = <PermissionGuide type={view === 'notification-permission' ? 'notification' : view === 'permission-settings-reminder' ? 'reminder' : view === 'camera-permission-info' ? 'camera' : 'microphone'} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
}
+205 -45
View File
@@ -1,29 +1,26 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import { uploadLargeFile, getAbuseReasons, submitAbuseReport, getFeedbackConfig, submitFeedback } from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
const navigate = view => HashNavigation.navigateTo(view);
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
};
export const SECURITY_SUPPORT_VIEWS = [
'fraud-risk-warning', 'fraud-risk-confirmation', 'abuse-report-reasons',
'abuse-report-details', 'user-feedback'
];
const REPORT_REASON_KEY = 'im-demo-abuse-reason';
const REPORT_REASONS = [
{code: 'fraud', label: '诈骗或骗取钱财', description: '诱导转账、刷单、投资或提供验证码', icon: 'currency_yuan', required: true},
{code: 'harassment', label: '骚扰或恶意联系', description: '频繁打扰、辱骂、威胁或跟踪', icon: 'record_voice_over'},
{code: 'sexual', label: '色情或低俗内容', description: '发送色情图片、文字或相关服务信息', icon: 'visibility_off'},
{code: 'illegal', label: '违法违禁内容', description: '赌博、毒品、暴力或其他违法信息', icon: 'gavel'},
{code: 'misinformation', label: '谣言或虚假信息', description: '发布误导性、冒充身份或不实信息', icon: 'campaign'},
{code: 'infringement', label: '侵权行为', description: '侵犯隐私、肖像、著作权或商标权', icon: 'copyright'},
{code: 'other', label: '其他违规行为', description: '不属于以上类型的问题', icon: 'more_horiz', required: true}
];
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back}) {
return <header className="support-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
return <header className="support-header"><button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
}
function Toast({message, onDone}) {
@@ -35,53 +32,216 @@ function Toast({message, onDone}) {
return message ? <div className="support-toast" role="status">{message}</div> : null;
}
function ConversationPreview() {
return <div className="risk-conversation-preview"><header><button type="button"><Icon>arrow_back_ios_new</Icon></button><span><strong>安妮</strong><small>在线</small></span><Icon>more_vert</Icon></header><main><p>今天 14:28</p><div className="peer">这个内部项目收益很高,名额有限。</div><div className="peer">先转一笔保证金,我马上帮你办理。</div><div className="mine">好的,我现在转账。</div></main><footer><span>输入消息</span><Icon>add_circle_outline</Icon></footer></div>;
// Backend has no "content risk scan" or "post-send fraud alert" mechanism (confirmed against
// im_be/server/im_http.go and session.go -- grepping for 诈骗/反诈/110/fraud in the whole backend
// only turns up abuse-report reason labels, no scanning logic and no system-notification event type
// for it). The two reference images (反诈弹窗(安卓).png / 反诈弹窗(二次提示安卓).png) show generic
// anti-fraud education content that isn't tied to any specific message or backend-driven trigger --
// there's no field anywhere (bootstrap, profile, settings) indicating "has this been shown before".
// So this is implemented as a standalone, always-reachable safety-info page (not something that
// pops up automatically), reusing the two designs' content/copy verbatim for their real interactive
// 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>
{secondary ? <>
<p className="risk-emphasis">您发送的内容可能涉及诈骗风险,请注意甄别。</p>
<p>切勿轻信陌生人的投资理财、兼职刷单、网络贷款等信息,谨防上当受骗。</p>
<aside><Icon>phone_in_talk</Icon><span>如遇诈骗请立即拨打 110 报警。</span></aside>
</> : <>
<p>为保障您的账号及资金安全,请您注意:</p>
<ul>
<li><Icon>receipt_long</Icon>不参与刷单返利活动</li>
<li><Icon>currency_yuan</Icon>不轻信高收益投资、理财信息</li>
<li><Icon>person_off</Icon>不出租、出借、出售账号及实名信息</li>
<li><Icon>lock</Icon>不向任何人泄露验证码、密码等敏感信息</li>
</ul>
<p className="risk-emphasis">请提高安全防范意识,谨防电信网络诈骗。</p>
</>}
<button type="button" className="primary" onClick={() => navigate(secondary ? 'fraud-risk-warning' : 'personal-center')}>{secondary ? '我已知晓' : '我已阅读并知晓'}</button>
</main></div>;
}
function RiskWarning({secondary, onToast}) {
const continueAction = () => {
if (!secondary) return navigate('fraud-risk-confirmation');
onToast('已记录风险确认,将返回原操作');
window.setTimeout(() => navigate('chat-standard'), 750);
};
return <div className="support-page risk-warning-page"><ConversationPreview /><div className="risk-overlay"><section className={'risk-dialog' + (secondary ? ' secondary' : '')} role="alertdialog" aria-modal="true"><span className="risk-symbol"><Icon>{secondary ? 'warning_amber' : 'shield_lock'}</Icon></span><h2>安全提醒</h2>{secondary ? <><p className="risk-emphasis">当前操作可能涉及诈骗风险,请再次确认。</p><p>请勿轻信陌生人的刷单返利、投资理财、网络贷款等信息。验证码、密码和转账信息不要提供给任何人。</p><aside><Icon>phone_in_talk</Icon><span>如遇诈骗,请立即停止操作并拨打 110 报警。</span></aside></> : <><p>为保障你的账号和资金安全,请注意:</p><ul><li><Icon>money_off</Icon>不参与刷单返利或高收益投资</li><li><Icon>badge</Icon>不出租、出借或出售账号和实名信息</li><li><Icon>password</Icon>不向任何人泄露验证码和密码</li><li><Icon>link_off</Icon>不打开来源不明的链接或安装包</li></ul></>}<button type="button" className={secondary ? 'danger' : 'primary'} onClick={continueAction}>{secondary ? '我已确认风险,仍要继续' : '我已阅读,继续'}</button><button type="button" onClick={() => {onToast('操作已取消'); window.setTimeout(() => navigate('chat-standard'), 650);}}>{secondary ? '停止操作' : '取消'}</button></section></div></div>;
// Exported for messages-view.jsx to render inline when composing a message that contains a URL --
// this is the one piece of Ch.11 with a genuine backend mechanism behind it (checkURL/confirmRiskChallenge,
// verified against im_http.go:1184-1285). `level`/`blocked` come straight from the checkURL response.
export function RiskConfirmDialog({level, blocked, onConfirm, onCancel}) {
return <div className="risk-overlay"><section className="risk-dialog secondary" role="alertdialog" aria-modal="true">
<span className="risk-symbol"><Icon>{blocked ? 'block' : 'warning_amber'}</Icon></span>
<h2>安全提醒</h2>
{blocked ? <p className="risk-emphasis">检测到该内容存在较高安全风险,暂无法发送。</p> : <>
<p className="risk-emphasis">您发送的内容可能涉及诈骗风险,请注意甄别。</p>
<p>切勿轻信陌生人的投资理财、兼职刷单、网络贷款等信息,谨防上当受骗。如遇诈骗请立即拨打 110 报警。</p>
</>}
{blocked
? <button type="button" onClick={onCancel}>我已知晓</button>
: <><button type="button" className="danger" onClick={onConfirm}>我已知晓,继续发送</button><button type="button" onClick={onCancel}>取消发送</button></>}
</section></div>;
}
function ReportReasons() {
const select = reason => {window.sessionStorage.setItem(REPORT_REASON_KEY, JSON.stringify(reason)); navigate('abuse-report-details');};
return <div className="support-page"><Header title="举报" back="friend-settings" /><main className="report-reasons"><section className="report-target"><img src="img/bkg/p09.jpg" alt="" /><span><strong>举报安妮</strong><small>举报人身份不会向对方公开</small></span><Icon>verified_user</Icon></section><h2>请选择举报原因</h2><section className="report-reason-list">{REPORT_REASONS.map(reason => <button type="button" key={reason.code} onClick={() => select(reason)}><span><Icon>{reason.icon}</Icon></span><span><strong>{reason.label}</strong><small>{reason.description}</small></span><Icon>chevron_right</Icon></button>)}</section></main></div>;
// target_type is one of user/group/message only (verified against server/store/store.go's
// AbuseReasons validation and im_http.go's normalizeIMAbuseTarget switch -- there is no
// "group_member" variant server-side; reporting a group member uses target_type=user and simply
// loses the "which group" context, which is a real backend limitation, not something fixable here).
function ReportReasons({hashParams, onToast}) {
const targetType = (hashParams && hashParams.target_type) || 'user';
const targetName = (hashParams && hashParams.target_name) || '';
const [reasons, setReasons] = React.useState(null);
React.useEffect(() => {
getAbuseReasons(targetType).then(data => setReasons(Array.isArray(data) ? data : (data && (data.reasons || data.items)) || []))
.catch(err => { onToast(err.message || '加载失败'); setReasons([]); });
}, [targetType]);
const select = reason => navigate('abuse-report-details', {...hashParams, reason_code: reason.reason_code || reason.code, reason_label: reason.label, requires_description: reason.requires_description ? '1' : ''});
{/* 'personal-center' made sense when contact-management-view.jsx's friend-settings page was the
only entry point; tinode-web.jsx's handleReportTopic (block-and-report from inside a chat,
which closes that chat before landing here) now also routes here, and for that origin
'personal-center' is a non-sequitur -- messages-home is a reasonable default for both. */}
return <div className="support-page"><Header title="举报" back="messages-home" /><main className="report-reasons">
{targetName ? <section className="report-target"><Icon>account_circle</Icon><span><strong>举报{targetName}</strong><small>举报人身份不会向对方公开</small></span></section> : null}
<h2>请选择举报原因</h2>
{reasons === null ? <p className="report-loading">加载中…</p> : <section className="report-reason-list">
{reasons.map(reason => <button type="button" key={reason.reason_code || reason.code} onClick={() => select(reason)}>
<span><strong>{reason.label}</strong></span><Icon>chevron_right</Icon>
</button>)}
</section>}
</main></div>;
}
function ReportDetails({onToast}) {
const reason = (() => {try { return JSON.parse(window.sessionStorage.getItem(REPORT_REASON_KEY)) || REPORT_REASONS[0]; } catch (_) { return REPORT_REASONS[0]; }})();
function ReportDetails({hashParams, tinode, onToast}) {
const reasonLabel = (hashParams && hashParams.reason_label) || '';
const reasonCode = (hashParams && hashParams.reason_code) || '';
const requiresDescription = (hashParams && hashParams.requires_description) === '1';
const [description, setDescription] = React.useState('');
const [evidence, setEvidence] = React.useState([]);
const [submitted, setSubmitted] = React.useState(false);
const addEvidence = () => {if (evidence.length >= 5) return onToast('最多上传 5 个证据文件'); setEvidence(items => items.concat({id: Date.now(), name: `聊天截图_${items.length + 1}.png`}));};
const submit = () => {if (reason.required && !description.trim()) return onToast('请补充问题描述'); setSubmitted(true);};
return <div className="support-page"><Header title="举报" back="abuse-report-reasons" />{submitted ? <main className="support-success"><Icon>task_alt</Icon><h2>举报已受理</h2><p>我们会依据平台规则核实处理,举报人身份不会向被举报方公开。</p><dl><div><dt>受理编号</dt><dd>ABR-20260825-1024</dd></div><div><dt>当前状态</dt><dd>审核中</dd></div></dl><button type="button" onClick={() => navigate('messages-home')}>完成</button></main> : <main className="report-details"><section className="report-selected-reason"><Icon>{reason.icon}</Icon><span><small>举报原因</small><strong>{reason.label}</strong></span><button type="button" onClick={() => navigate('abuse-report-reasons')}>修改</button></section><label className="support-textarea"><span>问题描述{reason.required ? <b>*</b> : null}</span><textarea value={description} maxLength="500" onChange={event => setDescription(event.target.value)} placeholder="请描述具体情况、发生时间和相关行为" /><small>{description.length}/500</small></label><section className="evidence-section"><header><span><strong>证据材料</strong><small>图片或文件,最多 5 个</small></span><em>{evidence.length}/5</em></header><div>{evidence.map(file => <article key={file.id}><Icon>image</Icon><span>{file.name}</span><button type="button" onClick={() => setEvidence(items => items.filter(item => item.id !== file.id))} aria-label="删除证据"><Icon>close</Icon></button></article>)}<button type="button" className="evidence-upload" onClick={addEvidence}><Icon>add_photo_alternate</Icon><span>添加证据</span></button></div></section><aside className="report-privacy-note"><Icon>lock</Icon><span>证据仅用于平台审核,将按照安全策略保存和访问。</span></aside></main>} {!submitted ? <footer className="support-submit-bar"><button type="button" onClick={submit}>提交举报</button></footer> : null}</div>;
const [uploading, setUploading] = React.useState(false);
const [submitting, setSubmitting] = React.useState(false);
const [submitted, setSubmitted] = React.useState(null);
const fileInputRef = React.useRef(null);
const addEvidence = event => {
const file = event.target.files && event.target.files[0];
event.target.value = '';
if (!file || !tinode) return;
if (evidence.length >= 9) { onToast('最多上传 9 个证据文件'); return; }
setUploading(true);
uploadLargeFile(tinode, file).then(({fileId}) => setEvidence(items => items.concat({id: fileId, name: file.name})))
.catch(err => onToast(err.message || '上传失败')).finally(() => setUploading(false));
};
const submit = () => {
if (requiresDescription && !description.trim()) { onToast('请补充问题描述'); return; }
const target = {type: hashParams.target_type || 'user'};
if (hashParams.target_type === 'message') { target.topic = hashParams.topic; target.seq = String(hashParams.seq); }
else if (hashParams.target_type === 'group') { target.topic = hashParams.topic || hashParams.target_id; }
else { target.user_id = hashParams.target_id; }
setSubmitting(true);
submitAbuseReport({target, reason_code: reasonCode, description: description.trim(), evidence_file_ids: evidence.map(e => e.id)})
.then(result => setSubmitted(result))
.catch(err => onToast(err.message || '提交失败'))
.finally(() => setSubmitting(false));
};
return <div className="support-page"><Header title="举报" back="abuse-report-reasons" />
{submitted ? <main className="support-success"><Icon>task_alt</Icon><h2>举报已受理</h2><p>我们会依据平台规则核实处理,举报人身份不会向被举报方公开。</p><dl><div><dt>受理编号</dt><dd>{submitted.report_id}</dd></div></dl><button type="button" onClick={() => navigate('messages-home')}>完成</button></main>
: <main className="report-details">
<section className="report-selected-reason"><span><small>举报原因</small><strong>{reasonLabel}</strong></span><button type="button" onClick={() => navigate('abuse-report-reasons', hashParams)}>修改</button></section>
<label className="support-textarea"><span>问题描述{requiresDescription ? <b>*</b> : null}</span><textarea value={description} maxLength="500" onChange={event => setDescription(event.target.value)} placeholder="请描述具体情况、发生时间和相关行为" /><small>{description.length}/500</small></label>
<section className="evidence-section">
<header><span><strong>证据材料</strong><small>图片或文件,最多 9 个</small></span><em>{evidence.length}/9</em></header>
<div>
{evidence.map(file => <article key={file.id}><Icon>image</Icon><span>{file.name}</span><button type="button" onClick={() => setEvidence(items => items.filter(item => item.id !== file.id))} aria-label="删除证据"><Icon>close</Icon></button></article>)}
<button type="button" className="evidence-upload" disabled={uploading} onClick={() => fileInputRef.current && fileInputRef.current.click()}><Icon>add_photo_alternate</Icon><span>{uploading ? '上传中…' : '添加证据'}</span></button>
<input ref={fileInputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={addEvidence} />
</div>
</section>
<aside className="report-privacy-note"><Icon>lock</Icon><span>证据仅用于平台审核,将按照安全策略保存和访问。</span></aside>
</main>}
{!submitted ? <footer className="support-submit-bar"><button type="button" disabled={submitting} onClick={submit}>{submitting ? '提交中…' : '提交举报'}</button></footer> : null}
</div>;
}
function UserFeedback({onToast}) {
const [category, setCategory] = React.useState('bug');
// category is REQUIRED server-side (POST /feedback rejects empty category) but the reference image
// (意见反馈.png) shows no category-selection UI at all -- picking a sensible default silently would
// mean every feedback submission gets misfiled under one bucket. A minimal category picker is added
// here (using the real categories from GET /feedback/config, not the mock's invented four-item list)
// since it's a real, meaningful, backend-required field -- same judgment call as Ch.6's "my nickname
// in this group" editor: no reference image, but a real backend capability worth a minimal UI for.
// `contact` (image has no input for it) is left unset -- it's optional server-side, so omitting it
// doesn't break the request, and adding a field the design never showed would be inventing UI.
function UserFeedback({tinode, onToast}) {
// Fixed/parameterless config (max files, categories, max length, diagnostics notice) reached from a
// single entry point (personal-center's "意见反馈" row) off the same hub whose own profile fetch is
// already cached -- seeding both `config` and its derived default `category` together from the same
// cached object avoids a flash where the category picker renders (gated on `config` alone) with
// nothing selected yet.
const cachedFeedbackConfig = readCachedData('feedback-config');
const [config, setConfig] = React.useState(() => cachedFeedbackConfig || null);
const [category, setCategory] = React.useState(() => (cachedFeedbackConfig && cachedFeedbackConfig.categories && cachedFeedbackConfig.categories[0]) || '');
const [content, setContent] = React.useState('');
const [contact, setContact] = React.useState('');
const [attachments, setAttachments] = React.useState(0);
const [images, setImages] = React.useState([]);
const [uploading, setUploading] = React.useState(false);
const [diagnostics, setDiagnostics] = React.useState(false);
const [submitted, setSubmitted] = React.useState(false);
const submit = () => {if (!content.trim()) return onToast('请填写反馈内容'); setSubmitted(true);};
if (submitted) return <div className="support-page"><Header title="意见反馈" back="personal-center" /><main className="support-success"><Icon>mark_email_read</Icon><h2>反馈已受理</h2><p>感谢你的反馈,我们会尽快查看并用于改进产品。</p><dl><div><dt>反馈编号</dt><dd>FB-20260825-0836</dd></div><div><dt>提交信息</dt><dd>Android 1.2.4</dd></div></dl><button type="button" onClick={() => navigate('personal-center')}>返回我的</button></main></div>;
const categories = [{id: 'bug', label: '功能异常', icon: 'bug_report'}, {id: 'suggestion', label: '产品建议', icon: 'lightbulb'}, {id: 'complaint', label: '服务投诉', icon: 'support_agent'}, {id: 'other', label: '其他', icon: 'more_horiz'}];
return <div className="support-page feedback-page"><Header title="意见反馈" back="personal-center" /><main><section className="feedback-categories"><h2>反馈类型</h2><div>{categories.map(item => <button type="button" className={category === item.id ? 'active' : ''} key={item.id} onClick={() => setCategory(item.id)}><Icon>{item.icon}</Icon><span>{item.label}</span></button>)}</div></section><label className="support-textarea"><span>问题或建议<b>*</b></span><textarea value={content} maxLength="1000" onChange={event => setContent(event.target.value)} placeholder="请详细描述你遇到的问题或建议" /><small>{content.length}/1000</small></label><section className="feedback-upload"><header><span><strong>截图或附件</strong><small>最多 5 个,单个不超过 20 MB</small></span><em>{attachments}/5</em></header><button type="button" onClick={() => attachments < 5 ? setAttachments(value => value + 1) : onToast('最多上传 5 个附件')}><Icon>add_photo_alternate</Icon><span>{attachments ? `已选择 ${attachments} 个附件` : '添加附件'}</span></button></section><label className="feedback-contact"><span>联系方式</span><input value={contact} maxLength="80" onChange={event => setContact(event.target.value)} placeholder="手机号或邮箱(选填)" /></label><label className="diagnostics-consent"><input type="checkbox" checked={diagnostics} onChange={event => setDiagnostics(event.target.checked)} /><span><strong>同意上传脱敏诊断信息</strong><small>仅包含 Android 版本、应用版本和错误日志,不包含聊天内容、密码或 Token。</small></span></label></main><footer className="support-submit-bar"><button type="button" onClick={submit}>提交反馈</button></footer></div>;
const [submitting, setSubmitting] = React.useState(false);
const [submitted, setSubmitted] = React.useState(null);
const fileInputRef = React.useRef(null);
const maxFiles = (config && config.attachment_policy && config.attachment_policy.max_files) || 5;
const maxLength = (config && config.max_length) || 300;
React.useEffect(() => {
getFeedbackConfig().then(data => {
writeCachedData('feedback-config', data);
setConfig(data); setCategory((data.categories && data.categories[0]) || 'other');
}).catch(() => {});
}, []);
const CATEGORY_LABELS = {bug: '功能异常', suggestion: '产品建议', complaint: '服务投诉', other: '其他'};
const addImage = event => {
const file = event.target.files && event.target.files[0];
event.target.value = '';
if (!file || !tinode) return;
if (images.length >= maxFiles) { onToast(`最多上传 ${maxFiles} 张图片`); return; }
setUploading(true);
uploadLargeFile(tinode, file).then(({fileId}) => setImages(items => items.concat({id: fileId})))
.catch(err => onToast(err.message || '上传失败')).finally(() => setUploading(false));
};
const submit = () => {
if (!content.trim()) { onToast('请填写反馈内容'); return; }
setSubmitting(true);
submitFeedback({category, content: content.trim(), attachment_file_ids: images.map(i => i.id), include_diagnostics: diagnostics})
.then(result => setSubmitted(result))
.catch(err => onToast(err.message || '提交失败'))
.finally(() => setSubmitting(false));
};
if (submitted) return <div className="support-page"><Header title="意见反馈" back="personal-center" /><main className="support-success"><Icon>mark_email_read</Icon><h2>反馈已受理</h2><p>感谢你的反馈,我们会尽快查看并用于改进产品。</p><dl><div><dt>反馈编号</dt><dd>{submitted.feedback_id}</dd></div></dl><button type="button" onClick={() => navigate('personal-center')}>返回我的</button></main></div>;
return <div className="support-page feedback-page"><Header title="意见反馈" back="personal-center" /><main>
{config && config.categories && config.categories.length > 1 ? <section className="feedback-categories"><h2>反馈类型</h2><div>
{config.categories.map(id => <button type="button" className={category === id ? 'active' : ''} key={id} onClick={() => setCategory(id)}>{CATEGORY_LABELS[id] || id}</button>)}
</div></section> : null}
<label className="support-textarea">
<span>问题反馈<b>*</b></span>
<textarea value={content} maxLength={maxLength} onChange={event => setContent(event.target.value)} placeholder="为了更好的帮您解决问题,请在提交问题时上传截图信息,方便我们更快速的帮助您解决问题。" />
<small>{content.length}/{maxLength}</small>
</label>
<section className="feedback-upload">
<header><span><strong>图片</strong></span><em>{images.length}/{maxFiles}</em></header>
<button type="button" disabled={uploading} onClick={() => fileInputRef.current && fileInputRef.current.click()}><Icon>add_a_photo</Icon><span>{uploading ? '上传中…' : '点击上传'}</span></button>
<input ref={fileInputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={addImage} />
</section>
{config && config.diagnostics_notice ? <label className="diagnostics-consent"><input type="checkbox" checked={diagnostics} onChange={event => setDiagnostics(event.target.checked)} /><span><strong>同意上传脱敏诊断信息</strong><small>{config.diagnostics_notice}</small></span></label> : null}
</main><footer className="support-submit-bar"><button type="button" disabled={submitting} onClick={submit}>{submitting ? '提交中…' : '提交'}</button></footer></div>;
}
export default function SecuritySupportView({view}) {
export default function SecuritySupportView({view, hashParams, tinode}) {
const [toast, setToast] = React.useState('');
let content;
if (view === 'fraud-risk-warning' || view === 'fraud-risk-confirmation') content = <RiskWarning secondary={view === 'fraud-risk-confirmation'} onToast={setToast} />;
else if (view === 'abuse-report-reasons') content = <ReportReasons />;
else if (view === 'abuse-report-details') content = <ReportDetails onToast={setToast} />;
else content = <UserFeedback onToast={setToast} />;
if (view === 'fraud-risk-warning' || view === 'fraud-risk-confirmation') content = <FraudAwareness secondary={view === 'fraud-risk-confirmation'} />;
else if (view === 'abuse-report-reasons') content = <ReportReasons hashParams={hashParams} onToast={setToast} />;
else if (view === 'abuse-report-details') content = <ReportDetails hashParams={hashParams} tinode={tinode} onToast={setToast} />;
else content = <UserFeedback tinode={tinode} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
}
+30 -9
View File
@@ -134,7 +134,20 @@ class SidepanelView extends React.PureComponent {
if (chapterOneViews.includes(view)) {
return <div id="sidepanel" className="auth-onboarding-panel">
<AuthOnboardingView view={view} authenticated={!!this.props.myUserId} />
<AuthOnboardingView
view={view}
authenticated={!!this.props.myUserId}
login={this.props.login}
loginDisabled={this.props.loginDisabled}
persist={this.props.persist}
errorText={this.props.errorText}
errorLevel={this.props.errorLevel}
onLoginRequest={this.props.onLoginRequest}
onPersistenceChange={this.props.onPersistenceChange}
onCreateAccount={this.props.onCreateAccount}
onImRegisterRequest={this.props.onImRegisterRequest}
onTenantResolved={this.props.onTenantResolved}
onError={this.props.onError} />
</div>;
}
@@ -147,7 +160,8 @@ class SidepanelView extends React.PureComponent {
connected={this.props.connected}
chatList={this.props.chatList}
myUserId={this.props.myUserId}
onTopicSelected={this.props.onTopicSelected} />
onTopicSelected={this.props.onTopicSelected}
onOpenScanner={this.props.onOpenScanner} />
</div>;
}
@@ -168,19 +182,22 @@ class SidepanelView extends React.PureComponent {
if (CONTACT_MANAGEMENT_VIEWS.includes(view)) {
return <div id="sidepanel" className="contact-management-panel">
<ContactManagementView view={view} key={view} />
<ContactManagementView view={view} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} onOpenScanner={this.props.onOpenScanner} key={view} />
</div>;
}
if (GROUP_MANAGEMENT_VIEWS.includes(view)) {
return <div id="sidepanel" className="group-management-panel">
<GroupManagementView view={view} key={view} />
<GroupManagementView view={view} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} key={view} />
</div>;
}
if (CONTENT_LIBRARY_VIEWS.includes(view)) {
return <div id="sidepanel" className="content-library-panel">
<ContentLibraryView view={view} key={view} />
<ContentLibraryView view={view} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} chatList={this.props.chatList} key={view} />
</div>;
}
@@ -192,19 +209,23 @@ class SidepanelView extends React.PureComponent {
if (WORKPLACE_COLLABORATION_VIEWS.includes(view)) {
return <div id="sidepanel" className="workplace-collaboration-panel">
<WorkplaceCollaborationView view={view} key={view} />
<WorkplaceCollaborationView view={view} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} key={view} />
</div>;
}
if (PERSONAL_SETTINGS_VIEWS.includes(view)) {
return <div id="sidepanel" className="personal-settings-panel">
<PersonalSettingsView view={view} key={view} />
<PersonalSettingsView view={view} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
tinode={this.props.tinode} chatList={this.props.chatList}
onUpdatePassword={this.props.onUpdatePassword} onDeleteAccount={this.props.onDeleteAccount}
onLogout={this.props.onLogout} key={view} />
</div>;
}
if (SECURITY_SUPPORT_VIEWS.includes(view)) {
return <div id="sidepanel" className="security-support-panel">
<SecuritySupportView view={view} key={view} />
<SecuritySupportView view={view} hashParams={this.props.hashParams} tinode={this.props.tinode} key={view} />
</div>;
}
@@ -331,7 +352,7 @@ class SidepanelView extends React.PureComponent {
onLogout={this.props.onLogout}
onDeleteAccount={this.props.onDeleteAccount}
onShowAlert={this.props.onShowAlert}
onShowBlocked={() => this.props.onNavigate('block-list')}
onShowBlocked={this.props.onShowBlocked}
onToggleIncognitoMode={this.props.onToggleIncognitoMode} />
</Suspense> :
+344 -21
View File
@@ -11,7 +11,9 @@ import { Drafty, Tinode } from 'tinode-sdk';
import Alert from '../widgets/alert.jsx';
import ContextMenu from '../widgets/context-menu.jsx';
import ForwardDialog from '../widgets/forward-dialog.jsx';
import BatchForwardDialog from '../widgets/batch-forward-dialog.jsx';
import CallIncoming from '../widgets/call-incoming.jsx';
import QrScanner, { GroupJoinPreview } from '../widgets/qr-scanner.jsx';
const PhoneCountrySelector = React.lazy(_ => import('../widgets/phone-country-selector.jsx'));
const InfoView = React.lazy(_ => import('./info-view.jsx'));
@@ -27,9 +29,11 @@ import { PACKAGE_VERSION } from '../version.js';
import { base64ReEncode, makeImageUrl } from '../lib/blob-helpers.js';
import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js';
import LocalStorageUtil from '../lib/local-storage.js';
import { getTenantContext, resolveQRCode, createGroupJoinRequest } from '../lib/im-api.js';
import { writeCachedData } from '../lib/use-cached-data.js';
import HashNavigation from '../lib/navigation.js';
import { secondsToTime } from '../lib/strformat.js'
import { defaultWallpaper, updateFavicon } from '../lib/utils.js';
import { defaultWallpaper, theCard, topicDisplayName, updateFavicon } from '../lib/utils.js';
// Sound to play on message received.
const POP_SOUND = new Audio('audio/msg.m4a');
@@ -119,6 +123,7 @@ class TinodeWeb extends React.Component {
this.handleAutoreconnectIteration = this.handleAutoreconnectIteration.bind(this);
this.doLogin = this.doLogin.bind(this);
this.handleLoginSuccessful = this.handleLoginSuccessful.bind(this);
this.handleTenantResolved = this.handleTenantResolved.bind(this);
this.handleDisconnect = this.handleDisconnect.bind(this);
this.tnMeMetaDesc = this.tnMeMetaDesc.bind(this);
this.tnMeContactUpdate = this.tnMeContactUpdate.bind(this);
@@ -133,6 +138,7 @@ class TinodeWeb extends React.Component {
this.handleNewChatInvitation = this.handleNewChatInvitation.bind(this);
this.handleNewAccount = this.handleNewAccount.bind(this);
this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this);
this.handleImRegisterRequest = this.handleImRegisterRequest.bind(this);
this.handleUpdatePasswordRequest = this.handleUpdatePasswordRequest.bind(this);
this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this);
this.handleToggleIncognitoMode = this.handleToggleIncognitoMode.bind(this);
@@ -182,6 +188,18 @@ class TinodeWeb extends React.Component {
this.handleShowForwardDialog = this.handleShowForwardDialog.bind(this);
this.handleHideForwardDialog = this.handleHideForwardDialog.bind(this);
this.handleEnterSelectMode = this.handleEnterSelectMode.bind(this);
this.handleToggleMessageSelect = this.handleToggleMessageSelect.bind(this);
this.handleExitSelectMode = this.handleExitSelectMode.bind(this);
this.handleShowBatchForward = this.handleShowBatchForward.bind(this);
this.handleHideBatchForward = this.handleHideBatchForward.bind(this);
this.handleOpenScanner = this.handleOpenScanner.bind(this);
this.handleCloseScanner = this.handleCloseScanner.bind(this);
this.handleQrDetected = this.handleQrDetected.bind(this);
this.handleSubmitGroupJoinRequest = this.handleSubmitGroupJoinRequest.bind(this);
this.handleCancelGroupJoinPreview = this.handleCancelGroupJoinPreview.bind(this);
this.handleStartVideoCall = this.handleStartVideoCall.bind(this);
this.handleStartAudioCall = this.handleStartAudioCall.bind(this);
this.handleInfoMessage = this.handleInfoMessage.bind(this);
@@ -253,6 +271,7 @@ class TinodeWeb extends React.Component {
errorActionText: null,
sidePanelSelected: 'tenant',
sidePanelParams: {},
sidePanelTitle: null,
sidePanelAvatar: null,
myTrustedBadges: [],
@@ -289,6 +308,21 @@ class TinodeWeb extends React.Component {
forwardDialogVisible: false,
forwardMessage: null,
// Ch.4 multi-select: entered via the message context menu's "menu_item_select" item.
selectMode: false,
selectModeTopic: null,
selectedSeqs: [],
batchForwardVisible: false,
// QR scanning ("扫一扫", Ch.2/5). Holds live camera state so it's modeled the same way as
// the call panel/forward dialog above, not as a hash route -- there's nothing to deep-link
// into mid-scan, and the camera stream needs a reliable single owner to tear down.
scannerOpen: false,
// Set once a "join_group" QR resolves to a real group the user isn't in yet (status
// "can_request") -- holds enough of the group's summary to render the confirm sheet, plus
// the scanned token so the join request can reference how it was created.
groupJoinPreview: null,
// Modal alert dialog.
alertVisible: false,
alertParams: {},
@@ -357,7 +391,12 @@ class TinodeWeb extends React.Component {
});
new Promise((resolve, reject) => {
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, isSecureConnection(), this.state.transport,
// Must match state.secureConnection's own fallback (line ~233), not just isSecureConnection()
// directly -- otherwise a tenant resolved on a prior visit (which persists secureConnection via
// handleTenantResolved) diverges from the client actually constructed here on this load, and
// handleTenantResolved's own "did anything change" check (comparing against this same state)
// then wrongly concludes there's nothing to reconnect.
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.secureConnection, this.state.transport,
this.props.intl.locale, this.state.persist, resolve);
this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
@@ -423,9 +462,46 @@ class TinodeWeb extends React.Component {
secure: secureConnection, persist: persistentCache}, onSetupCompleted);
tinode.setHumanLanguage(locale);
tinode.enableLogging(LOGGING_ENABLED, true);
TinodeWeb.installTenantHiPatch(tinode);
return tinode;
}
// The server extends two stock packets with fields the JS SDK has no API for (server/datamodel.go):
// MsgClientHi.Tenant (`hi.tenant`, resolved via POST /v1/im/tenants/resolve) and MsgClientAcc's
// `username`/`phone`/`invite_code` (used by server/user.go replyCreateUser during registration).
// #initPacket is a private class field so the only usable hook is wrapping the connection's sendText
// and injecting fields into the outgoing packet's already-serialized JSON. A cheap substring check
// keeps this a no-op for the hot path (every {pub}/{note}/... also goes through sendText).
static installTenantHiPatch(tinode) {
const conn = tinode && tinode._connection;
if (!conn || conn.__tenantPatched) {
return;
}
const original = conn.sendText.bind(conn);
conn.sendText = msg => {
if (typeof msg == 'string' && (msg.indexOf('"hi":') >= 0 || msg.indexOf('"acc":') >= 0)) {
try {
const parsed = JSON.parse(msg);
if (parsed.hi) {
const tenant = getTenantContext();
if (tenant && tenant.code) {
parsed.hi.tenant = tenant.code;
msg = JSON.stringify(parsed);
}
} else if (parsed.acc && tinode.__pendingAccExtra) {
Object.assign(parsed.acc, tinode.__pendingAccExtra);
tinode.__pendingAccExtra = null;
msg = JSON.stringify(parsed);
}
} catch (e) {
// Not valid JSON -- send as is.
}
}
return original(msg);
};
conn.__tenantPatched = true;
}
// Tinode received a push notification from the server.
handlePushMessage(payload) {
this.tinode.oobNotification(payload.data || {});
@@ -593,7 +669,7 @@ class TinodeWeb extends React.Component {
'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-role-message','group-qr','group-governance','group-admins','group-admin-add',
'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',
@@ -603,19 +679,22 @@ class TinodeWeb extends React.Component {
'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','personal-qrcode','android-settings','cache-clear-confirm',
'app-version','app-update-available','privacy-settings','friend-discovery-settings',
'youth-mode-off','youth-mode-confirm','youth-mode-on','youth-mode-disable',
'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])) {
newState.sidePanelSelected = hash.path[0];
newState.sidePanelParams = hash.params;
} else {
console.warn("Unknown sidepanel view", hash.path[0]);
}
@@ -638,7 +717,7 @@ class TinodeWeb extends React.Component {
}
} else {
// Empty hashpath
Object.assign(newState, {sidePanelSelected: '', topicSelected: null});
Object.assign(newState, {sidePanelSelected: '', sidePanelParams: {}, topicSelected: null});
}
// Save credential validation parameters, if available.
@@ -890,6 +969,11 @@ class TinodeWeb extends React.Component {
if (LocalStorageUtil.getObject('keep-logged-in')) {
LocalStorageUtil.setObject('auth-token', this.tinode.getAuthToken());
}
// im-api.js's REST calls (bootstrap, friends, ...) need a bearer token independent of whether the
// user opted into surviving a browser restart -- sessionStorage covers "this tab's session",
// matching how long the in-memory WS auth actually lasts when "keep logged in" is off.
const authToken = this.tinode.getAuthToken();
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
// On reconnect, requestedTopic is undefined but topicSelected may still be set.
// Preserve the open topic so it gets restored after re-login.
@@ -924,7 +1008,10 @@ class TinodeWeb extends React.Component {
}).finally(_ => {
this.setState({loadSpinnerVisible: false});
});
let urlHash = HashNavigation.setUrlSidePanel(window.location.hash, 'contacts');
// 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');
if (goToTopic) {
urlHash = HashNavigation.setUrlTopic(urlHash, goToTopic);
}
@@ -1310,6 +1397,23 @@ class TinodeWeb extends React.Component {
});
}
// Registration via the product's username/phone flow (chapter 1.4). `extra` carries the fields the
// server added to {acc} (username/phone/invite_code, see server/user.go resolveRegistrationAccount) --
// the SDK has no API for them, so they're injected by the sendText patch installed in tnSetup().
// `cred_` (when phone was verified through POST /v1/im/auth/verification-codes) is
// {meth:'tel', val, resp: verification_token, params: {challenge_id}} -- see server/user.go
// consumeProductVerificationCred, which validates resp/params against the REST-issued token.
//
// `login_` must equal what the server's own resolveRegistrationAccount will compute as the
// account name, or replyCreateUser rejects the whole request with a policy violation before it
// even looks at username/phone/cred: for phone registration that's the E.164 phone (not blank --
// registrationSecretMatches requires a non-empty match), for username registration it's the
// username itself.
handleImRegisterRequest(login_, password_, displayName, extra, cred_) {
this.tinode.__pendingAccExtra = extra;
this.handleNewAccountRequest(login_, password_, theCard(displayName, null), cred_);
}
handleToggleIncognitoMode(on) {
// Make state undefined.
this.setState({incognitoMode: null});
@@ -1424,6 +1528,36 @@ class TinodeWeb extends React.Component {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, ''));
}
// Tenant was resolved via POST /v1/im/tenants/resolve (see auth-onboarding-view.jsx TenantEntry).
// Persists the tenant context (read by im-api.js and by the {hi}-patching in tnSetup) and, if the
// resolved tenant genuinely lives on a different host, rebuilds the Tinode client to point there.
handleTenantResolved(data, requestedCode) {
const tenant = data.tenant || {};
const endpoints = data.endpoints || {};
LocalStorageUtil.setObject('im-tenant', {
code: tenant.code || requestedCode,
name: tenant.name || tenant.code || requestedCode,
endpoints
});
let host = this.state.serverAddress;
let secureConnection = this.state.secureConnection;
if (endpoints.websocket) {
try {
const wsUrl = new URL(endpoints.websocket);
host = wsUrl.host;
secureConnection = wsUrl.protocol === 'wss:';
} catch (e) {
// Malformed endpoint; keep the current host/scheme.
}
}
if (host && (host !== this.state.serverAddress || secureConnection !== this.state.secureConnection)) {
this.handleGlobalSettings({serverAddress: host, secureConnection});
}
// Else: same host/scheme, nothing to reconnect -- the sendText patch installed in tnSetup() reads
// the tenant context fresh on every {hi}, so the newly-persisted code takes effect immediately.
}
// User chose 'Archived chats'.
handleShowArchive() {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
@@ -1642,6 +1776,7 @@ class TinodeWeb extends React.Component {
localStorage.removeItem('auth-token');
localStorage.removeItem('firebase-token');
localStorage.removeItem('settings');
sessionStorage.removeItem('im-session-token');
if (this.state.firebaseToken && this.fcm) {
// Unsubscribe failures (e.g. 403 token-unsubscribe-failed) should not block logout.
firebaseDelToken(this.fcm).catch(err => {
@@ -1666,7 +1801,9 @@ class TinodeWeb extends React.Component {
this.setState(this.getBlankState());
cleared.then(_ => {
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, isSecureConnection(),
// Same fix as the initial tnSetup call in componentDidMount: use the resolved
// state.secureConnection (settings-aware), not a raw isSecureConnection() page-protocol check.
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.secureConnection,
this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'), _ => {
this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
@@ -1679,7 +1816,7 @@ class TinodeWeb extends React.Component {
}
handleDeleteAccount() {
this.tinode.delCurrentUser(true).then(_ => {
return this.tinode.delCurrentUser(true).then(_ => {
this.handleLogout();
});
}
@@ -1744,14 +1881,24 @@ class TinodeWeb extends React.Component {
return;
}
// Publish spam report.
this.tinode.report('report', topicName);
// The native `this.tinode.report('report', topicName)` call (WS pub to the "sys" topic) requires
// auth.LevelRoot server-side (server/session.go) which a normal logged-in user never has -- it
// silently fails permission checks and never reaches the abuse-report tables. Route to the real
// REST reporting flow (im-api.js's getAbuseReasons/submitAbuseReport, backed by POST /reports/abuse)
// instead of the old dead-end call.
const isP2P = topic.isP2PType();
const targetName = topicDisplayName(topicName, topic.public, topicName);
const params = isP2P
? {target_type: 'user', target_id: topicName, target_name: targetName}
: {target_type: 'group', target_id: topicName, topic: topicName, target_name: targetName};
// Remove J and P permissions.
// Remove J and P permissions (the "block" half of "block and report").
topic.updateMode(null, '-JP')
.then(_ => {
// Hide MessagesView and InfoView panels.
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
let hash = HashNavigation.setUrlTopic(window.location.hash, '');
hash = HashNavigation.setUrlSidePanel(hash, 'abuse-report-reasons');
Object.entries(params).forEach(([key, value]) => { hash = HashNavigation.addUrlParam(hash, key, value); });
HashNavigation.navigateTo(hash);
})
.catch(err => this.handleError(err.message, 'err'));
}
@@ -1876,9 +2023,113 @@ class TinodeWeb extends React.Component {
}
} else if (action == 'menu_item_forward') {
this.handleShowForwardDialog(params);
} else if (action == 'menu_item_select') {
this.handleEnterSelectMode(params);
}
}
// Ch.4 multi-select: pre-selects the message that was long-pressed to reach "多选".
handleEnterSelectMode(params) {
this.setState({
selectMode: true,
selectModeTopic: params.topicName,
selectedSeqs: [params.seq]
});
}
handleToggleMessageSelect(seq) {
const selectedSeqs = this.state.selectedSeqs.includes(seq) ?
this.state.selectedSeqs.filter(s => s != seq) :
this.state.selectedSeqs.concat(seq);
this.setState({selectedSeqs});
}
handleExitSelectMode() {
this.setState({selectMode: false, selectModeTopic: null, selectedSeqs: [], batchForwardVisible: false});
}
handleShowBatchForward() {
this.setState({batchForwardVisible: true});
}
handleHideBatchForward() {
this.setState({batchForwardVisible: false});
}
handleOpenScanner() {
this.setState({scannerOpen: true});
}
handleCloseScanner() {
this.setState({scannerOpen: false});
}
// `text` is whatever jsQR decoded from the camera frame -- only codes this app itself generated
// (createMyQRCode/createGroupQRCode, both `im://qr?token=...&purpose=...`) resolve to anything;
// a QR code from the outside world (a URL, a WiFi code) is reported as unrecognized rather than
// silently failing the resolve call with a confusing server error.
handleQrDetected(text) {
const hasScheme = typeof text === 'string' && text.indexOf('im://qr') === 0;
const params = hasScheme ? new URLSearchParams(text.slice(text.indexOf('?') + 1)) : null;
const token = params ? params.get('token') : null;
const purpose = params ? params.get('purpose') : null;
if (!token) {
this.setState({scannerOpen: false});
this.handleError('无法识别的二维码', 'err');
return;
}
resolveQRCode(token, purpose).then(data => {
if (data.purpose === 'join_group' && data.group) {
if (data.status === 'joined') {
this.setState({scannerOpen: false});
this.handleTopicSelected(data.group.topic);
} else {
this.setState({scannerOpen: false, groupJoinPreview: {...data.group, qrToken: token}});
}
return;
}
if (data.user) {
this.setState({scannerOpen: false});
// The profile page this navigates to re-fetches via GET /users/{id}/profile, which 403s for
// anyone who isn't already a friend (server-side privacy default) -- but resolveQRCode's
// response already carries the same fields unconditionally, so stash it under the same
// cache key contact-management-view.jsx's Profile/Verification read on mount, the same fix
// applied there for the account-search entry point into this identical dead end.
writeCachedData('profile-seed:' + data.user.user_id, {
...data.user,
capabilities: {can_add_friend: !!data.user.can_add}
});
let hash = HashNavigation.setUrlTopic(window.location.hash, '');
hash = HashNavigation.setUrlSidePanel(hash, data.user.relationship === 'friend' ? 'friend-profile' : 'group-member-profile');
hash = HashNavigation.addUrlParam(hash, 'uid', data.user.user_id);
HashNavigation.navigateTo(hash);
return;
}
this.setState({scannerOpen: false});
this.handleError('无法识别的二维码', 'err');
}).catch(err => {
this.setState({scannerOpen: false});
this.handleError(err.message || '二维码已失效', 'err');
});
}
handleSubmitGroupJoinRequest(message) {
const preview = this.state.groupJoinPreview;
if (!preview) {
return Promise.resolve();
}
return createGroupJoinRequest(preview.topic, {message, source: 'qr', qrToken: preview.qrToken}).then(() => {
this.setState({groupJoinPreview: null});
this.handleError('申请已提交,等待管理员通过', 'info');
}).catch(err => {
this.handleError(err.message || '申请提交失败', 'err');
});
}
handleCancelGroupJoinPreview() {
this.setState({groupJoinPreview: null});
}
handleShowAlert(title, content, onConfirm, confirmText, onReject, rejectText) {
this.setState({
alertVisible: true,
@@ -1893,7 +2144,35 @@ class TinodeWeb extends React.Component {
});
}
// The "信息" context-menu item used to always open the old, pre-migration InfoView (info-view.jsx)
// -- for P2P/group topics that's a real, previously-unnoticed gap: the new contact-management-view.jsx
// "friend-settings" / group-management-view.jsx "group-settings" pages (built and verified across
// Ch.5/6, with real HashNavigation.goBack()-based back navigation) had no way to actually be reached
// from an open chat's own header -- every test of them this engagement typed the hash directly.
// InfoView's own back button (handleBackNavigate in info-view.jsx) still hardcodes a single fixed
// parent per panel and never used real browser history, so routing here fixed both problems at once
// rather than teaching the old view a second back-navigation scheme it's slated for removal anyway.
handleShowInfoView() {
const topicName = this.state.topicSelected;
if (Tinode.isP2PTopicName(topicName)) {
// Base the new hash on '' (not window.location.hash) -- the current hash's path[1] still holds
// the open chat's own topic, and setUrlSidePanel only ever touches path[0]. Reusing the current
// hash as-is here leaves that topic sitting in path[1] where it collides with group-settings'/
// friend-settings' own query params, and handleHashRoute re-interprets the leftover path[1] as a
// fresh topic selection, blanking the whole view -- the exact bug already found and fixed for
// handleShowAnnouncement/handleShowJoinRequests/handleSendFriendVerification; same fix here.
let hash = HashNavigation.setUrlSidePanel('', 'friend-settings');
hash = HashNavigation.addUrlParam(hash, 'uid', topicName);
HashNavigation.navigateTo(hash);
return;
}
if (!Tinode.isSelfTopicName(topicName)) {
let hash = HashNavigation.setUrlSidePanel('', 'group-settings');
hash = HashNavigation.addUrlParam(hash, 'topic', topicName);
HashNavigation.navigateTo(hash);
return;
}
// "已保存的消息" (self topic) has no equivalent new settings page -- keep the old InfoView for it.
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', 'info'));
this.setState({infoPanel: 'info'});
}
@@ -2179,7 +2458,7 @@ class TinodeWeb extends React.Component {
const chapterSixActive = ['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-role-message', 'group-qr', 'group-governance',
'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'].includes(activeProductView);
@@ -2192,14 +2471,15 @@ class TinodeWeb extends React.Component {
'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off',
'call-minimized'].includes(activeProductView);
const workplaceCollaborationActive = ['workbench', 'organization-directory', 'meeting-organizer',
'meeting-attendee', 'daily-report-editor', 'daily-report-mine', 'daily-report-team',
'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'].includes(activeProductView);
const personalSettingsActive = ['personal-center', 'personal-profile', 'profile-nickname',
'profile-birth-date', 'profile-signature', 'personal-qrcode', 'android-settings',
'cache-clear-confirm', 'app-version', 'app-update-available', 'privacy-settings',
'friend-discovery-settings', 'youth-mode-off', 'youth-mode-confirm', 'youth-mode-on',
'youth-mode-disable', 'notification-permission', 'permission-settings-reminder',
'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'].includes(activeProductView);
const securitySupportActive = ['fraud-risk-warning', 'fraud-risk-confirmation',
'abuse-report-reasons', 'abuse-report-details', 'user-feedback'].includes(activeProductView);
@@ -2240,6 +2520,24 @@ class TinodeWeb extends React.Component {
:
null
}
{this.state.batchForwardVisible ?
<BatchForwardDialog
tinode={this.tinode}
contacts={this.state.chatList}
topicSelected={this.state.topicSelected}
myUserId={this.state.myUserId}
topicName={this.state.selectModeTopic}
seqs={this.state.selectedSeqs}
hide={sent => { this.handleHideBatchForward(); if (sent) { this.handleExitSelectMode(); } }}
onInitFind={this.tnInitFind}
searchResults={this.state.searchResults}
onSearchContacts={this.handleSearchContacts}
/>
:
null
}
{this.state.callTopic && this.state.callState == CALL_STATE_INCOMING_RECEIVED ?
<CallIncoming
tinode={this.tinode}
@@ -2255,6 +2553,20 @@ class TinodeWeb extends React.Component {
:
null
}
{this.state.scannerOpen ?
<QrScanner onDetect={this.handleQrDetected} onClose={this.handleCloseScanner} />
:
null
}
{this.state.groupJoinPreview ?
<GroupJoinPreview
group={this.state.groupJoinPreview}
onSubmit={this.handleSubmitGroupJoinRequest}
onCancel={this.handleCancelGroupJoinPreview}
/>
:
null
}
{this.state.alertVisible ?
<Alert
title={this.state.alertParams.title}
@@ -2272,6 +2584,7 @@ class TinodeWeb extends React.Component {
connected={this.state.connected}
displayMobile={this.state.displayMobile}
state={this.state.sidePanelSelected}
hashParams={this.state.sidePanelParams}
title={this.state.sidePanelTitle}
avatar={this.state.sidePanelAvatar}
trustedBadges={this.state.myTrustedBadges}
@@ -2314,8 +2627,10 @@ class TinodeWeb extends React.Component {
onSettings={this.handleSettings}
onNavigate={this.basicNavigator}
onLoginRequest={this.handleLoginRequest}
onTenantResolved={this.handleTenantResolved}
onPersistenceChange={this.handlePersistenceChange}
onCreateAccount={this.handleNewAccountRequest}
onImRegisterRequest={this.handleImRegisterRequest}
onUpdateAccountDesc={this.handleTopicUpdateRequest}
onUpdatePassword={this.handleUpdatePasswordRequest}
onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
@@ -2331,6 +2646,7 @@ class TinodeWeb extends React.Component {
onCredDelete={this.handleCredDelete}
onCredConfirm={this.handleCredConfirm}
onTopicSelected={this.handleTopicSelected}
onOpenScanner={this.handleOpenScanner}
onCreateTopic={this.handleStartTopicRequest}
onLogout={this.handleLogout}
onDeleteAccount={this.handleDeleteAccount}
@@ -2364,6 +2680,7 @@ class TinodeWeb extends React.Component {
viewportWidth={this.state.viewportWidth}
viewportHeight={this.state.viewportHeight}
topic={this.state.topicSelected}
jumpToSeq={this.state.sidePanelParams && this.state.sidePanelParams.jumpseq}
myUserId={this.state.myUserId}
// User public.fn.
myUserName={this.state.sidePanelTitle}
@@ -2377,6 +2694,12 @@ class TinodeWeb extends React.Component {
forwardMessage={this.state.forwardMessage}
onCancelForwardMessage={this.handleHideForwardDialog}
selectMode={this.state.selectMode && this.state.selectModeTopic == this.state.topicSelected}
selectedSeqs={this.state.selectedSeqs}
onToggleMessageSelect={this.handleToggleMessageSelect}
onExitSelectMode={this.handleExitSelectMode}
onShowBatchForward={this.handleShowBatchForward}
callTopic={this.state.callTopic}
callSeq={this.state.callSeq}
callState={this.state.callState}
File diff suppressed because it is too large Load Diff