@oxyhq/services 22.8.7 → 22.9.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 (116) hide show
  1. package/lib/commonjs/ui/components/OxyAccountDialog.js +30 -39
  2. package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
  3. package/lib/commonjs/ui/components/OxyProvider.js +9 -29
  4. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  5. package/lib/commonjs/ui/components/SurfaceScreen.js +146 -0
  6. package/lib/commonjs/ui/components/SurfaceScreen.js.map +1 -0
  7. package/lib/commonjs/ui/context/OxyContext.js +32 -1
  8. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  9. package/lib/commonjs/ui/navigation/bottomSheetManager.js +38 -56
  10. package/lib/commonjs/ui/navigation/bottomSheetManager.js.map +1 -1
  11. package/lib/commonjs/ui/navigation/routes.js +7 -57
  12. package/lib/commonjs/ui/navigation/routes.js.map +1 -1
  13. package/lib/commonjs/ui/navigation/surfaceNavStack.js +98 -0
  14. package/lib/commonjs/ui/navigation/surfaceNavStack.js.map +1 -0
  15. package/lib/commonjs/ui/navigation/surfaceRegistry.js +108 -0
  16. package/lib/commonjs/ui/navigation/surfaceRegistry.js.map +1 -0
  17. package/lib/commonjs/ui/navigation/surfaces.js +219 -0
  18. package/lib/commonjs/ui/navigation/surfaces.js.map +1 -0
  19. package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js +9 -7
  20. package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
  21. package/lib/commonjs/ui/types/surfaceScreen.js +6 -0
  22. package/lib/commonjs/ui/types/surfaceScreen.js.map +1 -0
  23. package/lib/module/ui/components/OxyAccountDialog.js +31 -40
  24. package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
  25. package/lib/module/ui/components/OxyProvider.js +10 -30
  26. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  27. package/lib/module/ui/components/SurfaceScreen.js +141 -0
  28. package/lib/module/ui/components/SurfaceScreen.js.map +1 -0
  29. package/lib/module/ui/context/OxyContext.js +32 -1
  30. package/lib/module/ui/context/OxyContext.js.map +1 -1
  31. package/lib/module/ui/navigation/bottomSheetManager.js +37 -53
  32. package/lib/module/ui/navigation/bottomSheetManager.js.map +1 -1
  33. package/lib/module/ui/navigation/routes.js +6 -56
  34. package/lib/module/ui/navigation/routes.js.map +1 -1
  35. package/lib/module/ui/navigation/surfaceNavStack.js +95 -0
  36. package/lib/module/ui/navigation/surfaceNavStack.js.map +1 -0
  37. package/lib/module/ui/navigation/surfaceRegistry.js +102 -0
  38. package/lib/module/ui/navigation/surfaceRegistry.js.map +1 -0
  39. package/lib/module/ui/navigation/surfaces.js +207 -0
  40. package/lib/module/ui/navigation/surfaces.js.map +1 -0
  41. package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js +8 -7
  42. package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
  43. package/lib/module/ui/types/surfaceScreen.js +4 -0
  44. package/lib/module/ui/types/surfaceScreen.js.map +1 -0
  45. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts +29 -27
  46. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts +3 -2
  48. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/ui/components/SurfaceScreen.d.ts +24 -0
  50. package/lib/typescript/commonjs/ui/components/SurfaceScreen.d.ts.map +1 -0
  51. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/ui/navigation/bottomSheetManager.d.ts +24 -16
  53. package/lib/typescript/commonjs/ui/navigation/bottomSheetManager.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/ui/navigation/routes.d.ts +2 -60
  55. package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/ui/navigation/surfaceNavStack.d.ts +51 -0
  57. package/lib/typescript/commonjs/ui/navigation/surfaceNavStack.d.ts.map +1 -0
  58. package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts +117 -0
  59. package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts.map +1 -0
  60. package/lib/typescript/commonjs/ui/navigation/surfaces.d.ts +79 -0
  61. package/lib/typescript/commonjs/ui/navigation/surfaces.d.ts.map +1 -0
  62. package/lib/typescript/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/ui/types/surfaceScreen.d.ts +38 -0
  64. package/lib/typescript/commonjs/ui/types/surfaceScreen.d.ts.map +1 -0
  65. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts +29 -27
  66. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
  67. package/lib/typescript/module/ui/components/OxyProvider.d.ts +3 -2
  68. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  69. package/lib/typescript/module/ui/components/SurfaceScreen.d.ts +24 -0
  70. package/lib/typescript/module/ui/components/SurfaceScreen.d.ts.map +1 -0
  71. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  72. package/lib/typescript/module/ui/navigation/bottomSheetManager.d.ts +24 -16
  73. package/lib/typescript/module/ui/navigation/bottomSheetManager.d.ts.map +1 -1
  74. package/lib/typescript/module/ui/navigation/routes.d.ts +2 -60
  75. package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
  76. package/lib/typescript/module/ui/navigation/surfaceNavStack.d.ts +51 -0
  77. package/lib/typescript/module/ui/navigation/surfaceNavStack.d.ts.map +1 -0
  78. package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts +117 -0
  79. package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts.map +1 -0
  80. package/lib/typescript/module/ui/navigation/surfaces.d.ts +79 -0
  81. package/lib/typescript/module/ui/navigation/surfaces.d.ts.map +1 -0
  82. package/lib/typescript/module/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/types/surfaceScreen.d.ts +38 -0
  84. package/lib/typescript/module/ui/types/surfaceScreen.d.ts.map +1 -0
  85. package/package.json +2 -2
  86. package/src/ui/components/OxyAccountDialog.tsx +32 -38
  87. package/src/ui/components/OxyProvider.tsx +12 -34
  88. package/src/ui/components/SurfaceScreen.tsx +166 -0
  89. package/src/ui/context/OxyContext.tsx +27 -1
  90. package/src/ui/navigation/__tests__/getSurfaceConfig.test.ts +39 -0
  91. package/src/ui/navigation/bottomSheetManager.ts +46 -73
  92. package/src/ui/navigation/routes.ts +7 -106
  93. package/src/ui/navigation/surfaceNavStack.ts +110 -0
  94. package/src/ui/navigation/surfaceRegistry.ts +174 -0
  95. package/src/ui/navigation/surfaces.ts +244 -0
  96. package/src/ui/screens/fileManagement/hooks/useFileUploadState.ts +9 -7
  97. package/src/ui/types/surfaceScreen.ts +38 -0
  98. package/lib/commonjs/ui/components/BottomSheet.js +0 -49
  99. package/lib/commonjs/ui/components/BottomSheet.js.map +0 -1
  100. package/lib/commonjs/ui/components/BottomSheetRouter.js +0 -298
  101. package/lib/commonjs/ui/components/BottomSheetRouter.js.map +0 -1
  102. package/lib/module/ui/components/BottomSheet.js +0 -45
  103. package/lib/module/ui/components/BottomSheet.js.map +0 -1
  104. package/lib/module/ui/components/BottomSheetRouter.js +0 -292
  105. package/lib/module/ui/components/BottomSheetRouter.js.map +0 -1
  106. package/lib/typescript/commonjs/ui/components/BottomSheet.d.ts +0 -29
  107. package/lib/typescript/commonjs/ui/components/BottomSheet.d.ts.map +0 -1
  108. package/lib/typescript/commonjs/ui/components/BottomSheetRouter.d.ts +0 -9
  109. package/lib/typescript/commonjs/ui/components/BottomSheetRouter.d.ts.map +0 -1
  110. package/lib/typescript/module/ui/components/BottomSheet.d.ts +0 -29
  111. package/lib/typescript/module/ui/components/BottomSheet.d.ts.map +0 -1
  112. package/lib/typescript/module/ui/components/BottomSheetRouter.d.ts +0 -9
  113. package/lib/typescript/module/ui/components/BottomSheetRouter.d.ts.map +0 -1
  114. package/src/ui/components/BottomSheet.tsx +0 -48
  115. package/src/ui/components/BottomSheetRouter.tsx +0 -310
  116. package/src/ui/navigation/__tests__/getSheetConfig.test.ts +0 -36
@@ -0,0 +1,38 @@
1
+ import type { RouteName } from '../navigation/routes.js';
2
+ import type { SurfaceProps, SurfaceResult } from '../navigation/surfaceRegistry.js';
3
+ /**
4
+ * The typed prop contract a screen receives when it is bound into an SDK surface.
5
+ *
6
+ * This is the successor to the grab-bag `BaseScreenProps`: `props` carries the
7
+ * route's own {@link SurfaceProps typed props}, and the navigation methods are
8
+ * split along the two axes the surface system models — NAV-WITHIN
9
+ * (`navigate`/`replace`/`goBack`/`step`) inside this surface, and DEPTH
10
+ * (`present`/`dismiss`) across the stack.
11
+ *
12
+ * In P1 the ~35 screens still consume the legacy `BaseScreenProps` shape (the
13
+ * `SurfaceScreen` binding passes a superset that satisfies both), so this type is
14
+ * the target contract for the per-screen migration in a later phase — it is not
15
+ * yet the render-time prop shape for existing screens.
16
+ */
17
+ export interface SurfaceScreenProps<K extends RouteName> {
18
+ /** The route's typed props (from the {@link SurfaceProps registry}). */
19
+ props: SurfaceProps<K>;
20
+ /** Drill in to another route — WITHIN this surface, or a new surface if the
21
+ * target's presentation differs. */
22
+ navigate: <T extends RouteName>(route: T, props?: SurfaceProps<T>) => void;
23
+ /** Replace the current route in place (no history entry). */
24
+ replace: <T extends RouteName>(route: T, props?: SurfaceProps<T>) => void;
25
+ /** Pop one level; if at the root, dismiss the surface. */
26
+ goBack: () => void;
27
+ /** Whether a back is possible (a deeper route OR a prior wizard step). */
28
+ canGoBack: () => boolean;
29
+ /** Dismiss THIS surface, resolving its `present()` promise with `result`. */
30
+ dismiss: (result?: SurfaceResult<K>) => void;
31
+ /** Present a NEW surface on top of the stack (DEPTH); resolves with its result. */
32
+ present: <T extends RouteName>(route: T, props?: SurfaceProps<T>) => Promise<SurfaceResult<T> | undefined>;
33
+ /** The current wizard step of this frame. */
34
+ step: number;
35
+ /** Set the current wizard step. */
36
+ setStep: (step: number) => void;
37
+ }
38
+ //# sourceMappingURL=surfaceScreen.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"surfaceScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/types/surfaceScreen.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAsB,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,kCAA+B,CAAC;AAEjF;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,SAAS;IACrD,wEAAwE;IACxE,KAAK,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC;IACvB;wCACoC;IACpC,QAAQ,EAAE,CAAC,CAAC,SAAS,SAAS,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;IAC3E,6DAA6D;IAC7D,OAAO,EAAE,CAAC,CAAC,SAAS,SAAS,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;IAC1E,0DAA0D;IAC1D,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,0EAA0E;IAC1E,SAAS,EAAE,MAAM,OAAO,CAAC;IACzB,6EAA6E;IAC7E,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;IAC7C,mFAAmF;IACnF,OAAO,EAAE,CAAC,CAAC,SAAS,SAAS,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IAC3G,6CAA6C;IAC7C,IAAI,EAAE,MAAM,CAAC;IACb,mCAAmC;IACnC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACjC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/services",
3
- "version": "22.8.7",
3
+ "version": "22.9.0",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -176,7 +176,7 @@
176
176
  },
177
177
  "peerDependencies": {
178
178
  "@expo/vector-icons": "^15.0.3",
179
- "@oxyhq/bloom": ">=0.44.1",
179
+ "@oxyhq/bloom": ">=0.46.0",
180
180
  "@oxyhq/core": "^12.9.0",
181
181
  "@react-native-async-storage/async-storage": "^2.0.0",
182
182
  "@react-native-community/netinfo": "^11.4.1",
@@ -1,38 +1,30 @@
1
1
  /**
2
- * OxyAccountDialog — the ONE unified account dialog for `@oxyhq/services`.
2
+ * OxyAccountDialog — the ONE unified account dialog BODY for `@oxyhq/services`.
3
3
  *
4
- * Bloom `<Dialog>` chrome (header, back button, placement, scroll body) around
5
- * the headless chooser logic, which lives in `OxyAuthChooser` — the account
6
- * switcher, sign-in, sign-up, and QR views are ALL there, extracted so the
7
- * same chooser can be mounted bare by a future host with no Dialog chrome
8
- * (e.g. an auth.oxy.so hub page driving the cross-origin passkey popup, b2).
9
- * This file owns ONLY the chrome: the title/subtitle per view, the back
10
- * button, and the Bloom `<Dialog>` surface itself.
4
+ * The header (title/subtitle per view + back button) around the headless chooser
5
+ * logic, which lives in `OxyAuthChooser` — the account switcher, sign-in,
6
+ * sign-up, and QR views are ALL there, extracted so the same chooser can be
7
+ * mounted bare by a future host with no Dialog chrome (e.g. an auth.oxy.so hub
8
+ * page driving the cross-origin passkey popup, b2). This file owns ONLY the
9
+ * header + the scroll body.
11
10
  *
12
- * The surface is Bloom's `<Dialog>` (`@oxyhq/bloom/dialog`) with a responsive
13
- * `placement`a bottom sheet on narrow viewports, a centered card on wide ones.
14
- * It REPLACES the hand-rolled RN `<Modal>` + `<GestureHandlerRootView>` + manual
15
- * backdrop/card wrapper this component used before. That RN `<Modal>` is invisible
16
- * under React StrictMode on web: react-native-web's `ModalPortal` appends its host
17
- * node during render but removes it in an effect cleanup and never re-attaches, so
18
- * the dialog never paints in a dev Vite build. Bloom's `<Dialog>` renders through
19
- * its own Portal and has no such lifecycle hazard.
20
- *
21
- * Open/close is CONTROLLED by `isAccountDialogOpen` (the `open` prop); the Dialog
22
- * stays mounted whenever the controller exists so it can animate its own close.
23
- * Backdrop / swipe-to-dismiss is disabled (`dismissOnBackdrop={false}`) on purpose:
24
- * Bloom's controlled `bottom` placement does not fire `onClose` on a gesture or
25
- * backdrop dismissal, so allowing it would desync `isAccountDialogOpen` from the
26
- * sheet and block reopening. The header close button (and `OxyAuthChooser`'s
27
- * `onComplete`) drive `closeAccountDialog`, which flips `open` and runs the exit
28
- * animation.
11
+ * It is presented as the `AccountDialog` route on the shared Bloom SURFACE STACK
12
+ * (`@oxyhq/bloom/surfaces`)`OxyContext.openAccountDialog` calls
13
+ * `presentDetached('AccountDialog', …, { placement: { base: 'bottom', md:
14
+ * 'center' }, dismissOnBackdrop: false, maxWidth: 420 })`, so the STACK owns the
15
+ * responsive `<Dialog>` chrome and this component renders only its content. That
16
+ * replaces the previous standalone controlled `<Dialog open={isAccountDialogOpen}>`
17
+ * mount: dismissal now flows through the stack (backdrop/swipe disabled), and the
18
+ * header close button (and `OxyAuthChooser`'s `onComplete`) drive
19
+ * `useOxy().closeAccountDialog`, which dismisses the surface and runs its exit
20
+ * animation. The view-enum (`accounts|signin|qr|add|signup`) stays internal here,
21
+ * driven by the shared `AccountDialogController` in `@oxyhq/core`.
29
22
  */
30
23
 
31
24
  import type React from 'react';
32
25
  import { useCallback, useSyncExternalStore } from 'react';
33
26
  import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
34
27
  import { MaterialCommunityIcons } from '@expo/vector-icons';
35
- import { Dialog } from '@oxyhq/bloom/dialog';
36
28
  import { Text } from '@oxyhq/bloom/typography';
37
29
  import { useTheme } from '@oxyhq/bloom/theme';
38
30
  import type { AccountDialogSnapshot } from '@oxyhq/core';
@@ -45,11 +37,20 @@ type Theme = ReturnType<typeof useTheme>;
45
37
  type Translate = ReturnType<typeof useI18n>['t'];
46
38
 
47
39
  /**
48
- * The unified account dialog. Mounted once by `OxyProvider`; opened imperatively
49
- * via `useOxy().openAccountDialog(view?)` or imperative `openAccountDialog('signin')`.
40
+ * The unified account dialog BODY the header + `OxyAuthChooser` chooser.
41
+ *
42
+ * Presented as the `AccountDialog` surface (route) on the shared Bloom surface
43
+ * stack; the surface owns the `<Dialog>` chrome (responsive `{ base: 'bottom',
44
+ * md: 'center' }` placement, `dismissOnBackdrop={false}`, `maxWidth={420}` — set
45
+ * by `OxyContext` when it presents this surface), so this component renders ONLY
46
+ * the content. Open it via `useOxy().openAccountDialog(view?)` or the imperative
47
+ * `openAccountDialog('signin')`; the view-enum (`accounts|signin|qr|add|signup`)
48
+ * stays internal here, driven by the shared `AccountDialogController` in
49
+ * `@oxyhq/core`. Closing routes through `useOxy().closeAccountDialog`, which
50
+ * dismisses the surface and runs its exit animation.
50
51
  */
51
52
  const OxyAccountDialog: React.FC = () => {
52
- const { accountDialogController: controller, isAccountDialogOpen, closeAccountDialog } = useOxy();
53
+ const { accountDialogController: controller, closeAccountDialog } = useOxy();
53
54
  const theme = useTheme();
54
55
  const { t } = useI18n();
55
56
 
@@ -75,14 +76,7 @@ const OxyAccountDialog: React.FC = () => {
75
76
  view === 'qr' || view === 'signup' || (view === 'add' && snapshot.accounts.length > 0);
76
77
 
77
78
  return (
78
- <Dialog
79
- open={isAccountDialogOpen}
80
- onClose={closeAccountDialog}
81
- placement={{ base: 'bottom', md: 'center' }}
82
- dismissOnBackdrop={false}
83
- maxWidth={420}
84
- label={headerCopy(view, snapshot.accounts.length, t).title}
85
- >
79
+ <>
86
80
  <DialogHeader
87
81
  snapshot={snapshot}
88
82
  theme={theme}
@@ -94,7 +88,7 @@ const OxyAccountDialog: React.FC = () => {
94
88
  <ScrollView style={styles.body} contentContainerStyle={styles.bodyContent} showsVerticalScrollIndicator={false}>
95
89
  <OxyAuthChooser onComplete={closeAccountDialog} />
96
90
  </ScrollView>
97
- </Dialog>
91
+ </>
98
92
  );
99
93
  };
100
94
 
@@ -1,4 +1,4 @@
1
- import { lazy, Suspense, useEffect, useRef, useState, type ComponentType, type FC, type ReactNode } from 'react';
1
+ import { useEffect, useRef, useState, type FC, type ReactNode } from 'react';
2
2
  import { AppState, Platform, StyleSheet, View } from 'react-native';
3
3
  import { GestureHandlerRootView } from 'react-native-gesture-handler';
4
4
  import { SafeAreaProvider } from 'react-native-safe-area-context';
@@ -6,6 +6,7 @@ import type { OxyProviderProps } from '../types/navigation';
6
6
  import { OxyContextProvider, type OxyContextProviderProps } from '../context/OxyContext';
7
7
  import { QueryClientProvider, focusManager, onlineManager } from '@tanstack/react-query';
8
8
  import { BloomDialogProvider } from '@oxyhq/bloom';
9
+ import { SurfaceProvider } from '@oxyhq/bloom/surfaces';
9
10
  import { ToastOutlet } from '@oxyhq/bloom/toast';
10
11
  import { logger as loggerUtil } from '@oxyhq/core';
11
12
  import { RequireOxyAuth } from './RequireOxyAuth';
@@ -49,35 +50,14 @@ const isWeb = Platform.OS === 'web';
49
50
  // static analyzer cannot trace this into the web bundle. Native-only.
50
51
  const KEYBOARD_CONTROLLER_MODULE = 'react-native-keyboard-controller';
51
52
 
52
- // Lazy-load optional components (avoids require() for ESM compatibility).
53
- // The .then() extracts + casts the default export so that `lazy()` sees
54
- // `Promise<{ default: ComponentType }>` instead of the full module namespace.
55
- const LazyBottomSheetRouter = lazy((): Promise<{ default: ComponentType }> =>
56
- import('./BottomSheetRouter').then(
57
- (mod) => ({ default: mod.default as unknown as ComponentType }),
58
- (error) => {
59
- if (__DEV__) {
60
- loggerUtil.error('Failed to load BottomSheetRouter', error instanceof Error ? error : new Error(String(error)), { component: 'OxyProvider' });
61
- }
62
- return { default: (() => null) as FC };
63
- },
64
- ),
65
- );
66
-
67
- const LazyOxyAccountDialog = lazy((): Promise<{ default: ComponentType }> =>
68
- import('./OxyAccountDialog').then(
69
- (mod) => ({ default: mod.default as unknown as ComponentType }),
70
- () => ({ default: (() => null) as FC }),
71
- ),
72
- );
73
-
74
53
  /**
75
54
  * OxyProvider - Universal provider for Expo apps (native + web)
76
55
  *
77
56
  * Provides authentication, session management, query client, and UI overlays.
78
57
  * Wraps its own overlay stack in SafeAreaProvider and GestureHandlerRootView so
79
- * BottomSheetRouter and OxyAccountDialog can safely render even when a consuming
80
- * app has not mounted those providers yet.
58
+ * the shared Bloom SURFACE STACK (`<SurfaceProvider>` / `<SurfaceHost>`) which
59
+ * hosts every SDK surface (the ~35 bottom-sheet routes + the AccountDialog) — can
60
+ * safely render even when a consuming app has not mounted those providers yet.
81
61
  *
82
62
  * Usage:
83
63
  * ```tsx
@@ -316,15 +296,13 @@ const OxyProvider: FC<OxyProviderProps> = ({
316
296
  hubSync={hubSync}
317
297
  onAuthStateChange={onAuthStateChange as OxyContextProviderProps['onAuthStateChange']}
318
298
  >
319
- {requireAuth === 'off' ? (
320
- children
321
- ) : (
322
- <RequireOxyAuth prompt={requireAuth}>{children}</RequireOxyAuth>
323
- )}
324
- <Suspense fallback={null}>
325
- <LazyBottomSheetRouter />
326
- <LazyOxyAccountDialog />
327
- </Suspense>
299
+ <SurfaceProvider>
300
+ {requireAuth === 'off' ? (
301
+ children
302
+ ) : (
303
+ <RequireOxyAuth prompt={requireAuth}>{children}</RequireOxyAuth>
304
+ )}
305
+ </SurfaceProvider>
328
306
  <ToastOutlet />
329
307
  </OxyContextProvider>
330
308
  </BloomDialogProvider>
@@ -0,0 +1,166 @@
1
+ import React, { useCallback, useEffect, useMemo, type ErrorInfo } from 'react';
2
+ import { StyleSheet, Text, View } from 'react-native';
3
+ import { useStore } from 'zustand';
4
+ import { useTheme } from '@oxyhq/bloom/theme';
5
+ import type { SurfaceControls } from '@oxyhq/bloom/surfaces';
6
+ import type { RouteName } from '../navigation/routes';
7
+ import { getScreenComponent } from '../navigation/routes';
8
+ import type { SurfaceNavStack } from '../navigation/surfaceNavStack';
9
+ import type { SurfacePresentation } from '../navigation/surfaceRegistry';
10
+ import {
11
+ closeAllRouteSurfaces,
12
+ navigateWithinOrPresent,
13
+ presentRoute,
14
+ replaceWithinOrPresent,
15
+ } from '../navigation/surfaces';
16
+ import type { BaseScreenProps } from '../types/navigation';
17
+
18
+ /** Error boundary catching screen render failures (e.g. a lazy `require()` throw). */
19
+ interface ScreenErrorBoundaryState {
20
+ error: Error | null;
21
+ }
22
+
23
+ class ScreenErrorBoundary extends React.Component<
24
+ { screenName: string; children: React.ReactNode },
25
+ ScreenErrorBoundaryState
26
+ > {
27
+ state: ScreenErrorBoundaryState = { error: null };
28
+
29
+ static getDerivedStateFromError(error: Error): ScreenErrorBoundaryState {
30
+ return { error };
31
+ }
32
+
33
+ componentDidCatch(error: Error, info: ErrorInfo): void {
34
+ if (__DEV__) {
35
+ console.error(
36
+ `[SurfaceScreen] Screen "${this.props.screenName}" crashed:`,
37
+ error,
38
+ info.componentStack,
39
+ );
40
+ }
41
+ }
42
+
43
+ componentDidUpdate(prevProps: { screenName: string }): void {
44
+ if (prevProps.screenName !== this.props.screenName && this.state.error) {
45
+ this.setState({ error: null });
46
+ }
47
+ }
48
+
49
+ render(): React.ReactNode {
50
+ if (this.state.error) {
51
+ return (
52
+ <View style={errorStyles.container}>
53
+ <Text style={errorStyles.title}>Something went wrong</Text>
54
+ {__DEV__ && <Text style={errorStyles.message}>{this.state.error.message}</Text>}
55
+ </View>
56
+ );
57
+ }
58
+ return this.props.children;
59
+ }
60
+ }
61
+
62
+ const errorStyles = StyleSheet.create({
63
+ container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
64
+ title: { fontSize: 16, fontWeight: '600', marginBottom: 8 },
65
+ message: { fontSize: 13, textAlign: 'center' },
66
+ });
67
+
68
+ export interface SurfaceScreenProps {
69
+ /** This surface's route history (the NAV-WITHIN axis). */
70
+ navStack: SurfaceNavStack;
71
+ /** Bloom's per-surface controls (dismiss / present a child) — the DEPTH axis. */
72
+ surface: SurfaceControls;
73
+ /** This surface's presentation, so `navigate` knows when to drill in vs. stack. */
74
+ presentation: SurfacePresentation;
75
+ }
76
+
77
+ /**
78
+ * Binds one SDK surface to its content: resolves the lazy screen for the current
79
+ * nav-stack frame and renders it inside the error boundary with the per-surface
80
+ * navigation props. Mounted by `surfaces.present` as the Bloom surface's content.
81
+ *
82
+ * Dismissal is driven through the nav stack's `closing` flag (watched in an
83
+ * effect) rather than a captured-controls call, so a dismiss requested before
84
+ * this surface mounted still resolves the Bloom surface exactly once.
85
+ */
86
+ function SurfaceScreen({ navStack, surface, presentation }: SurfaceScreenProps) {
87
+ const theme = useTheme();
88
+ const state = useStore(navStack.store);
89
+ const top = state.frames[state.frames.length - 1];
90
+
91
+ useEffect(() => {
92
+ if (state.closing) surface.dismiss(state.closeResult);
93
+ }, [state.closing, state.closeResult, surface]);
94
+
95
+ const ScreenComponent = useMemo(() => getScreenComponent(top.route) ?? null, [top.route]);
96
+
97
+ const navigate = useCallback(
98
+ (route: RouteName, props?: Record<string, unknown>) =>
99
+ navigateWithinOrPresent(presentation, navStack, route, props),
100
+ [navStack, presentation],
101
+ );
102
+
103
+ const replace = useCallback(
104
+ (route: RouteName, props?: Record<string, unknown>) =>
105
+ replaceWithinOrPresent(presentation, navStack, route, props),
106
+ [navStack, presentation],
107
+ );
108
+
109
+ const dismiss = useCallback((result?: unknown) => navStack.requestDismiss(result), [navStack]);
110
+
111
+ const canGoBack = useCallback(() => navStack.canGoBack(), [navStack]);
112
+
113
+ const setStep = useCallback((step: number) => navStack.setStep(step), [navStack]);
114
+
115
+ // Screen-facing back: pop a frame, else step a wizard back, else dismiss the
116
+ // surface — the exact behaviour the single `BottomSheetRouter` had per session.
117
+ const goBack = useCallback((): void => {
118
+ if (navStack.goBack()) return;
119
+ const current = navStack.getTop();
120
+ if (current.step > 0) {
121
+ navStack.setStep(current.step - 1);
122
+ return;
123
+ }
124
+ navStack.requestDismiss();
125
+ }, [navStack]);
126
+
127
+ const present = useCallback(
128
+ (route: RouteName, props?: Record<string, unknown>) => presentRoute(route, props).result,
129
+ [],
130
+ );
131
+
132
+ const screenProps = useMemo<BaseScreenProps>(() => {
133
+ const { initialStep: _omitInitialStep, ...rest } = top.props;
134
+ return {
135
+ // NAV-WITHIN
136
+ navigate,
137
+ goBack,
138
+ replace,
139
+ canGoBack,
140
+ initialStep: top.step,
141
+ step: top.step,
142
+ onStepChange: setStep,
143
+ setStep,
144
+ currentScreen: top.route,
145
+ // DEPTH — `onClose`/`onAuthenticated` unwind the whole session (matching the
146
+ // old `closeBottomSheet`); `dismiss`/`present` are the per-surface controls.
147
+ onClose: closeAllRouteSurfaces,
148
+ onAuthenticated: closeAllRouteSurfaces,
149
+ dismiss,
150
+ present,
151
+ // Theme + the route's own props.
152
+ theme: theme.mode,
153
+ ...rest,
154
+ };
155
+ }, [navigate, goBack, replace, canGoBack, setStep, present, dismiss, theme.mode, top.route, top.step, top.props]);
156
+
157
+ if (!ScreenComponent) return null;
158
+
159
+ return (
160
+ <ScreenErrorBoundary screenName={top.route}>
161
+ <ScreenComponent {...screenProps} />
162
+ </ScreenErrorBoundary>
163
+ );
164
+ }
165
+
166
+ export default SurfaceScreen;
@@ -45,6 +45,7 @@ import { useDeviceManagement } from '../hooks/useDeviceManagement';
45
45
  import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
46
46
  import type { RouteName } from '../navigation/routes';
47
47
  import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
48
+ import { presentDetached, type SurfaceInstance } from '../navigation/surfaces';
48
49
  import { useQueryClient, onlineManager } from '@tanstack/react-query';
49
50
  import { clearQueryCache } from '../hooks/queryClient';
50
51
  import { useAvatarPicker } from '../hooks/useAvatarPicker';
@@ -643,6 +644,11 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
643
644
  const commitSwitchedSessionRef = useRef(commitSwitchedSession);
644
645
  commitSwitchedSessionRef.current = commitSwitchedSession;
645
646
 
647
+ // The live AccountDialog surface (a stacked Bloom surface), while open. Held so
648
+ // `openAccountDialog` can no-op when already open and `closeAccountDialog` /
649
+ // `onSignedIn` can dismiss it. Cleared when the surface settles.
650
+ const accountDialogSurfaceRef = useRef<SurfaceInstance<'AccountDialog'> | null>(null);
651
+
646
652
  const accountDialogControllerRef = useRef<AccountDialogController | null>(null);
647
653
  if (!accountDialogControllerRef.current) {
648
654
  accountDialogControllerRef.current = createAccountDialogController({
@@ -655,7 +661,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
655
661
  socketFactory: io,
656
662
  commitSession: (session) => handleWebSessionRef.current(session),
657
663
  commitSwitchedSession: (session) => commitSwitchedSessionRef.current(session),
658
- onSignedIn: () => setAccountDialogOpen(false),
664
+ onSignedIn: () => {
665
+ accountDialogSurfaceRef.current?.dismiss();
666
+ setAccountDialogOpen(false);
667
+ },
659
668
  openUrl: (url) => {
660
669
  if (isWebBrowser()) {
661
670
  redirectToAuthorize(url);
@@ -680,11 +689,28 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
680
689
 
681
690
  const openAccountDialog = useCallback((view?: AccountDialogView): void => {
682
691
  accountDialogControllerRef.current?.setView(view ?? 'accounts');
692
+ // Present the AccountDialog surface on the shared Bloom stack the FIRST time
693
+ // (subsequent opens just re-point the controller's view above). `presentDetached`
694
+ // keeps it OUT of the `showBottomSheet` route-surface lineage, so closing the
695
+ // bottom-sheet session never touches the account dialog and vice-versa.
696
+ if (!accountDialogSurfaceRef.current) {
697
+ const instance = presentDetached(
698
+ 'AccountDialog',
699
+ { initialView: view ?? 'accounts' },
700
+ { placement: { base: 'bottom', md: 'center' }, dismissOnBackdrop: false, maxWidth: 420 },
701
+ );
702
+ accountDialogSurfaceRef.current = instance;
703
+ instance.result.finally(() => {
704
+ if (accountDialogSurfaceRef.current === instance) accountDialogSurfaceRef.current = null;
705
+ setAccountDialogOpen(false);
706
+ });
707
+ }
683
708
  setAccountDialogOpen(true);
684
709
  }, []);
685
710
 
686
711
  const closeAccountDialog = useCallback((): void => {
687
712
  accountDialogControllerRef.current?.cancelSignIn();
713
+ accountDialogSurfaceRef.current?.dismiss();
688
714
  setAccountDialogOpen(false);
689
715
  }, []);
690
716
 
@@ -0,0 +1,39 @@
1
+ import { getSurfaceConfig, getSurfacePresentation } from '../surfaceRegistry';
2
+
3
+ /**
4
+ * `getSurfaceConfig` resolves which Bloom surface a route is presented in. The
5
+ * flagship image picker (FileManagement in image-only select mode) upgrades to a
6
+ * full-bleed `'fullScreen'` surface that owns its own scrolling; every other
7
+ * route stays on the responsive `'sheet'`. These guard the routing contract.
8
+ */
9
+ describe('getSurfaceConfig presentation', () => {
10
+ const imageOnlyPickerProps = {
11
+ selectMode: true,
12
+ disabledMimeTypes: ['video/', 'audio/', 'application/pdf'],
13
+ };
14
+
15
+ it('defaults every route to the responsive sheet', () => {
16
+ expect(getSurfaceConfig('ManageAccount', {}).presentation).toBe('sheet');
17
+ expect(getSurfaceConfig('AvatarCrop', {}).presentation).toBe('sheet');
18
+ expect(getSurfacePresentation('ManageAccount', {})).toBe('sheet');
19
+ });
20
+
21
+ it('routes the FileManagement image-only picker to a full-screen surface', () => {
22
+ const config = getSurfaceConfig('FileManagement', imageOnlyPickerProps);
23
+ expect(config.presentation).toBe('fullScreen');
24
+ // The full-bleed picker owns its own FlatList → the host must not wrap it.
25
+ expect(config.scrollable).toBe(false);
26
+ });
27
+
28
+ it('keeps FileManagement on the sheet when it is not an image-only picker', () => {
29
+ // Browse mode (no selectMode) — the standard file manager.
30
+ expect(getSurfaceConfig('FileManagement', {}).presentation).toBe('sheet');
31
+ // Select mode that still allows non-image types is not the flagship picker.
32
+ expect(
33
+ getSurfaceConfig('FileManagement', {
34
+ selectMode: true,
35
+ disabledMimeTypes: ['video/'],
36
+ }).presentation,
37
+ ).toBe('sheet');
38
+ });
39
+ });
@@ -1,88 +1,61 @@
1
1
  import type { RouteName } from './routes';
2
2
  import { isValidRoute } from './routes';
3
- import { createStore } from 'zustand/vanilla';
3
+ import { getSurfacePresentation } from './surfaceRegistry';
4
+ import { closeAllRouteSurfaces, presentRoute, topRouteSurface } from './surfaces';
4
5
 
5
6
  /**
6
- * Bottom Sheet State Manager
7
+ * Bottom-sheet manager the INTERNAL adapter that keeps the historical
8
+ * `showBottomSheet` / `closeBottomSheet` API working on top of the new Bloom
9
+ * surface stack (`navigation/surfaces.ts`). The public signatures are unchanged;
10
+ * this file just re-expresses them as thin calls into the typed surface layer.
11
+ *
12
+ * The old single-surface store is gone: `showBottomSheet` now open-or-navigates
13
+ * the "base" (top-most) route surface, and cross-presentation targets (the
14
+ * full-bleed image picker) stack a NEW surface on top — Bloom coordinates the
15
+ * z-order / backdrop / dismiss so nothing clashes.
7
16
  */
8
17
 
9
- export interface BottomSheetState {
10
- currentScreen: RouteName | null;
11
- screenProps: Record<string, unknown>;
12
- currentStep?: number;
13
- history: Array<{ screen: RouteName; props: Record<string, unknown>; step?: number }>;
14
- isOpen: boolean;
15
- fullScreen: boolean;
16
- }
17
-
18
- const initialState: BottomSheetState = {
19
- currentScreen: null,
20
- screenProps: {},
21
- currentStep: undefined,
22
- history: [],
23
- isOpen: false,
24
- fullScreen: false,
25
- };
26
-
27
- export const bottomSheetStore = createStore<BottomSheetState>(() => initialState);
28
-
29
- export const getState = () => bottomSheetStore.getState();
30
-
18
+ /**
19
+ * Open a bottom-sheet route. If the top-most active surface hosts the SAME
20
+ * presentation (the common all-sheets case) the route is drilled into it; a route
21
+ * with a different presentation (the image picker's full-bleed surface) is
22
+ * presented as a NEW surface on top. Opening with no active surface presents the
23
+ * first one.
24
+ *
25
+ * `fullScreen` is accepted for signature compatibility and ignored — a route's
26
+ * surface is now derived from its registry config, not a per-call flag.
27
+ */
31
28
  export const showBottomSheet = (
32
- screenOrConfig: RouteName | { screen: RouteName; props?: Record<string, unknown>; fullScreen?: boolean },
29
+ screenOrConfig:
30
+ | RouteName
31
+ | { screen: RouteName; props?: Record<string, unknown>; fullScreen?: boolean },
33
32
  ): void => {
34
- const screen = typeof screenOrConfig === 'string' ? screenOrConfig : screenOrConfig.screen;
35
- const props = typeof screenOrConfig === 'string' ? {} : (screenOrConfig.props || {});
36
- const fullScreen = typeof screenOrConfig === 'string' ? false : (screenOrConfig.fullScreen ?? false);
37
-
38
- if (!isValidRoute(screen)) {
39
- if (__DEV__) console.warn(`[BottomSheet] Invalid route: ${screen}`);
40
- return;
41
- }
42
-
43
- const state = bottomSheetStore.getState();
44
-
45
- // Push current screen to history if navigating to different screen
46
- if (state.currentScreen && state.currentScreen !== screen) {
47
- bottomSheetStore.setState({
48
- history: [...state.history, {
49
- screen: state.currentScreen,
50
- props: state.screenProps,
51
- step: state.currentStep,
52
- }],
53
- });
54
- }
55
-
56
- bottomSheetStore.setState({
57
- currentScreen: screen,
58
- screenProps: props,
59
- currentStep: typeof props.initialStep === 'number' ? props.initialStep : undefined,
60
- isOpen: true,
61
- fullScreen,
62
- });
33
+ const screen = typeof screenOrConfig === 'string' ? screenOrConfig : screenOrConfig.screen;
34
+ const props = typeof screenOrConfig === 'string' ? {} : screenOrConfig.props ?? {};
35
+
36
+ if (!isValidRoute(screen)) {
37
+ if (__DEV__) console.warn(`[BottomSheet] Invalid route: ${screen}`);
38
+ return;
39
+ }
40
+
41
+ const top = topRouteSurface();
42
+ if (top && top.presentation === getSurfacePresentation(screen, props)) {
43
+ top.navigate(screen, props);
44
+ } else {
45
+ presentRoute(screen, props);
46
+ }
63
47
  };
64
48
 
49
+ /** Dismiss the whole bottom-sheet session (every active route surface). */
65
50
  export const closeBottomSheet = (): void => {
66
- bottomSheetStore.setState(initialState);
51
+ closeAllRouteSurfaces();
67
52
  };
68
53
 
54
+ /**
55
+ * Step back within the top-most surface's route history. Returns `true` when a
56
+ * frame was popped, `false` when the surface is already at its root frame.
57
+ */
69
58
  export const goBack = (): boolean => {
70
- const { history } = bottomSheetStore.getState();
71
-
72
- if (history.length > 0) {
73
- const prev = history[history.length - 1];
74
- bottomSheetStore.setState({
75
- currentScreen: prev.screen,
76
- screenProps: prev.props,
77
- currentStep: prev.step,
78
- history: history.slice(0, -1),
79
- });
80
- return true;
81
- }
82
-
83
- return false;
84
- };
85
-
86
- export const updateState = (updates: Partial<BottomSheetState>) => {
87
- bottomSheetStore.setState((state) => ({ ...state, ...updates }));
59
+ const top = topRouteSurface();
60
+ return top ? top.goBack() : false;
88
61
  };