@rocapine/community-core 0.1.0 → 0.3.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 +20 -5
- package/lib/config.d.ts +6 -0
- package/lib/events.d.ts +1 -0
- package/lib/events.js +1 -0
- package/lib/hooks.d.ts +4 -4
- package/lib/hooks.js +45 -38
- package/lib/inbox-service.d.ts +5 -0
- package/lib/inbox-service.js +31 -1
- package/lib/index.d.ts +3 -2
- package/lib/index.js +6 -2
- package/lib/locale.d.ts +8 -0
- package/lib/locale.js +22 -0
- package/lib/models.d.ts +33 -3
- package/lib/models.js +13 -2
- package/lib/service.d.ts +2 -2
- package/lib/service.js +65 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,10 @@ const config: CommunityConfig = {
|
|
|
38
38
|
push: false,
|
|
39
39
|
inbox: true,
|
|
40
40
|
reaction: { key: "cheer" }, // or `false` to disable the reaction module
|
|
41
|
+
// posts/comments/poll labels translated at publication into these
|
|
42
|
+
// locales — must equal the backend's COMMUNITY_TRANSLATION_LOCALES.
|
|
43
|
+
// Absent or `false` ⇒ every query and screen behaves as before.
|
|
44
|
+
translation: { locales: ["en", "es-419"] },
|
|
41
45
|
},
|
|
42
46
|
|
|
43
47
|
// Host adapters — every field optional, no-op defaults.
|
|
@@ -128,7 +132,8 @@ import { COMMUNITY_EVENTS, type CommunityEventName } from "@rocapine/community-c
|
|
|
128
132
|
`COMMUNITY_EVENTS` is a frozen map of stable event-name strings — `opened`,
|
|
129
133
|
`rulesAccepted`, `postPublished`, `pollVoted`, `threadOpened`, `postLiked`,
|
|
130
134
|
`commentPublished`, `profileOpened`, `profileUpdated`, `reactionAdded`,
|
|
131
|
-
`userReported`, `userBlocked`, `contentDeleted`, `inboxOpened
|
|
135
|
+
`userReported`, `userBlocked`, `contentDeleted`, `inboxOpened`,
|
|
136
|
+
`translationToggled` (`community_translation_toggled`, props `{ postId | commentId, to: "original" | "translation" }`) — forwarded to
|
|
132
137
|
`host.onEvent` by the exported `emitEvent(cfg, name, props)` helper. Route or
|
|
133
138
|
ignore these in your own tracking plan; nothing else in the package calls an
|
|
134
139
|
analytics provider directly.
|
|
@@ -148,7 +153,16 @@ runs in production and is silent in degraded mode.
|
|
|
148
153
|
`ReactionData`, and DB-mirroring constants `FEED_PAGE_SIZE`,
|
|
149
154
|
`POST_MAX_LENGTH` (2000), `COMMENT_MAX_LENGTH` (1000), `POLL_MIN_OPTIONS`
|
|
150
155
|
(2), `POLL_MAX_OPTIONS` (4), `POLL_OPTION_MAX_LENGTH` (60) — client-side
|
|
151
|
-
mirrors of the backend's check constraints.
|
|
156
|
+
mirrors of the backend's check constraints. `FeedPost.translation` /
|
|
157
|
+
`ThreadComment.translation` are `{ text, sourceLocale } | null` (embedded
|
|
158
|
+
only when the translation module is on and the reader locale resolves);
|
|
159
|
+
`text`/`content` always stay the original. Poll options carry
|
|
160
|
+
`translatedLabel: string | null` alongside the original `label`.
|
|
161
|
+
- **Locale** (`locale.ts`): `readerLocale(cfg)` — the locale translations
|
|
162
|
+
are read in (the host's `getLocale()` resolved against
|
|
163
|
+
`modules.translation.locales`), or `null` when the module is off or
|
|
164
|
+
nothing resolves. `languageOf(locale)` — the bare-language prefix of a
|
|
165
|
+
locale tag (e.g. `"es-419"` → `"es"`).
|
|
152
166
|
- **Identity**: `ensureIdentity()`, `resetIdentity()`, `syncProfileFromHost()`
|
|
153
167
|
— anonymous Supabase auth session management.
|
|
154
168
|
- **Queries** (`service.ts`): `fetchFeedPage`, `countNewPosts`, `fetchProfile`,
|
|
@@ -163,9 +177,10 @@ runs in production and is silent in degraded mode.
|
|
|
163
177
|
`useCreateComment`, `useToggleLike`, `useVotePoll`, `useReactToPost`,
|
|
164
178
|
`useReport`, `useBlockUser`, `useDeleteContent`.
|
|
165
179
|
- **Inbox** (opt-in via `modules.inbox`): `fetchInbox`, `markInboxSeen`,
|
|
166
|
-
`unreadCount`,
|
|
167
|
-
|
|
168
|
-
|
|
180
|
+
`unreadCount`, `localizeExcerpts` (swaps a notification's excerpt for its
|
|
181
|
+
reader-locale translation when one is available), and hooks
|
|
182
|
+
`useNotificationInbox`, `useUnreadNotificationCount`, `useMarkInboxSeen`.
|
|
183
|
+
- **`timeAgo(iso, nowMs)`** — hardcoded-English relative-time formatting, kept for
|
|
169
184
|
back-compat. **`timeAgoParts(iso, nowMs)`** — the language-free unit+value
|
|
170
185
|
decomposition the UI package actually localizes through its own catalog
|
|
171
186
|
(see `formatTimeAgo` in `@rocapine/community-ui`).
|
package/lib/config.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export type CommunityModules = {
|
|
|
11
11
|
reaction: {
|
|
12
12
|
key: string;
|
|
13
13
|
} | false;
|
|
14
|
+
/** Translation module: posts/comments/poll labels translated at publication
|
|
15
|
+
* into these locales (must equal the backend's COMMUNITY_TRANSLATION_LOCALES
|
|
16
|
+
* secret). Absent or false ⇒ every query and screen behaves as before. */
|
|
17
|
+
translation?: {
|
|
18
|
+
locales: string[];
|
|
19
|
+
} | false;
|
|
14
20
|
};
|
|
15
21
|
export type CommunityHostAdapters = {
|
|
16
22
|
getDisplayName?: () => string | null;
|
package/lib/events.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare const COMMUNITY_EVENTS: {
|
|
|
14
14
|
readonly userBlocked: "user_blocked";
|
|
15
15
|
readonly contentDeleted: "content_deleted";
|
|
16
16
|
readonly inboxOpened: "notification_center_opened";
|
|
17
|
+
readonly translationToggled: "community_translation_toggled";
|
|
17
18
|
};
|
|
18
19
|
export type CommunityEventName = (typeof COMMUNITY_EVENTS)[keyof typeof COMMUNITY_EVENTS];
|
|
19
20
|
/** Forwards to the host's onEvent adapter. Never throws — a bad adapter must never break the UI. */
|
package/lib/events.js
CHANGED
|
@@ -21,6 +21,7 @@ exports.COMMUNITY_EVENTS = {
|
|
|
21
21
|
userBlocked: "user_blocked",
|
|
22
22
|
contentDeleted: "content_deleted",
|
|
23
23
|
inboxOpened: "notification_center_opened",
|
|
24
|
+
translationToggled: "community_translation_toggled",
|
|
24
25
|
};
|
|
25
26
|
/** Forwards to the host's onEvent adapter. Never throws — a bad adapter must never break the UI. */
|
|
26
27
|
function emitEvent(cfg, name, props) {
|
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
|
@@ -30,15 +30,16 @@ exports.useDeleteContent = useDeleteContent;
|
|
|
30
30
|
const react_query_1 = require("@tanstack/react-query");
|
|
31
31
|
const events_1 = require("./events");
|
|
32
32
|
const identity_1 = require("./identity");
|
|
33
|
+
const locale_1 = require("./locale");
|
|
33
34
|
const models_1 = require("./models");
|
|
34
35
|
const provider_1 = require("./provider");
|
|
35
36
|
const service_1 = require("./service");
|
|
36
37
|
const FEED_KEY = ["community", "feed"];
|
|
37
|
-
const feedKey = (topic) => [...FEED_KEY, topic ?? "all"];
|
|
38
|
-
const threadKey = (postId) => ["community", "thread", postId];
|
|
38
|
+
const feedKey = (topic, locale) => [...FEED_KEY, topic ?? "all", locale ?? "src"];
|
|
39
|
+
const threadKey = (postId, locale) => ["community", "thread", postId, locale ?? "src"];
|
|
39
40
|
const profileKey = (userId) => ["community", "profile", userId];
|
|
40
|
-
const userPostsKey = (userId) => ["community", "userPosts", userId];
|
|
41
41
|
const USER_POSTS_KEY = ["community", "userPosts"];
|
|
42
|
+
const userPostsKey = (userId, locale) => [...USER_POSTS_KEY, userId, locale ?? "src"];
|
|
42
43
|
const SEARCH_KEY = ["community", "search"];
|
|
43
44
|
/**
|
|
44
45
|
* Adjust a post's commentCount across every cached feed page (all topic
|
|
@@ -94,8 +95,9 @@ function rollbackPostCaches(queryClient, snapshot) {
|
|
|
94
95
|
*/
|
|
95
96
|
function useCommunityFeed(topic, enabled = true) {
|
|
96
97
|
const cfg = (0, provider_1.useCommunityConfig)();
|
|
98
|
+
const locale = (0, locale_1.readerLocale)(cfg);
|
|
97
99
|
return (0, react_query_1.useInfiniteQuery)({
|
|
98
|
-
queryKey: feedKey(topic),
|
|
100
|
+
queryKey: feedKey(topic, locale),
|
|
99
101
|
queryFn: ({ pageParam }) => (0, service_1.fetchFeedPage)(cfg, { topic, cursor: pageParam }),
|
|
100
102
|
initialPageParam: 0,
|
|
101
103
|
getNextPageParam: (lastPage, pages) => lastPage.length === models_1.FEED_PAGE_SIZE ? pages.length : undefined,
|
|
@@ -111,8 +113,9 @@ function useCommunityFeed(topic, enabled = true) {
|
|
|
111
113
|
function useSearchPosts(term) {
|
|
112
114
|
const cfg = (0, provider_1.useCommunityConfig)();
|
|
113
115
|
const cleaned = term.trim();
|
|
116
|
+
const locale = (0, locale_1.readerLocale)(cfg);
|
|
114
117
|
return (0, react_query_1.useInfiniteQuery)({
|
|
115
|
-
queryKey: [...SEARCH_KEY, cleaned],
|
|
118
|
+
queryKey: [...SEARCH_KEY, cleaned, locale ?? "src"],
|
|
116
119
|
queryFn: ({ pageParam }) => (0, service_1.searchPosts)(cfg, cleaned, { cursor: pageParam }),
|
|
117
120
|
initialPageParam: 0,
|
|
118
121
|
getNextPageParam: (lastPage, pages) => lastPage.length === models_1.FEED_PAGE_SIZE ? pages.length : undefined,
|
|
@@ -163,8 +166,9 @@ function useCommunityUnseenCount(lastSeenAtIso) {
|
|
|
163
166
|
}
|
|
164
167
|
function useThread(postId) {
|
|
165
168
|
const cfg = (0, provider_1.useCommunityConfig)();
|
|
169
|
+
const locale = (0, locale_1.readerLocale)(cfg);
|
|
166
170
|
return (0, react_query_1.useQuery)({
|
|
167
|
-
queryKey: threadKey(postId ?? "none"),
|
|
171
|
+
queryKey: threadKey(postId ?? "none", locale),
|
|
168
172
|
queryFn: () => (0, service_1.fetchThread)(cfg, postId),
|
|
169
173
|
enabled: cfg.supabase !== null && !!postId,
|
|
170
174
|
staleTime: 1000 * 15,
|
|
@@ -186,8 +190,9 @@ function useProfile(userId) {
|
|
|
186
190
|
*/
|
|
187
191
|
function useUserPosts(userId) {
|
|
188
192
|
const cfg = (0, provider_1.useCommunityConfig)();
|
|
193
|
+
const locale = (0, locale_1.readerLocale)(cfg);
|
|
189
194
|
return (0, react_query_1.useInfiniteQuery)({
|
|
190
|
-
queryKey: userPostsKey(userId ?? "none"),
|
|
195
|
+
queryKey: userPostsKey(userId ?? "none", locale),
|
|
191
196
|
queryFn: ({ pageParam }) => (0, service_1.fetchUserPosts)(cfg, userId, { cursor: pageParam }),
|
|
192
197
|
initialPageParam: 0,
|
|
193
198
|
getNextPageParam: (lastPage, pages) => lastPage.length === models_1.FEED_PAGE_SIZE ? pages.length : undefined,
|
|
@@ -235,6 +240,7 @@ function useUpdateProfile() {
|
|
|
235
240
|
function useCreatePost() {
|
|
236
241
|
const cfg = (0, provider_1.useCommunityConfig)();
|
|
237
242
|
const queryClient = (0, react_query_1.useQueryClient)();
|
|
243
|
+
const locale = (0, locale_1.readerLocale)(cfg);
|
|
238
244
|
return (0, react_query_1.useMutation)({
|
|
239
245
|
onMutate: async ({ topic, text, authorName, pollOptions }) => {
|
|
240
246
|
await queryClient.cancelQueries({ queryKey: FEED_KEY });
|
|
@@ -259,7 +265,12 @@ function useCreatePost() {
|
|
|
259
265
|
// refetch below swaps in the real uuids.
|
|
260
266
|
poll: pollOptions && pollOptions.length > 0
|
|
261
267
|
? {
|
|
262
|
-
options: pollOptions.map((label, i) => ({
|
|
268
|
+
options: pollOptions.map((label, i) => ({
|
|
269
|
+
id: `temp-${i}`,
|
|
270
|
+
label,
|
|
271
|
+
translatedLabel: null,
|
|
272
|
+
votes: 0,
|
|
273
|
+
})),
|
|
263
274
|
myOptionId: null,
|
|
264
275
|
totalVotes: 0,
|
|
265
276
|
}
|
|
@@ -267,10 +278,11 @@ function useCreatePost() {
|
|
|
267
278
|
reactionCount: 0,
|
|
268
279
|
hasReacted: false,
|
|
269
280
|
lastReactorName: null,
|
|
281
|
+
translation: null,
|
|
270
282
|
};
|
|
271
283
|
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));
|
|
284
|
+
prepend(feedKey(undefined, locale));
|
|
285
|
+
prepend(feedKey(topic, locale));
|
|
274
286
|
return { tempId };
|
|
275
287
|
},
|
|
276
288
|
mutationFn: async ({ topic, text, pollOptions }) => {
|
|
@@ -313,9 +325,10 @@ function useCreatePost() {
|
|
|
313
325
|
function useCreateComment() {
|
|
314
326
|
const cfg = (0, provider_1.useCommunityConfig)();
|
|
315
327
|
const queryClient = (0, react_query_1.useQueryClient)();
|
|
328
|
+
const locale = (0, locale_1.readerLocale)(cfg);
|
|
316
329
|
return (0, react_query_1.useMutation)({
|
|
317
330
|
onMutate: async ({ postId, text, authorName }) => {
|
|
318
|
-
await queryClient.cancelQueries({ queryKey: threadKey(postId) });
|
|
331
|
+
await queryClient.cancelQueries({ queryKey: threadKey(postId, locale) });
|
|
319
332
|
await queryClient.cancelQueries({ queryKey: FEED_KEY });
|
|
320
333
|
const tempId = `optimistic-${Date.now()}`;
|
|
321
334
|
const optimistic = {
|
|
@@ -329,8 +342,9 @@ function useCreateComment() {
|
|
|
329
342
|
text,
|
|
330
343
|
isOwn: true,
|
|
331
344
|
createdAt: new Date().toISOString(),
|
|
345
|
+
translation: null,
|
|
332
346
|
};
|
|
333
|
-
queryClient.setQueryData(threadKey(postId), (d) => [
|
|
347
|
+
queryClient.setQueryData(threadKey(postId, locale), (d) => [
|
|
334
348
|
...(d ?? []),
|
|
335
349
|
optimistic,
|
|
336
350
|
]);
|
|
@@ -344,7 +358,7 @@ function useCreateComment() {
|
|
|
344
358
|
},
|
|
345
359
|
onSuccess: ({ id, verdict }, { postId, text }, { tempId }) => {
|
|
346
360
|
const rejected = verdict.status === "rejected";
|
|
347
|
-
queryClient.setQueryData(threadKey(postId), (d) => (d ?? [])
|
|
361
|
+
queryClient.setQueryData(threadKey(postId, locale), (d) => (d ?? [])
|
|
348
362
|
.filter((c) => !(rejected && c.id === tempId))
|
|
349
363
|
.map((c) => (c.id === tempId ? { ...c, id } : c)));
|
|
350
364
|
// A rejected comment never becomes visible, so undo the optimistic bump.
|
|
@@ -359,39 +373,29 @@ function useCreateComment() {
|
|
|
359
373
|
}
|
|
360
374
|
},
|
|
361
375
|
onError: (_e, { postId }, context) => {
|
|
362
|
-
queryClient.setQueryData(threadKey(postId), (d) => (d ?? []).filter((c) => c.id !== context?.tempId));
|
|
376
|
+
queryClient.setQueryData(threadKey(postId, locale), (d) => (d ?? []).filter((c) => c.id !== context?.tempId));
|
|
363
377
|
bumpFeedCommentCount(queryClient, postId, -1);
|
|
364
378
|
},
|
|
365
379
|
});
|
|
366
380
|
}
|
|
367
|
-
/** Optimistic like toggle across
|
|
381
|
+
/** Optimistic like toggle across every post cache (feed pages of every topic
|
|
382
|
+
* filter, user-posts and search — same sweep as votes/reactions, so a like
|
|
383
|
+
* from a profile or search screen is reflected immediately too). */
|
|
368
384
|
function useToggleLike() {
|
|
369
385
|
const cfg = (0, provider_1.useCommunityConfig)();
|
|
370
386
|
const queryClient = (0, react_query_1.useQueryClient)();
|
|
371
387
|
return (0, react_query_1.useMutation)({
|
|
372
388
|
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);
|
|
389
|
+
onMutate: ({ postId, liked }) => applyOptimisticToAllPostCaches(queryClient, (post) => post.id === postId
|
|
390
|
+
? {
|
|
391
|
+
...post,
|
|
392
|
+
likedByMe: liked,
|
|
393
|
+
likeCount: Math.max(0, post.likeCount + (liked ? 1 : -1)),
|
|
394
|
+
}
|
|
395
|
+
: post),
|
|
396
|
+
onError: (_err, _vars, snapshot) => {
|
|
397
|
+
if (snapshot)
|
|
398
|
+
rollbackPostCaches(queryClient, snapshot);
|
|
395
399
|
},
|
|
396
400
|
onSuccess: (_data, { liked, postId, topic }) => {
|
|
397
401
|
if (liked)
|
|
@@ -483,12 +487,15 @@ function useBlockUser() {
|
|
|
483
487
|
function useDeleteContent() {
|
|
484
488
|
const cfg = (0, provider_1.useCommunityConfig)();
|
|
485
489
|
const queryClient = (0, react_query_1.useQueryClient)();
|
|
490
|
+
const locale = (0, locale_1.readerLocale)(cfg);
|
|
486
491
|
return (0, react_query_1.useMutation)({
|
|
487
492
|
mutationFn: ({ kind, id }) => kind === "post" ? (0, service_1.deleteOwnPost)(cfg, id) : (0, service_1.deleteOwnComment)(cfg, id),
|
|
488
493
|
onSuccess: (_data, { kind, postId }) => {
|
|
489
494
|
(0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.contentDeleted, { contentType: kind });
|
|
490
|
-
queryClient.invalidateQueries({ queryKey: threadKey(postId) });
|
|
495
|
+
queryClient.invalidateQueries({ queryKey: threadKey(postId, locale) });
|
|
491
496
|
queryClient.invalidateQueries({ queryKey: FEED_KEY });
|
|
497
|
+
queryClient.invalidateQueries({ queryKey: USER_POSTS_KEY });
|
|
498
|
+
queryClient.invalidateQueries({ queryKey: SEARCH_KEY });
|
|
492
499
|
},
|
|
493
500
|
});
|
|
494
501
|
}
|
package/lib/inbox-service.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export interface InboxState {
|
|
|
12
12
|
/** Everything at or before this instant is read; null = never opened. */
|
|
13
13
|
seenAt: string | null;
|
|
14
14
|
}
|
|
15
|
+
/** Same truncation as list_notifications()'s `left(p.content, 140)`. */
|
|
16
|
+
export declare function localizeExcerpts(items: InboxItem[], translations: {
|
|
17
|
+
post_id: string;
|
|
18
|
+
content: string;
|
|
19
|
+
}[]): InboxItem[];
|
|
15
20
|
/** The inbox's whole state in one fetch: items (newest first) + seen marker. */
|
|
16
21
|
export declare function fetchInbox(cfg: ResolvedCommunityConfig): Promise<InboxState>;
|
|
17
22
|
/**
|
package/lib/inbox-service.js
CHANGED
|
@@ -12,11 +12,25 @@
|
|
|
12
12
|
// support-reply kind) passes through untouched instead of being special-cased
|
|
13
13
|
// here — the host UI decides what to do with a kind it doesn't recognize.
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.localizeExcerpts = localizeExcerpts;
|
|
15
16
|
exports.fetchInbox = fetchInbox;
|
|
16
17
|
exports.markInboxSeen = markInboxSeen;
|
|
17
18
|
exports.unreadCount = unreadCount;
|
|
18
19
|
const identity_1 = require("./identity");
|
|
20
|
+
const locale_1 = require("./locale");
|
|
19
21
|
const INBOX_PAGE_SIZE = 50;
|
|
22
|
+
/** Same truncation as list_notifications()'s `left(p.content, 140)`. */
|
|
23
|
+
function localizeExcerpts(items, translations) {
|
|
24
|
+
if (translations.length === 0)
|
|
25
|
+
return items;
|
|
26
|
+
const byPost = new Map(translations.map((t) => [t.post_id, t.content.slice(0, 140)]));
|
|
27
|
+
return items.map((item) => {
|
|
28
|
+
const translated = item.postId ? byPost.get(item.postId) : undefined;
|
|
29
|
+
return translated === undefined
|
|
30
|
+
? item
|
|
31
|
+
: { ...item, payload: { ...item.payload, postExcerpt: translated } };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
20
34
|
async function requireUid(cfg) {
|
|
21
35
|
const uid = await (0, identity_1.ensureIdentity)(cfg);
|
|
22
36
|
if (!uid)
|
|
@@ -61,7 +75,23 @@ async function fetchInbox(cfg) {
|
|
|
61
75
|
if (seenRes.error)
|
|
62
76
|
throw seenRes.error;
|
|
63
77
|
const items = (listRes.data ?? []).map(mapNotificationRow);
|
|
64
|
-
|
|
78
|
+
const locale = (0, locale_1.readerLocale)(cfg);
|
|
79
|
+
const postIds = [
|
|
80
|
+
...new Set(items.map((i) => i.postId).filter((id) => Boolean(id))),
|
|
81
|
+
];
|
|
82
|
+
let localized = items;
|
|
83
|
+
if (locale && postIds.length > 0) {
|
|
84
|
+
const { data, error } = await client
|
|
85
|
+
.from("post_translations")
|
|
86
|
+
.select("post_id, content")
|
|
87
|
+
.eq("locale", locale)
|
|
88
|
+
.in("post_id", postIds);
|
|
89
|
+
if (error) {
|
|
90
|
+
console.warn("[@rocapine/community-core] inbox translations unavailable, showing original excerpts:", error.message);
|
|
91
|
+
}
|
|
92
|
+
localized = localizeExcerpts(items, (data ?? []));
|
|
93
|
+
}
|
|
94
|
+
return { items: localized, seenAt: seenRes.data?.seen_at ?? null };
|
|
65
95
|
}
|
|
66
96
|
/**
|
|
67
97
|
* Marks everything currently in the inbox as read. The brief's signature
|
package/lib/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export type { CommunityTopicDef, CommunityModules, CommunityHostAdapters, CommunityFeedConfig, CommunityConfig, ResolvedCommunityConfig, } from "./config";
|
|
2
2
|
export { CommunityDisabledError, resolveConfig } from "./config";
|
|
3
|
+
export { readerLocale, languageOf } from "./locale";
|
|
3
4
|
export { CommunityProvider, useCommunityConfig } from "./provider";
|
|
4
|
-
export type { PostRow, PollOptionRow, CommentRow, ProfileRow, PollOption, FeedPoll, PollData, ReactionData, FeedPost, ThreadComment, CommunityProfile, } from "./models";
|
|
5
|
+
export type { PostRow, PollOptionRow, CommentRow, ProfileRow, PollOption, FeedPoll, PollData, ReactionData, FeedPost, ThreadComment, CommunityProfile, TranslationInfo, TranslationRowEmbed, } from "./models";
|
|
5
6
|
export { FEED_PAGE_SIZE, POST_MAX_LENGTH, COMMENT_MAX_LENGTH, POLL_MIN_OPTIONS, POLL_MAX_OPTIONS, POLL_OPTION_MAX_LENGTH, EMPTY_POLL_DATA, EMPTY_REACTION_DATA, displayName, normalizeTopic, mapProfileRow, buildPoll, pollPercent, mapPostRow, applyPollVote, applyReaction, mapCommentRow, newPostsLabel, newestCreatedAt, } from "./models";
|
|
6
7
|
export type { TimeAgoParts } from "./time";
|
|
7
8
|
export { timeAgo, timeAgoParts } from "./time";
|
|
@@ -11,7 +12,7 @@ export { ensureIdentity, resetIdentity, syncProfileFromHost } from "./identity";
|
|
|
11
12
|
export { buildFeedSelect, fetchFeedPage, countNewPosts, fetchProfile, fetchUserPosts, searchPosts, fetchThread, createPost, votePoll, createComment, moderateOne, setLike, setReaction, fetchReactionSummaries, reportContent, blockUser, deleteOwnPost, deleteOwnComment, updateProfile, uploadAvatar, } from "./service";
|
|
12
13
|
export type { ModerationVerdict, CreatePostInput, ReportReason, ReportInput, UpdateProfileInput, UpdateProfileResult, } from "./service";
|
|
13
14
|
export { useCommunityFeed, useSearchPosts, useNewPostsCount, useCommunityUnseenCount, useThread, useProfile, useUserPosts, useMyUid, useUpdateProfile, useCreatePost, useCreateComment, useToggleLike, useVotePoll, useReactToPost, useReport, useBlockUser, useDeleteContent, } from "./hooks";
|
|
14
|
-
export { fetchInbox, markInboxSeen, unreadCount } from "./inbox-service";
|
|
15
|
+
export { fetchInbox, localizeExcerpts, markInboxSeen, unreadCount } from "./inbox-service";
|
|
15
16
|
export type { InboxItem, InboxState } from "./inbox-service";
|
|
16
17
|
export { useNotificationInbox, useUnreadNotificationCount, useMarkInboxSeen } from "./inbox-hooks";
|
|
17
18
|
export { REQUIRED_SCHEMA_VERSION } from "./schema-check";
|
package/lib/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
exports.REQUIRED_SCHEMA_VERSION = exports.useMarkInboxSeen = exports.useUnreadNotificationCount = exports.useNotificationInbox = exports.unreadCount = exports.markInboxSeen = exports.fetchInbox = 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 = void 0;
|
|
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.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;
|
|
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; } });
|
|
8
|
+
var locale_1 = require("./locale");
|
|
9
|
+
Object.defineProperty(exports, "readerLocale", { enumerable: true, get: function () { return locale_1.readerLocale; } });
|
|
10
|
+
Object.defineProperty(exports, "languageOf", { enumerable: true, get: function () { return locale_1.languageOf; } });
|
|
8
11
|
var provider_1 = require("./provider");
|
|
9
12
|
Object.defineProperty(exports, "CommunityProvider", { enumerable: true, get: function () { return provider_1.CommunityProvider; } });
|
|
10
13
|
Object.defineProperty(exports, "useCommunityConfig", { enumerable: true, get: function () { return provider_1.useCommunityConfig; } });
|
|
@@ -79,6 +82,7 @@ Object.defineProperty(exports, "useBlockUser", { enumerable: true, get: function
|
|
|
79
82
|
Object.defineProperty(exports, "useDeleteContent", { enumerable: true, get: function () { return hooks_1.useDeleteContent; } });
|
|
80
83
|
var inbox_service_1 = require("./inbox-service");
|
|
81
84
|
Object.defineProperty(exports, "fetchInbox", { enumerable: true, get: function () { return inbox_service_1.fetchInbox; } });
|
|
85
|
+
Object.defineProperty(exports, "localizeExcerpts", { enumerable: true, get: function () { return inbox_service_1.localizeExcerpts; } });
|
|
82
86
|
Object.defineProperty(exports, "markInboxSeen", { enumerable: true, get: function () { return inbox_service_1.markInboxSeen; } });
|
|
83
87
|
Object.defineProperty(exports, "unreadCount", { enumerable: true, get: function () { return inbox_service_1.unreadCount; } });
|
|
84
88
|
var inbox_hooks_1 = require("./inbox-hooks");
|
package/lib/locale.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ResolvedCommunityConfig } from "./config";
|
|
2
|
+
export declare function languageOf(locale: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The locale translations are read in: the host's locale resolved against
|
|
5
|
+
* `modules.translation.locales` — exact match, else the first declared locale
|
|
6
|
+
* of the same language, else null (originals only). Null when the module is off.
|
|
7
|
+
*/
|
|
8
|
+
export declare function readerLocale(cfg: ResolvedCommunityConfig): string | null;
|
package/lib/locale.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.languageOf = languageOf;
|
|
4
|
+
exports.readerLocale = readerLocale;
|
|
5
|
+
function languageOf(locale) {
|
|
6
|
+
return locale.split("-")[0]?.toLowerCase() ?? "";
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The locale translations are read in: the host's locale resolved against
|
|
10
|
+
* `modules.translation.locales` — exact match, else the first declared locale
|
|
11
|
+
* of the same language, else null (originals only). Null when the module is off.
|
|
12
|
+
*/
|
|
13
|
+
function readerLocale(cfg) {
|
|
14
|
+
const module = cfg.modules.translation;
|
|
15
|
+
if (!module || module.locales.length === 0)
|
|
16
|
+
return null;
|
|
17
|
+
const host = cfg.host.getLocale();
|
|
18
|
+
if (module.locales.includes(host))
|
|
19
|
+
return host;
|
|
20
|
+
const lang = languageOf(host);
|
|
21
|
+
return module.locales.find((l) => languageOf(l) === lang) ?? null;
|
|
22
|
+
}
|
package/lib/models.d.ts
CHANGED
|
@@ -26,13 +26,32 @@ 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[];
|
|
32
|
+
/** At most one row: server-filtered to the reader's locale. Absent when the
|
|
33
|
+
* translation module is off. */
|
|
34
|
+
post_translations?: TranslationRowEmbed[];
|
|
31
35
|
}
|
|
32
36
|
export interface PollOptionRow {
|
|
33
37
|
id: string;
|
|
34
38
|
idx: number;
|
|
35
39
|
label: string;
|
|
40
|
+
/** At most one row: server-filtered to the reader's locale. */
|
|
41
|
+
poll_option_translations?: {
|
|
42
|
+
content: string;
|
|
43
|
+
}[];
|
|
44
|
+
}
|
|
45
|
+
/** One embedded translation row, filtered server-side on the reader locale. */
|
|
46
|
+
export interface TranslationRowEmbed {
|
|
47
|
+
locale: string;
|
|
48
|
+
source_locale: string;
|
|
49
|
+
content: string;
|
|
50
|
+
}
|
|
51
|
+
export interface TranslationInfo {
|
|
52
|
+
text: string;
|
|
53
|
+
/** Detected language of the original (short code: en, es, pt…). */
|
|
54
|
+
sourceLocale: string;
|
|
36
55
|
}
|
|
37
56
|
export interface CommentRow {
|
|
38
57
|
id: string;
|
|
@@ -47,10 +66,15 @@ export interface CommentRow {
|
|
|
47
66
|
handle: string | null;
|
|
48
67
|
avatar_url: string | null;
|
|
49
68
|
} | null;
|
|
69
|
+
/** At most one row: server-filtered to the reader's locale. Absent when the
|
|
70
|
+
* translation module is off. */
|
|
71
|
+
comment_translations?: TranslationRowEmbed[];
|
|
50
72
|
}
|
|
51
73
|
export interface PollOption {
|
|
52
74
|
id: string;
|
|
53
75
|
label: string;
|
|
76
|
+
/** Label in the reader locale when the translation module provided one. */
|
|
77
|
+
translatedLabel: string | null;
|
|
54
78
|
votes: number;
|
|
55
79
|
}
|
|
56
80
|
export interface FeedPoll {
|
|
@@ -106,6 +130,9 @@ export interface FeedPost {
|
|
|
106
130
|
/** Display name of the most recent reactor, for a "X and N others reacted" affordance;
|
|
107
131
|
* null when nobody has reacted yet or the reaction module is off. */
|
|
108
132
|
lastReactorName: string | null;
|
|
133
|
+
/** Reader-locale translation; null when the item is already in the reader's language,
|
|
134
|
+
* not translated yet, or the module is off. `text` stays the original. */
|
|
135
|
+
translation: TranslationInfo | null;
|
|
109
136
|
}
|
|
110
137
|
export interface ThreadComment {
|
|
111
138
|
id: string;
|
|
@@ -118,6 +145,9 @@ export interface ThreadComment {
|
|
|
118
145
|
text: string;
|
|
119
146
|
isOwn: boolean;
|
|
120
147
|
createdAt: string;
|
|
148
|
+
/** Reader-locale translation; null when the item is already in the reader's language,
|
|
149
|
+
* not translated yet, or the module is off. `text` stays the original. */
|
|
150
|
+
translation: TranslationInfo | null;
|
|
121
151
|
}
|
|
122
152
|
/**
|
|
123
153
|
* Public display name, with a host-configured fallback for profiles not yet named.
|
|
@@ -154,7 +184,7 @@ export interface CommunityProfile {
|
|
|
154
184
|
}
|
|
155
185
|
export declare function mapProfileRow(row: ProfileRow, fallback: string): CommunityProfile;
|
|
156
186
|
/** 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;
|
|
187
|
+
export declare function buildPoll(options: PollOptionRow[] | undefined, counts: ReadonlyMap<string, number>, myOptionId: string | null): FeedPoll | null;
|
|
158
188
|
/** Integer percentage for a result bar; a zero-vote poll shows 0% everywhere. */
|
|
159
189
|
export declare function pollPercent(votes: number, totalVotes: number): number;
|
|
160
190
|
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
|
@@ -65,13 +65,22 @@ function mapProfileRow(row, fallback) {
|
|
|
65
65
|
username: row.username ?? null,
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
+
function embedToTranslation(rows) {
|
|
69
|
+
const row = rows?.[0];
|
|
70
|
+
return row ? { text: row.content, sourceLocale: row.source_locale } : null;
|
|
71
|
+
}
|
|
68
72
|
/** Assemble a FeedPoll from the embedded option rows + batched vote data. */
|
|
69
73
|
function buildPoll(options, counts, myOptionId) {
|
|
70
|
-
if (options.length === 0)
|
|
74
|
+
if (!options || options.length === 0)
|
|
71
75
|
return null;
|
|
72
76
|
const mapped = [...options]
|
|
73
77
|
.sort((a, b) => a.idx - b.idx)
|
|
74
|
-
.map((o) => ({
|
|
78
|
+
.map((o) => ({
|
|
79
|
+
id: o.id,
|
|
80
|
+
label: o.label,
|
|
81
|
+
translatedLabel: o.poll_option_translations?.[0]?.content ?? null,
|
|
82
|
+
votes: counts.get(o.id) ?? 0,
|
|
83
|
+
}));
|
|
75
84
|
return {
|
|
76
85
|
options: mapped,
|
|
77
86
|
myOptionId,
|
|
@@ -108,6 +117,7 @@ transformPost) {
|
|
|
108
117
|
reactionCount: reactionData.counts.get(row.id) ?? 0,
|
|
109
118
|
hasReacted: reactionData.mine.has(row.id),
|
|
110
119
|
lastReactorName: reactionData.lastReactorName.get(row.id) ?? null,
|
|
120
|
+
translation: embedToTranslation(row.post_translations),
|
|
111
121
|
};
|
|
112
122
|
return transformPost ? transformPost(post, row) : post;
|
|
113
123
|
}
|
|
@@ -157,6 +167,7 @@ function mapCommentRow(row, myUid, fallback) {
|
|
|
157
167
|
text: row.content,
|
|
158
168
|
isOwn: row.author_id === myUid,
|
|
159
169
|
createdAt: row.created_at,
|
|
170
|
+
translation: embedToTranslation(row.comment_translations),
|
|
160
171
|
};
|
|
161
172
|
}
|
|
162
173
|
/** Floating pill label. "1 new post" / "N new posts". */
|
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, translations?: 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
|
|
@@ -46,7 +46,7 @@ export declare function fetchUserPosts(cfg: ResolvedCommunityConfig, userId: str
|
|
|
46
46
|
* page-index cursor as `fetchFeedPage` (product decision, reversing the
|
|
47
47
|
* earlier single-page scope cut).
|
|
48
48
|
*/
|
|
49
|
-
export declare function searchPosts(cfg: ResolvedCommunityConfig,
|
|
49
|
+
export declare function searchPosts(cfg: ResolvedCommunityConfig, searchTerm: string, opts?: {
|
|
50
50
|
cursor?: number;
|
|
51
51
|
}): Promise<FeedPost[]>;
|
|
52
52
|
/**
|
package/lib/service.js
CHANGED
|
@@ -30,6 +30,7 @@ exports.updateProfile = updateProfile;
|
|
|
30
30
|
exports.uploadAvatar = uploadAvatar;
|
|
31
31
|
const supabase_js_1 = require("@supabase/supabase-js");
|
|
32
32
|
const identity_1 = require("./identity");
|
|
33
|
+
const locale_1 = require("./locale");
|
|
33
34
|
const models_1 = require("./models");
|
|
34
35
|
/** Avatar images live in this Storage bucket on every install (mold value, unchanged). */
|
|
35
36
|
const AVATAR_BUCKET = "avatars";
|
|
@@ -41,7 +42,19 @@ async function requireUid(cfg) {
|
|
|
41
42
|
}
|
|
42
43
|
// Embedded filter: comment counts only count visible comments, so the badge
|
|
43
44
|
// 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)
|
|
45
|
+
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)";
|
|
46
|
+
/** Embedded only when the polls module is on: on a core-only backend the
|
|
47
|
+
* `poll_options` table does not exist and PostgREST rejects the whole select
|
|
48
|
+
* (PGRST200), which took the entire feed down for such installs. */
|
|
49
|
+
const POLL_OPTIONS_SELECT = "poll_options(id, idx, label)";
|
|
50
|
+
/** Same embed, plus each option's translation row — used when the reader has
|
|
51
|
+
* a resolved translation locale (see `readerLocale`). */
|
|
52
|
+
const POLL_OPTIONS_TRANSLATED_SELECT = "poll_options(id, idx, label, poll_option_translations(content))";
|
|
53
|
+
/** Embedded only when the reader has a resolved translation locale. */
|
|
54
|
+
const POST_TRANSLATIONS_SELECT = "post_translations(locale, source_locale, content)";
|
|
55
|
+
const COMMENTS_SELECT = "id, post_id, author_id, content, status, created_at, profiles(username, is_official, handle, avatar_url)";
|
|
56
|
+
/** Embedded only when the reader has a resolved translation locale. */
|
|
57
|
+
const COMMENT_TRANSLATIONS_SELECT = "comment_translations(locale, source_locale, content)";
|
|
45
58
|
const PROFILE_SELECT = "id, username, handle, is_official, bio, avatar_url";
|
|
46
59
|
/** Minimal injection guard for `CommunityConfig.feed.extraPostColumns`: a bare
|
|
47
60
|
* column name only, no `.`, `(`, `:`, whitespace, etc. — those are how a
|
|
@@ -54,9 +67,15 @@ const VALID_EXTRA_COLUMN = /^[a-z0-9_]+$/;
|
|
|
54
67
|
* exported so it's unit-testable without a Supabase client. Invalid column
|
|
55
68
|
* names are dropped with a `console.warn` rather than failing the query.
|
|
56
69
|
*/
|
|
57
|
-
function buildFeedSelect(extraPostColumns) {
|
|
70
|
+
function buildFeedSelect(extraPostColumns, polls = false, translations = false) {
|
|
71
|
+
const parts = [FEED_SELECT];
|
|
72
|
+
if (polls)
|
|
73
|
+
parts.push(translations ? POLL_OPTIONS_TRANSLATED_SELECT : POLL_OPTIONS_SELECT);
|
|
74
|
+
if (translations)
|
|
75
|
+
parts.push(POST_TRANSLATIONS_SELECT);
|
|
76
|
+
const base = parts.join(", ");
|
|
58
77
|
if (!extraPostColumns || extraPostColumns.length === 0)
|
|
59
|
-
return
|
|
78
|
+
return base;
|
|
60
79
|
const valid = extraPostColumns.filter((column) => {
|
|
61
80
|
if (VALID_EXTRA_COLUMN.test(column))
|
|
62
81
|
return true;
|
|
@@ -64,8 +83,29 @@ function buildFeedSelect(extraPostColumns) {
|
|
|
64
83
|
return false;
|
|
65
84
|
});
|
|
66
85
|
if (valid.length === 0)
|
|
67
|
-
return
|
|
68
|
-
return `${
|
|
86
|
+
return base;
|
|
87
|
+
return `${base}, ${valid.join(", ")}`;
|
|
88
|
+
}
|
|
89
|
+
/** The posts select for this install: `feed.extraPostColumns` + the poll
|
|
90
|
+
* embed iff `modules.polls`, + translation embeds iff the reader has a
|
|
91
|
+
* resolved translation locale. */
|
|
92
|
+
function postsSelect(cfg) {
|
|
93
|
+
return buildFeedSelect(cfg.feed.extraPostColumns, cfg.modules.polls, (0, locale_1.readerLocale)(cfg) !== null);
|
|
94
|
+
}
|
|
95
|
+
/** Embedded-resource filters: keep only the reader locale's translation rows,
|
|
96
|
+
* so `rows?.[0]` in the mappers is always that locale, never some other
|
|
97
|
+
* locale's leftover row. PostgREST accepts dotted paths for nested embeds,
|
|
98
|
+
* the same mechanism as the existing `.eq("comments.status", "visible")`. A
|
|
99
|
+
* no-op (module off / no resolved locale) leaves the query, and therefore
|
|
100
|
+
* every byte of it, unchanged from before translations existed. */
|
|
101
|
+
function withTranslationFilters(query, cfg) {
|
|
102
|
+
const locale = (0, locale_1.readerLocale)(cfg);
|
|
103
|
+
if (!locale)
|
|
104
|
+
return query;
|
|
105
|
+
let q = query.eq("post_translations.locale", locale);
|
|
106
|
+
if (cfg.modules.polls)
|
|
107
|
+
q = q.eq("poll_options.poll_option_translations.locale", locale);
|
|
108
|
+
return q;
|
|
69
109
|
}
|
|
70
110
|
async function fetchMyLikes(client, postIds, uid) {
|
|
71
111
|
if (postIds.length === 0)
|
|
@@ -143,7 +183,7 @@ async function toFeedPosts(cfg, client, rows, uid) {
|
|
|
143
183
|
const postIds = rows.map((r) => r.id);
|
|
144
184
|
const [liked, pollData, reactionData] = await Promise.all([
|
|
145
185
|
fetchMyLikes(client, postIds, uid),
|
|
146
|
-
fetchPollData(cfg, rows.filter((r) => r.poll_options.length > 0).map((r) => r.id), uid),
|
|
186
|
+
fetchPollData(cfg, rows.filter((r) => (r.poll_options ?? []).length > 0).map((r) => r.id), uid),
|
|
147
187
|
fetchReactionSummaries(cfg, postIds),
|
|
148
188
|
]);
|
|
149
189
|
return rows.map((r) => (0, models_1.mapPostRow)(r, uid, cfg.anonymousAuthorFallback, cfg.topics, liked, pollData, reactionData, cfg.feed.transformPost));
|
|
@@ -158,7 +198,7 @@ async function fetchFeedPage(cfg, opts = {}) {
|
|
|
158
198
|
const page = opts.cursor ?? 0;
|
|
159
199
|
let query = client
|
|
160
200
|
.from("posts")
|
|
161
|
-
.select(
|
|
201
|
+
.select(postsSelect(cfg))
|
|
162
202
|
.eq("comments.status", "visible")
|
|
163
203
|
// Others' rows are already limited to 'visible' by RLS; this also shows the
|
|
164
204
|
// author their own 'pending' posts (optimistic) while hiding their moderated-out
|
|
@@ -167,6 +207,7 @@ async function fetchFeedPage(cfg, opts = {}) {
|
|
|
167
207
|
.order("pinned_at", { ascending: false, nullsFirst: false })
|
|
168
208
|
.order("created_at", { ascending: false })
|
|
169
209
|
.range(page * models_1.FEED_PAGE_SIZE, page * models_1.FEED_PAGE_SIZE + models_1.FEED_PAGE_SIZE - 1);
|
|
210
|
+
query = withTranslationFilters(query, cfg);
|
|
170
211
|
if (opts.topic)
|
|
171
212
|
query = query.eq("topic", opts.topic);
|
|
172
213
|
const { data, error } = await query;
|
|
@@ -218,14 +259,16 @@ async function fetchUserPosts(cfg, userId, opts = {}) {
|
|
|
218
259
|
const client = cfg.requireClient();
|
|
219
260
|
const uid = await requireUid(cfg);
|
|
220
261
|
const page = opts.cursor ?? 0;
|
|
221
|
-
|
|
262
|
+
let query = client
|
|
222
263
|
.from("posts")
|
|
223
|
-
.select(
|
|
264
|
+
.select(postsSelect(cfg))
|
|
224
265
|
.eq("author_id", userId)
|
|
225
266
|
.eq("comments.status", "visible")
|
|
226
267
|
.in("status", ["visible", "pending"])
|
|
227
268
|
.order("created_at", { ascending: false })
|
|
228
269
|
.range(page * models_1.FEED_PAGE_SIZE, page * models_1.FEED_PAGE_SIZE + models_1.FEED_PAGE_SIZE - 1);
|
|
270
|
+
query = withTranslationFilters(query, cfg);
|
|
271
|
+
const { data, error } = await query;
|
|
229
272
|
if (error)
|
|
230
273
|
throw error;
|
|
231
274
|
return toFeedPosts(cfg, client, (data ?? []), uid);
|
|
@@ -235,21 +278,23 @@ async function fetchUserPosts(cfg, userId, opts = {}) {
|
|
|
235
278
|
* page-index cursor as `fetchFeedPage` (product decision, reversing the
|
|
236
279
|
* earlier single-page scope cut).
|
|
237
280
|
*/
|
|
238
|
-
async function searchPosts(cfg,
|
|
239
|
-
const cleaned =
|
|
281
|
+
async function searchPosts(cfg, searchTerm, opts = {}) {
|
|
282
|
+
const cleaned = searchTerm.trim();
|
|
240
283
|
if (cleaned.length === 0)
|
|
241
284
|
return [];
|
|
242
285
|
const client = cfg.requireClient();
|
|
243
286
|
const uid = await requireUid(cfg);
|
|
244
287
|
const page = opts.cursor ?? 0;
|
|
245
|
-
|
|
288
|
+
let query = client
|
|
246
289
|
.from("posts")
|
|
247
|
-
.select(
|
|
290
|
+
.select(postsSelect(cfg))
|
|
248
291
|
.eq("comments.status", "visible")
|
|
249
292
|
.eq("status", "visible")
|
|
250
293
|
.ilike("content", `%${cleaned}%`)
|
|
251
294
|
.order("created_at", { ascending: false })
|
|
252
295
|
.range(page * models_1.FEED_PAGE_SIZE, page * models_1.FEED_PAGE_SIZE + models_1.FEED_PAGE_SIZE - 1);
|
|
296
|
+
query = withTranslationFilters(query, cfg);
|
|
297
|
+
const { data, error } = await query;
|
|
253
298
|
if (error)
|
|
254
299
|
throw error;
|
|
255
300
|
return toFeedPosts(cfg, client, (data ?? []), uid);
|
|
@@ -263,12 +308,17 @@ async function searchPosts(cfg, query, opts = {}) {
|
|
|
263
308
|
async function fetchThread(cfg, postId) {
|
|
264
309
|
const client = cfg.requireClient();
|
|
265
310
|
const uid = await requireUid(cfg);
|
|
266
|
-
const
|
|
311
|
+
const locale = (0, locale_1.readerLocale)(cfg);
|
|
312
|
+
const select = locale ? `${COMMENTS_SELECT}, ${COMMENT_TRANSLATIONS_SELECT}` : COMMENTS_SELECT;
|
|
313
|
+
let query = client
|
|
267
314
|
.from("comments")
|
|
268
|
-
.select(
|
|
315
|
+
.select(select)
|
|
269
316
|
.eq("post_id", postId)
|
|
270
317
|
.in("status", ["visible", "pending"])
|
|
271
318
|
.order("created_at", { ascending: true });
|
|
319
|
+
if (locale)
|
|
320
|
+
query = query.eq("comment_translations.locale", locale);
|
|
321
|
+
const { data, error } = await query;
|
|
272
322
|
if (error)
|
|
273
323
|
throw error;
|
|
274
324
|
return (data ?? []).map((r) => (0, models_1.mapCommentRow)(r, uid, cfg.anonymousAuthorFallback));
|
package/package.json
CHANGED