// 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, }, root: { hydrationComplete: true, }, selectServer, recentEmojis, }, websocket: { lastConnectAt: payload.websocket?.lastConnectAt, lastDisconnectAt: payload.websocket?.lastDisconnectAt, }, }; 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; } export 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 = []; // Keep the last 60 posts in each recently viewed channel nextEntities.posts.postsInChannel = cleanUpPostsInChannel(payload.entities.posts.postsInChannel, lastChannelForTeam, keepCurrent ? currentChannelId : ''); postIdsToKeep.push(...getAllFromPostsInChannel(nextEntities.posts.postsInChannel)); // Keep any posts that appear in search results 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) { // This post has been removed by data retention, so don't keep it removeFromPostsInChannel(nextEntities.posts.postsInChannel, post.channel_id, postId); return; } // Keep the post nextEntities.posts.posts[postId] = post; // And its reactions const reaction = payload.entities.posts.reactions[postId]; if (reaction) { nextEntities.posts.reactions[postId] = reaction; } // And its files 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]; }); } // And its comments const postsInThread = payload.entities.posts.postsInThread[postId]; if (postsInThread) { nextEntities.posts.postsInThread[postId] = postsInThread; } } }); // Remove any pending posts that haven'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 && !post.failed) { Reflect.deleteProperty(posts, id); removeFromPostsInChannel(nextEntities.posts.postsInChannel, post.channel_id, id); removePendingPost(nextPendingPostIds, id); } else if (!post) { 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, }, }, websocket: { lastConnectAt: payload.websocket?.lastConnectAt, lastDisconnectAt: payload.websocket?.lastDisconnectAt, }, }; nextState.errors = payload.errors; return { type: action.type, payload: nextState, error: action.error, }; } // cleanUpPostsInChannel returns a copy of postsInChannel where only the most recent posts in each channel are kept export function cleanUpPostsInChannel(postsInChannel, lastChannelForTeam, currentChannelId, recentPostCount = 60) { const nextPostsInChannel = {}; for (const channelIds of Object.values(lastChannelForTeam)) { for (const channelId of channelIds) { if (nextPostsInChannel[channelId]) { // This is a DM or GM channel that we've already seen on another team continue; } const postsForChannel = postsInChannel[channelId]; if (!postsForChannel) { // We don't have anything to keep for this channel continue; } let nextPostsForChannel; if (channelId === currentChannelId) { // Keep all of the posts for this channel nextPostsForChannel = postsForChannel; } else { // Only keep the most recent posts for this channel const recentBlock = postsForChannel.find((block) => block.recent); if (!recentBlock) { // We don't have recent posts for this channel continue; } nextPostsForChannel = [{ ...recentBlock, order: recentBlock.order.slice(0, recentPostCount), }]; } nextPostsInChannel[channelId] = nextPostsForChannel; } } return nextPostsInChannel; } // getAllFromPostsInChannel returns an array of all post IDs found in postsInChannel export function getAllFromPostsInChannel(postsInChannel) { const postIds = []; for (const postsForChannel of Object.values(postsInChannel)) { for (const block of postsForChannel) { postIds.push(...block.order); } } return postIds; } function removeFromPostsInChannel(postsInChannel, channelId, postId) { const postsForChannel = postsInChannel[channelId]; if (!postsForChannel) { return; } // Since this has already gone through cleanUpPostsInChannel, we know that there can only be one block to look at const index = postsForChannel[0].order.indexOf(postId); if (index !== -1) { postsForChannel[0].order.splice(index, 1); } } function removePendingPost(pendingPostIds, id) { const pendingIndex = pendingPostIds.indexOf(id); if (pendingIndex !== -1) { pendingPostIds.splice(pendingIndex, 1); } }