diff --git a/lib/ops/thread-store-ops.js b/lib/ops/thread-store-ops.js index ba5866eb2..e83f7c600 100644 --- a/lib/ops/thread-store-ops.js +++ b/lib/ops/thread-store-ops.js @@ -1,35 +1,98 @@ // @flow +import { type BaseStoreOpsHandlers } from './base-ops.js'; import type { ClientDBThreadInfo, RawThreadInfo, + ThreadStore, } from '../types/thread-types.js'; +import { + convertClientDBThreadInfoToRawThreadInfo, + convertRawThreadInfoToClientDBThreadInfo, +} from '../utils/thread-ops-utils.js'; export type RemoveThreadOperation = { +type: 'remove', +payload: { +ids: $ReadOnlyArray }, }; export type RemoveAllThreadsOperation = { +type: 'remove_all', }; export type ReplaceThreadOperation = { +type: 'replace', +payload: { +id: string, +threadInfo: RawThreadInfo }, }; export type ThreadStoreOperation = | RemoveThreadOperation | RemoveAllThreadsOperation | ReplaceThreadOperation; export type ClientDBReplaceThreadOperation = { +type: 'replace', +payload: ClientDBThreadInfo, }; export type ClientDBThreadStoreOperation = | RemoveThreadOperation | RemoveAllThreadsOperation | ClientDBReplaceThreadOperation; + +export const threadStoreOpsHandlers: BaseStoreOpsHandlers< + ThreadStore, + ThreadStoreOperation, + ClientDBThreadStoreOperation, + { +[id: string]: RawThreadInfo }, + ClientDBThreadInfo, +> = { + processStoreOperations( + store: ThreadStore, + ops: $ReadOnlyArray, + ): ThreadStore { + if (ops.length === 0) { + return store; + } + let processedThreads = { ...store.threadInfos }; + for (const operation of ops) { + if (operation.type === 'replace') { + processedThreads[operation.payload.id] = operation.payload.threadInfo; + } else if (operation.type === 'remove') { + for (const id of operation.payload.ids) { + delete processedThreads[id]; + } + } else if (operation.type === 'remove_all') { + processedThreads = {}; + } + } + return { ...store, threadInfos: processedThreads }; + }, + + convertOpsToClientDBOps( + ops: $ReadOnlyArray, + ): $ReadOnlyArray { + return ops.map(threadStoreOperation => { + if (threadStoreOperation.type === 'replace') { + return { + type: 'replace', + payload: convertRawThreadInfoToClientDBThreadInfo( + threadStoreOperation.payload.threadInfo, + ), + }; + } + return threadStoreOperation; + }); + }, + + translateClientDBData(data: $ReadOnlyArray): { + +[id: string]: RawThreadInfo, + } { + return Object.fromEntries( + data.map((dbThreadInfo: ClientDBThreadInfo) => [ + dbThreadInfo.id, + convertClientDBThreadInfoToRawThreadInfo(dbThreadInfo), + ]), + ); + }, +}; diff --git a/lib/reducers/thread-reducer.js b/lib/reducers/thread-reducer.js index cddac9e0d..a2eec5d98 100644 --- a/lib/reducers/thread-reducer.js +++ b/lib/reducers/thread-reducer.js @@ -1,509 +1,493 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import { setThreadUnreadStatusActionTypes, updateActivityActionTypes, } from '../actions/activity-actions.js'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { saveMessagesActionType } from '../actions/message-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, leaveThreadActionTypes, } from '../actions/thread-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, updateSubscriptionActionTypes, } from '../actions/user-actions.js'; -import type { ThreadStoreOperation } from '../ops/thread-store-ops.js'; +import { + type ThreadStoreOperation, + threadStoreOpsHandlers, +} from '../ops/thread-store-ops.js'; import type { BaseAction } from '../types/redux-types.js'; import { type ClientThreadInconsistencyReportCreationRequest, reportTypes, } from '../types/report-types.js'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import type { RawThreadInfo, ThreadStore } from '../types/thread-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; import { actionLogger } from '../utils/action-logger.js'; import { setNewSessionActionType } from '../utils/action-utils.js'; import { getConfig } from '../utils/config.js'; import { generateReportID } from '../utils/report-utils.js'; import { sanitizeActionSecrets } from '../utils/sanitization.js'; +const { processStoreOperations: processThreadStoreOperations } = + threadStoreOpsHandlers; + function generateOpsForThreadUpdates( threadInfos: { +[id: string]: RawThreadInfo }, payload: { +updatesResult: { +newUpdates: $ReadOnlyArray, ... }, ... }, ): $ReadOnlyArray { const threadOperations: ThreadStoreOperation[] = []; for (const update of payload.updatesResult.newUpdates) { if ( (update.type === updateTypes.UPDATE_THREAD || update.type === updateTypes.JOIN_THREAD) && !_isEqual(threadInfos[update.threadInfo.id])(update.threadInfo) ) { threadOperations.push({ type: 'replace', payload: { id: update.threadInfo.id, threadInfo: update.threadInfo, }, }); } else if ( update.type === updateTypes.UPDATE_THREAD_READ_STATUS && threadInfos[update.threadID] && threadInfos[update.threadID].currentUser.unread !== update.unread ) { const updatedThread = { ...threadInfos[update.threadID], currentUser: { ...threadInfos[update.threadID].currentUser, unread: update.unread, }, }; threadOperations.push({ type: 'replace', payload: { id: update.threadID, threadInfo: updatedThread, }, }); } else if ( update.type === updateTypes.DELETE_THREAD && threadInfos[update.threadID] ) { threadOperations.push({ type: 'remove', payload: { ids: [update.threadID], }, }); } else if (update.type === updateTypes.DELETE_ACCOUNT) { for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; const newMembers = threadInfo.members.filter( member => member.id !== update.deletedUserID, ); if (newMembers.length < threadInfo.members.length) { const updatedThread = { ...threadInfo, members: newMembers, }; threadOperations.push({ type: 'replace', payload: { id: threadID, threadInfo: updatedThread, }, }); } } } } return threadOperations; } function findInconsistencies( action: BaseAction, beforeStateCheck: { +[id: string]: RawThreadInfo }, afterStateCheck: { +[id: string]: RawThreadInfo }, ): ClientThreadInconsistencyReportCreationRequest[] { if (_isEqual(beforeStateCheck)(afterStateCheck)) { return []; } return [ { type: reportTypes.THREAD_INCONSISTENCY, platformDetails: getConfig().platformDetails, beforeAction: beforeStateCheck, action: sanitizeActionSecrets(action), pushResult: afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), id: generateReportID(), }, ]; } function reduceThreadInfos( state: ThreadStore, action: BaseAction, ): { threadStore: ThreadStore, newThreadInconsistencies: $ReadOnlyArray, threadStoreOperations: $ReadOnlyArray, } { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || action.type === fullStateSyncActionType ) { const newThreadInfos = action.payload.threadInfos; const threadStoreOperations = [ { type: 'remove_all', }, ...Object.keys(newThreadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: newThreadInfos[id] }, })), ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { if (Object.keys(state.threadInfos).length === 0) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations = [ { type: 'remove_all', }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if ( action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success || action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType || action.type === newThreadActionTypes.success ) { const { newUpdates } = action.payload.updatesResult; if (newUpdates.length === 0) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations = generateOpsForThreadUpdates( state.threadInfos, action.payload, ); const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === updateSubscriptionActionTypes.success) { const { threadID, subscription } = action.payload; const newThreadInfo = { ...state.threadInfos[threadID], currentUser: { ...state.threadInfos[threadID].currentUser, subscription, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: threadID, threadInfo: newThreadInfo, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === saveMessagesActionType) { const threadIDToMostRecentTime = new Map(); for (const messageInfo of action.payload.rawMessageInfos) { const current = threadIDToMostRecentTime.get(messageInfo.threadID); if (!current || current < messageInfo.time) { threadIDToMostRecentTime.set(messageInfo.threadID, messageInfo.time); } } const changedThreadInfos = {}; for (const [threadID, mostRecentTime] of threadIDToMostRecentTime) { const threadInfo = state.threadInfos[threadID]; if ( !threadInfo || threadInfo.currentUser.unread || action.payload.updatesCurrentAsOf > mostRecentTime ) { continue; } changedThreadInfos[threadID] = { ...state.threadInfos[threadID], currentUser: { ...state.threadInfos[threadID].currentUser, unread: true, }, }; } if (Object.keys(changedThreadInfos).length !== 0) { const threadStoreOperations = Object.keys(changedThreadInfos).map(id => ({ type: 'replace', payload: { id, threadInfo: changedThreadInfos[id], }, })); const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const { rawThreadInfos, deleteThreadIDs } = checkStateRequest.stateChanges; if (!rawThreadInfos && !deleteThreadIDs) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations: ThreadStoreOperation[] = []; if (rawThreadInfos) { for (const rawThreadInfo of rawThreadInfos) { threadStoreOperations.push({ type: 'replace', payload: { id: rawThreadInfo.id, threadInfo: rawThreadInfo, }, }); } } if (deleteThreadIDs) { threadStoreOperations.push({ type: 'remove', payload: { ids: deleteThreadIDs, }, }); } const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); const newThreadInconsistencies = findInconsistencies( action, state.threadInfos, updatedThreadStore.threadInfos, ); return { threadStore: updatedThreadStore, newThreadInconsistencies, threadStoreOperations, }; } else if (action.type === updateActivityActionTypes.success) { const updatedThreadInfos = {}; for (const setToUnread of action.payload.result.unfocusedToUnread) { const threadInfo = state.threadInfos[setToUnread]; if (threadInfo && !threadInfo.currentUser.unread) { updatedThreadInfos[setToUnread] = { ...threadInfo, currentUser: { ...threadInfo.currentUser, unread: true, }, }; } } if (Object.keys(updatedThreadInfos).length === 0) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations = Object.keys(updatedThreadInfos).map(id => ({ type: 'replace', payload: { id, threadInfo: updatedThreadInfos[id], }, })); const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === setThreadUnreadStatusActionTypes.started) { const { threadID, unread } = action.payload; const updatedThreadInfo = { ...state.threadInfos[threadID], currentUser: { ...state.threadInfos[threadID].currentUser, unread, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: threadID, threadInfo: updatedThreadInfo, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === setThreadUnreadStatusActionTypes.success) { const { threadID, resetToUnread } = action.payload; const currentUser = state.threadInfos[threadID].currentUser; if (!resetToUnread || currentUser.unread) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const updatedUser = { ...currentUser, unread: true, }; const updatedThread = { ...state.threadInfos[threadID], currentUser: updatedUser, }; const threadStoreOperations = [ { type: 'replace', payload: { id: threadID, threadInfo: updatedThread, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === setClientDBStoreActionType) { return { threadStore: action.payload.threadStore ?? state, newThreadInconsistencies: [], threadStoreOperations: [], }; } return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } -function processThreadStoreOperations( - threadStore: ThreadStore, - threadStoreOperations: $ReadOnlyArray, -): ThreadStore { - if (threadStoreOperations.length === 0) { - return threadStore; - } - let processedThreads = { ...threadStore.threadInfos }; - for (const operation of threadStoreOperations) { - if (operation.type === 'replace') { - processedThreads[operation.payload.id] = operation.payload.threadInfo; - } else if (operation.type === 'remove') { - for (const id of operation.payload.ids) { - delete processedThreads[id]; - } - } else if (operation.type === 'remove_all') { - processedThreads = {}; - } - } - return { ...threadStore, threadInfos: processedThreads }; -} - -export { reduceThreadInfos, processThreadStoreOperations }; +export { reduceThreadInfos }; diff --git a/lib/utils/thread-ops-utils.js b/lib/utils/thread-ops-utils.js index fa0837415..e43afce73 100644 --- a/lib/utils/thread-ops-utils.js +++ b/lib/utils/thread-ops-utils.js @@ -1,95 +1,62 @@ // @flow -import type { - ClientDBThreadStoreOperation, - ThreadStoreOperation, -} from '../ops/thread-store-ops.js'; import { assertThreadType } from '../types/thread-types-enum.js'; import { type ClientDBThreadInfo, type RawThreadInfo, } from '../types/thread-types.js'; function convertRawThreadInfoToClientDBThreadInfo( rawThreadInfo: RawThreadInfo, ): ClientDBThreadInfo { return { ...rawThreadInfo, creationTime: rawThreadInfo.creationTime.toString(), members: JSON.stringify(rawThreadInfo.members), roles: JSON.stringify(rawThreadInfo.roles), currentUser: JSON.stringify(rawThreadInfo.currentUser), avatar: rawThreadInfo.avatar ? JSON.stringify(rawThreadInfo.avatar) : null, }; } function convertClientDBThreadInfoToRawThreadInfo( clientDBThreadInfo: ClientDBThreadInfo, ): RawThreadInfo { let rawThreadInfo: RawThreadInfo = { id: clientDBThreadInfo.id, type: assertThreadType(clientDBThreadInfo.type), name: clientDBThreadInfo.name, description: clientDBThreadInfo.description, color: clientDBThreadInfo.color, creationTime: Number(clientDBThreadInfo.creationTime), parentThreadID: clientDBThreadInfo.parentThreadID, containingThreadID: clientDBThreadInfo.containingThreadID, community: clientDBThreadInfo.community, members: JSON.parse(clientDBThreadInfo.members), roles: JSON.parse(clientDBThreadInfo.roles), currentUser: JSON.parse(clientDBThreadInfo.currentUser), repliesCount: clientDBThreadInfo.repliesCount, pinnedCount: clientDBThreadInfo.pinnedCount, }; if (clientDBThreadInfo.sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID: clientDBThreadInfo.sourceMessageID, }; } if (clientDBThreadInfo.avatar) { rawThreadInfo = { ...rawThreadInfo, avatar: JSON.parse(clientDBThreadInfo.avatar), }; } return rawThreadInfo; } -function convertThreadStoreOperationsToClientDBOperations( - threadStoreOperations: $ReadOnlyArray, -): $ReadOnlyArray { - return threadStoreOperations.map(threadStoreOperation => { - if (threadStoreOperation.type === 'replace') { - return { - type: 'replace', - payload: convertRawThreadInfoToClientDBThreadInfo( - threadStoreOperation.payload.threadInfo, - ), - }; - } - return threadStoreOperation; - }); -} - -function convertClientDBThreadInfosToRawThreadInfos( - clientDBThreadInfos: $ReadOnlyArray, -): { +[id: string]: RawThreadInfo } { - return Object.fromEntries( - clientDBThreadInfos.map((dbThreadInfo: ClientDBThreadInfo) => [ - dbThreadInfo.id, - convertClientDBThreadInfoToRawThreadInfo(dbThreadInfo), - ]), - ); -} - export { convertRawThreadInfoToClientDBThreadInfo, convertClientDBThreadInfoToRawThreadInfo, - convertThreadStoreOperationsToClientDBOperations, - convertClientDBThreadInfosToRawThreadInfos, }; diff --git a/native/data/sqlite-data-handler.js b/native/data/sqlite-data-handler.js index e23b4d06d..62ce39d93 100644 --- a/native/data/sqlite-data-handler.js +++ b/native/data/sqlite-data-handler.js @@ -1,226 +1,226 @@ // @flow import * as React from 'react'; import { Alert } from 'react-native'; import { useDispatch } from 'react-redux'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js'; import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js'; import { convertClientDBReportToClientReportCreationRequest } from 'lib/ops/report-store-ops.js'; +import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import { cookieSelector } from 'lib/selectors/keyserver-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { logInActionSources, type LogInActionSource, } from 'lib/types/account-types.js'; import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils.js'; import { getMessageForException } from 'lib/utils/errors.js'; -import { convertClientDBThreadInfosToRawThreadInfos } from 'lib/utils/thread-ops-utils.js'; import { filesystemMediaCache } from '../media/media-cache.js'; import { commCoreModule } from '../native-modules.js'; import { setStoreLoadedActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { StaffContext } from '../staff/staff-context.js'; import { useInitialNotificationsEncryptedMessage } from '../utils/crypto-utils.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; function SQLiteDataHandler(): React.Node { const storeLoaded = useSelector(state => state.storeLoaded); const dispatch = useDispatch(); const rehydrateConcluded = useSelector( state => !!(state._persist && state._persist.rehydrated), ); const cookie = useSelector(cookieSelector); const urlPrefix = useSelector(state => state.urlPrefix); const staffCanSee = useStaffCanSee(); const { staffUserHasBeenLoggedIn } = React.useContext(StaffContext); const loggedIn = useSelector(isLoggedIn); const currentLoggedInUserID = useSelector(state => state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id, ); const mediaCacheContext = React.useContext(MediaCacheContext); const getInitialNotificationsEncryptedMessage = useInitialNotificationsEncryptedMessage(); const callFetchNewCookieFromNativeCredentials = React.useCallback( async (source: LogInActionSource) => { try { await fetchNewCookieFromNativeCredentials( dispatch, cookie, urlPrefix, source, getInitialNotificationsEncryptedMessage, ); dispatch({ type: setStoreLoadedActionType }); } catch (fetchCookieException) { if (staffCanSee) { Alert.alert( `Error fetching new cookie from native credentials: ${ getMessageForException(fetchCookieException) ?? '{no exception message}' }. Please kill the app.`, ); } else { commCoreModule.terminate(); } } }, [ cookie, dispatch, staffCanSee, urlPrefix, getInitialNotificationsEncryptedMessage, ], ); const callClearSensitiveData = React.useCallback( async (triggeredBy: string) => { if (staffCanSee || staffUserHasBeenLoggedIn) { Alert.alert('Starting SQLite database deletion process'); } await commCoreModule.clearSensitiveData(); try { await filesystemMediaCache.clearCache(); } catch { throw new Error('clear_media_cache_failed'); } if (staffCanSee || staffUserHasBeenLoggedIn) { Alert.alert( 'SQLite database successfully deleted', `SQLite database deletion was triggered by ${triggeredBy}`, ); } }, [staffCanSee, staffUserHasBeenLoggedIn], ); const handleSensitiveData = React.useCallback(async () => { try { const databaseCurrentUserInfoID = await commCoreModule.getCurrentUserID(); if ( databaseCurrentUserInfoID && databaseCurrentUserInfoID !== currentLoggedInUserID ) { await callClearSensitiveData('change in logged-in user credentials'); } if (currentLoggedInUserID) { await commCoreModule.setCurrentUserID(currentLoggedInUserID); } const databaseDeviceID = await commCoreModule.getDeviceID(); if (!databaseDeviceID) { await commCoreModule.setDeviceID('MOBILE'); } } catch (e) { if (isTaskCancelledError(e)) { return; } if (__DEV__) { throw e; } console.log(e); if (e.message !== 'clear_media_cache_failed') { commCoreModule.terminate(); } } }, [callClearSensitiveData, currentLoggedInUserID]); React.useEffect(() => { if (!rehydrateConcluded) { return; } const databaseNeedsDeletion = commCoreModule.checkIfDatabaseNeedsDeletion(); if (databaseNeedsDeletion) { (async () => { try { await callClearSensitiveData('detecting corrupted database'); } catch (e) { if (__DEV__) { throw e; } console.log(e); if (e.message !== 'clear_media_cache_failed') { commCoreModule.terminate(); } } await callFetchNewCookieFromNativeCredentials( logInActionSources.corruptedDatabaseDeletion, ); })(); return; } const sensitiveDataHandled = handleSensitiveData(); if (storeLoaded) { return; } if (!loggedIn) { dispatch({ type: setStoreLoadedActionType }); return; } (async () => { await Promise.all([ sensitiveDataHandled, mediaCacheContext?.evictCache(), ]); try { const { threads, messages, drafts, messageStoreThreads, reports } = await commCoreModule.getClientDBStore(); const threadInfosFromDB = - convertClientDBThreadInfosToRawThreadInfos(threads); + threadStoreOpsHandlers.translateClientDBData(threads); const reportsFromDb = convertClientDBReportToClientReportCreationRequest(reports); dispatch({ type: setClientDBStoreActionType, payload: { drafts, messages, threadStore: { threadInfos: threadInfosFromDB }, currentUserID: currentLoggedInUserID, messageStoreThreads, reports: reportsFromDb, }, }); } catch (setStoreException) { if (isTaskCancelledError(setStoreException)) { dispatch({ type: setStoreLoadedActionType }); return; } if (staffCanSee) { Alert.alert( 'Error setting threadStore or messageStore', getMessageForException(setStoreException) ?? '{no exception message}', ); } await callFetchNewCookieFromNativeCredentials( logInActionSources.sqliteLoadFailure, ); } })(); }, [ currentLoggedInUserID, handleSensitiveData, loggedIn, cookie, dispatch, rehydrateConcluded, staffCanSee, storeLoaded, urlPrefix, staffUserHasBeenLoggedIn, callFetchNewCookieFromNativeCredentials, callClearSensitiveData, mediaCacheContext, ]); return null; } export { SQLiteDataHandler }; diff --git a/native/redux/persist.js b/native/redux/persist.js index f24f4b6b0..28b9577a1 100644 --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -1,771 +1,771 @@ // @flow import AsyncStorage from '@react-native-async-storage/async-storage'; import invariant from 'invariant'; import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createTransform } from 'redux-persist'; import type { Transform } from 'redux-persist/es/types.js'; import { convertEntryStoreToNewIDSchema, convertInviteLinksStoreToNewIDSchema, convertMessageStoreToNewIDSchema, convertRawMessageInfoToNewIDSchema, convertCalendarFilterToNewIDSchema, convertConnectionInfoToNewIDSchema, } from 'lib/_generated/migration-utils.js'; import type { ClientDBMessageStoreOperation } from 'lib/ops/message-store-ops.js'; import { type ReportStoreOperation, type ClientDBReportStoreOperation, convertReportStoreOperationToClientDBReportStoreOperation, convertReportsToReplaceReportOps, } from 'lib/ops/report-store-ops.js'; import type { ClientDBThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; +import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import { highestLocalIDSelector } from 'lib/selectors/local-id-selectors.js'; import { createAsyncMigrate } from 'lib/shared/create-async-migrate.js'; import { inconsistencyResponsesToReports } from 'lib/shared/report-utils.js'; import { getContainingThreadID, getCommunity, } from 'lib/shared/thread-utils.js'; import { DEPRECATED_unshimMessageStore, unshimFunc, } from 'lib/shared/unshim-utils.js'; import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type LocalMessageInfo, type MessageStore, } from 'lib/types/message-types.js'; import type { ReportStore, ClientReportCreationRequest, } from 'lib/types/report-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import type { ClientDBThreadInfo } from 'lib/types/thread-types.js'; import { convertMessageStoreOperationsToClientDBOperations, translateClientDBMessageInfoToRawMessageInfo, translateRawMessageInfoToClientDBMessageInfo, } from 'lib/utils/message-ops-utils.js'; import { generateIDSchemaMigrationOpsForDrafts, convertMessageStoreThreadsToNewIDSchema, convertThreadStoreThreadInfosToNewIDSchema, } from 'lib/utils/migration-utils.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { convertClientDBThreadInfoToRawThreadInfo, convertRawThreadInfoToClientDBThreadInfo, - convertThreadStoreOperationsToClientDBOperations, } from 'lib/utils/thread-ops-utils.js'; import { getUUID } from 'lib/utils/uuid.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { updateClientDBThreadStoreThreadInfos, createUpdateDBOpsForThreadStoreThreadInfos, createUpdateDBOpsForMessageStoreMessages, createUpdateDBOpsForMessageStoreThreads, } from './client-db-utils.js'; import { migrateThreadStoreForEditThreadPermissions } from './edit-thread-permission-migration.js'; import { persistMigrationForManagePinsThreadPermission } from './manage-pins-permission-migration.js'; import type { AppState } from './state-types.js'; import { unshimClientDB } from './unshim-utils.js'; import { updateRolesAndPermissions } from './update-roles-and-permissions.js'; import { commCoreModule } from '../native-modules.js'; import { defaultDeviceCameraInfo } from '../types/camera.js'; import { defaultGlobalThemeInfo } from '../types/themes.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; const migrations = { [1]: (state: AppState) => ({ ...state, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, }), [2]: (state: AppState) => ({ ...state, messageSentFromRoute: [], }), [3]: state => ({ currentUserInfo: state.currentUserInfo, entryStore: state.entryStore, threadInfos: state.threadInfos, userInfos: state.userInfos, messageStore: { ...state.messageStore, currentAsOf: state.currentAsOf, }, updatesCurrentAsOf: state.currentAsOf, cookie: state.cookie, deviceToken: state.deviceToken, urlPrefix: state.urlPrefix, customServer: state.customServer, notifPermissionAlertInfo: state.notifPermissionAlertInfo, messageSentFromRoute: state.messageSentFromRoute, _persist: state._persist, }), [4]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, }), [5]: (state: AppState) => ({ ...state, calendarFilters: defaultCalendarFilters, }), [6]: state => ({ ...state, threadInfos: undefined, threadStore: { threadInfos: state.threadInfos, inconsistencyResponses: [], }, }), [7]: state => ({ ...state, lastUserInteraction: undefined, sessionID: undefined, entryStore: { ...state.entryStore, inconsistencyResponses: [], }, }), [8]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], entryStore: { ...state.entryStore, actualizedCalendarQuery: undefined, }, }), [9]: (state: AppState) => ({ ...state, connection: { ...state.connection, lateResponses: [], }, }), [10]: (state: AppState) => ({ ...state, nextLocalID: highestLocalIDSelector(state) + 1, connection: { ...state.connection, showDisconnectedBar: false, }, messageStore: { ...state.messageStore, local: {}, }, }), [11]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.IMAGES, ]), }), [12]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [13]: (state: AppState) => ({ ...state, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), }), [14]: (state: AppState) => state, [15]: state => ({ ...state, threadStore: { ...state.threadStore, inconsistencyReports: inconsistencyResponsesToReports( state.threadStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: inconsistencyResponsesToReports( state.entryStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, queuedReports: [], }), [16]: state => { const result = { ...state, messageSentFromRoute: undefined, dataLoaded: !!state.currentUserInfo && !state.currentUserInfo.anonymous, }; if (state.navInfo) { result.navInfo = { ...state.navInfo, navigationState: undefined, }; } return result; }, [17]: state => ({ ...state, userInfos: undefined, userStore: { userInfos: state.userInfos, inconsistencyResponses: [], }, }), [18]: state => ({ ...state, userStore: { userInfos: state.userStore.userInfos, inconsistencyReports: [], }, }), [19]: state => { const threadInfos = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const { visibilityRules, ...rest } = threadInfo; threadInfos[threadID] = rest; } return { ...state, threadStore: { ...state.threadStore, threadInfos, }, }; }, [20]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.UPDATE_RELATIONSHIP, ]), }), [21]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.CREATE_SIDEBAR, messageTypes.SIDEBAR_SOURCE, ]), }), [22]: state => { for (const key in state.drafts) { const value = state.drafts[key]; try { commCoreModule.updateDraft(key, value); } catch (e) { if (!isTaskCancelledError(e)) { throw e; } } } return { ...state, drafts: undefined, }; }, [23]: state => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [24]: state => ({ ...state, enabledApps: defaultEnabledApps, }), [25]: state => ({ ...state, crashReportsEnabled: __DEV__, }), [26]: state => { const { currentUserInfo } = state; if (currentUserInfo.anonymous) { return state; } return { ...state, crashReportsEnabled: undefined, currentUserInfo: { id: currentUserInfo.id, username: currentUserInfo.username, }, enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, }; }, [27]: state => ({ ...state, queuedReports: undefined, enabledReports: undefined, threadStore: { ...state.threadStore, inconsistencyReports: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: undefined, }, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [ ...state.entryStore.inconsistencyReports, ...state.threadStore.inconsistencyReports, ...state.queuedReports, ], }, }), [28]: state => { const threadParentToChildren = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? state.threadStore.threadInfos[threadInfo.parentThreadID] : null; const parentIndex = parentThreadInfo ? parentThreadInfo.id : '-1'; if (!threadParentToChildren[parentIndex]) { threadParentToChildren[parentIndex] = []; } threadParentToChildren[parentIndex].push(threadID); } const rootIDs = threadParentToChildren['-1']; if (!rootIDs) { // This should never happen, but if it somehow does we'll let the state // check mechanism resolve it... return state; } const threadInfos = {}; const stack = [...rootIDs]; while (stack.length > 0) { const threadID = stack.shift(); const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; threadInfos[threadID] = { ...threadInfo, containingThreadID: getContainingThreadID( parentThreadInfo, threadInfo.type, ), community: getCommunity(parentThreadInfo), }; const children = threadParentToChildren[threadID]; if (children) { stack.push(...children); } } return { ...state, threadStore: { ...state.threadStore, threadInfos } }; }, [29]: (state: AppState) => { const updatedThreadInfos = migrateThreadStoreForEditThreadPermissions( state.threadStore.threadInfos, ); return { ...state, threadStore: { ...state.threadStore, threadInfos: updatedThreadInfos, }, }; }, [30]: (state: AppState) => { const threadInfos = state.threadStore.threadInfos; const operations = [ { type: 'remove_all', }, ...Object.keys(threadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: threadInfos[id] }, })), ]; try { commCoreModule.processThreadStoreOperationsSync( - convertThreadStoreOperationsToClientDBOperations(operations), + threadStoreOpsHandlers.convertOpsToClientDBOps(operations), ); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [31]: (state: AppState) => { const messages = state.messageStore.messages; const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...Object.keys(messages).map((id: string) => ({ type: 'replace', payload: translateRawMessageInfoToClientDBMessageInfo(messages[id]), })), ]; try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [32]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), [33]: (state: AppState) => unshimClientDB(state, [messageTypes.REACTION]), [34]: state => { const { threadIDsToNotifIDs, ...stateSansThreadIDsToNotifIDs } = state; return stateSansThreadIDsToNotifIDs; }, [35]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), [36]: (state: AppState) => { // 1. Get threads and messages from SQLite `threads` and `messages` tables. const clientDBThreadInfos = commCoreModule.getAllThreadsSync(); const clientDBMessageInfos = commCoreModule.getAllMessagesSync(); // 2. Translate `ClientDBThreadInfo`s to `RawThreadInfo`s and // `ClientDBMessageInfo`s to `RawMessageInfo`s. const rawThreadInfos = clientDBThreadInfos.map( convertClientDBThreadInfoToRawThreadInfo, ); const rawMessageInfos = clientDBMessageInfos.map( translateClientDBMessageInfoToRawMessageInfo, ); // 3. Unshim translated `RawMessageInfos` to get the TOGGLE_PIN messages const unshimmedRawMessageInfos = rawMessageInfos.map(messageInfo => unshimFunc(messageInfo, new Set([messageTypes.TOGGLE_PIN])), ); // 4. Filter out non-TOGGLE_PIN messages const filteredRawMessageInfos = unshimmedRawMessageInfos.filter( messageInfo => messageInfo.type === messageTypes.TOGGLE_PIN, ); // 5. We want only the last TOGGLE_PIN message for each message ID, // so 'pin', 'unpin', 'pin' don't count as 3 pins, but only 1. const lastMessageIDToRawMessageInfoMap = new Map(); for (const messageInfo of filteredRawMessageInfos) { const { targetMessageID } = messageInfo; lastMessageIDToRawMessageInfoMap.set(targetMessageID, messageInfo); } const lastMessageIDToRawMessageInfos = Array.from( lastMessageIDToRawMessageInfoMap.values(), ); // 6. Create a Map of threadIDs to pinnedCount const threadIDsToPinnedCount = new Map(); for (const messageInfo of lastMessageIDToRawMessageInfos) { const { threadID, type } = messageInfo; if (type === messageTypes.TOGGLE_PIN) { const pinnedCount = threadIDsToPinnedCount.get(threadID) || 0; threadIDsToPinnedCount.set(threadID, pinnedCount + 1); } } // 7. Include a pinnedCount for each rawThreadInfo const rawThreadInfosWithPinnedCount = rawThreadInfos.map(threadInfo => ({ ...threadInfo, pinnedCount: threadIDsToPinnedCount.get(threadInfo.id) || 0, })); // 8. Convert rawThreadInfos to a map of threadID to threadInfo const threadIDToThreadInfo = rawThreadInfosWithPinnedCount.reduce( (acc, threadInfo) => { acc[threadInfo.id] = threadInfo; return acc; }, {}, ); // 9. Add threadPermission to each threadInfo const rawThreadInfosWithThreadPermission = persistMigrationForManagePinsThreadPermission(threadIDToThreadInfo); // 10. Convert the new threadInfos back into an array const rawThreadInfosWithCountAndPermission = Object.keys( rawThreadInfosWithThreadPermission, ).map(id => rawThreadInfosWithThreadPermission[id]); // 11. Translate `RawThreadInfo`s to `ClientDBThreadInfo`s. const convertedClientDBThreadInfos = rawThreadInfosWithCountAndPermission.map( convertRawThreadInfoToClientDBThreadInfo, ); // 12. Construct `ClientDBThreadStoreOperation`s to clear SQLite `threads` // table and repopulate with `ClientDBThreadInfo`s. const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...convertedClientDBThreadInfos.map((thread: ClientDBThreadInfo) => ({ type: 'replace', payload: thread, })), ]; // 13. Try processing `ClientDBThreadStoreOperation`s and log out if // `processThreadStoreOperationsSync(...)` throws an exception. try { commCoreModule.processThreadStoreOperationsSync(operations); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } return state; }, [37]: state => { const operations = convertMessageStoreOperationsToClientDBOperations([ { type: 'remove_all_threads', }, { type: 'replace_threads', payload: { threads: state.messageStore.threads }, }, ]); try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.error(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [38]: state => updateClientDBThreadStoreThreadInfos(state, updateRolesAndPermissions), [39]: (state: AppState) => unshimClientDB(state, [messageTypes.EDIT_MESSAGE]), [40]: state => updateClientDBThreadStoreThreadInfos(state, updateRolesAndPermissions), [41]: (state: AppState) => { const queuedReports = state.reportStore.queuedReports.map(report => ({ ...report, id: getUUID(), })); return { ...state, reportStore: { ...state.reportStore, queuedReports }, }; }, [42]: (state: AppState) => { const reportStoreOperations: $ReadOnlyArray = [ { type: 'remove_all_reports' }, ...convertReportsToReplaceReportOps(state.reportStore.queuedReports), ]; const dbOperations: $ReadOnlyArray = convertReportStoreOperationToClientDBReportStoreOperation( reportStoreOperations, ); try { commCoreModule.processReportStoreOperationsSync(dbOperations); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [43]: async (state: AppState) => { const { messages, drafts, threads, messageStoreThreads } = await commCoreModule.getClientDBStore(); const messageStoreThreadsOperations = createUpdateDBOpsForMessageStoreThreads( messageStoreThreads, convertMessageStoreThreadsToNewIDSchema, ); const messageStoreMessagesOperations = createUpdateDBOpsForMessageStoreMessages(messages, messageInfos => messageInfos.map(convertRawMessageInfoToNewIDSchema), ); const threadOperations = createUpdateDBOpsForThreadStoreThreadInfos( threads, convertThreadStoreThreadInfosToNewIDSchema, ); const draftOperations = generateIDSchemaMigrationOpsForDrafts(drafts); try { await Promise.all([ commCoreModule.processMessageStoreOperations([ ...messageStoreMessagesOperations, ...messageStoreThreadsOperations, ]), commCoreModule.processThreadStoreOperations(threadOperations), commCoreModule.processDraftStoreOperations(draftOperations), ]); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } return { ...state, entryStore: convertEntryStoreToNewIDSchema(state.entryStore), messageStore: convertMessageStoreToNewIDSchema(state.messageStore), calendarFilters: state.calendarFilters.map( convertCalendarFilterToNewIDSchema, ), connection: convertConnectionInfoToNewIDSchema(state.connection), watchedThreadIDs: state.watchedThreadIDs.map( id => `${ashoatKeyserverID}|${id}`, ), inviteLinksStore: convertInviteLinksStoreToNewIDSchema( state.inviteLinksStore, ), }; }, [44]: async state => { const { cookie, ...rest } = state; return { ...rest, keyserverStore: { keyserverInfos: { [ashoatKeyserverID]: { cookie } } }, }; }, }; // After migration 31, we'll no longer want to persist `messageStore.messages` // via redux-persist. However, we DO want to continue persisting everything in // `messageStore` EXCEPT for `messages`. The `blacklist` property in // `persistConfig` allows us to specify top-level keys that shouldn't be // persisted. However, we aren't able to specify nested keys in `blacklist`. // As a result, if we want to prevent nested keys from being persisted we'll // need to use `createTransform(...)` to specify an `inbound` function that // allows us to modify the `state` object before it's passed through // `JSON.stringify(...)` and written to disk. We specify the keys for which // this transformation should be executed in the `whitelist` property of the // `config` object that's passed to `createTransform(...)`. // eslint-disable-next-line no-unused-vars type PersistedThreadMessageInfo = { +startReached: boolean, +lastNavigatedTo: number, +lastPruned: number, }; type PersistedMessageStore = { +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: number, +threads: { +[threadID: string]: PersistedThreadMessageInfo }, }; const messageStoreMessagesBlocklistTransform: Transform = createTransform( (state: MessageStore): PersistedMessageStore => { const { messages, threads, ...messageStoreSansMessages } = state; // We also do not want to persist `messageStore.threads[ID].messageIDs` // because they can be deterministically computed based on messages we have // from SQLite const threadsToPersist = {}; for (const threadID in threads) { const { messageIDs, ...threadsData } = threads[threadID]; threadsToPersist[threadID] = threadsData; } return { ...messageStoreSansMessages, threads: threadsToPersist }; }, (state: MessageStore): MessageStore => { const { threads: persistedThreads, ...messageStore } = state; const threads = {}; for (const threadID in persistedThreads) { threads[threadID] = { ...persistedThreads[threadID], messageIDs: [] }; } // We typically expect `messageStore.messages` to be `undefined` because // messages are persisted in the SQLite `messages` table rather than via // `redux-persist`. In this case we want to set `messageStore.messages` // to {} so we don't run into issues with `messageStore.messages` being // `undefined` (https://phab.comm.dev/D5545). // // However, in the case that a user is upgrading from a client where // `persistConfig.version` < 31, we expect `messageStore.messages` to // contain messages stored via `redux-persist` that we need in order // to correctly populate the SQLite `messages` table in migration 31 // (https://phab.comm.dev/D2600). // // However, because `messageStoreMessagesBlocklistTransform` modifies // `messageStore` before migrations are run, we need to make sure we aren't // inadvertently clearing `messageStore.messages` (by setting to {}) before // messages are stored in SQLite (https://linear.app/comm/issue/ENG-2377). return { ...messageStore, threads, messages: messageStore.messages ?? {} }; }, { whitelist: ['messageStore'] }, ); type PersistedReportStore = $Diff< ReportStore, { +queuedReports: $ReadOnlyArray }, >; const reportStoreTransform: Transform = createTransform( (state: ReportStore): PersistedReportStore => { return { enabledReports: state.enabledReports }; }, (state: PersistedReportStore): ReportStore => { return { ...state, queuedReports: [] }; }, { whitelist: ['reportStore'] }, ); const persistConfig = { key: 'root', storage: AsyncStorage, blacklist: [ 'loadingStatuses', 'lifecycleState', 'dimensions', 'draftStore', 'connectivity', 'deviceOrientation', 'frozen', 'threadStore', 'storeLoaded', ], debug: __DEV__, version: 44, transforms: [messageStoreMessagesBlocklistTransform, reportStoreTransform], migrate: (createAsyncMigrate(migrations, { debug: __DEV__ }): any), timeout: ((__DEV__ ? 0 : undefined): number | void), }; const codeVersion: number = commCoreModule.getCodeVersion(); // This local exists to avoid a circular dependency where redux-setup needs to // import all the navigation and screen stuff, but some of those screens want to // access the persistor to purge its state. let storedPersistor = null; function setPersistor(persistor: *) { storedPersistor = persistor; } function getPersistor(): empty { invariant(storedPersistor, 'should be set'); return storedPersistor; } export { persistConfig, codeVersion, setPersistor, getPersistor }; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index dc4cbc69b..5f440cceb 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,483 +1,483 @@ // @flow import { AppState as NativeAppState, Platform, Alert } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createStore, applyMiddleware, type Store, compose } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import thunk from 'redux-thunk'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js'; import { siweAuthActionTypes } from 'lib/actions/siwe-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, } from 'lib/actions/user-actions.js'; import type { ThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; +import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import baseReducer from 'lib/reducers/master-reducer.js'; -import { processThreadStoreOperations } from 'lib/reducers/thread-reducer.js'; import { invalidSessionDowngrade, invalidSessionRecovery, } from 'lib/shared/session-utils.js'; import { isStaff } from 'lib/shared/staff-utils.js'; import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import type { Dispatch, BaseAction } from 'lib/types/redux-types.js'; import { rehydrateActionType } from 'lib/types/redux-types.js'; import type { SetSessionPayload } from 'lib/types/session-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger.js'; import { setNewSessionActionType } from 'lib/utils/action-utils.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { updateDimensionsActiveType, updateConnectivityActiveType, updateThemeInfoActionType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, updateThreadLastNavigatedActionType, backgroundActionTypes, setReduxStateActionType, setStoreLoadedActionType, type Action, } from './action-types.js'; import { remoteReduxDevServerConfig } from './dev-tools.js'; import { defaultDimensionsInfo } from './dimensions-updater.react.js'; import { persistConfig, setPersistor } from './persist.js'; import { processDBStoreOperations } from './redux-utils.js'; import type { AppState } from './state-types.js'; import reduceGlobalThemeInfo from './theme-reducer.js'; import { defaultNavInfo } from '../navigation/default-state.js'; import { getGlobalNavContext } from '../navigation/icky-global.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import reactotron from '../reactotron.js'; import { defaultDeviceCameraInfo } from '../types/camera.js'; import { defaultConnectivityInfo } from '../types/connectivity.js'; import { defaultGlobalThemeInfo } from '../types/themes.js'; import { isStaffRelease } from '../utils/staff-utils.js'; import { defaultURLPrefix, natNodeServer, setCustomServer, getDevServerHostname, } from '../utils/url-utils.js'; const defaultState = ({ navInfo: defaultNavInfo, currentUserInfo: null, draftStore: { drafts: {} }, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, }, threadStore: { threadInfos: {}, }, userStore: { userInfos: {}, inconsistencyReports: [], }, messageStore: { messages: {}, threads: {}, local: {}, currentAsOf: 0, }, storeLoaded: false, updatesCurrentAsOf: 0, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, deviceToken: null, dataLoaded: false, urlPrefix: defaultURLPrefix, customServer: natNodeServer, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], lifecycleState: 'active', enabledApps: defaultEnabledApps, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [], }, nextLocalID: 0, _persist: null, dimensions: defaultDimensionsInfo, connectivity: defaultConnectivityInfo, globalThemeInfo: defaultGlobalThemeInfo, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), frozen: false, userPolicies: {}, commServicesAccessToken: null, inviteLinksStore: { links: {}, }, lastCommunicatedPlatformDetails: {}, keyserverStore: { keyserverInfos: {} }, }: AppState); function reducer(state: AppState = defaultState, action: Action) { if (action.type === setReduxStateActionType) { return action.payload.state; } // We want to alert staff/developers if there's a difference between the keys // we expect to see REHYDRATED and the keys that are actually REHYDRATED. // Context: https://linear.app/comm/issue/ENG-2127/ if ( action.type === rehydrateActionType && (__DEV__ || isStaffRelease || (state.currentUserInfo && state.currentUserInfo.id && isStaff(state.currentUserInfo.id))) ) { // 1. Construct set of keys expected to be REHYDRATED const defaultKeys = Object.keys(defaultState); const expectedKeys = defaultKeys.filter( each => !persistConfig.blacklist.includes(each), ); const expectedKeysSet = new Set(expectedKeys); // 2. Construct set of keys actually REHYDRATED const rehydratedKeys = Object.keys(action.payload ?? {}); const rehydratedKeysSet = new Set(rehydratedKeys); // 3. Determine the difference between the two sets const expectedKeysNotRehydrated = expectedKeys.filter( each => !rehydratedKeysSet.has(each), ); const rehydratedKeysNotExpected = rehydratedKeys.filter( each => !expectedKeysSet.has(each), ); // 4. Display alerts with the differences between the two sets if (expectedKeysNotRehydrated.length > 0) { Alert.alert( `EXPECTED KEYS NOT REHYDRATED: ${JSON.stringify( expectedKeysNotRehydrated, )}`, ); } if (rehydratedKeysNotExpected.length > 0) { Alert.alert( `REHYDRATED KEYS NOT EXPECTED: ${JSON.stringify( rehydratedKeysNotExpected, )}`, ); } } if ( (action.type === setNewSessionActionType && invalidSessionDowngrade( state, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === logOutActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return state; } if ( (action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && invalidSessionRecovery( state, action.payload.sessionChange.currentUserInfo, action.payload.logInActionSource, )) || ((action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success) && invalidSessionRecovery( state, action.payload.currentUserInfo, action.payload.logInActionSource, )) ) { return state; } state = { ...state, globalThemeInfo: reduceGlobalThemeInfo(state.globalThemeInfo, action), }; if (action.type === setCustomServer) { return { ...state, customServer: action.payload, }; } else if (action.type === updateDimensionsActiveType) { return { ...state, dimensions: { ...state.dimensions, ...action.payload, }, }; } else if (action.type === updateConnectivityActiveType) { return { ...state, connectivity: action.payload, }; } else if (action.type === updateThemeInfoActionType) { // Handled above by reduceGlobalThemeInfo return state; } else if (action.type === updateDeviceCameraInfoActionType) { return { ...state, deviceCameraInfo: { ...state.deviceCameraInfo, ...action.payload, }, }; } else if (action.type === updateDeviceOrientationActionType) { return { ...state, deviceOrientation: action.payload, }; } else if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; if (state.messageStore.threads[threadID]) { const updatedThreads = { [threadID]: { ...state.messageStore.threads[threadID], lastNavigatedTo: time, }, }; state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, ...updatedThreads, }, }, }; processDBStoreOperations({ draftStoreOperations: [], messageStoreOperations: [ { type: 'replace_threads', payload: { threads: updatedThreads, }, }, ], threadStoreOperations: [], reportStoreOperations: [], }); } return state; } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); } if (action.type === setStoreLoadedActionType) { return { ...state, storeLoaded: true, }; } if (action.type === setClientDBStoreActionType) { state = { ...state, storeLoaded: true, }; const currentLoggedInUserID = state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id; const actionCurrentLoggedInUserID = action.payload.currentUserID; if ( !currentLoggedInUserID || !actionCurrentLoggedInUserID || actionCurrentLoggedInUserID !== currentLoggedInUserID ) { // If user is logged out now, was logged out at the time action was // dispatched or their ID changed between action dispatch and a // call to reducer we ignore the SQLite data since it is not valid return state; } } const baseReducerResult = baseReducer(state, (action: BaseAction)); state = baseReducerResult.state; const { storeOperations } = baseReducerResult; const { draftStoreOperations, threadStoreOperations, messageStoreOperations, reportStoreOperations, } = storeOperations; const fixUnreadActiveThreadResult = fixUnreadActiveThread(state, action); state = fixUnreadActiveThreadResult.state; const threadStoreOperationsWithUnreadFix = [ ...threadStoreOperations, ...fixUnreadActiveThreadResult.threadStoreOperations, ]; processDBStoreOperations({ draftStoreOperations, messageStoreOperations, threadStoreOperations: threadStoreOperationsWithUnreadFix, reportStoreOperations, }); return state; } function sessionInvalidationAlert(payload: SetSessionPayload) { if ( !payload.sessionChange.cookieInvalidated || !payload.preRequestUserState || !payload.preRequestUserState.currentUserInfo || payload.preRequestUserState.currentUserInfo.anonymous ) { return; } if (payload.error === 'client_version_unsupported') { const app = Platform.select({ ios: 'App Store', android: 'Play Store', }); Alert.alert( 'App out of date', 'Your app version is pretty old, and the server doesn’t know how to ' + `speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK' }], { cancelable: true }, ); } else { Alert.alert( 'Session invalidated', 'We’re sorry, but your session was invalidated by the server. ' + 'Please log in again.', [{ text: 'OK' }], { cancelable: true }, ); } } // Makes sure a currently focused thread is never unread. Note that we consider // a backgrounded NativeAppState to actually be active if it last changed to // inactive more than 10 seconds ago. This is because there is a delay when // NativeAppState is updating in response to a foreground, and actions don't get // processed more than 10 seconds after a backgrounding anyways. However we // don't consider this for action types that can be expected to happen while the // app is backgrounded. type FixUnreadActiveThreadResult = { +state: AppState, +threadStoreOperations: $ReadOnlyArray, }; function fixUnreadActiveThread( state: AppState, action: *, ): FixUnreadActiveThreadResult { const navContext = getGlobalNavContext(); const activeThread = activeMessageListSelector(navContext); if ( !activeThread || !state.threadStore.threadInfos[activeThread]?.currentUser.unread || (NativeAppState.currentState !== 'active' && (appLastBecameInactive + 10000 >= Date.now() || backgroundActionTypes.has(action.type))) ) { return { state, threadStoreOperations: [] }; } const updatedActiveThreadInfo = { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: activeThread, threadInfo: updatedActiveThreadInfo, }, }, ]; - const updatedThreadStore = processThreadStoreOperations( + const updatedThreadStore = threadStoreOpsHandlers.processStoreOperations( state.threadStore, threadStoreOperations, ); return { state: { ...state, threadStore: updatedThreadStore }, threadStoreOperations, }; } let appLastBecameInactive = 0; function appBecameInactive() { appLastBecameInactive = Date.now(); } const middleware = applyMiddleware(thunk, reduxLoggerMiddleware); let composeFunc = compose; if (__DEV__ && global.HermesInternal) { const { composeWithDevTools } = require('remote-redux-devtools/src/index.js'); composeFunc = composeWithDevTools({ name: 'Redux', hostname: getDevServerHostname(), ...remoteReduxDevServerConfig, }); } else if (global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) { composeFunc = global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'Redux', }); } let enhancers; if (reactotron) { enhancers = composeFunc(middleware, reactotron.createEnhancer()); } else { enhancers = composeFunc(middleware); } const store: Store = createStore( persistReducer(persistConfig, reducer), defaultState, enhancers, ); const persistor = persistStore(store); setPersistor(persistor); const unsafeDispatch: any = store.dispatch; const dispatch: Dispatch = unsafeDispatch; export { store, dispatch, appBecameInactive }; diff --git a/native/redux/redux-utils.js b/native/redux/redux-utils.js index e05e9fe4e..3d6f17a97 100644 --- a/native/redux/redux-utils.js +++ b/native/redux/redux-utils.js @@ -1,80 +1,80 @@ // @flow import { useSelector as reactReduxUseSelector } from 'react-redux'; import { convertReportStoreOperationToClientDBReportStoreOperation } from 'lib/ops/report-store-ops.js'; +import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import type { StoreOperations } from 'lib/types/store-ops-types.js'; import { convertMessageStoreOperationsToClientDBOperations } from 'lib/utils/message-ops-utils.js'; -import { convertThreadStoreOperationsToClientDBOperations } from 'lib/utils/thread-ops-utils.js'; import type { AppState } from './state-types.js'; import { commCoreModule } from '../native-modules.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; function useSelector( selector: (state: AppState) => SS, equalityFn?: (a: SS, b: SS) => boolean, ): SS { return reactReduxUseSelector(selector, equalityFn); } async function processDBStoreOperations( storeOperations: StoreOperations, ): Promise { const { draftStoreOperations, threadStoreOperations, messageStoreOperations, reportStoreOperations, } = storeOperations; const convertedThreadStoreOperations = - convertThreadStoreOperationsToClientDBOperations(threadStoreOperations); + threadStoreOpsHandlers.convertOpsToClientDBOps(threadStoreOperations); const convertedMessageStoreOperations = convertMessageStoreOperationsToClientDBOperations(messageStoreOperations); const convertedReportStoreOperations = convertReportStoreOperationToClientDBReportStoreOperation( reportStoreOperations, ); try { const promises = []; if (convertedThreadStoreOperations.length > 0) { promises.push( commCoreModule.processThreadStoreOperations( convertedThreadStoreOperations, ), ); } if (convertedMessageStoreOperations.length > 0) { promises.push( commCoreModule.processMessageStoreOperations( convertedMessageStoreOperations, ), ); } if (draftStoreOperations.length > 0) { promises.push( commCoreModule.processDraftStoreOperations(draftStoreOperations), ); } if (convertedReportStoreOperations.length > 0) { promises.push( commCoreModule.processReportStoreOperations( convertedReportStoreOperations, ), ); } await Promise.all(promises); } catch (e) { if (isTaskCancelledError(e)) { return; } // this code will make an entry in SecureStore and cause re-creating // database when user will open app again commCoreModule.reportDBOperationsFailure(); commCoreModule.terminate(); } } export { useSelector, processDBStoreOperations };