// Copyright (c) 2015-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 initialState from 'app/initial_state'; import { captureException, LOGGER_JAVASCRIPT_WARNING, } from 'app/utils/sentry'; export function messageRetention(store) { return (next) => (action) => { if (action.type === 'persist/REHYDRATE') { const {app} = action.payload; const {entities, views} = action.payload; if (!entities || !views) { 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 let nextAction; try { nextAction = cleanupState(action); } catch (e) { // Sometimes, the payload is incomplete so log the error to Sentry and skip the cleanup console.warn(e); // eslint-disable-line no-console captureException(e, LOGGER_JAVASCRIPT_WARNING, store); nextAction = action; } return next(nextAction); } 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); let general = initialState.entities.general; if (payload.entities.general) { general = payload.entities.general; } let teams = initialState.entities.teams; if (payload.entities.teams) { teams = { currentTeamId: payload.entities.teams.currentTeamId, teams: payload.entities.teams.teams, myMembers: payload.entities.teams.myMembers, }; } let users = initialState.entities.users; if (payload.entities.users) { const currentUserId = payload.entities.users.currentUserId; if (currentUserId) { users = { currentUserId, profiles: { [currentUserId]: payload.entities.users.profiles[currentUserId], }, }; } } let preferences = initialState.entities.preferences; if (payload.entities.preferences) { preferences = payload.entities.preferences; } let roles = initialState.entities.roles; if (payload.entities.roles) { roles = payload.entities.roles; } let search = initialState.entities.search; if (payload.entities.search && payload.entities.search.recent) { search = { recent: payload.entities.search.recent, }; } let channelDrafts = initialState.views.channel.drafts; if (payload.views.channel && payload.views.channel.drafts) { channelDrafts = payload.views.channel.drafts; } let i18n = initialState.views.i18n; if (payload.views.i18n) { i18n = payload.views.i18n; } let lastTeamId = initialState.views.team.lastTeamId; if (payload.views.team && payload.views.team.lastTeamId) { lastTeamId = payload.views.team.lastTeamId; } const currentChannelId = lastChannelForTeam[lastTeamId] && lastChannelForTeam[lastTeamId].length ? lastChannelForTeam[lastTeamId][0] : ''; let channels = initialState.entities.channels; if (payload.entities.channels && currentChannelId) { channels = { currentChannelId, channels: { [currentChannelId]: payload.entities.channels.channels[currentChannelId], }, myMembers: { [currentChannelId]: payload.entities.channels.myMembers[currentChannelId], }, }; } let threadDrafts = initialState.views.thread.drafts; if (payload.views.thread && payload.views.thread.drafts) { threadDrafts = payload.views.thread.drafts; } let selectServer = initialState.views.selectServer; if (payload.views.selectServer) { selectServer = payload.views.selectServer; } let recentEmojis = initialState.views.recentEmojis; if (payload.views.recentEmojis) { recentEmojis = payload.views.recentEmojis; } const nextState = { app: { build: DeviceInfo.getBuildNumber(), version: DeviceInfo.getVersion(), }, entities: { channels, general, teams, users, preferences, search, roles, }, views: { channel: { drafts: channelDrafts, }, i18n, team: { lastTeamId, lastChannelForTeam, }, thread: { drafts: threadDrafts, }, selectServer, recentEmojis, }, }; 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 {lastChannelForTeam} = resetPayload.views.team; const nextEntities = { posts: { posts: {}, postsInChannel: {}, postsInThread: {}, reactions: {}, openGraph: payload.entities.posts.openGraph, selectedPostId: payload.entities.posts.selectedPostId, currentFocusedPostId: payload.entities.posts.currentFocusedPostId, }, files: { files: {}, fileIdsByPostId: {}, }, }; let retentionPeriod = 0; if (resetPayload.entities.general && resetPayload.entities.general.dataRetentionPolicy && resetPayload.entities.general.dataRetentionPolicy.message_deletion_enabled) { retentionPeriod = resetPayload.entities.general.dataRetentionPolicy.message_retention_cutoff; } 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 (!nextEntities.posts.postsInChannel[id] && payload.entities.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); } nextEntities.posts.postsInChannel[id] = postIds; return result.concat(postIds); } return result; }, []); return array.concat(ids); }, []); let searchResults = []; let flaggedPosts = []; if (payload.entities.search) { if (payload.entities.search.results.length) { const {results} = payload.entities.search; searchResults = results; postIdsToKeep.push(...results); } if (payload.entities.search.flagged.length) { const {flagged} = payload.entities.search; flaggedPosts = flagged; postIdsToKeep.push(...flagged); } } postIdsToKeep.forEach((postId) => { const post = payload.entities.posts.posts[postId]; if (post) { if (retentionPeriod && post.create_at < retentionPeriod) { const postsInChannel = nextEntities.posts.postsInChannel[post.channel_id] || []; const index = postsInChannel.indexOf(postId); if (index !== -1) { postsInChannel.splice(index, 1); } return; } nextEntities.posts.posts[postId] = post; const reaction = payload.entities.posts.reactions[postId]; if (reaction) { nextEntities.posts.reactions[postId] = reaction; } const fileIds = payload.entities.files.fileIdsByPostId[postId]; if (fileIds) { nextEntities.files.fileIdsByPostId[postId] = fileIds; fileIds.forEach((fileId) => { nextEntities.files.files[fileId] = payload.entities.files.files[fileId]; }); } const postsInThread = payload.entities.posts.postsInThread[postId]; if (postsInThread) { nextEntities.posts.postsInThread[postId] = postsInThread; } } else { // If the post is not in the store we need to remove it from the postsInChannel const channelIds = Object.keys(nextEntities.posts.postsInChannel); for (let i = 0; i < channelIds.length; i++) { const channelId = channelIds[i]; const posts = nextEntities.posts.postsInChannel[channelId]; const index = posts.indexOf(postId); if (index !== -1) { posts.splice(index, 1); break; } } } }); // remove any pending posts that hasn't failed if (payload.entities.posts && payload.entities.posts.pendingPostIds && payload.entities.posts.pendingPostIds.length) { const nextPendingPostIds = [...payload.entities.posts.pendingPostIds]; payload.entities.posts.pendingPostIds.forEach((id) => { const posts = nextEntities.posts.posts; const post = posts[id]; if (post) { const postsInChannel = [...nextEntities.posts.postsInChannel[post.channel_id]] || []; if (!post.failed) { Reflect.deleteProperty(posts, id); const index = postsInChannel.indexOf(id); if (index !== -1) { postsInChannel.splice(index, 1); nextEntities.posts.postsInChannel[post.channel_id] = postsInChannel; } removePendingPost(nextPendingPostIds, id); } } else { removePendingPost(nextPendingPostIds, id); } }); nextEntities.posts.pendingPostIds = nextPendingPostIds; } const nextState = { app: resetPayload.app, entities: { ...nextEntities, channels: payload.entities.channels, emojis: payload.entities.emojis, general: resetPayload.entities.general, preferences: resetPayload.entities.preferences, search: { ...resetPayload.entities.search, results: searchResults, flagged: flaggedPosts, }, teams: resetPayload.entities.teams, users: payload.entities.users, roles: resetPayload.entities.roles, }, views: { announcement: payload.views.announcement, ...resetPayload.views, channel: { ...resetPayload.views.channel, ...payload.views.channel, }, }, }; nextState.errors = payload.errors; return { type: action.type, payload: nextState, error: action.error, }; } function removePendingPost(pendingPostIds, id) { const pendingIndex = pendingPostIds.indexOf(id); if (pendingIndex !== -1) { pendingPostIds.splice(pendingIndex, 1); } }