From 7f58f8790a5d73384eeedad9915c69448ecc7674 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sun, 13 Sep 2026 18:27:21 +0200 Subject: [PATCH 1/4] fix(feeds): serve standard sorts from a complete preloaded page pkc-js preloads a single sort and only publishes pageCids once that page overflows, so a community or comment whose posts or replies all fit in the preloaded page publishes no other sort. Since 0.1.39 resolvePostSortType and resolveReplySortType only accepted published names, which left feeds empty for standard sorts on small communities and made clients fall back to the preloaded order, losing bump order. Treat a complete preloaded page (no nextCid, no pageCids) as serving every standard sort the feed sorter can compute, expose getPostPageSortType and getReplyPageSortType for the page that serves a sort, sort that page client-side in the feeds and replies stores, apply the time window of top timeframe sorts, and flatten flat reply sorts served from a hierarchical page. Custom sort names and paged records stay strict. --- README.md | 4 +- llms-full.txt | 4 +- src/hooks/feeds/feeds.test.ts | 37 ++++ src/hooks/feeds/feeds.ts | 3 +- src/hooks/replies.test.ts | 40 +++-- src/index.ts | 6 + src/lib/page-sorts.test.ts | 109 ++++++++++++ src/lib/page-sorts.ts | 167 +++++++++++++++--- .../communities-pages-store.test.ts | 20 +++ .../communities-pages-store.ts | 14 +- src/stores/feeds/utils.test.ts | 123 ++++++++++++- src/stores/feeds/utils.ts | 31 +++- .../replies-pages/replies-pages-store.ts | 13 +- src/stores/replies/replies-store.test.ts | 33 ++++ src/stores/replies/replies-store.ts | 6 +- src/stores/replies/utils.test.ts | 87 +++++++-- src/stores/replies/utils.ts | 16 +- 17 files changed, 635 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index dfccb543..1aa7a862 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ useBufferedFeeds({feedsOptions: UseFeedOptions[]}) // preload or buffer feeds in `useFeed().reset()` clears the current feed and refreshes the latest community snapshots before rebuilding it. `useFeed().expandTimeWindow(newerThan)` broadens `newerThan` in place without constructing a different sort name, so older posts can be appended without replacing the feed instance. -Feed and reply sort names are defined by each community record, not by a fixed hooks allowlist. Omit `sortType` to use the preloaded page, or discover the published names with `getAvailablePostSortTypes(community)` and `getAvailableReplySortTypes(comment)`. A requested sort that is not published is not silently replaced with another sort. Hooks preserve protocol page order for unknown custom sorts because their scoring algorithm is not available to the client. +Feed and reply sort names are defined by each community record, not by a fixed hooks allowlist. Omit `sortType` to use the preloaded page, or discover the requestable names with `getAvailablePostSortTypes(community)` and `getAvailableReplySortTypes(comment)`. pkc-js preloads a single sort and only publishes `pageCids` once that page overflows, so when every post or reply fits in the preloaded page the standard sorts (`hot`, `new`, `active` and `top*` for posts; `best`, `new`, `old`, `newFlat` and `oldFlat` for replies) are also requestable and are sorted client-side from that page, including the time window of `top*` timeframe sorts. `getPostPageSortType(community, sortType)` and `getReplyPageSortType(comment, sortType)` return the page that serves a sort. A requested sort that is neither published nor computable client-side is not silently replaced with another sort. Hooks preserve protocol page order for unknown custom sorts because their scoring algorithm is not available to the client. #### Actions Hooks @@ -237,6 +237,8 @@ getPreloadedPostSortType(community: Community): string | undefined getPreloadedReplySortType(comment: Comment): string | undefined resolvePostSortType(community: Community, requestedSortType?: string): string | undefined resolveReplySortType(comment: Comment, requestedSortType?: string): string | undefined +getPostPageSortType(community: Community, requestedSortType?: string): string | undefined // page sort serving the request, e.g. the preloaded page a single-page community re-sorts client-side +getReplyPageSortType(comment: Comment, requestedSortType?: string): string | undefined ``` `createCrosspost` requires a fully loaded comment with `comment.cid` and diff --git a/llms-full.txt b/llms-full.txt index 8f1dad0a..95e36522 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -202,7 +202,7 @@ useBufferedFeeds({feedsOptions: UseFeedOptions[]}) // preload or buffer feeds in `useFeed().reset()` clears the current feed and refreshes the latest community snapshots before rebuilding it. `useFeed().expandTimeWindow(newerThan)` broadens `newerThan` in place without constructing a different sort name, so older posts can be appended without replacing the feed instance. -Feed and reply sort names are defined by each community record, not by a fixed hooks allowlist. Omit `sortType` to use the preloaded page, or discover the published names with `getAvailablePostSortTypes(community)` and `getAvailableReplySortTypes(comment)`. A requested sort that is not published is not silently replaced with another sort. Hooks preserve protocol page order for unknown custom sorts because their scoring algorithm is not available to the client. +Feed and reply sort names are defined by each community record, not by a fixed hooks allowlist. Omit `sortType` to use the preloaded page, or discover the requestable names with `getAvailablePostSortTypes(community)` and `getAvailableReplySortTypes(comment)`. pkc-js preloads a single sort and only publishes `pageCids` once that page overflows, so when every post or reply fits in the preloaded page the standard sorts (`hot`, `new`, `active` and `top*` for posts; `best`, `new`, `old`, `newFlat` and `oldFlat` for replies) are also requestable and are sorted client-side from that page, including the time window of `top*` timeframe sorts. `getPostPageSortType(community, sortType)` and `getReplyPageSortType(comment, sortType)` return the page that serves a sort. A requested sort that is neither published nor computable client-side is not silently replaced with another sort. Hooks preserve protocol page order for unknown custom sorts because their scoring algorithm is not available to the client. #### Actions Hooks @@ -269,6 +269,8 @@ getPreloadedPostSortType(community: Community): string | undefined getPreloadedReplySortType(comment: Comment): string | undefined resolvePostSortType(community: Community, requestedSortType?: string): string | undefined resolveReplySortType(comment: Comment, requestedSortType?: string): string | undefined +getPostPageSortType(community: Community, requestedSortType?: string): string | undefined // page sort serving the request, e.g. the preloaded page a single-page community re-sorts client-side +getReplyPageSortType(comment: Comment, requestedSortType?: string): string | undefined ``` `createCrosspost` requires a fully loaded comment with `comment.cid` and diff --git a/src/hooks/feeds/feeds.test.ts b/src/hooks/feeds/feeds.test.ts index f36c8c59..fee39188 100644 --- a/src/hooks/feeds/feeds.test.ts +++ b/src/hooks/feeds/feeds.test.ts @@ -1440,6 +1440,43 @@ describe("feeds", () => { expect(rendered.result.current.feed).toEqual([]); }); + test("serves a standard sort client-side when every post fits in the preloaded page", async () => { + const simulateUpdateEvent = Community.prototype.simulateUpdateEvent; + Community.prototype.simulateUpdateEvent = async function () { + this.posts.pages = { + hot: { + comments: [ + { cid: "newer post", communityAddress: this.address, timestamp: 200, updatedAt: 200 }, + { + cid: "bumped post", + communityAddress: this.address, + timestamp: 100, + lastReplyTimestamp: 300, + updatedAt: 300, + }, + ], + }, + }; + this.posts.pageCids = {}; + this.updatedAt = 1; + this.updatingState = "succeeded"; + this.emit("update", this); + this.emit("updatingstatechange", "succeeded"); + }; + + try { + rendered.rerender({ communityAddresses: ["single page community"], sortType: "active" }); + await waitFor(() => rendered.result.current.feed.length === 2); + expect(rendered.result.current.feed.map((post: Comment) => post.cid)).toEqual([ + "bumped post", + "newer post", + ]); + expect(rendered.result.current.hasMore).toBe(false); + } finally { + Community.prototype.simulateUpdateEvent = simulateUpdateEvent; + } + }); + describe("getPage only has 1 page", () => { const getPage = Pages.prototype.getPage; diff --git a/src/hooks/feeds/feeds.ts b/src/hooks/feeds/feeds.ts index 9e75289b..816c7b6b 100644 --- a/src/hooks/feeds/feeds.ts +++ b/src/hooks/feeds/feeds.ts @@ -27,7 +27,8 @@ import { serializeFeedKey } from "../../lib/serialize-feed-key"; /** * @param communities - The communities to fetch, e.g. [{name: 'memes.eth'}, {publicKey: '12D3KooW...'}] - * @param sortType - A sort name published by the community. Omit it to use the preloaded sort. + * @param sortType - A sort name published by the community, or a standard sort computed client-side + * when every post fits in the preloaded page. Omit it to use the preloaded sort. * @param acountName - The nickname of the account, e.g. 'Account KoXpxTwfnjA5'. If no accountName is provided, use * the active account. */ diff --git a/src/hooks/replies.test.ts b/src/hooks/replies.test.ts index a68f957a..43722d23 100644 --- a/src/hooks/replies.test.ts +++ b/src/hooks/replies.test.ts @@ -901,7 +901,7 @@ describe("replies", () => { await testUtils.resetDatabasesAndStores(); }); - test("requested missing sorts do not use the single preloaded page", async () => { + test("requested standard sorts are served from the single preloaded page", async () => { const comment = { cid: "comment cid 1", postCid: "comment cid 1", @@ -912,7 +912,10 @@ describe("replies", () => { replies: { pages: { best: { - comments: [{ cid: "best reply", communityAddress: "sub", timestamp: 1, depth: 1 }], + comments: [ + { cid: "older reply", communityAddress: "sub", timestamp: 1, depth: 1 }, + { cid: "newer reply", communityAddress: "sub", timestamp: 2, depth: 1 }, + ], }, }, pageCids: {}, @@ -920,11 +923,27 @@ describe("replies", () => { }; rendered.rerender({ comment, sortType: "new" }); - await waitFor(() => rendered.result.current.hasMore === false); - expect(rendered.result.current.replies).toEqual([]); + await waitFor(() => rendered.result.current.replies.length === 2); + expect(rendered.result.current.replies.map((reply: any) => reply.cid)).toEqual([ + "newer reply", + "older reply", + ]); + expect(rendered.result.current.hasMore).toBe(false); rendered.rerender({ comment, sortType: "old" }); - await waitFor(() => rendered.result.current.hasMore === false); + await waitFor(() => rendered.result.current.replies[0]?.cid === "older reply"); + expect(rendered.result.current.replies.map((reply: any) => reply.cid)).toEqual([ + "older reply", + "newer reply", + ]); + expect(rendered.result.current.hasMore).toBe(false); + + // a custom sort the comment does not publish is still not substituted + rendered.rerender({ comment, sortType: "customSort" }); + await waitFor( + () => + rendered.result.current.hasMore === false && rendered.result.current.replies.length === 0, + ); expect(rendered.result.current.replies).toEqual([]); }); }); @@ -1589,7 +1608,7 @@ describe("replies", () => { expect(rendered.result.current.repliesDepth3.replies.length).toBeGreaterThan(0); }); - test("nested replies do not substitute best when new is requested", async () => { + test("nested replies serve new client-side from their complete preloaded best page", async () => { // mock nested replies on pages const pageToGet = Pages.prototype.pageToGet; Pages.prototype.pageToGet = function (pageCid) { @@ -1599,11 +1618,12 @@ describe("replies", () => { rendered.rerender({ commentCid: "comment cid 1", sortType: "new" }); - // as soon as depth 1 has replies, all other depths also should - await waitFor(() => rendered.result.current.repliesDepth1.replies.length > 0); + // nested replies only preload a complete 'best' page, which also serves 'new' + await waitFor(() => rendered.result.current.repliesDepth3.replies.length > 0); expect(rendered.result.current.repliesDepth1.replies.length).toBeGreaterThan(0); - expect(rendered.result.current.repliesDepth2.replies.length).toBe(0); - expect(rendered.result.current.repliesDepth3.replies.length).toBe(0); + expect(rendered.result.current.repliesDepth2.replies.length).toBeGreaterThan(0); + expect(rendered.result.current.repliesDepth3.replies.length).toBeGreaterThan(0); + expect(rendered.result.current.repliesDepth2.replies[0].cid).toMatch("nested 1"); Pages.prototype.pageToGet = pageToGet; }); diff --git a/src/index.ts b/src/index.ts index 18b96eec..7729939e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,6 +94,8 @@ import { getAvailableReplySortTypes, getPreloadedPostSortType, getPreloadedReplySortType, + getPostPageSortType, + getReplyPageSortType, resolvePostSortType, resolveReplySortType, } from "./lib/page-sorts"; @@ -179,6 +181,8 @@ export { getAvailableReplySortTypes, getPreloadedPostSortType, getPreloadedReplySortType, + getPostPageSortType, + getReplyPageSortType, resolvePostSortType, resolveReplySortType, }; @@ -261,6 +265,8 @@ const hooks = { getAvailableReplySortTypes, getPreloadedPostSortType, getPreloadedReplySortType, + getPostPageSortType, + getReplyPageSortType, resolvePostSortType, resolveReplySortType, }; diff --git a/src/lib/page-sorts.test.ts b/src/lib/page-sorts.test.ts index ee3f849b..cf8350bc 100644 --- a/src/lib/page-sorts.test.ts +++ b/src/lib/page-sorts.test.ts @@ -1,8 +1,12 @@ +import feedSorter from "../stores/feeds/feed-sorter"; import { getAvailablePostSortTypes, getAvailableReplySortTypes, + getPostPageSortType, getPreloadedPostSortType, getPreloadedReplySortType, + getReplyPageSortType, + getSortTimeframeSeconds, resolvePostSortType, resolveReplySortType, } from "./page-sorts"; @@ -36,6 +40,14 @@ describe("page sort helpers", () => { test("returns undefined instead of substituting a missing requested sort", () => { expect(resolvePostSortType(community as any, "missing")).toBeUndefined(); expect(resolveReplySortType(comment as any, "missing")).toBeUndefined(); + expect(getPostPageSortType(community as any, "missing")).toBeUndefined(); + expect(getReplyPageSortType(comment as any, "missing")).toBeUndefined(); + }); + + test("reads the published page of a requested sort", () => { + expect(getPostPageSortType(community as any, "newest")).toBe("newest"); + expect(getPostPageSortType(community as any)).toBe("sage"); + expect(getReplyPageSortType(comment as any, "nestedNewest")).toBe("nestedNewest"); }); test("falls back to the first pageCid only when no preloaded page exists", () => { @@ -45,4 +57,101 @@ describe("page sort helpers", () => { expect(getAvailablePostSortTypes()).toEqual([]); expect(getAvailableReplySortTypes()).toEqual([]); }); + + describe("complete preloaded pages", () => { + const singlePageCommunity = { posts: { pages: { hot: { comments: [{ cid: "post" }] } } } }; + const singlePageComment = { + replies: { pages: { best: { comments: [{ cid: "reply" }] } }, pageCids: {} }, + }; + + test("serves every standard sort from a complete preloaded page", () => { + expect(getAvailablePostSortTypes(singlePageCommunity as any)).toEqual([ + "hot", + "new", + "active", + "topHour", + "topDay", + "topWeek", + "topMonth", + "topYear", + "topAll", + ]); + expect(getAvailableReplySortTypes(singlePageComment as any)).toEqual([ + "best", + "new", + "old", + "newFlat", + "oldFlat", + ]); + expect(resolvePostSortType(singlePageCommunity as any, "active")).toBe("active"); + expect(getPostPageSortType(singlePageCommunity as any, "active")).toBe("hot"); + expect(getPostPageSortType(singlePageCommunity as any, "hot")).toBe("hot"); + expect(resolveReplySortType(singlePageComment as any, "old")).toBe("old"); + expect(getReplyPageSortType(singlePageComment as any, "newFlat")).toBe("best"); + expect(getPreloadedPostSortType(singlePageCommunity as any)).toBe("hot"); + expect(resolvePostSortType(singlePageCommunity as any)).toBe("hot"); + }); + + test("keeps custom sorts unavailable", () => { + expect(resolvePostSortType(singlePageCommunity as any, "sage")).toBeUndefined(); + expect(getPostPageSortType(singlePageCommunity as any, "sage")).toBeUndefined(); + expect(resolveReplySortType(singlePageComment as any, "chronological")).toBeUndefined(); + }); + + test("stays strict once a page continues or pageCids are published", () => { + const pagedCommunity = { posts: { pages: { hot: { comments: [], nextCid: "hot-next" } } } }; + const cidsCommunity = { + posts: { pages: { hot: { comments: [] } }, pageCids: { new: "new-cid" } }, + }; + expect(getAvailablePostSortTypes(pagedCommunity as any)).toEqual(["hot"]); + expect(resolvePostSortType(pagedCommunity as any, "active")).toBeUndefined(); + expect(getAvailablePostSortTypes(cidsCommunity as any)).toEqual(["hot", "new"]); + expect(getPostPageSortType(cidsCommunity as any, "new")).toBe("new"); + expect(getPostPageSortType(cidsCommunity as any, "active")).toBeUndefined(); + expect(getAvailablePostSortTypes({ posts: { pages: {} } } as any)).toEqual([]); + }); + + test("does not advertise flat sorts for a nested reply", () => { + const nestedReply = { + depth: 1, + parentCid: "post-cid", + replies: { pages: { best: { comments: [{ cid: "nested" }] } } }, + }; + expect(getAvailableReplySortTypes(nestedReply as any)).toEqual(["best", "new", "old"]); + expect(resolveReplySortType(nestedReply as any, "old")).toBe("old"); + expect(resolveReplySortType(nestedReply as any, "newFlat")).toBeUndefined(); + expect(getReplyPageSortType(nestedReply as any, "newFlat")).toBeUndefined(); + }); + + test("only serves flat sorts from a flat preloaded page", () => { + const flatComment = { replies: { pages: { newFlat: { comments: [] } } } }; + expect(getAvailableReplySortTypes(flatComment as any)).toEqual(["newFlat", "oldFlat"]); + expect(getReplyPageSortType(flatComment as any, "oldFlat")).toBe("newFlat"); + expect(resolveReplySortType(flatComment as any, "best")).toBeUndefined(); + }); + + test("every client-served sort has a client sorter", () => { + const feed = [ + { cid: "a", timestamp: 1, upvoteCount: 0, downvoteCount: 0 }, + { cid: "b", timestamp: 2, upvoteCount: 1, downvoteCount: 0 }, + ]; + const sortTypes = [ + ...getAvailablePostSortTypes(singlePageCommunity as any), + ...getAvailableReplySortTypes(singlePageComment as any), + ]; + for (const sortType of sortTypes) { + // the sorter returns the same array only for sort names it cannot compute + expect(feedSorter.sort(sortType, feed)).not.toBe(feed); + } + }); + + test("knows the time window of timeframe sorts", () => { + expect(getSortTimeframeSeconds("topHour")).toBe(3600); + expect(getSortTimeframeSeconds("topWeek")).toBe(604800); + expect(getSortTimeframeSeconds("controversialDay")).toBe(86400); + expect(getSortTimeframeSeconds("topAll")).toBeUndefined(); + expect(getSortTimeframeSeconds("active")).toBeUndefined(); + expect(getSortTimeframeSeconds(undefined)).toBeUndefined(); + }); + }); }); diff --git a/src/lib/page-sorts.ts b/src/lib/page-sorts.ts index f1182bd2..0385e395 100644 --- a/src/lib/page-sorts.ts +++ b/src/lib/page-sorts.ts @@ -5,7 +5,51 @@ type PagesRecord = { pageCids?: Record; }; -const getAvailablePageSortTypes = (record?: PagesRecord): string[] => { +type PreloadedPage = { comments?: unknown; nextCid?: string } | undefined; + +// Standard pkc-js sorts (pkc-js src/pages/util.ts) whose scoring the feed sorter reproduces. pkc-js +// preloads a single sort and only publishes pageCids once that page overflows, so a record whose +// comments all fit in the preloaded page can serve any of these by re-sorting that page client-side. +const CLIENT_SORTABLE_POST_SORT_TYPES = [ + "hot", + "new", + "active", + "topHour", + "topDay", + "topWeek", + "topMonth", + "topYear", + "topAll", +]; +const CLIENT_SORTABLE_REPLY_SORT_TYPES = ["best", "new", "old", "newFlat", "oldFlat"]; + +// pkc-js only publishes flat sorts for a post's replies (REPLY_REPLIES_SORT_TYPES has none), so a +// nested reply must not advertise them while its replies still fit in the preloaded page +const getClientSortableReplySortTypes = (comment?: Comment): string[] => { + const isPost = comment?.depth !== undefined ? comment.depth === 0 : !comment?.parentCid; + return isPost + ? CLIENT_SORTABLE_REPLY_SORT_TYPES + : CLIENT_SORTABLE_REPLY_SORT_TYPES.filter((sortType) => !isFlatSortType(sortType)); +}; + +// pkc-js TIMEFRAMES_TO_SECONDS, applied client-side when a timeframe sort is computed from a +// preloaded page instead of a page the community windowed itself +const SORT_TIMEFRAMES_SECONDS: Record = { + Hour: 3600, + Day: 86400, + Week: 604800, + Month: 2629746, + Year: 31557600, +}; + +export const isFlatSortType = (sortType?: string): boolean => Boolean(sortType?.endsWith("Flat")); + +export const getSortTimeframeSeconds = (sortType?: string): number | undefined => { + const timeframe = sortType?.match(/^(?:top|controversial)(Hour|Day|Week|Month|Year)$/)?.[1]; + return timeframe ? SORT_TIMEFRAMES_SECONDS[timeframe] : undefined; +}; + +const getPublishedPageSortTypes = (record?: PagesRecord): string[] => { const sortTypes = new Set(); for (const sortType of Object.keys(record?.pages || {})) { if (sortType) sortTypes.add(sortType); @@ -16,16 +60,93 @@ const getAvailablePageSortTypes = (record?: PagesRecord): string[] => { return [...sortTypes]; }; +// preloaded pages holding the record's complete comment set: no page continues with a nextCid and +// no pageCids are published (pkc-js publishes pageCids for every sort once the preloaded page overflows) +const getCompletePreloadedPageSortTypes = (record?: PagesRecord): string[] => { + if (Object.keys(record?.pageCids || {}).length > 0) { + return []; + } + const pages = record?.pages || {}; + const sortTypes = Object.keys(pages).filter((sortType) => + Array.isArray((pages[sortType] as PreloadedPage)?.comments), + ); + if ( + !sortTypes.length || + sortTypes.some((sortType) => (pages[sortType] as PreloadedPage)?.nextCid) + ) { + return []; + } + return sortTypes; +}; + +// a flat page cannot rebuild the reply tree, so it only serves flat sorts +const getClientSortableSortTypes = ( + record: PagesRecord | undefined, + clientSortableSortTypes: string[], +): string[] => { + const completeSortTypes = getCompletePreloadedPageSortTypes(record); + if (!completeSortTypes.length) { + return []; + } + if (completeSortTypes.some((sortType) => !isFlatSortType(sortType))) { + return clientSortableSortTypes; + } + return clientSortableSortTypes.filter(isFlatSortType); +}; + +const getAvailablePageSortTypes = ( + record: PagesRecord | undefined, + clientSortableSortTypes: string[], +): string[] => { + const sortTypes = getPublishedPageSortTypes(record); + for (const sortType of getClientSortableSortTypes(record, clientSortableSortTypes)) { + if (!sortTypes.includes(sortType)) sortTypes.push(sortType); + } + return sortTypes; +}; + const getPreloadedPageSortType = (record?: PagesRecord): string | undefined => { const preloadedSortType = Object.keys(record?.pages || {}).find(Boolean); - return preloadedSortType || getAvailablePageSortTypes(record)[0]; + return preloadedSortType || getPublishedPageSortTypes(record)[0]; +}; + +const resolvePageSortType = ( + record: PagesRecord | undefined, + requestedSortType: string | undefined, + clientSortableSortTypes: string[], +): string | undefined => { + if (requestedSortType !== undefined) { + return getAvailablePageSortTypes(record, clientSortableSortTypes).includes(requestedSortType) + ? requestedSortType + : undefined; + } + return getPreloadedPageSortType(record); +}; + +// the page that serves a resolved sort: its published page, or the complete preloaded page the +// client re-sorts (a flat sort can also be flattened from a hierarchical page) +const getPageSortTypeToRead = ( + record: PagesRecord | undefined, + sortType: string | undefined, +): string | undefined => { + if (sortType === undefined) { + return undefined; + } + if (getPublishedPageSortTypes(record).includes(sortType)) { + return sortType; + } + const completeSortTypes = getCompletePreloadedPageSortTypes(record); + return ( + completeSortTypes.find((preloadedSortType) => !isFlatSortType(preloadedSortType)) ?? + (isFlatSortType(sortType) ? completeSortTypes[0] : undefined) + ); }; export const getAvailablePostSortTypes = (community?: Community): string[] => - getAvailablePageSortTypes(community?.posts); + getAvailablePageSortTypes(community?.posts, CLIENT_SORTABLE_POST_SORT_TYPES); export const getAvailableReplySortTypes = (comment?: Comment): string[] => - getAvailablePageSortTypes(comment?.replies); + getAvailablePageSortTypes(comment?.replies, getClientSortableReplySortTypes(comment)); export const getPreloadedPostSortType = (community?: Community): string | undefined => getPreloadedPageSortType(community?.posts); @@ -36,23 +157,29 @@ export const getPreloadedReplySortType = (comment?: Comment): string | undefined export const resolvePostSortType = ( community: Community | undefined, requestedSortType?: string, -): string | undefined => { - if (requestedSortType !== undefined) { - return getAvailablePostSortTypes(community).includes(requestedSortType) - ? requestedSortType - : undefined; - } - return getPreloadedPostSortType(community); -}; +): string | undefined => + resolvePageSortType(community?.posts, requestedSortType, CLIENT_SORTABLE_POST_SORT_TYPES); export const resolveReplySortType = ( comment: Comment | undefined, requestedSortType?: string, -): string | undefined => { - if (requestedSortType !== undefined) { - return getAvailableReplySortTypes(comment).includes(requestedSortType) - ? requestedSortType - : undefined; - } - return getPreloadedReplySortType(comment); -}; +): string | undefined => + resolvePageSortType( + comment?.replies, + requestedSortType, + getClientSortableReplySortTypes(comment), + ); + +// the `community.posts` page sort that serves a request, e.g. the preloaded `hot` page when a +// single-page community serves `active` client-side; undefined when the sort cannot be served +export const getPostPageSortType = ( + community: Community | undefined, + requestedSortType?: string, +): string | undefined => + getPageSortTypeToRead(community?.posts, resolvePostSortType(community, requestedSortType)); + +export const getReplyPageSortType = ( + comment: Comment | undefined, + requestedSortType?: string, +): string | undefined => + getPageSortTypeToRead(comment?.replies, resolveReplySortType(comment, requestedSortType)); diff --git a/src/stores/communities-pages/communities-pages-store.test.ts b/src/stores/communities-pages/communities-pages-store.test.ts index 50e55865..67485d20 100644 --- a/src/stores/communities-pages/communities-pages-store.test.ts +++ b/src/stores/communities-pages/communities-pages-store.test.ts @@ -413,6 +413,26 @@ describe("communities pages store", () => { ); }); + test("getCommunityFirstPageCid reads the complete preloaded page for a client-served sort", () => { + const community = { + address: "addr", + posts: { pages: { hot: { comments: [{ cid: "c1" }] } } }, + }; + // 'active' is served from the preloaded hot page, which has no next page + expect(getCommunityFirstPageCid(community as any, "active", "posts")).toBeUndefined(); + + const pagedCommunity = { + address: "addr", + posts: { + pages: { hot: { nextCid: "hot-next", comments: [{ cid: "c1" }] } }, + pageCids: { active: "active-first-page" }, + }, + }; + expect(getCommunityFirstPageCid(pagedCommunity as any, "active", "posts")).toBe( + "active-first-page", + ); + }); + test("getCommunityFirstPageCid defaults pageType to posts", () => { const community = { address: "addr", diff --git a/src/stores/communities-pages/communities-pages-store.ts b/src/stores/communities-pages/communities-pages-store.ts index dea1f848..c1ca00bb 100644 --- a/src/stores/communities-pages/communities-pages-store.ts +++ b/src/stores/communities-pages/communities-pages-store.ts @@ -20,7 +20,7 @@ import { getPkcCreateCommunity, normalizeCommentCommunityAddress, } from "../../lib/pkc-compat"; -import { resolvePostSortType } from "../../lib/page-sorts"; +import { getPostPageSortType } from "../../lib/page-sorts"; const communitiesPagesDatabase = localForageLru.createInstance({ name: "bitsocialReactHooks-communitiesPages", @@ -416,16 +416,16 @@ export const getCommunityFirstPageCid = ( sortType === undefined || (typeof sortType === "string" && sortType.length > 0), `getCommunityFirstPageCid sortType '${sortType}' invalid`, ); - const resolvedSortType = - pageType === "posts" ? resolvePostSortType(community, sortType) : sortType; - if (!resolvedSortType) { + // posts served client-side from a complete preloaded page read that page instead of their own + const pageSortType = pageType === "posts" ? getPostPageSortType(community, sortType) : sortType; + if (!pageSortType) { return; } // community has preloaded posts for sort type - if (community[pageType]?.pages?.[resolvedSortType]?.comments) { - return community[pageType]?.pages?.[resolvedSortType]?.nextCid; + if (community[pageType]?.pages?.[pageSortType]?.comments) { + return community[pageType]?.pages?.[pageSortType]?.nextCid; } - return community[pageType]?.pageCids?.[resolvedSortType]; + return community[pageType]?.pageCids?.[pageSortType]; // TODO: if a loaded community doesn't have a first page, it's unclear what we should do // should we try to use another sort type by default, like 'hot', or should we just ignore it? diff --git a/src/stores/feeds/utils.test.ts b/src/stores/feeds/utils.test.ts index 068f3d0c..75c16061 100644 --- a/src/stores/feeds/utils.test.ts +++ b/src/stores/feeds/utils.test.ts @@ -505,32 +505,141 @@ describe("feeds utils", () => { expect(feeds.feed1).toEqual([]); }); - test("does not substitute a different single-page sort", () => { - const feedComment = { - cid: "fallback-cid", - communityAddress: "sub1", - timestamp: 1, - }; + test("sorts a complete preloaded page client-side for a standard sort", () => { const communities = { sub1: { address: "sub1", updatedAt: 1, posts: { pages: { - otherSort: { comments: [feedComment], nextCid: undefined }, + hot: { + comments: [ + { cid: "newer-post", communityAddress: "sub1", timestamp: 200 }, + { + cid: "bumped-post", + communityAddress: "sub1", + timestamp: 100, + lastReplyTimestamp: 300, + }, + ], + }, }, }, }, }; const feedsOptions = { feed1: { + communities: toCommunities(["sub1"]), + sortType: "active", + accountId: mockAccountId, + }, + feed2: { communities: toCommunities(["sub1"]), sortType: "new", accountId: mockAccountId, }, }; const feeds = getFilteredSortedFeeds(feedsOptions, communities, {}, makeMockAccounts()); + expect(feeds.feed1.map((post: any) => post.cid)).toEqual(["bumped-post", "newer-post"]); + expect(feeds.feed2.map((post: any) => post.cid)).toEqual(["newer-post", "bumped-post"]); + expect( + getFeedsHaveMore( + feedsOptions, + { feed1: [], feed2: [] }, + communities, + {}, + makeMockAccounts(), + ), + ).toEqual({ feed1: false, feed2: false }); + }); + + test("does not substitute a preloaded page for a custom sort", () => { + const communities = { + sub1: { + address: "sub1", + updatedAt: 1, + posts: { + pages: { + hot: { + comments: [{ cid: "fallback-cid", communityAddress: "sub1", timestamp: 1 }], + }, + }, + }, + }, + }; + const feedsOptions = { + feed1: { + communities: toCommunities(["sub1"]), + sortType: "customSort", + accountId: mockAccountId, + }, + }; + const feeds = getFilteredSortedFeeds(feedsOptions, communities, {}, makeMockAccounts()); expect(feeds.feed1).toEqual([]); + expect( + getFeedsHaveMore(feedsOptions, { feed1: [] }, communities, {}, makeMockAccounts()), + ).toEqual({ + feed1: false, + }); + }); + + test("applies the time window when computing a timeframe sort from the preloaded page", () => { + const now = Math.floor(Date.now() / 1000); + const communities = { + sub1: { + address: "sub1", + updatedAt: 1, + posts: { + pages: { + hot: { + comments: [ + { + cid: "old-top", + communityAddress: "sub1", + timestamp: now - 2 * 86400, + upvoteCount: 50, + downvoteCount: 0, + }, + { + cid: "recent", + communityAddress: "sub1", + timestamp: now - 60, + upvoteCount: 1, + downvoteCount: 0, + }, + { + cid: "pinned-old", + communityAddress: "sub1", + timestamp: now - 3 * 86400, + upvoteCount: 0, + downvoteCount: 0, + pinned: true, + }, + ], + }, + }, + }, + }, + }; + const feedsOptions = { + topDay: { + communities: toCommunities(["sub1"]), + sortType: "topDay", + accountId: mockAccountId, + }, + topAll: { + communities: toCommunities(["sub1"]), + sortType: "topAll", + accountId: mockAccountId, + }, + }; + const feeds = getFilteredSortedFeeds(feedsOptions, communities, {}, makeMockAccounts()); + expect(feeds.topDay.map((post: any) => post.cid)).toEqual(["pinned-old", "recent"]); + expect(feeds.topAll.map((post: any) => post.cid)).toEqual([ + "pinned-old", + "old-top", + "recent", + ]); }); test("uses the resolved default active sort when filtering by time", () => { diff --git a/src/stores/feeds/utils.ts b/src/stores/feeds/utils.ts index bf907794..159b3be1 100644 --- a/src/stores/feeds/utils.ts +++ b/src/stores/feeds/utils.ts @@ -25,7 +25,11 @@ import { getMatchingCommunityRefKeys, } from "../../lib/community-ref"; import Logger from "@pkcprotocol/pkc-logger"; -import { resolvePostSortType } from "../../lib/page-sorts"; +import { + getPostPageSortType, + getSortTimeframeSeconds, + resolvePostSortType, +} from "../../lib/page-sorts"; const log = Logger("bitsocial-react-hooks:feeds:stores"); const getFeedCommunityRefs = (feedOptions: Partial): CommunityLookupRef[] => @@ -174,6 +178,7 @@ export const getFilteredSortedFeeds = ( // use community preloaded posts if any const preloadedPosts = getPreloadedPosts(community, sortType); if (preloadedPosts) { + const clientTimeframeTimestamp = getClientSortTimeframeTimestamp(community, sortType); for (const post of preloadedPosts) { // posts are manually validated, could have fake communityAddress if ( @@ -181,6 +186,14 @@ export const getFilteredSortedFeeds = ( ) { break; } + // like the pages a community windows itself, pinned posts stay regardless of age + if ( + clientTimeframeTimestamp !== undefined && + !post.pinned && + post.timestamp <= clientTimeframeTimestamp + ) { + continue; + } const nextPost = getFeedPost(post, communityRef, community, modQueue, freshestComments); if (nextPost) { bufferedFeedPosts.push(nextPost); @@ -277,11 +290,21 @@ export const getFilteredSortedFeeds = ( }; const getPreloadedPosts = (community: Community, sortType?: string) => { - const resolvedSortType = resolvePostSortType(community, sortType); - if (!resolvedSortType) { + const pageSortType = getPostPageSortType(community, sortType); + if (!pageSortType) { + return; + } + return community.posts?.pages?.[pageSortType]?.comments; +}; + +// a timeframe sort computed client-side from a complete preloaded page applies its own window, +// like the pages a community publishes for that sort would +const getClientSortTimeframeTimestamp = (community: Community, sortType?: string) => { + const timeframeSeconds = getSortTimeframeSeconds(sortType); + if (!timeframeSeconds || getPostPageSortType(community, sortType) === sortType) { return; } - return community.posts?.pages?.[resolvedSortType]?.comments; + return Math.floor(Date.now() / 1000) - timeframeSeconds; }; export const getLoadedFeeds = async ( diff --git a/src/stores/replies-pages/replies-pages-store.ts b/src/stores/replies-pages/replies-pages-store.ts index 1f897fef..4e948879 100644 --- a/src/stores/replies-pages/replies-pages-store.ts +++ b/src/stores/replies-pages/replies-pages-store.ts @@ -9,7 +9,7 @@ import { addChildrenRepliesFeedsToAddToStore } from "./utils"; import localForageLru from "../../lib/localforage-lru"; import createStore from "zustand"; import assert from "assert"; -import { resolveReplySortType } from "../../lib/page-sorts"; +import { getReplyPageSortType, resolveReplySortType } from "../../lib/page-sorts"; const repliesPagesDatabase = localForageLru.createInstance({ name: "bitsocialReactHooks-repliesPages", @@ -305,15 +305,16 @@ export const getRepliesFirstPageCid = (comment: Comment, sortType?: string) => { sortType === undefined || (typeof sortType === "string" && sortType.length > 0), `getRepliesFirstPageCid sortType '${sortType}' invalid`, ); - const resolvedSortType = resolveReplySortType(comment, sortType); - if (!resolvedSortType) { + // replies served client-side from a complete preloaded page read that page instead of their own + const pageSortType = getReplyPageSortType(comment, sortType); + if (!pageSortType) { return; } // comment has preloaded replies for sort type - if (comment.replies?.pages?.[resolvedSortType]?.comments) { - return comment.replies?.pages?.[resolvedSortType]?.nextCid; + if (comment.replies?.pages?.[pageSortType]?.comments) { + return comment.replies?.pages?.[pageSortType]?.nextCid; } - return comment.replies?.pageCids?.[resolvedSortType]; + return comment.replies?.pageCids?.[pageSortType]; // TODO: if a loaded comment doesn't have a first page, it's unclear what we should do // should we try to use another sort type by default, like 'best', or should we just ignore it? diff --git a/src/stores/replies/replies-store.test.ts b/src/stores/replies/replies-store.test.ts index 1be727db..2434e300 100644 --- a/src/stores/replies/replies-store.test.ts +++ b/src/stores/replies/replies-store.test.ts @@ -368,6 +368,39 @@ describe("replies store", () => { expect(feedsForComment[0]).toContain(commentCid); }); + test("addFeedToStoreOrUpdateComment registers nested feeds through the page serving a client-side sort", async () => { + const commentCid = "client-sort-feed-unique-cid"; + const nestedCid = "nested-reply-client-sort-cid"; + const comment = new MockComment({ cid: commentCid }); + // the replies all fit in the preloaded best page, which also serves 'new' + (comment as any).replies = { + pages: { + best: { + comments: [{ cid: nestedCid, replies: { pages: {} }, depth: 1 }], + }, + }, + }; + + act(() => { + rendered.result.current.addFeedToStoreOrUpdateComment(comment, { + sortType: "new", + commentCid, + accountId: mockAccount.id, + }); + }); + const feedName = feedOptionsToFeedName({ + sortType: "new", + commentCid, + accountId: mockAccount.id, + }); + await waitFor(() => rendered.result.current.feedsOptions[feedName]); + + const feedsForNestedReply = Object.keys(rendered.result.current.feedsOptions).filter((fn) => + fn.includes(nestedCid), + ); + expect(feedsForNestedReply).toHaveLength(1); + }); + test("resetFeed resets page to 1 and clears loaded/updated", async () => { const commentCid = "reset-feed-cid"; const feedOptions = { sortType: "new", commentCid, accountId: mockAccount.id }; diff --git a/src/stores/replies/replies-store.ts b/src/stores/replies/replies-store.ts index cb9c1d3b..4c1bdb1b 100644 --- a/src/stores/replies/replies-store.ts +++ b/src/stores/replies/replies-store.ts @@ -18,6 +18,7 @@ import accountsStore from "../accounts"; import repliesCommentsStore from "./replies-comments-store"; import repliesPagesStore from "../replies-pages"; import { serializeFeedKey } from "../../lib/serialize-feed-key"; +import { getReplyPageSortType } from "../../lib/page-sorts"; import { getFeedsCommentsFirstPageCids, getLoadedFeeds, @@ -198,7 +199,10 @@ const repliesStore = createStore((setState: Function, getState: Fu // flat doesn't need nested feeds if (!feedOptions.flat) { - for (const reply of (sortType && comment.replies?.pages?.[sortType]?.comments) || []) { + // each comment serves the sort from its own page, e.g. its preloaded page re-sorted client-side + const pageSortType = getReplyPageSortType(comment, feedOptions.sortType); + for (const reply of (pageSortType && comment.replies?.pages?.[pageSortType]?.comments) || + []) { addRepliesFeedsToStoreRecursively(reply); } } diff --git a/src/stores/replies/utils.test.ts b/src/stores/replies/utils.test.ts index d7fc453b..0a340a8e 100644 --- a/src/stores/replies/utils.test.ts +++ b/src/stores/replies/utils.test.ts @@ -281,12 +281,44 @@ describe("replies utils", () => { expect(feeds.feed1.map((reply: any) => reply.cid)).toEqual(["r2", "r1"]); }); - test("does not substitute a single preloaded page for a missing requested sort", () => { - const reply = { - cid: "fallback-reply", - communityAddress: "sub1", - timestamp: 1, + test("sorts a complete preloaded page client-side for a standard sort", () => { + const comments = { + comment1: { + cid: "comment1", + communityAddress: "sub1", + updatedAt: 1, + replies: { + pages: { + best: { + comments: [ + { cid: "older-reply", communityAddress: "sub1", timestamp: 1 }, + { cid: "newer-reply", communityAddress: "sub1", timestamp: 2 }, + ], + }, + }, + }, + }, }; + const accounts = { [mockAccountId]: { pkc: {}, blockedAddresses: {}, blockedCids: {} } }; + const toFeedsOptions = (sortType: string) => ({ + feed1: { commentCid: "comment1", sortType, accountId: mockAccountId }, + }); + expect( + getFilteredSortedFeeds(toFeedsOptions("new"), comments, {}, accounts).feed1.map( + (reply: any) => reply.cid, + ), + ).toEqual(["newer-reply", "older-reply"]); + expect( + getFilteredSortedFeeds(toFeedsOptions("old"), comments, {}, accounts).feed1.map( + (reply: any) => reply.cid, + ), + ).toEqual(["older-reply", "newer-reply"]); + expect( + getFilteredSortedFeeds(toFeedsOptions("customSort"), comments, {}, accounts).feed1, + ).toEqual([]); + }); + + test("flattens a flat sort served from the preloaded hierarchical page", () => { const comments = { comment1: { cid: "comment1", @@ -294,17 +326,31 @@ describe("replies utils", () => { updatedAt: 1, replies: { pages: { - otherSort: { comments: [reply], nextCid: undefined }, + best: { + comments: [ + { + cid: "older-reply", + communityAddress: "sub1", + timestamp: 1, + replies: { + pages: { + best: { + comments: [ + { cid: "nested-reply", communityAddress: "sub1", timestamp: 3 }, + ], + }, + }, + }, + }, + { cid: "newer-reply", communityAddress: "sub1", timestamp: 2 }, + ], + }, }, }, }, }; const feedsOptions = { - feed1: { - commentCid: "comment1", - sortType: "new", - accountId: mockAccountId, - }, + feed1: { commentCid: "comment1", sortType: "newFlat", accountId: mockAccountId }, }; const feeds = getFilteredSortedFeeds( feedsOptions, @@ -312,7 +358,11 @@ describe("replies utils", () => { {}, { [mockAccountId]: { pkc: {}, blockedAddresses: {}, blockedCids: {} } }, ); - expect(feeds.feed1).toEqual([]); + expect(feeds.feed1.map((reply: any) => reply.cid)).toEqual([ + "nested-reply", + "newer-reply", + "older-reply", + ]); }); }); @@ -968,13 +1018,24 @@ describe("replies utils", () => { expect(getSortTypeFromComment(comment as any, { sortType: "custom" })).toBe("custom"); }); - test("does not substitute a similar or flat sort name", () => { + test("serves standard sorts from a complete preloaded page", () => { const comment = { replies: { pages: { topAll: { comments: [] }, newFlat: { comments: [] } }, pageCids: {}, }, }; + expect(getSortTypeFromComment(comment as any, { sortType: "best" })).toBe("best"); + expect(getSortTypeFromComment(comment as any, { sortType: "new", flat: true })).toBe("new"); + }); + + test("does not substitute a similar or flat sort name once pages continue", () => { + const comment = { + replies: { + pages: { topAll: { comments: [], nextCid: "topAll-next" } }, + pageCids: { topAll: "topAll-cid", newFlat: "newFlat-cid" }, + }, + }; expect(getSortTypeFromComment(comment as any, { sortType: "best" })).toBeUndefined(); expect( getSortTypeFromComment(comment as any, { sortType: "new", flat: true }), diff --git a/src/stores/replies/utils.ts b/src/stores/replies/utils.ts index fb468108..2b5c98f3 100644 --- a/src/stores/replies/utils.ts +++ b/src/stores/replies/utils.ts @@ -17,7 +17,7 @@ import accountsStore from "../accounts"; import { flattenCommentsPages, commentIsValid, removeInvalidComments } from "../../lib/utils"; import { areEquivalentCommunityAddresses } from "../../lib/community-address"; import Logger from "@pkcprotocol/pkc-logger"; -import { resolveReplySortType } from "../../lib/page-sorts"; +import { getReplyPageSortType, isFlatSortType, resolveReplySortType } from "../../lib/page-sorts"; const log = Logger("bitsocial-react-hooks:replies:stores"); /** @@ -46,11 +46,13 @@ export const getFilteredSortedFeeds = ( const sortType = getSortTypeFromComment(comment, feedsOptions[feedName]); const requestedSortIsUnavailable = requestedSortType !== undefined && sortType === undefined; + // the page serving the sort, re-sorted client-side when the comment preloads all of its replies + const pageSortType = comment ? getReplyPageSortType(comment, sortType) : undefined; // comment has loaded and cache not expired if (comment && !requestedSortIsUnavailable) { // use comment preloaded replies if any - const preloadedReplies = getPreloadedReplies(comment, sortType); + const preloadedReplies = getPreloadedReplies(comment, pageSortType); if (preloadedReplies) { for (const reply of preloadedReplies) { // replies are manually validated, could have fake communityAddress @@ -78,7 +80,8 @@ export const getFilteredSortedFeeds = ( } } - if (flat) { + // a flat sort served from a hierarchical page is flattened like the flat page pkc-js would publish + if (flat || (isFlatSortType(sortType) && pageSortType !== sortType)) { bufferedFeedReplies = flattenCommentsPages({ comments: bufferedFeedReplies }); } @@ -103,12 +106,11 @@ export const getFilteredSortedFeeds = ( return feeds; }; -const getPreloadedReplies = (comment: Comment, sortType?: string) => { - const resolvedSortType = resolveReplySortType(comment, sortType); - if (!resolvedSortType) { +const getPreloadedReplies = (comment: Comment, pageSortType?: string) => { + if (!pageSortType) { return; } - return comment.replies?.pages?.[resolvedSortType]?.comments; + return comment.replies?.pages?.[pageSortType]?.comments; }; const previousPageNumbers: { [feedName: string]: number } = {}; From a91b16f23b94fb9b7b3a274040df20dd6be19542 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Mon, 14 Sep 2026 00:27:25 +0200 Subject: [PATCH 2/4] fix(feeds): keep timeframe-windowed preloaded pages out of the complete set --- src/lib/page-sorts.test.ts | 8 ++++++++ src/lib/page-sorts.ts | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/page-sorts.test.ts b/src/lib/page-sorts.test.ts index cf8350bc..d54d8d62 100644 --- a/src/lib/page-sorts.test.ts +++ b/src/lib/page-sorts.test.ts @@ -123,6 +123,14 @@ describe("page sort helpers", () => { expect(getReplyPageSortType(nestedReply as any, "newFlat")).toBeUndefined(); }); + test("does not treat a timeframe-windowed preloaded page as the complete set", () => { + const windowedCommunity = { posts: { pages: { topDay: { comments: [{ cid: "post" }] } } } }; + expect(getAvailablePostSortTypes(windowedCommunity as any)).toEqual(["topDay"]); + expect(resolvePostSortType(windowedCommunity as any, "active")).toBeUndefined(); + expect(getPostPageSortType(windowedCommunity as any, "topDay")).toBe("topDay"); + expect(getPostPageSortType(windowedCommunity as any, "hot")).toBeUndefined(); + }); + test("only serves flat sorts from a flat preloaded page", () => { const flatComment = { replies: { pages: { newFlat: { comments: [] } } } }; expect(getAvailableReplySortTypes(flatComment as any)).toEqual(["newFlat", "oldFlat"]); diff --git a/src/lib/page-sorts.ts b/src/lib/page-sorts.ts index 0385e395..4b22e33b 100644 --- a/src/lib/page-sorts.ts +++ b/src/lib/page-sorts.ts @@ -76,7 +76,8 @@ const getCompletePreloadedPageSortTypes = (record?: PagesRecord): string[] => { ) { return []; } - return sortTypes; + // a page windowed by a timeframe sort only holds that window, never the complete set + return sortTypes.filter((sortType) => !getSortTimeframeSeconds(sortType)); }; // a flat page cannot rebuild the reply tree, so it only serves flat sorts From bf9111924a55f1a08056c9af629f08165d12727f Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Mon, 14 Sep 2026 00:33:42 +0200 Subject: [PATCH 3/4] chore(exports): re-export the page-sort timeframe and flat helpers --- README.md | 2 ++ llms-full.txt | 2 ++ src/index.ts | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/README.md b/README.md index 1aa7a862..d92f8f9c 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,8 @@ resolvePostSortType(community: Community, requestedSortType?: string): string | resolveReplySortType(comment: Comment, requestedSortType?: string): string | undefined getPostPageSortType(community: Community, requestedSortType?: string): string | undefined // page sort serving the request, e.g. the preloaded page a single-page community re-sorts client-side getReplyPageSortType(comment: Comment, requestedSortType?: string): string | undefined +getSortTimeframeSeconds(sortType?: string): number | undefined // time window of top*/controversial* timeframe sorts +isFlatSortType(sortType?: string): boolean ``` `createCrosspost` requires a fully loaded comment with `comment.cid` and diff --git a/llms-full.txt b/llms-full.txt index 95e36522..8a2793e8 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -271,6 +271,8 @@ resolvePostSortType(community: Community, requestedSortType?: string): string | resolveReplySortType(comment: Comment, requestedSortType?: string): string | undefined getPostPageSortType(community: Community, requestedSortType?: string): string | undefined // page sort serving the request, e.g. the preloaded page a single-page community re-sorts client-side getReplyPageSortType(comment: Comment, requestedSortType?: string): string | undefined +getSortTimeframeSeconds(sortType?: string): number | undefined // time window of top*/controversial* timeframe sorts +isFlatSortType(sortType?: string): boolean ``` `createCrosspost` requires a fully loaded comment with `comment.cid` and diff --git a/src/index.ts b/src/index.ts index 7729939e..642863b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,6 +96,8 @@ import { getPreloadedReplySortType, getPostPageSortType, getReplyPageSortType, + getSortTimeframeSeconds, + isFlatSortType, resolvePostSortType, resolveReplySortType, } from "./lib/page-sorts"; @@ -183,6 +185,8 @@ export { getPreloadedReplySortType, getPostPageSortType, getReplyPageSortType, + getSortTimeframeSeconds, + isFlatSortType, resolvePostSortType, resolveReplySortType, }; @@ -267,6 +271,8 @@ const hooks = { getPreloadedReplySortType, getPostPageSortType, getReplyPageSortType, + getSortTimeframeSeconds, + isFlatSortType, resolvePostSortType, resolveReplySortType, }; From 730604c9b703e91757f9f19b8be03155e6490cb3 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Mon, 14 Sep 2026 00:40:37 +0200 Subject: [PATCH 4/4] fix(feeds): require a complete reply tree for client flat sorts and window the reconciled post --- src/lib/page-sorts.test.ts | 31 +++++++++++++++++++++++++ src/lib/page-sorts.ts | 41 +++++++++++++++++++++++++++------- src/stores/feeds/utils.test.ts | 35 +++++++++++++++++++++++++++-- src/stores/feeds/utils.ts | 16 +++++++------ 4 files changed, 106 insertions(+), 17 deletions(-) diff --git a/src/lib/page-sorts.test.ts b/src/lib/page-sorts.test.ts index d54d8d62..bbf2aa77 100644 --- a/src/lib/page-sorts.test.ts +++ b/src/lib/page-sorts.test.ts @@ -131,6 +131,37 @@ describe("page sort helpers", () => { expect(getPostPageSortType(windowedCommunity as any, "hot")).toBeUndefined(); }); + test("advertises flat sorts from a hierarchical page only when the nested tree is complete", () => { + const withNestedReplies = (replies: unknown) => ({ + depth: 0, + replies: { + pages: { best: { comments: [{ cid: "reply", replyCount: 1, replies }] } }, + }, + }); + const completeTree = withNestedReplies({ + pages: { best: { comments: [{ cid: "nested", replyCount: 0 }] } }, + }); + expect(getAvailableReplySortTypes(completeTree as any)).toEqual([ + "best", + "new", + "old", + "newFlat", + "oldFlat", + ]); + expect(getReplyPageSortType(completeTree as any, "newFlat")).toBe("best"); + + const continuedTree = withNestedReplies({ + pages: { best: { comments: [{ cid: "nested" }], nextCid: "nested-next" } }, + }); + const pagedTree = withNestedReplies({ pages: {}, pageCids: { new: "nested-new-cid" } }); + const missingTree = withNestedReplies(undefined); + for (const comment of [continuedTree, pagedTree, missingTree]) { + expect(getAvailableReplySortTypes(comment as any)).toEqual(["best", "new", "old"]); + expect(resolveReplySortType(comment as any, "newFlat")).toBeUndefined(); + expect(resolveReplySortType(comment as any, "new")).toBe("new"); + } + }); + test("only serves flat sorts from a flat preloaded page", () => { const flatComment = { replies: { pages: { newFlat: { comments: [] } } } }; expect(getAvailableReplySortTypes(flatComment as any)).toEqual(["newFlat", "oldFlat"]); diff --git a/src/lib/page-sorts.ts b/src/lib/page-sorts.ts index 4b22e33b..bde039b1 100644 --- a/src/lib/page-sorts.ts +++ b/src/lib/page-sorts.ts @@ -80,7 +80,23 @@ const getCompletePreloadedPageSortTypes = (record?: PagesRecord): string[] => { return sortTypes.filter((sortType) => !getSortTimeframeSeconds(sortType)); }; -// a flat page cannot rebuild the reply tree, so it only serves flat sorts +// a flat sort flattens the whole reply tree, so every nested reply chain must be complete too +const hasCompleteReplyTree = (page: PreloadedPage): boolean => + ((page?.comments as Comment[] | undefined) || []).every((comment) => { + const completeSortTypes = getCompletePreloadedPageSortTypes(comment?.replies); + if (!completeSortTypes.length) { + // no preloaded replies is only complete when the comment reports none, and a continued or + // paged chain is never complete + return !(comment?.replyCount > 0) && !Object.keys(comment?.replies?.pages || {}).length; + } + const hierarchicalSortType = completeSortTypes.find((sortType) => !isFlatSortType(sortType)); + return hierarchicalSortType === undefined + ? true + : hasCompleteReplyTree(comment.replies.pages[hierarchicalSortType]); + }); + +// a flat page cannot rebuild the reply tree, so it only serves flat sorts; a hierarchical page +// serves flat sorts only when its nested reply chains are complete const getClientSortableSortTypes = ( record: PagesRecord | undefined, clientSortableSortTypes: string[], @@ -89,10 +105,16 @@ const getClientSortableSortTypes = ( if (!completeSortTypes.length) { return []; } - if (completeSortTypes.some((sortType) => !isFlatSortType(sortType))) { - return clientSortableSortTypes; + const hierarchicalSortType = completeSortTypes.find((sortType) => !isFlatSortType(sortType)); + if (hierarchicalSortType === undefined) { + return clientSortableSortTypes.filter(isFlatSortType); } - return clientSortableSortTypes.filter(isFlatSortType); + const canFlatten = + completeSortTypes.some(isFlatSortType) || + hasCompleteReplyTree(record?.pages?.[hierarchicalSortType] as PreloadedPage); + return canFlatten + ? clientSortableSortTypes + : clientSortableSortTypes.filter((sortType) => !isFlatSortType(sortType)); }; const getAvailablePageSortTypes = ( @@ -125,7 +147,7 @@ const resolvePageSortType = ( }; // the page that serves a resolved sort: its published page, or the complete preloaded page the -// client re-sorts (a flat sort can also be flattened from a hierarchical page) +// client re-sorts (a flat sort prefers a complete flat page, else flattens a hierarchical one) const getPageSortTypeToRead = ( record: PagesRecord | undefined, sortType: string | undefined, @@ -137,10 +159,13 @@ const getPageSortTypeToRead = ( return sortType; } const completeSortTypes = getCompletePreloadedPageSortTypes(record); - return ( - completeSortTypes.find((preloadedSortType) => !isFlatSortType(preloadedSortType)) ?? - (isFlatSortType(sortType) ? completeSortTypes[0] : undefined) + const hierarchicalSortType = completeSortTypes.find( + (preloadedSortType) => !isFlatSortType(preloadedSortType), ); + if (isFlatSortType(sortType)) { + return completeSortTypes.find(isFlatSortType) ?? hierarchicalSortType; + } + return hierarchicalSortType; }; export const getAvailablePostSortTypes = (community?: Community): string[] => diff --git a/src/stores/feeds/utils.test.ts b/src/stores/feeds/utils.test.ts index 75c16061..671021e4 100644 --- a/src/stores/feeds/utils.test.ts +++ b/src/stores/feeds/utils.test.ts @@ -615,12 +615,32 @@ describe("feeds utils", () => { downvoteCount: 0, pinned: true, }, + { + cid: "pinned-later", + communityAddress: "sub1", + timestamp: now - 4 * 86400, + updatedAt: now - 4 * 86400, + upvoteCount: 0, + downvoteCount: 0, + }, ], }, }, }, }, }; + // the fresher cached version of the old post was pinned after the page was published + const freshestComments = { + "pinned-later": { + cid: "pinned-later", + communityAddress: "sub1", + timestamp: now - 4 * 86400, + updatedAt: now, + upvoteCount: 0, + downvoteCount: 0, + pinned: true, + }, + }; const feedsOptions = { topDay: { communities: toCommunities(["sub1"]), @@ -633,10 +653,21 @@ describe("feeds utils", () => { accountId: mockAccountId, }, }; - const feeds = getFilteredSortedFeeds(feedsOptions, communities, {}, makeMockAccounts()); - expect(feeds.topDay.map((post: any) => post.cid)).toEqual(["pinned-old", "recent"]); + const feeds = getFilteredSortedFeeds( + feedsOptions, + communities, + {}, + makeMockAccounts(), + freshestComments as any, + ); + expect(feeds.topDay.map((post: any) => post.cid)).toEqual([ + "pinned-old", + "pinned-later", + "recent", + ]); expect(feeds.topAll.map((post: any) => post.cid)).toEqual([ "pinned-old", + "pinned-later", "old-top", "recent", ]); diff --git a/src/stores/feeds/utils.ts b/src/stores/feeds/utils.ts index 159b3be1..0989a217 100644 --- a/src/stores/feeds/utils.ts +++ b/src/stores/feeds/utils.ts @@ -186,18 +186,20 @@ export const getFilteredSortedFeeds = ( ) { break; } - // like the pages a community windows itself, pinned posts stay regardless of age + const nextPost = getFeedPost(post, communityRef, community, modQueue, freshestComments); + if (!nextPost) { + continue; + } + // window the reconciled post: pinned is mutable moderation state, and like the pages a + // community windows itself, pinned posts stay regardless of age if ( clientTimeframeTimestamp !== undefined && - !post.pinned && - post.timestamp <= clientTimeframeTimestamp + !nextPost.pinned && + nextPost.timestamp <= clientTimeframeTimestamp ) { continue; } - const nextPost = getFeedPost(post, communityRef, community, modQueue, freshestComments); - if (nextPost) { - bufferedFeedPosts.push(nextPost); - } + bufferedFeedPosts.push(nextPost); } }