@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/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>;
@@ -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>;