diff --git a/app/initial_state.js b/app/initial_state.js index 7cb0aa85f..e9215a0e5 100644 --- a/app/initial_state.js +++ b/app/initial_state.js @@ -143,6 +143,7 @@ const state = { root: { deepLinkURL: '', hydrationComplete: false, + purge: false, }, selectServer: { serverUrl: Config.DefaultServerUrl, diff --git a/app/reducers/views/root.js b/app/reducers/views/root.js index f66efe3ee..e66408d2e 100644 --- a/app/reducers/views/root.js +++ b/app/reducers/views/root.js @@ -26,7 +26,17 @@ function hydrationComplete(state = false, action) { } } +function purge(state = false, action) { + switch (action.type) { + case General.OFFLINE_STORE_PURGE: + return true; + default: + return state; + } +} + export default combineReducers({ deepLinkURL, hydrationComplete, + purge, }); diff --git a/app/store/middleware.js b/app/store/middleware.js index f7cb06280..88381d304 100644 --- a/app/store/middleware.js +++ b/app/store/middleware.js @@ -1,23 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Platform} from 'react-native'; import DeviceInfo from 'react-native-device-info'; -import {batchActions} from 'redux-batched-actions'; -import AsyncStorage from '@react-native-community/async-storage'; -import {purgeStoredState} from 'redux-persist'; -import {NavigationTypes, ViewTypes} from 'app/constants'; +import {ViewTypes} from 'app/constants'; import initialState from 'app/initial_state'; -import {throttle} from 'app/utils/general'; - -import {General} from '@mm-redux/constants'; -import {ErrorTypes, GeneralTypes} from '@mm-redux/action_types'; -import EventEmitter from '@mm-redux/utils/event_emitter'; - -import mattermostBucket from 'app/mattermost_bucket'; - -import {getStateForReset} from './utils'; import EphemeralStore from './ephemeral_store'; @@ -26,124 +13,7 @@ import { LOGGER_JAVASCRIPT_WARNING, } from 'app/utils/sentry'; -const SAVE_STATE_ACTIONS = [ - 'CONNECTION_CHANGED', - 'DATA_CLEANUP', - 'LOGIN', - 'Offline/STATUS_CHANGED', - 'persist/REHYDRATE', - 'RECEIVED_APP_STATE', - 'WEBSOCKET_CLOSED', - 'WEBSOCKET_SUCCESS', -]; - -// This middleware stores key parts of state entities into a file (in the App Group container) on certain actions. -// iOS only. Allows the share extension to work, without having access available to the redux store object. -// Remove this middleware if/when state is moved to a persisted solution. -const saveShareExtensionState = (store) => { - return (next) => (action) => { - if (SAVE_STATE_ACTIONS.includes(action.type)) { - throttle(saveStateToFile(store)); - } - return next(action); - }; -}; - -const saveStateToFile = async (store) => { - if (Platform.OS === 'ios') { - const state = store.getState(); - - if (state.entities) { - const channelsInTeam = {...state.entities.channels.channelsInTeam}; - Object.keys(channelsInTeam).forEach((teamId) => { - channelsInTeam[teamId] = Array.from(channelsInTeam[teamId]); - }); - - const profilesInChannel = {...state.entities.users.profilesInChannel}; - Object.keys(profilesInChannel).forEach((channelId) => { - profilesInChannel[channelId] = Array.from(profilesInChannel[channelId]); - }); - - let url; - if (state.entities.users.currentUserId) { - url = state.entities.general.credentials.url || state.views.selectServer.serverUrl; - } - - const entities = { - ...state.entities, - general: { - ...state.entities.general, - credentials: { - url, - }, - }, - channels: { - ...state.entities.channels, - channelsInTeam, - }, - users: { - ...state.entities.users, - profilesInChannel, - profilesNotInTeam: [], - profilesWithoutTeam: [], - profilesNotInChannel: [], - }, - }; - - mattermostBucket.writeToFile('entities', JSON.stringify(entities)); - } - } -}; - -const purgeAppCacheWrapper = (persistConfig) => (store) => { - return (next) => (action) => { - if (action.type === General.OFFLINE_STORE_PURGE) { - purgeStoredState({...persistConfig, storage: AsyncStorage}); - - const state = store.getState(); - const resetState = getStateForReset(initialState, state); - - store.dispatch(batchActions([ - { - type: General.OFFLINE_STORE_RESET, - data: resetState, - }, - { - type: ErrorTypes.RESTORE_ERRORS, - data: [...state.errors], - }, - { - type: GeneralTypes.RECEIVED_APP_DEVICE_TOKEN, - data: state.entities.general.deviceToken, - }, - { - type: GeneralTypes.RECEIVED_APP_CREDENTIALS, - data: { - url: state.entities.general.credentials.url, - }, - }, - { - type: ViewTypes.SERVER_URL_CHANGED, - serverUrl: state.entities.general.credentials.url || state.views.selectServer.serverUrl, - }, - { - type: GeneralTypes.RECEIVED_SERVER_VERSION, - data: state.entities.general.serverVersion, - }, - { - type: General.STORE_REHYDRATION_COMPLETE, - }, - ], 'BATCH_FOR_RESTART')); - - setTimeout(() => { - EventEmitter.emit(NavigationTypes.RESTART_APP); - }, 500); - } - return next(action); - }; -}; - -const messageRetention = (store) => { +export function messageRetention(store) { return (next) => (action) => { if (action.type === 'persist/REHYDRATE') { const {app} = action.payload; @@ -188,7 +58,7 @@ const messageRetention = (store) => { return next(action); }; -}; +} function resetStateForNewVersion(action) { const {payload} = action; @@ -564,17 +434,3 @@ function removePendingPost(pendingPostIds, id) { pendingPostIds.splice(pendingIndex, 1); } } - -export const middlewares = (persistConfig) => { - const middlewareFunctions = [ - messageRetention, - purgeAppCacheWrapper(persistConfig), - ]; - - if (Platform.OS === 'ios') { - middlewareFunctions.push(saveShareExtensionState); - } - - return middlewareFunctions; -}; - diff --git a/app/store/middleware.test.js b/app/store/middleware.test.js index 38f38668a..55d793097 100644 --- a/app/store/middleware.test.js +++ b/app/store/middleware.test.js @@ -10,12 +10,10 @@ import { cleanUpPostsInChannel, cleanUpState, getAllFromPostsInChannel, - middlewares, + messageRetention, } from 'app/store/middleware'; describe('messageRetention', () => { - const messageRetention = middlewares()[0]; - describe('should chain the same incoming action type', () => { const actions = [ { diff --git a/app/store/store.js b/app/store/store.js index fbab8f025..1470c89f5 100644 --- a/app/store/store.js +++ b/app/store/store.js @@ -1,21 +1,29 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {batchActions} from 'redux-batched-actions'; +import {Platform} from 'react-native'; import AsyncStorage from '@react-native-community/async-storage'; import {createBlacklistFilter} from 'redux-persist-transform-filter'; import {createTransform, persistStore} from 'redux-persist'; -import {General} from '@mm-redux/constants'; -import {getConfig} from '@mm-redux/selectors/entities/general'; -import configureStore from '@mm-redux/store'; +import {ErrorTypes, GeneralTypes} from 'mattermost-redux/action_types'; +import {General} from 'mattermost-redux/constants'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import configureStore from 'mattermost-redux/store'; +import EventEmitter from 'mattermost-redux/utils/event_emitter'; +import {NavigationTypes, ViewTypes} from 'app/constants'; import appReducer from 'app/reducers'; +import {throttle} from 'app/utils/general'; import {getSiteUrl, setSiteUrl} from 'app/utils/image_cache_manager'; import {createSentryMiddleware} from 'app/utils/sentry/middleware'; -import {middlewares} from './middleware'; +import mattermostBucket from 'app/mattermost_bucket'; + +import {messageRetention} from './middleware'; import {createThunkMiddleware} from './thunk'; -import {transformSet} from './utils'; +import {transformSet, getStateForReset} from './utils'; function getAppReducer() { return require('../../app/reducers'); // eslint-disable-line global-require @@ -42,140 +50,232 @@ const setTransforms = [ ...rolesSetTransform, ]; -const viewsBlackListFilter = createBlacklistFilter( - 'views', - ['extension', 'root'], -); - -const typingBlackListFilter = createBlacklistFilter( - 'entities', - ['typing'], -); - -const channelViewBlackList = {loading: true, refreshing: true, loadingPosts: true, retryFailed: true, loadMorePostsVisible: true}; -const channelViewBlackListFilter = createTransform( - (inboundState) => { - const channel = {}; - - for (const channelKey of Object.keys(inboundState.channel)) { - if (!channelViewBlackList[channelKey]) { - channel[channelKey] = inboundState.channel[channelKey]; - } - } - - return { - ...inboundState, - channel, - }; - }, - null, - {whitelist: ['views']}, // Only run this filter on the views state (or any other entry that ends up being named views) -); - -const emojiBlackList = {nonExistentEmoji: true}; -const emojiBlackListFilter = createTransform( - (inboundState) => { - const emojis = {}; - - for (const emojiKey of Object.keys(inboundState.emojis)) { - if (!emojiBlackList[emojiKey]) { - emojis[emojiKey] = inboundState.emojis[emojiKey]; - } - } - - return { - ...inboundState, - emojis, - }; - }, - null, - {whitelist: ['entities']}, // Only run this filter on the entities state (or any other entry that ends up being named entities) -); - -const setTransformer = createTransform( - (inboundState, key) => { - if (key === 'entities') { - const state = {...inboundState}; - for (const prop in state) { - if (state.hasOwnProperty(prop)) { - state[prop] = transformSet(state[prop], setTransforms); - } - } - - return state; - } - - return inboundState; - }, - (outboundState, key) => { - if (key === 'entities') { - const state = {...outboundState}; - for (const prop in state) { - if (state.hasOwnProperty(prop)) { - state[prop] = transformSet(state[prop], setTransforms, false); - } - } - - return state; - } - - return outboundState; - }, -); - -const persistConfig = { - effect: (effect, action) => { - if (typeof effect !== 'function') { - throw new Error('Offline Action: effect must be a function.'); - } else if (!action.meta.offline.commit) { - throw new Error('Offline Action: commit action must be present.'); - } - - return effect(); - }, - persist: (store, options) => { - const persistor = persistStore(store, {storage: AsyncStorage, ...options}, () => { - store.dispatch({ - type: General.STORE_REHYDRATION_COMPLETE, - }); - }); - - store.subscribe(async () => { - const state = store.getState(); - const config = getConfig(state); - - if (getSiteUrl() !== config?.SiteURL) { - setSiteUrl(config.SiteURL); - } - }); - - return persistor; - }, - persistOptions: { - autoRehydrate: { - log: false, - }, - blacklist: ['device', 'navigation', 'offline', 'requests'], - debounce: 500, - transforms: [ - setTransformer, - viewsBlackListFilter, - typingBlackListFilter, - channelViewBlackListFilter, - emojiBlackListFilter, - ], - }, -}; - export default function configureAppStore(initialState) { + const viewsBlackListFilter = createBlacklistFilter( + 'views', + ['extension', 'login', 'root'], + ); + + const typingBlackListFilter = createBlacklistFilter( + 'entities', + ['typing'], + ); + + const channelViewBlackList = {loading: true, refreshing: true, loadingPosts: true, retryFailed: true, loadMorePostsVisible: true}; + const channelViewBlackListFilter = createTransform( + (inboundState) => { + const channel = {}; + + for (const channelKey of Object.keys(inboundState.channel)) { + if (!channelViewBlackList[channelKey]) { + channel[channelKey] = inboundState.channel[channelKey]; + } + } + + return { + ...inboundState, + channel, + }; + }, + null, + {whitelist: ['views']}, // Only run this filter on the views state (or any other entry that ends up being named views) + ); + + const emojiBlackList = {nonExistentEmoji: true}; + const emojiBlackListFilter = createTransform( + (inboundState) => { + const emojis = {}; + + for (const emojiKey of Object.keys(inboundState.emojis)) { + if (!emojiBlackList[emojiKey]) { + emojis[emojiKey] = inboundState.emojis[emojiKey]; + } + } + + return { + ...inboundState, + emojis, + }; + }, + null, + {whitelist: ['entities']}, // Only run this filter on the entities state (or any other entry that ends up being named entities) + ); + + const setTransformer = createTransform( + (inboundState, key) => { + if (key === 'entities') { + const state = {...inboundState}; + for (const prop in state) { + if (state.hasOwnProperty(prop)) { + state[prop] = transformSet(state[prop], setTransforms); + } + } + + return state; + } + + return inboundState; + }, + (outboundState, key) => { + if (key === 'entities') { + const state = {...outboundState}; + for (const prop in state) { + if (state.hasOwnProperty(prop)) { + state[prop] = transformSet(state[prop], setTransforms, false); + } + } + + return state; + } + + return outboundState; + }, + ); + + const offlineOptions = { + effect: (effect, action) => { + if (typeof effect !== 'function') { + throw new Error('Offline Action: effect must be a function.'); + } else if (!action.meta.offline.commit) { + throw new Error('Offline Action: commit action must be present.'); + } + + return effect(); + }, + persist: (store, options) => { + const persistor = persistStore(store, {storage: AsyncStorage, ...options}, () => { + store.dispatch({ + type: General.STORE_REHYDRATION_COMPLETE, + }); + }); + + let purging = false; + + // for iOS write the entities to a shared file + if (Platform.OS === 'ios') { + store.subscribe(throttle(() => { + const state = store.getState(); + if (state.entities) { + const channelsInTeam = {...state.entities.channels.channelsInTeam}; + Object.keys(channelsInTeam).forEach((teamId) => { + channelsInTeam[teamId] = Array.from(channelsInTeam[teamId]); + }); + + const profilesInChannel = {...state.entities.users.profilesInChannel}; + Object.keys(profilesInChannel).forEach((channelId) => { + profilesInChannel[channelId] = Array.from(profilesInChannel[channelId]); + }); + + let url; + if (state.entities.users.currentUserId) { + url = state.entities.general.credentials.url || state.views.selectServer.serverUrl; + } + + const entities = { + ...state.entities, + general: { + ...state.entities.general, + credentials: { + url, + }, + }, + channels: { + ...state.entities.channels, + channelsInTeam, + }, + users: { + ...state.entities.users, + profilesInChannel, + profilesNotInTeam: [], + profilesWithoutTeam: [], + profilesNotInChannel: [], + }, + }; + mattermostBucket.writeToFile('entities', JSON.stringify(entities)); + } + }, 1000)); + } + + // check to see if the logout request was successful + store.subscribe(async () => { + const state = store.getState(); + const config = getConfig(state); + + if (getSiteUrl() !== config?.SiteURL) { + setSiteUrl(config.SiteURL); + } + + if (state.views.root.purge && !purging) { + purging = true; + + await persistor.purge(); + + const resetState = getStateForReset(initialState, state); + + store.dispatch(batchActions([ + { + type: General.OFFLINE_STORE_RESET, + data: resetState, + }, + { + type: ErrorTypes.RESTORE_ERRORS, + data: [...state.errors], + }, + { + type: GeneralTypes.RECEIVED_APP_DEVICE_TOKEN, + data: state.entities.general.deviceToken, + }, + { + type: GeneralTypes.RECEIVED_APP_CREDENTIALS, + data: { + url: state.entities.general.credentials.url, + }, + }, + { + type: ViewTypes.SERVER_URL_CHANGED, + serverUrl: state.entities.general.credentials.url || state.views.selectServer.serverUrl, + }, + { + type: GeneralTypes.RECEIVED_SERVER_VERSION, + data: state.entities.general.serverVersion, + }, + { + type: General.STORE_REHYDRATION_COMPLETE, + }, + ], 'BATCH_FOR_RESTART')); + + setTimeout(() => { + purging = false; + EventEmitter.emit(NavigationTypes.RESTART_APP); + }, 500); + } + }); + + return persistor; + }, + persistOptions: { + autoRehydrate: { + log: false, + }, + blacklist: ['device', 'navigation', 'offline', 'requests'], + transforms: [ + setTransformer, + viewsBlackListFilter, + typingBlackListFilter, + channelViewBlackListFilter, + emojiBlackListFilter, + ], + }, + }; + const clientOptions = { additionalMiddleware: [ createThunkMiddleware(), createSentryMiddleware(), - ...middlewares(persistConfig), + messageRetention, ], enableThunk: false, // We override the default thunk middleware }; - return configureStore(initialState, appReducer, persistConfig, getAppReducer, clientOptions); + return configureStore(initialState, appReducer, offlineOptions, getAppReducer, clientOptions); }