diff --git a/keyserver/src/push/providers.js b/keyserver/src/push/providers.js --- a/keyserver/src/push/providers.js +++ b/keyserver/src/push/providers.js @@ -101,6 +101,13 @@ return cachedWebPushConfig; } +async function ensureWebPushInitialized() { + if (cachedWebPushConfig) { + return; + } + await getWebPushConfig(); +} + export { getAPNPushProfileForCodeVersion, getFCMPushProfileForCodeVersion, @@ -110,4 +117,5 @@ endAPNs, getAPNsNotificationTopic, getWebPushConfig, + ensureWebPushInitialized, }; diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -39,6 +39,7 @@ getUnreadCounts, apnMaxNotificationPayloadByteSize, fcmMaxNotificationPayloadByteSize, + webPush, } from './utils.js'; import createIDs from '../creators/id-creator.js'; import { createUpdates } from '../creators/update-creator.js'; @@ -59,7 +60,7 @@ +devices: Device[], +messageInfos: RawMessageInfo[], }; -type Delivery = IOSDelivery | AndroidDelivery | { collapsedInto: string }; +type Delivery = PushDelivery | { collapsedInto: string }; type NotificationRow = { +dbID: string, +userID: string, @@ -213,6 +214,23 @@ deliveryPromises.push(deliveryPromise); } } + const webVersionsToTokens = byPlatform.get('web'); + if (webVersionsToTokens) { + for (const [codeVersion, deviceTokens] of webVersionsToTokens) { + const deliveryPromise = (async () => { + const notification = await prepareWebNotification( + allMessageInfos, + threadInfo, + unreadCounts[userID], + ); + return await sendWebNotification(notification, [...deviceTokens], { + ...notificationInfo, + codeVersion, + }); + })(); + deliveryPromises.push(deliveryPromise); + } + } for (const newMessageInfo of remainingNewMessageInfos) { const newDBID = dbIDs.shift(); @@ -247,7 +265,7 @@ // The results in deliveryResults will be combined with the rows // in rowsToSave and then written to the notifications table async function saveNotifResults( - deliveryResults: $ReadOnlyArray, + deliveryResults: $ReadOnlyArray, inputRowsToSave: Map, rescindable: boolean, ) { @@ -596,6 +614,26 @@ return notification; } +async function prepareWebNotification( + allMessageInfos: MessageInfo[], + threadInfo: ThreadInfo, + unreadCount: number, +): Promise { + const id = uuidv4(); + const { merged, ...rest } = await notifTextsForMessageInfo( + allMessageInfos, + threadInfo, + getENSNames, + ); + const notification = { + ...rest, + unreadCount, + id, + threadID: threadInfo.id, + }; + return notification; +} + type NotificationInfo = | { +source: 'new_message', @@ -653,6 +691,8 @@ return result; } +type PushResult = AndroidResult | IOSResult | WebResult; +type PushDelivery = AndroidDelivery | IOSDelivery | WebDelivery; type AndroidDelivery = { source: $PropertyType, deviceType: 'android', @@ -702,6 +742,49 @@ return result; } +type WebDelivery = { + source: $PropertyType, + deviceType: 'web', + deviceTokens: $ReadOnlyArray, + codeVersion?: number, + errors?: $ReadOnlyArray, +}; +type WebResult = { + info: NotificationInfo, + delivery: WebDelivery, + invalidTokens?: $ReadOnlyArray, +}; +async function sendWebNotification( + notification: Object, + deviceTokens: $ReadOnlyArray, + notificationInfo: NotificationInfo, +): Promise { + const { source, codeVersion } = notificationInfo; + + const response = await webPush({ + notification, + deviceTokens, + }); + + const delivery: WebDelivery = { + source, + deviceType: 'web', + deviceTokens, + codeVersion, + }; + if (response.errors) { + delivery.errors = response.errors; + } + const result: WebResult = { + info: notificationInfo, + delivery, + }; + if (response.invalidTokens) { + result.invalidTokens = response.invalidTokens; + } + return result; +} + type InvalidToken = { +userID: string, +tokens: $ReadOnlyArray, diff --git a/keyserver/src/push/utils.js b/keyserver/src/push/utils.js --- a/keyserver/src/push/utils.js +++ b/keyserver/src/push/utils.js @@ -4,6 +4,7 @@ import type { ResponseFailure } from '@parse/node-apn'; import type { FirebaseApp, FirebaseError } from 'firebase-admin'; import invariant from 'invariant'; +import webpush from 'web-push'; import { threadSubscriptions } from 'lib/types/subscription-types.js'; import { threadPermissions } from 'lib/types/thread-types.js'; @@ -13,6 +14,7 @@ getFCMPushProfileForCodeVersion, getAPNProvider, getFCMProvider, + ensureWebPushInitialized, } from './providers.js'; import { dbQuery, SQL } from '../database/database.js'; @@ -198,9 +200,69 @@ return usersToUnreadCounts; } +type WebPushResult = { + +success?: true, + +errors?: $ReadOnlyArray, + +invalidTokens?: $ReadOnlyArray, +}; +async function webPush({ + notification, + deviceTokens, +}: { + +notification: Object, + +deviceTokens: $ReadOnlyArray, +}): Promise { + await ensureWebPushInitialized(); + const notificationString = JSON.stringify(notification); + + const promises = []; + for (const deviceTokenString of deviceTokens) { + const deviceToken: PushSubscriptionJSON = JSON.parse(deviceTokenString); + promises.push( + (async () => { + try { + await webpush.sendNotification(deviceToken, notificationString); + } catch (error) { + return { error }; + } + return {}; + })(), + ); + } + + const pushResults = await Promise.all(promises); + + const errors = []; + const invalidTokens = []; + for (let i = 0; i < pushResults.length; i++) { + const pushResult = pushResults[i]; + if (pushResult.error) { + errors.push(pushResult.error); + if ( + pushResult.error.statusCode === 404 || + pushResult.error.statusCode === 410 + ) { + invalidTokens.push(deviceTokens[i]); + } + } + } + + const result = {}; + if (errors.length > 0) { + result.errors = errors; + } else { + result.success = true; + } + if (invalidTokens.length > 0) { + result.invalidTokens = invalidTokens; + } + return { ...result }; +} + export { apnPush, fcmPush, + webPush, getUnreadCounts, apnMaxNotificationPayloadByteSize, fcmMaxNotificationPayloadByteSize,