@rocapine/community-core 0.1.0 → 0.2.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
@@ -165,7 +165,7 @@ runs in production and is silent in degraded mode.
165
165
  - **Inbox** (opt-in via `modules.inbox`): `fetchInbox`, `markInboxSeen`,
166
166
  `unreadCount`, and hooks `useNotificationInbox`,
167
167
  `useUnreadNotificationCount`, `useMarkInboxSeen`.
168
- - **`timeAgo(iso)`** — hardcoded-English relative-time formatting, kept for
168
+ - **`timeAgo(iso, nowMs)`** — hardcoded-English relative-time formatting, kept for
169
169
  back-compat. **`timeAgoParts(iso, nowMs)`** — the language-free unit+value
170
170
  decomposition the UI package actually localizes through its own catalog
171
171
  (see `formatTimeAgo` in `@rocapine/community-ui`).
package/lib/hooks.d.ts CHANGED
@@ -76,14 +76,14 @@ export declare function useCreateComment(): import("@tanstack/react-query").UseM
76
76
  }, {
77
77
  tempId: string;
78
78
  }>;
79
- /** Optimistic like toggle across all cached feed pages (every topic filter). */
79
+ /** Optimistic like toggle across every post cache (feed pages of every topic
80
+ * filter, user-posts and search — same sweep as votes/reactions, so a like
81
+ * from a profile or search screen is reflected immediately too). */
80
82
  export declare function useToggleLike(): import("@tanstack/react-query").UseMutationResult<void, Error, {
81
83
  postId: string;
82
84
  liked: boolean;
83
85
  topic: string | null;
84
- }, {
85
- previous: [readonly unknown[], InfiniteData<FeedPost[], unknown> | undefined][];
86
- }>;
86
+ }, PostCacheSnapshot>;
87
87
  /**
88
88
  * Optimistic poll vote across every post cache (mirror of useToggleLike, but
89
89
  * reaching the user-posts/search caches too — see `applyOptimisticToAllPostCaches`).
package/lib/hooks.js CHANGED
@@ -364,34 +364,24 @@ function useCreateComment() {
364
364
  },
365
365
  });
366
366
  }
367
- /** Optimistic like toggle across all cached feed pages (every topic filter). */
367
+ /** Optimistic like toggle across every post cache (feed pages of every topic
368
+ * filter, user-posts and search — same sweep as votes/reactions, so a like
369
+ * from a profile or search screen is reflected immediately too). */
368
370
  function useToggleLike() {
369
371
  const cfg = (0, provider_1.useCommunityConfig)();
370
372
  const queryClient = (0, react_query_1.useQueryClient)();
371
373
  return (0, react_query_1.useMutation)({
372
374
  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);
375
+ onMutate: ({ postId, liked }) => applyOptimisticToAllPostCaches(queryClient, (post) => post.id === postId
376
+ ? {
377
+ ...post,
378
+ likedByMe: liked,
379
+ likeCount: Math.max(0, post.likeCount + (liked ? 1 : -1)),
380
+ }
381
+ : post),
382
+ onError: (_err, _vars, snapshot) => {
383
+ if (snapshot)
384
+ rollbackPostCaches(queryClient, snapshot);
395
385
  },
396
386
  onSuccess: (_data, { liked, postId, topic }) => {
397
387
  if (liked)
@@ -489,6 +479,8 @@ function useDeleteContent() {
489
479
  (0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.contentDeleted, { contentType: kind });
490
480
  queryClient.invalidateQueries({ queryKey: threadKey(postId) });
491
481
  queryClient.invalidateQueries({ queryKey: FEED_KEY });
482
+ queryClient.invalidateQueries({ queryKey: USER_POSTS_KEY });
483
+ queryClient.invalidateQueries({ queryKey: SEARCH_KEY });
492
484
  },
493
485
  });
494
486
  }
package/lib/models.d.ts CHANGED
@@ -26,8 +26,9 @@ export interface PostRow {
26
26
  comments: {
27
27
  count: number;
28
28
  }[];
29
- /** Empty for a post without a poll. */
30
- poll_options: PollOptionRow[];
29
+ /** Empty for a post without a poll; absent entirely when the polls module
30
+ * is off (the embed is then not part of the select). */
31
+ poll_options?: PollOptionRow[];
31
32
  }
32
33
  export interface PollOptionRow {
33
34
  id: string;
@@ -154,7 +155,7 @@ export interface CommunityProfile {
154
155
  }
155
156
  export declare function mapProfileRow(row: ProfileRow, fallback: string): CommunityProfile;
156
157
  /** Assemble a FeedPoll from the embedded option rows + batched vote data. */
157
- export declare function buildPoll(options: PollOptionRow[], counts: ReadonlyMap<string, number>, myOptionId: string | null): FeedPoll | null;
158
+ export declare function buildPoll(options: PollOptionRow[] | undefined, counts: ReadonlyMap<string, number>, myOptionId: string | null): FeedPoll | null;
158
159
  /** Integer percentage for a result bar; a zero-vote poll shows 0% everywhere. */
159
160
  export declare function pollPercent(votes: number, totalVotes: number): number;
160
161
  export declare function mapPostRow(row: PostRow, myUid: string, fallback: string, topics: readonly CommunityTopicDef[], likedPostIds: ReadonlySet<string>, pollData?: PollData, reactionData?: ReactionData,
package/lib/models.js CHANGED
@@ -67,7 +67,7 @@ function mapProfileRow(row, fallback) {
67
67
  }
68
68
  /** Assemble a FeedPoll from the embedded option rows + batched vote data. */
69
69
  function buildPoll(options, counts, myOptionId) {
70
- if (options.length === 0)
70
+ if (!options || options.length === 0)
71
71
  return null;
72
72
  const mapped = [...options]
73
73
  .sort((a, b) => a.idx - b.idx)
package/lib/service.d.ts CHANGED
@@ -5,7 +5,7 @@ import { type CommunityProfile, type FeedPost, type ReactionData, type ThreadCom
5
5
  * exported so it's unit-testable without a Supabase client. Invalid column
6
6
  * names are dropped with a `console.warn` rather than failing the query.
7
7
  */
8
- export declare function buildFeedSelect(extraPostColumns?: readonly string[]): string;
8
+ export declare function buildFeedSelect(extraPostColumns?: readonly string[], polls?: boolean): string;
9
9
  /**
10
10
  * Batched reaction state for one page: count + last reactor's display name + my-reaction
11
11
  * flag, all from the single security-definer `post_reaction_summary` RPC (the backend
package/lib/service.js CHANGED
@@ -41,7 +41,11 @@ async function requireUid(cfg) {
41
41
  }
42
42
  // Embedded filter: comment counts only count visible comments, so the badge
43
43
  // on a card always matches what the opened thread renders.
44
- const FEED_SELECT = "id, author_id, topic, content, status, pinned_at, created_at, profiles!posts_author_id_fkey(username, is_official, handle, avatar_url), likes(count), comments(count), poll_options(id, idx, label)";
44
+ const FEED_SELECT = "id, author_id, topic, content, status, pinned_at, created_at, profiles!posts_author_id_fkey(username, is_official, handle, avatar_url), likes(count), comments(count)";
45
+ /** Embedded only when the polls module is on: on a core-only backend the
46
+ * `poll_options` table does not exist and PostgREST rejects the whole select
47
+ * (PGRST200), which took the entire feed down for such installs. */
48
+ const POLL_OPTIONS_SELECT = "poll_options(id, idx, label)";
45
49
  const PROFILE_SELECT = "id, username, handle, is_official, bio, avatar_url";
46
50
  /** Minimal injection guard for `CommunityConfig.feed.extraPostColumns`: a bare
47
51
  * column name only, no `.`, `(`, `:`, whitespace, etc. — those are how a
@@ -54,9 +58,10 @@ const VALID_EXTRA_COLUMN = /^[a-z0-9_]+$/;
54
58
  * exported so it's unit-testable without a Supabase client. Invalid column
55
59
  * names are dropped with a `console.warn` rather than failing the query.
56
60
  */
57
- function buildFeedSelect(extraPostColumns) {
61
+ function buildFeedSelect(extraPostColumns, polls = false) {
62
+ const base = polls ? `${FEED_SELECT}, ${POLL_OPTIONS_SELECT}` : FEED_SELECT;
58
63
  if (!extraPostColumns || extraPostColumns.length === 0)
59
- return FEED_SELECT;
64
+ return base;
60
65
  const valid = extraPostColumns.filter((column) => {
61
66
  if (VALID_EXTRA_COLUMN.test(column))
62
67
  return true;
@@ -64,8 +69,13 @@ function buildFeedSelect(extraPostColumns) {
64
69
  return false;
65
70
  });
66
71
  if (valid.length === 0)
67
- return FEED_SELECT;
68
- return `${FEED_SELECT}, ${valid.join(", ")}`;
72
+ return base;
73
+ return `${base}, ${valid.join(", ")}`;
74
+ }
75
+ /** The posts select for this install: `feed.extraPostColumns` + the poll
76
+ * embed iff `modules.polls`. */
77
+ function postsSelect(cfg) {
78
+ return buildFeedSelect(cfg.feed.extraPostColumns, cfg.modules.polls);
69
79
  }
70
80
  async function fetchMyLikes(client, postIds, uid) {
71
81
  if (postIds.length === 0)
@@ -143,7 +153,7 @@ async function toFeedPosts(cfg, client, rows, uid) {
143
153
  const postIds = rows.map((r) => r.id);
144
154
  const [liked, pollData, reactionData] = await Promise.all([
145
155
  fetchMyLikes(client, postIds, uid),
146
- fetchPollData(cfg, rows.filter((r) => r.poll_options.length > 0).map((r) => r.id), uid),
156
+ fetchPollData(cfg, rows.filter((r) => (r.poll_options ?? []).length > 0).map((r) => r.id), uid),
147
157
  fetchReactionSummaries(cfg, postIds),
148
158
  ]);
149
159
  return rows.map((r) => (0, models_1.mapPostRow)(r, uid, cfg.anonymousAuthorFallback, cfg.topics, liked, pollData, reactionData, cfg.feed.transformPost));
@@ -158,7 +168,7 @@ async function fetchFeedPage(cfg, opts = {}) {
158
168
  const page = opts.cursor ?? 0;
159
169
  let query = client
160
170
  .from("posts")
161
- .select(buildFeedSelect(cfg.feed.extraPostColumns))
171
+ .select(postsSelect(cfg))
162
172
  .eq("comments.status", "visible")
163
173
  // Others' rows are already limited to 'visible' by RLS; this also shows the
164
174
  // author their own 'pending' posts (optimistic) while hiding their moderated-out
@@ -220,7 +230,7 @@ async function fetchUserPosts(cfg, userId, opts = {}) {
220
230
  const page = opts.cursor ?? 0;
221
231
  const { data, error } = await client
222
232
  .from("posts")
223
- .select(buildFeedSelect(cfg.feed.extraPostColumns))
233
+ .select(postsSelect(cfg))
224
234
  .eq("author_id", userId)
225
235
  .eq("comments.status", "visible")
226
236
  .in("status", ["visible", "pending"])
@@ -244,7 +254,7 @@ async function searchPosts(cfg, query, opts = {}) {
244
254
  const page = opts.cursor ?? 0;
245
255
  const { data, error } = await client
246
256
  .from("posts")
247
- .select(buildFeedSelect(cfg.feed.extraPostColumns))
257
+ .select(postsSelect(cfg))
248
258
  .eq("comments.status", "visible")
249
259
  .eq("status", "visible")
250
260
  .ilike("content", `%${cleaned}%`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": {