@oxyhq/services 10.3.3 → 10.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/lib/commonjs/ui/components/AnotherDeviceQR.js +121 -0
  2. package/lib/commonjs/ui/components/AnotherDeviceQR.js.map +1 -0
  3. package/lib/commonjs/ui/components/OxyProvider.js +2 -0
  4. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  5. package/lib/commonjs/ui/components/SignInModal.js +145 -356
  6. package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
  7. package/lib/commonjs/ui/context/OxyContext.js +49 -9
  8. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  9. package/lib/commonjs/ui/hooks/queries/queryKeys.js +5 -0
  10. package/lib/commonjs/ui/hooks/queries/queryKeys.js.map +1 -1
  11. package/lib/commonjs/ui/hooks/useFollow.js +28 -12
  12. package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
  13. package/lib/commonjs/ui/hooks/useOxyAuthSession.js +458 -0
  14. package/lib/commonjs/ui/hooks/useOxyAuthSession.js.map +1 -0
  15. package/lib/commonjs/ui/screens/OxyAuthScreen.js +62 -460
  16. package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
  17. package/lib/module/ui/components/AnotherDeviceQR.js +116 -0
  18. package/lib/module/ui/components/AnotherDeviceQR.js.map +1 -0
  19. package/lib/module/ui/components/OxyProvider.js +2 -0
  20. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  21. package/lib/module/ui/components/SignInModal.js +149 -358
  22. package/lib/module/ui/components/SignInModal.js.map +1 -1
  23. package/lib/module/ui/context/OxyContext.js +49 -9
  24. package/lib/module/ui/context/OxyContext.js.map +1 -1
  25. package/lib/module/ui/hooks/queries/queryKeys.js +5 -0
  26. package/lib/module/ui/hooks/queries/queryKeys.js.map +1 -1
  27. package/lib/module/ui/hooks/useFollow.js +29 -13
  28. package/lib/module/ui/hooks/useFollow.js.map +1 -1
  29. package/lib/module/ui/hooks/useOxyAuthSession.js +452 -0
  30. package/lib/module/ui/hooks/useOxyAuthSession.js.map +1 -0
  31. package/lib/module/ui/screens/OxyAuthScreen.js +62 -460
  32. package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
  33. package/lib/typescript/commonjs/ui/components/AnotherDeviceQR.d.ts +24 -0
  34. package/lib/typescript/commonjs/ui/components/AnotherDeviceQR.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/ui/components/SignInModal.d.ts +10 -5
  37. package/lib/typescript/commonjs/ui/components/SignInModal.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +10 -0
  39. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts +4 -0
  41. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts +83 -0
  44. package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts.map +1 -0
  45. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts +12 -8
  46. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/ui/types/navigation.d.ts +17 -0
  48. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/components/AnotherDeviceQR.d.ts +24 -0
  50. package/lib/typescript/module/ui/components/AnotherDeviceQR.d.ts.map +1 -0
  51. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  52. package/lib/typescript/module/ui/components/SignInModal.d.ts +10 -5
  53. package/lib/typescript/module/ui/components/SignInModal.d.ts.map +1 -1
  54. package/lib/typescript/module/ui/context/OxyContext.d.ts +10 -0
  55. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  56. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts +4 -0
  57. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  58. package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
  59. package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts +83 -0
  60. package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts.map +1 -0
  61. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts +12 -8
  62. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  63. package/lib/typescript/module/ui/types/navigation.d.ts +17 -0
  64. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  65. package/package.json +2 -2
  66. package/src/ui/components/AnotherDeviceQR.tsx +111 -0
  67. package/src/ui/components/OxyProvider.tsx +2 -0
  68. package/src/ui/components/SignInModal.tsx +142 -381
  69. package/src/ui/context/OxyContext.tsx +59 -8
  70. package/src/ui/hooks/queries/queryKeys.ts +6 -0
  71. package/src/ui/hooks/useFollow.ts +30 -12
  72. package/src/ui/hooks/useOxyAuthSession.ts +556 -0
  73. package/src/ui/screens/OxyAuthScreen.tsx +61 -506
  74. package/src/ui/types/navigation.ts +17 -0
@@ -161,6 +161,16 @@ export interface OxyContextProviderProps {
161
161
  * for the cross-app device sign-in flow. See {@link OxyContextState.clientId}.
162
162
  */
163
163
  clientId?: string;
164
+ /**
165
+ * When `true`, skips ONLY the terminal `sso-bounce` cold-boot step — the
166
+ * force-redirect to `auth.<apex>/sso?prompt=none` that fires for a visitor
167
+ * with no recoverable local session. Every other cold-boot step still runs
168
+ * (callback consume, FedCM silent, `/auth/silent` iframe, stored-session,
169
+ * cookie-restore), so a returning signed-in user is still silently
170
+ * restored; only the bounce for a truly anonymous visitor is suppressed.
171
+ * Default `false`.
172
+ */
173
+ disableAutoSso?: boolean;
164
174
  onAuthStateChange?: (user: User | null) => void;
165
175
  onError?: (error: ApiError) => void;
166
176
  }
@@ -254,6 +264,39 @@ const COOKIE_RESTORE_TIMEOUT = 3000;
254
264
  */
255
265
  const COLD_BOOT_OVERALL_DEADLINE = 20000;
256
266
 
267
+ /**
268
+ * Per-session timeout (ms) for the parallel stored-session validation in
269
+ * `restoreStoredSession`. Each `validateSession` call races against this timer
270
+ * so a single slow/offline session never blocks the whole startup validation
271
+ * sweep — sessions that don't answer in time resolve to `null` (treated as
272
+ * unvalidated) and the remaining sessions still settle.
273
+ */
274
+ const VALIDATION_TIMEOUT = 8000;
275
+
276
+ /**
277
+ * Fallback client-session validity window (ms) — 7 days — applied when a
278
+ * restored account/session does not carry an explicit `expiresAt`. This is only
279
+ * a local display/bookkeeping hint for the multi-session store; the server
280
+ * remains the source of truth for actual session expiry. Used in the refresh
281
+ * cookie restore, stored-session restore, and web-SSO session paths.
282
+ */
283
+ const DEFAULT_SESSION_VALIDITY_MS = 7 * 24 * 60 * 60 * 1000;
284
+
285
+ /**
286
+ * Minimum interval (ms) between visibility-driven IdP `/auth/session-check`
287
+ * probes. Debounces the hidden-iframe session check so rapid tab focus/blur
288
+ * cycles can't spawn a check-iframe storm; at most one probe runs per window.
289
+ */
290
+ const IDP_SESSION_CHECK_COOLDOWN = 30000;
291
+
292
+ /**
293
+ * Hard timeout (ms) for a single visibility-driven IdP `/auth/session-check`
294
+ * iframe. If the IdP never posts a `oxy-session-check` message back, the iframe
295
+ * and its listener are torn down after this budget so a non-responsive check
296
+ * can never leak an iframe or a `message` listener.
297
+ */
298
+ const IDP_SESSION_CHECK_TIMEOUT = 5000;
299
+
257
300
  function getHttpStatus(error: unknown): number | undefined {
258
301
  if (!error || typeof error !== 'object') {
259
302
  return undefined;
@@ -367,6 +410,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
367
410
  authRedirectUri,
368
411
  storageKeyPrefix = 'oxy_session',
369
412
  clientId: clientIdProp,
413
+ disableAutoSso = false,
370
414
  onAuthStateChange,
371
415
  onError,
372
416
  }) => {
@@ -849,7 +893,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
849
893
  const clientSessions: ClientSession[] = snapshot.accounts.map((account) => ({
850
894
  sessionId: account.sessionId,
851
895
  deviceId: '',
852
- expiresAt: account.expiresAt || new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
896
+ expiresAt: account.expiresAt || new Date(now.getTime() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
853
897
  lastActive: now.toISOString(),
854
898
  userId: account.user?.id,
855
899
  isCurrent: account.sessionId === activeAccount.sessionId,
@@ -906,9 +950,8 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
906
950
  let validSessions: ClientSession[] = [];
907
951
 
908
952
  if (storedSessionIds.length > 0) {
909
- // Validate all sessions in parallel (with 8s timeout per session) to avoid
953
+ // Validate all sessions in parallel (with a per-session timeout) to avoid
910
954
  // sequential blocking that freezes the app on startup
911
- const VALIDATION_TIMEOUT = 8000;
912
955
  const results = await Promise.allSettled(
913
956
  storedSessionIds.map(async (sessionId) => {
914
957
  const timeoutPromise = new Promise<null>((resolve) =>
@@ -931,7 +974,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
931
974
  const clientSession: ClientSession = {
932
975
  sessionId,
933
976
  deviceId: '',
934
- expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
977
+ expiresAt: new Date(now.getTime() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
935
978
  lastActive: now.toISOString(),
936
979
  userId: validation.user.id?.toString() ?? '',
937
980
  isCurrent: sessionId === storedActiveSessionId,
@@ -1241,6 +1284,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1241
1284
  // still active (loop + self-heal protection).
1242
1285
  id: 'sso-bounce',
1243
1286
  enabled: () => {
1287
+ // Opt-out: when the consumer disabled auto-SSO, never bounce a
1288
+ // truly-anonymous visitor to the central IdP. All other restore
1289
+ // steps already ran above, so a signed-in user is still
1290
+ // recovered; only this terminal force-bounce is suppressed.
1291
+ if (disableAutoSso) {
1292
+ return false;
1293
+ }
1244
1294
  if (!isWebBrowser() || window.top !== window.self) {
1245
1295
  return false;
1246
1296
  }
@@ -1335,6 +1385,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1335
1385
  restoreStoredSession,
1336
1386
  runSsoReturn,
1337
1387
  markAuthResolved,
1388
+ disableAutoSso,
1338
1389
  ]);
1339
1390
 
1340
1391
  useEffect(() => {
@@ -1456,7 +1507,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1456
1507
  const clientSession = {
1457
1508
  sessionId: session.sessionId,
1458
1509
  deviceId: session.deviceId || '',
1459
- expiresAt: session.expiresAt || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
1510
+ expiresAt: session.expiresAt || new Date(Date.now() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
1460
1511
  lastActive: new Date().toISOString(),
1461
1512
  userId: session.user.id?.toString() ?? '',
1462
1513
  isCurrent: true,
@@ -1550,9 +1601,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1550
1601
  const idpOrigin = resolvedAuthWebUrl || 'https://auth.oxy.so';
1551
1602
 
1552
1603
  const checkIdPSession = () => {
1553
- // Debounce: check at most once per 30 seconds
1604
+ // Debounce: check at most once per cooldown window
1554
1605
  const now = Date.now();
1555
- if (now - lastIdPCheckRef.current < 30000) return;
1606
+ if (now - lastIdPCheckRef.current < IDP_SESSION_CHECK_COOLDOWN) return;
1556
1607
  lastIdPCheckRef.current = now;
1557
1608
 
1558
1609
  // Clean up any in-flight check before starting a new one
@@ -1591,7 +1642,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1591
1642
 
1592
1643
  window.addEventListener('message', handleMessage);
1593
1644
  document.body.appendChild(iframe);
1594
- setTimeout(cleanup, 5000); // Timeout after 5s
1645
+ setTimeout(cleanup, IDP_SESSION_CHECK_TIMEOUT);
1595
1646
  pendingIdPCleanupRef.current = cleanup;
1596
1647
  };
1597
1648
 
@@ -80,6 +80,12 @@ export const queryKeys = {
80
80
  list: () => [...queryKeys.connectedApps.all, 'list'] as const,
81
81
  },
82
82
 
83
+ // Follow / social graph queries
84
+ follow: {
85
+ all: ['follow'] as const,
86
+ counts: (userId: string) => [...queryKeys.follow.all, 'counts', userId] as const,
87
+ },
88
+
83
89
  // Payment / wallet / subscription queries
84
90
  payments: {
85
91
  all: ['payments'] as const,
@@ -1,8 +1,10 @@
1
- import { useCallback, useMemo, useEffect } from 'react';
1
+ import { useCallback, useMemo } from 'react';
2
+ import { useQuery } from '@tanstack/react-query';
2
3
  import { useFollowStore } from '../stores/followStore';
3
4
  import { useOxy } from '../context/OxyContext';
4
- import { logger as loggerUtil, type OxyServices, type BulkFollowResult, type BulkUnfollowResult } from '@oxyhq/core';
5
+ import { type OxyServices, type BulkFollowResult, type BulkUnfollowResult } from '@oxyhq/core';
5
6
  import { useShallow } from 'zustand/react/shallow';
7
+ import { queryKeys } from './queries/queryKeys';
6
8
 
7
9
  /**
8
10
  * useFollow — Hook for follow state management.
@@ -21,6 +23,9 @@ export const useFollow = (userId?: string | string[]) => {
21
23
  const { oxyServices, canUsePrivateApi } = useOxy();
22
24
  const userIds = useMemo(() => (Array.isArray(userId) ? userId : userId ? [userId] : []), [userId]);
23
25
  const isSingleUser = typeof userId === 'string';
26
+ // Narrowed single-user id for use in closures (callbacks/queryFn) where TS
27
+ // can't carry the `isSingleUser` boolean back to a `string` narrowing.
28
+ const singleUserId: string | undefined = typeof userId === 'string' ? userId : undefined;
24
29
 
25
30
  // Granular Zustand selectors — only re-render when THIS user's data changes
26
31
  const isFollowing = useFollowStore(
@@ -128,16 +133,29 @@ export const useFollow = (userId?: string | string[]) => {
128
133
  useFollowStore.getState().setFollowingCount(userId, count);
129
134
  }, [isSingleUser, userId]);
130
135
 
131
- // Auto-fetch counts when hook is used for a single user and counts are missing.
132
- useEffect(() => {
133
- if (!isSingleUser || !userId) return;
134
-
135
- if ((followerCount === null || followingCount === null) && !isLoadingCounts) {
136
- fetchUserCounts().catch((error: unknown) => {
137
- loggerUtil.warn('useFollow: fetchUserCounts failed', { component: 'useFollow' }, error);
138
- });
139
- }
140
- }, [isSingleUser, userId, followerCount, followingCount, isLoadingCounts, fetchUserCounts]);
136
+ // Auto-fetch counts for single-user mode via React Query instead of a manual
137
+ // useEffect. The Zustand store remains the canonical home for count values
138
+ // (it is also written by toggleFollowUser / updateCountsFromFollowAction), so
139
+ // components keep reading `followerCount` / `followingCount` through the
140
+ // granular selectors above this query only owns the FETCH lifecycle
141
+ // (dedup, caching, retry/backoff). It stays disabled until the counts are
142
+ // actually missing, reproducing the old effect's `(followerCount === null ||
143
+ // followingCount === null)` gate without a render-phase side effect. The
144
+ // store action does the network call and writes the counts; the query simply
145
+ // surfaces the resolved pair as its cached data.
146
+ useQuery({
147
+ queryKey: singleUserId ? queryKeys.follow.counts(singleUserId) : queryKeys.follow.all,
148
+ queryFn: async () => {
149
+ if (!singleUserId) return null;
150
+ await useFollowStore.getState().fetchUserCounts(singleUserId, oxyServices);
151
+ const state = useFollowStore.getState();
152
+ return {
153
+ followers: state.followerCounts[singleUserId] ?? null,
154
+ following: state.followingCounts[singleUserId] ?? null,
155
+ };
156
+ },
157
+ enabled: !!singleUserId && followerCount === null && followingCount === null,
158
+ });
141
159
 
142
160
  // Multi-user callbacks
143
161
  const toggleFollowForUser = useCallback(async (targetUserId: string) => {