@oxyhq/services 22.8.0 → 22.8.3

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 (35) hide show
  1. package/lib/commonjs/ui/hooks/queries/userCacheRelationship.js +31 -0
  2. package/lib/commonjs/ui/hooks/queries/userCacheRelationship.js.map +1 -0
  3. package/lib/commonjs/ui/hooks/useFollow.js +6 -2
  4. package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
  5. package/lib/commonjs/ui/screens/FileManagementScreen.js +15 -15
  6. package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
  7. package/lib/commonjs/ui/screens/ProfileScreen.js +1 -1
  8. package/lib/commonjs/ui/screens/ProfileScreen.js.map +1 -1
  9. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js +133 -82
  10. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  11. package/lib/module/ui/hooks/queries/userCacheRelationship.js +27 -0
  12. package/lib/module/ui/hooks/queries/userCacheRelationship.js.map +1 -0
  13. package/lib/module/ui/hooks/useFollow.js +7 -3
  14. package/lib/module/ui/hooks/useFollow.js.map +1 -1
  15. package/lib/module/ui/screens/FileManagementScreen.js +15 -15
  16. package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
  17. package/lib/module/ui/screens/ProfileScreen.js +1 -1
  18. package/lib/module/ui/screens/ProfileScreen.js.map +1 -1
  19. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js +134 -83
  20. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  21. package/lib/typescript/commonjs/ui/hooks/queries/userCacheRelationship.d.ts +9 -0
  22. package/lib/typescript/commonjs/ui/hooks/queries/userCacheRelationship.d.ts.map +1 -0
  23. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  25. package/lib/typescript/module/ui/hooks/queries/userCacheRelationship.d.ts +9 -0
  26. package/lib/typescript/module/ui/hooks/queries/userCacheRelationship.d.ts.map +1 -0
  27. package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
  28. package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  29. package/package.json +3 -3
  30. package/src/ui/hooks/queries/__tests__/userCache.test.ts +55 -0
  31. package/src/ui/hooks/queries/userCacheRelationship.ts +33 -0
  32. package/src/ui/hooks/useFollow.ts +7 -3
  33. package/src/ui/screens/FileManagementScreen.tsx +13 -13
  34. package/src/ui/screens/ProfileScreen.tsx +1 -1
  35. package/src/ui/screens/fileManagement/PhotoPickerSection.tsx +110 -73
@@ -206,6 +206,8 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
206
206
  return sorted;
207
207
  }, [files, searchQuery, viewMode, sortBy, sortOrder]);
208
208
  const [photoDimensions, setPhotoDimensions] = useState<{ [key: string]: { width: number, height: number } }>({});
209
+ const photoDimensionsRef = useRef(photoDimensions);
210
+ photoDimensionsRef.current = photoDimensions;
209
211
  const [loadingDimensions, setLoadingDimensions] = useState(false);
210
212
  // Selection state
211
213
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set(initialSelectedIds));
@@ -441,11 +443,10 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
441
443
  if (photos.length === 0) return;
442
444
 
443
445
  setLoadingDimensions(true);
444
- const newDimensions: { [key: string]: { width: number, height: number } } = { ...photoDimensions };
445
- let hasNewDimensions = false;
446
446
 
447
- // Only load dimensions for photos we don't have yet
448
- const photosToLoad = photos.filter(photo => !newDimensions[photo.id]);
447
+ // Snapshot ids missing from the cache at kick-off; concurrent runs may
448
+ // overlap but each merge is applied via a functional updater.
449
+ const photosToLoad = photos.filter((photo) => !photoDimensionsRef.current[photo.id]);
449
450
 
450
451
  if (photosToLoad.length === 0) {
451
452
  setLoadingDimensions(false);
@@ -453,6 +454,8 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
453
454
  }
454
455
 
455
456
  try {
457
+ const measured: { [key: string]: { width: number; height: number } } = {};
458
+
456
459
  await Promise.all(
457
460
  photosToLoad.map(async (photo) => {
458
461
  try {
@@ -470,35 +473,32 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
470
473
  Image.getSize(
471
474
  downloadUrl,
472
475
  (width: number, height: number) => {
473
- newDimensions[photo.id] = { width, height };
474
- hasNewDimensions = true;
476
+ measured[photo.id] = { width, height };
475
477
  resolve();
476
478
  },
477
479
  () => {
478
480
  // Fallback dimensions
479
- newDimensions[photo.id] = { width: 1, height: 1 };
480
- hasNewDimensions = true;
481
+ measured[photo.id] = { width: 1, height: 1 };
481
482
  resolve();
482
483
  }
483
484
  );
484
485
  });
485
486
  } catch (error) {
486
487
  // Fallback dimensions for any errors
487
- newDimensions[photo.id] = { width: 1, height: 1 };
488
- hasNewDimensions = true;
488
+ measured[photo.id] = { width: 1, height: 1 };
489
489
  }
490
490
  })
491
491
  );
492
492
 
493
- if (hasNewDimensions) {
494
- setPhotoDimensions(newDimensions);
493
+ if (Object.keys(measured).length > 0) {
494
+ setPhotoDimensions((prev) => ({ ...prev, ...measured }));
495
495
  }
496
496
  } catch (error) {
497
497
  // Photo dimensions loading failed, continue without dimensions
498
498
  } finally {
499
499
  setLoadingDimensions(false);
500
500
  }
501
- }, [thumbSourceFor, photoDimensions]);
501
+ }, [thumbSourceFor]);
502
502
 
503
503
  // Re-measure photo dimensions when their private-safe URLs resolve. The
504
504
  // justified grid's own trigger fires on the photo SET, not on URL
@@ -138,7 +138,7 @@ const ProfileScreen: React.FC<ProfileScreenProps> = ({ userId, username, theme,
138
138
  setError(errorMessage);
139
139
  })
140
140
  .finally(() => setIsLoading(false));
141
- }, [userId]);
141
+ }, [userId, currentUser?.id, oxyServices]);
142
142
 
143
143
  if (isLoading) {
144
144
  return (
@@ -7,18 +7,11 @@ import {
7
7
  RefreshControl,
8
8
  FlatList,
9
9
  Platform,
10
+ Animated,
10
11
  useWindowDimensions,
11
12
  type LayoutChangeEvent,
12
13
  } from 'react-native';
13
14
  import { Image as ExpoImage } from 'expo-image';
14
- import Reanimated, {
15
- FadeIn,
16
- useAnimatedStyle,
17
- useSharedValue,
18
- withSequence,
19
- withSpring,
20
- withTiming,
21
- } from 'react-native-reanimated';
22
15
  import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
23
16
  import type { FileMetadata } from '@oxyhq/core';
24
17
  import { computePhotoGridLayout } from './photoGridLayout';
@@ -144,6 +137,33 @@ export interface PhotoPickerViewProps {
144
137
  t: (key: string, vars?: Record<string, string | number>) => string;
145
138
  }
146
139
 
140
+ /**
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
143
+ * Reanimated `entering={FadeIn}` LAYOUT animation, which cannot load on the
144
+ * RN-Web build (no `react-native-worklets` babel plugin) and only warns +
145
+ * no-ops there. RN `Animated` runs cross-platform: native-driven on device,
146
+ * JS-driven on web (`useNativeDriver: false`), so the fade actually plays on
147
+ * web instead of being skipped.
148
+ */
149
+ const STAGGER_PER_CELL_MS = 15;
150
+ const MAX_TOTAL_STAGGER_MS = 800;
151
+ const ENTRANCE_DURATION_MS = 200;
152
+ /** Selection-ring pulse: a quick scale bump that springs back to rest. */
153
+ const RING_PULSE_PEAK = 1.05;
154
+ const RING_PULSE_UP_MS = 110;
155
+ const RING_SPRING_FRICTION = 9;
156
+ const RING_SPRING_TENSION = 120;
157
+ /**
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.
163
+ */
164
+ const CELL_CORNER_RADIUS = 8; // `rounded-radius-8`
165
+ const SELECTION_RING_WIDTH = 3; // `border-[3px]`
166
+
147
167
  /**
148
168
  * A single photo cell. Memoized so re-renders during selection only touch
149
169
  * affected cells — selection of one photo must not redraw the whole grid.
@@ -153,7 +173,7 @@ export interface PhotoPickerViewProps {
153
173
  */
154
174
  /**
155
175
  * Shared cell chrome (image, dim, badge). Ring border is injected by the
156
- * static vs animated wrappers so web never touches Reanimated worklets.
176
+ * static vs animated wrappers.
157
177
  */
158
178
  function PhotoPickerCellContent(props: {
159
179
  photo: FileMetadata;
@@ -263,82 +283,89 @@ const PhotoPickerCellStatic = React.memo(function PhotoPickerCellStatic(props: P
263
283
  const PhotoPickerCellAnimated = React.memo(function PhotoPickerCellAnimated(props: PhotoPickerCellProps) {
264
284
  const {
265
285
  photo, size, marginRight, marginBottom, isSelected, selectionIndex,
266
- dim, primaryColor, thumbUrl, enterIndex, reduceMotion, onPress,
267
- onLongPress, a11yLabel,
286
+ dim, primaryColor, thumbUrl, enterIndex, onPress, onLongPress, a11yLabel,
268
287
  } = props;
269
288
 
270
- const STAGGER_PER_CELL_MS = 15;
271
- const MAX_TOTAL_STAGGER_MS = 800;
272
289
  const delay = Math.min(enterIndex * STAGGER_PER_CELL_MS, MAX_TOTAL_STAGGER_MS);
273
290
 
274
- const ringScale = useSharedValue(1);
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;
296
+ useEffect(() => {
297
+ const entrance = Animated.timing(opacity, {
298
+ toValue: 1,
299
+ duration: ENTRANCE_DURATION_MS,
300
+ delay,
301
+ useNativeDriver: Platform.OS !== 'web',
302
+ });
303
+ entrance.start();
304
+ return () => entrance.stop();
305
+ }, [opacity, delay]);
306
+
307
+ // Ring pulse: a quick scale bump each time a cell transitions INTO the
308
+ // selected state, springing back to rest.
309
+ const ringScale = useRef(new Animated.Value(1)).current;
275
310
  const prevSelected = useRef(isSelected);
276
311
  useEffect(() => {
277
312
  if (prevSelected.current === isSelected) return;
278
313
  prevSelected.current = isSelected;
279
314
  if (!isSelected) {
280
- ringScale.value = 1;
315
+ ringScale.setValue(1);
281
316
  return;
282
317
  }
283
- if (reduceMotion) {
284
- ringScale.value = 1;
285
- return;
286
- }
287
- ringScale.value = withSequence(
288
- withTiming(1.05, { duration: 110 }),
289
- withSpring(1, { damping: 14, stiffness: 200 }),
290
- );
291
- }, [isSelected, reduceMotion, ringScale]);
292
-
293
- const ringAnimatedStyle = useAnimatedStyle(() => ({
294
- transform: [{ scale: ringScale.value }],
295
- }));
296
-
297
- const cellWrapperStyle = {
298
- width: size,
299
- height: size,
300
- marginRight,
301
- marginBottom,
302
- };
303
-
318
+ const pulse = Animated.sequence([
319
+ Animated.timing(ringScale, {
320
+ toValue: RING_PULSE_PEAK,
321
+ duration: RING_PULSE_UP_MS,
322
+ useNativeDriver: Platform.OS !== 'web',
323
+ }),
324
+ Animated.spring(ringScale, {
325
+ toValue: 1,
326
+ friction: RING_SPRING_FRICTION,
327
+ tension: RING_SPRING_TENSION,
328
+ useNativeDriver: Platform.OS !== 'web',
329
+ }),
330
+ ]);
331
+ pulse.start();
332
+ 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.
304
338
  const ring = isSelected ? (
305
- <Reanimated.View
339
+ <Animated.View
306
340
  pointerEvents="none"
307
- className="absolute inset-0 rounded-radius-8 border-[3px]"
308
- style={[{ borderColor: primaryColor }, ringAnimatedStyle]}
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
+ }}
309
352
  />
310
353
  ) : null;
311
354
 
312
- if (reduceMotion) {
313
- return (
314
- <TouchableOpacity
315
- activeOpacity={0.85}
316
- onPress={onPress}
317
- onLongPress={onLongPress}
318
- className="relative"
319
- style={cellWrapperStyle}
320
- accessibilityRole="button"
321
- accessibilityLabel={a11yLabel}
322
- accessibilityState={{ selected: isSelected }}
323
- >
324
- <PhotoPickerCellContent
325
- photo={photo}
326
- dim={dim}
327
- isSelected={isSelected}
328
- selectionIndex={selectionIndex}
329
- primaryColor={primaryColor}
330
- thumbUrl={thumbUrl}
331
- ring={ring}
332
- />
333
- </TouchableOpacity>
334
- );
335
- }
336
-
337
355
  return (
338
- <Reanimated.View
339
- entering={FadeIn.delay(delay).duration(200)}
340
- className="relative"
341
- style={cellWrapperStyle}
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.
360
+ <Animated.View
361
+ style={{
362
+ width: size,
363
+ height: size,
364
+ marginRight,
365
+ marginBottom,
366
+ position: 'relative',
367
+ opacity,
368
+ }}
342
369
  >
343
370
  <TouchableOpacity
344
371
  activeOpacity={0.85}
@@ -359,12 +386,15 @@ const PhotoPickerCellAnimated = React.memo(function PhotoPickerCellAnimated(prop
359
386
  ring={ring}
360
387
  />
361
388
  </TouchableOpacity>
362
- </Reanimated.View>
389
+ </Animated.View>
363
390
  );
364
391
  });
365
392
 
366
393
  const PhotoPickerCell = React.memo(function PhotoPickerCell(props: PhotoPickerCellProps) {
367
- if (Platform.OS === 'web') {
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) {
368
398
  return <PhotoPickerCellStatic {...props} />;
369
399
  }
370
400
  return <PhotoPickerCellAnimated {...props} />;
@@ -541,8 +571,14 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
541
571
 
542
572
  return (
543
573
  // Measured wrapper: `style`-only (no className) so RN-Web fires onLayout.
544
- <View style={{ flex: 1 }} onLayout={onRootLayout}>
545
- <View className="flex-1 bg-black">
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 }}>
546
582
  {/* Photo grid (renders behind translucent header) */}
547
583
  {isEmpty ? (
548
584
  <View className="flex-1 items-center justify-center px-space-32 pt-[88px]">
@@ -586,6 +622,7 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
586
622
  keyExtractor={keyExtractor}
587
623
  numColumns={columns}
588
624
  className="flex-1"
625
+ style={{ minHeight: 0 }}
589
626
  contentContainerClassName="pt-[88px] pb-space-24"
590
627
  showsVerticalScrollIndicator={false}
591
628
  refreshControl={
@@ -600,7 +637,7 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
600
637
  onEndReached={handleEndReached}
601
638
  onEndReachedThreshold={0.4}
602
639
  ListFooterComponent={listFooter}
603
- removeClippedSubviews
640
+ removeClippedSubviews={Platform.OS !== 'web'}
604
641
  initialNumToRender={Math.max(12, columns * 6)}
605
642
  windowSize={9}
606
643
  />