import { Box, ScrollBox, Text } from "../../ui"; import { TextAttributes, type ScrollBoxRenderable } from "../../ui"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; import { t } from "../../i18n"; import { useAppLanguage } from "../../i18n/react"; import { displayWidth, formatTimeAgo } from "../../utils/format"; import { colors } from "../../theme/colors"; import { isPlainKey } from "../../utils/keyboard"; import { toTimestampMillis } from "../../utils/timestamp"; import { DataTableStackView } from "../data-table/stack-view"; import { usePaneFooter } from "../layout/pane/footer"; import type { DataTableRootKeyContext } from "../data-table/view"; import { activeStackIndex, sortIndexedStackRows, type IndexedStackRow, type StackSortPreference, } from "../feed-stack-controller"; import { ExternalLink, type DataTableCell, type DataTableColumn } from "../ui"; import { PaneLinkMenu } from "../ui/external-link"; import { wrapTextLines } from "../../utils/text-wrap"; import { tableColumnWidth } from "../ui/table-layout"; export interface FeedDataTableItem { id: string; eyebrow?: string; title: string; timestamp?: Date | string | null; detailTitle?: string; detailMeta?: string[]; detailBody?: string | null; detailNote?: string | null; } type DetailColumnId = "time" | "source" | "title"; type DetailColumn = DataTableColumn & { id: DetailColumnId }; type DetailRow = IndexedStackRow; type SortPreference = StackSortPreference; interface FeedDataTableStackViewProps { width: number; height: number; focused: boolean; items: FeedDataTableItem[]; selectedIdx: number; onSelect: (index: number) => void; rootBefore?: ReactNode; rootAfter?: ReactNode; onRootKeyDown?: (event: { name?: string; sequence?: string; ctrl?: boolean; meta?: boolean; super?: boolean; alt?: boolean; option?: boolean; shift?: boolean; preventDefault?: () => void; stopPropagation?: () => void; }, context: DataTableRootKeyContext) => boolean | void; sourceLabel?: string; titleLabel?: string; emptyStateTitle?: string; emptyStateHint?: string; isItemRead?: (item: FeedDataTableItem) => boolean; onOpenItem?: (item: FeedDataTableItem, index: number) => void; /** Controlled open item, for panes that keep it in pane state so it restores and travels with a share. */ openItemId?: string | null; onOpenItemIdChange?: (itemId: string | null) => void; scrollRef?: RefObject; onBodyScrollActivity?: () => void; /** Warms the detail of an item once the cursor rests on its row; see DataTableStackView. */ prefetchDetail?: (item: FeedDataTableItem, index: number) => void; } function timestampValue(item: FeedDataTableItem): number { if (!item.timestamp) return 0; const timestamp = toTimestampMillis(item.timestamp); return Number.isFinite(timestamp) ? timestamp : 0; } function compareText(a: string, b: string): number { return a.localeCompare(b, "en-US", { sensitivity: "base" }); } function compareRows(a: DetailRow, b: DetailRow, columnId: DetailColumnId) { switch (columnId) { case "time": return timestampValue(a.item) - timestampValue(b.item); case "source": return compareText(a.item.eyebrow ?? "", b.item.eyebrow ?? ""); case "title": return compareText(a.item.title, b.item.title); } } function nextSortPreference( current: SortPreference, columnId: DetailColumnId, ): SortPreference { if (current.columnId === columnId) { return { columnId, direction: current.direction === "asc" ? "desc" : "asc", }; } return { columnId, direction: columnId === "time" ? "desc" : "asc", }; } function buildColumns( width: number, sourceLabel: string, titleLabel: string, items: FeedDataTableItem[], ): DetailColumn[] { const timeWidth = 8; // Long sources (insider names, publishers) get more room as the pane // widens, from 14 cells up to 24; the title keeps its minimum. const sourceCap = Math.min(24, Math.max(14, Math.floor(width * 0.18))); let longestSource = Math.max(displayWidth(sourceLabel), 6); for (const item of items) { if (item.eyebrow) longestSource = Math.max(longestSource, displayWidth(item.eyebrow)); } // The table widens a column to fit its header, so budget what it will draw. const sourceWidth = tableColumnWidth({ width: Math.min(longestSource, sourceCap), label: sourceLabel }); const titleWidth = Math.max( 16, width - (timeWidth + 1) - (sourceWidth + 1) - 3, ); return [ { id: "time", label: t("Time"), width: timeWidth, align: "left" }, { id: "source", label: sourceLabel, width: sourceWidth, align: "left" }, { id: "title", label: titleLabel, width: titleWidth, align: "left" }, ]; } export function FeedDataTableStackView({ width, height, focused, items, selectedIdx, onSelect, rootBefore, rootAfter, onRootKeyDown, sourceLabel = "Source", titleLabel = "Headline", emptyStateTitle = "No items.", emptyStateHint, isItemRead, onOpenItem, openItemId: controlledOpenItemId, onOpenItemIdChange, scrollRef, onBodyScrollActivity, prefetchDetail, }: FeedDataTableStackViewProps) { const language = useAppLanguage(); const [sortPreference, setSortPreference] = useState({ columnId: "time", direction: "desc", }); const [localOpenItemId, setLocalOpenItemId] = useState(null); const controlled = controlledOpenItemId !== undefined; const openItemId = controlled ? controlledOpenItemId : localOpenItemId; const setOpenItemId = useCallback((itemId: string | null) => { if (controlled) onOpenItemIdChange?.(itemId); else setLocalOpenItemId(itemId); }, [controlled, onOpenItemIdChange]); const detailScrollRef = useRef(null); const detailTextWidth = Math.max(width - 2, 12); const columns = useMemo( () => buildColumns(width, t(sourceLabel), t(titleLabel), items), [items, language, sourceLabel, titleLabel, width], ); const sortedRows = useMemo(() => { return sortIndexedStackRows(items, sortPreference, compareRows); }, [items, sortPreference]); const selectedRowIndex = sortedRows.findIndex( (row) => row.itemIndex === selectedIdx, ); const activeRowIndex = activeStackIndex(sortedRows.length, selectedRowIndex); const openItem = useMemo( () => openItemId ? items.find((item) => item.id === openItemId) ?? null : null, [items, openItemId], ); const activeOpenItemId = openItem ? openItem.id : null; const scrollDetailBy = useCallback((delta: number) => { const scrollBox = detailScrollRef.current; if (!scrollBox?.viewport) return; const maxScrollTop = Math.max( 0, scrollBox.scrollHeight - scrollBox.viewport.height, ); scrollBox.scrollTop = Math.max( 0, Math.min(maxScrollTop, scrollBox.scrollTop + delta), ); }, []); const openRow = useCallback((row: DetailRow | undefined) => { if (!row) return; onOpenItem?.(row.item, row.itemIndex); setOpenItemId(row.item.id); }, [onOpenItem, setOpenItemId]); // Reading on: the next or previous item opens in place, and the list cursor // follows it so Back lands on the last one read. const openRowIndex = openItem ? sortedRows.findIndex((row) => row.item.id === openItem.id) : -1; const openAdjacent = useCallback((step: 1 | -1) => { const row = openRowIndex >= 0 ? sortedRows[openRowIndex + step] : undefined; if (!row) return; onSelect(row.itemIndex); openRow(row); }, [onSelect, openRow, openRowIndex, sortedRows]); const detailFooterId = `feed-detail:${useId()}`; usePaneFooter(detailFooterId, () => (focused && openRowIndex >= 0 ? { // After the pane's own hints. order: 1, hints: [ { id: "feed-next", key: "n", label: "ext", title: "Next Item", disabled: openRowIndex >= sortedRows.length - 1, onPress: () => openAdjacent(1) }, { id: "feed-previous", key: "p", label: "rev", title: "Previous Item", disabled: openRowIndex <= 0, onPress: () => openAdjacent(-1) }, ], } : null), [focused, openAdjacent, openRowIndex, sortedRows.length]); useEffect(() => { // A controlled id may name an item that is still loading; the owner // decides when to give it up. if (openItemId && !openItem && !controlled) { setOpenItemId(null); } }, [controlled, openItem, openItemId, setOpenItemId]); useEffect(() => { if (!controlled) onOpenItemIdChange?.(activeOpenItemId); }, [activeOpenItemId, controlled, onOpenItemIdChange]); useEffect(() => { if (!openItemId) return; const scrollBox = detailScrollRef.current; if (scrollBox) scrollBox.scrollTop = 0; }, [openItemId]); useEffect(() => { if (items.length > 0 && selectedIdx >= items.length) { onSelect(Math.max(0, items.length - 1)); } }, [items.length, onSelect, selectedIdx]); const renderCell = useCallback(( row: DetailRow, column: DetailColumn, _index: number, rowState: { selected: boolean }, ): DataTableCell => { const selectedColor = rowState.selected ? colors.selectedText : undefined; switch (column.id) { case "time": return { text: row.item.timestamp ? formatTimeAgo(row.item.timestamp) : "", color: selectedColor ?? colors.textDim, }; case "source": return { text: row.item.eyebrow ?? "", color: selectedColor ?? colors.textMuted, }; case "title": return { text: row.item.title, color: selectedColor ?? colors.text, attributes: isItemRead ? isItemRead(row.item) ? TextAttributes.NONE : TextAttributes.BOLD : rowState.selected ? TextAttributes.BOLD : TextAttributes.NONE, }; } }, [isItemRead]); const handleDetailKeyDown = useCallback((event: { name?: string; preventDefault?: () => void; stopPropagation?: () => void; }) => { if (isPlainKey(event, "j", "down")) { event.stopPropagation?.(); event.preventDefault?.(); scrollDetailBy(1); return true; } if (isPlainKey(event, "k", "up")) { event.stopPropagation?.(); event.preventDefault?.(); scrollDetailBy(-1); return true; } return false; }, [scrollDetailBy]); const detailContent = openItem ? ( {(openItem.detailMeta ?? []) .flatMap((entry) => wrapTextLines(entry, detailTextWidth, 2)) .map((line, index) => ( {line} ))} {wrapTextLines(openItem.detailBody ?? "", detailTextWidth).map( (line, index) => ( {line} ), )} {openItem.detailNote ? ( <> {wrapTextLines(openItem.detailNote, detailTextWidth).map( (line, index) => /^https?:\/\/\S+$/.test(line.trim()) ? ( ) : ( {line} ), )} ) : null} ) : ( ); return ( focused={focused} detailOpen={!!openItem} onBack={() => setOpenItemId(null)} detailContent={{detailContent}} detailTitle={openItem ? openItem.detailTitle ?? openItem.title : undefined} selection={{ kind: "index", selectedIndex: activeRowIndex, onChange: (_index, row) => onSelect(row.itemIndex), }} onActivate={(row) => openRow(row)} prefetchDetail={prefetchDetail ? (row) => prefetchDetail(row.item, row.itemIndex) : undefined} rootBefore={rootBefore} rootAfter={rootAfter} rootWidth={width} rootHeight={height} onRootKeyDown={onRootKeyDown} onDetailKeyDown={handleDetailKeyDown} columns={columns} items={sortedRows} sortColumnId={sortPreference.columnId} sortDirection={sortPreference.direction} onHeaderClick={(columnId) => setSortPreference((current) => nextSortPreference(current, columnId as DetailColumnId) )} getItemKey={(row) => row.item.id} renderCell={renderCell} emptyStateTitle={t(emptyStateTitle)} emptyStateHint={emptyStateHint} showHorizontalScrollbar={false} scrollRef={scrollRef} onBodyScrollActivity={onBodyScrollActivity} /> ); }