mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(catalog): add reply-count sorting mode
This commit is contained in:
@@ -198,7 +198,7 @@ const SortOptions = () => {
|
|||||||
const { sortType, setSortType } = useSortingStore();
|
const { sortType, setSortType } = useSortingStore();
|
||||||
|
|
||||||
const handleSortChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleSortChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const type = event.target.value as 'active' | 'new';
|
const type = event.target.value as 'active' | 'new' | 'replyCount';
|
||||||
setSortType(type);
|
setSortType(type);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
@@ -207,6 +207,7 @@ const SortOptions = () => {
|
|||||||
<select className='capitalize' value={sortType} onChange={handleSortChange}>
|
<select className='capitalize' value={sortType} onChange={handleSortChange}>
|
||||||
<option value='active'>{t('bump_order')}</option>
|
<option value='active'>{t('bump_order')}</option>
|
||||||
<option value='new'>{t('creation_date')}</option>
|
<option value='new'>{t('creation_date')}</option>
|
||||||
|
<option value='replyCount'>{t('reply_count')}</option>
|
||||||
</select>
|
</select>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T extends CatalogPost>(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];
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/** Catalog-only sort state. Used by catalog view sort selector. */
|
||||||
|
export type CatalogSortType = 'active' | 'new' | 'replyCount';
|
||||||
|
|
||||||
interface SortingStore {
|
interface SortingStore {
|
||||||
sortType: 'active' | 'new';
|
sortType: CatalogSortType;
|
||||||
setSortType: (type: 'active' | 'new') => void;
|
setSortType: (type: CatalogSortType) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useSortingStore = create<SortingStore>((set) => ({
|
const useSortingStore = create<SortingStore>((set) => ({
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-director
|
|||||||
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
||||||
import { useFeedStateString } from '../../hooks/use-state-string';
|
import { useFeedStateString } from '../../hooks/use-state-string';
|
||||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
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 useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store';
|
||||||
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
|
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
|
||||||
import { getPageSlice } from '../../lib/utils/board-feed-pagination';
|
import { getPageSlice } from '../../lib/utils/board-feed-pagination';
|
||||||
@@ -24,6 +23,9 @@ import { Post } from '../post';
|
|||||||
|
|
||||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
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 {
|
interface BoardFooterProps {
|
||||||
subplebbitAddresses: string[];
|
subplebbitAddresses: string[];
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
@@ -145,8 +147,6 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
|||||||
return [subplebbitAddress];
|
return [subplebbitAddress];
|
||||||
}, [isInAllView, isInSubscriptionsView, isInModView, subplebbitAddress, directoryAddresses, filteredDirectoryAddresses, subscriptions, accountSubplebbitAddresses]);
|
}, [isInAllView, isInSubscriptionsView, isInModView, subplebbitAddress, directoryAddresses, filteredDirectoryAddresses, subscriptions, accountSubplebbitAddresses]);
|
||||||
|
|
||||||
const { sortType } = useSortingStore();
|
|
||||||
|
|
||||||
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
|
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
|
||||||
const isForcedInfiniteScroll = isInAllView || isInSubscriptionsView || isInModView;
|
const isForcedInfiniteScroll = isInAllView || isInSubscriptionsView || isInModView;
|
||||||
const effectiveInfiniteScroll = enableInfiniteScroll || isForcedInfiniteScroll;
|
const effectiveInfiniteScroll = enableInfiniteScroll || isForcedInfiniteScroll;
|
||||||
@@ -156,16 +156,16 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
|||||||
const feedOptions = useMemo(
|
const feedOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
subplebbitAddresses,
|
subplebbitAddresses,
|
||||||
sortType,
|
sortType: BOARD_SORT_TYPE,
|
||||||
postsPerPage: effectiveInfiniteScroll ? infiniteFeedPostsPerPage : paginationFeedPostsPerPage,
|
postsPerPage: effectiveInfiniteScroll ? infiniteFeedPostsPerPage : paginationFeedPostsPerPage,
|
||||||
}),
|
}),
|
||||||
[subplebbitAddresses, sortType, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage],
|
[subplebbitAddresses, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { feed, hasMore, loadMore, reset, subplebbitAddressesWithNewerPosts } = useFeed(feedOptions);
|
const { feed, hasMore, loadMore, reset, subplebbitAddressesWithNewerPosts } = useFeed(feedOptions);
|
||||||
const { accountComments } = useAccountComments();
|
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 pathWithoutSettings = location.pathname.replace(/\/settings$/, '');
|
||||||
const currentPage = getPageFromFeedPath(pathWithoutSettings);
|
const currentPage = getPageFromFeedPath(pathWithoutSettings);
|
||||||
const paginationBasePath = stripPageFromFeedPath(pathWithoutSettings);
|
const paginationBasePath = stripPageFromFeedPath(pathWithoutSettings);
|
||||||
@@ -310,7 +310,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
|||||||
);
|
);
|
||||||
|
|
||||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
const virtuosoRef = useRef<VirtuosoHandle | null>(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 navigationType = useNavigationType();
|
||||||
|
|
||||||
const hasBeenVisibleRef = useRef(false);
|
const hasBeenVisibleRef = useRef(false);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import ErrorDisplay from '../../components/error-display/error-display';
|
|||||||
import PageFooterDesktop from '../../components/page-footer-desktop';
|
import PageFooterDesktop from '../../components/page-footer-desktop';
|
||||||
import styles from './catalog.module.css';
|
import styles from './catalog.module.css';
|
||||||
import { commentMatchesPattern } from '../../lib/utils/pattern-utils';
|
import { commentMatchesPattern } from '../../lib/utils/pattern-utils';
|
||||||
|
import { sortCatalogFeedForDisplay } from '../../lib/utils/catalog-sort';
|
||||||
|
|
||||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||||
|
|
||||||
@@ -267,6 +268,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
}, [isInAllView, isInSubscriptionsView, isInModView, location.pathname, navigate]);
|
}, [isInAllView, isInSubscriptionsView, isInModView, location.pathname, navigate]);
|
||||||
|
|
||||||
const { sortType } = useSortingStore();
|
const { sortType } = useSortingStore();
|
||||||
|
const feedSortType = sortType === 'new' ? 'new' : 'active';
|
||||||
|
|
||||||
// Create a stable callback for filter matching
|
// Create a stable callback for filter matching
|
||||||
const handleFilterMatch = useCallback((filterIndex: number, cid: string, subplebbitAddress: string) => {
|
const handleFilterMatch = useCallback((filterIndex: number, cid: string, subplebbitAddress: string) => {
|
||||||
@@ -293,13 +295,13 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
subplebbitAddresses,
|
subplebbitAddresses,
|
||||||
sortType,
|
sortType: feedSortType,
|
||||||
postsPerPage: catalogPostsPerPage,
|
postsPerPage: catalogPostsPerPage,
|
||||||
filter: createCombinedFilter(filterItems, searchText, subplebbitAddress || 'all', handleFilterMatch),
|
filter: createCombinedFilter(filterItems, searchText, subplebbitAddress || 'all', handleFilterMatch),
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
subplebbitAddresses,
|
subplebbitAddresses,
|
||||||
sortType,
|
feedSortType,
|
||||||
isInAllView,
|
isInAllView,
|
||||||
isInSubscriptionsView,
|
isInSubscriptionsView,
|
||||||
isInModView,
|
isInModView,
|
||||||
@@ -363,6 +365,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
[effectiveInfiniteScroll, combinedFeed, boardPostsPerPage, maxGuiPages],
|
[effectiveInfiniteScroll, combinedFeed, boardPostsPerPage, maxGuiPages],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sortedFeed = useMemo(() => sortCatalogFeedForDisplay(cappedFeed, sortType), [cappedFeed, sortType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filteredComments.length > 0 && !resetTriggeredRef.current) {
|
if (filteredComments.length > 0 && !resetTriggeredRef.current) {
|
||||||
reset();
|
reset();
|
||||||
@@ -424,18 +428,18 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
|
|
||||||
const isFeedLoaded = feed.length > 0 || state === 'failed';
|
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(() => {
|
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);
|
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
|
// Separate posts that match "top" filters
|
||||||
const topPosts: Comment[] = [];
|
const topPosts: Comment[] = [];
|
||||||
const regularPosts: Comment[] = [];
|
const regularPosts: Comment[] = [];
|
||||||
|
|
||||||
cappedFeed.forEach((comment) => {
|
sortedFeed.forEach((comment) => {
|
||||||
if (!comment) return;
|
if (!comment) return;
|
||||||
|
|
||||||
let isTop = false;
|
let isTop = false;
|
||||||
@@ -455,7 +459,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
|
|
||||||
// Return top posts followed by regular posts
|
// Return top posts followed by regular posts
|
||||||
return [...topPosts, ...regularPosts];
|
return [...topPosts, ...regularPosts];
|
||||||
}, [cappedFeed, filterItems]);
|
}, [sortedFeed, filterItems]);
|
||||||
|
|
||||||
const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, subplebbit);
|
const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, subplebbit);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user