@oxyhq/services 22.5.0 → 22.6.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 (168) hide show
  1. package/lib/commonjs/index.js +1 -1
  2. package/lib/commonjs/ui/client.js +1 -1
  3. package/lib/commonjs/ui/components/FontLoader.js +18 -145
  4. package/lib/commonjs/ui/components/FontLoader.js.map +1 -1
  5. package/lib/commonjs/ui/components/FontLoader.native.js +88 -0
  6. package/lib/commonjs/ui/components/FontLoader.native.js.map +1 -0
  7. package/lib/commonjs/ui/components/OxyProvider.js +1 -1
  8. package/lib/commonjs/ui/components/fileManagement/FileDetailsModal.js +16 -27
  9. package/lib/commonjs/ui/components/fileManagement/FileDetailsModal.js.map +1 -1
  10. package/lib/commonjs/ui/components/fileManagement/UploadPreview.js.map +1 -1
  11. package/lib/commonjs/ui/components/fileManagement/styles.js +1 -225
  12. package/lib/commonjs/ui/components/fileManagement/styles.js.map +1 -1
  13. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +5 -2
  14. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  15. package/lib/commonjs/ui/context/useOxyAccountGraph.js +7 -0
  16. package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
  17. package/lib/commonjs/ui/hooks/queries/useAccountQueries.js +10 -4
  18. package/lib/commonjs/ui/hooks/queries/useAccountQueries.js.map +1 -1
  19. package/lib/commonjs/ui/hooks/queries/userCache.js +33 -9
  20. package/lib/commonjs/ui/hooks/queries/userCache.js.map +1 -1
  21. package/lib/commonjs/ui/hooks/useAvatarPicker.js +23 -8
  22. package/lib/commonjs/ui/hooks/useAvatarPicker.js.map +1 -1
  23. package/lib/commonjs/ui/hooks/useFileDownloadUrl.js +7 -21
  24. package/lib/commonjs/ui/hooks/useFileDownloadUrl.js.map +1 -1
  25. package/lib/commonjs/ui/hooks/useResolvedFileUrls.js +163 -0
  26. package/lib/commonjs/ui/hooks/useResolvedFileUrls.js.map +1 -0
  27. package/lib/commonjs/ui/index.js +1 -1
  28. package/lib/commonjs/ui/screens/FileManagementScreen.js +51 -22
  29. package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
  30. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js +216 -203
  31. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  32. package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js +26 -9
  33. package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
  34. package/lib/commonjs/ui/screens/fileManagement/photoGridLayout.js +44 -0
  35. package/lib/commonjs/ui/screens/fileManagement/photoGridLayout.js.map +1 -0
  36. package/lib/commonjs/ui/session/tokenTransport.js +21 -10
  37. package/lib/commonjs/ui/session/tokenTransport.js.map +1 -1
  38. package/lib/commonjs/ui/utils/fileManagement.js +0 -40
  39. package/lib/commonjs/ui/utils/fileManagement.js.map +1 -1
  40. package/lib/module/index.js +1 -1
  41. package/lib/module/index.js.map +1 -1
  42. package/lib/module/ui/client.js +1 -1
  43. package/lib/module/ui/client.js.map +1 -1
  44. package/lib/module/ui/components/FontLoader.js +18 -146
  45. package/lib/module/ui/components/FontLoader.js.map +1 -1
  46. package/lib/module/ui/components/FontLoader.native.js +83 -0
  47. package/lib/module/ui/components/FontLoader.native.js.map +1 -0
  48. package/lib/module/ui/components/OxyProvider.js +1 -1
  49. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  50. package/lib/module/ui/components/fileManagement/FileDetailsModal.js +16 -27
  51. package/lib/module/ui/components/fileManagement/FileDetailsModal.js.map +1 -1
  52. package/lib/module/ui/components/fileManagement/UploadPreview.js.map +1 -1
  53. package/lib/module/ui/components/fileManagement/styles.js +0 -224
  54. package/lib/module/ui/components/fileManagement/styles.js.map +1 -1
  55. package/lib/module/ui/context/hooks/useAuthOperations.js +5 -2
  56. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  57. package/lib/module/ui/context/useOxyAccountGraph.js +7 -0
  58. package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
  59. package/lib/module/ui/hooks/queries/useAccountQueries.js +10 -4
  60. package/lib/module/ui/hooks/queries/useAccountQueries.js.map +1 -1
  61. package/lib/module/ui/hooks/queries/userCache.js +33 -9
  62. package/lib/module/ui/hooks/queries/userCache.js.map +1 -1
  63. package/lib/module/ui/hooks/useAvatarPicker.js +23 -8
  64. package/lib/module/ui/hooks/useAvatarPicker.js.map +1 -1
  65. package/lib/module/ui/hooks/useFileDownloadUrl.js +7 -21
  66. package/lib/module/ui/hooks/useFileDownloadUrl.js.map +1 -1
  67. package/lib/module/ui/hooks/useResolvedFileUrls.js +157 -0
  68. package/lib/module/ui/hooks/useResolvedFileUrls.js.map +1 -0
  69. package/lib/module/ui/index.js +1 -1
  70. package/lib/module/ui/index.js.map +1 -1
  71. package/lib/module/ui/screens/FileManagementScreen.js +52 -23
  72. package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
  73. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js +218 -205
  74. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  75. package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js +26 -9
  76. package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
  77. package/lib/module/ui/screens/fileManagement/photoGridLayout.js +39 -0
  78. package/lib/module/ui/screens/fileManagement/photoGridLayout.js.map +1 -0
  79. package/lib/module/ui/session/tokenTransport.js +21 -10
  80. package/lib/module/ui/session/tokenTransport.js.map +1 -1
  81. package/lib/module/ui/utils/fileManagement.js +0 -39
  82. package/lib/module/ui/utils/fileManagement.js.map +1 -1
  83. package/lib/typescript/commonjs/ui/components/FontLoader.d.ts +14 -5
  84. package/lib/typescript/commonjs/ui/components/FontLoader.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/ui/components/FontLoader.native.d.ts +13 -0
  86. package/lib/typescript/commonjs/ui/components/FontLoader.native.d.ts.map +1 -0
  87. package/lib/typescript/commonjs/ui/components/fileManagement/FileDetailsModal.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/ui/components/fileManagement/styles.d.ts +0 -218
  89. package/lib/typescript/commonjs/ui/components/fileManagement/styles.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/ui/hooks/queries/useAccountQueries.d.ts +9 -0
  93. package/lib/typescript/commonjs/ui/hooks/queries/useAccountQueries.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/ui/hooks/queries/userCache.d.ts +3 -0
  95. package/lib/typescript/commonjs/ui/hooks/queries/userCache.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/ui/hooks/useAvatarPicker.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/ui/hooks/useFileDownloadUrl.d.ts +3 -2
  98. package/lib/typescript/commonjs/ui/hooks/useFileDownloadUrl.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/hooks/useResolvedFileUrls.d.ts +48 -0
  100. package/lib/typescript/commonjs/ui/hooks/useResolvedFileUrls.d.ts.map +1 -0
  101. package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts +1 -1
  103. package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/ui/screens/fileManagement/photoGridLayout.d.ts +37 -0
  106. package/lib/typescript/commonjs/ui/screens/fileManagement/photoGridLayout.d.ts.map +1 -0
  107. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +15 -8
  108. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/ui/utils/fileManagement.d.ts +4 -11
  110. package/lib/typescript/commonjs/ui/utils/fileManagement.d.ts.map +1 -1
  111. package/lib/typescript/module/index.d.ts +1 -1
  112. package/lib/typescript/module/index.d.ts.map +1 -1
  113. package/lib/typescript/module/ui/client.d.ts +1 -1
  114. package/lib/typescript/module/ui/client.d.ts.map +1 -1
  115. package/lib/typescript/module/ui/components/FontLoader.d.ts +14 -5
  116. package/lib/typescript/module/ui/components/FontLoader.d.ts.map +1 -1
  117. package/lib/typescript/module/ui/components/FontLoader.native.d.ts +13 -0
  118. package/lib/typescript/module/ui/components/FontLoader.native.d.ts.map +1 -0
  119. package/lib/typescript/module/ui/components/fileManagement/FileDetailsModal.d.ts.map +1 -1
  120. package/lib/typescript/module/ui/components/fileManagement/styles.d.ts +0 -218
  121. package/lib/typescript/module/ui/components/fileManagement/styles.d.ts.map +1 -1
  122. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  123. package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  124. package/lib/typescript/module/ui/hooks/queries/useAccountQueries.d.ts +9 -0
  125. package/lib/typescript/module/ui/hooks/queries/useAccountQueries.d.ts.map +1 -1
  126. package/lib/typescript/module/ui/hooks/queries/userCache.d.ts +3 -0
  127. package/lib/typescript/module/ui/hooks/queries/userCache.d.ts.map +1 -1
  128. package/lib/typescript/module/ui/hooks/useAvatarPicker.d.ts.map +1 -1
  129. package/lib/typescript/module/ui/hooks/useFileDownloadUrl.d.ts +3 -2
  130. package/lib/typescript/module/ui/hooks/useFileDownloadUrl.d.ts.map +1 -1
  131. package/lib/typescript/module/ui/hooks/useResolvedFileUrls.d.ts +48 -0
  132. package/lib/typescript/module/ui/hooks/useResolvedFileUrls.d.ts.map +1 -0
  133. package/lib/typescript/module/ui/index.d.ts +1 -1
  134. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  135. package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
  136. package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts +1 -1
  137. package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  138. package/lib/typescript/module/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
  139. package/lib/typescript/module/ui/screens/fileManagement/photoGridLayout.d.ts +37 -0
  140. package/lib/typescript/module/ui/screens/fileManagement/photoGridLayout.d.ts.map +1 -0
  141. package/lib/typescript/module/ui/session/tokenTransport.d.ts +15 -8
  142. package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -1
  143. package/lib/typescript/module/ui/utils/fileManagement.d.ts +4 -11
  144. package/lib/typescript/module/ui/utils/fileManagement.d.ts.map +1 -1
  145. package/package.json +5 -10
  146. package/src/ui/components/FontLoader.native.tsx +88 -0
  147. package/src/ui/components/FontLoader.tsx +16 -151
  148. package/src/ui/components/fileManagement/FileDetailsModal.tsx +17 -18
  149. package/src/ui/components/fileManagement/UploadPreview.tsx +1 -1
  150. package/src/ui/components/fileManagement/styles.ts +0 -225
  151. package/src/ui/context/hooks/useAuthOperations.ts +5 -2
  152. package/src/ui/context/useOxyAccountGraph.ts +5 -0
  153. package/src/ui/hooks/queries/__tests__/userCache.test.ts +29 -0
  154. package/src/ui/hooks/queries/useAccountQueries.ts +10 -4
  155. package/src/ui/hooks/queries/userCache.ts +31 -8
  156. package/src/ui/hooks/useAvatarPicker.ts +27 -7
  157. package/src/ui/hooks/useFileDownloadUrl.ts +7 -23
  158. package/src/ui/hooks/useResolvedFileUrls.ts +170 -0
  159. package/src/ui/screens/FileManagementScreen.tsx +55 -27
  160. package/src/ui/screens/__tests__/photoGridLayout.test.ts +57 -0
  161. package/src/ui/screens/fileManagement/PhotoPickerSection.tsx +215 -213
  162. package/src/ui/screens/fileManagement/hooks/useFileUploadState.ts +27 -3
  163. package/src/ui/screens/fileManagement/photoGridLayout.ts +45 -0
  164. package/src/ui/session/__tests__/tokenTransport.test.ts +25 -10
  165. package/src/ui/session/tokenTransport.ts +21 -10
  166. package/src/ui/utils/fileManagement.ts +3 -46
  167. package/lib/typescript/types/expo-vector-icons.d.ts +0 -22
  168. package/src/types/expo-vector-icons.d.ts +0 -22
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useMemo, useRef } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -7,7 +7,7 @@ import {
7
7
  RefreshControl,
8
8
  FlatList,
9
9
  Platform,
10
- useWindowDimensions,
10
+ type LayoutChangeEvent,
11
11
  } from 'react-native';
12
12
  import { Image as ExpoImage } from 'expo-image';
13
13
  import Reanimated, {
@@ -20,7 +20,7 @@ import Reanimated, {
20
20
  } from 'react-native-reanimated';
21
21
  import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
22
22
  import type { FileMetadata } from '@oxyhq/core';
23
- import { photoPickerStyles } from '../../components/fileManagement/styles';
23
+ import { computePhotoGridLayout } from './photoGridLayout';
24
24
 
25
25
  /**
26
26
  * Haptic feedback wrapper. `expo-haptics` is an optional dependency — when not
@@ -96,6 +96,22 @@ const hapticNotification = async (type: HapticNotification): Promise<void> => {
96
96
  }
97
97
  };
98
98
 
99
+ /**
100
+ * The bottom sheet renders below the status bar already (its `maxHeight` is
101
+ * capped by `SCREEN_HEIGHT - insets.top`), so the picker MUST NOT add an extra
102
+ * safe-area inset to the header. Header zone from the sheet top:
103
+ * • 28dp drag-handle hit area floats at the very top of the sheet
104
+ * • 56dp app bar sits immediately below the handle
105
+ * Grid content is pushed down by the full header zone + 4dp of breathing room
106
+ * so the first row clears the translucent header. These are fixed layout
107
+ * constants — the equivalent `pt-[28px]` / `min-h-[84px]` / `pt-[88px]` utility
108
+ * classes in the JSX MUST stay in sync with them.
109
+ */
110
+ const HANDLE_ZONE = 28;
111
+ const APP_BAR_HEIGHT = 56;
112
+ const HEADER_HEIGHT = HANDLE_ZONE + APP_BAR_HEIGHT; // 84
113
+ const GRID_CONTENT_PADDING_TOP = HEADER_HEIGHT + 4; // 88
114
+
99
115
  /**
100
116
  * Props for the dedicated photo picker view. Used by FileManagementScreen
101
117
  * only when `selectMode + image-only` is active. All callbacks are wired by
@@ -114,7 +130,7 @@ export interface PhotoPickerViewProps {
114
130
  hasMore: boolean;
115
131
  loadingMore: boolean;
116
132
  reduceMotion: boolean;
117
- getThumbUrl: (file: FileMetadata, variant?: string) => string;
133
+ getThumbUrl: (file: FileMetadata) => string | undefined;
118
134
  primaryColor: string;
119
135
  isOwner: boolean;
120
136
  onTogglePhoto: (photo: FileMetadata) => void;
@@ -143,7 +159,7 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
143
159
  selectionIndex: number; // 1-based for badge; 0 if not selected
144
160
  dim: boolean; // any selection exists and this cell is not selected
145
161
  primaryColor: string;
146
- thumbUrl: string;
162
+ thumbUrl: string | undefined;
147
163
  enterIndex: number;
148
164
  reduceMotion: boolean;
149
165
  onPress: () => void;
@@ -187,12 +203,25 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
187
203
  transform: [{ scale: ringScale.value }],
188
204
  }));
189
205
 
206
+ // Per-tile pixel geometry is derived from the measured grid width, so it
207
+ // stays an inline `style` — NativeWind cannot express a runtime pixel size.
208
+ const cellWrapperStyle = {
209
+ width: size,
210
+ height: size,
211
+ marginRight,
212
+ marginBottom,
213
+ };
214
+
190
215
  const inner = (
191
216
  <>
192
- <View style={[photoPickerStyles.cellInner, dim && photoPickerStyles.cellDim]}>
217
+ <View
218
+ className={`flex-1 rounded-radius-8 overflow-hidden bg-[#111111]${dim ? ' opacity-60' : ''}`}
219
+ >
193
220
  <ExpoImage
194
221
  source={{ uri: thumbUrl }}
195
- style={photoPickerStyles.cellImage}
222
+ // expo-image is a third-party component (no NativeWind
223
+ // className remap), so fill via inline style.
224
+ style={{ width: '100%', height: '100%' }}
196
225
  contentFit="cover"
197
226
  transition={120}
198
227
  cachePolicy="memory-disk"
@@ -202,19 +231,19 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
202
231
  {isSelected && (
203
232
  <Reanimated.View
204
233
  pointerEvents="none"
205
- style={[
206
- photoPickerStyles.cellRing,
207
- { borderColor: primaryColor },
208
- ringAnimatedStyle,
209
- ]}
234
+ className="absolute inset-0 rounded-radius-8 border-[3px]"
235
+ // borderColor is the theme primary (dynamic); the animated
236
+ // scale transform must ride on `style` too.
237
+ style={[{ borderColor: primaryColor }, ringAnimatedStyle]}
210
238
  />
211
239
  )}
212
240
  {isSelected && (
213
241
  <View
214
242
  pointerEvents="none"
215
- style={[photoPickerStyles.cellBadge, { backgroundColor: primaryColor }]}
243
+ className="absolute top-1.5 right-1.5 min-w-[22px] h-[22px] px-1.5 rounded-full items-center justify-center"
244
+ style={{ backgroundColor: primaryColor }}
216
245
  >
217
- <Text style={photoPickerStyles.cellBadgeText}>
246
+ <Text className="text-white text-[12px] font-bold leading-[14px]">
218
247
  {selectionIndex > 0 ? String(selectionIndex) : ''}
219
248
  </Text>
220
249
  </View>
@@ -222,20 +251,14 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
222
251
  </>
223
252
  );
224
253
 
225
- const cellWrapperStyle = {
226
- width: size,
227
- height: size,
228
- marginRight,
229
- marginBottom,
230
- };
231
-
232
254
  if (reduceMotion) {
233
255
  return (
234
256
  <TouchableOpacity
235
257
  activeOpacity={0.85}
236
258
  onPress={onPress}
237
259
  onLongPress={onLongPress}
238
- style={[photoPickerStyles.cellWrapper, cellWrapperStyle]}
260
+ className="relative"
261
+ style={cellWrapperStyle}
239
262
  accessibilityRole="button"
240
263
  accessibilityLabel={a11yLabel}
241
264
  accessibilityState={{ selected: isSelected }}
@@ -248,13 +271,14 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
248
271
  return (
249
272
  <Reanimated.View
250
273
  entering={FadeIn.delay(delay).duration(200)}
251
- style={[photoPickerStyles.cellWrapper, cellWrapperStyle]}
274
+ className="relative"
275
+ style={cellWrapperStyle}
252
276
  >
253
277
  <TouchableOpacity
254
278
  activeOpacity={0.85}
255
279
  onPress={onPress}
256
280
  onLongPress={onLongPress}
257
- style={{ flex: 1 }}
281
+ className="flex-1"
258
282
  accessibilityRole="button"
259
283
  accessibilityLabel={a11yLabel}
260
284
  accessibilityState={{ selected: isSelected }}
@@ -290,23 +314,21 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
290
314
  onConfirm,
291
315
  t,
292
316
  }) => {
293
- const { width: windowWidth, height: windowHeight } = useWindowDimensions();
294
-
295
- // Layout: 3 columns phone portrait, 2 columns phone landscape,
296
- // 4 columns tablet (>= 600 width). Apple Photos-ish.
297
- const columns = useMemo(() => {
298
- if (windowWidth >= 600) return 4;
299
- // Landscape phone: width > height
300
- if (windowWidth > windowHeight) return 2;
301
- return 3;
302
- }, [windowWidth, windowHeight]);
303
-
304
- const GUTTER = 2;
305
- const cellSize = useMemo(() => {
306
- // (windowWidth - (columns - 1) * GUTTER) / columns, but
307
- // FlatList in 3-col layout means each row has 2 inter-cell gutters.
308
- return Math.floor((windowWidth - GUTTER * (columns - 1)) / columns);
309
- }, [windowWidth, columns]);
317
+ // Measure the grid CONTAINER (the bottom-sheet content width), NOT the
318
+ // device screen. On wide viewports the sheet is centered/narrower than the
319
+ // screen, so screen-width tile math overflows. The measured wrapper carries
320
+ // ONLY a `style` (no `className`) because RN-Web does not fire `onLayout`
321
+ // for `className`'d nodes.
322
+ const [gridWidth, setGridWidth] = useState(0);
323
+ const onRootLayout = useCallback((e: LayoutChangeEvent) => {
324
+ const w = Math.round(e.nativeEvent.layout.width);
325
+ setGridWidth((prev) => (prev === w ? prev : w));
326
+ }, []);
327
+
328
+ const { columns, cellSize, gutter } = useMemo(
329
+ () => computePhotoGridLayout(gridWidth),
330
+ [gridWidth],
331
+ );
310
332
 
311
333
  // Map selectedIds → 1-based selection order for the badge. We freeze a
312
334
  // stable order at the time of selection: the order is the insertion
@@ -322,28 +344,14 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
322
344
 
323
345
  const hasAnySelection = selectedIds.size > 0;
324
346
 
325
- // Compact icon-only upload pill on narrow screens; full pill otherwise.
326
- const showUploadLabel = windowWidth >= 360;
327
-
328
- // The bottom sheet renders below the status bar already (its `maxHeight`
329
- // is capped by `SCREEN_HEIGHT - insets.top`), so the picker MUST NOT add
330
- // an additional safe-area inset to the header. Header layout:
331
- // • 28dp drag-handle hit area floats at the very top of the sheet
332
- // • 56dp app bar sits immediately below the handle
333
- // Total header zone = 28 + 56 = 84dp from sheet top.
334
- const HANDLE_ZONE = 28;
335
- const APP_BAR_HEIGHT = 56;
336
- const headerHeight = HANDLE_ZONE + APP_BAR_HEIGHT;
337
- const contentPaddingTop = headerHeight + 4;
347
+ // Compact icon-only upload pill on narrow sheets; full pill otherwise.
348
+ // Defaults to the full label until the sheet width has been measured.
349
+ const showUploadLabel = gridWidth === 0 || gridWidth >= 360;
338
350
 
339
351
  const isEmpty = photos.length === 0;
340
- const a11yColumnsAnnouncement = useRef<number>(columns);
341
-
342
- useEffect(() => {
343
- if (a11yColumnsAnnouncement.current !== columns) {
344
- a11yColumnsAnnouncement.current = columns;
345
- }
346
- }, [columns]);
352
+ // Only mount the grid once a real container width is known, so columns /
353
+ // tile size derive from the sheet width instead of a placeholder.
354
+ const gridReady = gridWidth > 0 && cellSize > 0;
347
355
 
348
356
  const handleCellPress = useCallback(
349
357
  (photo: FileMetadata) => {
@@ -393,13 +401,13 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
393
401
  <PhotoPickerCell
394
402
  photo={item}
395
403
  size={cellSize}
396
- marginRight={isLastInRow ? 0 : GUTTER}
397
- marginBottom={GUTTER}
404
+ marginRight={isLastInRow ? 0 : gutter}
405
+ marginBottom={gutter}
398
406
  isSelected={isSelected}
399
407
  selectionIndex={selIndex}
400
408
  dim={multiSelect && hasAnySelection && !isSelected}
401
409
  primaryColor={primaryColor}
402
- thumbUrl={getThumbUrl(item, 'thumb')}
410
+ thumbUrl={getThumbUrl(item)}
403
411
  enterIndex={index}
404
412
  reduceMotion={reduceMotion}
405
413
  onPress={() => handleCellPress(item)}
@@ -409,7 +417,7 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
409
417
  );
410
418
  },
411
419
  [
412
- selectedIds, selectionOrder, columns, cellSize, multiSelect, hasAnySelection,
420
+ selectedIds, selectionOrder, columns, cellSize, gutter, multiSelect, hasAnySelection,
413
421
  primaryColor, getThumbUrl, reduceMotion, handleCellPress, handleCellLongPress, t,
414
422
  ],
415
423
  );
@@ -419,7 +427,7 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
419
427
  const listFooter = useMemo(() => {
420
428
  if (!loadingMore) return null;
421
429
  return (
422
- <View style={{ paddingVertical: 16, alignItems: 'center' }}>
430
+ <View className="py-space-16 items-center">
423
431
  <ActivityIndicator size="small" color="#FFFFFF" />
424
432
  </View>
425
433
  );
@@ -441,168 +449,162 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
441
449
  : 0;
442
450
 
443
451
  return (
444
- <View style={photoPickerStyles.root}>
445
- {/* Photo grid (renders behind translucent header) */}
446
- {isEmpty ? (
447
- <View style={[photoPickerStyles.empty, { paddingTop: contentPaddingTop }]}>
448
- <View style={photoPickerStyles.emptyIconWrap}>
449
- <MaterialCommunityIcons name="image-outline" size={64} color="#FFFFFF" />
452
+ // Measured wrapper: `style`-only (no className) so RN-Web fires onLayout.
453
+ <View style={{ flex: 1 }} onLayout={onRootLayout}>
454
+ <View className="flex-1 bg-black">
455
+ {/* Photo grid (renders behind translucent header) */}
456
+ {isEmpty ? (
457
+ <View className="flex-1 items-center justify-center px-space-32 pt-[88px]">
458
+ <View className="opacity-30 mb-space-16">
459
+ <MaterialCommunityIcons name="image-outline" size={64} color="#FFFFFF" />
460
+ </View>
461
+ <Text className="text-white text-[17px] font-semibold text-center mb-1.5">
462
+ {t('fileManagement.photoPicker.emptyTitle')}
463
+ </Text>
464
+ <Text className="text-white text-[14px] font-normal text-center opacity-70 mb-space-24">
465
+ {t('fileManagement.photoPicker.emptySubtitle')}
466
+ </Text>
467
+ {isOwner && allowUpload && (
468
+ <TouchableOpacity
469
+ className="flex-row items-center gap-space-8 px-[22px] py-space-12 rounded-full"
470
+ style={{ backgroundColor: primaryColor }}
471
+ onPress={onUpload}
472
+ disabled={uploading || isPickingDocument}
473
+ accessibilityRole="button"
474
+ accessibilityLabel={t('fileManagement.uploadPhoto')}
475
+ accessibilityState={{ busy: uploading || isPickingDocument }}
476
+ >
477
+ {(uploading || isPickingDocument) ? (
478
+ <ActivityIndicator size="small" color="#FFFFFF" />
479
+ ) : (
480
+ <>
481
+ <Ionicons name="cloud-upload" size={18} color="#FFFFFF" />
482
+ <Text className="text-white text-[15px] font-semibold">
483
+ {t('fileManagement.uploadPhoto')}
484
+ </Text>
485
+ </>
486
+ )}
487
+ </TouchableOpacity>
488
+ )}
450
489
  </View>
451
- <Text style={[photoPickerStyles.emptyTitle, { color: '#FFFFFF' }]}>
452
- {t('fileManagement.photoPicker.emptyTitle')}
453
- </Text>
454
- <Text style={[photoPickerStyles.emptySubtitle, { color: '#FFFFFF' }]}>
455
- {t('fileManagement.photoPicker.emptySubtitle')}
456
- </Text>
457
- {isOwner && allowUpload && (
458
- <TouchableOpacity
459
- style={[photoPickerStyles.emptyCta, { backgroundColor: primaryColor }]}
460
- onPress={onUpload}
461
- disabled={uploading || isPickingDocument}
462
- accessibilityRole="button"
463
- accessibilityLabel={t('fileManagement.uploadPhoto')}
464
- accessibilityState={{ busy: uploading || isPickingDocument }}
465
- >
466
- {(uploading || isPickingDocument) ? (
467
- <ActivityIndicator size="small" color="#FFFFFF" />
468
- ) : (
469
- <>
470
- <Ionicons name="cloud-upload" size={18} color="#FFFFFF" />
471
- <Text style={photoPickerStyles.emptyCtaText}>
472
- {t('fileManagement.uploadPhoto')}
473
- </Text>
474
- </>
475
- )}
476
- </TouchableOpacity>
477
- )}
478
- </View>
479
- ) : (
480
- <FlatList
481
- key={`cols-${columns}`}
482
- data={photos}
483
- renderItem={renderItem}
484
- keyExtractor={keyExtractor}
485
- numColumns={columns}
486
- contentContainerStyle={[
487
- photoPickerStyles.gridContent,
488
- { paddingTop: contentPaddingTop },
489
- ]}
490
- style={photoPickerStyles.grid}
491
- showsVerticalScrollIndicator={false}
492
- refreshControl={
493
- <RefreshControl
494
- refreshing={refreshing}
495
- onRefresh={onRefresh}
496
- tintColor="#FFFFFF"
497
- colors={[primaryColor]}
498
- progressViewOffset={contentPaddingTop}
490
+ ) : (
491
+ gridReady && (
492
+ <FlatList
493
+ key={`cols-${columns}`}
494
+ data={photos}
495
+ renderItem={renderItem}
496
+ keyExtractor={keyExtractor}
497
+ numColumns={columns}
498
+ className="flex-1"
499
+ contentContainerClassName="pt-[88px] pb-space-24"
500
+ showsVerticalScrollIndicator={false}
501
+ refreshControl={
502
+ <RefreshControl
503
+ refreshing={refreshing}
504
+ onRefresh={onRefresh}
505
+ tintColor="#FFFFFF"
506
+ colors={[primaryColor]}
507
+ progressViewOffset={GRID_CONTENT_PADDING_TOP}
508
+ />
509
+ }
510
+ onEndReached={handleEndReached}
511
+ onEndReachedThreshold={0.4}
512
+ ListFooterComponent={listFooter}
513
+ removeClippedSubviews
514
+ initialNumToRender={Math.max(12, columns * 6)}
515
+ windowSize={9}
499
516
  />
500
- }
501
- onEndReached={handleEndReached}
502
- onEndReachedThreshold={0.4}
503
- ListFooterComponent={listFooter}
504
- removeClippedSubviews
505
- initialNumToRender={Math.max(12, columns * 6)}
506
- windowSize={9}
507
- />
508
- )}
517
+ )
518
+ )}
509
519
 
510
- {/* Translucent black header. The bottom sheet already sits below
511
- the status bar, so we do NOT add `insets.top` here — that would
512
- double-pad. `paddingTop: HANDLE_ZONE` clears the 28dp drag
513
- handle floating at the top of the sheet. */}
514
- <View
515
- style={[
516
- photoPickerStyles.header,
517
- { paddingTop: HANDLE_ZONE, minHeight: headerHeight },
518
- ]}
519
- >
520
- <View style={photoPickerStyles.headerRow}>
521
- <View style={[photoPickerStyles.headerSide, photoPickerStyles.headerSideLeft]}>
522
- <TouchableOpacity
523
- onPress={onCancel}
524
- style={photoPickerStyles.headerCancel}
525
- accessibilityRole="button"
526
- accessibilityLabel={t('fileManagement.a11y.cancelPicker')}
527
- hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
528
- >
529
- <Text style={photoPickerStyles.headerCancelText}>
530
- {t('fileManagement.cancel')}
531
- </Text>
532
- </TouchableOpacity>
533
- </View>
534
- <View pointerEvents="none">
535
- <Text style={photoPickerStyles.headerTitle} numberOfLines={1}>
536
- {t('fileManagement.choosePhoto')}
537
- </Text>
538
- </View>
539
- <View style={[photoPickerStyles.headerSide, photoPickerStyles.headerSideRight]}>
540
- {multiSelect ? (
520
+ {/* Translucent black header. The bottom sheet already sits below
521
+ the status bar, so we do NOT add `insets.top` — that would
522
+ double-pad. `pt-[28px]` clears the 28dp drag handle floating
523
+ at the top of the sheet; `min-h-[84px]` = handle + app bar. */}
524
+ <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]">
525
+ <View className="flex-row items-center justify-between w-full h-14">
526
+ <View className="basis-0 grow flex-row items-center justify-start">
541
527
  <TouchableOpacity
542
- onPress={handleConfirm}
543
- disabled={confirmDisabled}
544
- style={[
545
- photoPickerStyles.headerPrimaryPill,
546
- {
547
- backgroundColor: confirmDisabled
548
- ? 'rgba(255,255,255,0.18)'
549
- : primaryColor,
550
- opacity: confirmDisabled ? 0.6 : 1,
551
- },
552
- ]}
528
+ onPress={onCancel}
529
+ className="px-1.5 py-space-8 min-h-9 min-w-11 justify-center"
553
530
  accessibilityRole="button"
554
- accessibilityLabel={t('fileManagement.a11y.confirmSelection')}
555
- accessibilityState={{ disabled: confirmDisabled }}
531
+ accessibilityLabel={t('fileManagement.a11y.cancelPicker')}
532
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
556
533
  >
557
- <Text style={photoPickerStyles.headerPrimaryText}>
558
- {confirmLabel}
534
+ <Text className="text-white text-[17px] font-medium">
535
+ {t('fileManagement.cancel')}
559
536
  </Text>
560
537
  </TouchableOpacity>
561
- ) : (
562
- isOwner && allowUpload && (
538
+ </View>
539
+ <View pointerEvents="none">
540
+ <Text
541
+ className="text-white text-[17px] font-semibold text-center tracking-[-0.2px]"
542
+ numberOfLines={1}
543
+ >
544
+ {t('fileManagement.choosePhoto')}
545
+ </Text>
546
+ </View>
547
+ <View className="basis-0 grow flex-row items-center justify-end">
548
+ {multiSelect ? (
563
549
  <TouchableOpacity
564
- onPress={onUpload}
565
- disabled={uploading || isPickingDocument}
566
- style={[
567
- photoPickerStyles.headerPrimaryPill,
568
- !showUploadLabel && photoPickerStyles.headerPrimaryPillIconOnly,
569
- { backgroundColor: primaryColor },
570
- ]}
550
+ onPress={handleConfirm}
551
+ disabled={confirmDisabled}
552
+ className="flex-row items-center gap-1.5 px-3.5 py-space-8 rounded-full min-h-9"
553
+ style={{
554
+ backgroundColor: confirmDisabled
555
+ ? 'rgba(255,255,255,0.18)'
556
+ : primaryColor,
557
+ opacity: confirmDisabled ? 0.6 : 1,
558
+ }}
571
559
  accessibilityRole="button"
572
- accessibilityLabel={t('fileManagement.a11y.uploadFromDevice')}
573
- accessibilityState={{ busy: uploading || isPickingDocument }}
560
+ accessibilityLabel={t('fileManagement.a11y.confirmSelection')}
561
+ accessibilityState={{ disabled: confirmDisabled }}
574
562
  >
575
- {(uploading || isPickingDocument) ? (
576
- <ActivityIndicator size="small" color="#FFFFFF" />
577
- ) : (
578
- <>
579
- <Ionicons name="cloud-upload" size={16} color="#FFFFFF" />
580
- {showUploadLabel && (
581
- <Text style={photoPickerStyles.headerPrimaryText}>
582
- {t('fileManagement.upload')}
583
- </Text>
584
- )}
585
- </>
586
- )}
563
+ <Text className="text-white text-[15px] font-semibold">
564
+ {confirmLabel}
565
+ </Text>
587
566
  </TouchableOpacity>
588
- )
589
- )}
567
+ ) : (
568
+ isOwner && allowUpload && (
569
+ <TouchableOpacity
570
+ onPress={onUpload}
571
+ disabled={uploading || isPickingDocument}
572
+ className={`flex-row items-center gap-1.5 py-space-8 rounded-full min-h-9${showUploadLabel ? ' px-3.5' : ' px-space-8 w-9 justify-center'}`}
573
+ style={{ backgroundColor: primaryColor }}
574
+ accessibilityRole="button"
575
+ accessibilityLabel={t('fileManagement.a11y.uploadFromDevice')}
576
+ accessibilityState={{ busy: uploading || isPickingDocument }}
577
+ >
578
+ {(uploading || isPickingDocument) ? (
579
+ <ActivityIndicator size="small" color="#FFFFFF" />
580
+ ) : (
581
+ <>
582
+ <Ionicons name="cloud-upload" size={16} color="#FFFFFF" />
583
+ {showUploadLabel && (
584
+ <Text className="text-white text-[15px] font-semibold">
585
+ {t('fileManagement.upload')}
586
+ </Text>
587
+ )}
588
+ </>
589
+ )}
590
+ </TouchableOpacity>
591
+ )
592
+ )}
593
+ </View>
590
594
  </View>
591
- </View>
592
- {/* Subtle top progress bar during upload (non-blocking). */}
593
- {uploading && (
594
- <View style={photoPickerStyles.headerProgressBarTrack}>
595
- <View
596
- style={[
597
- photoPickerStyles.headerProgressBarFill,
598
- {
595
+ {/* Subtle top progress bar during upload (non-blocking). */}
596
+ {uploading && (
597
+ <View className="absolute left-0 right-0 bottom-0 h-0.5 bg-[#FFFFFF1F]">
598
+ <View
599
+ className="h-full"
600
+ style={{
599
601
  width: `${Math.round(progressFraction * 100)}%`,
600
602
  backgroundColor: primaryColor,
601
- },
602
- ]}
603
- />
604
- </View>
605
- )}
603
+ }}
604
+ />
605
+ </View>
606
+ )}
607
+ </View>
606
608
  </View>
607
609
  </View>
608
610
  );
@@ -16,6 +16,17 @@ import {
16
16
  loadDocumentPicker,
17
17
  } from '../shared';
18
18
 
19
+ /**
20
+ * Optimistic-entry metadata. `uploading` flags the placeholder so no asset URL
21
+ * is ever built from its client-minted `temp-…` id; `localPreviewUri` lets the
22
+ * grid render the locally-picked image while the real upload is in flight (a
23
+ * bare guard would leave an empty tile). See `useResolvedFileUrls`.
24
+ */
25
+ type OptimisticFileMetadata = NonNullable<FileMetadata['metadata']> & {
26
+ uploading?: boolean;
27
+ localPreviewUri?: string;
28
+ };
29
+
19
30
  /** Dependencies threaded into the upload-state hook from the orchestrator. */
20
31
  export interface UseFileUploadStateParams {
21
32
  targetUserId?: string;
@@ -121,6 +132,15 @@ export const useFileUploadState = ({
121
132
  continue;
122
133
  }
123
134
 
135
+ // Preserve the locally-picked uri so the optimistic tile
136
+ // shows the real image (not an empty placeholder) while the
137
+ // upload is in flight. Never an asset URL — the id is a
138
+ // client-minted `temp-…`.
139
+ const previewUri = candidateUri(raw);
140
+ const optimisticMetadata: OptimisticFileMetadata = {
141
+ uploading: true,
142
+ ...(previewUri ? { localPreviewUri: previewUri } : {}),
143
+ };
124
144
  const optimisticFile: FileMetadata = {
125
145
  id: optimisticId,
126
146
  filename: fileName,
@@ -128,7 +148,7 @@ export const useFileUploadState = ({
128
148
  length: fileSize,
129
149
  chunkSize: 0,
130
150
  uploadDate: new Date().toISOString(),
131
- metadata: { uploading: true },
151
+ metadata: optimisticMetadata,
132
152
  variants: [],
133
153
  };
134
154
  useFileStore.getState().addFile(optimisticFile, { prepend: true });
@@ -158,8 +178,12 @@ export const useFileUploadState = ({
158
178
  uploadedFiles.push(merged);
159
179
  successCount++;
160
180
  } else {
161
- // Fallback: will reconcile on later list refresh
162
- useFileStore.getState().updateFile(optimisticId, { metadata: { uploading: false } as Partial<FileMetadata>['metadata'] });
181
+ // Upload succeeded but the API returned no file payload,
182
+ // so we cannot build a real entry. Drop the optimistic
183
+ // placeholder instead of stranding a `temp-…` id (which
184
+ // would leak into asset URLs); the scheduled silent
185
+ // reconcile below re-fetches the persisted record.
186
+ useFileStore.getState().removeFile(optimisticId);
163
187
  if (__DEV__) {
164
188
  console.warn('Upload completed but no file data returned:', { fileName, result });
165
189
  }