diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js index 61399c521..58a8b9f10 100644 --- a/lib/selectors/thread-selectors.js +++ b/lib/selectors/thread-selectors.js @@ -1,555 +1,559 @@ // @flow import _compact from 'lodash/fp/compact.js'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _groupBy from 'lodash/fp/groupBy.js'; import _map from 'lodash/fp/map.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _some from 'lodash/fp/some.js'; import _sortBy from 'lodash/fp/sortBy.js'; import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { filteredThreadIDsSelector, includeDeletedSelector, } from './calendar-filter-selectors.js'; import { relativeMemberInfoSelectorForMembersOfThread } from './user-selectors.js'; import genesis from '../facts/genesis.js'; -import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { getAvatarForThread, getRandomDefaultEmojiAvatar, } from '../shared/avatar-utils.js'; import { createEntryInfo } from '../shared/entry-utils.js'; import { threadInHomeChatList, threadInBackgroundChatList, threadInFilterList, threadInfoFromRawThreadInfo, threadHasAdminRole, roleIsAdminRole, threadIsPending, getPendingThreadID, } from '../shared/thread-utils.js'; import type { ClientAvatar, ClientEmojiAvatar } from '../types/avatar-types'; import type { EntryInfo } from '../types/entry-types.js'; import type { MessageStore } from '../types/message-types.js'; import type { RelativeMemberInfo, ThreadInfo, RawThreadInfo, ThickRawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { threadTypeIsCommunityRoot, type ThreadType, threadTypeIsSidebar, } from '../types/thread-types-enum.js'; import type { MixedRawThreadInfos, RawThreadInfos, ThickRawThreadInfos, } from '../types/thread-types.js'; import { dateString, dateFromString } from '../utils/date-utils.js'; import { values } from '../utils/objects.js'; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); type ThreadInfoSelectorType = (state: BaseAppState<>) => { +[id: string]: ThreadInfo, }; const threadInfoSelector: ThreadInfoSelectorType = createObjectSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, threadInfoFromRawThreadInfo, ); const communityThreadSelector: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadTypeIsCommunityRoot(threadInfo.type)) { continue; } result.push(threadInfo); } return result; }, ); const canBeOnScreenThreadInfos: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadInFilterList(threadInfo)) { continue; } result.push(threadInfo); } return result; }, ); const onScreenThreadInfos: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( filteredThreadIDsSelector, canBeOnScreenThreadInfos, ( inputThreadIDs: ?$ReadOnlySet, threadInfos: $ReadOnlyArray, ): $ReadOnlyArray => { const threadIDs = inputThreadIDs; if (!threadIDs) { return threadInfos; } return threadInfos.filter(threadInfo => threadIDs.has(threadInfo.id)); }, ); const entryInfoSelector: (state: BaseAppState<>) => { +[id: string]: EntryInfo, } = createObjectSelector( (state: BaseAppState<>) => state.entryStore.entryInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, createEntryInfo, ); // "current" means within startDate/endDate range, not deleted, and in // onScreenThreadInfos const currentDaysToEntries: (state: BaseAppState<>) => { +[dayString: string]: EntryInfo[], } = createSelector( entryInfoSelector, (state: BaseAppState<>) => state.entryStore.daysToEntries, (state: BaseAppState<>) => state.navInfo.startDate, (state: BaseAppState<>) => state.navInfo.endDate, onScreenThreadInfos, includeDeletedSelector, ( entryInfos: { +[id: string]: EntryInfo }, daysToEntries: { +[day: string]: string[] }, startDateString: string, endDateString: string, onScreen: $ReadOnlyArray, includeDeleted: boolean, ) => { const allDaysWithinRange: { [string]: string[] } = {}, startDate = dateFromString(startDateString), endDate = dateFromString(endDateString); for ( const curDate = startDate; curDate <= endDate; curDate.setDate(curDate.getDate() + 1) ) { allDaysWithinRange[dateString(curDate)] = []; } return _mapValuesWithKeys((_: string[], dayString: string) => _flow( _map((entryID: string) => entryInfos[entryID]), _compact, _filter( (entryInfo: EntryInfo) => (includeDeleted || !entryInfo.deleted) && _some(['id', entryInfo.threadID])(onScreen), ), _sortBy('creationTime'), )(daysToEntries[dayString] ? daysToEntries[dayString] : []), )(allDaysWithinRange); }, ); const childThreadInfos: (state: BaseAppState<>) => { +[id: string]: $ReadOnlyArray, } = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result: { [string]: ThreadInfo[], } = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; const parentThreadID = threadInfo.parentThreadID; if (parentThreadID === null || parentThreadID === undefined) { continue; } if (result[parentThreadID] === undefined) { result[parentThreadID] = ([]: ThreadInfo[]); } result[parentThreadID].push(threadInfo); } return result; }, ); const containedThreadInfos: (state: BaseAppState<>) => { +[id: string]: $ReadOnlyArray, } = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result: { [string]: ThreadInfo[], } = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; const { containingThreadID } = threadInfo; if (containingThreadID === null || containingThreadID === undefined) { continue; } if (result[containingThreadID] === undefined) { result[containingThreadID] = ([]: ThreadInfo[]); } result[containingThreadID].push(threadInfo); } return result; }, ); const thickRawThreadInfosSelector: ( state: BaseAppState<>, ) => ThickRawThreadInfos = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): ThickRawThreadInfos => { const thickRawThreadInfos: { [id: string]: ThickRawThreadInfo } = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; if (!threadInfo.thick) { continue; } thickRawThreadInfos[id] = threadInfo; } return thickRawThreadInfos; }, ); const unreadThickThreadIDsSelector: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): $ReadOnlyArray => Object.entries(threadInfos) .filter( ([, threadInfo]) => !!threadInfo.thick && !!threadInfo.currentUser.unread, ) .map(([id]) => id), ); const unreadCount: (state: BaseAppState<>) => number = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): number => values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); -const allUnreadCounts: (state: BaseAppState<>) => { +const thinThreadsUnreadCountSelector: (state: BaseAppState<>) => { +[keyserverID: string]: number, } = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): { +[keyserverID: string]: number } => { + const thinThreadInfosList = values(threadInfos).filter( + threadInfo => !threadInfo.thick, + ); + const keyserverToThreads = _groupBy(threadInfo => - extractKeyserverIDFromIDOptional(threadInfo.id), + extractKeyserverIDFromID(threadInfo.id), )( - values(threadInfos).filter(threadInfo => + thinThreadInfosList.filter(threadInfo => threadInHomeChatList(threadInfo), ), ); const keyserverUnreadCountPairs = Object.entries(keyserverToThreads).map( ([keyserverID, keyserverThreadInfos]) => [ keyserverID, keyserverThreadInfos.filter(threadInfo => threadInfo.currentUser.unread) .length, ], ); return Object.fromEntries(keyserverUnreadCountPairs); }, ); const unreadBackgroundCount: (state: BaseAppState<>) => number = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): number => values(threadInfos).filter( threadInfo => threadInBackgroundChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const baseUnreadCountSelectorForCommunity: ( communityID: string, ) => (BaseAppState<>) => number = (communityID: string) => createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): number => Object.values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread && (communityID === threadInfo.community || communityID === threadInfo.id), ).length, ); const unreadCountSelectorForCommunity: ( communityID: string, ) => (state: BaseAppState<>) => number = _memoize( baseUnreadCountSelectorForCommunity, ); const baseAncestorThreadInfos: ( threadID: string, ) => (BaseAppState<>) => $ReadOnlyArray = (threadID: string) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state), (threadInfos: { +[id: string]: ThreadInfo, }): $ReadOnlyArray => { const pathComponents: ThreadInfo[] = []; let node: ?ThreadInfo = threadInfos[threadID]; while (node) { pathComponents.push(node); node = node.parentThreadID ? threadInfos[node.parentThreadID] : null; } pathComponents.reverse(); return pathComponents; }, ); const ancestorThreadInfos: ( threadID: string, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseAncestorThreadInfos, ); const baseOtherUsersButNoOtherAdmins: ( threadID: string, ) => (BaseAppState<>) => boolean = (threadID: string) => createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos[threadID], relativeMemberInfoSelectorForMembersOfThread(threadID), ( threadInfo: ?RawThreadInfo, members: $ReadOnlyArray, ): boolean => { if (!threadInfo) { return false; } if (!threadHasAdminRole(threadInfo)) { return false; } let otherUsersExist = false; let otherAdminsExist = false; for (const member of members) { const role = member.role; if (role === undefined || role === null || member.isViewer) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo?.roles[role])) { otherAdminsExist = true; break; } } return otherUsersExist && !otherAdminsExist; }, ); const otherUsersButNoOtherAdmins: ( threadID: string, ) => (state: BaseAppState<>) => boolean = _memoize( baseOtherUsersButNoOtherAdmins, ); function mostRecentlyReadThread( messageStore: MessageStore, threadInfos: MixedRawThreadInfos, ): ?string { let mostRecent = null; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.currentUser.unread) { continue; } const threadMessageInfo = messageStore.threads[threadID]; if (!threadMessageInfo) { continue; } const mostRecentMessageTime = threadMessageInfo.messageIDs.length === 0 ? threadInfo.creationTime : messageStore.messages[threadMessageInfo.messageIDs[0]].time; if (mostRecent && mostRecent.time >= mostRecentMessageTime) { continue; } const topLevelThreadID = threadTypeIsSidebar(threadInfo.type) ? threadInfo.parentThreadID : threadID; mostRecent = { threadID: topLevelThreadID, time: mostRecentMessageTime }; } return mostRecent ? mostRecent.threadID : null; } const mostRecentlyReadThreadSelector: (state: BaseAppState<>) => ?string = createSelector( (state: BaseAppState<>) => state.messageStore, (state: BaseAppState<>) => state.threadStore.threadInfos, mostRecentlyReadThread, ); const threadInfoFromSourceMessageIDSelector: (state: BaseAppState<>) => { +[id: string]: ThreadInfo, } = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, threadInfoSelector, ( rawThreadInfos: RawThreadInfos, threadInfos: { +[id: string]: ThreadInfo, }, ) => { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector(rawThreadInfos); const result: { [string]: ThreadInfo } = {}; for (const realizedID of pendingToRealizedThreadIDs.values()) { const threadInfo = threadInfos[realizedID]; if (threadInfo && threadInfo.sourceMessageID) { result[threadInfo.sourceMessageID] = threadInfo; } } return result; }, ); const pendingToRealizedThreadIDsSelector: ( rawThreadInfos: RawThreadInfos, ) => $ReadOnlyMap = createSelector( (rawThreadInfos: RawThreadInfos) => rawThreadInfos, (rawThreadInfos: RawThreadInfos) => { const result = new Map(); for (const threadID in rawThreadInfos) { const rawThreadInfo = rawThreadInfos[threadID]; if ( threadIsPending(threadID) || (rawThreadInfo.parentThreadID !== genesis().id && !threadTypeIsSidebar(rawThreadInfo.type)) ) { continue; } const actualMemberIDs = rawThreadInfo.members .filter(member => member.role) .map(member => member.id); const pendingThreadID = getPendingThreadID( rawThreadInfo.type, actualMemberIDs, rawThreadInfo.sourceMessageID, ); const existingResult = result.get(pendingThreadID); if ( !existingResult || rawThreadInfos[existingResult].creationTime > rawThreadInfo.creationTime ) { result.set(pendingThreadID, threadID); } } return result; }, ); const baseSavedEmojiAvatarSelectorForThread: ( threadID: string, containingThreadID: ?string, ) => (BaseAppState<>) => () => ClientAvatar = ( threadID: string, containingThreadID: ?string, ) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state)[threadID], (state: BaseAppState<>) => containingThreadID ? threadInfoSelector(state)[containingThreadID] : null, (threadInfo: ThreadInfo, containingThreadInfo: ?ThreadInfo) => { return () => { let threadAvatar = getAvatarForThread(threadInfo, containingThreadInfo); if (threadAvatar.type !== 'emoji') { threadAvatar = getRandomDefaultEmojiAvatar(); } return threadAvatar; }; }, ); const savedEmojiAvatarSelectorForThread: ( threadID: string, containingThreadID: ?string, ) => (state: BaseAppState<>) => () => ClientEmojiAvatar = _memoize( baseSavedEmojiAvatarSelectorForThread, ); const baseThreadInfosSelectorForThreadType: ( threadType: ThreadType, ) => (BaseAppState<>) => $ReadOnlyArray = ( threadType: ThreadType, ) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state), (threadInfos: { +[id: string]: ThreadInfo, }): $ReadOnlyArray => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.type === threadType) { result.push(threadInfo); } } return result; }, ); const threadInfosSelectorForThreadType: ( threadType: ThreadType, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseThreadInfosSelectorForThreadType, ); export { ancestorThreadInfos, threadInfoSelector, communityThreadSelector, onScreenThreadInfos, entryInfoSelector, currentDaysToEntries, childThreadInfos, containedThreadInfos, unreadCount, - allUnreadCounts, + thinThreadsUnreadCountSelector, unreadBackgroundCount, unreadCountSelectorForCommunity, otherUsersButNoOtherAdmins, mostRecentlyReadThread, mostRecentlyReadThreadSelector, threadInfoFromSourceMessageIDSelector, pendingToRealizedThreadIDsSelector, savedEmojiAvatarSelectorForThread, threadInfosSelectorForThreadType, thickRawThreadInfosSelector, unreadThickThreadIDsSelector, }; diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index 565c8f75a..8fa34847a 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,876 +1,878 @@ // @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 { extractKeyserverIDFromID, extractKeyserverIDFromIDOptional, } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { deviceTokensSelector, allUpdatesCurrentAsOfSelector, allConnectionInfosSelector, } from 'lib/selectors/keyserver-selectors.js'; import { threadInfoSelector, - allUnreadCounts, + 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 - +unreadCount: { +[keyserverID: string]: number }, + +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, }, }; 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 curUnreadCounts = this.props.unreadCount; + 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 curUnreadCounts) { + for (const keyserverID in curThinUnreadCounts) { if (curConnections[keyserverID]?.status !== 'connected') { notifsStorageQueries.push(keyserverID); continue; } notifStorageUpdates.push({ id: keyserverID, - unreadCount: curUnreadCounts[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.unreadCount); + 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]; if (!updatesCurrentAsOf) { continue; } 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) .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); }; androidMessageReceived = async (message: AndroidMessage) => { const parsedMessage = parseAndroidMessage(message); this.onPushNotifBootsApp(); const { messageInfos } = parsedMessage; this.saveMessageInfos(messageInfos); const keyserverID = extractKeyserverIDFromID(message.threadID); const updateCurrentAsOf = this.props.allUpdatesCurrentAsOf[keyserverID]; handleAndroidMessage( parsedMessage, updateCurrentAsOf, 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 unreadCount = useSelector(allUnreadCounts); + 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/app.react.js b/web/app.react.js index d2f56bfe3..251567c48 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,599 +1,598 @@ // @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 BadgeHandler 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; diff --git a/web/push-notif/badge-handler.react.js b/web/push-notif/badge-handler.react.js index df5ca6575..44b2e4c9f 100644 --- a/web/push-notif/badge-handler.react.js +++ b/web/push-notif/badge-handler.react.js @@ -1,60 +1,95 @@ // @flow import * as React from 'react'; import { allConnectionInfosSelector } from 'lib/selectors/keyserver-selectors.js'; -import { allUnreadCounts } from 'lib/selectors/thread-selectors.js'; +import { + thinThreadsUnreadCountSelector, + unreadThickThreadIDsSelector, +} from 'lib/selectors/thread-selectors.js'; +import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; import { updateNotifsUnreadCountStorage, queryNotifsUnreadCountStorage, + getNotifsUnreadThickThreadIDs, + updateNotifsUnreadThickThreadIDsStorage, } from './notif-crypto-utils.js'; import electron from '../electron.js'; import { useSelector } from '../redux/redux-utils.js'; import getTitle from '../title/get-title.js'; function useBadgeHandler() { const connection = useSelector(allConnectionInfosSelector); - const unreadCount = useSelector(allUnreadCounts); + const thinThreadsUnreadCount = useSelector(thinThreadsUnreadCountSelector); + + const { socketState: tunnelbrokerSocketState } = useTunnelbroker(); + const currentUnreadThickThreadIDs = useSelector(unreadThickThreadIDsSelector); React.useEffect(() => { void (async () => { const unreadCountUpdates: { [keyserverID: string]: number, } = {}; const unreadCountQueries: Array = []; - for (const keyserverID in unreadCount) { + for (const keyserverID in thinThreadsUnreadCount) { if (connection[keyserverID]?.status !== 'connected') { unreadCountQueries.push(keyserverID); continue; } - unreadCountUpdates[keyserverID] = unreadCount[keyserverID]; + unreadCountUpdates[keyserverID] = thinThreadsUnreadCount[keyserverID]; } let queriedUnreadCounts: { +[keyserverID: string]: ?number } = {}; - [queriedUnreadCounts] = await Promise.all([ + let unreadThickThreadIDs: $ReadOnlyArray = []; + + const handleUnreadThickThreadIDsInNotifsStoragePromise = (async () => { + if (tunnelbrokerSocketState.connected) { + await updateNotifsUnreadThickThreadIDsStorage({ + type: 'set', + threadIDs: currentUnreadThickThreadIDs, + forceWrite: true, + }); + return currentUnreadThickThreadIDs; + } + return getNotifsUnreadThickThreadIDs(); + })(); + + [queriedUnreadCounts, unreadThickThreadIDs] = await Promise.all([ queryNotifsUnreadCountStorage(unreadCountQueries), + handleUnreadThickThreadIDsInNotifsStoragePromise, updateNotifsUnreadCountStorage(unreadCountUpdates), ]); let totalUnreadCount = 0; for (const keyserverID in unreadCountUpdates) { totalUnreadCount += unreadCountUpdates[keyserverID]; } for (const keyserverID in queriedUnreadCounts) { if (!queriedUnreadCounts[keyserverID]) { - totalUnreadCount += unreadCount[keyserverID]; + totalUnreadCount += thinThreadsUnreadCount[keyserverID]; continue; } totalUnreadCount += queriedUnreadCounts[keyserverID]; } + totalUnreadCount += unreadThickThreadIDs.length; document.title = getTitle(totalUnreadCount); electron?.setBadge(totalUnreadCount === 0 ? null : totalUnreadCount); })(); - }, [unreadCount, connection]); + }, [ + tunnelbrokerSocketState, + currentUnreadThickThreadIDs, + thinThreadsUnreadCount, + connection, + ]); +} + +function BadgeHandler(): React.Node { + useBadgeHandler(); + return null; } -export default useBadgeHandler; +export default BadgeHandler; diff --git a/web/push-notif/notif-crypto-utils.js b/web/push-notif/notif-crypto-utils.js index 87237cc27..32a3914e4 100644 --- a/web/push-notif/notif-crypto-utils.js +++ b/web/push-notif/notif-crypto-utils.js @@ -1,1271 +1,1410 @@ // @flow import olm from '@commapp/olm'; import type { EncryptResult } from '@commapp/olm'; import invariant from 'invariant'; import localforage from 'localforage'; import uuid from 'uuid'; import { olmEncryptedMessageTypes, type NotificationsOlmDataType, type PickledOLMAccount, } from 'lib/types/crypto-types.js'; import type { PlainTextWebNotification, EncryptedWebNotification, SenderDeviceDescriptor, } from 'lib/types/notif-types.js'; import { getCookieIDFromCookie } from 'lib/utils/cookie-utils.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { assertWithValidator } from 'lib/utils/validation-utils.js'; import { fetchAuthMetadata, getNotifsInboundKeysForDeviceID, } from './services-client.js'; import { type EncryptedData, decryptData, encryptData, importJWKKey, exportKeyToJWK, generateCryptoKey, encryptedAESDataValidator, extendedCryptoKeyValidator, } from '../crypto/aes-gcm-crypto-utils.js'; import { initOlm } from '../olm/olm-utils.js'; import { NOTIFICATIONS_OLM_DATA_CONTENT, NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, } from '../shared-worker/utils/constants.js'; import { isDesktopSafari } from '../shared-worker/utils/db-utils.js'; export type WebNotifDecryptionError = { +id: string, +error: string, +displayErrorMessage?: boolean, }; export type WebNotifsServiceUtilsData = { +olmWasmPath: string, +staffCanSee: boolean, }; export type NotificationAccountWithPicklingKey = { +notificationAccount: olm.Account, +picklingKey: string, +synchronizationValue: ?string, +accountEncryptionKey?: CryptoKey, }; type DecryptionResult = { +newPendingSessionUpdate: string, +newUpdateCreationTimestamp: number, +decryptedNotification: T, }; export const WEB_NOTIFS_SERVICE_UTILS_KEY = 'webNotifsServiceUtils'; const SESSION_UPDATE_MAX_PENDING_TIME = 10 * 1000; const INDEXED_DB_KEYSERVER_PREFIX = 'keyserver'; const INDEXED_DB_KEY_SEPARATOR = ':'; const INDEXED_DB_DEVICE_PREFIX = 'device'; const INDEXED_DB_NOTIFS_SYNC_KEY = 'notifsSyncKey'; // This constant is only used to migrate the existing notifications // session with production keyserver to new IndexedDB key format. This // migration will fire when user updates the app. It will also fire // on dev env provided old keyserver set up is used. Developers willing // to use new keyserver set up must log out before updating the app. // Do not introduce new usages of this constant in the code!!! const ASHOAT_KEYSERVER_ID_USED_ONLY_FOR_MIGRATION_FROM_LEGACY_NOTIF_STORAGE = '256'; const INDEXED_DB_UNREAD_COUNT_SUFFIX = 'unreadCount'; const INDEXED_DB_NOTIFS_ACCOUNT_KEY = 'notificationAccount'; const INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL = 'notificationAccountEncryptionKey'; +// thick threads unread count +const INDEXED_DB_UNREAD_THICK_THREAD_IDS = 'unreadThickThreadIDs'; +const INDEXED_DB_UNREAD_THICK_THREAD_IDS_ENCRYPTION_KEY_DB_LABEL = + 'unreadThickThreadIDsEncryptionKey'; +const INDEXED_DB_UNREAD_THICK_THREADS_SYNC_KEY = 'unreadThickThreadIDsSyncKey'; + async function deserializeEncryptedData( encryptedData: EncryptedData, encryptionKey: CryptoKey, ): Promise { const serializedData = await decryptData(encryptedData, encryptionKey); const data: T = JSON.parse(new TextDecoder().decode(serializedData)); return data; } async function deserializeEncryptedDataOptional( encryptedData: ?EncryptedData, encryptionKey: ?CryptoKey, ): Promise { if (!encryptedData || !encryptionKey) { return undefined; } return deserializeEncryptedData(encryptedData, encryptionKey); } async function serializeUnencryptedData( data: T, encryptionKey: CryptoKey, ): Promise { const dataAsString = JSON.stringify(data); invariant( dataAsString, 'Attempt to serialize null or undefined is forbidden', ); return await encryptData( new TextEncoder().encode(dataAsString), encryptionKey, ); } async function validateCryptoKey( cryptoKey: CryptoKey | SubtleCrypto$JsonWebKey, ): Promise { if (!isDesktopSafari) { return ((cryptoKey: any): CryptoKey); } return await importJWKKey(((cryptoKey: any): SubtleCrypto$JsonWebKey)); } async function validateCryptoKeyOptional( cryptoKey: ?CryptoKey | ?SubtleCrypto$JsonWebKey, ): Promise { if (!cryptoKey) { return undefined; } return validateCryptoKey(cryptoKey); } async function getCryptoKeyPersistentForm( cryptoKey: CryptoKey, ): Promise { if (!isDesktopSafari) { return cryptoKey; } // Safari doesn't support structured clone algorithm in service // worker context so we have to store CryptoKey as JSON return await exportKeyToJWK(cryptoKey); } async function getNotifsAccountWithOlmData( senderDeviceDescriptor: SenderDeviceDescriptor, ): Promise<{ +encryptedOlmData: ?EncryptedData, +encryptionKey: ?CryptoKey, +olmDataKey: string, +encryptionKeyDBLabel: string, +encryptedOlmAccount: ?EncryptedData, +accountEncryptionKey: ?CryptoKey, +synchronizationValue: ?string, }> { let olmDataKey; let olmDataEncryptionKeyDBLabel; const { keyserverID, senderDeviceID } = senderDeviceDescriptor; if (keyserverID) { const olmDBKeys = await getNotifsOlmSessionDBKeys(keyserverID); const { olmDataKey: fetchedOlmDataKey, encryptionKeyDBKey } = olmDBKeys; olmDataKey = fetchedOlmDataKey; olmDataEncryptionKeyDBLabel = encryptionKeyDBKey; } else { invariant( senderDeviceID, 'keyserverID or SenderDeviceID must be present to decrypt a notif', ); olmDataKey = getOlmDataKeyForDeviceID(senderDeviceID); olmDataEncryptionKeyDBLabel = getOlmEncryptionKeyDBLabelForDeviceID(senderDeviceID); } const queryResult = await localforage.getMultipleItems<{ notificationAccount: ?EncryptedData, notificationAccountEncryptionKey: ?CryptoKey, synchronizationValue: ?number, [string]: ?EncryptedData | ?CryptoKey | ?SubtleCrypto$JsonWebKey, }>( [ INDEXED_DB_NOTIFS_ACCOUNT_KEY, INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL, olmDataEncryptionKeyDBLabel, olmDataKey, ], INDEXED_DB_NOTIFS_SYNC_KEY, ); const { values: { notificationAccount, notificationAccountEncryptionKey, [olmDataKey]: maybeEncryptedOlmData, [olmDataEncryptionKeyDBLabel]: maybeOlmDataEncryptionKey, }, synchronizationValue, } = queryResult; const encryptedOlmData: ?EncryptedData = maybeEncryptedOlmData ? assertWithValidator(maybeEncryptedOlmData, encryptedAESDataValidator) : undefined; const olmDataEncryptionKey: ?CryptoKey | ?SubtleCrypto$JsonWebKey = maybeOlmDataEncryptionKey ? assertWithValidator( maybeOlmDataEncryptionKey, extendedCryptoKeyValidator, ) : undefined; const [encryptionKey, accountEncryptionKey] = await Promise.all([ validateCryptoKeyOptional(olmDataEncryptionKey), validateCryptoKeyOptional(notificationAccountEncryptionKey), ]); return { encryptedOlmData, encryptionKey, encryptionKeyDBLabel: olmDataEncryptionKeyDBLabel, encryptedOlmAccount: notificationAccount, olmDataKey, accountEncryptionKey, synchronizationValue, }; } async function persistNotifsAccountWithOlmData(input: { +olmDataKey?: string, +olmEncryptionKeyDBLabel?: string, +olmData?: ?NotificationsOlmDataType, +encryptionKey?: ?CryptoKey, +accountEncryptionKey?: ?CryptoKey, +accountWithPicklingKey?: PickledOLMAccount, +synchronizationValue: ?string, +forceWrite: boolean, }): Promise { const { olmData, olmDataKey, accountEncryptionKey, accountWithPicklingKey, encryptionKey, synchronizationValue, olmEncryptionKeyDBLabel, forceWrite, } = input; const shouldPersistOlmData = olmDataKey && olmData && (encryptionKey || olmEncryptionKeyDBLabel); const shouldPersistAccount = !!accountWithPicklingKey; if (!shouldPersistOlmData && !shouldPersistAccount) { return; } const serializationPromises: { [string]: Promise, } = {}; if (olmDataKey && olmData && encryptionKey) { serializationPromises[olmDataKey] = serializeUnencryptedData( olmData, encryptionKey, ); } else if (olmData && olmDataKey && olmEncryptionKeyDBLabel) { const newEncryptionKey = await generateCryptoKey({ extractable: isDesktopSafari, }); serializationPromises[olmDataKey] = serializeUnencryptedData( olmData, newEncryptionKey, ); serializationPromises[olmEncryptionKeyDBLabel] = getCryptoKeyPersistentForm(newEncryptionKey); } if (accountWithPicklingKey && accountEncryptionKey) { serializationPromises[INDEXED_DB_NOTIFS_ACCOUNT_KEY] = serializeUnencryptedData( accountWithPicklingKey, accountEncryptionKey, ); } else if (accountWithPicklingKey) { const newEncryptionKey = await generateCryptoKey({ extractable: isDesktopSafari, }); serializationPromises[INDEXED_DB_NOTIFS_ACCOUNT_KEY] = serializeUnencryptedData( accountWithPicklingKey, newEncryptionKey, ); serializationPromises[INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL] = getCryptoKeyPersistentForm(newEncryptionKey); } const setMultipleItemsInput = await promiseAll(serializationPromises); const newSynchronizationValue = uuid.v4(); try { await localforage.setMultipleItems( setMultipleItemsInput, INDEXED_DB_NOTIFS_SYNC_KEY, synchronizationValue, newSynchronizationValue, forceWrite, ); } catch (e) { if ( !e.message?.includes( localforage.getSetMultipleItemsRaceConditionErrorMessage(), ) ) { throw e; } // likely shared worker persisted its own data console.log(e); } } async function decryptWebNotification( encryptedNotification: EncryptedWebNotification, ): Promise { const { id, encryptedPayload, type: messageType, ...rest } = encryptedNotification; const senderDeviceDescriptor: SenderDeviceDescriptor = rest; const utilsData = await localforage.getItem( WEB_NOTIFS_SERVICE_UTILS_KEY, ); if (!utilsData) { return { id, error: 'Necessary data not found in IndexedDB' }; } const { olmWasmPath, staffCanSee } = (utilsData: WebNotifsServiceUtilsData); let notifsAccountWithOlmData; try { notifsAccountWithOlmData = await getNotifsAccountWithOlmData( senderDeviceDescriptor, ); } catch (e) { return { id, error: e.message, displayErrorMessage: staffCanSee, }; } const { encryptionKey, encryptedOlmData, olmDataKey, encryptionKeyDBLabel: olmEncryptionKeyDBLabel, accountEncryptionKey, encryptedOlmAccount, synchronizationValue, } = notifsAccountWithOlmData; try { const [notificationsOlmData, accountWithPicklingKey] = await Promise.all([ deserializeEncryptedDataOptional( encryptedOlmData, encryptionKey, ), deserializeEncryptedDataOptional( encryptedOlmAccount, accountEncryptionKey, ), olm.init({ locateFile: () => olmWasmPath }), ]); let decryptedNotification; let updatedOlmData; let updatedNotifsAccount; const { senderDeviceID, keyserverID } = senderDeviceDescriptor; if (keyserverID) { invariant( notificationsOlmData && encryptionKey, 'Received encrypted notification but keyserver olm session was not created', ); const { decryptedNotification: resultDecryptedNotification, updatedOlmData: resultUpdatedOlmData, } = await commonDecrypt( notificationsOlmData, encryptedPayload, ); decryptedNotification = resultDecryptedNotification; updatedOlmData = resultUpdatedOlmData; const { unreadCount } = decryptedNotification; invariant(keyserverID, 'Keyserver ID must be set to update badge counts'); await Promise.all([ persistNotifsAccountWithOlmData({ olmDataKey, olmData: updatedOlmData, olmEncryptionKeyDBLabel, encryptionKey, forceWrite: false, synchronizationValue, }), updateNotifsUnreadCountStorage({ [keyserverID]: unreadCount, }), ]); return { id, ...decryptedNotification }; } else { invariant( senderDeviceID, 'keyserverID or SenderDeviceID must be present to decrypt a notif', ); invariant( accountWithPicklingKey, 'Received encrypted notification but notifs olm account not created', ); const { decryptedNotification: resultDecryptedNotification, updatedOlmData: resultUpdatedOlmData, updatedNotifsAccount: resultUpdatedNotifsAccount, } = await commonPeerDecrypt( senderDeviceID, notificationsOlmData, accountWithPicklingKey, messageType, encryptedPayload, ); decryptedNotification = resultDecryptedNotification; updatedOlmData = resultUpdatedOlmData; updatedNotifsAccount = resultUpdatedNotifsAccount; - await persistNotifsAccountWithOlmData({ - accountWithPicklingKey: updatedNotifsAccount, - accountEncryptionKey, - encryptionKey, - olmData: updatedOlmData, - olmDataKey, - olmEncryptionKeyDBLabel, - synchronizationValue, - forceWrite: false, - }); + const { threadID } = decryptedNotification; + + await Promise.all([ + persistNotifsAccountWithOlmData({ + accountWithPicklingKey: updatedNotifsAccount, + accountEncryptionKey, + encryptionKey, + olmData: updatedOlmData, + olmDataKey, + olmEncryptionKeyDBLabel, + synchronizationValue, + forceWrite: false, + }), + updateNotifsUnreadThickThreadIDsStorage({ + type: 'add', + threadIDs: [threadID], + forceWrite: false, + }), + ]); return { id, ...decryptedNotification }; } } catch (e) { return { id, error: e.message, displayErrorMessage: staffCanSee, }; } } async function decryptDesktopNotification( encryptedPayload: string, messageType: string, staffCanSee: boolean, senderDeviceDescriptor: SenderDeviceDescriptor, ): Promise<{ +[string]: mixed }> { const { keyserverID, senderDeviceID } = senderDeviceDescriptor; let notifsAccountWithOlmData; try { [notifsAccountWithOlmData] = await Promise.all([ getNotifsAccountWithOlmData(senderDeviceDescriptor), initOlm(), ]); } catch (e) { return { error: e.message, displayErrorMessage: staffCanSee, }; } const { encryptionKey, encryptedOlmData, olmDataKey, encryptionKeyDBLabel: olmEncryptionKeyDBLabel, accountEncryptionKey, encryptedOlmAccount, synchronizationValue, } = notifsAccountWithOlmData; try { const [notificationsOlmData, accountWithPicklingKey] = await Promise.all([ deserializeEncryptedDataOptional( encryptedOlmData, encryptionKey, ), deserializeEncryptedDataOptional( encryptedOlmAccount, accountEncryptionKey, ), ]); if (keyserverID) { invariant( notificationsOlmData && encryptionKey, 'Received encrypted notification but keyserver olm session was not created', ); const { decryptedNotification, updatedOlmData } = await commonDecrypt<{ +[string]: mixed, }>(notificationsOlmData, encryptedPayload); const updatedOlmDataPersistencePromise = persistNotifsAccountWithOlmData({ olmDataKey, olmData: updatedOlmData, olmEncryptionKeyDBLabel, encryptionKey, forceWrite: false, synchronizationValue, }); // iOS notifications require that unread count is set under // `badge` key. Since MacOS notifications are created by the // same function the unread count is also set under `badge` key const { badge } = decryptedNotification; if (typeof badge === 'number') { await Promise.all([ updateNotifsUnreadCountStorage({ [(keyserverID: string)]: badge }), updatedOlmDataPersistencePromise, ]); return decryptedNotification; } const { unreadCount } = decryptedNotification; if (typeof unreadCount === 'number') { await Promise.all([ updateNotifsUnreadCountStorage({ [(keyserverID: string)]: unreadCount, }), updatedOlmDataPersistencePromise, ]); } return decryptedNotification; } else { invariant( senderDeviceID, 'keyserverID or SenderDeviceID must be present to decrypt a notif', ); invariant( accountWithPicklingKey, 'Received encrypted notification but notifs olm account not created', ); const { decryptedNotification, updatedOlmData, updatedNotifsAccount } = await commonPeerDecrypt<{ +[string]: mixed, }>( senderDeviceID, notificationsOlmData, accountWithPicklingKey, messageType, encryptedPayload, ); - await persistNotifsAccountWithOlmData({ - accountWithPicklingKey: updatedNotifsAccount, - accountEncryptionKey, - encryptionKey, - olmData: updatedOlmData, - olmDataKey, - olmEncryptionKeyDBLabel, - synchronizationValue, - forceWrite: false, - }); + const { threadID } = decryptedNotification; + invariant(typeof threadID === 'string', 'threadID should be string'); + + await Promise.all([ + persistNotifsAccountWithOlmData({ + accountWithPicklingKey: updatedNotifsAccount, + accountEncryptionKey, + encryptionKey, + olmData: updatedOlmData, + olmDataKey, + olmEncryptionKeyDBLabel, + synchronizationValue, + forceWrite: false, + }), + updateNotifsUnreadThickThreadIDsStorage({ + type: 'add', + threadIDs: [threadID], + forceWrite: false, + }), + ]); return decryptedNotification; } } catch (e) { return { error: e.message, staffCanSee, }; } } async function commonDecrypt( notificationsOlmData: NotificationsOlmDataType, encryptedPayload: string, ): Promise<{ +decryptedNotification: T, +updatedOlmData: NotificationsOlmDataType, }> { const { mainSession, picklingKey, pendingSessionUpdate, updateCreationTimestamp, } = notificationsOlmData; let updatedOlmData: NotificationsOlmDataType; let decryptedNotification: T; const shouldUpdateMainSession = Date.now() - updateCreationTimestamp > SESSION_UPDATE_MAX_PENDING_TIME; const decryptionWithPendingSessionResult = decryptWithPendingSession( pendingSessionUpdate, picklingKey, encryptedPayload, ); if (decryptionWithPendingSessionResult.decryptedNotification) { const { decryptedNotification: notifDecryptedWithPendingSession, newPendingSessionUpdate, newUpdateCreationTimestamp, } = decryptionWithPendingSessionResult; decryptedNotification = notifDecryptedWithPendingSession; updatedOlmData = { mainSession: shouldUpdateMainSession ? pendingSessionUpdate : mainSession, pendingSessionUpdate: newPendingSessionUpdate, updateCreationTimestamp: newUpdateCreationTimestamp, picklingKey, }; } else { const { newUpdateCreationTimestamp, decryptedNotification: notifDecryptedWithMainSession, } = decryptWithSession(mainSession, picklingKey, encryptedPayload); decryptedNotification = notifDecryptedWithMainSession; updatedOlmData = { mainSession: mainSession, pendingSessionUpdate, updateCreationTimestamp: newUpdateCreationTimestamp, picklingKey, }; } return { decryptedNotification, updatedOlmData }; } async function commonPeerDecrypt( senderDeviceID: string, notificationsOlmData: ?NotificationsOlmDataType, notificationAccount: PickledOLMAccount, messageType: string, encryptedPayload: string, ): Promise<{ +decryptedNotification: T, +updatedOlmData?: NotificationsOlmDataType, +updatedNotifsAccount?: PickledOLMAccount, }> { if ( messageType !== olmEncryptedMessageTypes.PREKEY.toString() && messageType !== olmEncryptedMessageTypes.TEXT.toString() ) { throw new Error( `Received message of invalid type from device: ${senderDeviceID}`, ); } let isSenderChainEmpty = true; let hasReceivedMessage = false; const sessionExists = !!notificationsOlmData; if (notificationsOlmData) { const session = new olm.Session(); session.unpickle( notificationsOlmData.picklingKey, notificationsOlmData.pendingSessionUpdate, ); isSenderChainEmpty = session.is_sender_chain_empty(); hasReceivedMessage = session.has_received_message(); } // regular message const isRegularMessage = !!notificationsOlmData && messageType === olmEncryptedMessageTypes.TEXT.toString(); const isRegularPrekeyMessage = !!notificationsOlmData && messageType === olmEncryptedMessageTypes.PREKEY.toString() && isSenderChainEmpty && hasReceivedMessage; if (!!notificationsOlmData && (isRegularMessage || isRegularPrekeyMessage)) { return await commonDecrypt(notificationsOlmData, encryptedPayload); } // At this point we either face race condition or session reset attempt or // session initialization attempt. For each of this scenario new inbound // session must be created in order to decrypt message const authMetadata = await fetchAuthMetadata(); const notifInboundKeys = await getNotifsInboundKeysForDeviceID( senderDeviceID, authMetadata, ); const account = new olm.Account(); const session = new olm.Session(); account.unpickle( notificationAccount.picklingKey, notificationAccount.pickledAccount, ); if (notifInboundKeys.error) { throw new Error(notifInboundKeys.error); } invariant( notifInboundKeys.curve25519, 'curve25519 must be present in notifs inbound keys', ); session.create_inbound_from( account, notifInboundKeys.curve25519, encryptedPayload, ); const decryptedNotification: T = JSON.parse( session.decrypt(Number(messageType), encryptedPayload), ); // session reset attempt or session initialization - handled the same const sessionResetAttempt = sessionExists && !isSenderChainEmpty && hasReceivedMessage; // race condition const raceCondition = sessionExists && !isSenderChainEmpty && !hasReceivedMessage; const { deviceID: ourDeviceID } = authMetadata; invariant(ourDeviceID, 'Session creation attempt but no device id'); const thisDeviceWinsRaceCondition = ourDeviceID > senderDeviceID; if ( !sessionExists || sessionResetAttempt || (raceCondition && !thisDeviceWinsRaceCondition) ) { const pickledOlmSession = session.pickle(notificationAccount.picklingKey); const updatedOlmData = { mainSession: pickledOlmSession, pendingSessionUpdate: pickledOlmSession, updateCreationTimestamp: Date.now(), picklingKey: notificationAccount.picklingKey, }; const updatedNotifsAccount = { pickledAccount: account.pickle(notificationAccount.picklingKey), picklingKey: notificationAccount.picklingKey, }; return { decryptedNotification, updatedOlmData, updatedNotifsAccount, }; } // If there is a race condition but we win device id comparison // we return object that carries decrypted data but won't persist // any session state return { decryptedNotification }; } function decryptWithSession( pickledSession: string, picklingKey: string, encryptedPayload: string, ): DecryptionResult { const session = new olm.Session(); session.unpickle(picklingKey, pickledSession); const decryptedNotification: T = JSON.parse( session.decrypt(olmEncryptedMessageTypes.TEXT, encryptedPayload), ); const newPendingSessionUpdate = session.pickle(picklingKey); const newUpdateCreationTimestamp = Date.now(); return { decryptedNotification, newUpdateCreationTimestamp, newPendingSessionUpdate, }; } function decryptWithPendingSession( pendingSessionUpdate: string, picklingKey: string, encryptedPayload: string, ): DecryptionResult | { +error: string } { try { const { decryptedNotification, newPendingSessionUpdate, newUpdateCreationTimestamp, } = decryptWithSession( pendingSessionUpdate, picklingKey, encryptedPayload, ); return { newPendingSessionUpdate, newUpdateCreationTimestamp, decryptedNotification, }; } catch (e) { return { error: e.message }; } } async function encryptNotification( payload: string, deviceID: string, ): Promise { const olmDataKey = getOlmDataKeyForDeviceID(deviceID); const olmEncryptionKeyDBLabel = getOlmEncryptionKeyDBLabelForDeviceID(deviceID); let encryptedOlmData, encryptionKey, synchronizationValue; try { const { values: { [olmDataKey]: fetchedEncryptedOlmData, [olmEncryptionKeyDBLabel]: fetchedEncryptionKey, }, synchronizationValue: fetchedSynchronizationValue, } = await localforage.getMultipleItems<{ +[string]: ?EncryptedData | ?CryptoKey | ?SubtleCrypto$JsonWebKey, }>([olmDataKey, olmEncryptionKeyDBLabel], INDEXED_DB_NOTIFS_SYNC_KEY); encryptedOlmData = fetchedEncryptedOlmData; encryptionKey = fetchedEncryptionKey; synchronizationValue = fetchedSynchronizationValue; } catch (e) { throw new Error( `Failed to fetch olm session from IndexedDB for device: ${deviceID}. Details: ${ getMessageForException(e) ?? '' }`, ); } if (!encryptionKey || !encryptedOlmData) { throw new Error(`Session with device: ${deviceID} not initialized.`); } const validatedEncryptedOlmData = assertWithValidator( encryptedOlmData, encryptedAESDataValidator, ); const validatedEncryptionKey = await validateCryptoKey( assertWithValidator(encryptionKey, extendedCryptoKeyValidator), ); let encryptedNotification; try { encryptedNotification = await encryptNotificationWithOlmSession( payload, validatedEncryptedOlmData, olmDataKey, validatedEncryptionKey, synchronizationValue, ); } catch (e) { throw new Error( `Failed encrypt notification for device: ${deviceID}. Details: ${ getMessageForException(e) ?? '' }`, ); } return encryptedNotification; } async function encryptNotificationWithOlmSession( payload: string, encryptedOlmData: EncryptedData, olmDataKey: string, encryptionKey: CryptoKey, synchronizationValue: ?string, ): Promise { const serializedOlmData = await decryptData(encryptedOlmData, encryptionKey); const { mainSession, picklingKey, pendingSessionUpdate, updateCreationTimestamp, }: NotificationsOlmDataType = JSON.parse( new TextDecoder().decode(serializedOlmData), ); const session = new olm.Session(); session.unpickle(picklingKey, pendingSessionUpdate); const encryptedNotification = session.encrypt(payload); const newPendingSessionUpdate = session.pickle(picklingKey); const updatedOlmData: NotificationsOlmDataType = { mainSession, pendingSessionUpdate: newPendingSessionUpdate, picklingKey, updateCreationTimestamp, }; const updatedEncryptedSession = await encryptData( new TextEncoder().encode(JSON.stringify(updatedOlmData)), encryptionKey, ); const newSynchronizationValue = uuid.v4(); await localforage.setMultipleItems( { [olmDataKey]: updatedEncryptedSession }, INDEXED_DB_NOTIFS_SYNC_KEY, synchronizationValue, newSynchronizationValue, // This method (encryptNotification) is expected to be called // exclusively from the shared worker which must always win race // condition against push notifications service-worker. true, ); return encryptedNotification; } // notifications account manipulation async function getNotifsCryptoAccount(): Promise { const { values: { [INDEXED_DB_NOTIFS_ACCOUNT_KEY]: encryptedNotifsAccount, [INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL]: notifsAccountEncryptionKey, }, synchronizationValue, } = await localforage.getMultipleItems<{ +notificationAccount: ?EncryptedData, +notificationAccountEncryptionKey: ?CryptoKey | ?SubtleCrypto$JsonWebKey, }>( [ INDEXED_DB_NOTIFS_ACCOUNT_KEY, INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL, ], INDEXED_DB_NOTIFS_SYNC_KEY, ); if (!encryptedNotifsAccount || !notifsAccountEncryptionKey) { throw new Error( 'Attempt to retrieve notifs olm account but account not created.', ); } const validatedNotifsAccountEncryptionKey = await validateCryptoKey( notifsAccountEncryptionKey, ); const pickledOLMAccount = await deserializeEncryptedData( encryptedNotifsAccount, validatedNotifsAccountEncryptionKey, ); const { pickledAccount, picklingKey } = pickledOLMAccount; const notificationAccount = new olm.Account(); notificationAccount.unpickle(picklingKey, pickledAccount); return { notificationAccount, picklingKey, synchronizationValue, accountEncryptionKey: validatedNotifsAccountEncryptionKey, }; } async function retrieveEncryptionKey( encryptionKeyDBLabel: string, ): Promise { if (!isDesktopSafari) { return await localforage.getItem(encryptionKeyDBLabel); } // Safari doesn't support structured clone algorithm in service // worker context so we have to store CryptoKey as JSON const persistedCryptoKey = await localforage.getItem(encryptionKeyDBLabel); if (!persistedCryptoKey) { return null; } return await importJWKKey(persistedCryptoKey); } async function persistEncryptionKey( encryptionKeyDBLabel: string, encryptionKey: CryptoKey, ): Promise { let cryptoKeyPersistentForm; if (isDesktopSafari) { // Safari doesn't support structured clone algorithm in service // worker context so we have to store CryptoKey as JSON cryptoKeyPersistentForm = await exportKeyToJWK(encryptionKey); } else { cryptoKeyPersistentForm = encryptionKey; } await localforage.setItem(encryptionKeyDBLabel, cryptoKeyPersistentForm); } async function getNotifsOlmSessionDBKeys(keyserverID?: string): Promise<{ +olmDataKey: string, +encryptionKeyDBKey: string, }> { const olmDataKeyForKeyserverPrefix = getOlmDataKeyForCookie( undefined, keyserverID, ); const olmEncryptionKeyDBLabelForKeyserverPrefix = getOlmEncryptionKeyDBLabelForCookie(undefined, keyserverID); const dbKeys = await localforage.keys(); const olmDataKeys = sortOlmDBKeysArray( dbKeys.filter(key => key.startsWith(olmDataKeyForKeyserverPrefix)), ); const encryptionKeyDBLabels = sortOlmDBKeysArray( dbKeys.filter(key => key.startsWith(olmEncryptionKeyDBLabelForKeyserverPrefix), ), ); if (olmDataKeys.length === 0 || encryptionKeyDBLabels.length === 0) { throw new Error( 'Received encrypted notification but olm session was not created', ); } const latestDataKey = olmDataKeys[olmDataKeys.length - 1]; const latestEncryptionKeyDBKey = encryptionKeyDBLabels[encryptionKeyDBLabels.length - 1]; const latestDataCookieID = getCookieIDFromOlmDBKey(latestDataKey); const latestEncryptionKeyCookieID = getCookieIDFromOlmDBKey( latestEncryptionKeyDBKey, ); if (latestDataCookieID !== latestEncryptionKeyCookieID) { throw new Error( 'Olm sessions and their encryption keys out of sync. Latest cookie ' + `id for olm sessions ${latestDataCookieID}. Latest cookie ` + `id for olm session encryption keys ${latestEncryptionKeyCookieID}`, ); } const olmDBKeys = { olmDataKey: latestDataKey, encryptionKeyDBKey: latestEncryptionKeyDBKey, }; const keysToDelete: $ReadOnlyArray = [ ...olmDataKeys.slice(0, olmDataKeys.length - 1), ...encryptionKeyDBLabels.slice(0, encryptionKeyDBLabels.length - 1), ]; await Promise.all(keysToDelete.map(key => localforage.removeItem(key))); return olmDBKeys; } function getOlmDataKeyForCookie(cookie: ?string, keyserverID?: string): string { let olmDataKeyBase; if (keyserverID) { olmDataKeyBase = [ INDEXED_DB_KEYSERVER_PREFIX, keyserverID, NOTIFICATIONS_OLM_DATA_CONTENT, ].join(INDEXED_DB_KEY_SEPARATOR); } else { olmDataKeyBase = NOTIFICATIONS_OLM_DATA_CONTENT; } if (!cookie) { return olmDataKeyBase; } const cookieID = getCookieIDFromCookie(cookie); return [olmDataKeyBase, cookieID].join(INDEXED_DB_KEY_SEPARATOR); } function getOlmDataKeyForDeviceID(deviceID: string): string { return [ INDEXED_DB_DEVICE_PREFIX, deviceID, NOTIFICATIONS_OLM_DATA_CONTENT, ].join(INDEXED_DB_KEY_SEPARATOR); } function getOlmEncryptionKeyDBLabelForCookie( cookie: ?string, keyserverID?: string, ): string { let olmEncryptionKeyDBLabelBase; if (keyserverID) { olmEncryptionKeyDBLabelBase = [ INDEXED_DB_KEYSERVER_PREFIX, keyserverID, NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, ].join(INDEXED_DB_KEY_SEPARATOR); } else { olmEncryptionKeyDBLabelBase = NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY; } if (!cookie) { return olmEncryptionKeyDBLabelBase; } const cookieID = getCookieIDFromCookie(cookie); return [olmEncryptionKeyDBLabelBase, cookieID].join(INDEXED_DB_KEY_SEPARATOR); } function getOlmEncryptionKeyDBLabelForDeviceID(deviceID: string): string { return [ INDEXED_DB_DEVICE_PREFIX, deviceID, NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, ].join(INDEXED_DB_KEY_SEPARATOR); } function getCookieIDFromOlmDBKey(olmDBKey: string): string | '0' { // Olm DB keys comply to one of the following formats: // KEYSERVER::(OLM_CONTENT | OLM_ENCRYPTION_KEY): // or legacy (OLM_CONTENT | OLM_ENCRYPTION_KEY):. // Legacy format may be used in case a new version of the web app // is running on a old desktop version that uses legacy key format. const cookieID = olmDBKey.split(INDEXED_DB_KEY_SEPARATOR).slice(-1)[0]; return cookieID ?? '0'; } function sortOlmDBKeysArray( olmDBKeysArray: $ReadOnlyArray, ): $ReadOnlyArray { return olmDBKeysArray .map(key => ({ cookieID: Number(getCookieIDFromOlmDBKey(key)), key, })) .sort( ({ cookieID: cookieID1 }, { cookieID: cookieID2 }) => cookieID1 - cookieID2, ) .map(({ key }) => key); } async function migrateLegacyOlmNotificationsSessions() { const keyValuePairsToInsert: { [key: string]: EncryptedData | CryptoKey } = {}; const keysToDelete = []; await localforage.iterate((value: EncryptedData | CryptoKey, key) => { let keyToInsert; if (key.startsWith(NOTIFICATIONS_OLM_DATA_CONTENT)) { const cookieID = getCookieIDFromOlmDBKey(key); keyToInsert = getOlmDataKeyForCookie( cookieID, ASHOAT_KEYSERVER_ID_USED_ONLY_FOR_MIGRATION_FROM_LEGACY_NOTIF_STORAGE, ); } else if (key.startsWith(NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY)) { const cookieID = getCookieIDFromOlmDBKey(key); keyToInsert = getOlmEncryptionKeyDBLabelForCookie( cookieID, ASHOAT_KEYSERVER_ID_USED_ONLY_FOR_MIGRATION_FROM_LEGACY_NOTIF_STORAGE, ); } else { return undefined; } keyValuePairsToInsert[keyToInsert] = value; keysToDelete.push(key); return undefined; }); const insertionPromises = Object.entries(keyValuePairsToInsert).map( ([key, value]) => (async () => { await localforage.setItem(key, value); })(), ); const deletionPromises = keysToDelete.map(key => (async () => await localforage.removeItem(key))(), ); await Promise.all([...insertionPromises, ...deletionPromises]); } // Multiple keyserver unread count utilities function getKeyserverUnreadCountKey(keyserverID: string) { return [ INDEXED_DB_KEYSERVER_PREFIX, keyserverID, INDEXED_DB_UNREAD_COUNT_SUFFIX, ].join(INDEXED_DB_KEY_SEPARATOR); } async function updateNotifsUnreadCountStorage(perKeyserverUnreadCount: { +[keyserverID: string]: number, }) { const unreadCountUpdatePromises: Array> = Object.entries( perKeyserverUnreadCount, ).map(([keyserverID, unreadCount]) => { const keyserverUnreadCountKey = getKeyserverUnreadCountKey(keyserverID); return localforage.setItem(keyserverUnreadCountKey, unreadCount); }); await Promise.all(unreadCountUpdatePromises); } async function queryNotifsUnreadCountStorage( keyserverIDs: $ReadOnlyArray, ): Promise<{ +[keyserverID: string]: ?number, }> { const queryUnreadCountPromises: Array> = keyserverIDs.map(async keyserverID => { const keyserverUnreadCountKey = getKeyserverUnreadCountKey(keyserverID); const unreadCount = await localforage.getItem( keyserverUnreadCountKey, ); return [keyserverID, unreadCount]; }); const queriedUnreadCounts: $ReadOnlyArray<[string, ?number]> = await Promise.all(queryUnreadCountPromises); return Object.fromEntries(queriedUnreadCounts); } +async function updateNotifsUnreadThickThreadIDsStorage(input: { + +type: 'add' | 'remove' | 'set', + +threadIDs: $ReadOnlyArray, + +forceWrite: boolean, +}): Promise { + const { type, threadIDs, forceWrite } = input; + + const { + values: { + [INDEXED_DB_UNREAD_THICK_THREAD_IDS]: encryptedData, + [INDEXED_DB_UNREAD_THICK_THREAD_IDS_ENCRYPTION_KEY_DB_LABEL]: + encryptionKey, + }, + synchronizationValue, + } = await localforage.getMultipleItems<{ + unreadThickThreadIDs: ?EncryptedData, + unreadThickThreadIDsEncryptionKey: ?(CryptoKey | SubtleCrypto$JsonWebKey), + }>( + [ + INDEXED_DB_UNREAD_THICK_THREAD_IDS, + INDEXED_DB_UNREAD_THICK_THREAD_IDS_ENCRYPTION_KEY_DB_LABEL, + ], + INDEXED_DB_UNREAD_THICK_THREADS_SYNC_KEY, + ); + + let unreadThickThreadIDs; + let unreadThickThreadIDsEncryptionKey; + + if (encryptedData && encryptionKey) { + unreadThickThreadIDsEncryptionKey = await validateCryptoKey(encryptionKey); + unreadThickThreadIDs = new Set( + await deserializeEncryptedData>( + encryptedData, + unreadThickThreadIDsEncryptionKey, + ), + ); + } else { + unreadThickThreadIDs = new Set(); + unreadThickThreadIDsEncryptionKey = await generateCryptoKey({ + extractable: isDesktopSafari, + }); + } + + if (type === 'add') { + for (const threadID of threadIDs) { + unreadThickThreadIDs.add(threadID); + } + } else if (type === 'remove') { + for (const threadID of threadIDs) { + unreadThickThreadIDs.delete(threadID); + } + } else { + unreadThickThreadIDs = new Set(threadIDs); + } + + const [encryptionKeyPersistentForm, updatedEncryptedData] = await Promise.all( + [ + getCryptoKeyPersistentForm(unreadThickThreadIDsEncryptionKey), + serializeUnencryptedData( + [...unreadThickThreadIDs], + unreadThickThreadIDsEncryptionKey, + ), + ], + ); + + const newSynchronizationValue = uuid.v4(); + await localforage.setMultipleItems( + { + [INDEXED_DB_UNREAD_THICK_THREAD_IDS]: updatedEncryptedData, + [INDEXED_DB_UNREAD_THICK_THREAD_IDS_ENCRYPTION_KEY_DB_LABEL]: + encryptionKeyPersistentForm, + }, + INDEXED_DB_UNREAD_THICK_THREADS_SYNC_KEY, + synchronizationValue, + newSynchronizationValue, + forceWrite, + ); +} + +async function getNotifsUnreadThickThreadIDs(): Promise< + $ReadOnlyArray, +> { + const { + values: { + [INDEXED_DB_UNREAD_THICK_THREAD_IDS]: encryptedData, + [INDEXED_DB_UNREAD_THICK_THREAD_IDS_ENCRYPTION_KEY_DB_LABEL]: + encryptionKey, + }, + } = await localforage.getMultipleItems<{ + unreadThickThreadIDs: ?EncryptedData, + unreadThickThreadIDsEncryptionKey: ?(CryptoKey | SubtleCrypto$JsonWebKey), + }>( + [ + INDEXED_DB_UNREAD_THICK_THREAD_IDS, + INDEXED_DB_UNREAD_THICK_THREAD_IDS_ENCRYPTION_KEY_DB_LABEL, + ], + INDEXED_DB_UNREAD_THICK_THREADS_SYNC_KEY, + ); + + if (!encryptionKey || !encryptedData) { + return []; + } + + const unreadThickThreadIDsEncryptionKey = + await validateCryptoKey(encryptionKey); + + return await deserializeEncryptedData>( + encryptedData, + unreadThickThreadIDsEncryptionKey, + ); +} + export { decryptWebNotification, decryptDesktopNotification, encryptNotification, getOlmDataKeyForCookie, getOlmEncryptionKeyDBLabelForCookie, getOlmDataKeyForDeviceID, getOlmEncryptionKeyDBLabelForDeviceID, migrateLegacyOlmNotificationsSessions, updateNotifsUnreadCountStorage, queryNotifsUnreadCountStorage, getNotifsCryptoAccount, persistEncryptionKey, retrieveEncryptionKey, persistNotifsAccountWithOlmData, + updateNotifsUnreadThickThreadIDsStorage, + getNotifsUnreadThickThreadIDs, };