@oxy.so/services 1.0.2 → 1.1.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.
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/LanguageBridge.js +51 -0
- package/lib/commonjs/ui/components/LanguageBridge.js.map +1 -0
- package/lib/commonjs/ui/components/OxyConsentScreen.js +132 -2
- package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -1
- package/lib/commonjs/ui/components/OxyProvider.js +19 -1
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/components/ProfileButton.js +20 -12
- package/lib/commonjs/ui/components/ProfileButton.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/LanguageBridge.js +47 -0
- package/lib/module/ui/components/LanguageBridge.js.map +1 -0
- package/lib/module/ui/components/OxyConsentScreen.js +132 -2
- package/lib/module/ui/components/OxyConsentScreen.js.map +1 -1
- package/lib/module/ui/components/OxyProvider.js +19 -1
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/components/ProfileButton.js +20 -12
- package/lib/module/ui/components/ProfileButton.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts +19 -0
- package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +17 -1
- package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts +14 -0
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/types/navigation.d.ts +29 -0
- package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/LanguageBridge.d.ts +19 -0
- package/lib/typescript/module/ui/components/LanguageBridge.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +17 -1
- package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyProvider.d.ts +14 -0
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/types/navigation.d.ts +29 -0
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/index.ts +1 -0
- package/src/ui/components/LanguageBridge.tsx +44 -0
- package/src/ui/components/OxyConsentScreen.tsx +127 -0
- package/src/ui/components/OxyProvider.tsx +17 -0
- package/src/ui/components/ProfileButton.tsx +25 -11
- package/src/ui/components/__tests__/LanguageBridge.test.tsx +81 -0
- package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +32 -0
- package/src/ui/types/navigation.ts +30 -0
|
@@ -30,6 +30,7 @@ const isWeb = Platform.OS === 'web';
|
|
|
30
30
|
*/
|
|
31
31
|
const BG_TRANSITION_MS = 150;
|
|
32
32
|
const AVATAR_TRANSITION_MS = 250;
|
|
33
|
+
const OPACITY_TRANSITION_MS = 150;
|
|
33
34
|
const LEAVE_DELAY_MS = 50;
|
|
34
35
|
|
|
35
36
|
/** Shrunk-avatar scale on hover, matching Bluesky's `scale: 2/3`. */
|
|
@@ -266,13 +267,10 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
|
|
|
266
267
|
|
|
267
268
|
// ── Expanded row animation ──────────────────────────────────────────────
|
|
268
269
|
// On native everything is statically visible (no hover exists). On web we
|
|
269
|
-
// reproduce Bluesky's reveal: the row fills with a subtle contrast bg
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
// Here the identity is ALWAYS visible in the expanded row, so that tuck
|
|
274
|
-
// was a permanent overlap: at the default 40px avatar the name started 8px
|
|
275
|
-
// under it (OxyHQ/Alia#546). The row's own gap is the whole spacing now.
|
|
270
|
+
// reproduce Bluesky's reveal: the row fills with a subtle contrast bg, the
|
|
271
|
+
// avatar shrinks + slides left, and the name/handle/actions fade in from
|
|
272
|
+
// behind it. Keeping the identity hidden while inactive is what makes the
|
|
273
|
+
// negative margin intentional instead of an overlap.
|
|
276
274
|
const reducedMotion = isWeb && prefersReducedMotion();
|
|
277
275
|
|
|
278
276
|
// Slide the shrunk avatar left so its visual left edge stays put as it
|
|
@@ -307,8 +305,24 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
|
|
|
307
305
|
}
|
|
308
306
|
: undefined;
|
|
309
307
|
|
|
310
|
-
|
|
311
|
-
|
|
308
|
+
const identityStyle: ViewStyle | undefined = isWeb
|
|
309
|
+
? {
|
|
310
|
+
marginLeft: -resolvedAvatarSize / 2,
|
|
311
|
+
opacity: active ? 1 : 0,
|
|
312
|
+
transitionProperty: 'opacity',
|
|
313
|
+
transitionDuration: `${OPACITY_TRANSITION_MS}ms`,
|
|
314
|
+
transitionDelay: active ? '0ms' : `${LEAVE_DELAY_MS}ms`,
|
|
315
|
+
}
|
|
316
|
+
: undefined;
|
|
317
|
+
|
|
318
|
+
const actionStyle: ViewStyle | undefined = isWeb
|
|
319
|
+
? {
|
|
320
|
+
opacity: active ? 1 : 0,
|
|
321
|
+
transitionProperty: 'opacity',
|
|
322
|
+
transitionDuration: `${OPACITY_TRANSITION_MS}ms`,
|
|
323
|
+
transitionDelay: active ? '0ms' : `${LEAVE_DELAY_MS}ms`,
|
|
324
|
+
}
|
|
325
|
+
: undefined;
|
|
312
326
|
|
|
313
327
|
return (
|
|
314
328
|
<View className={className} style={[styles.fullWidth, style]}>
|
|
@@ -320,7 +334,7 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
|
|
|
320
334
|
{...webInteractionProps}
|
|
321
335
|
>
|
|
322
336
|
<View style={avatarWrapperStyle}>{avatarNode}</View>
|
|
323
|
-
<View style={styles.identity}>
|
|
337
|
+
<View style={[styles.identity, identityStyle]}>
|
|
324
338
|
<Text
|
|
325
339
|
style={[styles.displayName, { color: colors.text }]}
|
|
326
340
|
numberOfLines={1}
|
|
@@ -336,7 +350,7 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
|
|
|
336
350
|
</Text>
|
|
337
351
|
) : null}
|
|
338
352
|
</View>
|
|
339
|
-
<View>
|
|
353
|
+
<View style={actionStyle}>
|
|
340
354
|
<MaterialCommunityIcons
|
|
341
355
|
name="dots-horizontal"
|
|
342
356
|
size={18}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import { LanguageBridge } from '../LanguageBridge';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `OxyProvider`'s language config prop, realized: the one place Oxy's
|
|
7
|
+
* resolved language reaches a host app's own i18n library. `useOxy` is mocked here —
|
|
8
|
+
* it is the SDK's own well-tested context — so these tests exercise exactly
|
|
9
|
+
* what this component adds: coercion to the caller's catalog, and calling
|
|
10
|
+
* back only when the RESOLVED locale actually changes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
let mockCurrentLanguage = 'en-US';
|
|
14
|
+
jest.mock('../../context/OxyContext', () => ({
|
|
15
|
+
useOxy: () => ({ currentLanguage: mockCurrentLanguage }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const HOST_CATALOG = ['en-US', 'es-ES', 'fr-FR'] as const;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
mockCurrentLanguage = 'en-US';
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('resolves and reports the account language on mount', () => {
|
|
25
|
+
mockCurrentLanguage = 'es-ES';
|
|
26
|
+
const onChange = jest.fn();
|
|
27
|
+
render(<LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />);
|
|
28
|
+
|
|
29
|
+
expect(onChange).toHaveBeenCalledWith('es-ES');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('coerces a locale the host never shipped a catalog for, by base language', () => {
|
|
33
|
+
mockCurrentLanguage = 'es-MX';
|
|
34
|
+
const onChange = jest.fn();
|
|
35
|
+
render(<LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />);
|
|
36
|
+
|
|
37
|
+
expect(onChange).toHaveBeenCalledWith('es-ES');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('falls back to the caller default when nothing matches, even by base', () => {
|
|
41
|
+
mockCurrentLanguage = 'ja-JP';
|
|
42
|
+
const onChange = jest.fn();
|
|
43
|
+
render(<LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />);
|
|
44
|
+
|
|
45
|
+
expect(onChange).toHaveBeenCalledWith('en-US');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('calls back again only when the RESOLVED locale changes', () => {
|
|
49
|
+
const onChange = jest.fn();
|
|
50
|
+
const { rerender } = render(
|
|
51
|
+
<LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />,
|
|
52
|
+
);
|
|
53
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
54
|
+
|
|
55
|
+
// Same resolved locale, brand-new callback identity (the common case for an
|
|
56
|
+
// inline arrow function) — must not re-fire.
|
|
57
|
+
rerender(<LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={() => {}} />);
|
|
58
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
59
|
+
|
|
60
|
+
mockCurrentLanguage = 'fr-FR';
|
|
61
|
+
rerender(<LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />);
|
|
62
|
+
expect(onChange).toHaveBeenCalledTimes(2);
|
|
63
|
+
expect(onChange).toHaveBeenLastCalledWith('fr-FR');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('reports a rejected onChange to onError instead of throwing', async () => {
|
|
67
|
+
const failure = new Error('catalog failed to load');
|
|
68
|
+
const onChange = jest.fn().mockRejectedValue(failure);
|
|
69
|
+
const onError = jest.fn();
|
|
70
|
+
render(
|
|
71
|
+
<LanguageBridge
|
|
72
|
+
supportedLocales={HOST_CATALOG}
|
|
73
|
+
fallbackLocale="en-US"
|
|
74
|
+
onChange={onChange}
|
|
75
|
+
onError={onError}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
80
|
+
expect(onError).toHaveBeenCalledWith(failure, 'en-US');
|
|
81
|
+
});
|
|
@@ -24,6 +24,7 @@ function renderScreen(props: Partial<OxyConsentScreenProps> = {}) {
|
|
|
24
24
|
application={props.application ?? baseApp}
|
|
25
25
|
scopes={props.scopes ?? ['openid', 'profile']}
|
|
26
26
|
user={props.user}
|
|
27
|
+
resource={props.resource}
|
|
27
28
|
onAllow={props.onAllow ?? onAllow}
|
|
28
29
|
onDeny={props.onDeny ?? onDeny}
|
|
29
30
|
busy={props.busy}
|
|
@@ -138,6 +139,37 @@ describe('OxyConsentScreen', () => {
|
|
|
138
139
|
expect(getByTestId('consent-account-name').textContent).toBe('Alice A');
|
|
139
140
|
});
|
|
140
141
|
|
|
142
|
+
it('renders the exact MCP app, protected resource, account id and catalog-derived write actions', () => {
|
|
143
|
+
const { getByTestId } = renderScreen({
|
|
144
|
+
scopes: ['email.read', 'email.send'],
|
|
145
|
+
user: {
|
|
146
|
+
accountId: 'account-mail-1',
|
|
147
|
+
displayName: 'Oxy Mail',
|
|
148
|
+
handle: 'oxy-mail-account',
|
|
149
|
+
},
|
|
150
|
+
resource: {
|
|
151
|
+
application: { name: 'Inbox', iconUrl: 'inbox-icon' },
|
|
152
|
+
uri: 'https://mcp.inbox.oxy.so',
|
|
153
|
+
writeActions: [{
|
|
154
|
+
name: 'sendEmail',
|
|
155
|
+
version: '1.0.0',
|
|
156
|
+
description: 'Send an email from the selected mailbox.',
|
|
157
|
+
requiredCapabilities: ['email.send'],
|
|
158
|
+
effect: 'external',
|
|
159
|
+
}],
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(getByTestId('consent-resource-app').textContent).toBe('Inbox');
|
|
164
|
+
expect(getByTestId('consent-resource-uri').textContent).toBe('https://mcp.inbox.oxy.so');
|
|
165
|
+
expect(getByTestId('consent-account-id').textContent).toContain('account-mail-1');
|
|
166
|
+
expect(getByTestId('consent-scope-email.send').textContent).toContain('email.send');
|
|
167
|
+
expect(getByTestId('consent-write-action-sendEmail').textContent).toContain(
|
|
168
|
+
'Send an email from the selected mailbox.',
|
|
169
|
+
);
|
|
170
|
+
expect(getByTestId('consent-write-action-sendEmail').textContent).toContain('External');
|
|
171
|
+
});
|
|
172
|
+
|
|
141
173
|
it('falls back to the handle when the account has no display name (D5)', () => {
|
|
142
174
|
const { getByTestId } = renderScreen({ user: { handle: 'bob' } });
|
|
143
175
|
expect(getByTestId('consent-account-name').textContent).toBe('bob');
|
|
@@ -172,4 +172,34 @@ export interface OxyProviderProps {
|
|
|
172
172
|
* @default 'persistent'
|
|
173
173
|
*/
|
|
174
174
|
deviceCredentialStorage?: 'persistent' | 'ephemeral';
|
|
175
|
+
/**
|
|
176
|
+
* Wires the app's OWN i18n library to Oxy's resolved language, so the
|
|
177
|
+
* account (or, signed out, the device/guest locale) drives the app's
|
|
178
|
+
* translated UI directly — no app-local effect or "sync" component to
|
|
179
|
+
* remember to mount. Oxy decides WHICH language; the app keeps owning its
|
|
180
|
+
* own translation catalogs and library (i18next, FormatJS, or anything
|
|
181
|
+
* else) and is told through `onChange` when to switch.
|
|
182
|
+
*
|
|
183
|
+
* Omitted entirely (the default) when an app has no i18n of its own, or
|
|
184
|
+
* manages it independently of the account/device locale.
|
|
185
|
+
*/
|
|
186
|
+
language?: OxyLanguageConfig;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface OxyLanguageConfig {
|
|
190
|
+
/** The exact locales this app ships a translation catalog for. */
|
|
191
|
+
supportedLocales: readonly string[];
|
|
192
|
+
/**
|
|
193
|
+
* Used when Oxy's resolved language matches none of `supportedLocales`,
|
|
194
|
+
* not even by base language (Oxy's account-locale catalog is broader
|
|
195
|
+
* than any one app's translations).
|
|
196
|
+
*/
|
|
197
|
+
fallbackLocale: string;
|
|
198
|
+
/**
|
|
199
|
+
* Called whenever the resolved locale changes, coerced to the closest
|
|
200
|
+
* one in `supportedLocales`. May return a promise; a rejection is
|
|
201
|
+
* reported to `onError` instead of throwing into the app tree.
|
|
202
|
+
*/
|
|
203
|
+
onChange: (locale: string) => void | Promise<void>;
|
|
204
|
+
onError?: (error: unknown, locale: string) => void;
|
|
175
205
|
}
|