@oxyhq/services 22.6.4 → 22.7.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 (45) hide show
  1. package/android/src/main/java/so/oxy/identity/OxyIdentityStore.kt +109 -14
  2. package/lib/commonjs/ui/boot/runProviderColdBoot.js +66 -1
  3. package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
  4. package/lib/commonjs/ui/components/fileManagement/AnimatedButton.js +7 -1
  5. package/lib/commonjs/ui/components/fileManagement/AnimatedButton.js.map +1 -1
  6. package/lib/commonjs/ui/context/OxyContext.js +43 -0
  7. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  8. package/lib/commonjs/ui/screens/FileManagementScreen.js +6 -51
  9. package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
  10. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js +130 -57
  11. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  12. package/lib/module/ui/boot/runProviderColdBoot.js +64 -0
  13. package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
  14. package/lib/module/ui/components/fileManagement/AnimatedButton.js +7 -1
  15. package/lib/module/ui/components/fileManagement/AnimatedButton.js.map +1 -1
  16. package/lib/module/ui/context/OxyContext.js +44 -1
  17. package/lib/module/ui/context/OxyContext.js.map +1 -1
  18. package/lib/module/ui/screens/FileManagementScreen.js +1 -46
  19. package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
  20. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js +130 -57
  21. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  22. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +12 -0
  23. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/ui/components/fileManagement/AnimatedButton.d.ts +1 -0
  25. package/lib/typescript/commonjs/ui/components/fileManagement/AnimatedButton.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +2 -2
  28. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  31. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +12 -0
  32. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  33. package/lib/typescript/module/ui/components/fileManagement/AnimatedButton.d.ts +1 -0
  34. package/lib/typescript/module/ui/components/fileManagement/AnimatedButton.d.ts.map +1 -1
  35. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  36. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +2 -2
  37. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  38. package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
  39. package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  40. package/package.json +4 -4
  41. package/src/ui/boot/runProviderColdBoot.ts +67 -0
  42. package/src/ui/components/fileManagement/AnimatedButton.tsx +9 -1
  43. package/src/ui/context/OxyContext.tsx +43 -1
  44. package/src/ui/screens/FileManagementScreen.tsx +1 -54
  45. package/src/ui/screens/fileManagement/PhotoPickerSection.tsx +131 -54
@@ -18,6 +18,59 @@ import type { CommitInput } from '../context/oxyContextTypes';
18
18
  /** How long the cold boot waits for the post-boot SessionClient handoff (ms). */
19
19
  export const SESSION_HANDOFF_DEADLINE_MS = 6000;
20
20
 
21
+ /**
22
+ * HARD overall deadline (ms) for the whole `runSessionColdBoot` step chain.
23
+ *
24
+ * Bounds time-to-route: routing gates on `isAuthResolved`, which resolves when
25
+ * the cold boot finishes, so a network step that never settles (a black-hole
26
+ * network that neither connects nor rejects) would otherwise hang routing
27
+ * indefinitely. 12s comfortably exceeds the healthy worst case of the
28
+ * sequential, single-attempt (`retry:false`), 5s-capped network steps, so it is
29
+ * INERT on healthy loads and only trips on a pathological network. Offline
30
+ * devices short-circuit far sooner via the connectivity hint below.
31
+ */
32
+ export const COLD_BOOT_OVERALL_DEADLINE_MS = 12_000;
33
+
34
+ /**
35
+ * Timeout (ms) for the best-effort native connectivity probe. Kept tight so the
36
+ * probe never itself adds meaningful latency to a healthy boot — an unknown
37
+ * result within this window is treated as "online".
38
+ */
39
+ const OFFLINE_PROBE_TIMEOUT_MS = 500;
40
+
41
+ /**
42
+ * Best-effort, FAST connectivity probe run once before the cold boot.
43
+ *
44
+ * Returns `true` ONLY on an EXPLICIT disconnected verdict; every ambiguous
45
+ * outcome (probe timeout, unknown/`null` state, NetInfo unavailable, a thrown
46
+ * error) resolves to `false` (assume online) so a flaky probe can never falsely
47
+ * skip a real sign-in. Never rejects. On web it reads `navigator.onLine`; on
48
+ * native it races `NetInfo.fetch()` against {@link OFFLINE_PROBE_TIMEOUT_MS},
49
+ * mirroring the existing NetInfo dynamic-import pattern in `OxyProvider`.
50
+ */
51
+ async function detectOfflineHint(): Promise<boolean> {
52
+ try {
53
+ if (isWebBrowser()) {
54
+ const online = (globalThis as { navigator?: { onLine?: boolean } }).navigator?.onLine;
55
+ // Only an explicit `false` is an offline verdict; `undefined` ⇒ assume online.
56
+ return online === false;
57
+ }
58
+ const NetInfo = await import('@react-native-community/netinfo');
59
+ const state = await Promise.race([
60
+ NetInfo.default.fetch(),
61
+ new Promise<null>((resolve) => {
62
+ setTimeout(() => resolve(null), OFFLINE_PROBE_TIMEOUT_MS);
63
+ }),
64
+ ]);
65
+ // `null` ⇒ the probe timed out (unknown → assume online). Only an explicit
66
+ // `isConnected === false` disables the network steps.
67
+ return state?.isConnected === false;
68
+ } catch {
69
+ // NetInfo missing / probe threw — never block sign-in on a probe failure.
70
+ return false;
71
+ }
72
+ }
73
+
21
74
  export interface RunProviderColdBootOptions {
22
75
  oxyServices: OxyServices;
23
76
  authStore: AuthStateStore;
@@ -79,10 +132,24 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
79
132
  return;
80
133
  }
81
134
 
135
+ // Best-effort connectivity probe up front: an EXPLICIT offline verdict skips
136
+ // the two doomed network steps so routing settles immediately instead of
137
+ // burning the overall deadline on a mint that cannot succeed. Any ambiguity
138
+ // resolves to "online" — the network steps still run.
139
+ const offline = await detectOfflineHint();
140
+
82
141
  const outcome = await runSessionColdBoot({
83
142
  oxy: oxyServices,
84
143
  store: authStore,
85
144
  platform: { isWeb: isWebBrowser(), isNative: !isWebBrowser() },
145
+ overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE_MS,
146
+ isOffline: () => offline,
147
+ onStepDeadline: (stepId) => {
148
+ loggerUtil.warn(
149
+ `Cold-boot step "${stepId}" exceeded the ${COLD_BOOT_OVERALL_DEADLINE_MS}ms overall deadline — abandoned; routing proceeds signed-out`,
150
+ { component: 'runProviderColdBoot', method: 'onStepDeadline' },
151
+ );
152
+ },
86
153
  onSession: async (session) => {
87
154
  // Mint already persisted `{deviceId, deviceSecret}` to the store; sync the
88
155
  // in-memory SessionClient host so sockets + tab-focus re-mint can use it.
@@ -10,6 +10,7 @@ interface AnimatedButtonProps {
10
10
  primaryColor: string;
11
11
  textColor: string;
12
12
  style: Record<string, unknown>;
13
+ accessibilityLabel: string;
13
14
  }
14
15
 
15
16
  /**
@@ -23,6 +24,7 @@ export const AnimatedButton: React.FC<AnimatedButtonProps> = ({
23
24
  primaryColor,
24
25
  textColor,
25
26
  style,
27
+ accessibilityLabel,
26
28
  }) => {
27
29
  const animatedValue = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
28
30
 
@@ -41,7 +43,13 @@ export const AnimatedButton: React.FC<AnimatedButtonProps> = ({
41
43
  });
42
44
 
43
45
  return (
44
- <TouchableOpacity onPress={onPress} activeOpacity={0.7}>
46
+ <TouchableOpacity
47
+ onPress={onPress}
48
+ activeOpacity={0.7}
49
+ accessibilityRole="button"
50
+ accessibilityLabel={accessibilityLabel}
51
+ accessibilityState={{ selected: isSelected }}
52
+ >
45
53
  <Animated.View style={[style, { backgroundColor }]}>
46
54
  <Animated.View>
47
55
  <MaterialCommunityIcons
@@ -45,7 +45,7 @@ import { useDeviceManagement } from '../hooks/useDeviceManagement';
45
45
  import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
46
46
  import type { RouteName } from '../navigation/routes';
47
47
  import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
48
- import { useQueryClient } from '@tanstack/react-query';
48
+ import { useQueryClient, onlineManager } from '@tanstack/react-query';
49
49
  import { clearQueryCache } from '../hooks/queryClient';
50
50
  import { useAvatarPicker } from '../hooks/useAvatarPicker';
51
51
  import { useAccountStore } from '../stores/accountStore';
@@ -848,6 +848,41 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
848
848
  return () => document.removeEventListener('visibilitychange', onVisibility);
849
849
  }, [oxyServices, sessionClient, sessionClientHost, syncFromClient]);
850
850
 
851
+ // Reconnect heal: when connectivity transitions offline→online while there is
852
+ // no live access token but a persisted device credential exists, re-mint ONCE
853
+ // through the SAME shared single-flight lane the scheduler / tab-focus
854
+ // reconcile / 401 path use — never a private mint lane, so this can't
855
+ // double-rotate the device secret against them. A device that cold-booted
856
+ // OFFLINE skipped the network mint step, so its session is otherwise only
857
+ // recovered by the next scheduled re-mint; healing on the reconnect edge makes
858
+ // recovery immediate. `onlineManager` is fed by OxyProvider's existing NetInfo
859
+ // (native) / `navigator.onLine` (web) listener, so this reuses the one
860
+ // connectivity signal instead of opening a second NetInfo subscription. Works
861
+ // on native and web alike.
862
+ useEffect(() => {
863
+ // Seed from the current verdict so the first callback heals only on a
864
+ // genuine offline→online edge, never an initial subscribe fan-out.
865
+ let previousOnline = onlineManager.isOnline();
866
+ return onlineManager.subscribe((online: boolean) => {
867
+ const wasOffline = previousOnline === false;
868
+ previousOnline = online;
869
+ if (!online || !wasOffline) {
870
+ return;
871
+ }
872
+ void (async () => {
873
+ if (oxyServices.getAccessToken() || !sessionClientHost.getDeviceCredential()) {
874
+ return;
875
+ }
876
+ await oxyServices.httpService.refreshAccessToken('preflight');
877
+ if (!oxyServices.getAccessToken()) {
878
+ return;
879
+ }
880
+ await sessionClient.bootstrap();
881
+ await syncFromClient();
882
+ })().catch(() => undefined);
883
+ });
884
+ }, [oxyServices, sessionClient, sessionClientHost, syncFromClient]);
885
+
851
886
  // Exposed `refreshSessions`: re-bootstrap the server-authoritative device
852
887
  // state and reproject — the manual counterpart to the realtime socket.
853
888
  const refreshSessionsForContext = useCallback(async (): Promise<void> => {
@@ -877,6 +912,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
877
912
  [sessionClient, syncFromClient],
878
913
  );
879
914
 
915
+ // Thin passthroughs to the platform-agnostic KeyManager. NOTE: both now THROW
916
+ // `IdentityUnavailableError` (from `@oxyhq/core`) when identity storage is
917
+ // locked/unreadable, instead of flattening that into `false`/`null`. We keep
918
+ // the signatures and deliberately let the typed error PROPAGATE to the caller
919
+ // — a locked keychain must never be misreported as "no identity". `hasIdentity`
920
+ // still resolves `false` and `getPublicKey` still resolves `null` for a genuine
921
+ // absence.
880
922
  const hasIdentity = useCallback(async (): Promise<boolean> => KeyManager.hasIdentity(), []);
881
923
  const getPublicKey = useCallback(async (): Promise<string | null> => KeyManager.getPublicKey(), []);
882
924
 
@@ -40,6 +40,7 @@ import { useFileUploadState } from './fileManagement/hooks/useFileUploadState';
40
40
  import PhotoPickerView from './fileManagement/PhotoPickerSection';
41
41
  import FileListSection, { type FileListItem } from './fileManagement/FileListSection';
42
42
  import UploadBar from './fileManagement/UploadBar';
43
+ import { AnimatedButton } from '../components/fileManagement/AnimatedButton';
43
44
 
44
45
  // Genuinely-inline-only styles: `viewModeButton` is spread into an Animated.View
45
46
  // style array (interpolated backgroundColor), and the photo tiles are
@@ -58,60 +59,6 @@ const screenStyles = StyleSheet.create({
58
59
  justifiedPhotoImage: { width: '100%', height: '100%', borderRadius: 6 },
59
60
  });
60
61
 
61
- // Animated button component for smooth transitions
62
- const AnimatedButton: React.FC<{
63
- isSelected: boolean;
64
- onPress: () => void;
65
- icon: string;
66
- primaryColor: string;
67
- textColor: string;
68
- style: Record<string, unknown>;
69
- accessibilityLabel: string;
70
- }> = ({ isSelected, onPress, icon, primaryColor, textColor, style, accessibilityLabel }) => {
71
- const animatedValue = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
72
-
73
- useEffect(() => {
74
- Animated.timing(animatedValue, {
75
- toValue: isSelected ? 1 : 0,
76
- duration: 200,
77
- easing: Easing.out(Easing.ease),
78
- useNativeDriver: false,
79
- }).start();
80
- }, [isSelected, animatedValue]);
81
-
82
- const backgroundColor = animatedValue.interpolate({
83
- inputRange: [0, 1],
84
- outputRange: ['transparent', primaryColor],
85
- });
86
-
87
- return (
88
- <TouchableOpacity
89
- onPress={onPress}
90
- activeOpacity={0.7}
91
- accessibilityRole="button"
92
- accessibilityLabel={accessibilityLabel}
93
- accessibilityState={{ selected: isSelected }}
94
- >
95
- <Animated.View
96
- style={[
97
- style,
98
- {
99
- backgroundColor,
100
- },
101
- ]}
102
- >
103
- <Animated.View>
104
- <MaterialCommunityIcons
105
- name={icon as React.ComponentProps<typeof MaterialCommunityIcons>['name']}
106
- size={16}
107
- color={isSelected ? '#FFFFFF' : textColor}
108
- />
109
- </Animated.View>
110
- </Animated.View>
111
- </TouchableOpacity>
112
- );
113
- };
114
-
115
62
  const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
116
63
  onClose,
117
64
  theme,
@@ -151,14 +151,61 @@ export interface PhotoPickerViewProps {
151
151
  * Apple Photos pattern: when any cell is selected, *non-selected* siblings
152
152
  * fade to 0.6 opacity to focus attention on the active selection.
153
153
  */
154
- const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
154
+ /**
155
+ * Shared cell chrome (image, dim, badge). Ring border is injected by the
156
+ * static vs animated wrappers so web never touches Reanimated worklets.
157
+ */
158
+ function PhotoPickerCellContent(props: {
159
+ photo: FileMetadata;
160
+ dim: boolean;
161
+ isSelected: boolean;
162
+ selectionIndex: number;
163
+ primaryColor: string;
164
+ thumbUrl: string | undefined;
165
+ ring: React.ReactNode;
166
+ }) {
167
+ const {
168
+ photo, dim, isSelected, selectionIndex, primaryColor, thumbUrl, ring,
169
+ } = props;
170
+
171
+ return (
172
+ <>
173
+ <View
174
+ className={`flex-1 rounded-radius-8 overflow-hidden bg-[#111111]${dim ? ' opacity-60' : ''}`}
175
+ >
176
+ <ExpoImage
177
+ source={{ uri: thumbUrl }}
178
+ style={{ width: '100%', height: '100%' }}
179
+ contentFit="cover"
180
+ transition={120}
181
+ cachePolicy="memory-disk"
182
+ accessibilityLabel={photo.filename}
183
+ />
184
+ </View>
185
+ {ring}
186
+ {isSelected && (
187
+ <View
188
+ pointerEvents="none"
189
+ className="absolute top-1.5 right-1.5 min-w-[22px] h-[22px] px-1.5 rounded-full items-center justify-center"
190
+ style={{ backgroundColor: primaryColor }}
191
+ >
192
+ <Text className="text-white text-[12px] font-bold leading-[14px]">
193
+ {selectionIndex > 0 ? String(selectionIndex) : ''}
194
+ </Text>
195
+ </View>
196
+ )}
197
+ </>
198
+ );
199
+ }
200
+
201
+ type PhotoPickerCellProps = {
155
202
  photo: FileMetadata;
156
203
  size: number;
157
204
  marginRight: number;
158
205
  marginBottom: number;
159
206
  isSelected: boolean;
160
- selectionIndex: number; // 1-based for badge; 0 if not selected
161
- dim: boolean; // any selection exists and this cell is not selected
207
+ selectionIndex: number;
208
+ dim: boolean;
162
209
  primaryColor: string;
163
210
  thumbUrl: string | undefined;
164
211
  enterIndex: number;
@@ -166,21 +213,64 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
166
213
  onPress: () => void;
167
214
  onLongPress: () => void;
168
215
  a11yLabel: string;
169
- }) {
216
+ };
217
+
218
+ const PhotoPickerCellStatic = React.memo(function PhotoPickerCellStatic(props: PhotoPickerCellProps) {
219
+ const {
220
+ photo, size, marginRight, marginBottom, isSelected, selectionIndex,
221
+ dim, primaryColor, thumbUrl, onPress, onLongPress, a11yLabel,
222
+ } = props;
223
+
224
+ const cellWrapperStyle = {
225
+ width: size,
226
+ height: size,
227
+ marginRight,
228
+ marginBottom,
229
+ };
230
+
231
+ const ring = isSelected ? (
232
+ <View
233
+ pointerEvents="none"
234
+ className="absolute inset-0 rounded-radius-8 border-[3px]"
235
+ style={{ borderColor: primaryColor }}
236
+ />
237
+ ) : null;
238
+
239
+ return (
240
+ <TouchableOpacity
241
+ activeOpacity={0.85}
242
+ onPress={onPress}
243
+ onLongPress={onLongPress}
244
+ className="relative"
245
+ style={cellWrapperStyle}
246
+ accessibilityRole="button"
247
+ accessibilityLabel={a11yLabel}
248
+ accessibilityState={{ selected: isSelected }}
249
+ >
250
+ <PhotoPickerCellContent
251
+ photo={photo}
252
+ dim={dim}
253
+ isSelected={isSelected}
254
+ selectionIndex={selectionIndex}
255
+ primaryColor={primaryColor}
256
+ thumbUrl={thumbUrl}
257
+ ring={ring}
258
+ />
259
+ </TouchableOpacity>
260
+ );
261
+ });
262
+
263
+ const PhotoPickerCellAnimated = React.memo(function PhotoPickerCellAnimated(props: PhotoPickerCellProps) {
170
264
  const {
171
265
  photo, size, marginRight, marginBottom, isSelected, selectionIndex,
172
266
  dim, primaryColor, thumbUrl, enterIndex, reduceMotion, onPress,
173
267
  onLongPress, a11yLabel,
174
268
  } = props;
175
269
 
176
- // Cap the cumulative stagger at ~800ms total so the very long grid does
177
- // not keep fading in late tiles. Beyond ~53 tiles the delay maxes out.
178
270
  const STAGGER_PER_CELL_MS = 15;
179
271
  const MAX_TOTAL_STAGGER_MS = 800;
180
272
  const delay = Math.min(enterIndex * STAGGER_PER_CELL_MS, MAX_TOTAL_STAGGER_MS);
181
273
 
182
- // Selection ring pulse animation: 1.0 → 1.05 → 1.0 on transition to
183
- // selected. Plays at most once per selection change; reduce-motion skips.
184
274
  const ringScale = useSharedValue(1);
185
275
  const prevSelected = useRef(isSelected);
186
276
  useEffect(() => {
@@ -204,8 +294,6 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
204
294
  transform: [{ scale: ringScale.value }],
205
295
  }));
206
296
 
207
- // Per-tile pixel geometry is derived from the measured grid width, so it
208
- // stays an inline `style` — NativeWind cannot express a runtime pixel size.
209
297
  const cellWrapperStyle = {
210
298
  width: size,
211
299
  height: size,
@@ -213,49 +301,15 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
213
301
  marginBottom,
214
302
  };
215
303
 
216
- const inner = (
217
- <>
218
- <View
219
- className={`flex-1 rounded-radius-8 overflow-hidden bg-[#111111]${dim ? ' opacity-60' : ''}`}
220
- >
221
- <ExpoImage
222
- source={{ uri: thumbUrl }}
223
- // expo-image is a third-party component (no NativeWind
224
- // className remap), so fill via inline style.
225
- style={{ width: '100%', height: '100%' }}
226
- contentFit="cover"
227
- transition={120}
228
- cachePolicy="memory-disk"
229
- accessibilityLabel={photo.filename}
230
- />
231
- </View>
232
- {isSelected && (
233
- <Reanimated.View
234
- pointerEvents="none"
235
- className="absolute inset-0 rounded-radius-8 border-[3px]"
236
- // borderColor is the theme primary (dynamic); the animated
237
- // scale transform must ride on `style` too.
238
- style={[{ borderColor: primaryColor }, ringAnimatedStyle]}
239
- />
240
- )}
241
- {isSelected && (
242
- <View
243
- pointerEvents="none"
244
- className="absolute top-1.5 right-1.5 min-w-[22px] h-[22px] px-1.5 rounded-full items-center justify-center"
245
- style={{ backgroundColor: primaryColor }}
246
- >
247
- <Text className="text-white text-[12px] font-bold leading-[14px]">
248
- {selectionIndex > 0 ? String(selectionIndex) : ''}
249
- </Text>
250
- </View>
251
- )}
252
- </>
253
- );
304
+ const ring = isSelected ? (
305
+ <Reanimated.View
306
+ pointerEvents="none"
307
+ className="absolute inset-0 rounded-radius-8 border-[3px]"
308
+ style={[{ borderColor: primaryColor }, ringAnimatedStyle]}
309
+ />
310
+ ) : null;
254
311
 
255
- // Reanimated layout `entering` animations don't load on web (no worklets
256
- // babel plugin in the RN-Web build) — they warn and no-op. So on web (and
257
- // when reduce-motion is set) render the plain cell with no entering stagger.
258
- if (reduceMotion || Platform.OS === 'web') {
312
+ if (reduceMotion) {
259
313
  return (
260
314
  <TouchableOpacity
261
315
  activeOpacity={0.85}
@@ -267,7 +321,15 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
267
321
  accessibilityLabel={a11yLabel}
268
322
  accessibilityState={{ selected: isSelected }}
269
323
  >
270
- {inner}
324
+ <PhotoPickerCellContent
325
+ photo={photo}
326
+ dim={dim}
327
+ isSelected={isSelected}
328
+ selectionIndex={selectionIndex}
329
+ primaryColor={primaryColor}
330
+ thumbUrl={thumbUrl}
331
+ ring={ring}
332
+ />
271
333
  </TouchableOpacity>
272
334
  );
273
335
  }
@@ -287,12 +349,27 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
287
349
  accessibilityLabel={a11yLabel}
288
350
  accessibilityState={{ selected: isSelected }}
289
351
  >
290
- {inner}
352
+ <PhotoPickerCellContent
353
+ photo={photo}
354
+ dim={dim}
355
+ isSelected={isSelected}
356
+ selectionIndex={selectionIndex}
357
+ primaryColor={primaryColor}
358
+ thumbUrl={thumbUrl}
359
+ ring={ring}
360
+ />
291
361
  </TouchableOpacity>
292
362
  </Reanimated.View>
293
363
  );
294
364
  });
295
365
 
366
+ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: PhotoPickerCellProps) {
367
+ if (Platform.OS === 'web') {
368
+ return <PhotoPickerCellStatic {...props} />;
369
+ }
370
+ return <PhotoPickerCellAnimated {...props} />;
371
+ });
372
+
296
373
  const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
297
374
  photos,
298
375
  selectedIds,