@baukit/notifications-expo 0.7.1 → 0.7.3
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/package.json +3 -2
- package/src/index.test.ts +256 -0
- package/src/index.ts +134 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baukit/notifications-expo",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Optional expo-notifications adapter for @baukit/notifications-core that replaces only notifications carrying one owner's namespace marker.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Patrick Koss",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|
|
33
|
+
"src",
|
|
33
34
|
"README.md",
|
|
34
35
|
"LICENSE"
|
|
35
36
|
],
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"vitest": "5.0.3"
|
|
49
50
|
},
|
|
50
51
|
"peerDependencies": {
|
|
51
|
-
"@baukit/notifications-core": "^0.7.
|
|
52
|
+
"@baukit/notifications-core": "^0.7.3",
|
|
52
53
|
"expo-notifications": "^57.0.21"
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeOwnedNotificationMarker,
|
|
3
|
+
encodeOwnedNotificationMarker,
|
|
4
|
+
NotificationPlatformFaultState,
|
|
5
|
+
OWNED_NOTIFICATION_DATA_KEY,
|
|
6
|
+
ownedNotificationIdentifier,
|
|
7
|
+
type PendingNotification,
|
|
8
|
+
} from '@baukit/notifications-core';
|
|
9
|
+
import { describeOwnedNotificationSchedulerContract } from '@baukit/notifications-core/vitest';
|
|
10
|
+
import type {
|
|
11
|
+
NotificationPermissionsStatus,
|
|
12
|
+
NotificationRequest,
|
|
13
|
+
NotificationRequestInput,
|
|
14
|
+
} from 'expo-notifications';
|
|
15
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
createExpoNotificationPlatform,
|
|
19
|
+
createExpoOwnedNotificationScheduler,
|
|
20
|
+
type ExpoNotificationContent,
|
|
21
|
+
type ExpoNotificationsApi,
|
|
22
|
+
type ExpoOwnedNotificationSchedulerOptions,
|
|
23
|
+
} from './index.js';
|
|
24
|
+
|
|
25
|
+
const TRIGGER_TYPES = {
|
|
26
|
+
CALENDAR: 'calendar',
|
|
27
|
+
DAILY: 'daily',
|
|
28
|
+
WEEKLY: 'weekly',
|
|
29
|
+
MONTHLY: 'monthly',
|
|
30
|
+
YEARLY: 'yearly',
|
|
31
|
+
DATE: 'date',
|
|
32
|
+
TIME_INTERVAL: 'timeInterval',
|
|
33
|
+
} as unknown as ExpoNotificationsApi['SchedulableTriggerInputTypes'];
|
|
34
|
+
|
|
35
|
+
const IOS_STATUS = {
|
|
36
|
+
NOT_DETERMINED: 0,
|
|
37
|
+
DENIED: 1,
|
|
38
|
+
AUTHORIZED: 2,
|
|
39
|
+
PROVISIONAL: 3,
|
|
40
|
+
EPHEMERAL: 4,
|
|
41
|
+
} as unknown as ExpoNotificationsApi['IosAuthorizationStatus'];
|
|
42
|
+
|
|
43
|
+
const OWNER = { namespace: 'reminders' };
|
|
44
|
+
const INSTANT = Date.parse('2030-01-01T09:00:00Z');
|
|
45
|
+
|
|
46
|
+
function permissionStatus(
|
|
47
|
+
permission: NotificationPlatformFaultState['permission'],
|
|
48
|
+
): NotificationPermissionsStatus {
|
|
49
|
+
return {
|
|
50
|
+
granted: permission === 'granted',
|
|
51
|
+
status: permission as unknown as NotificationPermissionsStatus['status'],
|
|
52
|
+
expires: 'never',
|
|
53
|
+
canAskAgain: permission !== 'denied',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function asResolved<T>(action: () => T): Promise<T> {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
resolve(action());
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class ExpoNotificationsMock {
|
|
64
|
+
readonly faults = new NotificationPlatformFaultState();
|
|
65
|
+
readonly requests = new Map<string, NotificationRequest>();
|
|
66
|
+
readonly scheduled: NotificationRequestInput[] = [];
|
|
67
|
+
readonly cancelAll = vi.fn(() => Promise.resolve());
|
|
68
|
+
permissionOverride: NotificationPermissionsStatus | null = null;
|
|
69
|
+
|
|
70
|
+
readonly api = {
|
|
71
|
+
SchedulableTriggerInputTypes: TRIGGER_TYPES,
|
|
72
|
+
IosAuthorizationStatus: IOS_STATUS,
|
|
73
|
+
cancelAllScheduledNotificationsAsync: this.cancelAll,
|
|
74
|
+
getAllScheduledNotificationsAsync: async (): Promise<NotificationRequest[]> => {
|
|
75
|
+
await this.faults.beforeList();
|
|
76
|
+
const snapshot = [...this.requests.values()];
|
|
77
|
+
this.faults.afterList();
|
|
78
|
+
return snapshot;
|
|
79
|
+
},
|
|
80
|
+
cancelScheduledNotificationAsync: (identifier: string): Promise<void> =>
|
|
81
|
+
asResolved(() => {
|
|
82
|
+
this.faults.beforeCancel(identifier);
|
|
83
|
+
this.requests.delete(identifier);
|
|
84
|
+
}),
|
|
85
|
+
getPermissionsAsync: (): Promise<NotificationPermissionsStatus> =>
|
|
86
|
+
asResolved(() => {
|
|
87
|
+
this.faults.beforePermission();
|
|
88
|
+
return this.permissionOverride ?? permissionStatus(this.faults.permission);
|
|
89
|
+
}),
|
|
90
|
+
scheduleNotificationAsync: (request: NotificationRequestInput): Promise<string> =>
|
|
91
|
+
asResolved(() => {
|
|
92
|
+
const identifier = request.identifier ?? 'generated';
|
|
93
|
+
this.faults.beforeSchedule(identifier);
|
|
94
|
+
this.scheduled.push(request);
|
|
95
|
+
this.requests.set(identifier, {
|
|
96
|
+
identifier,
|
|
97
|
+
content: request.content,
|
|
98
|
+
trigger: request.trigger,
|
|
99
|
+
} as NotificationRequest);
|
|
100
|
+
return identifier;
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
addForeign(identifier: string, data: Record<string, unknown> | null): void {
|
|
105
|
+
this.requests.set(identifier, {
|
|
106
|
+
identifier,
|
|
107
|
+
content: { title: 'foreign', data },
|
|
108
|
+
trigger: null,
|
|
109
|
+
} as unknown as NotificationRequest);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
pending(): PendingNotification[] {
|
|
113
|
+
return [...this.requests.values()].map((request) => ({
|
|
114
|
+
identifier: request.identifier,
|
|
115
|
+
marker: decodeOwnedNotificationMarker(request.content.data?.[OWNED_NOTIFICATION_DATA_KEY]),
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function setup(options: ExpoOwnedNotificationSchedulerOptions = {}) {
|
|
121
|
+
const mock = new ExpoNotificationsMock();
|
|
122
|
+
const scheduler = createExpoOwnedNotificationScheduler(mock.api, options);
|
|
123
|
+
return { mock, scheduler };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function desired(logicalId: string, content: ExpoNotificationContent) {
|
|
127
|
+
return { logicalId, epochMilliseconds: INSTANT, contentDigest: 'v1', content };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe('Expo owned notification scheduler', () => {
|
|
131
|
+
describeOwnedNotificationSchedulerContract<ExpoNotificationContent>((options) => {
|
|
132
|
+
const { mock, scheduler } = setup(options);
|
|
133
|
+
return {
|
|
134
|
+
scheduler,
|
|
135
|
+
faults: mock.faults,
|
|
136
|
+
content: (_logicalId, text) => ({ content: { title: text, body: text } }),
|
|
137
|
+
addUnrelated: (identifier) => {
|
|
138
|
+
mock.addForeign(identifier, { kind: 'other-feature' });
|
|
139
|
+
},
|
|
140
|
+
pending: () => mock.pending(),
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('schedules a date trigger with the marker merged into product data', async () => {
|
|
145
|
+
const { mock, scheduler } = setup();
|
|
146
|
+
await scheduler.replaceOwned(OWNER, [
|
|
147
|
+
desired('a', {
|
|
148
|
+
content: { title: 'Time to train', data: { screen: 'plan' } },
|
|
149
|
+
channelId: 'daily',
|
|
150
|
+
}),
|
|
151
|
+
]);
|
|
152
|
+
const identifier = ownedNotificationIdentifier(OWNER.namespace, 'a');
|
|
153
|
+
expect(mock.scheduled).toEqual([
|
|
154
|
+
{
|
|
155
|
+
identifier,
|
|
156
|
+
content: {
|
|
157
|
+
title: 'Time to train',
|
|
158
|
+
data: {
|
|
159
|
+
screen: 'plan',
|
|
160
|
+
[OWNED_NOTIFICATION_DATA_KEY]: encodeOwnedNotificationMarker({
|
|
161
|
+
namespace: OWNER.namespace,
|
|
162
|
+
logicalId: 'a',
|
|
163
|
+
epochMilliseconds: INSTANT,
|
|
164
|
+
contentDigest: 'v1',
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
trigger: { type: 'date', date: INSTANT, channelId: 'daily' },
|
|
169
|
+
},
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('omits the channel when the product sets none', async () => {
|
|
174
|
+
const { mock, scheduler } = setup();
|
|
175
|
+
await scheduler.replaceOwned(OWNER, [desired('a', { content: { title: 'x' } })]);
|
|
176
|
+
expect(mock.scheduled[0]?.trigger).toEqual({ type: 'date', date: INSTANT });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('rejects product data that uses the reserved key before listing', async () => {
|
|
180
|
+
const { mock, scheduler } = setup();
|
|
181
|
+
mock.faults.failNextList();
|
|
182
|
+
const content = { content: { title: 'x', data: { [OWNED_NOTIFICATION_DATA_KEY]: 'mine' } } };
|
|
183
|
+
await expect(scheduler.replaceOwned(OWNER, [desired('a', content)])).rejects.toMatchObject({
|
|
184
|
+
code: 'reserved_data_key',
|
|
185
|
+
logicalId: 'a',
|
|
186
|
+
});
|
|
187
|
+
expect(await scheduler.replaceOwned(OWNER, [])).toMatchObject({
|
|
188
|
+
failures: [{ code: 'list_failed' }],
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('leaves requests with forged, invalid or missing markers alone', async () => {
|
|
193
|
+
const { mock, scheduler } = setup();
|
|
194
|
+
const marker = encodeOwnedNotificationMarker({
|
|
195
|
+
namespace: OWNER.namespace,
|
|
196
|
+
logicalId: 'a',
|
|
197
|
+
epochMilliseconds: INSTANT,
|
|
198
|
+
contentDigest: 'v1',
|
|
199
|
+
});
|
|
200
|
+
mock.addForeign('forged-identifier', { [OWNED_NOTIFICATION_DATA_KEY]: marker });
|
|
201
|
+
mock.addForeign('invalid-marker', { [OWNED_NOTIFICATION_DATA_KEY]: '{"version":1}' });
|
|
202
|
+
mock.addForeign('no-data', null);
|
|
203
|
+
|
|
204
|
+
expect(await scheduler.replaceOwned(OWNER, [])).toMatchObject({
|
|
205
|
+
status: 'complete',
|
|
206
|
+
cancelled: [],
|
|
207
|
+
});
|
|
208
|
+
expect([...mock.requests.keys()].sort()).toEqual(
|
|
209
|
+
['forged-identifier', 'invalid-marker', 'no-data'].sort(),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('never cancels every scheduled notification', async () => {
|
|
214
|
+
const { mock, scheduler } = setup();
|
|
215
|
+
await scheduler.replaceOwned(OWNER, [desired('a', { content: { title: 'x' } })]);
|
|
216
|
+
await scheduler.replaceOwned(OWNER, []);
|
|
217
|
+
expect(mock.cancelAll).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it.each([
|
|
221
|
+
['provisional', IOS_STATUS.PROVISIONAL],
|
|
222
|
+
['ephemeral', IOS_STATUS.EPHEMERAL],
|
|
223
|
+
['authorized', IOS_STATUS.AUTHORIZED],
|
|
224
|
+
])('treats iOS %s authorization as granted', async (_label, iosStatus) => {
|
|
225
|
+
const { mock, scheduler } = setup();
|
|
226
|
+
mock.permissionOverride = {
|
|
227
|
+
...permissionStatus('undetermined'),
|
|
228
|
+
ios: { status: iosStatus },
|
|
229
|
+
} as NotificationPermissionsStatus;
|
|
230
|
+
expect(
|
|
231
|
+
await scheduler.replaceOwned(OWNER, [desired('a', { content: { title: 'x' } })]),
|
|
232
|
+
).toMatchObject({ status: 'complete', scheduled: ['a'] });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('treats an undetermined permission as not granted', async () => {
|
|
236
|
+
const { mock, scheduler } = setup();
|
|
237
|
+
mock.faults.setPermission('undetermined');
|
|
238
|
+
expect(
|
|
239
|
+
await scheduler.replaceOwned(OWNER, [desired('a', { content: { title: 'x' } })]),
|
|
240
|
+
).toMatchObject({
|
|
241
|
+
status: 'incomplete',
|
|
242
|
+
failures: [{ code: 'permission_denied', logicalId: 'a' }],
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('reports the platform permission states', async () => {
|
|
247
|
+
const mock = new ExpoNotificationsMock();
|
|
248
|
+
const platform = createExpoNotificationPlatform(mock.api);
|
|
249
|
+
mock.faults.setPermission('denied');
|
|
250
|
+
expect(await platform.permission()).toBe('denied');
|
|
251
|
+
mock.faults.setPermission('undetermined');
|
|
252
|
+
expect(await platform.permission()).toBe('undetermined');
|
|
253
|
+
mock.faults.setPermission('granted');
|
|
254
|
+
expect(await platform.permission()).toBe('granted');
|
|
255
|
+
});
|
|
256
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createOwnedNotificationScheduler,
|
|
3
|
+
decodeOwnedNotificationMarker,
|
|
4
|
+
encodeOwnedNotificationMarker,
|
|
5
|
+
NotificationPlanError,
|
|
6
|
+
OWNED_NOTIFICATION_DATA_KEY,
|
|
7
|
+
type NotificationPermission,
|
|
8
|
+
type NotificationPlatform,
|
|
9
|
+
type OwnedNotificationScheduler,
|
|
10
|
+
type OwnedNotificationScheduleRequest,
|
|
11
|
+
type OwnedNotificationSchedulerOptions,
|
|
12
|
+
type PendingNotification,
|
|
13
|
+
} from '@baukit/notifications-core';
|
|
14
|
+
import type * as ExpoNotifications from 'expo-notifications';
|
|
15
|
+
import type {
|
|
16
|
+
DateTriggerInput,
|
|
17
|
+
NotificationContentInput,
|
|
18
|
+
NotificationPermissionsStatus,
|
|
19
|
+
NotificationRequest,
|
|
20
|
+
} from 'expo-notifications';
|
|
21
|
+
|
|
22
|
+
export const IOS_PENDING_NOTIFICATION_LIMIT = 64;
|
|
23
|
+
|
|
24
|
+
export type ExpoNotificationsApi = Pick<
|
|
25
|
+
typeof ExpoNotifications,
|
|
26
|
+
| 'getAllScheduledNotificationsAsync'
|
|
27
|
+
| 'cancelScheduledNotificationAsync'
|
|
28
|
+
| 'scheduleNotificationAsync'
|
|
29
|
+
| 'getPermissionsAsync'
|
|
30
|
+
| 'SchedulableTriggerInputTypes'
|
|
31
|
+
| 'IosAuthorizationStatus'
|
|
32
|
+
>;
|
|
33
|
+
|
|
34
|
+
export interface ExpoNotificationContent {
|
|
35
|
+
readonly content: NotificationContentInput;
|
|
36
|
+
readonly channelId?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ExpoOwnedNotificationSchedulerOptions = OwnedNotificationSchedulerOptions;
|
|
40
|
+
|
|
41
|
+
const UNDETERMINED = 'undetermined';
|
|
42
|
+
|
|
43
|
+
export function createExpoNotificationPlatform(
|
|
44
|
+
api: ExpoNotificationsApi,
|
|
45
|
+
): NotificationPlatform<ExpoNotificationContent> {
|
|
46
|
+
return {
|
|
47
|
+
async list() {
|
|
48
|
+
const requests = await api.getAllScheduledNotificationsAsync();
|
|
49
|
+
return requests.map(pendingNotification);
|
|
50
|
+
},
|
|
51
|
+
cancel(identifier) {
|
|
52
|
+
return api.cancelScheduledNotificationAsync(identifier);
|
|
53
|
+
},
|
|
54
|
+
async permission() {
|
|
55
|
+
return permissionFrom(api, await api.getPermissionsAsync());
|
|
56
|
+
},
|
|
57
|
+
async schedule(request) {
|
|
58
|
+
await api.scheduleNotificationAsync({
|
|
59
|
+
identifier: request.identifier,
|
|
60
|
+
content: contentWithMarker(request),
|
|
61
|
+
trigger: dateTrigger(api, request),
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createExpoOwnedNotificationScheduler(
|
|
68
|
+
api: ExpoNotificationsApi,
|
|
69
|
+
options: ExpoOwnedNotificationSchedulerOptions = {},
|
|
70
|
+
): OwnedNotificationScheduler<ExpoNotificationContent> {
|
|
71
|
+
const scheduler = createOwnedNotificationScheduler(createExpoNotificationPlatform(api), options);
|
|
72
|
+
return {
|
|
73
|
+
replaceOwned(owner, desired, replaceOptions) {
|
|
74
|
+
const reserved = desired.find((entry) => hasReservedKey(entry.content.content));
|
|
75
|
+
if (reserved !== undefined) {
|
|
76
|
+
return Promise.reject(new NotificationPlanError('reserved_data_key', reserved.logicalId));
|
|
77
|
+
}
|
|
78
|
+
return scheduler.replaceOwned(owner, desired, replaceOptions);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pendingNotification(request: NotificationRequest): PendingNotification {
|
|
84
|
+
return {
|
|
85
|
+
identifier: request.identifier,
|
|
86
|
+
marker: decodeOwnedNotificationMarker(request.content.data?.[OWNED_NOTIFICATION_DATA_KEY]),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hasReservedKey(content: NotificationContentInput): boolean {
|
|
91
|
+
return content.data !== undefined && Object.hasOwn(content.data, OWNED_NOTIFICATION_DATA_KEY);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function contentWithMarker(
|
|
95
|
+
request: OwnedNotificationScheduleRequest<ExpoNotificationContent>,
|
|
96
|
+
): NotificationContentInput {
|
|
97
|
+
const { content } = request.content;
|
|
98
|
+
return {
|
|
99
|
+
...content,
|
|
100
|
+
data: {
|
|
101
|
+
...content.data,
|
|
102
|
+
[OWNED_NOTIFICATION_DATA_KEY]: encodeOwnedNotificationMarker(request.marker),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dateTrigger(
|
|
108
|
+
api: ExpoNotificationsApi,
|
|
109
|
+
request: OwnedNotificationScheduleRequest<ExpoNotificationContent>,
|
|
110
|
+
): DateTriggerInput {
|
|
111
|
+
const trigger: DateTriggerInput = {
|
|
112
|
+
type: api.SchedulableTriggerInputTypes.DATE,
|
|
113
|
+
date: request.marker.epochMilliseconds,
|
|
114
|
+
};
|
|
115
|
+
const { channelId } = request.content;
|
|
116
|
+
return channelId === undefined ? trigger : { ...trigger, channelId };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function permissionFrom(
|
|
120
|
+
api: ExpoNotificationsApi,
|
|
121
|
+
permission: NotificationPermissionsStatus,
|
|
122
|
+
): NotificationPermission {
|
|
123
|
+
const iosStatus = permission.ios?.status;
|
|
124
|
+
if (
|
|
125
|
+
permission.granted ||
|
|
126
|
+
iosStatus === api.IosAuthorizationStatus.AUTHORIZED ||
|
|
127
|
+
iosStatus === api.IosAuthorizationStatus.PROVISIONAL ||
|
|
128
|
+
iosStatus === api.IosAuthorizationStatus.EPHEMERAL
|
|
129
|
+
) {
|
|
130
|
+
return 'granted';
|
|
131
|
+
}
|
|
132
|
+
const status: string = permission.status;
|
|
133
|
+
return status === UNDETERMINED ? 'undetermined' : 'denied';
|
|
134
|
+
}
|