mattermost-mobile/app/store/middleware.js
Amit Uttam 605cc2afd8
Run message retention cleanup off of pre-existing state (#4211)
Instead of a reconstructed "zero" state.

Only posts in channels, searched posts and flag posts are recalculated (as per data retention policy, if applicable). The rest of state is cloned from existing state.
2020-04-23 12:57:58 -03:00

279 lines
9.2 KiB
JavaScript

// 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 {
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;
const build = DeviceInfo.getBuildNumber();
const version = DeviceInfo.getVersion();
const previousVersion = app?.version;
action.payload = {
...action.payload,
app: {
build,
version,
previousVersion,
},
};
if (!entities || !views) {
return next(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 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} = action;
const nextState = Object.assign({}, payload);
const lastTeamId = payload.views?.team?.lastTeamId;
const lastChannelForTeam = getLastChannelForTeam(payload);
const currentChannelId = lastChannelForTeam[lastTeamId] && lastChannelForTeam[lastTeamId].length ? lastChannelForTeam[lastTeamId][0] : '';
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 (payload.entities.general && payload.entities.general.dataRetentionPolicy &&
payload.entities.general.dataRetentionPolicy.message_deletion_enabled) {
retentionPeriod = payload.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);
}
}
const nextSearch = {
...payload.entities.search,
results: searchResults,
flagged: flaggedPosts,
};
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;
}
nextState.entities = {
...nextState.entities,
...nextEntities,
search: nextSearch,
};
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);
}
}