diff --git a/lib/push/send-hooks.react.js b/lib/push/send-hooks.react.js index 8dcc66d9e..7e5e73cd4 100644 --- a/lib/push/send-hooks.react.js +++ b/lib/push/send-hooks.react.js @@ -1,67 +1,199 @@ // @flow import * as React from 'react'; +import uuid from 'uuid'; import { preparePushNotifs, 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 type { MessageData } from '../types/message-types.js'; +import { useTunnelbroker } from '../tunnelbroker/tunnelbroker-context.js'; import type { - EncryptedNotifUtilsAPI, - SenderDeviceDescriptor, + TargetedAPNsNotification, + TargetedAndroidNotification, + TargetedWebNotification, + TargetedWNSNotification, + NotificationsCreationData, } from '../types/notif-types.js'; +import { deviceToTunnelbrokerMessageTypes } from '../types/tunnelbroker/messages.js'; +import type { + TunnelbrokerAPNsNotif, + TunnelbrokerFCMNotif, + TunnelbrokerWebPushNotif, + 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'; -function usePreparePushNotifs(): ( - encryptedNotifsUtilsAPI: EncryptedNotifUtilsAPI, - senderDeviceDescriptor: SenderDeviceDescriptor, - messageDatas: $ReadOnlyArray, +function apnsNotifToTunnelbrokerAPNsNotif( + targetedNotification: TargetedAPNsNotification, +): TunnelbrokerAPNsNotif { + const { + deliveryID: deviceID, + notification: { headers, ...payload }, + } = targetedNotification; + + const newHeaders = { + ...headers, + 'apns-push-type': 'Alert', + }; + + return { + type: deviceToTunnelbrokerMessageTypes.TUNNELBROKER_APNS_NOTIF, + deviceID, + headers: JSON.stringify(newHeaders), + payload: JSON.stringify(payload), + clientMessageID: uuid.v4(), + }; +} + +function androidNotifToTunnelbrokerFCMNotif( + targetedNotification: TargetedAndroidNotification, +): TunnelbrokerFCMNotif { + const { + deliveryID: deviceID, + notification: { data }, + priority, + } = targetedNotification; + + return { + type: deviceToTunnelbrokerMessageTypes.TUNNELBROKER_FCM_NOTIF, + deviceID, + clientMessageID: uuid.v4(), + data: JSON.stringify(data), + priority: priority === 'normal' ? 'NORMAL' : 'HIGH', + }; +} + +function webNotifToTunnelbrokerWebPushNotif( + targetedNotification: TargetedWebNotification, +): TunnelbrokerWebPushNotif { + const { deliveryID: deviceID, notification } = targetedNotification; + return { + type: deviceToTunnelbrokerMessageTypes.TUNNELBROKER_WEB_PUSH_NOTIF, + deviceID, + clientMessageID: uuid.v4(), + payload: JSON.stringify(notification), + }; +} + +function wnsNotifToTunnelbrokerWNSNofif( + targetedNotification: TargetedWNSNotification, +): TunnelbrokerWNSNotif { + const { deliveryID: deviceID, notification } = targetedNotification; + return { + type: deviceToTunnelbrokerMessageTypes.TUNNELBROKER_WNS_NOTIF, + deviceID, + clientMessageID: uuid.v4(), + payload: JSON.stringify(notification), + }; +} + +function useSendPushNotifs(): ( + notifCreationData: NotificationsCreationData, ) => Promise { const rawMessageInfos = useSelector(state => state.messageStore.messages); const thickRawThreadInfos = useSelector(thickRawThreadInfosSelector); const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); const userInfos = useSelector(state => state.userStore.userInfos); - const { getENSNames } = React.useContext(ENSCacheContext); const getFCNames = React.useContext(NeynarClientContext)?.getFCNames; - const { createOlmSessionsWithPeer: olmSessionCreator } = usePeerOlmSessionsCreatorContext(); + const { sendNotif } = useTunnelbroker(); + const { encryptedNotifUtilsAPI } = getConfig(); return React.useCallback( - ( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - senderDeviceDescriptor: SenderDeviceDescriptor, - messageDatas: $ReadOnlyArray, - ) => { - return preparePushNotifs({ + async (notifCreationData: NotificationsCreationData) => { + const deviceID = await getContentSigningKey(); + const senderDeviceDescriptor = { senderDeviceID: deviceID }; + const { messageDatas } = notifCreationData; + + const pushNotifsPreparationInput = { encryptedNotifUtilsAPI, senderDeviceDescriptor, olmSessionCreator, messageInfos: rawMessageInfos, thickRawThreadInfos, auxUserInfos, messageDatas, userInfos, getENSNames, getFCNames, - }); + }; + + const preparedPushNotifs = await preparePushNotifs( + pushNotifsPreparationInput, + ); + + if (!preparedPushNotifs) { + return; + } + + const sendPromises = []; + for (const userID in preparedPushNotifs) { + for (const notif of preparedPushNotifs[userID]) { + if (notif.targetedNotification.notification.encryptionFailed) { + continue; + } + + let tunnelbrokerNotif; + if (notif.platform === 'ios' || notif.platform === 'macos') { + tunnelbrokerNotif = apnsNotifToTunnelbrokerAPNsNotif( + notif.targetedNotification, + ); + } else if (notif.platform === 'android') { + tunnelbrokerNotif = androidNotifToTunnelbrokerFCMNotif( + notif.targetedNotification, + ); + } else if (notif.platform === 'web') { + tunnelbrokerNotif = webNotifToTunnelbrokerWebPushNotif( + notif.targetedNotification, + ); + } else if (notif.platform === 'windows') { + tunnelbrokerNotif = wnsNotifToTunnelbrokerWNSNofif( + notif.targetedNotification, + ); + } else { + continue; + } + + sendPromises.push( + (async () => { + try { + await sendNotif(tunnelbrokerNotif); + } catch (e) { + console.log( + `Failed to send notification to device: ${ + tunnelbrokerNotif.deviceID + }. Details: ${getMessageForException(e) ?? ''}`, + ); + } + })(), + ); + } + } + + await Promise.all(sendPromises); }, [ + sendNotif, + encryptedNotifUtilsAPI, olmSessionCreator, rawMessageInfos, thickRawThreadInfos, auxUserInfos, userInfos, getENSNames, getFCNames, ], ); } -export { usePreparePushNotifs }; +export { useSendPushNotifs }; diff --git a/lib/types/notif-types.js b/lib/types/notif-types.js index 29401b86c..68c439fde 100644 --- a/lib/types/notif-types.js +++ b/lib/types/notif-types.js @@ -1,411 +1,415 @@ // @flow import type { EncryptResult } from '@commapp/olm'; import t, { type TInterface, type TUnion } from 'tcomb'; -import type { Platform } from './device-types.js'; +import type { MessageData } from './message-types.js'; import type { EntityText, ThreadEntity } from '../utils/entity-text.js'; import { tShape } from '../utils/validation-utils.js'; export type NotifTexts = { +merged: string | EntityText, +body: string | EntityText, +title: string | ThreadEntity, +prefix?: string | EntityText, }; export type ResolvedNotifTexts = { +merged: string, +body: string, +title: string, +prefix?: string, }; export const resolvedNotifTextsValidator: TInterface = tShape({ merged: t.String, body: t.String, title: t.String, prefix: t.maybe(t.String), }); +export type NotificationsCreationData = { + +messageDatas: $ReadOnlyArray, +}; + export type SenderDeviceDescriptor = | { +keyserverID: string } | { +senderDeviceID: string }; export const senderDeviceDescriptorValidator: TUnion = t.union([ tShape({ keyserverID: t.String }), tShape({ senderDeviceID: t.String }), ]); // Web notifs types export type PlainTextWebNotificationPayload = { +body: string, +prefix?: string, +title: string, +unreadCount?: number, +threadID: string, +encryptionFailed?: '1', }; export type PlainTextWebNotification = $ReadOnly<{ +id: string, ...PlainTextWebNotificationPayload, }>; export type EncryptedWebNotification = $ReadOnly<{ ...SenderDeviceDescriptor, +id: string, +encryptedPayload: string, +type: '0' | '1', }>; export type WebNotification = | PlainTextWebNotification | EncryptedWebNotification; // WNS notifs types export type PlainTextWNSNotification = { +body: string, +prefix?: string, +title: string, +unreadCount?: number, +threadID: string, +encryptionFailed?: '1', }; export type EncryptedWNSNotification = $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, +type: '0' | '1', }>; export type WNSNotification = | PlainTextWNSNotification | EncryptedWNSNotification; // Android notifs types export type AndroidVisualNotificationPayloadBase = $ReadOnly<{ +badge?: string, +body: string, +title: string, +prefix?: string, +threadID: string, +collapseID?: string, +badgeOnly?: '0' | '1', +encryptionFailed?: '1', }>; type AndroidSmallVisualNotificationPayload = $ReadOnly<{ ...AndroidVisualNotificationPayloadBase, +messageInfos?: string, }>; type AndroidLargeVisualNotificationPayload = $ReadOnly<{ ...AndroidVisualNotificationPayloadBase, +blobHash: string, +encryptionKey: string, }>; export type AndroidVisualNotificationPayload = | AndroidSmallVisualNotificationPayload | AndroidLargeVisualNotificationPayload; type EncryptedThinThreadPayload = { +keyserverID: string, +encryptedPayload: string, +type: '0' | '1', }; type EncryptedThickThreadPayload = { +senderDeviceID: string, +encryptedPayload: string, +type: '0' | '1', }; export type AndroidVisualNotification = { +data: $ReadOnly<{ +id?: string, ... | AndroidSmallVisualNotificationPayload | AndroidLargeVisualNotificationPayload | EncryptedThinThreadPayload | EncryptedThickThreadPayload, }>, }; type AndroidThinThreadRescindPayload = { +badge: string, +rescind: 'true', +rescindID?: string, +setUnreadStatus: 'true', +threadID: string, +encryptionFailed?: string, }; type AndroidThickThreadRescindPayload = { +rescind: 'true', +setUnreadStatus: 'true', +threadID: string, +encryptionFailed?: string, }; export type AndroidNotificationRescind = { +data: | AndroidThinThreadRescindPayload | AndroidThickThreadRescindPayload | EncryptedThinThreadPayload | EncryptedThickThreadPayload, }; type AndroidKeyserverBadgeOnlyPayload = { +badge: string, +badgeOnly: '1', +encryptionFailed?: string, }; type AndroidThickThreadBadgeOnlyPayload = { +threadID: string, +badgeOnly: '1', +encryptionFailed?: string, }; export type AndroidBadgeOnlyNotification = { +data: | AndroidKeyserverBadgeOnlyPayload | AndroidThickThreadBadgeOnlyPayload | EncryptedThinThreadPayload | EncryptedThickThreadPayload, }; type AndroidNotificationWithPriority = | { +notification: AndroidVisualNotification, +priority: 'high', } | { +notification: AndroidBadgeOnlyNotification | AndroidNotificationRescind, +priority: 'normal', }; // APNs notifs types export type APNsNotificationTopic = | 'app.comm.macos' | 'app.comm' | 'org.squadcal.app'; export type APNsNotificationHeaders = { +'apns-priority'?: 1 | 5 | 10, +'apns-id'?: string, +'apns-expiration'?: number, +'apns-topic': APNsNotificationTopic, +'apns-collapse-id'?: string, +'apns-push-type': 'background' | 'alert' | 'voip', }; type EncryptedAPNsSilentNotificationsAps = { +'mutable-content': number, +'alert'?: { body: 'ENCRYPTED' }, }; export type EncryptedAPNsSilentNotification = $ReadOnly<{ ...SenderDeviceDescriptor, +headers: APNsNotificationHeaders, +encryptedPayload: string, +type: '1' | '0', +aps: EncryptedAPNsSilentNotificationsAps, }>; type EncryptedAPNsVisualNotificationAps = $ReadOnly<{ ...EncryptedAPNsSilentNotificationsAps, +sound?: string, }>; export type EncryptedAPNsVisualNotification = $ReadOnly<{ ...EncryptedAPNsSilentNotification, +aps: EncryptedAPNsVisualNotificationAps, +id: string, }>; type APNsVisualNotificationPayloadBase = { +aps: { +'badge'?: string | number, +'alert'?: string | { +body?: string, ... }, +'thread-id': string, +'mutable-content'?: number, +'sound'?: string, }, +body: string, +title: string, +prefix?: string, +threadID: string, +collapseID?: string, +encryptionFailed?: '1', }; type APNsSmallVisualNotificationPayload = $ReadOnly<{ ...APNsVisualNotificationPayloadBase, +messageInfos?: string, }>; type APNsLargeVisualNotificationPayload = $ReadOnly<{ ...APNsVisualNotificationPayloadBase, +blobHash: string, +encryptionKey: string, }>; export type APNsVisualNotification = | $ReadOnly<{ +headers: APNsNotificationHeaders, +id: string, ... | APNsSmallVisualNotificationPayload | APNsLargeVisualNotificationPayload, }> | EncryptedAPNsVisualNotification; type APNsLegacyRescindPayload = { +backgroundNotifType: 'CLEAR', +notificationId: string, +setUnreadStatus: true, +threadID: string, +aps: { +'badge': string | number, +'content-available': number, }, }; type APNsKeyserverRescindPayload = { +backgroundNotifType: 'CLEAR', +notificationId: string, +setUnreadStatus: true, +threadID: string, +aps: { +'badge': string | number, +'mutable-content': number, }, }; type APNsThickThreadRescindPayload = { +backgroundNotifType: 'CLEAR', +setUnreadStatus: true, +threadID: string, +aps: { +'mutable-content': number, }, }; export type APNsNotificationRescind = | $ReadOnly<{ +headers: APNsNotificationHeaders, +encryptionFailed?: '1', ... | APNsLegacyRescindPayload | APNsKeyserverRescindPayload | APNsThickThreadRescindPayload, }> | EncryptedAPNsSilentNotification; type APNsLegacyBadgeOnlyNotification = { +aps: { +badge: string | number, }, }; type APNsKeyserverBadgeOnlyNotification = { +aps: { +'badge': string | number, +'mutable-content': number, }, }; type APNsThickThreadBadgeOnlyNotification = { +aps: { +'mutable-content': number, }, +threadID: string, }; export type APNsBadgeOnlyNotification = | $ReadOnly<{ +headers: APNsNotificationHeaders, +encryptionFailed?: '1', ... | APNsLegacyBadgeOnlyNotification | APNsKeyserverBadgeOnlyNotification | APNsThickThreadBadgeOnlyNotification, }> | EncryptedAPNsSilentNotification; export type APNsNotification = | APNsVisualNotification | APNsNotificationRescind | APNsBadgeOnlyNotification; export type TargetedAPNsNotification = { +notification: APNsNotification, +deliveryID: string, +encryptedPayloadHash?: string, +encryptionOrder?: number, }; export type TargetedAndroidNotification = $ReadOnly<{ ...AndroidNotificationWithPriority, +deliveryID: string, +encryptionOrder?: number, }>; export type TargetedWebNotification = { +notification: WebNotification, +deliveryID: string, +encryptionOrder?: number, }; export type TargetedWNSNotification = { +notification: WNSNotification, +deliveryID: string, +encryptionOrder?: number, }; export type NotificationTargetDevice = { +cryptoID: string, +deliveryID: string, +blobHolder?: string, }; -export type TargetedNotificationWithPlatform = { - +platform: Platform, - +targetedNotification: - | TargetedAPNsNotification - | TargetedWNSNotification - | TargetedWebNotification - | TargetedAndroidNotification, -}; +export type TargetedNotificationWithPlatform = + | { + +platform: 'ios' | 'macos', + +targetedNotification: TargetedAPNsNotification, + } + | { +platform: 'android', +targetedNotification: TargetedAndroidNotification } + | { +platform: 'web', +targetedNotification: TargetedWebNotification } + | { +platform: 'windows', +targetedNotification: TargetedWNSNotification }; export type EncryptedNotifUtilsAPI = { +encryptSerializedNotifPayload: ( cryptoID: string, unencryptedPayload: string, encryptedPayloadSizeValidator?: ( encryptedPayload: string, type: '1' | '0', ) => boolean, ) => Promise<{ +encryptedData: EncryptResult, +sizeLimitViolated?: boolean, +encryptionOrder?: number, }>, +uploadLargeNotifPayload: ( payload: string, numberOfHolders: number, ) => Promise< | { +blobHolders: $ReadOnlyArray, +blobHash: string, +encryptionKey: string, } | { +blobUploadError: string }, >, +getNotifByteSize: (serializedNotification: string) => number, +getEncryptedNotifHash: (serializedNotification: string) => Promise, }; diff --git a/lib/types/tunnelbroker/messages.js b/lib/types/tunnelbroker/messages.js index 03f6a7ce1..3a40903c0 100644 --- a/lib/types/tunnelbroker/messages.js +++ b/lib/types/tunnelbroker/messages.js @@ -1,92 +1,94 @@ // @flow import type { TUnion } from 'tcomb'; import t from 'tcomb'; import { messageToDeviceRequestStatusValidator, type DeviceToTunnelbrokerRequestStatus, } from './device-to-tunnelbroker-request-status-types.js'; import { type MessageReceiveConfirmation } from './message-receive-confirmation-types.js'; import { type MessageToDeviceRequest } from './message-to-device-request-types.js'; import { type MessageToDevice, messageToDeviceValidator, } from './message-to-device-types.js'; import { type MessageToTunnelbrokerRequest } from './message-to-tunnelbroker-request-types.js'; import { type TunnelbrokerNotif } from './notif-types.js'; import { type AnonymousInitializationMessage, type ConnectionInitializationMessage, } from './session-types.js'; import { type ConnectionInitializationResponse, connectionInitializationResponseValidator, } from '../websocket/connection-initialization-response-types.js'; import { type Heartbeat, heartbeatValidator, } from '../websocket/heartbeat-types.js'; /* * This file defines types and validation for messages exchanged * with the Tunnelbroker. The definitions in this file should remain in sync * with the structures defined in the corresponding * Rust file at `shared/tunnelbroker_messages/src/messages/mod.rs`. * * If you edit the definitions in one file, * please make sure to update the corresponding definitions in the other. * */ // Messages sent from Device to Tunnelbroker. export const deviceToTunnelbrokerMessageTypes = Object.freeze({ CONNECTION_INITIALIZATION_MESSAGE: 'ConnectionInitializationMessage', ANONYMOUS_INITIALIZATION_MESSAGE: 'AnonymousInitializationMessage', TUNNELBROKER_APNS_NOTIF: 'APNsNotif', TUNNELBROKER_FCM_NOTIF: 'FCMNotif', + TUNNELBROKER_WEB_PUSH_NOTIF: 'WebPushNotif', + TUNNELBROKER_WNS_NOTIF: 'WNSNotif', MESSAGE_TO_DEVICE_REQUEST: 'MessageToDeviceRequest', MESSAGE_RECEIVE_CONFIRMATION: 'MessageReceiveConfirmation', MESSAGE_TO_TUNNELBROKER_REQUEST: 'MessageToTunnelbrokerRequest', HEARTBEAT: 'Heartbeat', }); export type DeviceToTunnelbrokerMessage = | ConnectionInitializationMessage | AnonymousInitializationMessage | TunnelbrokerNotif | MessageToDeviceRequest | MessageReceiveConfirmation | MessageToTunnelbrokerRequest | Heartbeat; // Types having `clientMessageID` prop. // When using this type, it is possible to use Promise abstraction, // and await sending a message until Tunnelbroker responds that // the request was processed. export type DeviceToTunnelbrokerRequest = | TunnelbrokerNotif | MessageToDeviceRequest | MessageToTunnelbrokerRequest; // Messages sent from Tunnelbroker to Device. export const tunnelbrokerToDeviceMessageTypes = Object.freeze({ CONNECTION_INITIALIZATION_RESPONSE: 'ConnectionInitializationResponse', DEVICE_TO_TUNNELBROKER_REQUEST_STATUS: 'MessageToDeviceRequestStatus', MESSAGE_TO_DEVICE: 'MessageToDevice', HEARTBEAT: 'Heartbeat', }); export type TunnelbrokerToDeviceMessage = | ConnectionInitializationResponse | DeviceToTunnelbrokerRequestStatus | MessageToDevice | Heartbeat; export const tunnelbrokerToDeviceMessageValidator: TUnion = t.union([ connectionInitializationResponseValidator, messageToDeviceRequestStatusValidator, messageToDeviceValidator, heartbeatValidator, ]); diff --git a/lib/utils/__mocks__/config.js b/lib/utils/__mocks__/config.js index e1f64946d..2b7382b3c 100644 --- a/lib/utils/__mocks__/config.js +++ b/lib/utils/__mocks__/config.js @@ -1,53 +1,59 @@ // @flow import { type Config } from '../config.js'; const getConfig = (): Config => ({ resolveKeyserverSessionInvalidationUsingNativeCredentials: null, setSessionIDOnRequest: true, calendarRangeInactivityLimit: null, platformDetails: { platform: 'web', codeVersion: 70, stateVersion: 50, }, authoritativeKeyserverID: '123', olmAPI: { initializeCryptoAccount: jest.fn(), getUserPublicKey: jest.fn(), encrypt: jest.fn(), encryptAndPersist: jest.fn(), encryptNotification: jest.fn(), decrypt: jest.fn(), decryptAndPersist: jest.fn(), contentInboundSessionCreator: jest.fn(), contentOutboundSessionCreator: jest.fn(), keyserverNotificationsSessionCreator: jest.fn(), notificationsOutboundSessionCreator: jest.fn(), isContentSessionInitialized: jest.fn(), isDeviceNotificationsSessionInitialized: jest.fn(), isNotificationsSessionInitializedWithDevices: jest.fn(), getOneTimeKeys: jest.fn(), validateAndUploadPrekeys: jest.fn(), signMessage: jest.fn(), verifyMessage: jest.fn(), markPrekeysAsPublished: jest.fn(), }, sqliteAPI: { getAllInboundP2PMessages: jest.fn(), removeInboundP2PMessages: jest.fn(), processDBStoreOperations: jest.fn(), getAllOutboundP2PMessages: jest.fn(), markOutboundP2PMessageAsSent: jest.fn(), removeOutboundP2PMessage: jest.fn(), resetOutboundP2PMessagesForDevice: jest.fn(), getRelatedMessages: jest.fn(), getOutboundP2PMessagesByID: jest.fn(), searchMessages: jest.fn(), fetchMessages: jest.fn(), }, + encryptedNotifUtilsAPI: { + encryptSerializedNotifPayload: jest.fn(), + uploadLargeNotifPayload: jest.fn(), + getEncryptedNotifHash: jest.fn(), + getNotifByteSize: jest.fn(), + }, }); const hasConfig = (): boolean => true; export { getConfig, hasConfig }; diff --git a/lib/utils/config.js b/lib/utils/config.js index 3e0ca0d60..c2405fb06 100644 --- a/lib/utils/config.js +++ b/lib/utils/config.js @@ -1,49 +1,51 @@ // @flow import invariant from 'invariant'; import type { CallSingleKeyserverEndpoint } from '../keyserver-conn/call-single-keyserver-endpoint.js'; import type { CallKeyserverEndpoint } from '../keyserver-conn/keyserver-conn-types.js'; import type { InitialNotifMessageOptions } from '../shared/crypto-utils.js'; import type { RecoveryActionSource } from '../types/account-types.js'; import type { OlmAPI } from '../types/crypto-types.js'; import type { PlatformDetails } from '../types/device-types.js'; +import type { EncryptedNotifUtilsAPI } from '../types/notif-types.js'; import type { SQLiteAPI } from '../types/sqlite-types.js'; import type { DispatchActionPromise } from '../utils/redux-promise-utils.js'; export type Config = { +resolveKeyserverSessionInvalidationUsingNativeCredentials: ?( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, callKeyserverEndpoint: CallKeyserverEndpoint, dispatchActionPromise: DispatchActionPromise, recoveryActionSource: RecoveryActionSource, keyserverID: string, getInitialNotificationsEncryptedMessage: ( options?: ?InitialNotifMessageOptions, ) => Promise, hasBeenCancelled: () => boolean, ) => Promise, +setSessionIDOnRequest: boolean, +calendarRangeInactivityLimit: ?number, +platformDetails: PlatformDetails, +authoritativeKeyserverID: string, +olmAPI: OlmAPI, +sqliteAPI: SQLiteAPI, + +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, }; let registeredConfig: ?Config = null; const registerConfig = (config: Config) => { registeredConfig = { ...registeredConfig, ...config }; }; const getConfig = (): Config => { invariant(registeredConfig, 'config should be set'); return registeredConfig; }; const hasConfig = (): boolean => { return !!registeredConfig; }; export { registerConfig, getConfig, hasConfig }; diff --git a/native/config.js b/native/config.js index 53c5fb32b..61b2867b3 100644 --- a/native/config.js +++ b/native/config.js @@ -1,25 +1,27 @@ // @flow import { Platform } from 'react-native'; import { registerConfig } from 'lib/utils/config.js'; import { resolveKeyserverSessionInvalidationUsingNativeCredentials } from './account/legacy-recover-keyserver-session.js'; import { authoritativeKeyserverID } from './authoritative-keyserver.js'; import { olmAPI } from './crypto/olm-api.js'; import { sqliteAPI } from './database/sqlite-api.js'; +import encryptedNotifUtilsAPI from './push/encrypted-notif-utils-api.js'; import { persistConfig, codeVersion } from './redux/persist.js'; registerConfig({ resolveKeyserverSessionInvalidationUsingNativeCredentials, setSessionIDOnRequest: false, calendarRangeInactivityLimit: 15 * 60 * 1000, platformDetails: { platform: Platform.OS, codeVersion, stateVersion: persistConfig.version, }, authoritativeKeyserverID, olmAPI, sqliteAPI, + encryptedNotifUtilsAPI, }); diff --git a/web/app.react.js b/web/app.react.js index bb04df58e..d2f56bfe3 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,597 +1,599 @@ // @flow import 'basscss/css/basscss.min.css'; import './theme.css'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import classnames from 'classnames'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions.js'; import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js'; import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js'; import { FarcasterDataHandler } from 'lib/components/farcaster-data-handler.react.js'; import { ModalProvider, useModalContext, } from 'lib/components/modal-provider.react.js'; import { NeynarClientProvider } from 'lib/components/neynar-client-provider.react.js'; import PlatformDetailsSynchronizer from 'lib/components/platform-details-synchronizer.react.js'; import { QRAuthProvider } from 'lib/components/qr-auth-provider.react.js'; import { StaffContextProvider } from 'lib/components/staff-provider.react.js'; import { DBOpsHandler } from 'lib/handlers/db-ops-handler.react.js'; import { TunnelbrokerDeviceTokenHandler } from 'lib/handlers/tunnelbroker-device-token-handler.react.js'; import { UserInfosHandler } from 'lib/handlers/user-infos-handler.react.js'; import { IdentitySearchProvider } from 'lib/identity-search/identity-search-context.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { extractMajorDesktopVersion } from 'lib/shared/version-utils.js'; import type { SecondaryTunnelbrokerConnection } from 'lib/tunnelbroker/secondary-tunnelbroker-connection.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { WebNavInfo } from 'lib/types/nav-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import type { MessageToDeviceRequest } from 'lib/types/tunnelbroker/message-to-device-request-types.js'; import { getConfig, registerConfig } from 'lib/utils/config.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { AlchemyENSCacheProvider } from 'lib/utils/wagmi-utils.js'; import QRCodeLogin from './account/qr-code-login.react.js'; import AppThemeWrapper from './app-theme-wrapper.react.js'; import { authoritativeKeyserverID } from './authoritative-keyserver.js'; import WebEditThreadAvatarProvider from './avatars/web-edit-thread-avatar-provider.react.js'; import Calendar from './calendar/calendar.react.js'; import Chat from './chat/chat.react.js'; import { EditModalProvider } from './chat/edit-message-provider.js'; import { MemberListSidebarProvider } from './chat/member-list-sidebar/member-list-sidebar-provider.react.js'; import { AutoJoinCommunityHandler } from './components/auto-join-community-handler.react.js'; import CommunitiesRefresher from './components/communities-refresher.react.js'; import LogOutIfMissingCSATHandler from './components/log-out-if-missing-csat-handler.react.js'; import NavigationArrows from './components/navigation-arrows.react.js'; import MinVersionHandler from './components/version-handler.react.js'; import { olmAPI } from './crypto/olm-api.js'; import { sqliteAPI } from './database/sqlite-api.js'; import electron from './electron.js'; import InputStateContainer from './input/input-state-container.react.js'; import InviteLinkHandler from './invite-links/invite-link-handler.react.js'; import InviteLinksRefresher from './invite-links/invite-links-refresher.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { MenuProvider } from './menu-provider.react.js'; import UpdateModalHandler from './modals/update-modal.react.js'; import SettingsSwitcher from './navigation-panels/settings-switcher.react.js'; import Topbar from './navigation-panels/topbar.react.js'; import useBadgeHandler from './push-notif/badge-handler.react.js'; +import encryptedNotifUtilsAPI from './push-notif/encrypted-notif-utils-api.js'; import { PushNotificationsHandler } from './push-notif/push-notifs-handler.js'; import { updateNavInfoActionType } from './redux/action-types.js'; import DisconnectedBar from './redux/disconnected-bar.js'; import FocusHandler from './redux/focus-handler.react.js'; import { KeyserverReachabilityHandler } from './redux/keyserver-reachability-handler.js'; import { persistConfig } from './redux/persist.js'; import PolicyAcknowledgmentHandler from './redux/policy-acknowledgment-handler.js'; import { useSelector } from './redux/redux-utils.js'; import VisibilityHandler from './redux/visibility-handler.react.js'; import history from './router-history.js'; import { MessageSearchStateProvider } from './search/message-search-state-provider.react.js'; import AccountSettings from './settings/account-settings.react.js'; import DangerZone from './settings/danger-zone.react.js'; import KeyserverSelectionList from './settings/keyserver-selection-list.react.js'; import { getCommSharedWorker } from './shared-worker/shared-worker-provider.js'; import CommunityPicker from './sidebar/community-picker.react.js'; import Splash from './splash/splash.react.js'; import './typography.css'; import css from './style.css'; import { TooltipProvider } from './tooltips/tooltip-provider.js'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js'; import { composeTunnelbrokerQRAuthMessage, generateQRAuthAESKey, parseTunnelbrokerQRAuthMessage, useHandleSecondaryDeviceLogInError, } from './utils/qr-code-utils.js'; import { useWebLock, TUNNELBROKER_LOCK_NAME } from './web-lock.js'; // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS, // so we disable the autoAddCss logic and import the CSS file. Otherwise every // icon flashes huge for a second before the CSS is loaded. import '@fortawesome/fontawesome-svg-core/styles.css'; faConfig.autoAddCss = false; const desktopDetails = electron?.version ? { majorDesktopVersion: extractMajorDesktopVersion(electron?.version) } : null; registerConfig({ // We can't securely cache credentials on web resolveKeyserverSessionInvalidationUsingNativeCredentials: null, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: electron?.platform ?? 'web', codeVersion: 112, stateVersion: persistConfig.version, ...desktopDetails, }, authoritativeKeyserverID, olmAPI, sqliteAPI, + encryptedNotifUtilsAPI, }); const versionBroadcast = new BroadcastChannel('comm_version'); versionBroadcast.postMessage(getConfig().platformDetails.codeVersion); versionBroadcast.onmessage = (event: MessageEvent) => { if (event.data && event.data !== getConfig().platformDetails.codeVersion) { location.reload(); } }; // Start initializing the shared worker immediately void getCommSharedWorker(); type BaseProps = { +location: { +pathname: string, ... }, }; type Props = { ...BaseProps, // Redux state +navInfo: WebNavInfo, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +activeThreadCurrentlyUnread: boolean, // Redux dispatch functions +dispatch: Dispatch, +modals: $ReadOnlyArray, }; class App extends React.PureComponent { componentDidMount() { const { navInfo, location: { pathname }, loggedIn, } = this.props; const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (pathname !== newURL) { history.replace(newURL); } } componentDidUpdate(prevProps: Props) { const { navInfo, location: { pathname }, loggedIn, } = this.props; if (!_isEqual(navInfo)(prevProps.navInfo)) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.push(newURL); } } else if (pathname !== prevProps.location.pathname) { const urlInfo = infoFromURL(pathname); const newNavInfo = navInfoFromURL(urlInfo, { navInfo }); if (!_isEqual(newNavInfo)(navInfo)) { this.props.dispatch({ type: updateNavInfoActionType, payload: newNavInfo, }); } } else if (loggedIn !== prevProps.loggedIn) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.replace(newURL); } } if (loggedIn !== prevProps.loggedIn) { electron?.clearHistory(); } } onWordmarkClicked = () => { this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat' }, }); }; render(): React.Node { let content; if (this.props.loggedIn) { content = ( <> {this.renderMainContent()} {this.props.modals} ); } else { content = ( <> {this.renderLoginPage()} {this.props.modals} ); } return ( {content} ); } onHeaderDoubleClick = (): void => electron?.doubleClickTopBar(); stopDoubleClickPropagation: ?(SyntheticEvent) => void = electron ? e => e.stopPropagation() : null; renderLoginPage(): React.Node { const { loginMethod } = this.props.navInfo; if (loginMethod === 'qr-code') { return ; } return ; } renderMainContent(): React.Node { const mainContent = this.getMainContentWithSwitcher(); let navigationArrows = null; if (electron) { navigationArrows = ; } const headerClasses = classnames({ [css.header]: true, [css['electron-draggable']]: electron, }); const wordmarkClasses = classnames({ [css.wordmark]: true, [css['electron-non-draggable']]: electron, [css['wordmark-macos']]: electron?.platform === 'macos', }); return (

Comm

{navigationArrows}
{mainContent}
); } getMainContentWithSwitcher(): React.Node { const { tab, settingsSection } = this.props.navInfo; let mainContent: React.Node; if (tab === 'settings') { if (settingsSection === 'account') { mainContent = ; } else if (settingsSection === 'friend-list') { mainContent = null; } else if (settingsSection === 'block-list') { mainContent = null; } else if (settingsSection === 'keyservers') { mainContent = ; } else if (settingsSection === 'build-info') { mainContent = null; } else if (settingsSection === 'danger-zone') { mainContent = ; } return (
{mainContent}
); } if (tab === 'calendar') { mainContent = ; } else if (tab === 'chat') { mainContent = ; } const mainContentClass = classnames( css['main-content-container'], css['main-content-container-column'], ); return (
{mainContent}
); } } const WEB_TUNNELBROKER_CHANNEL = new BroadcastChannel('shared-tunnelbroker'); const WEB_TUNNELBROKER_MESSAGE_TYPES = Object.freeze({ SEND_MESSAGE: 'send-message', MESSAGE_STATUS: 'message-status', }); function useOtherTabsTunnelbrokerConnection(): SecondaryTunnelbrokerConnection { const onSendMessageCallbacks = React.useRef< Set<(MessageToDeviceRequest) => mixed>, >(new Set()); const onMessageStatusCallbacks = React.useRef< Set<(messageID: string, error: ?string) => mixed>, >(new Set()); React.useEffect(() => { const messageHandler = (event: MessageEvent) => { if (typeof event.data !== 'object' || !event.data) { console.log( 'Invalid message received from shared ' + 'tunnelbroker broadcast channel', event.data, ); return; } const data = event.data; if (data.type === WEB_TUNNELBROKER_MESSAGE_TYPES.SEND_MESSAGE) { if (typeof data.message !== 'object' || !data.message) { console.log( 'Invalid tunnelbroker message request received ' + 'from shared tunnelbroker broadcast channel', event.data, ); return; } // We know that the input was already validated const message: MessageToDeviceRequest = (data.message: any); for (const callback of onSendMessageCallbacks.current) { callback(message); } } else if (data.type === WEB_TUNNELBROKER_MESSAGE_TYPES.MESSAGE_STATUS) { if (typeof data.messageID !== 'string') { console.log( 'Missing message id in message status message ' + 'from shared tunnelbroker broadcast channel', ); return; } const messageID = data.messageID; if ( typeof data.error !== 'string' && data.error !== null && data.error !== undefined ) { console.log( 'Invalid error in message status message ' + 'from shared tunnelbroker broadcast channel', data.error, ); return; } const error = data.error; for (const callback of onMessageStatusCallbacks.current) { callback(messageID, error); } } else { console.log( 'Invalid message type ' + 'from shared tunnelbroker broadcast channel', data, ); } }; WEB_TUNNELBROKER_CHANNEL.addEventListener('message', messageHandler); return () => WEB_TUNNELBROKER_CHANNEL.removeEventListener('message', messageHandler); }, [onMessageStatusCallbacks, onSendMessageCallbacks]); return React.useMemo( () => ({ sendMessage: message => WEB_TUNNELBROKER_CHANNEL.postMessage({ type: WEB_TUNNELBROKER_MESSAGE_TYPES.SEND_MESSAGE, message, }), onSendMessage: callback => { onSendMessageCallbacks.current.add(callback); return () => { onSendMessageCallbacks.current.delete(callback); }; }, setMessageStatus: (messageID, error) => { WEB_TUNNELBROKER_CHANNEL.postMessage({ type: WEB_TUNNELBROKER_MESSAGE_TYPES.MESSAGE_STATUS, messageID, error, }); }, onMessageStatus: callback => { onMessageStatusCallbacks.current.add(callback); return () => { onMessageStatusCallbacks.current.delete(callback); }; }, }), [onMessageStatusCallbacks, onSendMessageCallbacks], ); } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const ConnectedApp: React.ComponentType = React.memo( function ConnectedApp(props) { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const navInfo = useSelector(state => state.navInfo); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const activeThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); useBadgeHandler(); const dispatch = useDispatch(); const modalContext = useModalContext(); const modals = React.useMemo( () => modalContext.modals.map(([modal, key]) => ( {modal} )), [modalContext.modals], ); const { lockStatus, releaseLockOrAbortRequest } = useWebLock( TUNNELBROKER_LOCK_NAME, ); const secondaryTunnelbrokerConnection: SecondaryTunnelbrokerConnection = useOtherTabsTunnelbrokerConnection(); const handleSecondaryDeviceLogInError = useHandleSecondaryDeviceLogInError(); return ( ); }, ); function AppWithProvider(props: BaseProps): React.Node { return ( ); } export default AppWithProvider;