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 > → > → > (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
{md}
);
}
case 'emoji':
return e.url ? (
{inner}
; case 'blockquote': return{inner}; case 'unordered-list-item': case 'ordered-list-item': return
{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(