import { useState, useEffect } from 'react'; import { fetchEntryArtifacts, resolveTcoUrls } from '../api'; // ── Date helper ──────────────────────────────────────────────────────────────── function fmtDate(secs) { if (!secs) return ''; return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', }).format(new Date(secs * 1000)); } // Decode common HTML entities Twitter stores in full_text. // MUST be called on already-sliced segments, not on the raw string before // entity-offset arithmetic, because entity offsets are into the stored text. function decodeEnt(str) { if (!str || !str.includes('&')) return str; // & first so &gt; → > → > (handles double-encoded entities) return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'"); } // ── Inline style definitions ─────────────────────────────────────────────────── const S = { // ── Tweet card ── card: { background: 'var(--paper)', border: '1px solid var(--line)', borderRadius: '12px', overflow: 'hidden', maxWidth: '560px', margin: '0 auto', fontFamily: 'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)', }, threadOuter: { border: '1px solid var(--line)', borderRadius: '12px', overflow: 'hidden', maxWidth: '560px', margin: '0 auto', background: 'var(--paper)', fontFamily: 'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)', }, tweetRow: { display: 'flex', gap: '10px', padding: '10px 12px', }, tweetRowThread: { display: 'flex', gap: '10px', padding: '10px 12px 0', }, leftCol: { display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0, width: '36px', }, rightCol: { flex: 1, minWidth: 0, paddingBottom: '8px', }, avatar: { width: '36px', height: '36px', borderRadius: '50%', objectFit: 'cover', flexShrink: 0, display: 'block', }, avatarPh: { width: '36px', height: '36px', borderRadius: '50%', background: 'var(--line)', flexShrink: 0, }, threadLine: { flex: 1, width: '2px', background: 'var(--line-soft, var(--line))', margin: '4px 0', minHeight: '12px', borderRadius: '1px', }, authorRow: { display: 'flex', alignItems: 'baseline', gap: '4px', flexWrap: 'wrap', marginBottom: '4px', lineHeight: '1.3', }, authorName: { fontWeight: '700', fontSize: '14px', color: 'var(--ink)', }, authorHandle: { fontSize: '13px', color: 'var(--muted)', }, datePart: { fontSize: '13px', color: 'var(--muted)', }, tweetText: { fontSize: '14px', lineHeight: '1.5', color: 'var(--ink)', whiteSpace: 'pre-line', marginBottom: '8px', wordBreak: 'break-word', }, stats: { display: 'flex', gap: '12px', fontSize: '13px', color: 'var(--muted)', }, link: { color: 'var(--accent)', textDecoration: 'none', }, loading: { padding: '24px 16px', textAlign: 'center', color: 'var(--muted)', fontSize: '14px', }, error: { padding: '24px 16px', textAlign: 'center', color: 'var(--alert)', fontSize: '14px', }, mediaGrid: { marginBottom: '8px', borderRadius: '10px', overflow: 'hidden', border: '1px solid var(--line)', }, mediaImg: { display: 'block', width: '100%', objectFit: 'cover', maxHeight: '260px', }, mediaVideo: { display: 'block', width: '100%', maxHeight: '260px', background: '#000', }, // ── Article ── article: { maxWidth: '560px', margin: '0 auto', border: '1px solid var(--line)', borderRadius: '12px', overflow: 'hidden', background: 'var(--paper)', fontFamily: 'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)', }, aCover: { width: '100%', display: 'block', objectFit: 'cover', maxHeight: '280px', }, aMeta: { padding: '10px 14px 0', }, aTweetTitle: { fontSize: '20px', fontWeight: '800', letterSpacing: '-0.3px', color: 'var(--ink)', lineHeight: '1.3', marginBottom: '8px', }, aAuthorRow: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px', }, aAvatar: { width: '36px', height: '36px', borderRadius: '50%', objectFit: 'cover', flexShrink: 0, display: 'block', }, aAvatarPh: { width: '36px', height: '36px', borderRadius: '50%', background: 'var(--line)', flexShrink: 0, }, aAuthorName: { fontSize: '14px', fontWeight: '700', color: 'var(--ink)', lineHeight: '1.3', }, aAuthorSub: { fontSize: '13px', color: 'var(--muted)', lineHeight: '1.3', }, aDivider: { border: 'none', borderTop: '1px solid var(--line)', margin: '0', }, aBody: { padding: '4px 14px 16px', }, // ── Article blocks ── bH1: { fontSize: '22px', fontWeight: '800', letterSpacing: '-0.4px', color: 'var(--ink)', lineHeight: '1.25', margin: '16px 0 6px', }, bH2: { fontSize: '18px', fontWeight: '700', letterSpacing: '-0.2px', color: 'var(--ink)', lineHeight: '1.3', margin: '14px 0 4px', }, bP: { fontSize: '15px', color: 'var(--ink)', lineHeight: '1.65', marginBottom: '12px', marginTop: '0', }, bSpacer: { height: '4px', display: 'block', }, bQuote: { borderLeft: '3px solid var(--line)', padding: '2px 12px', margin: '12px 0', color: 'var(--muted)', fontSize: '15px', lineHeight: '1.6', }, bHr: { border: 'none', borderTop: '1px solid var(--line)', margin: '14px 0', }, bImg: { width: '100%', display: 'block', borderRadius: '8px', margin: '12px 0', }, bUl: { margin: '8px 0 12px', paddingLeft: '24px', }, bOl: { margin: '8px 0 12px', paddingLeft: '24px', }, bLi: { fontSize: '15px', color: 'var(--ink)', lineHeight: '1.6', marginBottom: '4px', }, bTweet: { display: 'flex', alignItems: 'center', gap: '10px', border: '1px solid var(--line)', borderRadius: '10px', padding: '10px 14px', margin: '12px 0', color: 'var(--muted)', fontSize: '14px', textDecoration: 'none', }, bMdPre: { borderRadius: '8px', margin: '10px 0', overflow: 'auto', background: 'var(--paper-3, var(--field))', padding: '12px 14px', }, bMdCode: { fontFamily: "ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace", fontSize: '12px', lineHeight: '1.6', color: 'var(--ink)', background: 'transparent', display: 'block', }, iCode: { fontFamily: "ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace", fontSize: '0.875em', background: 'var(--paper-3, var(--field))', padding: '1px 5px', borderRadius: '4px', color: 'var(--ink)', }, // ── QT badge ── qtBadge: { fontSize: '11px', color: 'var(--muted)', display: 'inline-flex', alignItems: 'center', gap: '2px', marginLeft: '4px', letterSpacing: '0.03em', flexShrink: 0, }, // ── Lightbox ── lightboxBackdrop: { position: 'fixed', inset: 0, zIndex: 500, background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px', }, lightboxContent: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px', maxWidth: '90vw', maxHeight: '90vh', }, lightboxToolbar: { display: 'flex', alignItems: 'center', gap: '10px', alignSelf: 'flex-end', }, lightboxImg: { maxWidth: '88vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: '6px', display: 'block', }, lightboxNav: { display: 'flex', gap: '12px', alignItems: 'center', }, lightboxNavBtn: { background: 'rgba(255,255,255,0.18)', border: 'none', color: '#fff', fontSize: '20px', cursor: 'pointer', borderRadius: '50%', width: '36px', height: '36px', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1, flexShrink: 0, }, lightboxBtn: { background: 'rgba(255,255,255,0.18)', border: 'none', color: '#fff', fontSize: '16px', cursor: 'pointer', borderRadius: '50%', width: '30px', height: '30px', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1, flexShrink: 0, }, lightboxLink: { color: 'rgba(255,255,255,0.7)', textDecoration: 'none', fontSize: '14px', }, lightboxCounter: { color: 'rgba(255,255,255,0.6)', fontSize: '13px', }, }; // ── Artifact URL helpers ──────────────────────────────────────────────────────── // Build relpath → artifact API URL map from the artifacts array so archived // copies are preferred over CDN URLs (avatars, media, cover images). function buildArtifactMap(archiveId, entryUid, artifacts) { const map = {}; if (!artifacts) return map; artifacts.forEach((a, idx) => { if (a.relpath) { map[a.relpath] = `/api/archives/${archiveId}/entries/${entryUid}/artifacts/${idx}`; } }); return map; } // Return the archived artifact URL if available, otherwise fall back to cdnUrl. function resolveUrl(localPath, cdnUrl, artifactMap) { if (localPath && artifactMap[localPath]) return artifactMap[localPath]; return cdnUrl || null; } // ── SVG X logo ───────────────────────────────────────────────────────────────── function XLogo() { return ( ); } // ── Tweet text renderer ──────────────────────────────────────────────────────── // Splits full_text at entity boundaries, replacing t.co URLs with display_url // links and @mentions with x.com links. Returns an array of React nodes. // white-space: pre-line on the container preserves newlines in plain segments. // Resolve start/end offsets for a URL or mention entity, handling three // storage formats: camelCase fromIndex/toIndex (article inline), Twitter // native indices:[s,e] array, and fallback exact-string search in fullText. function resolveEntityBounds(ent, fullText, ...candidates) { if (ent.fromIndex != null && ent.toIndex != null) return { s: ent.fromIndex, e: ent.toIndex }; if (ent.indices?.length === 2) return { s: ent.indices[0], e: ent.indices[1] }; if (fullText) { for (const c of candidates) { if (!c) continue; const idx = fullText.indexOf(c); if (idx !== -1) return { s: idx, e: idx + c.length }; } } return null; } // Build a canonical URL annotation from any entity format. // Search candidates: short url first (appears in fullText), then expanded. function normalizeUrlAnn(u, fullText) { const href = u.expanded_url || u.url || u.text || ''; const display = u.display_url || u.expanded_url || u.url || u.text || ''; const bounds = resolveEntityBounds(u, fullText, u.url, u.text, u.display_url, u.expanded_url); if (!bounds || !href) return null; return { ...bounds, kind: 'url', href, display }; } // Build a canonical mention annotation from any entity format. function normalizeMentionAnn(m, fullText) { const screen_name = m.screen_name || m.name || m.text || ''; const matchStr = screen_name ? `@${screen_name}` : null; const bounds = resolveEntityBounds(m, fullText, matchStr); if (!bounds || !screen_name) return null; return { ...bounds, kind: 'mention', screen_name }; } // Linkify bare http(s) URLs in a plain-text string that have no entity coverage. // Returns an array of strings and nodes, or the original string if no URLs. const URL_RE = /https?:\/\/[^\s<>"'\])]+/g; // Characters that commonly trail a URL but aren't part of it const TRAIL_PUNCT = /[.,;:!?)()]+$/; function linkifyText(text, linkStyle) { const parts = []; let last = 0; let m; URL_RE.lastIndex = 0; while ((m = URL_RE.exec(text)) !== null) { if (m.index > last) parts.push(text.slice(last, m.index)); let href = m[0].replace(TRAIL_PUNCT, ''); parts.push( {href} ); // Put back any trimmed trailing chars as plain text const trail = m[0].slice(href.length); if (trail) parts.push(trail); last = m.index + m[0].length; } if (last === 0) return text; // no URLs — return string directly (no array alloc) if (last < text.length) parts.push(text.slice(last)); return parts; } function renderTweetTextJSX(fullText, entities, skipSpans = []) { if (!fullText) return null; const anns = [ ...(entities.urls || []).map(u => normalizeUrlAnn(u, fullText)).filter(Boolean), ...(entities.user_mentions || []).map(m => normalizeMentionAnn(m, fullText)).filter(Boolean), ]; // If there are no annotations and no spans to skip, fast path. if (anns.length === 0 && skipSpans.length === 0) return linkifyText(decodeEnt(fullText), S.link); const pts = new Set([0, fullText.length]); for (const a of anns) { if (a.s >= 0 && a.s <= fullText.length) pts.add(a.s); if (a.e >= 0 && a.e <= fullText.length) pts.add(a.e); } // Skip-span boundaries MUST be in pts so a media t.co that lives inside a // larger plain-text segment still gets split out and suppressed. for (const [ss, se] of skipSpans) { if (ss >= 0 && ss <= fullText.length) pts.add(ss); if (se >= 0 && se <= fullText.length) pts.add(se); } const isSkipped = (s, e) => skipSpans.some(([ss, se]) => ss <= s && se >= e); const sorted = [...pts].sort((a, b) => a - b); return sorted.slice(0, -1).map((s, i) => { const e = sorted[i + 1]; // Suppress spans whose range is covered by a rendered media item. if (isSkipped(s, e)) return null; const seg = fullText.slice(s, e); const active = anns.filter(a => a.s <= s && a.e >= e); const url = active.find(a => a.kind === 'url'); if (url) { return ( {url.display || seg} ); } const mention = active.find(a => a.kind === 'mention'); if (mention) { return ( {seg} ); } return {linkifyText(decodeEnt(seg), S.link)}; }).filter(n => n !== null); } // ── Article inline text renderer ─────────────────────────────────────────────── // Port of renderInline() from x-article-renderer. // Splits block text at style-range, URL, and mention boundaries, returning // an array of React nodes with the appropriate wrappers applied. function renderInlineJSX(text, styleRanges, urls, mentions) { if (!text) return null; styleRanges = styleRanges || []; urls = urls || []; mentions = mentions || []; const anns = []; for (const r of styleRanges) { if (r.length > 0) anns.push({ s: r.offset, e: r.offset + r.length, kind: 'style', style: r.style }); } for (const u of urls) { const ann = normalizeUrlAnn(u, text); if (ann) anns.push(ann); } for (const m of mentions) { const ann = normalizeMentionAnn(m, text); if (ann) anns.push(ann); } if (anns.length === 0) return linkifyText(text, S.link); const pts = new Set([0, text.length]); for (const a of anns) { if (a.s >= 0 && a.s <= text.length) pts.add(a.s); if (a.e >= 0 && a.e <= text.length) pts.add(a.e); } const sorted = [...pts].sort((a, b) => a - b); return sorted.slice(0, -1).map((s, i) => { const e = sorted[i + 1]; const active = anns.filter(a => a.s <= s && a.e >= e); const seg = text.slice(s, e); // Handle newlines within the segment by inserting
elements. let content; if (seg.includes('\n')) { const parts = seg.split('\n'); content = parts.flatMap((p, pi) => pi < parts.length - 1 ? [p,
] : [p] ); } else { content = seg; } // Apply inline styles innermost first (matching Draft.js precedence). if (active.some(a => a.kind === 'style' && a.style === 'Code')) content = {content}; if (active.some(a => a.kind === 'style' && a.style === 'Bold')) content = {content}; if (active.some(a => a.kind === 'style' && a.style === 'Italic')) content = {content}; if (active.some(a => a.kind === 'style' && a.style === 'Underline')) content = {content}; if (active.some(a => a.kind === 'style' && a.style === 'Strikethrough')) content = {content}; // Links wrap outermost. const url = active.find(a => a.kind === 'url'); if (url) { // If the visible segment is a raw t.co short URL, replace it with the // human-readable display URL; otherwise keep the styled anchor text. const isTco = /^https?:\/\/t\.co\//i.test(content); content = ( {isTco ? url.display : content} ); } const mention = active.find(a => a.kind === 'mention'); if (mention) { content = ( {content} ); } return {typeof content === 'string' ? linkifyText(content, S.link) : content}; }); } // ── Article atomic block renderer ────────────────────────────────────────────── // Port of renderAtomic() from x-article-renderer. function renderAtomicJSX(block, artifactMap, opts) { const entities = block.resolved_entities || []; if (entities.length === 0) return null; return entities.map((e, i) => { switch (e.type) { case 'divider': return
; case 'media': { const src = resolveUrl(e.local_path, e.url, artifactMap); if (!src) return null; return ( { if (!ev.metaKey && !ev.ctrlKey) { ev.preventDefault(); opts?.onImgClick?.(src); } }} > ); } case 'tweet': return e.tweet_id ? ( View post on X ) : null; case 'link': return e.url ? (

{e.url}

) : null; case 'markdown': { const md = e.markdown ?? e.data?.markdown ?? ''; return (
            {md}
          
); } case 'emoji': return e.url ? ( ) : null; default: return null; } }); } // ── Article single block renderer ────────────────────────────────────────────── // Port of renderBlock() from x-article-renderer. function renderBlockJSX(block, key, artifactMap, opts) { const type = block.type || ''; const text = block.text || ''; const styleRanges = block.inline_style_ranges || []; const data = block.data || {}; const inner = renderInlineJSX(text, styleRanges, data.urls || [], data.mentions || []); switch (type) { case 'header-one': return

{inner}

; case 'header-two': { const m = text.match(/(?:x\.com|twitter\.com)\/i\/status\/(\d+)/); if (m) { return ( View post on X ); } return

{inner}

; } case 'unstyled': if (!text.trim()) return ; return

{inner}

; case 'blockquote': return
{inner}
; case 'unordered-list-item': case 'ordered-list-item': return
  • {inner}
  • ; case 'atomic': return {renderAtomicJSX(block, artifactMap, opts)}; default: return text ?

    {inner}

    : null; } } // ── Article block list renderer ──────────────────────────────────────────────── // Port of renderBlocks() from x-article-renderer. // Groups consecutive same-type list items into a single ul/ol. function renderBlocksJSX(blocks, artifactMap, opts) { const items = []; let i = 0; while (i < blocks.length) { const b = blocks[i]; if (b.type === 'unordered-list-item') { const startIdx = i; const listItems = []; while (i < blocks.length && blocks[i].type === 'unordered-list-item') { listItems.push(renderBlockJSX(blocks[i], i, artifactMap, opts)); i++; } items.push(); } else if (b.type === 'ordered-list-item') { const startIdx = i; const listItems = []; while (i < blocks.length && blocks[i].type === 'ordered-list-item') { listItems.push(renderBlockJSX(blocks[i], i, artifactMap, opts)); i++; } items.push(
      {listItems}
    ); } else { items.push(renderBlockJSX(b, i, artifactMap, opts)); i++; } } return items; } // ── Article renderer ─────────────────────────────────────────────────────────── function ArticleRenderer({ article, tweetAuthor, artifactMap }) { const [lightboxSrc, setLightboxSrc] = useState(null); const cover = article.cover_media || {}; const author = article.author || tweetAuthor || {}; const date = article.first_published_at_secs ? fmtDate(article.first_published_at_secs) : ''; const handlePart = author.screen_name ? `@${author.screen_name}` : ''; const subLine = [handlePart, date].filter(Boolean).join(' · '); const coverSrc = resolveUrl(cover.local_path, cover.url, artifactMap); const avatarSrc = resolveUrl(author.avatar_local_path, author.avatar_url, artifactMap); return (
    {lightboxSrc && ( setLightboxSrc(null)} /> )} {coverSrc && ( { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); setLightboxSrc(coverSrc); } }} > Article cover )}
    {article.title && (
    {article.title}
    )}
    {avatarSrc ? {author.name :
    }
    {author.name || author.screen_name || 'Unknown'}
    {subLine && (
    {subLine}
    )}

    {renderBlocksJSX(article.blocks || [], artifactMap, { onImgClick: setLightboxSrc })}
    ); } // ── Tweet card ───────────────────────────────────────────────────────────────── // Renders one tweet. When isInThread, omits bottom padding from the row and // shows a thread connector line below the avatar (except on the last card). // ── Photo grid ───────────────────────────────────────────────────────────────── // Renders 1–4 photos in Twitter-style grid. Regular click = lightbox; // Cmd/Ctrl+click follows the href to open the image in a new tab. function PhotoGrid({ photos, onOpen }) { const n = photos.length; if (n === 0) return null; if (n === 1) { return ( { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); onOpen(0); } }} > {photos[0].alt ); } // 2 → two columns, 1 row; 3 → left spans 2 rows; 4 → 2×2 const rowH = n <= 2 ? '180px' : '140px'; return (
    {photos.map((ph, i) => ( { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); onOpen(i); } }} > {ph.alt ))}
    ); } // ── Media lightbox ───────────────────────────────────────────────────────────── // position:fixed escapes any overflow/stacking context. function MediaLightbox({ items, startIndex, onClose }) { const [idx, setIdx] = useState(startIndex); useEffect(() => { const h = e => { if (e.key === 'Escape' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.stopPropagation(); e.preventDefault(); } if (e.key === 'Escape') onClose(); if (e.key === 'ArrowRight') setIdx(i => Math.min(i + 1, items.length - 1)); if (e.key === 'ArrowLeft') setIdx(i => Math.max(i - 1, 0)); }; document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [onClose, items.length]); const item = items[idx]; return (
    e.stopPropagation()}>
    {items.length > 1 && ( {idx + 1} / {items.length} )}
    {item.alt {items.length > 1 && (
    )}
    ); } function TweetCard({ tweet, isInThread, isLast, artifactMap }) { const [lightboxIdx, setLightboxIdx] = useState(null); const author = tweet.author || {}; const date = tweet.created_at_secs ? fmtDate(tweet.created_at_secs) : ''; const entities = tweet.entities || {}; const avatarSrc = resolveUrl(author.avatar_local_path, author.avatar_url, artifactMap); const showConnector = isInThread && !isLast; const isQT = tweet.is_quote_status === true; const rowStyle = isInThread ? S.tweetRowThread : S.tweetRow; const ft = tweet.full_text || ''; // Build resolved media lists first; skip the grid entirely if every item resolves to null. const rawMedia = (tweet.extended_entities?.media?.length ? tweet.extended_entities.media : entities.media) || []; const photos = []; const videoItems = []; for (const m of rawMedia) { if (m.type === 'photo') { const src = resolveUrl(m.local_path, m.media_url_https, artifactMap); if (src) photos.push({ kind: 'photo', src, alt: m.alt_text || '' }); } else if (m.type === 'video' || m.type === 'animated_gif') { const src = (m.local_path && artifactMap[m.local_path]) || (() => { const variants = m.video_info?.variants || []; return variants .filter(v => v.content_type === 'video/mp4') .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0]?.url; })(); if (src) videoItems.push({ kind: m.type === 'animated_gif' ? 'gif' : 'video', src }); } } // Suppress media attachment t.co links from the tweet text when the media // actually rendered. Uses resolveEntityBounds (prefers indices/fromIndex, // falls back to indexOf) so the span is always precise. const mediaSkipSpans = []; for (const m of rawMedia) { if (!m.url) continue; const rendered = m.type === 'photo' ? photos.some(p => p.src === resolveUrl(m.local_path, m.media_url_https, artifactMap)) : videoItems.length > 0; if (!rendered) continue; const b = resolveEntityBounds(m, ft, m.url); if (b) mediaSkipSpans.push([b.s, b.e]); } return ( <> {lightboxIdx !== null && ( setLightboxIdx(null)} /> )}
    {avatarSrc ? {author.name :
    } {showConnector &&
    }
    {author.name || author.screen_name || 'Unknown'} {author.screen_name && ( @{author.screen_name} )} {date && · {date}} {isQT && ( ↻ QT )}
    {renderTweetTextJSX(ft, entities, mediaSkipSpans)}
    {photos.length > 0 && (
    setLightboxIdx(i)} />
    )} {videoItems.map((v, i) => (
    ))} {(tweet.retweet_count > 0 || tweet.favorite_count > 0) && (
    {tweet.favorite_count > 0 && ( ❤️ {tweet.favorite_count.toLocaleString()} )} {tweet.retweet_count > 0 && ( 🔁 {tweet.retweet_count.toLocaleString()} )}
    )}
    ); } // ── TweetPreview ─────────────────────────────────────────────────────────────── export default function TweetPreview({ archiveId, entryUid, artifacts, entityKind }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tweets, setTweets] = useState([]); useEffect(() => { setLoading(true); setError(null); setTweets([]); if (!artifacts || !archiveId || !entryUid) { setLoading(false); return; } const tweetArtifacts = artifacts .map((a, index) => ({ ...a, index })) .filter(a => a.artifact_role === 'raw_tweet_json'); if (tweetArtifacts.length === 0) { setError('No tweet data found.'); setLoading(false); return; } let cancelled = false; fetchEntryArtifacts(archiveId, entryUid, tweetArtifacts.map(a => a.index)) .then(async data => { if (cancelled) return; const tcoRe = /https?:\/\/t\.co\/[A-Za-z0-9]+/g; // For each tweet, build the set of [start,end) spans already covered by // existing URL entities. A regex match is only "entity-less" if its span // is not covered — avoids skipping real duplicate occurrences. const coveredRanges = tweet => { const ft = tweet.full_text || ''; return (tweet.entities?.urls || []).map(u => { if (u.fromIndex != null && u.toIndex != null) return [u.fromIndex, u.toIndex]; if (u.indices?.length === 2) return [u.indices[0], u.indices[1]]; if (u.url) { const i = ft.indexOf(u.url); if (i !== -1) return [i, i + u.url.length]; } return null; }).filter(Boolean); }; const isCovered = (ranges, s, e) => ranges.some(([cs, ce]) => cs <= s && ce >= e); const uniqueTco = new Set(); for (const t of data) { const ft = t.full_text || ''; const ranges = coveredRanges(t); let m; tcoRe.lastIndex = 0; while ((m = tcoRe.exec(ft)) !== null) { if (!isCovered(ranges, m.index, m.index + m[0].length)) uniqueTco.add(m[0]); } } const resolved = uniqueTco.size > 0 ? await resolveTcoUrls([...uniqueTco]).catch(() => ({})) : {}; const augmented = data.map(t => { const ft = t.full_text || ''; const ranges = coveredRanges(t); const synth = []; let m; tcoRe.lastIndex = 0; while ((m = tcoRe.exec(ft)) !== null) { const tco = m[0]; const exp = resolved[tco]; if (exp && exp !== tco && !isCovered(ranges, m.index, m.index + tco.length)) { synth.push({ url: tco, expanded_url: exp, display_url: (() => { try { const u = new URL(exp); return u.hostname + (u.pathname.length > 1 ? '/…' : ''); } catch (_) { return exp; } })(), fromIndex: m.index, toIndex: m.index + tco.length, }); } } if (synth.length === 0) return t; return { ...t, entities: { ...(t.entities || {}), urls: [...(t.entities?.urls || []), ...synth] } }; }); if (!cancelled) setTweets(augmented); }) .catch(e => { if (!cancelled) setError(e.message || 'Failed to load tweet.'); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [archiveId, entryUid, artifacts]); if (loading) return
    Loading…
    ; if (error) return
    Error: {error}
    ; if (tweets.length === 0) return null; // Build relpath → artifact URL map once for this entry const artifactMap = buildArtifactMap(archiveId, entryUid, artifacts); if (entityKind === 'tweet_thread') { return (
    {tweets.map((tweet, i) => ( ))}
    ); } const tweet = tweets[0]; if (tweet.is_article && tweet.article) { return ; } return (
    ); }