@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.
- package/lib/commonjs/index.js +6 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/FollowButton.js +24 -19
- package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAuthChooser.js +74 -94
- package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/commonjs/ui/components/oxyAuthChooserStyles.js +0 -19
- package/lib/commonjs/ui/components/oxyAuthChooserStyles.js.map +1 -1
- package/lib/commonjs/ui/context/useOxyAccountGraph.js +2 -0
- package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
- package/lib/commonjs/ui/hooks/useFollow.js +53 -10
- package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
- package/lib/commonjs/ui/hooks/useSessionManagement.js +2 -0
- package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/commonjs/ui/index.js +6 -0
- package/lib/commonjs/ui/index.js.map +1 -1
- package/lib/commonjs/ui/stores/followStore.js +276 -92
- package/lib/commonjs/ui/stores/followStore.js.map +1 -1
- package/lib/commonjs/ui/stores/resetSessionScopedStores.js +14 -0
- package/lib/commonjs/ui/stores/resetSessionScopedStores.js.map +1 -0
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/FollowButton.js +24 -19
- package/lib/module/ui/components/FollowButton.js.map +1 -1
- package/lib/module/ui/components/OxyAuthChooser.js +75 -95
- package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/module/ui/components/oxyAuthChooserStyles.js +0 -19
- package/lib/module/ui/components/oxyAuthChooserStyles.js.map +1 -1
- package/lib/module/ui/context/useOxyAccountGraph.js +2 -0
- package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
- package/lib/module/ui/hooks/useFollow.js +51 -9
- package/lib/module/ui/hooks/useFollow.js.map +1 -1
- package/lib/module/ui/hooks/useSessionManagement.js +2 -0
- package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/module/ui/index.js +1 -1
- package/lib/module/ui/index.js.map +1 -1
- package/lib/module/ui/stores/followStore.js +274 -91
- package/lib/module/ui/stores/followStore.js.map +1 -1
- package/lib/module/ui/stores/resetSessionScopedStores.js +11 -0
- package/lib/module/ui/stores/resetSessionScopedStores.js.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +1 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts +0 -19
- package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +3 -0
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +13 -2
- package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/index.d.ts +1 -1
- package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/followStore.d.ts +4 -1
- package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts +3 -0
- package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +1 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts +0 -19
- package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +3 -0
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useFollow.d.ts +13 -2
- package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
- package/lib/typescript/module/ui/index.d.ts +1 -1
- package/lib/typescript/module/ui/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/followStore.d.ts +4 -1
- package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts +3 -0
- package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/index.ts +1 -1
- package/src/ui/components/FollowButton.tsx +22 -20
- package/src/ui/components/OxyAuthChooser.tsx +87 -75
- package/src/ui/components/oxyAuthChooserStyles.ts +0 -19
- package/src/ui/context/useOxyAccountGraph.ts +2 -0
- package/src/ui/hooks/useFollow.ts +51 -9
- package/src/ui/hooks/useSessionManagement.ts +2 -0
- package/src/ui/index.ts +1 -1
- package/src/ui/stores/__tests__/followStore.test.ts +178 -0
- package/src/ui/stores/followStore.ts +277 -106
- 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
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
188
|
-
error instanceof Error && error.message
|
|
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
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
291
|
-
//
|
|
292
|
-
//
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
>
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
|