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:
co-authored by
Claude Sonnet 5
parent
ba8f48ee83
commit
251c9de87c
+394
-105
@@ -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 || (() => {})} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('')} /></>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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> {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;
|
||||
}
|
||||
|
||||
@@ -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('')} /></>;
|
||||
}
|
||||
|
||||
@@ -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('')} /></>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user