Skip to content

Commit 63944cb

Browse files
Merge upstream develop
2 parents 3decc31 + dc68539 commit 63944cb

36 files changed

Lines changed: 2362 additions & 1964 deletions

apps/polycentric/src/features/composer/ComposeSheetInner.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,20 @@ export function ComposeSheetInner({
185185

186186
resetComposer();
187187
await dismissSheet(DismissReason.PostSubmitted);
188-
void client.sync().catch((err) => {
189-
console.warn('compose sync failed:', err);
190-
});
188+
void client
189+
.sync()
190+
.then(() => {
191+
// Force the feed observables to re-fetch from servers so the
192+
// newly-synced post appears alongside whatever else changed.
193+
// The local-post injection already showed it optimistically.
194+
const identity = currentIdentityKey ?? '';
195+
client.core.invalidateQuery(['following_feed', identity]);
196+
client.core.invalidateQuery(['identity_feed', identity]);
197+
client.core.invalidateQuery(['explore_feed', identity]);
198+
})
199+
.catch((err) => {
200+
console.warn('compose sync failed:', err);
201+
});
191202
// TODO
192203
// await onPostCreatedRef.current(signedEvent);
193204
} catch (err) {
@@ -202,6 +213,7 @@ export function ComposeSheetInner({
202213
attachments,
203214
submitting,
204215
client,
216+
currentIdentityKey,
205217
dismissSheet,
206218
isReply,
207219
replyToEventKey,
Lines changed: 39 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,55 @@
1-
import { useCallback, useEffect, useRef, useState } from 'react';
2-
import { QueryStatus, v2 } from '@polycentric/react-native';
3-
import {
4-
decodeV2PostBundle,
5-
usePolycentricContext,
6-
type PostData,
7-
} from '@/src/common/lib/polycentric-hooks';
8-
import { EMPTY_POSTS, NOOP, type FeedHookResult } from './types';
9-
10-
type Sub = { unsubscribe: () => void };
1+
import { useCallback, useEffect, useMemo, useRef } from 'react';
2+
import { Query } from '@polycentric/react-native';
3+
import { usePolycentricContext } from '@/src/common/lib/polycentric-hooks';
4+
import { type FeedHookResult, NOOP } from './types';
5+
import { EMPTY_FEED, useFeedsStore } from './useFeeds';
116

127
export function useExploreFeed(options?: {
138
perServerLimit?: number;
149
enabled?: boolean;
1510
}): FeedHookResult {
1611
const { client } = usePolycentricContext();
1712
const enabled = options?.enabled ?? true;
18-
const [items, setItems] = useState<PostData[]>(EMPTY_POSTS);
19-
const [isLoading, setIsLoading] = useState(false);
20-
const [error, setError] = useState<Error | null>(null);
21-
22-
// Hold the live subscription so we can cancel on unmount / refresh.
23-
// The rust core fans out to every configured server and emits each
24-
// merged response as it arrives.
25-
const subscriptionRef = useRef<Sub | null>(null);
26-
27-
const cleanup = useCallback(() => {
28-
subscriptionRef.current?.unsubscribe();
29-
subscriptionRef.current = null;
30-
}, []);
31-
32-
const fetchFeed = useCallback(() => {
33-
cleanup();
34-
setItems(EMPTY_POSTS);
35-
setError(null);
36-
setIsLoading(true);
3713

38-
// Rust-side merge_fn already dedupes by EventKey — each next()
39-
// carries the full merged feed plus a status. `Loading` stays in
40-
// effect until the last per-server response arrives.
41-
const observable = client.core.getExploreFeed(
42-
client.activeIdentityKey ?? undefined,
43-
undefined,
44-
undefined,
45-
undefined,
46-
undefined,
47-
);
48-
subscriptionRef.current = observable.subscribe({
49-
next: (result) => {
50-
if (result.data) {
51-
const response = v2.GetFeedResponse.fromBinary(
52-
new Uint8Array(result.data),
53-
);
54-
const posts: PostData[] = [];
55-
for (const bundle of response.eventBundles) {
56-
const decoded = decodeV2PostBundle(bundle);
57-
if (decoded) posts.push(decoded);
58-
}
59-
setItems(posts);
60-
}
61-
setIsLoading(result.status === QueryStatus.Loading);
62-
},
63-
error: (message: string) => {
64-
console.warn('useExploreFeed error:', message);
65-
setError(new Error(message));
66-
},
67-
complete: () => {
68-
setIsLoading(false);
69-
},
70-
});
71-
}, [client, cleanup]);
14+
const identity = client.activeIdentityKey ?? '';
15+
const feedKey = `explore:${identity}`;
16+
const queryKey = useMemo(() => ['explore_feed', identity], [identity]);
17+
const query = useMemo(
18+
() =>
19+
new Query.GetExploreFeed({
20+
identity: identity === '' ? undefined : identity,
21+
}),
22+
[identity],
23+
);
24+
25+
const feed = useFeedsStore((s) => s.feeds.get(feedKey) ?? EMPTY_FEED);
26+
const subscribe = useFeedsStore((s) => s.subscribe);
27+
28+
// Re-subscribe on every mount so the rust observable re-emits cached
29+
// state (instant) and `FetchMode::Default` triggers a fresh fan-out.
30+
// State persists in the store across mount/unmount.
31+
const unsubscribeRef = useRef<(() => void) | null>(null);
7232

7333
useEffect(() => {
74-
if (enabled) fetchFeed();
75-
return cleanup;
76-
}, [enabled, fetchFeed, cleanup]);
34+
if (!enabled) return;
35+
unsubscribeRef.current = subscribe(client, feedKey, queryKey, query);
36+
return () => {
37+
unsubscribeRef.current?.();
38+
unsubscribeRef.current = null;
39+
};
40+
}, [enabled, subscribe, client, feedKey, queryKey, query]);
41+
42+
const refresh = useCallback(() => {
43+
unsubscribeRef.current?.();
44+
unsubscribeRef.current = subscribe(client, feedKey, queryKey, query);
45+
}, [subscribe, client, feedKey, queryKey, query]);
7746

7847
return {
79-
items,
80-
isLoading,
81-
error,
48+
items: feed.items,
49+
isLoading: feed.isLoading,
50+
error: feed.error,
8251
loadMore: NOOP,
8352
hasMore: false,
84-
refresh: fetchFeed,
53+
refresh,
8554
};
8655
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { create } from 'zustand';
2+
import {
3+
Query,
4+
QueryStatus,
5+
v2,
6+
type PolycentricClient,
7+
} from '@polycentric/react-native';
8+
import {
9+
decodeV2PostBundle,
10+
type PostData,
11+
} from '@/src/common/lib/polycentric-hooks';
12+
13+
type FeedKey = string;
14+
15+
type Sub = { unsubscribe: () => void };
16+
17+
export type FeedEntry = {
18+
items: PostData[];
19+
isLoading: boolean;
20+
error: Error | null;
21+
};
22+
23+
const EMPTY_ITEMS: PostData[] = [];
24+
25+
/**
26+
* Stable empty entry used as the selector fallback. Returning a fresh
27+
* `{}` on each render would defeat zustand's `Object.is` short-circuit
28+
* and force every consumer to re-render on any unrelated store update.
29+
*/
30+
export const EMPTY_FEED: FeedEntry = Object.freeze({
31+
items: EMPTY_ITEMS,
32+
isLoading: false,
33+
error: null,
34+
});
35+
36+
type FeedsState = {
37+
feeds: Map<FeedKey, FeedEntry>;
38+
setFeed: (key: FeedKey, patch: Partial<FeedEntry>) => void;
39+
/**
40+
* Subscribe to the underlying rust observable for `key`. Returns
41+
* an unsubscribe function — the caller (the hook's `useEffect`)
42+
* owns the subscription's lifetime. State stays in the store, so
43+
* remount sees the cached items immediately while a fresh fan-out
44+
* runs on the rust side (`FetchMode::Default` always re-fetches
45+
* unless one is already in flight).
46+
*/
47+
subscribe: (
48+
client: PolycentricClient,
49+
key: FeedKey,
50+
queryKey: string[],
51+
query: Query,
52+
) => () => void;
53+
/**
54+
* Prepend a locally-authored post (deduped by id). Used by the
55+
* `useLocalPosts` listener so newly-created posts appear without
56+
* waiting for the server round-trip.
57+
*/
58+
insertLocal: (key: FeedKey, post: PostData) => void;
59+
};
60+
61+
export const useFeedsStore = create<FeedsState>((set, get) => {
62+
const writeFeed = (key: FeedKey, patch: Partial<FeedEntry>) => {
63+
set((state) => {
64+
const prev = state.feeds.get(key) ?? EMPTY_FEED;
65+
const merged = { ...prev, ...patch };
66+
// Skip the Map clone if nothing actually changed — keeps selector
67+
// identity stable across no-op writes (e.g. duplicate emissions
68+
// from multiple concurrent subscribers to the same key).
69+
if (
70+
merged.items === prev.items &&
71+
merged.isLoading === prev.isLoading &&
72+
merged.error === prev.error
73+
) {
74+
return {};
75+
}
76+
const next = new Map(state.feeds);
77+
next.set(key, merged);
78+
return { feeds: next };
79+
});
80+
};
81+
82+
const subscribe = (
83+
client: PolycentricClient,
84+
key: FeedKey,
85+
queryKey: string[],
86+
query: Query,
87+
): (() => void) => {
88+
writeFeed(key, { isLoading: true, error: null });
89+
90+
const observable = client.core.fetchQuery(queryKey, query, undefined);
91+
const sub: Sub = observable.subscribe({
92+
next: (result) => {
93+
if (result.data) {
94+
const response = v2.GetFeedResponse.fromBinary(
95+
new Uint8Array(result.data),
96+
);
97+
const items: PostData[] = [];
98+
for (const bundle of response.eventBundles) {
99+
const decoded = decodeV2PostBundle(bundle);
100+
if (decoded) items.push(decoded);
101+
}
102+
writeFeed(key, {
103+
items,
104+
isLoading: result.status === QueryStatus.Loading,
105+
});
106+
} else {
107+
writeFeed(key, {
108+
isLoading: result.status === QueryStatus.Loading,
109+
});
110+
}
111+
},
112+
error: (message: string) => {
113+
console.warn(`feed ${key} error:`, message);
114+
writeFeed(key, { error: new Error(message), isLoading: false });
115+
},
116+
complete: () => {
117+
writeFeed(key, { isLoading: false });
118+
},
119+
});
120+
121+
return () => sub.unsubscribe();
122+
};
123+
124+
return {
125+
feeds: new Map(),
126+
setFeed: writeFeed,
127+
subscribe,
128+
insertLocal: (key, post) => {
129+
const prev = get().feeds.get(key) ?? EMPTY_FEED;
130+
if (prev.items.some((p) => p.id === post.id)) return;
131+
writeFeed(key, { items: [post, ...prev.items] });
132+
},
133+
};
134+
});

0 commit comments

Comments
 (0)