diff --git a/app/initial_state.js b/app/initial_state.js index 62cd3dc58..2a1abf9fc 100644 --- a/app/initial_state.js +++ b/app/initial_state.js @@ -139,7 +139,6 @@ 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 e7aaba0a2..6a45aaa6c 100644 --- a/app/reducers/views/root.js +++ b/app/reducers/views/root.js @@ -26,17 +26,7 @@ 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 1e09e69b3..2b6c86152 100644 --- a/app/store/middleware.js +++ b/app/store/middleware.js @@ -1,17 +1,147 @@ // 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 {ViewTypes} from 'app/constants'; +import {NavigationTypes, 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 { captureException, LOGGER_JAVASCRIPT_WARNING, } from 'app/utils/sentry'; -export function messageRetention(store) { +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) => { return (next) => (action) => { if (action.type === 'persist/REHYDRATE') { const {app} = action.payload; @@ -55,7 +185,7 @@ export function messageRetention(store) { return next(action); }; -} +}; function resetStateForNewVersion(action) { const {payload} = action; @@ -434,3 +564,17 @@ 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 55d793097..38f38668a 100644 --- a/app/store/middleware.test.js +++ b/app/store/middleware.test.js @@ -10,10 +10,12 @@ import { cleanUpPostsInChannel, cleanUpState, getAllFromPostsInChannel, - messageRetention, + middlewares, } 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 a3a1a65af..fbab8f025 100644 --- a/app/store/store.js +++ b/app/store/store.js @@ -1,29 +1,21 @@ // 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 {ErrorTypes, GeneralTypes} from '@mm-redux/action_types'; import {General} from '@mm-redux/constants'; import {getConfig} from '@mm-redux/selectors/entities/general'; import configureStore from '@mm-redux/store'; -import EventEmitter from '@mm-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 mattermostBucket from 'app/mattermost_bucket'; - -import {messageRetention} from './middleware'; +import {middlewares} from './middleware'; import {createThunkMiddleware} from './thunk'; -import {transformSet, getStateForReset} from './utils'; +import {transformSet} from './utils'; function getAppReducer() { return require('../../app/reducers'); // eslint-disable-line global-require @@ -50,233 +42,140 @@ 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', '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'], - debounce: 500, - transforms: [ - setTransformer, - viewsBlackListFilter, - typingBlackListFilter, - channelViewBlackListFilter, - emojiBlackListFilter, - ], - }, - }; - const clientOptions = { additionalMiddleware: [ createThunkMiddleware(), createSentryMiddleware(), - messageRetention, + ...middlewares(persistConfig), ], enableThunk: false, // We override the default thunk middleware }; - return configureStore(initialState, appReducer, offlineOptions, getAppReducer, clientOptions); + return configureStore(initialState, appReducer, persistConfig, getAppReducer, clientOptions); }