@oxyhq/services 22.13.0 → 22.13.1
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 +74 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/notifications/deviceNotifications.js +424 -0
- package/lib/commonjs/notifications/deviceNotifications.js.map +1 -0
- package/lib/commonjs/ui/components/OxyAuthChooser.js +5 -4
- package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/commonjs/ui/components/OxySignInRequestSurface.js +147 -0
- package/lib/commonjs/ui/components/OxySignInRequestSurface.js.map +1 -0
- package/lib/commonjs/ui/components/authChooser/SignInEntryView.js.map +1 -1
- package/lib/commonjs/ui/components/authChooser/SignInRequestView.js +65 -103
- package/lib/commonjs/ui/components/authChooser/SignInRequestView.js.map +1 -1
- package/lib/commonjs/ui/components/authChooser/TroubleDisclosure.js +0 -2
- package/lib/commonjs/ui/components/authChooser/TroubleDisclosure.js.map +1 -1
- package/lib/commonjs/ui/components/authChooser/requestSurfaces.js +12 -8
- package/lib/commonjs/ui/components/authChooser/requestSurfaces.js.map +1 -1
- package/lib/commonjs/ui/components/authChooser/types.js +11 -0
- package/lib/commonjs/ui/components/authChooser/types.js.map +1 -1
- package/lib/module/index.js +18 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/notifications/deviceNotifications.js +414 -0
- package/lib/module/notifications/deviceNotifications.js.map +1 -0
- package/lib/module/ui/components/OxyAuthChooser.js +5 -4
- package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/module/ui/components/OxySignInRequestSurface.js +141 -0
- package/lib/module/ui/components/OxySignInRequestSurface.js.map +1 -0
- package/lib/module/ui/components/authChooser/SignInEntryView.js.map +1 -1
- package/lib/module/ui/components/authChooser/SignInRequestView.js +66 -104
- package/lib/module/ui/components/authChooser/SignInRequestView.js.map +1 -1
- package/lib/module/ui/components/authChooser/TroubleDisclosure.js +0 -2
- package/lib/module/ui/components/authChooser/TroubleDisclosure.js.map +1 -1
- package/lib/module/ui/components/authChooser/requestSurfaces.js +12 -8
- package/lib/module/ui/components/authChooser/requestSurfaces.js.map +1 -1
- package/lib/module/ui/components/authChooser/types.js +11 -0
- package/lib/module/ui/components/authChooser/types.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +5 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/notifications/deviceNotifications.d.ts +173 -0
- package/lib/typescript/commonjs/notifications/deviceNotifications.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts +5 -3
- package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxySignInRequestSurface.d.ts +108 -0
- package/lib/typescript/commonjs/ui/components/OxySignInRequestSurface.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/authChooser/SignInEntryView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/authChooser/SignInRequestView.d.ts +18 -18
- package/lib/typescript/commonjs/ui/components/authChooser/SignInRequestView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/authChooser/TroubleDisclosure.d.ts +3 -10
- package/lib/typescript/commonjs/ui/components/authChooser/TroubleDisclosure.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/authChooser/requestSurfaces.d.ts +7 -3
- package/lib/typescript/commonjs/ui/components/authChooser/requestSurfaces.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts +18 -0
- package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +5 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/notifications/deviceNotifications.d.ts +173 -0
- package/lib/typescript/module/notifications/deviceNotifications.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts +5 -3
- package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxySignInRequestSurface.d.ts +108 -0
- package/lib/typescript/module/ui/components/OxySignInRequestSurface.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/authChooser/SignInEntryView.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/authChooser/SignInRequestView.d.ts +18 -18
- package/lib/typescript/module/ui/components/authChooser/SignInRequestView.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/authChooser/TroubleDisclosure.d.ts +3 -10
- package/lib/typescript/module/ui/components/authChooser/TroubleDisclosure.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/authChooser/requestSurfaces.d.ts +7 -3
- package/lib/typescript/module/ui/components/authChooser/requestSurfaces.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/authChooser/types.d.ts +18 -0
- package/lib/typescript/module/ui/components/authChooser/types.d.ts.map +1 -1
- package/package.json +15 -5
- package/src/index.ts +32 -0
- package/src/notifications/deviceNotifications.ts +458 -0
- package/src/ui/components/OxyAuthChooser.tsx +5 -4
- package/src/ui/components/OxySignInRequestSurface.tsx +202 -0
- package/src/ui/components/authChooser/SignInEntryView.tsx +9 -3
- package/src/ui/components/authChooser/SignInRequestView.tsx +78 -111
- package/src/ui/components/authChooser/TroubleDisclosure.tsx +3 -11
- package/src/ui/components/authChooser/requestSurfaces.tsx +14 -10
- package/src/ui/components/authChooser/types.ts +19 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE place `@oxyhq/services` touches `expo-notifications`.
|
|
3
|
+
*
|
|
4
|
+
* Every Oxy app that receives push needs the identical adapter — native-only,
|
|
5
|
+
* dynamically imported, reading the OS permission and minting an EXPO push
|
|
6
|
+
* token — so it lives here instead of being copied into each app. The
|
|
7
|
+
* bearer-authenticated transport (`registerPushToken` / `unregisterPushToken`)
|
|
8
|
+
* already lives in `@oxyhq/core`; core may never import an `expo-*` module, and
|
|
9
|
+
* this adapter is the Expo-side half that closes that gap.
|
|
10
|
+
*
|
|
11
|
+
* ## Native-only by construction
|
|
12
|
+
*
|
|
13
|
+
* Every entry point resolves its null/no-op result from `Platform.OS` BEFORE the
|
|
14
|
+
* dynamic `import()`, so a web bundle never requests `expo-notifications` at all.
|
|
15
|
+
* `expo-notifications` and `expo-constants` are OPTIONAL peer dependencies: an
|
|
16
|
+
* app that does not use push never installs them, and a build without them
|
|
17
|
+
* degrades to "notifications unavailable" rather than throwing at
|
|
18
|
+
* module-evaluation time.
|
|
19
|
+
*
|
|
20
|
+
* ## The token is an EXPO push token — never a raw device token
|
|
21
|
+
*
|
|
22
|
+
* {@link getExpoPushToken} calls `getExpoPushTokenAsync()`, which returns the
|
|
23
|
+
* `ExponentPushToken[…]` handle Expo's push service delivers through — the only
|
|
24
|
+
* form it accepts. `getDevicePushTokenAsync()` — the raw APNs/FCM token — is
|
|
25
|
+
* deliberately NOT used anywhere: registering one of those looks entirely
|
|
26
|
+
* successful (the row is stored, the endpoint returns 200) and then every push
|
|
27
|
+
* silently fails at delivery time. `@oxyhq/core`'s `registerPushToken` rejects a
|
|
28
|
+
* raw device token before sending, and this module is the reason it never has to.
|
|
29
|
+
*
|
|
30
|
+
* ## The payload is untrusted
|
|
31
|
+
*
|
|
32
|
+
* Nothing here surfaces a notification's `title` / `body` / `subtitle`. The only
|
|
33
|
+
* payload that leaves this module is the raw `content.data` — the routing keys —
|
|
34
|
+
* typed `unknown`, so a caller has to validate it before acting on it and there
|
|
35
|
+
* is no exported shape that invites rendering push-delivered text. The
|
|
36
|
+
* foreground handler likewise answers with a visibility verdict and nothing else.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { createLogger } from '@oxyhq/core';
|
|
40
|
+
import type { PushTokenPlatform } from '@oxyhq/core';
|
|
41
|
+
import { Platform } from 'react-native';
|
|
42
|
+
|
|
43
|
+
const log = createLogger('deviceNotifications');
|
|
44
|
+
|
|
45
|
+
type NotificationsModule = typeof import('expo-notifications');
|
|
46
|
+
type ConstantsModule = typeof import('expo-constants');
|
|
47
|
+
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return typeof value === 'object' && value !== null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Load `expo-notifications`, or `null` when it cannot serve this platform/build.
|
|
54
|
+
*
|
|
55
|
+
* The web guard runs before the `import()`, so a web bundle never reaches for the
|
|
56
|
+
* native module. The dynamic import is intentional: the module registry caches
|
|
57
|
+
* it, so repeated calls are cheap and no module-level mutable cache is
|
|
58
|
+
* introduced here.
|
|
59
|
+
*/
|
|
60
|
+
async function loadNotifications(): Promise<NotificationsModule | null> {
|
|
61
|
+
if (Platform.OS === 'web') {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return await import('expo-notifications');
|
|
66
|
+
} catch (error) {
|
|
67
|
+
log.warn(
|
|
68
|
+
'expo-notifications is unavailable in this build — push is disabled',
|
|
69
|
+
{ method: 'loadNotifications' },
|
|
70
|
+
error,
|
|
71
|
+
);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The EAS project id an Expo push token is minted against, or `null` when the
|
|
78
|
+
* app config carries none.
|
|
79
|
+
*
|
|
80
|
+
* Resolved exactly the way `expo-notifications` resolves it internally
|
|
81
|
+
* (`easConfig.projectId`, then `expoConfig.extra.eas.projectId`) and then handed
|
|
82
|
+
* BACK to `getExpoPushTokenAsync` explicitly, so the two can never disagree.
|
|
83
|
+
* Reading it here is what turns "no project id" from an opaque
|
|
84
|
+
* `ERR_NOTIFICATIONS_NO_EXPERIENCE_ID` rejection into a named, actionable state
|
|
85
|
+
* instead of one more entry in the indistinguishable "permission denied /
|
|
86
|
+
* offline" bucket.
|
|
87
|
+
*
|
|
88
|
+
* A missing `expo-constants` lands on the same `null` outcome — the single
|
|
89
|
+
* actionable warning is emitted once by the caller, so this path only records
|
|
90
|
+
* the extra detail at debug level rather than warning twice for one condition.
|
|
91
|
+
*/
|
|
92
|
+
async function easProjectId(): Promise<string | null> {
|
|
93
|
+
let constants: ConstantsModule['default'];
|
|
94
|
+
try {
|
|
95
|
+
constants = (await import('expo-constants')).default;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
log.debug(
|
|
98
|
+
'expo-constants is unavailable in this build — no EAS project id can be resolved',
|
|
99
|
+
{ method: 'easProjectId' },
|
|
100
|
+
error,
|
|
101
|
+
);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const fromEasConfig = constants.easConfig?.projectId;
|
|
106
|
+
if (typeof fromEasConfig === 'string' && fromEasConfig.length > 0) {
|
|
107
|
+
return fromEasConfig;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const extra: unknown = constants.expoConfig?.extra;
|
|
111
|
+
const eas = isRecord(extra) ? extra.eas : undefined;
|
|
112
|
+
const projectId = isRecord(eas) ? eas.projectId : undefined;
|
|
113
|
+
return typeof projectId === 'string' && projectId.length > 0 ? projectId : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The platform tag the push-token registry stores this installation under, or
|
|
118
|
+
* `null` on a platform Oxy does not deliver push to.
|
|
119
|
+
*
|
|
120
|
+
* Synchronous and import-free — the answer is a property of the bundle, not of
|
|
121
|
+
* any native module.
|
|
122
|
+
*
|
|
123
|
+
* Web is `null` on purpose: the registry accepts a `web` platform, but browser
|
|
124
|
+
* push needs a VAPID key + service-worker subscription no Oxy app has wired, so
|
|
125
|
+
* there is no token to register and claiming otherwise would store a row nothing
|
|
126
|
+
* can ever deliver to.
|
|
127
|
+
*/
|
|
128
|
+
export function pushTokenPlatform(): PushTokenPlatform | null {
|
|
129
|
+
return Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether the OS notification permission is ALREADY granted.
|
|
134
|
+
*
|
|
135
|
+
* Never prompts, so an app can re-check on every cold boot without ever asking
|
|
136
|
+
* the user twice. Use {@link requestNotificationPermission} for the one place
|
|
137
|
+
* that is allowed to show the system dialog.
|
|
138
|
+
*/
|
|
139
|
+
export async function hasNotificationPermission(): Promise<boolean> {
|
|
140
|
+
const notifications = await loadNotifications();
|
|
141
|
+
if (!notifications) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const permissions = await notifications.getPermissionsAsync();
|
|
146
|
+
return permissions.granted === true;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
log.warn(
|
|
149
|
+
'could not read the notification permission',
|
|
150
|
+
{ method: 'hasNotificationPermission' },
|
|
151
|
+
error,
|
|
152
|
+
);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Ensure the OS notification permission, prompting at most once.
|
|
159
|
+
*
|
|
160
|
+
* An installation that already answered the system dialog is never asked again:
|
|
161
|
+
* an already-granted permission resolves `true` without a second dialog, and a
|
|
162
|
+
* denial with `canAskAgain: false` resolves `false` without issuing a request
|
|
163
|
+
* the OS would silently ignore.
|
|
164
|
+
*
|
|
165
|
+
* @returns Whether notifications are granted once this call is done.
|
|
166
|
+
*/
|
|
167
|
+
export async function requestNotificationPermission(): Promise<boolean> {
|
|
168
|
+
const notifications = await loadNotifications();
|
|
169
|
+
if (!notifications) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const existing = await notifications.getPermissionsAsync();
|
|
174
|
+
if (existing.granted === true) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (existing.canAskAgain === false) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
const requested = await notifications.requestPermissionsAsync();
|
|
181
|
+
return requested.granted === true;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
log.warn(
|
|
184
|
+
'the notification permission could not be resolved',
|
|
185
|
+
{ method: 'requestNotificationPermission' },
|
|
186
|
+
error,
|
|
187
|
+
);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* This installation's Expo push token (`ExponentPushToken[…]`), or `null` when
|
|
194
|
+
* one cannot be minted.
|
|
195
|
+
*
|
|
196
|
+
* Deliberately `getExpoPushTokenAsync` — see the module header for why the raw
|
|
197
|
+
* device-token variant is never used.
|
|
198
|
+
*
|
|
199
|
+
* Every `null` is logged with the reason it happened. A missing EAS project id
|
|
200
|
+
* in particular is a BUILD MISCONFIGURATION, not a user state: no token can ever
|
|
201
|
+
* be minted, so push is off for every install of that build until the config is
|
|
202
|
+
* fixed. It is reported as such rather than blending into the
|
|
203
|
+
* indistinguishable "permission denied / offline" bucket.
|
|
204
|
+
*/
|
|
205
|
+
export async function getExpoPushToken(): Promise<string | null> {
|
|
206
|
+
const notifications = await loadNotifications();
|
|
207
|
+
if (!notifications) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const projectId = await easProjectId();
|
|
212
|
+
if (!projectId) {
|
|
213
|
+
log.warn(
|
|
214
|
+
'no EAS project id in the app config (expo.extra.eas.projectId) — an Expo push token cannot be minted, so push is disabled for this build',
|
|
215
|
+
{ method: 'getExpoPushToken' },
|
|
216
|
+
);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const token = await notifications.getExpoPushTokenAsync({ projectId });
|
|
222
|
+
if (token.data.length === 0) {
|
|
223
|
+
log.warn('the Expo push service returned an empty token', { method: 'getExpoPushToken' });
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
return token.data;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
log.warn('could not mint an Expo push token', { method: 'getExpoPushToken' }, error);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Take the payload of the notification that COLD-LAUNCHED the app, if any.
|
|
235
|
+
*
|
|
236
|
+
* "Take" is literal: the response is cleared from `expo-notifications` once
|
|
237
|
+
* read, so a listener attached later in the same launch is not handed the same
|
|
238
|
+
* tap again. Clearing is best-effort across OS/module versions, so a caller that
|
|
239
|
+
* must not act twice still needs its own idempotency ledger.
|
|
240
|
+
*
|
|
241
|
+
* @returns The raw, untrusted `content.data` of the launching notification, or
|
|
242
|
+
* `null`. Parsing/validation is the caller's job.
|
|
243
|
+
*/
|
|
244
|
+
export async function takeLaunchNotificationData(): Promise<unknown> {
|
|
245
|
+
const notifications = await loadNotifications();
|
|
246
|
+
if (!notifications) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const response = notifications.getLastNotificationResponse();
|
|
251
|
+
if (!response) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const data: unknown = response.notification.request.content.data;
|
|
255
|
+
try {
|
|
256
|
+
notifications.clearLastNotificationResponse();
|
|
257
|
+
} catch (clearError) {
|
|
258
|
+
log.warn(
|
|
259
|
+
'could not clear the launching notification response',
|
|
260
|
+
{ method: 'takeLaunchNotificationData' },
|
|
261
|
+
clearError,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return data;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
log.warn(
|
|
267
|
+
'could not read the launching notification',
|
|
268
|
+
{ method: 'takeLaunchNotificationData' },
|
|
269
|
+
error,
|
|
270
|
+
);
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* What the OS should do with a notification that arrives while the app is
|
|
277
|
+
* FOREGROUNDED.
|
|
278
|
+
*
|
|
279
|
+
* `'suppress'` is `expo-notifications`' own default for a foregrounded app, so a
|
|
280
|
+
* decision function that only ever returns `'show'` for the payloads it
|
|
281
|
+
* recognises leaves every other notification exactly as it behaves today.
|
|
282
|
+
*/
|
|
283
|
+
export type ForegroundPresentation = 'show' | 'suppress';
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Whether the process-wide foreground handler has already been ATTEMPTED.
|
|
287
|
+
*
|
|
288
|
+
* `setNotificationHandler` is global, last-writer-wins process state, so this is
|
|
289
|
+
* a one-shot install rather than something a mount/unmount cycle owns. A failed
|
|
290
|
+
* attempt is deliberately not retried: the only failure modes are permanent for
|
|
291
|
+
* the life of the process (web, a build without the native module, a native
|
|
292
|
+
* module that threw on registration).
|
|
293
|
+
*
|
|
294
|
+
* Module-scoped and only ever touched from an async install call — never read
|
|
295
|
+
* during render, so the React Compiler has no memoizable position to freeze it
|
|
296
|
+
* into.
|
|
297
|
+
*/
|
|
298
|
+
let foregroundHandlerAttempted = false;
|
|
299
|
+
|
|
300
|
+
/** How prominently Android surfaces a channel's notifications. */
|
|
301
|
+
export type NotificationChannelImportance = 'default' | 'high';
|
|
302
|
+
|
|
303
|
+
/** An Android notification channel this app delivers on. */
|
|
304
|
+
export interface NotificationChannelSpec {
|
|
305
|
+
/**
|
|
306
|
+
* The channel id. It MUST match the id the sender attaches: Android 8+ drops
|
|
307
|
+
* a notification whose channel the app has not created, silently and with no
|
|
308
|
+
* error on either side, which is the hardest push symptom there is to
|
|
309
|
+
* diagnose. Ids that cross the wire belong in `@oxyhq/contracts`, not in a
|
|
310
|
+
* string typed on both sides.
|
|
311
|
+
*/
|
|
312
|
+
id: string;
|
|
313
|
+
/** User-visible channel name — app copy, so the caller localizes it. */
|
|
314
|
+
name: string;
|
|
315
|
+
/** User-visible description shown in Android's notification settings. */
|
|
316
|
+
description?: string;
|
|
317
|
+
importance?: NotificationChannelImportance;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Create (or update) an Android notification channel.
|
|
322
|
+
*
|
|
323
|
+
* A no-op everywhere but Android: iOS has no channels, and web never loads the
|
|
324
|
+
* native module. Call it BEFORE registering a push token, so the very first
|
|
325
|
+
* notification the server sends already has a channel to land on.
|
|
326
|
+
*
|
|
327
|
+
* Failure is non-fatal and logged: a missing channel costs visibility, and
|
|
328
|
+
* refusing to register a token over it would cost the feature entirely.
|
|
329
|
+
*
|
|
330
|
+
* @returns Whether the channel was actually created (`false` off Android, or
|
|
331
|
+
* when the native module is unavailable, or when creation failed).
|
|
332
|
+
*/
|
|
333
|
+
export async function ensureNotificationChannel(
|
|
334
|
+
spec: NotificationChannelSpec,
|
|
335
|
+
): Promise<boolean> {
|
|
336
|
+
if (Platform.OS !== 'android') {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
const notifications = await loadNotifications();
|
|
340
|
+
if (!notifications) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
await notifications.setNotificationChannelAsync(spec.id, {
|
|
345
|
+
name: spec.name,
|
|
346
|
+
importance:
|
|
347
|
+
spec.importance === 'high'
|
|
348
|
+
? notifications.AndroidImportance.HIGH
|
|
349
|
+
: notifications.AndroidImportance.DEFAULT,
|
|
350
|
+
...(spec.description ? { description: spec.description } : {}),
|
|
351
|
+
});
|
|
352
|
+
return true;
|
|
353
|
+
} catch (error) {
|
|
354
|
+
log.warn(
|
|
355
|
+
'could not create the notification channel',
|
|
356
|
+
{ method: 'ensureNotificationChannel', channelId: spec.id },
|
|
357
|
+
error,
|
|
358
|
+
);
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Install the ONE process-wide handler that decides whether an incoming
|
|
365
|
+
* notification is shown while the app is in the FOREGROUND.
|
|
366
|
+
*
|
|
367
|
+
* Without a handler, `expo-notifications` shows nothing at all while the app is
|
|
368
|
+
* open — which is exactly when a time-critical notification (a "Sign in with
|
|
369
|
+
* Oxy" approval request, say) matters most, because the user is already holding
|
|
370
|
+
* the phone.
|
|
371
|
+
*
|
|
372
|
+
* The handler ONLY decides visibility. It never navigates, never acts on the
|
|
373
|
+
* notification, and never reads the payload for display — `decide` is handed the
|
|
374
|
+
* raw, untrusted `content.data` and may answer nothing but `'show'` /
|
|
375
|
+
* `'suppress'`. Acting on a notification stays with a TAP, routed by
|
|
376
|
+
* {@link subscribeToNotificationResponses}. No notification actions/categories
|
|
377
|
+
* are registered here, so the banner cannot carry an action button.
|
|
378
|
+
*
|
|
379
|
+
* The one-shot latch is set BEFORE the first `await`, so concurrent callers
|
|
380
|
+
* cannot both reach `setNotificationHandler`.
|
|
381
|
+
*
|
|
382
|
+
* @param decide - Maps the raw, untrusted payload to a presentation verdict.
|
|
383
|
+
* Must not throw; a throw is reported by the OS as a handling error and the
|
|
384
|
+
* notification is dropped.
|
|
385
|
+
* @returns Whether a handler was installed by THIS call (`false` on a repeat
|
|
386
|
+
* call, on web, or when the native module is unavailable).
|
|
387
|
+
*/
|
|
388
|
+
export async function installForegroundNotificationHandler(
|
|
389
|
+
decide: (data: unknown) => ForegroundPresentation,
|
|
390
|
+
): Promise<boolean> {
|
|
391
|
+
if (foregroundHandlerAttempted) {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
foregroundHandlerAttempted = true;
|
|
395
|
+
|
|
396
|
+
const notifications = await loadNotifications();
|
|
397
|
+
if (!notifications) {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
notifications.setNotificationHandler({
|
|
402
|
+
handleNotification: async (notification) => {
|
|
403
|
+
const data: unknown = notification.request.content.data;
|
|
404
|
+
const show = decide(data) === 'show';
|
|
405
|
+
return {
|
|
406
|
+
// Banner: the whole point — make the notification visible on screen.
|
|
407
|
+
shouldShowBanner: show,
|
|
408
|
+
// Notification centre: so the user can still review it after
|
|
409
|
+
// dismissing the banner.
|
|
410
|
+
shouldShowList: show,
|
|
411
|
+
// No sound: the app is foregrounded, so the user is already looking at
|
|
412
|
+
// the screen the banner appears on.
|
|
413
|
+
shouldPlaySound: false,
|
|
414
|
+
// Badging is an app-level unread concept this adapter has no view of.
|
|
415
|
+
shouldSetBadge: false,
|
|
416
|
+
};
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
return true;
|
|
420
|
+
} catch (error) {
|
|
421
|
+
log.warn(
|
|
422
|
+
'could not install the foreground notification handler',
|
|
423
|
+
{ method: 'installForegroundNotificationHandler' },
|
|
424
|
+
error,
|
|
425
|
+
);
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Subscribe to notification TAPS (responses) while the app is running.
|
|
432
|
+
*
|
|
433
|
+
* @param listener - Receives the raw, untrusted `content.data` of the tapped
|
|
434
|
+
* notification. Parsing/validation is the caller's job.
|
|
435
|
+
* @returns An unsubscribe function. Safe to call even if the subscription could
|
|
436
|
+
* not be established.
|
|
437
|
+
*/
|
|
438
|
+
export async function subscribeToNotificationResponses(
|
|
439
|
+
listener: (data: unknown) => void,
|
|
440
|
+
): Promise<() => void> {
|
|
441
|
+
const notifications = await loadNotifications();
|
|
442
|
+
if (!notifications) {
|
|
443
|
+
return () => undefined;
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const subscription = notifications.addNotificationResponseReceivedListener((response) => {
|
|
447
|
+
listener(response.notification.request.content.data);
|
|
448
|
+
});
|
|
449
|
+
return () => subscription.remove();
|
|
450
|
+
} catch (error) {
|
|
451
|
+
log.warn(
|
|
452
|
+
'could not subscribe to notification taps',
|
|
453
|
+
{ method: 'subscribeToNotificationResponses' },
|
|
454
|
+
error,
|
|
455
|
+
);
|
|
456
|
+
return () => undefined;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
* auto-starts the instant the view is reached, because the request surface
|
|
21
21
|
* (a QR on an unknown desktop, "Check Commons on your phone" where Oxy could
|
|
22
22
|
* deliver) IS the primary route there, never something behind a second tap.
|
|
23
|
-
* - `qr` → `SignInRequestView` — the ACTIVE REQUEST: the
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* - `qr` → `SignInRequestView` — the ACTIVE REQUEST: the controller-bound
|
|
24
|
+
* wiring over the shared, presentational `OxySignInRequestSurface` (the same
|
|
25
|
+
* component the auth.oxy.so IdP mounts from its OAuth-bound request). It maps
|
|
26
|
+
* `snapshot.signIn` onto that surface's props; alternatives stay behind
|
|
27
|
+
* "Having trouble?" until the chosen route reports `routeFailed`.
|
|
26
28
|
* - `signup` → `SignUpView` — account creation, Commons-first.
|
|
27
29
|
*
|
|
28
30
|
* Per-account color re-theming uses Bloom's `APP_COLOR_PRESETS` + `BloomColorScope`
|
|
@@ -376,7 +378,6 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
|
|
|
376
378
|
return (
|
|
377
379
|
<SignInRequestView
|
|
378
380
|
snapshot={snapshot}
|
|
379
|
-
theme={theme}
|
|
380
381
|
t={t}
|
|
381
382
|
onRetry={() => void controller.showQr()}
|
|
382
383
|
alternatives={alternatives}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OxySignInRequestSurface` — the in-flight "Sign in with Oxy" request, rendered
|
|
3
|
+
* from plain facts (issue #691, Phase 5).
|
|
4
|
+
*
|
|
5
|
+
* The user already performed their one action. From here the surface only
|
|
6
|
+
* REPORTS: the route-appropriate primary visual, the status line for the
|
|
7
|
+
* progress it was told about, and nothing else at the same weight. Alternatives
|
|
8
|
+
* stay behind "Having trouble?" until the chosen route could not be carried out,
|
|
9
|
+
* at which point they ARE the content and render plainly.
|
|
10
|
+
*
|
|
11
|
+
* PRESENTATIONAL AND HEADLESS-FRIENDLY. It owns no request, no controller, no
|
|
12
|
+
* polling, and no state of its own: everything it draws is a prop. That is what
|
|
13
|
+
* lets the two hosts of this surface share ONE implementation —
|
|
14
|
+
*
|
|
15
|
+
* - `OxyAuthChooser`'s `SignInRequestView`, driven by `AccountDialogController`'s
|
|
16
|
+
* device flow;
|
|
17
|
+
* - the auth.oxy.so IdP's OAuth-bound lane, whose request carries an OAuth
|
|
18
|
+
* binding the account dialog's session has no concept of.
|
|
19
|
+
*
|
|
20
|
+
* It resolves only what every `@oxyhq/services` consumer already has in context:
|
|
21
|
+
* the Bloom theme (`useTheme`) and the SDK's own locale dictionary (`useI18n`,
|
|
22
|
+
* the `accountSwitcher.*` copy in all 11 locales). Host-specific copy — the
|
|
23
|
+
* labels on {@link OxySignInRequestSurfaceProps.subordinate} and
|
|
24
|
+
* {@link OxySignInRequestSurfaceProps.alternatives} — arrives already localized.
|
|
25
|
+
*
|
|
26
|
+
* TWO THINGS IT DELIBERATELY DOES NOT DO:
|
|
27
|
+
* - it renders no failure REASON. A host surfaces that the way its own shell
|
|
28
|
+
* demands (the account dialog toasts it — errors never render inline there;
|
|
29
|
+
* a full page may show a banner around this surface). The surface only takes
|
|
30
|
+
* the fact that the request failed, and offers the way forward.
|
|
31
|
+
* - it derives nothing from {@link OxySignInRequestSurfaceProps.qrPayload}.
|
|
32
|
+
* Application identity, origin, and scopes are resolved server-side by the
|
|
33
|
+
* approver (Commons), never read out of a payload here — the plate encodes it
|
|
34
|
+
* and shows nothing about it.
|
|
35
|
+
*
|
|
36
|
+
* SECRETS: every prop is safe to render. There is no `sessionToken` (the secret
|
|
37
|
+
* claim/finalize credential), no bearer, and no authorization code — a host
|
|
38
|
+
* keeps those in its own controller, and no prop on this component invites one.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type React from 'react';
|
|
42
|
+
import { View } from 'react-native';
|
|
43
|
+
import { Button } from '@oxyhq/bloom/button';
|
|
44
|
+
import { useTheme } from '@oxyhq/bloom/theme';
|
|
45
|
+
import { Text } from '@oxyhq/bloom/typography';
|
|
46
|
+
import type { CommonsDeliveryRoute, SignInProgress } from '@oxyhq/core';
|
|
47
|
+
import { useI18n } from '../hooks/useI18n';
|
|
48
|
+
import TroubleDisclosure from './authChooser/TroubleDisclosure';
|
|
49
|
+
import { SubtleLink } from './authChooser/primitives';
|
|
50
|
+
import { GetCommonsPrompt, RequestPrimarySurface } from './authChooser/requestSurfaces';
|
|
51
|
+
import { signInProgressLabel } from './authChooser/signInProgress';
|
|
52
|
+
import { authChooserStyles as styles } from './authChooser/styles';
|
|
53
|
+
import type { OxySignInSurfaceAction } from './authChooser/types';
|
|
54
|
+
|
|
55
|
+
/** Shared empty list, so an omitted action bundle allocates nothing per render. */
|
|
56
|
+
const NO_ACTIONS: readonly OxySignInSurfaceAction[] = [];
|
|
57
|
+
|
|
58
|
+
export interface OxySignInRequestSurfaceProps {
|
|
59
|
+
/**
|
|
60
|
+
* The ONE primary delivery route chosen for this request, or `null` while it
|
|
61
|
+
* is still being resolved. There is no chain: the surface renders exactly one
|
|
62
|
+
* route's visual, and never guesses one that has not been chosen yet.
|
|
63
|
+
*/
|
|
64
|
+
route: CommonsDeliveryRoute | null;
|
|
65
|
+
/**
|
|
66
|
+
* The request's DERIVED progress — the only thing the status line is built
|
|
67
|
+
* from. It must come from a real observed signal (a server-reported delivery,
|
|
68
|
+
* an approver opening the request, an approval); the surface owns no clock and
|
|
69
|
+
* will never advance it on its own. `'idle'` means there is nothing honest to
|
|
70
|
+
* report, and renders no status line.
|
|
71
|
+
*/
|
|
72
|
+
progress: SignInProgress;
|
|
73
|
+
/**
|
|
74
|
+
* The PUBLIC `oxycommons://approve?…` payload to encode as the QR, or `null`.
|
|
75
|
+
* It carries the single-use approval handle — never a secret credential — and
|
|
76
|
+
* the surface renders NOTHING derived from it.
|
|
77
|
+
*/
|
|
78
|
+
qrPayload?: string | null;
|
|
79
|
+
/**
|
|
80
|
+
* `true` when the chosen primary route could not be carried out on this device.
|
|
81
|
+
* The one signal that reveals {@link alternatives} without the user asking;
|
|
82
|
+
* a host never silently cascades to another route on its own.
|
|
83
|
+
*/
|
|
84
|
+
routeFailed?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* `true` when the request itself is terminally failed — there is no working
|
|
87
|
+
* primary route left to report on, so {@link onRetry} becomes the surface's one
|
|
88
|
+
* primary action and the alternatives render plainly.
|
|
89
|
+
*/
|
|
90
|
+
failed?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Start a BRAND-NEW request. Rendered as the primary action while
|
|
93
|
+
* {@link failed}; omit it when a fresh attempt cannot help (a request that
|
|
94
|
+
* would fail identically every time), leaving the alternatives as the only
|
|
95
|
+
* way forward.
|
|
96
|
+
*/
|
|
97
|
+
onRetry?: () => void;
|
|
98
|
+
/**
|
|
99
|
+
* The acquisition action for a device where Commons is known to be ABSENT.
|
|
100
|
+
*
|
|
101
|
+
* Pass it ONLY on a positive "not installed" verdict. There, acquiring Commons
|
|
102
|
+
* is the GENUINE primary route — a same-device QR the user would have to scan
|
|
103
|
+
* with the very screen showing it is a dead end — so the surface leads with it
|
|
104
|
+
* and reports no route progress. Omit it wherever absence cannot be known (a
|
|
105
|
+
* browser cannot ask whether a URL scheme is registered).
|
|
106
|
+
*/
|
|
107
|
+
onAcquireCommons?: () => void;
|
|
108
|
+
/**
|
|
109
|
+
* Subordinate links rendered directly beneath the primary, always visible:
|
|
110
|
+
* the ways OUT of the surface that are not troubleshooting (creating an
|
|
111
|
+
* account, cancelling the request).
|
|
112
|
+
*/
|
|
113
|
+
subordinate?: readonly OxySignInSurfaceAction[];
|
|
114
|
+
/**
|
|
115
|
+
* The alternative ways to authenticate, kept behind "Having trouble?" until
|
|
116
|
+
* the user asks — or shown plainly once {@link failed} / {@link routeFailed}
|
|
117
|
+
* says the chosen route cannot deliver.
|
|
118
|
+
*/
|
|
119
|
+
alternatives?: readonly OxySignInSurfaceAction[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const OxySignInRequestSurface: React.FC<OxySignInRequestSurfaceProps> = ({
|
|
123
|
+
route,
|
|
124
|
+
progress,
|
|
125
|
+
qrPayload = null,
|
|
126
|
+
routeFailed = false,
|
|
127
|
+
failed = false,
|
|
128
|
+
onRetry,
|
|
129
|
+
onAcquireCommons,
|
|
130
|
+
subordinate = NO_ACTIONS,
|
|
131
|
+
alternatives = NO_ACTIONS,
|
|
132
|
+
}) => {
|
|
133
|
+
const theme = useTheme();
|
|
134
|
+
const { t } = useI18n();
|
|
135
|
+
|
|
136
|
+
const subordinateLinks = subordinate.map((action) => (
|
|
137
|
+
<SubtleLink
|
|
138
|
+
key={action.key}
|
|
139
|
+
label={action.label}
|
|
140
|
+
theme={theme}
|
|
141
|
+
onPress={action.onPress}
|
|
142
|
+
disabled={action.disabled}
|
|
143
|
+
testID={action.key}
|
|
144
|
+
/>
|
|
145
|
+
));
|
|
146
|
+
|
|
147
|
+
// A failed request has no working primary route left. Its REASON is the host's
|
|
148
|
+
// to surface (toast / banner); all this owes the user is the way forward.
|
|
149
|
+
if (failed) {
|
|
150
|
+
return (
|
|
151
|
+
<View style={styles.centeredBlock}>
|
|
152
|
+
{onRetry ? (
|
|
153
|
+
<Button variant="primary" onPress={onRetry} style={styles.primaryButton}>
|
|
154
|
+
{t('common.actions.tryAgain')}
|
|
155
|
+
</Button>
|
|
156
|
+
) : null}
|
|
157
|
+
{subordinateLinks}
|
|
158
|
+
<TroubleDisclosure actions={alternatives} revealed theme={theme} t={t} />
|
|
159
|
+
</View>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Commons is absent: acquiring it IS the primary route, not a fallback, so it
|
|
164
|
+
// renders as the one primary action and no route progress is reported (there is
|
|
165
|
+
// no route to report on until the host says otherwise).
|
|
166
|
+
if (onAcquireCommons) {
|
|
167
|
+
return (
|
|
168
|
+
<View style={styles.centeredBlock}>
|
|
169
|
+
<GetCommonsPrompt theme={theme} t={t} onGetCommons={onAcquireCommons} />
|
|
170
|
+
{subordinateLinks}
|
|
171
|
+
<TroubleDisclosure actions={alternatives} revealed={false} theme={theme} t={t} />
|
|
172
|
+
</View>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const status = signInProgressLabel(progress, route, t);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<View style={styles.centeredBlock}>
|
|
180
|
+
<RequestPrimarySurface
|
|
181
|
+
route={route}
|
|
182
|
+
progress={progress}
|
|
183
|
+
qrPayload={qrPayload}
|
|
184
|
+
theme={theme}
|
|
185
|
+
t={t}
|
|
186
|
+
/>
|
|
187
|
+
{status ? (
|
|
188
|
+
<Text
|
|
189
|
+
style={[styles.mutedText, { color: theme.colors.textSecondary }]}
|
|
190
|
+
accessibilityLiveRegion="polite"
|
|
191
|
+
testID="signin-progress"
|
|
192
|
+
>
|
|
193
|
+
{status}
|
|
194
|
+
</Text>
|
|
195
|
+
) : null}
|
|
196
|
+
{subordinateLinks}
|
|
197
|
+
<TroubleDisclosure actions={alternatives} revealed={routeFailed} theme={theme} t={t} />
|
|
198
|
+
</View>
|
|
199
|
+
);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export default OxySignInRequestSurface;
|