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>
549 lines
36 KiB
React
549 lines
36 KiB
React
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} />;
|
||
}
|
||
}
|