diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index e1dbd8e2f..faaa1462f 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -223,7 +223,8 @@ export function selectInitialChannel(teamId) { const {channels, myMembers} = state.entities.channels; const {currentUserId} = state.entities.users; const {myPreferences} = state.entities.preferences; - const lastChannelId = state.views.team.lastChannelForTeam[teamId] || ''; + const lastChannelForTeam = state.views.team.lastChannelForTeam[teamId]; + const lastChannelId = lastChannelForTeam && lastChannelForTeam.length ? lastChannelForTeam[0] : ''; const lastChannel = channels[lastChannelId]; const isDMVisible = lastChannel && lastChannel.type === General.DM_CHANNEL && diff --git a/app/constants/view.js b/app/constants/view.js index 24f3b4f99..f44fa70b4 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -10,6 +10,7 @@ export const UpgradeTypes = { }; const ViewTypes = keyMirror({ + DATA_CLEANUP: null, SERVER_URL_CHANGED: null, LOGIN_ID_CHANGED: null, diff --git a/app/mattermost.js b/app/mattermost.js index 90a1b0677..dc35cfbee 100644 --- a/app/mattermost.js +++ b/app/mattermost.js @@ -194,8 +194,10 @@ export default class Mattermost { try { if (!isActive && !this.inBackgroundSince) { this.inBackgroundSince = Date.now(); + dispatch({type: ViewTypes.DATA_CLEANUP, payload: getState()}); } else if (isActive && this.inBackgroundSince && (Date.now() - this.inBackgroundSince) >= AUTHENTICATION_TIMEOUT) { this.inBackgroundSince = null; + if (this.mdmEnabled) { const config = await mattermostManaged.getConfig(); const authNeeded = config.inAppPinCode && config.inAppPinCode === 'true'; @@ -206,6 +208,8 @@ export default class Mattermost { } } } + } else if (isActive) { + this.inBackgroundSince = null; } } catch (error) { // do nothing diff --git a/app/reducers/app/build.js b/app/reducers/app/build.js new file mode 100644 index 000000000..2fb04f960 --- /dev/null +++ b/app/reducers/app/build.js @@ -0,0 +1,7 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export default function build(state = '') { + return state; +} + diff --git a/app/reducers/app/index.js b/app/reducers/app/index.js new file mode 100644 index 000000000..52264ca07 --- /dev/null +++ b/app/reducers/app/index.js @@ -0,0 +1,12 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; + +import build from './build'; +import version from './version'; + +export default combineReducers({ + build, + version +}); diff --git a/app/reducers/app/version.js b/app/reducers/app/version.js new file mode 100644 index 000000000..1b8c813f4 --- /dev/null +++ b/app/reducers/app/version.js @@ -0,0 +1,7 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export default function version(state = '') { + return state; +} + diff --git a/app/reducers/index.js b/app/reducers/index.js index e80bcabcf..5c66152e1 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -1,11 +1,13 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import app from './app'; import device from './device'; import navigation from './navigation'; import views from './views'; export default { + app, device, navigation, views diff --git a/app/reducers/views/channel.js b/app/reducers/views/channel.js index 870624819..9c2b908c2 100644 --- a/app/reducers/views/channel.js +++ b/app/reducers/views/channel.js @@ -4,7 +4,8 @@ import {combineReducers} from 'redux'; import { ChannelTypes, - FileTypes + FileTypes, + PostTypes } from 'mattermost-redux/action_types'; import {ViewTypes} from 'app/constants'; @@ -238,6 +239,14 @@ function retryFailed(state = false, action) { switch (action.type) { case ViewTypes.SET_CHANNEL_RETRY_FAILED: return action.failed; + case PostTypes.GET_POSTS_SUCCESS: + case PostTypes.GET_POSTS_SINCE_SUCCESS: { + if (state) { + return false; + } + + return state; + } default: return state; } diff --git a/app/reducers/views/team.js b/app/reducers/views/team.js index c5d9184e9..12c4a03ec 100644 --- a/app/reducers/views/team.js +++ b/app/reducers/views/team.js @@ -17,11 +17,33 @@ function lastTeamId(state = '', action) { function lastChannelForTeam(state = {}, action) { switch (action.type) { - case ViewTypes.SET_LAST_CHANNEL_FOR_TEAM: + case ViewTypes.SET_LAST_CHANNEL_FOR_TEAM: { + const team = state[action.teamId]; + const channelIds = []; + + if (!action.channelId) { + return state; + } + + if (team) { + channelIds.push(...team); + const index = channelIds.indexOf(action.channelId); + if (index === -1) { + channelIds.unshift(action.channelId); + channelIds.slice(0, 5); + } else { + channelIds.splice(index, 1); + channelIds.unshift(action.channelId); + } + } else { + channelIds.push(action.channelId); + } + return { ...state, - [action.teamId]: action.channelId + [action.teamId]: channelIds }; + } default: return state; } diff --git a/app/screens/channel/index.js b/app/screens/channel/index.js index 8717e0104..f0584fc87 100644 --- a/app/screens/channel/index.js +++ b/app/screens/channel/index.js @@ -51,27 +51,4 @@ function mapDispatchToProps(dispatch) { }; } -function areStatesEqual(next, prev) { - // When switching teams - if (next.entities.teams.currentTeamId !== prev.entities.teams.currentTeamId) { - return false; - } - - // When we have a new channel after switching teams - const prevChannelId = prev.entities.channels.currentChannelId; - const nextChannelId = next.entities.channels.currentChannelId; - if (nextChannelId !== prevChannelId) { - return false; - } - - // When getting the channels for a team and the request fails - const prevStatus = prev.requests.channels.myChannels.status; - const nextStatus = next.requests.channels.myChannels.status; - if (!nextChannelId && prevStatus === RequestStatus.STARTED && nextStatus === RequestStatus.FAILURE) { - return false; - } - - return true; -} - -export default connect(mapStateToProps, mapDispatchToProps, null, {pure: true, areStatesEqual})(Channel); +export default connect(mapStateToProps, mapDispatchToProps)(Channel); diff --git a/app/store/index.js b/app/store/index.js index 25416ff94..e1fce8ba6 100644 --- a/app/store/index.js +++ b/app/store/index.js @@ -16,6 +16,7 @@ import appReducer from 'app/reducers'; import networkConnectionListener from 'app/utils/network'; import {createSentryMiddleware} from 'app/utils/sentry/middleware'; +import {messageRetention} from './middleware'; import {transformSet} from './utils'; function getAppReducer() { @@ -193,7 +194,8 @@ export default function configureAppStore(initialState) { } }; + const additionalMiddleware = [createSentryMiddleware(), messageRetention]; return configureStore(initialState, appReducer, offlineOptions, getAppReducer, { - additionalMiddleware: createSentryMiddleware() + additionalMiddleware }); } diff --git a/app/store/middleware.js b/app/store/middleware.js new file mode 100644 index 000000000..7696c2e5d --- /dev/null +++ b/app/store/middleware.js @@ -0,0 +1,201 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import DeviceInfo from 'react-native-device-info'; + +import {ViewTypes} from 'app/constants'; +import Config from 'assets/config'; + +export function messageRetention() { + return (next) => (action) => { + if (action.type === 'persist/REHYDRATE') { + const {app} = action.payload; + const {entities} = action.payload; + + if (!entities) { + return next(action); + } + + // When a new version of the app has been detected + if (!app || !app.version || app.version !== DeviceInfo.getVersion() || app.build !== DeviceInfo.getBuildNumber()) { + return next(resetStateForNewVersion(action)); + } + + // Keep only the last 60 messages for the last 5 viewed channels in each team + // and apply data retention on those posts if applies + return next(cleanupState(action)); + } else if (action.type === ViewTypes.DATA_CLEANUP) { + const nextAction = cleanupState(action, true); + return next(nextAction); + } + + return next(action); + }; +} + +function resetStateForNewVersion(action) { + const {payload} = action; + const lastChannelForTeam = getLastChannelForTeam(payload); + const currentUserId = payload.entities.users.currentUserId; + + const nextState = { + app: { + build: DeviceInfo.getBuildNumber(), + version: DeviceInfo.getVersion() + }, + entities: { + general: payload.entities.general, + teams: { + currentTeamId: payload.entities.teams.currentTeamId, + teams: payload.entities.teams.teams, + myMembers: payload.entities.teams.myMembers + }, + users: { + currentUserId, + profiles: { + [currentUserId]: payload.entities.users.profiles[currentUserId] + } + }, + preferences: payload.entities.preferences, + search: { + recent: payload.entities.search.recent + } + }, + views: { + channel: { + drafts: payload.views.channel.drafts + }, + i18n: payload.views.i18n, + fetchCache: payload.views.fetchCache, + team: { + lastTeamId: payload.views.team.lastTeamId, + lastChannelForTeam + }, + thread: { + drafts: payload.views.thread.drafts + }, + selectServer: payload.views.selectServer + } + }; + + return { + type: action.type, + payload: nextState, + error: action.error + }; +} + +function getLastChannelForTeam(payload) { + const lastChannelForTeam = {...payload.views.team.lastChannelForTeam}; + const convertLastChannelForTeam = Object.values(lastChannelForTeam).some((value) => !Array.isArray(value)); + + if (convertLastChannelForTeam) { + Object.keys(lastChannelForTeam).forEach((id) => { + lastChannelForTeam[id] = [lastChannelForTeam[id]]; + }); + } + + return lastChannelForTeam; +} + +function cleanupState(action, keepCurrent = false) { + const {payload: resetPayload} = resetStateForNewVersion(action); + const {payload} = action; + const {currentChannelId} = payload.entities.channels; + const {statuses, ...otherUsers} = payload.entities.users; //eslint-disable-line no-unused-vars + + const {lastChannelForTeam} = resetPayload.views.team; + const nextEntitites = { + posts: { + posts: {}, + postsInChannel: {}, + reactions: {}, + selectedPostId: payload.entities.posts.selectedPostId, + currentFocusedPostId: payload.entities.posts.currentFocusedPostId + }, + files: { + files: {}, + fileIdsByPostId: {} + } + }; + + const retentionPeriod = Config.EnableMessageRetention ? Config.MessageRetentionPeriod + 1 : 0; + const postIdsToKeep = Object.values(lastChannelForTeam).reduce((array, channelIds) => { + const ids = channelIds.reduce((result, id) => { + // we need to check that the channel id is not already included + // the reason it can be included is cause at least one of the last channels viewed + // in a team can be a DM or GM and the id can be duplicate + if (!nextEntitites.posts.postsInChannel[id]) { + let postIds; + if (keepCurrent && currentChannelId === id) { + postIds = payload.entities.posts.postsInChannel[id]; + } else { + postIds = payload.entities.posts.postsInChannel[id].slice(0, 60); + } + nextEntitites.posts.postsInChannel[id] = postIds; + return result.concat(postIds); + } + + return result; + }, []); + return array.concat(ids); + }, []); + + postIdsToKeep.forEach((postId) => { + const post = payload.entities.posts.posts[postId]; + const skip = keepCurrent && currentChannelId === post.channel_id; + + if (!skip && retentionPeriod && (Date.now() - post.create_at) / (1000 * 3600 * 24) > retentionPeriod) { + const postsInChannel = nextEntitites.posts.postsInChannel[post.channel_id]; + const index = postsInChannel.indexOf(postId); + if (index !== -1) { + postsInChannel.splice(index, 1); + } + return; + } + + nextEntitites.posts.posts[postId] = post; + + const reaction = payload.entities.posts.reactions[postId]; + if (reaction) { + nextEntitites.posts.reactions[postId] = reaction; + } + + const fileIds = payload.entities.files.fileIdsByPostId[postId]; + if (fileIds) { + nextEntitites.files.fileIdsByPostId[postId] = fileIds; + fileIds.forEach((fileId) => { + nextEntitites.files.files[fileId] = payload.entities.files.files[fileId]; + }); + } + }); + + const nextState = { + app: resetPayload.app, + entities: { + ...nextEntitites, + channels: payload.entities.channels, + emojis: payload.entities.emojis, + general: resetPayload.entities.general, + preferences: resetPayload.entities.preferences, + search: resetPayload.entities.search, + teams: resetPayload.entities.teams, + users: { + ...otherUsers + } + }, + views: { + ...resetPayload.views + } + }; + + if (keepCurrent) { + nextState.errors = payload.errors; + } + + return { + type: 'persist/REHYDRATE', + payload: nextState, + error: action.error + }; +} diff --git a/app/utils/network.js b/app/utils/network.js index c1963f326..a517929de 100644 --- a/app/utils/network.js +++ b/app/utils/network.js @@ -1,9 +1,9 @@ import {NetInfo} from 'react-native'; -import {Client4} from 'mattermost-redux/client'; - export async function checkConnection() { - const server = Client4.getUrl() || 'https://www.google.com'; + // If the websocket cannot connect probably is because the Mattermost server + // is down and we don't want to make the app think the device is offline + const server = 'https://www.google.com'; try { await fetch(server); diff --git a/assets/base/config.json b/assets/base/config.json index 6bc89e57d..a07e7ffa1 100644 --- a/assets/base/config.json +++ b/assets/base/config.json @@ -1,5 +1,7 @@ { "DefaultServerUrl": "", + "EnableMessageRetention": false, + "MessageRetentionPeriod": 30, "TestServerUrl": "http://localhost:8065", "DefaultTheme": "default", "ShowErrorsList": false,