From 24d7b3e057e7dced8ff7ac335bf1c6758fee89c5 Mon Sep 17 00:00:00 2001 From: plebeius Date: Mon, 23 Feb 2026 17:09:26 +0800 Subject: [PATCH] feat(catalog): add reply-count sorting mode --- .../board-buttons/board-buttons.tsx | 3 +- src/lib/utils/__tests__/catalog-sort.test.ts | 88 +++++++++++++++++++ src/lib/utils/catalog-sort.ts | 62 +++++++++++++ src/stores/use-sorting-store.ts | 7 +- src/views/board/board.tsx | 14 +-- src/views/catalog/catalog.tsx | 18 ++-- 6 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 src/lib/utils/__tests__/catalog-sort.test.ts create mode 100644 src/lib/utils/catalog-sort.ts diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx index fac1c350..3b626ab4 100644 --- a/src/components/board-buttons/board-buttons.tsx +++ b/src/components/board-buttons/board-buttons.tsx @@ -198,7 +198,7 @@ const SortOptions = () => { const { sortType, setSortType } = useSortingStore(); const handleSortChange = (event: React.ChangeEvent) => { - const type = event.target.value as 'active' | 'new'; + const type = event.target.value as 'active' | 'new' | 'replyCount'; setSortType(type); }; return ( @@ -207,6 +207,7 @@ const SortOptions = () => { ); diff --git a/src/lib/utils/__tests__/catalog-sort.test.ts b/src/lib/utils/__tests__/catalog-sort.test.ts new file mode 100644 index 00000000..de51529f --- /dev/null +++ b/src/lib/utils/__tests__/catalog-sort.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { sortCatalogFeedForDisplay, type CatalogPost } from '../catalog-sort'; + +describe('sortCatalogFeedForDisplay', () => { + describe('replyCount sort', () => { + it('orders by replyCount descending', () => { + const posts: CatalogPost[] = [ + { cid: 'a', replyCount: 5 }, + { cid: 'b', replyCount: 20 }, + { cid: 'c', replyCount: 10 }, + ]; + const result = sortCatalogFeedForDisplay(posts, 'replyCount'); + expect(result.map((p) => p.cid)).toEqual(['b', 'c', 'a']); + expect(result.map((p) => p.replyCount)).toEqual([20, 10, 5]); + }); + + it('tie-breaks on bump order (lastReplyTimestamp, then timestamp)', () => { + const posts: CatalogPost[] = [ + { cid: 'a', replyCount: 10, lastReplyTimestamp: 100, timestamp: 50 }, + { cid: 'b', replyCount: 10, lastReplyTimestamp: 300, timestamp: 60 }, + { cid: 'c', replyCount: 10, lastReplyTimestamp: 200, timestamp: 70 }, + ]; + const result = sortCatalogFeedForDisplay(posts, 'replyCount'); + expect(result.map((p) => p.cid)).toEqual(['b', 'c', 'a']); + }); + + it('handles undefined replyCount as 0', () => { + const posts: CatalogPost[] = [{ cid: 'a', replyCount: 5 }, { cid: 'b' }, { cid: 'c', replyCount: undefined }, { cid: 'd', replyCount: null }]; + const result = sortCatalogFeedForDisplay(posts, 'replyCount'); + expect(result.map((p) => p.cid)).toEqual(['a', 'b', 'c', 'd']); + }); + + it('keeps pinned posts at top in input order', () => { + const posts: CatalogPost[] = [ + { cid: 'mid', replyCount: 100 }, + { cid: 'pinned1', replyCount: 1, pinned: true }, + { cid: 'low', replyCount: 5 }, + { cid: 'pinned2', replyCount: 50, pinned: true }, + ]; + const result = sortCatalogFeedForDisplay(posts, 'replyCount'); + expect(result.map((p) => p.cid)).toEqual(['pinned1', 'pinned2', 'mid', 'low']); + }); + + it('produces deterministic ordering under full ties', () => { + const posts: CatalogPost[] = [ + { cid: 'z', replyCount: 0 }, + { cid: 'a', replyCount: 0 }, + { cid: 'm', replyCount: 0 }, + ]; + const result = sortCatalogFeedForDisplay(posts, 'replyCount'); + expect(result.map((p) => p.cid)).toEqual(['a', 'm', 'z']); + }); + + it('produces deterministic ordering under full ties (repeated runs)', () => { + const posts: CatalogPost[] = [ + { cid: 'x', replyCount: 5, lastReplyTimestamp: 100, timestamp: 50 }, + { cid: 'y', replyCount: 5, lastReplyTimestamp: 100, timestamp: 50 }, + ]; + const r1 = sortCatalogFeedForDisplay(posts, 'replyCount'); + const r2 = sortCatalogFeedForDisplay(posts, 'replyCount'); + expect(r1.map((p) => p.cid)).toEqual(r2.map((p) => p.cid)); + }); + }); + + describe('non-replyCount sorts', () => { + it('returns original order for active sort', () => { + const posts: CatalogPost[] = [ + { cid: 'c', replyCount: 10 }, + { cid: 'a', replyCount: 100 }, + { cid: 'b', replyCount: 50 }, + ]; + const result = sortCatalogFeedForDisplay(posts, 'active'); + expect(result).toBe(posts); + expect(result.map((p) => p.cid)).toEqual(['c', 'a', 'b']); + }); + + it('returns original order for new sort', () => { + const posts: CatalogPost[] = [ + { cid: 'c', replyCount: 10 }, + { cid: 'a', replyCount: 100 }, + { cid: 'b', replyCount: 50 }, + ]; + const result = sortCatalogFeedForDisplay(posts, 'new'); + expect(result).toBe(posts); + expect(result.map((p) => p.cid)).toEqual(['c', 'a', 'b']); + }); + }); +}); diff --git a/src/lib/utils/catalog-sort.ts b/src/lib/utils/catalog-sort.ts new file mode 100644 index 00000000..d26fe2b5 --- /dev/null +++ b/src/lib/utils/catalog-sort.ts @@ -0,0 +1,62 @@ +/** + * Catalog feed sorting utility for deterministic and testable behavior. + * Used when displaying catalog feeds with replyCount sort. + */ + +/** Minimal post shape for catalog sorting */ +export interface CatalogPost { + cid?: string | null; + pinned?: boolean; + replyCount?: number | null; + lastReplyTimestamp?: number | null; + timestamp?: number | null; +} + +/** Sort types supported by the catalog feed */ +export type CatalogSortType = 'active' | 'new' | 'replyCount'; + +/** + * Sort catalog feed for display. + * - For 'replyCount': deterministic sort by replyCount desc, bump order, timestamp, cid. + * - For 'active' | 'new': returns input order unchanged (no extra sort cost). + */ +export function sortCatalogFeedForDisplay(posts: T[], sortType: CatalogSortType): T[] { + if (sortType === 'active' || sortType === 'new') { + return posts; + } + + if (sortType !== 'replyCount') { + return posts; + } + + const pinned: T[] = []; + const unpinned: T[] = []; + + for (const post of posts) { + if (post.pinned) { + pinned.push(post); + } else { + unpinned.push(post); + } + } + + unpinned.sort((a, b) => { + const rcA = a.replyCount ?? 0; + const rcB = b.replyCount ?? 0; + if (rcB !== rcA) return rcB - rcA; + + const bumpA = a.lastReplyTimestamp ?? a.timestamp ?? 0; + const bumpB = b.lastReplyTimestamp ?? b.timestamp ?? 0; + if (bumpB !== bumpA) return bumpB - bumpA; + + const tsA = a.timestamp ?? 0; + const tsB = b.timestamp ?? 0; + if (tsB !== tsA) return tsB - tsA; + + const cidA = a.cid ?? ''; + const cidB = b.cid ?? ''; + return cidA.localeCompare(cidB); + }); + + return [...pinned, ...unpinned]; +} diff --git a/src/stores/use-sorting-store.ts b/src/stores/use-sorting-store.ts index c279258d..adce36dd 100644 --- a/src/stores/use-sorting-store.ts +++ b/src/stores/use-sorting-store.ts @@ -1,8 +1,11 @@ import { create } from 'zustand'; +/** Catalog-only sort state. Used by catalog view sort selector. */ +export type CatalogSortType = 'active' | 'new' | 'replyCount'; + interface SortingStore { - sortType: 'active' | 'new'; - setSortType: (type: 'active' | 'new') => void; + sortType: CatalogSortType; + setSortType: (type: CatalogSortType) => void; } const useSortingStore = create((set) => ({ diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx index b406c59a..277d9c7c 100644 --- a/src/views/board/board.tsx +++ b/src/views/board/board.tsx @@ -11,7 +11,6 @@ import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-director import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; import { useFeedStateString } from '../../hooks/use-state-string'; import useFeedResetStore from '../../stores/use-feed-reset-store'; -import useSortingStore from '../../stores/use-sorting-store'; import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store'; import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size'; import { getPageSlice } from '../../lib/utils/board-feed-pagination'; @@ -24,6 +23,9 @@ import { Post } from '../post'; const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; +/** Board feed always uses 'active' sort; catalog dropdown does not affect board ordering. */ +const BOARD_SORT_TYPE = 'active' as const; + interface BoardFooterProps { subplebbitAddresses: string[]; hasMore: boolean; @@ -145,8 +147,6 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i return [subplebbitAddress]; }, [isInAllView, isInSubscriptionsView, isInModView, subplebbitAddress, directoryAddresses, filteredDirectoryAddresses, subscriptions, accountSubplebbitAddresses]); - const { sortType } = useSortingStore(); - const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll); const isForcedInfiniteScroll = isInAllView || isInSubscriptionsView || isInModView; const effectiveInfiniteScroll = enableInfiniteScroll || isForcedInfiniteScroll; @@ -156,16 +156,16 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i const feedOptions = useMemo( () => ({ subplebbitAddresses, - sortType, + sortType: BOARD_SORT_TYPE, postsPerPage: effectiveInfiniteScroll ? infiniteFeedPostsPerPage : paginationFeedPostsPerPage, }), - [subplebbitAddresses, sortType, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage], + [subplebbitAddresses, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage], ); const { feed, hasMore, loadMore, reset, subplebbitAddressesWithNewerPosts } = useFeed(feedOptions); const { accountComments } = useAccountComments(); - const feedContextKey = `${isInAllView ? 'all' : isInSubscriptionsView ? 'subs' : isInModView ? 'mod' : (subplebbitAddress ?? 'board')}-${sortType}-${viewType ?? 'board'}-${effectiveInfiniteScroll}`; + const feedContextKey = `${isInAllView ? 'all' : isInSubscriptionsView ? 'subs' : isInModView ? 'mod' : (subplebbitAddress ?? 'board')}-${BOARD_SORT_TYPE}-${viewType ?? 'board'}-${effectiveInfiniteScroll}`; const pathWithoutSettings = location.pathname.replace(/\/settings$/, ''); const currentPage = getPageFromFeedPath(pathWithoutSettings); const paginationBasePath = stripPageFromFeedPath(pathWithoutSettings); @@ -310,7 +310,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i ); const virtuosoRef = useRef(null); - const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}-${sortType}`; + const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}-${BOARD_SORT_TYPE}`; const navigationType = useNavigationType(); const hasBeenVisibleRef = useRef(false); diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index a2db9fd4..1615da70 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -23,6 +23,7 @@ import ErrorDisplay from '../../components/error-display/error-display'; import PageFooterDesktop from '../../components/page-footer-desktop'; import styles from './catalog.module.css'; import { commentMatchesPattern } from '../../lib/utils/pattern-utils'; +import { sortCatalogFeedForDisplay } from '../../lib/utils/catalog-sort'; const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; @@ -267,6 +268,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, }, [isInAllView, isInSubscriptionsView, isInModView, location.pathname, navigate]); const { sortType } = useSortingStore(); + const feedSortType = sortType === 'new' ? 'new' : 'active'; // Create a stable callback for filter matching const handleFilterMatch = useCallback((filterIndex: number, cid: string, subplebbitAddress: string) => { @@ -293,13 +295,13 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, return { subplebbitAddresses, - sortType, + sortType: feedSortType, postsPerPage: catalogPostsPerPage, filter: createCombinedFilter(filterItems, searchText, subplebbitAddress || 'all', handleFilterMatch), }; }, [ subplebbitAddresses, - sortType, + feedSortType, isInAllView, isInSubscriptionsView, isInModView, @@ -363,6 +365,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, [effectiveInfiniteScroll, combinedFeed, boardPostsPerPage, maxGuiPages], ); + const sortedFeed = useMemo(() => sortCatalogFeedForDisplay(cappedFeed, sortType), [cappedFeed, sortType]); + useEffect(() => { if (filteredComments.length > 0 && !resetTriggeredRef.current) { reset(); @@ -424,18 +428,18 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, const isFeedLoaded = feed.length > 0 || state === 'failed'; - // Process the feed to move "top" posts to the top + // Process the feed to move "top" posts to the top (applied after display sort) const processedFeed = useMemo(() => { - if (!cappedFeed || cappedFeed.length === 0) return cappedFeed; + if (!sortedFeed || sortedFeed.length === 0) return sortedFeed; const enabledTopFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '' && item.top); - if (enabledTopFilters.length === 0) return cappedFeed; + if (enabledTopFilters.length === 0) return sortedFeed; // Separate posts that match "top" filters const topPosts: Comment[] = []; const regularPosts: Comment[] = []; - cappedFeed.forEach((comment) => { + sortedFeed.forEach((comment) => { if (!comment) return; let isTop = false; @@ -455,7 +459,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, // Return top posts followed by regular posts return [...topPosts, ...regularPosts]; - }, [cappedFeed, filterItems]); + }, [sortedFeed, filterItems]); const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, subplebbit);