@rocapine/community-core 0.2.0 → 0.5.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
@@ -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.
@@ -69,14 +73,14 @@ backend configured — the pattern `examples/expo-app` uses when
69
73
 
70
74
  All optional, all default to a no-op:
71
75
 
72
- | Field | Signature | Purpose |
73
- | -------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
74
- | `getDisplayName` | `() => string \| null` | Feeds the display name shown for the current user before their community username is set |
75
- | `getAnalyticsIds` | `() => { amplitudeId?: string; revenuecatId?: string }` | Cross-referencing ids attached where the host wants them |
76
- | `onEvent` | `(name: string, props: Record<string, unknown>) => void` | **The only analytics seam.** Every product event goes through this; the SDK never depends on any analytics provider. Never throws through to the UI even if the adapter itself throws |
77
- | `rulesAcceptance` | `{ get(): Promise<boolean>; set(): Promise<void> }` | Persists whether the user has accepted the one-time community guidelines sheet |
78
- | `onContentPublished` | `() => void` | Fired after a post/comment is successfully published (e.g. to arm a review-gate) |
79
- | `getLocale` | `() => string` | Selects which locale-specific behavior the _host_ wants (distinct from the UI package's own `translations.locale` prop) |
76
+ | Field | Signature | Purpose |
77
+ | -------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
78
+ | `getDisplayName` | `() => string \| null` | Feeds the display name shown for the current user before their community username is set |
79
+ | `getAnalyticsIds` | `() => { amplitudeId?: string; revenuecatId?: string }` | Cross-referencing ids attached where the host wants them |
80
+ | `onEvent` | `(name: string, props: Record<string, unknown>) => void` | **The only analytics seam.** Every product event goes through this; the SDK never depends on any analytics provider. Never throws through to the UI even if the adapter itself throws |
81
+ | `rulesAcceptance` | `{ get(): Promise<boolean>; set(): Promise<void> }` | Persists whether the user has accepted the one-time community guidelines sheet |
82
+ | `onContentPublished` | `() => void` | Fired after a post/comment is successfully published (e.g. to arm a review-gate) |
83
+ | `getLocale` | `() => string` | The app's language. Resolved against `modules.translation.locales` to choose the language content is read in (`readerLocale`), and mirrored to `profiles.locale` by `syncProfileFromHost` so pushes arrive in each recipient's language. Distinct from the UI package's `translations.locale` prop (interface copy) |
80
84
 
81
85
  ### Feed row extension point (`CommunityConfig.feed`)
82
86
 
@@ -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` — forwarded to
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,8 +177,9 @@ 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`, and hooks `useNotificationInbox`,
167
- `useUnreadNotificationCount`, `useMarkInboxSeen`.
180
+ `unreadCount`, `localizeExcerpts` (swaps a notification's excerpt for its
181
+ reader-locale translation when one is available), and hooks
182
+ `useNotificationInbox`, `useUnreadNotificationCount`, `useMarkInboxSeen`.
168
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
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
@@ -1,6 +1,21 @@
1
- import { type InfiniteData } from "@tanstack/react-query";
1
+ import { type InfiniteData, type QueryClient } from "@tanstack/react-query";
2
2
  import { type CommunityProfile, type FeedPost, type ThreadComment } from "./models";
3
3
  import { type ModerationVerdict, type ReportInput, type UpdateProfileInput, type UpdateProfileResult } from "./service";
4
+ /** True while a post/comment is still the optimistic placeholder inserted by
5
+ * `useCreatePost`/`useCreateComment` (its temp id is swapped for the real one
6
+ * once moderation answers). Server actions keyed on the id (reactions, likes,
7
+ * votes) must wait until this is false. */
8
+ export declare function isOptimistic(item: {
9
+ id: string;
10
+ }): boolean;
11
+ /** Our own author fields for an optimistic row, read from the query cache
12
+ * (`useMyUid` + `useProfile`), so the placeholder links to our profile and
13
+ * shows our avatar instead of an empty author until the next refetch. */
14
+ export declare function ownAuthorFields(queryClient: QueryClient): {
15
+ authorId: string;
16
+ authorHandle: string | null;
17
+ authorAvatarUrl: string | null;
18
+ };
4
19
  type PostCacheEntry = [readonly unknown[], InfiniteData<FeedPost[]> | undefined];
5
20
  /**
6
21
  * Snapshot of every post-holding cache, for optimistic-update rollback on
package/lib/hooks.js CHANGED
@@ -10,6 +10,8 @@
10
10
  // `applyReaction`) and unseen-count hook (parameterized on `lastSeenAtIso` instead of
11
11
  // reading that host's own store). No React Native / analytics-SDK / store imports here.
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.isOptimistic = isOptimistic;
14
+ exports.ownAuthorFields = ownAuthorFields;
13
15
  exports.useCommunityFeed = useCommunityFeed;
14
16
  exports.useSearchPosts = useSearchPosts;
15
17
  exports.useNewPostsCount = useNewPostsCount;
@@ -30,16 +32,32 @@ exports.useDeleteContent = useDeleteContent;
30
32
  const react_query_1 = require("@tanstack/react-query");
31
33
  const events_1 = require("./events");
32
34
  const identity_1 = require("./identity");
35
+ const keys_1 = require("./keys");
36
+ const locale_1 = require("./locale");
33
37
  const models_1 = require("./models");
34
38
  const provider_1 = require("./provider");
35
39
  const service_1 = require("./service");
36
- const FEED_KEY = ["community", "feed"];
37
- const feedKey = (topic) => [...FEED_KEY, topic ?? "all"];
38
- const threadKey = (postId) => ["community", "thread", postId];
39
40
  const profileKey = (userId) => ["community", "profile", userId];
40
- const userPostsKey = (userId) => ["community", "userPosts", userId];
41
- const USER_POSTS_KEY = ["community", "userPosts"];
42
- const SEARCH_KEY = ["community", "search"];
41
+ const MY_UID_KEY = ["community", "myUid"];
42
+ /** True while a post/comment is still the optimistic placeholder inserted by
43
+ * `useCreatePost`/`useCreateComment` (its temp id is swapped for the real one
44
+ * once moderation answers). Server actions keyed on the id (reactions, likes,
45
+ * votes) must wait until this is false. */
46
+ function isOptimistic(item) {
47
+ return item.id.startsWith("optimistic-");
48
+ }
49
+ /** Our own author fields for an optimistic row, read from the query cache
50
+ * (`useMyUid` + `useProfile`), so the placeholder links to our profile and
51
+ * shows our avatar instead of an empty author until the next refetch. */
52
+ function ownAuthorFields(queryClient) {
53
+ const uid = queryClient.getQueryData(MY_UID_KEY) ?? "";
54
+ const profile = uid ? queryClient.getQueryData(profileKey(uid)) : undefined;
55
+ return {
56
+ authorId: uid,
57
+ authorHandle: profile?.handle ?? null,
58
+ authorAvatarUrl: profile?.avatarUrl ?? null,
59
+ };
60
+ }
43
61
  /**
44
62
  * Adjust a post's commentCount across every cached feed page (all topic
45
63
  * filters). The feed is never refetched after commenting (kept smooth), so the
@@ -48,7 +66,7 @@ const SEARCH_KEY = ["community", "search"];
48
66
  * change is not reflected in the user-posts/search caches, same as upstream.
49
67
  */
50
68
  function bumpFeedCommentCount(queryClient, postId, delta) {
51
- queryClient.setQueriesData({ queryKey: FEED_KEY }, (data) => data
69
+ queryClient.setQueriesData({ queryKey: keys_1.FEED_KEY }, (data) => data
52
70
  ? {
53
71
  ...data,
54
72
  pages: data.pages.map((page) => page.map((post) => post.id === postId
@@ -67,19 +85,19 @@ function bumpFeedCommentCount(queryClient, postId, delta) {
67
85
  */
68
86
  async function applyOptimisticToAllPostCaches(queryClient, mapPost) {
69
87
  await Promise.all([
70
- queryClient.cancelQueries({ queryKey: FEED_KEY }),
71
- queryClient.cancelQueries({ queryKey: USER_POSTS_KEY }),
72
- queryClient.cancelQueries({ queryKey: SEARCH_KEY }),
88
+ queryClient.cancelQueries({ queryKey: keys_1.FEED_KEY }),
89
+ queryClient.cancelQueries({ queryKey: keys_1.USER_POSTS_KEY }),
90
+ queryClient.cancelQueries({ queryKey: keys_1.SEARCH_KEY }),
73
91
  ]);
74
92
  const entries = [
75
- ...queryClient.getQueriesData({ queryKey: FEED_KEY }),
76
- ...queryClient.getQueriesData({ queryKey: USER_POSTS_KEY }),
77
- ...queryClient.getQueriesData({ queryKey: SEARCH_KEY }),
93
+ ...queryClient.getQueriesData({ queryKey: keys_1.FEED_KEY }),
94
+ ...queryClient.getQueriesData({ queryKey: keys_1.USER_POSTS_KEY }),
95
+ ...queryClient.getQueriesData({ queryKey: keys_1.SEARCH_KEY }),
78
96
  ];
79
97
  const applyToPages = (data) => data ? { ...data, pages: data.pages.map((page) => page.map(mapPost)) } : data;
80
- queryClient.setQueriesData({ queryKey: FEED_KEY }, applyToPages);
81
- queryClient.setQueriesData({ queryKey: USER_POSTS_KEY }, applyToPages);
82
- queryClient.setQueriesData({ queryKey: SEARCH_KEY }, applyToPages);
98
+ queryClient.setQueriesData({ queryKey: keys_1.FEED_KEY }, applyToPages);
99
+ queryClient.setQueriesData({ queryKey: keys_1.USER_POSTS_KEY }, applyToPages);
100
+ queryClient.setQueriesData({ queryKey: keys_1.SEARCH_KEY }, applyToPages);
83
101
  return { entries };
84
102
  }
85
103
  function rollbackPostCaches(queryClient, snapshot) {
@@ -94,8 +112,9 @@ function rollbackPostCaches(queryClient, snapshot) {
94
112
  */
95
113
  function useCommunityFeed(topic, enabled = true) {
96
114
  const cfg = (0, provider_1.useCommunityConfig)();
115
+ const locale = (0, locale_1.readerLocale)(cfg);
97
116
  return (0, react_query_1.useInfiniteQuery)({
98
- queryKey: feedKey(topic),
117
+ queryKey: (0, keys_1.feedKey)(topic, locale),
99
118
  queryFn: ({ pageParam }) => (0, service_1.fetchFeedPage)(cfg, { topic, cursor: pageParam }),
100
119
  initialPageParam: 0,
101
120
  getNextPageParam: (lastPage, pages) => lastPage.length === models_1.FEED_PAGE_SIZE ? pages.length : undefined,
@@ -111,8 +130,9 @@ function useCommunityFeed(topic, enabled = true) {
111
130
  function useSearchPosts(term) {
112
131
  const cfg = (0, provider_1.useCommunityConfig)();
113
132
  const cleaned = term.trim();
133
+ const locale = (0, locale_1.readerLocale)(cfg);
114
134
  return (0, react_query_1.useInfiniteQuery)({
115
- queryKey: [...SEARCH_KEY, cleaned],
135
+ queryKey: (0, keys_1.searchKey)(cleaned, locale),
116
136
  queryFn: ({ pageParam }) => (0, service_1.searchPosts)(cfg, cleaned, { cursor: pageParam }),
117
137
  initialPageParam: 0,
118
138
  getNextPageParam: (lastPage, pages) => lastPage.length === models_1.FEED_PAGE_SIZE ? pages.length : undefined,
@@ -163,8 +183,9 @@ function useCommunityUnseenCount(lastSeenAtIso) {
163
183
  }
164
184
  function useThread(postId) {
165
185
  const cfg = (0, provider_1.useCommunityConfig)();
186
+ const locale = (0, locale_1.readerLocale)(cfg);
166
187
  return (0, react_query_1.useQuery)({
167
- queryKey: threadKey(postId ?? "none"),
188
+ queryKey: (0, keys_1.threadKey)(postId ?? "none", locale),
168
189
  queryFn: () => (0, service_1.fetchThread)(cfg, postId),
169
190
  enabled: cfg.supabase !== null && !!postId,
170
191
  staleTime: 1000 * 15,
@@ -186,8 +207,9 @@ function useProfile(userId) {
186
207
  */
187
208
  function useUserPosts(userId) {
188
209
  const cfg = (0, provider_1.useCommunityConfig)();
210
+ const locale = (0, locale_1.readerLocale)(cfg);
189
211
  return (0, react_query_1.useInfiniteQuery)({
190
- queryKey: userPostsKey(userId ?? "none"),
212
+ queryKey: (0, keys_1.userPostsKey)(userId ?? "none", locale),
191
213
  queryFn: ({ pageParam }) => (0, service_1.fetchUserPosts)(cfg, userId, { cursor: pageParam }),
192
214
  initialPageParam: 0,
193
215
  getNextPageParam: (lastPage, pages) => lastPage.length === models_1.FEED_PAGE_SIZE ? pages.length : undefined,
@@ -200,7 +222,7 @@ function useUserPosts(userId) {
200
222
  function useMyUid() {
201
223
  const cfg = (0, provider_1.useCommunityConfig)();
202
224
  const q = (0, react_query_1.useQuery)({
203
- queryKey: ["community", "myUid"],
225
+ queryKey: MY_UID_KEY,
204
226
  queryFn: () => (0, identity_1.ensureIdentity)(cfg),
205
227
  enabled: cfg.supabase !== null,
206
228
  staleTime: Infinity,
@@ -235,17 +257,16 @@ function useUpdateProfile() {
235
257
  function useCreatePost() {
236
258
  const cfg = (0, provider_1.useCommunityConfig)();
237
259
  const queryClient = (0, react_query_1.useQueryClient)();
260
+ const locale = (0, locale_1.readerLocale)(cfg);
238
261
  return (0, react_query_1.useMutation)({
239
262
  onMutate: async ({ topic, text, authorName, pollOptions }) => {
240
- await queryClient.cancelQueries({ queryKey: FEED_KEY });
263
+ await queryClient.cancelQueries({ queryKey: keys_1.FEED_KEY });
241
264
  const tempId = `optimistic-${Date.now()}`;
242
265
  const optimistic = {
243
266
  id: tempId,
244
- authorId: "",
245
267
  authorName,
246
268
  authorOfficial: false,
247
- authorHandle: null,
248
- authorAvatarUrl: null,
269
+ ...ownAuthorFields(queryClient),
249
270
  topic,
250
271
  text,
251
272
  status: "visible",
@@ -259,7 +280,12 @@ function useCreatePost() {
259
280
  // refetch below swaps in the real uuids.
260
281
  poll: pollOptions && pollOptions.length > 0
261
282
  ? {
262
- options: pollOptions.map((label, i) => ({ id: `temp-${i}`, label, votes: 0 })),
283
+ options: pollOptions.map((label, i) => ({
284
+ id: `temp-${i}`,
285
+ label,
286
+ translatedLabel: null,
287
+ votes: 0,
288
+ })),
263
289
  myOptionId: null,
264
290
  totalVotes: 0,
265
291
  }
@@ -267,10 +293,11 @@ function useCreatePost() {
267
293
  reactionCount: 0,
268
294
  hasReacted: false,
269
295
  lastReactorName: null,
296
+ translation: null,
270
297
  };
271
298
  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));
299
+ prepend((0, keys_1.feedKey)(undefined, locale));
300
+ prepend((0, keys_1.feedKey)(topic, locale));
274
301
  return { tempId };
275
302
  },
276
303
  mutationFn: async ({ topic, text, pollOptions }) => {
@@ -280,7 +307,7 @@ function useCreatePost() {
280
307
  },
281
308
  onSuccess: ({ id, verdict }, { topic, text, pollOptions }, { tempId }) => {
282
309
  const rejected = verdict.status === "rejected";
283
- queryClient.setQueriesData({ queryKey: FEED_KEY }, (d) => d
310
+ queryClient.setQueriesData({ queryKey: keys_1.FEED_KEY }, (d) => d
284
311
  ? {
285
312
  ...d,
286
313
  pages: d.pages.map((p) => rejected
@@ -299,11 +326,11 @@ function useCreatePost() {
299
326
  // The in-place swap above keeps the optimistic poll's temp option ids;
300
327
  // voting needs the real uuids, so a poll post refetches in background.
301
328
  if (hasPoll)
302
- queryClient.invalidateQueries({ queryKey: FEED_KEY });
329
+ queryClient.invalidateQueries({ queryKey: keys_1.FEED_KEY });
303
330
  }
304
331
  },
305
332
  onError: (_e, _v, context) => {
306
- queryClient.setQueriesData({ queryKey: FEED_KEY }, (d) => d
333
+ queryClient.setQueriesData({ queryKey: keys_1.FEED_KEY }, (d) => d
307
334
  ? { ...d, pages: d.pages.map((p) => p.filter((post) => post.id !== context?.tempId)) }
308
335
  : d);
309
336
  },
@@ -313,24 +340,24 @@ function useCreatePost() {
313
340
  function useCreateComment() {
314
341
  const cfg = (0, provider_1.useCommunityConfig)();
315
342
  const queryClient = (0, react_query_1.useQueryClient)();
343
+ const locale = (0, locale_1.readerLocale)(cfg);
316
344
  return (0, react_query_1.useMutation)({
317
345
  onMutate: async ({ postId, text, authorName }) => {
318
- await queryClient.cancelQueries({ queryKey: threadKey(postId) });
319
- await queryClient.cancelQueries({ queryKey: FEED_KEY });
346
+ await queryClient.cancelQueries({ queryKey: (0, keys_1.threadKey)(postId, locale) });
347
+ await queryClient.cancelQueries({ queryKey: keys_1.FEED_KEY });
320
348
  const tempId = `optimistic-${Date.now()}`;
321
349
  const optimistic = {
322
350
  id: tempId,
323
351
  postId,
324
- authorId: "",
325
352
  authorName,
326
353
  authorOfficial: false,
327
- authorHandle: null,
328
- authorAvatarUrl: null,
354
+ ...ownAuthorFields(queryClient),
329
355
  text,
330
356
  isOwn: true,
331
357
  createdAt: new Date().toISOString(),
358
+ translation: null,
332
359
  };
333
- queryClient.setQueryData(threadKey(postId), (d) => [
360
+ queryClient.setQueryData((0, keys_1.threadKey)(postId, locale), (d) => [
334
361
  ...(d ?? []),
335
362
  optimistic,
336
363
  ]);
@@ -344,7 +371,7 @@ function useCreateComment() {
344
371
  },
345
372
  onSuccess: ({ id, verdict }, { postId, text }, { tempId }) => {
346
373
  const rejected = verdict.status === "rejected";
347
- queryClient.setQueryData(threadKey(postId), (d) => (d ?? [])
374
+ queryClient.setQueryData((0, keys_1.threadKey)(postId, locale), (d) => (d ?? [])
348
375
  .filter((c) => !(rejected && c.id === tempId))
349
376
  .map((c) => (c.id === tempId ? { ...c, id } : c)));
350
377
  // A rejected comment never becomes visible, so undo the optimistic bump.
@@ -359,7 +386,7 @@ function useCreateComment() {
359
386
  }
360
387
  },
361
388
  onError: (_e, { postId }, context) => {
362
- queryClient.setQueryData(threadKey(postId), (d) => (d ?? []).filter((c) => c.id !== context?.tempId));
389
+ queryClient.setQueryData((0, keys_1.threadKey)(postId, locale), (d) => (d ?? []).filter((c) => c.id !== context?.tempId));
363
390
  bumpFeedCommentCount(queryClient, postId, -1);
364
391
  },
365
392
  });
@@ -473,14 +500,15 @@ function useBlockUser() {
473
500
  function useDeleteContent() {
474
501
  const cfg = (0, provider_1.useCommunityConfig)();
475
502
  const queryClient = (0, react_query_1.useQueryClient)();
503
+ const locale = (0, locale_1.readerLocale)(cfg);
476
504
  return (0, react_query_1.useMutation)({
477
505
  mutationFn: ({ kind, id }) => kind === "post" ? (0, service_1.deleteOwnPost)(cfg, id) : (0, service_1.deleteOwnComment)(cfg, id),
478
506
  onSuccess: (_data, { kind, postId }) => {
479
507
  (0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.contentDeleted, { contentType: kind });
480
- queryClient.invalidateQueries({ queryKey: threadKey(postId) });
481
- queryClient.invalidateQueries({ queryKey: FEED_KEY });
482
- queryClient.invalidateQueries({ queryKey: USER_POSTS_KEY });
483
- queryClient.invalidateQueries({ queryKey: SEARCH_KEY });
508
+ queryClient.invalidateQueries({ queryKey: (0, keys_1.threadKey)(postId, locale) });
509
+ queryClient.invalidateQueries({ queryKey: keys_1.FEED_KEY });
510
+ queryClient.invalidateQueries({ queryKey: keys_1.USER_POSTS_KEY });
511
+ queryClient.invalidateQueries({ queryKey: keys_1.SEARCH_KEY });
484
512
  },
485
513
  });
486
514
  }
@@ -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
  /**
@@ -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
- return { items, seenAt: seenRes.data?.seen_at ?? null };
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";
@@ -10,8 +11,8 @@ export type { CommunityEventName } from "./events";
10
11
  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
- 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";
14
+ export { useCommunityFeed, useSearchPosts, useNewPostsCount, useCommunityUnseenCount, useThread, useProfile, useUserPosts, useMyUid, useUpdateProfile, useCreatePost, useCreateComment, useToggleLike, useVotePoll, useReactToPost, useReport, useBlockUser, useDeleteContent, isOptimistic, } from "./hooks";
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.uploadAvatar = exports.updateProfile = 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.resolveConfig = exports.CommunityDisabledError = void 0;
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.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;
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; } });
@@ -77,8 +80,10 @@ Object.defineProperty(exports, "useReactToPost", { enumerable: true, get: functi
77
80
  Object.defineProperty(exports, "useReport", { enumerable: true, get: function () { return hooks_1.useReport; } });
78
81
  Object.defineProperty(exports, "useBlockUser", { enumerable: true, get: function () { return hooks_1.useBlockUser; } });
79
82
  Object.defineProperty(exports, "useDeleteContent", { enumerable: true, get: function () { return hooks_1.useDeleteContent; } });
83
+ Object.defineProperty(exports, "isOptimistic", { enumerable: true, get: function () { return hooks_1.isOptimistic; } });
80
84
  var inbox_service_1 = require("./inbox-service");
81
85
  Object.defineProperty(exports, "fetchInbox", { enumerable: true, get: function () { return inbox_service_1.fetchInbox; } });
86
+ Object.defineProperty(exports, "localizeExcerpts", { enumerable: true, get: function () { return inbox_service_1.localizeExcerpts; } });
82
87
  Object.defineProperty(exports, "markInboxSeen", { enumerable: true, get: function () { return inbox_service_1.markInboxSeen; } });
83
88
  Object.defineProperty(exports, "unreadCount", { enumerable: true, get: function () { return inbox_service_1.unreadCount; } });
84
89
  var inbox_hooks_1 = require("./inbox-hooks");
package/lib/keys.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export declare const FEED_KEY: readonly ["community", "feed"];
2
+ export declare const feedKey: (topic?: string, locale?: string | null) => readonly ["community", "feed", string, string];
3
+ export declare const threadKey: (postId: string, locale?: string | null) => readonly ["community", "thread", string, string];
4
+ export declare const USER_POSTS_KEY: readonly ["community", "userPosts"];
5
+ export declare const userPostsKey: (userId: string, locale?: string | null) => readonly ["community", "userPosts", string, string];
6
+ export declare const SEARCH_KEY: readonly ["community", "search"];
7
+ export declare const searchKey: (cleaned: string, locale?: string | null) => readonly ["community", "search", string, string];
package/lib/keys.js ADDED
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ // React Query keys (internal, not exported from the package). Every full key
3
+ // ends with the reader locale ("src" = originals) so switching locale never
4
+ // serves another locale's cached translations; the *_KEY prefixes stay
5
+ // prefixes of the full keys so setQueriesData/invalidateQueries match them all.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.searchKey = exports.SEARCH_KEY = exports.userPostsKey = exports.USER_POSTS_KEY = exports.threadKey = exports.feedKey = exports.FEED_KEY = void 0;
8
+ exports.FEED_KEY = ["community", "feed"];
9
+ const feedKey = (topic, locale) => [...exports.FEED_KEY, topic ?? "all", locale ?? "src"];
10
+ exports.feedKey = feedKey;
11
+ const threadKey = (postId, locale) => ["community", "thread", postId, locale ?? "src"];
12
+ exports.threadKey = threadKey;
13
+ exports.USER_POSTS_KEY = ["community", "userPosts"];
14
+ const userPostsKey = (userId, locale) => [...exports.USER_POSTS_KEY, userId, locale ?? "src"];
15
+ exports.userPostsKey = userPostsKey;
16
+ exports.SEARCH_KEY = ["community", "search"];
17
+ const searchKey = (cleaned, locale) => [...exports.SEARCH_KEY, cleaned, locale ?? "src"];
18
+ exports.searchKey = searchKey;
@@ -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
@@ -29,11 +29,29 @@ export interface PostRow {
29
29
  /** Empty for a post without a poll; absent entirely when the polls module
30
30
  * is off (the embed is then not part of the select). */
31
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[];
32
35
  }
33
36
  export interface PollOptionRow {
34
37
  id: string;
35
38
  idx: number;
36
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;
37
55
  }
38
56
  export interface CommentRow {
39
57
  id: string;
@@ -48,10 +66,15 @@ export interface CommentRow {
48
66
  handle: string | null;
49
67
  avatar_url: string | null;
50
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[];
51
72
  }
52
73
  export interface PollOption {
53
74
  id: string;
54
75
  label: string;
76
+ /** Label in the reader locale when the translation module provided one. */
77
+ translatedLabel: string | null;
55
78
  votes: number;
56
79
  }
57
80
  export interface FeedPoll {
@@ -107,6 +130,9 @@ export interface FeedPost {
107
130
  /** Display name of the most recent reactor, for a "X and N others reacted" affordance;
108
131
  * null when nobody has reacted yet or the reaction module is off. */
109
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;
110
136
  }
111
137
  export interface ThreadComment {
112
138
  id: string;
@@ -119,6 +145,9 @@ export interface ThreadComment {
119
145
  text: string;
120
146
  isOwn: boolean;
121
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;
122
151
  }
123
152
  /**
124
153
  * Public display name, with a host-configured fallback for profiles not yet named.
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
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) => ({ id: o.id, label: o.label, votes: counts.get(o.id) ?? 0 }));
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,16 @@ 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[], polls?: boolean): string;
8
+ export declare function buildFeedSelect(extraPostColumns?: readonly string[], polls?: boolean, translations?: boolean): string;
9
+ /** Embedded-resource filters: keep only the reader locale's translation rows,
10
+ * so `rows?.[0]` in the mappers is always that locale, never some other
11
+ * locale's leftover row. PostgREST accepts dotted paths for nested embeds,
12
+ * the same mechanism as the existing `.eq("comments.status", "visible")`. A
13
+ * no-op (module off / no resolved locale) leaves the query, and therefore
14
+ * every byte of it, unchanged from before translations existed. */
15
+ export declare function withTranslationFilters<Q extends {
16
+ eq(column: string, value: string): Q;
17
+ }>(query: Q, cfg: ResolvedCommunityConfig): Q;
9
18
  /**
10
19
  * Batched reaction state for one page: count + last reactor's display name + my-reaction
11
20
  * flag, all from the single security-definer `post_reaction_summary` RPC (the backend
@@ -46,9 +55,12 @@ export declare function fetchUserPosts(cfg: ResolvedCommunityConfig, userId: str
46
55
  * page-index cursor as `fetchFeedPage` (product decision, reversing the
47
56
  * earlier single-page scope cut).
48
57
  */
49
- export declare function searchPosts(cfg: ResolvedCommunityConfig, query: string, opts?: {
58
+ export declare function searchPosts(cfg: ResolvedCommunityConfig, searchTerm: string, opts?: {
50
59
  cursor?: number;
51
60
  }): Promise<FeedPost[]>;
61
+ /** The thread (comments) select: + the translation embed iff the reader has a
62
+ * resolved translation locale. */
63
+ export declare function threadSelect(cfg: ResolvedCommunityConfig): string;
52
64
  /**
53
65
  * Note: `cfg.feed.extraPostColumns`/`transformPost` do NOT apply here — this
54
66
  * queries `comments`, not `posts` (the thread's parent post is passed in by
package/lib/service.js CHANGED
@@ -9,12 +9,14 @@
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
11
  exports.buildFeedSelect = buildFeedSelect;
12
+ exports.withTranslationFilters = withTranslationFilters;
12
13
  exports.fetchReactionSummaries = fetchReactionSummaries;
13
14
  exports.fetchFeedPage = fetchFeedPage;
14
15
  exports.countNewPosts = countNewPosts;
15
16
  exports.fetchProfile = fetchProfile;
16
17
  exports.fetchUserPosts = fetchUserPosts;
17
18
  exports.searchPosts = searchPosts;
19
+ exports.threadSelect = threadSelect;
18
20
  exports.fetchThread = fetchThread;
19
21
  exports.createPost = createPost;
20
22
  exports.votePoll = votePoll;
@@ -30,6 +32,7 @@ exports.updateProfile = updateProfile;
30
32
  exports.uploadAvatar = uploadAvatar;
31
33
  const supabase_js_1 = require("@supabase/supabase-js");
32
34
  const identity_1 = require("./identity");
35
+ const locale_1 = require("./locale");
33
36
  const models_1 = require("./models");
34
37
  /** Avatar images live in this Storage bucket on every install (mold value, unchanged). */
35
38
  const AVATAR_BUCKET = "avatars";
@@ -46,6 +49,14 @@ const FEED_SELECT = "id, author_id, topic, content, status, pinned_at, created_a
46
49
  * `poll_options` table does not exist and PostgREST rejects the whole select
47
50
  * (PGRST200), which took the entire feed down for such installs. */
48
51
  const POLL_OPTIONS_SELECT = "poll_options(id, idx, label)";
52
+ /** Same embed, plus each option's translation row — used when the reader has
53
+ * a resolved translation locale (see `readerLocale`). */
54
+ const POLL_OPTIONS_TRANSLATED_SELECT = "poll_options(id, idx, label, poll_option_translations(content))";
55
+ /** Embedded only when the reader has a resolved translation locale. */
56
+ const POST_TRANSLATIONS_SELECT = "post_translations(locale, source_locale, content)";
57
+ const COMMENTS_SELECT = "id, post_id, author_id, content, status, created_at, profiles(username, is_official, handle, avatar_url)";
58
+ /** Embedded only when the reader has a resolved translation locale. */
59
+ const COMMENT_TRANSLATIONS_SELECT = "comment_translations(locale, source_locale, content)";
49
60
  const PROFILE_SELECT = "id, username, handle, is_official, bio, avatar_url";
50
61
  /** Minimal injection guard for `CommunityConfig.feed.extraPostColumns`: a bare
51
62
  * column name only, no `.`, `(`, `:`, whitespace, etc. — those are how a
@@ -58,8 +69,13 @@ const VALID_EXTRA_COLUMN = /^[a-z0-9_]+$/;
58
69
  * exported so it's unit-testable without a Supabase client. Invalid column
59
70
  * names are dropped with a `console.warn` rather than failing the query.
60
71
  */
61
- function buildFeedSelect(extraPostColumns, polls = false) {
62
- const base = polls ? `${FEED_SELECT}, ${POLL_OPTIONS_SELECT}` : FEED_SELECT;
72
+ function buildFeedSelect(extraPostColumns, polls = false, translations = false) {
73
+ const parts = [FEED_SELECT];
74
+ if (polls)
75
+ parts.push(translations ? POLL_OPTIONS_TRANSLATED_SELECT : POLL_OPTIONS_SELECT);
76
+ if (translations)
77
+ parts.push(POST_TRANSLATIONS_SELECT);
78
+ const base = parts.join(", ");
63
79
  if (!extraPostColumns || extraPostColumns.length === 0)
64
80
  return base;
65
81
  const valid = extraPostColumns.filter((column) => {
@@ -73,9 +89,25 @@ function buildFeedSelect(extraPostColumns, polls = false) {
73
89
  return `${base}, ${valid.join(", ")}`;
74
90
  }
75
91
  /** The posts select for this install: `feed.extraPostColumns` + the poll
76
- * embed iff `modules.polls`. */
92
+ * embed iff `modules.polls`, + translation embeds iff the reader has a
93
+ * resolved translation locale. */
77
94
  function postsSelect(cfg) {
78
- return buildFeedSelect(cfg.feed.extraPostColumns, cfg.modules.polls);
95
+ return buildFeedSelect(cfg.feed.extraPostColumns, cfg.modules.polls, (0, locale_1.readerLocale)(cfg) !== null);
96
+ }
97
+ /** Embedded-resource filters: keep only the reader locale's translation rows,
98
+ * so `rows?.[0]` in the mappers is always that locale, never some other
99
+ * locale's leftover row. PostgREST accepts dotted paths for nested embeds,
100
+ * the same mechanism as the existing `.eq("comments.status", "visible")`. A
101
+ * no-op (module off / no resolved locale) leaves the query, and therefore
102
+ * every byte of it, unchanged from before translations existed. */
103
+ function withTranslationFilters(query, cfg) {
104
+ const locale = (0, locale_1.readerLocale)(cfg);
105
+ if (!locale)
106
+ return query;
107
+ let q = query.eq("post_translations.locale", locale);
108
+ if (cfg.modules.polls)
109
+ q = q.eq("poll_options.poll_option_translations.locale", locale);
110
+ return q;
79
111
  }
80
112
  async function fetchMyLikes(client, postIds, uid) {
81
113
  if (postIds.length === 0)
@@ -177,6 +209,7 @@ async function fetchFeedPage(cfg, opts = {}) {
177
209
  .order("pinned_at", { ascending: false, nullsFirst: false })
178
210
  .order("created_at", { ascending: false })
179
211
  .range(page * models_1.FEED_PAGE_SIZE, page * models_1.FEED_PAGE_SIZE + models_1.FEED_PAGE_SIZE - 1);
212
+ query = withTranslationFilters(query, cfg);
180
213
  if (opts.topic)
181
214
  query = query.eq("topic", opts.topic);
182
215
  const { data, error } = await query;
@@ -228,7 +261,7 @@ async function fetchUserPosts(cfg, userId, opts = {}) {
228
261
  const client = cfg.requireClient();
229
262
  const uid = await requireUid(cfg);
230
263
  const page = opts.cursor ?? 0;
231
- const { data, error } = await client
264
+ let query = client
232
265
  .from("posts")
233
266
  .select(postsSelect(cfg))
234
267
  .eq("author_id", userId)
@@ -236,6 +269,8 @@ async function fetchUserPosts(cfg, userId, opts = {}) {
236
269
  .in("status", ["visible", "pending"])
237
270
  .order("created_at", { ascending: false })
238
271
  .range(page * models_1.FEED_PAGE_SIZE, page * models_1.FEED_PAGE_SIZE + models_1.FEED_PAGE_SIZE - 1);
272
+ query = withTranslationFilters(query, cfg);
273
+ const { data, error } = await query;
239
274
  if (error)
240
275
  throw error;
241
276
  return toFeedPosts(cfg, client, (data ?? []), uid);
@@ -245,14 +280,14 @@ async function fetchUserPosts(cfg, userId, opts = {}) {
245
280
  * page-index cursor as `fetchFeedPage` (product decision, reversing the
246
281
  * earlier single-page scope cut).
247
282
  */
248
- async function searchPosts(cfg, query, opts = {}) {
249
- const cleaned = query.trim();
283
+ async function searchPosts(cfg, searchTerm, opts = {}) {
284
+ const cleaned = searchTerm.trim();
250
285
  if (cleaned.length === 0)
251
286
  return [];
252
287
  const client = cfg.requireClient();
253
288
  const uid = await requireUid(cfg);
254
289
  const page = opts.cursor ?? 0;
255
- const { data, error } = await client
290
+ let query = client
256
291
  .from("posts")
257
292
  .select(postsSelect(cfg))
258
293
  .eq("comments.status", "visible")
@@ -260,10 +295,17 @@ async function searchPosts(cfg, query, opts = {}) {
260
295
  .ilike("content", `%${cleaned}%`)
261
296
  .order("created_at", { ascending: false })
262
297
  .range(page * models_1.FEED_PAGE_SIZE, page * models_1.FEED_PAGE_SIZE + models_1.FEED_PAGE_SIZE - 1);
298
+ query = withTranslationFilters(query, cfg);
299
+ const { data, error } = await query;
263
300
  if (error)
264
301
  throw error;
265
302
  return toFeedPosts(cfg, client, (data ?? []), uid);
266
303
  }
304
+ /** The thread (comments) select: + the translation embed iff the reader has a
305
+ * resolved translation locale. */
306
+ function threadSelect(cfg) {
307
+ return (0, locale_1.readerLocale)(cfg) ? `${COMMENTS_SELECT}, ${COMMENT_TRANSLATIONS_SELECT}` : COMMENTS_SELECT;
308
+ }
267
309
  /**
268
310
  * Note: `cfg.feed.extraPostColumns`/`transformPost` do NOT apply here — this
269
311
  * queries `comments`, not `posts` (the thread's parent post is passed in by
@@ -273,12 +315,16 @@ async function searchPosts(cfg, query, opts = {}) {
273
315
  async function fetchThread(cfg, postId) {
274
316
  const client = cfg.requireClient();
275
317
  const uid = await requireUid(cfg);
276
- const { data, error } = await client
318
+ const locale = (0, locale_1.readerLocale)(cfg);
319
+ let query = client
277
320
  .from("comments")
278
- .select("id, post_id, author_id, content, status, created_at, profiles(username, is_official, handle, avatar_url)")
321
+ .select(threadSelect(cfg))
279
322
  .eq("post_id", postId)
280
323
  .in("status", ["visible", "pending"])
281
324
  .order("created_at", { ascending: true });
325
+ if (locale)
326
+ query = query.eq("comment_translations.locale", locale);
327
+ const { data, error } = await query;
282
328
  if (error)
283
329
  throw error;
284
330
  return (data ?? []).map((r) => (0, models_1.mapCommentRow)(r, uid, cfg.anonymousAuthorFallback));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community-core",
3
- "version": "0.2.0",
3
+ "version": "0.5.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": {