Skip to content

Commit 513167e

Browse files
brandon-futomarkharding
authored andcommitted
[#107] Pagination for feeds
Changelog: enchancement
1 parent 2c45e83 commit 513167e

31 files changed

Lines changed: 1294 additions & 373 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/polycentric/src/common/components/List.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ function WebFeedViewer<T>({
141141
);
142142
const items = (data as readonly T[] | null | undefined) ?? [];
143143
const [visibleCount, setVisibleCount] = useState(WEB_INITIAL_VISIBLE);
144+
const [atEnd, setAtEnd] = useState(false);
144145

145146
// Keep parity with native `FlashList` by calling `onLoad`.
146147
const hasFiredOnLoad = useRef(false);
@@ -163,20 +164,27 @@ function WebFeedViewer<T>({
163164
if (!node || typeof IntersectionObserver === 'undefined') return;
164165
const observer = new IntersectionObserver(
165166
(entries) => {
166-
if (!entries.some((e) => e.isIntersecting)) return;
167-
setVisibleCount((c) => {
168-
// First grow the local window; only delegate to the
169-
// consumer's onEndReached once we've exhausted the data.
170-
if (c < items.length) return c + WEB_PAGE_SIZE;
171-
onEndReached?.();
172-
return c;
173-
});
167+
const intersecting = entries.some((e) => e.isIntersecting);
168+
setAtEnd(intersecting);
169+
if (!intersecting) return;
170+
// Grow the local window; the effect below delegates to the
171+
// consumer's onEndReached once we've exhausted the data.
172+
setVisibleCount((c) => (c < items.length ? c + WEB_PAGE_SIZE : c));
174173
},
175174
{ rootMargin: '400px' },
176175
);
177176
observer.observe(node);
178177
return () => observer.disconnect();
179-
}, [onEndReached, items.length]);
178+
}, [items.length]);
179+
180+
// Fetch more from the consumer once the local window is exhausted and
181+
// the sentinel is in view. Kept out of the setVisibleCount updater so
182+
// we never trigger a parent setState during render.
183+
useEffect(() => {
184+
if (atEnd && visibleCount >= items.length) {
185+
onEndReached?.();
186+
}
187+
}, [atEnd, visibleCount, items.length, onEndReached]);
180188

181189
const isEmpty = items.length === 0;
182190
const visibleItems = isEmpty ? items : items.slice(0, visibleCount);

apps/polycentric/src/common/lib/polycentric-hooks/helpers.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { v2 } from '@polycentric/react-native';
1+
import { QueryStatus, v2 } from '@polycentric/react-native';
2+
import type { UseQueryResult } from '@/src/common/query/hooks/useQuery';
23

34
export function toBase64(bytes: Uint8Array): string {
45
return btoa(String.fromCharCode.apply(null, Array.from(bytes)));
@@ -226,6 +227,42 @@ export function decodeFeedItems(response: v2.GetFeedResponse): PostData[] {
226227
return items;
227228
}
228229

230+
/** Extract the items and next page indicator from the feed response. */
231+
export function decodeFeedQueryResult(
232+
data: ArrayBuffer | undefined,
233+
): [PostData[], boolean] {
234+
if (!data) {
235+
return [[], false];
236+
}
237+
238+
const response = v2.GetFeedResponse.fromBinary(new Uint8Array(data));
239+
let hasNext = !!response.pageInfo?.hasNextPage;
240+
return [decodeFeedItems(response), hasNext];
241+
}
242+
243+
/** Get the forward cursor token from a previous feed query result */
244+
export function extractFeedToken(
245+
_status: QueryStatus | undefined,
246+
data: ArrayBuffer | undefined,
247+
): string | undefined {
248+
let forwardToken: string | undefined = undefined;
249+
250+
if (data) {
251+
const response = v2.GetFeedResponse.fromBinary(new Uint8Array(data));
252+
forwardToken = response.pageInfo?.endCursor;
253+
}
254+
255+
return forwardToken;
256+
}
257+
258+
/** Check whether it's reasonable to fetch more feed data from servers */
259+
export function shouldExtend(
260+
hasNextPage: boolean,
261+
query: UseQueryResult,
262+
): boolean {
263+
return hasNextPage && query.successfulServers > 0;
264+
}
265+
229266
/**
230267
* Dicebear identicon URL for a public key.
231268
*/

apps/polycentric/src/common/lib/polycentric-hooks/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export {
2929
decodeBundle,
3030
decodePostBundle as decodeV2PostBundle,
3131
decodeFeedItems,
32+
decodeFeedQueryResult,
33+
extractFeedToken,
34+
shouldExtend,
3235
pubkeyStr,
3336
identiconUrl,
3437
pickImageVariant,

apps/polycentric/src/common/query/hooks/useQuery.ts

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,20 @@ import {
99
import { create } from 'zustand';
1010
import { usePolycentric } from '../../lib/polycentric-hooks';
1111

12+
/**
13+
* Either a `Query` value or a function that can produce a `Query` from a previous
14+
* query's results.
15+
*/
16+
export type QuerySource =
17+
| Query
18+
| ((status: QueryStatus | undefined, data: ArrayBuffer | undefined) => Query);
19+
1220
export type QueryRef = {
1321
data: ArrayBuffer | undefined;
1422
status: QueryStatus;
1523
error: string | null;
24+
successfulServers: number;
25+
pendingServers: number | undefined;
1626
};
1727

1828
type QueryArgs = {
@@ -36,13 +46,16 @@ type QueryStoreState = {
3646
subscribe: (key: string, args: QueryArgs) => void;
3747
unsubscribe: (key: string) => void;
3848
refresh: (key: string, args?: QueryArgs) => void;
49+
extend: (key: string, args: QueryArgs) => void;
3950
invalidate: (key: string, args?: QueryArgs) => void;
4051
};
4152

4253
const EMPTY_ENTRY: QueryRef = Object.freeze({
4354
data: undefined,
4455
status: QueryStatus.Loading,
4556
error: null,
57+
successfulServers: 0,
58+
pendingServers: undefined,
4659
});
4760

4861
export const useQueryStore = create<QueryStoreState>((set, get) => {
@@ -53,7 +66,9 @@ export const useQueryStore = create<QueryStoreState>((set, get) => {
5366
if (
5467
merged.data === prev.data &&
5568
merged.status === prev.status &&
56-
merged.error === prev.error
69+
merged.error === prev.error &&
70+
merged.successfulServers === prev.successfulServers &&
71+
merged.pendingServers === prev.pendingServers
5772
) {
5873
return {};
5974
}
@@ -68,6 +83,8 @@ export const useQueryStore = create<QueryStoreState>((set, get) => {
6883
updateQueryRef(key, {
6984
status: QueryStatus.Loading,
7085
error: null,
86+
successfulServers: 0,
87+
pendingServers: undefined,
7188
});
7289

7390
// Request from rs-core
@@ -79,7 +96,12 @@ export const useQueryStore = create<QueryStoreState>((set, get) => {
7996
// Listen for outputs from relevant servers
8097
const sub = observable.subscribe({
8198
next(result) {
82-
updateQueryRef(key, { data: result.data, status: result.status });
99+
updateQueryRef(key, {
100+
data: result.data,
101+
status: result.status,
102+
successfulServers: result.successfulServers,
103+
pendingServers: result.pendingServers,
104+
});
83105
},
84106
error(message) {
85107
console.warn(`useQuery[${key}] error: ${message}`);
@@ -139,6 +161,13 @@ export const useQueryStore = create<QueryStoreState>((set, get) => {
139161
sub.dispose = fetch(key, next);
140162
},
141163

164+
extend(key, args) {
165+
const sub = get().subscriptions.get(key);
166+
if (!sub) return;
167+
sub.dispose();
168+
sub.dispose = fetch(key, args);
169+
},
170+
142171
invalidate(key, args) {
143172
const sub = get().subscriptions.get(key);
144173
const target = args ?? sub?.args;
@@ -151,6 +180,12 @@ export type UseQueryResult = QueryRef & {
151180
isLoading: boolean;
152181
/** Re-run the fan-out. Cached data stays visible until the new responses arrive. */
153182
refresh: () => void;
183+
/**
184+
* Re-run the fan-out, but without updating the subscription's query args.
185+
* This allows doing extra queries to add more data while still having refreshes
186+
* re-run the original query.
187+
*/
188+
extend: () => void;
154189
/**
155190
* Drop the rust-side cache for this key, then re-run the fan-out.
156191
* Optional `opts` overrides the original `QueryOpts` for this run
@@ -195,7 +230,9 @@ export function setQueryCache(
195230
if (
196231
merged.data === prev.data &&
197232
merged.status === prev.status &&
198-
merged.error === prev.error
233+
merged.error === prev.error &&
234+
merged.successfulServers === prev.successfulServers &&
235+
merged.pendingServers === prev.pendingServers
199236
) {
200237
return {};
201238
}
@@ -206,23 +243,33 @@ export function setQueryCache(
206243
}
207244

208245
/**
209-
* Subscribe to a rust-core query and share its state across every
210-
* consumer using the same `queryKey`. The first consumer kicks off
211-
* the rust-side fan-out; subsequent consumers refcount onto the same
212-
* subscription. `refresh` / `invalidate` re-run the shared fan-out
213-
* for every attached consumer. Set `enabled` to `false` to skip the
214-
* subscription entirely (the hook still returns cached state if any).
246+
* Subscribe to a rust-core query and share its state across every consumer using
247+
* the same `queryKey`.
248+
* The first consumer kicks off the rust-side fan-out, and subsequent consumers
249+
* refcount onto the same subscription.
250+
* `refresh()` / `invalidate()` re-run the shared fan-out for every attached consumer.
251+
* Set `enabled` to `false` to skip the subscription entirely (the hook still
252+
* returns cached state if any).
253+
*
254+
* Extending/infinite queries can be done by using the "merge" update mode and
255+
* providing a function for the query source.
256+
* Then, calling `extend()` will keep the query key and subscription the same,
257+
* while triggering a new fan-out that will be added to the existing data.
215258
*/
216259
export function useQuery(
217260
queryKey: QueryKey,
218-
query: Query,
261+
querySource: QuerySource,
219262
opts: QueryOpts = { fetchMode: FetchMode.Default },
220263
enabled = true,
221264
): UseQueryResult {
222265
const client = usePolycentric();
223266
const cacheKey = queryKey.join('\0');
224267

225268
const entry = useQueryStore((s) => s.queries.get(cacheKey) ?? EMPTY_ENTRY);
269+
const query =
270+
typeof querySource === 'function'
271+
? querySource(undefined, undefined)
272+
: querySource;
226273

227274
// Keep `argsRef` pointing at the freshest call args so the
228275
// imperative handlers below always see them without needing the
@@ -240,6 +287,16 @@ export function useQuery(
240287
return {
241288
...entry,
242289
isLoading: enabled && entry.status === QueryStatus.Loading,
290+
extend: () => {
291+
const query =
292+
typeof querySource === 'function'
293+
? querySource(entry.status, entry.data)
294+
: querySource;
295+
296+
useQueryStore
297+
.getState()
298+
.extend(cacheKey, { client, queryKey, query, opts });
299+
},
243300
refresh: () => useQueryStore.getState().refresh(cacheKey, argsRef.current),
244301
invalidate: (overrideOpts?: QueryOpts) =>
245302
useQueryStore

apps/polycentric/src/features/feed/hooks/feedCache.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,8 @@ export function injectPostIntoFeedCache(
3535
if (bundleEventId(b) === newId) return;
3636
}
3737

38-
const updated = v2.GetFeedResponse.create({
39-
eventBundles: [newBundle, ...response.eventBundles],
40-
});
41-
const bytes = v2.GetFeedResponse.toBinary(updated);
38+
response.eventBundles.unshift(newBundle);
39+
40+
const bytes = v2.GetFeedResponse.toBinary(response);
4241
setQueryCache(queryKey, { data: bytes.slice().buffer });
4342
}
Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { useMemo } from 'react';
2-
import { Query, QueryStatus, v2 } from '@polycentric/react-native';
2+
import { Query, QueryStatus, UpdateMode } from '@polycentric/react-native';
33
import {
4-
decodeFeedItems,
4+
decodeFeedQueryResult,
5+
extractFeedToken,
6+
shouldExtend,
57
usePolycentricContext,
68
} from '@/src/common/lib/polycentric-hooks';
7-
import { type FeedHookResult, NOOP } from './types';
9+
import { type FeedHookResult } from './types';
810
import { useQuery } from '@/src/common/query/hooks/useQuery';
911
import { feedQueryKeys } from './feedCache';
1012

@@ -18,27 +20,34 @@ export function useExploreFeed(options?: {
1820

1921
const query = useQuery(
2022
feedQueryKeys.explore(identity),
21-
new Query.GetExploreFeed({
22-
identity: identity === '' ? undefined : identity,
23-
}),
24-
undefined,
23+
(status, data) => {
24+
const forwardToken = extractFeedToken(status, data);
25+
26+
return new Query.GetExploreFeed({
27+
identity: identity === '' ? undefined : identity,
28+
limit: options?.perServerLimit,
29+
forwardToken,
30+
});
31+
},
32+
{ updateMode: UpdateMode.Merge },
2533
enabled,
2634
);
2735

28-
const items = useMemo(() => {
29-
if (!query.data) {
30-
return [];
31-
}
32-
const response = v2.GetFeedResponse.fromBinary(new Uint8Array(query.data));
33-
return decodeFeedItems(response);
34-
}, [query.data]);
36+
const [items, hasNext] = useMemo(
37+
() => decodeFeedQueryResult(query.data),
38+
[query.data],
39+
);
3540

3641
return {
3742
items,
3843
isLoading: query.status === QueryStatus.Loading,
3944
error: query.error ? new Error(query.error) : null,
40-
loadMore: NOOP,
41-
hasMore: false,
45+
loadMore: async () => {
46+
if (shouldExtend(hasNext, query)) {
47+
query.extend();
48+
}
49+
},
50+
hasMore: hasNext,
4251
refresh: query.refresh,
4352
};
4453
}

0 commit comments

Comments
 (0)