@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 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 lastOpenedId = null;
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 handleResponse = response => {
100
- if (!response || !config) {
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
- // The cold-start read and the listener can both report the launching tap.
104
- const id = `${response.notification.request.identifier}:${response.actionIdentifier}`;
105
- if (id === lastOpenedId) {
178
+ if (!config) {
179
+ pendingResponses.push(response);
106
180
  return;
107
181
  }
108
- lastOpenedId = id;
182
+ routedAt.set(key, Date.now());
109
183
  try {
110
- config.onOpen(toStringData(response.notification.request.content.data));
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.addNotificationResponseReceivedListener(handleResponse);
178
- void m.getLastNotificationResponseAsync().then(response => {
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
@@ -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
@@ -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 lastOpenedId = null;
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 handleResponse = response => {
96
- if (!response || !config) {
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
- // The cold-start read and the listener can both report the launching tap.
100
- const id = `${response.notification.request.identifier}:${response.actionIdentifier}`;
101
- if (id === lastOpenedId) {
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
- lastOpenedId = id;
175
+ routedAt.set(key, Date.now());
105
176
  try {
106
- config.onOpen(toStringData(response.notification.request.content.data));
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.addNotificationResponseReceivedListener(handleResponse);
174
- void m.getLastNotificationResponseAsync().then(response => {
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.27",
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": {