import { useCallback, useEffect, useRef, useState } from 'react'; import * as api from '../api/client'; import { useMailStore } from '../stores/mailStore'; import type { ThreadNode } from '../types'; import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons'; import { participantAddress, ccAddress } from '../lib/replyTarget'; import BackButton from './BackButton'; import { useIsNarrow } from '../hooks/useIsNarrow'; /** * 对话树视图(从线索根整树展开,分块加载)。 * * 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话, * 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。 * * 展开的起点是**线索的根**而不是当前这封。早先的实现是「锚点的祖先链 + 锚点的子树」 * 两个方向各自分页,结果兄弟节点整条分支都在盲区里:一封抄送给两个 Agent 的邮件 * 会收到两个回复,它们互为兄弟,从其中一个看树永远看不到另一个;挂在原件上的 * 转发分支同理。从根 BFS 之后,兄弟、抄送产生的平行回复、转发分支都是根的子孙。 * * 只剩一个加载方向(往后翻),因此不需要滚动位置补偿 —— 新内容追加在末尾。 * * 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链 * 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式, * 省掉一个渲染 SVG 的依赖和它带来的布局/缩放问题。 */ export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) { const [nodes, setNodes] = useState([]); const [hidden, setHidden] = useState(0); const [hasMore, setHasMore] = useState(false); const [nextOffset, setNextOffset] = useState(0); const [err, setErr] = useState(''); const [initial, setInitial] = useState(true); const [loading, setLoading] = useState(false); const scrollRef = useRef(null); const bottomSentinel = useRef(null); const anchorRef = useRef(null); // 请求代次:mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索 const gen = useRef(0); // loading 的同步副本。setState 是异步的,哨兵连续进入视口时 // 读 state 会看到旧的 false 而并发发两个请求。 const busy = useRef(false); const sortNodes = (list: ThreadNode[]) => list .slice() .sort((a, b) => a.depth - b.depth || a.created_at.localeCompare(b.created_at) ); const merge = useCallback((incoming: ThreadNode[]) => { setNodes(prev => { const seen = new Set(prev.map(n => n.mail_id)); return sortNodes([...prev, ...incoming.filter(n => !seen.has(n.mail_id))]); }); }, []); // 首屏 useEffect(() => { const myGen = ++gen.current; setNodes([]); setHidden(0); setErr(''); setInitial(true); busy.current = true; api .getMailThread(mailID, { offset: 0, limit: 60 }) .then(p => { if (gen.current !== myGen) return; setNodes(sortNodes(p.nodes)); setHidden(p.hidden); setHasMore(p.has_more); setNextOffset(p.next_offset); }) .catch(e => { if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败'); }) .finally(() => { if (gen.current === myGen) { setInitial(false); busy.current = false; } }); }, [mailID]); // 首屏渲染完把当前这封滚进视野。长线索里锚点可能在几十封之后, // 不滚过去的话用户点开一封邮件却停在整条线索的开头。 useEffect(() => { if (initial || !anchorRef.current) return; anchorRef.current.scrollIntoView({ block: 'center' }); }, [initial]); const loadMore = useCallback(async () => { if (busy.current || !hasMore) return; const myGen = gen.current; busy.current = true; setLoading(true); try { const p = await api.getMailThread(mailID, { offset: nextOffset, limit: 60 }); if (gen.current !== myGen) return; merge(p.nodes); setHidden(h => h + p.hidden); setHasMore(p.has_more); setNextOffset(p.next_offset); } catch (e) { if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败'); } finally { if (gen.current === myGen) setLoading(false); busy.current = false; } }, [mailID, merge, hasMore, nextOffset]); // 底部哨兵进入视口就续取。rootMargin 提前 200px 触发, // 让加载在用户滑到边界前完成。 useEffect(() => { const root = scrollRef.current; if (!root) return; const obs = new IntersectionObserver( entries => { for (const e of entries) if (e.isIntersecting) loadMore(); }, { root, rootMargin: '200px' } ); if (bottomSentinel.current) obs.observe(bottomSentinel.current); return () => obs.disconnect(); }, [loadMore]); return (
{/* 窄屏下对话树是盖在列表上的一层,得有返回出口。 它与右侧的「关闭」语义不同:返回退出整个详情栏回到列表, 关闭只收起树、留在这封邮件上。 */} 对话树 已加载 {nodes.length} 封 {hasMore && ',滑动加载更多'} {hidden > 0 && `,${hidden} 封无权查看`}
{loading && }
{initial && (
加载中
)} {err &&

{err}

} {!initial && ( <>
{nodes.map(n => ( ))}
{hasMore && ( )}
)}
); } function Node({ node, anchorID, anchorRef }: { node: ThreadNode; anchorID: string; anchorRef?: React.RefObject; }) { const openMailByID = useMailStore(s => s.openMailByID); const narrow = useIsNarrow(); const isPermission = node.mail_type === 'permission_request'; const isAnchor = node.mail_id === anchorID; const ccCount = node.cc_list?.length ?? 0; // 转发是一条新线索:主题带 Fwd: 前缀,且落在别的会话里。 // 树里把它标出来,否则一个分支为什么突然换了收件人无从判断。 const isForward = node.subject.startsWith('Fwd: '); // 缩进:每级的像素数与上限都随屏宽变。 // // 原先固定「每级 20px、上限 8 级」= 最多 160px。在 320px 屏上容器还要去掉 // px-4 的 32px 与连接线的 18px,卡片只剩 110px —— 发件人一行就被 truncate 吃掉。 // 窄屏改成每级 10px、上限 5 级(最多 50px),层级仍然看得出来,卡片还有余地。 const step = narrow ? 10 : 20; const maxDepth = narrow ? 5 : 8; const indent = Math.min(Math.max(node.depth, 0), maxDepth) * step; const time = new Date(node.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); return (
{indent > 0 && (
)}
); }