import type { MouseEvent } from '@opentui/core'; import { useKeyboard, useTerminalDimensions } from '@opentui/react'; import { useCallback, useMemo, useRef } from 'react'; import { sanitizeTerminalText } from '../../utils/terminal'; import { useListCursor, useScrollEchoGuard } from '../hooks/useListCursor'; import { useTuiActions } from '../hooks/useTuiActions'; import { cleanArticleDescription, formatArticleAge, getArticleWindow, truncateText } from '../models/article-list'; import { useAppDispatch, useAppState } from '../state/context'; import { terminalCellWidth, truncateTerminalText } from '../text'; import { colors, getFeedColor, icons } from '../theme'; export function ArticleList() { const state = useAppState(); const dispatch = useAppDispatch(); const { fetchAll, fetchOneFeed, markAllReadForFeed, openArticle, toggleArticleRead, toggleArticleStar } = useTuiActions(); const { width: termWidth, height: termHeight } = useTerminalDimensions(); const feedW = state.feedPanelWidth ?? Math.floor(termWidth * 0.2); const articleW = state.articlePanelWidth ?? Math.floor(termWidth * 0.35); const contentWidth = useMemo(() => { if (state.layoutMode === 'wide') { return articleW - 4; } if (state.layoutMode === 'medium') { return termWidth - feedW - 1 - 4; } return termWidth - 4; }, [state.layoutMode, termWidth, feedW, articleW]); const safeWidth = Math.max(0, contentWidth); const currentArticle = state.articles[state.articleListIndex] ?? null; const articleIdxRef = useRef(state.articleListIndex); articleIdxRef.current = state.articleListIndex; const { armEcho, shouldIgnore: shouldIgnoreEcho } = useScrollEchoGuard(); useListCursor({ isActive: state.activePanel === 'articles' && state.overlay === 'none', length: state.articles.length, currentIndex: state.articleListIndex, setIndex: useCallback((index: number) => dispatch({ type: 'SET_ARTICLE_LIST_INDEX', index }), [dispatch]), shouldIgnore: shouldIgnoreEcho, }); useKeyboard((key) => { if (state.activePanel !== 'articles' || state.overlay !== 'none') return; if ((key.name === 'enter' || key.name === 'return') && currentArticle) { openArticle(currentArticle); return; } if (key.name === 's' && currentArticle) { toggleArticleStar(currentArticle); return; } if (key.name === 'r' && !key.shift && currentArticle) { toggleArticleRead(currentArticle); return; } if (key.name === 'u') { dispatch({ type: 'TOGGLE_UNREAD_FILTER' }); return; } if (key.name === 't') { dispatch({ type: 'CYCLE_SORT_ORDER' }); return; } if (key.name === 'r' && key.shift) { if (state.selectedFeedType === 'starred') return; void markAllReadForFeed(state.selectedFeedId); return; } if (key.name === 'f' && !key.shift && state.selectedFeedId) { const feed = state.feeds.find((stateFeed) => stateFeed.id === state.selectedFeedId); if (feed) void fetchOneFeed(feed); return; } if (key.name === 'f' && key.shift) { void fetchAll(); return; } if (key.name === 'h' || key.name === 'left' || key.name === 'escape') { dispatch({ type: 'SET_PANEL', panel: 'feeds' }); return; } if (key.name === 'l' || key.name === 'right') { dispatch({ type: 'SET_PANEL', panel: 'reader' }); return; } }); const filterLabel = state.showUnreadOnly ? ' [unread]' : ''; const sortLabel = ` [${state.sortOrder}]`; const articleWindow = useMemo( () => getArticleWindow(state.articles, state.articleListIndex, termHeight), [state.articles, state.articleListIndex, termHeight], ); const visibleRows = articleWindow.rows; const showScrollUp = articleWindow.showScrollUp; const showScrollDown = articleWindow.showScrollDown; const handleMouseScroll = useCallback( (event: MouseEvent) => { if (state.activePanel !== 'articles' || state.overlay !== 'none') return; event.stopPropagation(); if (state.articles.length === 0) return; const dir = event.scroll?.direction; if (dir === 'down' || dir === 'up') { armEcho(dir); } const cur = articleIdxRef.current; let newIdx: number | undefined; if (dir === 'down') { newIdx = Math.min(cur + 1, state.articles.length - 1); } else if (dir === 'up') { newIdx = Math.max(cur - 1, 0); } if (newIdx === undefined) return; articleIdxRef.current = newIdx; dispatch({ type: 'SET_ARTICLE_LIST_INDEX', index: newIdx }); const article = state.articles[newIdx]; if (article) { dispatch({ type: 'SET_SELECTED_ARTICLE', article }); } }, [armEcho, state.activePanel, state.overlay, state.articles, dispatch], ); return ( {state.articles.length === 0 ? ( {state.loading ? 'Loading...' : state.searchQuery ? 'No matching articles' : 'No articles'} ) : ( {showScrollUp ? ( {'▲ more'} ) : null} {visibleRows.map((row) => { if (row.type === 'header') { const label = truncateTerminalText(row.headerText ?? '', Math.max(0, safeWidth - 1)); const lineLen = Math.max(0, safeWidth - terminalCellWidth(label) - 1); return ( {label} {icons.separator.repeat(lineLen)} ); } const article = row.article; if (!article) return null; const isSelected = row.articleIndex === state.articleListIndex; const readIcon = article.isRead ? icons.read : icons.unread; const age = formatArticleAge(article.publishedAt); const starStr = article.isStarred ? ` ${icons.starred}` : ''; const suffix = truncateTerminalText(`${starStr} ${age}`, Math.max(0, safeWidth - 2)); const titleBudget = Math.max(0, safeWidth - 2 - terminalCellWidth(suffix)); const title = truncateText(sanitizeTerminalText(article.title ?? 'Untitled'), titleBudget); const feedName = truncateTerminalText( sanitizeTerminalText(article.feed?.title ?? ''), Math.max(0, safeWidth - 2), ); const desc = cleanArticleDescription(article.description); const descBudget = Math.max(0, safeWidth - 2 - terminalCellWidth(feedName) - 3); const descTrunc = truncateText(desc, descBudget); const feedColor = article.feed ? getFeedColor(article.feed.id) : colors.textSecondary; return ( {readIcon} {title} {article.isStarred && suffix ? ( {suffix.startsWith(starStr) ? starStr : ''} ) : null} {suffix.startsWith(starStr) ? suffix.slice(starStr.length) : suffix}
{' '} {feedName} {descTrunc ? ( {' · '} {descTrunc} ) : null}
); })} {showScrollDown ? ( {'▼ more'} ) : null}
)}
); }