Files
im_webapp/src/views/message-center-view.jsx
T
Your NameandClaude Sonnet 5 4e8f45d7d8 路由从 hash 换成 History API(pushState/replaceState/popstate),不再依赖 location.hash
HashNavigation 改名 Navigation(src/lib/navigation.js),接口不变,内部改用真实路径:
parseUrlHash/composeUrlHash 改名 parseUrl/composeUrl;navigateTo/navigateReplace 改调
pushState/replaceState 后手动派发 popstate(pushState/replaceState 不会像赋值 location.hash
那样自动触发事件,这里复用 navigateReplace 早就在用的同一个手动 dispatch 手法);goBack()
不用改,history.back() 本来就是真实浏览器历史机制。新增 Navigation.currentUrl() 替代调用方
原本直接读的 window.location.hash(这个类从来没包装过这个读取)。

一处需要专门处理的边界情况:当 path[0] 是空字符串("不选中侧栏视图、只开一个会话"这个仓库里
的合法状态)时,拼出的真实路径开头会变成双斜杠,被浏览器当协议相对地址解析、试图跳到一个叫
会话id的host,pushState 直接抛 SecurityError。composeUrl/parseUrl 内部用占位 token "_" 处理
这种情况,对外的调用方(19个文件)完全无感。

tinode-web.jsx 的 handleHashRoute 改名 handleRouteChange,监听从 hashchange 换成 popstate;
service-worker.js 的推送通知点击跳转手动改写成同样的路径格式(这个文件在打包之外单独执行,
没法 import Navigation)。index.html/index-dev.html 加了 <base href="/">——所有静态资源引用
都是相对路径,hash 路由下页面路径永远是 /,换真实路径后深层地址刷新会按当前目录解析出错误
资源地址导致白屏,这一行是标准修法。nginx.conf 的 try_files 兜底规则本来就适用两种路由,只
更新了注释。

实机验证:地址栏不再有 #;点击会话/设置等各类视图正常跳转;浏览器前进后退和应用内"返回"
按钮都正常;收藏页内部的类型 tab 切换(navigateReplace)确认不产生新的历史记录;在会话页/
个人中心页刷新、直接用完整深层路径打开新标签页都能正确恢复到位,资源正常加载不白屏。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 21:00:39 +08:00

549 lines
36 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { Tinode } from 'tinode-sdk';
import Navigation from '../lib/navigation.js';
import { BASE_APP_NAME } from '../config.js';
import { getBootstrap, getRealnameStatus, 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';
// 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.
// `topic` 可选:系统消息里的群事件通知需要带着群 topic 跳进对应群聊设置页,写法与
// group-management-view.jsx 的同名辅助函数保持一致(不带 topic 时显式清掉,避免把上一个页面
// 残留的 topic 参数带进不相关的页面)。
const navigate = (view, topic) => {
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
hash = topic ? Navigation.addUrlParam(hash, 'topic', topic) : Navigation.removeUrlParam(hash, 'topic');
Navigation.navigateTo(hash);
};
// BottomNavigation's 消息<->工作台 switch is the app's root-level tab bar, not a navigation to a
// "different page" in the back-stack sense -- same bug/fix as content-library-view.jsx's
// navigateReplace (see its comment): navigate() here pushed a history entry on every tab click, so
// bouncing between 消息 and 工作台 a few times (a completely normal usage pattern for the app's only
// two bottom tabs) made the back button just cycle through past tab switches instead of leaving the
// app, exactly like a standard bottom-tab-bar app's back button should not have to unwind every past
// tab switch to back out.
const navigateReplace = (view) => Navigation.navigateReplace(Navigation.setUrlSidePanel(Navigation.currentUrl(), view));
// 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: '撤回', gated: true},
{id: 'delete', icon: 'delete_outline', label: '删除', gated: true},
{id: 'select', icon: 'select_all', 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}) {
return <i className="material-icons" aria-hidden="true">{children}</i>;
}
function Toast({message, onDone}) {
React.useEffect(() => {
if (!message) return undefined;
const timer = window.setTimeout(onDone, 2200);
return () => window.clearTimeout(timer);
}, [message, onDone]);
return message ? <div className="messages-toast" role="status">{message}</div> : null;
}
function Avatar({item, small = false}) {
// 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;
}
// 服务端的 system_notice/system_action(好友通过、群成员加入、群邀请卡片等,见 chat-message.jsx
// 对 x-im-type 的处理)以及合并转发消息都不是 Drafty,也没有 `txt` 字段,真正的可读文案在
// `text`(system_*)或 `comment`/`title`(forward_bundle)——原来这里只认 `txt`,这三类消息在
// 会话列表的预览行一律回退成没有信息量的"[消息]",即使内容本身早就是一句完整人话
// (比如"邀请你加入群聊。"),聊天页里点开看到的和列表预览完全对不上。
if (typeof content.text === 'string' && content.text) return content.text;
if (typeof content.comment === 'string' && content.comment) return content.comment;
if (typeof content.title === 'string' && content.title) return '[聊天记录] ' + content.title;
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);
const hhmm = () => date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
if (days === 0) return hhmm();
// 参考图 docs/images/消息.png 里昨天的会话显示的是"昨天21:03",带具体时间,不是只有"昨天"
if (days === 1) return '昨天' + hhmm();
if (days < 7) return ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][date.getDay()];
return date.toLocaleDateString('zh-CN', {month: 'numeric', day: 'numeric'});
}
// 参考图 系统消息.png 的时间格式是"6月28日 10:04"——不带年份、中文月日、不带秒。原来直接用
// toLocaleString('zh-CN') 默认格式,会输出"2026/8/20 18:04:00"(带年份、斜杠、秒),跟设计不符。
function formatNotificationTime(ts) {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${d.getMonth() + 1}月${d.getDate()}日 ${hh}:${mm}`;
}
// 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 [];
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) summary = draftyPreview(latest.content);
if (latest && latest.from === myUserId) summary = '我:' + summary;
const isGroup = typeof item.topic === 'string' && item.topic.startsWith('grp');
return {
id: item.topic || item.user || String(index),
title: topicDisplayName(item.topic, item.public, '未命名会话'),
summary,
time: formatConversationTime(item.touched),
unread: Math.max(Number(item.unread) || 0, 0),
// 真实头像:跟同文件 frequentContacts() 一样从 public.photo 取,原来漏了这一项,
// 导致会话列表每一行都只能落到下面的兜底图标,永远显示不出头像。
avatar: makeImageUrl(item.public && item.public.photo),
// 没有真实头像时的兜底图标要区分群聊和单聊,否则群聊也画成单人像,列表里分不出哪条是群
icon: isGroup ? 'group' : 'person',
// 在线状态只对单聊有意义:群不存在"在线",传 undefined 让 Avatar 不渲染那个状态点
online: isGroup ? undefined : !!item.online,
muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()),
// 原来读的是 item.pinned——这个键在原生订阅对象上根本不存在,永远 undefined,置顶开关点了
// 会话列表从来没有过任何视觉变化。真实存放位置是 subscription 原生 private.pinned 字段,
// 但只对**群聊**成立:group-management-view.jsx 的置顶走 updateGroupMemberSettings ->
// serveIMGroupMemberSettings,服务端确实写的是 Subs.Private,随原生 {meta} 同步下来(真实
// 验证:开关点亮后要等下一次重连/刷新才会在这里体现——同一会话内没有实时 {pres} 通知去让
// 已连接的这份 subscription 缓存失效,这是已知的、这次没有顺手解决的次要延迟,不是没生效)。
// contact-management-view.jsx 的单聊置顶走的是完全不同的 updateConversationSettings ->
// serveIMConversationSettings,服务端存进独立的 im_conversation_settings 表,从不写入原生
// Subs.Private——单聊置顶因此不管等多久重连都不会在这里出现,这是真实的后端架构缺口(没有
// 批量查询会话设置的接口,前端也不可能为渲染一屏会话列表挨个按 topic 发请求),已记录到
// 后端问题清单,不是这一行能修的。
pinned: !!(item.private && item.private.pinned)
};
});
}
function BottomNavigation() {
return <nav className="messages-bottom-nav" aria-label="主导航">
<button type="button" onClick={() => navigateReplace('workbench')}><Icon>dashboard</Icon><span>工作台</span></button>
<button type="button" className="active"><Icon>person</Icon><span>消息</span></button>
</nav>;
}
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">
{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>;
}
// 1.10 实名认证提醒:docs/images/实名认证(安卓).png 显示的是不可跳过的强制态(单按钮"去实名
// 认证",没有关闭/稍后处理)——但那张图对应的是 bootstrap.security.realname.required===true 这个
// 分支,这个环境(以及目前后端唯一会返回的默认配置)实际是 required:false / allow_skip:true。
// 照抄参考图做成完全关不掉的弹层,等于把"实名认证目前是可选的"这个真实配置,实现成挡住所有人的
// 强制墙——所以按这两个字段分叉:允许跳过时加一个参考图没画但后端明确允许的关闭按钮;且遵守
// remind_after,跳过后在这个时长内不重复弹(不是每次进消息首页都弹一次)。
function parseRemindAfter(text) {
const m = /^(\d+)\s*([hdm])$/i.exec(String(text || '').trim());
if (!m) return 24 * 60 * 60 * 1000;
const n = Number(m[1]);
return m[2].toLowerCase() === 'd' ? n * 86400000 : m[2].toLowerCase() === 'h' ? n * 3600000 : n * 60000;
}
function RealnameReminderModal({allowSkip, onDismiss}) {
return <div className="auth-modal-backdrop" onClick={allowSkip ? onDismiss : undefined}>
<div className="auth-modal" onClick={e => e.stopPropagation()}>
{allowSkip ? <button type="button" className="auth-modal-close" aria-label="关闭" onClick={onDismiss}><Icon>close</Icon></button> : null}
<h2>实名认证提醒</h2>
<p>根据国家法律法规要求,您需先进行实名认证才能使用该项 APP</p>
<button className="auth-primary" onClick={() => navigate('realname')}>去实名认证</button>
</div>
</div>;
}
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]);
// sidepanel-view.jsx 会在 sidePanelSelected 已经是 'messages-home'(例如收藏夹里的旧链接、浏览器
// 从上次关闭前恢复的标签页)但 myUserId 还没就绪时,照样把这个视图渲染出来(它自己的
// `fullPage = !myUserId || view !== 'messages-home'` 分支就是为这种情况准备的)——如果这时候
// token 续登录失败或者还没跑完,原来这里只看 `!!myUserId && connected===false`,myUserId 本身是
// null 时这个条件恒为 false,于是不会进入"加载中"分支,会话列表和 bootstrap 又因为
// useCachedData 的 key 是 null 而永远不发请求,最终呈现的是一句容易误导人的"暂无会话/未找到
// 相关会话"——用户会以为账号是空的,而不是"还没登录成功"。myUserId 本身为空也按"未连接"处理,
// 至少诚实地展示"加载中"而不是一个看起来正常但其实是半成品的空列表。
const offline = view === 'messages-offline' || !myUserId || connected === false;
// "置顶" only ever changed a background tint (see the `pinned` comment inside
// normalizeConversations) -- every other IM this product's users have used floats a pinned chat to
// the top of the list and keeps it there, so a tint-only "pin" reads as broken even where the
// underlying flag is genuinely correct (group chats). Array.prototype.sort is stable (guaranteed
// since ES2019), so this only reorders pinned-vs-not and otherwise preserves chatList's own
// ordering. Applied after normalizing, not to the concatenated list, so the two fixed system
// entries (system_messages/file_assistant) -- which have no `pinned` concept at all -- stay
// pinned to the very top the way they always have, ahead of even a pinned real conversation.
const realConversations = normalizeConversations(chatList, myUserId)
.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
const conversations = entryConversations(bootstrap).concat(realConversations);
const filtered = conversations.filter(item => `${item.title}${item.summary}`.toLowerCase().includes(query.trim().toLowerCase()));
const frequent = frequentContacts(chatList);
// bootstrap.security.realname carries required/allow_skip/remind_after, but -- confirmed live --
// its handler (serveIMBootstrap) builds that block from a 4-field literal that never includes
// submission_id, unlike the dedicated GET /security/realname endpoint (realNamePayload). The
// dismissKey fix below depends on submission_id to tell two separate rejections apart; sourcing
// `status` from bootstrap alone made that fix a no-op in practice (it always fell back to `status`,
// reproducing the exact bug it was meant to fix). Fetching this separately, the same
// useCachedData-with-retry way bootstrap itself is fetched, gets the real submission_id.
const realnameMeta = bootstrap && bootstrap.security && bootstrap.security.realname;
const [realnameStatus] = useCachedData(myUserId ? 'realname-status:' + myUserId : null, getRealnameStatus, [myUserId]);
const [reminderDismissed, setReminderDismissed] = React.useState(false);
// Keyed by submission_id (a new one is minted per resubmission, server/im_http.go's
// realNamePayload/serveIMRealNameSubmissions), not just status -- two separate rejections both
// report status:"rejected", and without something that actually changes between them, dismissing
// the first would keep suppressing the reminder for a brand new rejection submitted and rejected
// again within the same snooze window. Falls back to status for the states that never had a
// submission (e.g. never-submitted "unverified", where there's only ever one such state anyway).
const dismissKey = (myUserId && realnameStatus) ?
`realname-reminder-dismissed:${myUserId}:${realnameStatus.submission_id || realnameStatus.status}` : null;
let lastDismissedAt = 0;
try { lastDismissedAt = dismissKey ? Number(localStorage.getItem(dismissKey) || 0) : 0; } catch (_) { /* ignore */ }
const withinSnooze = lastDismissedAt > 0 && (Date.now() - lastDismissedAt) < parseRemindAfter(realnameMeta && realnameMeta.remind_after);
const showRealnameReminder = !!realnameStatus && !reminderDismissed && !withinSnooze &&
(realnameStatus.status === 'unverified' || realnameStatus.status === 'rejected');
const dismissRealnameReminder = () => {
if (dismissKey) { try { localStorage.setItem(dismissKey, String(Date.now())); } catch (_) { /* ignore */ } }
setReminderDismissed(true);
};
const selectConversation = item => {
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 {
navigate('chat-standard');
}
};
const runQuickAction = item => {
setMenuOpen(false);
if (item.id === 'friends') return navigate('address-book');
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');
if (item.id === 'scan' && onOpenScanner) return onOpenScanner();
setToast(item.id === 'scan' ? '扫码功能暂不可用' : `${item.label}页面将在对应章节中提供`);
};
return <div className="messages-product-page">
<header className="messages-home-header">
{offline ? <div className="messages-connection"><span className="messages-spinner" />加载中</div> : <h1>消息</h1>}
<button type="button" className="messages-menu-button" onClick={() => setMenuOpen(true)} aria-label="更多功能"><Icon>menu</Icon></button>
</header>
<main className="messages-home-content">
<label className="messages-search"><Icon>search</Icon><input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索" /></label>
{frequent.length > 0 ? <section className="messages-frequent">
<h2>常联系的人</h2>
<div className="messages-frequent-scroll">
{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> : 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)}>
<Avatar item={item} />
<span className="messages-conversation-copy"><strong>{item.title}</strong><small>{item.summary}</small></span>
<span className="messages-conversation-meta"><time>{item.time}</time>
{item.unread ? <b>{item.unread > 99 ? '99+' : item.unread}</b> : item.muted ? <Icon>notifications_off</Icon> : null}
</span>
</button>)}
{/* "未找到相关会话"(搜索图标)只在用户真的输入过搜索词却匹配不到时才准确——`conversations`
本身为空(例如尚未完成登录/数据尚未拉取到、或租户把系统消息与文件助手都关闭且用户还没有
任何会话)跟"搜索没匹配到"是两种不同情况,原来两者共用同一句"未找到相关会话"+放大镜
图标,会把"这个账号目前就是没有任何会话"说成"你的搜索没找到东西",用户没搜索过却看到
搜索无结果的图标和文案。 */}
{filtered.length === 0 ? (query.trim() ?
<div className="messages-empty"><Icon>search_off</Icon><p>未找到相关会话</p></div> :
<div className="messages-empty"><Icon>forum</Icon><p>暂无会话</p></div>) : null}
</section>
</main>
<BottomNavigation />
{menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} permissions={bootstrap && bootstrap.permissions} /> : null}
<Toast message={toast} onDone={() => setToast('')} />
{showRealnameReminder ? <RealnameReminderModal allowSkip={!!(realnameMeta && realnameMeta.allow_skip)} onDismiss={dismissRealnameReminder} /> : null}
</div>;
}
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);
if (item.id === 'copy' && navigator.clipboard) {
navigator.clipboard.writeText('明天上午十点开项目会,请准时参加。').catch(() => {});
}
setToast(`${item.label}操作已选择`);
setVisible(false);
};
return <div className="message-actions-page">
<header className="messages-sub-header"><button onClick={() => navigate('messages-home')} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>项目协作群</h1><span /></header>
<main className="message-actions-chat">
<p className="message-time">今天 10:24</p>
<div className="message-demo-row"><Avatar item={{avatar: 'img/bkg/p09.jpg'}} /><button type="button" className="message-demo-bubble" onContextMenu={e => {e.preventDefault(); setVisible(true);}}>明天上午十点开项目会,请准时参加。</button></div>
<div className="message-demo-row mine"><button type="button" className="message-demo-bubble mine" onContextMenu={e => {e.preventDefault(); setVisible(true);}}>收到,我会提前准备材料。</button><Avatar item={{avatar: 'img/bkg/p05.jpg'}} /></div>
{selected ? <p className="message-operation-result">已选择:{MESSAGE_ACTIONS.find(item => item.id === selected).label}</p> : null}
</main>
<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">
{actions.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => runAction(item)}>
<span>{item.label}</span><Icon>{item.icon}</Icon>
</button>)}
</div>
</div> : null}
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
// 系统通知的 summary 在多处后端代码里被直接填成机器码而不是人话:群事件统一写
// summary=event.EventType(server/im_group_events.go:42-44,所有群事件共用 title="群聊动态"),
// 好友申请处理结果写 summary=status(server/im_http.go:5956 与 db/mysql/tenant_adapter.go:8643,8700,
// 值就是 "accepted"/"rejected")。已记入后端问题清单,在后端给出可读文案之前前端按已知码兜底翻译。
const NOTIFICATION_TEXT = {
'group.created': '你创建了一个新群聊',
'group.join_request.created': '有人申请加入你管理的群聊',
// 实测 asn494180 账号的真实系统消息列表里出现过原样展示的 "group.announcement.updated"——
// db/mysql/tenant_groups.go 里 insertGroupEventTx 一共写了 9 种 group.* 事件码
// (announcement.updated/created/join_request.created/member.joined/member.left/
// member.updated/mute.updated/owner.transferred/role.updated/settings.updated),这张表原来
// 漏收了公告更新这一种,是 9 种里唯一没有查表命中、直接把机器码露给用户看的。
'group.announcement.updated': '群公告有更新',
'group.member.joined': '有成员加入了群聊',
'group.member.left': '有成员退出了群聊',
'group.member.updated': '群成员资料有更新',
'group.mute.updated': '群禁言设置有变更',
'group.owner.transferred': '群主已转让',
'group.role.updated': '群成员角色有变更',
'group.settings.updated': '群设置有更新',
// 上面那 9 种都是 insertGroupEventTx 的静态字符串调用,唯独审批入群申请这一条不是:
// TenantGroupJoinRequestResolve(tenant_groups.go:642)拼的是
// "group.join_request."+status,status 是 "accepted" 或 "rejected"(server/store/types/groups.go
// 的 GroupJoinRequestStatusAccepted/Rejected),实际落进 summary 的是
// "group.join_request.accepted"/"group.join_request.rejected" 这两个完整机器码,跟本已收录的
// "group.join_request.created"(申请提交时那条)是三个不同的 key。当初核对 9 种静态事件码时
// 这条动态拼接的漏了,管理员在群管理页批准/拒绝入群申请后,申请人这边系统消息列表同样会原样
// 展示机器码——跟 group.announcement.updated 是完全同一类问题,只是触发路径不同(未在真实
// 环境里等到一条这样的通知,是读 im_be 源码逐个比对 insertGroupEventTx 调用点发现的)。
'group.join_request.accepted': '你的入群申请已通过',
'group.join_request.rejected': '你的入群申请被拒绝',
accepted: '对方已通过你的好友申请',
rejected: '对方拒绝了你的好友申请',
// 新样本:security.risk.continue/cancel 事件的 summary 直接是 decision 字面量
// "continue"/"cancel"(风险确认审计记录,见 im-api.js 的 confirmRiskChallenge),同样是机器码——
// 两个 decision 枚举值只有一个"continue"曾被收录,"cancel"(第11章发消息前风险提示点"取消发送"
// 产生)此前会直接把英文单词"cancel"展示在系统消息列表里。
continue: '你确认继续了一次安全风险提示',
cancel: '你取消了一次风险操作确认'
};
function systemNotificationText(item) {
const summary = (item.summary || '').trim();
// 只在查表命中时才替换。后端的 summary 有些确实是机器码(好友申请通过时是 "accepted"),
// 但也有些就是真实数据——日报提醒的 summary 是纯日期字符串
// (db/mysql/tenant_adapter.go:4917 传 date.Format("2006-01-02")),会议通知的 summary 是会议标题。
// 一旦按"看起来像机器码就吞掉"来判断,这些真实内容会被换成一句无意义的兜底文案。
const mapped = NOTIFICATION_TEXT[summary] || NOTIFICATION_TEXT[item.event_type];
if (mapped) return mapped;
return summary || '您有一条新的系统通知。';
}
function SystemMessages({authenticated}) {
const [items, setItems] = React.useState([]);
const [cursor, setCursor] = React.useState('');
const [loadingMore, setLoadingMore] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState('');
const [toast, setToast] = React.useState('');
React.useEffect(() => {
getSystemNotifications().then(data => { setItems(data.items || []); setCursor(data.next_cursor || ''); })
.catch(err => setError(err.message || '加载失败'))
.finally(() => setLoading(false));
}, []);
// GET /system-notifications does return next_cursor whenever there's more (real accounts with
// 50 unread saw it truncate silently at 20, with no way to reach or ever mark the rest read).
const loadMore = () => {
if (loadingMore || !cursor) return;
setLoadingMore(true);
getSystemNotifications(cursor).then(data => {
setItems(list => list.concat(data.items || []));
setCursor(data.next_cursor || '');
}).catch(() => {}).finally(() => setLoadingMore(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={() => { if (!Navigation.goBack()) navigate('messages-home'); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
<main className="system-notification-list">
{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>{formatNotificationTime(item.created_at)}</time>
<div className="system-notification-card">
<header><h2>{item.title}</h2>{!item.read_at ? <span>新</span> : null}</header>
<p>{systemNotificationText(item)}</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}
{/* 入口按通知自身的 action 决定,不再对所有通知一律显示"前往安全设置"。注意 action.topic
不能直接当成群:好友申请类通知的 action 里带的是 p2p 会话 topic
(db/mysql/tenant_adapter.go:8643),只有 grp 前缀才是真的群。都不匹配时不显示按钮,
避免把人导到跟这条通知无关的页面。
真实好友申请通知的 action 判断改用 event_type 而不是 action.type:实测
GET /system-notifications 拿到的 friend.request.created 通知,action 里只有
{user_id},从来没有 type 字段(当初写 action.type==='friend_request' 是凭对接文档
猜的,不是照真实响应写的)——导致"查看新朋友"按钮对这条最常见的通知永远不出现。 */}
{item.action && typeof item.action.topic === 'string' && item.action.topic.startsWith('grp') ?
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate('group-settings', item.action.topic); }}>查看群聊 <Icon>chevron_right</Icon></button> :
item.event_type === 'friend.request.created' ?
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate('friend-requests'); }}>查看新朋友 <Icon>chevron_right</Icon></button> :
item.action && (item.action.device || item.action.ip) ?
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate(authenticated ? 'security' : 'identity-check'); }}>前往安全设置 <Icon>chevron_right</Icon></button> :
null}
<footer>保持警惕<br />{BASE_APP_NAME}安全团队</footer>
</div>
</article>)}
{cursor ? <button type="button" className="system-notification-load-more" disabled={loadingMore} onClick={loadMore}>{loadingMore ? '加载中…' : '加载更多'}</button> : null}
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
export default function MessageCenterView(props) {
switch (props.view) {
case 'system-messages': return <SystemMessages authenticated={!!props.myUserId} />;
case 'message-actions': return <MessageActions />;
case 'messages-offline':
case 'messages-menu':
case 'messages-home':
default: return <MessageHome {...props} />;
}
}