@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.
- 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 +107 -106
- 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/components/passkeyHubPopup.js +33 -0
- package/lib/commonjs/ui/components/passkeyHubPopup.js.map +1 -0
- package/lib/commonjs/ui/context/OxyContext.js +7 -1
- package/lib/commonjs/ui/context/OxyContext.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 +108 -107
- 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/components/passkeyHubPopup.js +29 -0
- package/lib/module/ui/components/passkeyHubPopup.js.map +1 -0
- package/lib/module/ui/context/OxyContext.js +7 -1
- package/lib/module/ui/context/OxyContext.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/components/passkeyHubPopup.d.ts +23 -0
- package/lib/typescript/commonjs/ui/components/passkeyHubPopup.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.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/components/passkeyHubPopup.d.ts +23 -0
- package/lib/typescript/module/ui/components/passkeyHubPopup.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/useOxyAccountGraph.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 +12 -12
- package/src/index.ts +1 -1
- package/src/ui/components/FollowButton.tsx +22 -20
- package/src/ui/components/OxyAuthChooser.tsx +125 -89
- package/src/ui/components/oxyAuthChooserStyles.ts +0 -19
- package/src/ui/components/passkeyHubPopup.ts +28 -0
- package/src/ui/context/OxyContext.tsx +6 -0
- 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,
|
|
@@ -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
|
|
142
|
-
// dev host) —
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
180
|
-
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.',
|
|
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
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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={
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
341
|
+
passkeyMode={passkeyMode}
|
|
293
342
|
onCreateWithPasskey={handleCreateWithPasskey}
|
|
294
343
|
createPending={createPasskeyPending}
|
|
295
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
>
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
778
|
+
passkeyMode: 'direct' | 'hub' | 'none';
|
|
746
779
|
onCreateWithPasskey: (username: string) => Promise<void>;
|
|
747
780
|
createPending: boolean;
|
|
748
|
-
|
|
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
|
-
|
|
791
|
+
passkeyMode,
|
|
758
792
|
onCreateWithPasskey,
|
|
759
793
|
createPending,
|
|
760
|
-
|
|
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 (
|
|
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
|
|
855
|
-
//
|
|
856
|
-
//
|
|
885
|
+
// Web, non-Oxy origin (b2): passkey creation can't run locally here — the
|
|
886
|
+
// same WebAuthn RP-ID boundary as sign-in — so 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.
|
|
861
|
-
|
|
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
|
-
|
|
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
|
|