Skip to content

Commit 7873711

Browse files
committed
Emoji reactions
1 parent f08b004 commit 7873711

27 files changed

Lines changed: 14160 additions & 225 deletions

File tree

.vscode/settings.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"editor.formatOnSave": true,
99
"editor.codeActionsOnSave": {
1010
"source.fixAll": "explicit",
11-
"source.organizeImports": "explicit",
12-
"source.sortMembers": "explicit"
11+
"source.organizeImports": "never",
12+
"source.sortMembers": "never"
1313
}
1414
}

apps/polycentric/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
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/hover-card": "^1.4.0",
31+
"@rn-primitives/popover": "^1.4.0",
3032
"@rn-primitives/portal": "^1.4.0",
3133
"@shopify/flash-list": "^2.3.1",
3234
"@types/react-native-web": "^0.19.2",

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ function DropdownMenuItem({
6262
);
6363
}
6464

65-
function DropdownMenu({ children }: { children: ReactNode }) {
65+
function DropdownMenu({ children }: DropdownMenuPrimitive.RootProps) {
6666
return <DropdownMenuPrimitive.Root>{children}</DropdownMenuPrimitive.Root>;
6767
}
6868
DropdownMenu.Trigger = DropdownMenuPrimitive.Trigger;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as HoverCardPrimitive from '@rn-primitives/hover-card';
2+
import { StyleSheet, View } from 'react-native';
3+
import Animated, { BounceIn, FadeOut } from 'react-native-reanimated';
4+
import { isWeb } from '../util/platform';
5+
6+
export function HoverCardContent({
7+
children,
8+
...props
9+
}: HoverCardPrimitive.ContentProps) {
10+
const content = (
11+
<HoverCardPrimitive.Content {...props}>
12+
<Animated.View
13+
entering={BounceIn.duration(450)}
14+
exiting={FadeOut.duration(100)}
15+
>
16+
{children}
17+
</Animated.View>
18+
</HoverCardPrimitive.Content>
19+
);
20+
21+
return (
22+
<HoverCardPrimitive.Portal>
23+
{isWeb ? (
24+
content
25+
) : (
26+
<HoverCardPrimitive.Overlay style={StyleSheet.absoluteFill}>
27+
{content}
28+
</HoverCardPrimitive.Overlay>
29+
)}
30+
</HoverCardPrimitive.Portal>
31+
);
32+
}
33+
34+
function HoverCard({ children, ...props }: HoverCardPrimitive.RootProps) {
35+
return (
36+
<HoverCardPrimitive.Root {...props}>{children}</HoverCardPrimitive.Root>
37+
);
38+
}
39+
40+
HoverCard.Trigger = HoverCardPrimitive.Trigger;
41+
HoverCard.Content = HoverCardContent;
42+
43+
export type { TriggerRef } from '@rn-primitives/hover-card';
44+
45+
export default HoverCard;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as PopoverPrimitive from '@rn-primitives/popover';
2+
import { ReactNode } from 'react';
3+
4+
export function PopoverContent({ children }: { children: ReactNode }) {
5+
return (
6+
<PopoverPrimitive.Portal>
7+
<PopoverPrimitive.Content>{children}</PopoverPrimitive.Content>
8+
</PopoverPrimitive.Portal>
9+
);
10+
}
11+
12+
function Popover({ children }: { children: ReactNode }) {
13+
return <PopoverPrimitive.Root>{children}</PopoverPrimitive.Root>;
14+
}
15+
16+
Popover.Trigger = PopoverPrimitive.Trigger;
17+
Popover.Content = PopoverContent;
18+
19+
export default Popover;

apps/polycentric/src/common/lib/polycentric-hooks/PolycentricProvider.tsx

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
useStore,
2525
type PolycentricStoreApi,
2626
} from './store';
27+
import useReactions from '@/src/features/reaction/useReactions';
2728

2829
export interface PolycentricContextValue {
2930
client: PolycentricClient;
@@ -172,15 +173,19 @@ export function PolycentricProvider({
172173
// re-refreshes once new events have been pulled in.
173174
useFollows.getState().refresh(c);
174175
useReposts.getState().refresh(c);
175-
void c
176-
.sync()
177-
.then(() => Promise.all([
178-
useFollows.getState().refresh(c),
179-
useReposts.getState().refresh(c)
180-
]))
181-
.catch((syncError) => {
182-
console.warn('Initial Polycentric sync failed:', syncError);
183-
});
176+
(useReactions.getState().refresh(c),
177+
void c
178+
.sync()
179+
.then(() =>
180+
Promise.all([
181+
useFollows.getState().refresh(c),
182+
useReposts.getState().refresh(c),
183+
useReactions.getState().refresh(c),
184+
]),
185+
)
186+
.catch((syncError) => {
187+
console.warn('Initial Polycentric sync failed:', syncError);
188+
}));
184189
}
185190

186191
setIsLoading(false);
@@ -189,15 +194,17 @@ export function PolycentricProvider({
189194
if (cancelled) return;
190195
setCurrentIdentity(await resolveIdentity(c));
191196
await s.getState().refreshIdentities();
197+
// TODO: Cleanup this up
192198
useFollows.getState().refresh(c);
193199
useReposts.getState().refresh(c);
200+
useReactions.getState().refresh(c);
194201
});
195202

196-
// Identity onboarding (create / claim) publishes an Identity event,
197-
// which flows through onContentCreated. Re-resolve so the gate
198-
// flips from onboarding → app once the user completes signup.
199-
c.events.onContentCreated(async () => {
203+
// Identity onboarding (create / claim) publishes an Identity event.
204+
// Set to current identity
205+
c.events.onContentCreated(async ({ content }) => {
200206
if (cancelled) return;
207+
if (content?.contentBody.oneofKind !== 'identity') return;
201208
setCurrentIdentity(await resolveIdentity(c));
202209
});
203210
} catch (err) {
@@ -229,7 +236,6 @@ export function PolycentricProvider({
229236
}, [client]);
230237

231238
const value = useMemo<PolycentricContextValue | null>(() => {
232-
if (!client || !store) return null;
233239
return {
234240
client,
235241
store,

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

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,23 +57,76 @@ export function getKeyFingerprint(key?: v2.PublicKey): string | undefined {
5757
return bytesToHex(key.key).substring(0, 16);
5858
}
5959

60+
/** Every `Content.contentBody` variant that carries a payload — i.e. each
61+
* `oneofKind` except the empty `undefined` case. */
62+
export type ContentKind = Exclude<
63+
v2.Content['contentBody']['oneofKind'],
64+
undefined
65+
>;
66+
67+
/** The payload a given content kind carries. `ContentBodyOf<'follow'>` is
68+
* `v2.Follow`, `ContentBodyOf<'post'>` is `v2.Post`, and so on. Inferred
69+
* via a mapped type rather than `[K]` indexing, which TS rejects for a
70+
* generic key over a discriminated union. */
71+
export type ContentBodyOf<K extends ContentKind> =
72+
Extract<v2.Content['contentBody'], { oneofKind: K }> extends {
73+
[P in K]: infer T;
74+
}
75+
? T
76+
: never;
77+
78+
/** A bundle decoded against a specific content kind: the parsed event, the
79+
* content narrowed to that kind's payload type, and the bundle's raw
80+
* signed event (validated to be present). */
81+
export type DecodedBundle<K extends ContentKind> = {
82+
event: v2.Event;
83+
content: ContentBodyOf<K>;
84+
signedEvent: v2.SignedEvent;
85+
};
86+
87+
/**
88+
* Decode an `EventBundle` as a specific content kind, replacing the
89+
* per-kind `decodeReaction` / `decodeFollow` / `decodePost` helpers.
90+
*
91+
* Returns `null` when the bundle is malformed or carries a different kind.
92+
* The `kind` argument both filters and types the result: the returned
93+
* `content` is narrowed to that kind's payload, so `decodeBundle(b, 'follow')`
94+
* yields a `v2.Follow` and `decodeBundle(b, 'post')` a `v2.Post`.
95+
*/
96+
export function decodeBundle<K extends ContentKind>(
97+
bundle: v2.EventBundle,
98+
kind: K,
99+
): DecodedBundle<K> | null {
100+
if (!bundle.signedEvent || !bundle.serializedContent?.contentBytes) {
101+
return null;
102+
}
103+
let parsed: v2.Content;
104+
let event: v2.Event;
105+
try {
106+
parsed = v2.Content.fromBinary(bundle.serializedContent.contentBytes);
107+
event = v2.Event.fromBinary(bundle.signedEvent.eventBytes);
108+
} catch {
109+
return null;
110+
}
111+
if (parsed.contentBody.oneofKind !== kind) return null;
112+
// Safe: the oneofKind check above guarantees `kind` is the live payload
113+
// key. TS can't index a discriminated union by a generic key, so cast.
114+
const content = (
115+
parsed.contentBody as unknown as Record<K, ContentBodyOf<K>>
116+
)[kind];
117+
return { event, content, signedEvent: bundle.signedEvent };
118+
}
119+
60120
/** Decode a v2 EventBundle into PostData, or null if not a post. */
61121
export function decodePostBundle(bundle: v2.EventBundle): PostData | null {
122+
const decoded = decodeBundle(bundle, 'post');
123+
if (!decoded) return null;
62124
try {
63-
if (!bundle.signedEvent) return null;
64-
const event = v2.Event.fromBinary(bundle.signedEvent.eventBytes);
125+
const { event, content: post, signedEvent } = decoded;
65126
const key = event.key;
66127
if (!key?.signedBy?.key) return null;
67128

68-
if (!bundle.serializedContent?.contentBytes) return null;
69-
const content = v2.Content.fromBinary(
70-
bundle.serializedContent.contentBytes,
71-
);
72-
if (content.contentBody.oneofKind !== 'post') return null;
73-
74129
const id = bytesToHex(v2.EventKey.toBinary(key));
75-
76-
const post = content.contentBody.post;
77130
const reply = post.reply
78131
? {
79132
rootId: post.reply.root
@@ -99,8 +152,8 @@ export function decodePostBundle(bundle: v2.EventBundle): PostData | null {
99152
reply,
100153
quoteId,
101154
signedEvent: v2.SignedEvent.create({
102-
eventBytes: bundle.signedEvent.eventBytes,
103-
signature: bundle.signedEvent.signature,
155+
eventBytes: signedEvent.eventBytes,
156+
signature: signedEvent.signature,
104157
}),
105158
};
106159
} catch (e) {
@@ -116,17 +169,12 @@ function decodeRepostBundle(bundle: v2.EventBundle): {
116169
targetId: string;
117170
repostId: string;
118171
} | null {
172+
const decoded = decodeBundle(bundle, 'repost');
173+
if (!decoded) return null;
119174
try {
120-
if (!bundle.signedEvent) return null;
121-
const event = v2.Event.fromBinary(bundle.signedEvent.eventBytes);
122-
const key = event.key;
175+
const key = decoded.event.key;
123176
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;
177+
const target = decoded.content.post;
130178
if (!target) return null;
131179
return {
132180
repostedBy: key.identity,

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export {
2626

2727
// Helpers
2828
export {
29+
decodeBundle,
2930
decodePostBundle as decodeV2PostBundle,
3031
decodeFeedItems,
3132
pubkeyStr,
@@ -48,4 +49,9 @@ export {
4849
toBase64,
4950
fromBase64,
5051
} from './helpers';
51-
export type { PostData } from './helpers';
52+
export type {
53+
PostData,
54+
ContentKind,
55+
ContentBodyOf,
56+
DecodedBundle,
57+
} from './helpers';

apps/polycentric/src/features/follow/hooks/useFollows.ts

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import {
33
v2,
44
type PolycentricClient,
55
} from '@polycentric/react-native';
6+
import {
7+
decodeBundle,
8+
type DecodedBundle,
9+
} from '@/src/common/lib/polycentric-hooks/helpers';
610
import { create } from 'zustand';
711

812
type FollowsState = {
@@ -13,27 +17,6 @@ type FollowsState = {
1317
refresh: (client: PolycentricClient) => Promise<void>;
1418
};
1519

16-
/**
17-
* Decode a `Follow` content out of an EventBundle. Returns `null` if the
18-
* bundle doesn't carry a Follow.
19-
*/
20-
function decodeFollow(
21-
bundle: v2.EventBundle,
22-
): { event: v2.Event; identity: string } | null {
23-
if (!bundle.signedEvent || !bundle.serializedContent?.contentBytes) {
24-
return null;
25-
}
26-
let content: v2.Content;
27-
try {
28-
content = v2.Content.fromBinary(bundle.serializedContent.contentBytes);
29-
} catch {
30-
return null;
31-
}
32-
if (content.contentBody.oneofKind !== 'follow') return null;
33-
const event = v2.Event.fromBinary(bundle.signedEvent.eventBytes);
34-
return { event, identity: content.contentBody.follow.identity };
35-
}
36-
3720
const useFollows = create<FollowsState>((set, get) => ({
3821
follows: new Map(),
3922
isFollowing(identity) {
@@ -83,10 +66,10 @@ const useFollows = create<FollowsState>((set, get) => ({
8366
// `identity`. A single identity may have multiple follow events across
8467
// signing keys — delete them all.
8568
const targets = bundles
86-
.map(decodeFollow)
69+
.map((bundle) => decodeBundle(bundle, 'follow'))
8770
.filter(
88-
(entry): entry is { event: v2.Event; identity: string } =>
89-
entry !== null && entry.identity === identity,
71+
(entry): entry is DecodedBundle<'follow'> =>
72+
entry !== null && entry.content.identity === identity,
9073
);
9174

9275
// Optimistically upate the state
@@ -132,8 +115,8 @@ const useFollows = create<FollowsState>((set, get) => ({
132115

133116
const follows = new Map<string, boolean>();
134117
for (const bundle of bundles) {
135-
const entry = decodeFollow(bundle);
136-
if (entry) follows.set(entry.identity, true);
118+
const entry = decodeBundle(bundle, 'follow');
119+
if (entry) follows.set(entry.content.identity, true);
137120
}
138121

139122
set({ follows });

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ type RepostsState = {
2626
*/
2727
function decodeRepost(
2828
bundle: v2.EventBundle,
29-
): { event: v2.Event, targetIdHex: string, repostIdHex: string } | null {
29+
): { event: v2.Event; targetIdHex: string; repostIdHex: string } | null {
3030
if (!bundle.signedEvent || !bundle.serializedContent?.contentBytes) {
3131
return null;
3232
}
@@ -44,7 +44,7 @@ function decodeRepost(
4444
return {
4545
event,
4646
targetIdHex: bytesToHex(v2.EventKey.toBinary(target)),
47-
repostIdHex: bytesToHex(v2.EventKey.toBinary(event.key))
47+
repostIdHex: bytesToHex(v2.EventKey.toBinary(event.key)),
4848
};
4949
}
5050

@@ -108,8 +108,13 @@ const useReposts = create<RepostsState>((set, get) => ({
108108
// Tombstone every Repost for `targetId` (a single identity may have
109109
// multiple events across signing keys)
110110
const targets = bundles.map(decodeRepost).filter(
111-
(entry): entry is { event: v2.Event, targetIdHex: string, repostIdHex: string } =>
112-
entry !== null && entry.targetIdHex === targetId
111+
(
112+
entry,
113+
): entry is {
114+
event: v2.Event;
115+
targetIdHex: string;
116+
repostIdHex: string;
117+
} => entry !== null && entry.targetIdHex === targetId,
113118
);
114119

115120
const next = new Map(reposts);

0 commit comments

Comments
 (0)