@oxyhq/services 22.1.0 → 22.3.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 (100) hide show
  1. package/lib/commonjs/index.js +6 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/FollowButton.js +24 -19
  4. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/OxyAuthChooser.js +107 -106
  6. package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
  7. package/lib/commonjs/ui/components/oxyAuthChooserStyles.js +0 -19
  8. package/lib/commonjs/ui/components/oxyAuthChooserStyles.js.map +1 -1
  9. package/lib/commonjs/ui/components/passkeyHubPopup.js +33 -0
  10. package/lib/commonjs/ui/components/passkeyHubPopup.js.map +1 -0
  11. package/lib/commonjs/ui/context/OxyContext.js +7 -1
  12. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  13. package/lib/commonjs/ui/context/useOxyAccountGraph.js +2 -0
  14. package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
  15. package/lib/commonjs/ui/hooks/useFollow.js +53 -10
  16. package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
  17. package/lib/commonjs/ui/hooks/useSessionManagement.js +2 -0
  18. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  19. package/lib/commonjs/ui/index.js +6 -0
  20. package/lib/commonjs/ui/index.js.map +1 -1
  21. package/lib/commonjs/ui/stores/followStore.js +276 -92
  22. package/lib/commonjs/ui/stores/followStore.js.map +1 -1
  23. package/lib/commonjs/ui/stores/resetSessionScopedStores.js +14 -0
  24. package/lib/commonjs/ui/stores/resetSessionScopedStores.js.map +1 -0
  25. package/lib/module/index.js +1 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/ui/components/FollowButton.js +24 -19
  28. package/lib/module/ui/components/FollowButton.js.map +1 -1
  29. package/lib/module/ui/components/OxyAuthChooser.js +108 -107
  30. package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
  31. package/lib/module/ui/components/oxyAuthChooserStyles.js +0 -19
  32. package/lib/module/ui/components/oxyAuthChooserStyles.js.map +1 -1
  33. package/lib/module/ui/components/passkeyHubPopup.js +29 -0
  34. package/lib/module/ui/components/passkeyHubPopup.js.map +1 -0
  35. package/lib/module/ui/context/OxyContext.js +7 -1
  36. package/lib/module/ui/context/OxyContext.js.map +1 -1
  37. package/lib/module/ui/context/useOxyAccountGraph.js +2 -0
  38. package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
  39. package/lib/module/ui/hooks/useFollow.js +51 -9
  40. package/lib/module/ui/hooks/useFollow.js.map +1 -1
  41. package/lib/module/ui/hooks/useSessionManagement.js +2 -0
  42. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  43. package/lib/module/ui/index.js +1 -1
  44. package/lib/module/ui/index.js.map +1 -1
  45. package/lib/module/ui/stores/followStore.js +274 -91
  46. package/lib/module/ui/stores/followStore.js.map +1 -1
  47. package/lib/module/ui/stores/resetSessionScopedStores.js +11 -0
  48. package/lib/module/ui/stores/resetSessionScopedStores.js.map +1 -0
  49. package/lib/typescript/commonjs/index.d.ts +1 -1
  50. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts +0 -19
  54. package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/components/passkeyHubPopup.d.ts +23 -0
  56. package/lib/typescript/commonjs/ui/components/passkeyHubPopup.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +13 -2
  60. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/ui/index.d.ts +1 -1
  63. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/stores/followStore.d.ts +4 -1
  65. package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts +3 -0
  67. package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts.map +1 -0
  68. package/lib/typescript/module/index.d.ts +1 -1
  69. package/lib/typescript/module/index.d.ts.map +1 -1
  70. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  71. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
  72. package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts +0 -19
  73. package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
  74. package/lib/typescript/module/ui/components/passkeyHubPopup.d.ts +23 -0
  75. package/lib/typescript/module/ui/components/passkeyHubPopup.d.ts.map +1 -0
  76. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  77. package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  78. package/lib/typescript/module/ui/hooks/useFollow.d.ts +13 -2
  79. package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
  80. package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/index.d.ts +1 -1
  82. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/stores/followStore.d.ts +4 -1
  84. package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
  85. package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts +3 -0
  86. package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts.map +1 -0
  87. package/package.json +12 -12
  88. package/src/index.ts +1 -1
  89. package/src/ui/components/FollowButton.tsx +22 -20
  90. package/src/ui/components/OxyAuthChooser.tsx +125 -89
  91. package/src/ui/components/oxyAuthChooserStyles.ts +0 -19
  92. package/src/ui/components/passkeyHubPopup.ts +28 -0
  93. package/src/ui/context/OxyContext.tsx +6 -0
  94. package/src/ui/context/useOxyAccountGraph.ts +2 -0
  95. package/src/ui/hooks/useFollow.ts +51 -9
  96. package/src/ui/hooks/useSessionManagement.ts +2 -0
  97. package/src/ui/index.ts +1 -1
  98. package/src/ui/stores/__tests__/followStore.test.ts +178 -0
  99. package/src/ui/stores/followStore.ts +277 -106
  100. package/src/ui/stores/resetSessionScopedStores.ts +8 -0
@@ -61,6 +61,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
61
61
  import QRCode from 'react-native-qrcode-svg';
62
62
  import { Avatar } from '@oxyhq/bloom/avatar';
63
63
  import { Button } from '@oxyhq/bloom/button';
64
+ import { toast } from '@oxyhq/bloom';
64
65
  import { Text } from '@oxyhq/bloom/typography';
65
66
  import {
66
67
  useTheme,
@@ -138,52 +139,61 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
138
139
  const { t } = useI18n();
139
140
  const queryClient = useQueryClient();
140
141
 
141
- // A credential minted for `oxy.so` can only be asserted there (or a loopback
142
- // dev host) — this is a hard WebAuthn RP-ID boundary, not feature detection,
143
- // so it is the ONLY gate on the passkey affordances. `isOxyRpOrigin()` is
144
- // `false` on native by construction (no `location`), so no separate native
145
- // check is needed.
146
- const passkeyAvailable = useMemo(() => isOxyRpOrigin(), []);
142
+ // A credential minted for `oxy.so` can only be ASSERTED there (or a loopback
143
+ // dev host) — a hard WebAuthn RP-ID boundary the browser enforces, not
144
+ // feature detection. On a first-party Oxy web origin the ceremony runs
145
+ // directly ('direct'). On any OTHER web origin (b2) it can't run locally, so
146
+ // the passkey action instead opens a popup at the auth.oxy.so passkey hub —
147
+ // where the SAME ceremony IS first-party — and relays the resulting session
148
+ // back via `AccountDialogController.startPasskeyHubSignIn` ('hub'). Native
149
+ // has neither: Commons owns identity there ('none'). `isOxyRpOrigin()` reads
150
+ // `location` once and is stable for the component's lifetime.
151
+ const passkeyMode = useMemo<'direct' | 'hub' | 'none'>(() => {
152
+ if (!isWebBrowser()) return 'none';
153
+ return isOxyRpOrigin() ? 'direct' : 'hub';
154
+ }, []);
155
+ const passkeyAvailable = passkeyMode !== 'none';
147
156
 
148
157
  const [signInPasskeyPending, setSignInPasskeyPending] = useState(false);
149
- const [signInPasskeyError, setSignInPasskeyError] = useState<string | null>(null);
150
158
  const handleSignInWithPasskey = useCallback(async () => {
151
159
  if (signInPasskeyPending) return;
152
160
  setSignInPasskeyPending(true);
153
- setSignInPasskeyError(null);
154
161
  try {
155
162
  await signInWithPasskey();
156
163
  onComplete?.();
157
164
  } catch (error) {
158
165
  // A cancelled/failed ceremony keeps the view open so the user can retry
159
- // or fall back to the QR — surface a concise message, never swallow.
160
- setSignInPasskeyError(
161
- error instanceof Error && error.message ? error.message : 'Passkey sign-in failed.',
166
+ // or fall back to the QR — surface the reason as a toast (owner mandate:
167
+ // errors never render inline inside the dialog), never swallow.
168
+ toast.error(
169
+ error instanceof Error && error.message
170
+ ? error.message
171
+ : t('accountSwitcher.toasts.passkeySignInFailed') || 'Passkey sign-in failed.',
162
172
  );
163
173
  } finally {
164
174
  setSignInPasskeyPending(false);
165
175
  }
166
- }, [signInPasskeyPending, signInWithPasskey, onComplete]);
176
+ }, [signInPasskeyPending, signInWithPasskey, onComplete, t]);
167
177
 
168
178
  const [createPasskeyPending, setCreatePasskeyPending] = useState(false);
169
- const [createPasskeyError, setCreatePasskeyError] = useState<string | null>(null);
170
179
  const handleCreateWithPasskey = useCallback(
171
180
  async (username: string) => {
172
181
  if (createPasskeyPending) return;
173
182
  setCreatePasskeyPending(true);
174
- setCreatePasskeyError(null);
175
183
  try {
176
184
  await registerWithPasskey({ username });
177
185
  onComplete?.();
178
186
  } catch (error) {
179
- setCreatePasskeyError(
180
- error instanceof Error && error.message ? error.message : 'Account creation failed.',
187
+ toast.error(
188
+ error instanceof Error && error.message
189
+ ? error.message
190
+ : t('signup.toasts.passkeyCreateFailed') || 'Account creation failed.',
181
191
  );
182
192
  } finally {
183
193
  setCreatePasskeyPending(false);
184
194
  }
185
195
  },
186
- [createPasskeyPending, registerWithPasskey, onComplete],
196
+ [createPasskeyPending, registerWithPasskey, onComplete, t],
187
197
  );
188
198
 
189
199
  // Bind the headless controller. `getSnapshot` returns a stable reference
@@ -200,6 +210,30 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
200
210
  const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
201
211
  const { view } = snapshot;
202
212
 
213
+ // Sign-in device-flow failures land on `snapshot.signIn` asynchronously (poll /
214
+ // socket / popup cancel). Toast at the controller notification site — never an
215
+ // inline banner in the QR view (owner mandate, same contract as account-switch).
216
+ useEffect(() => {
217
+ if (!controller) return;
218
+ let lastToastedSignInError: string | null = null;
219
+ const maybeToastSignInError = () => {
220
+ const { signIn } = controller.getSnapshot();
221
+ if (signIn.phase === 'error' && signIn.error && signIn.error !== lastToastedSignInError) {
222
+ lastToastedSignInError = signIn.error;
223
+ toast.error(
224
+ signIn.error ||
225
+ t('accountSwitcher.toasts.signInFailed') ||
226
+ 'Sign-in failed. Please try again.',
227
+ );
228
+ }
229
+ if (signIn.phase !== 'error') {
230
+ lastToastedSignInError = null;
231
+ }
232
+ };
233
+ maybeToastSignInError();
234
+ return controller.subscribe(maybeToastSignInError);
235
+ }, [controller, t]);
236
+
203
237
  // Web: "Sign in with Oxy" auto-starts the instant the sign-in entry is
204
238
  // reached — no button tap needed. `signInWithOxy()` tries the shared
205
239
  // keychain first (silently, native-only) then falls to the QR handoff,
@@ -226,20 +260,28 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
226
260
  setSwitching(true);
227
261
  try {
228
262
  await controller.switchTo(accountId);
229
- // A switch succeeds unless the controller recorded an error. On success
230
- // reload the app's account graph and drop cached account-scoped data so
231
- // the new active identity re-fetches the same side effects the
232
- // context's own `switchToAccount` performs.
233
- if (!controller.getSnapshot().error) {
234
- void refreshAccounts();
235
- queryClient.invalidateQueries();
236
- onComplete?.();
263
+ // `switchTo` never throws it records a failure on the controller's
264
+ // snapshot. Read it back at the point the switch settles and surface it
265
+ // as a toast (event-driven, at the failure site NOT an inline banner
266
+ // and NOT a snapshot-reaction). On success reload the app's account
267
+ // graph and drop cached account-scoped data so the new active identity
268
+ // re-fetches — the same side effects the context's own
269
+ // `switchToAccount` performs.
270
+ if (controller.getSnapshot().error) {
271
+ toast.error(
272
+ t('accountSwitcher.toasts.switchFailed') ||
273
+ 'There was a problem switching accounts. Please try again.',
274
+ );
275
+ return;
237
276
  }
277
+ void refreshAccounts();
278
+ queryClient.invalidateQueries();
279
+ onComplete?.();
238
280
  } finally {
239
281
  setSwitching(false);
240
282
  }
241
283
  },
242
- [controller, switching, snapshot.activeAccountId, onComplete, refreshAccounts, queryClient],
284
+ [controller, switching, snapshot.activeAccountId, onComplete, refreshAccounts, queryClient, t],
243
285
  );
244
286
 
245
287
  const handleManage = useCallback(() => {
@@ -274,9 +316,16 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
274
316
  t={t}
275
317
  onRetry={() => void controller.showQr()}
276
318
  passkeyAvailable={passkeyAvailable}
277
- onSignInWithPasskey={() => void handleSignInWithPasskey()}
278
- passkeyPending={signInPasskeyPending}
279
- passkeyError={signInPasskeyError}
319
+ onSignInWithPasskey={
320
+ passkeyMode === 'direct'
321
+ ? () => void handleSignInWithPasskey()
322
+ : () => void controller.startPasskeyHubSignIn()
323
+ }
324
+ // The hub-popup flow's pending state is `snapshot.signIn` (already
325
+ // rendered elsewhere in this view) — only the DIRECT ceremony uses this
326
+ // component-local pending flag. A ceremony FAILURE is surfaced as a
327
+ // toast by `handleSignInWithPasskey`, never inline.
328
+ passkeyPending={passkeyMode === 'direct' ? signInPasskeyPending : false}
280
329
  onCreateAccount={() => controller.startSignup()}
281
330
  />
282
331
  );
@@ -289,10 +338,10 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
289
338
  theme={theme}
290
339
  t={t}
291
340
  oxyServices={oxyServices}
292
- passkeyAvailable={passkeyAvailable}
341
+ passkeyMode={passkeyMode}
293
342
  onCreateWithPasskey={handleCreateWithPasskey}
294
343
  createPending={createPasskeyPending}
295
- createError={createPasskeyError}
344
+ onOpenHub={() => void controller.startPasskeyHubSignIn()}
296
345
  onBackToSignIn={() => controller.setView('signin')}
297
346
  />
298
347
  );
@@ -315,22 +364,6 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
315
364
  // Accounts view
316
365
  // ---------------------------------------------------------------------------
317
366
 
318
- /**
319
- * Inline error banner for `snapshot.error` (the account-list / switch-failure
320
- * error — distinct from `snapshot.signIn.error`, which `QrView` already
321
- * renders). `AccountsView` and `SignInView` previously swallowed it: a
322
- * blocked/failed switch left the user with no feedback outside the QR view.
323
- */
324
- const ErrorBanner: React.FC<{ error: string | null; theme: Theme }> = ({ error, theme }) => {
325
- if (!error) return null;
326
- return (
327
- <View style={[styles.errorBanner, { backgroundColor: theme.colors.negativeSubtle }]}>
328
- <MaterialCommunityIcons name="alert-circle-outline" size={18} color={theme.colors.error} />
329
- <Text style={[styles.errorBannerText, { color: theme.colors.error }]}>{error}</Text>
330
- </View>
331
- );
332
- };
333
-
334
367
  interface AccountsViewProps {
335
368
  snapshot: AccountDialogSnapshot;
336
369
  theme: Theme;
@@ -352,7 +385,6 @@ const AccountsView: React.FC<AccountsViewProps> = ({ snapshot, theme, t, handler
352
385
 
353
386
  return (
354
387
  <View style={styles.rows}>
355
- <ErrorBanner error={snapshot.error} theme={theme} />
356
388
  {snapshot.accounts.map((account) => (
357
389
  <AccountRow
358
390
  key={account.accountId}
@@ -474,7 +506,6 @@ const SignInView: React.FC<SignInViewProps> = ({
474
506
  onCreateAccount,
475
507
  }) => (
476
508
  <View style={styles.signInBlock}>
477
- <ErrorBanner error={snapshot.error} theme={theme} />
478
509
  {snapshot.accounts.length > 0 ? (
479
510
  <View style={styles.rows}>
480
511
  {snapshot.accounts.map((account) => (
@@ -555,7 +586,6 @@ interface QrViewProps {
555
586
  passkeyAvailable: boolean;
556
587
  onSignInWithPasskey: () => void;
557
588
  passkeyPending: boolean;
558
- passkeyError: string | null;
559
589
  onCreateAccount: () => void;
560
590
  }
561
591
 
@@ -567,32 +597,28 @@ const QrView: React.FC<QrViewProps> = ({
567
597
  passkeyAvailable,
568
598
  onSignInWithPasskey,
569
599
  passkeyPending,
570
- passkeyError,
571
600
  onCreateAccount,
572
601
  }) => {
573
602
  const { signIn, commonsAvailability } = snapshot;
574
603
  const [showQrAnyway, setShowQrAnyway] = useState(false);
575
604
 
605
+ // A ceremony FAILURE is surfaced as a toast by `handleSignInWithPasskey` — the
606
+ // link itself never renders an inline error (owner mandate).
576
607
  const passkeyLink =
577
608
  passkeyAvailable && signIn.phase !== 'authorized' ? (
578
- <>
579
- <Pressable
580
- onPress={onSignInWithPasskey}
581
- disabled={passkeyPending}
582
- accessibilityRole="button"
583
- style={styles.footerLink}
584
- testID="passkey-signin-link"
585
- >
586
- <Text style={[styles.linkText, { color: theme.colors.textSecondary }]}>
587
- {passkeyPending
588
- ? t('accountSwitcher.passkeySigningIn') || 'Signing in…'
589
- : t('accountSwitcher.useIdentityOnDevice') || 'No Commons? Use a passkey on this device instead'}
590
- </Text>
591
- </Pressable>
592
- {passkeyError ? (
593
- <Text style={[styles.errorText, { color: theme.colors.error }]}>{passkeyError}</Text>
594
- ) : null}
595
- </>
609
+ <Pressable
610
+ onPress={onSignInWithPasskey}
611
+ disabled={passkeyPending}
612
+ accessibilityRole="button"
613
+ style={styles.footerLink}
614
+ testID="passkey-signin-link"
615
+ >
616
+ <Text style={[styles.linkText, { color: theme.colors.textSecondary }]}>
617
+ {passkeyPending
618
+ ? t('accountSwitcher.passkeySigningIn') || 'Signing in…'
619
+ : t('accountSwitcher.useIdentityOnDevice') || 'No Commons? Use a passkey on this device instead'}
620
+ </Text>
621
+ </Pressable>
596
622
  ) : null;
597
623
 
598
624
  // Native, Commons confirmed NOT installed: a same-device QR is a dead
@@ -624,11 +650,10 @@ const QrView: React.FC<QrViewProps> = ({
624
650
  }
625
651
 
626
652
  if (signIn.phase === 'error') {
653
+ // The failure reason is toasted by the controller subscription above — no
654
+ // inline error copy here (owner mandate).
627
655
  return (
628
656
  <View style={styles.centeredBlock}>
629
- <Text style={[styles.errorText, { color: theme.colors.error }]}>
630
- {signIn.error || 'Sign-in failed. Please try again.'}
631
- </Text>
632
657
  <Button variant="secondary" onPress={onRetry} style={styles.secondaryButton}>
633
658
  {t('common.actions.tryAgain') || 'Try again'}
634
659
  </Button>
@@ -674,7 +699,10 @@ const QrView: React.FC<QrViewProps> = ({
674
699
  type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken';
675
700
 
676
701
  /** Debounced username-availability check over the existing SDK method. */
677
- function useUsernameAvailability(oxyServices: OxyServices): {
702
+ function useUsernameAvailability(
703
+ oxyServices: OxyServices,
704
+ t: Translate,
705
+ ): {
678
706
  status: UsernameStatus;
679
707
  check: (value: string) => void;
680
708
  } {
@@ -700,11 +728,16 @@ function useUsernameAvailability(oxyServices: OxyServices): {
700
728
  })
701
729
  .catch(() => {
702
730
  if (seq !== requestSeqRef.current) return;
703
- setStatus('idle'); // network/error don't block on a stale verdict
731
+ // Reset the (now stale) verdict rather than block on it, and surface
732
+ // the failure as a toast — never an inline error inside the dialog.
733
+ setStatus('idle');
734
+ toast.error(
735
+ t('accounts.create.username.checkFailed') || 'Could not check availability',
736
+ );
704
737
  });
705
738
  }, 400);
706
739
  },
707
- [oxyServices],
740
+ [oxyServices, t],
708
741
  );
709
742
 
710
743
  return { status, check };
@@ -742,10 +775,11 @@ interface SignUpViewProps {
742
775
  theme: Theme;
743
776
  t: Translate;
744
777
  oxyServices: OxyServices;
745
- passkeyAvailable: boolean;
778
+ passkeyMode: 'direct' | 'hub' | 'none';
746
779
  onCreateWithPasskey: (username: string) => Promise<void>;
747
780
  createPending: boolean;
748
- createError: string | null;
781
+ /** Open the auth.oxy.so hub popup (b2) — the 'hub' mode's only path. */
782
+ onOpenHub: () => void;
749
783
  onBackToSignIn: () => void;
750
784
  }
751
785
 
@@ -754,14 +788,14 @@ const SignUpView: React.FC<SignUpViewProps> = ({
754
788
  theme,
755
789
  t,
756
790
  oxyServices,
757
- passkeyAvailable,
791
+ passkeyMode,
758
792
  onCreateWithPasskey,
759
793
  createPending,
760
- createError,
794
+ onOpenHub,
761
795
  onBackToSignIn,
762
796
  }) => {
763
797
  const [username, setUsername] = useState('');
764
- const { status: usernameStatus, check: checkUsername } = useUsernameAvailability(oxyServices);
798
+ const { status: usernameStatus, check: checkUsername } = useUsernameAvailability(oxyServices, t);
765
799
  const canSubmit = username.trim().length >= 3 && usernameStatus === 'available' && !createPending;
766
800
 
767
801
  // Native — Commons owns identity creation. Mirrors the QR view's own
@@ -794,7 +828,7 @@ const SignUpView: React.FC<SignUpViewProps> = ({
794
828
  // passkey creation is the de-emphasized alternative underneath for someone
795
829
  // who doesn't want to install anything — secondary button, introduced by
796
830
  // its own "No Commons?" framing, same subordination as the QR view's link.
797
- if (passkeyAvailable) {
831
+ if (passkeyMode === 'direct') {
798
832
  return (
799
833
  <View style={styles.signInBlock}>
800
834
  <Text style={[styles.qrHeadline, { color: theme.colors.text }]}>
@@ -832,9 +866,6 @@ const SignUpView: React.FC<SignUpViewProps> = ({
832
866
  testID="signup-username-input"
833
867
  />
834
868
  <UsernameStatusText status={usernameStatus} theme={theme} t={t} />
835
- {createError ? (
836
- <Text style={[styles.errorText, { color: theme.colors.error }]}>{createError}</Text>
837
- ) : null}
838
869
  <Button
839
870
  variant="secondary"
840
871
  onPress={() => void onCreateWithPasskey(username.trim())}
@@ -851,15 +882,20 @@ const SignUpView: React.FC<SignUpViewProps> = ({
851
882
  );
852
883
  }
853
884
 
854
- // Web, non-Oxy origin: passkey creation here requires the cross-origin hub
855
- // relay (b2), which has not shipped yet an honest message beats a button
856
- // that would fail.
885
+ // Web, non-Oxy origin (b2): passkey creation can't run locally here the
886
+ // same WebAuthn RP-ID boundary as sign-inso this opens the SAME
887
+ // auth.oxy.so passkey hub popup `QrView`'s "No Commons?" link uses.
888
+ // Completing EITHER a sign-in OR a fresh sign-up there relays the resulting
889
+ // session back via the SAME poll/socket engine `showQr` drives.
857
890
  return (
858
891
  <View style={styles.centeredBlock}>
859
892
  <Text style={[styles.mutedText, { color: theme.colors.textSecondary }]}>
860
- {t('signup.notAvailableHere') ||
861
- "Account creation isn't available in this app yet — create your identity with Commons, or visit oxy.so to sign up with a passkey."}
893
+ {t('signup.hubExplainer') ||
894
+ 'Create your Oxy ID in a secure window, then come right back.'}
862
895
  </Text>
896
+ <Button variant="primary" onPress={onOpenHub} style={styles.primaryButton}>
897
+ {t('signup.continueInWindow') || 'Continue in a new window'}
898
+ </Button>
863
899
  <SignInFooterLink theme={theme} t={t} onPress={onBackToSignIn} />
864
900
  </View>
865
901
  );
@@ -125,25 +125,6 @@ export const authChooserStyles = StyleSheet.create({
125
125
  fontWeight: '700',
126
126
  textAlign: 'center',
127
127
  },
128
- errorText: {
129
- fontSize: 14,
130
- textAlign: 'center',
131
- },
132
- errorBanner: {
133
- flexDirection: 'row',
134
- alignItems: 'center',
135
- gap: 8,
136
- width: '100%',
137
- borderRadius: 12,
138
- paddingVertical: 10,
139
- paddingHorizontal: 12,
140
- marginBottom: 12,
141
- },
142
- errorBannerText: {
143
- flex: 1,
144
- fontSize: 13.5,
145
- lineHeight: 18,
146
- },
147
128
  qrPlate: {
148
129
  padding: 16,
149
130
  borderRadius: 16,
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Popup opener for the cross-origin passkey hub (b2).
3
+ *
4
+ * On a non-Oxy web origin (e.g. mention.earth) a WebAuthn ceremony can't run
5
+ * locally — the credential's RP ID is bound to `oxy.so`. Instead,
6
+ * `AccountDialogController.startPasskeyHubSignIn` opens a popup at the
7
+ * auth.oxy.so passkey hub, where the SAME ceremony IS a first-party operation.
8
+ *
9
+ * Kept in its own module (mirroring `oauthNavigation.ts`'s
10
+ * `redirectToAuthorize`) so `window.open` sits behind a plain function the
11
+ * controller wiring in `OxyContext` can inject without touching `window`
12
+ * itself, and so a test can substitute a fake opener.
13
+ */
14
+
15
+ import type { PopupWindowHandle } from '@oxyhq/core';
16
+
17
+ /**
18
+ * Open a new, EMPTY popup window. Must be called SYNCHRONOUSLY from within a
19
+ * user-gesture handler (before any `await`) — opening it after an async call
20
+ * loses the gesture attribution and the browser silently blocks it. Returns
21
+ * `null` when blocked, or when there is no DOM `window` (native/SSR — the
22
+ * hub-popup flow does not apply there).
23
+ */
24
+ export function openPasskeyHubPopup(): PopupWindowHandle | null {
25
+ const win = (globalThis as { window?: Window }).window;
26
+ if (!win) return null;
27
+ return win.open('', '_blank', 'width=420,height=640');
28
+ }
@@ -32,6 +32,7 @@ import {
32
32
  notifyAccountDialogVisibility,
33
33
  } from '../navigation/accountDialogManager';
34
34
  import { redirectToAuthorize } from '../components/oauthNavigation';
35
+ import { openPasskeyHubPopup } from '../components/passkeyHubPopup';
35
36
  import { isWebBrowser } from '../utils/isWebBrowser';
36
37
  import { runProviderColdBoot } from '../boot/runProviderColdBoot';
37
38
  import { loadPersistedDeviceCredential } from '../utils/deviceCredential';
@@ -668,6 +669,11 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
668
669
  // controller short-circuits, so `redirectToAuthorize` never runs for the
669
670
  // `oxycommons://` payload.
670
671
  canOpenApp: isWebBrowser() ? undefined : (url) => Linking.canOpenURL(url),
672
+ // Web-only: lets `startPasskeyHubSignIn` open the auth.oxy.so passkey hub
673
+ // popup (b2) for a non-Oxy origin. `undefined` on native — there is no
674
+ // popup concept there, and off-origin passkey sign-in isn't reachable
675
+ // (Commons owns the native flow).
676
+ openPopup: isWebBrowser() ? openPasskeyHubPopup : undefined,
671
677
  });
672
678
  }
673
679
  const accountDialogController = accountDialogControllerRef.current;
@@ -4,6 +4,7 @@ import type { OxyServices, AccountNode, CreateAccountInput, AccountDialogControl
4
4
  import { logger as loggerUtil } from '@oxyhq/core';
5
5
  import { useAuthStore } from '../stores/authStore';
6
6
  import { isUnauthorizedStatus } from './oxyContextHelpers';
7
+ import { resetSessionScopedStores } from '../stores/resetSessionScopedStores';
7
8
  import type { CommitInput } from './oxyContextTypes';
8
9
 
9
10
  interface UseOxyAccountGraphParams {
@@ -61,6 +62,7 @@ export function useOxyAccountGraph({
61
62
  }, [isAuthenticated, initialized, tokenReady, refreshAccounts, accountDialogControllerRef]);
62
63
 
63
64
  const runPostAccountSwitchSideEffects = useCallback(async (): Promise<void> => {
65
+ resetSessionScopedStores();
64
66
  await refreshAccounts();
65
67
  queryClient.invalidateQueries();
66
68
  }, [refreshAccounts, queryClient]);
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo } from 'react';
1
+ import { useCallback, useMemo, useState } from 'react';
2
2
  import { useQuery } from '@tanstack/react-query';
3
3
  import { useFollowStore } from '../stores/followStore';
4
4
  import { useOxy } from '../context/OxyContext';
@@ -110,7 +110,7 @@ export const useFollow = (userId?: string | string[]) => {
110
110
  const fetchStatus = useCallback(async () => {
111
111
  if (!isSingleUser || !userId) throw new Error('fetchStatus is only available for single user mode');
112
112
  if (!canUsePrivateApi) return;
113
- await useFollowStore.getState().fetchFollowStatus(userId, oxyServices);
113
+ useFollowStore.getState().resolveFollowStatuses([userId], oxyServices);
114
114
  }, [isSingleUser, userId, canUsePrivateApi, oxyServices]);
115
115
 
116
116
  const clearError = useCallback(() => {
@@ -170,13 +170,14 @@ export const useFollow = (userId?: string | string[]) => {
170
170
 
171
171
  const fetchStatusForUser = useCallback(async (targetUserId: string) => {
172
172
  if (!canUsePrivateApi) return;
173
- await useFollowStore.getState().fetchFollowStatus(targetUserId, oxyServices);
173
+ useFollowStore.getState().resolveFollowStatuses([targetUserId], oxyServices);
174
174
  }, [canUsePrivateApi, oxyServices]);
175
175
 
176
+ // Resolve EVERY member's status in ONE micro-batched bulk call (never N
177
+ // single requests). Ids already known/seeded are skipped by the resolver.
176
178
  const fetchAllStatuses = useCallback(async () => {
177
179
  if (!canUsePrivateApi) return;
178
- const store = useFollowStore.getState();
179
- await Promise.all(userIds.map(uid => store.fetchFollowStatus(uid, oxyServices)));
180
+ useFollowStore.getState().resolveFollowStatuses(userIds, oxyServices);
180
181
  }, [canUsePrivateApi, userIds, oxyServices]);
181
182
 
182
183
  // Bulk follow — follows ALL users in ONE network call (never unfollows).
@@ -242,10 +243,32 @@ export const useFollow = (userId?: string | string[]) => {
242
243
  * - Only subscribes to the specific user's follow and loading state
243
244
  * - Returns only what FollowButton needs (no counts, no multi-user)
244
245
  */
245
- export const useFollowForButton = (userId: string, oxyServices: OxyServices) => {
246
+ export const useFollowForButton = (userId: string, oxyServices: OxyServices, initiallyFollowing?: boolean) => {
247
+ // Seed the store synchronously on the FIRST render (before paint) when the
248
+ // caller provided a definite `initiallyFollowing` and the store has no entry
249
+ // yet. This makes the first paint show the correct label (no post-mount
250
+ // "Follow → Following" correction) and marks the id KNOWN so the batched
251
+ // resolver skips it. An OMITTED `initiallyFollowing` leaves the id UNKNOWN,
252
+ // so the batched resolver fetches its real status. The useState lazy
253
+ // initializer runs exactly once per mount; `setFollowStatuses` is
254
+ // seed-only-if-absent, so it never clobbers a live value.
255
+ useState(() => {
256
+ if (initiallyFollowing === undefined) return null;
257
+ const store = useFollowStore.getState();
258
+ if (!Object.prototype.hasOwnProperty.call(store.followingUsers, userId)) {
259
+ store.setFollowStatuses({ [userId]: initiallyFollowing });
260
+ }
261
+ return null;
262
+ });
263
+
246
264
  const isFollowing = useFollowStore(
247
265
  useCallback((s) => s.followingUsers[userId] ?? false, [userId])
248
266
  );
267
+ // Tri-state: a present key (seeded or fetched) is a DEFINITE status; a missing
268
+ // key is UNKNOWN. The button renders a neutral/disabled state while unknown.
269
+ const isKnown = useFollowStore(
270
+ useCallback((s) => Object.prototype.hasOwnProperty.call(s.followingUsers, userId), [userId])
271
+ );
249
272
  const isLoading = useFollowStore(
250
273
  useCallback((s) => s.loadingUsers[userId] ?? false, [userId])
251
274
  );
@@ -258,8 +281,12 @@ export const useFollowForButton = (userId: string, oxyServices: OxyServices) =>
258
281
  await useFollowStore.getState().toggleFollowUser(userId, oxyServices, currentlyFollowing);
259
282
  }, [userId, oxyServices]);
260
283
 
261
- const fetchStatus = useCallback(async () => {
262
- await useFollowStore.getState().fetchFollowStatus(userId, oxyServices);
284
+ // Enqueue this id into the micro-batched resolver — every button that enqueues
285
+ // in the same tick coalesces into ONE bulk `getFollowStatuses` call. Known /
286
+ // seeded / in-flight ids are skipped by the resolver, so this is a no-op for
287
+ // them.
288
+ const resolveStatus = useCallback(() => {
289
+ useFollowStore.getState().resolveFollowStatuses([userId], oxyServices);
263
290
  }, [userId, oxyServices]);
264
291
 
265
292
  const setFollowStatus = useCallback((following: boolean) => {
@@ -272,15 +299,30 @@ export const useFollowForButton = (userId: string, oxyServices: OxyServices) =>
272
299
 
273
300
  return {
274
301
  isFollowing,
302
+ isKnown,
275
303
  isLoading,
276
304
  error,
277
305
  toggleFollow,
278
- fetchStatus,
306
+ resolveStatus,
279
307
  setFollowStatus,
280
308
  clearError,
281
309
  };
282
310
  };
283
311
 
312
+ /**
313
+ * useSeedFollowStatuses — returns a stable callback that bulk-seeds follow
314
+ * statuses into the store (seed-only-if-absent; never clobbers a live value).
315
+ *
316
+ * Wire this at the app root to seed from `oxyServices.getViewerGraph()
317
+ * .followingIds` (map each followed id → `true`) so a page of `FollowButton`s
318
+ * paints the correct label on first render with ZERO follow-status network
319
+ * calls — the batched resolver then skips every seeded id.
320
+ */
321
+ export const useSeedFollowStatuses = () =>
322
+ useCallback((statuses: Record<string, boolean>) => {
323
+ useFollowStore.getState().setFollowStatuses(statuses);
324
+ }, []);
325
+
284
326
  // Convenience hook for just follower counts
285
327
  export const useFollowerCounts = (userId: string) => {
286
328
  const { oxyServices } = useOxy();
@@ -10,6 +10,7 @@ import type { QueryClient } from '@tanstack/react-query';
10
10
  import { clearQueryCache } from './queryClient';
11
11
  import { isWebBrowser } from '../utils/isWebBrowser';
12
12
  import { clearCrossOriginRestoreGuards } from '../utils/crossOriginRestore';
13
+ import { resetSessionScopedStores } from '../stores/resetSessionScopedStores';
13
14
 
14
15
  export interface UseSessionManagementOptions {
15
16
  oxyServices: OxyServices;
@@ -169,6 +170,7 @@ export const useSessionManagement = ({
169
170
  setSessions([]);
170
171
  setActiveSessionId(null);
171
172
  logoutStore();
173
+ resetSessionScopedStores();
172
174
 
173
175
  // Clear the access token on the client instance. Without this the
174
176
  // TokenStore retained the stale bearer until the next 401, leaving the
package/src/ui/index.ts CHANGED
@@ -32,7 +32,7 @@ export { default as ProfileButton } from './components/ProfileButton';
32
32
  // Context + hooks
33
33
  export { useOxy } from './context/OxyContext';
34
34
  export { useAuth } from './hooks/useAuth';
35
- export { useFollow } from './hooks/useFollow';
35
+ export { useFollow, useSeedFollowStatuses } from './hooks/useFollow';
36
36
  export { useStorage } from './hooks/useStorage';
37
37
  export type { UseStorageOptions, UseStorageResult } from './hooks/useStorage';
38
38