import { formatDistanceToNow, isThisWeek, isToday, isYesterday } from 'date-fns'; import { truncateUtf16 } from '../../utils/bounded-text'; import { MAX_DESCRIPTION_SIZE } from '../../utils/constants'; import { stripHtml } from '../../utils/html'; import { sanitizeTerminalText } from '../../utils/terminal'; import type { ArticleWithFeed } from '../state/types'; import { truncateTerminalText } from '../text'; export interface ArticleListRow { type: 'header' | 'article'; key: string; article?: ArticleWithFeed; headerText?: string; articleIndex: number; } export interface ArticleWindow { scrollOffset: number; visibleArticles: ArticleWithFeed[]; rows: ArticleListRow[]; showScrollUp: boolean; showScrollDown: boolean; } export function getArticleDateGroup(date: Date | null): string { if (!date) return 'Unknown'; if (isToday(date)) return 'Today'; if (isYesterday(date)) return 'Yesterday'; if (isThisWeek(date)) return 'This Week'; return 'Earlier'; } export function formatArticleAge(date: Date | null): string { if (!date) return ''; return formatDistanceToNow(date, { addSuffix: false }) .replace(' minutes', 'm') .replace(' minute', 'm') .replace(' hours', 'h') .replace(' hour', 'h') .replace(' days', 'd') .replace(' day', 'd') .replace('about ', '') .replace('less than a', '<1'); } export function cleanArticleDescription(raw: string | null): string { if (!raw) return ''; const bounded = truncateUtf16(raw, MAX_DESCRIPTION_SIZE) ?? ''; return sanitizeTerminalText( stripHtml(bounded) .replace(/&[a-z#0-9]+;/gi, ' ') .replace(/\s+/g, ' ') .trim(), ); } /** * Build a selected-article window whose real rendered rows fit the panel. * Article rows are two cells high; date headers and each active scroll * indicator consume one additional row. */ export function getArticleWindow( articles: ArticleWithFeed[], selectedIndex: number, termHeight: number, ): ArticleWindow { const panelRows = Math.max(0, termHeight - 6); if (articles.length === 0 || panelRows === 0) { return { scrollOffset: 0, visibleArticles: [], rows: [], showScrollUp: false, showScrollDown: false }; } const groupBoundaryPrefix = new Array(articles.length).fill(0); let previousGroup = getArticleDateGroup(articles[0]?.publishedAt ?? null); for (let index = 1; index < articles.length; index++) { const group = getArticleDateGroup(articles[index]?.publishedAt ?? null); groupBoundaryPrefix[index] = (groupBoundaryPrefix[index - 1] ?? 0) + Number(group !== previousGroup); previousGroup = group; } const rowCount = (start: number, end: number): number => { if (start >= end) return 0; const groupRows = 1 + (groupBoundaryPrefix[end - 1] ?? 0) - (groupBoundaryPrefix[start] ?? 0); return (end - start) * 2 + groupRows + Number(start > 0) + Number(end < articles.length); }; const selected = Math.max(0, Math.min(selectedIndex, articles.length - 1)); let start = selected; let end = selected + 1; if (rowCount(start, end) > panelRows) { return { scrollOffset: start, visibleArticles: [], rows: [], showScrollUp: false, showScrollDown: false }; } // Prefer the selected article in the second slot, matching the existing // scroll behavior, then fill forward and use spare room above near the end. if (start > 0 && rowCount(start - 1, end) <= panelRows) start -= 1; while (end < articles.length && rowCount(start, end + 1) <= panelRows) end += 1; while (start > 0 && rowCount(start - 1, end) <= panelRows) start -= 1; const visibleArticles = articles.slice(start, end); return { scrollOffset: start, visibleArticles, rows: buildArticleRows(visibleArticles, start), showScrollUp: start > 0, showScrollDown: end < articles.length, }; } export function getArticleScrollOffset(selectedIndex: number, visibleSlots: number, articleCount: number): number { if (selectedIndex < visibleSlots - 1) return 0; return Math.min(Math.max(0, selectedIndex - 1), Math.max(0, articleCount - visibleSlots)); } export function getVisibleArticles(articles: ArticleWithFeed[], scrollOffset: number, visibleSlots: number) { return articles.slice(scrollOffset, scrollOffset + visibleSlots); } export function buildArticleRows(visibleArticles: ArticleWithFeed[], scrollOffset: number): ArticleListRow[] { const result: ArticleListRow[] = []; let lastGroup = ''; for (let i = 0; i < visibleArticles.length; i++) { const article = visibleArticles[i] as ArticleWithFeed; const articleIndex = scrollOffset + i; const group = getArticleDateGroup(article.publishedAt); if (group !== lastGroup) { result.push({ type: 'header', key: `header-${group}-${articleIndex}`, headerText: group, articleIndex: -1, }); lastGroup = group; } result.push({ type: 'article', key: `article-${article.id}`, article, articleIndex, }); } return result; } export function truncateText(value: string, maxLength: number): string { return truncateTerminalText(value, maxLength); } /** * Resolve the next/previous article to show in the reader, robust to the * currently-open article having been removed from the list (e.g. the * unread-only filter marks-read-and-removes on open, or un-starring in the * Starred view). * * `currentId` is the id of the article currently open in the reader, and * `fallbackIndex` is the list index it was last known to occupy. When the * article is still present we step relative to its real position; when it has * been removed, the article now occupying `fallbackIndex` is "next" and the * one before it is "prev". * * Returns null when there is no article to move to. */ export function resolveAdjacentArticle( articles: ArticleWithFeed[], currentId: number | null | undefined, fallbackIndex: number, direction: 'next' | 'prev', ): { article: ArticleWithFeed; index: number } | null { if (articles.length === 0) return null; const currentIndex = currentId != null ? articles.findIndex((a) => a.id === currentId) : -1; let targetIndex: number; if (currentIndex >= 0) { targetIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1; } else { // Current article was removed: the article now at fallbackIndex has slid // into its place, so it is "next"; "prev" is the slot before it. targetIndex = direction === 'next' ? fallbackIndex : fallbackIndex - 1; } targetIndex = Math.max(0, Math.min(targetIndex, articles.length - 1)); const article = articles[targetIndex]; if (!article) return null; return { article, index: targetIndex }; }