@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
@@ -50,6 +50,15 @@ export const useUserProfiles = (sessionIds: string[], options?: { enabled?: bool
50
50
  /**
51
51
  * Get current authenticated user.
52
52
  *
53
+ * Hydrates from `GET /users/me` (the bearer identifies the session) rather than
54
+ * `GET /session/user/:activeSessionId`. Post zero-cookie cutover the bearer is
55
+ * the single source of session truth, so keying the CURRENT user off a session
56
+ * id in the URL was a second, independently-updated source that could disagree
57
+ * with the bearer during an account switch — the request fired under the
58
+ * previous account's token against the new account's session id and 404'd. The
59
+ * query KEY stays scoped to the active account so the cache re-scopes (and
60
+ * refetches) on a switch; only the URL stopped carrying a session id.
61
+ *
53
62
  * The store-mirror effect must NOT overwrite the in-memory user while a
54
63
  * write mutation is in flight — otherwise a stale background refetch
55
64
  * landing between optimistic update and server-confirmed update would
@@ -67,10 +76,7 @@ export const useCurrentUser = (options?: { enabled?: boolean }) => {
67
76
  const query = useQuery({
68
77
  queryKey: queryKeys.accounts.current(activeSessionId),
69
78
  queryFn: async () => {
70
- if (!activeSessionId) {
71
- throw new Error('No active session');
72
- }
73
- return await oxyServices.getUserBySession(activeSessionId);
79
+ return await oxyServices.getCurrentUser();
74
80
  },
75
81
  enabled: (options?.enabled !== false) && isAuthenticated && !!activeSessionId,
76
82
  staleTime: 1 * 60 * 1000, // 1 minute for current user
@@ -29,6 +29,9 @@
29
29
  * marked stale — it is already managed).
30
30
  * - Nested objects (`name`, `_count`, `relationship`) merge field-by-field, so
31
31
  * a partial `name`/`_count`/`relationship` never replaces a fuller one.
32
+ * - `relationship` is written ONLY under the viewer-scoped by-username key —
33
+ * the viewer-independent by-id key never stores it (prevents one viewer's
34
+ * follow state from leaking into every other viewer's identity cache).
32
35
  * - Anti-degradation: a good `username` / `name.displayName` / `avatar` is
33
36
  * never overwritten by a degraded/empty one (empty username, the
34
37
  * `'Unknown user'` ghost-author sentinel, `null` avatar).
@@ -184,8 +187,17 @@ function mergeRelationship(
184
187
  /**
185
188
  * Merge a (normalized) incoming user over an existing cache entry: keep every
186
189
  * existing field, override only with the meaningful fields of `incoming`.
190
+ *
191
+ * When `includeRelationship` is false (the viewer-independent by-id key), the
192
+ * viewer-relative `relationship` field is never read, written, or preserved —
193
+ * only the by-username key carries it (`useUserByUsername`).
187
194
  */
188
- function mergeUsers(existing: CachedUser, incoming: CachedUser): CachedUser {
195
+ function mergeUsers(
196
+ existing: CachedUser,
197
+ incoming: CachedUser,
198
+ options?: { includeRelationship?: boolean },
199
+ ): CachedUser {
200
+ const includeRelationship = options?.includeRelationship ?? true;
189
201
  const merged: CachedUser = { ...existing };
190
202
  for (const [key, value] of Object.entries(incoming)) {
191
203
  if (key === 'name' || key === '_count' || key === 'relationship') continue;
@@ -199,8 +211,12 @@ function mergeUsers(existing: CachedUser, incoming: CachedUser): CachedUser {
199
211
  if (name !== undefined) merged.name = name;
200
212
  const count = mergeCount(existing._count, incoming._count);
201
213
  if (count !== undefined) merged._count = count;
202
- const relationship = mergeRelationship(existing.relationship, incoming.relationship);
203
- if (relationship !== undefined) merged.relationship = relationship;
214
+ if (includeRelationship) {
215
+ const relationship = mergeRelationship(existing.relationship, incoming.relationship);
216
+ if (relationship !== undefined) merged.relationship = relationship;
217
+ } else {
218
+ delete merged.relationship;
219
+ }
204
220
  return merged;
205
221
  }
206
222
 
@@ -209,17 +225,20 @@ function upsertOneKey(
209
225
  queryClient: QueryClient,
210
226
  key: readonly unknown[],
211
227
  incoming: CachedUser,
228
+ options: { includeRelationship: boolean },
212
229
  ): void {
230
+ const mergeOpts = { includeRelationship: options.includeRelationship };
213
231
  const existing = queryClient.getQueryData<CacheableUser>(key);
214
232
  if (existing === undefined) {
215
233
  // Cold slot: seed the full incoming object, STALE, so react-query refetches
216
234
  // the full authoritative profile (relationship, counts, createdAt, …).
217
- queryClient.setQueryData<CachedUser>(key, incoming, { updatedAt: 0 });
235
+ const seeded = mergeUsers({ id: incoming.id }, incoming, mergeOpts);
236
+ queryClient.setQueryData<CachedUser>(key, seeded, { updatedAt: 0 });
218
237
  return;
219
238
  }
220
239
  // Existing entry: merge and leave its freshness lifecycle untouched. The
221
240
  // existing entry is keyed by `incoming.id`, so use it as the fallback id.
222
- const merged = mergeUsers(toCachedUser(existing, incoming.id), incoming);
241
+ const merged = mergeUsers(toCachedUser(existing, incoming.id), incoming, mergeOpts);
223
242
  const dataUpdatedAt = queryClient.getQueryState(key)?.dataUpdatedAt ?? 0;
224
243
  queryClient.setQueryData<CachedUser>(key, merged, { updatedAt: dataUpdatedAt });
225
244
  }
@@ -253,8 +272,12 @@ export function upsertCachedUser(
253
272
  const incoming = normalizeIncoming(user);
254
273
  if (!incoming) return;
255
274
 
256
- // By-id identity entry (read by `useUserById`). Not viewer-scoped.
257
- upsertOneKey(queryClient, queryKeys.users.detail(incoming.id), incoming);
275
+ // By-id identity entry (read by `useUserById`). Not viewer-scoped — never store
276
+ // the viewer-relative `relationship` here or one viewer's follow state leaks
277
+ // into every other viewer's by-id cache entry.
278
+ upsertOneKey(queryClient, queryKeys.users.detail(incoming.id), incoming, {
279
+ includeRelationship: false,
280
+ });
258
281
 
259
282
  const username = incoming.username;
260
283
  if (typeof username === 'string' && username.trim() !== '') {
@@ -263,7 +286,7 @@ export function upsertCachedUser(
263
286
  // the key through the SAME helper the hook uses so username normalization
264
287
  // (`trim().toLowerCase()`) matches byte-for-byte.
265
288
  const key = queryKeys.users.byUsername(username, resolveViewerId(viewerId));
266
- upsertOneKey(queryClient, key, incoming);
289
+ upsertOneKey(queryClient, key, incoming, { includeRelationship: true });
267
290
  }
268
291
  }
269
292
 
@@ -100,11 +100,19 @@ export function useAvatarPicker({
100
100
  );
101
101
 
102
102
  /**
103
- * After the user picks a file in the FileManagement sheet, route to the
104
- * crop screen with the file's download URL as the source image.
103
+ * After the user picks a file in the FileManagement sheet, resolve a working
104
+ * source URL and route to the crop screen.
105
+ *
106
+ * The picked file is usually PRIVATE, so the synchronous
107
+ * `getFileDownloadUrl` (public CDN origin) would 404 and the crop screen's
108
+ * `Image.getSize` would fail silently. We resolve the authenticated URL via
109
+ * `assetGetUrl` (which throws on failure — unlike `getFileDownloadUrlAsync`,
110
+ * which swallows errors and falls back to the broken public URL) so a
111
+ * failure surfaces a real user-facing error instead of a blank crop canvas.
112
+ * No variant is requested — cropping needs the original.
105
113
  */
106
114
  const handleFilePicked = useCallback(
107
- (file: FileMetadata) => {
115
+ async (file: FileMetadata) => {
108
116
  if (!file.contentType?.startsWith('image/')) {
109
117
  toast.error(
110
118
  translate(currentLanguage ?? undefined, 'editProfile.toasts.selectImage') ||
@@ -113,10 +121,22 @@ export function useAvatarPicker({
113
121
  return;
114
122
  }
115
123
 
116
- // Use the public download URL of the picked file as the crop source.
117
- // The OxyServices file URL endpoints accept image variants, but for
118
- // cropping we want the original — passing no variant returns it.
119
- const sourceUri = oxyServices.getFileDownloadUrl(file.id);
124
+ let sourceUri: string;
125
+ try {
126
+ const resolved = await oxyServices.assetGetUrl(file.id);
127
+ if (!resolved?.url) {
128
+ throw new Error('No download URL returned for the selected image');
129
+ }
130
+ sourceUri = resolved.url;
131
+ } catch (e: unknown) {
132
+ const message = e instanceof Error ? e.message : undefined;
133
+ toast.error(
134
+ message ||
135
+ translate(currentLanguage ?? undefined, 'editProfile.toasts.cropMeasureFailed') ||
136
+ 'Could not load the selected image',
137
+ );
138
+ return;
139
+ }
120
140
 
121
141
  showBottomSheet({
122
142
  screen: 'AvatarCrop',
@@ -15,8 +15,9 @@ export interface UseFileDownloadUrlResult {
15
15
  /**
16
16
  * Hook to resolve a file's download URL asynchronously.
17
17
  *
18
- * Uses `getFileDownloadUrlAsync` first, falling back to the synchronous
19
- * `getFileDownloadUrl` if the async call fails.
18
+ * Uses `getFileDownloadUrlAsync`, which returns a scoped media-token stream URL
19
+ * for private assets. There is no sync CDN fallback — the synchronous
20
+ * `getFileDownloadUrl` is public-CDN-only and 404s for private uploads.
20
21
  */
21
22
  export const useFileDownloadUrl = (
22
23
  oxyServices: OxyServices | null | undefined,
@@ -54,35 +55,18 @@ export const useFileDownloadUrl = (
54
55
  setError(null);
55
56
 
56
57
  try {
57
- let resolvedUrl: string | null = null;
58
-
59
- if (typeof instance.getFileDownloadUrlAsync === 'function') {
60
- resolvedUrl = await instance.getFileDownloadUrlAsync(targetFileId, variant, expiresIn);
58
+ if (typeof instance.getFileDownloadUrlAsync !== 'function') {
59
+ throw new Error('getFileDownloadUrlAsync is not available on this OxyServices instance');
61
60
  }
62
61
 
63
- if (!resolvedUrl && typeof instance.getFileDownloadUrl === 'function') {
64
- resolvedUrl = instance.getFileDownloadUrl(targetFileId, variant, expiresIn);
65
- }
62
+ const resolvedUrl = await instance.getFileDownloadUrlAsync(targetFileId, variant, expiresIn);
66
63
 
67
64
  if (!cancelled) {
68
65
  setUrl(resolvedUrl || null);
69
66
  }
70
67
  } catch (err: unknown) {
71
- // Fallback to sync URL on error where possible
72
- try {
73
- if (typeof instance.getFileDownloadUrl === 'function') {
74
- const fallbackUrl = instance.getFileDownloadUrl(targetFileId, variant, expiresIn);
75
- if (!cancelled) {
76
- setUrl(fallbackUrl || null);
77
- setError(err instanceof Error ? err : new Error(String(err)));
78
- }
79
- return;
80
- }
81
- } catch {
82
- // Secondary failure: surface the original error below.
83
- }
84
-
85
68
  if (!cancelled) {
69
+ setUrl(null);
86
70
  setError(err instanceof Error ? err : new Error(String(err)));
87
71
  }
88
72
  } finally {
@@ -0,0 +1,170 @@
1
+ import { useMemo } from 'react';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import type { FileMetadata } from '@oxyhq/core';
4
+
5
+ /**
6
+ * The variant-aware batch resolver contract (`@oxyhq/core`).
7
+ *
8
+ * `getFileDownloadUrls` takes a per-file `{ fileId, variant }` list and returns
9
+ * a map keyed by `fileId`; ids that are denied or missing are OMITTED (never an
10
+ * empty string), and PRIVATE assets resolve to a working scoped-media-token
11
+ * stream URL. Typed here rather than `Pick<OxyServices, …>` so this file states
12
+ * the exact contract it depends on.
13
+ */
14
+ export interface AssetUrlBatchResolver {
15
+ getFileDownloadUrls(
16
+ requests: Array<{ fileId: string; variant?: string }>,
17
+ options?: { expiresIn?: number; context?: string },
18
+ ): Promise<Record<string, string>>;
19
+ }
20
+
21
+ /**
22
+ * Private-safe grid/thumbnail URL resolution.
23
+ *
24
+ * The synchronous `oxyServices.getFileDownloadUrl(id, variant)` always yields
25
+ * the public CDN origin (`cloud.oxy.so/<id>`), which 404s for PRIVATE assets —
26
+ * and uploads default to private. Thumbnails and previews therefore have to be
27
+ * resolved through the authenticated batch endpoint, which returns a working
28
+ * `/assets/:id/stream?…&mt=<scoped media token>` URL for private files.
29
+ *
30
+ * This hook resolves an ENTIRE page of files in ONE (chunked) batch request
31
+ * rather than N per-tile calls, and caches the result in React Query keyed by
32
+ * the (fileId, variant) set. `staleTime` is deliberately shorter than the
33
+ * server media-token TTL so a tile never renders a URL whose token has already
34
+ * expired.
35
+ *
36
+ * Optimistic (`temp-…` / `uploading`) entries are NEVER resolved here — an
37
+ * asset URL must never be built from an id the server does not yet know. Their
38
+ * preview comes from the locally-picked uri (see {@link fileThumbSource}).
39
+ */
40
+
41
+ /** Server media-token lifetime we request for each resolved URL (seconds). */
42
+ const ASSET_URL_TTL_SECONDS = 600; // 10 minutes
43
+ /**
44
+ * Refresh window. Kept below {@link ASSET_URL_TTL_SECONDS} so React Query
45
+ * re-resolves in the background before a rendered URL's token can die.
46
+ */
47
+ const ASSET_URL_STALE_MS = 5 * 60 * 1000; // 5 minutes
48
+ const ASSET_URL_GC_MS = 30 * 60 * 1000;
49
+ /** Server-side batch cap for `POST /assets/batch-access` — chunk beyond this. */
50
+ const BATCH_CAP = 100;
51
+
52
+ /** React Query prefix — scoped media URLs must never leak across accounts. */
53
+ export const ASSET_DOWNLOAD_URLS_QUERY_KEY = 'assetDownloadUrls' as const;
54
+
55
+ /**
56
+ * True when a file is an optimistic placeholder that has not been persisted by
57
+ * the server yet — either its id is still the client-minted `temp-…` id, or it
58
+ * carries the `uploading` flag. Mirrors the `temp-` id guard precedent in
59
+ * `@oxyhq/core`'s `avatarUtils.updateAvatarVisibility`.
60
+ */
61
+ export function isOptimisticFile(file: Pick<FileMetadata, 'id' | 'metadata'>): boolean {
62
+ if (typeof file.id === 'string' && file.id.startsWith('temp-')) return true;
63
+ return file.metadata?.uploading === true;
64
+ }
65
+
66
+ /** Grid thumbnail variant for a file, or `undefined` when it needs no URL. */
67
+ function thumbVariantFor(file: FileMetadata): string | undefined {
68
+ if (file.contentType.startsWith('video/')) return 'poster';
69
+ if (file.contentType.startsWith('image/')) return 'thumb';
70
+ return undefined;
71
+ }
72
+
73
+ /**
74
+ * Locally-picked preview uri stashed on an optimistic entry's metadata by the
75
+ * upload flow, if present.
76
+ */
77
+ function localPreviewUri(file: FileMetadata): string | undefined {
78
+ const uri = (file.metadata as Record<string, unknown> | undefined)?.localPreviewUri;
79
+ return typeof uri === 'string' && uri.length > 0 ? uri : undefined;
80
+ }
81
+
82
+ /**
83
+ * Resolve the image source for a grid tile.
84
+ *
85
+ * - Optimistic entry → its locally-picked preview uri (never an asset URL).
86
+ * - Persisted entry → the private-safe URL from {@link useResolvedFileUrls},
87
+ * or `undefined` while it is still resolving / was denied (tile shows a
88
+ * placeholder instead of a guaranteed-404 CDN URL).
89
+ */
90
+ export function fileThumbSource(
91
+ file: FileMetadata,
92
+ resolved: ReadonlyMap<string, string>,
93
+ ): string | undefined {
94
+ if (isOptimisticFile(file)) {
95
+ return localPreviewUri(file);
96
+ }
97
+ return resolved.get(file.id);
98
+ }
99
+
100
+ /**
101
+ * Batch-resolve private-safe thumbnail URLs for the given files.
102
+ *
103
+ * Only image/video, non-optimistic files are resolved (documents render an
104
+ * icon, optimistic entries render their local preview). Returns a stable
105
+ * `Map<fileId, url>`; missing entries mean "not resolved yet or access denied".
106
+ */
107
+ export function useResolvedFileUrls(
108
+ oxyServices: AssetUrlBatchResolver,
109
+ files: FileMetadata[],
110
+ /** Active viewer account id — media tokens are minted per user; must be in the query key. */
111
+ viewerId?: string | null,
112
+ ): ReadonlyMap<string, string> {
113
+ const viewerKey = viewerId ?? '';
114
+ const requests = useMemo(() => {
115
+ const list: Array<{ fileId: string; variant?: string }> = [];
116
+ for (const file of files) {
117
+ if (isOptimisticFile(file)) continue;
118
+ const variant = thumbVariantFor(file);
119
+ if (!variant) continue;
120
+ list.push({ fileId: file.id, variant });
121
+ }
122
+ return list;
123
+ }, [files]);
124
+
125
+ // A content signature so the query key is stable across renders while the
126
+ // resolvable set is unchanged (the `files` array is a fresh reference every
127
+ // render). Sorted so ordering churn does not force a refetch.
128
+ const signature = useMemo(
129
+ () => requests.map((r) => `${r.fileId}:${r.variant ?? ''}`).sort().join('|'),
130
+ [requests],
131
+ );
132
+
133
+ const query = useQuery({
134
+ queryKey: [ASSET_DOWNLOAD_URLS_QUERY_KEY, viewerKey, signature],
135
+ enabled: requests.length > 0 && viewerKey.length > 0,
136
+ staleTime: ASSET_URL_STALE_MS,
137
+ gcTime: ASSET_URL_GC_MS,
138
+ // Keep previously-resolved URLs on screen while the SAME viewer's file set
139
+ // grows (pagination), but never carry another account's scoped media tokens
140
+ // across a switch — `keepPreviousData` alone would do that.
141
+ placeholderData: (previousData, previousQuery) => {
142
+ if (!previousData || !previousQuery) return undefined;
143
+ const prevViewer = previousQuery.queryKey[1];
144
+ if (prevViewer !== viewerKey) return undefined;
145
+ return previousData;
146
+ },
147
+ queryFn: async (): Promise<Record<string, string>> => {
148
+ const merged: Record<string, string> = {};
149
+ for (let i = 0; i < requests.length; i += BATCH_CAP) {
150
+ const chunk = requests.slice(i, i + BATCH_CAP);
151
+ const urls = await oxyServices.getFileDownloadUrls(chunk, {
152
+ expiresIn: ASSET_URL_TTL_SECONDS,
153
+ });
154
+ Object.assign(merged, urls);
155
+ }
156
+ return merged;
157
+ },
158
+ });
159
+
160
+ return useMemo(() => {
161
+ const map = new Map<string, string>();
162
+ const data = query.data;
163
+ if (data) {
164
+ for (const [id, url] of Object.entries(data)) {
165
+ if (typeof url === 'string' && url.length > 0) map.set(id, url);
166
+ }
167
+ }
168
+ return map;
169
+ }, [query.data]);
170
+ }
@@ -28,8 +28,8 @@ import { useUploadFile } from '../hooks/mutations/useAccountMutations';
28
28
  import {
29
29
  formatFileSize,
30
30
  getFileIcon,
31
- getSafeDownloadUrl,
32
31
  } from '../utils/fileManagement';
32
+ import { useResolvedFileUrls, fileThumbSource } from '../hooks/useResolvedFileUrls';
33
33
  import { FileViewer } from '../components/fileManagement/FileViewer';
34
34
  import { FileDetailsModal } from '../components/fileManagement/FileDetailsModal';
35
35
  import { UploadPreview } from '../components/fileManagement/UploadPreview';
@@ -356,12 +356,18 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
356
356
  onClose?.();
357
357
  }, [selectMode, multiSelect, selectedIds, files, onConfirmSelection, onClose, defaultVisibility, oxyServices, linkContext]);
358
358
 
359
- // Helper to safely request a thumbnail variant only for image mime types.
360
- const getSafeDownloadUrlCallback = useCallback(
361
- (file: FileMetadata, variant = 'thumb') => {
362
- return getSafeDownloadUrl(file, variant, (fileId: string, variant?: string) => oxyServices.getFileDownloadUrl(fileId, variant));
363
- },
364
- [oxyServices]
359
+ // Private-safe thumbnail URLs for the currently-shown files, resolved in
360
+ // ONE batch per page (not N per-tile) and cached by React Query. Uploads
361
+ // default to private, so the synchronous public-CDN URL would 404 for
362
+ // every private thumbnail.
363
+ const resolvedThumbUrls = useResolvedFileUrls(oxyServices, filteredFiles, user?.id);
364
+
365
+ // Image source for a grid tile: the resolved private-safe URL for a
366
+ // persisted file, or the locally-picked preview for an in-flight optimistic
367
+ // entry. Never builds an asset URL from a `temp-…`/uploading id.
368
+ const thumbSourceFor = useCallback(
369
+ (file: FileMetadata): string | undefined => fileThumbSource(file, resolvedThumbUrls),
370
+ [resolvedThumbUrls],
365
371
  );
366
372
 
367
373
  const bloomTheme = useTheme();
@@ -480,7 +486,14 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
480
486
  await Promise.all(
481
487
  photosToLoad.map(async (photo) => {
482
488
  try {
483
- const downloadUrl = getSafeDownloadUrlCallback(photo, 'thumb');
489
+ const downloadUrl = thumbSourceFor(photo);
490
+ // URL not resolved yet — skip (do NOT record fallback
491
+ // dims) so this photo is re-measured once its private-safe
492
+ // URL resolves. Measuring the broken public URL is exactly
493
+ // the bug we are fixing.
494
+ if (!downloadUrl) {
495
+ return;
496
+ }
484
497
 
485
498
  // Unified approach using Image.getSize (works on all platforms)
486
499
  await new Promise<void>((resolve) => {
@@ -515,7 +528,19 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
515
528
  } finally {
516
529
  setLoadingDimensions(false);
517
530
  }
518
- }, [getSafeDownloadUrlCallback, photoDimensions]);
531
+ }, [thumbSourceFor, photoDimensions]);
532
+
533
+ // Re-measure photo dimensions when their private-safe URLs resolve. The
534
+ // justified grid's own trigger fires on the photo SET, not on URL
535
+ // availability, so newly-resolved private tiles would otherwise keep their
536
+ // skipped (unmeasured) state and render with fallback geometry.
537
+ useEffect(() => {
538
+ if (viewMode !== 'photos') return;
539
+ const photos = filteredFiles.filter((file) => file.contentType.startsWith('image/'));
540
+ if (photos.length > 0) {
541
+ loadPhotoDimensions(photos);
542
+ }
543
+ }, [resolvedThumbUrls, viewMode, filteredFiles, loadPhotoDimensions]);
519
544
 
520
545
  // Create justified rows from photos with responsive algorithm
521
546
  const createJustifiedRows = useCallback((photos: FileMetadata[], containerWidth: number) => {
@@ -646,9 +671,10 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
646
671
  // Unified download function - works on all platforms
647
672
  const handleFileDownload = async (fileId: string, filename: string) => {
648
673
  try {
649
- // Try to use the download URL with a simple approach
650
- // On web, this creates a download link. On mobile, it opens the URL.
651
- const downloadUrl = oxyServices.getFileDownloadUrl(fileId);
674
+ // Resolve an authenticated, private-safe URL. The synchronous
675
+ // `getFileDownloadUrl` yields the public CDN origin, which 404s for
676
+ // private assets (the default visibility).
677
+ const downloadUrl = await oxyServices.getFileDownloadUrlAsync(fileId);
652
678
 
653
679
  // For web platforms, use link download
654
680
  if (typeof window !== 'undefined' && window.document) {
@@ -715,8 +741,10 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
715
741
  file.contentType.includes('pdf') ||
716
742
  file.contentType.startsWith('video/') ||
717
743
  file.contentType.startsWith('audio/')) {
718
- // For images, PDFs, videos, and audio, we'll use the URL directly
719
- const downloadUrl = oxyServices.getFileDownloadUrl(file.id);
744
+ // For images, PDFs, videos, and audio, we render the URL
745
+ // directly. Resolve the authenticated, private-safe URL —
746
+ // the synchronous public-CDN URL 404s for private assets.
747
+ const downloadUrl = await oxyServices.getFileDownloadUrlAsync(file.id);
720
748
  setFileContent(downloadUrl);
721
749
  } else {
722
750
  // For text files, get the content using authenticated request
@@ -755,7 +783,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
755
783
  };
756
784
 
757
785
  const renderSimplePhotoItem = useCallback((photo: FileMetadata, index: number) => {
758
- const downloadUrl = getSafeDownloadUrlCallback(photo, 'thumb');
786
+ const downloadUrl = thumbSourceFor(photo);
759
787
 
760
788
  // Calculate photo item width based on actual container size from bottom sheet
761
789
  let itemsPerRow = 3; // Default for mobile
@@ -803,10 +831,10 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
803
831
  </View>
804
832
  </TouchableOpacity>
805
833
  );
806
- }, [oxyServices, safeContainerWidth, selectMode, selectedIds, colors.primary, colors.text]);
834
+ }, [thumbSourceFor, safeContainerWidth, selectMode, selectedIds, colors.primary, colors.text]);
807
835
 
808
836
  const renderJustifiedPhotoItem = useCallback((photo: FileMetadata, width: number, height: number, isLast: boolean) => {
809
- const downloadUrl = getSafeDownloadUrlCallback(photo, 'thumb');
837
+ const downloadUrl = thumbSourceFor(photo);
810
838
 
811
839
  return (
812
840
  <TouchableOpacity
@@ -843,7 +871,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
843
871
  </View>
844
872
  </TouchableOpacity>
845
873
  );
846
- }, [oxyServices, selectMode, selectedIds, multiSelect, colors.primary, colors.text]);
874
+ }, [thumbSourceFor, selectMode, selectedIds, multiSelect, colors.primary, colors.text]);
847
875
 
848
876
  // Run initial load once per targetUserId change to avoid accidental loops
849
877
  const lastLoadedFor = useRef<string | undefined>(undefined);
@@ -879,7 +907,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
879
907
  <View style={fileManagementStyles.filePreview}>
880
908
  {isImage && (
881
909
  <ExpoImage
882
- source={{ uri: getSafeDownloadUrlCallback(file, 'thumb') }}
910
+ source={{ uri: thumbSourceFor(file) }}
883
911
  style={fileManagementStyles.previewImage}
884
912
  contentFit="cover"
885
913
  transition={120}
@@ -899,7 +927,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
899
927
  {isVideo && (
900
928
  <View style={fileManagementStyles.videoPreviewWrapper}>
901
929
  <ExpoImage
902
- source={{ uri: getSafeDownloadUrlCallback(file, 'thumb') }}
930
+ source={{ uri: thumbSourceFor(file) }}
903
931
  style={fileManagementStyles.videoPosterImage}
904
932
  contentFit="cover"
905
933
  transition={120}
@@ -919,7 +947,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
919
947
  style={[fileManagementStyles.fallbackIcon, { display: isImage ? 'none' : 'flex' }]}
920
948
  >
921
949
  <Ionicons
922
- name={getFileIcon(file.contentType) as React.ComponentProps<typeof Ionicons>['name']}
950
+ name={getFileIcon(file.contentType)}
923
951
  size={32}
924
952
  color={colors.primary}
925
953
  />
@@ -934,7 +962,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
934
962
  ) : (
935
963
  <View style={fileManagementStyles.fileIconContainer}>
936
964
  <Ionicons
937
- name={getFileIcon(file.contentType) as React.ComponentProps<typeof Ionicons>['name']}
965
+ name={getFileIcon(file.contentType)}
938
966
  size={32}
939
967
  color={colors.primary}
940
968
  />
@@ -1016,7 +1044,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
1016
1044
  fileIcon = (
1017
1045
  <View style={{ width: 36, height: 36, borderRadius: 18, overflow: 'hidden' }}>
1018
1046
  <ExpoImage
1019
- source={{ uri: getSafeDownloadUrlCallback(file, 'thumb') }}
1047
+ source={{ uri: thumbSourceFor(file) }}
1020
1048
  style={{ width: 36, height: 36 }}
1021
1049
  contentFit="cover"
1022
1050
  transition={120}
@@ -1032,7 +1060,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
1032
1060
  fileIcon = (
1033
1061
  <View style={{ width: 36, height: 36, borderRadius: 18, overflow: 'hidden', backgroundColor: '#000000', position: 'relative' }}>
1034
1062
  <ExpoImage
1035
- source={{ uri: getSafeDownloadUrlCallback(file, 'thumb') }}
1063
+ source={{ uri: thumbSourceFor(file) }}
1036
1064
  style={{ width: 36, height: 36 }}
1037
1065
  contentFit="cover"
1038
1066
  transition={120}
@@ -1102,7 +1130,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
1102
1130
  ) : undefined,
1103
1131
  };
1104
1132
  });
1105
- }, [filteredFiles, theme, deleting, handleFileDownload, confirmFileDelete, handleFileOpen, getSafeDownloadUrlCallback, selectMode, selectedIds]);
1133
+ }, [filteredFiles, theme, deleting, handleFileDownload, confirmFileDelete, handleFileOpen, thumbSourceFor, selectMode, selectedIds]);
1106
1134
 
1107
1135
  // Scroll to selected file after selection
1108
1136
  useEffect(() => {
@@ -1191,7 +1219,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
1191
1219
  }, [lastSelectedFileId]);
1192
1220
 
1193
1221
  const renderPhotoItem = (photo: FileMetadata, index: number) => {
1194
- const downloadUrl = getSafeDownloadUrlCallback(photo, 'thumb');
1222
+ const downloadUrl = thumbSourceFor(photo);
1195
1223
 
1196
1224
  // Calculate photo item width based on actual container size from bottom sheet
1197
1225
  let itemsPerRow = 3; // Default for mobile
@@ -1570,7 +1598,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
1570
1598
  hasMore={paging.hasMore}
1571
1599
  loadingMore={paging.loadingMore}
1572
1600
  reduceMotion={reduceMotion}
1573
- getThumbUrl={getSafeDownloadUrlCallback}
1601
+ getThumbUrl={thumbSourceFor}
1574
1602
  primaryColor={colors.primary}
1575
1603
  isOwner={isOwner}
1576
1604
  onTogglePhoto={toggleSelect}
@@ -0,0 +1,57 @@
1
+ import {
2
+ computePhotoGridLayout,
3
+ PHOTO_GRID_GUTTER,
4
+ PHOTO_GRID_WIDE_BREAKPOINT,
5
+ } from '../fileManagement/photoGridLayout';
6
+
7
+ describe('computePhotoGridLayout', () => {
8
+ it('uses 3 columns below the wide breakpoint', () => {
9
+ expect(computePhotoGridLayout(390).columns).toBe(3);
10
+ expect(computePhotoGridLayout(500).columns).toBe(3);
11
+ expect(computePhotoGridLayout(PHOTO_GRID_WIDE_BREAKPOINT - 1).columns).toBe(3);
12
+ });
13
+
14
+ it('uses 4 columns at/above the wide breakpoint', () => {
15
+ expect(computePhotoGridLayout(PHOTO_GRID_WIDE_BREAKPOINT).columns).toBe(4);
16
+ expect(computePhotoGridLayout(700).columns).toBe(4);
17
+ expect(computePhotoGridLayout(1024).columns).toBe(4);
18
+ });
19
+
20
+ it('floors the tile size so a full row never exceeds the container', () => {
21
+ // The whole point of the bug fix: geometry derives from the passed
22
+ // container width, and a full row of tiles + gutters must fit inside it.
23
+ for (const width of [320, 375, 390, 428, 500, 599, 600, 768, 834, 1024]) {
24
+ const { columns, cellSize, gutter } = computePhotoGridLayout(width);
25
+ const rowWidth = columns * cellSize + (columns - 1) * gutter;
26
+ expect(rowWidth).toBeLessThanOrEqual(width);
27
+ // ...and the floor is tight (adding one px per tile would overflow).
28
+ expect((columns * (cellSize + 1)) + (columns - 1) * gutter).toBeGreaterThan(width);
29
+ }
30
+ });
31
+
32
+ it('derives smaller tiles from a narrow sheet than from a wide screen', () => {
33
+ // Regression guard for the original bug: a centered dialog (narrow
34
+ // container) must NOT size tiles from a wider screen width.
35
+ const narrowSheet = computePhotoGridLayout(500); // 3 cols
36
+ const wideScreen = computePhotoGridLayout(1200); // 4 cols
37
+ expect(narrowSheet.cellSize).toBeLessThan(wideScreen.cellSize);
38
+ // Cross-check the exact arithmetic against the gutter constant.
39
+ expect(narrowSheet.cellSize).toBe(
40
+ Math.floor((500 - PHOTO_GRID_GUTTER * 2) / 3),
41
+ );
42
+ expect(wideScreen.cellSize).toBe(
43
+ Math.floor((1200 - PHOTO_GRID_GUTTER * 3) / 4),
44
+ );
45
+ });
46
+
47
+ it('returns cellSize 0 for a not-yet-measured (<= 0) width', () => {
48
+ expect(computePhotoGridLayout(0).cellSize).toBe(0);
49
+ expect(computePhotoGridLayout(-10).cellSize).toBe(0);
50
+ // Columns still resolve to the narrow default so callers can render.
51
+ expect(computePhotoGridLayout(0).columns).toBe(3);
52
+ });
53
+
54
+ it('exposes the gutter it used to derive the tile size', () => {
55
+ expect(computePhotoGridLayout(500).gutter).toBe(PHOTO_GRID_GUTTER);
56
+ });
57
+ });