Skip to content

Commit f08b004

Browse files
committed
Merge branch 'repost-mapping' into 'develop'
Local state for reposts, and remove some awaits in PolycentricProvider See merge request polycentric/polycentric!528
2 parents 74843e2 + 4fe250a commit f08b004

4 files changed

Lines changed: 179 additions & 49 deletions

File tree

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DEFAULT_IDENTITY_NAME } from '@/src/common/constants';
22
import useFollows from '@/src/features/follow/hooks/useFollows';
3+
import useReposts from '@/src/features/post/hooks/useReposts';
34
import {
45
PolycentricClient,
56
createPolycentricClient,
@@ -169,10 +170,14 @@ export function PolycentricProvider({
169170
// Read follows from the local store immediately — don't gate
170171
// the UI on the network sync. The sync runs in parallel and
171172
// re-refreshes once new events have been pulled in.
172-
await useFollows.getState().refresh(c);
173+
useFollows.getState().refresh(c);
174+
useReposts.getState().refresh(c);
173175
void c
174176
.sync()
175-
.then(() => useFollows.getState().refresh(c))
177+
.then(() => Promise.all([
178+
useFollows.getState().refresh(c),
179+
useReposts.getState().refresh(c)
180+
]))
176181
.catch((syncError) => {
177182
console.warn('Initial Polycentric sync failed:', syncError);
178183
});
@@ -184,7 +189,8 @@ export function PolycentricProvider({
184189
if (cancelled) return;
185190
setCurrentIdentity(await resolveIdentity(c));
186191
await s.getState().refreshIdentities();
187-
await useFollows.getState().refresh(c);
192+
useFollows.getState().refresh(c);
193+
useReposts.getState().refresh(c);
188194
});
189195

190196
// Identity onboarding (create / claim) publishes an Identity event,

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

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,6 @@ type PostActions = {
1818
* Delete events act as tombstone of a referenced event.
1919
*/
2020
deleteAsync: () => Promise<void>;
21-
/**
22-
* Creates a 'Repost'.
23-
* Different to quote posts. To make a quote post, make a normal
24-
* post with a 'quote' field, referencing the post you wish to quote.
25-
*/
26-
repostAsync: () => Promise<void>;
27-
/**
28-
* Undo a repost by tombstoning the repost event itself (the event
29-
* identified by `post.repostId`). No-op when the item isn't a repost.
30-
*/
31-
undoRepostAsync: () => Promise<void>;
3221
};
3322

3423
/**
@@ -79,32 +68,5 @@ export default function usePostActions(post: PostData): PostActions {
7968
return;
8069
}
8170
},
82-
undoRepostAsync: async () => {
83-
if (!post.repostId) return;
84-
await deleteEventAtKey(post.repostId);
85-
invalidateFeeds(post.repostedBy ?? post.identity);
86-
},
87-
repostAsync: async () => {
88-
const eventKeyBytes = hexToBytes(post.id);
89-
90-
const eventKey = v2.EventKey.fromBinary(eventKeyBytes);
91-
92-
const repostContent = v2.Content.create({
93-
contentBody: {
94-
oneofKind: 'repost',
95-
repost: { post: eventKey },
96-
},
97-
});
98-
await client.contentManager.save(repostContent);
99-
const repostEvent = await client.buildEvent(
100-
repostContent,
101-
COLLECTION.FEED,
102-
);
103-
const signedEvent = await client.signEvent(repostEvent);
104-
await client.commitEvent(signedEvent, repostContent);
105-
106-
// TODO: do we care if push failed?
107-
await client.push();
108-
},
10971
};
11072
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import {
2+
bytesToHex,
3+
hexToBytes,
4+
PostData,
5+
} from '@/src/common/lib/polycentric-hooks/helpers';
6+
import { invalidateQuery } from '@/src/common/query/hooks/useQuery';
7+
import {
8+
COLLECTION,
9+
v2,
10+
type PolycentricClient,
11+
} from '@polycentric/react-native';
12+
import { create } from 'zustand';
13+
import { feedQueryKeys } from '../../feed/hooks/feedCache';
14+
15+
type RepostsState = {
16+
reposts: Map<string, string>;
17+
hasReposted: (targetId: string) => boolean;
18+
addRepost: (client: PolycentricClient, post: PostData) => Promise<void>;
19+
removeRepost: (client: PolycentricClient, targetId: string) => Promise<void>;
20+
refresh: (client: PolycentricClient) => Promise<void>;
21+
};
22+
23+
/**
24+
* Decode `Repost` target/repost ids out of an EventBundle. Returns `null` if the
25+
* bundle doesn't carry a Repost.
26+
*/
27+
function decodeRepost(
28+
bundle: v2.EventBundle,
29+
): { event: v2.Event, targetIdHex: string, repostIdHex: string } | null {
30+
if (!bundle.signedEvent || !bundle.serializedContent?.contentBytes) {
31+
return null;
32+
}
33+
let content: v2.Content;
34+
try {
35+
content = v2.Content.fromBinary(bundle.serializedContent.contentBytes);
36+
} catch {
37+
return null;
38+
}
39+
if (content.contentBody.oneofKind !== 'repost') return null;
40+
const target = content.contentBody.repost.post;
41+
if (!target) return null;
42+
const event = v2.Event.fromBinary(bundle.signedEvent.eventBytes);
43+
if (!event.key) return null;
44+
return {
45+
event,
46+
targetIdHex: bytesToHex(v2.EventKey.toBinary(target)),
47+
repostIdHex: bytesToHex(v2.EventKey.toBinary(event.key))
48+
};
49+
}
50+
51+
function invalidateFeeds(client: PolycentricClient, identity: string) {
52+
invalidateQuery(client, feedQueryKeys.following());
53+
invalidateQuery(client, feedQueryKeys.identity(identity));
54+
invalidateQuery(client, feedQueryKeys.explore(identity));
55+
}
56+
57+
const useReposts = create<RepostsState>((set, get) => ({
58+
reposts: new Map(),
59+
hasReposted(targetId) {
60+
return get().reposts.has(targetId);
61+
},
62+
/**
63+
* Build the Repost event for `post.id`, optimistically record the mapping,
64+
* then commit & sync. Reverts the mapping on error.
65+
*/
66+
async addRepost(client, post) {
67+
const reposts = get().reposts;
68+
if (reposts.has(post.id)) return;
69+
70+
const targetKey = v2.EventKey.fromBinary(hexToBytes(post.id));
71+
const repostContent = v2.Content.create({
72+
contentBody: {
73+
oneofKind: 'repost',
74+
repost: { post: targetKey },
75+
},
76+
});
77+
78+
await client.contentManager.save(repostContent);
79+
const repostEvent = await client.buildEvent(repostContent, COLLECTION.FEED);
80+
const signedEvent = await client.signEvent(repostEvent);
81+
82+
const event = v2.Event.fromBinary(signedEvent.eventBytes);
83+
if (!event.key) return;
84+
const repostIdHex = bytesToHex(v2.EventKey.toBinary(event.key));
85+
86+
set({ reposts: new Map(reposts).set(post.id, repostIdHex) });
87+
88+
try {
89+
await client.commitEvent(signedEvent, repostContent);
90+
await client.sync();
91+
invalidateFeeds(client, client.activeIdentityKey ?? post.identity);
92+
} catch (err) {
93+
console.error(err);
94+
set({ reposts });
95+
}
96+
},
97+
/**
98+
* Tombstone the repost event for `targetId`, optimistically removing the
99+
* mapping. Reverts on error.
100+
*/
101+
async removeRepost(client, targetId) {
102+
const self = client.activeIdentityKey;
103+
if (!self) return;
104+
105+
const reposts = get().reposts;
106+
const bundles = client.listValidEvents(self, COLLECTION.FEED);
107+
108+
// Tombstone every Repost for `targetId` (a single identity may have
109+
// multiple events across signing keys)
110+
const targets = bundles.map(decodeRepost).filter(
111+
(entry): entry is { event: v2.Event, targetIdHex: string, repostIdHex: string } =>
112+
entry !== null && entry.targetIdHex === targetId
113+
);
114+
115+
const next = new Map(reposts);
116+
next.delete(targetId);
117+
set({ reposts: next });
118+
119+
try {
120+
for (const { event } of targets) {
121+
if (!event.key) continue;
122+
const deleteContent = v2.Content.create({
123+
contentBody: {
124+
oneofKind: 'delete',
125+
delete: { eventKey: event.key },
126+
},
127+
});
128+
await client.contentManager.save(deleteContent);
129+
const deleteEvent = await client.buildEvent(
130+
deleteContent,
131+
COLLECTION.FEED,
132+
);
133+
const signedDelete = await client.signEvent(deleteEvent);
134+
await client.commitEvent(signedDelete, deleteContent);
135+
}
136+
} catch (err) {
137+
console.error(err);
138+
set({ reposts });
139+
}
140+
141+
if (targets.length > 0) {
142+
await client.sync();
143+
invalidateFeeds(client, self);
144+
}
145+
},
146+
/**
147+
* Read live repost events from the local outbox and rebuild the map.
148+
*/
149+
async refresh(client) {
150+
const identity = client.activeIdentityKey;
151+
if (!identity) return;
152+
const bundles = client.listValidEvents(identity, COLLECTION.FEED);
153+
const reposts = new Map<string, string>();
154+
for (const bundle of bundles) {
155+
const decoded = decodeRepost(bundle);
156+
if (decoded) reposts.set(decoded.targetIdHex, decoded.repostIdHex);
157+
}
158+
set({ reposts });
159+
},
160+
}));
161+
162+
export default useReposts;

apps/polycentric/src/features/post/toolbar/RepostButton.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,26 @@ import Icon from '@/src/common/components/Icon';
44
import { openCompose } from '@/src/common/constants';
55
import {
66
PostData,
7-
useCurrentIdentity,
7+
usePolycentric
88
} from '@/src/common/lib/polycentric-hooks';
99
import { Atoms } from '@/src/common/theme';
1010
import { View } from 'react-native';
11-
import usePostActions from '../hooks/usePostActions';
11+
import useReposts from '../hooks/useReposts';
1212
import PostActionButton from './PostActionButton';
1313

1414
type RepostButtonProps = { post: PostData };
1515

1616
export default function RepostButton({ post }: RepostButtonProps) {
17-
const { identityKey: currentIdentity } = useCurrentIdentity();
18-
const { repostAsync, undoRepostAsync } = usePostActions(post);
19-
20-
const hasReposted = post.repostedBy === currentIdentity;
17+
const client = usePolycentric();
18+
const hasReposted = useReposts((s) => s.hasReposted(post.id));
19+
const addRepost = useReposts((s) => s.addRepost);
20+
const removeRepost = useReposts((s) => s.removeRepost);
2121

2222
const onRepostPress = async () => {
2323
if (hasReposted) {
24-
await undoRepostAsync();
24+
await removeRepost(client, post.id);
2525
} else {
26-
await repostAsync();
26+
await addRepost(client, post);
2727
}
2828
};
2929

0 commit comments

Comments
 (0)