@oxyhq/bloom 0.32.1 → 0.33.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.
Files changed (91) hide show
  1. package/lib/commonjs/hooks/index.js +13 -0
  2. package/lib/commonjs/hooks/index.js.map +1 -1
  3. package/lib/commonjs/hooks/useImagePreload.js +52 -0
  4. package/lib/commonjs/hooks/useImagePreload.js.map +1 -0
  5. package/lib/commonjs/image-aspect-ratio-cache/imageAspectRatioCache.js +65 -0
  6. package/lib/commonjs/image-aspect-ratio-cache/imageAspectRatioCache.js.map +1 -0
  7. package/lib/commonjs/image-aspect-ratio-cache/index.js +37 -0
  8. package/lib/commonjs/image-aspect-ratio-cache/index.js.map +1 -0
  9. package/lib/commonjs/index.js +15 -0
  10. package/lib/commonjs/index.js.map +1 -1
  11. package/lib/commonjs/index.web.js +15 -0
  12. package/lib/commonjs/index.web.js.map +1 -1
  13. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +664 -0
  14. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -0
  15. package/lib/commonjs/zoomable-image-gallery/constants.js +41 -0
  16. package/lib/commonjs/zoomable-image-gallery/constants.js.map +1 -0
  17. package/lib/commonjs/zoomable-image-gallery/index.js +19 -0
  18. package/lib/commonjs/zoomable-image-gallery/index.js.map +1 -0
  19. package/lib/commonjs/zoomable-image-gallery/types.js +2 -0
  20. package/lib/commonjs/zoomable-image-gallery/types.js.map +1 -0
  21. package/lib/module/hooks/index.js +1 -0
  22. package/lib/module/hooks/index.js.map +1 -1
  23. package/lib/module/hooks/useImagePreload.js +48 -0
  24. package/lib/module/hooks/useImagePreload.js.map +1 -0
  25. package/lib/module/image-aspect-ratio-cache/imageAspectRatioCache.js +57 -0
  26. package/lib/module/image-aspect-ratio-cache/imageAspectRatioCache.js.map +1 -0
  27. package/lib/module/image-aspect-ratio-cache/index.js +4 -0
  28. package/lib/module/image-aspect-ratio-cache/index.js.map +1 -0
  29. package/lib/module/index.js +2 -0
  30. package/lib/module/index.js.map +1 -1
  31. package/lib/module/index.web.js +2 -0
  32. package/lib/module/index.web.js.map +1 -1
  33. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +659 -0
  34. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -0
  35. package/lib/module/zoomable-image-gallery/constants.js +38 -0
  36. package/lib/module/zoomable-image-gallery/constants.js.map +1 -0
  37. package/lib/module/zoomable-image-gallery/index.js +4 -0
  38. package/lib/module/zoomable-image-gallery/index.js.map +1 -0
  39. package/lib/module/zoomable-image-gallery/types.js +2 -0
  40. package/lib/module/zoomable-image-gallery/types.js.map +1 -0
  41. package/lib/typescript/commonjs/hooks/index.d.ts +1 -0
  42. package/lib/typescript/commonjs/hooks/index.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/hooks/useImagePreload.d.ts +10 -0
  44. package/lib/typescript/commonjs/hooks/useImagePreload.d.ts.map +1 -0
  45. package/lib/typescript/commonjs/image-aspect-ratio-cache/imageAspectRatioCache.d.ts +14 -0
  46. package/lib/typescript/commonjs/image-aspect-ratio-cache/imageAspectRatioCache.d.ts.map +1 -0
  47. package/lib/typescript/commonjs/image-aspect-ratio-cache/index.d.ts +2 -0
  48. package/lib/typescript/commonjs/image-aspect-ratio-cache/index.d.ts.map +1 -0
  49. package/lib/typescript/commonjs/index.d.ts +1 -0
  50. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/index.web.d.ts +1 -0
  52. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts +5 -0
  54. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/zoomable-image-gallery/constants.d.ts +33 -0
  56. package/lib/typescript/commonjs/zoomable-image-gallery/constants.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts +3 -0
  58. package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts +46 -0
  60. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts.map +1 -0
  61. package/lib/typescript/module/hooks/index.d.ts +1 -0
  62. package/lib/typescript/module/hooks/index.d.ts.map +1 -1
  63. package/lib/typescript/module/hooks/useImagePreload.d.ts +10 -0
  64. package/lib/typescript/module/hooks/useImagePreload.d.ts.map +1 -0
  65. package/lib/typescript/module/image-aspect-ratio-cache/imageAspectRatioCache.d.ts +14 -0
  66. package/lib/typescript/module/image-aspect-ratio-cache/imageAspectRatioCache.d.ts.map +1 -0
  67. package/lib/typescript/module/image-aspect-ratio-cache/index.d.ts +2 -0
  68. package/lib/typescript/module/image-aspect-ratio-cache/index.d.ts.map +1 -0
  69. package/lib/typescript/module/index.d.ts +1 -0
  70. package/lib/typescript/module/index.d.ts.map +1 -1
  71. package/lib/typescript/module/index.web.d.ts +1 -0
  72. package/lib/typescript/module/index.web.d.ts.map +1 -1
  73. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts +5 -0
  74. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -0
  75. package/lib/typescript/module/zoomable-image-gallery/constants.d.ts +33 -0
  76. package/lib/typescript/module/zoomable-image-gallery/constants.d.ts.map +1 -0
  77. package/lib/typescript/module/zoomable-image-gallery/index.d.ts +3 -0
  78. package/lib/typescript/module/zoomable-image-gallery/index.d.ts.map +1 -0
  79. package/lib/typescript/module/zoomable-image-gallery/types.d.ts +46 -0
  80. package/lib/typescript/module/zoomable-image-gallery/types.d.ts.map +1 -0
  81. package/package.json +27 -1
  82. package/src/hooks/index.ts +1 -0
  83. package/src/hooks/useImagePreload.ts +51 -0
  84. package/src/image-aspect-ratio-cache/imageAspectRatioCache.ts +62 -0
  85. package/src/image-aspect-ratio-cache/index.ts +7 -0
  86. package/src/index.ts +1 -0
  87. package/src/index.web.ts +1 -0
  88. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +695 -0
  89. package/src/zoomable-image-gallery/constants.ts +25 -0
  90. package/src/zoomable-image-gallery/index.ts +8 -0
  91. package/src/zoomable-image-gallery/types.ts +49 -0
@@ -0,0 +1,695 @@
1
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ Pressable,
7
+ Platform,
8
+ ScrollView,
9
+ useWindowDimensions,
10
+ NativeSyntheticEvent,
11
+ NativeScrollEvent,
12
+ type ViewStyle,
13
+ } from 'react-native';
14
+ import { Image } from 'expo-image';
15
+ import { BlurView } from 'expo-blur';
16
+ import Animated, {
17
+ useSharedValue,
18
+ useAnimatedStyle,
19
+ withSpring,
20
+ withTiming,
21
+ runOnJS,
22
+ Easing,
23
+ } from 'react-native-reanimated';
24
+ import {
25
+ GestureHandlerRootView,
26
+ Gesture,
27
+ GestureDetector,
28
+ } from 'react-native-gesture-handler';
29
+ import { useTheme } from '../theme/use-theme';
30
+ import { Portal } from '../portal';
31
+ import { Z_INDEX } from '../styles/z-index';
32
+ import {
33
+ getAspectRatio,
34
+ fetchAspectRatio,
35
+ DEFAULT_ASPECT_RATIO,
36
+ } from '../image-aspect-ratio-cache';
37
+ import {
38
+ FIT_FRACTION,
39
+ MAX_DRAG_FRACTION,
40
+ SCALE_DRAG_FRACTION,
41
+ MIN_DRAG_SCALE,
42
+ DISMISS_FRACTION,
43
+ AXIS_DECISION_OFFSET,
44
+ OPEN_DURATION_WEB,
45
+ CLOSE_DURATION_WEB,
46
+ OPACITY_DURATION,
47
+ OPEN_SPRING,
48
+ CLOSE_SPRING,
49
+ SNAP_BACK_SPRING,
50
+ DEFAULT_CORNER_RADIUS,
51
+ } from './constants';
52
+ import type {
53
+ GalleryImage,
54
+ MeasuredRect,
55
+ ZoomableImageGalleryHandle,
56
+ ZoomableImageGalleryProps,
57
+ } from './types';
58
+
59
+ const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
60
+ const AnimatedImage = Animated.createAnimatedComponent(Image);
61
+
62
+ // Web-only interaction hints (no-ops on native): the zoom surfaces are
63
+ // tap-to-dismiss (`cursor: pointer`) and must not select text or drag the
64
+ // image. `Platform.select` keeps them off native; the `as ViewStyle` cast
65
+ // mirrors Bloom's other web-only style casts (e.g. Dialog.web's `animation`).
66
+ const webPointerStyle = Platform.select({
67
+ web: { userSelect: 'none', cursor: 'pointer' } as ViewStyle,
68
+ default: undefined,
69
+ });
70
+ const webUserSelectNoneStyle = Platform.select({
71
+ web: { userSelect: 'none' } as ViewStyle,
72
+ default: undefined,
73
+ });
74
+
75
+ interface FittedSize {
76
+ width: number;
77
+ height: number;
78
+ }
79
+
80
+ /**
81
+ * Fullscreen, swipeable image viewer that replicates the profile avatar's
82
+ * measured-origin zoom transition (`ZoomableAvatar`) for rectangular post media:
83
+ *
84
+ * - Open/close feel is identical to the avatar (same spring configs, web
85
+ * timing/easing, blur backdrop, and the measure-origin technique). The viewer
86
+ * renders through the Bloom `Portal` on BOTH platforms (RN's `Modal` is not
87
+ * used on native — on the New Architecture / Fabric Android its host views
88
+ * mount full-screen but never composite, leaving the viewer invisible).
89
+ * - The OPENING (tapped) image animates from its measured rect to a centered,
90
+ * aspect-ratio-preserving fit within {@link FIT_FRACTION} of the screen. Once
91
+ * the open animation settles, a horizontal paging `ScrollView` mounts seeded at
92
+ * the tapped index so the user can swipe between every image in the post.
93
+ * - Gesture disambiguation: the pager owns horizontal swipes; a vertical-only
94
+ * `Gesture.Pan` (`activeOffsetY` + `failOffsetX`) owns drag-to-dismiss, so the
95
+ * two never fight.
96
+ */
97
+ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, ZoomableImageGalleryProps>(({ measureThumb, cornerRadius = DEFAULT_CORNER_RADIUS }, ref) => {
98
+ const theme = useTheme();
99
+ const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = useWindowDimensions();
100
+
101
+ const [isOpen, setIsOpen] = useState(false);
102
+ // Once true, the swipeable pager is mounted and the single open-image hidden.
103
+ const [pagerReady, setPagerReady] = useState(false);
104
+ const [images, setImages] = useState<GalleryImage[]>([]);
105
+ const [activeIndex, setActiveIndex] = useState(0);
106
+ // Aspect ratio of the OPENING image (drives the open-animation fit box).
107
+ const [openRatio, setOpenRatio] = useState<number>(DEFAULT_ASPECT_RATIO);
108
+ // Per-image aspect ratios for the pager pages (index-aligned with `images`).
109
+ const [pageRatios, setPageRatios] = useState<Record<number, number>>({});
110
+
111
+ const scale = useSharedValue(1);
112
+ const translateX = useSharedValue(0);
113
+ const translateY = useSharedValue(0);
114
+ const opacity = useSharedValue(0);
115
+
116
+ // Origin (offset from screen center) of the tapped image.
117
+ const originX = useSharedValue(0);
118
+ const originY = useSharedValue(0);
119
+
120
+ const startX = useSharedValue(0);
121
+ const startY = useSharedValue(0);
122
+ // Per-drag baseline captured at pan start (transient).
123
+ const startScale = useSharedValue(1);
124
+ // Persistent scale of the open-image at its thumbnail origin (thumbWidth /
125
+ // fittedWidth). The open animation grows from this to 1; the close animation
126
+ // shrinks back to it. Never overwritten by the drag gesture.
127
+ const originScale = useSharedValue(1);
128
+
129
+ const pagerRef = useRef<ScrollView>(null);
130
+ // Latch the index the pager must land on once it has mounted + laid out.
131
+ const pendingIndexRef = useRef(0);
132
+ // Mirror of `activeIndex` readable synchronously from callbacks/worklets
133
+ // (`handleDismiss` runs via `runOnJS` and from `Pressable.onPress`, where the
134
+ // state closure can be stale). Kept in lockstep by `setActiveIndexBoth`.
135
+ const activeIndexRef = useRef(0);
136
+
137
+ // Single writer for the current index: updates state (drives indicator + open
138
+ // image) and the synchronous mirror together, and only when it changes.
139
+ const setActiveIndexBoth = useCallback((next: number) => {
140
+ activeIndexRef.current = next;
141
+ setActiveIndex((prev) => (prev === next ? prev : next));
142
+ }, []);
143
+
144
+ // Box the fitted image must fit inside.
145
+ const fitBox = useMemo<FittedSize>(
146
+ () => ({ width: SCREEN_WIDTH * FIT_FRACTION, height: SCREEN_HEIGHT * FIT_FRACTION }),
147
+ [SCREEN_WIDTH, SCREEN_HEIGHT]
148
+ );
149
+
150
+ // Compute the largest width/height for `ratio` that fits inside `fitBox`
151
+ // without cropping (contain).
152
+ const fitForRatio = useCallback((ratio: number): FittedSize => {
153
+ const safeRatio = ratio > 0 && Number.isFinite(ratio) ? ratio : DEFAULT_ASPECT_RATIO;
154
+ let width = fitBox.width;
155
+ let height = width / safeRatio;
156
+ if (height > fitBox.height) {
157
+ height = fitBox.height;
158
+ width = height * safeRatio;
159
+ }
160
+ return { width, height };
161
+ }, [fitBox]);
162
+
163
+ // Fitted size of the single open-image for the CURRENT page. On open this is
164
+ // the opened image's fit (active index == opened index, ratio == `openRatio`);
165
+ // after swiping it tracks the viewed image so the collapse-on-dismiss renders
166
+ // and flies back the image actually on screen.
167
+ const activeRatio = pageRatios[activeIndex] ?? openRatio;
168
+ const activeFit = useMemo(() => fitForRatio(activeRatio), [fitForRatio, activeRatio]);
169
+
170
+ // Alt text (accessibility description) of the image currently on screen, shown
171
+ // as a caption at the bottom of the viewer (Bluesky-style lightbox footer).
172
+ const activeAlt = images[activeIndex]?.alt?.trim() || undefined;
173
+
174
+ const ensureRatio = useCallback((index: number, uri: string) => {
175
+ const cached = getAspectRatio(uri);
176
+ if (cached !== undefined) {
177
+ setPageRatios((prev) => (prev[index] === cached ? prev : { ...prev, [index]: cached }));
178
+ return;
179
+ }
180
+ void fetchAspectRatio(uri).then((ratio) => {
181
+ setPageRatios((prev) => (prev[index] === ratio ? prev : { ...prev, [index]: ratio }));
182
+ });
183
+ }, []);
184
+
185
+ // Unmount + reset all animation values to their neutral baseline. Shared by
186
+ // every dismiss path (fly-back and fade-out fallback).
187
+ const finalizeDismiss = useCallback(() => {
188
+ setIsOpen(false);
189
+ scale.value = 1;
190
+ translateX.value = 0;
191
+ translateY.value = 0;
192
+ opacity.value = 0;
193
+ }, [opacity, scale, translateX, translateY]);
194
+
195
+ // Fly the (possibly dragged) image back toward `target` — the rect of the
196
+ // thumbnail currently being viewed — shrinking to its footprint. Uses the
197
+ // EXACT same close spring (native) / timing (web) as the avatar transition.
198
+ const flyBackTo = useCallback(
199
+ (target: { x: number; y: number; scale: number }) => {
200
+ if (Platform.OS === 'web') {
201
+ const duration = CLOSE_DURATION_WEB;
202
+ const easing = Easing.in(Easing.cubic);
203
+ scale.value = withTiming(target.scale, { duration, easing });
204
+ translateX.value = withTiming(target.x, { duration, easing });
205
+ translateY.value = withTiming(target.y, { duration, easing });
206
+ opacity.value = withTiming(0, { duration, easing });
207
+ setTimeout(finalizeDismiss, duration + 20);
208
+ } else {
209
+ scale.value = withSpring(target.scale, CLOSE_SPRING);
210
+ translateX.value = withSpring(target.x, CLOSE_SPRING);
211
+ translateY.value = withSpring(target.y, CLOSE_SPRING);
212
+ opacity.value = withTiming(0, { duration: OPACITY_DURATION });
213
+ setTimeout(finalizeDismiss, CLOSE_DURATION_WEB);
214
+ }
215
+ },
216
+ [finalizeDismiss, opacity, scale, translateX, translateY]
217
+ );
218
+
219
+ // Fallback when the current thumbnail cannot be measured (ref missing /
220
+ // unmounted / virtualized): a plain center fade-out with a slight scale-down,
221
+ // then unmount. Keeps the same web timing / native spring feel.
222
+ const fadeOutCenter = useCallback(() => {
223
+ if (Platform.OS === 'web') {
224
+ const duration = CLOSE_DURATION_WEB;
225
+ const easing = Easing.in(Easing.cubic);
226
+ scale.value = withTiming(MIN_DRAG_SCALE, { duration, easing });
227
+ opacity.value = withTiming(0, { duration, easing });
228
+ setTimeout(finalizeDismiss, duration + 20);
229
+ } else {
230
+ scale.value = withSpring(MIN_DRAG_SCALE, CLOSE_SPRING);
231
+ opacity.value = withTiming(0, { duration: OPACITY_DURATION });
232
+ setTimeout(finalizeDismiss, CLOSE_DURATION_WEB);
233
+ }
234
+ }, [finalizeDismiss, opacity, scale]);
235
+
236
+ const handleDismiss = useCallback(() => {
237
+ // Collapse the pager back to the single open-image so the fly-back animates
238
+ // one image (the current one) rather than the whole scrolled strip.
239
+ setPagerReady(false);
240
+
241
+ const index = activeIndexRef.current;
242
+ const current = images[index];
243
+ // Recompute the fly-back target from the CURRENT image: the live rect of its
244
+ // thumbnail + the same fitted box (`activeFit`) the open-image is rendered
245
+ // at, using the same screen-center math as `open`. Falls back to a center
246
+ // fade-out when the thumbnail can't be measured.
247
+ if (measureThumb && current) {
248
+ const centerX = SCREEN_WIDTH / 2;
249
+ const centerY = SCREEN_HEIGHT / 2;
250
+ void measureThumb(index).then((rect) => {
251
+ if (rect && rect.width > 0 && activeFit.width > 0) {
252
+ flyBackTo({
253
+ x: rect.x + rect.width / 2 - centerX,
254
+ y: rect.y + rect.height / 2 - centerY,
255
+ scale: rect.width / activeFit.width,
256
+ });
257
+ } else {
258
+ fadeOutCenter();
259
+ }
260
+ });
261
+ return;
262
+ }
263
+
264
+ fadeOutCenter();
265
+ }, [
266
+ activeFit,
267
+ fadeOutCenter,
268
+ flyBackTo,
269
+ images,
270
+ measureThumb,
271
+ SCREEN_HEIGHT,
272
+ SCREEN_WIDTH,
273
+ ]);
274
+
275
+ // Reveal the swipeable pager once the open animation has settled. The index it
276
+ // lands on is held in `pendingIndexRef` (set synchronously in `open`) and
277
+ // applied in `onPagerLayout`.
278
+ const revealPager = useCallback(() => {
279
+ setPagerReady(true);
280
+ }, []);
281
+
282
+ const open = useCallback(
283
+ (nextImages: GalleryImage[], index: number, rect?: MeasuredRect) => {
284
+ if (isOpen || nextImages.length === 0) return;
285
+ const safeIndex = Math.min(Math.max(index, 0), nextImages.length - 1);
286
+ const target = nextImages[safeIndex];
287
+ if (!target) return;
288
+ const knownRatio = target.aspectRatio ?? getAspectRatio(target.uri);
289
+ const ratio = knownRatio ?? DEFAULT_ASPECT_RATIO;
290
+
291
+ setImages(nextImages);
292
+ setActiveIndexBoth(safeIndex);
293
+ setOpenRatio(ratio);
294
+ // Seed every page whose ratio is already known (consumer-provided metadata
295
+ // or a previously-cached probe) so swiping never hits the same snap — only
296
+ // a genuinely-unknown image falls through to `ensureRatio`'s async probe.
297
+ const initialRatios: Record<number, number> = {};
298
+ nextImages.forEach((img, i) => {
299
+ const r = img.aspectRatio ?? getAspectRatio(img.uri);
300
+ if (r !== undefined) initialRatios[i] = r;
301
+ });
302
+ setPageRatios(initialRatios);
303
+ pendingIndexRef.current = safeIndex;
304
+
305
+ // Resolve the opening ratio if it was not yet known, then re-fit.
306
+ if (knownRatio === undefined) {
307
+ void fetchAspectRatio(target.uri).then((resolved) => {
308
+ setOpenRatio(resolved);
309
+ setPageRatios((prev) => ({ ...prev, [safeIndex]: resolved }));
310
+ });
311
+ }
312
+
313
+ // The open-image box is rendered at its FINAL fitted size; the open
314
+ // animation grows it from the thumbnail's footprint (scale < 1) up to 1,
315
+ // mirroring the avatar's small→big scale. The initial scale is the ratio
316
+ // of the thumbnail width to the fitted width.
317
+ const fitted = fitForRatio(ratio);
318
+ const centerX = SCREEN_WIDTH / 2;
319
+ const centerY = SCREEN_HEIGHT / 2;
320
+ if (rect && rect.width > 0) {
321
+ originX.value = rect.x + rect.width / 2 - centerX;
322
+ originY.value = rect.y + rect.height / 2 - centerY;
323
+ originScale.value = rect.width / fitted.width;
324
+ } else {
325
+ originX.value = 0;
326
+ originY.value = 0;
327
+ originScale.value = 1;
328
+ }
329
+
330
+ setIsOpen(true);
331
+ translateX.value = originX.value;
332
+ translateY.value = originY.value;
333
+ scale.value = originScale.value;
334
+ opacity.value = 0;
335
+
336
+ if (Platform.OS === 'web') {
337
+ requestAnimationFrame(() => {
338
+ const duration = OPEN_DURATION_WEB;
339
+ const easing = Easing.out(Easing.cubic);
340
+ scale.value = withTiming(1, { duration, easing });
341
+ translateX.value = withTiming(0, { duration, easing });
342
+ translateY.value = withTiming(0, { duration, easing });
343
+ opacity.value = withTiming(1, { duration, easing });
344
+ setTimeout(revealPager, duration);
345
+ });
346
+ } else {
347
+ setTimeout(() => {
348
+ scale.value = withSpring(1, OPEN_SPRING);
349
+ translateX.value = withSpring(0, OPEN_SPRING);
350
+ translateY.value = withSpring(0, OPEN_SPRING);
351
+ opacity.value = withTiming(1, { duration: OPACITY_DURATION });
352
+ setTimeout(revealPager, OPEN_DURATION_WEB);
353
+ }, 0);
354
+ }
355
+ },
356
+ [
357
+ isOpen,
358
+ SCREEN_WIDTH,
359
+ SCREEN_HEIGHT,
360
+ fitForRatio,
361
+ opacity,
362
+ originScale,
363
+ originX,
364
+ originY,
365
+ revealPager,
366
+ scale,
367
+ setActiveIndexBoth,
368
+ translateX,
369
+ translateY,
370
+ ]
371
+ );
372
+
373
+ React.useImperativeHandle(ref, () => ({ open }), [open]);
374
+
375
+ const panGesture = useMemo(
376
+ () =>
377
+ Gesture.Pan()
378
+ .enabled(isOpen)
379
+ // Only claim drags that are decided to be vertical; horizontal drags
380
+ // fall through to the pager's ScrollView so swiping changes pages.
381
+ .activeOffsetY([-AXIS_DECISION_OFFSET, AXIS_DECISION_OFFSET])
382
+ .failOffsetX([-AXIS_DECISION_OFFSET, AXIS_DECISION_OFFSET])
383
+ .onStart(() => {
384
+ startX.value = translateX.value;
385
+ startY.value = translateY.value;
386
+ startScale.value = scale.value;
387
+ })
388
+ .onUpdate((event) => {
389
+ translateX.value = startX.value + event.translationX;
390
+ translateY.value = startY.value + event.translationY;
391
+ const dragDistance = Math.sqrt(event.translationX ** 2 + event.translationY ** 2);
392
+ const maxDrag = SCREEN_HEIGHT * MAX_DRAG_FRACTION;
393
+ opacity.value = Math.max(0, 1 - dragDistance / maxDrag);
394
+ const scaleReduction = Math.max(
395
+ MIN_DRAG_SCALE,
396
+ 1 - dragDistance / (SCREEN_HEIGHT * SCALE_DRAG_FRACTION)
397
+ );
398
+ scale.value = startScale.value * scaleReduction;
399
+ })
400
+ .onEnd((event) => {
401
+ const dragDistance = Math.sqrt(event.translationX ** 2 + event.translationY ** 2);
402
+ if (dragDistance > SCREEN_HEIGHT * DISMISS_FRACTION) {
403
+ runOnJS(handleDismiss)();
404
+ } else {
405
+ scale.value = withSpring(1, SNAP_BACK_SPRING);
406
+ translateX.value = withSpring(0, SNAP_BACK_SPRING);
407
+ translateY.value = withSpring(0, SNAP_BACK_SPRING);
408
+ opacity.value = withTiming(1, { duration: OPACITY_DURATION });
409
+ }
410
+ }),
411
+ [handleDismiss, isOpen, SCREEN_HEIGHT, opacity, scale, startScale, startX, startY, translateX, translateY]
412
+ );
413
+
414
+ const backdropStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
415
+
416
+ // The single open-image animates from origin → fitted center.
417
+ const openImageStyle = useAnimatedStyle(() => ({
418
+ transform: [
419
+ { translateX: translateX.value },
420
+ { translateY: translateY.value },
421
+ { scale: scale.value },
422
+ ],
423
+ }));
424
+
425
+ // While dragging to dismiss, the whole pager follows the finger + fades.
426
+ const pagerContainerStyle = useAnimatedStyle(() => ({
427
+ opacity: opacity.value,
428
+ transform: [
429
+ { translateX: translateX.value },
430
+ { translateY: translateY.value },
431
+ { scale: scale.value },
432
+ ],
433
+ }));
434
+
435
+ // Derive the current page from the scroll offset, clamp into range, and update
436
+ // `activeIndex` only when it actually changes (drives the live indicator + the
437
+ // close fly-back target). Shared by `onMomentumScrollEnd` (native) and `onScroll`
438
+ // (web, where paging may not fire a reliable momentum-end).
439
+ const updateIndexFromOffset = useCallback(
440
+ (offsetX: number) => {
441
+ const lastIndex = images.length - 1;
442
+ if (lastIndex < 0) return;
443
+ const next = Math.min(Math.max(Math.round(offsetX / SCREEN_WIDTH), 0), lastIndex);
444
+ if (next === activeIndexRef.current) return;
445
+ setActiveIndexBoth(next);
446
+ const img = images[next];
447
+ if (img) ensureRatio(next, img.uri);
448
+ },
449
+ [ensureRatio, images, setActiveIndexBoth, SCREEN_WIDTH]
450
+ );
451
+
452
+ const onPagerScroll = useCallback(
453
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
454
+ updateIndexFromOffset(event.nativeEvent.contentOffset.x);
455
+ },
456
+ [updateIndexFromOffset]
457
+ );
458
+
459
+ // When the pager mounts, jump it to the open index without animation so the
460
+ // swap from the open-image to the pager is seamless.
461
+ const onPagerLayout = useCallback(() => {
462
+ const idx = pendingIndexRef.current;
463
+ if (idx > 0) {
464
+ pagerRef.current?.scrollTo({ x: idx * SCREEN_WIDTH, animated: false });
465
+ }
466
+ }, [SCREEN_WIDTH]);
467
+
468
+ const renderContent = () => (
469
+ <GestureHandlerRootView style={styles.modalContainer}>
470
+ <Pressable style={StyleSheet.absoluteFill} onPress={handleDismiss} hitSlop={0}>
471
+ <AnimatedBlurView
472
+ intensity={80}
473
+ tint={theme.isDark ? 'dark' : 'light'}
474
+ experimentalBlurMethod="dimezisBlurView"
475
+ style={[StyleSheet.absoluteFill, backdropStyle]}
476
+ >
477
+ <Animated.View
478
+ style={[StyleSheet.absoluteFill, { backgroundColor: theme.colors.overlay }, backdropStyle]}
479
+ />
480
+ </AnimatedBlurView>
481
+ </Pressable>
482
+
483
+ <GestureDetector gesture={panGesture}>
484
+ <Animated.View
485
+ style={[
486
+ StyleSheet.absoluteFill,
487
+ styles.zoomContainer,
488
+ { width: SCREEN_WIDTH, height: SCREEN_HEIGHT },
489
+ webUserSelectNoneStyle,
490
+ ]}
491
+ pointerEvents="box-none"
492
+ >
493
+ {!pagerReady && (
494
+ <Pressable onPress={handleDismiss} style={webPointerStyle}>
495
+ <AnimatedImage
496
+ source={{ uri: images[activeIndex]?.uri }}
497
+ contentFit="contain"
498
+ style={[
499
+ { width: activeFit.width, height: activeFit.height, borderRadius: cornerRadius },
500
+ openImageStyle,
501
+ ]}
502
+ transition={0}
503
+ {...(Platform.OS === 'web' ? { draggable: false } : {})}
504
+ />
505
+ </Pressable>
506
+ )}
507
+
508
+ {pagerReady && (
509
+ <Animated.View style={[StyleSheet.absoluteFill, pagerContainerStyle]}>
510
+ <ScrollView
511
+ ref={pagerRef}
512
+ horizontal
513
+ pagingEnabled
514
+ showsHorizontalScrollIndicator={false}
515
+ contentOffset={{ x: pendingIndexRef.current * SCREEN_WIDTH, y: 0 }}
516
+ onLayout={onPagerLayout}
517
+ onMomentumScrollEnd={onPagerScroll}
518
+ {...(Platform.OS === 'web' ? { onScroll: onPagerScroll } : {})}
519
+ scrollEventThrottle={16}
520
+ style={StyleSheet.absoluteFill}
521
+ >
522
+ {images.map((img, idx) => {
523
+ const ratio = pageRatios[idx] ?? getAspectRatio(img.uri) ?? DEFAULT_ASPECT_RATIO;
524
+ const fit = fitForRatio(ratio);
525
+ return (
526
+ <Pressable
527
+ key={`${img.uri}-${idx}`}
528
+ onPress={handleDismiss}
529
+ style={[styles.page, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT }, webPointerStyle]}
530
+ >
531
+ <Image
532
+ source={{ uri: img.uri }}
533
+ contentFit="contain"
534
+ style={{ width: fit.width, height: fit.height, borderRadius: cornerRadius }}
535
+ transition={0}
536
+ {...(Platform.OS === 'web' ? { draggable: false } : {})}
537
+ />
538
+ </Pressable>
539
+ );
540
+ })}
541
+ </ScrollView>
542
+ </Animated.View>
543
+ )}
544
+
545
+ {pagerReady && images.length > 1 && (
546
+ <Animated.View style={[styles.indicatorWrap, backdropStyle]} pointerEvents="none">
547
+ <View style={styles.counterPill}>
548
+ <Text style={styles.counterText}>{`${activeIndex + 1} / ${images.length}`}</Text>
549
+ </View>
550
+ <View style={styles.dotsRow}>
551
+ {images.map((img, idx) => (
552
+ <View
553
+ key={`dot-${img.uri}-${idx}`}
554
+ style={[styles.dot, idx === activeIndex ? styles.dotActive : styles.dotInactive]}
555
+ />
556
+ ))}
557
+ </View>
558
+ </Animated.View>
559
+ )}
560
+
561
+ {activeAlt ? (
562
+ <Animated.View
563
+ style={[
564
+ styles.altCaptionWrap,
565
+ images.length > 1 && styles.altCaptionWrapWithIndicator,
566
+ backdropStyle,
567
+ ]}
568
+ pointerEvents="none"
569
+ >
570
+ <View style={styles.altCaptionPill}>
571
+ <Text style={styles.altCaptionText} numberOfLines={4}>{activeAlt}</Text>
572
+ </View>
573
+ </Animated.View>
574
+ ) : null}
575
+ </Animated.View>
576
+ </GestureDetector>
577
+ </GestureHandlerRootView>
578
+ );
579
+
580
+ if (!isOpen) return null;
581
+
582
+ // Both platforms render through the Bloom `Portal` so the viewer overlays the
583
+ // whole app from the root `Outlet`. RN's `Modal` is intentionally NOT used on
584
+ // native: on the New Architecture (Fabric) Android its host views mount
585
+ // full-screen in the tree but never composite to the screen, leaving the
586
+ // entire viewer (blur backdrop + zoomed image) invisible — tapping appeared to
587
+ // "do nothing". The Portal path is the same one the working web build uses.
588
+ return <Portal>{renderContent()}</Portal>;
589
+ });
590
+
591
+ ZoomableImageGalleryInner.displayName = 'ZoomableImageGallery';
592
+
593
+ export const ZoomableImageGallery = ZoomableImageGalleryInner;
594
+
595
+ const styles = StyleSheet.create({
596
+ modalContainer: {
597
+ justifyContent: 'center',
598
+ alignItems: 'center',
599
+ ...Platform.select({
600
+ web: {
601
+ // Full-screen fixed overlay (web). Mirrors Bloom's Portal/Dialog.web
602
+ // `position: 'fixed' as 'absolute'` pattern; the fullscreen z-index
603
+ // token keeps the viewer above the app chrome.
604
+ position: 'fixed' as 'absolute',
605
+ top: 0,
606
+ left: 0,
607
+ right: 0,
608
+ bottom: 0,
609
+ zIndex: Z_INDEX.fullscreen,
610
+ },
611
+ default: {
612
+ // Mounted through the Bloom Portal `Outlet` (which renders its content
613
+ // inline at the app root on native, NOT wrapped in a full-screen view),
614
+ // so the gallery root must absolutely fill the screen itself to overlay
615
+ // the app. RN's `Modal` is not used on native: on the New Architecture
616
+ // (Fabric) Android its host views mount full-screen but never paint
617
+ // (the entire viewer — backdrop + image — stays invisible).
618
+ position: 'absolute',
619
+ top: 0,
620
+ left: 0,
621
+ right: 0,
622
+ bottom: 0,
623
+ backgroundColor: 'transparent',
624
+ },
625
+ }),
626
+ },
627
+ zoomContainer: {
628
+ justifyContent: 'center',
629
+ alignItems: 'center',
630
+ },
631
+ page: {
632
+ justifyContent: 'center',
633
+ alignItems: 'center',
634
+ },
635
+ indicatorWrap: {
636
+ position: 'absolute',
637
+ bottom: 48,
638
+ left: 0,
639
+ right: 0,
640
+ alignItems: 'center',
641
+ gap: 10,
642
+ },
643
+ counterPill: {
644
+ paddingHorizontal: 12,
645
+ paddingVertical: 4,
646
+ borderRadius: 999,
647
+ backgroundColor: 'rgba(0,0,0,0.55)',
648
+ },
649
+ counterText: {
650
+ color: '#fff',
651
+ fontSize: 13,
652
+ fontWeight: '600',
653
+ },
654
+ dotsRow: {
655
+ flexDirection: 'row',
656
+ gap: 6,
657
+ },
658
+ dot: {
659
+ width: 7,
660
+ height: 7,
661
+ borderRadius: 999,
662
+ },
663
+ dotActive: {
664
+ backgroundColor: '#fff',
665
+ },
666
+ dotInactive: {
667
+ backgroundColor: 'rgba(255,255,255,0.4)',
668
+ },
669
+ altCaptionWrap: {
670
+ position: 'absolute',
671
+ bottom: 48,
672
+ left: 0,
673
+ right: 0,
674
+ alignItems: 'center',
675
+ paddingHorizontal: 24,
676
+ },
677
+ // Lifted above the page counter + dots when the multi-image indicator shows.
678
+ altCaptionWrapWithIndicator: {
679
+ bottom: 110,
680
+ },
681
+ altCaptionPill: {
682
+ maxWidth: 600,
683
+ paddingHorizontal: 14,
684
+ paddingVertical: 8,
685
+ borderRadius: 12,
686
+ backgroundColor: 'rgba(0,0,0,0.6)',
687
+ },
688
+ altCaptionText: {
689
+ color: '#fff',
690
+ fontSize: 13,
691
+ lineHeight: 18,
692
+ },
693
+ });
694
+
695
+ export default ZoomableImageGallery;