Convert im_webapp from static mock prototypes to real backend integration
Implements all 11 product chapters (auth, messaging, contacts, groups,
files/favorites, calls, workplace/reports, settings, security) against the
real /v1/im REST API and native Tinode WS protocol, replacing the hardcoded
mock data the ba8f48ee prototype commit shipped with. Adds src/lib/im-api.js
as the REST client, plus supporting fixes across messages-view.jsx and the
attachment/upload/download pipeline (wrong SDK auth headers, group topic
naming, seq/timezone edge cases) found via live end-to-end testing against
the real backend.
Backup checkpoint before reconciling with origin/dev's parallel rewrite.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ba8f48ee83
commit
251c9de87c
@@ -1,55 +1,54 @@
|
||||
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';
|
||||
|
||||
const navigate = view => HashNavigation.navigateTo(view);
|
||||
// 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.
|
||||
const navigate = view => HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, view));
|
||||
|
||||
const DEMO_CONVERSATIONS = [
|
||||
{id: 'system', title: '系统消息', summary: '您有一条新的安全通知', time: '昨天 21:03', icon: 'notifications', tone: 'orange', system: true},
|
||||
{id: 'sun', title: '孙光洪', summary: '最近忙什么', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p03.jpg', online: true},
|
||||
{id: 'files', title: '文件助手', summary: '你上传了 1 份文件', time: '昨天 21:03', icon: 'folder', tone: 'green', fileAssistant: true},
|
||||
{id: 'zhao', title: '赵吾行', summary: '好久不见,怎样了,都挺好的', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p07.jpg'},
|
||||
{id: 'wang', title: '王厚哲', summary: '恭喜你呀', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p12.jpg', online: true},
|
||||
{id: 'sunmu', title: '孙牧森', summary: '真有意思', time: '昨天 21:03', muted: true, avatar: 'img/bkg/p18.jpg'}
|
||||
];
|
||||
|
||||
const FREQUENT_CONTACTS = [
|
||||
{id: 'a', name: '吃火锅', avatar: 'img/bkg/p02.jpg', online: true},
|
||||
{id: 'b', name: '爱上草莓', avatar: 'img/bkg/p05.jpg', online: true},
|
||||
{id: 'c', name: '好久鼻尖', avatar: 'img/bkg/p09.jpg'},
|
||||
{id: 'd', name: '太想念你', avatar: 'img/bkg/p14.jpg', online: true},
|
||||
{id: 'e', name: '夏纳', avatar: 'img/bkg/p21.jpg'}
|
||||
];
|
||||
|
||||
const SYSTEM_NOTIFICATIONS = [
|
||||
{
|
||||
id: 'device-1', date: '6月28日 10:04', title: '新设备登录成功',
|
||||
loginAt: '2026/08/24 14:17', device: 'Chrome · macOS', ip: '220.198.***.212', read: false
|
||||
},
|
||||
{
|
||||
id: 'device-2', date: '6月30日 13:04', title: '新设备登录成功',
|
||||
loginAt: '2026/08/20 09:42', device: 'iPhone 15', ip: '116.25.***.86', read: true
|
||||
}
|
||||
];
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{id: 'friends', icon: 'group', label: '朋友'},
|
||||
{id: 'profile', icon: 'person_outline', label: '我的'},
|
||||
{id: 'create-group', icon: 'add_comment', label: '创建群组'},
|
||||
{id: 'add-friend', icon: 'person_add_alt', label: '添加好友'},
|
||||
{id: 'scan', icon: 'qr_code_scanner', label: '扫一扫'}
|
||||
];
|
||||
// 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: '撤回'},
|
||||
{id: 'delete', icon: 'delete_outline', label: '删除'},
|
||||
{id: 'recall', icon: 'undo', label: '撤回', gated: true},
|
||||
{id: 'delete', icon: 'delete_outline', label: '删除', gated: true},
|
||||
{id: 'select', icon: 'select_all', label: '多选'},
|
||||
{id: 'receipts', icon: 'published_with_changes', 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}) {
|
||||
@@ -66,25 +65,77 @@ function Toast({message, onDone}) {
|
||||
}
|
||||
|
||||
function Avatar({item, small = false}) {
|
||||
const className = 'messages-avatar ' + (small ? 'small ' : '') + (item.tone || 'blue');
|
||||
// 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 DEMO_CONVERSATIONS;
|
||||
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 && typeof latest.content === 'string') summary = latest.content;
|
||||
if (latest) summary = draftyPreview(latest.content);
|
||||
if (latest && latest.from === myUserId) summary = '我:' + summary;
|
||||
return {
|
||||
id: item.topic || item.user || String(index),
|
||||
title: item.public && item.public.fn ? item.public.fn : '未命名会话',
|
||||
title: topicDisplayName(item.topic, item.public, '未命名会话'),
|
||||
summary,
|
||||
time: item.touched ? new Date(item.touched).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'}) : '',
|
||||
time: formatConversationTime(item.touched),
|
||||
unread: Math.max(Number(item.unread) || 0, 0),
|
||||
online: !!item.online,
|
||||
muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()),
|
||||
@@ -100,27 +151,41 @@ function BottomNavigation() {
|
||||
</nav>;
|
||||
}
|
||||
|
||||
function QuickMenu({onClose, onAction}) {
|
||||
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">
|
||||
{QUICK_ACTIONS.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => onAction(item)}>
|
||||
{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}) {
|
||||
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 = normalizeConversations(chatList, myUserId);
|
||||
|
||||
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 || item.id === 'system') {
|
||||
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 {
|
||||
@@ -134,7 +199,8 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
|
||||
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');
|
||||
setToast(item.id === 'scan' ? '扫码功能将在客户端能力章节提供' : `${item.label}页面将在对应章节中提供`);
|
||||
if (item.id === 'scan' && onOpenScanner) return onOpenScanner();
|
||||
setToast(item.id === 'scan' ? '扫码功能暂不可用' : `${item.label}页面将在对应章节中提供`);
|
||||
};
|
||||
|
||||
return <div className="messages-product-page">
|
||||
@@ -144,14 +210,14 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
|
||||
</header>
|
||||
<main className="messages-home-content">
|
||||
<label className="messages-search"><Icon>search</Icon><input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索" /></label>
|
||||
<section className="messages-frequent">
|
||||
{frequent.length > 0 ? <section className="messages-frequent">
|
||||
<h2>常联系的人</h2>
|
||||
<div className="messages-frequent-scroll">
|
||||
{FREQUENT_CONTACTS.map(item => <button type="button" key={item.id} onClick={() => navigate('chat-standard')}>
|
||||
{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>
|
||||
</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)}>
|
||||
@@ -165,7 +231,7 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
|
||||
</section>
|
||||
</main>
|
||||
<BottomNavigation />
|
||||
{menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} /> : null}
|
||||
{menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} permissions={bootstrap && bootstrap.permissions} /> : null}
|
||||
<Toast message={toast} onDone={() => setToast('')} />
|
||||
</div>;
|
||||
}
|
||||
@@ -174,6 +240,17 @@ 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);
|
||||
@@ -195,7 +272,7 @@ function MessageActions() {
|
||||
<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">
|
||||
{MESSAGE_ACTIONS.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => runAction(item)}>
|
||||
{actions.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => runAction(item)}>
|
||||
<span>{item.label}</span><Icon>{item.icon}</Icon>
|
||||
</button>)}
|
||||
</div>
|
||||
@@ -205,20 +282,40 @@ function MessageActions() {
|
||||
}
|
||||
|
||||
function SystemMessages({authenticated}) {
|
||||
const [items, setItems] = React.useState(SYSTEM_NOTIFICATIONS);
|
||||
const [items, setItems] = React.useState([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState('');
|
||||
const [toast, setToast] = React.useState('');
|
||||
const markRead = id => setItems(items.map(item => item.id === id ? {...item, read: true} : item));
|
||||
|
||||
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={() => navigate('messages-home')} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
|
||||
<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">
|
||||
{items.map(item => <article key={item.id} className={item.read ? 'read' : ''} onClick={() => markRead(item.id)}>
|
||||
<time>{item.date}</time>
|
||||
{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 ? <span>新</span> : null}</header>
|
||||
<p>亲爱的用户,您的账号于 <button type="button" onClick={() => setToast('登录时间:' + item.loginAt)}>{item.loginAt}</button> 在新设备上登录成功。</p>
|
||||
<dl><div><dt>设备:</dt><dd>{item.device}</dd></div><div><dt>IP地址:</dt><dd>{item.ip}</dd></div></dl>
|
||||
<p>如果不是本人操作,请及时修改密码并检查登录设备。</p>
|
||||
<button type="button" className="system-security-link" onClick={() => navigate(authenticated ? 'security' : 'identity-check')}>前往安全设置 <Icon>chevron_right</Icon></button>
|
||||
<header><h2>{item.title}</h2>{!item.read_at ? <span>新</span> : null}</header>
|
||||
<p>{item.summary}</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}
|
||||
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate(authenticated ? 'security' : 'identity-check'); }}>前往安全设置 <Icon>chevron_right</Icon></button>
|
||||
<footer>保持警惕<br />即时通讯安全团队</footer>
|
||||
</div>
|
||||
</article>)}
|
||||
|
||||
Reference in New Issue
Block a user