Skip to content

Commit a19f55e

Browse files
committed
Merge branch 'mh/hints' into 'develop'
Introduce Hints See merge request polycentric/polycentric!515
2 parents 74283e1 + 7239f4a commit a19f55e

39 files changed

Lines changed: 4010 additions & 1415 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function useExploreFeed(options?: {
4343
undefined,
4444
undefined,
4545
undefined,
46+
undefined,
4647
);
4748
subscriptionRef.current = observable.subscribe({
4849
next: (result) => {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export function useFollowingFeed(options?: {
4848
undefined,
4949
undefined,
5050
undefined,
51+
undefined,
5152
);
5253
subscriptionRef.current = observable.subscribe({
5354
next: (result) => {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export function useIdentityFeed(
4747
undefined,
4848
undefined,
4949
undefined,
50+
undefined,
5051
);
5152
subscriptionRef.current = observable.subscribe({
5253
next: (result) => {
Lines changed: 66 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,103 @@
1-
import { useEffect, useState } from 'react';
2-
import { COLLECTION, v2 } from '@polycentric/react-native';
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
import { COLLECTION, QueryStatus, v2 } from '@polycentric/react-native';
33
import {
4-
bytesToHex,
54
decodeV2PostBundle,
6-
hexToBytes,
75
usePolycentricContext,
86
type PostData,
97
} from '@/src/common/lib/polycentric-hooks';
108
import { getKeyFingerprint } from '@/src/common/lib/polycentric-hooks/helpers';
119

10+
type Sub = { unsubscribe: () => void };
11+
1212
/**
1313
* Load a single post by its (identity, sequence) route params.
1414
*
15-
* Uses `listEvents` with `sequenceGt = seq - 1` / `sequenceLt = seq + 1` to
16-
* pin the query to exactly one sequence number server-side. No local cache —
17-
* the detail screen re-queries when it mounts.
15+
* Subscribes to `core.getEvent`, which checks the local store first
16+
* and falls back to a `ListEvents` query with `sequenceGt = seq - 1`
17+
* / `sequenceLt = seq + 1` to pin the network query to exactly one
18+
* sequence. `keyFingerprint` is used only to verify the returned
19+
* bundle matches the expected signer; the rust side picks the first
20+
* local-store hit at that sequence.
1821
*/
1922
export function usePostById(
2023
identityId: string | undefined,
2124
keyFingerprint: string | undefined,
22-
sequence: BigInt | undefined,
25+
sequence: bigint | undefined,
2326
): { post: PostData | null; isLoading: boolean; error: Error | null } {
2427
const { client } = usePolycentricContext();
2528
const [post, setPost] = useState<PostData | null>(null);
2629
const [isLoading, setIsLoading] = useState(false);
2730
const [error, setError] = useState<Error | null>(null);
2831

32+
// Hold the live subscription so we can cancel on unmount / param change.
33+
const subscriptionRef = useRef<Sub | null>(null);
34+
const cleanup = useCallback(() => {
35+
subscriptionRef.current?.unsubscribe();
36+
subscriptionRef.current = null;
37+
}, []);
38+
2939
useEffect(() => {
30-
if (!keyFingerprint || !identityId || !sequence) {
40+
cleanup();
41+
if (!identityId || sequence == null || !keyFingerprint) {
3142
setPost(null);
43+
setIsLoading(false);
44+
setError(null);
3245
return;
3346
}
3447

35-
let cancelled = false;
36-
setIsLoading(true);
48+
setPost(null);
3749
setError(null);
50+
setIsLoading(true);
3851

39-
(async () => {
40-
try {
41-
const bundles = await client.listEvents({
42-
identity: identityId,
43-
collection: COLLECTION.FEED,
44-
sequenceGt: Number(sequence) - 1,
45-
sequenceLt: Number(sequence) + 1,
46-
});
47-
if (cancelled) return;
52+
const observable = client.core.getEvent(
53+
identityId,
54+
COLLECTION.FEED,
55+
sequence,
56+
undefined,
57+
);
4858

49-
const match = bundles.find((b) => {
50-
if (!b.signedEvent) return false;
59+
subscriptionRef.current = observable.subscribe({
60+
next: (result) => {
61+
if (result.data && result.data.byteLength > 0) {
5162
try {
52-
const ev = v2.Event.fromBinary(b.signedEvent.eventBytes);
53-
if (!ev.vectorClock) return false;
54-
return (
55-
getKeyFingerprint(ev.key?.signedBy) === keyFingerprint &&
56-
ev.key?.sequence === sequence
63+
const bundle = v2.EventBundle.fromBinary(
64+
new Uint8Array(result.data),
5765
);
66+
// Verify the signer fingerprint matches the URL so a
67+
// sequence collision across signers doesn't render the
68+
// wrong post.
69+
if (bundle.signedEvent) {
70+
const ev = v2.Event.fromBinary(bundle.signedEvent.eventBytes);
71+
if (
72+
getKeyFingerprint(ev.key?.signedBy) === keyFingerprint &&
73+
ev.key?.sequence === sequence
74+
) {
75+
setPost(decodeV2PostBundle(bundle));
76+
} else if (result.status === QueryStatus.Success) {
77+
setPost(null);
78+
}
79+
} else if (result.status === QueryStatus.Success) {
80+
setPost(null);
81+
}
5882
} catch {
59-
return false;
83+
// Ignore malformed bundle, wait for next emission or completion.
6084
}
61-
});
62-
63-
setPost(match ? decodeV2PostBundle(match) : null);
64-
} catch (e) {
65-
if (!cancelled) {
66-
setError(e instanceof Error ? e : new Error(String(e)));
85+
} else if (result.status === QueryStatus.Success) {
86+
setPost(null);
6787
}
68-
} finally {
69-
if (!cancelled) setIsLoading(false);
70-
}
71-
})();
88+
setIsLoading(result.status === QueryStatus.Loading);
89+
},
90+
error: (message: string) => {
91+
setError(new Error(message));
92+
setIsLoading(false);
93+
},
94+
complete: () => {
95+
setIsLoading(false);
96+
},
97+
});
7298

73-
return () => {
74-
cancelled = true;
75-
};
76-
}, [client, identityId, keyFingerprint, sequence]);
99+
return cleanup;
100+
}, [client, identityId, keyFingerprint, sequence, cleanup]);
77101

78102
return { post, isLoading, error };
79103
}

apps/polycentric/src/features/post/hooks/useThread.ts

Lines changed: 68 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
import { useEffect, useState } from 'react';
2-
import { COLLECTION, v2 } from '@polycentric/react-native';
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
import {
3+
COLLECTION,
4+
QueryStatus,
5+
v2,
6+
type EventKey,
7+
} from '@polycentric/react-native';
38
import {
49
decodeV2PostBundle,
510
useLocalPostInjection,
611
usePolycentricContext,
712
type PostData,
813
} from '@/src/common/lib/polycentric-hooks';
914

15+
type Sub = { unsubscribe: () => void };
16+
1017
/**
1118
* Load the thread for a given post via the server's `GetPostThread` RPC.
1219
* The server returns a flat ordered list — ancestors (root → direct parent),
@@ -24,48 +31,76 @@ export function useThread(
2431
const [isLoading, setIsLoading] = useState(false);
2532
const [error, setError] = useState<Error | null>(null);
2633

34+
// Hold the live subscription so we can cancel on unmount / refresh /
35+
// post change. The rust core fans out to every configured server
36+
// and pushes one `next` per server, then a single `complete`.
37+
const subscriptionRef = useRef<Sub | null>(null);
38+
39+
const cleanup = useCallback(() => {
40+
subscriptionRef.current?.unsubscribe();
41+
subscriptionRef.current = null;
42+
}, []);
43+
2744
useEffect(() => {
45+
cleanup();
2846
if (!post) {
2947
setThread([]);
48+
setIsLoading(false);
49+
setError(null);
3050
return;
3151
}
3252

33-
let cancelled = false;
34-
setIsLoading(true);
53+
setThread([]);
3554
setError(null);
55+
setIsLoading(true);
3656

37-
(async () => {
38-
try {
39-
const response = await client.getPostThread({
40-
eventKey: v2.EventKey.create({
41-
collection: COLLECTION.FEED,
42-
identity: post.identity,
43-
signedBy: post.signedBy,
44-
sequence: BigInt(post.sequence),
45-
}),
46-
limit: options?.limit ?? null,
47-
});
48-
if (cancelled) return;
57+
// `post.signedBy.key` is a Uint8Array view into the wire-decoded
58+
// message buffer (protobuf-ts uses subarray). `.buffer` would be
59+
// the whole message buffer, not just the key bytes — copy through
60+
// `.slice()` so the FFI receives exactly the public-key bytes.
61+
const keyBytes = post.signedBy.key.slice().buffer as ArrayBuffer;
62+
const eventKey: EventKey = {
63+
collection: COLLECTION.FEED,
64+
identity: post.identity,
65+
signedBy: {
66+
keyType: post.signedBy.keyType,
67+
key: keyBytes,
68+
},
69+
sequence: BigInt(post.sequence),
70+
};
4971

50-
const decoded: PostData[] = [];
51-
for (const bundle of response?.thread ?? []) {
52-
const d = decodeV2PostBundle(bundle);
53-
if (d) decoded.push(d);
54-
}
55-
setThread(decoded);
56-
} catch (e) {
57-
if (!cancelled) {
58-
setError(e instanceof Error ? e : new Error(String(e)));
72+
const observable = client.core.getPostThread(
73+
eventKey,
74+
options?.limit ?? 0,
75+
undefined,
76+
);
77+
78+
subscriptionRef.current = observable.subscribe({
79+
next: (result) => {
80+
if (result.data) {
81+
const response = v2.GetPostThreadResponse.fromBinary(
82+
new Uint8Array(result.data),
83+
);
84+
const decoded: PostData[] = [];
85+
for (const bundle of response.thread) {
86+
const d = decodeV2PostBundle(bundle);
87+
if (d) decoded.push(d);
88+
}
89+
setThread(decoded);
5990
}
60-
} finally {
61-
if (!cancelled) setIsLoading(false);
62-
}
63-
})();
91+
setIsLoading(result.status === QueryStatus.Loading);
92+
},
93+
error: (message: string) => {
94+
setError(new Error(message));
95+
setIsLoading(false);
96+
},
97+
complete: () => {
98+
setIsLoading(false);
99+
},
100+
});
64101

65-
return () => {
66-
cancelled = true;
67-
};
68-
}, [client, post, options?.limit]);
102+
return cleanup;
103+
}, [client, post, options?.limit, cleanup]);
69104

70105
useLocalPostInjection({
71106
enabled: !!post,

apps/polycentric/src/features/profile/EditProfileScreen.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@/src/common/lib/sheet';
1414
import { Atoms } from '@/src/common/theme';
1515
import { useProfile } from '@/src/features/profile/hooks/useProfile';
16+
import { FetchMode } from '@polycentric/react-native';
1617
import { router, useLocalSearchParams } from 'expo-router';
1718
import { useCallback } from 'react';
1819
import { View } from 'react-native';
@@ -25,7 +26,7 @@ function EditProfileSheet({
2526
dismissSheet: DismissSheet;
2627
}) {
2728
const fallbackUsername = useUsername(identityKey);
28-
const profile = useProfile(identityKey);
29+
const profile = useProfile(identityKey, { fetchMode: FetchMode.Default });
2930
const username = profile.name ?? fallbackUsername;
3031
const edit = useProfileEdit(username, profile);
3132

apps/polycentric/src/features/profile/ProfileHeader.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { Atoms } from '@/src/common/theme';
1515
import { FeedChip } from '@/src/features/post/FeedChip';
1616
import { useProfile } from '@/src/features/profile/hooks/useProfile';
17+
import { FetchMode } from '@polycentric/react-native';
1718
import { router } from 'expo-router';
1819
import { memo, useCallback } from 'react';
1920
import { View } from 'react-native';
@@ -32,7 +33,7 @@ function ProfileHeaderInner({ bannerColors, onBack }: ProfileHeaderProps) {
3233
useProfileContext();
3334

3435
const fallbackUsername = useUsername(identityKey);
35-
const profile = useProfile(identityKey);
36+
const profile = useProfile(identityKey, { fetchMode: FetchMode.Default });
3637
const username = profile.name ?? fallbackUsername;
3738

3839
const short = identityKey ? shortenIdentityId(identityKey) : '...';

apps/polycentric/src/features/profile/hooks/useProfile.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useEffect } from 'react';
2-
import { v2 } from '@polycentric/react-native';
2+
import { FetchMode, v2 } from '@polycentric/react-native';
33
import { usePolycentric } from '@/src/common/lib/polycentric-hooks';
44
import useProfiles, { emptyProfile } from './useProfiles';
55

@@ -13,22 +13,31 @@ export interface ProfileHookResult {
1313
refresh: () => void;
1414
}
1515

16+
export interface UseProfileOptions {
17+
/**
18+
* Defaults to `OfflineOnly` (will not fetch unless its found in caches)
19+
*/
20+
fetchMode?: FetchMode;
21+
}
22+
1623
export function useProfile(
1724
identityKey: string | null | undefined,
25+
options?: UseProfileOptions,
1826
): ProfileHookResult {
1927
const client = usePolycentric();
28+
const fetchMode = options?.fetchMode ?? FetchMode.OfflineOnly;
2029
const profile = useProfiles((s) =>
2130
identityKey ? s.profiles.get(identityKey) : undefined,
2231
);
2332

2433
useEffect(() => {
2534
if (!identityKey) return;
26-
void useProfiles.getState().fetchProfile(client, identityKey);
27-
}, [client, identityKey]);
35+
useProfiles.getState().fetchProfile(client, identityKey, fetchMode);
36+
}, [client, identityKey, fetchMode]);
2837

2938
const refresh = useCallback(() => {
3039
if (!identityKey) return;
31-
void useProfiles.getState().fetchProfile(client, identityKey, true);
40+
useProfiles.getState().fetchProfile(client, identityKey, FetchMode.Default);
3241
}, [client, identityKey]);
3342

3443
const data = profile ?? emptyProfile(identityKey ?? '');

0 commit comments

Comments
 (0)