import type { MouseEvent } from '@opentui/core'; import { useKeyboard, useTerminalDimensions } from '@opentui/react'; import { useCallback, useMemo, useRef } from 'react'; import { useListCursor, useScrollEchoGuard } from '../hooks/useListCursor'; import { useTuiActions } from '../hooks/useTuiActions'; import { buildFeedListItems, type FeedListItem, getFeedScrollOffset, getFeedVisibleRows, getNavigableFeedIndices, getVisibleFeedItems, truncateFeedLabel, } from '../models/feed-list'; import { useAppDispatch, useAppState } from '../state/context'; import { terminalCellWidth, truncateTerminalText } from '../text'; import { colors, getFeedColor, icons } from '../theme'; export function FeedList() { const state = useAppState(); const dispatch = useAppDispatch(); const { deleteFeed, fetchAll, fetchOneFeed, markAllReadForFeed } = useTuiActions(); const { width: termWidth, height: termHeight } = useTerminalDimensions(); const feedW = state.feedPanelWidth ?? Math.floor(termWidth * 0.2); const contentWidth = Math.max(0, feedW - 4); const items = useMemo( () => buildFeedListItems(state.categories, state.totalUnread, state.totalStarred), [state.categories, state.totalUnread, state.totalStarred], ); const categoryByName = useMemo(() => { const categories = new Map(); for (const category of state.categories) { if (!categories.has(category.name)) categories.set(category.name, category); } return categories; }, [state.categories]); const navigableIndices = useMemo(() => getNavigableFeedIndices(items), [items]); const currentNavIdx = useMemo( () => navigableIndices.indexOf(state.feedListIndex), [navigableIndices, state.feedListIndex], ); const navIdxRef = useRef(currentNavIdx); navIdxRef.current = currentNavIdx; const { armEcho, shouldIgnore: shouldIgnoreEcho } = useScrollEchoGuard(); useListCursor({ isActive: state.activePanel === 'feeds' && state.overlay === 'none', length: navigableIndices.length, currentIndex: currentNavIdx, setIndex: useCallback( (navIdx: number) => dispatch({ type: 'SET_FEED_LIST_INDEX', index: navigableIndices[navIdx] ?? 0 }), [dispatch, navigableIndices], ), shouldIgnore: shouldIgnoreEcho, }); const handleSelect = useCallback( (item: FeedListItem) => { if (item.type === 'categoryHeader' && item.categoryName) { dispatch({ type: 'TOGGLE_CATEGORY', categoryName: item.categoryName, }); } else if (item.feedType) { dispatch({ type: 'SELECT_FEED', feedId: item.feedId ?? null, feedType: item.feedType, }); dispatch({ type: 'SET_PANEL', panel: 'articles' }); } }, [dispatch], ); const availableRows = getFeedVisibleRows(termHeight); const showScrollIndicators = availableRows >= 3; const visibleRows = showScrollIndicators ? availableRows - 2 : availableRows; const scrollOffset = useMemo( () => getFeedScrollOffset(state.feedListIndex, visibleRows, items.length), [state.feedListIndex, visibleRows, items.length], ); const visibleItems = useMemo( () => getVisibleFeedItems(items, scrollOffset, visibleRows), [items, scrollOffset, visibleRows], ); useKeyboard((key) => { if (state.activePanel !== 'feeds' || state.overlay !== 'none') return; if (key.name === 'enter' || key.name === 'return') { const item = items[state.feedListIndex]; if (item) handleSelect(item); return; } if (key.name === 'a') { dispatch({ type: 'SET_OVERLAY', overlay: 'addFeed' }); return; } if (key.name === 'd') { const item = items[state.feedListIndex]; if (item?.type === 'feed' && item.feedId) { const feedId = item.feedId; dispatch({ type: 'SET_CONFIRM', action: { message: `Delete feed "${item.label}"?`, onConfirm: async () => { await deleteFeed(feedId); }, }, }); } return; } if (key.name === 'r' && key.shift) { const item = items[state.feedListIndex]; if (!item || item.type === 'separator' || item.type === 'categoryHeader') return; if (item.type === 'virtual' && item.feedType === 'starred') return; void markAllReadForFeed(item.type === 'feed' && item.feedId ? item.feedId : null); return; } if (key.name === 'f' && !key.shift) { const item = items[state.feedListIndex]; if (item?.type !== 'feed' || !item.feedId) return; const feed = state.feeds.find((stateFeed) => stateFeed.id === item.feedId); if (!feed) return; void fetchOneFeed(feed); return; } if (key.name === 'f' && key.shift) { void fetchAll(); return; } if (key.name === 'e') { const item = items[state.feedListIndex]; if (item?.type === 'feed' && item.feedId) { const feed = state.feeds.find((stateFeed) => stateFeed.id === item.feedId); if (feed) { dispatch({ type: 'SET_EDITING_FEED', feed }); } } return; } if (key.name === 'l' || key.name === 'right') { dispatch({ type: 'SET_PANEL', panel: 'articles' }); return; } }); const showScrollUp = showScrollIndicators && scrollOffset > 0; const showScrollDown = showScrollIndicators && scrollOffset + visibleRows < items.length; const handleMouseScroll = useCallback( (event: MouseEvent) => { if (state.activePanel !== 'feeds' || state.overlay !== 'none') return; event.stopPropagation(); const dir = event.scroll?.direction; if (dir === 'down' || dir === 'up') { armEcho(dir); } const cur = navIdxRef.current; let newNavIdx: number | undefined; if (dir === 'down') { newNavIdx = Math.min(cur + 1, navigableIndices.length - 1); } else if (dir === 'up') { newNavIdx = Math.max(cur - 1, 0); } if (newNavIdx === undefined) return; const newItemIdx = navigableIndices[newNavIdx] ?? 0; navIdxRef.current = newNavIdx; dispatch({ type: 'SET_FEED_LIST_INDEX', index: newItemIdx }); const item = items[newItemIdx]; if (item && item.type !== 'separator' && item.type !== 'categoryHeader' && item.feedType) { dispatch({ type: 'SELECT_FEED', feedId: item.feedId ?? null, feedType: item.feedType, }); } }, [armEcho, navigableIndices, items, dispatch, state.activePanel, state.overlay], ); return ( {showScrollUp ? ( {'▲ more'} ) : null} {visibleItems.map(({ item, index }) => { const isSelected = index === state.feedListIndex; if (item.type === 'separator') { return ( {icons.separator.repeat(Math.max(0, contentWidth))} ); } if (item.type === 'virtual') { const countStr = item.unreadCount ? ` ${item.unreadCount}` : ''; const visibleCount = truncateTerminalText(countStr, contentWidth); const label = truncateFeedLabel(item.label, visibleCount, 0, contentWidth); return ( {label} {visibleCount ? {visibleCount} : null} ); } if (item.type === 'categoryHeader') { const category = item.categoryName ? categoryByName.get(item.categoryName) : undefined; const icon = category?.collapsed ? icons.categoryClosed : icons.categoryOpen; const countStr = item.unreadCount ? ` (${item.unreadCount})` : ''; const available = Math.max(0, contentWidth - terminalCellWidth(`${icon} `)); const visibleCount = truncateTerminalText(countStr, available); const label = truncateFeedLabel(item.label, visibleCount, 2, contentWidth); return ( {icon} {label} {visibleCount ? {visibleCount} : null} ); } const hasUnread = (item.unreadCount ?? 0) > 0; const countStr = hasUnread ? ` ${icons.unread} ${item.unreadCount}` : ` ${icons.read}`; const available = Math.max(0, contentWidth - 2); const visibleCount = truncateTerminalText(countStr, available); const label = truncateFeedLabel(item.label, visibleCount, 2, contentWidth); const color = item.feedId ? getFeedColor(item.feedId) : colors.textPrimary; return ( {label} {hasUnread ? ( {visibleCount} ) : ( {visibleCount} )} ); })} {showScrollDown ? ( {'▼ more'} ) : null} ); }