@oxyhq/services 22.8.5 → 22.8.7

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 (51) hide show
  1. package/lib/commonjs/ui/components/ProfileButton.js +4 -10
  2. package/lib/commonjs/ui/components/ProfileButton.js.map +1 -1
  3. package/lib/commonjs/ui/components/fileManagement/UploadPreview.js +3 -3
  4. package/lib/commonjs/ui/components/fileManagement/UploadPreview.js.map +1 -1
  5. package/lib/commonjs/ui/screens/FileManagementScreen.js +56 -173
  6. package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
  7. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js +60 -3
  8. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  9. package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js +5 -10
  10. package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
  11. package/lib/commonjs/ui/screens/fileManagement/shared.js +13 -1
  12. package/lib/commonjs/ui/screens/fileManagement/shared.js.map +1 -1
  13. package/lib/module/ui/components/ProfileButton.js +3 -10
  14. package/lib/module/ui/components/ProfileButton.js.map +1 -1
  15. package/lib/module/ui/components/fileManagement/UploadPreview.js +3 -3
  16. package/lib/module/ui/components/fileManagement/UploadPreview.js.map +1 -1
  17. package/lib/module/ui/screens/FileManagementScreen.js +57 -174
  18. package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
  19. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js +60 -3
  20. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  21. package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js +6 -11
  22. package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
  23. package/lib/module/ui/screens/fileManagement/shared.js +12 -0
  24. package/lib/module/ui/screens/fileManagement/shared.js.map +1 -1
  25. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/ui/components/fileManagement/UploadPreview.d.ts +3 -10
  27. package/lib/typescript/commonjs/ui/components/fileManagement/UploadPreview.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts +7 -0
  30. package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.d.ts +1 -1
  32. package/lib/typescript/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/ui/screens/fileManagement/shared.d.ts +4 -0
  34. package/lib/typescript/commonjs/ui/screens/fileManagement/shared.d.ts.map +1 -1
  35. package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -1
  36. package/lib/typescript/module/ui/components/fileManagement/UploadPreview.d.ts +3 -10
  37. package/lib/typescript/module/ui/components/fileManagement/UploadPreview.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 +7 -0
  40. package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  41. package/lib/typescript/module/ui/screens/fileManagement/hooks/useFileUploadState.d.ts +1 -1
  42. package/lib/typescript/module/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
  43. package/lib/typescript/module/ui/screens/fileManagement/shared.d.ts +4 -0
  44. package/lib/typescript/module/ui/screens/fileManagement/shared.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/ui/components/ProfileButton.tsx +2 -14
  47. package/src/ui/components/fileManagement/UploadPreview.tsx +8 -16
  48. package/src/ui/screens/FileManagementScreen.tsx +43 -174
  49. package/src/ui/screens/fileManagement/PhotoPickerSection.tsx +69 -7
  50. package/src/ui/screens/fileManagement/hooks/useFileUploadState.ts +7 -11
  51. package/src/ui/screens/fileManagement/shared.ts +23 -0
@@ -9,15 +9,13 @@ import {
9
9
  RefreshControl,
10
10
  TextInput,
11
11
  Image,
12
- Animated,
13
- Easing,
14
12
  AccessibilityInfo,
15
- Platform,
16
13
  useWindowDimensions,
17
14
  } from 'react-native';
18
15
  import { Image as ExpoImage } from 'expo-image';
19
16
  import type { FileManagementScreenProps } from '../types/fileManagement';
20
17
  import { Dialog, toast, useDialogControl } from '@oxyhq/bloom';
18
+ import * as Skeleton from '@oxyhq/bloom/skeleton';
21
19
  import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
22
20
  import type { FileMetadata } from '@oxyhq/core';
23
21
  import { useFileStore, useFiles, useUploading as useUploadingStore, useUploadAggregateProgress, useDeleting as useDeletingStore } from '../stores/fileStore';
@@ -1121,193 +1119,63 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
1121
1119
  </View>
1122
1120
  );
1123
1121
 
1124
- // Professional Skeleton Loading Component with Advanced Shimmer Effect
1125
- const SkeletonLoader = React.memo(() => {
1126
- const shimmerAnim = useRef(new Animated.Value(0)).current;
1127
- const skeletonContainerWidth = safeContainerWidth;
1128
-
1129
- useEffect(() => {
1130
- const shimmer = Animated.loop(
1131
- Animated.timing(shimmerAnim, {
1132
- toValue: 1,
1133
- duration: 2000,
1134
- easing: Easing.linear,
1135
- // Web has no native animated module; forcing the native
1136
- // driver there warns and falls back to JS. The shimmer only
1137
- // drives a transform, so JS-driving it on web is fine.
1138
- useNativeDriver: Platform.OS !== 'web',
1139
- })
1140
- );
1141
- shimmer.start();
1142
- return () => shimmer.stop();
1143
- }, [shimmerAnim]);
1144
-
1145
- // Create a sweeping shimmer effect
1146
- const shimmerTranslateX = shimmerAnim.interpolate({
1147
- inputRange: [0, 1],
1148
- outputRange: [-skeletonContainerWidth * 2, skeletonContainerWidth * 2],
1149
- });
1150
-
1151
- const SkeletonBox = ({ width, height, borderRadius = 8, style, delay = 0 }: { width: number | string; height: number; borderRadius?: number; style?: Record<string, unknown>; delay?: number }) => {
1152
- const delayedTranslateX = shimmerAnim.interpolate({
1153
- inputRange: [0, 1],
1154
- outputRange: [-skeletonContainerWidth * 2 + delay, skeletonContainerWidth * 2 + delay],
1155
- });
1156
-
1157
- return (
1158
- <View
1159
- style={[
1160
- {
1161
- width,
1162
- height,
1163
- borderRadius,
1164
- backgroundColor: colors.backgroundSecondary,
1165
- overflow: 'hidden',
1166
- position: 'relative',
1167
- },
1168
- style,
1169
- ]}
1170
- >
1171
- {/* Base background */}
1172
- <View
1173
- style={{
1174
- position: 'absolute',
1175
- top: 0,
1176
- left: 0,
1177
- right: 0,
1178
- bottom: 0,
1179
- backgroundColor: colors.backgroundSecondary,
1180
- }}
1181
- />
1182
- {/* Shimmer gradient effect */}
1183
- <Animated.View
1184
- style={{
1185
- position: 'absolute',
1186
- top: 0,
1187
- left: 0,
1188
- width: '100%',
1189
- height: '100%',
1190
- transform: [{ translateX: delayedTranslateX }],
1191
- }}
1192
- >
1193
- <View
1194
- style={{
1195
- width: skeletonContainerWidth,
1196
- height: '100%',
1197
- backgroundColor: bloomTheme.isDark
1198
- ? 'rgba(255, 255, 255, 0.08)'
1199
- : 'rgba(255, 255, 255, 0.8)',
1200
- shadowColor: bloomTheme.isDark ? '#000' : '#FFF',
1201
- shadowOffset: { width: 0, height: 0 },
1202
- shadowOpacity: 0.3,
1203
- shadowRadius: 10,
1204
- }}
1205
- />
1206
- </Animated.View>
1207
- </View>
1208
- );
1209
- };
1210
-
1211
- // Skeleton file item matching SettingsListItem structure
1212
- const SkeletonFileItem = ({ index }: { index: number }) => (
1213
- <View
1214
- style={[
1215
- {
1216
- flexDirection: 'row',
1217
- alignItems: 'center',
1218
- paddingHorizontal: 16,
1219
- paddingVertical: 12,
1220
- backgroundColor: colors.background,
1221
- borderBottomWidth: StyleSheet.hairlineWidth,
1222
- borderBottomColor: colors.border,
1223
- },
1224
- ]}
1225
- >
1226
- {/* Icon/Image skeleton */}
1227
- <SkeletonBox width={44} height={44} borderRadius={8} delay={index * 50} />
1228
-
1229
- {/* Content skeleton */}
1230
- <View style={{ flex: 1, marginLeft: 12, justifyContent: 'center' }}>
1231
- <SkeletonBox
1232
- width={index % 3 === 0 ? '85%' : index % 3 === 1 ? '70%' : '90%'}
1233
- height={16}
1234
- style={{ marginBottom: 8 }}
1235
- delay={index * 50 + 20}
1236
- />
1237
- <SkeletonBox
1238
- width={index % 2 === 0 ? '50%' : '60%'}
1239
- height={12}
1240
- delay={index * 50 + 40}
1241
- />
1242
- </View>
1243
- </View>
1122
+ // Loading state — Bloom's shared `Skeleton` primitives own the shimmer, so
1123
+ // this is pure layout: the top chrome (header / controls / search) plus a
1124
+ // photo grid of shimmering tiles sized with the same 3-per-row geometry the
1125
+ // real photo grid uses. No hand-rolled `Animated` shimmer.
1126
+ //
1127
+ // The image-only picker owns its OWN loading skeleton (rendered by
1128
+ // `PhotoPickerView` when its photo list is still loading), so it inherits the
1129
+ // picker's placement-aware container width / columns / tile size and the
1130
+ // Dialog-vs-bottom-sheet sizing. Skipping the browse chrome here is what keeps
1131
+ // the picker from flashing the wrong (file-manager) skeleton inside the Dialog.
1132
+ if (loading && !isImageOnlyPicker) {
1133
+ const GRID_PADDING = 10;
1134
+ const TILE_GAP = 4;
1135
+ const GRID_COLUMNS = 3;
1136
+ const GRID_ROWS = 6;
1137
+ const tileSize = Math.floor(
1138
+ (safeContainerWidth - GRID_PADDING * 2 - TILE_GAP * (GRID_COLUMNS - 1)) / GRID_COLUMNS,
1244
1139
  );
1245
1140
 
1246
1141
  return (
1247
1142
  <View className="flex-1" style={{ backgroundColor }}>
1248
- {/* Header Skeleton */}
1143
+ {/* Header */}
1249
1144
  <View
1250
- className="flex-row items-center justify-between px-[16px] py-[12px] relative"
1145
+ className="flex-row items-center justify-between px-[16px] py-[12px]"
1251
1146
  style={{ borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth }}
1252
1147
  >
1253
- <SkeletonBox width={44} height={44} borderRadius={12} />
1254
- <View className="flex-1 items-center justify-center mx-[16px]">
1255
- <SkeletonBox width={140} height={20} style={{ marginBottom: 6 }} />
1256
- <SkeletonBox width={100} height={14} />
1257
- </View>
1258
- <SkeletonBox width={44} height={44} borderRadius={12} />
1148
+ <Skeleton.Box width={44} height={44} borderRadius={12} />
1149
+ <Skeleton.Col style={{ alignItems: 'center', marginHorizontal: 16, gap: 6 }}>
1150
+ <Skeleton.Box width={140} height={20} />
1151
+ <Skeleton.Box width={100} height={14} />
1152
+ </Skeleton.Col>
1153
+ <Skeleton.Box width={44} height={44} borderRadius={12} />
1259
1154
  </View>
1260
1155
 
1261
- {/* Controls Bar Skeleton */}
1262
- <View className="flex-row items-center justify-between px-[12px] py-[12px] gap-[12px]">
1263
- <SkeletonBox width={100} height={36} borderRadius={18} />
1264
- <SkeletonBox width={44} height={44} borderRadius={22} />
1265
- </View>
1156
+ {/* Controls bar */}
1157
+ <Skeleton.Row style={{ alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 12, paddingVertical: 12 }}>
1158
+ <Skeleton.Box width={100} height={36} borderRadius={18} />
1159
+ <Skeleton.Box width={44} height={44} borderRadius={22} />
1160
+ </Skeleton.Row>
1266
1161
 
1267
- {/* Search Bar Skeleton */}
1268
- <View
1269
- className="flex-row items-center px-[14px] py-[10px] mx-[12px] mb-[12px] rounded-full gap-[10px]"
1270
- style={{ backgroundColor: colors.card }}
1271
- >
1272
- <SkeletonBox width="100%" height={44} borderRadius={12} />
1162
+ {/* Search bar */}
1163
+ <View className="mx-[12px] mb-[12px]">
1164
+ <Skeleton.Box width="100%" height={44} borderRadius={22} />
1273
1165
  </View>
1274
1166
 
1275
- {/* Stats Container Skeleton */}
1276
- <View
1277
- className="flex-row px-[14px] py-[10px] mx-[12px] mb-[12px] rounded-[18px]"
1278
- style={{ backgroundColor: colors.card }}
1279
- >
1280
- {[1, 2, 3].map((i) => (
1281
- <View key={i} className="flex-1 items-center py-[4px]">
1282
- <SkeletonBox width={50} height={20} style={{ marginBottom: 4 }} delay={i * 30} />
1283
- <SkeletonBox width={40} height={14} delay={i * 30 + 15} />
1284
- </View>
1167
+ {/* Photo grid */}
1168
+ <View style={{ padding: GRID_PADDING, gap: TILE_GAP }}>
1169
+ {Array.from({ length: GRID_ROWS }, (_, row) => (
1170
+ <Skeleton.Row key={row} style={{ gap: TILE_GAP }}>
1171
+ {Array.from({ length: GRID_COLUMNS }, (_, col) => (
1172
+ <Skeleton.Box key={col} width={tileSize} height={tileSize} borderRadius={6} />
1173
+ ))}
1174
+ </Skeleton.Row>
1285
1175
  ))}
1286
1176
  </View>
1287
-
1288
- {/* File List Skeleton - Matching SettingsListItem */}
1289
- <ScrollView
1290
- className="flex-1"
1291
- contentContainerClassName="px-[12px] pt-0 pb-[12px]"
1292
- showsVerticalScrollIndicator={false}
1293
- >
1294
- <View style={{
1295
- backgroundColor: colors.card,
1296
- borderRadius: 18,
1297
- overflow: 'hidden',
1298
- marginTop: 8,
1299
- }}>
1300
- {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
1301
- <SkeletonFileItem key={i} index={i} />
1302
- ))}
1303
- </View>
1304
- </ScrollView>
1305
1177
  </View>
1306
1178
  );
1307
- });
1308
-
1309
- if (loading) {
1310
- return <SkeletonLoader />;
1311
1179
  }
1312
1180
 
1313
1181
  // Dedicated flagship-style photo picker view used in the avatar-picker
@@ -1335,6 +1203,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
1335
1203
  uploadProgress={uploadProgress}
1336
1204
  hasMore={paging.hasMore}
1337
1205
  loadingMore={paging.loadingMore}
1206
+ loading={loading}
1338
1207
  reduceMotion={reduceMotion}
1339
1208
  getThumbUrl={thumbSourceFor}
1340
1209
  primaryColor={colors.primary}
@@ -14,6 +14,7 @@ import {
14
14
  import { Image as ExpoImage } from 'expo-image';
15
15
  import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
16
16
  import { BREAKPOINTS } from '@oxyhq/bloom/styles';
17
+ import * as Skeleton from '@oxyhq/bloom/skeleton';
17
18
  import type { FileMetadata } from '@oxyhq/core';
18
19
  import { computePhotoGridLayout } from './photoGridLayout';
19
20
 
@@ -122,6 +123,13 @@ export interface PhotoPickerViewProps {
122
123
  uploadProgress: { current: number; total: number } | null;
123
124
  hasMore: boolean;
124
125
  loadingMore: boolean;
126
+ /**
127
+ * Initial-load flag. While `true` AND no photos have arrived yet, the picker
128
+ * renders its OWN loading skeleton (placement-aware grid of shimmer tiles)
129
+ * instead of the empty state — so the loading UI matches the picker's shape
130
+ * and container width, not the browse file-manager chrome.
131
+ */
132
+ loading: boolean;
125
133
  reduceMotion: boolean;
126
134
  getThumbUrl: (file: FileMetadata) => string | undefined;
127
135
  primaryColor: string;
@@ -161,6 +169,18 @@ const RING_SPRING_TENSION = 120;
161
169
  const CELL_CORNER_RADIUS = 8; // `rounded-radius-8`
162
170
  const SELECTION_RING_WIDTH = 3; // `border-[3px]`
163
171
 
172
+ /**
173
+ * Loading-skeleton tile fill. The picker's backdrop is ALWAYS black regardless
174
+ * of theme, so we cannot use Bloom `Skeleton.Box`'s theme default (`contrast50`,
175
+ * which is a near-black gray on a dark theme → invisible on this backdrop). A
176
+ * neutral dark gray reads as a photo placeholder on black; Bloom's shimmer still
177
+ * pulses the tile opacity on top of it. `Skeleton.Box` applies `style` after its
178
+ * own base, so this override wins while the animated opacity is preserved.
179
+ */
180
+ const SKELETON_TILE_COLOR = '#26262A';
181
+ /** Minimum skeleton rows so the grid never looks empty on a very short panel. */
182
+ const SKELETON_MIN_ROWS = 4;
183
+
164
184
  type PhotoPickerCellProps = {
165
185
  photo: FileMetadata;
166
186
  size: number;
@@ -325,6 +345,7 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
325
345
  uploadProgress,
326
346
  hasMore,
327
347
  loadingMore,
348
+ loading,
328
349
  reduceMotion,
329
350
  getThumbUrl,
330
351
  primaryColor,
@@ -366,14 +387,13 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
366
387
  // better below full height); the bottom sheet may run taller. Keep this
367
388
  // breakpoint in sync with `DIALOG_PLACEMENT` in BottomSheetRouter.
368
389
  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
- }),
390
+ const maxPanelHeight = useMemo(
391
+ () => (isCenteredPanel
392
+ ? Math.min(Math.round(windowHeight * 0.85), 720)
393
+ : Math.round(windowHeight * 0.9)),
375
394
  [isCenteredPanel, windowHeight],
376
395
  );
396
+ const rootStyle = useMemo(() => ({ maxHeight: maxPanelHeight }), [maxPanelHeight]);
377
397
 
378
398
  const effectiveWidth = gridWidth > 0 ? gridWidth : windowWidth;
379
399
  const { columns, cellSize, gutter } = useMemo(
@@ -381,6 +401,18 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
381
401
  [effectiveWidth],
382
402
  );
383
403
 
404
+ // How many shimmer tiles the loading skeleton renders: enough FULL rows to
405
+ // fill the panel down to (but never past) its `maxHeight` cap, so the panel
406
+ // hugs the skeleton content instead of overflowing or scrolling. `floor`
407
+ // keeps the last row whole; `SKELETON_MIN_ROWS` guards a very short panel.
408
+ const skeletonTileCount = useMemo(() => {
409
+ const rowHeight = cellSize + gutter;
410
+ if (rowHeight <= 0) return columns * SKELETON_MIN_ROWS;
411
+ const available = Math.max(0, maxPanelHeight - GRID_CONTENT_PADDING_TOP);
412
+ const rows = Math.max(SKELETON_MIN_ROWS, Math.floor(available / rowHeight));
413
+ return rows * columns;
414
+ }, [cellSize, gutter, columns, maxPanelHeight]);
415
+
384
416
  // Map selectedIds → 1-based selection order for the badge. We freeze a
385
417
  // stable order at the time of selection: the order is the insertion
386
418
  // order of the Set (which JS preserves natively).
@@ -515,7 +547,37 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
515
547
  where Yoga already defaults min-height to 0). */}
516
548
  <View className="flex-1 min-h-0 bg-black">
517
549
  {/* Photo grid (renders behind the translucent header). */}
518
- {isEmpty ? (
550
+ {isEmpty && loading ? (
551
+ /* Loading skeleton — the picker's OWN shape: shimmer tiles laid
552
+ out with the SAME placement-aware geometry the real grid uses
553
+ (`computePhotoGridLayout(effectiveWidth)`), pushed below the
554
+ translucent header by the grid content padding. `flexWrap`
555
+ breaks rows exactly at `columns` because the last tile in each
556
+ row carries no right margin (a full row + gutters fits the
557
+ measured width by construction). The real translucent header
558
+ still floats on top, so Cancel stays reachable while loading. */
559
+ <View
560
+ className="pt-[60px]"
561
+ style={{ flexDirection: 'row', flexWrap: 'wrap' }}
562
+ pointerEvents="none"
563
+ accessibilityElementsHidden
564
+ importantForAccessibility="no-hide-descendants"
565
+ >
566
+ {Array.from({ length: skeletonTileCount }, (_, index) => (
567
+ <Skeleton.Box
568
+ key={index}
569
+ width={cellSize}
570
+ height={cellSize}
571
+ borderRadius={CELL_CORNER_RADIUS}
572
+ style={{
573
+ marginRight: (index + 1) % columns === 0 ? 0 : gutter,
574
+ marginBottom: gutter,
575
+ backgroundColor: SKELETON_TILE_COLOR,
576
+ }}
577
+ />
578
+ ))}
579
+ </View>
580
+ ) : isEmpty ? (
519
581
  <View className="flex-1 items-center justify-center px-space-32 pt-[60px]">
520
582
  <View className="opacity-30 mb-space-16">
521
583
  <MaterialCommunityIcons name="image-outline" size={64} color="#FFFFFF" />
@@ -6,6 +6,7 @@ import { useFileStore } from '../../../stores/fileStore';
6
6
  import type { useUploadFile } from '../../../hooks/mutations/useAccountMutations';
7
7
  import { convertDocumentPickerAssetToFile, formatFileSize } from '../../../utils/fileManagement';
8
8
  import {
9
+ createPendingUploadFile,
9
10
  type PendingUploadFile,
10
11
  type UploadCandidate,
11
12
  candidateName,
@@ -52,7 +53,7 @@ export interface UseFileUploadStateResult {
52
53
  handleFileUpload: () => Promise<void>;
53
54
  handleConfirmUpload: () => Promise<void>;
54
55
  handleCancelUpload: () => void;
55
- removePendingFile: (index: number) => void;
56
+ removePendingFile: (id: string) => void;
56
57
  }
57
58
 
58
59
  /**
@@ -305,13 +306,7 @@ export const useFileUploadState = ({
305
306
  }
306
307
  }
307
308
 
308
- processedFiles.push({
309
- file,
310
- preview,
311
- size,
312
- name,
313
- type: fileType
314
- });
309
+ processedFiles.push(createPendingUploadFile(file, preview, size, name, fileType));
315
310
  }
316
311
 
317
312
  if (processedFiles.length === 0) {
@@ -387,12 +382,13 @@ export const useFileUploadState = ({
387
382
  setShowUploadPreview(false);
388
383
  };
389
384
 
390
- const removePendingFile = (index: number) => {
391
- const file = pendingFiles[index];
385
+ const removePendingFile = (id: string) => {
386
+ const file = pendingFiles.find((pf) => pf.id === id);
387
+ if (!file) return;
392
388
  if (file.preview) {
393
389
  URL.revokeObjectURL(file.preview);
394
390
  }
395
- const updated = pendingFiles.filter((_, i) => i !== index);
391
+ const updated = pendingFiles.filter((pf) => pf.id !== id);
396
392
  setPendingFiles(updated);
397
393
  if (updated.length === 0) {
398
394
  setShowUploadPreview(false);
@@ -57,9 +57,32 @@ export const candidateUri = (candidate: UploadCandidate): string | undefined =>
57
57
 
58
58
  /** A processed file ready for review in the upload preview modal. */
59
59
  export interface PendingUploadFile {
60
+ /** Stable row identity for React keys and removal — survives reorder/filter. */
61
+ id: string;
60
62
  file: UploadCandidate;
61
63
  preview?: string;
62
64
  size: number;
63
65
  name: string;
64
66
  type: string;
65
67
  }
68
+
69
+ const createPendingUploadId = (): string =>
70
+ typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
71
+ ? crypto.randomUUID()
72
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
73
+
74
+ /** Mint a {@link PendingUploadFile} row from a validated upload candidate. */
75
+ export const createPendingUploadFile = (
76
+ file: UploadCandidate,
77
+ preview: string | undefined,
78
+ size: number,
79
+ name: string,
80
+ type: string,
81
+ ): PendingUploadFile => ({
82
+ id: createPendingUploadId(),
83
+ file,
84
+ preview,
85
+ size,
86
+ name,
87
+ type,
88
+ });