用户实机测试反馈:系统消息列表里每条通知底部都是同一个"前往安全设置"按钮,点进去跟通知内容毫不 相关;正文还直接显示 "group.settings.updated" 这种原始事件码。查证后两个问题性质不同: 入口按钮(前端问题,已修):原来无条件对所有通知渲染同一个按钮、固定跳 security 页。改为按通知 自己的 action 字段决定——action.topic 是 grp 开头的进对应群聊设置页,好友申请类(action.type 为 friend_request)进"新朋友"页,带 device/ip 的安全类才进安全设置页,都不匹配就不显示按钮。 注意 action.topic 不能直接当群用:好友申请通知的 action 里带的是 p2p 会话 topic (db/mysql/tenant_adapter.go:8643),所以判断加了 grp 前缀检查。跳转目标都核对过在 tinode-web.jsx 的路由白名单里(friend-requests / group-settings),没有凭空造路由名。 正文文案(后端问题,前端只做兜底):后端多处把机器码直接当 summary 写进通知——群事件统一写 summary=event.EventType(im_group_events.go:42-44,7 种群事件还共用同一个 title"群聊动态"), 好友申请处理结果写 summary=status(im_http.go:5956 等,值就是 "accepted"/"rejected")。前端加 了一层兜底:summary 命中"纯小写字母数字点下划线"这种机器码特征时,按已知码翻译成中文,翻译不了 就退回通用文案;后端以后补上真正的文案会自动优先使用,不会被兜底覆盖。这条已记入后端问题清单。 实机验证:两条群通知正文由 "group.settings.updated" 变为"群设置有更新"、按钮变为"查看群聊"且 点击后正确进入 grpXDkK5cbXoK0 的群聊设置页;两条好友通知正文由 "accepted" 变为"对方已通过你的 好友申请",不再显示无关的安全设置按钮。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
381 lines
21 KiB
React
381 lines
21 KiB
React
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';
|
||
|
||
// 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 = HashNavigation.setUrlSidePanel(window.location.hash, view);
|
||
hash = topic ? HashNavigation.addUrlParam(hash, 'topic', topic) : HashNavigation.removeUrlParam(hash, 'topic');
|
||
HashNavigation.navigateTo(hash);
|
||
};
|
||
|
||
// 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;
|
||
}
|
||
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 [];
|
||
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;
|
||
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),
|
||
online: !!item.online,
|
||
muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()),
|
||
pinned: !!item.pinned
|
||
};
|
||
});
|
||
}
|
||
|
||
function BottomNavigation() {
|
||
return <nav className="messages-bottom-nav" aria-label="主导航">
|
||
<button type="button" onClick={() => navigate('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>;
|
||
}
|
||
|
||
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 = 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) {
|
||
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>)}
|
||
{filtered.length === 0 ? <div className="messages-empty"><Icon>search_off</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('')} />
|
||
</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.member.joined': '有成员加入了群聊',
|
||
'group.member.left': '有成员退出了群聊',
|
||
'group.member.updated': '群成员资料有更新',
|
||
'group.mute.updated': '群禁言设置有变更',
|
||
'group.owner.transferred': '群主已转让',
|
||
'group.role.updated': '群成员角色有变更',
|
||
'group.settings.updated': '群设置有更新',
|
||
accepted: '对方已通过你的好友申请',
|
||
rejected: '对方拒绝了你的好友申请'
|
||
};
|
||
|
||
// 机器码判定:纯 ASCII 且只由小写字母/数字/点/下划线/连字符组成("accepted"、
|
||
// "group.settings.updated" 这类),正常中文文案不会命中。
|
||
const MACHINE_CODE = /^[a-z0-9._-]+$/;
|
||
|
||
function systemNotificationText(item) {
|
||
const summary = (item.summary || '').trim();
|
||
// 后端给了真正的文案就直接用;只有当它是机器码时才走兜底,避免把未来后端补上的正常文案盖掉。
|
||
if (summary && !MACHINE_CODE.test(summary)) return summary;
|
||
return NOTIFICATION_TEXT[summary] || NOTIFICATION_TEXT[item.event_type] || '您有一条新的系统通知。';
|
||
}
|
||
|
||
function SystemMessages({authenticated}) {
|
||
const [items, setItems] = React.useState([]);
|
||
const [loading, setLoading] = React.useState(true);
|
||
const [error, setError] = React.useState('');
|
||
const [toast, setToast] = React.useState('');
|
||
|
||
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={() => { if (!HashNavigation.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>{new Date(item.created_at).toLocaleString('zh-CN')}</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 前缀才是真的群。都不匹配时不显示按钮,
|
||
避免把人导到跟这条通知无关的页面。 */}
|
||
{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.action && item.action.type === 'friend_request' ?
|
||
<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 />即时通讯安全团队</footer>
|
||
</div>
|
||
</article>)}
|
||
</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} />;
|
||
}
|
||
}
|