@oxyhq/services 22.12.0 → 22.12.2

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 (71) 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 +2 -2
  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/screens/ChangeAvatarScreen.js +48 -42
  8. package/lib/commonjs/ui/screens/ChangeAvatarScreen.js.map +1 -1
  9. package/lib/commonjs/ui/screens/CreateAccountScreen.js +55 -98
  10. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
  11. package/lib/commonjs/ui/screens/FileManagementScreen.js +32 -13
  12. package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
  13. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js +35 -41
  14. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  15. package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js +14 -6
  16. package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
  17. package/lib/module/ui/components/FollowButton.js +5 -8
  18. package/lib/module/ui/components/FollowButton.js.map +1 -1
  19. package/lib/module/ui/hooks/useSurfaceHeader.js +2 -2
  20. package/lib/module/ui/hooks/useSurfaceHeader.js.map +1 -1
  21. package/lib/module/ui/navigation/surfaceNavStack.js +8 -7
  22. package/lib/module/ui/navigation/surfaceNavStack.js.map +1 -1
  23. package/lib/module/ui/screens/ChangeAvatarScreen.js +48 -42
  24. package/lib/module/ui/screens/ChangeAvatarScreen.js.map +1 -1
  25. package/lib/module/ui/screens/CreateAccountScreen.js +58 -101
  26. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
  27. package/lib/module/ui/screens/FileManagementScreen.js +32 -13
  28. package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
  29. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js +35 -41
  30. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  31. package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js +14 -6
  32. package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
  33. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/ui/hooks/useSurfaceHeader.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/ui/navigation/surfaceNavStack.d.ts +7 -4
  36. package/lib/typescript/commonjs/ui/navigation/surfaceNavStack.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/screens/ChangeAvatarScreen.d.ts +9 -5
  38. package/lib/typescript/commonjs/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts +1 -1
  41. package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.d.ts +3 -1
  44. package/lib/typescript/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/ui/types/fileManagement.d.ts +10 -0
  46. package/lib/typescript/commonjs/ui/types/fileManagement.d.ts.map +1 -1
  47. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  48. package/lib/typescript/module/ui/hooks/useSurfaceHeader.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/navigation/surfaceNavStack.d.ts +7 -4
  50. package/lib/typescript/module/ui/navigation/surfaceNavStack.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/screens/ChangeAvatarScreen.d.ts +9 -5
  52. package/lib/typescript/module/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
  53. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  54. package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts +1 -1
  55. package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
  56. package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  57. package/lib/typescript/module/ui/screens/fileManagement/hooks/useFileUploadState.d.ts +3 -1
  58. package/lib/typescript/module/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
  59. package/lib/typescript/module/ui/types/fileManagement.d.ts +10 -0
  60. package/lib/typescript/module/ui/types/fileManagement.d.ts.map +1 -1
  61. package/package.json +4 -4
  62. package/src/ui/components/FollowButton.tsx +5 -8
  63. package/src/ui/hooks/useSurfaceHeader.ts +2 -17
  64. package/src/ui/navigation/__tests__/surfaceNavStack.test.ts +49 -17
  65. package/src/ui/navigation/surfaceNavStack.ts +15 -11
  66. package/src/ui/screens/ChangeAvatarScreen.tsx +46 -39
  67. package/src/ui/screens/CreateAccountScreen.tsx +123 -167
  68. package/src/ui/screens/FileManagementScreen.tsx +28 -12
  69. package/src/ui/screens/fileManagement/PhotoPickerSection.tsx +35 -44
  70. package/src/ui/screens/fileManagement/hooks/useFileUploadState.ts +16 -6
  71. 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
- import { useState, useCallback, useRef, useEffect } from 'react';
3
- import {
4
- View,
5
- ActivityIndicator,
6
- TouchableOpacity,
7
- } from 'react-native';
2
+ import { useState, useCallback, useRef } from 'react';
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
 
@@ -172,15 +169,6 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
172
169
  checkUsername(cleaned);
173
170
  }, [checkUsername]);
174
171
 
175
- // Cleanup debounce timer
176
- useEffect(() => {
177
- return () => {
178
- if (debounceTimerRef.current) {
179
- clearTimeout(debounceTimerRef.current);
180
- }
181
- };
182
- }, []);
183
-
184
172
  const canCreate = usernameStatus === 'available' && displayName.trim().length > 0 && !isCreating;
185
173
 
186
174
  const handleCreate = useCallback(async () => {
@@ -234,168 +222,136 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
234
222
  const title = t('accounts.create.title') || 'Create account';
235
223
 
236
224
  return (
237
- <>
238
-
239
- <View className="px-screen-margin pt-space-16 pb-space-32 gap-space-24">
240
-
241
- {/* Kind picker */}
225
+ <View className="px-screen-margin pt-space-16 pb-space-32">
226
+
227
+ {/* Account type canonical grouped selection rows (checkmark on the chosen one) */}
228
+ <SettingsListGroup title={t('accounts.create.typeSection') || 'Account type'}>
229
+ {KIND_OPTIONS.map((option) => {
230
+ const selected = option.value === kind;
231
+ return (
232
+ <SettingsListItem
233
+ key={option.value}
234
+ icon={(
235
+ <Ionicons
236
+ name={option.icon}
237
+ size={22}
238
+ color={selected ? bloomTheme.colors.primary : bloomTheme.colors.icon}
239
+ />
240
+ )}
241
+ title={kindLabel(t, option.value)}
242
+ description={kindDescription(t, option.value)}
243
+ onPress={() => setKind(option.value)}
244
+ showChevron={false}
245
+ rightElement={selected ? (
246
+ <Ionicons name="checkmark-circle" size={20} color={bloomTheme.colors.primary} />
247
+ ) : undefined}
248
+ accessibilityLabel={kindLabel(t, option.value)}
249
+ />
250
+ );
251
+ })}
252
+ </SettingsListGroup>
253
+
254
+ {/* Organization category — grouped selection rows, shown only for organizations */}
255
+ {kind === 'organization' ? (
256
+ <SettingsListGroup title={t('accounts.create.organizationCategory.label')}>
257
+ {ORGANIZATION_CATEGORY_OPTIONS.map((option) => {
258
+ const selected = option === organizationCategory;
259
+ return (
260
+ <SettingsListItem
261
+ key={option}
262
+ title={organizationCategoryLabel(t, option)}
263
+ onPress={() => setOrganizationCategory(option)}
264
+ showChevron={false}
265
+ rightElement={selected ? (
266
+ <Ionicons name="checkmark-circle" size={20} color={bloomTheme.colors.primary} />
267
+ ) : undefined}
268
+ accessibilityLabel={organizationCategoryLabel(t, option)}
269
+ />
270
+ );
271
+ })}
272
+ </SettingsListGroup>
273
+ ) : null}
274
+
275
+ {/* Details — a grouped section card hosting the form fields */}
276
+ <SettingsListGroup title={t('accounts.create.detailsSection') || 'Details'}>
277
+ <View className="p-space-16 gap-space-16">
278
+ {/* Username */}
242
279
  <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
- })}
280
+ <TextField isInvalid={usernameIsInvalid}>
281
+ <TextFieldInput
282
+ floatingLabel
283
+ label={t('accounts.create.username.label') || 'Username'}
284
+ value={username}
285
+ onChangeText={handleUsernameChange}
286
+ isInvalid={usernameIsInvalid}
287
+ autoCapitalize="none"
288
+ autoCorrect={false}
289
+ autoComplete="off"
290
+ maxLength={USERNAME_MAX}
291
+ />
292
+ </TextField>
293
+ {(usernameStatus === 'checking' || usernameMessage) ? (
294
+ <View className="flex-row items-center gap-space-4 px-space-4">
295
+ {usernameStatus === 'checking' ? (
296
+ <ActivityIndicator size="small" color={bloomTheme.colors.primary} />
297
+ ) : usernameStatus === 'available' ? (
298
+ <Ionicons name="checkmark-circle" size={16} color={bloomTheme.colors.success} />
299
+ ) : usernameIsInvalid ? (
300
+ <Ionicons name="alert-circle" size={16} color={bloomTheme.colors.negative} />
301
+ ) : null}
302
+ {usernameMessage ? (
303
+ <Text className="text-caption font-caption" style={{ color: statusColor }}>
304
+ {usernameMessage}
305
+ </Text>
306
+ ) : null}
307
+ </View>
308
+ ) : null}
282
309
  </View>
283
310
 
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 */}
311
+ {/* Display Name */}
312
+ <TextField>
313
+ <TextFieldInput
314
+ floatingLabel
315
+ label={t('accounts.create.displayName.label') || 'Display name'}
316
+ value={displayName}
317
+ onChangeText={setDisplayName}
318
+ maxLength={DISPLAY_NAME_MAX}
319
+ />
320
+ </TextField>
321
+
322
+ {/* Bio */}
323
+ <View className="gap-space-4">
356
324
  <TextField>
357
325
  <TextFieldInput
358
326
  floatingLabel
359
- label={t('accounts.create.displayName.label') || 'Display name'}
360
- value={displayName}
361
- onChangeText={setDisplayName}
362
- maxLength={DISPLAY_NAME_MAX}
327
+ label={t('accounts.create.bio.label') || 'Bio (optional)'}
328
+ value={bio}
329
+ onChangeText={setBio}
330
+ maxLength={BIO_MAX}
331
+ multiline
332
+ numberOfLines={3}
333
+ textAlignVertical="top"
363
334
  />
364
335
  </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>
336
+ <Text className="text-caption font-caption text-text-tertiary px-space-4 text-right">
337
+ {bio.length}/{BIO_MAX}
338
+ </Text>
384
339
  </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
340
  </View>
398
- </>
341
+ </SettingsListGroup>
342
+
343
+ {/* Create Button */}
344
+ <Button
345
+ variant="primary"
346
+ onPress={handleCreate}
347
+ disabled={!canCreate}
348
+ loading={isCreating}
349
+ accessibilityLabel={title}
350
+ className="w-full"
351
+ >
352
+ {title}
353
+ </Button>
354
+ </View>
399
355
  );
400
356
  };
401
357
 
@@ -1,4 +1,5 @@
1
- import React, { useState, useCallback, useMemo, useRef } from 'react';
1
+ import type React from 'react';
2
+ import { useState, useCallback, useMemo, useRef } from 'react';
2
3
  import {
3
4
  View,
4
5
  Text,
@@ -87,6 +88,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
87
88
  selectMode = false,
88
89
  multiSelect = false,
89
90
  onSelect,
91
+ onPicked,
90
92
  onConfirmSelection,
91
93
  initialSelectedIds = [],
92
94
  maxSelection,
@@ -304,7 +306,13 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
304
306
  }
305
307
 
306
308
  if (!multiSelect) {
307
- 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) {
308
316
  // Legacy callback caller (inbox, WelcomeNewUser): keep the
309
317
  // callback + afterSelect behaviour unchanged.
310
318
  onSelect(file);
@@ -314,8 +322,8 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
314
322
  onClose?.();
315
323
  }
316
324
  } else {
317
- // Promise-based picker (avatar picker): resolve the surface's
318
- // present() promise with the picked file.
325
+ // Promise-based picker: resolve the surface's present() promise
326
+ // with the picked file.
319
327
  dismiss?.(file);
320
328
  }
321
329
  return;
@@ -335,7 +343,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
335
343
  return next;
336
344
  });
337
345
  scrollToSelectedFile(file.id);
338
- }, [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]);
339
347
 
340
348
  const confirmMultiSelection = useCallback(async () => {
341
349
  if (!selectMode || !multiSelect) return;
@@ -426,6 +434,7 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
426
434
  multiSelect,
427
435
  afterSelect,
428
436
  onSelect,
437
+ onPicked,
429
438
  goBack,
430
439
  onClose,
431
440
  selectedIds,
@@ -998,16 +1007,21 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
998
1007
  const isOwner = user?.id === targetUserId;
999
1008
  const allowUpload = isOwner && allowUploadInSelectMode;
1000
1009
 
1001
- // Cancel = the nav bar's back affordance. Legacy callback callers keep their
1002
- // close/back behaviour; the promise-based avatar picker dismisses its surface.
1010
+ // Cancel = the nav bar's back affordance. A forward-nav picker (`onPicked`,
1011
+ // e.g. the avatar flow) pops its own frame; legacy callback callers keep their
1012
+ // close/back behaviour; a promise-based picker dismisses its surface.
1003
1013
  const pickerCancel = useCallback(() => {
1004
- if (onSelect || onConfirmSelection) {
1014
+ if (onPicked) {
1015
+ // Forward-nav picker: Cancel/back pops THIS frame, returning to the
1016
+ // frame that opened the picker — never closes the whole session.
1017
+ goBack?.();
1018
+ } else if (onSelect || onConfirmSelection) {
1005
1019
  if (onClose) onClose();
1006
1020
  else goBack?.();
1007
1021
  } else {
1008
1022
  dismiss?.();
1009
1023
  }
1010
- }, [onSelect, onConfirmSelection, onClose, goBack, dismiss]);
1024
+ }, [onPicked, onSelect, onConfirmSelection, onClose, goBack, dismiss]);
1011
1025
 
1012
1026
  // The picker's ONE trailing CTA, declared through the Dialog header's
1013
1027
  // `primaryAction` (a proper Bloom Button with loading/disabled) — the
@@ -1100,12 +1114,14 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
1100
1114
  <Skeleton.Box width="100%" height={44} borderRadius={22} />
1101
1115
  </View>
1102
1116
 
1103
- {/* Photo grid */}
1117
+ {/* Photo grid — static skeleton placeholders; index keys are stable */}
1104
1118
  <View style={{ padding: GRID_PADDING, gap: TILE_GAP }}>
1105
1119
  {Array.from({ length: GRID_ROWS }, (_, row) => (
1106
- <Skeleton.Row key={row} style={{ gap: TILE_GAP }}>
1120
+ // biome-ignore lint/suspicious/noArrayIndexKey: fixed-size skeleton grid, order never changes
1121
+ <Skeleton.Row key={`skeleton-row-${row}`} style={{ gap: TILE_GAP }}>
1107
1122
  {Array.from({ length: GRID_COLUMNS }, (_, col) => (
1108
- <Skeleton.Box key={col} width={tileSize} height={tileSize} borderRadius={6} />
1123
+ // biome-ignore lint/suspicious/noArrayIndexKey: fixed-size skeleton grid, order never changes
1124
+ <Skeleton.Box key={`skeleton-${row}-${col}`} width={tileSize} height={tileSize} borderRadius={6} />
1109
1125
  ))}
1110
1126
  </Skeleton.Row>
1111
1127
  ))}