@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 +27 -12
- package/lib/config.d.ts +6 -0
- package/lib/events.d.ts +1 -0
- package/lib/events.js +1 -0
- package/lib/hooks.d.ts +16 -1
- package/lib/hooks.js +71 -43
- package/lib/inbox-service.d.ts +5 -0
- package/lib/inbox-service.js +31 -1
- package/lib/index.d.ts +4 -3
- package/lib/index.js +7 -2
- package/lib/keys.d.ts +7 -0
- package/lib/keys.js +18 -0
- package/lib/locale.d.ts +8 -0
- package/lib/locale.js +22 -0
- package/lib/models.d.ts +29 -0
- package/lib/models.js +12 -1
- package/lib/service.d.ts +14 -2
- package/lib/service.js +56 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,10 @@ const config: CommunityConfig = {
|
|
|
38
38
|
push: false,
|
|
39
39
|
inbox: true,
|
|
40
40
|
reaction: { key: "cheer" }, // or `false` to disable the reaction module
|
|
41
|
+
// posts/comments/poll labels translated at publication into these
|
|
42
|
+
// locales — must equal the backend's COMMUNITY_TRANSLATION_LOCALES.
|
|
43
|
+
// Absent or `false` ⇒ every query and screen behaves as before.
|
|
44
|
+
translation: { locales: ["en", "es-419"] },
|
|
41
45
|
},
|
|
42
46
|
|
|
43
47
|
// Host adapters — every field optional, no-op defaults.
|
|
@@ -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` |
|
|
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
|
|
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`,
|
|
167
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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) => ({
|
|
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
|
-
|
|
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
|
}
|
package/lib/inbox-service.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export interface InboxState {
|
|
|
12
12
|
/** Everything at or before this instant is read; null = never opened. */
|
|
13
13
|
seenAt: string | null;
|
|
14
14
|
}
|
|
15
|
+
/** Same truncation as list_notifications()'s `left(p.content, 140)`. */
|
|
16
|
+
export declare function localizeExcerpts(items: InboxItem[], translations: {
|
|
17
|
+
post_id: string;
|
|
18
|
+
content: string;
|
|
19
|
+
}[]): InboxItem[];
|
|
15
20
|
/** The inbox's whole state in one fetch: items (newest first) + seen marker. */
|
|
16
21
|
export declare function fetchInbox(cfg: ResolvedCommunityConfig): Promise<InboxState>;
|
|
17
22
|
/**
|
package/lib/inbox-service.js
CHANGED
|
@@ -12,11 +12,25 @@
|
|
|
12
12
|
// support-reply kind) passes through untouched instead of being special-cased
|
|
13
13
|
// here — the host UI decides what to do with a kind it doesn't recognize.
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.localizeExcerpts = localizeExcerpts;
|
|
15
16
|
exports.fetchInbox = fetchInbox;
|
|
16
17
|
exports.markInboxSeen = markInboxSeen;
|
|
17
18
|
exports.unreadCount = unreadCount;
|
|
18
19
|
const identity_1 = require("./identity");
|
|
20
|
+
const locale_1 = require("./locale");
|
|
19
21
|
const INBOX_PAGE_SIZE = 50;
|
|
22
|
+
/** Same truncation as list_notifications()'s `left(p.content, 140)`. */
|
|
23
|
+
function localizeExcerpts(items, translations) {
|
|
24
|
+
if (translations.length === 0)
|
|
25
|
+
return items;
|
|
26
|
+
const byPost = new Map(translations.map((t) => [t.post_id, t.content.slice(0, 140)]));
|
|
27
|
+
return items.map((item) => {
|
|
28
|
+
const translated = item.postId ? byPost.get(item.postId) : undefined;
|
|
29
|
+
return translated === undefined
|
|
30
|
+
? item
|
|
31
|
+
: { ...item, payload: { ...item.payload, postExcerpt: translated } };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
20
34
|
async function requireUid(cfg) {
|
|
21
35
|
const uid = await (0, identity_1.ensureIdentity)(cfg);
|
|
22
36
|
if (!uid)
|
|
@@ -61,7 +75,23 @@ async function fetchInbox(cfg) {
|
|
|
61
75
|
if (seenRes.error)
|
|
62
76
|
throw seenRes.error;
|
|
63
77
|
const items = (listRes.data ?? []).map(mapNotificationRow);
|
|
64
|
-
|
|
78
|
+
const locale = (0, locale_1.readerLocale)(cfg);
|
|
79
|
+
const postIds = [
|
|
80
|
+
...new Set(items.map((i) => i.postId).filter((id) => Boolean(id))),
|
|
81
|
+
];
|
|
82
|
+
let localized = items;
|
|
83
|
+
if (locale && postIds.length > 0) {
|
|
84
|
+
const { data, error } = await client
|
|
85
|
+
.from("post_translations")
|
|
86
|
+
.select("post_id, content")
|
|
87
|
+
.eq("locale", locale)
|
|
88
|
+
.in("post_id", postIds);
|
|
89
|
+
if (error) {
|
|
90
|
+
console.warn("[@rocapine/community-core] inbox translations unavailable, showing original excerpts:", error.message);
|
|
91
|
+
}
|
|
92
|
+
localized = localizeExcerpts(items, (data ?? []));
|
|
93
|
+
}
|
|
94
|
+
return { items: localized, seenAt: seenRes.data?.seen_at ?? null };
|
|
65
95
|
}
|
|
66
96
|
/**
|
|
67
97
|
* Marks everything currently in the inbox as read. The brief's signature
|
package/lib/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export type { CommunityTopicDef, CommunityModules, CommunityHostAdapters, CommunityFeedConfig, CommunityConfig, ResolvedCommunityConfig, } from "./config";
|
|
2
2
|
export { CommunityDisabledError, resolveConfig } from "./config";
|
|
3
|
+
export { readerLocale, languageOf } from "./locale";
|
|
3
4
|
export { CommunityProvider, useCommunityConfig } from "./provider";
|
|
4
|
-
export type { PostRow, PollOptionRow, CommentRow, ProfileRow, PollOption, FeedPoll, PollData, ReactionData, FeedPost, ThreadComment, CommunityProfile, } from "./models";
|
|
5
|
+
export type { PostRow, PollOptionRow, CommentRow, ProfileRow, PollOption, FeedPoll, PollData, ReactionData, FeedPost, ThreadComment, CommunityProfile, TranslationInfo, TranslationRowEmbed, } from "./models";
|
|
5
6
|
export { FEED_PAGE_SIZE, POST_MAX_LENGTH, COMMENT_MAX_LENGTH, POLL_MIN_OPTIONS, POLL_MAX_OPTIONS, POLL_OPTION_MAX_LENGTH, EMPTY_POLL_DATA, EMPTY_REACTION_DATA, displayName, normalizeTopic, mapProfileRow, buildPoll, pollPercent, mapPostRow, applyPollVote, applyReaction, mapCommentRow, newPostsLabel, newestCreatedAt, } from "./models";
|
|
6
7
|
export type { TimeAgoParts } from "./time";
|
|
7
8
|
export { timeAgo, timeAgoParts } from "./time";
|
|
@@ -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.
|
|
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;
|
package/lib/locale.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ResolvedCommunityConfig } from "./config";
|
|
2
|
+
export declare function languageOf(locale: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The locale translations are read in: the host's locale resolved against
|
|
5
|
+
* `modules.translation.locales` — exact match, else the first declared locale
|
|
6
|
+
* of the same language, else null (originals only). Null when the module is off.
|
|
7
|
+
*/
|
|
8
|
+
export declare function readerLocale(cfg: ResolvedCommunityConfig): string | null;
|
package/lib/locale.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.languageOf = languageOf;
|
|
4
|
+
exports.readerLocale = readerLocale;
|
|
5
|
+
function languageOf(locale) {
|
|
6
|
+
return locale.split("-")[0]?.toLowerCase() ?? "";
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The locale translations are read in: the host's locale resolved against
|
|
10
|
+
* `modules.translation.locales` — exact match, else the first declared locale
|
|
11
|
+
* of the same language, else null (originals only). Null when the module is off.
|
|
12
|
+
*/
|
|
13
|
+
function readerLocale(cfg) {
|
|
14
|
+
const module = cfg.modules.translation;
|
|
15
|
+
if (!module || module.locales.length === 0)
|
|
16
|
+
return null;
|
|
17
|
+
const host = cfg.host.getLocale();
|
|
18
|
+
if (module.locales.includes(host))
|
|
19
|
+
return host;
|
|
20
|
+
const lang = languageOf(host);
|
|
21
|
+
return module.locales.find((l) => languageOf(l) === lang) ?? null;
|
|
22
|
+
}
|
package/lib/models.d.ts
CHANGED
|
@@ -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) => ({
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
249
|
-
const cleaned =
|
|
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
|
-
|
|
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
|
|
318
|
+
const locale = (0, locale_1.readerLocale)(cfg);
|
|
319
|
+
let query = client
|
|
277
320
|
.from("comments")
|
|
278
|
-
.select(
|
|
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