diff --git a/README.md b/README.md index 832c868..0d02ba1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The official Push Notification adapter for Parse Server. See [Parse Server Push - [Using a Custom Version on Parse Server](#using-a-custom-version-on-parse-server) - [Install Push Adapter](#install-push-adapter) - [Configure Parse Server](#configure-parse-server) + - [Expo Push Options](#expo-push-options) # Silent Notifications @@ -57,16 +58,32 @@ const parseServerOptions = { push: { adapter: new PushAdapter({ ios: { - /* Apple push notification options */ + /* Apple push options */ }, android: { /* Android push options */ - } + }, web: { /* Web push options */ - } - }) + }, + expo: { + /* Expo push options */ + }, + }), }, /* Other Parse Server options */ } ``` + +### Expo Push Options + +Example options: + +```js +expo: { + accessToken: '', +}, +``` + +For more information see the [Expo docs](https://docs.expo.dev/push-notifications/overview/). + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c564bee..5762cdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@parse/node-apn": "6.0.1", "@parse/node-gcm": "1.0.2", - "firebase-admin": "^12.1.0", + "expo-server-sdk": "3.10.0", + "firebase-admin": "12.1.0", "npmlog": "7.0.1", "parse": "5.0.0", "web-push": "3.6.7" @@ -4197,6 +4198,11 @@ "node": ">=10.17.0" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -4377,6 +4383,16 @@ "node": ">=6" } }, + "node_modules/expo-server-sdk": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-3.10.0.tgz", + "integrity": "sha512-isymUVz18Syp9G+TPs2MVZ6WdMoyLw8hDLhpywOd8JqM6iGTka6Dr8Dzq7mjGQ8C8486rxLawZx/W+ps+vkjLQ==", + "dependencies": { + "node-fetch": "^2.6.0", + "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -7142,7 +7158,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "devOptional": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -10949,6 +10964,31 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "engines": { + "node": ">= 4" + } + }, "node_modules/proto3-json-serializer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.1.tgz", @@ -13391,8 +13431,7 @@ "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", - "devOptional": true + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" }, "node_modules/traverse": { "version": "0.6.6", @@ -13752,8 +13791,7 @@ "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", - "devOptional": true + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" }, "node_modules/websocket-driver": { "version": "0.7.4", @@ -13780,7 +13818,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "devOptional": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -17601,6 +17638,11 @@ } } }, + "err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -17734,6 +17776,16 @@ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" }, + "expo-server-sdk": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-3.10.0.tgz", + "integrity": "sha512-isymUVz18Syp9G+TPs2MVZ6WdMoyLw8hDLhpywOd8JqM6iGTka6Dr8Dzq7mjGQ8C8486rxLawZx/W+ps+vkjLQ==", + "requires": { + "node-fetch": "^2.6.0", + "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1" + } + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -19911,7 +19963,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "devOptional": true, "requires": { "whatwg-url": "^5.0.0" } @@ -22665,6 +22716,27 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==" + }, + "promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "dependencies": { + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==" + } + } + }, "proto3-json-serializer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.1.tgz", @@ -24611,8 +24683,7 @@ "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", - "devOptional": true + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" }, "traverse": { "version": "0.6.6", @@ -24893,8 +24964,7 @@ "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", - "devOptional": true + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" }, "websocket-driver": { "version": "0.7.4", @@ -24915,7 +24985,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "devOptional": true, "requires": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" diff --git a/package.json b/package.json index 1696055..393d531 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dependencies": { "@parse/node-apn": "6.0.1", "@parse/node-gcm": "1.0.2", + "expo-server-sdk": "3.10.0", "firebase-admin": "12.1.0", "npmlog": "7.0.1", "parse": "5.0.0", diff --git a/spec/EXPO.spec.js b/spec/EXPO.spec.js new file mode 100644 index 0000000..12d2721 --- /dev/null +++ b/spec/EXPO.spec.js @@ -0,0 +1,143 @@ +const EXPO = require('../src/EXPO').default; +const Expo = require('expo-server-sdk').Expo; + +function mockSender(success) { + return spyOn(EXPO.prototype, 'sendNotifications').and.callFake((payload, tokens) => { + return Promise.resolve(tokens.map(() => ({ status: success ? 'ok' : 'error' }))); + }); +} + +function mockExpoPush(success) { + return spyOn(Expo.prototype, 'sendPushNotificationsAsync').and.callFake((deviceToken) => { + if (success) { + return Promise.resolve(deviceToken.map(() => ({ status: 'ok' }))); + } + return Promise.resolve(deviceToken.map(() => ({ status: 'error', message: 'Failed to send' }))); + }); +} + +describe('EXPO', () => { + it('can initialize', () => { + const args = { }; + new EXPO(args); + }); + + it('can throw on initializing with invalid args', () => { + expect(function() { new EXPO(123); }).toThrow(); + expect(function() { new EXPO(undefined); }).toThrow(); + }); + + it('can send successful EXPO request', async () => { + const log = require('npmlog'); + const spy = spyOn(log, 'verbose'); + + const expo = new EXPO({ vapidDetails: 'apiKey' }); + spyOn(EXPO.prototype, 'sendNotifications').and.callFake(() => { + return Promise.resolve([{ status: 'ok' }]); + }); + const data = { data: { alert: 'alert' } }; + const devices = [{ deviceToken: 'token' }]; + const response = await expo.send(data, devices); + expect(EXPO.prototype.sendNotifications).toHaveBeenCalled(); + const args = EXPO.prototype.sendNotifications.calls.first().args; + expect(args.length).toEqual(2); + expect(args[0]).toEqual(data.data); + expect(args[1]).toEqual(['token']); + expect(spy).toHaveBeenCalled(); + expect(response).toEqual([{ + device: { deviceToken: 'token', pushType: 'expo' }, + response: { status: 'ok' }, + transmitted: true + }]); + }); + + it('can send failed EXPO request', async () => { + const log = require('npmlog'); + const expo = new EXPO({ vapidDetails: 'apiKey' }); + spyOn(EXPO.prototype, 'sendNotifications').and.callFake(() => { + return Promise.resolve([{ status: 'error', message: 'DeviceNotRegistered' }])}); + const data = { data: { alert: 'alert' } }; + const devices = [{ deviceToken: 'token' }]; + const response = await expo.send(data, devices); + + expect(EXPO.prototype.sendNotifications).toHaveBeenCalled(); + const args = EXPO.prototype.sendNotifications.calls.first().args; + expect(args.length).toEqual(2); + expect(args[0]).toEqual(data.data); + expect(args[1]).toEqual(['token']); + + expect(response).toEqual([{ + device: { deviceToken: 'token', pushType: 'expo' }, + response: { status: 'error', message: 'DeviceNotRegistered', error: 'NotRegistered' }, + transmitted: false + }]); + }); + + it('can send multiple successful EXPO request', async () => { + const expo = new EXPO({ }); + const data = { data: { alert: 'alert' } }; + const devices = [ + { deviceToken: 'token1', deviceType: 'ios' }, + { deviceToken: 'token2', deviceType: 'ios' }, + { deviceToken: 'token3', deviceType: 'ios' }, + { deviceToken: 'token4', deviceType: 'ios' }, + { deviceToken: 'token5', deviceType: 'ios' }, + ]; + mockSender(true); + const response = await expo.send(data, devices); + + expect(Array.isArray(response)).toBe(true); + expect(response.length).toEqual(devices.length); + response.forEach((res, index) => { + expect(res.transmitted).toEqual(true); + expect(res.device.deviceToken).toEqual(devices[index].deviceToken); + }); + }); + + it('can send multiple failed EXPO request', async () => { + const expo = new EXPO({ }); + const data = { data: { alert: 'alert' } }; + const devices = [ + { deviceToken: 'token1' }, + { deviceToken: 'token2' }, + { deviceToken: 'token3' }, + { deviceToken: 'token4' }, + { deviceToken: 'token5' }, + ]; + mockSender(false); + const response = await expo.send(data, devices); + expect(Array.isArray(response)).toBe(true); + expect(response.length).toEqual(devices.length); + response.forEach((res, index) => { + expect(res.transmitted).toEqual(false); + expect(res.device.deviceToken).toEqual(devices[index].deviceToken); + }); + }); + + it('can run successful payload', async () => { + const payload = { alert: 'alert' }; + const deviceTokens = ['ExpoPush[1]']; + mockExpoPush(true); + const response = await new EXPO({}).sendNotifications(payload, deviceTokens); + expect(response.length).toEqual(1); + expect(response[0].status).toEqual('ok'); + }); + + it('can run failed payload', async () => { + const payload = { alert: 'alert' }; + const deviceTokens = ['ExpoPush[1]']; + mockExpoPush(false); + const response = await new EXPO({}).sendNotifications(payload, deviceTokens); + expect(response.length).toEqual(1); + expect(response[0].status).toEqual('error'); + }); + + it('can run successful payload with wrong types', async () => { + const payload = JSON.stringify({ alert: 'alert' }); + const deviceTokens = ['ExpoPush[1]']; + mockExpoPush(true); + const response = await new EXPO({}).sendNotifications(payload, deviceTokens); + expect(response.length).toEqual(1); + expect(response[0].status).toEqual('ok'); + }); +}); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index 17fd80f..c4c6fb9 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -6,6 +6,7 @@ var GCM = require('../src/GCM').default; var WEB = require('../src/WEB').default; var MockAPNProvider = require('./MockAPNProvider'); var FCM = require('../src/FCM').default +var EXPO = require('../src/EXPO').default const path = require('path'); describe('ParsePushAdapter', () => { @@ -24,6 +25,7 @@ describe('ParsePushAdapter', () => { expect(typeof ParsePushAdapterPackage.APNS).toBe('function'); expect(typeof ParsePushAdapterPackage.GCM).toBe('function'); expect(typeof ParsePushAdapterPackage.WEB).toBe('function'); + expect(typeof ParsePushAdapterPackage.EXPO).toBe('function'); expect(typeof ParsePushAdapterPackage.utils).toBe('object'); }); @@ -41,6 +43,9 @@ describe('ParsePushAdapter', () => { senderId: 'senderId', apiKey: 'apiKey' }, + expo: { + apiKey: 'key' + }, ios: [ { cert: new Buffer('testCert'), @@ -67,6 +72,9 @@ describe('ParsePushAdapter', () => { // Check web var webSender = parsePushAdapter.senderMap['web']; expect(webSender instanceof WEB).toBe(true); + // Check expo + var expoSender = parsePushAdapter.senderMap['expo']; + expect(expoSender instanceof EXPO).toBe(true); done(); }); @@ -154,13 +162,13 @@ describe('ParsePushAdapter', () => { it('can get valid push types', (done) => { var parsePushAdapter = new ParsePushAdapter(); - expect(parsePushAdapter.getValidPushTypes()).toEqual(['ios', 'osx', 'tvos', 'android', 'fcm', 'web']); + expect(parsePushAdapter.getValidPushTypes()).toEqual(['ios', 'osx', 'tvos', 'android', 'fcm', 'web', 'expo']); done(); }); it('can classify installation', (done) => { // Mock installations - var validPushTypes = ['ios', 'osx', 'tvos', 'android', 'fcm', 'web']; + var validPushTypes = ['ios', 'osx', 'tvos', 'android', 'fcm', 'web', 'expo']; var installations = [ { deviceType: 'android', @@ -189,6 +197,11 @@ describe('ParsePushAdapter', () => { { deviceType: 'android', deviceToken: undefined + }, + { + deviceType: 'ios', + pushType: 'expo', + deviceToken: 'expoToken' } ]; @@ -199,6 +212,7 @@ describe('ParsePushAdapter', () => { expect(deviceMap['tvos']).toEqual([makeDevice('tvosToken', 'tvos')]); expect(deviceMap['web']).toEqual([makeDevice('webToken', 'web')]); expect(deviceMap['win']).toBe(undefined); + expect(deviceMap['expo']).toEqual([makeDevice('expoToken', 'ios')]); done(); }); @@ -218,11 +232,15 @@ describe('ParsePushAdapter', () => { var webSender = { send: jasmine.createSpy('send') } + var expoSender = { + send: jasmine.createSpy('send') + } var senderMap = { osx: osxSender, ios: iosSender, android: androidSender, web: webSender, + expo: expoSender, }; parsePushAdapter.senderMap = senderMap; // Mock installations @@ -250,6 +268,11 @@ describe('ParsePushAdapter', () => { { deviceType: 'android', deviceToken: undefined + }, + { + deviceType: 'ios', + pushType: 'expo', + deviceToken: 'expoToken' } ]; var data = {}; @@ -283,6 +306,13 @@ describe('ParsePushAdapter', () => { expect(args[1]).toEqual([ makeDevice('webToken', 'web') ]); + // Check expo sender + expect(expoSender.send).toHaveBeenCalled(); + args = expoSender.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('expoToken', 'ios') + ]); done(); }); @@ -375,6 +405,9 @@ describe('ParsePushAdapter', () => { publicKey: 'publicKey', privateKey: 'privateKey', }, + }, + expo: { + }, android: { senderId: 'senderId', @@ -433,6 +466,11 @@ describe('ParsePushAdapter', () => { { deviceType: 'android', deviceToken: undefined + }, + { + deviceType: 'android', + pushType: 'expo', + deviceToken: 'expoToken' } ]; @@ -440,15 +478,18 @@ describe('ParsePushAdapter', () => { parsePushAdapter.send({ data: { alert: 'some' } }, installations).then((results) => { expect(Array.isArray(results)).toBe(true); - // 2x iOS, 1x android, 1x osx, 1x tvos, 1x web - expect(results.length).toBe(6); - results.forEach((result) => { + // 2x iOS, 1x android, 1x osx, 1x tvos, 1x web, 1x expo + expect(results.length).toBe(7); + results.forEach((result) => { expect(typeof result.device).toBe('object'); if (!result.device) { fail('result should have device'); return; } const device = result.device; + if (device.pushType) { + expect(typeof device.pushType).toBe('string'); + } expect(typeof device.deviceType).toBe('string'); expect(typeof device.deviceToken).toBe('string'); if (['ios', 'osx', 'web'].includes(device.deviceType)) { diff --git a/src/EXPO.js b/src/EXPO.js new file mode 100644 index 0000000..34a5fa6 --- /dev/null +++ b/src/EXPO.js @@ -0,0 +1,107 @@ +"use strict"; + +import Parse from 'parse'; +import log from 'npmlog'; +import { Expo } from 'expo-server-sdk'; + +const LOG_PREFIX = 'parse-server-push-adapter EXPO'; + +function expoResultToParseResponse(result) { + if (result.status === 'ok') { + return result; + } else { + // ParseServer looks for "error", and supports ceratin codes like 'NotRegistered' for + // cleanup. Expo returns slighyly different ones so changing to match what is expected + // This can be taken out if the responsibility gets moved to the adapter itself. + const error = result.message === 'DeviceNotRegistered' ? + 'NotRegistered' : result.message; + return { + error, + ...result + } + } +} + +export class EXPO { + expo = undefined; + /** + * Create a new EXPO push adapter. Based on Web Adapter. + * + * @param {Object} args https://github.com/expo/expo-server-sdk-node / https://docs.expo.dev/push-notifications/sending-notifications/ + */ + constructor(args) { + if (typeof args !== 'object') { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'EXPO Push Configuration is invalid'); + } + + this.expo = new Expo(args) + this.options = args; + } + + /** + * Send Expo push notification request. + * + * @param {Object} data The data we need to send, the format is the same with api request body + * @param {Array} devices An array of devices + * @returns {Object} A promise which is resolved immediately + */ + async send(data, devices) { + const coreData = data && data.data; + + if (!coreData || !devices || !Array.isArray(devices)) { + log.warn(LOG_PREFIX, 'invalid push payload'); + return; + } + const devicesMap = devices.reduce((memo, device) => { + memo[device.deviceToken] = device; + return memo; + }, {}); + const deviceTokens = Object.keys(devicesMap); + + const resolvers = []; + const promises = deviceTokens.map(() => new Promise(resolve => resolvers.push(resolve))); + let length = deviceTokens.length; + + log.verbose(LOG_PREFIX, `sending to ${length} ${length > 1 ? 'devices' : 'device'}`); + + const response = await this.sendNotifications(coreData, deviceTokens); + + log.verbose(LOG_PREFIX, `EXPO Response: %d sent`, response.length); + + deviceTokens.forEach((token, index) => { + const resolve = resolvers[index]; + const result = response[index]; + const device = devicesMap[token]; + const resolution = { + transmitted: result.status === 'ok', + device: { + ...device, + pushType: 'expo' + }, + response: expoResultToParseResponse(result), + }; + resolve(resolution); + }); + return Promise.all(promises); + } + + /** + * Send multiple Expo push notification request. + * + * @param {Object} payload The data we need to send, the format is the same with api request body + * @param {Array} deviceTokens An array of devicesTokens + * @param {Object} options The options for the request + * @returns {Object} A promise which is resolved immediately + */ + async sendNotifications({alert, body, ...payload}, deviceTokens) { + const messages = deviceTokens.map((token) => ({ + to: token, + body: body || alert, + ...payload + })); + + return await this.expo.sendPushNotificationsAsync(messages); + } +} + +export default EXPO; diff --git a/src/ParsePushAdapter.js b/src/ParsePushAdapter.js index 2e35d74..0c2aed0 100644 --- a/src/ParsePushAdapter.js +++ b/src/ParsePushAdapter.js @@ -5,6 +5,7 @@ import APNS from './APNS'; import GCM from './GCM'; import FCM from './FCM'; import WEB from './WEB'; +import EXPO from './EXPO'; import { classifyInstallations } from './PushAdapterUtils'; const LOG_PREFIX = 'parse-server-push-adapter'; @@ -14,7 +15,7 @@ export default class ParsePushAdapter { supportsPushTracking = true; constructor(pushConfig = {}) { - this.validPushTypes = ['ios', 'osx', 'tvos', 'android', 'fcm', 'web']; + this.validPushTypes = ['ios', 'osx', 'tvos', 'android', 'fcm', 'web', 'expo']; this.senderMap = {}; // used in PushController for Dashboard Features this.feature = { @@ -41,6 +42,9 @@ export default class ParsePushAdapter { case 'web': this.senderMap[pushType] = new WEB(pushConfig[pushType]); break; + case 'expo': + this.senderMap[pushType] = new EXPO(pushConfig[pushType]); + break; case 'android': case 'fcm': if (pushConfig[pushType].hasOwnProperty('firebaseServiceAccount')) { diff --git a/src/index.js b/src/index.js index 8360c4b..45769e8 100644 --- a/src/index.js +++ b/src/index.js @@ -13,7 +13,8 @@ import ParsePushAdapter from './ParsePushAdapter'; import GCM from './GCM'; import APNS from './APNS'; import WEB from './WEB'; +import EXPO from './EXPO'; import * as utils from './PushAdapterUtils'; export default ParsePushAdapter; -export { ParsePushAdapter, APNS, GCM, WEB, utils }; +export { ParsePushAdapter, APNS, GCM, WEB, EXPO, utils };