Skip to content

Commit 8c8de1c

Browse files
committed
Merge branch 'mh/reposts' into 'develop'
Implement Repost feature See merge request polycentric/polycentric!524
2 parents cfc43ed + 2d1c916 commit 8c8de1c

29 files changed

Lines changed: 644 additions & 234 deletions

apps/polycentric/app/_layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Atoms, ThemeProvider, useTheme } from '@/src/common/theme';
33
import { isWeb } from '@/src/common/util/platform';
44
import '@/src/common/util/react-native-screens-feature-flags';
55
import { TrueSheetProvider } from '@lodev09/react-native-true-sheet';
6+
import { PortalHost } from '@rn-primitives/portal';
67
import { Stack } from 'expo-router';
78
import * as SplashScreen from 'expo-splash-screen';
89
import { StatusBar } from 'expo-status-bar';
@@ -79,6 +80,7 @@ export default function RootLayout() {
7980
<PolycentricProvider onInitialized={onInitialized}>
8081
<TrueSheetProvider>
8182
<RootStack />
83+
<PortalHost />
8284
</TrueSheetProvider>
8385
</PolycentricProvider>
8486
</ThemeProvider>

apps/polycentric/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@react-navigation/elements": "^2.8.0",
2828
"@react-navigation/native": "^7.1.32",
2929
"@rn-primitives/dropdown-menu": "^1.4.0",
30+
"@rn-primitives/portal": "^1.4.0",
3031
"@shopify/flash-list": "^2.3.1",
3132
"@types/react-native-web": "^0.19.2",
3233
"better-sqlite3": "^12.6.2",

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

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as DropdownMenuPrimitive from '@rn-primitives/dropdown-menu';
2-
import React, { ReactNode, useState } from 'react';
3-
import { View } from 'react-native';
2+
import React, { ReactNode } from 'react';
3+
import { StyleSheet, View } from 'react-native';
44

55
import { Atoms, useTheme } from '../theme';
66

@@ -13,17 +13,17 @@ function DropdownMenuContent({
1313

1414
return (
1515
<DropdownMenuPrimitive.Portal>
16-
<DropdownMenuPrimitive.Overlay>
16+
<DropdownMenuPrimitive.Overlay style={StyleSheet.absoluteFill}>
1717
<DropdownMenuPrimitive.Content
18-
style={(state) => [
18+
style={StyleSheet.flatten([
1919
{ borderWidth: 1, borderColor: theme.palette.neutral_50 },
2020
Atoms.rounded_lg,
21-
{ minWidth: 256 },
22-
//Atoms.outline_none,
21+
{ minWidth: 225 },
22+
Atoms.outline_none,
2323
{ backgroundColor: theme.palette.neutral_25 },
2424
Atoms.overflow_hidden,
25-
typeof style === 'function' ? style(state) : style,
26-
]}
25+
typeof style === 'function' ? undefined : style,
26+
])}
2727
{...props}
2828
/>
2929
</DropdownMenuPrimitive.Overlay>
@@ -37,27 +37,27 @@ function DropdownMenuItem({
3737
...props
3838
}: DropdownMenuPrimitive.ItemProps & { children: ReactNode }) {
3939
const { theme } = useTheme();
40-
const [hovered, setHovering] = useState(false);
4140

4241
return (
43-
<DropdownMenuPrimitive.Item
44-
style={{ outline: 'none' }}
45-
onHoverIn={() => setHovering(true)}
46-
onHoverOut={() => setHovering(false)}
47-
{...props}
48-
>
49-
<View
50-
style={[
51-
Atoms.py_md,
52-
Atoms.px_lg,
53-
hovered && { backgroundColor: theme.palette.neutral_50 },
54-
Atoms.flex_row,
55-
Atoms.align_center,
56-
Atoms.gap_lg,
57-
]}
58-
>
59-
{children}
60-
</View>
42+
<DropdownMenuPrimitive.Item style={Atoms.outline_none} {...props}>
43+
{({ pressed, hovered }) => (
44+
<View
45+
style={[
46+
Atoms.py_md,
47+
Atoms.px_lg,
48+
// Press on native mirrors hover on web — same highlight, so items
49+
// give feedback on touch devices that have no hover state.
50+
(hovered || pressed) && {
51+
backgroundColor: theme.palette.neutral_50,
52+
},
53+
Atoms.flex_row,
54+
Atoms.align_center,
55+
Atoms.gap_lg,
56+
]}
57+
>
58+
{children}
59+
</View>
60+
)}
6161
</DropdownMenuPrimitive.Item>
6262
);
6363
}

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ export type PostData = {
3838
/** Hex of the quoted post's EventKey — same encoding as `PostData.id`. */
3939
quoteId?: string;
4040

41+
/** Identity that reposted this post, when this item represents a
42+
* repost. The rest of the fields are the *reposted* post's data. */
43+
repostedBy?: string;
44+
/** Hex of the repost event's own EventKey — used as the feed list
45+
* key so a repost is distinct from the original post. */
46+
repostId?: string;
47+
4148
signedEvent: v2.SignedEvent;
4249
};
4350

@@ -102,6 +109,71 @@ export function decodePostBundle(bundle: v2.EventBundle): PostData | null {
102109
}
103110
}
104111

112+
/** A repost event decoded into who reposted, the target post's id, and
113+
* the repost event's own id. `null` if the bundle isn't a repost. */
114+
function decodeRepostBundle(bundle: v2.EventBundle): {
115+
repostedBy: string;
116+
targetId: string;
117+
repostId: string;
118+
} | null {
119+
try {
120+
if (!bundle.signedEvent) return null;
121+
const event = v2.Event.fromBinary(bundle.signedEvent.eventBytes);
122+
const key = event.key;
123+
if (!key?.signedBy?.key) return null;
124+
if (!bundle.serializedContent?.contentBytes) return null;
125+
const content = v2.Content.fromBinary(
126+
bundle.serializedContent.contentBytes,
127+
);
128+
if (content.contentBody.oneofKind !== 'repost') return null;
129+
const target = content.contentBody.repost.post;
130+
if (!target) return null;
131+
return {
132+
repostedBy: key.identity,
133+
targetId: bytesToHex(v2.EventKey.toBinary(target)),
134+
repostId: bytesToHex(v2.EventKey.toBinary(key)),
135+
};
136+
} catch {
137+
return null;
138+
}
139+
}
140+
141+
/**
142+
* Decode a `GetFeedResponse` into renderable posts. Plain posts decode
143+
* directly; reposts resolve their target post from `event_hints` (the
144+
* server ships the reposted post alongside) and surface it tagged with
145+
* `repostedBy`. A repost whose target isn't in the hints is dropped.
146+
*/
147+
export function decodeFeedItems(response: v2.GetFeedResponse): PostData[] {
148+
const hintPosts = new Map<string, PostData>();
149+
for (const hint of response.eventHints) {
150+
if (!hint.eventBundle) continue;
151+
const post = decodePostBundle(hint.eventBundle);
152+
if (post) hintPosts.set(post.id, post);
153+
}
154+
155+
const items: PostData[] = [];
156+
for (const bundle of response.eventBundles) {
157+
const post = decodePostBundle(bundle);
158+
if (post) {
159+
items.push(post);
160+
continue;
161+
}
162+
const repost = decodeRepostBundle(bundle);
163+
if (repost) {
164+
const target = hintPosts.get(repost.targetId);
165+
if (target) {
166+
items.push({
167+
...target,
168+
repostedBy: repost.repostedBy,
169+
repostId: repost.repostId,
170+
});
171+
}
172+
}
173+
}
174+
return items;
175+
}
176+
105177
/**
106178
* Dicebear identicon URL for a public key.
107179
*/

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export {
2727
// Helpers
2828
export {
2929
decodePostBundle as decodeV2PostBundle,
30+
decodeFeedItems,
3031
pubkeyStr,
3132
identiconUrl,
3233
pickImageVariant,

apps/polycentric/src/common/theme/tokens.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { StyleSheet, TextStyle } from 'react-native';
1+
import { StyleSheet, TextStyle, ViewStyle } from 'react-native';
22

33
export const Spacing = {
44
'0': 0,
@@ -210,6 +210,10 @@ export const Atoms = StyleSheet.create({
210210
textAlign: 'right',
211211
},
212212

213+
text_underline: {
214+
textDecorationLine: 'underline',
215+
},
216+
213217
font_normal: {
214218
fontWeight: 400,
215219
},
@@ -647,4 +651,15 @@ export const Atoms = StyleSheet.create({
647651
rounded_full: {
648652
borderRadius: BorderRadius.full,
649653
},
654+
655+
/**
656+
* Outline
657+
*/
658+
// `outlineStyle: 'none'` is web-only and not in RN's ViewStyle union, so cast
659+
// (as TextInput does). On web this fully removes the focus ring that
660+
// `outlineWidth: 0` alone leaves behind; on native both props are ignored.
661+
outline_none: {
662+
outlineStyle: 'none',
663+
outlineWidth: 0,
664+
} as unknown as ViewStyle,
650665
});

apps/polycentric/src/features/feed/FeedList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export type FeedListProps = Omit<ListProps<PostData>, 'data' | 'renderItem'> & {
1818
renderItem?: ListProps<PostData>['renderItem'];
1919
};
2020

21-
const defaultKeyExtractor = (item: PostData) => item.id;
21+
const defaultKeyExtractor = (item: PostData) => item.repostId ?? item.id;
2222

2323
const defaultRenderItem: ListRenderItem<PostData> = ({ item }) => (
2424
<Post post={item} />

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

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { useMemo } from 'react';
22
import { Query, QueryStatus, v2 } from '@polycentric/react-native';
33
import {
4-
decodeV2PostBundle,
5-
PostData,
4+
decodeFeedItems,
65
usePolycentricContext,
76
} from '@/src/common/lib/polycentric-hooks';
87
import { type FeedHookResult, NOOP } from './types';
@@ -31,13 +30,7 @@ export function useExploreFeed(options?: {
3130
return [];
3231
}
3332
const response = v2.GetFeedResponse.fromBinary(new Uint8Array(query.data));
34-
const items: PostData[] = [];
35-
for (const bundle of response.eventBundles) {
36-
const decoded = decodeV2PostBundle(bundle);
37-
if (decoded) items.push(decoded);
38-
}
39-
40-
return items;
33+
return decodeFeedItems(response);
4134
}, [query.data]);
4235

4336
return {

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

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { useCallback, useEffect, useMemo, useRef } from 'react';
22
import { Query, QueryStatus, v2 } from '@polycentric/react-native';
33
import {
4-
decodeV2PostBundle,
5-
PostData,
4+
decodeFeedItems,
65
usePolycentricContext,
76
} from '@/src/common/lib/polycentric-hooks';
87
import { type FeedHookResult, NOOP } from './types';
@@ -29,13 +28,7 @@ export function useFollowingFeed(options?: {
2928
return [];
3029
}
3130
const response = v2.GetFeedResponse.fromBinary(new Uint8Array(query.data));
32-
const items: PostData[] = [];
33-
for (const bundle of response.eventBundles) {
34-
const decoded = decodeV2PostBundle(bundle);
35-
if (decoded) items.push(decoded);
36-
}
37-
38-
return items;
31+
return decodeFeedItems(response);
3932
}, [query.data]);
4033

4134
return {

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

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import { useMemo } from 'react';
22
import { Query, QueryStatus, v2 } from '@polycentric/react-native';
3-
import {
4-
decodeV2PostBundle,
5-
PostData,
6-
} from '@/src/common/lib/polycentric-hooks';
3+
import { decodeFeedItems } from '@/src/common/lib/polycentric-hooks';
74
import { type FeedHookResult, NOOP } from './types';
85
import { useQuery } from '@/src/common/query/hooks/useQuery';
96
import { feedQueryKeys } from './feedCache';
@@ -26,13 +23,7 @@ export function useIdentityFeed(
2623
return [];
2724
}
2825
const response = v2.GetFeedResponse.fromBinary(new Uint8Array(query.data));
29-
const items: PostData[] = [];
30-
for (const bundle of response.eventBundles) {
31-
const decoded = decodeV2PostBundle(bundle);
32-
if (decoded) items.push(decoded);
33-
}
34-
35-
return items;
26+
return decodeFeedItems(response);
3627
}, [query.data]);
3728

3829
return {

0 commit comments

Comments
 (0)