@oxyhq/services 22.2.0 → 22.4.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 (88) 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 +74 -94
  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/context/useOxyAccountGraph.js +2 -0
  10. package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
  11. package/lib/commonjs/ui/hooks/useFollow.js +53 -10
  12. package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
  13. package/lib/commonjs/ui/hooks/useSessionManagement.js +2 -0
  14. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  15. package/lib/commonjs/ui/index.js +6 -0
  16. package/lib/commonjs/ui/index.js.map +1 -1
  17. package/lib/commonjs/ui/stores/followStore.js +276 -92
  18. package/lib/commonjs/ui/stores/followStore.js.map +1 -1
  19. package/lib/commonjs/ui/stores/resetSessionScopedStores.js +14 -0
  20. package/lib/commonjs/ui/stores/resetSessionScopedStores.js.map +1 -0
  21. package/lib/module/index.js +1 -1
  22. package/lib/module/index.js.map +1 -1
  23. package/lib/module/ui/components/FollowButton.js +24 -19
  24. package/lib/module/ui/components/FollowButton.js.map +1 -1
  25. package/lib/module/ui/components/OxyAuthChooser.js +75 -95
  26. package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
  27. package/lib/module/ui/components/oxyAuthChooserStyles.js +0 -19
  28. package/lib/module/ui/components/oxyAuthChooserStyles.js.map +1 -1
  29. package/lib/module/ui/context/useOxyAccountGraph.js +2 -0
  30. package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
  31. package/lib/module/ui/hooks/useFollow.js +51 -9
  32. package/lib/module/ui/hooks/useFollow.js.map +1 -1
  33. package/lib/module/ui/hooks/useSessionManagement.js +2 -0
  34. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  35. package/lib/module/ui/index.js +1 -1
  36. package/lib/module/ui/index.js.map +1 -1
  37. package/lib/module/ui/stores/followStore.js +274 -91
  38. package/lib/module/ui/stores/followStore.js.map +1 -1
  39. package/lib/module/ui/stores/resetSessionScopedStores.js +11 -0
  40. package/lib/module/ui/stores/resetSessionScopedStores.js.map +1 -0
  41. package/lib/typescript/commonjs/index.d.ts +1 -1
  42. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts +0 -19
  46. package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +3 -0
  49. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +13 -2
  51. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/ui/index.d.ts +1 -1
  54. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/stores/followStore.d.ts +4 -1
  56. package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts +3 -0
  58. package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts.map +1 -0
  59. package/lib/typescript/module/index.d.ts +1 -1
  60. package/lib/typescript/module/index.d.ts.map +1 -1
  61. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  62. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
  63. package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts +0 -19
  64. package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
  65. package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  66. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +3 -0
  67. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  68. package/lib/typescript/module/ui/hooks/useFollow.d.ts +13 -2
  69. package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
  70. package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
  71. package/lib/typescript/module/ui/index.d.ts +1 -1
  72. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  73. package/lib/typescript/module/ui/stores/followStore.d.ts +4 -1
  74. package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
  75. package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts +3 -0
  76. package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts.map +1 -0
  77. package/package.json +5 -5
  78. package/src/index.ts +1 -1
  79. package/src/ui/components/FollowButton.tsx +22 -20
  80. package/src/ui/components/OxyAuthChooser.tsx +87 -75
  81. package/src/ui/components/oxyAuthChooserStyles.ts +0 -19
  82. package/src/ui/context/useOxyAccountGraph.ts +2 -0
  83. package/src/ui/hooks/useFollow.ts +51 -9
  84. package/src/ui/hooks/useSessionManagement.ts +2 -0
  85. package/src/ui/index.ts +1 -1
  86. package/src/ui/stores/__tests__/followStore.test.ts +178 -0
  87. package/src/ui/stores/followStore.ts +277 -106
  88. 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,
@@ -154,44 +155,45 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
154
155
  const passkeyAvailable = passkeyMode !== 'none';
155
156
 
156
157
  const [signInPasskeyPending, setSignInPasskeyPending] = useState(false);
157
- const [signInPasskeyError, setSignInPasskeyError] = useState<string | null>(null);
158
158
  const handleSignInWithPasskey = useCallback(async () => {
159
159
  if (signInPasskeyPending) return;
160
160
  setSignInPasskeyPending(true);
161
- setSignInPasskeyError(null);
162
161
  try {
163
162
  await signInWithPasskey();
164
163
  onComplete?.();
165
164
  } catch (error) {
166
165
  // A cancelled/failed ceremony keeps the view open so the user can retry
167
- // or fall back to the QR — surface a concise message, never swallow.
168
- setSignInPasskeyError(
169
- 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.',
170
172
  );
171
173
  } finally {
172
174
  setSignInPasskeyPending(false);
173
175
  }
174
- }, [signInPasskeyPending, signInWithPasskey, onComplete]);
176
+ }, [signInPasskeyPending, signInWithPasskey, onComplete, t]);
175
177
 
176
178
  const [createPasskeyPending, setCreatePasskeyPending] = useState(false);
177
- const [createPasskeyError, setCreatePasskeyError] = useState<string | null>(null);
178
179
  const handleCreateWithPasskey = useCallback(
179
180
  async (username: string) => {
180
181
  if (createPasskeyPending) return;
181
182
  setCreatePasskeyPending(true);
182
- setCreatePasskeyError(null);
183
183
  try {
184
184
  await registerWithPasskey({ username });
185
185
  onComplete?.();
186
186
  } catch (error) {
187
- setCreatePasskeyError(
188
- 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.',
189
191
  );
190
192
  } finally {
191
193
  setCreatePasskeyPending(false);
192
194
  }
193
195
  },
194
- [createPasskeyPending, registerWithPasskey, onComplete],
196
+ [createPasskeyPending, registerWithPasskey, onComplete, t],
195
197
  );
196
198
 
197
199
  // Bind the headless controller. `getSnapshot` returns a stable reference
@@ -208,6 +210,30 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
208
210
  const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
209
211
  const { view } = snapshot;
210
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
+
211
237
  // Web: "Sign in with Oxy" auto-starts the instant the sign-in entry is
212
238
  // reached — no button tap needed. `signInWithOxy()` tries the shared
213
239
  // keychain first (silently, native-only) then falls to the QR handoff,
@@ -234,20 +260,28 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
234
260
  setSwitching(true);
235
261
  try {
236
262
  await controller.switchTo(accountId);
237
- // A switch succeeds unless the controller recorded an error. On success
238
- // reload the app's account graph and drop cached account-scoped data so
239
- // the new active identity re-fetches the same side effects the
240
- // context's own `switchToAccount` performs.
241
- if (!controller.getSnapshot().error) {
242
- void refreshAccounts();
243
- queryClient.invalidateQueries();
244
- 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;
245
276
  }
277
+ void refreshAccounts();
278
+ queryClient.invalidateQueries();
279
+ onComplete?.();
246
280
  } finally {
247
281
  setSwitching(false);
248
282
  }
249
283
  },
250
- [controller, switching, snapshot.activeAccountId, onComplete, refreshAccounts, queryClient],
284
+ [controller, switching, snapshot.activeAccountId, onComplete, refreshAccounts, queryClient, t],
251
285
  );
252
286
 
253
287
  const handleManage = useCallback(() => {
@@ -287,11 +321,11 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
287
321
  ? () => void handleSignInWithPasskey()
288
322
  : () => void controller.startPasskeyHubSignIn()
289
323
  }
290
- // The hub-popup flow's pending/error state is `snapshot.signIn`
291
- // (already rendered elsewhere in this view) — only the DIRECT ceremony
292
- // uses this component-local pair.
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.
293
328
  passkeyPending={passkeyMode === 'direct' ? signInPasskeyPending : false}
294
- passkeyError={passkeyMode === 'direct' ? signInPasskeyError : null}
295
329
  onCreateAccount={() => controller.startSignup()}
296
330
  />
297
331
  );
@@ -307,7 +341,6 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
307
341
  passkeyMode={passkeyMode}
308
342
  onCreateWithPasskey={handleCreateWithPasskey}
309
343
  createPending={createPasskeyPending}
310
- createError={createPasskeyError}
311
344
  onOpenHub={() => void controller.startPasskeyHubSignIn()}
312
345
  onBackToSignIn={() => controller.setView('signin')}
313
346
  />
@@ -331,22 +364,6 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
331
364
  // Accounts view
332
365
  // ---------------------------------------------------------------------------
333
366
 
334
- /**
335
- * Inline error banner for `snapshot.error` (the account-list / switch-failure
336
- * error — distinct from `snapshot.signIn.error`, which `QrView` already
337
- * renders). `AccountsView` and `SignInView` previously swallowed it: a
338
- * blocked/failed switch left the user with no feedback outside the QR view.
339
- */
340
- const ErrorBanner: React.FC<{ error: string | null; theme: Theme }> = ({ error, theme }) => {
341
- if (!error) return null;
342
- return (
343
- <View style={[styles.errorBanner, { backgroundColor: theme.colors.negativeSubtle }]}>
344
- <MaterialCommunityIcons name="alert-circle-outline" size={18} color={theme.colors.error} />
345
- <Text style={[styles.errorBannerText, { color: theme.colors.error }]}>{error}</Text>
346
- </View>
347
- );
348
- };
349
-
350
367
  interface AccountsViewProps {
351
368
  snapshot: AccountDialogSnapshot;
352
369
  theme: Theme;
@@ -368,7 +385,6 @@ const AccountsView: React.FC<AccountsViewProps> = ({ snapshot, theme, t, handler
368
385
 
369
386
  return (
370
387
  <View style={styles.rows}>
371
- <ErrorBanner error={snapshot.error} theme={theme} />
372
388
  {snapshot.accounts.map((account) => (
373
389
  <AccountRow
374
390
  key={account.accountId}
@@ -490,7 +506,6 @@ const SignInView: React.FC<SignInViewProps> = ({
490
506
  onCreateAccount,
491
507
  }) => (
492
508
  <View style={styles.signInBlock}>
493
- <ErrorBanner error={snapshot.error} theme={theme} />
494
509
  {snapshot.accounts.length > 0 ? (
495
510
  <View style={styles.rows}>
496
511
  {snapshot.accounts.map((account) => (
@@ -571,7 +586,6 @@ interface QrViewProps {
571
586
  passkeyAvailable: boolean;
572
587
  onSignInWithPasskey: () => void;
573
588
  passkeyPending: boolean;
574
- passkeyError: string | null;
575
589
  onCreateAccount: () => void;
576
590
  }
577
591
 
@@ -583,32 +597,28 @@ const QrView: React.FC<QrViewProps> = ({
583
597
  passkeyAvailable,
584
598
  onSignInWithPasskey,
585
599
  passkeyPending,
586
- passkeyError,
587
600
  onCreateAccount,
588
601
  }) => {
589
602
  const { signIn, commonsAvailability } = snapshot;
590
603
  const [showQrAnyway, setShowQrAnyway] = useState(false);
591
604
 
605
+ // A ceremony FAILURE is surfaced as a toast by `handleSignInWithPasskey` — the
606
+ // link itself never renders an inline error (owner mandate).
592
607
  const passkeyLink =
593
608
  passkeyAvailable && signIn.phase !== 'authorized' ? (
594
- <>
595
- <Pressable
596
- onPress={onSignInWithPasskey}
597
- disabled={passkeyPending}
598
- accessibilityRole="button"
599
- style={styles.footerLink}
600
- testID="passkey-signin-link"
601
- >
602
- <Text style={[styles.linkText, { color: theme.colors.textSecondary }]}>
603
- {passkeyPending
604
- ? t('accountSwitcher.passkeySigningIn') || 'Signing in…'
605
- : t('accountSwitcher.useIdentityOnDevice') || 'No Commons? Use a passkey on this device instead'}
606
- </Text>
607
- </Pressable>
608
- {passkeyError ? (
609
- <Text style={[styles.errorText, { color: theme.colors.error }]}>{passkeyError}</Text>
610
- ) : null}
611
- </>
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>
612
622
  ) : null;
613
623
 
614
624
  // Native, Commons confirmed NOT installed: a same-device QR is a dead
@@ -640,11 +650,10 @@ const QrView: React.FC<QrViewProps> = ({
640
650
  }
641
651
 
642
652
  if (signIn.phase === 'error') {
653
+ // The failure reason is toasted by the controller subscription above — no
654
+ // inline error copy here (owner mandate).
643
655
  return (
644
656
  <View style={styles.centeredBlock}>
645
- <Text style={[styles.errorText, { color: theme.colors.error }]}>
646
- {signIn.error || 'Sign-in failed. Please try again.'}
647
- </Text>
648
657
  <Button variant="secondary" onPress={onRetry} style={styles.secondaryButton}>
649
658
  {t('common.actions.tryAgain') || 'Try again'}
650
659
  </Button>
@@ -690,7 +699,10 @@ const QrView: React.FC<QrViewProps> = ({
690
699
  type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken';
691
700
 
692
701
  /** Debounced username-availability check over the existing SDK method. */
693
- function useUsernameAvailability(oxyServices: OxyServices): {
702
+ function useUsernameAvailability(
703
+ oxyServices: OxyServices,
704
+ t: Translate,
705
+ ): {
694
706
  status: UsernameStatus;
695
707
  check: (value: string) => void;
696
708
  } {
@@ -716,11 +728,16 @@ function useUsernameAvailability(oxyServices: OxyServices): {
716
728
  })
717
729
  .catch(() => {
718
730
  if (seq !== requestSeqRef.current) return;
719
- 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
+ );
720
737
  });
721
738
  }, 400);
722
739
  },
723
- [oxyServices],
740
+ [oxyServices, t],
724
741
  );
725
742
 
726
743
  return { status, check };
@@ -761,7 +778,6 @@ interface SignUpViewProps {
761
778
  passkeyMode: 'direct' | 'hub' | 'none';
762
779
  onCreateWithPasskey: (username: string) => Promise<void>;
763
780
  createPending: boolean;
764
- createError: string | null;
765
781
  /** Open the auth.oxy.so hub popup (b2) — the 'hub' mode's only path. */
766
782
  onOpenHub: () => void;
767
783
  onBackToSignIn: () => void;
@@ -775,12 +791,11 @@ const SignUpView: React.FC<SignUpViewProps> = ({
775
791
  passkeyMode,
776
792
  onCreateWithPasskey,
777
793
  createPending,
778
- createError,
779
794
  onOpenHub,
780
795
  onBackToSignIn,
781
796
  }) => {
782
797
  const [username, setUsername] = useState('');
783
- const { status: usernameStatus, check: checkUsername } = useUsernameAvailability(oxyServices);
798
+ const { status: usernameStatus, check: checkUsername } = useUsernameAvailability(oxyServices, t);
784
799
  const canSubmit = username.trim().length >= 3 && usernameStatus === 'available' && !createPending;
785
800
 
786
801
  // Native — Commons owns identity creation. Mirrors the QR view's own
@@ -851,9 +866,6 @@ const SignUpView: React.FC<SignUpViewProps> = ({
851
866
  testID="signup-username-input"
852
867
  />
853
868
  <UsernameStatusText status={usernameStatus} theme={theme} t={t} />
854
- {createError ? (
855
- <Text style={[styles.errorText, { color: theme.colors.error }]}>{createError}</Text>
856
- ) : null}
857
869
  <Button
858
870
  variant="secondary"
859
871
  onPress={() => void onCreateWithPasskey(username.trim())}
@@ -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,
@@ -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