@oxy.so/services 4.0.0 → 4.0.2

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 (122) hide show
  1. package/lib/commonjs/ui/components/OxyAccountDialogScreen.js +10 -16
  2. package/lib/commonjs/ui/components/OxyAccountDialogScreen.js.map +1 -1
  3. package/lib/commonjs/ui/components/OxyAuthChooser.js +8 -2
  4. package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
  5. package/lib/commonjs/ui/components/OxyConsentScreen.js +2 -1
  6. package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -1
  7. package/lib/commonjs/ui/components/OxySignInRequestSurface.js +1 -0
  8. package/lib/commonjs/ui/components/OxySignInRequestSurface.js.map +1 -1
  9. package/lib/commonjs/ui/components/ProfileButton.js +4 -3
  10. package/lib/commonjs/ui/components/ProfileButton.js.map +1 -1
  11. package/lib/commonjs/ui/components/authChooser/SignInEntryView.js +5 -1
  12. package/lib/commonjs/ui/components/authChooser/SignInEntryView.js.map +1 -1
  13. package/lib/commonjs/ui/components/authChooser/SignUpView.js +2 -0
  14. package/lib/commonjs/ui/components/authChooser/SignUpView.js.map +1 -1
  15. package/lib/commonjs/ui/components/authChooser/TroubleDisclosure.js +2 -1
  16. package/lib/commonjs/ui/components/authChooser/TroubleDisclosure.js.map +1 -1
  17. package/lib/commonjs/ui/components/authChooser/requestSurfaces.js +1 -0
  18. package/lib/commonjs/ui/components/authChooser/requestSurfaces.js.map +1 -1
  19. package/lib/commonjs/ui/components/authChooser/styles.js +13 -3
  20. package/lib/commonjs/ui/components/authChooser/styles.js.map +1 -1
  21. package/lib/commonjs/ui/hooks/accountDialogSnapshot.js +1 -0
  22. package/lib/commonjs/ui/hooks/accountDialogSnapshot.js.map +1 -1
  23. package/lib/commonjs/ui/icons/Ionicons.js +3 -1
  24. package/lib/commonjs/ui/icons/Ionicons.js.map +1 -1
  25. package/lib/commonjs/ui/icons/MaterialCommunityIcons.js +3 -1
  26. package/lib/commonjs/ui/icons/MaterialCommunityIcons.js.map +1 -1
  27. package/lib/commonjs/ui/icons/decorativeIconSet.js +50 -0
  28. package/lib/commonjs/ui/icons/decorativeIconSet.js.map +1 -0
  29. package/lib/commonjs/ui/screens/ManageAccountScreen.js +10 -7
  30. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  31. package/lib/commonjs/ui/utils/deviceSessionTitle.js +30 -0
  32. package/lib/commonjs/ui/utils/deviceSessionTitle.js.map +1 -0
  33. package/lib/module/ui/components/OxyAccountDialogScreen.js +10 -16
  34. package/lib/module/ui/components/OxyAccountDialogScreen.js.map +1 -1
  35. package/lib/module/ui/components/OxyAuthChooser.js +8 -2
  36. package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
  37. package/lib/module/ui/components/OxyConsentScreen.js +2 -1
  38. package/lib/module/ui/components/OxyConsentScreen.js.map +1 -1
  39. package/lib/module/ui/components/OxySignInRequestSurface.js +1 -0
  40. package/lib/module/ui/components/OxySignInRequestSurface.js.map +1 -1
  41. package/lib/module/ui/components/ProfileButton.js +4 -3
  42. package/lib/module/ui/components/ProfileButton.js.map +1 -1
  43. package/lib/module/ui/components/authChooser/SignInEntryView.js +5 -1
  44. package/lib/module/ui/components/authChooser/SignInEntryView.js.map +1 -1
  45. package/lib/module/ui/components/authChooser/SignUpView.js +2 -0
  46. package/lib/module/ui/components/authChooser/SignUpView.js.map +1 -1
  47. package/lib/module/ui/components/authChooser/TroubleDisclosure.js +2 -1
  48. package/lib/module/ui/components/authChooser/TroubleDisclosure.js.map +1 -1
  49. package/lib/module/ui/components/authChooser/requestSurfaces.js +1 -0
  50. package/lib/module/ui/components/authChooser/requestSurfaces.js.map +1 -1
  51. package/lib/module/ui/components/authChooser/styles.js +13 -3
  52. package/lib/module/ui/components/authChooser/styles.js.map +1 -1
  53. package/lib/module/ui/hooks/accountDialogSnapshot.js +1 -0
  54. package/lib/module/ui/hooks/accountDialogSnapshot.js.map +1 -1
  55. package/lib/module/ui/icons/Ionicons.js +4 -1
  56. package/lib/module/ui/icons/Ionicons.js.map +1 -1
  57. package/lib/module/ui/icons/MaterialCommunityIcons.js +4 -1
  58. package/lib/module/ui/icons/MaterialCommunityIcons.js.map +1 -1
  59. package/lib/module/ui/icons/decorativeIconSet.js +47 -0
  60. package/lib/module/ui/icons/decorativeIconSet.js.map +1 -0
  61. package/lib/module/ui/screens/ManageAccountScreen.js +10 -7
  62. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  63. package/lib/module/ui/utils/deviceSessionTitle.js +26 -0
  64. package/lib/module/ui/utils/deviceSessionTitle.js.map +1 -0
  65. package/lib/typescript/commonjs/ui/components/OxyAccountDialogScreen.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +10 -1
  68. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/ui/components/authChooser/SignInEntryView.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/ui/components/authChooser/TroubleDisclosure.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/ui/components/authChooser/styles.d.ts +12 -2
  73. package/lib/typescript/commonjs/ui/components/authChooser/styles.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/ui/hooks/accountDialogSnapshot.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts +2 -1
  76. package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +2 -1
  78. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/ui/icons/decorativeIconSet.d.ts +26 -0
  80. package/lib/typescript/commonjs/ui/icons/decorativeIconSet.d.ts.map +1 -0
  81. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/ui/utils/deviceSessionTitle.d.ts +18 -0
  83. package/lib/typescript/commonjs/ui/utils/deviceSessionTitle.d.ts.map +1 -0
  84. package/lib/typescript/module/ui/components/OxyAccountDialogScreen.d.ts.map +1 -1
  85. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
  86. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +10 -1
  87. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -1
  88. package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -1
  89. package/lib/typescript/module/ui/components/authChooser/SignInEntryView.d.ts.map +1 -1
  90. package/lib/typescript/module/ui/components/authChooser/TroubleDisclosure.d.ts.map +1 -1
  91. package/lib/typescript/module/ui/components/authChooser/styles.d.ts +12 -2
  92. package/lib/typescript/module/ui/components/authChooser/styles.d.ts.map +1 -1
  93. package/lib/typescript/module/ui/hooks/accountDialogSnapshot.d.ts.map +1 -1
  94. package/lib/typescript/module/ui/icons/Ionicons.d.ts +2 -1
  95. package/lib/typescript/module/ui/icons/Ionicons.d.ts.map +1 -1
  96. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +2 -1
  97. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
  98. package/lib/typescript/module/ui/icons/decorativeIconSet.d.ts +26 -0
  99. package/lib/typescript/module/ui/icons/decorativeIconSet.d.ts.map +1 -0
  100. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  101. package/lib/typescript/module/ui/utils/deviceSessionTitle.d.ts +18 -0
  102. package/lib/typescript/module/ui/utils/deviceSessionTitle.d.ts.map +1 -0
  103. package/package.json +6 -6
  104. package/src/ui/components/OxyAccountDialogScreen.tsx +10 -16
  105. package/src/ui/components/OxyAuthChooser.tsx +8 -2
  106. package/src/ui/components/OxyConsentScreen.tsx +11 -1
  107. package/src/ui/components/OxySignInRequestSurface.tsx +1 -1
  108. package/src/ui/components/ProfileButton.tsx +4 -3
  109. package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +16 -0
  110. package/src/ui/components/authChooser/SignInEntryView.tsx +3 -0
  111. package/src/ui/components/authChooser/SignUpView.tsx +2 -2
  112. package/src/ui/components/authChooser/TroubleDisclosure.tsx +5 -2
  113. package/src/ui/components/authChooser/requestSurfaces.tsx +1 -1
  114. package/src/ui/components/authChooser/styles.ts +12 -2
  115. package/src/ui/hooks/accountDialogSnapshot.ts +1 -0
  116. package/src/ui/icons/Ionicons.ts +9 -4
  117. package/src/ui/icons/MaterialCommunityIcons.ts +9 -4
  118. package/src/ui/icons/__tests__/decorativeIconSet.test.tsx +40 -0
  119. package/src/ui/icons/decorativeIconSet.ts +41 -0
  120. package/src/ui/screens/ManageAccountScreen.tsx +13 -18
  121. package/src/ui/utils/__tests__/deviceSessionTitle.test.ts +31 -0
  122. package/src/ui/utils/deviceSessionTitle.ts +29 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decorativeIconSet.d.ts","sourceRoot":"","sources":["../../../../../src/ui/icons/decorativeIconSet.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsC,KAAK,aAAa,EAAE,MAAM,OAAO,CAAC;AAK/E;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,aAAa,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,WAAW,EAAE,MAAM,GAAG,CAAC,CAWlG"}
@@ -1 +1 @@
1
- {"version":3,"file":"ManageAccountScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/screens/ManageAccountScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAuB/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAqB,CAAC;AAsD3D;;;;;;;GAOG;AACH,QAAA,MAAM,mBAAmB,EAAE,KAAK,CAAC,EAAE,CAAC,eAAe,CAy1BlD,CAAC;AAYF,eAAe,mBAAmB,CAAC"}
1
+ {"version":3,"file":"ManageAccountScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/screens/ManageAccountScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAwB/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAqB,CAAC;AA+C3D;;;;;;;GAOG;AACH,QAAA,MAAM,mBAAmB,EAAE,KAAK,CAAC,EAAE,CAAC,eAAe,CA01BlD,CAAC;AAYF,eAAe,mBAAmB,CAAC"}
@@ -0,0 +1,18 @@
1
+ import { type DeviceLinkedSession } from '@oxy.so/core';
2
+ type Translate = (key: string, vars?: Record<string, string | number>) => string;
3
+ /**
4
+ * The title of one row in "Sessions & devices".
5
+ *
6
+ * `GET /session/device/sessions/:sessionId` lists the sessions that share THIS
7
+ * physical device — one per account signed in here — and sends no
8
+ * `deviceName` at all, although the model declared one. The row interpolated
9
+ * it unconditionally and Android QA read "undefined (This device)"
10
+ * (OxyHQ/oxy#1375 item 11).
11
+ *
12
+ * A name the server does send is used; otherwise the current session is "This
13
+ * device" and any other is named by the account it belongs to, which the
14
+ * response does carry. Never the string "undefined".
15
+ */
16
+ export declare function deviceSessionTitle(session: Pick<DeviceLinkedSession, 'deviceName' | 'isCurrent' | 'user'>, t: Translate): string;
17
+ export {};
18
+ //# sourceMappingURL=deviceSessionTitle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deviceSessionTitle.d.ts","sourceRoot":"","sources":["../../../../../src/ui/utils/deviceSessionTitle.ts"],"names":[],"mappings":"AAAA,OAAO,EAA2B,KAAK,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEjF,KAAK,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,KAAK,MAAM,CAAC;AAEjF;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,IAAI,CAAC,mBAAmB,EAAE,YAAY,GAAG,WAAW,GAAG,MAAM,CAAC,EACvE,CAAC,EAAE,SAAS,GACX,MAAM,CAQR"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/services",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
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",
@@ -155,7 +155,7 @@
155
155
  },
156
156
  "dependencies": {
157
157
  "@expo/config-plugins": "57.0.9",
158
- "@oxy.so/contracts": "1.3.0",
158
+ "@oxy.so/contracts": "1.4.0",
159
159
  "@simplewebauthn/browser": "^13.3.0",
160
160
  "color": "^4.2.3"
161
161
  },
@@ -164,7 +164,7 @@
164
164
  "@commitlint/cli": "^21.2.1",
165
165
  "@commitlint/config-conventional": "^21.2.0",
166
166
  "@expo/vector-icons": "^15.0.3",
167
- "@oxy.so/core": "1.7.2",
167
+ "@oxy.so/core": "1.9.0",
168
168
  "@react-native-async-storage/async-storage": "^2.0.0",
169
169
  "@react-native-community/netinfo": "^12.0.1",
170
170
  "@react-native/babel-preset": "^0.86.2",
@@ -179,7 +179,7 @@
179
179
  "@types/jest": "^30.0.0",
180
180
  "@types/node": "^20.19.43",
181
181
  "@types/react": "*",
182
- "@oxy.so/bloom": "^3.2.1",
182
+ "@oxy.so/bloom": "^4.21.1",
183
183
  "copyfiles": "^2.4.1",
184
184
  "expo-constants": "~57.0.5",
185
185
  "expo-file-system": "~57.0.1",
@@ -215,8 +215,8 @@
215
215
  "//@oxy.so/bloom": "RE-RAISE THIS FLOOR ON EVERY BREAKING BLOOM RELEASE, in the same commit as the root package.json catalog entry and the code that requires it. The breaking position is the MAJOR, so `^3.0.1` means `>=3.0.1 <4.0.0`: 3.x minors are admitted on semver's additive promise, and Bloom 4.0.0 is refused until someone raises this deliberately. The CAP is the part that is load-bearing. An earlier revision of this key recorded the opposite trade -- an uncapped `>=` floor, accepted so that intentionally-breaking Bloom 0.x minors would not be blocked -- and that trade is closed by evidence rather than by preference: an unbounded range is exactly what let `bun install` pair this package with Bloom 1.0.0 and emit no warning at all, and 1.0.0 broke it four ways (a deleted provider, a renamed confirm option, a renamed Chip fill, and two REMOVED SUBPATHS that fail at bundle time, before any typecheck runs). A floor is a claim about what was measured; a cap is the claim that nothing beyond it was. RAISED TO 3.0.1 from `^2.1.0`. Bloom 3.0.0 breaks exactly two things -- `PageHeader` defaults to `presentation=\"floating\"` and stops hiding the title under `transparent`, and `Field disabled` now reaches the control that reads the field context -- and THIS PACKAGE IMPORTS NEITHER: it renders no `PageHeader` and no `Field`, and 3.0 removes no subpath (unlike 2.0.0, which is why that floor cost code changes and this one does not). Its six `transparent` occurrences are CSS colour values, not the PageHeader prop. The cap had to move rather than widen to a union, because leaving `^2.1.0` in place is what made `bun install` resolve a SECOND, nested Bloom 2.12.4 under this package for any app on Bloom 3 -- two copies of a library whose 3.0 contracts (`control-surface`, `TopEdgeProvider`, `ScrollOffsetProvider`) are React contexts, so a provider from one copy cannot be read by a consumer from the other. Mention's `oxyRuntimeCompatibility` test is what caught it; nothing in a typecheck can. RAISED AGAIN TO 3.2.0 from `^3.0.1`, for the same class of reason the 2.x note records, only in the other direction: 3.2.0 ADDS the per-glyph icon subpaths (`@oxy.so/bloom/icons/RiAtLine`) that this package's screens now import instead of the `@oxy.so/bloom/icons` barrel, and a missing subpath fails at bundle time, in the consumer's Metro, long after this package has typechecked and shipped. `^3.0.1` would have admitted Bloom 3.0.1 and 3.1.0, neither of which has those subpaths, so the floor is the only thing standing between a consumer and a red build. Nothing else about 3.1.0 or 3.2.0 is required here; the cap stays at the major. RAISED TO ^4.2.0: FollowButton now delegates its visuals to Bloom media-header FollowButton with loading, textStyle and large size; the new shared UI contracts require one Bloom4 context across Services and its consumer.",
216
216
  "peerDependencies": {
217
217
  "@expo/vector-icons": "^15.0.3",
218
- "@oxy.so/bloom": "^4.2.0",
219
- "@oxy.so/core": "^1.7.3",
218
+ "@oxy.so/bloom": "^4.21.1",
219
+ "@oxy.so/core": "^1.9.0",
220
220
  "@react-native-async-storage/async-storage": "^2.0.0",
221
221
  "@react-native-community/netinfo": ">=11.4.1",
222
222
  "@tanstack/query-async-storage-persister": "^5.101",
@@ -74,22 +74,16 @@ const OxyAccountDialogScreen: React.FC<BaseScreenProps> = ({ canGoBack }) => {
74
74
  const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
75
75
 
76
76
  const { view } = snapshot;
77
- // "Add another account" only makes sense as a back destination when there IS
78
- // another — i.e. somebody is already signed in on this device.
77
+ // Names the entry view "Add another account" rather than "Sign in" when
78
+ // somebody is already signed in on this device.
79
79
  const hasSignedInAccounts = (snapshot.directory?.principals.length ?? 0) > 0;
80
- const showBack = view === 'qr' || view === 'signup' || (view === 'add' && hasSignedInAccounts);
81
- const goToAccounts = useCallback(() => {
82
- if (!controller) return;
83
- const { view: currentView, signIn } = controller.getSnapshot();
84
- // Backing out of an active request must withdraw it — otherwise the
85
- // authorizeCode stays approvable until expiry (issue #691 phase 5).
86
- if (
87
- (currentView === 'qr' || currentView === 'add') &&
88
- (signIn.phase === 'starting' || signIn.phase === 'waiting')
89
- ) {
90
- controller.cancelSignIn();
91
- }
92
- controller.setView('accounts');
80
+ // Where back leads is the CONTROLLER's answer (`snapshot.backView`), never a
81
+ // table here: a host-side "back = accounts" assumed a signed-in origin and
82
+ // opened the account menu for nobody on a signed-out Back from sign-up.
83
+ const showBack = snapshot.backView !== null;
84
+ const goBack = useCallback(() => {
85
+ // Withdraws an active request itself before leaving it.
86
+ controller?.back();
93
87
  }, [controller]);
94
88
  // An ENTRY view (accounts / signin — no in-dialog back of its own) that is
95
89
  // MORPHED into a host surface (ManageAccount → switcher) has a frame beneath it,
@@ -109,7 +103,7 @@ const OxyAccountDialogScreen: React.FC<BaseScreenProps> = ({ canGoBack }) => {
109
103
  titleContent: copy ? undefined : NAV_LOGO,
110
104
  title: copy?.title,
111
105
  subtitle: copy?.subtitle ?? undefined,
112
- onBack: showBack ? goToAccounts : backsToHost ? closeAccountDialog : undefined,
106
+ onBack: showBack ? goBack : backsToHost ? closeAccountDialog : undefined,
113
107
  });
114
108
 
115
109
  if (!controller) {
@@ -370,7 +370,13 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
370
370
  // Real storage usage for the account menu's "Oxy storage" block. Disabled
371
371
  // (no fetch) until a private-API session exists, so it is inert on the
372
372
  // sign-in/request/sign-up views; when present the block shows live used/total.
373
- const storageQuery = useAccountStorageUsage({ enabled: view === 'accounts' });
373
+ // The account MENU describes the signed-in account — its hero, its storage,
374
+ // its sign-out. The controller already refuses `accounts` without a session;
375
+ // this is the render-side half of the same rule, so no controller state (or
376
+ // an older core) can ever put "Sign out" in front of someone who is not
377
+ // signed in. Without an account the sign-in entry renders in its place.
378
+ const showsAccountMenu = view === 'accounts' && user !== null && user !== undefined;
379
+ const storageQuery = useAccountStorageUsage({ enabled: showsAccountMenu });
374
380
  const storage = useMemo<AccountStorageModel | null>(
375
381
  () =>
376
382
  storageQuery.data
@@ -419,7 +425,7 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
419
425
  return null;
420
426
  }
421
427
 
422
- if (view === 'accounts') {
428
+ if (showsAccountMenu) {
423
429
  return (
424
430
  <AccountsMenuView
425
431
  snapshot={snapshot}
@@ -86,6 +86,15 @@ export interface OxyConsentScreenProps {
86
86
  onDeny: () => void;
87
87
  /** True while a decision is in flight; disables both actions and spins Allow. */
88
88
  busy?: boolean;
89
+ /**
90
+ * Disables Allow WITHOUT a spinner, leaving Deny live. For a host that owns an
91
+ * extra gate the person must clear first — e.g. the device approval page's
92
+ * mandatory "I started this sign-in myself" acknowledgement, which is the
93
+ * login-CSRF control for a request that arrived as a bare code and cannot be
94
+ * skipped. `busy` would be wrong there: nothing is in flight, and a spinning
95
+ * button reads as "working" rather than "not yet".
96
+ */
97
+ allowDisabled?: boolean;
89
98
  /** A blocking error message for the request, when present. */
90
99
  error?: string | null;
91
100
  }
@@ -213,6 +222,7 @@ export function OxyConsentScreen({
213
222
  onAllow,
214
223
  onDeny,
215
224
  busy = false,
225
+ allowDisabled = false,
216
226
  error = null,
217
227
  }: OxyConsentScreenProps) {
218
228
  const theme = useTheme();
@@ -431,7 +441,7 @@ export function OxyConsentScreen({
431
441
  testID="consent-allow"
432
442
  appearance="solid" tone="accent"
433
443
  onPress={handleAllow}
434
- disabled={busy}
444
+ disabled={busy || allowDisabled}
435
445
  loading={busy}
436
446
  style={styles.actionButton}
437
447
  >
@@ -150,7 +150,7 @@ export const OxySignInRequestSurface: React.FC<OxySignInRequestSurfaceProps> = (
150
150
  return (
151
151
  <View style={styles.centeredBlock}>
152
152
  {onRetry ? (
153
- <Button appearance="solid" tone="accent" onPress={onRetry} style={styles.primaryButton}>
153
+ <Button appearance="solid" tone="accent" size="lg" onPress={onRetry} style={styles.primaryButton}>
154
154
  {t('common.actions.tryAgain')}
155
155
  </Button>
156
156
  ) : null}
@@ -5,7 +5,8 @@ import {
5
5
  type ViewStyle,
6
6
  } from 'react-native';
7
7
  import { View } from 'react-native-css/components';
8
- import * as Icons from '@oxy.so/bloom/icons';
8
+ import { RiLoginBoxLine } from '@oxy.so/bloom/icons/RiLoginBoxLine';
9
+ import { RiMoreLine } from '@oxy.so/bloom/icons/RiMoreLine';
9
10
  import { Button } from '@oxy.so/bloom/button';
10
11
  import { Avatar } from '@oxy.so/bloom/avatar';
11
12
  import * as Skeleton from '@oxy.so/bloom/skeleton';
@@ -128,13 +129,13 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
128
129
  trailing={
129
130
  <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, ...(expanded ? { flex: 1 } : {}) }}>
130
131
  {signedIn ? <Avatar source={avatarUrl} variant="thumb" name={displayName} size={resolvedAvatarSize} />
131
- : <Icons.RiLoginBoxLine width={resolvedAvatarSize / 2} height={resolvedAvatarSize / 2} color={colors.icon} />}
132
+ : <RiLoginBoxLine width={resolvedAvatarSize / 2} height={resolvedAvatarSize / 2} color={colors.icon} />}
132
133
  {expanded ? <>
133
134
  <View style={{ flex: 1, minWidth: 0 }}>
134
135
  <Text variant="body-semibold" numberOfLines={1}>{displayName}</Text>
135
136
  {handle ? <Text variant="caption-1-regular" numberOfLines={1} style={{ color: colors.textSecondary }}>@{handle}</Text> : null}
136
137
  </View>
137
- {signedIn ? <Icons.RiMoreLine width={18} height={18} color={colors.textSecondary} /> : null}
138
+ {signedIn ? <RiMoreLine width={18} height={18} color={colors.textSecondary} /> : null}
138
139
  </> : null}
139
140
  </View>
140
141
  }
@@ -28,6 +28,7 @@ function renderScreen(props: Partial<OxyConsentScreenProps> = {}) {
28
28
  onAllow={props.onAllow ?? onAllow}
29
29
  onDeny={props.onDeny ?? onDeny}
30
30
  busy={props.busy}
31
+ allowDisabled={props.allowDisabled}
31
32
  error={props.error}
32
33
  />,
33
34
  );
@@ -132,6 +133,21 @@ describe('OxyConsentScreen', () => {
132
133
  expect(onDeny).not.toHaveBeenCalled();
133
134
  });
134
135
 
136
+ it('allowDisabled blocks only Allow and leaves Deny usable', () => {
137
+ const { getByTestId, onAllow, onDeny } = renderScreen({ allowDisabled: true });
138
+
139
+ const allow = getByTestId('consent-allow') as HTMLButtonElement;
140
+ const deny = getByTestId('consent-deny') as HTMLButtonElement;
141
+ expect(allow.disabled).toBe(true);
142
+ expect(deny.disabled).toBe(false);
143
+
144
+ fireEvent.click(allow);
145
+ expect(onAllow).not.toHaveBeenCalled();
146
+ // Declining must stay possible while the host's own gate is uncleared.
147
+ fireEvent.click(deny);
148
+ expect(onDeny).toHaveBeenCalledTimes(1);
149
+ });
150
+
135
151
  it('renders the authorizing account using the display name when present', () => {
136
152
  const { getByTestId } = renderScreen({
137
153
  user: { displayName: ' Alice A ', handle: 'alice', avatarUri: 'file-1' },
@@ -108,6 +108,9 @@ const SignInEntryView: React.FC<SignInEntryViewProps> = ({
108
108
 
109
109
  <Button
110
110
  appearance="solid" tone="accent"
111
+ // `lg` (44dp): the surface's ONE primary action is a full-width CTA, and
112
+ // the default `md` (36dp) is under the touch-target minimum on Android.
113
+ size="lg"
111
114
  onPress={web ? alternatives.onSignInWithPasskey : onContinueWithOxy}
112
115
  style={styles.primaryButton}
113
116
  testID="continue-with-oxy"
@@ -49,7 +49,7 @@ const SignUpView: React.FC<SignUpViewProps> = ({
49
49
  return (
50
50
  <View style={styles.centeredBlock}>
51
51
  <Button
52
- appearance="solid" tone="accent"
52
+ appearance="solid" tone="accent" size="lg"
53
53
  onPress={commonsInstalled ? onCreateIdentityInCommons : onGetCommons}
54
54
  style={styles.primaryButton}
55
55
  >
@@ -65,7 +65,7 @@ const SignUpView: React.FC<SignUpViewProps> = ({
65
65
  <Text style={[styles.mutedText, { color: theme.colors.textSecondary }]}>
66
66
  {t('accountSwitcher.passkeyHint')}
67
67
  </Text>
68
- <Button appearance="solid" tone="accent" onPress={onOpenHub} style={styles.primaryButton} testID="signup-open-identity">
68
+ <Button appearance="solid" tone="accent" size="lg" onPress={onOpenHub} style={styles.primaryButton} testID="signup-open-identity">
69
69
  {t('signup.createAccount')}
70
70
  </Button>
71
71
  <SubtleLink label={t('signup.backToSignInLink')} theme={theme} onPress={onBackToSignIn} />
@@ -72,11 +72,14 @@ const TroubleDisclosure: React.FC<TroubleDisclosureProps> = ({ actions, revealed
72
72
  type="single"
73
73
  value={openItem}
74
74
  onValueChange={setOpenItem}
75
- style={styles.troubleTrigger}
75
+ style={styles.troubleDisclosure}
76
76
  testID="trouble-disclosure"
77
77
  >
78
78
  <AccordionItem value={TROUBLE_ITEM}>
79
- <AccordionTrigger textStyle={[styles.linkText, { color: theme.colors.textSecondary }]}>
79
+ <AccordionTrigger
80
+ style={styles.troubleTrigger}
81
+ textStyle={[styles.linkText, { color: theme.colors.textSecondary }]}
82
+ >
80
83
  {t('accountSwitcher.havingTrouble')}
81
84
  </AccordionTrigger>
82
85
  <AccordionContent>{links}</AccordionContent>
@@ -81,7 +81,7 @@ export const GetCommonsPrompt: React.FC<{
81
81
  {t('accountSwitcher.commonsNotInstalled')}
82
82
  </Text>
83
83
  <Button
84
- appearance="solid" tone="accent"
84
+ appearance="solid" tone="accent" size="lg"
85
85
  onPress={onGetCommons}
86
86
  style={styles.primaryButton}
87
87
  testID="get-commons-button"
@@ -205,9 +205,19 @@ export const authChooserStyles = StyleSheet.create({
205
205
  width: '100%',
206
206
  alignItems: 'center',
207
207
  },
208
- /** The disclosure trigger, aligned with the subordinate links it reveals. */
208
+ /**
209
+ * The disclosure spans the content width, so the alternatives it reveals are
210
+ * centred across the sheet rather than inside the trigger's own width. It
211
+ * used to be `alignSelf: 'center'` — shrink-wrapped — and on Android that
212
+ * both wrapped the trigger to "Having / trouble?" and squeezed the revealed
213
+ * links into the same narrow column (OxyHQ/oxy#1375).
214
+ */
215
+ troubleDisclosure: {
216
+ width: '100%',
217
+ marginTop: 12,
218
+ },
219
+ /** The trigger itself stays a compact, centred affordance like the links. */
209
220
  troubleTrigger: {
210
221
  alignSelf: 'center',
211
- marginTop: 12,
212
222
  },
213
223
  });
@@ -14,6 +14,7 @@ import type { AccountDialogSnapshot } from '@oxy.so/core';
14
14
  */
15
15
  export const EMPTY_ACCOUNT_DIALOG_SNAPSHOT: AccountDialogSnapshot = {
16
16
  view: 'accounts',
17
+ backView: null,
17
18
  directory: null,
18
19
  activeContext: null,
19
20
  activatingContextId: null,
@@ -1,11 +1,16 @@
1
1
  import createIconSet from '@expo/vector-icons/createIconSet';
2
+ import { decorativeIconSet } from './decorativeIconSet';
2
3
  import font from '../../assets/fonts/icons/OxyServicesIonicons.ttf';
3
4
  import { ioniconsGlyphMap } from './subsetGlyphMaps';
4
5
 
5
- const Ionicons = createIconSet(
6
- ioniconsGlyphMap,
7
- 'OxyServicesIonicons',
8
- font,
6
+ /** Glyphs are decorative: hidden from assistive technology (see `decorativeIconSet`). */
7
+ const Ionicons = decorativeIconSet(
8
+ createIconSet(
9
+ ioniconsGlyphMap,
10
+ 'OxyServicesIonicons',
11
+ font,
12
+ ),
13
+ 'Ionicons',
9
14
  );
10
15
 
11
16
  export default Ionicons;
@@ -1,11 +1,16 @@
1
1
  import createIconSet from '@expo/vector-icons/createIconSet';
2
+ import { decorativeIconSet } from './decorativeIconSet';
2
3
  import font from '../../assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf';
3
4
  import { materialCommunityIconsGlyphMap } from './subsetGlyphMaps';
4
5
 
5
- const MaterialCommunityIcons = createIconSet(
6
- materialCommunityIconsGlyphMap,
7
- 'OxyServicesMaterialCommunityIcons',
8
- font,
6
+ /** Glyphs are decorative: hidden from assistive technology (see `decorativeIconSet`). */
7
+ const MaterialCommunityIcons = decorativeIconSet(
8
+ createIconSet(
9
+ materialCommunityIconsGlyphMap,
10
+ 'OxyServicesMaterialCommunityIcons',
11
+ font,
12
+ ),
13
+ 'MaterialCommunityIcons',
9
14
  );
10
15
 
11
16
  export default MaterialCommunityIcons;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Icon-font glyphs are private-use code points in a `Text` node; unhidden, a
3
+ * screen reader announces them (`"\u{F0140}"` beside "Switch account" in the
4
+ * Android account menu, OxyHQ/oxy#1375). Both SDK icon families hide every
5
+ * glyph, and a caller cannot switch that back on.
6
+ */
7
+
8
+ import { render } from '@testing-library/react';
9
+ import Ionicons from '../Ionicons';
10
+ import MaterialCommunityIcons from '../MaterialCommunityIcons';
11
+ import { decorativeIconSet } from '../decorativeIconSet';
12
+
13
+ describe('the SDK icon families are decorative', () => {
14
+ it.each([
15
+ ['MaterialCommunityIcons', MaterialCommunityIcons, 'chevron-down'],
16
+ ['Ionicons', Ionicons, 'add'],
17
+ ] as const)('%s hides its glyph from assistive technology', (_family, Icon, name) => {
18
+ const { container } = render(<Icon name={name as never} size={20} />);
19
+
20
+ expect(container.querySelector(`[data-icon="${name}"]`)?.getAttribute('aria-hidden')).toBe('true');
21
+ });
22
+
23
+ it('keeps the glyph hidden even when a caller asks otherwise', () => {
24
+ const { container } = render(
25
+ <MaterialCommunityIcons name="chevron-down" aria-hidden={false} />,
26
+ );
27
+
28
+ expect(container.querySelector('[data-icon]')?.getAttribute('aria-hidden')).toBe('true');
29
+ });
30
+
31
+ it('carries the family statics, so it stays a drop-in for the family', () => {
32
+ expect(MaterialCommunityIcons.glyphMap['chevron-down']).toEqual(expect.any(Number));
33
+ expect(Ionicons.glyphMap.add).toEqual(expect.any(Number));
34
+
35
+ const Family = Object.assign(() => null, { glyphMap: { a: 1 }, getFontFamily: () => 'F' });
36
+ const Wrapped = decorativeIconSet(Family, 'Wrapped');
37
+ expect(Wrapped.glyphMap).toBe(Family.glyphMap);
38
+ expect(Wrapped.getFontFamily()).toBe('F');
39
+ });
40
+ });
@@ -0,0 +1,41 @@
1
+ import { createElement, type ComponentProps, type ComponentType } from 'react';
2
+
3
+ /** Function built-ins and statics a function component must not inherit. */
4
+ const NOT_COPIED = new Set(['length', 'name', 'prototype', 'caller', 'arguments', 'defaultProps', 'displayName']);
5
+
6
+ /**
7
+ * Wrap an icon-font family so every glyph it renders is hidden from assistive
8
+ * technology.
9
+ *
10
+ * A vector-icons glyph is a `Text` node whose content is a private-use code
11
+ * point. A screen reader reads it like any other text: Android QA of the account
12
+ * menu found `"\u{F0140}"` announced next to "Switch account" (OxyHQ/oxy#1375),
13
+ * and on Android the code point is folded into the label of the pressable around
14
+ * it, because RN builds an unlabelled accessible view's description from its
15
+ * children's text.
16
+ *
17
+ * Every glyph in this SDK is decorative — the control or row around it carries
18
+ * the words — so it is hidden here, once, rather than at each call site where
19
+ * the next icon would forget it. `aria-hidden` is
20
+ * `importantForAccessibility="no-hide-descendants"` on Android,
21
+ * `accessibilityElementsHidden` on iOS and `aria-hidden` on web. It is applied
22
+ * AFTER the caller's props so it cannot be switched back on; a glyph that needs
23
+ * a name belongs inside a control that has one. (Mention's own wrapper,
24
+ * `components/common/Ionicons.tsx`, is the same rule.)
25
+ *
26
+ * The family's statics (`glyphMap`, `getImageSource`, `loadFont`, …) are carried
27
+ * over, so the wrapper is a drop-in for the family itself.
28
+ */
29
+ // biome-ignore lint/suspicious/noExplicitAny: an icon family's own props are whatever `createIconSet` typed them as; the wrapper returns the SAME type.
30
+ export function decorativeIconSet<T extends ComponentType<any>>(IconSet: T, displayName: string): T {
31
+ function DecorativeIcon(props: ComponentProps<T>) {
32
+ return createElement(IconSet, { ...props, 'aria-hidden': true });
33
+ }
34
+ for (const key of Object.getOwnPropertyNames(IconSet)) {
35
+ if (NOT_COPIED.has(key)) continue;
36
+ const descriptor = Object.getOwnPropertyDescriptor(IconSet, key);
37
+ if (descriptor) Object.defineProperty(DecorativeIcon, key, descriptor);
38
+ }
39
+ DecorativeIcon.displayName = displayName;
40
+ return DecorativeIcon as unknown as T;
41
+ }
@@ -20,6 +20,7 @@ import {
20
20
  getNormalizedUserHandle,
21
21
  logger as loggerUtil,
22
22
  packageInfo,
23
+ type DeviceLinkedSession,
23
24
  } from '@oxy.so/core';
24
25
  import type { BaseScreenProps } from '../types/navigation';
25
26
  import ProfileSummaryCard from '../components/ProfileSummaryCard';
@@ -33,16 +34,9 @@ import { isWebBrowser } from '../utils/isWebBrowser';
33
34
  import { useCurrentUser } from '../hooks/queries/useAccountQueries';
34
35
  import { useUserSubscription } from '../hooks/queries/usePaymentQueries';
35
36
  import { useDeviceSessions } from '../hooks/queries/useServicesQueries';
37
+ import { deviceSessionTitle } from '../utils/deviceSessionTitle';
36
38
 
37
- interface DeviceSessionRow {
38
- sessionId: string;
39
- deviceId: string;
40
- deviceName: string;
41
- isActive: boolean;
42
- lastActive: string;
43
- expiresAt: string;
44
- isCurrent: boolean;
45
- }
39
+ type DeviceSessionRow = DeviceLinkedSession;
46
40
 
47
41
  const AVATAR_SIZE = 88;
48
42
 
@@ -172,8 +166,7 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
172
166
  const confirmed = await surfaces.confirm({
173
167
  title: t('manageAccount.confirms.removeDeviceTitle') || 'Remove device',
174
168
  description:
175
- t('manageAccount.confirms.removeDevice', { name: device.deviceName })
176
- || `Sign out from "${device.deviceName}"?`,
169
+ t('manageAccount.confirms.removeDevice', { name: deviceSessionTitle(device, t) }),
177
170
  confirmLabel: t('common.remove') || 'Remove',
178
171
  cancelLabel: t('common.cancel') || 'Cancel',
179
172
  destructive: true,
@@ -186,8 +179,7 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
186
179
  await oxyServices.logoutSession(activeSessionId, device.sessionId);
187
180
  await refetchDeviceSessions();
188
181
  toast.success(
189
- t('manageAccount.toasts.deviceRemoved', { name: device.deviceName })
190
- || `Signed out from ${device.deviceName}`,
182
+ t('manageAccount.toasts.deviceRemoved', { name: deviceSessionTitle(device, t) }),
191
183
  );
192
184
  } catch (error) {
193
185
  loggerUtil.warn('Remove device failed', { component: 'ManageAccountScreen' }, error as unknown);
@@ -503,7 +495,7 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
503
495
  }
504
496
  />
505
497
  }
506
- title={`${device.deviceName}${device.isCurrent ? ` (${t('manageAccount.sessions.thisDevice') || 'This device'})` : ''}`}
498
+ title={deviceSessionTitle(device, t)}
507
499
  description={
508
500
  t('manageAccount.sessions.lastActive', {
509
501
  relative: formatRelative(device.lastActive),
@@ -711,10 +703,13 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
711
703
  description={
712
704
  accounts.length > 0
713
705
  ? (
714
- t('accounts.manage.switch.count', {
715
- count: accounts.length,
716
- })
717
- || `${accounts.length} ${accounts.length === 1 ? 'account' : 'accounts'}`
706
+ // One account is "1 account", not "1 accounts".
707
+ t(
708
+ accounts.length === 1
709
+ ? 'accounts.manage.switch.countOne'
710
+ : 'accounts.manage.switch.count',
711
+ { count: accounts.length },
712
+ )
718
713
  )
719
714
  : (
720
715
  t('accounts.manage.switch.empty')
@@ -0,0 +1,31 @@
1
+ import type { User } from '@oxy.so/core';
2
+ import { deviceSessionTitle } from '../deviceSessionTitle';
3
+
4
+ const t = (key: string) =>
5
+ ({
6
+ 'manageAccount.sessions.thisDevice': 'This device',
7
+ 'manageAccount.sessions.otherSession': 'Another session',
8
+ })[key] ?? key;
9
+
10
+ describe('deviceSessionTitle', () => {
11
+ it('never reads "undefined" when the server sends no device name (OxyHQ/oxy#1375 item 11)', () => {
12
+ // The live response shape: no `deviceName` key at all.
13
+ const title = deviceSessionTitle({ isCurrent: true } as never, t);
14
+ expect(title).toBe('This device');
15
+ expect(title).not.toContain('undefined');
16
+ });
17
+
18
+ it("names another session by its account, which the response does carry", () => {
19
+ const user = { id: 'u2', username: 'bob', name: { displayName: 'Bob' } } as unknown as User;
20
+ expect(deviceSessionTitle({ isCurrent: false, user } as never, t)).toBe('Bob');
21
+ });
22
+
23
+ it('falls back to a generic label when nothing names the session', () => {
24
+ expect(deviceSessionTitle({ isCurrent: false } as never, t)).toBe('Another session');
25
+ });
26
+
27
+ it('keeps a real device name, marking the current one', () => {
28
+ expect(deviceSessionTitle({ deviceName: 'Pixel 8a', isCurrent: true }, t)).toBe('Pixel 8a (This device)');
29
+ expect(deviceSessionTitle({ deviceName: 'Pixel 8a', isCurrent: false }, t)).toBe('Pixel 8a');
30
+ });
31
+ });
@@ -0,0 +1,29 @@
1
+ import { getNormalizedUserHandle, type DeviceLinkedSession } from '@oxy.so/core';
2
+
3
+ type Translate = (key: string, vars?: Record<string, string | number>) => string;
4
+
5
+ /**
6
+ * The title of one row in "Sessions & devices".
7
+ *
8
+ * `GET /session/device/sessions/:sessionId` lists the sessions that share THIS
9
+ * physical device — one per account signed in here — and sends no
10
+ * `deviceName` at all, although the model declared one. The row interpolated
11
+ * it unconditionally and Android QA read "undefined (This device)"
12
+ * (OxyHQ/oxy#1375 item 11).
13
+ *
14
+ * A name the server does send is used; otherwise the current session is "This
15
+ * device" and any other is named by the account it belongs to, which the
16
+ * response does carry. Never the string "undefined".
17
+ */
18
+ export function deviceSessionTitle(
19
+ session: Pick<DeviceLinkedSession, 'deviceName' | 'isCurrent' | 'user'>,
20
+ t: Translate,
21
+ ): string {
22
+ const thisDevice = t('manageAccount.sessions.thisDevice');
23
+ const deviceName = typeof session.deviceName === 'string' ? session.deviceName.trim() : '';
24
+ if (deviceName) return session.isCurrent ? `${deviceName} (${thisDevice})` : deviceName;
25
+ if (session.isCurrent) return thisDevice;
26
+ const user = session.user;
27
+ const name = user?.name?.displayName?.trim() || (user ? getNormalizedUserHandle(user) : null);
28
+ return name || t('manageAccount.sessions.otherSession');
29
+ }