|
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'; |
3 | 3 | import { |
4 | | - bytesToHex, |
5 | 4 | decodeV2PostBundle, |
6 | | - hexToBytes, |
7 | 5 | usePolycentricContext, |
8 | 6 | type PostData, |
9 | 7 | } from '@/src/common/lib/polycentric-hooks'; |
10 | 8 | import { getKeyFingerprint } from '@/src/common/lib/polycentric-hooks/helpers'; |
11 | 9 |
|
| 10 | +type Sub = { unsubscribe: () => void }; |
| 11 | + |
12 | 12 | /** |
13 | 13 | * Load a single post by its (identity, sequence) route params. |
14 | 14 | * |
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. |
18 | 21 | */ |
19 | 22 | export function usePostById( |
20 | 23 | identityId: string | undefined, |
21 | 24 | keyFingerprint: string | undefined, |
22 | | - sequence: BigInt | undefined, |
| 25 | + sequence: bigint | undefined, |
23 | 26 | ): { post: PostData | null; isLoading: boolean; error: Error | null } { |
24 | 27 | const { client } = usePolycentricContext(); |
25 | 28 | const [post, setPost] = useState<PostData | null>(null); |
26 | 29 | const [isLoading, setIsLoading] = useState(false); |
27 | 30 | const [error, setError] = useState<Error | null>(null); |
28 | 31 |
|
| 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 | + |
29 | 39 | useEffect(() => { |
30 | | - if (!keyFingerprint || !identityId || !sequence) { |
| 40 | + cleanup(); |
| 41 | + if (!identityId || sequence == null || !keyFingerprint) { |
31 | 42 | setPost(null); |
| 43 | + setIsLoading(false); |
| 44 | + setError(null); |
32 | 45 | return; |
33 | 46 | } |
34 | 47 |
|
35 | | - let cancelled = false; |
36 | | - setIsLoading(true); |
| 48 | + setPost(null); |
37 | 49 | setError(null); |
| 50 | + setIsLoading(true); |
38 | 51 |
|
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 | + ); |
48 | 58 |
|
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) { |
51 | 62 | 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), |
57 | 65 | ); |
| 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 | + } |
58 | 82 | } catch { |
59 | | - return false; |
| 83 | + // Ignore malformed bundle, wait for next emission or completion. |
60 | 84 | } |
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); |
67 | 87 | } |
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 | + }); |
72 | 98 |
|
73 | | - return () => { |
74 | | - cancelled = true; |
75 | | - }; |
76 | | - }, [client, identityId, keyFingerprint, sequence]); |
| 99 | + return cleanup; |
| 100 | + }, [client, identityId, keyFingerprint, sequence, cleanup]); |
77 | 101 |
|
78 | 102 | return { post, isLoading, error }; |
79 | 103 | } |
0 commit comments