@rocapine/community-core 0.9.0 → 0.11.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 +18 -2
- package/lib/config.d.ts +4 -0
- package/lib/hooks.d.ts +24 -13
- package/lib/hooks.js +47 -29
- package/lib/index.d.ts +4 -4
- package/lib/index.js +5 -2
- package/lib/models.d.ts +32 -3
- package/lib/models.js +19 -5
- package/lib/service.d.ts +20 -6
- package/lib/service.js +75 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,10 @@ const config: CommunityConfig = {
|
|
|
44
44
|
// locales — must equal the backend's COMMUNITY_TRANSLATION_LOCALES.
|
|
45
45
|
// Absent or `false` ⇒ every query and screen behaves as before.
|
|
46
46
|
translation: { locales: ["en", "es-419"] },
|
|
47
|
+
// One photo per post and per comment. Needs the backend `images` module
|
|
48
|
+
// and, in the UI package, expo-image-manipulator. Absent or `false` ⇒
|
|
49
|
+
// every query and screen behaves as before.
|
|
50
|
+
images: true,
|
|
47
51
|
},
|
|
48
52
|
|
|
49
53
|
// Host adapters — every field optional, no-op defaults.
|
|
@@ -136,7 +140,8 @@ import { COMMUNITY_EVENTS, type CommunityEventName } from "@rocapine/community-c
|
|
|
136
140
|
`commentPublished`, `profileOpened`, `profileUpdated`, `reactionAdded`,
|
|
137
141
|
`userReported`, `userBlocked`, `contentDeleted`, `inboxOpened`,
|
|
138
142
|
`translationToggled` (`community_translation_toggled`, props `{ postId | commentId, to: "original" | "translation" }`) — forwarded to
|
|
139
|
-
`host.onEvent` by the exported `emitEvent(cfg, name, props)` helper.
|
|
143
|
+
`host.onEvent` by the exported `emitEvent(cfg, name, props)` helper. `postPublished` and `commentPublished` carry
|
|
144
|
+
`hasImage: boolean` (alongside `hasPoll`). Route or
|
|
140
145
|
ignore these in your own tracking plan; nothing else in the package calls an
|
|
141
146
|
analytics provider directly.
|
|
142
147
|
|
|
@@ -160,6 +165,13 @@ runs in production and is silent in degraded mode.
|
|
|
160
165
|
only when the translation module is on and the reader locale resolves);
|
|
161
166
|
`text`/`content` always stay the original. Poll options carry
|
|
162
167
|
`translatedLabel: string | null` alongside the original `label`.
|
|
168
|
+
`FeedPost.image` / `ThreadComment.image` are `PostImage | null`
|
|
169
|
+
(`{ url, width, height }`; `url` is a local `file://` on an optimistic item)
|
|
170
|
+
— always `null` unless `modules.images` is on. **They are required fields:**
|
|
171
|
+
a host that builds `FeedPost`/`ThreadComment` literals by hand (tests,
|
|
172
|
+
fixtures, mocks) must add `image: null`. `LocalImage` is the same shape: a
|
|
173
|
+
re-encoded local JPEG ready to upload. `mapImage(row, imageBase)` turns the
|
|
174
|
+
`image_path`/`image_width`/`image_height` columns into a `PostImage`.
|
|
163
175
|
- **Locale** (`locale.ts`): `readerLocale(cfg)` — the locale translations
|
|
164
176
|
are read in (the host's `getLocale()` resolved against
|
|
165
177
|
`modules.translation.locales`), or `null` when the module is off or
|
|
@@ -171,7 +183,11 @@ runs in production and is silent in degraded mode.
|
|
|
171
183
|
`fetchUserPosts`, `searchPosts`, `fetchThread`, `createPost`, `votePoll`,
|
|
172
184
|
`createComment`, `moderateOne`, `setLike`, `setReaction`,
|
|
173
185
|
`fetchReactionSummaries`, `reportContent`, `blockUser`, `deleteOwnPost`,
|
|
174
|
-
`deleteOwnComment`, `updateProfile`, `uploadAvatar`, `
|
|
186
|
+
`deleteOwnComment`, `updateProfile`, `uploadAvatar`, `uploadPostImage`
|
|
187
|
+
(re-encoded JPEG → the public `post-images` bucket, `POST_IMAGES_BUCKET`,
|
|
188
|
+
under the caller's own folder; `createPost` / `createComment` take its
|
|
189
|
+
result, and `useCreatePost` / `useCreateComment` take an `image?: LocalImage`
|
|
190
|
+
and do both steps), `buildFeedSelect`
|
|
175
191
|
(pure helper behind `feed.extraPostColumns`, see below).
|
|
176
192
|
- **React Query hooks** (`hooks.ts`): `useCommunityFeed`, `useSearchPosts`,
|
|
177
193
|
`useNewPostsCount`, `useCommunityUnseenCount`, `useThread`, `useProfile`,
|
package/lib/config.d.ts
CHANGED
|
@@ -17,6 +17,10 @@ export type CommunityModules = {
|
|
|
17
17
|
translation?: {
|
|
18
18
|
locales: string[];
|
|
19
19
|
} | false;
|
|
20
|
+
/** Images module: one photo per post and per comment (public `post-images`
|
|
21
|
+
* bucket, columns added by the images migration). Absent or false ⇒ every
|
|
22
|
+
* query and screen behaves as before. */
|
|
23
|
+
images?: boolean;
|
|
20
24
|
};
|
|
21
25
|
export type CommunityHostAdapters = {
|
|
22
26
|
getDisplayName?: () => string | null;
|
package/lib/hooks.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type InfiniteData, type QueryClient } from "@tanstack/react-query";
|
|
2
|
-
import { type CommunityProfile, type FeedPost, type ThreadComment } from "./models";
|
|
1
|
+
import { type InfiniteData, type QueryClient, type UseMutationOptions } from "@tanstack/react-query";
|
|
2
|
+
import { type CommunityProfile, type FeedPost, type LocalImage, type ThreadComment } from "./models";
|
|
3
|
+
import type { ResolvedCommunityConfig } from "./config";
|
|
3
4
|
import { type NotificationPreferenceKey, type NotificationPreferences, type ModerationVerdict, type ReportInput, type UpdateProfileInput, type UpdateProfileResult } from "./service";
|
|
4
5
|
/** True while a post/comment is still the optimistic placeholder inserted by
|
|
5
6
|
* `useCreatePost`/`useCreateComment` (its temp id is swapped for the real one
|
|
@@ -84,20 +85,32 @@ type PostResult = {
|
|
|
84
85
|
* moderation we reconcile the cache in place, published -> swap the temp id for
|
|
85
86
|
* the real one (stays), rejected -> remove it. Smooth, no loading flicker.
|
|
86
87
|
*/
|
|
87
|
-
export declare function useCreatePost(): import("@tanstack/react-query").UseMutationResult<PostResult, Error, {
|
|
88
|
+
export declare function useCreatePost(): import("@tanstack/react-query").UseMutationResult<PostResult, Error, CreatePostVariables, {
|
|
89
|
+
tempId: string;
|
|
90
|
+
}>;
|
|
91
|
+
export type CreatePostVariables = {
|
|
88
92
|
topic: string;
|
|
89
93
|
text: string;
|
|
90
94
|
authorName: string;
|
|
91
95
|
pollOptions?: string[];
|
|
92
|
-
|
|
96
|
+
image?: LocalImage;
|
|
97
|
+
};
|
|
98
|
+
/** The options behind `useCreatePost`, split out so they can be driven without React. */
|
|
99
|
+
export declare function createPostMutationOptions(cfg: ResolvedCommunityConfig, queryClient: QueryClient, locale: string | null): UseMutationOptions<PostResult, Error, CreatePostVariables, {
|
|
93
100
|
tempId: string;
|
|
94
101
|
}>;
|
|
95
102
|
/** Optimistic comment creation, same reconcile-in-place model as posts. */
|
|
96
|
-
export declare function useCreateComment(): import("@tanstack/react-query").UseMutationResult<PostResult, Error, {
|
|
103
|
+
export declare function useCreateComment(): import("@tanstack/react-query").UseMutationResult<PostResult, Error, CreateCommentVariables, {
|
|
104
|
+
tempId: string;
|
|
105
|
+
}>;
|
|
106
|
+
export type CreateCommentVariables = {
|
|
97
107
|
postId: string;
|
|
98
108
|
text: string;
|
|
99
109
|
authorName: string;
|
|
100
|
-
|
|
110
|
+
image?: LocalImage;
|
|
111
|
+
};
|
|
112
|
+
/** The options behind `useCreateComment`, split out so they can be driven without React. */
|
|
113
|
+
export declare function createCommentMutationOptions(cfg: ResolvedCommunityConfig, queryClient: QueryClient, locale: string | null): UseMutationOptions<PostResult, Error, CreateCommentVariables, {
|
|
101
114
|
tempId: string;
|
|
102
115
|
}>;
|
|
103
116
|
/** Optimistic like toggle across every post cache (feed pages of every topic
|
|
@@ -120,16 +133,14 @@ export declare function useVotePoll(): import("@tanstack/react-query").UseMutati
|
|
|
120
133
|
optionId: string;
|
|
121
134
|
}, PostCacheSnapshot>;
|
|
122
135
|
/**
|
|
123
|
-
* Optimistic reaction across every post cache (mirror of
|
|
124
|
-
* from the mold's `usePrayForPost`). `
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* that post already had `hasReacted` so a retap of an already-reacted post
|
|
128
|
-
* (the reaction is non-retractable, `setReaction` is idempotent) does not
|
|
129
|
-
* re-emit the analytics event.
|
|
136
|
+
* Optimistic reaction toggle across every post cache (mirror of useToggleLike;
|
|
137
|
+
* renamed from the mold's `usePrayForPost`). `on` defaults to true (react);
|
|
138
|
+
* `on: false` withdraws it. `alreadyReacted` keeps a retap of an
|
|
139
|
+
* already-reacted post from re-emitting the analytics event.
|
|
130
140
|
*/
|
|
131
141
|
export declare function useReactToPost(): import("@tanstack/react-query").UseMutationResult<void, Error, {
|
|
132
142
|
postId: string;
|
|
143
|
+
on?: boolean;
|
|
133
144
|
}, PostCacheSnapshot & {
|
|
134
145
|
alreadyReacted: boolean;
|
|
135
146
|
}>;
|
package/lib/hooks.js
CHANGED
|
@@ -23,7 +23,9 @@ exports.useNotificationPreferences = useNotificationPreferences;
|
|
|
23
23
|
exports.useMyUid = useMyUid;
|
|
24
24
|
exports.useUpdateProfile = useUpdateProfile;
|
|
25
25
|
exports.useCreatePost = useCreatePost;
|
|
26
|
+
exports.createPostMutationOptions = createPostMutationOptions;
|
|
26
27
|
exports.useCreateComment = useCreateComment;
|
|
28
|
+
exports.createCommentMutationOptions = createCommentMutationOptions;
|
|
27
29
|
exports.useToggleLike = useToggleLike;
|
|
28
30
|
exports.useVotePoll = useVotePoll;
|
|
29
31
|
exports.useReactToPost = useReactToPost;
|
|
@@ -294,9 +296,12 @@ function useUpdateProfile() {
|
|
|
294
296
|
function useCreatePost() {
|
|
295
297
|
const cfg = (0, provider_1.useCommunityConfig)();
|
|
296
298
|
const queryClient = (0, react_query_1.useQueryClient)();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
299
|
+
return (0, react_query_1.useMutation)(createPostMutationOptions(cfg, queryClient, (0, locale_1.readerLocale)(cfg)));
|
|
300
|
+
}
|
|
301
|
+
/** The options behind `useCreatePost`, split out so they can be driven without React. */
|
|
302
|
+
function createPostMutationOptions(cfg, queryClient, locale) {
|
|
303
|
+
return {
|
|
304
|
+
onMutate: async ({ topic, text, authorName, pollOptions, image }) => {
|
|
300
305
|
await queryClient.cancelQueries({ queryKey: keys_1.FEED_KEY });
|
|
301
306
|
const tempId = `optimistic-${Date.now()}`;
|
|
302
307
|
const optimistic = {
|
|
@@ -331,18 +336,23 @@ function useCreatePost() {
|
|
|
331
336
|
hasReacted: false,
|
|
332
337
|
lastReactorName: null,
|
|
333
338
|
translation: null,
|
|
339
|
+
// The local file uri until the post is published; the background
|
|
340
|
+
// refetch in onSuccess swaps in the server image.
|
|
341
|
+
image: image ?? null,
|
|
334
342
|
};
|
|
335
343
|
const prepend = (key) => queryClient.setQueryData(key, (d) => d ? { ...d, pages: [[optimistic, ...(d.pages[0] ?? [])], ...d.pages.slice(1)] } : d);
|
|
336
344
|
prepend((0, keys_1.feedKey)(undefined, locale));
|
|
337
345
|
prepend((0, keys_1.feedKey)(topic, locale));
|
|
338
346
|
return { tempId };
|
|
339
347
|
},
|
|
340
|
-
mutationFn: async ({ topic, text, pollOptions }) => {
|
|
341
|
-
|
|
348
|
+
mutationFn: async ({ topic, text, pollOptions, image }) => {
|
|
349
|
+
// Upload first: a failed upload must not leave a post row behind.
|
|
350
|
+
const uploaded = image ? await (0, service_1.uploadPostImage)(cfg, image) : undefined;
|
|
351
|
+
const id = await (0, service_1.createPost)(cfg, { topic, body: text, pollOptions, image: uploaded });
|
|
342
352
|
const verdict = await (0, service_1.moderateOne)(cfg, { kind: "post", id });
|
|
343
353
|
return { id, verdict };
|
|
344
354
|
},
|
|
345
|
-
onSuccess: ({ id, verdict }, { topic, text, pollOptions }, { tempId }) => {
|
|
355
|
+
onSuccess: ({ id, verdict }, { topic, text, pollOptions, image }, { tempId }) => {
|
|
346
356
|
const rejected = verdict.status === "rejected";
|
|
347
357
|
queryClient.setQueriesData({ queryKey: keys_1.FEED_KEY }, (d) => d
|
|
348
358
|
? {
|
|
@@ -358,11 +368,13 @@ function useCreatePost() {
|
|
|
358
368
|
topic,
|
|
359
369
|
bodyLength: text.trim().length,
|
|
360
370
|
hasPoll,
|
|
371
|
+
hasImage: !!image,
|
|
361
372
|
});
|
|
362
373
|
cfg.host.onContentPublished();
|
|
363
|
-
// The in-place swap above keeps the optimistic poll's temp option ids
|
|
364
|
-
// voting needs the real uuids, so a poll
|
|
365
|
-
|
|
374
|
+
// The in-place swap above keeps the optimistic poll's temp option ids
|
|
375
|
+
// (voting needs the real uuids) and the image's local uri, so a poll or
|
|
376
|
+
// image post refetches in background.
|
|
377
|
+
if (hasPoll || image)
|
|
366
378
|
queryClient.invalidateQueries({ queryKey: keys_1.FEED_KEY });
|
|
367
379
|
}
|
|
368
380
|
},
|
|
@@ -371,15 +383,18 @@ function useCreatePost() {
|
|
|
371
383
|
? { ...d, pages: d.pages.map((p) => p.filter((post) => post.id !== context?.tempId)) }
|
|
372
384
|
: d);
|
|
373
385
|
},
|
|
374
|
-
}
|
|
386
|
+
};
|
|
375
387
|
}
|
|
376
388
|
/** Optimistic comment creation, same reconcile-in-place model as posts. */
|
|
377
389
|
function useCreateComment() {
|
|
378
390
|
const cfg = (0, provider_1.useCommunityConfig)();
|
|
379
391
|
const queryClient = (0, react_query_1.useQueryClient)();
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
392
|
+
return (0, react_query_1.useMutation)(createCommentMutationOptions(cfg, queryClient, (0, locale_1.readerLocale)(cfg)));
|
|
393
|
+
}
|
|
394
|
+
/** The options behind `useCreateComment`, split out so they can be driven without React. */
|
|
395
|
+
function createCommentMutationOptions(cfg, queryClient, locale) {
|
|
396
|
+
return {
|
|
397
|
+
onMutate: async ({ postId, text, authorName, image }) => {
|
|
383
398
|
await queryClient.cancelQueries({ queryKey: (0, keys_1.threadKey)(postId, locale) });
|
|
384
399
|
await queryClient.cancelQueries({ queryKey: keys_1.FEED_KEY });
|
|
385
400
|
const tempId = `optimistic-${Date.now()}`;
|
|
@@ -393,6 +408,7 @@ function useCreateComment() {
|
|
|
393
408
|
isOwn: true,
|
|
394
409
|
createdAt: new Date().toISOString(),
|
|
395
410
|
translation: null,
|
|
411
|
+
image: image ?? null,
|
|
396
412
|
};
|
|
397
413
|
queryClient.setQueryData((0, keys_1.threadKey)(postId, locale), (d) => [
|
|
398
414
|
...(d ?? []),
|
|
@@ -401,12 +417,13 @@ function useCreateComment() {
|
|
|
401
417
|
bumpFeedCommentCount(queryClient, postId, 1);
|
|
402
418
|
return { tempId };
|
|
403
419
|
},
|
|
404
|
-
mutationFn: async ({ postId, text }) => {
|
|
405
|
-
const
|
|
420
|
+
mutationFn: async ({ postId, text, image }) => {
|
|
421
|
+
const uploaded = image ? await (0, service_1.uploadPostImage)(cfg, image) : undefined;
|
|
422
|
+
const id = await (0, service_1.createComment)(cfg, postId, text, uploaded);
|
|
406
423
|
const verdict = await (0, service_1.moderateOne)(cfg, { kind: "comment", id });
|
|
407
424
|
return { id, verdict };
|
|
408
425
|
},
|
|
409
|
-
onSuccess: ({ id, verdict }, { postId, text }, { tempId }) => {
|
|
426
|
+
onSuccess: ({ id, verdict }, { postId, text, image }, { tempId }) => {
|
|
410
427
|
const rejected = verdict.status === "rejected";
|
|
411
428
|
queryClient.setQueryData((0, keys_1.threadKey)(postId, locale), (d) => (d ?? [])
|
|
412
429
|
.filter((c) => !(rejected && c.id === tempId))
|
|
@@ -418,15 +435,19 @@ function useCreateComment() {
|
|
|
418
435
|
(0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.commentPublished, {
|
|
419
436
|
postId,
|
|
420
437
|
bodyLength: text.trim().length,
|
|
438
|
+
hasImage: !!image,
|
|
421
439
|
});
|
|
422
440
|
cfg.host.onContentPublished();
|
|
441
|
+
// Swap the local image uri for the server one.
|
|
442
|
+
if (image)
|
|
443
|
+
queryClient.invalidateQueries({ queryKey: (0, keys_1.threadKey)(postId, locale) });
|
|
423
444
|
}
|
|
424
445
|
},
|
|
425
446
|
onError: (_e, { postId }, context) => {
|
|
426
447
|
queryClient.setQueryData((0, keys_1.threadKey)(postId, locale), (d) => (d ?? []).filter((c) => c.id !== context?.tempId));
|
|
427
448
|
bumpFeedCommentCount(queryClient, postId, -1);
|
|
428
449
|
},
|
|
429
|
-
}
|
|
450
|
+
};
|
|
430
451
|
}
|
|
431
452
|
/** Optimistic like toggle across every post cache (feed pages of every topic
|
|
432
453
|
* filter, user-posts and search — same sweep as votes/reactions, so a like
|
|
@@ -476,27 +497,24 @@ function useVotePoll() {
|
|
|
476
497
|
});
|
|
477
498
|
}
|
|
478
499
|
/**
|
|
479
|
-
* Optimistic reaction across every post cache (mirror of
|
|
480
|
-
* from the mold's `usePrayForPost`). `
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
* that post already had `hasReacted` so a retap of an already-reacted post
|
|
484
|
-
* (the reaction is non-retractable, `setReaction` is idempotent) does not
|
|
485
|
-
* re-emit the analytics event.
|
|
500
|
+
* Optimistic reaction toggle across every post cache (mirror of useToggleLike;
|
|
501
|
+
* renamed from the mold's `usePrayForPost`). `on` defaults to true (react);
|
|
502
|
+
* `on: false` withdraws it. `alreadyReacted` keeps a retap of an
|
|
503
|
+
* already-reacted post from re-emitting the analytics event.
|
|
486
504
|
*/
|
|
487
505
|
function useReactToPost() {
|
|
488
506
|
const cfg = (0, provider_1.useCommunityConfig)();
|
|
489
507
|
const queryClient = (0, react_query_1.useQueryClient)();
|
|
490
508
|
return (0, react_query_1.useMutation)({
|
|
491
|
-
mutationFn: ({ postId }) => (0, service_1.setReaction)(cfg, postId),
|
|
492
|
-
onMutate: async ({ postId }) => {
|
|
509
|
+
mutationFn: ({ postId, on = true }) => (0, service_1.setReaction)(cfg, postId, on),
|
|
510
|
+
onMutate: async ({ postId, on = true }) => {
|
|
493
511
|
let alreadyReacted = false;
|
|
494
512
|
const snapshot = await applyOptimisticToAllPostCaches(queryClient, (post) => {
|
|
495
513
|
if (post.id !== postId)
|
|
496
514
|
return post;
|
|
497
515
|
if (post.hasReacted)
|
|
498
516
|
alreadyReacted = true;
|
|
499
|
-
return (0, models_1.applyReaction)(post);
|
|
517
|
+
return (0, models_1.applyReaction)(post, on);
|
|
500
518
|
});
|
|
501
519
|
return { ...snapshot, alreadyReacted };
|
|
502
520
|
},
|
|
@@ -504,8 +522,8 @@ function useReactToPost() {
|
|
|
504
522
|
if (context)
|
|
505
523
|
rollbackPostCaches(queryClient, context);
|
|
506
524
|
},
|
|
507
|
-
onSuccess: (_data, { postId }, context) => {
|
|
508
|
-
if (!context?.alreadyReacted)
|
|
525
|
+
onSuccess: (_data, { postId, on = true }, context) => {
|
|
526
|
+
if (on && !context?.alreadyReacted)
|
|
509
527
|
(0, events_1.emitEvent)(cfg, events_1.COMMUNITY_EVENTS.reactionAdded, { postId });
|
|
510
528
|
},
|
|
511
529
|
});
|
package/lib/index.d.ts
CHANGED
|
@@ -2,15 +2,15 @@ export type { CommunityTopicDef, CommunityModules, CommunityHostAdapters, Commun
|
|
|
2
2
|
export { CommunityDisabledError, resolveConfig } from "./config";
|
|
3
3
|
export { readerLocale, languageOf } from "./locale";
|
|
4
4
|
export { CommunityProvider, useCommunityConfig } from "./provider";
|
|
5
|
-
export type { PostRow, PollOptionRow, CommentRow, ProfileRow, PollOption, FeedPoll, PollData, ReactionData, FeedPost, ThreadComment, CommunityProfile, TranslationInfo, TranslationRowEmbed, } from "./models";
|
|
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";
|
|
5
|
+
export type { PostRow, PollOptionRow, CommentRow, ProfileRow, PollOption, FeedPoll, PollData, ReactionData, FeedPost, ThreadComment, PostImage, LocalImage, CommunityProfile, TranslationInfo, TranslationRowEmbed, } from "./models";
|
|
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, mapImage, pollPercent, mapPostRow, applyPollVote, applyReaction, mapCommentRow, newPostsLabel, newestCreatedAt, } from "./models";
|
|
7
7
|
export type { TimeAgoParts } from "./time";
|
|
8
8
|
export { timeAgo, timeAgoParts } from "./time";
|
|
9
9
|
export { COMMUNITY_EVENTS, emitEvent } from "./events";
|
|
10
10
|
export type { CommunityEventName } from "./events";
|
|
11
11
|
export { ensureIdentity, resetIdentity, syncProfileFromHost } from "./identity";
|
|
12
|
-
export { buildFeedSelect, fetchFeedPage, countNewPosts, fetchProfile, fetchUserPosts, searchPosts, fetchThread, createPost, votePoll, createComment, moderateOne, setLike, setReaction, fetchReactionSummaries, reportContent, blockUser, deleteOwnPost, deleteOwnComment, updateProfile, fetchNotificationPreferences, setNotificationPreference, NOTIFICATION_PREFERENCE_KEYS, uploadAvatar, } from "./service";
|
|
13
|
-
export type { ModerationVerdict, CreatePostInput, ReportReason, ReportInput, UpdateProfileInput, UpdateProfileResult, NotificationPreferenceKey, NotificationPreferences, } from "./service";
|
|
12
|
+
export { buildFeedSelect, fetchFeedPage, countNewPosts, fetchProfile, fetchUserPosts, searchPosts, fetchThread, createPost, votePoll, createComment, moderateOne, setLike, setReaction, fetchReactionSummaries, reportContent, blockUser, deleteOwnPost, deleteOwnComment, updateProfile, fetchNotificationPreferences, setNotificationPreference, NOTIFICATION_PREFERENCE_KEYS, uploadAvatar, uploadPostImage, POST_IMAGES_BUCKET, } from "./service";
|
|
13
|
+
export type { ModerationVerdict, CreatePostInput, UploadedImage, ReportReason, ReportInput, UpdateProfileInput, UpdateProfileResult, NotificationPreferenceKey, NotificationPreferences, } from "./service";
|
|
14
14
|
export { useCommunityFeed, useSearchPosts, useNewPostsCount, useCommunityUnseenCount, useThread, useProfile, useUserPosts, useMyUid, useNotificationPreferences, useUpdateProfile, useCreatePost, useCreateComment, useToggleLike, useVotePoll, useReactToPost, useReport, useBlockUser, useDeleteContent, isOptimistic, } from "./hooks";
|
|
15
15
|
export { fetchInbox, localizeExcerpts, markInboxSeen, unreadCount } from "./inbox-service";
|
|
16
16
|
export type { InboxItem, InboxState } from "./inbox-service";
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.localizeExcerpts = exports.fetchInbox = exports.isOptimistic = exports.useDeleteContent = exports.useBlockUser = exports.useReport = exports.useReactToPost = exports.useVotePoll = exports.useToggleLike = exports.useCreateComment = exports.useCreatePost = exports.useUpdateProfile = exports.useNotificationPreferences = exports.useMyUid = exports.useUserPosts = exports.useProfile = exports.useThread = exports.useCommunityUnseenCount = exports.useNewPostsCount = exports.useSearchPosts = exports.useCommunityFeed = exports.uploadAvatar = exports.NOTIFICATION_PREFERENCE_KEYS = exports.setNotificationPreference = exports.fetchNotificationPreferences = exports.updateProfile = void 0;
|
|
3
|
+
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.mapImage = 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.useNotificationPreferences = exports.useMyUid = exports.useUserPosts = exports.useProfile = exports.useThread = exports.useCommunityUnseenCount = exports.useNewPostsCount = exports.useSearchPosts = exports.useCommunityFeed = exports.POST_IMAGES_BUCKET = exports.uploadPostImage = exports.uploadAvatar = exports.NOTIFICATION_PREFERENCE_KEYS = exports.setNotificationPreference = exports.fetchNotificationPreferences = exports.updateProfile = exports.deleteOwnComment = 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; } });
|
|
@@ -24,6 +24,7 @@ Object.defineProperty(exports, "displayName", { enumerable: true, get: function
|
|
|
24
24
|
Object.defineProperty(exports, "normalizeTopic", { enumerable: true, get: function () { return models_1.normalizeTopic; } });
|
|
25
25
|
Object.defineProperty(exports, "mapProfileRow", { enumerable: true, get: function () { return models_1.mapProfileRow; } });
|
|
26
26
|
Object.defineProperty(exports, "buildPoll", { enumerable: true, get: function () { return models_1.buildPoll; } });
|
|
27
|
+
Object.defineProperty(exports, "mapImage", { enumerable: true, get: function () { return models_1.mapImage; } });
|
|
27
28
|
Object.defineProperty(exports, "pollPercent", { enumerable: true, get: function () { return models_1.pollPercent; } });
|
|
28
29
|
Object.defineProperty(exports, "mapPostRow", { enumerable: true, get: function () { return models_1.mapPostRow; } });
|
|
29
30
|
Object.defineProperty(exports, "applyPollVote", { enumerable: true, get: function () { return models_1.applyPollVote; } });
|
|
@@ -65,6 +66,8 @@ Object.defineProperty(exports, "fetchNotificationPreferences", { enumerable: tru
|
|
|
65
66
|
Object.defineProperty(exports, "setNotificationPreference", { enumerable: true, get: function () { return service_1.setNotificationPreference; } });
|
|
66
67
|
Object.defineProperty(exports, "NOTIFICATION_PREFERENCE_KEYS", { enumerable: true, get: function () { return service_1.NOTIFICATION_PREFERENCE_KEYS; } });
|
|
67
68
|
Object.defineProperty(exports, "uploadAvatar", { enumerable: true, get: function () { return service_1.uploadAvatar; } });
|
|
69
|
+
Object.defineProperty(exports, "uploadPostImage", { enumerable: true, get: function () { return service_1.uploadPostImage; } });
|
|
70
|
+
Object.defineProperty(exports, "POST_IMAGES_BUCKET", { enumerable: true, get: function () { return service_1.POST_IMAGES_BUCKET; } });
|
|
68
71
|
var hooks_1 = require("./hooks");
|
|
69
72
|
Object.defineProperty(exports, "useCommunityFeed", { enumerable: true, get: function () { return hooks_1.useCommunityFeed; } });
|
|
70
73
|
Object.defineProperty(exports, "useSearchPosts", { enumerable: true, get: function () { return hooks_1.useSearchPosts; } });
|
package/lib/models.d.ts
CHANGED
|
@@ -5,6 +5,22 @@ export declare const COMMENT_MAX_LENGTH = 1000;
|
|
|
5
5
|
export declare const POLL_MIN_OPTIONS = 2;
|
|
6
6
|
export declare const POLL_MAX_OPTIONS = 4;
|
|
7
7
|
export declare const POLL_OPTION_MAX_LENGTH = 60;
|
|
8
|
+
export interface PostImage {
|
|
9
|
+
/** Public URL, or a local `file://` uri on optimistic items. */
|
|
10
|
+
url: string;
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
}
|
|
14
|
+
/** A re-encoded local file, ready to upload. */
|
|
15
|
+
export type LocalImage = PostImage;
|
|
16
|
+
type ImageColumns = {
|
|
17
|
+
image_path?: string | null;
|
|
18
|
+
image_width?: number | null;
|
|
19
|
+
image_height?: number | null;
|
|
20
|
+
};
|
|
21
|
+
/** Null unless the row has a path and both dimensions and the images module
|
|
22
|
+
* gave a bucket base (`imageBase`, ending in `/`). */
|
|
23
|
+
export declare function mapImage(row: ImageColumns, imageBase: string | undefined): PostImage | null;
|
|
8
24
|
/** Raw shape returned by the feed select (see the services layer). */
|
|
9
25
|
export interface PostRow {
|
|
10
26
|
id: string;
|
|
@@ -32,6 +48,10 @@ export interface PostRow {
|
|
|
32
48
|
/** At most one row: server-filtered to the reader's locale. Absent when the
|
|
33
49
|
* translation module is off. */
|
|
34
50
|
post_translations?: TranslationRowEmbed[];
|
|
51
|
+
/** Absent when the images module is off (columns not in the select). */
|
|
52
|
+
image_path?: string | null;
|
|
53
|
+
image_width?: number | null;
|
|
54
|
+
image_height?: number | null;
|
|
35
55
|
}
|
|
36
56
|
export interface PollOptionRow {
|
|
37
57
|
id: string;
|
|
@@ -69,6 +89,10 @@ export interface CommentRow {
|
|
|
69
89
|
/** At most one row: server-filtered to the reader's locale. Absent when the
|
|
70
90
|
* translation module is off. */
|
|
71
91
|
comment_translations?: TranslationRowEmbed[];
|
|
92
|
+
/** Absent when the images module is off (columns not in the select). */
|
|
93
|
+
image_path?: string | null;
|
|
94
|
+
image_width?: number | null;
|
|
95
|
+
image_height?: number | null;
|
|
72
96
|
}
|
|
73
97
|
export interface PollOption {
|
|
74
98
|
id: string;
|
|
@@ -133,6 +157,8 @@ export interface FeedPost {
|
|
|
133
157
|
/** Reader-locale translation; null when the item is already in the reader's language,
|
|
134
158
|
* not translated yet, or the module is off. `text` stays the original. */
|
|
135
159
|
translation: TranslationInfo | null;
|
|
160
|
+
/** Null when the post has no photo or the images module is off. */
|
|
161
|
+
image: PostImage | null;
|
|
136
162
|
}
|
|
137
163
|
export interface ThreadComment {
|
|
138
164
|
id: string;
|
|
@@ -148,6 +174,8 @@ export interface ThreadComment {
|
|
|
148
174
|
/** Reader-locale translation; null when the item is already in the reader's language,
|
|
149
175
|
* not translated yet, or the module is off. `text` stays the original. */
|
|
150
176
|
translation: TranslationInfo | null;
|
|
177
|
+
/** Null when the comment has no photo or the images module is off. */
|
|
178
|
+
image: PostImage | null;
|
|
151
179
|
}
|
|
152
180
|
/**
|
|
153
181
|
* Public display name, with a host-configured fallback for profiles not yet named.
|
|
@@ -190,7 +218,7 @@ export declare function pollPercent(votes: number, totalVotes: number): number;
|
|
|
190
218
|
export declare function mapPostRow(row: PostRow, myUid: string, fallback: string, topics: readonly CommunityTopicDef[], likedPostIds: ReadonlySet<string>, pollData?: PollData, reactionData?: ReactionData,
|
|
191
219
|
/** Host extension point (`CommunityConfig.feed.transformPost`), run last with
|
|
192
220
|
* the raw row (including any `extraPostColumns`) this post was built from. */
|
|
193
|
-
transformPost?: (post: FeedPost, row: Record<string, unknown>) => FeedPost): FeedPost;
|
|
221
|
+
transformPost?: (post: FeedPost, row: Record<string, unknown>) => FeedPost, imageBase?: string): FeedPost;
|
|
194
222
|
/**
|
|
195
223
|
* Optimistic vote application: moves my vote to `optionId` on the matching
|
|
196
224
|
* post, shifting per-option counts; the total only grows on a first vote.
|
|
@@ -203,8 +231,8 @@ export declare function applyPollVote(post: FeedPost, postId: string, optionId:
|
|
|
203
231
|
* caller is expected to have already located the target post (e.g. via
|
|
204
232
|
* `posts.map((p) => (p.id === postId ? applyReaction(p) : p))`).
|
|
205
233
|
*/
|
|
206
|
-
export declare function applyReaction(post: FeedPost): FeedPost;
|
|
207
|
-
export declare function mapCommentRow(row: CommentRow, myUid: string, fallback: string): ThreadComment;
|
|
234
|
+
export declare function applyReaction(post: FeedPost, on?: boolean): FeedPost;
|
|
235
|
+
export declare function mapCommentRow(row: CommentRow, myUid: string, fallback: string, imageBase?: string): ThreadComment;
|
|
208
236
|
/** Floating pill label. "1 new post" / "N new posts". */
|
|
209
237
|
export declare function newPostsLabel(count: number): string;
|
|
210
238
|
/**
|
|
@@ -215,3 +243,4 @@ export declare function newPostsLabel(count: number): string;
|
|
|
215
243
|
export declare function newestCreatedAt(posts: readonly {
|
|
216
244
|
createdAt: string;
|
|
217
245
|
}[]): string | null;
|
|
246
|
+
export {};
|
package/lib/models.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// No React Native / Supabase imports: this module is exercised by vitest in a node env.
|
|
4
4
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
5
|
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 = void 0;
|
|
6
|
+
exports.mapImage = mapImage;
|
|
6
7
|
exports.displayName = displayName;
|
|
7
8
|
exports.normalizeTopic = normalizeTopic;
|
|
8
9
|
exports.mapProfileRow = mapProfileRow;
|
|
@@ -25,6 +26,13 @@ exports.COMMENT_MAX_LENGTH = 1000;
|
|
|
25
26
|
exports.POLL_MIN_OPTIONS = 2;
|
|
26
27
|
exports.POLL_MAX_OPTIONS = 4;
|
|
27
28
|
exports.POLL_OPTION_MAX_LENGTH = 60;
|
|
29
|
+
/** Null unless the row has a path and both dimensions and the images module
|
|
30
|
+
* gave a bucket base (`imageBase`, ending in `/`). */
|
|
31
|
+
function mapImage(row, imageBase) {
|
|
32
|
+
if (!imageBase || !row.image_path || !row.image_width || !row.image_height)
|
|
33
|
+
return null;
|
|
34
|
+
return { url: imageBase + row.image_path, width: row.image_width, height: row.image_height };
|
|
35
|
+
}
|
|
28
36
|
exports.EMPTY_POLL_DATA = { counts: new Map(), myVotes: new Map() };
|
|
29
37
|
exports.EMPTY_REACTION_DATA = {
|
|
30
38
|
counts: new Map(),
|
|
@@ -96,7 +104,7 @@ function pollPercent(votes, totalVotes) {
|
|
|
96
104
|
function mapPostRow(row, myUid, fallback, topics, likedPostIds, pollData = exports.EMPTY_POLL_DATA, reactionData = exports.EMPTY_REACTION_DATA,
|
|
97
105
|
/** Host extension point (`CommunityConfig.feed.transformPost`), run last with
|
|
98
106
|
* the raw row (including any `extraPostColumns`) this post was built from. */
|
|
99
|
-
transformPost) {
|
|
107
|
+
transformPost, imageBase) {
|
|
100
108
|
const post = {
|
|
101
109
|
id: row.id,
|
|
102
110
|
authorId: row.author_id,
|
|
@@ -118,6 +126,7 @@ transformPost) {
|
|
|
118
126
|
hasReacted: reactionData.mine.has(row.id),
|
|
119
127
|
lastReactorName: reactionData.lastReactorName.get(row.id) ?? null,
|
|
120
128
|
translation: embedToTranslation(row.post_translations),
|
|
129
|
+
image: mapImage(row, imageBase),
|
|
121
130
|
};
|
|
122
131
|
return transformPost ? transformPost(post, row) : post;
|
|
123
132
|
}
|
|
@@ -150,12 +159,16 @@ function applyPollVote(post, postId, optionId) {
|
|
|
150
159
|
* caller is expected to have already located the target post (e.g. via
|
|
151
160
|
* `posts.map((p) => (p.id === postId ? applyReaction(p) : p))`).
|
|
152
161
|
*/
|
|
153
|
-
function applyReaction(post) {
|
|
154
|
-
if (post.hasReacted)
|
|
162
|
+
function applyReaction(post, on = true) {
|
|
163
|
+
if (post.hasReacted === on)
|
|
155
164
|
return post;
|
|
156
|
-
return {
|
|
165
|
+
return {
|
|
166
|
+
...post,
|
|
167
|
+
hasReacted: on,
|
|
168
|
+
reactionCount: Math.max(0, post.reactionCount + (on ? 1 : -1)),
|
|
169
|
+
};
|
|
157
170
|
}
|
|
158
|
-
function mapCommentRow(row, myUid, fallback) {
|
|
171
|
+
function mapCommentRow(row, myUid, fallback, imageBase) {
|
|
159
172
|
return {
|
|
160
173
|
id: row.id,
|
|
161
174
|
postId: row.post_id,
|
|
@@ -168,6 +181,7 @@ function mapCommentRow(row, myUid, fallback) {
|
|
|
168
181
|
isOwn: row.author_id === myUid,
|
|
169
182
|
createdAt: row.created_at,
|
|
170
183
|
translation: embedToTranslation(row.comment_translations),
|
|
184
|
+
image: mapImage(row, imageBase),
|
|
171
185
|
};
|
|
172
186
|
}
|
|
173
187
|
/** Floating pill label. "1 new post" / "N new posts". */
|
package/lib/service.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ResolvedCommunityConfig } from "./config";
|
|
2
|
-
import { type CommunityProfile, type FeedPost, type ReactionData, type ThreadComment } from "./models";
|
|
2
|
+
import { type CommunityProfile, type FeedPost, type LocalImage, type ReactionData, type ThreadComment } from "./models";
|
|
3
|
+
export declare const POST_IMAGES_BUCKET = "post-images";
|
|
3
4
|
/**
|
|
4
5
|
* Appends `extraPostColumns` (if any) to the posts feed select. Pure and
|
|
5
6
|
* exported so it's unit-testable without a Supabase client. Invalid column
|
|
6
7
|
* names are dropped with a `console.warn` rather than failing the query.
|
|
7
8
|
*/
|
|
8
|
-
export declare function buildFeedSelect(extraPostColumns?: readonly string[], polls?: boolean, translations?: boolean): string;
|
|
9
|
+
export declare function buildFeedSelect(extraPostColumns?: readonly string[], polls?: boolean, translations?: boolean, images?: boolean): string;
|
|
9
10
|
/** Embedded-resource filters: keep only the reader locale's translation rows,
|
|
10
11
|
* so `rows?.[0]` in the mappers is always that locale, never some other
|
|
11
12
|
* locale's leftover row. PostgREST accepts dotted paths for nested embeds,
|
|
@@ -73,10 +74,17 @@ export type ModerationVerdict = {
|
|
|
73
74
|
status: "published" | "rejected" | "pending";
|
|
74
75
|
reason?: string;
|
|
75
76
|
};
|
|
77
|
+
/** What `uploadPostImage` returns and `createPost`/`createComment` store. */
|
|
78
|
+
export type UploadedImage = {
|
|
79
|
+
path: string;
|
|
80
|
+
width: number;
|
|
81
|
+
height: number;
|
|
82
|
+
};
|
|
76
83
|
export type CreatePostInput = {
|
|
77
84
|
topic: string;
|
|
78
85
|
body: string;
|
|
79
86
|
pollOptions?: string[];
|
|
87
|
+
image?: UploadedImage;
|
|
80
88
|
};
|
|
81
89
|
/**
|
|
82
90
|
* Insert a post as 'pending' (invisible to others until moderated). Returns id.
|
|
@@ -94,7 +102,7 @@ export declare function createPost(cfg: ResolvedCommunityConfig, input: CreatePo
|
|
|
94
102
|
*/
|
|
95
103
|
export declare function votePoll(cfg: ResolvedCommunityConfig, postId: string, optionId: string): Promise<void>;
|
|
96
104
|
/** Insert a comment as 'pending'. Returns id. */
|
|
97
|
-
export declare function createComment(cfg: ResolvedCommunityConfig, postId: string, body: string): Promise<string>;
|
|
105
|
+
export declare function createComment(cfg: ResolvedCommunityConfig, postId: string, body: string, image?: UploadedImage): Promise<string>;
|
|
98
106
|
/** Run synchronous moderation on a just-created pending post/comment. */
|
|
99
107
|
export declare function moderateOne(cfg: ResolvedCommunityConfig, input: {
|
|
100
108
|
kind: "post" | "comment";
|
|
@@ -102,10 +110,10 @@ export declare function moderateOne(cfg: ResolvedCommunityConfig, input: {
|
|
|
102
110
|
}): Promise<ModerationVerdict>;
|
|
103
111
|
export declare function setLike(cfg: ResolvedCommunityConfig, postId: string, on: boolean): Promise<void>;
|
|
104
112
|
/**
|
|
105
|
-
* `setPrayed` generalized to `setReaction`: insert my private
|
|
106
|
-
* reaction row
|
|
113
|
+
* `setPrayed` generalized to `setReaction`: insert (`on`) or delete my private
|
|
114
|
+
* reaction row, like `setLike`. Gated on `cfg.modules.reaction`.
|
|
107
115
|
*/
|
|
108
|
-
export declare function setReaction(cfg: ResolvedCommunityConfig, postId: string): Promise<void>;
|
|
116
|
+
export declare function setReaction(cfg: ResolvedCommunityConfig, postId: string, on?: boolean): Promise<void>;
|
|
109
117
|
export type ReportReason = "spam" | "harassment" | "hate" | "inappropriate" | "other";
|
|
110
118
|
export interface ReportInput {
|
|
111
119
|
reportedUserId: string;
|
|
@@ -166,3 +174,9 @@ export declare function updateProfile(cfg: ResolvedCommunityConfig, input: Updat
|
|
|
166
174
|
* the portable way to read local bytes in React Native.
|
|
167
175
|
*/
|
|
168
176
|
export declare function uploadAvatar(cfg: ResolvedCommunityConfig, fileUri: string): Promise<string>;
|
|
177
|
+
/**
|
|
178
|
+
* Upload a re-encoded JPEG (see `LocalImage`) to the public `post-images`
|
|
179
|
+
* bucket under the caller's own folder. Returns what `createPost` /
|
|
180
|
+
* `createComment` store; the public URL is rebuilt from `path` on read.
|
|
181
|
+
*/
|
|
182
|
+
export declare function uploadPostImage(cfg: ResolvedCommunityConfig, image: LocalImage): Promise<UploadedImage>;
|
package/lib/service.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// feed/thread composition (search, poll batching via `poll_vote_counts`, reaction batching
|
|
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
|
-
exports.NOTIFICATION_PREFERENCE_KEYS = void 0;
|
|
11
|
+
exports.NOTIFICATION_PREFERENCE_KEYS = exports.POST_IMAGES_BUCKET = void 0;
|
|
12
12
|
exports.buildFeedSelect = buildFeedSelect;
|
|
13
13
|
exports.withTranslationFilters = withTranslationFilters;
|
|
14
14
|
exports.fetchReactionSummaries = fetchReactionSummaries;
|
|
@@ -33,12 +33,17 @@ exports.deleteOwnPost = deleteOwnPost;
|
|
|
33
33
|
exports.deleteOwnComment = deleteOwnComment;
|
|
34
34
|
exports.updateProfile = updateProfile;
|
|
35
35
|
exports.uploadAvatar = uploadAvatar;
|
|
36
|
+
exports.uploadPostImage = uploadPostImage;
|
|
36
37
|
const supabase_js_1 = require("@supabase/supabase-js");
|
|
37
38
|
const identity_1 = require("./identity");
|
|
38
39
|
const locale_1 = require("./locale");
|
|
39
40
|
const models_1 = require("./models");
|
|
40
41
|
/** Avatar images live in this Storage bucket on every install (mold value, unchanged). */
|
|
41
42
|
const AVATAR_BUCKET = "avatars";
|
|
43
|
+
exports.POST_IMAGES_BUCKET = "post-images";
|
|
44
|
+
/** Selected only when the images module is on: installs without the module
|
|
45
|
+
* don't have these columns and PostgREST would reject the whole select. */
|
|
46
|
+
const IMAGE_COLUMNS = "image_path, image_width, image_height";
|
|
42
47
|
async function requireUid(cfg) {
|
|
43
48
|
const uid = await (0, identity_1.ensureIdentity)(cfg);
|
|
44
49
|
if (!uid)
|
|
@@ -72,12 +77,14 @@ const VALID_EXTRA_COLUMN = /^[a-z0-9_]+$/;
|
|
|
72
77
|
* exported so it's unit-testable without a Supabase client. Invalid column
|
|
73
78
|
* names are dropped with a `console.warn` rather than failing the query.
|
|
74
79
|
*/
|
|
75
|
-
function buildFeedSelect(extraPostColumns, polls = false, translations = false) {
|
|
80
|
+
function buildFeedSelect(extraPostColumns, polls = false, translations = false, images = false) {
|
|
76
81
|
const parts = [FEED_SELECT];
|
|
77
82
|
if (polls)
|
|
78
83
|
parts.push(translations ? POLL_OPTIONS_TRANSLATED_SELECT : POLL_OPTIONS_SELECT);
|
|
79
84
|
if (translations)
|
|
80
85
|
parts.push(POST_TRANSLATIONS_SELECT);
|
|
86
|
+
if (images)
|
|
87
|
+
parts.push(IMAGE_COLUMNS);
|
|
81
88
|
const base = parts.join(", ");
|
|
82
89
|
if (!extraPostColumns || extraPostColumns.length === 0)
|
|
83
90
|
return base;
|
|
@@ -95,7 +102,15 @@ function buildFeedSelect(extraPostColumns, polls = false, translations = false)
|
|
|
95
102
|
* embed iff `modules.polls`, + translation embeds iff the reader has a
|
|
96
103
|
* resolved translation locale. */
|
|
97
104
|
function postsSelect(cfg) {
|
|
98
|
-
return buildFeedSelect(cfg.feed.extraPostColumns, cfg.modules.polls, (0, locale_1.readerLocale)(cfg) !== null);
|
|
105
|
+
return buildFeedSelect(cfg.feed.extraPostColumns, cfg.modules.polls, (0, locale_1.readerLocale)(cfg) !== null, !!cfg.modules.images);
|
|
106
|
+
}
|
|
107
|
+
/** Public URL prefix of the `post-images` bucket (ends with `/`); undefined
|
|
108
|
+
* when the images module is off. */
|
|
109
|
+
function imageBase(cfg) {
|
|
110
|
+
if (!cfg.modules.images)
|
|
111
|
+
return undefined;
|
|
112
|
+
const { publicUrl } = cfg.requireClient().storage.from(exports.POST_IMAGES_BUCKET).getPublicUrl("").data;
|
|
113
|
+
return publicUrl.endsWith("/") ? publicUrl : `${publicUrl}/`;
|
|
99
114
|
}
|
|
100
115
|
/** Embedded-resource filters: keep only the reader locale's translation rows,
|
|
101
116
|
* so `rows?.[0]` in the mappers is always that locale, never some other
|
|
@@ -191,7 +206,8 @@ async function toFeedPosts(cfg, client, rows, uid) {
|
|
|
191
206
|
fetchPollData(cfg, rows.filter((r) => (r.poll_options ?? []).length > 0).map((r) => r.id), uid),
|
|
192
207
|
fetchReactionSummaries(cfg, postIds),
|
|
193
208
|
]);
|
|
194
|
-
|
|
209
|
+
const base = imageBase(cfg);
|
|
210
|
+
return rows.map((r) => (0, models_1.mapPostRow)(r, uid, cfg.authorFallback(), cfg.topics, liked, pollData, reactionData, cfg.feed.transformPost, base));
|
|
195
211
|
}
|
|
196
212
|
/**
|
|
197
213
|
* One feed page, newest first. RLS already excludes other users' hidden and
|
|
@@ -307,7 +323,12 @@ async function searchPosts(cfg, searchTerm, opts = {}) {
|
|
|
307
323
|
/** The thread (comments) select: + the translation embed iff the reader has a
|
|
308
324
|
* resolved translation locale. */
|
|
309
325
|
function threadSelect(cfg) {
|
|
310
|
-
|
|
326
|
+
const parts = [COMMENTS_SELECT];
|
|
327
|
+
if ((0, locale_1.readerLocale)(cfg))
|
|
328
|
+
parts.push(COMMENT_TRANSLATIONS_SELECT);
|
|
329
|
+
if (cfg.modules.images)
|
|
330
|
+
parts.push(IMAGE_COLUMNS);
|
|
331
|
+
return parts.join(", ");
|
|
311
332
|
}
|
|
312
333
|
/**
|
|
313
334
|
* Note: `cfg.feed.extraPostColumns`/`transformPost` do NOT apply here — this
|
|
@@ -330,8 +351,10 @@ async function fetchThread(cfg, postId) {
|
|
|
330
351
|
const { data, error } = await query;
|
|
331
352
|
if (error)
|
|
332
353
|
throw error;
|
|
333
|
-
|
|
354
|
+
const base = imageBase(cfg);
|
|
355
|
+
return (data ?? []).map((r) => (0, models_1.mapCommentRow)(r, uid, cfg.authorFallback(), base));
|
|
334
356
|
}
|
|
357
|
+
const imageColumns = (image) => image ? { image_path: image.path, image_width: image.width, image_height: image.height } : {};
|
|
335
358
|
/**
|
|
336
359
|
* Insert a post as 'pending' (invisible to others until moderated). Returns id.
|
|
337
360
|
* With pollOptions, the post and its options are created transactionally by
|
|
@@ -339,10 +362,14 @@ async function fetchThread(cfg, postId) {
|
|
|
339
362
|
* that branch is gated on `cfg.modules.polls`.
|
|
340
363
|
*/
|
|
341
364
|
async function createPost(cfg, input) {
|
|
342
|
-
const { topic, body, pollOptions } = input;
|
|
365
|
+
const { topic, body, pollOptions, image } = input;
|
|
343
366
|
const hasPoll = !!pollOptions && pollOptions.length > 0;
|
|
344
367
|
if (hasPoll && !cfg.modules.polls)
|
|
345
368
|
throw new Error("polls module is not enabled");
|
|
369
|
+
if (image && !cfg.modules.images)
|
|
370
|
+
throw new Error("images module is not enabled");
|
|
371
|
+
if (image && hasPoll)
|
|
372
|
+
throw new Error("a post cannot have both a poll and an image");
|
|
346
373
|
const client = cfg.requireClient();
|
|
347
374
|
const uid = await requireUid(cfg);
|
|
348
375
|
if (hasPoll) {
|
|
@@ -357,7 +384,7 @@ async function createPost(cfg, input) {
|
|
|
357
384
|
}
|
|
358
385
|
const { data, error } = await client
|
|
359
386
|
.from("posts")
|
|
360
|
-
.insert({ author_id: uid, topic, content: body })
|
|
387
|
+
.insert({ author_id: uid, topic, content: body, ...imageColumns(image) })
|
|
361
388
|
.select("id")
|
|
362
389
|
.single();
|
|
363
390
|
if (error)
|
|
@@ -383,12 +410,14 @@ async function votePoll(cfg, postId, optionId) {
|
|
|
383
410
|
throw error;
|
|
384
411
|
}
|
|
385
412
|
/** Insert a comment as 'pending'. Returns id. */
|
|
386
|
-
async function createComment(cfg, postId, body) {
|
|
413
|
+
async function createComment(cfg, postId, body, image) {
|
|
414
|
+
if (image && !cfg.modules.images)
|
|
415
|
+
throw new Error("images module is not enabled");
|
|
387
416
|
const client = cfg.requireClient();
|
|
388
417
|
const uid = await requireUid(cfg);
|
|
389
418
|
const { data, error } = await client
|
|
390
419
|
.from("comments")
|
|
391
|
-
.insert({ post_id: postId, author_id: uid, content: body })
|
|
420
|
+
.insert({ post_id: postId, author_id: uid, content: body, ...imageColumns(image) })
|
|
392
421
|
.select("id")
|
|
393
422
|
.single();
|
|
394
423
|
if (error)
|
|
@@ -419,18 +448,28 @@ async function setLike(cfg, postId, on) {
|
|
|
419
448
|
}
|
|
420
449
|
}
|
|
421
450
|
/**
|
|
422
|
-
* `setPrayed` generalized to `setReaction`: insert my private
|
|
423
|
-
* reaction row
|
|
451
|
+
* `setPrayed` generalized to `setReaction`: insert (`on`) or delete my private
|
|
452
|
+
* reaction row, like `setLike`. Gated on `cfg.modules.reaction`.
|
|
424
453
|
*/
|
|
425
|
-
async function setReaction(cfg, postId) {
|
|
454
|
+
async function setReaction(cfg, postId, on = true) {
|
|
426
455
|
if (!cfg.modules.reaction)
|
|
427
456
|
throw new Error("reaction module is not enabled");
|
|
428
457
|
const client = cfg.requireClient();
|
|
429
458
|
const uid = await requireUid(cfg);
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
459
|
+
if (on) {
|
|
460
|
+
const { error } = await client.from("post_reactions").insert({ post_id: postId, user_id: uid });
|
|
461
|
+
// 23505 = already reacted to this post: the desired state holds.
|
|
462
|
+
if (error && error.code !== "23505")
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
const { error } = await client
|
|
467
|
+
.from("post_reactions")
|
|
468
|
+
.delete()
|
|
469
|
+
.match({ post_id: postId, user_id: uid });
|
|
470
|
+
if (error)
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
434
473
|
}
|
|
435
474
|
async function reportContent(cfg, input) {
|
|
436
475
|
const client = cfg.requireClient();
|
|
@@ -539,3 +578,22 @@ async function uploadAvatar(cfg, fileUri) {
|
|
|
539
578
|
throw error;
|
|
540
579
|
return path;
|
|
541
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* Upload a re-encoded JPEG (see `LocalImage`) to the public `post-images`
|
|
583
|
+
* bucket under the caller's own folder. Returns what `createPost` /
|
|
584
|
+
* `createComment` store; the public URL is rebuilt from `path` on read.
|
|
585
|
+
*/
|
|
586
|
+
async function uploadPostImage(cfg, image) {
|
|
587
|
+
const client = cfg.requireClient();
|
|
588
|
+
const uid = await requireUid(cfg);
|
|
589
|
+
const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
590
|
+
const path = `${uid}/${id}.jpg`;
|
|
591
|
+
const response = await fetch(image.url);
|
|
592
|
+
const bytes = await response.arrayBuffer();
|
|
593
|
+
const { error } = await client.storage
|
|
594
|
+
.from(exports.POST_IMAGES_BUCKET)
|
|
595
|
+
.upload(path, bytes, { contentType: "image/jpeg" });
|
|
596
|
+
if (error)
|
|
597
|
+
throw error;
|
|
598
|
+
return { path, width: image.width, height: image.height };
|
|
599
|
+
}
|
package/package.json
CHANGED