diff --git a/lib/actions/integrity-actions.js b/lib/actions/integrity-actions.js new file mode 100644 --- /dev/null +++ b/lib/actions/integrity-actions.js @@ -0,0 +1,5 @@ +// @flow + +const updateIntegrityStoreActionType = 'UPDATE_INTEGRITY_STORE'; + +export { updateIntegrityStoreActionType }; diff --git a/lib/components/integrity-handler.react.js b/lib/components/integrity-handler.react.js new file mode 100644 --- /dev/null +++ b/lib/components/integrity-handler.react.js @@ -0,0 +1,63 @@ +// @flow + +import * as React from 'react'; +import { useDispatch } from 'react-redux'; + +import { updateIntegrityStoreActionType } from '../actions/integrity-actions.js'; +import { splitIntoChunks } from '../utils/array.js'; +import { useSelector } from '../utils/redux-utils.js'; + +const BATCH_SIZE = 50; +// Time between hashing of each thread batch +const BATCH_INTERVAL = 500; // in milliseconds + +function IntegrityHandler(): null { + const dispatch = useDispatch(); + + const threadInfos = useSelector(state => state.threadStore.threadInfos); + const integrityStore = useSelector(state => state.integrityStore); + + const [batches, setBatches] = React.useState(null); + const timeout = React.useRef(null); + + React.useEffect(() => { + if (integrityStore.threadHashingStatus === 'starting') { + const threadIDs = Object.keys(threadInfos); + setBatches(splitIntoChunks(threadIDs, BATCH_SIZE)); + dispatch({ + type: updateIntegrityStoreActionType, + payload: { threadHashingStatus: 'running' }, + }); + } else if (integrityStore.threadHashingStatus === 'completed') { + clearTimeout(timeout.current); + setBatches(null); + } + }, [dispatch, integrityStore.threadHashingStatus, threadInfos]); + + React.useEffect(() => { + if (!batches) { + return undefined; + } + const [batch, ...rest] = batches; + if (!batch) { + dispatch({ + type: updateIntegrityStoreActionType, + payload: { threadHashingStatus: 'completed' }, + }); + return undefined; + } + + dispatch({ + type: updateIntegrityStoreActionType, + payload: { threadIDsToHash: batch }, + }); + + const timeoutID = setTimeout(() => setBatches(rest), BATCH_INTERVAL); + timeout.current = timeoutID; + return () => clearTimeout(timeoutID); + }, [batches, dispatch]); + + return null; +} + +export default IntegrityHandler; diff --git a/lib/reducers/integrity-reducer.js b/lib/reducers/integrity-reducer.js new file mode 100644 --- /dev/null +++ b/lib/reducers/integrity-reducer.js @@ -0,0 +1,85 @@ +// @flow + +import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; +import { updateIntegrityStoreActionType } from '../actions/integrity-actions.js'; +import { siweAuthActionTypes } from '../actions/siwe-actions.js'; +import { + logInActionTypes, + registerActionTypes, +} from '../actions/user-actions.js'; +import type { ThreadStoreOperation } from '../ops/thread-store-ops'; +import type { IntegrityStore } from '../types/integrity-types'; +import type { BaseAction } from '../types/redux-types.js'; +import { fullStateSyncActionType } from '../types/socket-types.js'; +import type { RawThreadInfo } from '../types/thread-types.js'; +import { hash } from '../utils/objects.js'; + +function reduceIntegrityStore( + state: IntegrityStore, + action: BaseAction, + threadInfos: { +[string]: RawThreadInfo }, + threadStoreOperations: $ReadOnlyArray, +): IntegrityStore { + if ( + action.type === logInActionTypes.success || + action.type === siweAuthActionTypes.success || + action.type === registerActionTypes.success || + action.type === fullStateSyncActionType || + (action.type === setClientDBStoreActionType && + !!action.payload.threadStore && + state.threadHashingStatus !== 'completed') + ) { + return { threadHashes: {}, threadHashingStatus: 'starting' }; + } + let newState = state; + if (action.type === updateIntegrityStoreActionType) { + if (action.payload.threadIDsToHash) { + const newThreadHashes = Object.fromEntries( + action.payload.threadIDsToHash + .map(id => [id, threadInfos[id]]) + .filter(([, info]) => !!info) + .map(([id, info]) => [id, hash(info)]), + ); + + newState = { + ...newState, + threadHashes: { + ...newState.threadHashes, + ...newThreadHashes, + }, + }; + } + if (action.payload.threadHashingStatus) { + newState = { + ...newState, + threadHashingStatus: action.payload.threadHashingStatus, + }; + } + } + if (threadStoreOperations.length === 0) { + return newState; + } + let processedThreadHashes = { ...newState.threadHashes }; + let threadHashingStatus = newState.threadHashingStatus; + for (const operation of threadStoreOperations) { + if (operation.type === 'replace') { + processedThreadHashes[operation.payload.id] = hash( + operation.payload.threadInfo, + ); + } else if (operation.type === 'remove') { + for (const id of operation.payload.ids) { + delete processedThreadHashes[id]; + } + } else if (operation.type === 'remove_all') { + processedThreadHashes = {}; + threadHashingStatus = 'completed'; + } + } + return { + ...newState, + threadHashes: processedThreadHashes, + threadHashingStatus, + }; +} + +export { reduceIntegrityStore }; diff --git a/lib/reducers/master-reducer.js b/lib/reducers/master-reducer.js --- a/lib/reducers/master-reducer.js +++ b/lib/reducers/master-reducer.js @@ -7,6 +7,7 @@ import { reduceDraftStore } from './draft-reducer.js'; import reduceEnabledApps from './enabled-apps-reducer.js'; import { reduceEntryInfos } from './entry-reducer.js'; +import { reduceIntegrityStore } from './integrity-reducer.js'; import reduceInviteLinks from './invite-links-reducer.js'; import reduceKeyserverStore from './keyserver-reducer.js'; import reduceLifecycleState from './lifecycle-state-reducer.js'; @@ -150,6 +151,12 @@ state.threadActivityStore, action, ), + integrityStore: reduceIntegrityStore( + state.integrityStore, + action, + threadStore.threadInfos, + threadStoreOperations, + ), }, storeOperations: { draftStoreOperations, diff --git a/lib/types/integrity-types.js b/lib/types/integrity-types.js new file mode 100644 --- /dev/null +++ b/lib/types/integrity-types.js @@ -0,0 +1,10 @@ +// @flow + +export type IntegrityStore = { + +threadHashes: { +[string]: number }, + +threadHashingStatus: + | 'data_not_loaded' + | 'starting' + | 'running' + | 'completed', +}; diff --git a/lib/types/redux-types.js b/lib/types/redux-types.js --- a/lib/types/redux-types.js +++ b/lib/types/redux-types.js @@ -38,6 +38,7 @@ CalendarThreadFilter, SetCalendarDeletedFilterPayload, } from './filter-types.js'; +import type { IntegrityStore } from './integrity-types.js'; import type { KeyserverStore } from './keyserver-types.js'; import type { LifecycleState } from './lifecycle-state-types.js'; import type { @@ -139,6 +140,7 @@ +inviteLinksStore: InviteLinksStore, +keyserverStore: KeyserverStore, +threadActivityStore: ThreadActivityStore, + +integrityStore: IntegrityStore, ... }; @@ -1214,6 +1216,13 @@ | { +type: 'UPDATE_THREAD_LAST_NAVIGATED', +payload: { +threadID: string, +time: number }, + } + | { + +type: 'UPDATE_INTEGRITY_STORE', + +payload: { + +threadIDsToHash?: $ReadOnlyArray, + +threadHashingStatus?: 'starting' | 'running' | 'completed', + }, }; export type ActionPayload = ?(Object | Array<*> | $ReadOnlyArray<*> | string); diff --git a/lib/utils/array.js b/lib/utils/array.js --- a/lib/utils/array.js +++ b/lib/utils/array.js @@ -23,4 +23,19 @@ } } -export { getAllTuples, cartesianProduct, pushAll }; +function splitIntoChunks( + array: $ReadOnlyArray, + batchSize: number, +): Array> { + const chunks = []; + + let i = 0; + while (i < array.length) { + chunks.push(array.slice(i, i + batchSize)); + i += batchSize; + } + + return chunks; +} + +export { getAllTuples, cartesianProduct, pushAll, splitIntoChunks }; diff --git a/native/redux/default-state.js b/native/redux/default-state.js --- a/native/redux/default-state.js +++ b/native/redux/default-state.js @@ -86,6 +86,7 @@ isBackupEnabled: false, }, threadActivityStore: {}, + integrityStore: { threadHashes: {}, threadHashingStatus: 'starting' }, }: AppState); export { defaultState }; diff --git a/native/redux/persist.js b/native/redux/persist.js --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -776,6 +776,13 @@ }, }; }, + [52]: async state => ({ + ...state, + integrityStore: { + threadHashes: {}, + threadHashingStatus: 'data_not_loaded', + }, + }), }; // After migration 31, we'll no longer want to persist `messageStore.messages` @@ -907,7 +914,7 @@ 'connection', ], debug: __DEV__, - version: 51, + version: 52, transforms: [ messageStoreMessagesBlocklistTransform, reportStoreTransform, diff --git a/native/redux/state-types.js b/native/redux/state-types.js --- a/native/redux/state-types.js +++ b/native/redux/state-types.js @@ -7,6 +7,7 @@ import type { EnabledApps } from 'lib/types/enabled-apps.js'; import type { EntryStore, CalendarQuery } from 'lib/types/entry-types.js'; import type { CalendarFilter } from 'lib/types/filter-types.js'; +import type { IntegrityStore } from 'lib/types/integrity-types.js'; import type { KeyserverStore } from 'lib/types/keyserver-types.js'; import type { LifecycleState } from 'lib/types/lifecycle-state-types.js'; import type { InviteLinksStore } from 'lib/types/link-types.js'; @@ -60,4 +61,5 @@ +keyserverStore: KeyserverStore, +threadActivityStore: ThreadActivityStore, +localSettings: LocalSettings, + +integrityStore: IntegrityStore, }; diff --git a/native/root.react.js b/native/root.react.js --- a/native/root.react.js +++ b/native/root.react.js @@ -21,6 +21,7 @@ import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js'; import { ENSCacheProvider } from 'lib/components/ens-cache-provider.react.js'; +import IntegrityHandler from 'lib/components/integrity-handler.react.js'; import { MediaCacheProvider } from 'lib/components/media-cache-provider.react.js'; import { actionLogger } from 'lib/utils/action-logger.js'; @@ -243,6 +244,7 @@ + ); let navigation; diff --git a/web/redux/default-state.js b/web/redux/default-state.js --- a/web/redux/default-state.js +++ b/web/redux/default-state.js @@ -89,6 +89,7 @@ }, threadActivityStore: {}, initialStateLoaded: false, + integrityStore: { threadHashes: {}, threadHashingStatus: 'starting' }, }); export { defaultWebState }; diff --git a/web/redux/persist.js b/web/redux/persist.js --- a/web/redux/persist.js +++ b/web/redux/persist.js @@ -130,6 +130,10 @@ return state; }, + [6]: async state => ({ + ...state, + integrityStore: { threadHashes: {}, threadHashingStatus: 'starting' }, + }), }; const persistWhitelist = [ @@ -242,7 +246,7 @@ { debug: isDev }, migrateStorageToSQLite, ): any), - version: 5, + version: 6, transforms: [keyserverStoreTransform], }; diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -22,6 +22,7 @@ import type { EnabledApps } from 'lib/types/enabled-apps.js'; import type { EntryStore, CalendarQuery } from 'lib/types/entry-types.js'; import { type CalendarFilter } from 'lib/types/filter-types.js'; +import type { IntegrityStore } from 'lib/types/integrity-types.js'; import type { KeyserverStore } from 'lib/types/keyserver-types.js'; import type { LifecycleState } from 'lib/types/lifecycle-state-types.js'; import type { InviteLinksStore } from 'lib/types/link-types.js'; @@ -100,6 +101,7 @@ +keyserverStore: KeyserverStore, +threadActivityStore: ThreadActivityStore, +initialStateLoaded: boolean, + +integrityStore: IntegrityStore, }; export type Action = diff --git a/web/root.js b/web/root.js --- a/web/root.js +++ b/web/root.js @@ -9,6 +9,7 @@ import { persistReducer, persistStore } from 'redux-persist'; import thunk from 'redux-thunk'; +import IntegrityHandler from 'lib/components/integrity-handler.react.js'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger.js'; import App from './app.react.js'; @@ -41,6 +42,7 @@ +