@rocapine/community-core 0.8.1 → 0.10.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/README.md CHANGED
@@ -175,7 +175,8 @@ runs in production and is silent in degraded mode.
175
175
  (pure helper behind `feed.extraPostColumns`, see below).
176
176
  - **React Query hooks** (`hooks.ts`): `useCommunityFeed`, `useSearchPosts`,
177
177
  `useNewPostsCount`, `useCommunityUnseenCount`, `useThread`, `useProfile`,
178
- `useUserPosts`, `useMyUid`, `useUpdateProfile`, `useCreatePost`,
178
+ `useUserPosts`, `useMyUid`, `useNotificationPreferences` (push module: the
179
+ caller's `notify_*` flags), `useUpdateProfile`, `useCreatePost`,
179
180
  `useCreateComment`, `useToggleLike`, `useVotePoll`, `useReactToPost`,
180
181
  `useReport`, `useBlockUser`, `useDeleteContent`.
181
182
  - **Inbox** (opt-in via `modules.inbox`): `fetchInbox`, `markInboxSeen`,
package/lib/hooks.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type InfiniteData, type QueryClient } from "@tanstack/react-query";
2
2
  import { type CommunityProfile, type FeedPost, type ThreadComment } from "./models";
3
- import { type ModerationVerdict, type ReportInput, type UpdateProfileInput, type UpdateProfileResult } from "./service";
3
+ import { type NotificationPreferenceKey, type NotificationPreferences, type ModerationVerdict, type ReportInput, type UpdateProfileInput, type UpdateProfileResult } from "./service";
4
4
  /** True while a post/comment is still the optimistic placeholder inserted by
5
5
  * `useCreatePost`/`useCreateComment` (its temp id is swapped for the real one
6
6
  * once moderation answers). Server actions keyed on the id (reactions, likes,
@@ -54,14 +54,23 @@ export declare function useNewPostsCount(sinceIso: string | null, topic?: string
54
54
  * package has no store access at all, so the host now owns that value and
55
55
  * passes it in).
56
56
  */
57
- export declare function useCommunityUnseenCount(lastSeenAtIso: string | null): number;
57
+ export declare function useCommunityUnseenCount(lastSeenAtIso: string | null, options?: {
58
+ enabled?: boolean;
59
+ }): number;
58
60
  export declare function useThread(postId: string | null): import("@tanstack/react-query").UseQueryResult<ThreadComment[], Error>;
59
61
  export declare function useProfile(userId: string | null): import("@tanstack/react-query").UseQueryResult<CommunityProfile, Error>;
60
62
  /**
61
63
  * One user's posts, newest first, infinite-scrolling like the feed.
62
64
  */
63
65
  export declare function useUserPosts(userId: string | null): import("@tanstack/react-query").UseInfiniteQueryResult<InfiniteData<FeedPost[], unknown>, Error>;
64
- /** Our own stable community uid, resolved once and cached for the app session. */
66
+ /** The caller's push preferences (push module). `preferences` is `null` until the
67
+ * device has a push row; `setPreference` updates one flag optimistically. */
68
+ export declare function useNotificationPreferences(): {
69
+ preferences: NotificationPreferences | null;
70
+ isLoading: boolean;
71
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NotificationPreferences | null, Error>>;
72
+ setPreference: (key: NotificationPreferenceKey, enabled: boolean) => Promise<void>;
73
+ };
65
74
  export declare function useMyUid(): string | null;
66
75
  /** Profile edits refresh everything derived from a profile row: the profile
67
76
  * itself, plus the handle/avatar shown on every cached feed and thread. */
@@ -111,16 +120,14 @@ export declare function useVotePoll(): import("@tanstack/react-query").UseMutati
111
120
  optionId: string;
112
121
  }, PostCacheSnapshot>;
113
122
  /**
114
- * Optimistic reaction across every post cache (mirror of useVotePoll; renamed
115
- * from the mold's `usePrayForPost`). `applyReaction(post)` is single-argument
116
- * (Task 3 ruling — it does not filter by post id), so the mapper here pre-filters
117
- * to the target post itself before calling it, and separately tracks whether
118
- * that post already had `hasReacted` so a retap of an already-reacted post
119
- * (the reaction is non-retractable, `setReaction` is idempotent) does not
120
- * re-emit the analytics event.
123
+ * Optimistic reaction toggle across every post cache (mirror of useToggleLike;
124
+ * renamed from the mold's `usePrayForPost`). `on` defaults to true (react);
125
+ * `on: false` withdraws it. `alreadyReacted` keeps a retap of an
126
+ * already-reacted post from re-emitting the analytics event.
121
127
  */
122
128
  export declare function useReactToPost(): import("@tanstack/react-query").UseMutationResult<void, Error, {
123
129
  postId: string;
130
+ on?: boolean;
124
131
  }, PostCacheSnapshot & {
125
132
  alreadyReacted: boolean;
126
133
  }>;
package/lib/hooks.js CHANGED
@@ -19,6 +19,7 @@ exports.useCommunityUnseenCount = useCommunityUnseenCount;
19
19
  exports.useThread = useThread;
20
20
  exports.useProfile = useProfile;
21
21
  exports.useUserPosts = useUserPosts;
22
+ exports.useNotificationPreferences = useNotificationPreferences;
22
23
  exports.useMyUid = useMyUid;
23
24
  exports.useUpdateProfile = useUpdateProfile;
24
25
  exports.useCreatePost = useCreatePost;
@@ -168,12 +169,13 @@ function useNewPostsCount(sinceIso, topic, polling = true) {
168
169
  * package has no store access at all, so the host now owns that value and
169
170
  * passes it in).
170
171
  */
171
- function useCommunityUnseenCount(lastSeenAtIso) {
172
+ function useCommunityUnseenCount(lastSeenAtIso, options = {}) {
172
173
  const cfg = (0, provider_1.useCommunityConfig)();
173
174
  const q = (0, react_query_1.useQuery)({
174
175
  queryKey: ["community", "unseen", lastSeenAtIso ?? "never"],
175
176
  queryFn: () => (0, service_1.countNewPosts)(cfg, lastSeenAtIso ?? "1970-01-01T00:00:00Z"),
176
- enabled: cfg.supabase !== null,
177
+ // `enabled: false` keeps a host's kill-switch arm from creating the identity or polling.
178
+ enabled: cfg.supabase !== null && options.enabled !== false,
177
179
  staleTime: 1000 * 60,
178
180
  refetchInterval: 1000 * 120,
179
181
  refetchIntervalInBackground: false,
@@ -219,6 +221,40 @@ function useUserPosts(userId) {
219
221
  });
220
222
  }
221
223
  /** Our own stable community uid, resolved once and cached for the app session. */
224
+ const NOTIFICATION_PREFS_KEY = ["community", "notificationPreferences"];
225
+ /** The caller's push preferences (push module). `preferences` is `null` until the
226
+ * device has a push row; `setPreference` updates one flag optimistically. */
227
+ function useNotificationPreferences() {
228
+ const cfg = (0, provider_1.useCommunityConfig)();
229
+ const queryClient = (0, react_query_1.useQueryClient)();
230
+ const q = (0, react_query_1.useQuery)({
231
+ queryKey: NOTIFICATION_PREFS_KEY,
232
+ queryFn: () => (0, service_1.fetchNotificationPreferences)(cfg),
233
+ enabled: cfg.supabase !== null && cfg.modules.push,
234
+ staleTime: 1000 * 60,
235
+ });
236
+ const mutation = (0, react_query_1.useMutation)({
237
+ mutationFn: ({ key, enabled }) => (0, service_1.setNotificationPreference)(cfg, key, enabled),
238
+ onMutate: async ({ key, enabled }) => {
239
+ await queryClient.cancelQueries({ queryKey: NOTIFICATION_PREFS_KEY });
240
+ const previous = queryClient.getQueryData(NOTIFICATION_PREFS_KEY);
241
+ if (previous)
242
+ queryClient.setQueryData(NOTIFICATION_PREFS_KEY, { ...previous, [key]: enabled });
243
+ return { previous };
244
+ },
245
+ onError: (_error, _vars, context) => {
246
+ if (context)
247
+ queryClient.setQueryData(NOTIFICATION_PREFS_KEY, context.previous);
248
+ },
249
+ onSettled: () => queryClient.invalidateQueries({ queryKey: NOTIFICATION_PREFS_KEY }),
250
+ });
251
+ return {
252
+ preferences: q.data ?? null,
253
+ isLoading: q.isLoading,
254
+ refetch: q.refetch,
255
+ setPreference: (key, enabled) => mutation.mutateAsync({ key, enabled }),
256
+ };
257
+ }
222
258
  function useMyUid() {
223
259
  const cfg = (0, provider_1.useCommunityConfig)();
224
260
  const q = (0, react_query_1.useQuery)({
@@ -440,27 +476,24 @@ function useVotePoll() {
440
476
  });
441
477
  }
442
478
  /**
443
- * Optimistic reaction across every post cache (mirror of useVotePoll; renamed
444
- * from the mold's `usePrayForPost`). `applyReaction(post)` is single-argument
445
- * (Task 3 ruling — it does not filter by post id), so the mapper here pre-filters
446
- * to the target post itself before calling it, and separately tracks whether
447
- * that post already had `hasReacted` so a retap of an already-reacted post
448
- * (the reaction is non-retractable, `setReaction` is idempotent) does not
449
- * re-emit the analytics event.
479
+ * Optimistic reaction toggle across every post cache (mirror of useToggleLike;
480
+ * renamed from the mold's `usePrayForPost`). `on` defaults to true (react);
481
+ * `on: false` withdraws it. `alreadyReacted` keeps a retap of an
482
+ * already-reacted post from re-emitting the analytics event.
450
483
  */
451
484
  function useReactToPost() {
452
485
  const cfg = (0, provider_1.useCommunityConfig)();
453
486
  const queryClient = (0, react_query_1.useQueryClient)();
454
487
  return (0, react_query_1.useMutation)({
455
- mutationFn: ({ postId }) => (0, service_1.setReaction)(cfg, postId),
456
- onMutate: async ({ postId }) => {
488
+ mutationFn: ({ postId, on = true }) => (0, service_1.setReaction)(cfg, postId, on),
489
+ onMutate: async ({ postId, on = true }) => {
457
490
  let alreadyReacted = false;
458
491
  const snapshot = await applyOptimisticToAllPostCaches(queryClient, (post) => {
459
492
  if (post.id !== postId)
460
493
  return post;
461
494
  if (post.hasReacted)
462
495
  alreadyReacted = true;
463
- return (0, models_1.applyReaction)(post);
496
+ return (0, models_1.applyReaction)(post, on);
464
497
  });
465
498
  return { ...snapshot, alreadyReacted };
466
499
  },
@@ -468,8 +501,8 @@ function useReactToPost() {
468
501
  if (context)
469
502
  rollbackPostCaches(queryClient, context);
470
503
  },
471
- onSuccess: (_data, { postId }, context) => {
472
- if (!context?.alreadyReacted)
504
+ onSuccess: (_data, { postId, on = true }, context) => {
505
+ if (on && !context?.alreadyReacted)
473
506
  (0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.reactionAdded, { postId });
474
507
  },
475
508
  });
package/lib/index.d.ts CHANGED
@@ -9,9 +9,9 @@ export { timeAgo, timeAgoParts } from "./time";
9
9
  export { COMMUNITY_EVENTS, emitEvent } from "./events";
10
10
  export type { CommunityEventName } from "./events";
11
11
  export { ensureIdentity, resetIdentity, syncProfileFromHost } from "./identity";
12
- export { buildFeedSelect, fetchFeedPage, countNewPosts, fetchProfile, fetchUserPosts, searchPosts, fetchThread, createPost, votePoll, createComment, moderateOne, setLike, setReaction, fetchReactionSummaries, reportContent, blockUser, deleteOwnPost, deleteOwnComment, updateProfile, uploadAvatar, } from "./service";
13
- export type { ModerationVerdict, CreatePostInput, ReportReason, ReportInput, UpdateProfileInput, UpdateProfileResult, } from "./service";
14
- export { useCommunityFeed, useSearchPosts, useNewPostsCount, useCommunityUnseenCount, useThread, useProfile, useUserPosts, useMyUid, useUpdateProfile, useCreatePost, useCreateComment, useToggleLike, useVotePoll, useReactToPost, useReport, useBlockUser, useDeleteContent, isOptimistic, } from "./hooks";
12
+ export { buildFeedSelect, fetchFeedPage, countNewPosts, fetchProfile, fetchUserPosts, searchPosts, fetchThread, createPost, votePoll, createComment, moderateOne, setLike, setReaction, fetchReactionSummaries, reportContent, blockUser, deleteOwnPost, deleteOwnComment, updateProfile, fetchNotificationPreferences, setNotificationPreference, NOTIFICATION_PREFERENCE_KEYS, uploadAvatar, } from "./service";
13
+ export type { ModerationVerdict, CreatePostInput, ReportReason, ReportInput, UpdateProfileInput, UpdateProfileResult, NotificationPreferenceKey, NotificationPreferences, } from "./service";
14
+ export { useCommunityFeed, useSearchPosts, useNewPostsCount, useCommunityUnseenCount, useThread, useProfile, useUserPosts, useMyUid, useNotificationPreferences, useUpdateProfile, useCreatePost, useCreateComment, useToggleLike, useVotePoll, useReactToPost, useReport, useBlockUser, useDeleteContent, isOptimistic, } from "./hooks";
15
15
  export { fetchInbox, localizeExcerpts, markInboxSeen, unreadCount } from "./inbox-service";
16
16
  export type { InboxItem, InboxState } from "./inbox-service";
17
17
  export { useNotificationInbox, useUnreadNotificationCount, useMarkInboxSeen } from "./inbox-hooks";
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.deleteOwnComment = exports.deleteOwnPost = exports.blockUser = exports.reportContent = exports.fetchReactionSummaries = exports.setReaction = exports.setLike = exports.moderateOne = exports.createComment = exports.votePoll = exports.createPost = exports.fetchThread = exports.searchPosts = exports.fetchUserPosts = exports.fetchProfile = exports.countNewPosts = exports.fetchFeedPage = exports.buildFeedSelect = exports.syncProfileFromHost = exports.resetIdentity = exports.ensureIdentity = exports.emitEvent = exports.COMMUNITY_EVENTS = exports.timeAgoParts = exports.timeAgo = exports.newestCreatedAt = exports.newPostsLabel = exports.mapCommentRow = exports.applyReaction = exports.applyPollVote = exports.mapPostRow = exports.pollPercent = exports.buildPoll = exports.mapProfileRow = exports.normalizeTopic = exports.displayName = exports.EMPTY_REACTION_DATA = exports.EMPTY_POLL_DATA = exports.POLL_OPTION_MAX_LENGTH = exports.POLL_MAX_OPTIONS = exports.POLL_MIN_OPTIONS = exports.COMMENT_MAX_LENGTH = exports.POST_MAX_LENGTH = exports.FEED_PAGE_SIZE = exports.useCommunityConfig = exports.CommunityProvider = exports.languageOf = exports.readerLocale = exports.resolveConfig = exports.CommunityDisabledError = void 0;
4
- exports.REQUIRED_SCHEMA_VERSION = exports.useMarkInboxSeen = exports.useUnreadNotificationCount = exports.useNotificationInbox = exports.unreadCount = exports.markInboxSeen = exports.localizeExcerpts = exports.fetchInbox = exports.isOptimistic = exports.useDeleteContent = exports.useBlockUser = exports.useReport = exports.useReactToPost = exports.useVotePoll = exports.useToggleLike = exports.useCreateComment = exports.useCreatePost = exports.useUpdateProfile = exports.useMyUid = exports.useUserPosts = exports.useProfile = exports.useThread = exports.useCommunityUnseenCount = exports.useNewPostsCount = exports.useSearchPosts = exports.useCommunityFeed = exports.uploadAvatar = exports.updateProfile = void 0;
4
+ exports.REQUIRED_SCHEMA_VERSION = exports.useMarkInboxSeen = exports.useUnreadNotificationCount = exports.useNotificationInbox = exports.unreadCount = exports.markInboxSeen = exports.localizeExcerpts = exports.fetchInbox = exports.isOptimistic = exports.useDeleteContent = exports.useBlockUser = exports.useReport = exports.useReactToPost = exports.useVotePoll = exports.useToggleLike = exports.useCreateComment = exports.useCreatePost = exports.useUpdateProfile = exports.useNotificationPreferences = exports.useMyUid = exports.useUserPosts = exports.useProfile = exports.useThread = exports.useCommunityUnseenCount = exports.useNewPostsCount = exports.useSearchPosts = exports.useCommunityFeed = exports.uploadAvatar = exports.NOTIFICATION_PREFERENCE_KEYS = exports.setNotificationPreference = exports.fetchNotificationPreferences = exports.updateProfile = void 0;
5
5
  var config_1 = require("./config");
6
6
  Object.defineProperty(exports, "CommunityDisabledError", { enumerable: true, get: function () { return config_1.CommunityDisabledError; } });
7
7
  Object.defineProperty(exports, "resolveConfig", { enumerable: true, get: function () { return config_1.resolveConfig; } });
@@ -61,6 +61,9 @@ Object.defineProperty(exports, "blockUser", { enumerable: true, get: function ()
61
61
  Object.defineProperty(exports, "deleteOwnPost", { enumerable: true, get: function () { return service_1.deleteOwnPost; } });
62
62
  Object.defineProperty(exports, "deleteOwnComment", { enumerable: true, get: function () { return service_1.deleteOwnComment; } });
63
63
  Object.defineProperty(exports, "updateProfile", { enumerable: true, get: function () { return service_1.updateProfile; } });
64
+ Object.defineProperty(exports, "fetchNotificationPreferences", { enumerable: true, get: function () { return service_1.fetchNotificationPreferences; } });
65
+ Object.defineProperty(exports, "setNotificationPreference", { enumerable: true, get: function () { return service_1.setNotificationPreference; } });
66
+ Object.defineProperty(exports, "NOTIFICATION_PREFERENCE_KEYS", { enumerable: true, get: function () { return service_1.NOTIFICATION_PREFERENCE_KEYS; } });
64
67
  Object.defineProperty(exports, "uploadAvatar", { enumerable: true, get: function () { return service_1.uploadAvatar; } });
65
68
  var hooks_1 = require("./hooks");
66
69
  Object.defineProperty(exports, "useCommunityFeed", { enumerable: true, get: function () { return hooks_1.useCommunityFeed; } });
@@ -71,6 +74,7 @@ Object.defineProperty(exports, "useThread", { enumerable: true, get: function ()
71
74
  Object.defineProperty(exports, "useProfile", { enumerable: true, get: function () { return hooks_1.useProfile; } });
72
75
  Object.defineProperty(exports, "useUserPosts", { enumerable: true, get: function () { return hooks_1.useUserPosts; } });
73
76
  Object.defineProperty(exports, "useMyUid", { enumerable: true, get: function () { return hooks_1.useMyUid; } });
77
+ Object.defineProperty(exports, "useNotificationPreferences", { enumerable: true, get: function () { return hooks_1.useNotificationPreferences; } });
74
78
  Object.defineProperty(exports, "useUpdateProfile", { enumerable: true, get: function () { return hooks_1.useUpdateProfile; } });
75
79
  Object.defineProperty(exports, "useCreatePost", { enumerable: true, get: function () { return hooks_1.useCreatePost; } });
76
80
  Object.defineProperty(exports, "useCreateComment", { enumerable: true, get: function () { return hooks_1.useCreateComment; } });
package/lib/models.d.ts CHANGED
@@ -203,7 +203,7 @@ export declare function applyPollVote(post: FeedPost, postId: string, optionId:
203
203
  * caller is expected to have already located the target post (e.g. via
204
204
  * `posts.map((p) => (p.id === postId ? applyReaction(p) : p))`).
205
205
  */
206
- export declare function applyReaction(post: FeedPost): FeedPost;
206
+ export declare function applyReaction(post: FeedPost, on?: boolean): FeedPost;
207
207
  export declare function mapCommentRow(row: CommentRow, myUid: string, fallback: string): ThreadComment;
208
208
  /** Floating pill label. "1 new post" / "N new posts". */
209
209
  export declare function newPostsLabel(count: number): string;
package/lib/models.js CHANGED
@@ -150,10 +150,14 @@ function applyPollVote(post, postId, optionId) {
150
150
  * caller is expected to have already located the target post (e.g. via
151
151
  * `posts.map((p) => (p.id === postId ? applyReaction(p) : p))`).
152
152
  */
153
- function applyReaction(post) {
154
- if (post.hasReacted)
153
+ function applyReaction(post, on = true) {
154
+ if (post.hasReacted === on)
155
155
  return post;
156
- return { ...post, hasReacted: true, reactionCount: post.reactionCount + 1 };
156
+ return {
157
+ ...post,
158
+ hasReacted: on,
159
+ reactionCount: Math.max(0, post.reactionCount + (on ? 1 : -1)),
160
+ };
157
161
  }
158
162
  function mapCommentRow(row, myUid, fallback) {
159
163
  return {
package/lib/service.d.ts CHANGED
@@ -102,10 +102,10 @@ export declare function moderateOne(cfg: ResolvedCommunityConfig, input: {
102
102
  }): Promise<ModerationVerdict>;
103
103
  export declare function setLike(cfg: ResolvedCommunityConfig, postId: string, on: boolean): Promise<void>;
104
104
  /**
105
- * `setPrayed` generalized to `setReaction`: insert my private, non-retractable
106
- * reaction row (rows are never deleted — see spec §6). Gated on `cfg.modules.reaction`.
105
+ * `setPrayed` generalized to `setReaction`: insert (`on`) or delete my private
106
+ * reaction row, like `setLike`. Gated on `cfg.modules.reaction`.
107
107
  */
108
- export declare function setReaction(cfg: ResolvedCommunityConfig, postId: string): Promise<void>;
108
+ export declare function setReaction(cfg: ResolvedCommunityConfig, postId: string, on?: boolean): Promise<void>;
109
109
  export type ReportReason = "spam" | "harassment" | "hate" | "inappropriate" | "other";
110
110
  export interface ReportInput {
111
111
  reportedUserId: string;
@@ -116,6 +116,12 @@ export interface ReportInput {
116
116
  }
117
117
  export declare function reportContent(cfg: ResolvedCommunityConfig, input: ReportInput): Promise<void>;
118
118
  export declare function blockUser(cfg: ResolvedCommunityConfig, blockedId: string): Promise<void>;
119
+ export declare const NOTIFICATION_PREFERENCE_KEYS: readonly ["notify_likes", "notify_comments", "notify_reactions"];
120
+ export type NotificationPreferenceKey = (typeof NOTIFICATION_PREFERENCE_KEYS)[number];
121
+ export type NotificationPreferences = Record<NotificationPreferenceKey, boolean>;
122
+ /** The caller's `push_tokens` preferences (push module); `null` while the device has no push row. */
123
+ export declare function fetchNotificationPreferences(cfg: ResolvedCommunityConfig): Promise<NotificationPreferences | null>;
124
+ export declare function setNotificationPreference(cfg: ResolvedCommunityConfig, key: NotificationPreferenceKey, enabled: boolean): Promise<void>;
119
125
  export declare function deleteOwnPost(cfg: ResolvedCommunityConfig, postId: string): Promise<void>;
120
126
  export declare function deleteOwnComment(cfg: ResolvedCommunityConfig, commentId: string): Promise<void>;
121
127
  /**
package/lib/service.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // feed/thread composition (search, poll batching via `poll_vote_counts`, reaction batching
9
9
  // generalized from that host's prayer-style feature's summary RPC into `post_reaction_summary`).
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.NOTIFICATION_PREFERENCE_KEYS = void 0;
11
12
  exports.buildFeedSelect = buildFeedSelect;
12
13
  exports.withTranslationFilters = withTranslationFilters;
13
14
  exports.fetchReactionSummaries = fetchReactionSummaries;
@@ -26,6 +27,8 @@ exports.setLike = setLike;
26
27
  exports.setReaction = setReaction;
27
28
  exports.reportContent = reportContent;
28
29
  exports.blockUser = blockUser;
30
+ exports.fetchNotificationPreferences = fetchNotificationPreferences;
31
+ exports.setNotificationPreference = setNotificationPreference;
29
32
  exports.deleteOwnPost = deleteOwnPost;
30
33
  exports.deleteOwnComment = deleteOwnComment;
31
34
  exports.updateProfile = updateProfile;
@@ -416,18 +419,28 @@ async function setLike(cfg, postId, on) {
416
419
  }
417
420
  }
418
421
  /**
419
- * `setPrayed` generalized to `setReaction`: insert my private, non-retractable
420
- * reaction row (rows are never deleted — see spec §6). Gated on `cfg.modules.reaction`.
422
+ * `setPrayed` generalized to `setReaction`: insert (`on`) or delete my private
423
+ * reaction row, like `setLike`. Gated on `cfg.modules.reaction`.
421
424
  */
422
- async function setReaction(cfg, postId) {
425
+ async function setReaction(cfg, postId, on = true) {
423
426
  if (!cfg.modules.reaction)
424
427
  throw new Error("reaction module is not enabled");
425
428
  const client = cfg.requireClient();
426
429
  const uid = await requireUid(cfg);
427
- const { error } = await client.from("post_reactions").insert({ post_id: postId, user_id: uid });
428
- // 23505 = already reacted to this post: the desired state holds.
429
- if (error && error.code !== "23505")
430
- throw error;
430
+ if (on) {
431
+ const { error } = await client.from("post_reactions").insert({ post_id: postId, user_id: uid });
432
+ // 23505 = already reacted to this post: the desired state holds.
433
+ if (error && error.code !== "23505")
434
+ throw error;
435
+ }
436
+ else {
437
+ const { error } = await client
438
+ .from("post_reactions")
439
+ .delete()
440
+ .match({ post_id: postId, user_id: uid });
441
+ if (error)
442
+ throw error;
443
+ }
431
444
  }
432
445
  async function reportContent(cfg, input) {
433
446
  const client = cfg.requireClient();
@@ -450,6 +463,34 @@ async function blockUser(cfg, blockedId) {
450
463
  if (error && error.code !== "23505")
451
464
  throw error; // already blocked is fine
452
465
  }
466
+ exports.NOTIFICATION_PREFERENCE_KEYS = [
467
+ "notify_likes",
468
+ "notify_comments",
469
+ "notify_reactions",
470
+ ];
471
+ /** The caller's `push_tokens` preferences (push module); `null` while the device has no push row. */
472
+ async function fetchNotificationPreferences(cfg) {
473
+ const client = cfg.requireClient();
474
+ const uid = await requireUid(cfg);
475
+ const { data, error } = await client
476
+ .from("push_tokens")
477
+ .select(exports.NOTIFICATION_PREFERENCE_KEYS.join(","))
478
+ .eq("user_id", uid)
479
+ .maybeSingle();
480
+ if (error)
481
+ throw error;
482
+ return data ?? null;
483
+ }
484
+ async function setNotificationPreference(cfg, key, enabled) {
485
+ const client = cfg.requireClient();
486
+ const uid = await requireUid(cfg);
487
+ const { error } = await client
488
+ .from("push_tokens")
489
+ .update({ [key]: enabled })
490
+ .eq("user_id", uid);
491
+ if (error)
492
+ throw error;
493
+ }
453
494
  async function deleteOwnPost(cfg, postId) {
454
495
  const client = cfg.requireClient();
455
496
  await requireUid(cfg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community-core",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "Headless Supabase-backed community backend: identity, feed/thread/moderation services, and React Query hooks.",
5
5
  "license": "MIT",
6
6
  "repository": {