Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -237,6 +237,10 @@ 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
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
Expand Down
6 changes: 5 additions & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -269,6 +269,10 @@ 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
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
Expand Down
37 changes: 37 additions & 0 deletions src/hooks/feeds/feeds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion src/hooks/feeds/feeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
40 changes: 30 additions & 10 deletions src/hooks/replies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -912,19 +912,38 @@ 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: {},
},
};

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([]);
});
});
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
});
Expand Down
12 changes: 12 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ import {
getAvailableReplySortTypes,
getPreloadedPostSortType,
getPreloadedReplySortType,
getPostPageSortType,
getReplyPageSortType,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
getSortTimeframeSeconds,
isFlatSortType,
resolvePostSortType,
resolveReplySortType,
} from "./lib/page-sorts";
Expand Down Expand Up @@ -179,6 +183,10 @@ export {
getAvailableReplySortTypes,
getPreloadedPostSortType,
getPreloadedReplySortType,
getPostPageSortType,
getReplyPageSortType,
getSortTimeframeSeconds,
isFlatSortType,
resolvePostSortType,
resolveReplySortType,
};
Expand Down Expand Up @@ -261,6 +269,10 @@ const hooks = {
getAvailableReplySortTypes,
getPreloadedPostSortType,
getPreloadedReplySortType,
getPostPageSortType,
getReplyPageSortType,
getSortTimeframeSeconds,
isFlatSortType,
resolvePostSortType,
resolveReplySortType,
};
Expand Down
148 changes: 148 additions & 0 deletions src/lib/page-sorts.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import feedSorter from "../stores/feeds/feed-sorter";
import {
getAvailablePostSortTypes,
getAvailableReplySortTypes,
getPostPageSortType,
getPreloadedPostSortType,
getPreloadedReplySortType,
getReplyPageSortType,
getSortTimeframeSeconds,
resolvePostSortType,
resolveReplySortType,
} from "./page-sorts";
Expand Down Expand Up @@ -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", () => {
Expand All @@ -45,4 +57,140 @@ 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("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("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"]);
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();
});
});
});
Loading
Loading