@oxyhq/services 22.5.0 → 22.6.1

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 (179) 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 +34 -41
  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/components/photogrid/JustifiedPhotoGrid.js +0 -2
  14. package/lib/commonjs/ui/components/photogrid/JustifiedPhotoGrid.js.map +1 -1
  15. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +5 -2
  16. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  17. package/lib/commonjs/ui/context/useOxyAccountGraph.js +7 -0
  18. package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
  19. package/lib/commonjs/ui/hooks/queries/useAccountQueries.js +10 -4
  20. package/lib/commonjs/ui/hooks/queries/useAccountQueries.js.map +1 -1
  21. package/lib/commonjs/ui/hooks/queries/userCache.js +33 -9
  22. package/lib/commonjs/ui/hooks/queries/userCache.js.map +1 -1
  23. package/lib/commonjs/ui/hooks/useAvatarPicker.js +23 -8
  24. package/lib/commonjs/ui/hooks/useAvatarPicker.js.map +1 -1
  25. package/lib/commonjs/ui/hooks/useFileDownloadUrl.js +7 -21
  26. package/lib/commonjs/ui/hooks/useFileDownloadUrl.js.map +1 -1
  27. package/lib/commonjs/ui/hooks/useResolvedFileUrls.js +163 -0
  28. package/lib/commonjs/ui/hooks/useResolvedFileUrls.js.map +1 -0
  29. package/lib/commonjs/ui/index.js +1 -1
  30. package/lib/commonjs/ui/screens/FileManagementScreen.js +51 -22
  31. package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
  32. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js +227 -204
  33. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  34. package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js +26 -9
  35. package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
  36. package/lib/commonjs/ui/screens/fileManagement/photoGridLayout.js +44 -0
  37. package/lib/commonjs/ui/screens/fileManagement/photoGridLayout.js.map +1 -0
  38. package/lib/commonjs/ui/session/tokenTransport.js +21 -10
  39. package/lib/commonjs/ui/session/tokenTransport.js.map +1 -1
  40. package/lib/commonjs/ui/utils/fileManagement.js +3 -43
  41. package/lib/commonjs/ui/utils/fileManagement.js.map +1 -1
  42. package/lib/module/index.js +1 -1
  43. package/lib/module/index.js.map +1 -1
  44. package/lib/module/ui/client.js +1 -1
  45. package/lib/module/ui/client.js.map +1 -1
  46. package/lib/module/ui/components/FontLoader.js +18 -146
  47. package/lib/module/ui/components/FontLoader.js.map +1 -1
  48. package/lib/module/ui/components/FontLoader.native.js +83 -0
  49. package/lib/module/ui/components/FontLoader.native.js.map +1 -0
  50. package/lib/module/ui/components/OxyProvider.js +1 -1
  51. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  52. package/lib/module/ui/components/fileManagement/FileDetailsModal.js +34 -41
  53. package/lib/module/ui/components/fileManagement/FileDetailsModal.js.map +1 -1
  54. package/lib/module/ui/components/fileManagement/UploadPreview.js.map +1 -1
  55. package/lib/module/ui/components/fileManagement/styles.js +0 -224
  56. package/lib/module/ui/components/fileManagement/styles.js.map +1 -1
  57. package/lib/module/ui/components/photogrid/JustifiedPhotoGrid.js +0 -2
  58. package/lib/module/ui/components/photogrid/JustifiedPhotoGrid.js.map +1 -1
  59. package/lib/module/ui/context/hooks/useAuthOperations.js +5 -2
  60. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  61. package/lib/module/ui/context/useOxyAccountGraph.js +7 -0
  62. package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
  63. package/lib/module/ui/hooks/queries/useAccountQueries.js +10 -4
  64. package/lib/module/ui/hooks/queries/useAccountQueries.js.map +1 -1
  65. package/lib/module/ui/hooks/queries/userCache.js +33 -9
  66. package/lib/module/ui/hooks/queries/userCache.js.map +1 -1
  67. package/lib/module/ui/hooks/useAvatarPicker.js +23 -8
  68. package/lib/module/ui/hooks/useAvatarPicker.js.map +1 -1
  69. package/lib/module/ui/hooks/useFileDownloadUrl.js +7 -21
  70. package/lib/module/ui/hooks/useFileDownloadUrl.js.map +1 -1
  71. package/lib/module/ui/hooks/useResolvedFileUrls.js +157 -0
  72. package/lib/module/ui/hooks/useResolvedFileUrls.js.map +1 -0
  73. package/lib/module/ui/index.js +1 -1
  74. package/lib/module/ui/index.js.map +1 -1
  75. package/lib/module/ui/screens/FileManagementScreen.js +52 -23
  76. package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
  77. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js +229 -206
  78. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  79. package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js +26 -9
  80. package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
  81. package/lib/module/ui/screens/fileManagement/photoGridLayout.js +39 -0
  82. package/lib/module/ui/screens/fileManagement/photoGridLayout.js.map +1 -0
  83. package/lib/module/ui/session/tokenTransport.js +21 -10
  84. package/lib/module/ui/session/tokenTransport.js.map +1 -1
  85. package/lib/module/ui/utils/fileManagement.js +3 -42
  86. package/lib/module/ui/utils/fileManagement.js.map +1 -1
  87. package/lib/typescript/commonjs/ui/components/FontLoader.d.ts +14 -5
  88. package/lib/typescript/commonjs/ui/components/FontLoader.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/ui/components/FontLoader.native.d.ts +13 -0
  90. package/lib/typescript/commonjs/ui/components/FontLoader.native.d.ts.map +1 -0
  91. package/lib/typescript/commonjs/ui/components/fileManagement/FileDetailsModal.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/ui/components/fileManagement/styles.d.ts +0 -218
  93. package/lib/typescript/commonjs/ui/components/fileManagement/styles.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/ui/components/photogrid/JustifiedPhotoGrid.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +2 -2
  98. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/hooks/queries/useAccountQueries.d.ts +9 -0
  100. package/lib/typescript/commonjs/ui/hooks/queries/useAccountQueries.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/ui/hooks/queries/userCache.d.ts +3 -0
  102. package/lib/typescript/commonjs/ui/hooks/queries/userCache.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/ui/hooks/useAvatarPicker.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/ui/hooks/useFileDownloadUrl.d.ts +3 -2
  105. package/lib/typescript/commonjs/ui/hooks/useFileDownloadUrl.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/hooks/useResolvedFileUrls.d.ts +48 -0
  107. package/lib/typescript/commonjs/ui/hooks/useResolvedFileUrls.d.ts.map +1 -0
  108. package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts +1 -1
  110. package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/ui/screens/fileManagement/photoGridLayout.d.ts +37 -0
  113. package/lib/typescript/commonjs/ui/screens/fileManagement/photoGridLayout.d.ts.map +1 -0
  114. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +15 -8
  115. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/ui/utils/fileManagement.d.ts +4 -11
  117. package/lib/typescript/commonjs/ui/utils/fileManagement.d.ts.map +1 -1
  118. package/lib/typescript/module/index.d.ts +1 -1
  119. package/lib/typescript/module/index.d.ts.map +1 -1
  120. package/lib/typescript/module/ui/client.d.ts +1 -1
  121. package/lib/typescript/module/ui/client.d.ts.map +1 -1
  122. package/lib/typescript/module/ui/components/FontLoader.d.ts +14 -5
  123. package/lib/typescript/module/ui/components/FontLoader.d.ts.map +1 -1
  124. package/lib/typescript/module/ui/components/FontLoader.native.d.ts +13 -0
  125. package/lib/typescript/module/ui/components/FontLoader.native.d.ts.map +1 -0
  126. package/lib/typescript/module/ui/components/fileManagement/FileDetailsModal.d.ts.map +1 -1
  127. package/lib/typescript/module/ui/components/fileManagement/styles.d.ts +0 -218
  128. package/lib/typescript/module/ui/components/fileManagement/styles.d.ts.map +1 -1
  129. package/lib/typescript/module/ui/components/photogrid/JustifiedPhotoGrid.d.ts.map +1 -1
  130. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  131. package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +2 -2
  133. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  134. package/lib/typescript/module/ui/hooks/queries/useAccountQueries.d.ts +9 -0
  135. package/lib/typescript/module/ui/hooks/queries/useAccountQueries.d.ts.map +1 -1
  136. package/lib/typescript/module/ui/hooks/queries/userCache.d.ts +3 -0
  137. package/lib/typescript/module/ui/hooks/queries/userCache.d.ts.map +1 -1
  138. package/lib/typescript/module/ui/hooks/useAvatarPicker.d.ts.map +1 -1
  139. package/lib/typescript/module/ui/hooks/useFileDownloadUrl.d.ts +3 -2
  140. package/lib/typescript/module/ui/hooks/useFileDownloadUrl.d.ts.map +1 -1
  141. package/lib/typescript/module/ui/hooks/useResolvedFileUrls.d.ts +48 -0
  142. package/lib/typescript/module/ui/hooks/useResolvedFileUrls.d.ts.map +1 -0
  143. package/lib/typescript/module/ui/index.d.ts +1 -1
  144. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  145. package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
  146. package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts +1 -1
  147. package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  148. package/lib/typescript/module/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
  149. package/lib/typescript/module/ui/screens/fileManagement/photoGridLayout.d.ts +37 -0
  150. package/lib/typescript/module/ui/screens/fileManagement/photoGridLayout.d.ts.map +1 -0
  151. package/lib/typescript/module/ui/session/tokenTransport.d.ts +15 -8
  152. package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -1
  153. package/lib/typescript/module/ui/utils/fileManagement.d.ts +4 -11
  154. package/lib/typescript/module/ui/utils/fileManagement.d.ts.map +1 -1
  155. package/package.json +5 -10
  156. package/src/ui/components/FontLoader.native.tsx +88 -0
  157. package/src/ui/components/FontLoader.tsx +16 -151
  158. package/src/ui/components/fileManagement/FileDetailsModal.tsx +29 -28
  159. package/src/ui/components/fileManagement/UploadPreview.tsx +1 -1
  160. package/src/ui/components/fileManagement/styles.ts +0 -225
  161. package/src/ui/components/photogrid/JustifiedPhotoGrid.tsx +0 -1
  162. package/src/ui/context/hooks/useAuthOperations.ts +5 -2
  163. package/src/ui/context/useOxyAccountGraph.ts +5 -0
  164. package/src/ui/hooks/queries/__tests__/userCache.test.ts +29 -0
  165. package/src/ui/hooks/queries/useAccountQueries.ts +10 -4
  166. package/src/ui/hooks/queries/userCache.ts +31 -8
  167. package/src/ui/hooks/useAvatarPicker.ts +27 -7
  168. package/src/ui/hooks/useFileDownloadUrl.ts +7 -23
  169. package/src/ui/hooks/useResolvedFileUrls.ts +170 -0
  170. package/src/ui/screens/FileManagementScreen.tsx +55 -27
  171. package/src/ui/screens/__tests__/photoGridLayout.test.ts +57 -0
  172. package/src/ui/screens/fileManagement/PhotoPickerSection.tsx +229 -215
  173. package/src/ui/screens/fileManagement/hooks/useFileUploadState.ts +27 -3
  174. package/src/ui/screens/fileManagement/photoGridLayout.ts +45 -0
  175. package/src/ui/session/__tests__/tokenTransport.test.ts +25 -10
  176. package/src/ui/session/tokenTransport.ts +21 -10
  177. package/src/ui/utils/fileManagement.ts +8 -49
  178. package/lib/typescript/types/expo-vector-icons.d.ts +0 -22
  179. 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,16 @@ 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;
355
+ const atSelectionLimit =
356
+ multiSelect && maxSelection != null && selectedIds.size >= maxSelection;
347
357
 
348
358
  const handleCellPress = useCallback(
349
359
  (photo: FileMetadata) => {
@@ -393,13 +403,17 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
393
403
  <PhotoPickerCell
394
404
  photo={item}
395
405
  size={cellSize}
396
- marginRight={isLastInRow ? 0 : GUTTER}
397
- marginBottom={GUTTER}
406
+ marginRight={isLastInRow ? 0 : gutter}
407
+ marginBottom={gutter}
398
408
  isSelected={isSelected}
399
409
  selectionIndex={selIndex}
400
- dim={multiSelect && hasAnySelection && !isSelected}
410
+ dim={
411
+ multiSelect
412
+ && !isSelected
413
+ && (hasAnySelection || atSelectionLimit)
414
+ }
401
415
  primaryColor={primaryColor}
402
- thumbUrl={getThumbUrl(item, 'thumb')}
416
+ thumbUrl={getThumbUrl(item)}
403
417
  enterIndex={index}
404
418
  reduceMotion={reduceMotion}
405
419
  onPress={() => handleCellPress(item)}
@@ -409,8 +423,9 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
409
423
  );
410
424
  },
411
425
  [
412
- selectedIds, selectionOrder, columns, cellSize, multiSelect, hasAnySelection,
413
- primaryColor, getThumbUrl, reduceMotion, handleCellPress, handleCellLongPress, t,
426
+ selectedIds, selectionOrder, columns, cellSize, gutter, multiSelect, hasAnySelection,
427
+ atSelectionLimit, primaryColor, getThumbUrl, reduceMotion, handleCellPress,
428
+ handleCellLongPress, t,
414
429
  ],
415
430
  );
416
431
 
@@ -419,7 +434,7 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
419
434
  const listFooter = useMemo(() => {
420
435
  if (!loadingMore) return null;
421
436
  return (
422
- <View style={{ paddingVertical: 16, alignItems: 'center' }}>
437
+ <View className="py-space-16 items-center">
423
438
  <ActivityIndicator size="small" color="#FFFFFF" />
424
439
  </View>
425
440
  );
@@ -441,168 +456,167 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
441
456
  : 0;
442
457
 
443
458
  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" />
450
- </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}
499
- />
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
- )}
509
-
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>
459
+ // Measured wrapper: `style`-only (no className) so RN-Web fires onLayout.
460
+ <View style={{ flex: 1 }} onLayout={onRootLayout}>
461
+ <View className="flex-1 bg-black">
462
+ {/* Photo grid (renders behind translucent header) */}
463
+ {isEmpty ? (
464
+ <View className="flex-1 items-center justify-center px-space-32 pt-[88px]">
465
+ <View className="opacity-30 mb-space-16">
466
+ <MaterialCommunityIcons name="image-outline" size={64} color="#FFFFFF" />
467
+ </View>
468
+ <Text className="text-white text-[17px] font-semibold text-center mb-1.5">
469
+ {t('fileManagement.photoPicker.emptyTitle')}
470
+ </Text>
471
+ <Text className="text-white text-[14px] font-normal text-center opacity-70 mb-space-24">
472
+ {t('fileManagement.photoPicker.emptySubtitle')}
473
+ </Text>
474
+ {isOwner && allowUpload && (
475
+ <TouchableOpacity
476
+ className="flex-row items-center gap-space-8 px-[22px] py-space-12 rounded-full"
477
+ style={{ backgroundColor: primaryColor }}
478
+ onPress={onUpload}
479
+ disabled={uploading || isPickingDocument}
480
+ accessibilityRole="button"
481
+ accessibilityLabel={t('fileManagement.uploadPhoto')}
482
+ accessibilityState={{ busy: uploading || isPickingDocument }}
483
+ >
484
+ {(uploading || isPickingDocument) ? (
485
+ <ActivityIndicator size="small" color="#FFFFFF" />
486
+ ) : (
487
+ <>
488
+ <Ionicons name="cloud-upload" size={18} color="#FFFFFF" />
489
+ <Text className="text-white text-[15px] font-semibold">
490
+ {t('fileManagement.uploadPhoto')}
491
+ </Text>
492
+ </>
493
+ )}
494
+ </TouchableOpacity>
495
+ )}
533
496
  </View>
534
- <View pointerEvents="none">
535
- <Text style={photoPickerStyles.headerTitle} numberOfLines={1}>
536
- {t('fileManagement.choosePhoto')}
497
+ ) : !gridReady ? (
498
+ <View className="flex-1 items-center justify-center pt-[88px]">
499
+ <ActivityIndicator size="large" color="#FFFFFF" />
500
+ <Text className="text-white text-[14px] mt-space-12 opacity-70">
501
+ {t('fileManagement.loadingPhotoLayout')}
537
502
  </Text>
538
503
  </View>
539
- <View style={[photoPickerStyles.headerSide, photoPickerStyles.headerSideRight]}>
540
- {multiSelect ? (
504
+ ) : (
505
+ <FlatList
506
+ key={`cols-${columns}`}
507
+ data={photos}
508
+ renderItem={renderItem}
509
+ keyExtractor={keyExtractor}
510
+ numColumns={columns}
511
+ className="flex-1"
512
+ contentContainerClassName="pt-[88px] pb-space-24"
513
+ showsVerticalScrollIndicator={false}
514
+ refreshControl={
515
+ <RefreshControl
516
+ refreshing={refreshing}
517
+ onRefresh={onRefresh}
518
+ tintColor="#FFFFFF"
519
+ colors={[primaryColor]}
520
+ progressViewOffset={GRID_CONTENT_PADDING_TOP}
521
+ />
522
+ }
523
+ onEndReached={handleEndReached}
524
+ onEndReachedThreshold={0.4}
525
+ ListFooterComponent={listFooter}
526
+ removeClippedSubviews
527
+ initialNumToRender={Math.max(12, columns * 6)}
528
+ windowSize={9}
529
+ />
530
+ )}
531
+
532
+ {/* Translucent black header. The bottom sheet already sits below
533
+ the status bar, so we do NOT add `insets.top` — that would
534
+ double-pad. `pt-[28px]` clears the 28dp drag handle floating
535
+ at the top of the sheet; `min-h-[84px]` = handle + app bar. */}
536
+ <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]">
537
+ <View className="flex-row items-center justify-between w-full h-14">
538
+ <View className="basis-0 grow flex-row items-center justify-start">
541
539
  <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
- ]}
540
+ onPress={onCancel}
541
+ className="px-1.5 py-space-8 min-h-9 min-w-11 justify-center"
553
542
  accessibilityRole="button"
554
- accessibilityLabel={t('fileManagement.a11y.confirmSelection')}
555
- accessibilityState={{ disabled: confirmDisabled }}
543
+ accessibilityLabel={t('fileManagement.a11y.cancelPicker')}
544
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
556
545
  >
557
- <Text style={photoPickerStyles.headerPrimaryText}>
558
- {confirmLabel}
546
+ <Text className="text-white text-[17px] font-medium">
547
+ {t('fileManagement.cancel')}
559
548
  </Text>
560
549
  </TouchableOpacity>
561
- ) : (
562
- isOwner && allowUpload && (
550
+ </View>
551
+ <View pointerEvents="none">
552
+ <Text
553
+ className="text-white text-[17px] font-semibold text-center tracking-[-0.2px]"
554
+ numberOfLines={1}
555
+ >
556
+ {t('fileManagement.choosePhoto')}
557
+ </Text>
558
+ </View>
559
+ <View className="basis-0 grow flex-row items-center justify-end">
560
+ {multiSelect ? (
563
561
  <TouchableOpacity
564
- onPress={onUpload}
565
- disabled={uploading || isPickingDocument}
566
- style={[
567
- photoPickerStyles.headerPrimaryPill,
568
- !showUploadLabel && photoPickerStyles.headerPrimaryPillIconOnly,
569
- { backgroundColor: primaryColor },
570
- ]}
562
+ onPress={handleConfirm}
563
+ disabled={confirmDisabled}
564
+ className="flex-row items-center gap-1.5 px-3.5 py-space-8 rounded-full min-h-9"
565
+ style={{
566
+ backgroundColor: confirmDisabled
567
+ ? 'rgba(255,255,255,0.18)'
568
+ : primaryColor,
569
+ opacity: confirmDisabled ? 0.6 : 1,
570
+ }}
571
571
  accessibilityRole="button"
572
- accessibilityLabel={t('fileManagement.a11y.uploadFromDevice')}
573
- accessibilityState={{ busy: uploading || isPickingDocument }}
572
+ accessibilityLabel={t('fileManagement.a11y.confirmSelection')}
573
+ accessibilityState={{ disabled: confirmDisabled }}
574
574
  >
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
- )}
575
+ <Text className="text-white text-[15px] font-semibold">
576
+ {confirmLabel}
577
+ </Text>
587
578
  </TouchableOpacity>
588
- )
589
- )}
579
+ ) : (
580
+ isOwner && allowUpload && (
581
+ <TouchableOpacity
582
+ onPress={onUpload}
583
+ disabled={uploading || isPickingDocument}
584
+ 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'}`}
585
+ style={{ backgroundColor: primaryColor }}
586
+ accessibilityRole="button"
587
+ accessibilityLabel={t('fileManagement.a11y.uploadFromDevice')}
588
+ accessibilityState={{ busy: uploading || isPickingDocument }}
589
+ >
590
+ {(uploading || isPickingDocument) ? (
591
+ <ActivityIndicator size="small" color="#FFFFFF" />
592
+ ) : (
593
+ <>
594
+ <Ionicons name="cloud-upload" size={16} color="#FFFFFF" />
595
+ {showUploadLabel && (
596
+ <Text className="text-white text-[15px] font-semibold">
597
+ {t('fileManagement.upload')}
598
+ </Text>
599
+ )}
600
+ </>
601
+ )}
602
+ </TouchableOpacity>
603
+ )
604
+ )}
605
+ </View>
590
606
  </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
- {
607
+ {/* Subtle top progress bar during upload (non-blocking). */}
608
+ {uploading && (
609
+ <View className="absolute left-0 right-0 bottom-0 h-0.5 bg-[#FFFFFF1F]">
610
+ <View
611
+ className="h-full"
612
+ style={{
599
613
  width: `${Math.round(progressFraction * 100)}%`,
600
614
  backgroundColor: primaryColor,
601
- },
602
- ]}
603
- />
604
- </View>
605
- )}
615
+ }}
616
+ />
617
+ </View>
618
+ )}
619
+ </View>
606
620
  </View>
607
621
  </View>
608
622
  );
@@ -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
  }