@astratra/native-ui 0.1.1 → 1.0.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.
@@ -0,0 +1,609 @@
1
+ /**
2
+ * Pictures full screen, the way Photos shows them: black around the picture,
3
+ * round glass controls that a tap hides and shows again.
4
+ *
5
+ * - Swipe sideways from one picture to the next.
6
+ * - Pinch to zoom, or double-tap where you want to look closer; double-tap
7
+ * again to come back. While zoomed, the finger moves around the picture and
8
+ * the pages stay put.
9
+ * - Pull the picture down to close: the black fades as it goes.
10
+ * - At the top: close, where you are ("3 of 12", the app's words), and the
11
+ * details when the app gives some.
12
+ * - At the bottom: the title, then what can be done — share (the app's hook),
13
+ * and the app's own actions (save, remove…). An action may answer with a
14
+ * short notice ("Saved"), shown a moment over the picture.
15
+ *
16
+ * Zoom is native on iOS (a zooming ScrollView: its bounce, its deceleration).
17
+ * Android's ScrollView does not zoom — the first version simply had no pinch
18
+ * there — so Android gets a pinch of its own: two fingers scale around the
19
+ * point between them, one finger moves the zoomed picture, and on release it
20
+ * settles back inside its bounds (logic/picture.js).
21
+ *
22
+ * Nothing here is worded or fetched: labels, icons, actions and how a picture
23
+ * becomes a source (`resolveSource`, typically @astratra/native's
24
+ * `createPictureSources().resolve`, which alone decides whether a token goes
25
+ * with it) all come from the app. "Reduce Motion": no fades, no springs, no
26
+ * shrinking pull; the viewer opens and closes at once.
27
+ */
28
+ const { React, h, RN, Reanimated, AnimatedView, useReducedMotion } = require('./runtime');
29
+ const { GlassButton } = require('./GlassButton');
30
+ const { GlassSurface } = require('./GlassSurface');
31
+ const {
32
+ VIEWER_GESTURES,
33
+ clampIndex,
34
+ clampPan,
35
+ isDoubleTap,
36
+ isPullToClose,
37
+ pageFromOffset,
38
+ pinchTransform,
39
+ pullEffect,
40
+ settleTransform,
41
+ shouldClose,
42
+ touchCentre,
43
+ touchDistance,
44
+ zoomAt,
45
+ zoomRect
46
+ } = require('../logic/picture');
47
+
48
+ const { Easing, runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming } = Reanimated;
49
+
50
+ /* Always drawn on black: the viewer keeps its own colours whatever the app's
51
+ theme, as Photos does. */
52
+ const BLACK = '#000000';
53
+ const INK = '#ffffff';
54
+ const SOFT = 'rgba(255,255,255,0.72)';
55
+ const PULL_BACK = { damping: 20, stiffness: 260, mass: 1 };
56
+ const GAP = 12;
57
+ const GUTTER = 16;
58
+
59
+ function haptic(onHaptic, kind) {
60
+ if (typeof onHaptic === 'function') onHaptic(kind);
61
+ }
62
+
63
+ /** Taps told apart: a single tap waits out the double-tap window before counting. */
64
+ function useTaps(onSingle, onDouble) {
65
+ const last = React.useRef(0);
66
+ const timer = React.useRef(null);
67
+ const handlers = React.useRef({ onSingle, onDouble });
68
+ handlers.current = { onSingle, onDouble };
69
+ React.useEffect(() => () => clearTimeout(timer.current), []);
70
+ return React.useCallback((point) => {
71
+ const now = Date.now();
72
+ clearTimeout(timer.current);
73
+ timer.current = null;
74
+ if (isDoubleTap(last.current, now)) {
75
+ last.current = 0;
76
+ handlers.current.onDouble(point);
77
+ return;
78
+ }
79
+ last.current = now;
80
+ timer.current = setTimeout(() => {
81
+ timer.current = null;
82
+ handlers.current.onSingle();
83
+ }, VIEWER_GESTURES.doubleTapMs);
84
+ }, []);
85
+ }
86
+
87
+ /** The source of one picture, resolved by the app; a failure is a state, not a spinner forever. */
88
+ function usePictureSource(picture, resolveSource) {
89
+ const [state, setState] = React.useState({ source: null, failed: false });
90
+ const resolver = React.useRef(resolveSource);
91
+ resolver.current = resolveSource;
92
+ React.useEffect(() => {
93
+ let live = true;
94
+ setState({ source: null, failed: false });
95
+ Promise.resolve()
96
+ .then(() => (typeof resolver.current === 'function' ? resolver.current(picture) : picture.source))
97
+ .then(
98
+ (source) => {
99
+ if (!live) return;
100
+ setState(source && typeof source.uri === 'string' && source.uri ? { source, failed: false } : { source: null, failed: true });
101
+ },
102
+ () => live && setState({ source: null, failed: true })
103
+ );
104
+ return () => {
105
+ live = false;
106
+ };
107
+ /* A picture is its key: a new object for the same key is the same picture. */
108
+ }, [picture.key]);
109
+ const fail = React.useCallback(() => setState({ source: null, failed: true }), []);
110
+ return { ...state, fail };
111
+ }
112
+
113
+ /** One page: the picture, its zoom, its taps. */
114
+ function Page({ picture, width, height, resolveSource, ImageComponent, imageProps, failedIcon, onSingleTap, onZoomChange, reduceMotion, onHaptic, zoomHint }) {
115
+ const { source, failed, fail } = usePictureSource(picture, resolveSource);
116
+ const frame = { width, height };
117
+ const android = RN.Platform.OS === 'android';
118
+ const zoomed = React.useRef(false);
119
+ /* The gesture handlers are made once per frame size: they read the props
120
+ of the moment through this ref, never those of their creation. */
121
+ const live = React.useRef({ reduceMotion, onZoomChange });
122
+ live.current = { reduceMotion, onZoomChange };
123
+ const setZoomed = (next) => {
124
+ if (next === zoomed.current) return;
125
+ zoomed.current = next;
126
+ live.current.onZoomChange(next);
127
+ };
128
+
129
+ /* iOS: the ScrollView zooms. */
130
+ const scroll = React.useRef(null);
131
+ /* Android: a transform of our own, driven from the gesture. */
132
+ const scale = useSharedValue(1);
133
+ const tx = useSharedValue(0);
134
+ const ty = useSharedValue(0);
135
+ const current = React.useRef({ scale: 1, x: 0, y: 0 });
136
+
137
+ const moveTo = (next, animate) => {
138
+ current.current = next;
139
+ const glide = animate && !live.current.reduceMotion;
140
+ const timing = { duration: 240, easing: Easing.out(Easing.cubic) };
141
+ scale.value = glide ? withTiming(next.scale, timing) : next.scale;
142
+ tx.value = glide ? withTiming(next.x, timing) : next.x;
143
+ ty.value = glide ? withTiming(next.y, timing) : next.y;
144
+ setZoomed(next.scale > 1.01);
145
+ };
146
+
147
+ const onDouble = (point) => {
148
+ haptic(onHaptic, 'selection');
149
+ if (android) {
150
+ moveTo(zoomed.current ? { scale: 1, x: 0, y: 0 } : zoomAt(point, frame, VIEWER_GESTURES.zoomIn), true);
151
+ return;
152
+ }
153
+ const target = zoomed.current ? { x: 0, y: 0, width, height } : zoomRect(point, frame, VIEWER_GESTURES.zoomIn);
154
+ if (scroll.current) scroll.current.scrollResponderZoomTo({ ...target, animated: !live.current.reduceMotion });
155
+ };
156
+ const tap = useTaps(onSingleTap, onDouble);
157
+
158
+ const gesture = React.useRef({ start: null, pinch: null, moved: false });
159
+ const responder = React.useMemo(
160
+ () =>
161
+ android
162
+ ? RN.PanResponder.create({
163
+ onStartShouldSetPanResponder: () => true,
164
+ onMoveShouldSetPanResponder: (event) => zoomed.current || (event.nativeEvent.touches || []).length >= 2,
165
+ onPanResponderGrant: () => {
166
+ gesture.current = { start: { ...current.current }, pinch: null, moved: false };
167
+ },
168
+ onPanResponderMove: (event, move) => {
169
+ const touches = event.nativeEvent.touches || [];
170
+ const state = gesture.current;
171
+ if (Math.abs(move.dx) > VIEWER_GESTURES.tapSlop || Math.abs(move.dy) > VIEWER_GESTURES.tapSlop) state.moved = true;
172
+ if (touches.length >= 2) {
173
+ state.moved = true;
174
+ if (!state.pinch) {
175
+ state.pinch = { ...current.current, distance: touchDistance(touches), focus: touchCentre(touches) };
176
+ }
177
+ moveTo(pinchTransform(state.pinch, touchDistance(touches), touchCentre(touches), frame), false);
178
+ } else if (current.current.scale > 1.01 && !state.pinch) {
179
+ const pan = clampPan({ x: state.start.x + move.dx, y: state.start.y + move.dy }, current.current.scale, frame);
180
+ moveTo({ scale: current.current.scale, x: pan.x, y: pan.y }, false);
181
+ }
182
+ },
183
+ onPanResponderRelease: (event) => {
184
+ const state = gesture.current;
185
+ if (!state.moved) {
186
+ tap({ x: event.nativeEvent.locationX, y: event.nativeEvent.locationY });
187
+ return;
188
+ }
189
+ moveTo(settleTransform(current.current, frame), true);
190
+ },
191
+ /* Not zoomed, the list may take a sideways swipe; zoomed, the finger is ours. */
192
+ onPanResponderTerminationRequest: () => current.current.scale <= 1.01,
193
+ onPanResponderTerminate: () => moveTo(settleTransform(current.current, frame), true)
194
+ })
195
+ : null,
196
+ // The frame is read through `frame` at each gesture; a rotation re-creates the page.
197
+ [android, width, height]
198
+ );
199
+
200
+ const transform = useAnimatedStyle(() => ({
201
+ transform: [{ translateX: tx.value }, { translateY: ty.value }, { scale: scale.value }]
202
+ }));
203
+
204
+ const label = picture.accessibilityLabel || picture.title;
205
+ const body = failed
206
+ ? failedIcon || null
207
+ : source
208
+ ? h(ImageComponent || RN.Image, {
209
+ ...(ImageComponent && ImageComponent !== RN.Image ? { contentFit: 'contain' } : { resizeMode: 'contain' }),
210
+ ...(imageProps || {}),
211
+ source,
212
+ style: { width, height },
213
+ onError: fail,
214
+ accessibilityIgnoresInvertColors: true
215
+ })
216
+ : h(RN.ActivityIndicator, { color: SOFT });
217
+
218
+ const a11y = { accessible: true, accessibilityRole: 'image', accessibilityLabel: label, accessibilityHint: zoomHint };
219
+
220
+ if (android) {
221
+ return h(
222
+ RN.View,
223
+ { style: { width, height, overflow: 'hidden' }, testID: `page-${picture.key}` },
224
+ h(
225
+ AnimatedView,
226
+ { ...responder.panHandlers, ...a11y, style: [{ width, height, alignItems: 'center', justifyContent: 'center' }, transform] },
227
+ body
228
+ )
229
+ );
230
+ }
231
+
232
+ return h(
233
+ RN.ScrollView,
234
+ {
235
+ ref: scroll,
236
+ testID: `page-${picture.key}`,
237
+ style: { width, height },
238
+ contentContainerStyle: { width, height },
239
+ maximumZoomScale: VIEWER_GESTURES.maxZoom,
240
+ minimumZoomScale: 1,
241
+ bouncesZoom: !reduceMotion,
242
+ centerContent: true,
243
+ scrollEventThrottle: 32,
244
+ onScroll: (event) => setZoomed((event.nativeEvent.zoomScale || 1) > 1.01),
245
+ showsHorizontalScrollIndicator: false,
246
+ showsVerticalScrollIndicator: false
247
+ },
248
+ h(
249
+ RN.Pressable,
250
+ {
251
+ ...a11y,
252
+ onPress: (event) => tap({ x: event.nativeEvent.locationX, y: event.nativeEvent.locationY }),
253
+ style: { width, height, alignItems: 'center', justifyContent: 'center' }
254
+ },
255
+ body
256
+ )
257
+ );
258
+ }
259
+
260
+ /** A round glass control on black. */
261
+ function Control({ icon, label, onPress, busy, disabled, testID }) {
262
+ return h(
263
+ GlassButton,
264
+ { accessibilityLabel: label, onPress, size: 44, scheme: 'dark', disabled: Boolean(busy || disabled), testID },
265
+ busy ? h(RN.ActivityIndicator, { color: INK }) : icon
266
+ );
267
+ }
268
+
269
+ function ImageViewer({
270
+ pictures,
271
+ start,
272
+ onClose,
273
+ onIndexChange,
274
+ resolveSource,
275
+ ImageComponent,
276
+ imageProps,
277
+ labels = {},
278
+ icons = {},
279
+ onShare,
280
+ actions,
281
+ onActionError,
282
+ renderDetails,
283
+ insets,
284
+ onHaptic,
285
+ testID
286
+ }) {
287
+ const list = Array.isArray(pictures) ? pictures : [];
288
+ const { width, height } = RN.useWindowDimensions();
289
+ const reduceMotion = useReducedMotion();
290
+ const open = start !== null && start !== undefined && list.length > 0;
291
+ const top = (insets && insets.top) || 0;
292
+ const bottom = (insets && insets.bottom) || 0;
293
+
294
+ const [index, setIndex] = React.useState(0);
295
+ const [chrome, setChrome] = React.useState(true);
296
+ const [details, setDetails] = React.useState(false);
297
+ const [zoomed, setZoomed] = React.useState(false);
298
+ const [busy, setBusy] = React.useState(null);
299
+ const [notice, setNotice] = React.useState(null);
300
+ const pull = useSharedValue(0);
301
+ const shown = useSharedValue(1);
302
+ const listRef = React.useRef(null);
303
+ const mounted = React.useRef(true);
304
+ React.useEffect(
305
+ () => () => {
306
+ mounted.current = false;
307
+ },
308
+ []
309
+ );
310
+
311
+ const at = Math.min(index, Math.max(0, list.length - 1));
312
+ const picture = open ? list[at] : null;
313
+
314
+ /* Opened on a picture: that one, controls shown, nothing pulled. */
315
+ React.useEffect(() => {
316
+ if (start === null || start === undefined) return;
317
+ setIndex(clampIndex(start, list.length) ?? 0);
318
+ setChrome(true);
319
+ setDetails(false);
320
+ setZoomed(false);
321
+ setNotice(null);
322
+ pull.value = 0;
323
+ // Only a new opening resets; the list changing under an open viewer does not.
324
+ }, [start]);
325
+
326
+ React.useEffect(() => {
327
+ shown.value = reduceMotion ? (chrome ? 1 : 0) : withTiming(chrome ? 1 : 0, { duration: VIEWER_GESTURES.chromeFadeMs });
328
+ }, [chrome, reduceMotion, shown]);
329
+
330
+ React.useEffect(() => {
331
+ if (!notice) return undefined;
332
+ if (RN.AccessibilityInfo && typeof RN.AccessibilityInfo.announceForAccessibility === 'function') {
333
+ RN.AccessibilityInfo.announceForAccessibility(notice);
334
+ }
335
+ const timer = setTimeout(() => setNotice(null), VIEWER_GESTURES.noticeMs);
336
+ return () => clearTimeout(timer);
337
+ }, [notice]);
338
+
339
+ /* The last picture removed while open: nothing left to show. */
340
+ const wasOpen = start !== null && start !== undefined;
341
+ React.useEffect(() => {
342
+ if (wasOpen && list.length === 0 && typeof onClose === 'function') onClose();
343
+ }, [wasOpen, list.length, onClose]);
344
+
345
+ /* A rotation changes the page width: the list is put back on the same picture. */
346
+ const lastWidth = React.useRef(width);
347
+ React.useEffect(() => {
348
+ if (lastWidth.current === width) return;
349
+ lastWidth.current = width;
350
+ if (open && listRef.current && typeof listRef.current.scrollToIndex === 'function') {
351
+ listRef.current.scrollToIndex({ index: at, animated: false });
352
+ }
353
+ }, [width, open, at]);
354
+
355
+ const latest = React.useRef({ zoomed, onClose, reduceMotion, height, onHaptic });
356
+ latest.current = { zoomed, onClose, reduceMotion, height, onHaptic };
357
+
358
+ const close = React.useCallback(() => {
359
+ if (typeof latest.current.onClose === 'function') latest.current.onClose();
360
+ }, []);
361
+
362
+ const dismiss = React.useMemo(
363
+ () =>
364
+ RN.PanResponder.create({
365
+ /* One finger, downward, not zoomed: a pull. Two fingers are a pinch. */
366
+ onMoveShouldSetPanResponderCapture: (event, gesture) => {
367
+ const touches = (event && event.nativeEvent && event.nativeEvent.touches) || [];
368
+ return touches.length < 2 && isPullToClose(gesture, latest.current.zoomed);
369
+ },
370
+ onPanResponderMove: (_, gesture) => {
371
+ pull.value = Math.max(0, gesture.dy);
372
+ },
373
+ onPanResponderRelease: (_, gesture) => {
374
+ const { reduceMotion: still, height: screen } = latest.current;
375
+ if (shouldClose(gesture)) {
376
+ haptic(latest.current.onHaptic, 'impact');
377
+ if (still) {
378
+ pull.value = 0;
379
+ close();
380
+ return;
381
+ }
382
+ pull.value = withTiming(screen, { duration: VIEWER_GESTURES.closeMs }, (finished) => {
383
+ if (finished) runOnJS(close)();
384
+ });
385
+ } else {
386
+ pull.value = still ? 0 : withSpring(0, PULL_BACK);
387
+ }
388
+ },
389
+ onPanResponderTerminate: () => {
390
+ pull.value = latest.current.reduceMotion ? 0 : withSpring(0, PULL_BACK);
391
+ }
392
+ }),
393
+ [pull, close]
394
+ );
395
+
396
+ const backdrop = useAnimatedStyle(() => ({ opacity: pullEffect(pull.value, height, reduceMotion).backdrop }));
397
+ const carried = useAnimatedStyle(() => ({
398
+ transform: [{ translateY: pull.value }, { scale: pullEffect(pull.value, height, reduceMotion).scale }]
399
+ }));
400
+ const chromeStyle = useAnimatedStyle(() => ({ opacity: shown.value * pullEffect(pull.value, height, reduceMotion).backdrop }));
401
+
402
+ const run = async (key, handler) => {
403
+ if (!picture || busy) return;
404
+ setBusy(key);
405
+ try {
406
+ const result = await handler(picture);
407
+ if (typeof result === 'string' && result) {
408
+ haptic(onHaptic, 'success');
409
+ if (mounted.current) setNotice(result);
410
+ }
411
+ } catch (error) {
412
+ haptic(onHaptic, 'failure');
413
+ if (typeof onActionError === 'function') onActionError(error, key, picture);
414
+ } finally {
415
+ if (mounted.current) setBusy(null);
416
+ }
417
+ };
418
+
419
+ const allActions = [
420
+ ...(typeof onShare === 'function' && icons.share ? [{ key: 'share', label: labels.share, icon: icons.share, onPress: onShare }] : []),
421
+ ...(Array.isArray(actions) ? actions.filter((action) => action && action.key && typeof action.onPress === 'function') : [])
422
+ ];
423
+ const canDetail = typeof renderDetails === 'function' && icons.details;
424
+ const hiddenFromReaders = chrome ? {} : { accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' };
425
+
426
+ return h(
427
+ RN.Modal,
428
+ {
429
+ visible: open,
430
+ transparent: true,
431
+ animationType: reduceMotion ? 'none' : 'fade',
432
+ onRequestClose: onClose,
433
+ statusBarTranslucent: true,
434
+ supportedOrientations: ['portrait', 'landscape']
435
+ },
436
+ h(RN.StatusBar, { hidden: open && !chrome, barStyle: 'light-content', animated: !reduceMotion }),
437
+ h(
438
+ RN.View,
439
+ { testID, style: { flex: 1 }, accessibilityViewIsModal: true, onAccessibilityEscape: onClose },
440
+ h(AnimatedView, { pointerEvents: 'none', style: [RN.StyleSheet.absoluteFill, { backgroundColor: BLACK }, backdrop] }),
441
+ h(
442
+ AnimatedView,
443
+ { ...dismiss.panHandlers, style: [{ flex: 1 }, carried] },
444
+ open
445
+ ? h(RN.FlatList, {
446
+ ref: listRef,
447
+ data: list,
448
+ keyExtractor: (item) => String(item.key),
449
+ horizontal: true,
450
+ pagingEnabled: true,
451
+ scrollEnabled: !zoomed,
452
+ initialScrollIndex: clampIndex(start, list.length) ?? 0,
453
+ getItemLayout: (_, position) => ({ length: width, offset: width * position, index: position }),
454
+ showsHorizontalScrollIndicator: false,
455
+ onMomentumScrollEnd: (event) => {
456
+ const next = pageFromOffset(event.nativeEvent.contentOffset.x, width, list.length);
457
+ if (next === at) return;
458
+ setIndex(next);
459
+ setDetails(false);
460
+ haptic(onHaptic, 'selection');
461
+ if (typeof onIndexChange === 'function') onIndexChange(next);
462
+ },
463
+ renderItem: ({ item }) =>
464
+ h(Page, {
465
+ picture: item,
466
+ width,
467
+ height,
468
+ resolveSource,
469
+ ImageComponent,
470
+ imageProps,
471
+ failedIcon: icons.failed,
472
+ zoomHint: labels.zoomHint,
473
+ reduceMotion,
474
+ onHaptic,
475
+ onZoomChange: setZoomed,
476
+ onSingleTap: () => {
477
+ setChrome((visible) => !visible);
478
+ setDetails(false);
479
+ }
480
+ })
481
+ })
482
+ : null
483
+ ),
484
+ picture
485
+ ? [
486
+ /* The top: close, where you are, the details. */
487
+ h(
488
+ AnimatedView,
489
+ {
490
+ key: 'top',
491
+ ...hiddenFromReaders,
492
+ pointerEvents: chrome ? 'box-none' : 'none',
493
+ style: [
494
+ { position: 'absolute', top: top + 8, left: GUTTER, right: GUTTER, flexDirection: 'row', alignItems: 'center' },
495
+ chromeStyle
496
+ ]
497
+ },
498
+ h(Control, { icon: icons.close, label: labels.close, onPress: onClose, testID: 'viewer-close' }),
499
+ h(
500
+ RN.View,
501
+ { style: { flex: 1, alignItems: 'center' } },
502
+ list.length > 1 && typeof labels.counter === 'function'
503
+ ? h(
504
+ GlassSurface,
505
+ { scheme: 'dark', style: { borderRadius: 999 } },
506
+ h(
507
+ RN.Text,
508
+ {
509
+ style: {
510
+ color: INK,
511
+ fontSize: 13,
512
+ fontWeight: '600',
513
+ fontVariant: ['tabular-nums'],
514
+ paddingHorizontal: 12,
515
+ paddingVertical: 6
516
+ }
517
+ },
518
+ labels.counter(at + 1, list.length)
519
+ )
520
+ )
521
+ : null
522
+ ),
523
+ canDetail
524
+ ? h(Control, {
525
+ icon: details && icons.detailsActive ? icons.detailsActive : icons.details,
526
+ label: labels.details,
527
+ onPress: () => setDetails((visible) => !visible),
528
+ testID: 'viewer-details'
529
+ })
530
+ : h(RN.View, { style: { width: 44 } })
531
+ ),
532
+ /* The bottom: the details when asked, else the title; then what can be done. */
533
+ h(
534
+ AnimatedView,
535
+ {
536
+ key: 'bottom',
537
+ ...hiddenFromReaders,
538
+ pointerEvents: chrome ? 'box-none' : 'none',
539
+ style: [{ position: 'absolute', left: GUTTER, right: GUTTER, bottom: bottom + GAP, gap: GAP }, chromeStyle]
540
+ },
541
+ details && canDetail
542
+ ? h(
543
+ GlassSurface,
544
+ { scheme: 'dark', style: { borderRadius: 24 } },
545
+ h(RN.ScrollView, { style: { maxHeight: height * 0.3 }, contentContainerStyle: { padding: GUTTER, gap: 8 } }, renderDetails(picture))
546
+ )
547
+ : picture.title
548
+ ? h(
549
+ RN.Text,
550
+ {
551
+ numberOfLines: 2,
552
+ style: {
553
+ color: INK,
554
+ fontSize: 17,
555
+ fontWeight: '600',
556
+ textAlign: 'center',
557
+ textShadowColor: 'rgba(0,0,0,0.6)',
558
+ textShadowRadius: 8
559
+ }
560
+ },
561
+ picture.title
562
+ )
563
+ : null,
564
+ allActions.length
565
+ ? h(
566
+ RN.View,
567
+ { style: { flexDirection: 'row', justifyContent: 'center', gap: GUTTER } },
568
+ allActions.map((action) =>
569
+ h(Control, {
570
+ key: action.key,
571
+ icon: action.icon,
572
+ label: action.label,
573
+ busy: busy === action.key,
574
+ disabled: busy !== null && busy !== action.key,
575
+ onPress: () => void run(action.key, action.onPress),
576
+ testID: `viewer-action-${action.key}`
577
+ })
578
+ )
579
+ )
580
+ : null
581
+ ),
582
+ notice
583
+ ? h(
584
+ RN.View,
585
+ {
586
+ key: 'notice',
587
+ pointerEvents: 'none',
588
+ accessibilityLiveRegion: 'polite',
589
+ style: { position: 'absolute', top: height / 2 - 40, left: 0, right: 0, alignItems: 'center' }
590
+ },
591
+ h(
592
+ GlassSurface,
593
+ { scheme: 'dark', style: { borderRadius: 24 } },
594
+ h(
595
+ RN.View,
596
+ { style: { alignItems: 'center', gap: 8, paddingHorizontal: 24, paddingVertical: GUTTER } },
597
+ icons.notice || null,
598
+ h(RN.Text, { style: { color: INK, fontSize: 15, fontWeight: '600' } }, notice)
599
+ )
600
+ )
601
+ )
602
+ : null
603
+ ]
604
+ : null
605
+ )
606
+ );
607
+ }
608
+
609
+ module.exports = { ImageViewer };