diff --git a/lib/utils/migration-utils.js b/lib/utils/migration-utils.js index e77aea2f5..3211d2335 100644 --- a/lib/utils/migration-utils.js +++ b/lib/utils/migration-utils.js @@ -1,364 +1,344 @@ // @flow import invariant from 'invariant'; import { authoritativeKeyserverID } from './authoritative-keyserver.js'; import { getConfig } from './config.js'; import type { TranslatedThreadMessageInfos } from './message-ops-utils.js'; import { entries } from './objects.js'; -import { - convertRawMessageInfoToNewIDSchema, - convertRawThreadInfoToNewIDSchema, -} from '../_generated/migration-utils.js'; +import { convertRawThreadInfoToNewIDSchema } from '../_generated/migration-utils.js'; import { parsePendingThreadID, getPendingThreadID, draftKeySuffix, } from '../shared/thread-utils.js'; import type { ClientDBDraftInfo, ClientDBDraftStoreOperation, DraftStore, } from '../types/draft-types'; -import type { RawMessageInfo } from '../types/message-types.js'; import type { BaseNavInfo } from '../types/nav-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import type { StoreOperations } from '../types/store-ops-types.js'; import { syncedMetadataNames } from '../types/synced-metadata-types.js'; import { threadPermissions, threadPermissionPropagationPrefixes, threadPermissionFilterPrefixes, } from '../types/thread-permission-types.js'; import type { MixedRawThreadInfos } from '../types/thread-types.js'; function convertDraftKeyToNewIDSchema(key: string): string { const threadID = key.slice(0, -draftKeySuffix.length); const convertedThreadID = convertIDToNewSchema( threadID, authoritativeKeyserverID(), ); return `${convertedThreadID}${draftKeySuffix}`; } function convertDraftStoreToNewIDSchema(store: DraftStore): DraftStore { return { drafts: Object.fromEntries( entries(store.drafts).map(([key, value]) => [ convertDraftKeyToNewIDSchema(key), value, ]), ), }; } function generateIDSchemaMigrationOpsForDrafts( drafts: $ReadOnlyArray, ): $ReadOnlyArray { const operations = drafts.map(draft => ({ type: 'update', payload: { key: convertDraftKeyToNewIDSchema(draft.key), text: draft.text, }, })); return [{ type: 'remove_all' }, ...operations]; } function convertMessageStoreThreadsToNewIDSchema( messageStoreThreads: TranslatedThreadMessageInfos, ): TranslatedThreadMessageInfos { return Object.fromEntries( entries(messageStoreThreads).map(([id, translatedThreadMessageInfo]) => [ `${authoritativeKeyserverID()}|` + id, translatedThreadMessageInfo, ]), ); } function convertThreadStoreThreadInfosToNewIDSchema( threadStoreThreadInfos: MixedRawThreadInfos, ): MixedRawThreadInfos { return Object.fromEntries( entries(threadStoreThreadInfos).map(([id, threadInfo]) => { invariant( !threadInfo.minimallyEncoded, `threadInfo during ID schema migration shouldn't be minimallyEncoded`, ); return [ `${authoritativeKeyserverID()}|` + id, convertRawThreadInfoToNewIDSchema(threadInfo), ]; }), ); } function convertIDToNewSchema(threadID: string, idPrefix: string): string { const pendingIDContents = parsePendingThreadID(threadID); if (!pendingIDContents) { return convertNonPendingIDToNewSchema(threadID, idPrefix); } const { threadType, sourceMessageID, memberIDs } = pendingIDContents; if (!sourceMessageID) { return threadID; } return getPendingThreadID( threadType, memberIDs, convertNonPendingIDToNewSchema(sourceMessageID, idPrefix), ); } function convertNonPendingIDToNewSchema( threadID: string, idPrefix: string, ): string { if (threadID.indexOf('|') === -1) { return `${idPrefix}|${threadID}`; } return threadID; } -function convertNotificationMessageInfoToNewIDSchema( - messageInfosString: ?string, -): ?$ReadOnlyArray { - let messageInfos: ?$ReadOnlyArray = null; - if (messageInfosString) { - messageInfos = JSON.parse(messageInfosString); - } - - if (messageInfos?.some(message => message.threadID.indexOf('|') === -1)) { - messageInfos = messageInfos?.map(convertRawMessageInfoToNewIDSchema); - } - return messageInfos; -} - // This is an array of all permissions that need to be removed // in an upcoming migration for roles. Once the migrations are landed, // no changes to this array should be made to prevent future migrations // from having unexpected behavior. // See context in https://linear.app/comm/issue/ENG-5622/#comment-2d98a2cd const permissionsToRemoveInMigration: $ReadOnlyArray = [ threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.OPEN + threadPermissions.VOICED, threadPermissions.JOIN_THREAD, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_ENTRIES, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_NAME, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_DESCRIPTION, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_COLOR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.TOP_LEVEL + threadPermissions.CREATE_SUBCHANNELS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.TOP_LEVEL + threadPermissions.CREATE_SIDEBARS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.ADD_MEMBERS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.REMOVE_MEMBERS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.CHANGE_ROLE, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_PERMISSIONS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_PINS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.REACT_TO_MESSAGE, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_MESSAGE, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_INVITE_LINKS, ]; export type LegacyMigrationManifest> = { +[number | string]: (T) => Promise, }; type PersistedState> = T | void; type ConfigType = { +debug: boolean, }; export type StorageMigrationFunction> = ( debug: boolean, ) => Promise>; export type MigrationFunction< N: BaseNavInfo, T: BaseAppState, > = T => Promise<{ +state: T, +ops: StoreOperations, +changesSchema?: boolean, }>; export type MigrationsManifest> = { +[number | string]: MigrationFunction, }; function createAsyncMigrate>( legacyMigrations: LegacyMigrationManifest, config: ConfigType, migrations: MigrationsManifest, handleException: (error: Error, state: T) => T, storageMigration: ?StorageMigrationFunction, ): ( state: PersistedState, currentVersion: number, ) => Promise> { const debug = process.env.NODE_ENV !== 'production' && !!config?.debug; return async function ( state: ?PersistedState, currentVersion: number, ): Promise> { if (!state && storageMigration) { state = await storageMigration(debug); } if (!state) { if (debug) { console.log('redux-persist: no inbound state, skipping migration'); } return undefined; } const inboundVersion: number = state?._persist?.version ?? -1; if (inboundVersion === currentVersion) { if (debug) { console.log('redux-persist: versions match, noop migration'); } return state; } if (inboundVersion > currentVersion) { if (debug) { console.error('redux-persist: downgrading version is not supported'); } return state; } const { state: newState } = await runMigrations( legacyMigrations, migrations, state, inboundVersion, currentVersion, debug, handleException, ); return newState; }; } async function runMigrations>( legacyMigrations: LegacyMigrationManifest, migrations: MigrationsManifest, state: T, inboundVersion: number, currentVersion: number, debug: boolean, handleException?: (error: Error, state: T) => T, ): Promise<{ +state: PersistedState, +schemaChanged: boolean, }> { const migrationKeys = [ ...Object.keys(legacyMigrations), ...Object.keys(migrations), ] .map(ver => parseInt(ver)) .filter(key => currentVersion >= key && key > inboundVersion); const sortedMigrationKeys = migrationKeys.sort((a, b) => a - b); if (debug) { console.log('redux-persist: migrationKeys', sortedMigrationKeys); } let migratedState = state; let schemaChanged = false; for (const versionKey of sortedMigrationKeys) { if (debug) { console.log( 'redux-persist: running migration for versionKey', versionKey, ); } if (!versionKey) { continue; } try { if (legacyMigrations[versionKey]) { migratedState = await legacyMigrations[versionKey](migratedState); } else { const { state: newState, ops, changesSchema, } = await migrations[versionKey](migratedState); schemaChanged = schemaChanged || !!changesSchema; migratedState = newState; const versionUpdateOp = { type: 'replace_synced_metadata_entry', payload: { name: syncedMetadataNames.DB_VERSION, data: versionKey.toString(), }, }; const dbOps = { ...ops, syncedMetadataStoreOperations: [ ...(ops.syncedMetadataStoreOperations ?? []), versionUpdateOp, ], }; await getConfig().sqliteAPI.processDBStoreOperations(dbOps); } } catch (exception) { if (handleException) { return { state: handleException(exception, state), schemaChanged, }; } throw exception; } } return { state: migratedState, schemaChanged }; } export { convertDraftKeyToNewIDSchema, convertDraftStoreToNewIDSchema, generateIDSchemaMigrationOpsForDrafts, convertMessageStoreThreadsToNewIDSchema, convertThreadStoreThreadInfosToNewIDSchema, - convertNonPendingIDToNewSchema, convertIDToNewSchema, - convertNotificationMessageInfoToNewIDSchema, permissionsToRemoveInMigration, createAsyncMigrate, runMigrations, }; diff --git a/native/push/android.js b/native/push/android.js index 7c92dd809..c5e22b086 100644 --- a/native/push/android.js +++ b/native/push/android.js @@ -1,113 +1,93 @@ // @flow import { NativeModules, NativeEventEmitter } from 'react-native'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; -import { - convertNonPendingIDToNewSchema, - convertNotificationMessageInfoToNewIDSchema, -} from 'lib/utils/migration-utils.js'; -import { thickThreadIDRegex } from 'lib/utils/validation-utils.js'; - -import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; type CommAndroidNotificationsConstants = { +NOTIFICATIONS_IMPORTANCE_HIGH: number, +COMM_ANDROID_NOTIFICATIONS_TOKEN: 'commAndroidNotificationsToken', +COMM_ANDROID_NOTIFICATIONS_MESSAGE: 'commAndroidNotificationsMessage', +COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED: 'commAndroidNotificationsNotificationOpened', }; type CommAndroidNotificationsModuleType = { +removeAllActiveNotificationsForThread: (threadID: string) => void, +getInitialNotificationThreadID: () => Promise, +createChannel: ( channelID: string, name: string, importance: number, description: ?string, ) => void, +getConstants: () => CommAndroidNotificationsConstants, +setBadge: (count: number) => void, +removeAllDeliveredNotifications: () => void, +hasPermission: () => Promise, +getToken: () => Promise, +requestNotificationsPermission: () => Promise, +canRequestNotificationsPermissionFromUser: () => Promise, ...CommAndroidNotificationsConstants, }; export type AndroidMessage = { +body: string, +title: string, +threadID: string, +prefix?: string, +messageInfos: ?string, }; export type ParsedAndroidMessage = { ...AndroidMessage, +messageInfos: ?$ReadOnlyArray, }; function parseAndroidMessage(message: AndroidMessage): ParsedAndroidMessage { - const { threadID, messageInfos } = message; - if (thickThreadIDRegex.test(threadID)) { - return { - ...message, - messageInfos: messageInfos ? JSON.parse(messageInfos) : null, - }; - } - + const { messageInfos } = message; return { ...message, - threadID: convertNonPendingIDToNewSchema( - message.threadID, - authoritativeKeyserverID, - ), - messageInfos: convertNotificationMessageInfoToNewIDSchema( - message.messageInfos, - ), + messageInfos: messageInfos ? JSON.parse(messageInfos) : null, }; } const { CommAndroidNotificationsEventEmitter } = NativeModules; const CommAndroidNotifications: CommAndroidNotificationsModuleType = NativeModules.CommAndroidNotifications; const androidNotificationChannelID = 'default'; function handleAndroidMessage( message: ParsedAndroidMessage, handleIfActive?: ( threadID: string, texts: { body: string, title: ?string }, ) => boolean, ) { const { title, prefix, threadID } = message; let { body } = message; ({ body } = mergePrefixIntoBody({ body, title, prefix })); if (handleIfActive) { const texts = { title, body }; const isActive = handleIfActive(threadID, texts); if (isActive) { return; } } } function getCommAndroidNotificationsEventEmitter(): NativeEventEmitter<{ commAndroidNotificationsToken: [string], commAndroidNotificationsMessage: [AndroidMessage], commAndroidNotificationsNotificationOpened: [string], }> { return new NativeEventEmitter(CommAndroidNotificationsEventEmitter); } export { parseAndroidMessage, androidNotificationChannelID, handleAndroidMessage, getCommAndroidNotificationsEventEmitter, CommAndroidNotifications, }; diff --git a/native/push/comm-ios-notification.js b/native/push/comm-ios-notification.js index 5df4a992b..6023012fc 100644 --- a/native/push/comm-ios-notification.js +++ b/native/push/comm-ios-notification.js @@ -1,90 +1,68 @@ // @flow import { NativeModules } from 'react-native'; import type { RawMessageInfo } from 'lib/types/message-types.js'; -import { - convertNonPendingIDToNewSchema, - convertNotificationMessageInfoToNewIDSchema, -} from 'lib/utils/migration-utils.js'; -import { thickThreadIDRegex } from 'lib/utils/validation-utils.js'; - -import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; const { CommIOSNotifications } = NativeModules; // This is the basic data we receive from Objective-C // Its keys are explained as follow: // `id` - unique ID generated by keyserver // `message` - comes from `alert` property of raw Apple payload // which carries displayable content of the notification // `body` and `title` - actual content of the // message and sender name respectively export type CoreIOSNotificationData = { +id: string, +message: ?string, +threadID: string, +title: ?string, +messageInfos: ?string, +body: ?string, +prefix?: string, }; // Objective-C can also include notification request identifier // associated with certain notification so that we can interact // with notification center from JS. Read for explanation: // https://developer.apple.com/documentation/usernotifications/unnotificationrequest?language=objc export type CoreIOSNotificationDataWithRequestIdentifier = { ...CoreIOSNotificationData, +identifier: string, }; export type ParsedCoreIOSNotificationData = { ...CoreIOSNotificationData, +messageInfos: ?$ReadOnlyArray, }; export class CommIOSNotification { data: ParsedCoreIOSNotificationData; remoteNotificationCompleteCallbackCalled: boolean; constructor(notification: CoreIOSNotificationData) { this.remoteNotificationCompleteCallbackCalled = false; - const { threadID, messageInfos } = notification; - - if (thickThreadIDRegex.test(threadID)) { - this.data = { - ...notification, - messageInfos: messageInfos ? JSON.parse(messageInfos) : null, - }; - return; - } - + const { messageInfos } = notification; this.data = { ...notification, - threadID: convertNonPendingIDToNewSchema( - notification.threadID, - authoritativeKeyserverID, - ), - messageInfos: convertNotificationMessageInfoToNewIDSchema( - notification.messageInfos, - ), + messageInfos: messageInfos ? JSON.parse(messageInfos) : null, }; } getMessage(): ?string { return this.data.message; } getData(): ParsedCoreIOSNotificationData { return this.data; } finish(fetchResult: string) { if (!this.data.id || this.remoteNotificationCompleteCallbackCalled) { return; } CommIOSNotifications.completeNotif(this.data.id, fetchResult); this.remoteNotificationCompleteCallbackCalled = true; } } diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index 06d42d02d..2dd11da47 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,865 +1,858 @@ // @flow import * as Haptics from 'expo-haptics'; import _groupBy from 'lodash/fp/groupBy.js'; import * as React from 'react'; import { LogBox, Platform } from 'react-native'; import { Notification as InAppNotification } from 'react-native-in-app-message'; import { recordAlertActionType } from 'lib/actions/alert-actions.js'; import { type DeviceTokens, setDeviceTokenActionTypes, type SetDeviceTokenStartedPayload, type SetDeviceTokenActionPayload, useSetDeviceToken, useSetDeviceTokenFanout, } from 'lib/actions/device-actions.js'; import { saveMessagesActionType } from 'lib/actions/message-actions.js'; import { extractKeyserverIDFromIDOptional } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { deviceTokensSelector, allUpdatesCurrentAsOfSelector, allConnectionInfosSelector, } from 'lib/selectors/keyserver-selectors.js'; import { threadInfoSelector, thinThreadsUnreadCountSelector, unreadThickThreadIDsSelector, } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; import { alertTypes, type AlertInfo, type RecordAlertActionPayload, } from 'lib/types/alert-types.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import type { ConnectionInfo } from 'lib/types/socket-types.js'; import type { GlobalTheme } from 'lib/types/theme-types.js'; -import { - convertNonPendingIDToNewSchema, - convertNotificationMessageInfoToNewIDSchema, -} from 'lib/utils/migration-utils.js'; import { shouldSkipPushPermissionAlert } from 'lib/utils/push-alerts.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import sleep from 'lib/utils/sleep.js'; import { type AndroidMessage, androidNotificationChannelID, CommAndroidNotifications, getCommAndroidNotificationsEventEmitter, handleAndroidMessage, parseAndroidMessage, } from './android.js'; import { CommIOSNotification, type CoreIOSNotificationData, type CoreIOSNotificationDataWithRequestIdentifier, } from './comm-ios-notification.js'; import InAppNotif from './in-app-notif.react.js'; import { CommIOSNotifications, type CoreIOSNotificationBackgroundData, getCommIOSNotificationsEventEmitter, iosPushPermissionResponseReceived, requestIOSPushPermissions, } from './ios.js'; -import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle.js'; import { commCoreModule } from '../native-modules.js'; import { replaceWithThreadActionType } from '../navigation/action-types.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { RootContext, type RootContextType } from '../root-context.js'; import type { EventSubscription } from '../types/react-native.js'; import Alert from '../utils/alert.js'; LogBox.ignoreLogs([ // react-native-in-app-message 'ForceTouchGestureHandler is not available', ]); type BaseProps = { +navigation: RootNavigationProp<'App'>, }; type Props = { ...BaseProps, // Navigation state +activeThread: ?string, // Redux state +thinThreadsUnreadCount: { +[keyserverID: string]: number }, +unreadThickThreadIDs: $ReadOnlyArray, +connection: { +[keyserverID: string]: ?ConnectionInfo }, +deviceTokens: { +[keyserverID: string]: ?string, }, +threadInfos: { +[id: string]: ThreadInfo, }, +notifPermissionAlertInfo: AlertInfo, +allUpdatesCurrentAsOf: { +[keyserverID: string]: number, }, +activeTheme: ?GlobalTheme, +loggedIn: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +setDeviceToken: ( input: DeviceTokens, ) => Promise, +setDeviceTokenFanout: ( deviceToken: ?string, ) => Promise, // withRootContext +rootContext: ?RootContextType, +localToken: ?string, +tunnelbrokerSocketState: | { +connected: true, +isAuthorized: boolean, } | { +connected: false, +retryCount: number, }, }; type State = { +inAppNotifProps: ?{ +customComponent: React.Node, +blurType: ?('xlight' | 'dark'), +onPress: () => void, }, }; class PushHandler extends React.PureComponent { state: State = { inAppNotifProps: null, }; currentState: ?string = getCurrentLifecycleState(); appStarted = 0; androidNotificationsEventSubscriptions: Array = []; androidNotificationsPermissionPromise: ?Promise = undefined; initialAndroidNotifHandled = false; openThreadOnceReceived: Set = new Set(); lifecycleSubscription: ?EventSubscription; iosNotificationEventSubscriptions: Array = []; componentDidMount() { this.appStarted = Date.now(); this.lifecycleSubscription = addLifecycleListener( this.handleAppStateChange, ); this.onForeground(); if (Platform.OS === 'ios') { const commIOSNotificationsEventEmitter = getCommIOSNotificationsEventEmitter(); this.iosNotificationEventSubscriptions.push( commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTERED_EVENT, registration => this.registerPushPermissions(registration?.deviceToken), ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTRATION_FAILED_EVENT, this.failedToRegisterPushPermissionsIOS, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_FOREGROUND_EVENT, this.iosForegroundNotificationReceived, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants().NOTIFICATION_OPENED_EVENT, this.iosNotificationOpened, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_BACKGROUND_EVENT, this.iosBackgroundNotificationReceived, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.createChannel( androidNotificationChannelID, 'Default', CommAndroidNotifications.getConstants().NOTIFICATIONS_IMPORTANCE_HIGH, 'Comm notifications channel', ); const commAndroidNotificationsEventEmitter = getCommAndroidNotificationsEventEmitter(); this.androidNotificationsEventSubscriptions.push( commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_TOKEN, this.handleAndroidDeviceToken, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_MESSAGE, this.androidMessageReceived, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED, this.androidNotificationOpened, ), ); } void this.updateBadgeCount(); } componentWillUnmount() { if (this.lifecycleSubscription) { this.lifecycleSubscription.remove(); } if (Platform.OS === 'ios') { for (const iosNotificationEventSubscription of this .iosNotificationEventSubscriptions) { iosNotificationEventSubscription.remove(); } } else if (Platform.OS === 'android') { for (const androidNotificationsEventSubscription of this .androidNotificationsEventSubscriptions) { androidNotificationsEventSubscription.remove(); } this.androidNotificationsEventSubscriptions = []; } } handleAppStateChange = (nextState: ?string) => { if (!nextState || nextState === 'unknown') { return; } const lastState = this.currentState; this.currentState = nextState; if (lastState === 'background' && nextState === 'active') { this.onForeground(); this.clearNotifsOfThread(); } }; onForeground() { if (this.props.loggedIn) { void this.ensurePushNotifsEnabled(); } else { // We do this in case there was a crash, so we can clear deviceToken from // any other cookies it might be set for const deviceTokensMap: { [string]: string } = {}; for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken) { deviceTokensMap[keyserverID] = deviceToken; } } this.setDeviceToken(deviceTokensMap, { type: 'nothing_to_set' }); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.activeThread !== prevProps.activeThread) { this.clearNotifsOfThread(); } void this.updateBadgeCount(); for (const threadID of this.openThreadOnceReceived) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, false); this.openThreadOnceReceived.clear(); break; } } if (this.props.loggedIn && !prevProps.loggedIn) { void this.ensurePushNotifsEnabled(); } else if (!this.props.localToken && prevProps.localToken) { void this.ensurePushNotifsEnabled(); } else { for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; const prevDeviceToken = prevProps.deviceTokens[keyserverID]; if (!deviceToken && prevDeviceToken) { void this.ensurePushNotifsEnabled(); break; } } } if (!this.props.loggedIn && prevProps.loggedIn) { this.clearAllNotifs(); void this.resetBadgeCount(); } if ( this.state.inAppNotifProps && this.state.inAppNotifProps !== prevState.inAppNotifProps ) { Haptics.notificationAsync(); InAppNotification.show(); } } async updateBadgeCount() { const curThinUnreadCounts = this.props.thinThreadsUnreadCount; const curConnections = this.props.connection; const currentUnreadThickThreads = this.props.unreadThickThreadIDs; const currentTunnelbrokerConnectionStatus = this.props.tunnelbrokerSocketState.connected; const notifStorageUpdates: Array<{ +id: string, +unreadCount: number, }> = []; const notifsStorageQueries: Array = []; for (const keyserverID in curThinUnreadCounts) { if (curConnections[keyserverID]?.status !== 'connected') { notifsStorageQueries.push(keyserverID); continue; } notifStorageUpdates.push({ id: keyserverID, unreadCount: curThinUnreadCounts[keyserverID], }); } let queriedKeyserverData: $ReadOnlyArray<{ +id: string, +unreadCount: number, }> = []; const handleUnreadThickThreadIDsInNotifsStorage = (async () => { if (currentTunnelbrokerConnectionStatus) { await commCoreModule.updateUnreadThickThreadsInNotifsStorage( currentUnreadThickThreads, ); return currentUnreadThickThreads; } return await commCoreModule.getUnreadThickThreadIDsFromNotifsStorage(); })(); let unreadThickThreadIDs: $ReadOnlyArray; try { [queriedKeyserverData, unreadThickThreadIDs] = await Promise.all([ commCoreModule.getKeyserverDataFromNotifStorage(notifsStorageQueries), handleUnreadThickThreadIDsInNotifsStorage, commCoreModule.updateKeyserverDataInNotifStorage(notifStorageUpdates), ]); } catch (e) { if (__DEV__) { Alert.alert( 'MMKV error', 'Failed to update keyserver data in MMKV.' + e.message, ); } console.log(e); return; } let totalUnreadCount = 0; for (const keyserverData of notifStorageUpdates) { totalUnreadCount += keyserverData.unreadCount; } for (const keyserverData of queriedKeyserverData) { totalUnreadCount += keyserverData.unreadCount; } totalUnreadCount += unreadThickThreadIDs.length; if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(totalUnreadCount); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(totalUnreadCount); } } async resetBadgeCount() { const keyserversDataToRemove = Object.keys( this.props.thinThreadsUnreadCount, ); try { await commCoreModule.removeKeyserverDataFromNotifStorage( keyserversDataToRemove, ); } catch (e) { if (__DEV__) { Alert.alert( 'MMKV error', 'Failed to remove keyserver from MMKV.' + e.message, ); } console.log(e); return; } if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(0); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(0); } } clearAllNotifs() { if (Platform.OS === 'ios') { CommIOSNotifications.removeAllDeliveredNotifications(); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllDeliveredNotifications(); } } clearNotifsOfThread() { const { activeThread } = this.props; if (!activeThread) { return; } if (Platform.OS === 'ios') { CommIOSNotifications.getDeliveredNotifications(notifications => PushHandler.clearDeliveredIOSNotificationsForThread( activeThread, notifications, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllActiveNotificationsForThread( activeThread, ); } } static clearDeliveredIOSNotificationsForThread( threadID: string, notifications: $ReadOnlyArray, ) { const identifiersToClear = []; for (const notification of notifications) { if (notification.threadID === threadID) { identifiersToClear.push(notification.identifier); } } if (identifiersToClear) { CommIOSNotifications.removeDeliveredNotifications(identifiersToClear); } } async ensurePushNotifsEnabled() { if (!this.props.loggedIn) { return; } if (Platform.OS === 'android') { await this.ensureAndroidPushNotifsEnabled(); return; } if (Platform.OS !== 'ios') { return; } let missingDeviceToken = !this.props.localToken; if (!missingDeviceToken) { for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken === null || deviceToken === undefined) { missingDeviceToken = true; break; } } } await requestIOSPushPermissions(missingDeviceToken); } async ensureAndroidPushNotifsEnabled() { const permissionPromisesResult = await Promise.all([ CommAndroidNotifications.hasPermission(), CommAndroidNotifications.canRequestNotificationsPermissionFromUser(), ]); let [hasPermission] = permissionPromisesResult; const [, canRequestPermission] = permissionPromisesResult; if (!hasPermission && canRequestPermission) { const permissionResponse = await (async () => { // We issue a call to sleep to match iOS behavior where prompt // doesn't appear immediately but after logged-out modal disappears await sleep(10); return await this.requestAndroidNotificationsPermission(); })(); hasPermission = permissionResponse; } if (!hasPermission) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); return; } try { const fcmToken = await CommAndroidNotifications.getToken(); await this.handleAndroidDeviceToken(fcmToken); } catch (e) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); } } requestAndroidNotificationsPermission = (): Promise => { if (!this.androidNotificationsPermissionPromise) { this.androidNotificationsPermissionPromise = (async () => { const notifPermission = await CommAndroidNotifications.requestNotificationsPermission(); this.androidNotificationsPermissionPromise = undefined; return notifPermission; })(); } return this.androidNotificationsPermissionPromise; }; handleAndroidDeviceToken = async (deviceToken: string) => { this.registerPushPermissions(deviceToken); await this.handleInitialAndroidNotification(); }; async handleInitialAndroidNotification() { if (this.initialAndroidNotifHandled) { return; } this.initialAndroidNotifHandled = true; const initialNotifThreadID = await CommAndroidNotifications.getInitialNotificationThreadID(); if (initialNotifThreadID) { await this.androidNotificationOpened(initialNotifThreadID); } } registerPushPermissions = (deviceToken: ?string) => { const deviceType = Platform.OS; if (deviceType !== 'android' && deviceType !== 'ios') { return; } if (deviceType === 'ios') { iosPushPermissionResponseReceived(); } const deviceTokensMap: { [string]: ?string } = {}; for (const keyserverID in this.props.deviceTokens) { const keyserverDeviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken !== keyserverDeviceToken) { deviceTokensMap[keyserverID] = deviceToken; } } this.setDeviceToken(deviceTokensMap, { type: 'device_token', deviceToken }); }; setDeviceToken( deviceTokens: DeviceTokens, payload: SetDeviceTokenStartedPayload, ) { void this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceToken(deviceTokens), undefined, payload, ); } setAllDeviceTokensNull = () => { void this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceTokenFanout(null), undefined, { type: 'clear_device_token' }, ); }; failedToRegisterPushPermissionsIOS = () => { this.setAllDeviceTokensNull(); if (!this.props.loggedIn) { return; } iosPushPermissionResponseReceived(); }; failedToRegisterPushPermissionsAndroid = ( shouldShowAlertOnAndroid: boolean, ) => { this.setAllDeviceTokensNull(); if (!this.props.loggedIn) { return; } if (shouldShowAlertOnAndroid) { this.showNotifAlertOnAndroid(); } }; showNotifAlertOnAndroid() { const alertInfo = this.props.notifPermissionAlertInfo; if (shouldSkipPushPermissionAlert(alertInfo)) { return; } const payload: RecordAlertActionPayload = { alertType: alertTypes.NOTIF_PERMISSION, time: Date.now(), }; this.props.dispatch({ type: recordAlertActionType, payload, }); Alert.alert( 'Unable to initialize notifs!', 'Please check your network connection, make sure Google Play ' + 'services are installed and enabled, and confirm that your Google ' + 'Play credentials are valid in the Google Play Store.', undefined, { cancelable: true }, ); } navigateToThread(threadInfo: ThreadInfo, clearChatRoutes: boolean) { if (clearChatRoutes) { this.props.navigation.dispatch({ type: replaceWithThreadActionType, payload: { threadInfo }, }); } else { this.props.navigateToThread({ threadInfo }); } } onPressNotificationForThread(threadID: string, clearChatRoutes: boolean) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, clearChatRoutes); } else { this.openThreadOnceReceived.add(threadID); } } saveMessageInfos(rawMessageInfos: ?$ReadOnlyArray) { if (!rawMessageInfos) { return; } const keyserverIDToMessageInfos = _groupBy(messageInfos => extractKeyserverIDFromIDOptional(messageInfos.threadID), )(rawMessageInfos); for (const keyserverID in keyserverIDToMessageInfos) { const updatesCurrentAsOf = this.props.allUpdatesCurrentAsOf[keyserverID]; const messageInfos = keyserverIDToMessageInfos[keyserverID]; this.props.dispatch({ type: saveMessagesActionType, payload: { rawMessageInfos: messageInfos, updatesCurrentAsOf }, }); } } iosForegroundNotificationReceived = ( rawNotification: CoreIOSNotificationData, ) => { const notification = new CommIOSNotification(rawNotification); if (Date.now() < this.appStarted + 1500) { // On iOS, when the app is opened from a notif press, for some reason this // callback gets triggered before iosNotificationOpened. In fact this // callback shouldn't be triggered at all. To avoid weirdness we are // ignoring any foreground notification received within the first second // of the app being started, since they are most likely to be erroneous. notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NO_DATA, ); return; } const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); let title = notification.getData().title; let body = notification.getData().body; if (title && body) { ({ title, body } = mergePrefixIntoBody({ title, body })); } else { body = notification.getMessage(); } if (body) { this.showInAppNotification(threadID, body, title); } else { console.log( 'Non-rescind foreground notification without alert received!', ); } notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; iosBackgroundNotificationReceived = ( backgroundData: CoreIOSNotificationBackgroundData, ) => { const convertedMessageInfos = backgroundData.messageInfosArray - .flatMap(convertNotificationMessageInfoToNewIDSchema) + .flatMap(messageInfosString => + messageInfosString ? JSON.parse(messageInfosString) : null, + ) .filter(Boolean); if (!convertedMessageInfos.length) { return; } this.saveMessageInfos(convertedMessageInfos); }; onPushNotifBootsApp() { if ( this.props.rootContext && this.props.rootContext.detectUnsupervisedBackground ) { this.props.rootContext.detectUnsupervisedBackground(false); } } iosNotificationOpened = (rawNotification: CoreIOSNotificationData) => { const notification = new CommIOSNotification(rawNotification); this.onPushNotifBootsApp(); const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); this.onPressNotificationForThread(threadID, true); notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; showInAppNotification(threadID: string, message: string, title?: ?string) { if (threadID === this.props.activeThread) { return; } this.setState({ inAppNotifProps: { customComponent: ( ), blurType: this.props.activeTheme === 'dark' ? 'xlight' : 'dark', onPress: () => { InAppNotification.hide(); this.onPressNotificationForThread(threadID, false); }, }, }); } androidNotificationOpened = async (threadID: string) => { - const convertedThreadID = convertNonPendingIDToNewSchema( - threadID, - authoritativeKeyserverID, - ); this.onPushNotifBootsApp(); - this.onPressNotificationForThread(convertedThreadID, true); + this.onPressNotificationForThread(threadID, true); }; androidMessageReceived = async (message: AndroidMessage) => { const parsedMessage = parseAndroidMessage(message); this.onPushNotifBootsApp(); const { messageInfos } = parsedMessage; this.saveMessageInfos(messageInfos); handleAndroidMessage(parsedMessage, this.handleAndroidNotificationIfActive); }; handleAndroidNotificationIfActive = ( threadID: string, texts: { body: string, title: ?string }, ): boolean => { if (this.currentState !== 'active') { return false; } this.showInAppNotification(threadID, texts.body, texts.title); return true; }; render(): React.Node { return ( ); } } const ConnectedPushHandler: React.ComponentType = React.memo(function ConnectedPushHandler(props: BaseProps) { const navContext = React.useContext(NavContext); const activeThread = activeMessageListSelector(navContext); const thinThreadsUnreadCount = useSelector(thinThreadsUnreadCountSelector); const unreadThickThreadIDs = useSelector(unreadThickThreadIDsSelector); const connection = useSelector(allConnectionInfosSelector); const deviceTokens = useSelector(deviceTokensSelector); const threadInfos = useSelector(threadInfoSelector); const notifPermissionAlertInfo = useSelector( state => state.alertStore.alertInfos[alertTypes.NOTIF_PERMISSION], ); const allUpdatesCurrentAsOf = useSelector(allUpdatesCurrentAsOfSelector); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const loggedIn = useSelector(isLoggedIn); const localToken = useSelector( state => state.tunnelbrokerDeviceToken.localToken, ); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceToken(); const callSetDeviceTokenFanout = useSetDeviceTokenFanout(); const rootContext = React.useContext(RootContext); const { socketState: tunnelbrokerSocketState } = useTunnelbroker(); return ( ); }); export default ConnectedPushHandler; diff --git a/web/push-notif/push-notifs-handler.js b/web/push-notif/push-notifs-handler.js index 0be9b790c..e11750508 100644 --- a/web/push-notif/push-notifs-handler.js +++ b/web/push-notif/push-notifs-handler.js @@ -1,333 +1,326 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { recordAlertActionType } from 'lib/actions/alert-actions.js'; import { useSetDeviceTokenFanout, setDeviceTokenActionTypes, } from 'lib/actions/device-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { alertTypes, type RecordAlertActionPayload, } from 'lib/types/alert-types.js'; import { isDesktopPlatform } from 'lib/types/device-types.js'; import type { SenderDeviceDescriptor } from 'lib/types/notif-types.js'; import { getConfig } from 'lib/utils/config.js'; -import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js'; import { shouldSkipPushPermissionAlert } from 'lib/utils/push-alerts.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { decryptDesktopNotification, migrateLegacyOlmNotificationsSessions, } from './notif-crypto-utils.js'; -import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import electron from '../electron.js'; import PushNotifModal from '../modals/push-notif-modal.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { getOlmWasmPath } from '../shared-worker/utils/constants.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; function useCreateDesktopPushSubscription() { const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceTokenFanout(); const staffCanSee = useStaffCanSee(); const [notifsOlmSessionMigrated, setNotifsSessionsMigrated] = React.useState(false); const platformDetails = getConfig().platformDetails; React.useEffect(() => { if ( !isDesktopPlatform(platformDetails.platform) || !hasMinCodeVersion(platformDetails, { majorDesktop: 12 }) ) { return; } void (async () => { await migrateLegacyOlmNotificationsSessions(); setNotifsSessionsMigrated(true); })(); }, [platformDetails]); React.useEffect( () => electron?.onDeviceTokenRegistered?.((token: ?string) => { void dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(token), undefined, { type: 'device_token', deviceToken: token }, ); }), [callSetDeviceToken, dispatchActionPromise], ); React.useEffect(() => { electron?.fetchDeviceToken?.(); }, []); React.useEffect(() => { if ( hasMinCodeVersion(platformDetails, { majorDesktop: 12 }) && !notifsOlmSessionMigrated ) { return undefined; } return electron?.onEncryptedNotification?.( async ({ encryptedPayload, senderDeviceDescriptor, type: messageType, }: { encryptedPayload: string, type: string, senderDeviceDescriptor: SenderDeviceDescriptor, }) => { const decryptedPayload = await decryptDesktopNotification( encryptedPayload, messageType, staffCanSee, senderDeviceDescriptor, ); electron?.showDecryptedNotification(decryptedPayload); }, ); }, [staffCanSee, notifsOlmSessionMigrated, platformDetails]); const dispatch = useDispatch(); React.useEffect( () => electron?.onNotificationClicked?.( ({ threadID }: { +threadID: string }) => { - const convertedThreadID = convertNonPendingIDToNewSchema( - threadID, - authoritativeKeyserverID, - ); - const payload = { chatMode: 'view', - activeChatThreadID: convertedThreadID, + activeChatThreadID: threadID, tab: 'chat', }; dispatch({ type: updateNavInfoActionType, payload }); }, ), [dispatch], ); // Handle invalid device token const localToken = useSelector( state => state.tunnelbrokerDeviceToken.localToken, ); const prevLocalToken = React.useRef(localToken); React.useEffect(() => { if (prevLocalToken.current && !localToken) { electron?.fetchDeviceToken?.(); } prevLocalToken.current = localToken; }, [localToken]); } function useCreatePushSubscription(): () => Promise { const publicKey = useSelector(state => state.pushApiPublicKey); const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceTokenFanout(); const staffCanSee = useStaffCanSee(); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { getAuthMetadata } = identityContext; return React.useCallback(async () => { if (!publicKey) { return; } const workerRegistration = await navigator.serviceWorker?.ready; const authMetadata = await getAuthMetadata(); if ( !workerRegistration || !workerRegistration.pushManager || !authMetadata ) { return; } workerRegistration.active?.postMessage({ olmWasmPath: getOlmWasmPath(), staffCanSee, authMetadata, }); const subscription = await workerRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: publicKey, }); const token = JSON.stringify(subscription); void dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(token), undefined, { type: 'device_token', deviceToken: token }, ); }, [ callSetDeviceToken, dispatchActionPromise, publicKey, staffCanSee, getAuthMetadata, ]); } function PushNotificationsHandler(): React.Node { useCreateDesktopPushSubscription(); const createPushSubscription = useCreatePushSubscription(); const notifPermissionAlertInfo = useSelector( state => state.alertStore.alertInfos[alertTypes.NOTIF_PERMISSION], ); const modalContext = useModalContext(); const loggedIn = useSelector(isLoggedIn); const dispatch = useDispatch(); const supported = 'Notification' in window && !electron; React.useEffect(() => { void (async () => { if (!navigator.serviceWorker || !supported) { return; } await navigator.serviceWorker.register('worker/notif', { scope: '/' }); if (Notification.permission === 'granted') { // Make sure the subscription is current if we have the permissions await createPushSubscription(); } else if ( Notification.permission === 'default' && loggedIn && !shouldSkipPushPermissionAlert(notifPermissionAlertInfo) ) { // Ask existing users that are already logged in for permission modalContext.pushModal(); const payload: RecordAlertActionPayload = { alertType: alertTypes.NOTIF_PERMISSION, time: Date.now(), }; dispatch({ type: recordAlertActionType, payload, }); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Ask for permission on login const prevLoggedIn = React.useRef(loggedIn); React.useEffect(() => { if (!navigator.serviceWorker || !supported) { return; } if (!prevLoggedIn.current && loggedIn) { if (Notification.permission === 'granted') { void createPushSubscription(); } else if ( Notification.permission === 'default' && !shouldSkipPushPermissionAlert(notifPermissionAlertInfo) ) { modalContext.pushModal(); const payload: RecordAlertActionPayload = { alertType: alertTypes.NOTIF_PERMISSION, time: Date.now(), }; dispatch({ type: recordAlertActionType, payload, }); } } prevLoggedIn.current = loggedIn; }, [ createPushSubscription, dispatch, loggedIn, modalContext, notifPermissionAlertInfo, prevLoggedIn, supported, ]); // Redirect to thread on notification click React.useEffect(() => { if (!navigator.serviceWorker || !supported) { return undefined; } const callback = (event: MessageEvent) => { if (typeof event.data !== 'object' || !event.data) { return; } if (event.data.targetThreadID) { const payload = { chatMode: 'view', activeChatThreadID: event.data.targetThreadID, tab: 'chat', }; dispatch({ type: updateNavInfoActionType, payload }); } }; navigator.serviceWorker.addEventListener('message', callback); return () => navigator.serviceWorker?.removeEventListener('message', callback); }, [dispatch, supported]); // Handle invalid device token const localToken = useSelector( state => state.tunnelbrokerDeviceToken.localToken, ); const prevLocalToken = React.useRef(localToken); React.useEffect(() => { if ( !navigator.serviceWorker || !supported || Notification.permission !== 'granted' ) { return; } if (prevLocalToken.current && !localToken) { void createPushSubscription(); } prevLocalToken.current = localToken; }, [createPushSubscription, localToken, supported]); return null; } export { PushNotificationsHandler, useCreatePushSubscription }; diff --git a/web/push-notif/service-worker.js b/web/push-notif/service-worker.js index 2767298a3..3fad4b7a1 100644 --- a/web/push-notif/service-worker.js +++ b/web/push-notif/service-worker.js @@ -1,190 +1,185 @@ // @flow import localforage from 'localforage'; import type { AuthMetadata } from 'lib/shared/identity-client-context.js'; import type { PlainTextWebNotification, WebNotification, } from 'lib/types/notif-types.js'; -import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js'; import { decryptWebNotification, migrateLegacyOlmNotificationsSessions, WEB_NOTIFS_SERVICE_UTILS_KEY, type WebNotifsServiceUtilsData, type WebNotifDecryptionError, } from './notif-crypto-utils.js'; import { persistAuthMetadata } from './services-client.js'; -import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { localforageConfig } from '../shared-worker/utils/constants.js'; declare class PushMessageData { json(): Object; } declare class PushEvent extends ExtendableEvent { +data: PushMessageData; } declare class CommAppMessage extends ExtendableEvent { +data: { +olmWasmPath?: string, +staffCanSee?: boolean, +authMetadata?: AuthMetadata, }; } declare var clients: Clients; declare function skipWaiting(): Promise; const commIconUrl = 'https://web.comm.app/favicon.ico'; function buildDecryptionErrorNotification( decryptionError: WebNotifDecryptionError, ) { const baseErrorPayload = { badge: commIconUrl, icon: commIconUrl, tag: decryptionError.id, data: { isError: true, }, }; if (decryptionError.displayErrorMessage && decryptionError.error) { return { body: decryptionError.error, ...baseErrorPayload, }; } return baseErrorPayload; } self.addEventListener('install', skipWaiting); self.addEventListener('activate', (event: ExtendableEvent) => { event.waitUntil(clients.claim()); }); self.addEventListener('message', (event: CommAppMessage) => { localforage.config(localforageConfig); event.waitUntil( (async () => { const { olmWasmPath, staffCanSee, authMetadata } = event.data; if (!olmWasmPath || staffCanSee === undefined || !authMetadata) { return; } const webNotifsServiceUtils: WebNotifsServiceUtilsData = { olmWasmPath: olmWasmPath, staffCanSee: staffCanSee, }; await Promise.all([ localforage.setItem( WEB_NOTIFS_SERVICE_UTILS_KEY, webNotifsServiceUtils, ), persistAuthMetadata(authMetadata), ]); await migrateLegacyOlmNotificationsSessions(); })(), ); }); self.addEventListener('push', (event: PushEvent) => { localforage.config(localforageConfig); const data: WebNotification = event.data.json(); event.waitUntil( (async () => { let plainTextData: PlainTextWebNotification; let decryptionResult: PlainTextWebNotification | WebNotifDecryptionError; if (data.encryptedPayload) { decryptionResult = await decryptWebNotification(data); } if (decryptionResult && decryptionResult.error) { const decryptionErrorNotification = buildDecryptionErrorNotification(decryptionResult); await self.registration.showNotification( 'Comm notification', decryptionErrorNotification, ); return; } else if (decryptionResult && decryptionResult.body) { plainTextData = decryptionResult; } else if (data.body) { plainTextData = data; } else { // We will never enter ths branch. It is // necessary since flow doesn't differentiate // between union types out-of-the-box. return; } let body = plainTextData.body; if (data.prefix) { body = `${data.prefix} ${body}`; } await self.registration.showNotification(plainTextData.title, { body, badge: commIconUrl, icon: commIconUrl, tag: plainTextData.id, data: { unreadCount: plainTextData.unreadCount, threadID: plainTextData.threadID, }, }); })(), ); }); self.addEventListener('notificationclick', (event: NotificationEvent) => { event.notification.close(); event.waitUntil( (async () => { const clientList: Array = (await clients.matchAll({ type: 'window', }): any); const selectedClient = clientList.find(client => client.focused) ?? clientList[0]; // Decryption error notifications don't contain threadID // but we still want them to be interactive in terms of basic // navigation. let threadID; if (!event.notification.data.isError) { - threadID = convertNonPendingIDToNewSchema( - event.notification.data.threadID, - authoritativeKeyserverID, - ); + threadID = event.notification.data.threadID; } if (selectedClient) { if (!selectedClient.focused) { await selectedClient.focus(); } if (threadID) { selectedClient.postMessage({ targetThreadID: threadID, }); } } else { const baseURL = process.env.NODE_ENV === 'production' ? 'https://web.comm.app' : 'http://localhost:3000/webapp'; const url = threadID ? baseURL + `/chat/thread/${threadID}/` : baseURL; await clients.openWindow(url); } })(), ); });