@oxy.so/services 4.0.1 → 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 (115) 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/authChooser/SignInEntryView.js +5 -1
  10. package/lib/commonjs/ui/components/authChooser/SignInEntryView.js.map +1 -1
  11. package/lib/commonjs/ui/components/authChooser/SignUpView.js +2 -0
  12. package/lib/commonjs/ui/components/authChooser/SignUpView.js.map +1 -1
  13. package/lib/commonjs/ui/components/authChooser/TroubleDisclosure.js +2 -1
  14. package/lib/commonjs/ui/components/authChooser/TroubleDisclosure.js.map +1 -1
  15. package/lib/commonjs/ui/components/authChooser/requestSurfaces.js +1 -0
  16. package/lib/commonjs/ui/components/authChooser/requestSurfaces.js.map +1 -1
  17. package/lib/commonjs/ui/components/authChooser/styles.js +13 -3
  18. package/lib/commonjs/ui/components/authChooser/styles.js.map +1 -1
  19. package/lib/commonjs/ui/hooks/accountDialogSnapshot.js +1 -0
  20. package/lib/commonjs/ui/hooks/accountDialogSnapshot.js.map +1 -1
  21. package/lib/commonjs/ui/icons/Ionicons.js +3 -1
  22. package/lib/commonjs/ui/icons/Ionicons.js.map +1 -1
  23. package/lib/commonjs/ui/icons/MaterialCommunityIcons.js +3 -1
  24. package/lib/commonjs/ui/icons/MaterialCommunityIcons.js.map +1 -1
  25. package/lib/commonjs/ui/icons/decorativeIconSet.js +50 -0
  26. package/lib/commonjs/ui/icons/decorativeIconSet.js.map +1 -0
  27. package/lib/commonjs/ui/screens/ManageAccountScreen.js +10 -7
  28. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  29. package/lib/commonjs/ui/utils/deviceSessionTitle.js +30 -0
  30. package/lib/commonjs/ui/utils/deviceSessionTitle.js.map +1 -0
  31. package/lib/module/ui/components/OxyAccountDialogScreen.js +10 -16
  32. package/lib/module/ui/components/OxyAccountDialogScreen.js.map +1 -1
  33. package/lib/module/ui/components/OxyAuthChooser.js +8 -2
  34. package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
  35. package/lib/module/ui/components/OxyConsentScreen.js +2 -1
  36. package/lib/module/ui/components/OxyConsentScreen.js.map +1 -1
  37. package/lib/module/ui/components/OxySignInRequestSurface.js +1 -0
  38. package/lib/module/ui/components/OxySignInRequestSurface.js.map +1 -1
  39. package/lib/module/ui/components/authChooser/SignInEntryView.js +5 -1
  40. package/lib/module/ui/components/authChooser/SignInEntryView.js.map +1 -1
  41. package/lib/module/ui/components/authChooser/SignUpView.js +2 -0
  42. package/lib/module/ui/components/authChooser/SignUpView.js.map +1 -1
  43. package/lib/module/ui/components/authChooser/TroubleDisclosure.js +2 -1
  44. package/lib/module/ui/components/authChooser/TroubleDisclosure.js.map +1 -1
  45. package/lib/module/ui/components/authChooser/requestSurfaces.js +1 -0
  46. package/lib/module/ui/components/authChooser/requestSurfaces.js.map +1 -1
  47. package/lib/module/ui/components/authChooser/styles.js +13 -3
  48. package/lib/module/ui/components/authChooser/styles.js.map +1 -1
  49. package/lib/module/ui/hooks/accountDialogSnapshot.js +1 -0
  50. package/lib/module/ui/hooks/accountDialogSnapshot.js.map +1 -1
  51. package/lib/module/ui/icons/Ionicons.js +4 -1
  52. package/lib/module/ui/icons/Ionicons.js.map +1 -1
  53. package/lib/module/ui/icons/MaterialCommunityIcons.js +4 -1
  54. package/lib/module/ui/icons/MaterialCommunityIcons.js.map +1 -1
  55. package/lib/module/ui/icons/decorativeIconSet.js +47 -0
  56. package/lib/module/ui/icons/decorativeIconSet.js.map +1 -0
  57. package/lib/module/ui/screens/ManageAccountScreen.js +10 -7
  58. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  59. package/lib/module/ui/utils/deviceSessionTitle.js +26 -0
  60. package/lib/module/ui/utils/deviceSessionTitle.js.map +1 -0
  61. package/lib/typescript/commonjs/ui/components/OxyAccountDialogScreen.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +10 -1
  64. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/components/authChooser/SignInEntryView.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/components/authChooser/TroubleDisclosure.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/ui/components/authChooser/styles.d.ts +12 -2
  68. package/lib/typescript/commonjs/ui/components/authChooser/styles.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/ui/hooks/accountDialogSnapshot.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts +2 -1
  71. package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +2 -1
  73. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/ui/icons/decorativeIconSet.d.ts +26 -0
  75. package/lib/typescript/commonjs/ui/icons/decorativeIconSet.d.ts.map +1 -0
  76. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/ui/utils/deviceSessionTitle.d.ts +18 -0
  78. package/lib/typescript/commonjs/ui/utils/deviceSessionTitle.d.ts.map +1 -0
  79. package/lib/typescript/module/ui/components/OxyAccountDialogScreen.d.ts.map +1 -1
  80. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +10 -1
  82. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/components/authChooser/SignInEntryView.d.ts.map +1 -1
  84. package/lib/typescript/module/ui/components/authChooser/TroubleDisclosure.d.ts.map +1 -1
  85. package/lib/typescript/module/ui/components/authChooser/styles.d.ts +12 -2
  86. package/lib/typescript/module/ui/components/authChooser/styles.d.ts.map +1 -1
  87. package/lib/typescript/module/ui/hooks/accountDialogSnapshot.d.ts.map +1 -1
  88. package/lib/typescript/module/ui/icons/Ionicons.d.ts +2 -1
  89. package/lib/typescript/module/ui/icons/Ionicons.d.ts.map +1 -1
  90. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +2 -1
  91. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
  92. package/lib/typescript/module/ui/icons/decorativeIconSet.d.ts +26 -0
  93. package/lib/typescript/module/ui/icons/decorativeIconSet.d.ts.map +1 -0
  94. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  95. package/lib/typescript/module/ui/utils/deviceSessionTitle.d.ts +18 -0
  96. package/lib/typescript/module/ui/utils/deviceSessionTitle.d.ts.map +1 -0
  97. package/package.json +6 -6
  98. package/src/ui/components/OxyAccountDialogScreen.tsx +10 -16
  99. package/src/ui/components/OxyAuthChooser.tsx +8 -2
  100. package/src/ui/components/OxyConsentScreen.tsx +11 -1
  101. package/src/ui/components/OxySignInRequestSurface.tsx +1 -1
  102. package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +16 -0
  103. package/src/ui/components/authChooser/SignInEntryView.tsx +3 -0
  104. package/src/ui/components/authChooser/SignUpView.tsx +2 -2
  105. package/src/ui/components/authChooser/TroubleDisclosure.tsx +5 -2
  106. package/src/ui/components/authChooser/requestSurfaces.tsx +1 -1
  107. package/src/ui/components/authChooser/styles.ts +12 -2
  108. package/src/ui/hooks/accountDialogSnapshot.ts +1 -0
  109. package/src/ui/icons/Ionicons.ts +9 -4
  110. package/src/ui/icons/MaterialCommunityIcons.ts +9 -4
  111. package/src/ui/icons/__tests__/decorativeIconSet.test.tsx +40 -0
  112. package/src/ui/icons/decorativeIconSet.ts +41 -0
  113. package/src/ui/screens/ManageAccountScreen.tsx +13 -18
  114. package/src/ui/utils/__tests__/deviceSessionTitle.test.ts +31 -0
  115. package/src/ui/utils/deviceSessionTitle.ts +29 -0
@@ -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
+ }