@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 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. Route or
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`, `buildFeedSelect`
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 useVotePoll; renamed
124
- * from the mold's `usePrayForPost`). `applyReaction(post)` is single-argument
125
- * (Task 3 ruling — it does not filter by post id), so the mapper here pre-filters
126
- * to the target post itself before calling it, and separately tracks whether
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
- const locale = (0, locale_1.readerLocale)(cfg);
298
- return (0, react_query_1.useMutation)({
299
- onMutate: async ({ topic, text, authorName, pollOptions }) => {
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
- const id = await (0, service_1.createPost)(cfg, { topic, body: text, pollOptions });
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 post refetches in background.
365
- if (hasPoll)
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
- const locale = (0, locale_1.readerLocale)(cfg);
381
- return (0, react_query_1.useMutation)({
382
- onMutate: async ({ postId, text, authorName }) => {
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 id = await (0, service_1.createComment)(cfg, postId, text);
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 useVotePoll; renamed
480
- * from the mold's `usePrayForPost`). `applyReaction(post)` is single-argument
481
- * (Task 3 ruling — it does not filter by post id), so the mapper here pre-filters
482
- * to the target post itself before calling it, and separately tracks whether
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.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.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 { ...post, hasReacted: true, reactionCount: post.reactionCount + 1 };
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, non-retractable
106
- * reaction row (rows are never deleted — see spec §6). Gated on `cfg.modules.reaction`.
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
- return rows.map((r) => (0, models_1.mapPostRow)(r, uid, cfg.authorFallback(), cfg.topics, liked, pollData, reactionData, cfg.feed.transformPost));
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
- return (0, locale_1.readerLocale)(cfg) ? `${COMMENTS_SELECT}, ${COMMENT_TRANSLATIONS_SELECT}` : COMMENTS_SELECT;
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
- return (data ?? []).map((r) => (0, models_1.mapCommentRow)(r, uid, cfg.authorFallback()));
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, non-retractable
423
- * reaction row (rows are never deleted — see spec §6). Gated on `cfg.modules.reaction`.
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
- const { error } = await client.from("post_reactions").insert({ post_id: postId, user_id: uid });
431
- // 23505 = already reacted to this post: the desired state holds.
432
- if (error && error.code !== "23505")
433
- throw error;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community-core",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Headless Supabase-backed community backend: identity, feed/thread/moderation services, and React Query hooks.",
5
5
  "license": "MIT",
6
6
  "repository": {