@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 +1 -1
- package/lib/hooks.d.ts +4 -4
- package/lib/hooks.js +15 -23
- package/lib/models.d.ts +4 -3
- package/lib/models.js +1 -1
- package/lib/service.d.ts +1 -1
- package/lib/service.js +19 -9
- package/package.json +1 -1
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
|
|
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
|
|
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:
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
68
|
-
return `${
|
|
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(
|
|
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(
|
|
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(
|
|
257
|
+
.select(postsSelect(cfg))
|
|
248
258
|
.eq("comments.status", "visible")
|
|
249
259
|
.eq("status", "visible")
|
|
250
260
|
.ilike("content", `%${cleaned}%`)
|
package/package.json
CHANGED