@oxyhq/services 22.8.3 → 22.8.5

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 (32) hide show
  1. package/lib/commonjs/ui/components/BottomSheetRouter.js +68 -23
  2. package/lib/commonjs/ui/components/BottomSheetRouter.js.map +1 -1
  3. package/lib/commonjs/ui/navigation/routes.js +7 -4
  4. package/lib/commonjs/ui/navigation/routes.js.map +1 -1
  5. package/lib/commonjs/ui/screens/fileManagement/FileListSection.js +3 -0
  6. package/lib/commonjs/ui/screens/fileManagement/FileListSection.js.map +1 -1
  7. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js +111 -195
  8. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  9. package/lib/module/ui/components/BottomSheetRouter.js +69 -24
  10. package/lib/module/ui/components/BottomSheetRouter.js.map +1 -1
  11. package/lib/module/ui/navigation/routes.js +7 -4
  12. package/lib/module/ui/navigation/routes.js.map +1 -1
  13. package/lib/module/ui/screens/fileManagement/FileListSection.js +3 -0
  14. package/lib/module/ui/screens/fileManagement/FileListSection.js.map +1 -1
  15. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js +112 -196
  16. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  17. package/lib/typescript/commonjs/ui/components/BottomSheetRouter.d.ts.map +1 -1
  18. package/lib/typescript/commonjs/ui/navigation/routes.d.ts +12 -0
  19. package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
  20. package/lib/typescript/commonjs/ui/screens/fileManagement/FileListSection.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  22. package/lib/typescript/module/ui/components/BottomSheetRouter.d.ts.map +1 -1
  23. package/lib/typescript/module/ui/navigation/routes.d.ts +12 -0
  24. package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
  25. package/lib/typescript/module/ui/screens/fileManagement/FileListSection.d.ts.map +1 -1
  26. package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/ui/components/BottomSheetRouter.tsx +81 -28
  29. package/src/ui/navigation/__tests__/getSheetConfig.test.ts +36 -0
  30. package/src/ui/navigation/routes.ts +19 -4
  31. package/src/ui/screens/fileManagement/FileListSection.tsx +1 -0
  32. package/src/ui/screens/fileManagement/PhotoPickerSection.tsx +127 -192
@@ -13,6 +13,7 @@ import {
13
13
  } from 'react-native';
14
14
  import { Image as ExpoImage } from 'expo-image';
15
15
  import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
16
+ import { BREAKPOINTS } from '@oxyhq/bloom/styles';
16
17
  import type { FileMetadata } from '@oxyhq/core';
17
18
  import { computePhotoGridLayout } from './photoGridLayout';
18
19
 
@@ -91,20 +92,18 @@ const hapticNotification = async (type: HapticNotification): Promise<void> => {
91
92
  };
92
93
 
93
94
  /**
94
- * The bottom sheet renders below the status bar already (its `maxHeight` is
95
- * capped by `SCREEN_HEIGHT - insets.top`), so the picker MUST NOT add an extra
96
- * safe-area inset to the header. Header zone from the sheet top:
97
- * 28dp drag-handle hit area floats at the very top of the sheet
98
- * 56dp app bar sits immediately below the handle
99
- * Grid content is pushed down by the full header zone + 4dp of breathing room
100
- * so the first row clears the translucent header. These are fixed layout
101
- * constants — the equivalent `pt-[28px]` / `min-h-[84px]` / `pt-[88px]` utility
102
- * classes in the JSX MUST stay in sync with them.
95
+ * The picker renders inside a Bloom `<Dialog>` (bottom-sheet on narrow, centered
96
+ * card on `md+`). The Dialog owns its own chrome: it draws its OWN drag handle
97
+ * in bottom placement (a floating 28dp pill that paints ABOVE this header via a
98
+ * higher z-index) and none in center placement so the picker's translucent
99
+ * header no longer reserves the handle strip itself. The header is just the 56dp
100
+ * app bar; grid content is pushed down by the app bar + 4dp of breathing room so
101
+ * the first row clears it. These are fixed layout constants — the equivalent
102
+ * `min-h-[56px]` / `pt-[60px]` utility classes in the JSX MUST stay in sync.
103
103
  */
104
- const HANDLE_ZONE = 28;
105
104
  const APP_BAR_HEIGHT = 56;
106
- const HEADER_HEIGHT = HANDLE_ZONE + APP_BAR_HEIGHT; // 84
107
- const GRID_CONTENT_PADDING_TOP = HEADER_HEIGHT + 4; // 88
105
+ const HEADER_HEIGHT = APP_BAR_HEIGHT; // 56
106
+ const GRID_CONTENT_PADDING_TOP = HEADER_HEIGHT + 4; // 60
108
107
 
109
108
  /**
110
109
  * Props for the dedicated photo picker view. Used by FileManagementScreen
@@ -138,8 +137,8 @@ export interface PhotoPickerViewProps {
138
137
  }
139
138
 
140
139
  /**
141
- * Photo-cell animation tuning. The animated cell uses RN's own `Animated`
142
- * (NOT Reanimated): its entrance is a staggered opacity fade — replacing the
140
+ * Photo-cell animation tuning. The cell uses RN's own `Animated` (NOT
141
+ * Reanimated): its entrance is a staggered opacity fade — replacing the
143
142
  * Reanimated `entering={FadeIn}` LAYOUT animation, which cannot load on the
144
143
  * RN-Web build (no `react-native-worklets` babel plugin) and only warns +
145
144
  * no-ops there. RN `Animated` runs cross-platform: native-driven on device,
@@ -155,69 +154,13 @@ const RING_PULSE_UP_MS = 110;
155
154
  const RING_SPRING_FRICTION = 9;
156
155
  const RING_SPRING_TENSION = 120;
157
156
  /**
158
- * Inline mirror of the `rounded-radius-8 border-[3px]` classes the static ring
159
- * carries. The animated ring MUST use inline `style` (its animated transform
160
- * has to be inline, and NativeWind `className` interop is not guaranteed on an
161
- * `Animated.View` under the RN-Web build), so these keep it pixel-identical to
162
- * the static ring.
157
+ * The selection ring is an `Animated.View` (its pulse transform must be inline,
158
+ * and `className` interop on `Animated.View` is not guaranteed under the RN-Web
159
+ * build), so these mirror the `rounded-radius-8 border-[3px]` design tokens.
163
160
  */
164
161
  const CELL_CORNER_RADIUS = 8; // `rounded-radius-8`
165
162
  const SELECTION_RING_WIDTH = 3; // `border-[3px]`
166
163
 
167
- /**
168
- * A single photo cell. Memoized so re-renders during selection only touch
169
- * affected cells — selection of one photo must not redraw the whole grid.
170
- *
171
- * Apple Photos pattern: when any cell is selected, *non-selected* siblings
172
- * fade to 0.6 opacity to focus attention on the active selection.
173
- */
174
- /**
175
- * Shared cell chrome (image, dim, badge). Ring border is injected by the
176
- * static vs animated wrappers.
177
- */
178
- function PhotoPickerCellContent(props: {
179
- photo: FileMetadata;
180
- dim: boolean;
181
- isSelected: boolean;
182
- selectionIndex: number;
183
- primaryColor: string;
184
- thumbUrl: string | undefined;
185
- ring: React.ReactNode;
186
- }) {
187
- const {
188
- photo, dim, isSelected, selectionIndex, primaryColor, thumbUrl, ring,
189
- } = props;
190
-
191
- return (
192
- <>
193
- <View
194
- className={`flex-1 rounded-radius-8 overflow-hidden bg-[#111111]${dim ? ' opacity-60' : ''}`}
195
- >
196
- <ExpoImage
197
- source={{ uri: thumbUrl }}
198
- style={{ width: '100%', height: '100%' }}
199
- contentFit="cover"
200
- transition={120}
201
- cachePolicy="memory-disk"
202
- accessibilityLabel={photo.filename}
203
- />
204
- </View>
205
- {ring}
206
- {isSelected && (
207
- <View
208
- pointerEvents="none"
209
- className="absolute top-1.5 right-1.5 min-w-[22px] h-[22px] px-1.5 rounded-full items-center justify-center"
210
- style={{ backgroundColor: primaryColor }}
211
- >
212
- <Text className="text-white text-[12px] font-bold leading-[14px]">
213
- {selectionIndex > 0 ? String(selectionIndex) : ''}
214
- </Text>
215
- </View>
216
- )}
217
- </>
218
- );
219
- }
220
-
221
164
  type PhotoPickerCellProps = {
222
165
  photo: FileMetadata;
223
166
  size: number;
@@ -235,65 +178,33 @@ type PhotoPickerCellProps = {
235
178
  a11yLabel: string;
236
179
  };
237
180
 
238
- const PhotoPickerCellStatic = React.memo(function PhotoPickerCellStatic(props: PhotoPickerCellProps) {
239
- const {
240
- photo, size, marginRight, marginBottom, isSelected, selectionIndex,
241
- dim, primaryColor, thumbUrl, onPress, onLongPress, a11yLabel,
242
- } = props;
243
-
244
- const cellWrapperStyle = {
245
- width: size,
246
- height: size,
247
- marginRight,
248
- marginBottom,
249
- };
250
-
251
- const ring = isSelected ? (
252
- <View
253
- pointerEvents="none"
254
- className="absolute inset-0 rounded-radius-8 border-[3px]"
255
- style={{ borderColor: primaryColor }}
256
- />
257
- ) : null;
258
-
259
- return (
260
- <TouchableOpacity
261
- activeOpacity={0.85}
262
- onPress={onPress}
263
- onLongPress={onLongPress}
264
- className="relative"
265
- style={cellWrapperStyle}
266
- accessibilityRole="button"
267
- accessibilityLabel={a11yLabel}
268
- accessibilityState={{ selected: isSelected }}
269
- >
270
- <PhotoPickerCellContent
271
- photo={photo}
272
- dim={dim}
273
- isSelected={isSelected}
274
- selectionIndex={selectionIndex}
275
- primaryColor={primaryColor}
276
- thumbUrl={thumbUrl}
277
- ring={ring}
278
- />
279
- </TouchableOpacity>
280
- );
281
- });
282
-
283
- const PhotoPickerCellAnimated = React.memo(function PhotoPickerCellAnimated(props: PhotoPickerCellProps) {
181
+ /**
182
+ * A single photo cell. Memoized so re-renders during selection only touch
183
+ * affected cells — selecting one photo must not redraw the whole grid.
184
+ *
185
+ * Apple Photos pattern: when any cell is selected, *non-selected* siblings fade
186
+ * to 0.6 opacity to focus attention on the active selection. A staggered
187
+ * entrance fade plays on mount; a spring pulse plays each time a cell enters the
188
+ * selected state. Both animations are pinned off under reduce-motion, so the
189
+ * one component covers both the animated and the reduced-motion paths.
190
+ */
191
+ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: PhotoPickerCellProps) {
284
192
  const {
285
193
  photo, size, marginRight, marginBottom, isSelected, selectionIndex,
286
- dim, primaryColor, thumbUrl, enterIndex, onPress, onLongPress, a11yLabel,
194
+ dim, primaryColor, thumbUrl, enterIndex, reduceMotion,
195
+ onPress, onLongPress, a11yLabel,
287
196
  } = props;
288
197
 
289
198
  const delay = Math.min(enterIndex * STAGGER_PER_CELL_MS, MAX_TOTAL_STAGGER_MS);
290
199
 
291
- // Entrance: a per-cell opacity fade, staggered by the cell's grid index.
292
- // `useNativeDriver` is only available on native — on web the animation is
293
- // JS-driven, which still plays the fade (unlike Reanimated layout entering,
294
- // which no-ops on the plugin-less RN-Web build).
295
- const opacity = useRef(new Animated.Value(0)).current;
200
+ // Entrance fade, staggered by the cell's grid index. Reduce-motion pins
201
+ // opacity at 1 and never animates.
202
+ const opacity = useRef(new Animated.Value(reduceMotion ? 1 : 0)).current;
296
203
  useEffect(() => {
204
+ if (reduceMotion) {
205
+ opacity.setValue(1);
206
+ return;
207
+ }
297
208
  const entrance = Animated.timing(opacity, {
298
209
  toValue: 1,
299
210
  duration: ENTRANCE_DURATION_MS,
@@ -302,16 +213,18 @@ const PhotoPickerCellAnimated = React.memo(function PhotoPickerCellAnimated(prop
302
213
  });
303
214
  entrance.start();
304
215
  return () => entrance.stop();
305
- }, [opacity, delay]);
216
+ }, [opacity, delay, reduceMotion]);
306
217
 
307
- // Ring pulse: a quick scale bump each time a cell transitions INTO the
308
- // selected state, springing back to rest.
218
+ // Selection-ring pulse: a quick scale bump each time the cell transitions
219
+ // INTO the selected state, springing back to rest. Reduce-motion holds the
220
+ // ring at rest scale (still tracks selection state so a later un-reduce does
221
+ // not fire a stale pulse).
309
222
  const ringScale = useRef(new Animated.Value(1)).current;
310
223
  const prevSelected = useRef(isSelected);
311
224
  useEffect(() => {
312
225
  if (prevSelected.current === isSelected) return;
313
226
  prevSelected.current = isSelected;
314
- if (!isSelected) {
227
+ if (reduceMotion || !isSelected) {
315
228
  ringScale.setValue(1);
316
229
  return;
317
230
  }
@@ -330,33 +243,13 @@ const PhotoPickerCellAnimated = React.memo(function PhotoPickerCellAnimated(prop
330
243
  ]);
331
244
  pulse.start();
332
245
  return () => pulse.stop();
333
- }, [isSelected, ringScale]);
334
-
335
- // The animated ring reads `ringScale`; its border/radius/position are inline
336
- // `style` because NativeWind `className` interop is not guaranteed on an
337
- // `Animated.View` under the RN-Web build.
338
- const ring = isSelected ? (
339
- <Animated.View
340
- pointerEvents="none"
341
- style={{
342
- position: 'absolute',
343
- top: 0,
344
- right: 0,
345
- bottom: 0,
346
- left: 0,
347
- borderRadius: CELL_CORNER_RADIUS,
348
- borderWidth: SELECTION_RING_WIDTH,
349
- borderColor: primaryColor,
350
- transform: [{ scale: ringScale }],
351
- }}
352
- />
353
- ) : null;
246
+ }, [isSelected, ringScale, reduceMotion]);
354
247
 
355
248
  return (
356
- // Animated wrapper carries the entrance opacity + layout box as inline
357
- // `style` (animated values must be inline, and className interop on
358
- // Animated.View is not guaranteed on RN-Web). `className` stays on the
359
- // non-animated TouchableOpacity + content below.
249
+ // The animated wrapper carries the entrance opacity + the layout box as
250
+ // inline `style`: animated values must be inline, and the box's
251
+ // size/margins are runtime pixels className cannot express. `className`
252
+ // drives the static chrome below (`flex-1` fills this fixed-size box).
360
253
  <Animated.View
361
254
  style={{
362
255
  width: size,
@@ -376,30 +269,50 @@ const PhotoPickerCellAnimated = React.memo(function PhotoPickerCellAnimated(prop
376
269
  accessibilityLabel={a11yLabel}
377
270
  accessibilityState={{ selected: isSelected }}
378
271
  >
379
- <PhotoPickerCellContent
380
- photo={photo}
381
- dim={dim}
382
- isSelected={isSelected}
383
- selectionIndex={selectionIndex}
384
- primaryColor={primaryColor}
385
- thumbUrl={thumbUrl}
386
- ring={ring}
387
- />
272
+ <View
273
+ className={`flex-1 rounded-radius-8 overflow-hidden bg-[#111111]${dim ? ' opacity-60' : ''}`}
274
+ >
275
+ <ExpoImage
276
+ source={{ uri: thumbUrl }}
277
+ style={{ width: '100%', height: '100%' }}
278
+ contentFit="cover"
279
+ transition={120}
280
+ cachePolicy="memory-disk"
281
+ accessibilityLabel={photo.filename}
282
+ />
283
+ </View>
284
+ {isSelected && (
285
+ <Animated.View
286
+ pointerEvents="none"
287
+ style={{
288
+ position: 'absolute',
289
+ top: 0,
290
+ right: 0,
291
+ bottom: 0,
292
+ left: 0,
293
+ borderRadius: CELL_CORNER_RADIUS,
294
+ borderWidth: SELECTION_RING_WIDTH,
295
+ borderColor: primaryColor,
296
+ transform: [{ scale: ringScale }],
297
+ }}
298
+ />
299
+ )}
300
+ {isSelected && (
301
+ <View
302
+ pointerEvents="none"
303
+ className="absolute top-1.5 right-1.5 min-w-[22px] h-[22px] px-1.5 rounded-full items-center justify-center"
304
+ style={{ backgroundColor: primaryColor }}
305
+ >
306
+ <Text className="text-white text-[12px] font-bold leading-[14px]">
307
+ {selectionIndex > 0 ? String(selectionIndex) : ''}
308
+ </Text>
309
+ </View>
310
+ )}
388
311
  </TouchableOpacity>
389
312
  </Animated.View>
390
313
  );
391
314
  });
392
315
 
393
- const PhotoPickerCell = React.memo(function PhotoPickerCell(props: PhotoPickerCellProps) {
394
- // reduceMotion → the static (unanimated) cell on every platform. Otherwise
395
- // the RN-`Animated` cell, which now plays on web too (no Reanimated layout
396
- // entering, so no plugin-less RN-Web warning / no-op).
397
- if (props.reduceMotion) {
398
- return <PhotoPickerCellStatic {...props} />;
399
- }
400
- return <PhotoPickerCellAnimated {...props} />;
401
- });
402
-
403
316
  const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
404
317
  photos,
405
318
  selectedIds,
@@ -435,13 +348,33 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
435
348
  // then snaps to the sheet width once `onLayout` reports it. The measured
436
349
  // wrapper carries ONLY a `style` (no `className`) to maximise the chance
437
350
  // `onLayout` fires on web.
438
- const { width: windowWidth } = useWindowDimensions();
351
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
439
352
  const [gridWidth, setGridWidth] = useState(0);
440
353
  const onRootLayout = useCallback((e: LayoutChangeEvent) => {
441
354
  const w = Math.round(e.nativeEvent.layout.width);
442
355
  setGridWidth((prev) => (prev === w ? prev : w));
443
356
  }, []);
444
357
 
358
+ // The hosting Dialog uses `{ base: 'bottom', md: 'center' }`. In BOTH
359
+ // placements the panel/sheet has no intrinsic height that a `flex: 1` root
360
+ // could fill down to the FlatList: the centered card hugs its content, and
361
+ // the bottom sheet's own `maxHeight` (several flex levels above this root)
362
+ // does not propagate a definite bound through the chain. So the root owns a
363
+ // `maxHeight` DIRECTLY — the FlatList then scrolls within it when the grid is
364
+ // long, and the panel hugs its content when the grid is short (no huge empty
365
+ // panel). The cap is tighter for the centered card (a floating modal reads
366
+ // better below full height); the bottom sheet may run taller. Keep this
367
+ // breakpoint in sync with `DIALOG_PLACEMENT` in BottomSheetRouter.
368
+ const isCenteredPanel = windowWidth >= BREAKPOINTS.md;
369
+ const rootStyle = useMemo(
370
+ () => ({
371
+ maxHeight: isCenteredPanel
372
+ ? Math.min(Math.round(windowHeight * 0.85), 720)
373
+ : Math.round(windowHeight * 0.9),
374
+ }),
375
+ [isCenteredPanel, windowHeight],
376
+ );
377
+
445
378
  const effectiveWidth = gridWidth > 0 ? gridWidth : windowWidth;
446
379
  const { columns, cellSize, gutter } = useMemo(
447
380
  () => computePhotoGridLayout(effectiveWidth),
@@ -571,17 +504,19 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
571
504
 
572
505
  return (
573
506
  // Measured wrapper: `style`-only (no className) so RN-Web fires onLayout.
574
- // `minHeight: 0` down the whole flex chain: the sheet is `scrollable=false`
575
- // (the FlatList owns scrolling) and clamped by its `maxHeight`, but on web a
576
- // flex child defaults to `min-height: auto` and grows to its content, so the
577
- // list overflows the clamp and is clipped (renders "half", no scroll). Letting
578
- // each flex link shrink to the clamped height gives the FlatList a bounded
579
- // scroll area. Native (Yoga) already defaults min to 0, so this is web-only.
580
- <View style={{ flex: 1, minHeight: 0 }} onLayout={onRootLayout}>
581
- <View className="flex-1 bg-black" style={{ minHeight: 0 }}>
582
- {/* Photo grid (renders behind translucent header) */}
507
+ // `rootStyle` is placement-aware a `maxHeight` cap that gives the
508
+ // FlatList a bounded scroll region while letting a short grid hug its
509
+ // content (see the `rootStyle` derivation). It must NOT carry a className
510
+ // (that can suppress web `onLayout`).
511
+ <View style={rootStyle} onLayout={onRootLayout}>
512
+ {/* `min-h-0` lets this flex child shrink below its content on web so
513
+ the FlatList gets a bounded scroll region inside the height-capped
514
+ root (the CSS flexbox `min-height:auto` trap; a no-op on native,
515
+ where Yoga already defaults min-height to 0). */}
516
+ <View className="flex-1 min-h-0 bg-black">
517
+ {/* Photo grid (renders behind the translucent header). */}
583
518
  {isEmpty ? (
584
- <View className="flex-1 items-center justify-center px-space-32 pt-[88px]">
519
+ <View className="flex-1 items-center justify-center px-space-32 pt-[60px]">
585
520
  <View className="opacity-30 mb-space-16">
586
521
  <MaterialCommunityIcons name="image-outline" size={64} color="#FFFFFF" />
587
522
  </View>
@@ -621,9 +556,8 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
621
556
  renderItem={renderItem}
622
557
  keyExtractor={keyExtractor}
623
558
  numColumns={columns}
624
- className="flex-1"
625
- style={{ minHeight: 0 }}
626
- contentContainerClassName="pt-[88px] pb-space-24"
559
+ className="flex-1 min-h-0"
560
+ contentContainerClassName="pt-[60px] pb-space-24"
627
561
  showsVerticalScrollIndicator={false}
628
562
  refreshControl={
629
563
  <RefreshControl
@@ -643,11 +577,12 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
643
577
  />
644
578
  )}
645
579
 
646
- {/* Translucent black header. The bottom sheet already sits below
647
- the status bar, so we do NOT add `insets.top` that would
648
- double-pad. `pt-[28px]` clears the 28dp drag handle floating
649
- at the top of the sheet; `min-h-[84px]` = handle + app bar. */}
650
- <View className="absolute top-0 left-0 right-0 flex-row items-center justify-between px-space-12 z-30 bg-[#000000EB] pt-[28px] min-h-[84px]">
580
+ {/* Translucent black header floating over the grid. The Dialog owns
581
+ the drag handle (a floating pill above this bar in bottom
582
+ placement, drawn at a higher z-index; none in center), so the
583
+ header no longer reserves the 28dp handle strip it is just the
584
+ 56dp app bar (`min-h-[56px]`). */}
585
+ <View className="absolute top-0 left-0 right-0 flex-row items-center justify-between px-space-12 z-30 bg-[#000000EB] min-h-[56px]">
651
586
  <View className="flex-row items-center justify-between w-full h-14">
652
587
  <View className="basis-0 grow flex-row items-center justify-start">
653
588
  <TouchableOpacity