1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00
archivr/frontend/src/components/TweetPreview.jsx
TheGeneralist 46a5251316
feat: suppress rendered media attachment URLs from tweet text
renderTweetTextJSX now accepts skipSpans=[] as third param.
Skip-span boundaries are added to the pts split set so a trailing
media t.co inside a plain segment still gets isolated and suppressed—
not re-linked by linkifyText. Early return only when both anns and
skipSpans are empty.

TweetCard computes mediaSkipSpans after building photos/videoItems:
for each rawMedia item whose src resolved (photo src match; any video),
resolveEntityBounds(m, ft, m.url) gives the precise [s,e] span using
indices/fromIndex first, indexOf fallback—then the span is passed to
renderTweetTextJSX so the t.co attachment URL is silently dropped.
2026-07-12 16:09:35 +02:00

1269 lines
38 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/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 (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
style={{ flexShrink: 0, color: 'var(--ink)' }}
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.259 5.63L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z" />
</svg>
);
}
// ── 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 <a> 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(
<a key={m.index} href={href} target="_blank" rel="noopener noreferrer" style={linkStyle}>
{href}
</a>
);
// 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 (
<a key={i} href={url.href} target="_blank" rel="noopener noreferrer" style={S.link}>
{url.display || seg}
</a>
);
}
const mention = active.find(a => a.kind === 'mention');
if (mention) {
return (
<a
key={i}
href={`https://x.com/${mention.screen_name}`}
target="_blank"
rel="noopener noreferrer"
style={S.link}
>
{seg}
</a>
);
}
return <span key={i}>{linkifyText(decodeEnt(seg), S.link)}</span>;
}).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 <br /> elements.
let content;
if (seg.includes('\n')) {
const parts = seg.split('\n');
content = parts.flatMap((p, pi) =>
pi < parts.length - 1 ? [p, <br key={pi} />] : [p]
);
} else {
content = seg;
}
// Apply inline styles innermost first (matching Draft.js precedence).
if (active.some(a => a.kind === 'style' && a.style === 'Code'))
content = <code style={S.iCode}>{content}</code>;
if (active.some(a => a.kind === 'style' && a.style === 'Bold'))
content = <strong>{content}</strong>;
if (active.some(a => a.kind === 'style' && a.style === 'Italic'))
content = <em>{content}</em>;
if (active.some(a => a.kind === 'style' && a.style === 'Underline'))
content = <u>{content}</u>;
if (active.some(a => a.kind === 'style' && a.style === 'Strikethrough'))
content = <s>{content}</s>;
// 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 = (
<a href={url.href} target="_blank" rel="noopener noreferrer" style={S.link}>
{isTco ? url.display : content}
</a>
);
}
const mention = active.find(a => a.kind === 'mention');
if (mention) {
content = (
<a
href={`https://x.com/${mention.screen_name}`}
target="_blank"
rel="noopener noreferrer"
style={S.link}
>
{content}
</a>
);
}
return <span key={i}>{typeof content === 'string' ? linkifyText(content, S.link) : content}</span>;
});
}
// ── 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 <hr key={i} style={S.bHr} />;
case 'media': {
const src = resolveUrl(e.local_path, e.url, artifactMap);
if (!src) return null;
return (
<a
key={i}
href={src}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'block', cursor: 'zoom-in' }}
onClick={ev => {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
opts?.onImgClick?.(src);
}
}}
>
<img src={src} style={S.bImg} loading="lazy" alt="" />
</a>
);
}
case 'tweet':
return e.tweet_id ? (
<a
key={i}
href={`https://x.com/i/status/${e.tweet_id}`}
target="_blank"
rel="noopener noreferrer"
style={S.bTweet}
>
<XLogo />
View post on X
</a>
) : null;
case 'link':
return e.url ? (
<p key={i} style={S.bP}>
<a href={e.url} target="_blank" rel="noopener noreferrer" style={S.link}>
{e.url}
</a>
</p>
) : null;
case 'markdown': {
const md = e.markdown ?? e.data?.markdown ?? '';
return (
<pre key={i} style={S.bMdPre}>
<code style={S.bMdCode}>{md}</code>
</pre>
);
}
case 'emoji':
return e.url ? (
<img
key={i}
src={e.url}
alt=""
style={{ height: '1.2em', verticalAlign: 'middle', margin: '0 1px' }}
/>
) : 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 <h1 key={key} style={S.bH1}>{inner}</h1>;
case 'header-two': {
const m = text.match(/(?:x\.com|twitter\.com)\/i\/status\/(\d+)/);
if (m) {
return (
<a
key={key}
href={`https://x.com/i/status/${m[1]}`}
target="_blank"
rel="noopener noreferrer"
style={S.bTweet}
>
<XLogo />
View post on X
</a>
);
}
return <h2 key={key} style={S.bH2}>{inner}</h2>;
}
case 'unstyled':
if (!text.trim()) return <span key={key} style={S.bSpacer} />;
return <p key={key} style={S.bP}>{inner}</p>;
case 'blockquote':
return <blockquote key={key} style={S.bQuote}>{inner}</blockquote>;
case 'unordered-list-item':
case 'ordered-list-item':
return <li key={key} style={S.bLi}>{inner}</li>;
case 'atomic':
return <span key={key}>{renderAtomicJSX(block, artifactMap, opts)}</span>;
default:
return text ? <p key={key} style={S.bP}>{inner}</p> : 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(<ul key={`ul-${startIdx}`} style={S.bUl}>{listItems}</ul>);
} 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(<ol key={`ol-${startIdx}`} style={S.bOl}>{listItems}</ol>);
} 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 (
<div style={S.article}>
{lightboxSrc && (
<MediaLightbox
items={[{ src: lightboxSrc, alt: '' }]}
startIndex={0}
onClose={() => setLightboxSrc(null)}
/>
)}
{coverSrc && (
<a
href={coverSrc}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'block', cursor: 'zoom-in' }}
onClick={e => { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); setLightboxSrc(coverSrc); } }}
>
<img src={coverSrc} style={S.aCover} alt="Article cover" />
</a>
)}
<div style={S.aMeta}>
{article.title && (
<div style={S.aTweetTitle}>{article.title}</div>
)}
<div style={S.aAuthorRow}>
{avatarSrc
? <img src={avatarSrc} style={S.aAvatar} alt={author.name || ''} />
: <div style={S.aAvatarPh} />
}
<div>
<div style={S.aAuthorName}>
{author.name || author.screen_name || 'Unknown'}
</div>
{subLine && (
<div style={S.aAuthorSub}>{subLine}</div>
)}
</div>
</div>
</div>
<hr style={S.aDivider} />
<div style={S.aBody}>
{renderBlocksJSX(article.blocks || [], artifactMap, { onImgClick: setLightboxSrc })}
</div>
</div>
);
}
// ── 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 14 photos in Twitter-style grid. Regular click = lightbox;
// Cmd/Ctrl+click follows the <a> 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 (
<a
href={photos[0].src}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'block' }}
onClick={e => { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); onOpen(0); } }}
>
<img src={photos[0].src} alt={photos[0].alt || ''} style={S.mediaImg} loading="lazy" />
</a>
);
}
// 2 → two columns, 1 row; 3 → left spans 2 rows; 4 → 2×2
const rowH = n <= 2 ? '180px' : '140px';
return (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: n === 2 ? rowH : `${rowH} ${rowH}`,
gap: '2px',
}}>
{photos.map((ph, i) => (
<a
key={i}
href={ph.src}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
overflow: 'hidden',
gridRow: (n === 3 && i === 0) ? 'span 2' : undefined,
}}
onClick={e => { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); onOpen(i); } }}
>
<img
src={ph.src}
alt={ph.alt || ''}
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</a>
))}
</div>
);
}
// ── 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 (
<div style={S.lightboxBackdrop} onClick={onClose}>
<div style={S.lightboxContent} onClick={e => e.stopPropagation()}>
<div style={S.lightboxToolbar}>
{items.length > 1 && (
<span style={S.lightboxCounter}>{idx + 1} / {items.length}</span>
)}
<a
href={item.src}
target="_blank"
rel="noopener noreferrer"
style={S.lightboxLink}
title="Open in new tab"
></a>
<button style={S.lightboxBtn} onClick={onClose} aria-label="Close">×</button>
</div>
<img src={item.src} alt={item.alt || ''} style={S.lightboxImg} />
{items.length > 1 && (
<div style={S.lightboxNav}>
<button
style={S.lightboxNavBtn}
onClick={() => setIdx(i => Math.max(i - 1, 0))}
disabled={idx === 0}
></button>
<button
style={S.lightboxNavBtn}
onClick={() => setIdx(i => Math.min(i + 1, items.length - 1))}
disabled={idx === items.length - 1}
></button>
</div>
)}
</div>
</div>
);
}
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 && (
<MediaLightbox
items={photos}
startIndex={lightboxIdx}
onClose={() => setLightboxIdx(null)}
/>
)}
<div style={rowStyle}>
<div style={S.leftCol}>
{avatarSrc
? <img src={avatarSrc} style={S.avatar} alt={author.name || ''} />
: <div style={S.avatarPh} />
}
{showConnector && <div style={S.threadLine} />}
</div>
<div style={S.rightCol}>
<div style={S.authorRow}>
<span style={S.authorName}>{author.name || author.screen_name || 'Unknown'}</span>
{author.screen_name && (
<span style={S.authorHandle}>@{author.screen_name}</span>
)}
{date && <span style={S.datePart}>· {date}</span>}
{isQT && (
<span style={S.qtBadge} title="Quote tweet"> QT</span>
)}
</div>
<div style={S.tweetText}>
{renderTweetTextJSX(ft, entities, mediaSkipSpans)}
</div>
{photos.length > 0 && (
<div style={S.mediaGrid}>
<PhotoGrid photos={photos} onOpen={i => setLightboxIdx(i)} />
</div>
)}
{videoItems.map((v, i) => (
<div key={i} style={S.mediaGrid}>
<video
src={v.src}
style={S.mediaVideo}
controls
loop={v.kind === 'gif'}
muted={v.kind === 'gif'}
autoPlay={v.kind === 'gif'}
/>
</div>
))}
{(tweet.retweet_count > 0 || tweet.favorite_count > 0) && (
<div style={S.stats}>
{tweet.favorite_count > 0 && (
<span> {tweet.favorite_count.toLocaleString()}</span>
)}
{tweet.retweet_count > 0 && (
<span>🔁 {tweet.retweet_count.toLocaleString()}</span>
)}
</div>
)}
</div>
</div>
</>
);
}
// ── 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 <div style={S.loading}>Loading</div>;
if (error) return <div style={S.error}>Error: {error}</div>;
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 (
<div style={S.threadOuter}>
{tweets.map((tweet, i) => (
<TweetCard
key={tweet.id || i}
tweet={tweet}
isInThread
isLast={i === tweets.length - 1}
artifactMap={artifactMap}
/>
))}
</div>
);
}
const tweet = tweets[0];
if (tweet.is_article && tweet.article) {
return <ArticleRenderer article={tweet.article} tweetAuthor={tweet.author} artifactMap={artifactMap} />;
}
return (
<div style={S.card}>
<TweetCard tweet={tweet} isInThread={false} isLast artifactMap={artifactMap} />
</div>
);
}