@oxy.so/services 12.5.0 → 12.6.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 (90) hide show
  1. package/lib/commonjs/index.js.map +1 -1
  2. package/lib/commonjs/ui/client.js.map +1 -1
  3. package/lib/commonjs/ui/components/FollowButton.js +27 -14
  4. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/FollowTargetButton.js +39 -51
  6. package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -1
  7. package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -1
  8. package/lib/commonjs/ui/components/authChooser/types.js.map +1 -1
  9. package/lib/commonjs/ui/components/followButtonAccessibility.js +24 -7
  10. package/lib/commonjs/ui/components/followButtonAccessibility.js.map +1 -1
  11. package/lib/commonjs/ui/components/followLabels.js +38 -0
  12. package/lib/commonjs/ui/components/followLabels.js.map +1 -0
  13. package/lib/commonjs/ui/components/followRules.js +29 -13
  14. package/lib/commonjs/ui/components/followRules.js.map +1 -1
  15. package/lib/commonjs/ui/hooks/useI18n.js +2 -0
  16. package/lib/commonjs/ui/hooks/useI18n.js.map +1 -1
  17. package/lib/commonjs/ui/utils/deviceSessionTitle.js.map +1 -1
  18. package/lib/module/index.js.map +1 -1
  19. package/lib/module/ui/client.js.map +1 -1
  20. package/lib/module/ui/components/FollowButton.js +28 -15
  21. package/lib/module/ui/components/FollowButton.js.map +1 -1
  22. package/lib/module/ui/components/FollowTargetButton.js +39 -51
  23. package/lib/module/ui/components/FollowTargetButton.js.map +1 -1
  24. package/lib/module/ui/components/OxyConsentScreen.js.map +1 -1
  25. package/lib/module/ui/components/authChooser/types.js.map +1 -1
  26. package/lib/module/ui/components/followButtonAccessibility.js +24 -7
  27. package/lib/module/ui/components/followButtonAccessibility.js.map +1 -1
  28. package/lib/module/ui/components/followLabels.js +35 -0
  29. package/lib/module/ui/components/followLabels.js.map +1 -0
  30. package/lib/module/ui/components/followRules.js +30 -13
  31. package/lib/module/ui/components/followRules.js.map +1 -1
  32. package/lib/module/ui/hooks/useI18n.js +2 -0
  33. package/lib/module/ui/hooks/useI18n.js.map +1 -1
  34. package/lib/module/ui/utils/deviceSessionTitle.js.map +1 -1
  35. package/lib/typescript/commonjs/index.d.ts +2 -1
  36. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/client.d.ts +3 -1
  38. package/lib/typescript/commonjs/ui/client.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts +13 -0
  40. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts +6 -18
  42. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts +1 -2
  45. package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/ui/components/followButtonAccessibility.d.ts +12 -2
  47. package/lib/typescript/commonjs/ui/components/followButtonAccessibility.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/ui/components/followLabels.d.ts +28 -0
  49. package/lib/typescript/commonjs/ui/components/followLabels.d.ts.map +1 -0
  50. package/lib/typescript/commonjs/ui/components/followRules.d.ts +20 -9
  51. package/lib/typescript/commonjs/ui/components/followRules.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/ui/hooks/useI18n.d.ts +3 -1
  53. package/lib/typescript/commonjs/ui/hooks/useI18n.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/ui/utils/deviceSessionTitle.d.ts +1 -2
  55. package/lib/typescript/commonjs/ui/utils/deviceSessionTitle.d.ts.map +1 -1
  56. package/lib/typescript/module/index.d.ts +2 -1
  57. package/lib/typescript/module/index.d.ts.map +1 -1
  58. package/lib/typescript/module/ui/client.d.ts +3 -1
  59. package/lib/typescript/module/ui/client.d.ts.map +1 -1
  60. package/lib/typescript/module/ui/components/FollowButton.d.ts +13 -0
  61. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  62. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts +6 -18
  63. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -1
  64. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -1
  65. package/lib/typescript/module/ui/components/authChooser/types.d.ts +1 -2
  66. package/lib/typescript/module/ui/components/authChooser/types.d.ts.map +1 -1
  67. package/lib/typescript/module/ui/components/followButtonAccessibility.d.ts +12 -2
  68. package/lib/typescript/module/ui/components/followButtonAccessibility.d.ts.map +1 -1
  69. package/lib/typescript/module/ui/components/followLabels.d.ts +28 -0
  70. package/lib/typescript/module/ui/components/followLabels.d.ts.map +1 -0
  71. package/lib/typescript/module/ui/components/followRules.d.ts +20 -9
  72. package/lib/typescript/module/ui/components/followRules.d.ts.map +1 -1
  73. package/lib/typescript/module/ui/hooks/useI18n.d.ts +3 -1
  74. package/lib/typescript/module/ui/hooks/useI18n.d.ts.map +1 -1
  75. package/lib/typescript/module/ui/utils/deviceSessionTitle.d.ts +1 -2
  76. package/lib/typescript/module/ui/utils/deviceSessionTitle.d.ts.map +1 -1
  77. package/package.json +4 -4
  78. package/src/index.ts +2 -5
  79. package/src/ui/client.ts +7 -5
  80. package/src/ui/components/FollowButton.tsx +53 -23
  81. package/src/ui/components/FollowTargetButton.tsx +39 -52
  82. package/src/ui/components/OxyConsentScreen.tsx +1 -3
  83. package/src/ui/components/__tests__/followButtonAccessibility.test.tsx +32 -6
  84. package/src/ui/components/__tests__/followTargetButton.test.ts +7 -1
  85. package/src/ui/components/authChooser/types.ts +1 -2
  86. package/src/ui/components/followButtonAccessibility.ts +38 -25
  87. package/src/ui/components/followLabels.ts +48 -0
  88. package/src/ui/components/followRules.ts +29 -13
  89. package/src/ui/hooks/useI18n.ts +5 -3
  90. package/src/ui/utils/deviceSessionTitle.ts +1 -2
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import React from 'react';
11
11
  import { render } from '@testing-library/react';
12
+ import { translate } from '@oxy.so/core';
12
13
  import {
13
14
  describeFollowAllButton,
14
15
  describeFollowButton,
@@ -52,6 +53,7 @@ jest.mock('../../hooks/useFollow', () => ({
52
53
 
53
54
  jest.mock('../../context/oxyRuntimeContext', () => ({
54
55
  useOxy: () => ({ oxyServices: {}, canUsePrivateApi: true, user: { id: 'viewer' } }),
56
+ useOptionalOxy: () => null,
55
57
  }));
56
58
 
57
59
  let mockTarget = {
@@ -73,8 +75,16 @@ jest.mock('../../hooks/useFollowTarget', () => ({
73
75
  import { FollowButton } from '../FollowButton';
74
76
  import { FollowTargetButton } from '../FollowTargetButton';
75
77
 
78
+ const t = (key: string, vars?: Record<string, string | number>) => translate('en-US', key, vars);
79
+
76
80
  describe('describeFollowButton', () => {
77
- const known = { isKnown: true, isPending: false, username: 'nate' };
81
+ const known = {
82
+ isKnown: true,
83
+ isPending: false,
84
+ username: 'nate',
85
+ verb: 'follow' as const,
86
+ labels: { idle: 'Follow', active: 'Following' },
87
+ };
78
88
 
79
89
  it.each([
80
90
  [{ ...known, isFollowing: true }, 'Following @nate', 'Unfollows @nate'],
@@ -94,7 +104,7 @@ describe('describeFollowButton', () => {
94
104
  undefined,
95
105
  ],
96
106
  ])('%o reads "%s"', (input, label, hint) => {
97
- expect(describeFollowButton(input)).toEqual(
107
+ expect(describeFollowButton(input, t)).toEqual(
98
108
  hint ? { accessibilityLabel: label, accessibilityHint: hint } : { accessibilityLabel: label },
99
109
  );
100
110
  });
@@ -112,22 +122,22 @@ describe('describeFollowAllButton', () => {
112
122
 
113
123
  it('names the state and counts the accounts in the hint', () => {
114
124
  expect(
115
- describeFollowAllButton({ ...labels, allFollowing: false, isPending: false, count: 3 }),
125
+ describeFollowAllButton({ ...labels, allFollowing: false, isPending: false, count: 3 }, t),
116
126
  ).toEqual({
117
127
  accessibilityLabel: 'Follow all',
118
128
  accessibilityHint: 'Follows all 3 accounts',
119
129
  });
120
130
  expect(
121
- describeFollowAllButton({ ...labels, allFollowing: true, isPending: false, count: 1 }),
131
+ describeFollowAllButton({ ...labels, allFollowing: true, isPending: false, count: 1 }, t),
122
132
  ).toEqual({
123
133
  accessibilityLabel: 'Following',
124
- accessibilityHint: 'Unfollows all 1 account',
134
+ accessibilityHint: 'Unfollows 1 account',
125
135
  });
126
136
  });
127
137
 
128
138
  it('drops the hint while the bulk write is in flight', () => {
129
139
  expect(
130
- describeFollowAllButton({ ...labels, allFollowing: true, isPending: true, count: 3 }),
140
+ describeFollowAllButton({ ...labels, allFollowing: true, isPending: true, count: 3 }, t),
131
141
  ).toEqual({
132
142
  accessibilityLabel: 'Following',
133
143
  });
@@ -178,6 +188,22 @@ describe('FollowButton hands Bloom a name that follows the state', () => {
178
188
  });
179
189
  });
180
190
 
191
+ it('reads its verb: "Join" and "Joined", named and hinted to match', () => {
192
+ mockFollowState = { isFollowing: false, isKnown: true, isLoading: false };
193
+ render(<FollowButton userId="c-id" username="fediverse" verb="join" />);
194
+ expect(lastBloomProps()).toMatchObject({
195
+ label: 'Join',
196
+ followingLabel: 'Joined',
197
+ accessibilityLabel: 'Join @fediverse',
198
+ });
199
+ mockFollowState = { isFollowing: true, isKnown: true, isLoading: false };
200
+ render(<FollowButton userId="c-id" username="fediverse" verb="join" />);
201
+ expect(lastBloomProps()).toMatchObject({
202
+ accessibilityLabel: 'Joined @fediverse',
203
+ accessibilityHint: 'Leaves @fediverse',
204
+ });
205
+ });
206
+
181
207
  it('names the "Follow all" button by its state too', () => {
182
208
  mockMulti = { allFollowing: true, isAnyLoading: false };
183
209
  render(<FollowButton userIds={['a', 'b']} />);
@@ -28,7 +28,7 @@ import {
28
28
 
29
29
  const DURATIONS = [
30
30
  { label: '24 hours', seconds: 86400 },
31
- { label: 'A week', seconds: 604800 },
31
+ { label: 'a week', seconds: 604800 },
32
32
  ];
33
33
 
34
34
  const base = {
@@ -97,6 +97,12 @@ describe('buildFollowMenuItems', () => {
97
97
  expect(buildFollowMenuItems(following)[0].label).toBe('Don’t show in Mention');
98
98
  });
99
99
 
100
+ it('words "everywhere" with the button’s verb', () => {
101
+ expect(buildFollowMenuItems({ ...following, verb: 'join' })[1].label).toBe(
102
+ 'Leave everywhere',
103
+ );
104
+ });
105
+
100
106
  it('offers turning it back on once it is off here', () => {
101
107
  const items = buildFollowMenuItems({ ...following, applicationMode: 'disabled' });
102
108
  expect(keys(items)).toEqual(['enable-here', 'unfollow-everywhere']);
@@ -11,10 +11,9 @@ import { APP_COLOR_NAMES } from '@oxy.so/bloom/color-presets';
11
11
  import type { AccountStorageUsageResponse } from '@oxy.so/core';
12
12
  import { APP_COLOR_PRESETS, type useTheme } from '@oxy.so/bloom/theme';
13
13
  import type { AccountDialogMenuSection } from '../../navigation/accountDialogManager';
14
- import type { useI18n } from '../../hooks/useI18n';
15
14
 
16
15
  export type Theme = ReturnType<typeof useTheme>;
17
- export type Translate = ReturnType<typeof useI18n>['t'];
16
+ export type { Translate } from '../../hooks/useI18n';
18
17
 
19
18
  /**
20
19
  * One SUBORDINATE action on a sign-in surface — an alternative revealed behind
@@ -8,10 +8,16 @@
8
8
  * (OxyHQ/oxy#1375 item 22). The name now says the state, and who it is for
9
9
  * when the caller knows the handle.
10
10
  *
11
+ * The words follow the button's verb ("Joined @fediverse", hinted "Leaves
12
+ * @fediverse") and its language: every string is `@oxy.so/core`'s `follow.*`.
13
+ *
11
14
  * Pure, and kept apart from the components, so every state is pinned by a
12
15
  * table test rather than by a render that can only see one state at a time.
13
16
  */
14
17
 
18
+ import type { Translate } from '../hooks/useI18n';
19
+ import type { FollowLabels, FollowVerb } from './followLabels';
20
+
15
21
  export interface FollowButtonAccessibility {
16
22
  accessibilityLabel: string;
17
23
  accessibilityHint?: string;
@@ -33,36 +39,40 @@ export function formatHandle(username: string | undefined): string | undefined {
33
39
  * because a press does nothing until the write settles.
34
40
  * - Following: "Following @nate", hinted "Unfollows @nate", because pressing
35
41
  * it unfollows, which the visible "Following" does not say.
42
+ *
43
+ * The name is the visible label (the caller's, when it passed `labels`) beside
44
+ * the handle; the checking line and the hint are the verb's.
36
45
  */
37
- export function describeFollowButton(input: {
38
- isKnown: boolean;
39
- isFollowing: boolean;
40
- isPending: boolean;
41
- username?: string;
42
- }): FollowButtonAccessibility {
46
+ export function describeFollowButton(
47
+ input: {
48
+ isKnown: boolean;
49
+ isFollowing: boolean;
50
+ isPending: boolean;
51
+ username?: string;
52
+ verb: FollowVerb;
53
+ labels: Pick<FollowLabels, 'idle' | 'active'>;
54
+ },
55
+ t: Translate,
56
+ ): FollowButtonAccessibility {
43
57
  const handle = formatHandle(input.username);
58
+ const words = `follow.verbs.${input.verb}`;
44
59
 
45
60
  if (!input.isKnown) {
46
61
  return {
47
62
  accessibilityLabel: handle
48
- ? `Checking whether you follow ${handle}`
49
- : 'Checking follow status',
63
+ ? t(`${words}.checkingNamed`, { name: handle })
64
+ : t(`${words}.checking`),
50
65
  };
51
66
  }
52
67
 
53
- const accessibilityLabel = input.isFollowing
54
- ? handle
55
- ? `Following ${handle}`
56
- : 'Following'
57
- : handle
58
- ? `Follow ${handle}`
59
- : 'Follow';
68
+ const label = input.isFollowing ? input.labels.active : input.labels.idle;
69
+ const accessibilityLabel = handle ? t('follow.named', { label, name: handle }) : label;
60
70
 
61
71
  if (input.isPending || !input.isFollowing) return { accessibilityLabel };
62
72
 
63
73
  return {
64
74
  accessibilityLabel,
65
- accessibilityHint: handle ? `Unfollows ${handle}` : 'Unfollows this account',
75
+ accessibilityHint: handle ? t(`${words}.undoNamed`, { name: handle }) : t(`${words}.undo`),
66
76
  };
67
77
  }
68
78
 
@@ -70,18 +80,21 @@ export function describeFollowButton(input: {
70
80
  * The "Follow all" button. The labels are the caller's, since a starter pack
71
81
  * and a feed's member list word it differently; the hint carries the count.
72
82
  */
73
- export function describeFollowAllButton(input: {
74
- allFollowing: boolean;
75
- isPending: boolean;
76
- count: number;
77
- followAllLabel: string;
78
- followedAllLabel: string;
79
- }): FollowButtonAccessibility {
83
+ export function describeFollowAllButton(
84
+ input: {
85
+ allFollowing: boolean;
86
+ isPending: boolean;
87
+ count: number;
88
+ followAllLabel: string;
89
+ followedAllLabel: string;
90
+ },
91
+ t: Translate,
92
+ ): FollowButtonAccessibility {
80
93
  const accessibilityLabel = input.allFollowing ? input.followedAllLabel : input.followAllLabel;
81
94
  if (input.isPending) return { accessibilityLabel };
82
- const accounts = input.count === 1 ? '1 account' : `${input.count} accounts`;
95
+ const hint = input.allFollowing ? 'follow.all.undoHint' : 'follow.all.hint';
83
96
  return {
84
97
  accessibilityLabel,
85
- accessibilityHint: input.allFollowing ? `Unfollows all ${accounts}` : `Follows all ${accounts}`,
98
+ accessibilityHint: t(input.count === 1 ? `${hint}One` : hint, { count: input.count }),
86
99
  };
87
100
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The words on a follow button, for every verb the ecosystem uses.
3
+ *
4
+ * "Follow" is wrong for half the things an Oxy user can follow: you subscribe
5
+ * to a channel, you join a community, you watch a listing. Both follow buttons
6
+ * read their words here, translated through `@oxy.so/core`'s `follow.*`
7
+ * strings, so a verb reads the same — and in the same language — wherever it is
8
+ * drawn.
9
+ */
10
+
11
+ import { useMemo } from 'react';
12
+ import { useI18n } from '../hooks/useI18n';
13
+
14
+ /**
15
+ * The small vocabulary of verbs the ecosystem actually uses. An application
16
+ * whose verb is not here passes `labels` instead — the escape hatch exists so
17
+ * this union never has to grow to accommodate one product's wording.
18
+ */
19
+ export type FollowVerb = 'follow' | 'subscribe' | 'join' | 'watch';
20
+
21
+ export interface FollowLabels {
22
+ /** Not following yet. */
23
+ idle: string;
24
+ /** Following, and this application acts on it. */
25
+ active: string;
26
+ /** Asked, waiting for the other side to accept. */
27
+ pending: string;
28
+ /** Following globally, but switched off in this application. */
29
+ disabled: string;
30
+ }
31
+
32
+ /** The labels for `verb` in the current language, with the caller's `overrides` on top. */
33
+ export function useFollowLabels(
34
+ verb: FollowVerb = 'follow',
35
+ overrides?: Partial<FollowLabels>,
36
+ ): FollowLabels {
37
+ const { t } = useI18n();
38
+ return useMemo(
39
+ () => ({
40
+ idle: t(`follow.verbs.${verb}.idle`),
41
+ active: t(`follow.verbs.${verb}.active`),
42
+ pending: t('follow.pending'),
43
+ disabled: t('follow.disabled'),
44
+ ...overrides,
45
+ }),
46
+ [t, verb, overrides],
47
+ );
48
+ }
@@ -5,16 +5,26 @@
5
5
  * application drawing its own affordance (a chip grid, a compact row) imports
6
6
  * them from here — through `@oxy.so/services/ui/client` — without paying for the
7
7
  * button's dropdown menu.
8
+ *
9
+ * The menu's words are `@oxy.so/core`'s `follow.*` strings, read through the
10
+ * caller's `t`; without one they are English.
8
11
  */
9
12
 
10
13
  import type { FollowApplicationMode } from '@oxy.so/contracts';
14
+ import { translate } from '@oxy.so/core';
15
+ import type { Translate } from '../hooks/useI18n';
16
+ import type { FollowVerb } from './followLabels';
11
17
 
12
18
  /** A timed-follow choice offered in the menu. */
13
19
  export interface FollowDuration {
20
+ /** The duration as it reads inside a sentence: "24 hours", "a week". */
14
21
  label: string;
15
22
  seconds: number;
16
23
  }
17
24
 
25
+ /** The fallback language for a caller that passes no translator. */
26
+ const english: Translate = (key, vars) => translate(undefined, key, vars);
27
+
18
28
  /**
19
29
  * Whether an action leaves the target ACTIVE in this application.
20
30
  *
@@ -50,15 +60,6 @@ export interface FollowMenuItem {
50
60
  | { type: 'unfollow' };
51
61
  }
52
62
 
53
- /**
54
- * Which choices exist, given the state.
55
- *
56
- * Pure and exported because this is the product decision, not a rendering
57
- * detail: a timed follow is only offered before following, turning it off here
58
- * is only offered while following, and NEITHER is offered before the server has
59
- * answered — every one of them addresses a relationship that does not exist
60
- * yet, so offering them mid-write would mean sending a guessed id.
61
- */
62
63
  /**
63
64
  * What the main button should do for the current state.
64
65
  *
@@ -75,15 +76,30 @@ export function resolveFollowPrimaryAction(input: {
75
76
  return 'unfollow';
76
77
  }
77
78
 
79
+ /**
80
+ * Which choices exist, given the state.
81
+ *
82
+ * Pure and exported because this is the product decision, not a rendering
83
+ * detail: a timed follow is only offered before following, turning it off here
84
+ * is only offered while following, and NEITHER is offered before the server has
85
+ * answered — every one of them addresses a relationship that does not exist
86
+ * yet, so offering them mid-write would mean sending a guessed id.
87
+ */
78
88
  export function buildFollowMenuItems(input: {
79
89
  following: boolean;
80
90
  applicationMode: FollowApplicationMode;
81
91
  hasRelationship: boolean;
82
92
  isPending: boolean;
83
93
  durations: FollowDuration[] | false;
94
+ /** The button's idle label, which a timed choice starts with: "Follow for 24 hours". */
84
95
  idleVerb: string;
85
96
  applicationName: string;
97
+ /** Words the "everywhere" line with this verb: "Leave everywhere". Default `follow`. */
98
+ verb?: FollowVerb;
99
+ /** `useI18n().t`, for the menu in the app's language. Default English. */
100
+ t?: Translate;
86
101
  }): FollowMenuItem[] {
102
+ const t = input.t ?? english;
87
103
  const items: FollowMenuItem[] = [];
88
104
 
89
105
  if (!input.following) {
@@ -91,7 +107,7 @@ export function buildFollowMenuItems(input: {
91
107
  for (const d of input.durations) {
92
108
  items.push({
93
109
  key: `for-${d.seconds}`,
94
- label: `${input.idleVerb} for ${d.label.toLowerCase()}`,
110
+ label: t('follow.menu.forDuration', { label: input.idleVerb, duration: d.label }),
95
111
  action: { type: 'follow-timed', seconds: d.seconds, durationLabel: d.label },
96
112
  });
97
113
  }
@@ -104,12 +120,12 @@ export function buildFollowMenuItems(input: {
104
120
  input.applicationMode === 'disabled'
105
121
  ? {
106
122
  key: 'enable-here',
107
- label: `Show in ${input.applicationName}`,
123
+ label: t('follow.menu.showIn', { app: input.applicationName }),
108
124
  action: { type: 'enable-here' },
109
125
  }
110
126
  : {
111
127
  key: 'disable-here',
112
- label: `Don’t show in ${input.applicationName}`,
128
+ label: t('follow.menu.hideIn', { app: input.applicationName }),
113
129
  action: { type: 'disable-here' },
114
130
  },
115
131
  );
@@ -117,7 +133,7 @@ export function buildFollowMenuItems(input: {
117
133
  // Named for what it does. "Unfollow" beside "don't show here" would read as
118
134
  // the same action twice, and the user would pick the wrong one.
119
135
  key: 'unfollow-everywhere',
120
- label: 'Unfollow everywhere',
136
+ label: t(`follow.verbs.${input.verb ?? 'follow'}.everywhere`),
121
137
  action: { type: 'unfollow' },
122
138
  });
123
139
 
@@ -9,6 +9,9 @@ import { getLocalesVersion, subscribeLocales, translate } from '@oxy.so/core';
9
9
  */
10
10
  const FALLBACK_LOCALE = 'en-US';
11
11
 
12
+ /** `useI18n().t`: a key and its `{{placeholders}}` in, the current language's text out. */
13
+ export type Translate = (key: string, vars?: Record<string, string | number>) => string;
14
+
12
15
  /**
13
16
  * Display copy for SDK surfaces.
14
17
  *
@@ -32,9 +35,8 @@ export function useI18n() {
32
35
  getLocalesVersion,
33
36
  );
34
37
  // biome-ignore lint/correctness/useExhaustiveDependencies: `localesVersion` is the re-translate signal — a new `t` makes consumers re-render once a dictionary lands.
35
- const t = useMemo(() => {
36
- return (key: string, vars?: Record<string, string | number>) =>
37
- translate(currentLanguage, key, vars);
38
+ const t = useMemo<Translate>(() => {
39
+ return (key, vars) => translate(currentLanguage, key, vars);
38
40
  }, [currentLanguage, localesVersion]);
39
41
  return { t, locale: currentLanguage };
40
42
  }
@@ -1,6 +1,5 @@
1
1
  import { getNormalizedUserHandle, type DeviceLinkedSession } from '@oxy.so/core';
2
-
3
- type Translate = (key: string, vars?: Record<string, string | number>) => string;
2
+ import type { Translate } from '../hooks/useI18n';
4
3
 
5
4
  /**
6
5
  * The title of one row in "Sessions & devices".