@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 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` — 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,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`, and hooks `useNotificationInbox`,
167
- `useUnreadNotificationCount`, `useMarkInboxSeen`.
168
- - **`timeAgo(iso)`** — hardcoded-English relative-time formatting, kept for
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 all cached feed pages (every topic filter). */
79
+ /** Optimistic like toggle across every post cache (feed pages of every topic
80
+ * filter, user-posts and search — same sweep as votes/reactions, so a like
81
+ * from a profile or search screen is reflected immediately too). */
80
82
  export declare function useToggleLike(): import("@tanstack/react-query").UseMutationResult<void, Error, {
81
83
  postId: string;
82
84
  liked: boolean;
83
85
  topic: string | null;
84
- }, {
85
- previous: [readonly unknown[], InfiniteData<FeedPost[], unknown> | undefined][];
86
- }>;
86
+ }, PostCacheSnapshot>;
87
87
  /**
88
88
  * Optimistic poll vote across every post cache (mirror of useToggleLike, but
89
89
  * reaching the user-posts/search caches too — see `applyOptimisticToAllPostCaches`).
package/lib/hooks.js CHANGED
@@ -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) => ({ id: `temp-${i}`, label, votes: 0 })),
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 all cached feed pages (every topic filter). */
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: async ({ postId, liked }) => {
374
- await queryClient.cancelQueries({ queryKey: FEED_KEY });
375
- const previous = queryClient.getQueriesData({
376
- queryKey: FEED_KEY,
377
- });
378
- queryClient.setQueriesData({ queryKey: FEED_KEY }, (data) => data
379
- ? {
380
- ...data,
381
- pages: data.pages.map((page) => page.map((post) => post.id === postId
382
- ? {
383
- ...post,
384
- likedByMe: liked,
385
- likeCount: Math.max(0, post.likeCount + (liked ? 1 : -1)),
386
- }
387
- : post)),
388
- }
389
- : data);
390
- return { previous };
391
- },
392
- onError: (_err, _vars, context) => {
393
- for (const [key, data] of context?.previous ?? [])
394
- queryClient.setQueryData(key, data);
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
  }
@@ -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";
@@ -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.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.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");
@@ -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
- poll_options: PollOptionRow[];
29
+ /** Empty for a post without a poll; absent entirely when the polls module
30
+ * is off (the embed is then not part of the select). */
31
+ poll_options?: PollOptionRow[];
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) => ({ 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,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, query: string, opts?: {
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), poll_options(id, idx, label)";
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 FEED_SELECT;
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 FEED_SELECT;
68
- return `${FEED_SELECT}, ${valid.join(", ")}`;
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(buildFeedSelect(cfg.feed.extraPostColumns))
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
- const { data, error } = await client
262
+ let query = client
222
263
  .from("posts")
223
- .select(buildFeedSelect(cfg.feed.extraPostColumns))
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, query, opts = {}) {
239
- const cleaned = query.trim();
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
- const { data, error } = await client
288
+ let query = client
246
289
  .from("posts")
247
- .select(buildFeedSelect(cfg.feed.extraPostColumns))
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 { data, error } = await client
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("id, post_id, author_id, content, status, created_at, profiles(username, is_official, handle, avatar_url)")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community-core",
3
- "version": "0.1.0",
3
+ "version": "0.3.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": {