@oxyhq/services 22.12.1 → 22.12.3

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 (85) hide show
  1. package/lib/commonjs/ui/components/FollowButton.js +5 -8
  2. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  3. package/lib/commonjs/ui/hooks/useSurfaceHeader.js +44 -7
  4. package/lib/commonjs/ui/hooks/useSurfaceHeader.js.map +1 -1
  5. package/lib/commonjs/ui/navigation/surfaceNavStack.js +8 -7
  6. package/lib/commonjs/ui/navigation/surfaceNavStack.js.map +1 -1
  7. package/lib/commonjs/ui/navigation/surfaces.js +8 -3
  8. package/lib/commonjs/ui/navigation/surfaces.js.map +1 -1
  9. package/lib/commonjs/ui/screens/ChangeAvatarScreen.js +48 -42
  10. package/lib/commonjs/ui/screens/ChangeAvatarScreen.js.map +1 -1
  11. package/lib/commonjs/ui/screens/CreateAccountScreen.js +60 -98
  12. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
  13. package/lib/commonjs/ui/screens/FileManagementScreen.js +22 -8
  14. package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
  15. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js +10 -7
  16. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  17. package/lib/commonjs/ui/screens/fileManagement/UploadBar.js +19 -11
  18. package/lib/commonjs/ui/screens/fileManagement/UploadBar.js.map +1 -1
  19. package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js +42 -9
  20. package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
  21. package/lib/module/ui/components/FollowButton.js +5 -8
  22. package/lib/module/ui/components/FollowButton.js.map +1 -1
  23. package/lib/module/ui/hooks/useSurfaceHeader.js +45 -8
  24. package/lib/module/ui/hooks/useSurfaceHeader.js.map +1 -1
  25. package/lib/module/ui/navigation/surfaceNavStack.js +8 -7
  26. package/lib/module/ui/navigation/surfaceNavStack.js.map +1 -1
  27. package/lib/module/ui/navigation/surfaces.js +7 -1
  28. package/lib/module/ui/navigation/surfaces.js.map +1 -1
  29. package/lib/module/ui/screens/ChangeAvatarScreen.js +48 -42
  30. package/lib/module/ui/screens/ChangeAvatarScreen.js.map +1 -1
  31. package/lib/module/ui/screens/CreateAccountScreen.js +62 -100
  32. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
  33. package/lib/module/ui/screens/FileManagementScreen.js +22 -8
  34. package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
  35. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js +10 -7
  36. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  37. package/lib/module/ui/screens/fileManagement/UploadBar.js +20 -12
  38. package/lib/module/ui/screens/fileManagement/UploadBar.js.map +1 -1
  39. package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js +43 -10
  40. package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
  41. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/ui/hooks/useSurfaceHeader.d.ts +7 -0
  43. package/lib/typescript/commonjs/ui/hooks/useSurfaceHeader.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/ui/navigation/surfaceNavStack.d.ts +7 -4
  45. package/lib/typescript/commonjs/ui/navigation/surfaceNavStack.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/ui/navigation/surfaces.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/ui/screens/ChangeAvatarScreen.d.ts +9 -5
  48. package/lib/typescript/commonjs/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/ui/screens/fileManagement/UploadBar.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.d.ts +5 -1
  54. package/lib/typescript/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/types/fileManagement.d.ts +10 -0
  56. package/lib/typescript/commonjs/ui/types/fileManagement.d.ts.map +1 -1
  57. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  58. package/lib/typescript/module/ui/hooks/useSurfaceHeader.d.ts +7 -0
  59. package/lib/typescript/module/ui/hooks/useSurfaceHeader.d.ts.map +1 -1
  60. package/lib/typescript/module/ui/navigation/surfaceNavStack.d.ts +7 -4
  61. package/lib/typescript/module/ui/navigation/surfaceNavStack.d.ts.map +1 -1
  62. package/lib/typescript/module/ui/navigation/surfaces.d.ts.map +1 -1
  63. package/lib/typescript/module/ui/screens/ChangeAvatarScreen.d.ts +9 -5
  64. package/lib/typescript/module/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
  65. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  66. package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
  67. package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  68. package/lib/typescript/module/ui/screens/fileManagement/UploadBar.d.ts.map +1 -1
  69. package/lib/typescript/module/ui/screens/fileManagement/hooks/useFileUploadState.d.ts +5 -1
  70. package/lib/typescript/module/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
  71. package/lib/typescript/module/ui/types/fileManagement.d.ts +10 -0
  72. package/lib/typescript/module/ui/types/fileManagement.d.ts.map +1 -1
  73. package/package.json +4 -4
  74. package/src/ui/components/FollowButton.tsx +5 -8
  75. package/src/ui/hooks/useSurfaceHeader.ts +65 -8
  76. package/src/ui/navigation/__tests__/surfaceNavStack.test.ts +49 -17
  77. package/src/ui/navigation/surfaceNavStack.ts +15 -11
  78. package/src/ui/navigation/surfaces.ts +9 -1
  79. package/src/ui/screens/ChangeAvatarScreen.tsx +46 -39
  80. package/src/ui/screens/CreateAccountScreen.tsx +128 -166
  81. package/src/ui/screens/FileManagementScreen.tsx +22 -8
  82. package/src/ui/screens/fileManagement/PhotoPickerSection.tsx +4 -5
  83. package/src/ui/screens/fileManagement/UploadBar.tsx +17 -10
  84. package/src/ui/screens/fileManagement/hooks/useFileUploadState.ts +51 -10
  85. package/src/ui/types/fileManagement.ts +10 -0
@@ -4,11 +4,15 @@
4
4
  * Shows the current avatar large, then the four sources the user can pick from:
5
5
  * device gallery, camera, their existing Oxy files, and removing the current
6
6
  * photo. Every source that yields an image NAVIGATES WITHIN THIS SURFACE to
7
- * `AvatarCrop` (never presents it on top), so the panel morphs from this list's
8
- * size to the crop editor's and back Cancel on the cropper returns here to
9
- * re-pick. One container reshaping, not two sheets. The forward morph is
10
- * flash-free because only the top frame renders and the cropper does all its own
11
- * async work (URL resolution + measurement), so this list is never left on screen
7
+ * `AvatarCrop` (never presents it on top), so the panel morphs from frame to
8
+ * frame instead of stacking sheets. The device / camera sources navigate straight
9
+ * to the cropper; the "My Oxy files" source first navigates to the `FileManagement`
10
+ * picker frame, which then forward-navigates to the cropper on selection a real
11
+ * three-frame stack (this list picker cropper). So Cancel on the cropper
12
+ * returns to whichever frame opened it (the picker when re-picking from Oxy files,
13
+ * this list otherwise), and Cancel on the picker returns here. The forward morph
14
+ * is flash-free because only the top frame renders and the cropper does all its
15
+ * own async work (URL resolution + measurement), so no frame is left on screen
12
16
  * waiting.
13
17
  *
14
18
  * The surface resolves with an {@link AvatarCropResult} (the crop confirmed) or
@@ -37,10 +41,10 @@ import { surfaces as bloomSurfaces } from '@oxyhq/bloom/surfaces';
37
41
  import { useTheme } from '@oxyhq/bloom/theme';
38
42
  import { toast } from '@oxyhq/bloom';
39
43
  import { getAccountDisplayName, logger } from '@oxyhq/core';
44
+ import type { FileMetadata } from '@oxyhq/core';
40
45
  import { useOxy } from '../context/OxyContext';
41
46
  import { useI18n } from '../hooks/useI18n';
42
47
  import { useSurfaceHeader } from '../hooks/useSurfaceHeader';
43
- import { openWithinOrPresent } from '../navigation/surfaces';
44
48
  import { SettingsIcon } from '../components/SettingsIcon';
45
49
  import type { BaseScreenProps } from '../types/navigation';
46
50
 
@@ -229,44 +233,47 @@ const ChangeAvatarScreen: React.FC<BaseScreenProps> = ({ navigate, dismiss }) =>
229
233
  );
230
234
 
231
235
  /**
232
- * Pick from the files the user already has on Oxy. The image-only media
233
- * selector MORPHS into this surface (a nested sub-flow) like every other
234
- * screen and resolves with the chosen file; picking one then morphs on to the
235
- * crop editor. When triggered with no surface open it presents cold instead.
236
+ * Forward the file the user picked from their Oxy library on to the crop
237
+ * editor. Given to the `FileManagement` picker as its `onPicked` handler, so
238
+ * SELECTING a file pushes the cropper ON TOP of the picker frame the picker
239
+ * stays below, and Cancel on the cropper returns to it to re-pick.
240
+ *
241
+ * The file's ID is handed straight to the cropper WITHOUT resolving its
242
+ * (usually private) download URL here: the cropper resolves it and shows its
243
+ * own loading state on the dark canvas, so the picker never lingers for the
244
+ * network round-trip (the "flash back to the avatar screen" the resolve-here
245
+ * approach caused).
236
246
  */
237
- const pickFromOxyFiles = useCallback(async () => {
238
- if (busy) return;
239
- setBusy(true);
240
- try {
241
- const file = await openWithinOrPresent('FileManagement', {
242
- selectMode: true,
243
- multiSelect: false,
244
- disabledMimeTypes: NON_IMAGE_MIME_TYPES,
245
- });
246
- if (!file) return;
247
-
247
+ const handleOxyFilePicked = useCallback(
248
+ (file: FileMetadata) => {
249
+ // The image-only picker never surfaces non-images, but guard
250
+ // defensively — a non-image would break the cropper.
248
251
  if (!file.contentType?.startsWith('image/')) {
249
252
  toast.error(t('editProfile.toasts.selectImage'));
250
253
  return;
251
254
  }
252
-
253
- // Hand the file's ID straight to the cropper WITHOUT resolving its
254
- // (usually private) download URL here. The cropper resolves it — and
255
- // shows its own loading state on the dark canvas while it does — so
256
- // this source list is never left on screen for the network round-trip
257
- // that resolution takes (the "goes back to the avatar screen" flash).
258
255
  goToCrop({ imageFileId: file.id });
259
- } catch (err) {
260
- logger.error(
261
- 'Oxy files pick failed',
262
- err instanceof Error ? err : new Error(String(err)),
263
- { component: LOG_COMPONENT },
264
- );
265
- toast.error(t('changeAvatar.errors.loadImageFailed'));
266
- } finally {
267
- setBusy(false);
268
- }
269
- }, [busy, goToCrop, t]);
256
+ },
257
+ [goToCrop, t],
258
+ );
259
+
260
+ /**
261
+ * Pick from the files the user already has on Oxy. Navigates WITHIN this
262
+ * surface to the image-only `FileManagement` picker as a real frame (not a
263
+ * resolve-and-pop sub-flow): selecting a file forward-navigates to the crop
264
+ * editor via {@link handleOxyFilePicked}, giving a three-frame stack
265
+ * (ChangeAvatar → picker → crop). Back from the cropper returns to the picker
266
+ * to re-pick; back from the picker returns here. The cropper's confirm
267
+ * resolves the whole avatar flow with the crop result.
268
+ */
269
+ const pickFromOxyFiles = useCallback(() => {
270
+ navigate?.('FileManagement', {
271
+ selectMode: true,
272
+ multiSelect: false,
273
+ disabledMimeTypes: NON_IMAGE_MIME_TYPES,
274
+ onPicked: handleOxyFilePicked,
275
+ });
276
+ }, [navigate, handleOxyFilePicked]);
270
277
 
271
278
  /** Confirm, then resolve the surface with the removal so the caller writes it. */
272
279
  const removeCurrentPhoto = useCallback(async () => {
@@ -341,7 +348,7 @@ const ChangeAvatarScreen: React.FC<BaseScreenProps> = ({ navigate, dismiss }) =>
341
348
  title={t('changeAvatar.sources.files.title')}
342
349
  description={t('changeAvatar.sources.files.description')}
343
350
  disabled={busy}
344
- onPress={() => void pickFromOxyFiles()}
351
+ onPress={() => pickFromOxyFiles()}
345
352
  />
346
353
  <SettingsListItem
347
354
  icon={<SettingsIcon name="trash-can-outline" color={bloomTheme.colors.error} />}
@@ -1,10 +1,6 @@
1
1
  import type React from 'react';
2
2
  import { useState, useCallback, useRef, useEffect } from 'react';
3
- import {
4
- View,
5
- ActivityIndicator,
6
- TouchableOpacity,
7
- } from 'react-native';
3
+ import { View, ActivityIndicator } from 'react-native';
8
4
  import { Ionicons } from '@expo/vector-icons';
9
5
  import type { AccountKind, CreateAccountInput, OrganizationCategory } from '@oxyhq/core';
10
6
  import { ORGANIZATION_CATEGORIES } from '@oxyhq/core';
@@ -15,6 +11,7 @@ import { useTheme } from '@oxyhq/bloom/theme';
15
11
  import { Text } from '@oxyhq/bloom/typography';
16
12
  import { Button } from '@oxyhq/bloom/button';
17
13
  import { TextField, TextFieldInput } from '@oxyhq/bloom/text-field';
14
+ import { SettingsListGroup, SettingsListItem } from '@oxyhq/bloom/settings-list';
18
15
  import { useOxy } from '../context/OxyContext';
19
16
  import { toast } from '@oxyhq/bloom';
20
17
 
@@ -122,6 +119,12 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
122
119
 
123
120
  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
124
121
 
122
+ useEffect(() => () => {
123
+ if (debounceTimerRef.current) {
124
+ clearTimeout(debounceTimerRef.current);
125
+ }
126
+ }, []);
127
+
125
128
  // Debounced username availability check
126
129
  const checkUsername = useCallback((value: string) => {
127
130
  if (debounceTimerRef.current) {
@@ -172,15 +175,6 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
172
175
  checkUsername(cleaned);
173
176
  }, [checkUsername]);
174
177
 
175
- // Cleanup debounce timer
176
- useEffect(() => {
177
- return () => {
178
- if (debounceTimerRef.current) {
179
- clearTimeout(debounceTimerRef.current);
180
- }
181
- };
182
- }, []);
183
-
184
178
  const canCreate = usernameStatus === 'available' && displayName.trim().length > 0 && !isCreating;
185
179
 
186
180
  const handleCreate = useCallback(async () => {
@@ -234,168 +228,136 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
234
228
  const title = t('accounts.create.title') || 'Create account';
235
229
 
236
230
  return (
237
- <>
238
-
239
- <View className="px-screen-margin pt-space-16 pb-space-32 gap-space-24">
240
-
241
- {/* Kind picker */}
231
+ <View className="gap-space-16 px-screen-margin pt-space-16 pb-space-32">
232
+
233
+ {/* Account type canonical grouped selection rows (checkmark on the chosen one) */}
234
+ <SettingsListGroup title={t('accounts.create.typeSection') || 'Account type'}>
235
+ {KIND_OPTIONS.map((option) => {
236
+ const selected = option.value === kind;
237
+ return (
238
+ <SettingsListItem
239
+ key={option.value}
240
+ icon={(
241
+ <Ionicons
242
+ name={option.icon}
243
+ size={22}
244
+ color={selected ? bloomTheme.colors.primary : bloomTheme.colors.icon}
245
+ />
246
+ )}
247
+ title={kindLabel(t, option.value)}
248
+ description={kindDescription(t, option.value)}
249
+ onPress={() => setKind(option.value)}
250
+ showChevron={false}
251
+ rightElement={selected ? (
252
+ <Ionicons name="checkmark-circle" size={20} color={bloomTheme.colors.primary} />
253
+ ) : undefined}
254
+ accessibilityLabel={kindLabel(t, option.value)}
255
+ />
256
+ );
257
+ })}
258
+ </SettingsListGroup>
259
+
260
+ {/* Organization category — grouped selection rows, shown only for organizations */}
261
+ {kind === 'organization' ? (
262
+ <SettingsListGroup title={t('accounts.create.organizationCategory.label')}>
263
+ {ORGANIZATION_CATEGORY_OPTIONS.map((option) => {
264
+ const selected = option === organizationCategory;
265
+ return (
266
+ <SettingsListItem
267
+ key={option}
268
+ title={organizationCategoryLabel(t, option)}
269
+ onPress={() => setOrganizationCategory(option)}
270
+ showChevron={false}
271
+ rightElement={selected ? (
272
+ <Ionicons name="checkmark-circle" size={20} color={bloomTheme.colors.primary} />
273
+ ) : undefined}
274
+ accessibilityLabel={organizationCategoryLabel(t, option)}
275
+ />
276
+ );
277
+ })}
278
+ </SettingsListGroup>
279
+ ) : null}
280
+
281
+ {/* Details — a grouped section card hosting the form fields */}
282
+ <SettingsListGroup title={t('accounts.create.detailsSection') || 'Details'}>
283
+ <View className="p-space-16 gap-space-16">
284
+ {/* Username */}
242
285
  <View className="gap-space-8">
243
- {KIND_OPTIONS.map((option) => {
244
- const selected = option.value === kind;
245
- return (
246
- <TouchableOpacity
247
- key={option.value}
248
- accessibilityRole="radio"
249
- accessibilityState={{ selected }}
250
- accessibilityLabel={kindLabel(t, option.value)}
251
- onPress={() => setKind(option.value)}
252
- activeOpacity={0.7}
253
- className="flex-row items-center gap-space-12 p-space-16 rounded-radius-20"
254
- style={{
255
- backgroundColor: selected ? bloomTheme.colors.primarySubtle : bloomTheme.colors.card,
256
- borderWidth: 1,
257
- borderColor: selected ? bloomTheme.colors.primary : bloomTheme.colors.border,
258
- }}
259
- >
260
- <Ionicons
261
- name={option.icon}
262
- size={22}
263
- color={selected ? bloomTheme.colors.primary : bloomTheme.colors.icon}
264
- />
265
- <View className="flex-1">
266
- <Text
267
- className="text-body font-bodyBold"
268
- style={{ color: selected ? bloomTheme.colors.primary : bloomTheme.colors.text }}
269
- >
270
- {kindLabel(t, option.value)}
271
- </Text>
272
- <Text className="text-caption font-caption text-text-secondary">
273
- {kindDescription(t, option.value)}
274
- </Text>
275
- </View>
276
- {selected ? (
277
- <Ionicons name="checkmark-circle" size={20} color={bloomTheme.colors.primary} />
278
- ) : null}
279
- </TouchableOpacity>
280
- );
281
- })}
286
+ <TextField isInvalid={usernameIsInvalid}>
287
+ <TextFieldInput
288
+ floatingLabel
289
+ label={t('accounts.create.username.label') || 'Username'}
290
+ value={username}
291
+ onChangeText={handleUsernameChange}
292
+ isInvalid={usernameIsInvalid}
293
+ autoCapitalize="none"
294
+ autoCorrect={false}
295
+ autoComplete="off"
296
+ maxLength={USERNAME_MAX}
297
+ />
298
+ </TextField>
299
+ {(usernameStatus === 'checking' || usernameMessage) ? (
300
+ <View className="flex-row items-center gap-space-4 px-space-4">
301
+ {usernameStatus === 'checking' ? (
302
+ <ActivityIndicator size="small" color={bloomTheme.colors.primary} />
303
+ ) : usernameStatus === 'available' ? (
304
+ <Ionicons name="checkmark-circle" size={16} color={bloomTheme.colors.success} />
305
+ ) : usernameIsInvalid ? (
306
+ <Ionicons name="alert-circle" size={16} color={bloomTheme.colors.negative} />
307
+ ) : null}
308
+ {usernameMessage ? (
309
+ <Text className="text-caption font-caption" style={{ color: statusColor }}>
310
+ {usernameMessage}
311
+ </Text>
312
+ ) : null}
313
+ </View>
314
+ ) : null}
282
315
  </View>
283
316
 
284
- {kind === 'organization' ? (
285
- <View className="gap-space-8">
286
- <Text className="text-body font-bodyBold text-text">
287
- {t('accounts.create.organizationCategory.label')}
288
- </Text>
289
- <View className="flex-row flex-wrap gap-space-8">
290
- {ORGANIZATION_CATEGORY_OPTIONS.map((option) => {
291
- const selected = option === organizationCategory;
292
- return (
293
- <TouchableOpacity
294
- key={option}
295
- accessibilityRole="radio"
296
- accessibilityState={{ selected }}
297
- accessibilityLabel={organizationCategoryLabel(t, option)}
298
- onPress={() => setOrganizationCategory(option)}
299
- activeOpacity={0.7}
300
- className="px-space-12 py-space-8 rounded-radius-full"
301
- style={{
302
- backgroundColor: selected ? bloomTheme.colors.primarySubtle : bloomTheme.colors.card,
303
- borderWidth: 1,
304
- borderColor: selected ? bloomTheme.colors.primary : bloomTheme.colors.border,
305
- }}
306
- >
307
- <Text
308
- className="text-caption font-captionBold"
309
- style={{ color: selected ? bloomTheme.colors.primary : bloomTheme.colors.text }}
310
- >
311
- {organizationCategoryLabel(t, option)}
312
- </Text>
313
- </TouchableOpacity>
314
- );
315
- })}
316
- </View>
317
- </View>
318
- ) : null}
319
-
320
- {/* Form Content */}
321
- <View className="gap-space-16 p-space-16 rounded-radius-20 bg-fill">
322
- {/* Username */}
323
- <View className="gap-space-8">
324
- <TextField isInvalid={usernameIsInvalid}>
325
- <TextFieldInput
326
- floatingLabel
327
- label={t('accounts.create.username.label') || 'Username'}
328
- value={username}
329
- onChangeText={handleUsernameChange}
330
- isInvalid={usernameIsInvalid}
331
- autoCapitalize="none"
332
- autoCorrect={false}
333
- autoComplete="off"
334
- maxLength={USERNAME_MAX}
335
- />
336
- </TextField>
337
- {(usernameStatus === 'checking' || usernameMessage) ? (
338
- <View className="flex-row items-center gap-space-4 px-space-4">
339
- {usernameStatus === 'checking' ? (
340
- <ActivityIndicator size="small" color={bloomTheme.colors.primary} />
341
- ) : usernameStatus === 'available' ? (
342
- <Ionicons name="checkmark-circle" size={16} color={bloomTheme.colors.success} />
343
- ) : usernameIsInvalid ? (
344
- <Ionicons name="alert-circle" size={16} color={bloomTheme.colors.negative} />
345
- ) : null}
346
- {usernameMessage ? (
347
- <Text className="text-caption font-caption" style={{ color: statusColor }}>
348
- {usernameMessage}
349
- </Text>
350
- ) : null}
351
- </View>
352
- ) : null}
353
- </View>
354
-
355
- {/* Display Name */}
317
+ {/* Display Name */}
318
+ <TextField>
319
+ <TextFieldInput
320
+ floatingLabel
321
+ label={t('accounts.create.displayName.label') || 'Display name'}
322
+ value={displayName}
323
+ onChangeText={setDisplayName}
324
+ maxLength={DISPLAY_NAME_MAX}
325
+ />
326
+ </TextField>
327
+
328
+ {/* Bio */}
329
+ <View className="gap-space-4">
356
330
  <TextField>
357
331
  <TextFieldInput
358
332
  floatingLabel
359
- label={t('accounts.create.displayName.label') || 'Display name'}
360
- value={displayName}
361
- onChangeText={setDisplayName}
362
- maxLength={DISPLAY_NAME_MAX}
333
+ label={t('accounts.create.bio.label') || 'Bio (optional)'}
334
+ value={bio}
335
+ onChangeText={setBio}
336
+ maxLength={BIO_MAX}
337
+ multiline
338
+ numberOfLines={3}
339
+ textAlignVertical="top"
363
340
  />
364
341
  </TextField>
365
-
366
- {/* Bio */}
367
- <View className="gap-space-4">
368
- <TextField>
369
- <TextFieldInput
370
- floatingLabel
371
- label={t('accounts.create.bio.label') || 'Bio (optional)'}
372
- value={bio}
373
- onChangeText={setBio}
374
- maxLength={BIO_MAX}
375
- multiline
376
- numberOfLines={3}
377
- textAlignVertical="top"
378
- />
379
- </TextField>
380
- <Text className="text-caption font-caption text-text-tertiary px-space-4 text-right">
381
- {bio.length}/{BIO_MAX}
382
- </Text>
383
- </View>
342
+ <Text className="text-caption font-caption text-text-tertiary px-space-4 text-right">
343
+ {bio.length}/{BIO_MAX}
344
+ </Text>
384
345
  </View>
385
-
386
- {/* Create Button */}
387
- <Button
388
- variant="primary"
389
- onPress={handleCreate}
390
- disabled={!canCreate}
391
- loading={isCreating}
392
- accessibilityLabel={title}
393
- className="w-full"
394
- >
395
- {title}
396
- </Button>
397
346
  </View>
398
- </>
347
+ </SettingsListGroup>
348
+
349
+ {/* Create Button */}
350
+ <Button
351
+ variant="primary"
352
+ onPress={handleCreate}
353
+ disabled={!canCreate}
354
+ loading={isCreating}
355
+ accessibilityLabel={title}
356
+ className="w-full"
357
+ >
358
+ {title}
359
+ </Button>
360
+ </View>
399
361
  );
400
362
  };
401
363
 
@@ -88,6 +88,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
88
88
  selectMode = false,
89
89
  multiSelect = false,
90
90
  onSelect,
91
+ onPicked,
91
92
  onConfirmSelection,
92
93
  initialSelectedIds = [],
93
94
  maxSelection,
@@ -305,7 +306,13 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
305
306
  }
306
307
 
307
308
  if (!multiSelect) {
308
- if (onSelect) {
309
+ if (onPicked) {
310
+ // Forward-navigation picker (e.g. the avatar flow): hand the file
311
+ // to the caller, which pushes the NEXT frame (the cropper) on top
312
+ // of this picker. The picker frame stays below so Back returns here
313
+ // to re-pick — no dismiss, no session close.
314
+ onPicked(file);
315
+ } else if (onSelect) {
309
316
  // Legacy callback caller (inbox, WelcomeNewUser): keep the
310
317
  // callback + afterSelect behaviour unchanged.
311
318
  onSelect(file);
@@ -315,8 +322,8 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
315
322
  onClose?.();
316
323
  }
317
324
  } else {
318
- // Promise-based picker (avatar picker): resolve the surface's
319
- // present() promise with the picked file.
325
+ // Promise-based picker: resolve the surface's present() promise
326
+ // with the picked file.
320
327
  dismiss?.(file);
321
328
  }
322
329
  return;
@@ -336,7 +343,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
336
343
  return next;
337
344
  });
338
345
  scrollToSelectedFile(file.id);
339
- }, [multiSelect, onSelect, onClose, goBack, dismiss, disabledMimeTypes, maxSelection, afterSelect, defaultVisibility, oxyServices, linkContext, t, scrollToSelectedFile]);
346
+ }, [multiSelect, onPicked, onSelect, onClose, goBack, dismiss, disabledMimeTypes, maxSelection, afterSelect, defaultVisibility, oxyServices, linkContext, t, scrollToSelectedFile]);
340
347
 
341
348
  const confirmMultiSelection = useCallback(async () => {
342
349
  if (!selectMode || !multiSelect) return;
@@ -427,8 +434,10 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
427
434
  multiSelect,
428
435
  afterSelect,
429
436
  onSelect,
437
+ onPicked,
430
438
  goBack,
431
439
  onClose,
440
+ imagesOnly: isImageOnlyPicker,
432
441
  selectedIds,
433
442
  setSelectedIds,
434
443
  t,
@@ -999,16 +1008,21 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
999
1008
  const isOwner = user?.id === targetUserId;
1000
1009
  const allowUpload = isOwner && allowUploadInSelectMode;
1001
1010
 
1002
- // Cancel = the nav bar's back affordance. Legacy callback callers keep their
1003
- // close/back behaviour; the promise-based avatar picker dismisses its surface.
1011
+ // Cancel = the nav bar's back affordance. A forward-nav picker (`onPicked`,
1012
+ // e.g. the avatar flow) pops its own frame; legacy callback callers keep their
1013
+ // close/back behaviour; a promise-based picker dismisses its surface.
1004
1014
  const pickerCancel = useCallback(() => {
1005
- if (onSelect || onConfirmSelection) {
1015
+ if (onPicked) {
1016
+ // Forward-nav picker: Cancel/back pops THIS frame, returning to the
1017
+ // frame that opened the picker — never closes the whole session.
1018
+ goBack?.();
1019
+ } else if (onSelect || onConfirmSelection) {
1006
1020
  if (onClose) onClose();
1007
1021
  else goBack?.();
1008
1022
  } else {
1009
1023
  dismiss?.();
1010
1024
  }
1011
- }, [onSelect, onConfirmSelection, onClose, goBack, dismiss]);
1025
+ }, [onPicked, onSelect, onConfirmSelection, onClose, goBack, dismiss]);
1012
1026
 
1013
1027
  // The picker's ONE trailing CTA, declared through the Dialog header's
1014
1028
  // `primaryAction` (a proper Bloom Button with loading/disabled) — the
@@ -305,7 +305,6 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: PhotoPickerCe
305
305
  {isSelected && (
306
306
  <Animated.View
307
307
  ref={startRingPulse}
308
- pointerEvents="none"
309
308
  style={{
310
309
  position: 'absolute',
311
310
  top: 0,
@@ -316,14 +315,14 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: PhotoPickerCe
316
315
  borderWidth: SELECTION_RING_WIDTH,
317
316
  borderColor: primaryColor,
318
317
  transform: [{ scale: ringScale }],
318
+ pointerEvents: 'none',
319
319
  }}
320
320
  />
321
321
  )}
322
322
  {isSelected && (
323
323
  <View
324
- pointerEvents="none"
325
324
  className="absolute top-1.5 right-1.5 min-w-[22px] h-[22px] px-1.5 rounded-full items-center justify-center"
326
- style={{ backgroundColor: primaryColor }}
325
+ style={{ backgroundColor: primaryColor, pointerEvents: 'none' }}
327
326
  >
328
327
  <Text className="text-white text-[12px] font-bold leading-[14px]">
329
328
  {selectionIndex > 0 ? String(selectionIndex) : ''}
@@ -546,12 +545,12 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
546
545
  because the last tile in each row carries no right margin (a
547
546
  full row + gutters fits the measured width by construction). */
548
547
  <View
549
- style={{ flexDirection: 'row', flexWrap: 'wrap' }}
550
- pointerEvents="none"
548
+ style={{ flexDirection: 'row', flexWrap: 'wrap', pointerEvents: 'none' }}
551
549
  accessibilityElementsHidden
552
550
  importantForAccessibility="no-hide-descendants"
553
551
  >
554
552
  {Array.from({ length: skeletonTileCount }, (_, index) => (
553
+ // biome-ignore lint/suspicious/noArrayIndexKey: fixed-size skeleton grid, order never changes
555
554
  <Skeleton.Box
556
555
  key={`skeleton-${index}`}
557
556
  width={cellSize}
@@ -1,5 +1,5 @@
1
1
  import type React from 'react';
2
- import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
2
+ import { View, Text, ActivityIndicator, StyleSheet, Platform, type ViewStyle } from 'react-native';
3
3
  import { Ionicons } from '@expo/vector-icons';
4
4
 
5
5
  export interface UploadBarProps {
@@ -13,15 +13,19 @@ export interface UploadBarProps {
13
13
  }
14
14
 
15
15
  // The banner drop-shadow has no Bloom/Tailwind token equivalent, so it stays an
16
- // inline style (genuinely-required exception).
16
+ // inline style. `boxShadow` on web (RN-Web deprecated the `shadow*` props);
17
+ // RN `shadow*`/`elevation` on native.
17
18
  const shadowStyle = StyleSheet.create({
18
- banner: {
19
- shadowColor: '#000',
20
- shadowOpacity: 0.1,
21
- shadowRadius: 6,
22
- shadowOffset: { width: 0, height: 2 },
23
- elevation: 2,
24
- },
19
+ banner: Platform.select({
20
+ web: { boxShadow: '0px 2px 6px rgba(0, 0, 0, 0.1)' },
21
+ default: {
22
+ shadowColor: '#000',
23
+ shadowOpacity: 0.1,
24
+ shadowRadius: 6,
25
+ shadowOffset: { width: 0, height: 2 },
26
+ elevation: 2,
27
+ },
28
+ }) as ViewStyle,
25
29
  });
26
30
 
27
31
  /**
@@ -30,7 +34,10 @@ const shadowStyle = StyleSheet.create({
30
34
  * gates rendering on `!selectMode && uploading`.
31
35
  */
32
36
  const UploadBar: React.FC<UploadBarProps> = ({ uploadProgress, isDark, colors, t }) => (
33
- <View pointerEvents="none" className="absolute top-[72px] left-0 right-0 items-center z-50">
37
+ <View
38
+ className="absolute top-[72px] left-0 right-0 items-center z-50"
39
+ style={{ pointerEvents: 'none' }}
40
+ >
34
41
  <View
35
42
  className="flex-row items-center px-3.5 py-2.5 rounded-[24px] gap-2.5 border min-w-[200px]"
36
43
  style={[shadowStyle.banner, { backgroundColor: isDark ? '#222831EE' : '#FFFFFFEE', borderColor: colors.border }]}