import { buildFeedListItems } from '../models/feed-list'; import type { Action, AppState, SortOrder } from './types'; import { applyDeltaToCategories, applyDeltaToFeeds, clampCount } from './unread-counts'; const sortCycle: SortOrder[] = ['newest', 'oldest', 'title']; function promotePendingUpdate(state: AppState, requestedOverlay: AppState['overlay']): AppState['overlay'] { return requestedOverlay === 'none' && state.updateInfo ? 'update' : requestedOverlay; } function clampListIndex(index: number, itemCount: number): number { if (itemCount <= 0 || !Number.isFinite(index)) return 0; return Math.min(Math.max(0, index), itemCount - 1); } export function appReducer(state: AppState, action: Action): AppState { switch (action.type) { case 'SET_PANEL': return { ...state, activePanel: action.panel }; case 'SET_LAYOUT_MODE': return { ...state, layoutMode: action.mode }; case 'SET_OVERLAY': // Clear hint state whenever the overlay changes away from 'hint' so // a stray ENTER_HINT_MODE from a previous article can't leak. return { ...state, overlay: promotePendingUpdate(state, action.overlay), ...(action.overlay !== 'hint' ? { hintTargets: [], hintBuffer: '' } : {}), }; case 'SET_FEEDS': { // Clamp the cursor so a shrunken feed list (e.g. after deleting a feed) // can't leave feedListIndex pointing past the end, which would make // Enter/d/e/f silently no-op. const collapsedByCategory = new Map(); for (const category of state.categories) { if (!collapsedByCategory.has(category.name)) { collapsedByCategory.set(category.name, category.collapsed); } } const categories = action.categories.map((category) => ({ ...category, collapsed: collapsedByCategory.get(category.name) ?? category.collapsed, })); const selectedFeedStillExists = state.selectedFeedType !== 'feed' || action.feeds.some((feed) => feed.id === state.selectedFeedId); const itemCount = buildFeedListItems(categories, state.totalUnread, state.totalStarred).length; const feedListIndex = selectedFeedStillExists ? clampListIndex(state.feedListIndex, itemCount) : 0; return { ...state, feeds: action.feeds, categories, feedListIndex, ...(selectedFeedStillExists ? {} : { selectedFeedId: null, selectedFeedType: 'all' as const, articleListIndex: 0 }), }; } case 'SET_ARTICLES': { const highlightedArticleId = state.articles[state.articleListIndex]?.id; const nextIndex = highlightedArticleId ? action.articles.findIndex((article) => article.id === highlightedArticleId) : -1; const refreshedSelectedArticle = state.selectedArticle ? (action.articles.find((article) => article.id === state.selectedArticle?.id) ?? (state.activePanel === 'reader' ? state.selectedArticle : null)) : null; return { ...state, articles: action.articles, articleListIndex: clampListIndex(nextIndex, action.articles.length), selectedArticle: refreshedSelectedArticle, }; } case 'SET_SELECTED_ARTICLE': return { ...state, selectedArticle: action.article }; case 'SELECT_FEED': return { ...state, selectedFeedId: action.feedId, selectedFeedType: action.feedType, articleListIndex: 0, }; case 'SET_FEED_LIST_INDEX': return { ...state, feedListIndex: clampListIndex( action.index, buildFeedListItems(state.categories, state.totalUnread, state.totalStarred).length, ), }; case 'SET_ARTICLE_LIST_INDEX': return { ...state, articleListIndex: clampListIndex(action.index, state.articles.length) }; case 'TOGGLE_UNREAD_FILTER': return { ...state, showUnreadOnly: !state.showUnreadOnly }; case 'CYCLE_SORT_ORDER': { const currentIdx = sortCycle.indexOf(state.sortOrder); const nextIdx = (currentIdx + 1) % sortCycle.length; return { ...state, sortOrder: sortCycle[nextIdx] as SortOrder }; } case 'SET_SEARCH_QUERY': return { ...state, searchQuery: action.query }; case 'SET_STATS': return { ...state, totalUnread: action.unread, totalStarred: action.starred, lastFetchedAt: action.lastFetchedAt, }; case 'SET_LOADING': return { ...state, loading: action.loading }; case 'SET_FETCH_PROGRESS': return { ...state, fetchProgress: action.progress }; case 'SET_CONFIRM': return { ...state, confirmAction: action.action, overlay: action.action ? 'confirm' : promotePendingUpdate(state, 'none'), }; case 'SET_ERROR': return { ...state, error: action.error }; case 'TOGGLE_CATEGORY': return { ...state, categories: state.categories.map((cat) => cat.name === action.categoryName ? { ...cat, collapsed: !cat.collapsed } : cat, ), }; case 'UPDATE_ARTICLE': { const currentArticle = state.articles.find((article) => article.id === action.articleId) ?? (state.selectedArticle?.id === action.articleId ? state.selectedArticle : null); if (!currentArticle) { return state; } const nextArticle = { ...currentArticle, ...action.changes }; const unreadDelta = currentArticle.isRead === nextArticle.isRead ? 0 : nextArticle.isRead ? -1 : 1; const starredDelta = currentArticle.isStarred === nextArticle.isStarred ? 0 : nextArticle.isStarred ? 1 : -1; const shouldRemoveFromList = state.articles.some((article) => article.id === action.articleId) && ((state.showUnreadOnly && nextArticle.isRead) || (state.selectedFeedType === 'starred' && !nextArticle.isStarred)); const nextArticles = shouldRemoveFromList ? state.articles.filter((article) => article.id !== action.articleId) : state.articles.map((article) => article.id === action.articleId ? { ...article, ...action.changes } : article, ); const nextArticleListIndex = nextArticles.length === 0 ? 0 : Math.min(state.articleListIndex, nextArticles.length - 1); return { ...state, articles: nextArticles, articleListIndex: nextArticleListIndex, selectedArticle: state.selectedArticle?.id === action.articleId ? { ...state.selectedArticle, ...action.changes } : state.selectedArticle, totalUnread: clampCount(state.totalUnread + unreadDelta), totalStarred: clampCount(state.totalStarred + starredDelta), feeds: applyDeltaToFeeds(state.feeds, currentArticle.feedId, unreadDelta), categories: applyDeltaToCategories(state.categories, currentArticle.feedId, unreadDelta), }; } case 'SET_FEED_PANEL_WIDTH': return { ...state, feedPanelWidth: action.width }; case 'SET_ARTICLE_PANEL_WIDTH': return { ...state, articlePanelWidth: action.width }; case 'SET_EDITING_FEED': return { ...state, editingFeed: action.feed, overlay: action.feed ? 'editFeed' : promotePendingUpdate(state, 'none'), }; case 'ENTER_HINT_MODE': return { ...state, overlay: 'hint', hintTargets: action.targets, hintBuffer: '', }; case 'APPEND_HINT_KEY': return { ...state, hintBuffer: state.hintBuffer + action.key }; case 'EXIT_HINT_MODE': return { ...state, overlay: state.overlay === 'hint' ? promotePendingUpdate(state, 'none') : state.overlay, hintTargets: [], hintBuffer: '', }; case 'SET_UPDATE_INFO': { // Only auto-open the overlay if the user isn't busy with something else. // Always close it when info is cleared. const overlay = action.info ? state.overlay === 'none' ? 'update' : state.overlay : state.overlay === 'update' ? 'none' : state.overlay; return { ...state, updateInfo: action.info, overlay }; } default: return state; } }