UNPKG

13.1 kBJavaScriptView Raw
1// @flow
2
3import { EventEmitter, EventSubscription } from 'fbemitter';
4
5import invariant from 'invariant';
6import warning from 'fbjs/lib/warning';
7
8import { AsyncStorage, DeviceEventEmitter, NativeModules, Platform } from 'react-native';
9
10const { ExponentNotifications } = NativeModules;
11
12type Notification = {
13 origin: 'selected' | 'received',
14 data: any,
15 remote: boolean,
16 isMultiple: boolean,
17};
18
19type LocalNotification = {
20 title: string,
21 // How should we deal with body being required on iOS but not on Android?
22 body?: string,
23 data?: any,
24 ios?: {
25 sound?: boolean,
26 },
27 android?: {
28 channelId?: string,
29 icon?: string,
30 color?: string,
31 sticky?: boolean,
32 link?: string,
33 // DEPRECATED:
34 sound?: boolean,
35 vibrate?: boolean | Array<number>,
36 priority: string,
37 },
38};
39
40type Channel = {
41 name: string,
42 description?: string,
43 priority?: string,
44 sound?: boolean,
45 vibrate?: boolean | Array<number>,
46 badge?: boolean,
47};
48
49// Android assigns unique number to each notification natively.
50// Since that's not supported on iOS, we generate an unique string.
51type LocalNotificationId = string | number;
52
53let _emitter;
54let _initialNotification;
55
56function _maybeInitEmitter() {
57 if (!_emitter) {
58 _emitter = new EventEmitter();
59 DeviceEventEmitter.addListener('Exponent.notification', _emitNotification);
60 }
61}
62
63function _emitNotification(notification) {
64 if (typeof notification === 'string') {
65 notification = JSON.parse(notification);
66 }
67
68 /* Don't mutate the original notification */
69 notification = { ...notification };
70
71 if (typeof notification.data === 'string') {
72 try {
73 notification.data = JSON.parse(notification.data);
74 } catch (e) {
75 // It's actually just a string, that's fine
76 }
77 }
78
79 _emitter.emit('notification', notification);
80}
81
82function _processNotification(notification) {
83 notification = Object.assign({}, notification);
84
85 if (!notification.data) {
86 notification.data = {};
87 }
88
89 if (notification.hasOwnProperty('count')) {
90 delete notification.count;
91 }
92
93 // Delete any Android properties on iOS and merge the iOS properties on root notification object
94 if (Platform.OS === 'ios') {
95 if (notification.android) {
96 delete notification.android;
97 }
98
99 if (notification.ios) {
100 notification = Object.assign(notification, notification.ios);
101 delete notification.ios;
102 }
103 }
104
105 // Delete any iOS properties on Android and merge the Android properties on root notification
106 // object
107 if (Platform.OS === 'android') {
108 if (notification.ios) {
109 delete notification.ios;
110 }
111
112 if (notification.android) {
113 notification = Object.assign(notification, notification.android);
114 delete notification.android;
115 }
116 }
117
118 return notification;
119}
120
121function _validateNotification(notification) {
122 if (Platform.OS === 'ios') {
123 invariant(
124 !!notification.title && !!notification.body,
125 'Local notifications on iOS require both a title and a body'
126 );
127 } else if (Platform.OS === 'android') {
128 invariant(!!notification.title, 'Local notifications on Android require a title');
129 }
130}
131
132let ASYNC_STORAGE_PREFIX = '__expo_internal_channel_';
133// TODO: remove this before releasing
134// this will always be `true` for SDK 28+
135let IS_USING_NEW_BINARY = typeof ExponentNotifications.createChannel === 'function';
136
137async function _legacyReadChannel(id: string): Promise<Channel | null> {
138 try {
139 let channelString = await AsyncStorage.getItem(`${ASYNC_STORAGE_PREFIX}${id}`);
140 if (channelString) {
141 return JSON.parse(channelString);
142 }
143 } catch (e) {}
144 return null;
145}
146
147function _legacyDeleteChannel(id: string): Promise<void> {
148 return AsyncStorage.removeItem(`${ASYNC_STORAGE_PREFIX}${id}`);
149}
150
151if (Platform.OS === 'android') {
152 AsyncStorage.clear = async function(callback?: ?(error: ?Error) => void): Promise {
153 try {
154 let keys = await AsyncStorage.getAllKeys();
155 let result = null;
156 if (keys && keys.length) {
157 let filteredKeys = keys.filter(key => !key.startsWith(ASYNC_STORAGE_PREFIX));
158 result = await AsyncStorage.multiRemove(filteredKeys);
159 }
160 callback && callback(result);
161 return result;
162 } catch (e) {
163 callback && callback(e);
164 throw e;
165 }
166 };
167}
168
169// This codepath will never be triggered in SDK 28 and above
170// TODO: remove before releasing
171function _legacySaveChannel(id: string, channel: Channel): Promise<void> {
172 return AsyncStorage.setItem(`${ASYNC_STORAGE_PREFIX}${id}`, JSON.stringify(channel));
173}
174
175export default {
176 /* Only used internally to initialize the notification from top level props */
177 _setInitialNotification(notification: Notification) {
178 _initialNotification = notification;
179 },
180
181 /* Re-export */
182 getExpoPushTokenAsync(): Promise<string> {
183 return ExponentNotifications.getExponentPushTokenAsync();
184 },
185
186 /* Re-export, we can add flow here if we want as well */
187 getDevicePushTokenAsync: ExponentNotifications.getDevicePushTokenAsync,
188
189 createChannelAndroidAsync(id: string, channel: Channel): Promise<void> {
190 if (Platform.OS === 'ios') {
191 console.warn('createChannelAndroidAsync(...) has no effect on iOS');
192 return Promise.resolve();
193 }
194 // This codepath will never be triggered in SDK 28 and above
195 // TODO: remove before releasing
196 if (!IS_USING_NEW_BINARY) {
197 return _legacySaveChannel(id, channel);
198 }
199 return ExponentNotifications.createChannel(id, channel);
200 },
201
202 deleteChannelAndroidAsync(id: string): Promise<void> {
203 if (Platform.OS === 'ios') {
204 console.warn('deleteChannelAndroidAsync(...) has no effect on iOS');
205 return Promise.resolve();
206 }
207 // This codepath will never be triggered in SDK 28 and above
208 // TODO: remove before releasing
209 if (!IS_USING_NEW_BINARY) {
210 return Promise.resolve();
211 }
212 return ExponentNotifications.deleteChannel(id);
213 },
214
215 /* Shows a notification instantly */
216 async presentLocalNotificationAsync(
217 notification: LocalNotification
218 ): Promise<LocalNotificationId> {
219 _validateNotification(notification);
220 notification = _processNotification(notification);
221
222 if (Platform.OS === 'ios') {
223 return ExponentNotifications.presentLocalNotification(notification);
224 } else {
225 let _channel;
226 if (notification.channelId) {
227 _channel = await _legacyReadChannel(notification.channelId);
228 }
229
230 if (IS_USING_NEW_BINARY) {
231 // delete the legacy channel from AsyncStorage so this codepath isn't triggered anymore
232 _legacyDeleteChannel(notification.channelId);
233 return ExponentNotifications.presentLocalNotificationWithChannel(notification, _channel);
234 } else {
235 // TODO: remove this codepath before releasing, it will never be triggered on SDK 28+
236 // channel does not actually exist, so add its settings to the individual notification
237 if (_channel) {
238 notification.sound = _channel.sound;
239 notification.priority = _channel.priority;
240 notification.vibrate = _channel.vibrate;
241 }
242 return ExponentNotifications.presentLocalNotification(notification);
243 }
244 }
245 },
246
247 /* Schedule a notification at a later date */
248 async scheduleLocalNotificationAsync(
249 notification: LocalNotification,
250 options: {
251 time?: Date | number,
252 repeat?: 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year',
253 intervalMs?: number,
254 } = {}
255 ): Promise<LocalNotificationId> {
256 // set now at the beginning of the method, to prevent potential weird warnings when we validate
257 // options.time later on
258 const now = Date.now();
259
260 // Validate and process the notification data
261 _validateNotification(notification);
262 notification = _processNotification(notification);
263
264 // Validate `options.time`
265 if (options.time) {
266 let timeAsDateObj = null;
267 if (options.time && typeof options.time === 'number') {
268 timeAsDateObj = new Date(options.time);
269 // god, JS is the worst
270 if (((timeAsDateObj: any): string) === 'Invalid Date') {
271 timeAsDateObj = null;
272 }
273 } else if (options.time && options.time instanceof Date) {
274 timeAsDateObj = options.time;
275 }
276
277 // If we couldn't convert properly, throw an error
278 if (!timeAsDateObj) {
279 throw new Error(
280 `Provided value for "time" is invalid. Please verify that it's either a number representing Unix Epoch time in milliseconds, or a valid date object.`
281 );
282 }
283
284 // If someone passes in a value that is too small, say, by an order of 1000 (it's common to
285 // accidently pass seconds instead of ms), display a warning.
286 warning(
287 timeAsDateObj >= now,
288 `Provided value for "time" is before the current date. Did you possibly pass number of seconds since Unix Epoch instead of number of milliseconds?`
289 );
290
291 // If iOS, pass time as milliseconds
292 if (Platform.OS === 'ios') {
293 options = {
294 ...options,
295 time: timeAsDateObj.getTime(),
296 };
297 } else {
298 options = {
299 ...options,
300 time: timeAsDateObj,
301 };
302 }
303 }
304
305 if (options.intervalMs != null && options.repeat != null) {
306 throw new Error(`Pass either the "repeat" option or "intervalMs" option, not both`);
307 }
308
309 // Validate options.repeat
310 if (options.repeat != null) {
311 const validOptions = new Set(['minute', 'hour', 'day', 'week', 'month', 'year']);
312 if (!validOptions.has(options.repeat)) {
313 throw new Error(
314 `Pass one of ['minute', 'hour', 'day', 'week', 'month', 'year'] as the value for the "repeat" option`
315 );
316 }
317 }
318
319 if (options.intervalMs != null) {
320 if (Platform.OS === 'ios') {
321 throw new Error(`The "intervalMs" option is not supported on iOS`);
322 }
323
324 if (options.intervalMs <= 0 || !Number.isInteger(options.intervalMs)) {
325 throw new Error(
326 `Pass an integer greater than zero as the value for the "intervalMs" option`
327 );
328 }
329 }
330
331 if (Platform.OS === 'ios') {
332 return ExponentNotifications.scheduleLocalNotification(notification, options);
333 } else {
334 let _channel;
335 if (notification.channelId) {
336 _channel = await _legacyReadChannel(notification.channelId);
337 }
338
339 if (IS_USING_NEW_BINARY) {
340 // delete the legacy channel from AsyncStorage so this codepath isn't triggered anymore
341 _legacyDeleteChannel(notification.channelId);
342 return ExponentNotifications.scheduleLocalNotificationWithChannel(
343 notification,
344 options,
345 _channel
346 );
347 } else {
348 // TODO: remove this codepath before releasing, it will never be triggered on SDK 28+
349 // channel does not actually exist, so add its settings to the individual notification
350 if (_channel) {
351 notification.sound = _channel.sound;
352 notification.priority = _channel.priority;
353 notification.vibrate = _channel.vibrate;
354 }
355 return ExponentNotifications.scheduleLocalNotification(notification, options);
356 }
357 }
358 },
359
360 /* Dismiss currently shown notification with ID (Android only) */
361 async dismissNotificationAsync(notificationId: LocalNotificationId): Promise<void> {
362 if (Platform.OS === 'android') {
363 return ExponentNotifications.dismissNotification(notificationId);
364 } else {
365 throw new Error('Dismissing notifications is not supported on iOS');
366 }
367 },
368
369 /* Dismiss all currently shown notifications (Android only) */
370 async dismissAllNotificationsAsync(): Promise<void> {
371 if (Platform.OS === 'android') {
372 return ExponentNotifications.dismissAllNotifications();
373 } else {
374 throw new Error('Dismissing notifications is not supported on iOS');
375 }
376 },
377
378 /* Cancel scheduled notification notification with ID */
379 cancelScheduledNotificationAsync(notificationId: LocalNotificationId): Promise<void> {
380 return ExponentNotifications.cancelScheduledNotification(notificationId);
381 },
382
383 /* Cancel all scheduled notifications */
384 cancelAllScheduledNotificationsAsync(): Promise<void> {
385 return ExponentNotifications.cancelAllScheduledNotifications();
386 },
387
388 /* Primary public api */
389 addListener(listener: Function): EventSubscription {
390 _maybeInitEmitter();
391
392 if (_initialNotification) {
393 const initialNotification = _initialNotification;
394 _initialNotification = null;
395 setTimeout(() => {
396 _emitNotification(initialNotification);
397 }, 0);
398 }
399
400 return _emitter.addListener('notification', listener);
401 },
402
403 async getBadgeNumberAsync(): Promise<number> {
404 if (!ExponentNotifications.getBadgeNumberAsync) {
405 return 0;
406 }
407 return ExponentNotifications.getBadgeNumberAsync();
408 },
409
410 async setBadgeNumberAsync(number: number): Promise<void> {
411 if (!ExponentNotifications.setBadgeNumberAsync) {
412 return;
413 }
414 return ExponentNotifications.setBadgeNumberAsync(number);
415 },
416};