Skip to content

Commit e66c737

Browse files
committed
Improve post threading
1 parent b0fb3a3 commit e66c737

15 files changed

Lines changed: 371 additions & 224 deletions

File tree

apps/polycentric/src/common/constants/routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { router } from 'expo-router';
2-
import { EventKeyRef, PostData } from '../lib/polycentric-hooks';
2+
import { PostData } from '../lib/polycentric-hooks';
33

44
export type OpenComposeOptions = {
55
replyTo?: PostData['id'];

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

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,6 @@ export function fromBase64(base64: string): Uint8Array {
1313
return bytes;
1414
}
1515

16-
/**
17-
* BigInt-free mirror of `v2.EventKey`. The proto's `sequence` is a `bigint`,
18-
* which crashes React Native's render logger when any component receives it
19-
* as a prop — so we carry the sequence as a decimal string through the
20-
* UI/state layer and only convert back to `bigint` when talking to the core.
21-
*/
22-
export interface EventKeyRef {
23-
collection?: number;
24-
identity: string;
25-
signedBy?: v2.PublicKey;
26-
sequence: string;
27-
}
28-
2916
export type PostData = {
3017
/** Hex of the event key */
3118
id: string;
@@ -42,30 +29,22 @@ export type PostData = {
4229

4330
/** Set when the underlying `v2.Post` carried a `reply`. */
4431
reply?: {
45-
root?: EventKeyRef;
46-
parent?: EventKeyRef;
32+
/** Hex of the root post's EventKey — same encoding as `PostData.id`. */
33+
rootId?: string;
34+
/** Hex of the parent post's EventKey — same encoding as `PostData.id`. */
35+
parentId?: string;
4736
};
4837

4938
signedEvent: v2.SignedEvent;
5039
};
5140

52-
function decodeEventKey(key: v2.EventKey | undefined): EventKeyRef | undefined {
53-
if (!key) return undefined;
54-
return {
55-
collection: key.collection,
56-
identity: key.identity,
57-
signedBy: key.signedBy,
58-
sequence: key.sequence.toString(),
59-
};
60-
}
61-
6241
// A key fingerprint is the first 16 characters of the hex bytes of the key contents
6342
// It does not include the key type.
6443
export function getKeyFingerprint(key?: v2.PublicKey): string | undefined {
6544
if (!key) {
6645
return undefined;
6746
}
68-
return key.key.toHex().substring(0, 16);
47+
return bytesToHex(key.key).substring(0, 16);
6948
}
7049

7150
/** Decode a v2 EventBundle into PostData, or null if not a post. */
@@ -82,13 +61,17 @@ export function decodePostBundle(bundle: v2.EventBundle): PostData | null {
8261
);
8362
if (content.contentBody.oneofKind !== 'post') return null;
8463

85-
const id = v2.EventKey.toBinary(key).toHex();
64+
const id = bytesToHex(v2.EventKey.toBinary(key));
8665

8766
const post = content.contentBody.post;
8867
const reply = post.reply
8968
? {
90-
root: decodeEventKey(post.reply.root),
91-
parent: decodeEventKey(post.reply.parent),
69+
rootId: post.reply.root
70+
? bytesToHex(v2.EventKey.toBinary(post.reply.root))
71+
: undefined,
72+
parentId: post.reply.parent
73+
? bytesToHex(v2.EventKey.toBinary(post.reply.parent))
74+
: undefined,
9275
}
9376
: undefined;
9477

@@ -106,7 +89,8 @@ export function decodePostBundle(bundle: v2.EventBundle): PostData | null {
10689
signature: bundle.signedEvent.signature,
10790
}),
10891
};
109-
} catch {
92+
} catch (e) {
93+
console.warn('[decodePostBundle] drop: decode threw', e);
11094
return null;
11195
}
11296
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export {
2323
type ProfileEditState,
2424
} from '../../../features/profile/hooks/useProfileEdit';
2525

26+
// Local post injection (composer → live feeds)
27+
export { useLocalPosts as useLocalPostInjection } from '../../../features/post/hooks/useLocalPosts';
28+
2629
// Helpers
2730
export {
2831
decodePostBundle as decodeV2PostBundle,
@@ -45,4 +48,4 @@ export {
4548
toBase64,
4649
fromBase64,
4750
} from './helpers';
48-
export type { EventKeyRef, PostData } from './helpers';
51+
export type { PostData } from './helpers';

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
TextArea,
77
} from '@/src/common/components/primitives';
88
import {
9+
hexToBytes,
910
truncateName,
1011
useCurrentIdentity,
1112
usePolycentric,
@@ -67,6 +68,12 @@ export function ComposeSheetInner({
6768
sequence: BigInt(replyTo?.sequence ?? 0),
6869
});
6970

71+
// If replyTo is itself a reply, inherit its root.
72+
// Otherwise, replyTo *is* the root.
73+
const replyRootEventKey = replyTo?.reply?.rootId
74+
? v2.EventKey.fromBinary(hexToBytes(replyTo.reply.rootId))
75+
: replyToEventKey;
76+
7077
const replyAuthorName = useUsername(replyTo?.identity ?? null);
7178
const replyContent = replyTo?.content ?? '';
7279
const replyContentPreview =
@@ -156,7 +163,7 @@ export function ComposeSheetInner({
156163

157164
if (isReply) {
158165
post.reply = {
159-
root: replyToEventKey,
166+
root: replyRootEventKey,
160167
parent: replyToEventKey,
161168
};
162169
}
@@ -199,6 +206,7 @@ export function ComposeSheetInner({
199206
dismissSheet,
200207
isReply,
201208
replyToEventKey,
209+
replyRootEventKey,
202210
resetComposer,
203211
setSubmitting,
204212
setError,

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

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useCallback, useEffect, useRef, useState } from 'react';
2-
import { v2 } from '@polycentric/react-native';
32
import {
43
decodeV2PostBundle,
4+
useLocalPostInjection,
55
usePolycentricContext,
66
type PostData,
77
} from '@/src/common/lib/polycentric-hooks';
@@ -48,32 +48,14 @@ export function useAuthorFeed(
4848
if (identityId) fetchFeed();
4949
}, [identityId, fetchFeed]);
5050

51-
// Inject locally-committed posts by this author as soon as they're
52-
// committed, before the server round-trip.
53-
useEffect(() => {
54-
if (!identityId) return;
55-
const listener = ({
56-
signedEvent,
57-
content,
58-
}: {
59-
signedEvent: v2.SignedEvent;
60-
content?: v2.Content;
61-
}) => {
62-
if (!content) return;
63-
const decoded = decodeV2PostBundle(
64-
v2.EventBundle.create({
65-
signedEvent,
66-
serializedContent: { contentBytes: v2.Content.toBinary(content) },
67-
}),
68-
);
69-
if (!decoded || decoded.identity !== identityId) return;
51+
useLocalPostInjection({
52+
enabled: !!identityId,
53+
match: (p) => p.identity === identityId,
54+
insert: (decoded) =>
7055
setItems((prev) =>
7156
prev.some((p) => p.id === decoded.id) ? prev : [decoded, ...prev],
72-
);
73-
};
74-
client.events.onContentCreated(listener);
75-
return () => client.events.offContentCreated(listener);
76-
}, [client, identityId]);
57+
),
58+
});
7759

7860
return {
7961
items,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function useExploreFeed(options?: {
2929
}
3030
setItems(posts);
3131
} catch (e) {
32+
console.warn('[useExploreFeed] fetch failed', e);
3233
setError(e instanceof Error ? e : new Error(String(e)));
3334
} finally {
3435
setIsLoading(false);

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

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
22
import { v2 } from '@polycentric/react-native';
33
import {
44
decodeV2PostBundle,
5+
useLocalPostInjection,
56
usePolycentricContext,
67
type PostData,
78
} from '@/src/common/lib/polycentric-hooks';
@@ -48,31 +49,13 @@ export function useFollowingFeed(options?: {
4849
if (enabled) fetchFeed();
4950
}, [enabled, fetchFeed]);
5051

51-
// Add locally-committed posts to the top of the feed as soon as
52-
// they're committed — before the server round-trip.
53-
useEffect(() => {
54-
const listener = ({
55-
signedEvent,
56-
content,
57-
}: {
58-
signedEvent: v2.SignedEvent;
59-
content?: v2.Content;
60-
}) => {
61-
if (!content) return;
62-
const decoded = decodeV2PostBundle(
63-
v2.EventBundle.create({
64-
signedEvent,
65-
serializedContent: { contentBytes: v2.Content.toBinary(content) },
66-
}),
67-
);
68-
if (!decoded) return;
52+
useLocalPostInjection({
53+
match: () => true,
54+
insert: (decoded) =>
6955
setItems((prev) =>
7056
prev.some((p) => p.id === decoded.id) ? prev : [decoded, ...prev],
71-
);
72-
};
73-
client.events.onContentCreated(listener);
74-
return () => client.events.offContentCreated(listener);
75-
}, [client]);
57+
),
58+
});
7659

7760
return {
7861
items,

apps/polycentric/src/features/post/ConversationView.tsx

Lines changed: 27 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
1-
import { useCallback, useMemo, useState } from 'react';
2-
import {
3-
RefreshControl,
4-
StyleSheet,
5-
useWindowDimensions,
6-
View,
7-
} from 'react-native';
1+
import { useCallback, useState } from 'react';
2+
import { RefreshControl, useWindowDimensions, View } from 'react-native';
83
import { FlashList } from '@shopify/flash-list';
94
import { type PostData } from '@/src/common/lib/polycentric-hooks';
105
import { Post } from './Post';
11-
import { usePostById } from './hooks/usePostById';
12-
import { useThreadReplies } from './hooks/useThreadReplies';
6+
import { useThread } from './hooks/useThread';
137
import { Atoms } from '@/src/common/theme';
148
import { ComposerInput } from '../composer';
15-
import { getKeyFingerprint } from '@/src/common/lib/polycentric-hooks/helpers';
169

1710
interface ConversationViewProps {
1811
post: PostData;
@@ -22,27 +15,10 @@ export function ConversationView({ post }: ConversationViewProps) {
2215
const [refreshing, setRefreshing] = useState(false);
2316
const { height: windowHeight } = useWindowDimensions();
2417

25-
const { post: rootPost } = usePostById(
26-
post.reply?.root?.identity,
27-
getKeyFingerprint(post.reply?.root?.signedBy),
28-
BigInt(post.reply?.root?.sequence || ''),
29-
);
30-
const { post: parentPost } = usePostById(
31-
post.reply?.parent?.identity,
32-
getKeyFingerprint(post.reply?.parent?.signedBy),
33-
BigInt(post.reply?.parent?.sequence || ''),
34-
);
35-
const { replies } = useThreadReplies(post);
18+
const { thread } = useThread(post);
3619

37-
const items = useMemo(() => {
38-
const list: PostData[] = [];
39-
if (rootPost) list.push(rootPost);
40-
// Skip parent if it's the same as root (thread of depth 1).
41-
if (parentPost && parentPost.id !== rootPost?.id) list.push(parentPost);
42-
list.push(post);
43-
list.push(...replies);
44-
return list;
45-
}, [rootPost, parentPost, post, replies]);
20+
// Fall back to rendering just the subject until the server response lands.
21+
const items = thread.length > 0 ? thread : [post];
4622

4723
const handleRefresh = useCallback(() => {
4824
setRefreshing(true);
@@ -53,21 +29,27 @@ export function ConversationView({ post }: ConversationViewProps) {
5329
<FlashList
5430
data={items}
5531
keyExtractor={(p) => p.id}
56-
renderItem={({ item, index }) => (
57-
<View style={[Atoms.w_full]}>
58-
<Post
59-
post={item}
60-
hideReplyingTo={false}
61-
disablePress={item.id === post.id}
62-
showThreadLineAbove={item.id === post.id && !!item.reply?.parent}
63-
showThreadLineBelow={
64-
index < items.length - 1 && !!post.reply?.parent
65-
}
66-
hideBottomBorder={index < items.length - 1 && !!post.reply?.parent}
67-
/>
68-
{item.id === post.id ? <ComposerInput replyTo={post.id} /> : null}
69-
</View>
70-
)}
32+
renderItem={({ item, index }) => {
33+
const above = index > 0 ? items[index - 1] : null;
34+
const below = index < items.length - 1 ? items[index + 1] : null;
35+
const lineAbove =
36+
!!above && item.reply?.parentId === above.id && above.id !== post.id;
37+
const lineBelow = !!below && below.reply?.parentId === item.id;
38+
39+
return (
40+
<View style={[Atoms.w_full]}>
41+
<Post
42+
post={item}
43+
hideReplyingTo={false}
44+
disablePress={item.id === post.id}
45+
showThreadLineAbove={lineAbove}
46+
showThreadLineBelow={lineBelow}
47+
hideBottomBorder={lineBelow}
48+
/>
49+
{item.id === post.id ? <ComposerInput replyTo={post.id} /> : null}
50+
</View>
51+
);
52+
}}
7153
ListFooterComponent={<View style={{ height: windowHeight }} />}
7254
refreshControl={
7355
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />

apps/polycentric/src/features/post/Post.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,7 @@ export const Post = memo(function Post({
9999
}, [authorIdentity]);
100100

101101
const handleReply = useCallback(() => {
102-
// if (!authorIdentity) return;
103-
// const sequence = postIdToSequence(postId);
104-
// if (!sequence) return;
105-
// openCompose({ replyTo: { identityId: authorIdentity, sequence } });
102+
openCompose({ replyTo: post.id });
106103
}, [authorIdentity, post]);
107104

108105
const handleLike = useCallback(() => {}, []);

0 commit comments

Comments
 (0)