import type { ScrollBoxRenderable } from '@opentui/core'; import { useKeyboard, useTerminalDimensions } from '@opentui/react'; import { format } from 'date-fns'; import { useEffect, useMemo, useRef } from 'react'; import { fetchArticle } from '../../fetchers/article'; import { truncateUtf16 } from '../../utils/bounded-text'; import { MAX_CONTENT_SIZE, MAX_DESCRIPTION_SIZE } from '../../utils/constants'; import { containsHtml, hasRenderableContent, htmlToMarkdown } from '../../utils/html'; import { sanitizeTerminalText } from '../../utils/terminal'; import { markRead, toggleRead, toggleStar } from '../hooks/useDataLoader'; import { isTuiShutdownError, useTuiLifecycle } from '../lifecycle'; import { isSafeExternalUrl, parseDocument } from '../markdown/parse'; import { resolveAdjacentArticle } from '../models/article-list'; import { dispatchError, useAppDispatch, useAppState } from '../state/context'; import { generateHintLabels } from '../state/hint-labels'; import { truncateTerminalText } from '../text'; import { colors, icons } from '../theme'; import { MarkdownRenderer } from './MarkdownRenderer'; export function ArticleReader() { const state = useAppState(); const dispatch = useAppDispatch(); const lifecycle = useTuiLifecycle(); const { width, height } = useTerminalDimensions(); const article = state.selectedArticle; const scrollRef = useRef(null); const selectedArticleIdRef = useRef(article?.id ?? null); const contentFetchInFlightRef = useRef<{ articleId: number; link: string | undefined } | null>(null); const articleRef = useRef(article); selectedArticleIdRef.current = article?.id ?? null; articleRef.current = article; const articleId = article?.id; const articleLink = article?.link; const articleContent = article?.content; const articleContentFetchedAt = article?.contentFetchedAt; const hasUsableArticleContent = hasRenderableContent(articleContent); const articleUrl = article?.linkFixed ?? article?.link ?? ''; const readerPaneWidth = useMemo(() => { const terminalWidth = Math.max(0, Math.floor(width)); if (state.layoutMode !== 'wide') return terminalWidth; const feedWidth = Math.max(0, state.feedPanelWidth ?? 0); const articlePaneWidth = Math.max(0, state.articlePanelWidth ?? 0); return Math.max(0, terminalWidth - feedWidth - articlePaneWidth); }, [width, state.layoutMode, state.feedPanelWidth, state.articlePanelWidth]); const metadataRuleWidth = Math.max(0, Math.min(readerPaneWidth - 6, 60)); const markdownWidth = Math.max(0, readerPaneWidth - 6); // Fetch each selected article at most once while it is in flight. Read/star // updates replace the article object, so depending on object identity here // would otherwise issue duplicate content requests. useEffect(() => { const articleToFetch = articleRef.current; if (!articleToFetch || hasUsableArticleContent || articleContentFetchedAt || articleId === undefined) return; if (contentFetchInFlightRef.current?.articleId === articleId) return; let cancelled = false; // Capture the link generation as well as the ID. The same Article can be // refreshed with a new canonical link while an older extraction resolves. const request = { articleId, link: articleLink }; const requestController = new AbortController(); const abortRequest = () => requestController.abort(lifecycle.signal.reason); if (lifecycle.signal.aborted) abortRequest(); else lifecycle.signal.addEventListener('abort', abortRequest, { once: true }); contentFetchInFlightRef.current = request; void lifecycle .run(() => fetchArticle(articleToFetch, { skipBroken: false, flagBroken: true, signal: requestController.signal }), ) .then((result) => { if ( lifecycle.isAcceptingWork() && !cancelled && (result.persisted || result.current) && contentFetchInFlightRef.current === request && selectedArticleIdRef.current === articleId && articleRef.current?.link === request.link ) { dispatch({ type: 'UPDATE_ARTICLE', articleId, changes: { // A CAS loser can return a newer row written by another process. // Keep its fetch generation together: retaining the stale link // would make a subsequent retry target the wrong URL. link: result.article.link, content: result.article.content, contentFetchedAt: result.article.contentFetchedAt, isBroken: result.article.isBroken, brokenReason: result.article.brokenReason, linkFixed: result.article.linkFixed, updatedAt: result.article.updatedAt, }, }); } }) .catch((err) => { if ( !isTuiShutdownError(err) && lifecycle.isAcceptingWork() && !cancelled && selectedArticleIdRef.current === articleId ) { dispatchError(dispatch, err); } }) .finally(() => { lifecycle.signal.removeEventListener('abort', abortRequest); if (contentFetchInFlightRef.current === request) contentFetchInFlightRef.current = null; }) .catch(() => { // The success/error handlers above own fetch failures. This final guard // prevents an unexpected renderer/dispatch failure from becoming a // detached Promise rejection. }); return () => { cancelled = true; requestController.abort(); lifecycle.signal.removeEventListener('abort', abortRequest); if (contentFetchInFlightRef.current === request) contentFetchInFlightRef.current = null; }; }, [articleId, articleLink, articleContentFetchedAt, dispatch, hasUsableArticleContent, lifecycle]); // Reset scroll to the top whenever a different article is opened, so the // reader doesn't inherit the previous article's scroll position. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollRef is stable; article?.id is the intended trigger so switching articles resets scroll. useEffect(() => { scrollRef.current?.scrollTo({ x: 0, y: 0 }); }, [article?.id]); // Build the markdown text once per content change. Keyed on the raw content // strings rather than the article object identity so star/read toggles // (which produce a new article object) don't re-run cheerio + turndown. const contentTextForParse = useMemo(() => { let text = hasRenderableContent(article?.content) ? (truncateUtf16(article.content, MAX_CONTENT_SIZE) ?? '') : truncateUtf16(article?.description ?? undefined, MAX_DESCRIPTION_SIZE) || ''; text = sanitizeTerminalText(text, { preserveNewlines: true }); if (containsHtml(text)) text = htmlToMarkdown(text); return text; }, [article?.content, article?.description]); // Parse the document a single time and share both halves: the renderer reuses // `blocks`, and hint mode reuses `links`, so the markdown isn't parsed twice. const parsed = useMemo(() => parseDocument(contentTextForParse), [contentTextForParse]); const parsedLinks = parsed.links; // Hint targets are snapshotted at ENTER_HINT_MODE. If a background content // fetch lands while hint mode is open, the link set changes underneath the // overlay and label `i` would point at the wrong URL. Exit hint mode whenever // the link set changes (skipping the initial render so we don't close a // freshly-opened overlay). const prevLinksRef = useRef(parsedLinks); useEffect(() => { if (prevLinksRef.current !== parsedLinks) { prevLinksRef.current = parsedLinks; if (state.overlay === 'hint') { dispatch({ type: 'EXIT_HINT_MODE' }); } } }, [parsedLinks, state.overlay, dispatch]); // Map linkIndex -> hint label for the renderer to overlay. const hintLabels = useMemo(() => { if (state.overlay !== 'hint' || state.hintTargets.length === 0) return undefined; const map = new Map(); for (const [targetIndex, target] of state.hintTargets.entries()) { const linkIndex = parsedLinks[targetIndex]?.linkIndex; if (linkIndex !== undefined) map.set(linkIndex, target.label); } return map; }, [parsedLinks, state.overlay, state.hintTargets]); useKeyboard((key) => { if (state.activePanel !== 'reader') return; // Hint mode owns all keystrokes while active. if (state.overlay === 'hint') { if (key.name === 'escape') { dispatch({ type: 'EXIT_HINT_MODE' }); return; } if (key.name && /^[a-z]$/.test(key.name)) { const next = state.hintBuffer + key.name; const exact = state.hintTargets.find((t) => t.label === next); if (exact) { if (isSafeExternalUrl(exact.url)) { import('open').then((mod) => mod.default(exact.url)).catch(() => {}); } dispatch({ type: 'EXIT_HINT_MODE' }); return; } const hasPrefix = state.hintTargets.some((t) => t.label.startsWith(next)); if (hasPrefix) { dispatch({ type: 'APPEND_HINT_KEY', key: key.name }); return; } // Typo / no match — bail out so the user can retry. dispatch({ type: 'EXIT_HINT_MODE' }); return; } // Non-letter keys are swallowed silently. return; } if (state.overlay !== 'none') return; // u -> open link via hint mode if (key.name === 'u') { if (parsedLinks.length === 0) return; const labels = generateHintLabels(parsedLinks.length); const targets = parsedLinks.map((l, i) => ({ url: l.url, label: labels[i] ?? '' })); dispatch({ type: 'ENTER_HINT_MODE', targets }); return; } // space / pagedown -> page down ; b / pageup -> page up. // Keep ~3 lines of overlap so context isn't lost between pages. if (key.name === 'space' || key.name === 'pagedown') { const pageSize = Math.max(1, height - 8); scrollRef.current?.scrollBy({ x: 0, y: pageSize }); return; } if (key.name === 'b' || key.name === 'pageup') { const pageSize = Math.max(1, height - 8); scrollRef.current?.scrollBy({ x: 0, y: -pageSize }); return; } // h / Left / Escape -> back if (key.name === 'h' || key.name === 'left' || key.name === 'escape') { dispatch({ type: 'SET_PANEL', panel: 'articles' }); return; } // s -> toggle star if (key.name === 's' && article) { void lifecycle .run(() => toggleStar(article.id, dispatch, () => lifecycle.isAcceptingWork())) .catch((err) => { if (!isTuiShutdownError(err) && lifecycle.isAcceptingWork()) dispatchError(dispatch, err); }); return; } // r -> toggle read if (key.name === 'r' && article) { void lifecycle .run(() => toggleRead(article.id, dispatch, () => lifecycle.isAcceptingWork())) .catch((err) => { if (!isTuiShutdownError(err) && lifecycle.isAcceptingWork()) dispatchError(dispatch, err); }); return; } // o -> open in browser if (key.name === 'o' && article && isSafeExternalUrl(articleUrl)) { import('open').then((mod) => mod.default(articleUrl)).catch(() => {}); return; } // n -> next article ; p -> prev article. Resolve relative to the current // article's real position so navigation stays correct even when the open // article was removed from the list (unread-only / starred filters). if (key.name === 'n' || key.name === 'p') { const direction = key.name === 'n' ? 'next' : 'prev'; const target = resolveAdjacentArticle(state.articles, article?.id, state.articleListIndex, direction); if (target) { dispatch({ type: 'SET_ARTICLE_LIST_INDEX', index: target.index }); dispatch({ type: 'SET_SELECTED_ARTICLE', article: target.article }); if (!target.article.isRead) { void lifecycle .run(() => markRead(target.article.id, dispatch, () => lifecycle.isAcceptingWork())) .catch((err) => { if (!isTuiShutdownError(err) && lifecycle.isAcceptingWork()) dispatchError(dispatch, err); }); } } return; } }); if (!article) { return ( Select an article to read Press ? for keyboard shortcuts ); } const contentText = contentTextForParse; const articleTitle = sanitizeTerminalText(article.title ?? 'Untitled'); const feedTitle = sanitizeTerminalText(article.feed?.title ?? ''); const author = article.author ? sanitizeTerminalText(article.author) : ''; const displayedArticleUrl = sanitizeTerminalText(articleUrl); const publishDate = article.publishedAt ? format(article.publishedAt, "MMM d, yyyy 'at' HH:mm") : 'Unknown date'; const readStatus = article.isRead ? 'Read' : 'Unread'; const starStatus = article.isStarred ? `${icons.starred} Starred` : ''; const readerTitle = truncateTerminalText(articleTitle || 'Article', Math.max(0, Math.min(50, readerPaneWidth - 4))); return ( {/* Article metadata header */} {articleTitle} {'═'.repeat(metadataRuleWidth)} {feedTitle} {author ? ( {' · '} {author} ) : null} {' · '} {publishDate} {article.isRead ? icons.read : icons.unread} {readStatus} {starStatus ? ( {' · '} {starStatus} ) : null} {displayedArticleUrl} {'─'.repeat(metadataRuleWidth)} {/* Article content */} {contentText ? ( ) : ( No content available. Press 'o' to open in browser. )} ); }