@rocapine/community-core 0.10.0 → 0.11.1

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
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
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,14 +174,19 @@ 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.
154
182
  * Accepts either a raw username or any profile-shaped object carrying one (a row's
155
- * `.profiles`, a `ProfileRow`, or a previously-mapped `CommunityProfile`).
183
+ * `.profiles`, a `ProfileRow`, or a previously-mapped `CommunityProfile`). An unnamed
184
+ * profile that picked a handle shows the handle before the fallback (handles are only
185
+ * auto-assigned once a username exists, so this only kicks in for a chosen one).
156
186
  */
157
187
  export declare function displayName(profile: string | null | undefined | {
158
188
  username?: string | null;
189
+ handle?: string | null;
159
190
  }, fallback: string): string;
160
191
  /**
161
192
  * The topic column is app-defined free text. Unlike the mold (which dropped topics
@@ -190,7 +221,7 @@ export declare function pollPercent(votes: number, totalVotes: number): number;
190
221
  export declare function mapPostRow(row: PostRow, myUid: string, fallback: string, topics: readonly CommunityTopicDef[], likedPostIds: ReadonlySet<string>, pollData?: PollData, reactionData?: ReactionData,
191
222
  /** Host extension point (`CommunityConfig.feed.transformPost`), run last with
192
223
  * the raw row (including any `extraPostColumns`) this post was built from. */
193
- transformPost?: (post: FeedPost, row: Record<string, unknown>) => FeedPost): FeedPost;
224
+ transformPost?: (post: FeedPost, row: Record<string, unknown>) => FeedPost, imageBase?: string): FeedPost;
194
225
  /**
195
226
  * Optimistic vote application: moves my vote to `optionId` on the matching
196
227
  * post, shifting per-option counts; the total only grows on a first vote.
@@ -204,7 +235,7 @@ export declare function applyPollVote(post: FeedPost, postId: string, optionId:
204
235
  * `posts.map((p) => (p.id === postId ? applyReaction(p) : p))`).
205
236
  */
206
237
  export declare function applyReaction(post: FeedPost, on?: boolean): FeedPost;
207
- export declare function mapCommentRow(row: CommentRow, myUid: string, fallback: string): ThreadComment;
238
+ export declare function mapCommentRow(row: CommentRow, myUid: string, fallback: string, imageBase?: string): ThreadComment;
208
239
  /** Floating pill label. "1 new post" / "N new posts". */
209
240
  export declare function newPostsLabel(count: number): string;
210
241
  /**
@@ -215,3 +246,4 @@ export declare function newPostsLabel(count: number): string;
215
246
  export declare function newestCreatedAt(posts: readonly {
216
247
  createdAt: string;
217
248
  }[]): string | null;
249
+ 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(),
@@ -34,12 +42,14 @@ exports.EMPTY_REACTION_DATA = {
34
42
  /**
35
43
  * Public display name, with a host-configured fallback for profiles not yet named.
36
44
  * Accepts either a raw username or any profile-shaped object carrying one (a row's
37
- * `.profiles`, a `ProfileRow`, or a previously-mapped `CommunityProfile`).
45
+ * `.profiles`, a `ProfileRow`, or a previously-mapped `CommunityProfile`). An unnamed
46
+ * profile that picked a handle shows the handle before the fallback (handles are only
47
+ * auto-assigned once a username exists, so this only kicks in for a chosen one).
38
48
  */
39
49
  function displayName(profile, fallback) {
40
- const raw = typeof profile === "string" || profile == null ? profile : profile.username;
41
- const trimmed = raw?.trim();
42
- return trimmed ? trimmed : fallback;
50
+ if (typeof profile === "string" || profile == null)
51
+ return profile?.trim() || fallback;
52
+ return profile.username?.trim() || profile.handle?.trim() || fallback;
43
53
  }
44
54
  /**
45
55
  * The topic column is app-defined free text. Unlike the mold (which dropped topics
@@ -96,7 +106,7 @@ function pollPercent(votes, totalVotes) {
96
106
  function mapPostRow(row, myUid, fallback, topics, likedPostIds, pollData = exports.EMPTY_POLL_DATA, reactionData = exports.EMPTY_REACTION_DATA,
97
107
  /** Host extension point (`CommunityConfig.feed.transformPost`), run last with
98
108
  * the raw row (including any `extraPostColumns`) this post was built from. */
99
- transformPost) {
109
+ transformPost, imageBase) {
100
110
  const post = {
101
111
  id: row.id,
102
112
  authorId: row.author_id,
@@ -118,6 +128,7 @@ transformPost) {
118
128
  hasReacted: reactionData.mine.has(row.id),
119
129
  lastReactorName: reactionData.lastReactorName.get(row.id) ?? null,
120
130
  translation: embedToTranslation(row.post_translations),
131
+ image: mapImage(row, imageBase),
121
132
  };
122
133
  return transformPost ? transformPost(post, row) : post;
123
134
  }
@@ -159,7 +170,7 @@ function applyReaction(post, on = true) {
159
170
  reactionCount: Math.max(0, post.reactionCount + (on ? 1 : -1)),
160
171
  };
161
172
  }
162
- function mapCommentRow(row, myUid, fallback) {
173
+ function mapCommentRow(row, myUid, fallback, imageBase) {
163
174
  return {
164
175
  id: row.id,
165
176
  postId: row.post_id,
@@ -172,6 +183,7 @@ function mapCommentRow(row, myUid, fallback) {
172
183
  isOwn: row.author_id === myUid,
173
184
  createdAt: row.created_at,
174
185
  translation: embedToTranslation(row.comment_translations),
186
+ image: mapImage(row, imageBase),
175
187
  };
176
188
  }
177
189
  /** 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";
@@ -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)
@@ -549,3 +578,22 @@ async function uploadAvatar(cfg, fileUri) {
549
578
  throw error;
550
579
  return path;
551
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.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Headless Supabase-backed community backend: identity, feed/thread/moderation services, and React Query hooks.",
5
5
  "license": "MIT",
6
6
  "repository": {