import { useEffect, useRef } from 'react'; import { getArticleStats, listArticles, markArticleRead, toggleArticleRead, toggleArticleStar, } from '../../fetchers/article'; import { listFeeds } from '../../fetchers/feed'; import { isTuiShutdownError, useTuiLifecycle } from '../lifecycle'; import type { Action, AppState, ArticleWithFeed, FetchProgress, SortOrder } from '../state/types'; import { buildCategoryStates, buildFeedsWithCounts } from '../state/unread-counts'; type DispatchGuard = () => boolean; const canAlwaysDispatch: DispatchGuard = () => true; const articleLoadVersions = new WeakMap, number>(); const feedStatsLoadVersions = new WeakMap, number>(); const currentArticleLoadStates = new WeakMap, ArticleLoadState>(); const activeFetchSessions = new WeakMap, symbol>(); const articleMutationQueues = new WeakMap, Map>>(); export interface ArticleLoadRequest { isCurrent: () => boolean; } export interface FeedStatsLoadRequest { isCurrent: () => boolean; } export interface LoadArticlesOptions { canDispatch?: DispatchGuard; request?: ArticleLoadRequest; fetchArticles?: (options: Parameters[0]) => Promise; } export interface RefreshVisibleDataOptions { canDispatch?: DispatchGuard; } export interface LoadFeedsAndStatsOptions { canDispatch?: DispatchGuard; request?: FeedStatsLoadRequest; fetchFeeds?: typeof listFeeds; fetchStats?: typeof getArticleStats; } /** * Starts a newest-wins article request for a TUI session. This is shared by * effects and explicit refreshes so an older query can never overwrite a * later filter, feed, search, or sort selection. */ export function beginArticleLoadRequest(dispatch: React.Dispatch): ArticleLoadRequest { const version = (articleLoadVersions.get(dispatch) ?? 0) + 1; articleLoadVersions.set(dispatch, version); return { isCurrent: () => articleLoadVersions.get(dispatch) === version, }; } /** Start a newest-wins feed/stat request for this TUI session. */ export function beginFeedStatsLoadRequest(dispatch: React.Dispatch): FeedStatsLoadRequest { const version = (feedStatsLoadVersions.get(dispatch) ?? 0) + 1; feedStatsLoadVersions.set(dispatch, version); return { isCurrent: () => feedStatsLoadVersions.get(dispatch) === version }; } function setCurrentArticleLoadState(dispatch: React.Dispatch, state: ArticleLoadState): void { currentArticleLoadStates.set(dispatch, state); } function matchesCurrentArticleLoadState(dispatch: React.Dispatch, state: ArticleLoadState): boolean { const currentState = currentArticleLoadStates.get(dispatch); return currentState === undefined || isSameArticleLoadState(state, currentState); } /** * Runs one feed-fetch session at a time for a TUI dispatch instance. A second * auto/manual request becomes a no-op while the owner keeps progress accurate. */ export async function runFeedFetchSession( dispatch: React.Dispatch, run: (reportProgress: (progress: FetchProgress) => void) => Promise, canDispatch: DispatchGuard = canAlwaysDispatch, ): Promise<{ started: true; value: T } | { started: false }> { if (activeFetchSessions.has(dispatch)) return { started: false }; const session = Symbol('feed-fetch'); activeFetchSessions.set(dispatch, session); const reportProgress = (progress: FetchProgress) => { if (activeFetchSessions.get(dispatch) === session && canDispatch()) { dispatch({ type: 'SET_FETCH_PROGRESS', progress }); } }; try { return { started: true, value: await run(reportProgress) }; } finally { if (activeFetchSessions.get(dispatch) === session) { activeFetchSessions.delete(dispatch); if (canDispatch()) dispatch({ type: 'SET_FETCH_PROGRESS', progress: null }); } } } export function useDataLoader(state: AppState, dispatch: React.Dispatch) { const lifecycle = useTuiLifecycle(); const mountedRef = useRef(true); const { selectedFeedId, selectedFeedType, showUnreadOnly, sortOrder, searchQuery } = state; const currentCriteria = { selectedFeedId, selectedFeedType, showUnreadOnly, sortOrder, searchQuery }; const criteriaRef = useRef(currentCriteria); criteriaRef.current = currentCriteria; useEffect(() => { setCurrentArticleLoadState(dispatch, { selectedFeedId, selectedFeedType, showUnreadOnly, sortOrder, searchQuery }); }, [dispatch, selectedFeedId, selectedFeedType, showUnreadOnly, sortOrder, searchQuery]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Load feeds and stats on mount useEffect(() => { const canDispatch = () => mountedRef.current && lifecycle.isAcceptingWork(); const request = beginFeedStatsLoadRequest(dispatch); void lifecycle .run(() => loadFeedsAndStats(dispatch, { canDispatch, request })) .catch((error) => { if (!isTuiShutdownError(error) && canDispatch() && request.isCurrent()) { dispatch({ type: 'SET_ERROR', error: 'Failed to load feeds' }); } }); }, [dispatch, lifecycle]); // Load articles when feed selection changes useEffect(() => { const criteria = { selectedFeedId, selectedFeedType, showUnreadOnly, sortOrder, searchQuery }; const request = beginArticleLoadRequest(dispatch); let cancelled = false; const canDispatch = () => !cancelled && mountedRef.current && lifecycle.isAcceptingWork() && isSameArticleLoadState(criteria, criteriaRef.current); void lifecycle .run(() => loadArticles(criteria, dispatch, { canDispatch, request })) .catch((error) => { if (!isTuiShutdownError(error) && canDispatch() && request.isCurrent()) { dispatch({ type: 'SET_ERROR', error: 'Failed to load articles' }); } }); return () => { cancelled = true; }; }, [selectedFeedId, selectedFeedType, showUnreadOnly, sortOrder, searchQuery, dispatch, lifecycle]); } export type ArticleLoadState = { selectedFeedId: number | null; selectedFeedType: 'all' | 'starred' | 'feed'; showUnreadOnly: boolean; sortOrder: SortOrder; searchQuery: string; }; export function getArticleLoadState(state: AppState): ArticleLoadState { return { selectedFeedId: state.selectedFeedId, selectedFeedType: state.selectedFeedType, showUnreadOnly: state.showUnreadOnly, sortOrder: state.sortOrder, searchQuery: state.searchQuery, }; } export function isSameArticleLoadState(left: ArticleLoadState, right: ArticleLoadState): boolean { return ( left.selectedFeedId === right.selectedFeedId && left.selectedFeedType === right.selectedFeedType && left.showUnreadOnly === right.showUnreadOnly && left.sortOrder === right.sortOrder && left.searchQuery === right.searchQuery ); } export async function refreshVisibleData( state: ArticleLoadState, dispatch: React.Dispatch, { canDispatch = canAlwaysDispatch }: RefreshVisibleDataOptions = {}, ): Promise { // Claim the article slot before the feed/stat await. Otherwise a preceding // refresh with the same criteria could still apply after this one starts. const request = beginArticleLoadRequest(dispatch); const feedStatsRequest = beginFeedStatsLoadRequest(dispatch); await loadFeedsAndStats(dispatch, { canDispatch, request: feedStatsRequest }); await loadArticles(state, dispatch, { canDispatch, request }); } export async function loadFeedsAndStats( dispatch: React.Dispatch, { canDispatch = canAlwaysDispatch, request = beginFeedStatsLoadRequest(dispatch), fetchFeeds = listFeeds, fetchStats = getArticleStats, }: LoadFeedsAndStatsOptions = {}, ): Promise { if (canDispatch() && request.isCurrent()) dispatch({ type: 'SET_LOADING', loading: true }); try { const [allFeeds, stats] = await Promise.all([fetchFeeds({ active: true }), fetchStats()]); if (!canDispatch() || !request.isCurrent()) return false; const feedsWithCounts = buildFeedsWithCounts(allFeeds, stats.byFeed); const categoryStates = buildCategoryStates(feedsWithCounts); dispatch({ type: 'SET_FEEDS', feeds: feedsWithCounts, categories: categoryStates }); dispatch({ type: 'SET_STATS', unread: stats.unread, starred: stats.starred, lastFetchedAt: allFeeds.reduce((latest, f) => { if (!f.lastFetchedAt) return latest; if (!latest) return f.lastFetchedAt; return f.lastFetchedAt > latest ? f.lastFetchedAt : latest; }, null), }); dispatch({ type: 'SET_ERROR', error: null }); return true; } finally { if (canDispatch() && request.isCurrent()) dispatch({ type: 'SET_LOADING', loading: false }); } } export async function loadArticles( state: ArticleLoadState, dispatch: React.Dispatch, { canDispatch = canAlwaysDispatch, request = beginArticleLoadRequest(dispatch), fetchArticles = listArticles, }: LoadArticlesOptions = {}, ): Promise { const options: Parameters[0] = { limit: 200, sortBy: state.sortOrder === 'title' ? 'title' : 'publishedAt', sortOrder: state.sortOrder === 'oldest' ? 'asc' : 'desc', }; if (state.selectedFeedType === 'starred') { options.starred = true; } else if (state.selectedFeedType === 'feed' && state.selectedFeedId !== null) { options.feedId = state.selectedFeedId; } if (state.showUnreadOnly) { options.read = false; } if (state.searchQuery) { options.search = state.searchQuery; } const result = await fetchArticles(options); if (!canDispatch() || !request.isCurrent() || !matchesCurrentArticleLoadState(dispatch, state)) return false; dispatch({ type: 'SET_ARTICLES', articles: result }); dispatch({ type: 'SET_ERROR', error: null }); return true; } function enqueueArticleMutation( articleId: number, dispatch: React.Dispatch, operation: () => Promise, ): Promise { let queue = articleMutationQueues.get(dispatch); if (!queue) { queue = new Map(); articleMutationQueues.set(dispatch, queue); } const previous = queue.get(articleId) ?? Promise.resolve(); const current = previous.catch(() => undefined).then(operation); queue.set(articleId, current); // `finally()` mirrors the source rejection onto its returned Promise. This // cleanup branch is intentionally detached from the caller's mutation // Promise, so use both settlement handlers instead of leaving that mirrored // rejection unobserved. void current.then( () => { if (queue?.get(articleId) === current) queue.delete(articleId); }, () => { if (queue?.get(articleId) === current) queue.delete(articleId); }, ); return current; } /** Explicitly mark a newly-opened article read; this is intentionally not a toggle. */ export function markRead( articleId: number, dispatch: React.Dispatch, canDispatch: DispatchGuard = canAlwaysDispatch, ): Promise { return enqueueArticleMutation(articleId, dispatch, async () => { const article = await markArticleRead(articleId, true); if (!article || !canDispatch()) return; dispatch({ type: 'UPDATE_ARTICLE', articleId, changes: { isRead: article.isRead } }); dispatch({ type: 'SET_ERROR', error: null }); }); } /** Serialize semantic toggles per article so every keypress has one DB transition. */ export function toggleRead( articleId: number, dispatch: React.Dispatch, canDispatch: DispatchGuard = canAlwaysDispatch, ): Promise { return enqueueArticleMutation(articleId, dispatch, async () => { const article = await toggleArticleRead(articleId); if (!article || !canDispatch()) return; dispatch({ type: 'UPDATE_ARTICLE', articleId, changes: { isRead: article.isRead } }); dispatch({ type: 'SET_ERROR', error: null }); }); } export function toggleStar( articleId: number, dispatch: React.Dispatch, canDispatch: DispatchGuard = canAlwaysDispatch, ): Promise { return enqueueArticleMutation(articleId, dispatch, async () => { const article = await toggleArticleStar(articleId); if (!article || !canDispatch()) return; dispatch({ type: 'UPDATE_ARTICLE', articleId, changes: { isStarred: article.isStarred } }); dispatch({ type: 'SET_ERROR', error: null }); }); }