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 {children};
}
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
;
}
// 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
e.stopPropagation()}>
{allowSkip ? : null}
实名认证提醒
根据国家法律法规要求,您需先进行实名认证才能使用该项 APP
;
}
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
;
}
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
;
}
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 ;
}
}