@rocapine/community-core 0.2.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,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.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,7 +373,7 @@ 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
  });
@@ -473,11 +487,12 @@ function useBlockUser() {
473
487
  function useDeleteContent() {
474
488
  const cfg = (0, provider_1.useCommunityConfig)();
475
489
  const queryClient = (0, react_query_1.useQueryClient)();
490
+ const locale = (0, locale_1.readerLocale)(cfg);
476
491
  return (0, react_query_1.useMutation)({
477
492
  mutationFn: ({ kind, id }) => kind === "post" ? (0, service_1.deleteOwnPost)(cfg, id) : (0, service_1.deleteOwnComment)(cfg, id),
478
493
  onSuccess: (_data, { kind, postId }) => {
479
494
  (0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.contentDeleted, { contentType: kind });
480
- queryClient.invalidateQueries({ queryKey: threadKey(postId) });
495
+ queryClient.invalidateQueries({ queryKey: threadKey(postId, locale) });
481
496
  queryClient.invalidateQueries({ queryKey: FEED_KEY });
482
497
  queryClient.invalidateQueries({ queryKey: USER_POSTS_KEY });
483
498
  queryClient.invalidateQueries({ queryKey: SEARCH_KEY });
@@ -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
@@ -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,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[], polls?: boolean): 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";
@@ -46,6 +47,14 @@ const FEED_SELECT = "id, author_id, topic, content, status, pinned_at, created_a
46
47
  * `poll_options` table does not exist and PostgREST rejects the whole select
47
48
  * (PGRST200), which took the entire feed down for such installs. */
48
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)";
49
58
  const PROFILE_SELECT = "id, username, handle, is_official, bio, avatar_url";
50
59
  /** Minimal injection guard for `CommunityConfig.feed.extraPostColumns`: a bare
51
60
  * column name only, no `.`, `(`, `:`, whitespace, etc. — those are how a
@@ -58,8 +67,13 @@ const VALID_EXTRA_COLUMN = /^[a-z0-9_]+$/;
58
67
  * exported so it's unit-testable without a Supabase client. Invalid column
59
68
  * names are dropped with a `console.warn` rather than failing the query.
60
69
  */
61
- function buildFeedSelect(extraPostColumns, polls = false) {
62
- const base = polls ? `${FEED_SELECT}, ${POLL_OPTIONS_SELECT}` : FEED_SELECT;
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(", ");
63
77
  if (!extraPostColumns || extraPostColumns.length === 0)
64
78
  return base;
65
79
  const valid = extraPostColumns.filter((column) => {
@@ -73,9 +87,25 @@ function buildFeedSelect(extraPostColumns, polls = false) {
73
87
  return `${base}, ${valid.join(", ")}`;
74
88
  }
75
89
  /** The posts select for this install: `feed.extraPostColumns` + the poll
76
- * embed iff `modules.polls`. */
90
+ * embed iff `modules.polls`, + translation embeds iff the reader has a
91
+ * resolved translation locale. */
77
92
  function postsSelect(cfg) {
78
- return buildFeedSelect(cfg.feed.extraPostColumns, cfg.modules.polls);
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;
79
109
  }
80
110
  async function fetchMyLikes(client, postIds, uid) {
81
111
  if (postIds.length === 0)
@@ -177,6 +207,7 @@ async function fetchFeedPage(cfg, opts = {}) {
177
207
  .order("pinned_at", { ascending: false, nullsFirst: false })
178
208
  .order("created_at", { ascending: false })
179
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);
180
211
  if (opts.topic)
181
212
  query = query.eq("topic", opts.topic);
182
213
  const { data, error } = await query;
@@ -228,7 +259,7 @@ async function fetchUserPosts(cfg, userId, opts = {}) {
228
259
  const client = cfg.requireClient();
229
260
  const uid = await requireUid(cfg);
230
261
  const page = opts.cursor ?? 0;
231
- const { data, error } = await client
262
+ let query = client
232
263
  .from("posts")
233
264
  .select(postsSelect(cfg))
234
265
  .eq("author_id", userId)
@@ -236,6 +267,8 @@ async function fetchUserPosts(cfg, userId, opts = {}) {
236
267
  .in("status", ["visible", "pending"])
237
268
  .order("created_at", { ascending: false })
238
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;
239
272
  if (error)
240
273
  throw error;
241
274
  return toFeedPosts(cfg, client, (data ?? []), uid);
@@ -245,14 +278,14 @@ async function fetchUserPosts(cfg, userId, opts = {}) {
245
278
  * page-index cursor as `fetchFeedPage` (product decision, reversing the
246
279
  * earlier single-page scope cut).
247
280
  */
248
- async function searchPosts(cfg, query, opts = {}) {
249
- const cleaned = query.trim();
281
+ async function searchPosts(cfg, searchTerm, opts = {}) {
282
+ const cleaned = searchTerm.trim();
250
283
  if (cleaned.length === 0)
251
284
  return [];
252
285
  const client = cfg.requireClient();
253
286
  const uid = await requireUid(cfg);
254
287
  const page = opts.cursor ?? 0;
255
- const { data, error } = await client
288
+ let query = client
256
289
  .from("posts")
257
290
  .select(postsSelect(cfg))
258
291
  .eq("comments.status", "visible")
@@ -260,6 +293,8 @@ async function searchPosts(cfg, query, opts = {}) {
260
293
  .ilike("content", `%${cleaned}%`)
261
294
  .order("created_at", { ascending: false })
262
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;
263
298
  if (error)
264
299
  throw error;
265
300
  return toFeedPosts(cfg, client, (data ?? []), uid);
@@ -273,12 +308,17 @@ async function searchPosts(cfg, query, opts = {}) {
273
308
  async function fetchThread(cfg, postId) {
274
309
  const client = cfg.requireClient();
275
310
  const uid = await requireUid(cfg);
276
- 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
277
314
  .from("comments")
278
- .select("id, post_id, author_id, content, status, created_at, profiles(username, is_official, handle, avatar_url)")
315
+ .select(select)
279
316
  .eq("post_id", postId)
280
317
  .in("status", ["visible", "pending"])
281
318
  .order("created_at", { ascending: true });
319
+ if (locale)
320
+ query = query.eq("comment_translations.locale", locale);
321
+ const { data, error } = await query;
282
322
  if (error)
283
323
  throw error;
284
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.2.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": {