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 ; } function Toast({message, onDone}) { React.useEffect(() => { if (!message) return undefined; const timer = window.setTimeout(onDone, 2200); return () => window.clearTimeout(timer); }, [message, onDone]); return message ?
{message}
: 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
{item.avatar ? : {item.icon || 'person'}} {item.online !== undefined ? : null}
; } // 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 ; } 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
e.stopPropagation()} role="menu"> {actions.map(item => )}
; } 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
{offline ?
加载中
:

消息

}
{frequent.length > 0 ?

常联系的人

{frequent.map(item => )}
: null}
{filtered.map(item => )} {filtered.length === 0 ?
search_off

未找到相关会话

: null}
{menuOpen ? setMenuOpen(false)} onAction={runQuickAction} permissions={bootstrap && bootstrap.permissions} /> : null} setToast('')} />
; } 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

项目协作群

今天 10:24

{selected ?

已选择:{MESSAGE_ACTIONS.find(item => item.id === selected).label}

: null}
{visible ?
setVisible(false)}>
e.stopPropagation()} role="menu"> {actions.map(item => )}
: null} setToast('')} />
; } // 系统通知的 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

系统消息

{loading ?

hourglass_empty加载中…

: null} {error ?

error_outline{error}

: null} {!loading && !error && items.length === 0 ?

notifications_none暂无系统消息

: null} {items.map(item =>
markRead(item)}>

{item.title}

{!item.read_at ? 新 : null}

{systemNotificationText(item)}

{item.action && item.action.device ?
设备:
{item.action.device}
{item.action.ip ?
IP地址:
{item.action.ip}
: null}
: 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') ? : item.action && item.action.type === 'friend_request' ? : item.action && (item.action.device || item.action.ip) ? : null}
保持警惕
即时通讯安全团队
)}
setToast('')} />
; } export default function MessageCenterView(props) { switch (props.view) { case 'system-messages': return ; case 'message-actions': return ; case 'messages-offline': case 'messages-menu': case 'messages-home': default: return ; } }