: null;
}
// 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}) {
// 这里必须带上 risk-dialog:本页的 ul/li/aside/.risk-emphasis/button.primary 全部样式都挂在
// base.css 的 .risk-dialog 下(14 条规则),而 .risk-info-page 在 base.css 里一条规则都没有——
// 只写 risk-info-page 的话整页会渲染成带原生项目符号的裸列表。
// 图标名也修正了:shield_lock 只存在于 Material Symbols,index.html 加载的是经典 Material Icons,
// 那套字体里没有这个字形,会原样显示 "shield_lock" 这串文本。改用仓库已在用的 verified_user。
return
;
}
// 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
;
}
// 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
;
}
// 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');
// Was its own useState + one-shot useEffect with no retry -- a failed first fetch with nothing
// cached yet left `config` stuck at null for the rest of the page's life (the category picker
// below never renders since it's gated on `config`, but content/submit stay visible the whole
// time), and the only way out was a full page reload. Routing through useCachedData gets the same
// "one retry after 3s if there's truly nothing cached yet" behavior bootstrap and friends already
// have, for free.
const [config] = useCachedData('feedback-config', getFeedbackConfig, []);
const [category, setCategory] = React.useState(() => (cachedFeedbackConfig && cachedFeedbackConfig.categories && cachedFeedbackConfig.categories[0]) || '');
const [content, setContent] = React.useState('');
const [images, setImages] = React.useState([]);
const [uploading, setUploading] = React.useState(false);
const [diagnostics, setDiagnostics] = React.useState(false);
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;
// Only fills in the default once, the first time a fetch (initial or the useCachedData retry)
// actually resolves without one already picked synchronously above from a warm cache -- doesn't
// clobber a category the user already changed.
React.useEffect(() => {
if (config && config.categories && config.categories.length && !category) {
setCategory(config.categories[0]);
}
}, [config]);
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; }
// category is a required backend field (empty string 400s as MALFORMED_REQUEST) but the picker
// that sets it only renders once `config` has loaded -- if getFeedbackConfig() is still in
// flight or failed, `category` can still be '' here while content/submit stay visible the whole
// time, so this needs its own explicit guard rather than relying on the picker having run.
if (!category) { onToast('请选择反馈类型'); return; }
setSubmitting(true);
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