@rocapine/community-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/lib/config.d.ts +74 -0
- package/lib/config.js +51 -0
- package/lib/events.d.ts +20 -0
- package/lib/events.js +33 -0
- package/lib/hooks.d.ts +122 -0
- package/lib/hooks.js +494 -0
- package/lib/identity.d.ts +23 -0
- package/lib/identity.js +103 -0
- package/lib/inbox-hooks.d.ts +23 -0
- package/lib/inbox-hooks.js +57 -0
- package/lib/inbox-service.d.ts +26 -0
- package/lib/inbox-service.js +94 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.js +89 -0
- package/lib/models.d.ts +187 -0
- package/lib/models.js +178 -0
- package/lib/provider.d.ts +7 -0
- package/lib/provider.js +23 -0
- package/lib/schema-check.d.ts +19 -0
- package/lib/schema-check.js +52 -0
- package/lib/service.d.ts +147 -0
- package/lib/service.js +454 -0
- package/lib/time.d.ts +21 -0
- package/lib/time.js +32 -0
- package/package.json +32 -0
package/lib/hooks.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// React Query hooks over the community service — the primary consumer-facing API of this
|
|
3
|
+
// package. Every hook threads `cfg = useCommunityConfig()` through to the service layer and
|
|
4
|
+
// to `emitEvent`, and every query is gated on `cfg.supabase !== null` so a host that never
|
|
5
|
+
// configured a backend degrades to "always empty / never loading forever" instead of
|
|
6
|
+
// throwing inside a render.
|
|
7
|
+
//
|
|
8
|
+
// Ported from the mold (`sdk/client/hooks/useCommunity.ts`) with a host app's newer
|
|
9
|
+
// generalized reaction feature (`usePrayForPost` -> `useReactToPost`, optimistic
|
|
10
|
+
// `applyReaction`) and unseen-count hook (parameterized on `lastSeenAtIso` instead of
|
|
11
|
+
// reading that host's own store). No React Native / analytics-SDK / store imports here.
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.useCommunityFeed = useCommunityFeed;
|
|
14
|
+
exports.useSearchPosts = useSearchPosts;
|
|
15
|
+
exports.useNewPostsCount = useNewPostsCount;
|
|
16
|
+
exports.useCommunityUnseenCount = useCommunityUnseenCount;
|
|
17
|
+
exports.useThread = useThread;
|
|
18
|
+
exports.useProfile = useProfile;
|
|
19
|
+
exports.useUserPosts = useUserPosts;
|
|
20
|
+
exports.useMyUid = useMyUid;
|
|
21
|
+
exports.useUpdateProfile = useUpdateProfile;
|
|
22
|
+
exports.useCreatePost = useCreatePost;
|
|
23
|
+
exports.useCreateComment = useCreateComment;
|
|
24
|
+
exports.useToggleLike = useToggleLike;
|
|
25
|
+
exports.useVotePoll = useVotePoll;
|
|
26
|
+
exports.useReactToPost = useReactToPost;
|
|
27
|
+
exports.useReport = useReport;
|
|
28
|
+
exports.useBlockUser = useBlockUser;
|
|
29
|
+
exports.useDeleteContent = useDeleteContent;
|
|
30
|
+
const react_query_1 = require("@tanstack/react-query");
|
|
31
|
+
const events_1 = require("./events");
|
|
32
|
+
const identity_1 = require("./identity");
|
|
33
|
+
const models_1 = require("./models");
|
|
34
|
+
const provider_1 = require("./provider");
|
|
35
|
+
const service_1 = require("./service");
|
|
36
|
+
const FEED_KEY = ["community", "feed"];
|
|
37
|
+
const feedKey = (topic) => [...FEED_KEY, topic ?? "all"];
|
|
38
|
+
const threadKey = (postId) => ["community", "thread", postId];
|
|
39
|
+
const profileKey = (userId) => ["community", "profile", userId];
|
|
40
|
+
const userPostsKey = (userId) => ["community", "userPosts", userId];
|
|
41
|
+
const USER_POSTS_KEY = ["community", "userPosts"];
|
|
42
|
+
const SEARCH_KEY = ["community", "search"];
|
|
43
|
+
/**
|
|
44
|
+
* Adjust a post's commentCount across every cached feed page (all topic
|
|
45
|
+
* filters). The feed is never refetched after commenting (kept smooth), so the
|
|
46
|
+
* badge only tracks new comments if we reconcile the cache in place. Scoped to
|
|
47
|
+
* the feed only (matching the mold and the newer host's source) — a comment count
|
|
48
|
+
* change is not reflected in the user-posts/search caches, same as upstream.
|
|
49
|
+
*/
|
|
50
|
+
function bumpFeedCommentCount(queryClient, postId, delta) {
|
|
51
|
+
queryClient.setQueriesData({ queryKey: FEED_KEY }, (data) => data
|
|
52
|
+
? {
|
|
53
|
+
...data,
|
|
54
|
+
pages: data.pages.map((page) => page.map((post) => post.id === postId
|
|
55
|
+
? { ...post, commentCount: Math.max(0, post.commentCount + delta) }
|
|
56
|
+
: post)),
|
|
57
|
+
}
|
|
58
|
+
: data);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Apply `mapPost` optimistically across every cache that can hold a
|
|
62
|
+
* `FeedPost`: the topic feeds, user-posts and search results (all
|
|
63
|
+
* `InfiniteData<FeedPost[]>`). Returns a snapshot for `rollbackPostCaches` on
|
|
64
|
+
* error. Used by `useVotePoll` and `useReactToPost`, the two mutations whose
|
|
65
|
+
* optimistic update must be visible from any screen that renders a
|
|
66
|
+
* `FeedPost` (mirrors the mold's `POST_PAGES_KEYS` sweep).
|
|
67
|
+
*/
|
|
68
|
+
async function applyOptimisticToAllPostCaches(queryClient, mapPost) {
|
|
69
|
+
await Promise.all([
|
|
70
|
+
queryClient.cancelQueries({ queryKey: FEED_KEY }),
|
|
71
|
+
queryClient.cancelQueries({ queryKey: USER_POSTS_KEY }),
|
|
72
|
+
queryClient.cancelQueries({ queryKey: SEARCH_KEY }),
|
|
73
|
+
]);
|
|
74
|
+
const entries = [
|
|
75
|
+
...queryClient.getQueriesData({ queryKey: FEED_KEY }),
|
|
76
|
+
...queryClient.getQueriesData({ queryKey: USER_POSTS_KEY }),
|
|
77
|
+
...queryClient.getQueriesData({ queryKey: SEARCH_KEY }),
|
|
78
|
+
];
|
|
79
|
+
const applyToPages = (data) => data ? { ...data, pages: data.pages.map((page) => page.map(mapPost)) } : data;
|
|
80
|
+
queryClient.setQueriesData({ queryKey: FEED_KEY }, applyToPages);
|
|
81
|
+
queryClient.setQueriesData({ queryKey: USER_POSTS_KEY }, applyToPages);
|
|
82
|
+
queryClient.setQueriesData({ queryKey: SEARCH_KEY }, applyToPages);
|
|
83
|
+
return { entries };
|
|
84
|
+
}
|
|
85
|
+
function rollbackPostCaches(queryClient, snapshot) {
|
|
86
|
+
for (const [key, data] of snapshot.entries)
|
|
87
|
+
queryClient.setQueryData(key, data);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* One infinite-scrolling feed, newest (then pinned) first. `enabled` is kept
|
|
91
|
+
* beyond the brief's literal `(topic?)` signature (both source apps take it,
|
|
92
|
+
* e.g. to pause the feed query while an in-progress search query is active) —
|
|
93
|
+
* defaults to `true` so `useCommunityFeed(topic)` still matches the brief.
|
|
94
|
+
*/
|
|
95
|
+
function useCommunityFeed(topic, enabled = true) {
|
|
96
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
97
|
+
return (0, react_query_1.useInfiniteQuery)({
|
|
98
|
+
queryKey: feedKey(topic),
|
|
99
|
+
queryFn: ({ pageParam }) => (0, service_1.fetchFeedPage)(cfg, { topic, cursor: pageParam }),
|
|
100
|
+
initialPageParam: 0,
|
|
101
|
+
getNextPageParam: (lastPage, pages) => lastPage.length === models_1.FEED_PAGE_SIZE ? pages.length : undefined,
|
|
102
|
+
enabled: cfg.supabase !== null && enabled,
|
|
103
|
+
staleTime: 1000 * 30,
|
|
104
|
+
retry: 1,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Search visible posts by content, infinite-scrolling like the feed.
|
|
109
|
+
* Enabled once the term has >= 2 chars.
|
|
110
|
+
*/
|
|
111
|
+
function useSearchPosts(term) {
|
|
112
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
113
|
+
const cleaned = term.trim();
|
|
114
|
+
return (0, react_query_1.useInfiniteQuery)({
|
|
115
|
+
queryKey: [...SEARCH_KEY, cleaned],
|
|
116
|
+
queryFn: ({ pageParam }) => (0, service_1.searchPosts)(cfg, cleaned, { cursor: pageParam }),
|
|
117
|
+
initialPageParam: 0,
|
|
118
|
+
getNextPageParam: (lastPage, pages) => lastPage.length === models_1.FEED_PAGE_SIZE ? pages.length : undefined,
|
|
119
|
+
enabled: cfg.supabase !== null && cleaned.length >= 2,
|
|
120
|
+
staleTime: 1000 * 30,
|
|
121
|
+
retry: 1,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Polls (5s, while `polling`) for posts newer than the loaded top. `topic`
|
|
126
|
+
* and `polling` are kept beyond the brief's literal `(sinceIso)` signature
|
|
127
|
+
* (both source apps take them, needed for a topic-filtered "N new posts" pill —
|
|
128
|
+
* see Task 5's same call on `countNewPosts`) — both default so
|
|
129
|
+
* `useNewPostsCount(sinceIso)` still matches the brief.
|
|
130
|
+
*/
|
|
131
|
+
function useNewPostsCount(sinceIso, topic, polling = true) {
|
|
132
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
133
|
+
const q = (0, react_query_1.useQuery)({
|
|
134
|
+
queryKey: ["community", "newCount", topic ?? "all", sinceIso],
|
|
135
|
+
queryFn: () => (0, service_1.countNewPosts)(cfg, sinceIso, topic),
|
|
136
|
+
enabled: cfg.supabase !== null && !!sinceIso && polling,
|
|
137
|
+
refetchInterval: polling ? 5000 : false,
|
|
138
|
+
refetchIntervalInBackground: false,
|
|
139
|
+
staleTime: 0,
|
|
140
|
+
});
|
|
141
|
+
return q.data ?? 0;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Number of posts by others since the community was last opened (0 while
|
|
145
|
+
* loading/failing) — feeds a host's Community tab badge. Takes `lastSeenAtIso`
|
|
146
|
+
* as a parameter instead of reading a host's "last seen" store directly (the
|
|
147
|
+
* mold read a host-seam function, the newer host read its own store — this
|
|
148
|
+
* package has no store access at all, so the host now owns that value and
|
|
149
|
+
* passes it in).
|
|
150
|
+
*/
|
|
151
|
+
function useCommunityUnseenCount(lastSeenAtIso) {
|
|
152
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
153
|
+
const q = (0, react_query_1.useQuery)({
|
|
154
|
+
queryKey: ["community", "unseen", lastSeenAtIso ?? "never"],
|
|
155
|
+
queryFn: () => (0, service_1.countNewPosts)(cfg, lastSeenAtIso ?? "1970-01-01T00:00:00Z"),
|
|
156
|
+
enabled: cfg.supabase !== null,
|
|
157
|
+
staleTime: 1000 * 60,
|
|
158
|
+
refetchInterval: 1000 * 120,
|
|
159
|
+
refetchIntervalInBackground: false,
|
|
160
|
+
retry: 1,
|
|
161
|
+
});
|
|
162
|
+
return q.data ?? 0;
|
|
163
|
+
}
|
|
164
|
+
function useThread(postId) {
|
|
165
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
166
|
+
return (0, react_query_1.useQuery)({
|
|
167
|
+
queryKey: threadKey(postId ?? "none"),
|
|
168
|
+
queryFn: () => (0, service_1.fetchThread)(cfg, postId),
|
|
169
|
+
enabled: cfg.supabase !== null && !!postId,
|
|
170
|
+
staleTime: 1000 * 15,
|
|
171
|
+
retry: 1,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function useProfile(userId) {
|
|
175
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
176
|
+
return (0, react_query_1.useQuery)({
|
|
177
|
+
queryKey: profileKey(userId ?? "none"),
|
|
178
|
+
queryFn: () => (0, service_1.fetchProfile)(cfg, userId),
|
|
179
|
+
enabled: cfg.supabase !== null && !!userId,
|
|
180
|
+
staleTime: 1000 * 30,
|
|
181
|
+
retry: 1,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* One user's posts, newest first, infinite-scrolling like the feed.
|
|
186
|
+
*/
|
|
187
|
+
function useUserPosts(userId) {
|
|
188
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
189
|
+
return (0, react_query_1.useInfiniteQuery)({
|
|
190
|
+
queryKey: userPostsKey(userId ?? "none"),
|
|
191
|
+
queryFn: ({ pageParam }) => (0, service_1.fetchUserPosts)(cfg, userId, { cursor: pageParam }),
|
|
192
|
+
initialPageParam: 0,
|
|
193
|
+
getNextPageParam: (lastPage, pages) => lastPage.length === models_1.FEED_PAGE_SIZE ? pages.length : undefined,
|
|
194
|
+
enabled: cfg.supabase !== null && !!userId,
|
|
195
|
+
staleTime: 1000 * 30,
|
|
196
|
+
retry: 1,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/** Our own stable community uid, resolved once and cached for the app session. */
|
|
200
|
+
function useMyUid() {
|
|
201
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
202
|
+
const q = (0, react_query_1.useQuery)({
|
|
203
|
+
queryKey: ["community", "myUid"],
|
|
204
|
+
queryFn: () => (0, identity_1.ensureIdentity)(cfg),
|
|
205
|
+
enabled: cfg.supabase !== null,
|
|
206
|
+
staleTime: Infinity,
|
|
207
|
+
gcTime: Infinity,
|
|
208
|
+
});
|
|
209
|
+
return q.data ?? null;
|
|
210
|
+
}
|
|
211
|
+
/** Profile edits refresh everything derived from a profile row: the profile
|
|
212
|
+
* itself, plus the handle/avatar shown on every cached feed and thread. */
|
|
213
|
+
function useUpdateProfile() {
|
|
214
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
215
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
216
|
+
return (0, react_query_1.useMutation)({
|
|
217
|
+
mutationFn: (input) => (0, service_1.updateProfile)(cfg, input),
|
|
218
|
+
onSuccess: (result, input) => {
|
|
219
|
+
if (result.status !== "ok")
|
|
220
|
+
return;
|
|
221
|
+
queryClient.invalidateQueries({ queryKey: ["community"] });
|
|
222
|
+
(0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.profileUpdated, {
|
|
223
|
+
hasBio: !!input.bio,
|
|
224
|
+
hasAvatar: !!input.avatarUrl,
|
|
225
|
+
handleChanged: !!input.handle,
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Optimistic post creation with NO refetch: the post shows instantly, and after
|
|
232
|
+
* moderation we reconcile the cache in place, published -> swap the temp id for
|
|
233
|
+
* the real one (stays), rejected -> remove it. Smooth, no loading flicker.
|
|
234
|
+
*/
|
|
235
|
+
function useCreatePost() {
|
|
236
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
237
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
238
|
+
return (0, react_query_1.useMutation)({
|
|
239
|
+
onMutate: async ({ topic, text, authorName, pollOptions }) => {
|
|
240
|
+
await queryClient.cancelQueries({ queryKey: FEED_KEY });
|
|
241
|
+
const tempId = `optimistic-${Date.now()}`;
|
|
242
|
+
const optimistic = {
|
|
243
|
+
id: tempId,
|
|
244
|
+
authorId: "",
|
|
245
|
+
authorName,
|
|
246
|
+
authorOfficial: false,
|
|
247
|
+
authorHandle: null,
|
|
248
|
+
authorAvatarUrl: null,
|
|
249
|
+
topic,
|
|
250
|
+
text,
|
|
251
|
+
status: "visible",
|
|
252
|
+
likeCount: 0,
|
|
253
|
+
commentCount: 0,
|
|
254
|
+
likedByMe: false,
|
|
255
|
+
isOwn: true,
|
|
256
|
+
createdAt: new Date().toISOString(),
|
|
257
|
+
pinnedAt: null,
|
|
258
|
+
// Temp option ids: PollBlock refuses votes on them; the post-publish
|
|
259
|
+
// refetch below swaps in the real uuids.
|
|
260
|
+
poll: pollOptions && pollOptions.length > 0
|
|
261
|
+
? {
|
|
262
|
+
options: pollOptions.map((label, i) => ({ id: `temp-${i}`, label, votes: 0 })),
|
|
263
|
+
myOptionId: null,
|
|
264
|
+
totalVotes: 0,
|
|
265
|
+
}
|
|
266
|
+
: null,
|
|
267
|
+
reactionCount: 0,
|
|
268
|
+
hasReacted: false,
|
|
269
|
+
lastReactorName: null,
|
|
270
|
+
};
|
|
271
|
+
const prepend = (key) => queryClient.setQueryData(key, (d) => d ? { ...d, pages: [[optimistic, ...(d.pages[0] ?? [])], ...d.pages.slice(1)] } : d);
|
|
272
|
+
prepend(feedKey(undefined));
|
|
273
|
+
prepend(feedKey(topic));
|
|
274
|
+
return { tempId };
|
|
275
|
+
},
|
|
276
|
+
mutationFn: async ({ topic, text, pollOptions }) => {
|
|
277
|
+
const id = await (0, service_1.createPost)(cfg, { topic, body: text, pollOptions });
|
|
278
|
+
const verdict = await (0, service_1.moderateOne)(cfg, { kind: "post", id });
|
|
279
|
+
return { id, verdict };
|
|
280
|
+
},
|
|
281
|
+
onSuccess: ({ id, verdict }, { topic, text, pollOptions }, { tempId }) => {
|
|
282
|
+
const rejected = verdict.status === "rejected";
|
|
283
|
+
queryClient.setQueriesData({ queryKey: FEED_KEY }, (d) => d
|
|
284
|
+
? {
|
|
285
|
+
...d,
|
|
286
|
+
pages: d.pages.map((p) => rejected
|
|
287
|
+
? p.filter((post) => post.id !== tempId)
|
|
288
|
+
: p.map((post) => post.id === tempId ? { ...post, id, status: "visible" } : post)),
|
|
289
|
+
}
|
|
290
|
+
: d);
|
|
291
|
+
if (verdict.status === "published") {
|
|
292
|
+
const hasPoll = !!pollOptions && pollOptions.length > 0;
|
|
293
|
+
(0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.postPublished, {
|
|
294
|
+
topic,
|
|
295
|
+
bodyLength: text.trim().length,
|
|
296
|
+
hasPoll,
|
|
297
|
+
});
|
|
298
|
+
cfg.host.onContentPublished();
|
|
299
|
+
// The in-place swap above keeps the optimistic poll's temp option ids;
|
|
300
|
+
// voting needs the real uuids, so a poll post refetches in background.
|
|
301
|
+
if (hasPoll)
|
|
302
|
+
queryClient.invalidateQueries({ queryKey: FEED_KEY });
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
onError: (_e, _v, context) => {
|
|
306
|
+
queryClient.setQueriesData({ queryKey: FEED_KEY }, (d) => d
|
|
307
|
+
? { ...d, pages: d.pages.map((p) => p.filter((post) => post.id !== context?.tempId)) }
|
|
308
|
+
: d);
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** Optimistic comment creation, same reconcile-in-place model as posts. */
|
|
313
|
+
function useCreateComment() {
|
|
314
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
315
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
316
|
+
return (0, react_query_1.useMutation)({
|
|
317
|
+
onMutate: async ({ postId, text, authorName }) => {
|
|
318
|
+
await queryClient.cancelQueries({ queryKey: threadKey(postId) });
|
|
319
|
+
await queryClient.cancelQueries({ queryKey: FEED_KEY });
|
|
320
|
+
const tempId = `optimistic-${Date.now()}`;
|
|
321
|
+
const optimistic = {
|
|
322
|
+
id: tempId,
|
|
323
|
+
postId,
|
|
324
|
+
authorId: "",
|
|
325
|
+
authorName,
|
|
326
|
+
authorOfficial: false,
|
|
327
|
+
authorHandle: null,
|
|
328
|
+
authorAvatarUrl: null,
|
|
329
|
+
text,
|
|
330
|
+
isOwn: true,
|
|
331
|
+
createdAt: new Date().toISOString(),
|
|
332
|
+
};
|
|
333
|
+
queryClient.setQueryData(threadKey(postId), (d) => [
|
|
334
|
+
...(d ?? []),
|
|
335
|
+
optimistic,
|
|
336
|
+
]);
|
|
337
|
+
bumpFeedCommentCount(queryClient, postId, 1);
|
|
338
|
+
return { tempId };
|
|
339
|
+
},
|
|
340
|
+
mutationFn: async ({ postId, text }) => {
|
|
341
|
+
const id = await (0, service_1.createComment)(cfg, postId, text);
|
|
342
|
+
const verdict = await (0, service_1.moderateOne)(cfg, { kind: "comment", id });
|
|
343
|
+
return { id, verdict };
|
|
344
|
+
},
|
|
345
|
+
onSuccess: ({ id, verdict }, { postId, text }, { tempId }) => {
|
|
346
|
+
const rejected = verdict.status === "rejected";
|
|
347
|
+
queryClient.setQueryData(threadKey(postId), (d) => (d ?? [])
|
|
348
|
+
.filter((c) => !(rejected && c.id === tempId))
|
|
349
|
+
.map((c) => (c.id === tempId ? { ...c, id } : c)));
|
|
350
|
+
// A rejected comment never becomes visible, so undo the optimistic bump.
|
|
351
|
+
if (rejected)
|
|
352
|
+
bumpFeedCommentCount(queryClient, postId, -1);
|
|
353
|
+
if (verdict.status === "published") {
|
|
354
|
+
(0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.commentPublished, {
|
|
355
|
+
postId,
|
|
356
|
+
bodyLength: text.trim().length,
|
|
357
|
+
});
|
|
358
|
+
cfg.host.onContentPublished();
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
onError: (_e, { postId }, context) => {
|
|
362
|
+
queryClient.setQueryData(threadKey(postId), (d) => (d ?? []).filter((c) => c.id !== context?.tempId));
|
|
363
|
+
bumpFeedCommentCount(queryClient, postId, -1);
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/** Optimistic like toggle across all cached feed pages (every topic filter). */
|
|
368
|
+
function useToggleLike() {
|
|
369
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
370
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
371
|
+
return (0, react_query_1.useMutation)({
|
|
372
|
+
mutationFn: ({ postId, liked }) => (0, service_1.setLike)(cfg, postId, liked),
|
|
373
|
+
onMutate: async ({ postId, liked }) => {
|
|
374
|
+
await queryClient.cancelQueries({ queryKey: FEED_KEY });
|
|
375
|
+
const previous = queryClient.getQueriesData({
|
|
376
|
+
queryKey: FEED_KEY,
|
|
377
|
+
});
|
|
378
|
+
queryClient.setQueriesData({ queryKey: FEED_KEY }, (data) => data
|
|
379
|
+
? {
|
|
380
|
+
...data,
|
|
381
|
+
pages: data.pages.map((page) => page.map((post) => post.id === postId
|
|
382
|
+
? {
|
|
383
|
+
...post,
|
|
384
|
+
likedByMe: liked,
|
|
385
|
+
likeCount: Math.max(0, post.likeCount + (liked ? 1 : -1)),
|
|
386
|
+
}
|
|
387
|
+
: post)),
|
|
388
|
+
}
|
|
389
|
+
: data);
|
|
390
|
+
return { previous };
|
|
391
|
+
},
|
|
392
|
+
onError: (_err, _vars, context) => {
|
|
393
|
+
for (const [key, data] of context?.previous ?? [])
|
|
394
|
+
queryClient.setQueryData(key, data);
|
|
395
|
+
},
|
|
396
|
+
onSuccess: (_data, { liked, postId, topic }) => {
|
|
397
|
+
if (liked)
|
|
398
|
+
(0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.postLiked, { postId, topic });
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Optimistic poll vote across every post cache (mirror of useToggleLike, but
|
|
404
|
+
* reaching the user-posts/search caches too — see `applyOptimisticToAllPostCaches`).
|
|
405
|
+
* `optionId` is the poll option's row id (Task 5 ruling), not a numeric index —
|
|
406
|
+
* the brief's `optionIndex` naming is dropped since neither `votePoll` nor
|
|
407
|
+
* `applyPollVote` operate on one.
|
|
408
|
+
*/
|
|
409
|
+
function useVotePoll() {
|
|
410
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
411
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
412
|
+
return (0, react_query_1.useMutation)({
|
|
413
|
+
mutationFn: ({ postId, optionId }) => (0, service_1.votePoll)(cfg, postId, optionId),
|
|
414
|
+
onMutate: ({ postId, optionId }) => applyOptimisticToAllPostCaches(queryClient, (post) => (0, models_1.applyPollVote)(post, postId, optionId)),
|
|
415
|
+
onError: (_err, _vars, context) => {
|
|
416
|
+
if (context)
|
|
417
|
+
rollbackPostCaches(queryClient, context);
|
|
418
|
+
},
|
|
419
|
+
onSuccess: (_data, { postId, optionId }) => {
|
|
420
|
+
(0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.pollVoted, { postId, optionId });
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Optimistic reaction across every post cache (mirror of useVotePoll; renamed
|
|
426
|
+
* from the mold's `usePrayForPost`). `applyReaction(post)` is single-argument
|
|
427
|
+
* (Task 3 ruling — it does not filter by post id), so the mapper here pre-filters
|
|
428
|
+
* to the target post itself before calling it, and separately tracks whether
|
|
429
|
+
* that post already had `hasReacted` so a retap of an already-reacted post
|
|
430
|
+
* (the reaction is non-retractable, `setReaction` is idempotent) does not
|
|
431
|
+
* re-emit the analytics event.
|
|
432
|
+
*/
|
|
433
|
+
function useReactToPost() {
|
|
434
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
435
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
436
|
+
return (0, react_query_1.useMutation)({
|
|
437
|
+
mutationFn: ({ postId }) => (0, service_1.setReaction)(cfg, postId),
|
|
438
|
+
onMutate: async ({ postId }) => {
|
|
439
|
+
let alreadyReacted = false;
|
|
440
|
+
const snapshot = await applyOptimisticToAllPostCaches(queryClient, (post) => {
|
|
441
|
+
if (post.id !== postId)
|
|
442
|
+
return post;
|
|
443
|
+
if (post.hasReacted)
|
|
444
|
+
alreadyReacted = true;
|
|
445
|
+
return (0, models_1.applyReaction)(post);
|
|
446
|
+
});
|
|
447
|
+
return { ...snapshot, alreadyReacted };
|
|
448
|
+
},
|
|
449
|
+
onError: (_err, _vars, context) => {
|
|
450
|
+
if (context)
|
|
451
|
+
rollbackPostCaches(queryClient, context);
|
|
452
|
+
},
|
|
453
|
+
onSuccess: (_data, { postId }, context) => {
|
|
454
|
+
if (!context?.alreadyReacted)
|
|
455
|
+
(0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.reactionAdded, { postId });
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
function useReport() {
|
|
460
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
461
|
+
return (0, react_query_1.useMutation)({
|
|
462
|
+
mutationFn: (input) => (0, service_1.reportContent)(cfg, input),
|
|
463
|
+
onSuccess: (_data, input) => {
|
|
464
|
+
(0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.userReported, {
|
|
465
|
+
reason: input.reason,
|
|
466
|
+
contentType: input.postId ? "post" : "comment",
|
|
467
|
+
});
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/** Blocking re-filters everything: drop every community cache and refetch. */
|
|
472
|
+
function useBlockUser() {
|
|
473
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
474
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
475
|
+
return (0, react_query_1.useMutation)({
|
|
476
|
+
mutationFn: ({ userId }) => (0, service_1.blockUser)(cfg, userId),
|
|
477
|
+
onSuccess: () => {
|
|
478
|
+
(0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.userBlocked);
|
|
479
|
+
queryClient.invalidateQueries({ queryKey: ["community"] });
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
function useDeleteContent() {
|
|
484
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
485
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
486
|
+
return (0, react_query_1.useMutation)({
|
|
487
|
+
mutationFn: ({ kind, id }) => kind === "post" ? (0, service_1.deleteOwnPost)(cfg, id) : (0, service_1.deleteOwnComment)(cfg, id),
|
|
488
|
+
onSuccess: (_data, { kind, postId }) => {
|
|
489
|
+
(0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.contentDeleted, { contentType: kind });
|
|
490
|
+
queryClient.invalidateQueries({ queryKey: threadKey(postId) });
|
|
491
|
+
queryClient.invalidateQueries({ queryKey: FEED_KEY });
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ResolvedCommunityConfig } from "./config";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the community user id, signing in anonymously on first use.
|
|
4
|
+
* Never rejects: resolves to null when offline, when anonymous sign-ins are
|
|
5
|
+
* off, or when the host never configured a Supabase client (degraded mode) —
|
|
6
|
+
* the next call retries.
|
|
7
|
+
*/
|
|
8
|
+
export declare function ensureIdentity(cfg: ResolvedCommunityConfig): Promise<string | null>;
|
|
9
|
+
/**
|
|
10
|
+
* Drops the memoized identity (and every dedupe memo) so the next
|
|
11
|
+
* ensureIdentity() re-reads the (possibly different) session. Call after any
|
|
12
|
+
* host auth transition: login/SSO replaces the anonymous uid, logout needs a
|
|
13
|
+
* fresh anonymous identity.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resetIdentity(): void;
|
|
16
|
+
/**
|
|
17
|
+
* Mirror the host's display name, locale and analytics ids onto the
|
|
18
|
+
* profiles row. Safe to call on every app open / community open: each field
|
|
19
|
+
* hits the network at most once per launch per changed value (mold pattern
|
|
20
|
+
* from syncCommunityUsername / syncProfileLocale), batched into one update.
|
|
21
|
+
* No-op in degraded mode (no Supabase client configured).
|
|
22
|
+
*/
|
|
23
|
+
export declare function syncProfileFromHost(cfg: ResolvedCommunityConfig): Promise<void>;
|
package/lib/identity.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ensureIdentity = ensureIdentity;
|
|
4
|
+
exports.resetIdentity = resetIdentity;
|
|
5
|
+
exports.syncProfileFromHost = syncProfileFromHost;
|
|
6
|
+
// One anonymous Supabase identity per install. signInAnonymously() runs once;
|
|
7
|
+
// afterwards getSession() restores the same user (and uuid) from AsyncStorage.
|
|
8
|
+
// Ported from the mold's lib/community-identity.ts — see the design spec for
|
|
9
|
+
// what changed crossing the host-adapter seam.
|
|
10
|
+
let inFlight = null;
|
|
11
|
+
let lastSyncedUsername = null;
|
|
12
|
+
let lastSyncedLocale = null;
|
|
13
|
+
let lastSyncedAnalyticsKey = null;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the community user id, signing in anonymously on first use.
|
|
16
|
+
* Never rejects: resolves to null when offline, when anonymous sign-ins are
|
|
17
|
+
* off, or when the host never configured a Supabase client (degraded mode) —
|
|
18
|
+
* the next call retries.
|
|
19
|
+
*/
|
|
20
|
+
function ensureIdentity(cfg) {
|
|
21
|
+
if (!cfg.supabase)
|
|
22
|
+
return Promise.resolve(null);
|
|
23
|
+
inFlight ?? (inFlight = bootstrap(cfg).catch(() => {
|
|
24
|
+
inFlight = null; // allow a retry on the next call
|
|
25
|
+
return null;
|
|
26
|
+
}));
|
|
27
|
+
return inFlight;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Drops the memoized identity (and every dedupe memo) so the next
|
|
31
|
+
* ensureIdentity() re-reads the (possibly different) session. Call after any
|
|
32
|
+
* host auth transition: login/SSO replaces the anonymous uid, logout needs a
|
|
33
|
+
* fresh anonymous identity.
|
|
34
|
+
*/
|
|
35
|
+
function resetIdentity() {
|
|
36
|
+
inFlight = null;
|
|
37
|
+
lastSyncedUsername = null;
|
|
38
|
+
lastSyncedLocale = null;
|
|
39
|
+
lastSyncedAnalyticsKey = null;
|
|
40
|
+
}
|
|
41
|
+
async function bootstrap(cfg) {
|
|
42
|
+
const client = cfg.requireClient();
|
|
43
|
+
const { data } = await client.auth.getSession();
|
|
44
|
+
if (data.session)
|
|
45
|
+
return data.session.user.id;
|
|
46
|
+
const { data: signIn, error } = await client.auth.signInAnonymously();
|
|
47
|
+
if (error || !signIn.user)
|
|
48
|
+
throw error ?? new Error("anonymous sign-in returned no user");
|
|
49
|
+
const uid = signIn.user.id;
|
|
50
|
+
// Enrich the trigger-created profile so moderation alerts can identify the
|
|
51
|
+
// account (repeat offenders, account value). Best effort.
|
|
52
|
+
const { amplitudeId, revenuecatId } = cfg.host.getAnalyticsIds();
|
|
53
|
+
await client
|
|
54
|
+
.from("profiles")
|
|
55
|
+
.update({ amplitude_id: amplitudeId ?? null, revenuecat_id: revenuecatId ?? null })
|
|
56
|
+
.eq("id", uid);
|
|
57
|
+
return uid;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Mirror the host's display name, locale and analytics ids onto the
|
|
61
|
+
* profiles row. Safe to call on every app open / community open: each field
|
|
62
|
+
* hits the network at most once per launch per changed value (mold pattern
|
|
63
|
+
* from syncCommunityUsername / syncProfileLocale), batched into one update.
|
|
64
|
+
* No-op in degraded mode (no Supabase client configured).
|
|
65
|
+
*/
|
|
66
|
+
async function syncProfileFromHost(cfg) {
|
|
67
|
+
if (!cfg.supabase)
|
|
68
|
+
return;
|
|
69
|
+
const name = cfg.host.getDisplayName()?.trim() || null;
|
|
70
|
+
const locale = cfg.host.getLocale();
|
|
71
|
+
const { amplitudeId, revenuecatId } = cfg.host.getAnalyticsIds();
|
|
72
|
+
const analyticsKey = JSON.stringify({
|
|
73
|
+
amplitudeId: amplitudeId ?? null,
|
|
74
|
+
revenuecatId: revenuecatId ?? null,
|
|
75
|
+
});
|
|
76
|
+
const update = {};
|
|
77
|
+
if (name && name !== lastSyncedUsername)
|
|
78
|
+
update.username = name;
|
|
79
|
+
// profiles.locale only exists when the push module's migration ran
|
|
80
|
+
// (push/001_push.sql) — writing it on a core-only install fails the
|
|
81
|
+
// update with an unknown-column error.
|
|
82
|
+
if (cfg.modules.push && locale !== lastSyncedLocale)
|
|
83
|
+
update.locale = locale;
|
|
84
|
+
if (analyticsKey !== lastSyncedAnalyticsKey) {
|
|
85
|
+
update.amplitude_id = amplitudeId ?? null;
|
|
86
|
+
update.revenuecat_id = revenuecatId ?? null;
|
|
87
|
+
}
|
|
88
|
+
if (Object.keys(update).length === 0)
|
|
89
|
+
return;
|
|
90
|
+
const uid = await ensureIdentity(cfg);
|
|
91
|
+
if (!uid)
|
|
92
|
+
return;
|
|
93
|
+
const client = cfg.requireClient();
|
|
94
|
+
const { error } = await client.from("profiles").update(update).eq("id", uid);
|
|
95
|
+
if (!error) {
|
|
96
|
+
if ("username" in update)
|
|
97
|
+
lastSyncedUsername = name;
|
|
98
|
+
if ("locale" in update)
|
|
99
|
+
lastSyncedLocale = locale;
|
|
100
|
+
if ("amplitude_id" in update)
|
|
101
|
+
lastSyncedAnalyticsKey = analyticsKey;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type UseQueryResult } from "@tanstack/react-query";
|
|
2
|
+
import { type InboxState } from "./inbox-service";
|
|
3
|
+
/**
|
|
4
|
+
* The inbox, shared by the host's badge and the center screen — one query
|
|
5
|
+
* key backs both, so opening the screen never triggers a second fetch on top
|
|
6
|
+
* of the badge's. Refreshes on foreground like every query (host's own
|
|
7
|
+
* focusManager wiring, same as the rest of this package).
|
|
8
|
+
*/
|
|
9
|
+
export declare function useNotificationInbox(): UseQueryResult<InboxState>;
|
|
10
|
+
/**
|
|
11
|
+
* Unread badge count. Fail-soft rule (mold): the inbox is an enhancement, so a
|
|
12
|
+
* fetch error, a disabled module, or a degraded (no-backend) host must never
|
|
13
|
+
* surface upstream — this returns 0 in every one of those cases, never
|
|
14
|
+
* `undefined` and never a thrown error.
|
|
15
|
+
*/
|
|
16
|
+
export declare function useUnreadNotificationCount(): number;
|
|
17
|
+
/**
|
|
18
|
+
* Moves the seen marker past everything currently in the inbox so the badge
|
|
19
|
+
* clears immediately (optimistic), then persists the clock-skew-safe marker
|
|
20
|
+
* server-side (see `markInboxSeen`) — the next fetch reconciles the cache
|
|
21
|
+
* with that authoritative value.
|
|
22
|
+
*/
|
|
23
|
+
export declare function useMarkInboxSeen(): import("@tanstack/react-query").UseMutationResult<void, Error, void, void>;
|