diff --git a/lib/push/send-hooks.react.js b/lib/push/send-hooks.react.js --- a/lib/push/send-hooks.react.js +++ b/lib/push/send-hooks.react.js @@ -1,16 +1,19 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import uuid from 'uuid'; import { preparePushNotifs, + prepareOwnDevicesPushNotifs, type PerUserTargetedNotifications, } from './send-utils.js'; import { ENSCacheContext } from '../components/ens-cache-provider.react.js'; import { NeynarClientContext } from '../components/neynar-client-provider.react.js'; import { usePeerOlmSessionsCreatorContext } from '../components/peer-olm-session-creator-provider.react.js'; import { thickRawThreadInfosSelector } from '../selectors/thread-selectors.js'; +import { IdentityClientContext } from '../shared/identity-client-context.js'; import { useTunnelbroker } from '../tunnelbroker/tunnelbroker-context.js'; import type { TargetedAPNsNotification, @@ -27,7 +30,6 @@ TunnelbrokerWNSNotif, } from '../types/tunnelbroker/notif-types.js'; import { getConfig } from '../utils/config.js'; -import { getContentSigningKey } from '../utils/crypto-utils.js'; import { getMessageForException } from '../utils/errors.js'; import { useSelector } from '../utils/redux-utils.js'; @@ -98,6 +100,9 @@ function useSendPushNotifs(): ( notifCreationData: NotificationsCreationData, ) => Promise { + const client = React.useContext(IdentityClientContext); + invariant(client, 'Identity context should be set'); + const { getAuthMetadata } = client; const rawMessageInfos = useSelector(state => state.messageStore.messages); const thickRawThreadInfos = useSelector(thickRawThreadInfosSelector); const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); @@ -111,9 +116,17 @@ return React.useCallback( async (notifCreationData: NotificationsCreationData) => { - const deviceID = await getContentSigningKey(); + const { deviceID, userID: senderUserID } = await getAuthMetadata(); + if (!deviceID || !senderUserID) { + return; + } + const senderDeviceDescriptor = { senderDeviceID: deviceID }; - const { messageDatas } = notifCreationData; + const senderInfo = { + senderUserID, + senderDeviceDescriptor, + }; + const { messageDatas, rescindData, badgeUpdateData } = notifCreationData; const pushNotifsPreparationInput = { encryptedNotifUtilsAPI, @@ -128,17 +141,36 @@ getFCNames, }; - const preparedPushNotifs = await preparePushNotifs( - pushNotifsPreparationInput, - ); + const ownDevicesPushNotifsPreparationInput = { + encryptedNotifUtilsAPI, + senderInfo, + olmSessionCreator, + auxUserInfos, + rescindData, + badgeUpdateData, + }; + + const [preparedPushNotifs, preparedOwnDevicesPushNotifs] = + await Promise.all([ + preparePushNotifs(pushNotifsPreparationInput), + prepareOwnDevicesPushNotifs(ownDevicesPushNotifsPreparationInput), + ]); - if (!preparedPushNotifs) { + if (!preparedPushNotifs && !prepareOwnDevicesPushNotifs) { return; } + let allPreparedPushNotifs = preparedPushNotifs; + if (preparedOwnDevicesPushNotifs && senderUserID) { + allPreparedPushNotifs = { + ...allPreparedPushNotifs, + [senderUserID]: preparedOwnDevicesPushNotifs, + }; + } + const sendPromises = []; - for (const userID in preparedPushNotifs) { - for (const notif of preparedPushNotifs[userID]) { + for (const userID in allPreparedPushNotifs) { + for (const notif of allPreparedPushNotifs[userID]) { if (notif.targetedNotification.notification.encryptionFailed) { continue; } @@ -183,6 +215,7 @@ await Promise.all(sendPromises); }, [ + getAuthMetadata, sendNotif, encryptedNotifUtilsAPI, olmSessionCreator, diff --git a/lib/push/send-utils.js b/lib/push/send-utils.js --- a/lib/push/send-utils.js +++ b/lib/push/send-utils.js @@ -3,8 +3,16 @@ import _pickBy from 'lodash/fp/pickBy.js'; import uuidv4 from 'uuid/v4.js'; -import { createAndroidVisualNotification } from './android-notif-creators.js'; -import { createAPNsVisualNotification } from './apns-notif-creators.js'; +import { + createAndroidVisualNotification, + createAndroidBadgeOnlyNotification, + createAndroidNotificationRescind, +} from './android-notif-creators.js'; +import { + createAPNsVisualNotification, + createAPNsBadgeOnlyNotification, + createAPNsNotificationRescind, +} from './apns-notif-creators.js'; import { stringToVersionKey, getDevicesByPlatform, @@ -103,12 +111,12 @@ messageInfos: { +[id: string]: RawMessageInfo }, thickRawThreadInfos: ThickRawThreadInfos, auxUserInfos: AuxUserInfos, - messageDatas: $ReadOnlyArray, + messageDatas: ?$ReadOnlyArray, ): Promise<{ +pushInfos: ?PushInfo, +rescindInfos: ?PushInfo, }> { - if (messageDatas.length === 0) { + if (!messageDatas || messageDatas.length === 0) { return { pushInfos: null, rescindInfos: null }; } @@ -250,6 +258,48 @@ }; } +type SenderInfo = { + +senderUserID: string, + +senderDeviceDescriptor: SenderDeviceDescriptor, +}; + +type OwnDevicesPushInfo = { + +devices: $ReadOnlyArray, +}; + +function getOwnDevicesPushInfo( + senderInfo: SenderInfo, + auxUserInfos: AuxUserInfos, +): ?OwnDevicesPushInfo { + const { + senderUserID, + senderDeviceDescriptor: { senderDeviceID }, + } = senderInfo; + + if (!senderDeviceID) { + return null; + } + + const senderDevicesWithPlatformDetails = + auxUserInfos[senderUserID].devicesPlatformDetails; + + if (!senderDevicesWithPlatformDetails) { + return null; + } + + const devices = Object.entries(senderDevicesWithPlatformDetails) + .filter(([deviceID]) => deviceID !== senderDeviceID) + .map(([deviceID, identityPlatformDetails]) => ({ + platformDetails: identityPlatformDetailsToPlatformDetails( + identityPlatformDetails, + ), + deliveryID: deviceID, + cryptoID: deviceID, + })); + + return { devices }; +} + function pushInfoToCollapsableNotifInfo(pushInfo: PushInfo): { +usersToCollapseKeysToInfo: { [string]: { [string]: CollapsableNotifInfo }, @@ -642,6 +692,187 @@ return (await Promise.all(promises)).flat(); } +async function buildRescindsForOwnDevices( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + senderDeviceDescriptor: SenderDeviceDescriptor, + devicesByPlatform: $ReadOnlyMap< + Platform, + $ReadOnlyMap>, + >, + rescindData: { +threadID: string }, +): Promise<$ReadOnlyArray> { + const { threadID } = rescindData; + const promises: Array< + Promise<$ReadOnlyArray>, + > = []; + + const iosVersionToDevices = devicesByPlatform.get('ios'); + if (iosVersionToDevices) { + for (const [versionKey, devices] of iosVersionToDevices) { + const { codeVersion, stateVersion } = stringToVersionKey(versionKey); + const platformDetails = { + platform: 'ios', + codeVersion, + stateVersion, + }; + + promises.push( + (async () => { + return ( + await createAPNsNotificationRescind( + encryptedNotifUtilsAPI, + { + senderDeviceDescriptor, + threadID, + platformDetails, + }, + devices, + ) + ).map(targetedNotification => ({ + platform: 'ios', + targetedNotification, + })); + })(), + ); + } + } + + const androidVersionToDevices = devicesByPlatform.get('android'); + if (androidVersionToDevices) { + for (const [versionKey, devices] of androidVersionToDevices) { + const { codeVersion, stateVersion } = stringToVersionKey(versionKey); + const platformDetails = { + platform: 'android', + codeVersion, + stateVersion, + }; + + promises.push( + (async () => { + return ( + await createAndroidNotificationRescind( + encryptedNotifUtilsAPI, + { senderDeviceDescriptor, threadID, platformDetails }, + devices, + ) + ).map(targetedNotification => ({ + platform: 'android', + targetedNotification, + })); + })(), + ); + } + } + return (await Promise.all(promises)).flat(); +} + +async function buildBadgeUpdatesForOwnDevices( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + senderDeviceDescriptor: SenderDeviceDescriptor, + devicesByPlatform: $ReadOnlyMap< + Platform, + $ReadOnlyMap>, + >, + badgeUpdateData: { +threadID: string }, +): Promise<$ReadOnlyArray> { + const { threadID } = badgeUpdateData; + const promises: Array< + Promise<$ReadOnlyArray>, + > = []; + + const iosVersionToDevices = devicesByPlatform.get('ios'); + if (iosVersionToDevices) { + for (const [versionKey, devices] of iosVersionToDevices) { + const { codeVersion, stateVersion } = stringToVersionKey(versionKey); + const platformDetails = { + platform: 'ios', + codeVersion, + stateVersion, + }; + + promises.push( + (async () => { + return ( + await createAPNsBadgeOnlyNotification( + encryptedNotifUtilsAPI, + { + senderDeviceDescriptor, + threadID, + platformDetails, + }, + devices, + ) + ).map(targetedNotification => ({ + platform: 'ios', + targetedNotification, + })); + })(), + ); + } + } + + const androidVersionToDevices = devicesByPlatform.get('android'); + if (androidVersionToDevices) { + for (const [versionKey, devices] of androidVersionToDevices) { + const { codeVersion, stateVersion } = stringToVersionKey(versionKey); + const platformDetails = { + platform: 'android', + codeVersion, + stateVersion, + }; + + promises.push( + (async () => { + return ( + await createAndroidBadgeOnlyNotification( + encryptedNotifUtilsAPI, + { senderDeviceDescriptor, threadID, platformDetails }, + devices, + ) + ).map(targetedNotification => ({ + platform: 'android', + targetedNotification, + })); + })(), + ); + } + } + + const macosVersionToDevices = devicesByPlatform.get('macos'); + if (macosVersionToDevices) { + for (const [versionKey, devices] of macosVersionToDevices) { + const { codeVersion, stateVersion, majorDesktopVersion } = + stringToVersionKey(versionKey); + const platformDetails = { + platform: 'macos', + codeVersion, + stateVersion, + majorDesktopVersion, + }; + + promises.push( + (async () => { + return ( + await createAPNsBadgeOnlyNotification( + encryptedNotifUtilsAPI, + { + senderDeviceDescriptor, + threadID, + platformDetails, + }, + devices, + ) + ).map(targetedNotification => ({ + platform: 'macos', + targetedNotification, + })); + })(), + ); + } + } + return (await Promise.all(promises)).flat(); +} + export type PerUserTargetedNotifications = { +[userID: string]: $ReadOnlyArray, }; @@ -740,6 +971,43 @@ return promiseAll(perUserBuildNotifsResultPromises); } +async function createOlmSessionWithDevices( + deviceIDsToUserIDs: { + +[string]: string, + }, + olmSessionCreator: (userID: string, deviceID: string) => Promise, +): Promise { + const { + initializeCryptoAccount, + isNotificationsSessionInitializedWithDevices, + } = getConfig().olmAPI; + await initializeCryptoAccount(); + + const deviceIDsToSessionPresence = + await isNotificationsSessionInitializedWithDevices( + Object.keys(deviceIDsToUserIDs), + ); + + const olmSessionCreationPromises = []; + for (const deviceID in deviceIDsToSessionPresence) { + if (deviceIDsToSessionPresence[deviceID]) { + continue; + } + olmSessionCreationPromises.push( + olmSessionCreator(deviceIDsToUserIDs[deviceID], deviceID), + ); + } + + try { + await Promise.allSettled(olmSessionCreationPromises); + } catch (e) { + // session creation may fail for some devices + // but we should still pursue notification + // delivery for others + console.log(e); + } +} + type PreparePushNotifsInputData = { +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, +senderDeviceDescriptor: SenderDeviceDescriptor, @@ -747,7 +1015,7 @@ +messageInfos: { +[id: string]: RawMessageInfo }, +thickRawThreadInfos: ThickRawThreadInfos, +auxUserInfos: AuxUserInfos, - +messageDatas: $ReadOnlyArray, + +messageDatas: ?$ReadOnlyArray, +userInfos: UserInfos, +getENSNames: ?GetENSNames, +getFCNames: ?GetFCNames, @@ -780,12 +1048,6 @@ return null; } - const { - initializeCryptoAccount, - isNotificationsSessionInitializedWithDevices, - } = getConfig().olmAPI; - await initializeCryptoAccount(); - const deviceIDsToUserIDs: { [string]: string } = {}; for (const userID in pushInfos) { for (const device of pushInfos[userID].devices) { @@ -793,29 +1055,7 @@ } } - const deviceIDsToSessionPresence = - await isNotificationsSessionInitializedWithDevices( - Object.keys(deviceIDsToUserIDs), - ); - - const olmSessionCreationPromises = []; - for (const deviceID in deviceIDsToSessionPresence) { - if (deviceIDsToSessionPresence[deviceID]) { - continue; - } - olmSessionCreationPromises.push( - olmSessionCreator(deviceIDsToUserIDs[deviceID], deviceID), - ); - } - - try { - await Promise.allSettled(olmSessionCreationPromises); - } catch (e) { - // session creation may fail for some devices - // but we should still pursue notification - // delivery for others - console.log(e); - } + await createOlmSessionWithDevices(deviceIDsToUserIDs, olmSessionCreator); return await buildNotifsFromPushInfo({ encryptedNotifUtilsAPI, @@ -828,8 +1068,65 @@ }); } +type PrepareOwnDevicesPushNotifsInputData = { + +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + +senderInfo: SenderInfo, + +olmSessionCreator: (userID: string, deviceID: string) => Promise, + +auxUserInfos: AuxUserInfos, + +rescindData?: { threadID: string }, + +badgeUpdateData?: { threadID: string }, +}; + +async function prepareOwnDevicesPushNotifs( + inputData: PrepareOwnDevicesPushNotifsInputData, +): Promise> { + const { + encryptedNotifUtilsAPI, + senderInfo, + olmSessionCreator, + auxUserInfos, + rescindData, + badgeUpdateData, + } = inputData; + + const ownDevicesPushInfo = getOwnDevicesPushInfo(senderInfo, auxUserInfos); + + if (!ownDevicesPushInfo) { + return null; + } + + const { senderUserID, senderDeviceDescriptor } = senderInfo; + const deviceIDsToUserIDs: { [string]: string } = {}; + + for (const device of ownDevicesPushInfo.devices) { + deviceIDsToUserIDs[device.cryptoID] = senderUserID; + } + + await createOlmSessionWithDevices(deviceIDsToUserIDs, olmSessionCreator); + const devicesByPlatform = getDevicesByPlatform(ownDevicesPushInfo.devices); + + if (rescindData) { + return await buildRescindsForOwnDevices( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devicesByPlatform, + rescindData, + ); + } else if (badgeUpdateData) { + return await buildBadgeUpdatesForOwnDevices( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devicesByPlatform, + badgeUpdateData, + ); + } else { + return null; + } +} + export { preparePushNotifs, + prepareOwnDevicesPushNotifs, generateNotifUserInfoPromise, pushInfoToCollapsableNotifInfo, mergeUserToCollapsableInfoInPlace, diff --git a/lib/shared/dm-ops/change-thread-status-spec.js b/lib/shared/dm-ops/change-thread-status-spec.js --- a/lib/shared/dm-ops/change-thread-status-spec.js +++ b/lib/shared/dm-ops/change-thread-status-spec.js @@ -11,6 +11,15 @@ const changeThreadStatusSpec: DMOperationSpec = Object.freeze({ + notificationsCreationData: async ( + dmOperation: DMChangeThreadStatusOperation, + ) => { + const { threadID, unread } = dmOperation; + if (unread) { + return { badgeUpdateData: { threadID } }; + } + return { rescindData: { threadID } }; + }, processDMOperation: async (dmOperation: DMChangeThreadStatusOperation) => { const { threadID, unread, time } = dmOperation; const updateInfos = [ diff --git a/lib/types/notif-types.js b/lib/types/notif-types.js --- a/lib/types/notif-types.js +++ b/lib/types/notif-types.js @@ -28,9 +28,14 @@ prefix: t.maybe(t.String), }); -export type NotificationsCreationData = { - +messageDatas: $ReadOnlyArray, -}; +export type NotificationsCreationData = + | { + +messageDatas: $ReadOnlyArray, + } + | { + +rescindData: { threadID: string }, + } + | { +badgeUpdateData: { threadID: string } }; export type SenderDeviceDescriptor = | { +keyserverID: string }