@oxyhq/services 19.1.1 → 19.1.3
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 +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/boot/runProviderColdBoot.js +4 -0
- package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/commonjs/ui/components/OxyOAuthCallback.js +3 -4
- package/lib/commonjs/ui/components/OxyOAuthCallback.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +53 -52
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/commitSessionFlow.js +91 -0
- package/lib/commonjs/ui/context/commitSessionFlow.js.map +1 -0
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js +1 -2
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
- package/lib/commonjs/ui/hooks/useOxyEvent.js +28 -0
- package/lib/commonjs/ui/hooks/useOxyEvent.js.map +1 -0
- package/lib/commonjs/ui/screens/CreateAccountScreen.js +53 -2
- package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/FileManagementScreen.js +3 -9
- package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
- package/lib/commonjs/ui/session/createSessionClient.js +12 -8
- package/lib/commonjs/ui/session/createSessionClient.js.map +1 -1
- package/lib/commonjs/ui/session/tokenTransport.js +22 -32
- package/lib/commonjs/ui/session/tokenTransport.js.map +1 -1
- package/lib/commonjs/ui/utils/oauthReturn.js +27 -0
- package/lib/commonjs/ui/utils/oauthReturn.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/boot/runProviderColdBoot.js +5 -1
- package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/module/ui/components/OxyOAuthCallback.js +3 -4
- package/lib/module/ui/components/OxyOAuthCallback.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +54 -53
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/commitSessionFlow.js +87 -0
- package/lib/module/ui/context/commitSessionFlow.js.map +1 -0
- package/lib/module/ui/context/hooks/useAuthOperations.js +1 -2
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
- package/lib/module/ui/hooks/useOxyEvent.js +25 -0
- package/lib/module/ui/hooks/useOxyEvent.js.map +1 -0
- package/lib/module/ui/screens/CreateAccountScreen.js +53 -2
- package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
- package/lib/module/ui/screens/FileManagementScreen.js +3 -9
- package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
- package/lib/module/ui/session/createSessionClient.js +12 -8
- package/lib/module/ui/session/createSessionClient.js.map +1 -1
- package/lib/module/ui/session/tokenTransport.js +23 -33
- package/lib/module/ui/session/tokenTransport.js.map +1 -1
- package/lib/module/ui/utils/oauthReturn.js +26 -0
- package/lib/module/ui/utils/oauthReturn.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts +39 -0
- package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +3 -1
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +3 -2
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useOxyEvent.d.ts +8 -0
- package/lib/typescript/commonjs/ui/hooks/useOxyEvent.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +12 -8
- package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +11 -9
- package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/oauthReturn.d.ts +6 -0
- package/lib/typescript/commonjs/ui/utils/oauthReturn.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/commitSessionFlow.d.ts +39 -0
- package/lib/typescript/module/ui/context/commitSessionFlow.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +3 -1
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +3 -2
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useOxyEvent.d.ts +8 -0
- package/lib/typescript/module/ui/hooks/useOxyEvent.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/createSessionClient.d.ts +12 -8
- package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/tokenTransport.d.ts +11 -9
- package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/oauthReturn.d.ts +6 -0
- package/lib/typescript/module/ui/utils/oauthReturn.d.ts.map +1 -1
- package/package.json +16 -16
- package/src/index.ts +1 -0
- package/src/ui/boot/runProviderColdBoot.ts +5 -1
- package/src/ui/components/OxyOAuthCallback.tsx +3 -3
- package/src/ui/context/OxyContext.tsx +52 -52
- package/src/ui/context/__tests__/commitSessionFlow.test.ts +192 -0
- package/src/ui/context/commitSessionFlow.ts +115 -0
- package/src/ui/context/hooks/useAuthOperations.ts +2 -3
- package/src/ui/context/oxyContextTypes.ts +3 -1
- package/src/ui/context/useOxyAccountGraph.ts +1 -1
- package/src/ui/hooks/useOxyEvent.ts +22 -0
- package/src/ui/screens/CreateAccountScreen.tsx +59 -2
- package/src/ui/screens/FileManagementScreen.tsx +3 -9
- package/src/ui/session/__tests__/createSessionClient.test.ts +5 -5
- package/src/ui/session/__tests__/tokenTransport.test.ts +43 -60
- package/src/ui/session/createSessionClient.ts +13 -10
- package/src/ui/session/tokenTransport.ts +22 -41
- package/src/ui/utils/oauthReturn.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/services",
|
|
3
|
-
"version": "19.1.
|
|
3
|
+
"version": "19.1.3",
|
|
4
4
|
"description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|
|
@@ -118,11 +118,11 @@
|
|
|
118
118
|
}
|
|
119
119
|
},
|
|
120
120
|
"dependencies": {
|
|
121
|
-
"@oxyhq/contracts": "0.13.
|
|
121
|
+
"@oxyhq/contracts": "0.13.2",
|
|
122
122
|
"color": "^4.2.3"
|
|
123
123
|
},
|
|
124
124
|
"devDependencies": {
|
|
125
|
-
"@oxyhq/core": "9.2.
|
|
125
|
+
"@oxyhq/core": "9.2.3",
|
|
126
126
|
"nativewind": "5.0.0-preview.3",
|
|
127
127
|
"react-native-css": "^3.0.0",
|
|
128
128
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
@@ -147,16 +147,16 @@
|
|
|
147
147
|
"@types/color": "^4.2.1",
|
|
148
148
|
"@types/jest": "^29.5.14",
|
|
149
149
|
"@types/node": "^20.19.9",
|
|
150
|
-
"expo-file-system": "~
|
|
151
|
-
"expo-font": "~
|
|
152
|
-
"expo-haptics": "~
|
|
153
|
-
"expo-image": "~
|
|
154
|
-
"expo-linear-gradient": "~
|
|
150
|
+
"expo-file-system": "~57.0.0",
|
|
151
|
+
"expo-font": "~57.0.0",
|
|
152
|
+
"expo-haptics": "~57.0.0",
|
|
153
|
+
"expo-image": "~57.0.0",
|
|
154
|
+
"expo-linear-gradient": "~57.0.0",
|
|
155
155
|
"husky": "^4.3.8",
|
|
156
156
|
"jest": "~29.7.0",
|
|
157
157
|
"lint-staged": "^15.2.4",
|
|
158
158
|
"react-native-builder-bob": "^0.43.0",
|
|
159
|
-
"react-native-gesture-handler": "~2.
|
|
159
|
+
"react-native-gesture-handler": "~2.32.0",
|
|
160
160
|
"react-native-reanimated": "4.5.0",
|
|
161
161
|
"react-native-safe-area-context": "~5.8.0",
|
|
162
162
|
"react-native-svg": "15.15.5",
|
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
"peerDependencies": {
|
|
167
167
|
"@expo/vector-icons": "^15.0.3",
|
|
168
168
|
"@oxyhq/bloom": ">=0.16.0",
|
|
169
|
-
"@oxyhq/core": "^9.2.
|
|
169
|
+
"@oxyhq/core": "^9.2.3",
|
|
170
170
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
171
171
|
"@react-native-community/netinfo": "^11.4.1",
|
|
172
172
|
"@tanstack/query-async-storage-persister": "^5.101",
|
|
@@ -176,16 +176,16 @@
|
|
|
176
176
|
"@types/react": "*",
|
|
177
177
|
"@types/react-native": "*",
|
|
178
178
|
"expo": ">=56.0.0",
|
|
179
|
-
"expo-document-picker": "
|
|
180
|
-
"expo-file-system": "
|
|
179
|
+
"expo-document-picker": ">=56.0.0",
|
|
180
|
+
"expo-file-system": ">=56.0.0",
|
|
181
181
|
"expo-font": ">=13.0.0",
|
|
182
|
-
"expo-haptics": "
|
|
182
|
+
"expo-haptics": ">=56.0.0",
|
|
183
183
|
"expo-image": ">=2.0.0",
|
|
184
|
-
"expo-image-manipulator": "
|
|
185
|
-
"expo-linear-gradient": "
|
|
184
|
+
"expo-image-manipulator": ">=56.0.0",
|
|
185
|
+
"expo-linear-gradient": ">=56.0.0",
|
|
186
186
|
"react": ">=18.0.0",
|
|
187
187
|
"react-native": ">=0.85.0",
|
|
188
|
-
"react-native-gesture-handler": "~2.
|
|
188
|
+
"react-native-gesture-handler": "~2.32.0",
|
|
189
189
|
"react-native-keyboard-controller": ">=1.21.0",
|
|
190
190
|
"react-native-qrcode-svg": "^6.3.0",
|
|
191
191
|
"react-native-reanimated": ">=4.3.0",
|
package/src/index.ts
CHANGED
|
@@ -172,6 +172,7 @@ export {
|
|
|
172
172
|
export { useMutationStatus } from './ui/hooks/useMutationStatus';
|
|
173
173
|
export type { MutationStatus } from './ui/hooks/useMutationStatus';
|
|
174
174
|
export { useOnlineStatus } from './ui/hooks/useOnlineStatus';
|
|
175
|
+
export { useOxyEvent } from './ui/hooks/useOxyEvent';
|
|
175
176
|
|
|
176
177
|
// ---------------------------------------------------------------------------
|
|
177
178
|
// Error handlers
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
consumeSilentOAuthError,
|
|
11
11
|
maybeStartSilentOAuthRestore,
|
|
12
12
|
} from '../utils/crossOriginRestore';
|
|
13
|
-
import { tryCompleteOAuthReturn } from '../utils/oauthReturn';
|
|
13
|
+
import { tryCompleteOAuthReturn, consumeHubSyncFailure } from '../utils/oauthReturn';
|
|
14
14
|
import { isWebBrowser } from '../utils/isWebBrowser';
|
|
15
15
|
import type { CommitInput } from '../context/oxyContextTypes';
|
|
16
16
|
|
|
@@ -57,6 +57,7 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
|
|
|
57
57
|
|
|
58
58
|
try {
|
|
59
59
|
consumeSilentOAuthError();
|
|
60
|
+
consumeHubSyncFailure();
|
|
60
61
|
|
|
61
62
|
const oauthCompleted = await tryCompleteOAuthReturn({
|
|
62
63
|
oxyServices,
|
|
@@ -75,6 +76,9 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
|
|
|
75
76
|
store: authStore,
|
|
76
77
|
platform: { isWeb: isWebBrowser(), isNative: !isWebBrowser() },
|
|
77
78
|
onSession: async (session) => {
|
|
79
|
+
// Mint already persisted `{deviceId, deviceSecret}` to the store; sync the
|
|
80
|
+
// in-memory SessionClient host so sockets + tab-focus re-mint can use it.
|
|
81
|
+
await syncDeviceCredentialToHost();
|
|
78
82
|
const handoff = commitSession(
|
|
79
83
|
{
|
|
80
84
|
sessionId: session.sessionId,
|
|
@@ -14,7 +14,7 @@ export interface OxyOAuthCallbackProps {
|
|
|
14
14
|
* callback path for a Clerk-style loading shell while auth resolves.
|
|
15
15
|
*/
|
|
16
16
|
export function OxyOAuthCallback({ redirectTo = '/' }: OxyOAuthCallbackProps) {
|
|
17
|
-
const { isAuthResolved
|
|
17
|
+
const { isAuthResolved } = useAuth();
|
|
18
18
|
|
|
19
19
|
useEffect(() => {
|
|
20
20
|
if (!isAuthResolved) return;
|
|
@@ -24,11 +24,11 @@ export function OxyOAuthCallback({ redirectTo = '/' }: OxyOAuthCallbackProps) {
|
|
|
24
24
|
const params = new URLSearchParams(location.search);
|
|
25
25
|
if (!params.has('code') && !params.has('error')) return;
|
|
26
26
|
|
|
27
|
-
const target =
|
|
27
|
+
const target = redirectTo;
|
|
28
28
|
if (location.pathname + location.search !== target) {
|
|
29
29
|
window.location.replace(target);
|
|
30
30
|
}
|
|
31
|
-
}, [isAuthResolved,
|
|
31
|
+
}, [isAuthResolved, redirectTo]);
|
|
32
32
|
|
|
33
33
|
return (
|
|
34
34
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
installAuthRefreshHandler,
|
|
24
24
|
startTokenRefreshScheduler,
|
|
25
25
|
createAccountDialogController,
|
|
26
|
-
refreshPersistedSession,
|
|
27
26
|
logger as loggerUtil,
|
|
28
27
|
syncHubAfterSignIn,
|
|
29
28
|
} from '@oxyhq/core';
|
|
@@ -64,6 +63,7 @@ import {
|
|
|
64
63
|
type CommitInput,
|
|
65
64
|
} from './oxyContextTypes';
|
|
66
65
|
import { DEFAULT_SESSION_VALIDITY_MS, loadUseFollowHook } from './oxyContextHelpers';
|
|
66
|
+
import { commitDeviceSetAndResolve } from './commitSessionFlow';
|
|
67
67
|
import { useOxyAccountGraph } from './useOxyAccountGraph';
|
|
68
68
|
|
|
69
69
|
export type { OxyContextState, PasswordSignInResult, OxyContextProviderProps } from './oxyContextTypes';
|
|
@@ -281,9 +281,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
281
281
|
if (!sessionClientPairRef.current) {
|
|
282
282
|
sessionClientPairRef.current = createSessionClient(
|
|
283
283
|
oxyServices,
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
284
|
+
(origin) => {
|
|
285
|
+
// Erase the DURABLE device credential only on a `request`-origin verdict
|
|
286
|
+
// (a REST sign-out / revocation). A `push`-origin empty state is a socket
|
|
287
|
+
// broadcast that may be a transient reconnect artifact — clearing the
|
|
288
|
+
// credential on it would strand the user signed out with no way back, so
|
|
289
|
+
// we clear only the local UI session and keep the credential (a dead one
|
|
290
|
+
// re-mints to `no_active_session` and resolves signed-out on next boot).
|
|
291
|
+
if (origin === 'request') {
|
|
292
|
+
clearPersistedAuthSafe(authStore, logger);
|
|
293
|
+
}
|
|
287
294
|
void clearSessionStateRef.current().catch((clearError) => {
|
|
288
295
|
logger('Failed to clear local state on remote sign-out', clearError);
|
|
289
296
|
});
|
|
@@ -301,8 +308,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
301
308
|
return;
|
|
302
309
|
}
|
|
303
310
|
if (state.accounts.length === 0) {
|
|
311
|
+
// Clear the LOCAL UI session on any applied empty state, but NEVER the
|
|
312
|
+
// durable device credential here: `syncFromClient` runs for socket-pushed
|
|
313
|
+
// (possibly transient) states too, and wiping the credential on one would
|
|
314
|
+
// strand the user signed out. The credential wipe is gated on a
|
|
315
|
+
// `request`-origin verdict in `onUnauthenticated` above.
|
|
304
316
|
sessionClientHost.setCurrentAccountId(null);
|
|
305
|
-
clearPersistedAuthSafe(authStore, logger);
|
|
306
317
|
await clearSessionState();
|
|
307
318
|
return;
|
|
308
319
|
}
|
|
@@ -333,7 +344,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
333
344
|
oxyServices,
|
|
334
345
|
sessionClient,
|
|
335
346
|
sessionClientHost,
|
|
336
|
-
authStore,
|
|
337
347
|
updateSessions,
|
|
338
348
|
setActiveSessionId,
|
|
339
349
|
loginSuccess,
|
|
@@ -526,47 +536,27 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
526
536
|
setActiveSessionId(input.sessionId);
|
|
527
537
|
}
|
|
528
538
|
|
|
529
|
-
// Register into the device set
|
|
530
|
-
//
|
|
531
|
-
// the
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
// Hydrate the full user (the commit input carries only minimal data). Fall
|
|
551
|
-
// back to the minimal shape if the profile fetch fails.
|
|
552
|
-
let fullUser: User | null = null;
|
|
553
|
-
try {
|
|
554
|
-
fullUser = await oxyServices.getCurrentUser();
|
|
555
|
-
} catch (profileError) {
|
|
556
|
-
if (__DEV__) {
|
|
557
|
-
loggerUtil.debug(
|
|
558
|
-
'Failed to fetch full user on commit; using minimal fallback',
|
|
559
|
-
{ component: 'OxyContext', method: 'commitSession' },
|
|
560
|
-
profileError as unknown,
|
|
561
|
-
);
|
|
562
|
-
}
|
|
563
|
-
fullUser = (input.user as unknown as User) ?? null;
|
|
564
|
-
}
|
|
565
|
-
if (fullUser) {
|
|
566
|
-
loginSuccess(fullUser);
|
|
567
|
-
onAuthStateChangeRef.current?.(fullUser);
|
|
568
|
-
}
|
|
569
|
-
markAuthResolvedRef.current();
|
|
539
|
+
// Register into the device set, hydrate the full user, and flip the
|
|
540
|
+
// auth-resolution gate. A deliberate sign-in ACTIVATES the account and
|
|
541
|
+
// BLOCKS on the reconcile (register + socket + sessions projection) before
|
|
542
|
+
// resolving — unchanged ordering. The cold boot resolves auth from the
|
|
543
|
+
// profile fetch FIRST and reconciles the device set in the background, so
|
|
544
|
+
// first paint is not held behind those extra round-trips. Reconcile is
|
|
545
|
+
// best-effort — a failure never fails the sign-in (cold boot re-registers
|
|
546
|
+
// on the next load).
|
|
547
|
+
await commitDeviceSetAndResolve({
|
|
548
|
+
activate: options.activate,
|
|
549
|
+
userId: input.userId,
|
|
550
|
+
fallbackUser: (input.user as unknown as User) ?? null,
|
|
551
|
+
registerAndActivate: (userId) => sessionClient.registerAndActivate(userId),
|
|
552
|
+
addCurrentAccount: () => sessionClient.addCurrentAccount(),
|
|
553
|
+
startSocket: () => sessionClient.start(),
|
|
554
|
+
syncFromClient,
|
|
555
|
+
getCurrentUser: () => oxyServices.getCurrentUser(),
|
|
556
|
+
loginSuccess,
|
|
557
|
+
onAuthStateChange: onAuthStateChangeRef.current,
|
|
558
|
+
markAuthResolved: markAuthResolvedRef.current,
|
|
559
|
+
});
|
|
570
560
|
|
|
571
561
|
if (options.activate && options.hubSync && hubSync && isWebBrowser()) {
|
|
572
562
|
try {
|
|
@@ -598,9 +588,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
598
588
|
{
|
|
599
589
|
sessionId: session.sessionId,
|
|
600
590
|
accessToken: session.accessToken,
|
|
601
|
-
|
|
602
|
-
// even though `SessionLoginResponse` does not type it.
|
|
603
|
-
deviceSecret: (session as { deviceSecret?: string }).deviceSecret,
|
|
591
|
+
deviceSecret: session.deviceSecret,
|
|
604
592
|
deviceId: session.deviceId,
|
|
605
593
|
expiresAt: session.expiresAt,
|
|
606
594
|
userId: session.user.id,
|
|
@@ -638,6 +626,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
638
626
|
}
|
|
639
627
|
void Linking.openURL(url);
|
|
640
628
|
},
|
|
629
|
+
// Native-only probe so the controller can detect an installed Commons and
|
|
630
|
+
// deep-link straight into its approve screen (keeping the QR/polling live
|
|
631
|
+
// as fallback). `undefined` on web mirrors the `openUrl` split — the
|
|
632
|
+
// controller short-circuits, so `redirectToAuthorize` never runs for the
|
|
633
|
+
// `oxycommons://` payload.
|
|
634
|
+
canOpenApp: isWebBrowser() ? undefined : (url) => Linking.canOpenURL(url),
|
|
641
635
|
});
|
|
642
636
|
}
|
|
643
637
|
const accountDialogController = accountDialogControllerRef.current;
|
|
@@ -784,7 +778,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
784
778
|
if (document.visibilityState !== 'visible') return;
|
|
785
779
|
const reconcile = async (): Promise<void> => {
|
|
786
780
|
if (!oxyServices.getAccessToken() && sessionClientHost.getDeviceCredential()) {
|
|
787
|
-
|
|
781
|
+
// Route through the ONE shared single-flight the scheduler/preflight/401
|
|
782
|
+
// use — never a private mint lane — so a tab-focus reconcile can't
|
|
783
|
+
// double-rotate the device secret against them.
|
|
784
|
+
await oxyServices.httpService.refreshAccessToken('preflight');
|
|
788
785
|
}
|
|
789
786
|
if (!oxyServices.getAccessToken() && !sessionClientHost.getDeviceCredential()) {
|
|
790
787
|
return;
|
|
@@ -796,7 +793,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
796
793
|
};
|
|
797
794
|
document.addEventListener('visibilitychange', onVisibility);
|
|
798
795
|
return () => document.removeEventListener('visibilitychange', onVisibility);
|
|
799
|
-
}, [oxyServices, sessionClient, sessionClientHost,
|
|
796
|
+
}, [oxyServices, sessionClient, sessionClientHost, syncFromClient]);
|
|
800
797
|
|
|
801
798
|
// Exposed `refreshSessions`: re-bootstrap the server-authoritative device
|
|
802
799
|
// state and reproject — the manual counterpart to the realtime socket.
|
|
@@ -900,6 +897,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
900
897
|
storageKeyPrefix,
|
|
901
898
|
clientId,
|
|
902
899
|
oxyServices,
|
|
900
|
+
sessionClient,
|
|
903
901
|
useFollow: useFollowHook,
|
|
904
902
|
showBottomSheet: showBottomSheetForContext,
|
|
905
903
|
openAvatarPicker,
|
|
@@ -949,6 +947,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
949
947
|
storageKeyPrefix,
|
|
950
948
|
clientId,
|
|
951
949
|
oxyServices,
|
|
950
|
+
sessionClient,
|
|
952
951
|
useFollowHook,
|
|
953
952
|
showBottomSheetForContext,
|
|
954
953
|
openAvatarPicker,
|
|
@@ -1018,6 +1017,7 @@ const LOADING_STATE: OxyContextState = {
|
|
|
1018
1017
|
storageKeyPrefix: 'oxy_session',
|
|
1019
1018
|
clientId: null,
|
|
1020
1019
|
oxyServices: LOADING_STATE_OXY_SERVICES,
|
|
1020
|
+
sessionClient: null,
|
|
1021
1021
|
openAvatarPicker: () => {},
|
|
1022
1022
|
accountDialogController: null,
|
|
1023
1023
|
isAccountDialogOpen: false,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { User } from '@oxyhq/core';
|
|
2
|
+
import { logger } from '@oxyhq/core';
|
|
3
|
+
import {
|
|
4
|
+
commitDeviceSetAndResolve,
|
|
5
|
+
type CommitDeviceSetAndResolveDeps,
|
|
6
|
+
} from '../commitSessionFlow';
|
|
7
|
+
|
|
8
|
+
// A resolved promise chain (addCurrentAccount -> start -> syncFromClient) settles
|
|
9
|
+
// entirely within the microtask queue, which is fully drained before a macrotask
|
|
10
|
+
// (`setTimeout`) runs — so one tick flushes a detached reconcile task.
|
|
11
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
12
|
+
|
|
13
|
+
const makeUser = (id: string): User => ({ id, username: id } as unknown as User);
|
|
14
|
+
|
|
15
|
+
interface Deferred<T> {
|
|
16
|
+
promise: Promise<T>;
|
|
17
|
+
resolve: (value: T) => void;
|
|
18
|
+
reject: (reason?: unknown) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const deferred = <T,>(): Deferred<T> => {
|
|
22
|
+
let resolve: (value: T) => void = () => undefined;
|
|
23
|
+
let reject: (reason?: unknown) => void = () => undefined;
|
|
24
|
+
const promise = new Promise<T>((res, rej) => {
|
|
25
|
+
resolve = res;
|
|
26
|
+
reject = rej;
|
|
27
|
+
});
|
|
28
|
+
return { promise, resolve, reject };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a deps object whose every step appends to a shared `order` log, so tests
|
|
33
|
+
* can assert both WHICH steps ran and the RELATIVE ordering between the
|
|
34
|
+
* auth-resolution gate and the device-set reconcile.
|
|
35
|
+
*/
|
|
36
|
+
const buildDeps = (
|
|
37
|
+
order: string[],
|
|
38
|
+
overrides: Partial<CommitDeviceSetAndResolveDeps> = {},
|
|
39
|
+
): CommitDeviceSetAndResolveDeps => ({
|
|
40
|
+
activate: false,
|
|
41
|
+
userId: 'u1',
|
|
42
|
+
fallbackUser: makeUser('fallback'),
|
|
43
|
+
registerAndActivate: jest.fn(async () => {
|
|
44
|
+
order.push('registerAndActivate');
|
|
45
|
+
}),
|
|
46
|
+
addCurrentAccount: jest.fn(async () => {
|
|
47
|
+
order.push('addCurrentAccount');
|
|
48
|
+
}),
|
|
49
|
+
startSocket: jest.fn(async () => {
|
|
50
|
+
order.push('startSocket');
|
|
51
|
+
}),
|
|
52
|
+
syncFromClient: jest.fn(async () => {
|
|
53
|
+
order.push('syncFromClient');
|
|
54
|
+
}),
|
|
55
|
+
getCurrentUser: jest.fn(async () => {
|
|
56
|
+
order.push('getCurrentUser');
|
|
57
|
+
return makeUser('u1');
|
|
58
|
+
}),
|
|
59
|
+
loginSuccess: jest.fn(() => {
|
|
60
|
+
order.push('loginSuccess');
|
|
61
|
+
}),
|
|
62
|
+
onAuthStateChange: jest.fn(() => {
|
|
63
|
+
order.push('onAuthStateChange');
|
|
64
|
+
}),
|
|
65
|
+
markAuthResolved: jest.fn(() => {
|
|
66
|
+
order.push('markAuthResolved');
|
|
67
|
+
}),
|
|
68
|
+
...overrides,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('commitDeviceSetAndResolve — cold boot (activate: false)', () => {
|
|
72
|
+
it('resolves auth from getCurrentUser BEFORE the device-set reconcile', async () => {
|
|
73
|
+
const order: string[] = [];
|
|
74
|
+
const deps = buildDeps(order);
|
|
75
|
+
|
|
76
|
+
await commitDeviceSetAndResolve(deps);
|
|
77
|
+
await flush();
|
|
78
|
+
|
|
79
|
+
// loginSuccess + onAuthStateChange + markAuthResolved fire after getCurrentUser.
|
|
80
|
+
expect(deps.loginSuccess).toHaveBeenCalledWith(makeUser('u1'));
|
|
81
|
+
expect(deps.onAuthStateChange).toHaveBeenCalledWith(makeUser('u1'));
|
|
82
|
+
expect(deps.markAuthResolved).toHaveBeenCalledTimes(1);
|
|
83
|
+
|
|
84
|
+
expect(order.indexOf('getCurrentUser')).toBeLessThan(order.indexOf('loginSuccess'));
|
|
85
|
+
expect(order.indexOf('loginSuccess')).toBeLessThan(order.indexOf('markAuthResolved'));
|
|
86
|
+
|
|
87
|
+
// The reconcile ran (via addCurrentAccount, NOT registerAndActivate) but only
|
|
88
|
+
// AFTER the auth gate flipped — proving it did not block the resolve.
|
|
89
|
+
expect(deps.addCurrentAccount).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(deps.registerAndActivate).not.toHaveBeenCalled();
|
|
91
|
+
expect(order.indexOf('markAuthResolved')).toBeLessThan(order.indexOf('addCurrentAccount'));
|
|
92
|
+
expect(order.indexOf('addCurrentAccount')).toBeLessThan(order.indexOf('startSocket'));
|
|
93
|
+
expect(order.indexOf('startSocket')).toBeLessThan(order.indexOf('syncFromClient'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('still resolves auth even when the device-set reconcile hangs forever', async () => {
|
|
97
|
+
const order: string[] = [];
|
|
98
|
+
const hang = deferred<void>(); // addCurrentAccount never settles
|
|
99
|
+
const deps = buildDeps(order, {
|
|
100
|
+
addCurrentAccount: jest.fn(() => {
|
|
101
|
+
order.push('addCurrentAccount');
|
|
102
|
+
return hang.promise;
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Must resolve without waiting on the hung reconcile.
|
|
107
|
+
await commitDeviceSetAndResolve(deps);
|
|
108
|
+
|
|
109
|
+
expect(deps.markAuthResolved).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(deps.loginSuccess).toHaveBeenCalledWith(makeUser('u1'));
|
|
111
|
+
expect(deps.addCurrentAccount).toHaveBeenCalledTimes(1);
|
|
112
|
+
// The hung step gates the rest of the chain — never reached.
|
|
113
|
+
expect(deps.startSocket).not.toHaveBeenCalled();
|
|
114
|
+
expect(deps.syncFromClient).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('falls back to the minimal commit-input user when the profile fetch fails', async () => {
|
|
118
|
+
const order: string[] = [];
|
|
119
|
+
const deps = buildDeps(order, {
|
|
120
|
+
getCurrentUser: jest.fn(async () => {
|
|
121
|
+
throw new Error('offline');
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await commitDeviceSetAndResolve(deps);
|
|
126
|
+
await flush();
|
|
127
|
+
|
|
128
|
+
expect(deps.loginSuccess).toHaveBeenCalledWith(makeUser('fallback'));
|
|
129
|
+
expect(deps.markAuthResolved).toHaveBeenCalledTimes(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('logs (never swallows) a failing background reconcile and still resolves', async () => {
|
|
133
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
134
|
+
const order: string[] = [];
|
|
135
|
+
const failure = new Error('device-set boom');
|
|
136
|
+
const deps = buildDeps(order, {
|
|
137
|
+
addCurrentAccount: jest.fn(async () => {
|
|
138
|
+
throw failure;
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await commitDeviceSetAndResolve(deps);
|
|
143
|
+
await flush();
|
|
144
|
+
|
|
145
|
+
expect(deps.markAuthResolved).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
147
|
+
'commitSession: device-set registration failed',
|
|
148
|
+
{ component: 'OxyContext', method: 'commitSession' },
|
|
149
|
+
failure,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('commitDeviceSetAndResolve — deliberate sign-in (activate: true)', () => {
|
|
155
|
+
it('BLOCKS on the device-set reconcile before resolving auth (unchanged ordering)', async () => {
|
|
156
|
+
const order: string[] = [];
|
|
157
|
+
const gate = deferred<void>(); // registerAndActivate stays pending
|
|
158
|
+
const deps = buildDeps(order, {
|
|
159
|
+
activate: true,
|
|
160
|
+
registerAndActivate: jest.fn(() => {
|
|
161
|
+
order.push('registerAndActivate');
|
|
162
|
+
return gate.promise;
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const settled = jest.fn();
|
|
167
|
+
const pending = commitDeviceSetAndResolve(deps).then(settled);
|
|
168
|
+
|
|
169
|
+
// While the reconcile is pending, auth must NOT resolve yet.
|
|
170
|
+
await flush();
|
|
171
|
+
expect(deps.registerAndActivate).toHaveBeenCalledTimes(1);
|
|
172
|
+
expect(deps.addCurrentAccount).not.toHaveBeenCalled();
|
|
173
|
+
expect(deps.getCurrentUser).not.toHaveBeenCalled();
|
|
174
|
+
expect(deps.markAuthResolved).not.toHaveBeenCalled();
|
|
175
|
+
expect(settled).not.toHaveBeenCalled();
|
|
176
|
+
|
|
177
|
+
// Complete the reconcile → hydrate + resolve now runs.
|
|
178
|
+
gate.resolve();
|
|
179
|
+
await pending;
|
|
180
|
+
|
|
181
|
+
expect(order).toEqual([
|
|
182
|
+
'registerAndActivate',
|
|
183
|
+
'startSocket',
|
|
184
|
+
'syncFromClient',
|
|
185
|
+
'getCurrentUser',
|
|
186
|
+
'loginSuccess',
|
|
187
|
+
'onAuthStateChange',
|
|
188
|
+
'markAuthResolved',
|
|
189
|
+
]);
|
|
190
|
+
expect(settled).toHaveBeenCalledTimes(1);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { User } from '@oxyhq/core';
|
|
2
|
+
import { logger as loggerUtil } from '@oxyhq/core';
|
|
3
|
+
|
|
4
|
+
const LOG_CONTEXT = { component: 'OxyContext', method: 'commitSession' } as const;
|
|
5
|
+
|
|
6
|
+
export interface CommitDeviceSetAndResolveDeps {
|
|
7
|
+
/**
|
|
8
|
+
* Whether this commit is a deliberate sign-in on THIS device (activates the
|
|
9
|
+
* account and BLOCKS on the device-set reconcile before resolving) vs. a
|
|
10
|
+
* cold-boot restore (resolves auth from the profile fetch FIRST, then
|
|
11
|
+
* reconciles the device set in the background).
|
|
12
|
+
*/
|
|
13
|
+
activate: boolean;
|
|
14
|
+
/** The committing account id (only used by `registerAndActivate`). */
|
|
15
|
+
userId?: string;
|
|
16
|
+
/** Minimal user carried by the commit input; used if the profile fetch fails. */
|
|
17
|
+
fallbackUser: User | null;
|
|
18
|
+
/** Deliberate sign-in: register + activate the account in the device set. */
|
|
19
|
+
registerAndActivate: (userId?: string) => Promise<void>;
|
|
20
|
+
/** Cold boot: ensure membership only; the server's `activeAccountId` wins. */
|
|
21
|
+
addCurrentAccount: () => Promise<void>;
|
|
22
|
+
/** Start the device-scoped socket. */
|
|
23
|
+
startSocket: () => Promise<void>;
|
|
24
|
+
/** Project the server-authoritative device state onto local sessions. */
|
|
25
|
+
syncFromClient: () => Promise<void>;
|
|
26
|
+
/** Hydrate the full profile (the commit input carries only minimal data). */
|
|
27
|
+
getCurrentUser: () => Promise<User | null>;
|
|
28
|
+
loginSuccess: (user: User) => void;
|
|
29
|
+
onAuthStateChange?: (user: User | null) => void;
|
|
30
|
+
markAuthResolved: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Device-set registration + full-user hydration + auth-resolution gate flip.
|
|
35
|
+
*
|
|
36
|
+
* On a deliberate sign-in (`activate: true`) the device-set reconcile is awaited
|
|
37
|
+
* BEFORE hydrating and resolving — unchanged legacy ordering. On a cold boot
|
|
38
|
+
* (`activate: false`) auth resolves from the profile fetch FIRST so the app can
|
|
39
|
+
* paint an authenticated shell without waiting on device-set registration, the
|
|
40
|
+
* socket connect, and the authoritative sessions projection — those reconcile in
|
|
41
|
+
* a DETACHED background task a moment later.
|
|
42
|
+
*/
|
|
43
|
+
export async function commitDeviceSetAndResolve(
|
|
44
|
+
deps: CommitDeviceSetAndResolveDeps,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const {
|
|
47
|
+
activate,
|
|
48
|
+
userId,
|
|
49
|
+
fallbackUser,
|
|
50
|
+
registerAndActivate,
|
|
51
|
+
addCurrentAccount,
|
|
52
|
+
startSocket,
|
|
53
|
+
syncFromClient,
|
|
54
|
+
getCurrentUser,
|
|
55
|
+
loginSuccess,
|
|
56
|
+
onAuthStateChange,
|
|
57
|
+
markAuthResolved,
|
|
58
|
+
} = deps;
|
|
59
|
+
|
|
60
|
+
// Register into the device set, start the socket, then project server truth. A
|
|
61
|
+
// deliberate sign-in ACTIVATES the account (`registerAndActivate`); the cold
|
|
62
|
+
// boot only ensures membership and lets the server's own `activeAccountId` win
|
|
63
|
+
// (`addCurrentAccount`).
|
|
64
|
+
const reconcileDeviceSet = async (): Promise<void> => {
|
|
65
|
+
if (activate) {
|
|
66
|
+
await registerAndActivate(userId);
|
|
67
|
+
} else {
|
|
68
|
+
await addCurrentAccount();
|
|
69
|
+
}
|
|
70
|
+
await startSocket();
|
|
71
|
+
await syncFromClient();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const logReconcileError = (registrationError: unknown): void => {
|
|
75
|
+
loggerUtil.warn('commitSession: device-set registration failed', LOG_CONTEXT, registrationError);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Hydrate the full user then flip the auth-resolution gate. Falls back to the
|
|
79
|
+
// minimal commit-input shape if the profile fetch fails — never leaves auth
|
|
80
|
+
// unresolved.
|
|
81
|
+
const hydrateAndResolve = async (): Promise<void> => {
|
|
82
|
+
let fullUser: User | null = null;
|
|
83
|
+
try {
|
|
84
|
+
fullUser = await getCurrentUser();
|
|
85
|
+
} catch (profileError) {
|
|
86
|
+
if (__DEV__) {
|
|
87
|
+
loggerUtil.debug('Failed to fetch full user on commit; using minimal fallback', LOG_CONTEXT, profileError);
|
|
88
|
+
}
|
|
89
|
+
fullUser = fallbackUser;
|
|
90
|
+
}
|
|
91
|
+
if (fullUser) {
|
|
92
|
+
loginSuccess(fullUser);
|
|
93
|
+
onAuthStateChange?.(fullUser);
|
|
94
|
+
}
|
|
95
|
+
markAuthResolved();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (activate) {
|
|
99
|
+
// Deliberate sign-in: block on the device-set reconcile (unchanged ordering)
|
|
100
|
+
// before hydrating + resolving, so the account is fully registered/active.
|
|
101
|
+
try {
|
|
102
|
+
await reconcileDeviceSet();
|
|
103
|
+
} catch (registrationError) {
|
|
104
|
+
logReconcileError(registrationError);
|
|
105
|
+
}
|
|
106
|
+
await hydrateAndResolve();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Cold boot: resolve auth from the profile fetch FIRST; reconcile the device
|
|
111
|
+
// set (membership + socket + sessions projection) in a DETACHED background
|
|
112
|
+
// task so first paint is not blocked behind those round-trips.
|
|
113
|
+
await hydrateAndResolve();
|
|
114
|
+
void reconcileDeviceSet().catch(logReconcileError);
|
|
115
|
+
}
|
|
@@ -140,9 +140,8 @@ export const useAuthOperations = ({
|
|
|
140
140
|
// internally; when the response also carries the zero-cookie device
|
|
141
141
|
// credential (`deviceId` + `deviceSecret`), persist the durable blob so the
|
|
142
142
|
// next cold boot re-mints an access token from it. Best-effort — a failed
|
|
143
|
-
// persist never fails the sign-in.
|
|
144
|
-
|
|
145
|
-
const deviceSecret = (sessionResponse as { deviceSecret?: string }).deviceSecret;
|
|
143
|
+
// persist never fails the sign-in.
|
|
144
|
+
const deviceSecret = sessionResponse.deviceSecret;
|
|
146
145
|
if (sessionResponse.deviceId && deviceSecret) {
|
|
147
146
|
try {
|
|
148
147
|
await store.save({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type { OxyServices, User, SessionLoginResponse, AccountNode, CreateAccountInput, ClientSession, AccountDialogController, AccountDialogView, ApiError } from '@oxyhq/core';
|
|
2
|
+
import type { OxyServices, User, SessionLoginResponse, AccountNode, CreateAccountInput, ClientSession, AccountDialogController, AccountDialogView, ApiError, SessionClient } from '@oxyhq/core';
|
|
3
3
|
import type { SecurityAlert } from '@oxyhq/contracts';
|
|
4
4
|
import type { UseFollowHook } from '../hooks/useFollow.types';
|
|
5
5
|
import type { useLanguageManagement } from '../hooks/useLanguageManagement';
|
|
@@ -76,6 +76,8 @@ export interface OxyContextState {
|
|
|
76
76
|
storageKeyPrefix: string;
|
|
77
77
|
clientId: string | null;
|
|
78
78
|
oxyServices: OxyServices;
|
|
79
|
+
/** Server-authoritative device session client. `null` before an `OxyProvider` is mounted. */
|
|
80
|
+
sessionClient: SessionClient | null;
|
|
79
81
|
useFollow?: UseFollowHook;
|
|
80
82
|
showBottomSheet?: (screenOrConfig: RouteName | { screen: RouteName; props?: Record<string, unknown> }) => void;
|
|
81
83
|
openAvatarPicker: () => void;
|