@webority/mobile-core 0.0.27 → 0.0.28
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/README.md +69 -1
- package/lib/commonjs/push/index.js +18 -0
- package/lib/commonjs/push/push.js +177 -15
- package/lib/commonjs/push/task.js +43 -0
- package/lib/module/push/index.js +1 -1
- package/lib/module/push/push.js +170 -15
- package/lib/module/push/task.js +38 -0
- package/lib/typescript/commonjs/push/index.d.ts +2 -2
- package/lib/typescript/commonjs/push/push.d.ts +88 -0
- package/lib/typescript/commonjs/push/task.d.ts +15 -0
- package/lib/typescript/module/push/index.d.ts +2 -2
- package/lib/typescript/module/push/push.d.ts +88 -0
- package/lib/typescript/module/push/task.d.ts +15 -0
- package/package.json +15 -1
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ import { openDatabase } from '@webority/mobile-core/sqlite';
|
|
|
30
30
|
import { downloadFile } from '@webority/mobile-core/download';
|
|
31
31
|
import { shareFile } from '@webority/mobile-core/share';
|
|
32
32
|
import { initPush, enablePush, usePush } from '@webority/mobile-core/push';
|
|
33
|
+
import { definePushActionTask } from '@webority/mobile-core/push/task';
|
|
33
34
|
import { compressImage } from '@webority/mobile-core/compressor';
|
|
34
35
|
```
|
|
35
36
|
|
|
@@ -43,7 +44,74 @@ the native token (`Fcm` on Android, `Apns` on iOS) and the bundle id to `registe
|
|
|
43
44
|
re-registers when the token changes; `disablePush` calls `unregister`. `onOpen` receives the
|
|
44
45
|
tapped notification's data as strings, including the tap that launched the app. `usePush` gives a
|
|
45
46
|
settings switch its `status` and `enabled`. The library does not remember the switch: an app that
|
|
46
|
-
keeps push on calls `enablePush` at each start.
|
|
47
|
+
keeps push on calls `enablePush` at each start. `setBadgeCount(count)` sets the app icon badge.
|
|
48
|
+
|
|
49
|
+
### Notification buttons
|
|
50
|
+
|
|
51
|
+
A push names a category; the category's buttons are registered on the phone. Register them at
|
|
52
|
+
every start, with the same ids on both platforms:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { setCategories, notifyLocal } from '@webority/mobile-core/push';
|
|
56
|
+
|
|
57
|
+
await setCategories([
|
|
58
|
+
{
|
|
59
|
+
id: 'approval',
|
|
60
|
+
actions: [
|
|
61
|
+
{ id: 'approve', title: 'Approve', opensApp: false },
|
|
62
|
+
{ id: 'reject', title: 'Reject', destructive: true }
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'reply',
|
|
67
|
+
actions: [
|
|
68
|
+
{
|
|
69
|
+
id: 'reply',
|
|
70
|
+
title: 'Reply',
|
|
71
|
+
opensApp: false,
|
|
72
|
+
textInput: { submitTitle: 'Send', placeholder: 'Write a reply' }
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
]);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`opensApp` defaults to true; `destructive` and `authenticationRequired` (unlock first) are iOS
|
|
80
|
+
only. A button tap arrives as `{ actionId, userText, data, notificationId }`: `userText` is the
|
|
81
|
+
reply text, `data` the push data as strings, `notificationId` the notification's identifier on
|
|
82
|
+
the phone. A plain tap still goes to `onOpen`; a button tap with no handler opens like a plain tap.
|
|
83
|
+
|
|
84
|
+
To receive button taps in every app state, the killed app included, call `definePushActionTask`
|
|
85
|
+
once at module scope in the entry file, before the root component registers. It needs
|
|
86
|
+
`expo-task-manager`, an optional peer (`npx expo install expo-task-manager`), so it lives on its
|
|
87
|
+
own path:
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
// index.js
|
|
91
|
+
import { definePushActionTask } from '@webority/mobile-core/push/task';
|
|
92
|
+
import { notifyLocal } from '@webority/mobile-core/push';
|
|
93
|
+
|
|
94
|
+
definePushActionTask(async ({ actionId, userText, data }) => {
|
|
95
|
+
try {
|
|
96
|
+
await settle(actionId, userText, data);
|
|
97
|
+
} catch {
|
|
98
|
+
await notifyLocal({ title: 'Could not approve', body: 'Open the app to try again.' });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The handler's promise is awaited, so its request can finish before the background work ends.
|
|
104
|
+
`onAction(handler)` from `/push` sets the same handler for taps while JavaScript is running and
|
|
105
|
+
returns a function that removes it. A response reported twice within ten seconds (the launch read,
|
|
106
|
+
the listener and the Android task can each report it) runs once.
|
|
107
|
+
|
|
108
|
+
- **Android:** the background notification task runs the handler for a button tap on a
|
|
109
|
+
backgrounded or killed app. Buttons appear only on notifications expo-notifications draws (for
|
|
110
|
+
example `notifyLocal`). An app that draws its own notifications natively from data-only pushes
|
|
111
|
+
must build each button's intent with expo's `NotificationsService.createNotificationResponseIntent`,
|
|
112
|
+
so the tap reaches this handler.
|
|
113
|
+
- **iOS:** a button with `opensApp: false` launches the app in the background; the response
|
|
114
|
+
reaches the listener `definePushActionTask` sets up. The task is not registered on iOS.
|
|
47
115
|
|
|
48
116
|
Storage and network status stay on the main barrel but load no package on their own. Wire them at
|
|
49
117
|
startup with `setStorageImplementation` and `setNetworkStatusImplementation` if you want them.
|
|
@@ -21,12 +21,30 @@ Object.defineProperty(exports, "initPush", {
|
|
|
21
21
|
return _push.initPush;
|
|
22
22
|
}
|
|
23
23
|
});
|
|
24
|
+
Object.defineProperty(exports, "notifyLocal", {
|
|
25
|
+
enumerable: true,
|
|
26
|
+
get: function () {
|
|
27
|
+
return _push.notifyLocal;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
Object.defineProperty(exports, "onAction", {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
get: function () {
|
|
33
|
+
return _push.onAction;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
24
36
|
Object.defineProperty(exports, "setBadgeCount", {
|
|
25
37
|
enumerable: true,
|
|
26
38
|
get: function () {
|
|
27
39
|
return _push.setBadgeCount;
|
|
28
40
|
}
|
|
29
41
|
});
|
|
42
|
+
Object.defineProperty(exports, "setCategories", {
|
|
43
|
+
enumerable: true,
|
|
44
|
+
get: function () {
|
|
45
|
+
return _push.setCategories;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
30
48
|
Object.defineProperty(exports, "setPushImplementation", {
|
|
31
49
|
enumerable: true,
|
|
32
50
|
get: function () {
|
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", {
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
|
-
exports.usePush = exports.setPushImplementation = exports.setBadgeCount = exports.initPush = exports.enablePush = exports.disablePush = void 0;
|
|
6
|
+
exports.usePush = exports.toPushAction = exports.toNotificationActions = exports.setPushImplementation = exports.setCategories = exports.setBadgeCount = exports.routeTaskPayload = exports.resolveModules = exports.onAction = exports.notifyLocal = exports.initPush = exports.enablePush = exports.disablePush = void 0;
|
|
7
7
|
var _react = require("react");
|
|
8
8
|
var _reactNative = require("react-native");
|
|
9
9
|
var _index = require("../logger/index.js");
|
|
10
|
+
/** A button on a notification category. */
|
|
11
|
+
|
|
12
|
+
/** A set of buttons a push names through its category id. */
|
|
13
|
+
|
|
14
|
+
/** A button tap on a notification. */
|
|
15
|
+
|
|
10
16
|
/** The slice of expo-notifications this module calls, so the optional peer stays untyped at build. */
|
|
11
17
|
|
|
12
18
|
let override = null;
|
|
@@ -26,7 +32,7 @@ const setPushImplementation = impl => {
|
|
|
26
32
|
/**
|
|
27
33
|
* The only place expo-notifications and expo-application are named. Expo Go is
|
|
28
34
|
* checked first because expo-notifications refuses remote push there and its
|
|
29
|
-
* import throws on Android.
|
|
35
|
+
* import throws on Android. Exported for `./task`; not on the `/push` entry.
|
|
30
36
|
*/
|
|
31
37
|
exports.setPushImplementation = setPushImplementation;
|
|
32
38
|
const resolveModules = () => {
|
|
@@ -54,6 +60,7 @@ const resolveModules = () => {
|
|
|
54
60
|
}
|
|
55
61
|
return resolved;
|
|
56
62
|
};
|
|
63
|
+
exports.resolveModules = resolveModules;
|
|
57
64
|
let state = {
|
|
58
65
|
status: 'unknown',
|
|
59
66
|
enabled: false
|
|
@@ -71,7 +78,18 @@ const setState = next => {
|
|
|
71
78
|
let config = null;
|
|
72
79
|
let channelsReady = Promise.resolve();
|
|
73
80
|
let tokenSubscription = null;
|
|
74
|
-
let
|
|
81
|
+
let actionHandler = null;
|
|
82
|
+
let listening = false;
|
|
83
|
+
/** Responses that arrived before anything could handle them, e.g. the launching tap before initPush. */
|
|
84
|
+
const pendingResponses = [];
|
|
85
|
+
/** Response key to when it was routed. */
|
|
86
|
+
const routedAt = new Map();
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The cold-start read, the live listener and, on Android, the background task
|
|
90
|
+
* can each report the same response a moment apart.
|
|
91
|
+
*/
|
|
92
|
+
const REPEAT_WINDOW_MS = 10_000;
|
|
75
93
|
const requireConfig = () => {
|
|
76
94
|
if (!config) {
|
|
77
95
|
throw new Error('[@webority/mobile-core] call initPush before enablePush or disablePush.');
|
|
@@ -96,22 +114,112 @@ const toStringData = data => {
|
|
|
96
114
|
}
|
|
97
115
|
return out;
|
|
98
116
|
};
|
|
99
|
-
const
|
|
100
|
-
|
|
117
|
+
const responseData = response => {
|
|
118
|
+
const content = response.notification.request.content;
|
|
119
|
+
if (content.data) {
|
|
120
|
+
return toStringData(content.data);
|
|
121
|
+
}
|
|
122
|
+
if (typeof content.dataString === 'string') {
|
|
123
|
+
try {
|
|
124
|
+
return toStringData(JSON.parse(content.dataString));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
_index.Logger.error('[push] notification data is not JSON', error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return {};
|
|
130
|
+
};
|
|
131
|
+
const toPushAction = response => ({
|
|
132
|
+
actionId: response.actionIdentifier,
|
|
133
|
+
...(response.userText ? {
|
|
134
|
+
userText: response.userText
|
|
135
|
+
} : {}),
|
|
136
|
+
data: responseData(response),
|
|
137
|
+
notificationId: response.notification.request.identifier
|
|
138
|
+
});
|
|
139
|
+
exports.toPushAction = toPushAction;
|
|
140
|
+
const toNotificationActions = actions => actions.map(action => ({
|
|
141
|
+
identifier: action.id,
|
|
142
|
+
buttonTitle: action.title,
|
|
143
|
+
...(action.textInput ? {
|
|
144
|
+
textInput: {
|
|
145
|
+
submitButtonTitle: action.textInput.submitTitle,
|
|
146
|
+
placeholder: action.textInput.placeholder
|
|
147
|
+
}
|
|
148
|
+
} : {}),
|
|
149
|
+
options: {
|
|
150
|
+
opensAppToForeground: action.opensApp ?? true,
|
|
151
|
+
isDestructive: action.destructive ?? false,
|
|
152
|
+
isAuthenticationRequired: action.authenticationRequired ?? false
|
|
153
|
+
}
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Sends a button tap to the action handler and a plain tap to `onOpen`. A
|
|
158
|
+
* button tap with no action handler opens like a plain tap. Resolves once the
|
|
159
|
+
* handler has finished, so a background task stays alive for its request.
|
|
160
|
+
*/
|
|
161
|
+
exports.toNotificationActions = toNotificationActions;
|
|
162
|
+
const routeResponse = async (m, response) => {
|
|
163
|
+
const key = `${response.notification.request.identifier}:${response.actionIdentifier}`;
|
|
164
|
+
const last = routedAt.get(key);
|
|
165
|
+
if (last !== undefined && Date.now() - last < REPEAT_WINDOW_MS) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const isAction = response.actionIdentifier !== m.DEFAULT_ACTION_IDENTIFIER;
|
|
169
|
+
if (isAction && actionHandler) {
|
|
170
|
+
routedAt.set(key, Date.now());
|
|
171
|
+
try {
|
|
172
|
+
await actionHandler(toPushAction(response));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
_index.Logger.error('[push] onAction threw', error);
|
|
175
|
+
}
|
|
101
176
|
return;
|
|
102
177
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (id === lastOpenedId) {
|
|
178
|
+
if (!config) {
|
|
179
|
+
pendingResponses.push(response);
|
|
106
180
|
return;
|
|
107
181
|
}
|
|
108
|
-
|
|
182
|
+
routedAt.set(key, Date.now());
|
|
109
183
|
try {
|
|
110
|
-
config.onOpen(
|
|
184
|
+
config.onOpen(responseData(response));
|
|
111
185
|
} catch (error) {
|
|
112
186
|
_index.Logger.error('[push] onOpen threw', error);
|
|
113
187
|
}
|
|
114
188
|
};
|
|
189
|
+
const drainPending = async m => {
|
|
190
|
+
const queued = pendingResponses.splice(0);
|
|
191
|
+
for (const response of queued) {
|
|
192
|
+
await routeResponse(m, response);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** Follows taps and button taps, including the one that launched the app. Wired once per process. */
|
|
197
|
+
const listenForResponses = m => {
|
|
198
|
+
if (listening) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
listening = true;
|
|
202
|
+
m.addNotificationResponseReceivedListener(response => {
|
|
203
|
+
void routeResponse(m, response);
|
|
204
|
+
});
|
|
205
|
+
void m.getLastNotificationResponseAsync().then(async response => {
|
|
206
|
+
if (response) {
|
|
207
|
+
await routeResponse(m, response);
|
|
208
|
+
}
|
|
209
|
+
return m.clearLastNotificationResponseAsync?.();
|
|
210
|
+
}).catch(error => _index.Logger.error('[push] reading the launch notification failed', error));
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** The payload of the background notification task: routes button taps, ignores received pushes. */
|
|
214
|
+
const routeTaskPayload = async payload => {
|
|
215
|
+
const mods = resolveModules();
|
|
216
|
+
const response = payload;
|
|
217
|
+
if (!mods || typeof response?.actionIdentifier !== 'string' || typeof response.notification?.request?.identifier !== 'string') {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
await routeResponse(mods.notifications, response);
|
|
221
|
+
};
|
|
222
|
+
exports.routeTaskPayload = routeTaskPayload;
|
|
115
223
|
const createChannels = async (m, channels) => {
|
|
116
224
|
for (const channel of channels) {
|
|
117
225
|
try {
|
|
@@ -174,11 +282,8 @@ const initPush = next => {
|
|
|
174
282
|
if (_reactNative.Platform.OS === 'android' && next.androidChannels?.length) {
|
|
175
283
|
channelsReady = createChannels(m, next.androidChannels);
|
|
176
284
|
}
|
|
177
|
-
m
|
|
178
|
-
void m
|
|
179
|
-
handleResponse(response);
|
|
180
|
-
return m.clearLastNotificationResponseAsync?.();
|
|
181
|
-
}).catch(error => _index.Logger.error('[push] reading the launch notification failed', error));
|
|
285
|
+
listenForResponses(m);
|
|
286
|
+
void drainPending(m);
|
|
182
287
|
void m.getPermissionsAsync().then(response => setState({
|
|
183
288
|
status: toPermissionStatus(response)
|
|
184
289
|
})).catch(error => _index.Logger.error('[push] permission check failed', error));
|
|
@@ -256,7 +361,64 @@ const setBadgeCount = async count => {
|
|
|
256
361
|
}
|
|
257
362
|
await mods.notifications.setBadgeCountAsync(count);
|
|
258
363
|
};
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Registers the button sets a push can name by category id. Call it at every
|
|
367
|
+
* start, with the same ids on both platforms. On Android the buttons show only
|
|
368
|
+
* on notifications expo-notifications draws, such as `notifyLocal`.
|
|
369
|
+
*/
|
|
259
370
|
exports.setBadgeCount = setBadgeCount;
|
|
371
|
+
const setCategories = async categories => {
|
|
372
|
+
const mods = resolveModules();
|
|
373
|
+
if (!mods) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
for (const category of categories) {
|
|
377
|
+
await mods.notifications.setNotificationCategoryAsync(category.id, toNotificationActions(category.actions));
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Handles button taps while JavaScript is running; a plain tap still goes to
|
|
383
|
+
* `onOpen`. For taps on a killed app use `definePushActionTask` from
|
|
384
|
+
* `/push/task`, which sets this handler too. Returns a function that removes it.
|
|
385
|
+
*/
|
|
386
|
+
exports.setCategories = setCategories;
|
|
387
|
+
const onAction = handler => {
|
|
388
|
+
actionHandler = handler;
|
|
389
|
+
const mods = resolveModules();
|
|
390
|
+
if (mods) {
|
|
391
|
+
listenForResponses(mods.notifications);
|
|
392
|
+
void drainPending(mods.notifications);
|
|
393
|
+
}
|
|
394
|
+
return () => {
|
|
395
|
+
if (actionHandler === handler) {
|
|
396
|
+
actionHandler = null;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/** Shows a notification from the phone itself, for example when a button's request failed. */
|
|
402
|
+
exports.onAction = onAction;
|
|
403
|
+
const notifyLocal = async ({
|
|
404
|
+
title,
|
|
405
|
+
body,
|
|
406
|
+
data
|
|
407
|
+
}) => {
|
|
408
|
+
const mods = resolveModules();
|
|
409
|
+
if (!mods) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
await mods.notifications.scheduleNotificationAsync({
|
|
413
|
+
content: {
|
|
414
|
+
title,
|
|
415
|
+
body,
|
|
416
|
+
data: data ?? {}
|
|
417
|
+
},
|
|
418
|
+
trigger: null
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
exports.notifyLocal = notifyLocal;
|
|
260
422
|
const subscribe = notify => {
|
|
261
423
|
subscribers.add(notify);
|
|
262
424
|
return () => {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.definePushActionTask = void 0;
|
|
7
|
+
var _reactNative = require("react-native");
|
|
8
|
+
var _index = require("../logger/index.js");
|
|
9
|
+
var _push = require("./push.js");
|
|
10
|
+
const PUSH_ACTION_TASK = 'webority-push-action';
|
|
11
|
+
/**
|
|
12
|
+
* Handles notification button taps in every app state, the killed app
|
|
13
|
+
* included. Call it once at module scope in the app's entry file (index.js),
|
|
14
|
+
* before the root component registers: a tap on a killed app starts the
|
|
15
|
+
* JavaScript bundle without the UI, so nothing later in the app runs.
|
|
16
|
+
*
|
|
17
|
+
* Android runs the handler from a background notification task
|
|
18
|
+
* (expo-task-manager). iOS has no such task for button taps: it launches the
|
|
19
|
+
* app in the background and the response reaches the listener this sets up.
|
|
20
|
+
* The handler's promise is awaited, so a request it makes can finish first.
|
|
21
|
+
*/
|
|
22
|
+
const definePushActionTask = handler => {
|
|
23
|
+
(0, _push.onAction)(handler);
|
|
24
|
+
const mods = (0, _push.resolveModules)();
|
|
25
|
+
if (!mods || _reactNative.Platform.OS !== 'android') {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
/** The one place `expo-task-manager` is named. Import `/push/task` only if you use button taps. */
|
|
29
|
+
const taskManager = require('expo-task-manager');
|
|
30
|
+
taskManager.defineTask(PUSH_ACTION_TASK, async ({
|
|
31
|
+
data,
|
|
32
|
+
error
|
|
33
|
+
}) => {
|
|
34
|
+
if (error) {
|
|
35
|
+
_index.Logger.error('[push] the notification task failed', error);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await (0, _push.routeTaskPayload)(data);
|
|
39
|
+
});
|
|
40
|
+
void mods.notifications.registerTaskAsync(PUSH_ACTION_TASK).catch(registerError => _index.Logger.error('[push] registering the notification task failed', registerError));
|
|
41
|
+
};
|
|
42
|
+
exports.definePushActionTask = definePushActionTask;
|
|
43
|
+
//# sourceMappingURL=task.js.map
|
package/lib/module/push/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
export { disablePush, enablePush, initPush, setBadgeCount, setPushImplementation, usePush } from "./push.js";
|
|
3
|
+
export { disablePush, enablePush, initPush, notifyLocal, onAction, setBadgeCount, setCategories, setPushImplementation, usePush } from "./push.js";
|
|
4
4
|
//# sourceMappingURL=index.js.map
|
package/lib/module/push/push.js
CHANGED
|
@@ -4,6 +4,12 @@ import { useSyncExternalStore } from 'react';
|
|
|
4
4
|
import { Platform } from 'react-native';
|
|
5
5
|
import { Logger } from "../logger/index.js";
|
|
6
6
|
|
|
7
|
+
/** A button on a notification category. */
|
|
8
|
+
|
|
9
|
+
/** A set of buttons a push names through its category id. */
|
|
10
|
+
|
|
11
|
+
/** A button tap on a notification. */
|
|
12
|
+
|
|
7
13
|
/** The slice of expo-notifications this module calls, so the optional peer stays untyped at build. */
|
|
8
14
|
|
|
9
15
|
let override = null;
|
|
@@ -23,9 +29,9 @@ export const setPushImplementation = impl => {
|
|
|
23
29
|
/**
|
|
24
30
|
* The only place expo-notifications and expo-application are named. Expo Go is
|
|
25
31
|
* checked first because expo-notifications refuses remote push there and its
|
|
26
|
-
* import throws on Android.
|
|
32
|
+
* import throws on Android. Exported for `./task`; not on the `/push` entry.
|
|
27
33
|
*/
|
|
28
|
-
const resolveModules = () => {
|
|
34
|
+
export const resolveModules = () => {
|
|
29
35
|
if (override) {
|
|
30
36
|
return override;
|
|
31
37
|
}
|
|
@@ -67,7 +73,18 @@ const setState = next => {
|
|
|
67
73
|
let config = null;
|
|
68
74
|
let channelsReady = Promise.resolve();
|
|
69
75
|
let tokenSubscription = null;
|
|
70
|
-
let
|
|
76
|
+
let actionHandler = null;
|
|
77
|
+
let listening = false;
|
|
78
|
+
/** Responses that arrived before anything could handle them, e.g. the launching tap before initPush. */
|
|
79
|
+
const pendingResponses = [];
|
|
80
|
+
/** Response key to when it was routed. */
|
|
81
|
+
const routedAt = new Map();
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The cold-start read, the live listener and, on Android, the background task
|
|
85
|
+
* can each report the same response a moment apart.
|
|
86
|
+
*/
|
|
87
|
+
const REPEAT_WINDOW_MS = 10_000;
|
|
71
88
|
const requireConfig = () => {
|
|
72
89
|
if (!config) {
|
|
73
90
|
throw new Error('[@webority/mobile-core] call initPush before enablePush or disablePush.');
|
|
@@ -92,22 +109,109 @@ const toStringData = data => {
|
|
|
92
109
|
}
|
|
93
110
|
return out;
|
|
94
111
|
};
|
|
95
|
-
const
|
|
96
|
-
|
|
112
|
+
const responseData = response => {
|
|
113
|
+
const content = response.notification.request.content;
|
|
114
|
+
if (content.data) {
|
|
115
|
+
return toStringData(content.data);
|
|
116
|
+
}
|
|
117
|
+
if (typeof content.dataString === 'string') {
|
|
118
|
+
try {
|
|
119
|
+
return toStringData(JSON.parse(content.dataString));
|
|
120
|
+
} catch (error) {
|
|
121
|
+
Logger.error('[push] notification data is not JSON', error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return {};
|
|
125
|
+
};
|
|
126
|
+
export const toPushAction = response => ({
|
|
127
|
+
actionId: response.actionIdentifier,
|
|
128
|
+
...(response.userText ? {
|
|
129
|
+
userText: response.userText
|
|
130
|
+
} : {}),
|
|
131
|
+
data: responseData(response),
|
|
132
|
+
notificationId: response.notification.request.identifier
|
|
133
|
+
});
|
|
134
|
+
export const toNotificationActions = actions => actions.map(action => ({
|
|
135
|
+
identifier: action.id,
|
|
136
|
+
buttonTitle: action.title,
|
|
137
|
+
...(action.textInput ? {
|
|
138
|
+
textInput: {
|
|
139
|
+
submitButtonTitle: action.textInput.submitTitle,
|
|
140
|
+
placeholder: action.textInput.placeholder
|
|
141
|
+
}
|
|
142
|
+
} : {}),
|
|
143
|
+
options: {
|
|
144
|
+
opensAppToForeground: action.opensApp ?? true,
|
|
145
|
+
isDestructive: action.destructive ?? false,
|
|
146
|
+
isAuthenticationRequired: action.authenticationRequired ?? false
|
|
147
|
+
}
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Sends a button tap to the action handler and a plain tap to `onOpen`. A
|
|
152
|
+
* button tap with no action handler opens like a plain tap. Resolves once the
|
|
153
|
+
* handler has finished, so a background task stays alive for its request.
|
|
154
|
+
*/
|
|
155
|
+
const routeResponse = async (m, response) => {
|
|
156
|
+
const key = `${response.notification.request.identifier}:${response.actionIdentifier}`;
|
|
157
|
+
const last = routedAt.get(key);
|
|
158
|
+
if (last !== undefined && Date.now() - last < REPEAT_WINDOW_MS) {
|
|
97
159
|
return;
|
|
98
160
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
161
|
+
const isAction = response.actionIdentifier !== m.DEFAULT_ACTION_IDENTIFIER;
|
|
162
|
+
if (isAction && actionHandler) {
|
|
163
|
+
routedAt.set(key, Date.now());
|
|
164
|
+
try {
|
|
165
|
+
await actionHandler(toPushAction(response));
|
|
166
|
+
} catch (error) {
|
|
167
|
+
Logger.error('[push] onAction threw', error);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (!config) {
|
|
172
|
+
pendingResponses.push(response);
|
|
102
173
|
return;
|
|
103
174
|
}
|
|
104
|
-
|
|
175
|
+
routedAt.set(key, Date.now());
|
|
105
176
|
try {
|
|
106
|
-
config.onOpen(
|
|
177
|
+
config.onOpen(responseData(response));
|
|
107
178
|
} catch (error) {
|
|
108
179
|
Logger.error('[push] onOpen threw', error);
|
|
109
180
|
}
|
|
110
181
|
};
|
|
182
|
+
const drainPending = async m => {
|
|
183
|
+
const queued = pendingResponses.splice(0);
|
|
184
|
+
for (const response of queued) {
|
|
185
|
+
await routeResponse(m, response);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** Follows taps and button taps, including the one that launched the app. Wired once per process. */
|
|
190
|
+
const listenForResponses = m => {
|
|
191
|
+
if (listening) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
listening = true;
|
|
195
|
+
m.addNotificationResponseReceivedListener(response => {
|
|
196
|
+
void routeResponse(m, response);
|
|
197
|
+
});
|
|
198
|
+
void m.getLastNotificationResponseAsync().then(async response => {
|
|
199
|
+
if (response) {
|
|
200
|
+
await routeResponse(m, response);
|
|
201
|
+
}
|
|
202
|
+
return m.clearLastNotificationResponseAsync?.();
|
|
203
|
+
}).catch(error => Logger.error('[push] reading the launch notification failed', error));
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** The payload of the background notification task: routes button taps, ignores received pushes. */
|
|
207
|
+
export const routeTaskPayload = async payload => {
|
|
208
|
+
const mods = resolveModules();
|
|
209
|
+
const response = payload;
|
|
210
|
+
if (!mods || typeof response?.actionIdentifier !== 'string' || typeof response.notification?.request?.identifier !== 'string') {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
await routeResponse(mods.notifications, response);
|
|
214
|
+
};
|
|
111
215
|
const createChannels = async (m, channels) => {
|
|
112
216
|
for (const channel of channels) {
|
|
113
217
|
try {
|
|
@@ -170,11 +274,8 @@ export const initPush = next => {
|
|
|
170
274
|
if (Platform.OS === 'android' && next.androidChannels?.length) {
|
|
171
275
|
channelsReady = createChannels(m, next.androidChannels);
|
|
172
276
|
}
|
|
173
|
-
m
|
|
174
|
-
void m
|
|
175
|
-
handleResponse(response);
|
|
176
|
-
return m.clearLastNotificationResponseAsync?.();
|
|
177
|
-
}).catch(error => Logger.error('[push] reading the launch notification failed', error));
|
|
277
|
+
listenForResponses(m);
|
|
278
|
+
void drainPending(m);
|
|
178
279
|
void m.getPermissionsAsync().then(response => setState({
|
|
179
280
|
status: toPermissionStatus(response)
|
|
180
281
|
})).catch(error => Logger.error('[push] permission check failed', error));
|
|
@@ -249,6 +350,60 @@ export const setBadgeCount = async count => {
|
|
|
249
350
|
}
|
|
250
351
|
await mods.notifications.setBadgeCountAsync(count);
|
|
251
352
|
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Registers the button sets a push can name by category id. Call it at every
|
|
356
|
+
* start, with the same ids on both platforms. On Android the buttons show only
|
|
357
|
+
* on notifications expo-notifications draws, such as `notifyLocal`.
|
|
358
|
+
*/
|
|
359
|
+
export const setCategories = async categories => {
|
|
360
|
+
const mods = resolveModules();
|
|
361
|
+
if (!mods) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
for (const category of categories) {
|
|
365
|
+
await mods.notifications.setNotificationCategoryAsync(category.id, toNotificationActions(category.actions));
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Handles button taps while JavaScript is running; a plain tap still goes to
|
|
371
|
+
* `onOpen`. For taps on a killed app use `definePushActionTask` from
|
|
372
|
+
* `/push/task`, which sets this handler too. Returns a function that removes it.
|
|
373
|
+
*/
|
|
374
|
+
export const onAction = handler => {
|
|
375
|
+
actionHandler = handler;
|
|
376
|
+
const mods = resolveModules();
|
|
377
|
+
if (mods) {
|
|
378
|
+
listenForResponses(mods.notifications);
|
|
379
|
+
void drainPending(mods.notifications);
|
|
380
|
+
}
|
|
381
|
+
return () => {
|
|
382
|
+
if (actionHandler === handler) {
|
|
383
|
+
actionHandler = null;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** Shows a notification from the phone itself, for example when a button's request failed. */
|
|
389
|
+
export const notifyLocal = async ({
|
|
390
|
+
title,
|
|
391
|
+
body,
|
|
392
|
+
data
|
|
393
|
+
}) => {
|
|
394
|
+
const mods = resolveModules();
|
|
395
|
+
if (!mods) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
await mods.notifications.scheduleNotificationAsync({
|
|
399
|
+
content: {
|
|
400
|
+
title,
|
|
401
|
+
body,
|
|
402
|
+
data: data ?? {}
|
|
403
|
+
},
|
|
404
|
+
trigger: null
|
|
405
|
+
});
|
|
406
|
+
};
|
|
252
407
|
const subscribe = notify => {
|
|
253
408
|
subscribers.add(notify);
|
|
254
409
|
return () => {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { Platform } from 'react-native';
|
|
4
|
+
import { Logger } from "../logger/index.js";
|
|
5
|
+
import { onAction, resolveModules, routeTaskPayload } from "./push.js";
|
|
6
|
+
const PUSH_ACTION_TASK = 'webority-push-action';
|
|
7
|
+
/**
|
|
8
|
+
* Handles notification button taps in every app state, the killed app
|
|
9
|
+
* included. Call it once at module scope in the app's entry file (index.js),
|
|
10
|
+
* before the root component registers: a tap on a killed app starts the
|
|
11
|
+
* JavaScript bundle without the UI, so nothing later in the app runs.
|
|
12
|
+
*
|
|
13
|
+
* Android runs the handler from a background notification task
|
|
14
|
+
* (expo-task-manager). iOS has no such task for button taps: it launches the
|
|
15
|
+
* app in the background and the response reaches the listener this sets up.
|
|
16
|
+
* The handler's promise is awaited, so a request it makes can finish first.
|
|
17
|
+
*/
|
|
18
|
+
export const definePushActionTask = handler => {
|
|
19
|
+
onAction(handler);
|
|
20
|
+
const mods = resolveModules();
|
|
21
|
+
if (!mods || Platform.OS !== 'android') {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
/** The one place `expo-task-manager` is named. Import `/push/task` only if you use button taps. */
|
|
25
|
+
const taskManager = require('expo-task-manager');
|
|
26
|
+
taskManager.defineTask(PUSH_ACTION_TASK, async ({
|
|
27
|
+
data,
|
|
28
|
+
error
|
|
29
|
+
}) => {
|
|
30
|
+
if (error) {
|
|
31
|
+
Logger.error('[push] the notification task failed', error);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await routeTaskPayload(data);
|
|
35
|
+
});
|
|
36
|
+
void mods.notifications.registerTaskAsync(PUSH_ACTION_TASK).catch(registerError => Logger.error('[push] registering the notification task failed', registerError));
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=task.js.map
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export type { PushAndroidChannel, PushConfig, PushDevice, PushModules, PushNotificationsLike, PushPlatform, PushState, PushStatus } from './push';
|
|
2
|
-
export { disablePush, enablePush, initPush, setBadgeCount, setPushImplementation, usePush } from './push';
|
|
1
|
+
export type { PushAction, PushActionHandler, PushAndroidChannel, PushCategory, PushCategoryAction, PushConfig, PushDevice, PushLocalNotification, PushModules, PushNotificationsLike, PushPlatform, PushState, PushStatus } from './push';
|
|
2
|
+
export { disablePush, enablePush, initPush, notifyLocal, onAction, setBadgeCount, setCategories, setPushImplementation, usePush } from './push';
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -26,6 +26,42 @@ export interface PushConfig {
|
|
|
26
26
|
/** Whether a notification shown while the app is open plays its sound. Defaults to true. */
|
|
27
27
|
playSoundInForeground?: () => boolean;
|
|
28
28
|
}
|
|
29
|
+
/** A button on a notification category. */
|
|
30
|
+
export interface PushCategoryAction {
|
|
31
|
+
id: string;
|
|
32
|
+
title: string;
|
|
33
|
+
/** Whether the tap brings the app to the foreground. Defaults to true. */
|
|
34
|
+
opensApp?: boolean;
|
|
35
|
+
/** iOS only: the title shows in red. */
|
|
36
|
+
destructive?: boolean;
|
|
37
|
+
/** Turns the button into a reply box; the text arrives as `userText`. */
|
|
38
|
+
textInput?: {
|
|
39
|
+
submitTitle: string;
|
|
40
|
+
placeholder: string;
|
|
41
|
+
};
|
|
42
|
+
/** iOS only: the phone must be unlocked before the action runs. */
|
|
43
|
+
authenticationRequired?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** A set of buttons a push names through its category id. */
|
|
46
|
+
export interface PushCategory {
|
|
47
|
+
id: string;
|
|
48
|
+
actions: PushCategoryAction[];
|
|
49
|
+
}
|
|
50
|
+
/** A button tap on a notification. */
|
|
51
|
+
export interface PushAction {
|
|
52
|
+
actionId: string;
|
|
53
|
+
/** What the person typed, for a `textInput` action. */
|
|
54
|
+
userText?: string;
|
|
55
|
+
data: Record<string, string>;
|
|
56
|
+
/** The notification's identifier on the phone, not a server id in `data`. */
|
|
57
|
+
notificationId: string;
|
|
58
|
+
}
|
|
59
|
+
export type PushActionHandler = (action: PushAction) => Promise<void> | void;
|
|
60
|
+
export interface PushLocalNotification {
|
|
61
|
+
title: string;
|
|
62
|
+
body: string;
|
|
63
|
+
data?: Record<string, string>;
|
|
64
|
+
}
|
|
29
65
|
export type PushStatus = 'unknown' | 'granted' | 'denied' | 'unavailable';
|
|
30
66
|
export interface PushState {
|
|
31
67
|
status: PushStatus;
|
|
@@ -41,15 +77,31 @@ interface SubscriptionLike {
|
|
|
41
77
|
}
|
|
42
78
|
interface NotificationResponseLike {
|
|
43
79
|
actionIdentifier: string;
|
|
80
|
+
userText?: string;
|
|
44
81
|
notification: {
|
|
45
82
|
request: {
|
|
46
83
|
identifier: string;
|
|
84
|
+
/** A background task receives the raw content, where data may still be a JSON `dataString`. */
|
|
47
85
|
content: {
|
|
48
86
|
data?: Record<string, unknown> | null;
|
|
87
|
+
dataString?: string;
|
|
49
88
|
};
|
|
50
89
|
};
|
|
51
90
|
};
|
|
52
91
|
}
|
|
92
|
+
interface NotificationActionLike {
|
|
93
|
+
identifier: string;
|
|
94
|
+
buttonTitle: string;
|
|
95
|
+
textInput?: {
|
|
96
|
+
submitButtonTitle: string;
|
|
97
|
+
placeholder: string;
|
|
98
|
+
};
|
|
99
|
+
options: {
|
|
100
|
+
opensAppToForeground: boolean;
|
|
101
|
+
isDestructive: boolean;
|
|
102
|
+
isAuthenticationRequired: boolean;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
53
105
|
interface DevicePushTokenLike {
|
|
54
106
|
type: string;
|
|
55
107
|
data: unknown;
|
|
@@ -76,10 +128,22 @@ export interface PushNotificationsLike {
|
|
|
76
128
|
importance: number;
|
|
77
129
|
}) => Promise<unknown>;
|
|
78
130
|
setBadgeCountAsync: (count: number) => Promise<boolean>;
|
|
131
|
+
setNotificationCategoryAsync: (identifier: string, actions: NotificationActionLike[]) => Promise<unknown>;
|
|
132
|
+
scheduleNotificationAsync: (request: {
|
|
133
|
+
content: {
|
|
134
|
+
title: string;
|
|
135
|
+
body: string;
|
|
136
|
+
data: Record<string, string>;
|
|
137
|
+
};
|
|
138
|
+
trigger: null;
|
|
139
|
+
}) => Promise<string>;
|
|
140
|
+
registerTaskAsync: (taskName: string) => Promise<unknown>;
|
|
79
141
|
AndroidImportance: {
|
|
80
142
|
DEFAULT: number;
|
|
81
143
|
HIGH: number;
|
|
82
144
|
};
|
|
145
|
+
/** The `actionIdentifier` of a plain tap on the notification body. */
|
|
146
|
+
DEFAULT_ACTION_IDENTIFIER: string;
|
|
83
147
|
}
|
|
84
148
|
export interface PushModules {
|
|
85
149
|
notifications: PushNotificationsLike;
|
|
@@ -92,6 +156,16 @@ export interface PushModules {
|
|
|
92
156
|
* included. Pass `null` to restore expo-notifications.
|
|
93
157
|
*/
|
|
94
158
|
export declare const setPushImplementation: (impl: PushModules | null) => void;
|
|
159
|
+
/**
|
|
160
|
+
* The only place expo-notifications and expo-application are named. Expo Go is
|
|
161
|
+
* checked first because expo-notifications refuses remote push there and its
|
|
162
|
+
* import throws on Android. Exported for `./task`; not on the `/push` entry.
|
|
163
|
+
*/
|
|
164
|
+
export declare const resolveModules: () => PushModules | null;
|
|
165
|
+
export declare const toPushAction: (response: NotificationResponseLike) => PushAction;
|
|
166
|
+
export declare const toNotificationActions: (actions: PushCategoryAction[]) => NotificationActionLike[];
|
|
167
|
+
/** The payload of the background notification task: routes button taps, ignores received pushes. */
|
|
168
|
+
export declare const routeTaskPayload: (payload: unknown) => Promise<void>;
|
|
95
169
|
/**
|
|
96
170
|
* Wires push at startup: the foreground handler, Android channels, and tap
|
|
97
171
|
* handling, including the tap that launched the app. Calling it again only
|
|
@@ -110,6 +184,20 @@ export declare const enablePush: () => Promise<"granted" | "denied" | "unavailab
|
|
|
110
184
|
export declare const disablePush: () => Promise<void>;
|
|
111
185
|
/** Sets the app icon badge; a no-op where push is unavailable or the launcher has no badge. */
|
|
112
186
|
export declare const setBadgeCount: (count: number) => Promise<void>;
|
|
187
|
+
/**
|
|
188
|
+
* Registers the button sets a push can name by category id. Call it at every
|
|
189
|
+
* start, with the same ids on both platforms. On Android the buttons show only
|
|
190
|
+
* on notifications expo-notifications draws, such as `notifyLocal`.
|
|
191
|
+
*/
|
|
192
|
+
export declare const setCategories: (categories: PushCategory[]) => Promise<void>;
|
|
193
|
+
/**
|
|
194
|
+
* Handles button taps while JavaScript is running; a plain tap still goes to
|
|
195
|
+
* `onOpen`. For taps on a killed app use `definePushActionTask` from
|
|
196
|
+
* `/push/task`, which sets this handler too. Returns a function that removes it.
|
|
197
|
+
*/
|
|
198
|
+
export declare const onAction: (handler: PushActionHandler) => (() => void);
|
|
199
|
+
/** Shows a notification from the phone itself, for example when a button's request failed. */
|
|
200
|
+
export declare const notifyLocal: ({ title, body, data }: PushLocalNotification) => Promise<void>;
|
|
113
201
|
/** Push status for a settings switch, re-rendering when it changes. */
|
|
114
202
|
export declare const usePush: () => PushState & {
|
|
115
203
|
enable: () => Promise<void>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type PushActionHandler } from './push';
|
|
2
|
+
export type { PushAction, PushActionHandler } from './push';
|
|
3
|
+
/**
|
|
4
|
+
* Handles notification button taps in every app state, the killed app
|
|
5
|
+
* included. Call it once at module scope in the app's entry file (index.js),
|
|
6
|
+
* before the root component registers: a tap on a killed app starts the
|
|
7
|
+
* JavaScript bundle without the UI, so nothing later in the app runs.
|
|
8
|
+
*
|
|
9
|
+
* Android runs the handler from a background notification task
|
|
10
|
+
* (expo-task-manager). iOS has no such task for button taps: it launches the
|
|
11
|
+
* app in the background and the response reaches the listener this sets up.
|
|
12
|
+
* The handler's promise is awaited, so a request it makes can finish first.
|
|
13
|
+
*/
|
|
14
|
+
export declare const definePushActionTask: (handler: PushActionHandler) => void;
|
|
15
|
+
//# sourceMappingURL=task.d.ts.map
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export type { PushAndroidChannel, PushConfig, PushDevice, PushModules, PushNotificationsLike, PushPlatform, PushState, PushStatus } from './push.js';
|
|
2
|
-
export { disablePush, enablePush, initPush, setBadgeCount, setPushImplementation, usePush } from './push.js';
|
|
1
|
+
export type { PushAction, PushActionHandler, PushAndroidChannel, PushCategory, PushCategoryAction, PushConfig, PushDevice, PushLocalNotification, PushModules, PushNotificationsLike, PushPlatform, PushState, PushStatus } from './push.js';
|
|
2
|
+
export { disablePush, enablePush, initPush, notifyLocal, onAction, setBadgeCount, setCategories, setPushImplementation, usePush } from './push.js';
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -26,6 +26,42 @@ export interface PushConfig {
|
|
|
26
26
|
/** Whether a notification shown while the app is open plays its sound. Defaults to true. */
|
|
27
27
|
playSoundInForeground?: () => boolean;
|
|
28
28
|
}
|
|
29
|
+
/** A button on a notification category. */
|
|
30
|
+
export interface PushCategoryAction {
|
|
31
|
+
id: string;
|
|
32
|
+
title: string;
|
|
33
|
+
/** Whether the tap brings the app to the foreground. Defaults to true. */
|
|
34
|
+
opensApp?: boolean;
|
|
35
|
+
/** iOS only: the title shows in red. */
|
|
36
|
+
destructive?: boolean;
|
|
37
|
+
/** Turns the button into a reply box; the text arrives as `userText`. */
|
|
38
|
+
textInput?: {
|
|
39
|
+
submitTitle: string;
|
|
40
|
+
placeholder: string;
|
|
41
|
+
};
|
|
42
|
+
/** iOS only: the phone must be unlocked before the action runs. */
|
|
43
|
+
authenticationRequired?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** A set of buttons a push names through its category id. */
|
|
46
|
+
export interface PushCategory {
|
|
47
|
+
id: string;
|
|
48
|
+
actions: PushCategoryAction[];
|
|
49
|
+
}
|
|
50
|
+
/** A button tap on a notification. */
|
|
51
|
+
export interface PushAction {
|
|
52
|
+
actionId: string;
|
|
53
|
+
/** What the person typed, for a `textInput` action. */
|
|
54
|
+
userText?: string;
|
|
55
|
+
data: Record<string, string>;
|
|
56
|
+
/** The notification's identifier on the phone, not a server id in `data`. */
|
|
57
|
+
notificationId: string;
|
|
58
|
+
}
|
|
59
|
+
export type PushActionHandler = (action: PushAction) => Promise<void> | void;
|
|
60
|
+
export interface PushLocalNotification {
|
|
61
|
+
title: string;
|
|
62
|
+
body: string;
|
|
63
|
+
data?: Record<string, string>;
|
|
64
|
+
}
|
|
29
65
|
export type PushStatus = 'unknown' | 'granted' | 'denied' | 'unavailable';
|
|
30
66
|
export interface PushState {
|
|
31
67
|
status: PushStatus;
|
|
@@ -41,15 +77,31 @@ interface SubscriptionLike {
|
|
|
41
77
|
}
|
|
42
78
|
interface NotificationResponseLike {
|
|
43
79
|
actionIdentifier: string;
|
|
80
|
+
userText?: string;
|
|
44
81
|
notification: {
|
|
45
82
|
request: {
|
|
46
83
|
identifier: string;
|
|
84
|
+
/** A background task receives the raw content, where data may still be a JSON `dataString`. */
|
|
47
85
|
content: {
|
|
48
86
|
data?: Record<string, unknown> | null;
|
|
87
|
+
dataString?: string;
|
|
49
88
|
};
|
|
50
89
|
};
|
|
51
90
|
};
|
|
52
91
|
}
|
|
92
|
+
interface NotificationActionLike {
|
|
93
|
+
identifier: string;
|
|
94
|
+
buttonTitle: string;
|
|
95
|
+
textInput?: {
|
|
96
|
+
submitButtonTitle: string;
|
|
97
|
+
placeholder: string;
|
|
98
|
+
};
|
|
99
|
+
options: {
|
|
100
|
+
opensAppToForeground: boolean;
|
|
101
|
+
isDestructive: boolean;
|
|
102
|
+
isAuthenticationRequired: boolean;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
53
105
|
interface DevicePushTokenLike {
|
|
54
106
|
type: string;
|
|
55
107
|
data: unknown;
|
|
@@ -76,10 +128,22 @@ export interface PushNotificationsLike {
|
|
|
76
128
|
importance: number;
|
|
77
129
|
}) => Promise<unknown>;
|
|
78
130
|
setBadgeCountAsync: (count: number) => Promise<boolean>;
|
|
131
|
+
setNotificationCategoryAsync: (identifier: string, actions: NotificationActionLike[]) => Promise<unknown>;
|
|
132
|
+
scheduleNotificationAsync: (request: {
|
|
133
|
+
content: {
|
|
134
|
+
title: string;
|
|
135
|
+
body: string;
|
|
136
|
+
data: Record<string, string>;
|
|
137
|
+
};
|
|
138
|
+
trigger: null;
|
|
139
|
+
}) => Promise<string>;
|
|
140
|
+
registerTaskAsync: (taskName: string) => Promise<unknown>;
|
|
79
141
|
AndroidImportance: {
|
|
80
142
|
DEFAULT: number;
|
|
81
143
|
HIGH: number;
|
|
82
144
|
};
|
|
145
|
+
/** The `actionIdentifier` of a plain tap on the notification body. */
|
|
146
|
+
DEFAULT_ACTION_IDENTIFIER: string;
|
|
83
147
|
}
|
|
84
148
|
export interface PushModules {
|
|
85
149
|
notifications: PushNotificationsLike;
|
|
@@ -92,6 +156,16 @@ export interface PushModules {
|
|
|
92
156
|
* included. Pass `null` to restore expo-notifications.
|
|
93
157
|
*/
|
|
94
158
|
export declare const setPushImplementation: (impl: PushModules | null) => void;
|
|
159
|
+
/**
|
|
160
|
+
* The only place expo-notifications and expo-application are named. Expo Go is
|
|
161
|
+
* checked first because expo-notifications refuses remote push there and its
|
|
162
|
+
* import throws on Android. Exported for `./task`; not on the `/push` entry.
|
|
163
|
+
*/
|
|
164
|
+
export declare const resolveModules: () => PushModules | null;
|
|
165
|
+
export declare const toPushAction: (response: NotificationResponseLike) => PushAction;
|
|
166
|
+
export declare const toNotificationActions: (actions: PushCategoryAction[]) => NotificationActionLike[];
|
|
167
|
+
/** The payload of the background notification task: routes button taps, ignores received pushes. */
|
|
168
|
+
export declare const routeTaskPayload: (payload: unknown) => Promise<void>;
|
|
95
169
|
/**
|
|
96
170
|
* Wires push at startup: the foreground handler, Android channels, and tap
|
|
97
171
|
* handling, including the tap that launched the app. Calling it again only
|
|
@@ -110,6 +184,20 @@ export declare const enablePush: () => Promise<"granted" | "denied" | "unavailab
|
|
|
110
184
|
export declare const disablePush: () => Promise<void>;
|
|
111
185
|
/** Sets the app icon badge; a no-op where push is unavailable or the launcher has no badge. */
|
|
112
186
|
export declare const setBadgeCount: (count: number) => Promise<void>;
|
|
187
|
+
/**
|
|
188
|
+
* Registers the button sets a push can name by category id. Call it at every
|
|
189
|
+
* start, with the same ids on both platforms. On Android the buttons show only
|
|
190
|
+
* on notifications expo-notifications draws, such as `notifyLocal`.
|
|
191
|
+
*/
|
|
192
|
+
export declare const setCategories: (categories: PushCategory[]) => Promise<void>;
|
|
193
|
+
/**
|
|
194
|
+
* Handles button taps while JavaScript is running; a plain tap still goes to
|
|
195
|
+
* `onOpen`. For taps on a killed app use `definePushActionTask` from
|
|
196
|
+
* `/push/task`, which sets this handler too. Returns a function that removes it.
|
|
197
|
+
*/
|
|
198
|
+
export declare const onAction: (handler: PushActionHandler) => (() => void);
|
|
199
|
+
/** Shows a notification from the phone itself, for example when a button's request failed. */
|
|
200
|
+
export declare const notifyLocal: ({ title, body, data }: PushLocalNotification) => Promise<void>;
|
|
113
201
|
/** Push status for a settings switch, re-rendering when it changes. */
|
|
114
202
|
export declare const usePush: () => PushState & {
|
|
115
203
|
enable: () => Promise<void>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type PushActionHandler } from './push.js';
|
|
2
|
+
export type { PushAction, PushActionHandler } from './push.js';
|
|
3
|
+
/**
|
|
4
|
+
* Handles notification button taps in every app state, the killed app
|
|
5
|
+
* included. Call it once at module scope in the app's entry file (index.js),
|
|
6
|
+
* before the root component registers: a tap on a killed app starts the
|
|
7
|
+
* JavaScript bundle without the UI, so nothing later in the app runs.
|
|
8
|
+
*
|
|
9
|
+
* Android runs the handler from a background notification task
|
|
10
|
+
* (expo-task-manager). iOS has no such task for button taps: it launches the
|
|
11
|
+
* app in the background and the response reaches the listener this sets up.
|
|
12
|
+
* The handler's promise is awaited, so a request it makes can finish first.
|
|
13
|
+
*/
|
|
14
|
+
export declare const definePushActionTask: (handler: PushActionHandler) => void;
|
|
15
|
+
//# sourceMappingURL=task.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webority/mobile-core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.28",
|
|
4
4
|
"description": "Platform layer for Webority React Native apps: HTTP clients, auth/token storage, config, logging, formatters, validators, network status, permissions, storage and version checks. No UI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -171,6 +171,16 @@
|
|
|
171
171
|
"default": "./lib/commonjs/push/index.js"
|
|
172
172
|
}
|
|
173
173
|
},
|
|
174
|
+
"./push/task": {
|
|
175
|
+
"import": {
|
|
176
|
+
"types": "./lib/typescript/module/push/task.d.ts",
|
|
177
|
+
"default": "./lib/module/push/task.js"
|
|
178
|
+
},
|
|
179
|
+
"require": {
|
|
180
|
+
"types": "./lib/typescript/commonjs/push/task.d.ts",
|
|
181
|
+
"default": "./lib/commonjs/push/task.js"
|
|
182
|
+
}
|
|
183
|
+
},
|
|
174
184
|
"./smsRetriever": {
|
|
175
185
|
"import": {
|
|
176
186
|
"types": "./lib/typescript/module/smsRetriever/index.d.ts",
|
|
@@ -242,6 +252,7 @@
|
|
|
242
252
|
"expo-secure-store": ">=57.0.0",
|
|
243
253
|
"expo-sharing": ">=57.0.0",
|
|
244
254
|
"expo-sqlite": ">=57.0.0",
|
|
255
|
+
"expo-task-manager": ">=57.0.0",
|
|
245
256
|
"react": "^19.1.0",
|
|
246
257
|
"react-native": ">=0.81.0"
|
|
247
258
|
},
|
|
@@ -275,6 +286,9 @@
|
|
|
275
286
|
},
|
|
276
287
|
"expo-sharing": {
|
|
277
288
|
"optional": true
|
|
289
|
+
},
|
|
290
|
+
"expo-task-manager": {
|
|
291
|
+
"optional": true
|
|
278
292
|
}
|
|
279
293
|
},
|
|
280
294
|
"publishConfig": {
|