mattermost-redux
Version:
Common code (API client, Redux stores, logic, utility functions) for building a Mattermost client
1,255 lines (1,254 loc) • 58.3 kB
JavaScript
"use strict";
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
Object.defineProperty(exports, "__esModule", { value: true });
exports.zeroStateLimitedViews = void 0;
exports.removeUnneededMetadata = removeUnneededMetadata;
exports.nextPostsReplies = nextPostsReplies;
exports.handlePosts = handlePosts;
exports.handlePendingPosts = handlePendingPosts;
exports.postsInChannel = postsInChannel;
exports.removeNonRecentEmptyPostBlocks = removeNonRecentEmptyPostBlocks;
exports.mergePostBlocks = mergePostBlocks;
exports.mergePostOrder = mergePostOrder;
exports.postsInThread = postsInThread;
exports.postEditHistory = postEditHistory;
exports.reactions = reactions;
exports.acknowledgements = acknowledgements;
exports.openGraph = openGraph;
exports.limitedViews = limitedViews;
exports.default = reducer;
const action_types_1 = require("mattermost-redux/action_types");
const constants_1 = require("mattermost-redux/constants");
const posts_1 = require("mattermost-redux/constants/posts");
const post_utils_1 = require("mattermost-redux/utils/post_utils");
function removeUnneededMetadata(post) {
if (!post.metadata) {
return post;
}
const metadata = { ...post.metadata };
let changed = false;
// These fields are stored separately
if (metadata.emojis) {
Reflect.deleteProperty(metadata, 'emojis');
changed = true;
}
if (metadata.files) {
Reflect.deleteProperty(metadata, 'files');
changed = true;
}
if (metadata.reactions) {
Reflect.deleteProperty(metadata, 'reactions');
changed = true;
}
if (metadata.reactions) {
Reflect.deleteProperty(metadata, 'acknowledgements');
changed = true;
}
if (metadata.embeds) {
let embedsChanged = false;
const newEmbeds = metadata.embeds.map((embed) => {
switch (embed.type) {
case 'opengraph': {
const newEmbed = { ...embed };
Reflect.deleteProperty(newEmbed, 'data');
embedsChanged = true;
return newEmbed;
}
case 'permalink': {
const permalinkEmbed = { ...embed };
if (permalinkEmbed.data) {
Reflect.deleteProperty(permalinkEmbed.data, 'post');
}
embedsChanged = true;
return permalinkEmbed;
}
default:
return embed;
}
});
if (embedsChanged) {
metadata.embeds = newEmbeds;
changed = true;
}
}
if (!changed) {
// Nothing changed
return post;
}
return {
...post,
metadata,
};
}
function nextPostsReplies(state = {}, action) {
switch (action.type) {
case action_types_1.PostTypes.RECEIVED_POST:
case action_types_1.PostTypes.RECEIVED_NEW_POST: {
const post = action.data;
if (!post.id || !post.root_id || !post.reply_count) {
// Ignoring pending posts and root posts
return state;
}
const newState = { ...state };
newState[post.root_id] = post.reply_count;
return newState;
}
case action_types_1.PostTypes.RECEIVED_POSTS: {
const posts = Object.values(action.data.posts);
if (posts.length === 0) {
return state;
}
const nextState = { ...state };
for (const post of posts) {
if (post.root_id) {
nextState[post.root_id] = post.reply_count;
}
else {
nextState[post.id] = post.reply_count;
}
}
return nextState;
}
case action_types_1.PostTypes.POST_DELETED: {
const post = action.data;
if (!state[post.root_id] && !state[post.id]) {
return state;
}
const nextState = { ...state };
if (post.root_id && state[post.root_id]) {
nextState[post.root_id] -= 1;
}
if (!post.root_id && state[post.id]) {
Reflect.deleteProperty(nextState, post.id);
}
return nextState;
}
case action_types_1.UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
// Helper function to remove posts and permalink embeds for a set of channel IDs.
function removePostsAndEmbedsForChannels(state, channelIds) {
let postModified = false;
const nextState = { ...state };
for (const post of Object.values(state)) {
// Remove posts from the channels
if (channelIds.has(post.channel_id)) {
Reflect.deleteProperty(nextState, post.id);
postModified = true;
continue;
}
// Remove permalink embeds referencing those channels (matches server behavior)
if (post.metadata?.embeds?.length) {
const newEmbeds = [];
let embedRemoved = false;
for (const embed of post.metadata.embeds) {
if (embed.type === 'permalink' && embed.data && channelIds.has(embed.data.channel_id)) {
embedRemoved = true;
}
else {
newEmbeds.push(embed);
}
}
if (embedRemoved) {
nextState[post.id] = {
...nextState[post.id],
metadata: {
...nextState[post.id].metadata,
embeds: newEmbeds,
},
};
postModified = true;
}
}
}
if (!postModified) {
return state;
}
return nextState;
}
function handlePosts(state = {}, action) {
switch (action.type) {
case action_types_1.PostTypes.RECEIVED_POST:
case action_types_1.PostTypes.RECEIVED_NEW_POST: {
return handlePostReceived({ ...state }, action.data);
}
case action_types_1.PostTypes.RECEIVED_POSTS: {
const posts = Object.values(action.data.posts);
if (posts.length === 0) {
return state;
}
const nextState = { ...state };
for (const post of posts) {
handlePostReceived(nextState, post);
}
return nextState;
}
case action_types_1.PostTypes.POST_DELETED: {
const post = action.data;
if (!state[post.id]) {
return state;
}
if (state[post.id].type === posts_1.PostTypes.BURN_ON_READ) {
const nextState = { ...state };
Reflect.deleteProperty(nextState, post.id);
return nextState;
}
// Mark the post as deleted
const nextState = {
...state,
[post.id]: {
...state[post.id],
state: constants_1.Posts.POST_DELETED,
message: '',
file_ids: [],
has_reactions: false,
},
};
for (const otherPost of Object.values(state)) {
// Remove any of its comments
if (otherPost.root_id === post.id) {
Reflect.deleteProperty(nextState, otherPost.id);
}
// a deleted post may exist in some other post's
// embeds when its link is mentioned in the post message.
// We need to remove the deleted post from post embeds of all posts
// to ensure the deleted post's contents cannot be retrieved from the store.
if (otherPost.metadata && otherPost.metadata.embeds && otherPost.metadata.embeds.length > 0) {
// This will become the post's new embeds array.
// We'll add everything other than the deleted post's embed here.
const newEmbeds = [];
for (const embed of otherPost.metadata.embeds) {
if (embed.type === 'permalink' && embed.data && embed.data.post_id === post.id) {
// skip if the embed is the deleted post
continue;
}
// include everything else
newEmbeds.push(embed);
}
// if newEmbeds changed, update post's embeds
if (newEmbeds.length !== otherPost.metadata.embeds.length) {
// Since otherPost refers to the post from store, its frozen un immutable.
// That's why cloning it and modifying required parts here.
nextState[otherPost.id] = {
...nextState[otherPost.id],
metadata: {
...nextState[otherPost.id].metadata,
embeds: newEmbeds,
},
};
}
}
}
return nextState;
}
case action_types_1.PostTypes.POST_REMOVED: {
const post = action.data;
if (!state[post.id]) {
return state;
}
// Remove the post itself
const nextState = { ...state };
Reflect.deleteProperty(nextState, post.id);
// Remove any of its comments
for (const otherPost of Object.values(state)) {
if (otherPost.root_id === post.id) {
Reflect.deleteProperty(nextState, otherPost.id);
}
}
return nextState;
}
case action_types_1.PostTypes.POST_PINNED_CHANGED: {
const { postId, isPinned, updateAt } = action;
if (!state[postId]) {
return state;
}
return {
...state,
[postId]: {
...state[postId],
is_pinned: isPinned,
last_update_at: updateAt,
},
};
}
case action_types_1.PostTypes.REVEAL_BURN_ON_READ_SUCCESS: {
const { post, expireAt } = action.data;
if (!state[post.id]) {
return state;
}
const currentPost = state[post.id];
const currentMetadata = currentPost.metadata || {};
const newMetadata = post.metadata || {};
return {
...state,
[post.id]: {
...currentPost,
...post,
metadata: {
...currentMetadata,
...newMetadata,
expire_at: expireAt,
},
},
};
}
case action_types_1.PostTypes.POST_RECIPIENTS_UPDATED: {
const { postId, recipients } = action.data;
if (!state[postId]) {
return state;
}
const currentPost = state[postId];
const currentMetadata = currentPost.metadata || {};
const currentRecipients = currentMetadata.recipients || [];
// Merge new recipients with existing ones (don't replace).
// Server sends incremental updates (only the revealing user), so we must merge.
const mergedRecipients = [...new Set([...currentRecipients, ...recipients])];
return {
...state,
[postId]: {
...currentPost,
metadata: {
...currentMetadata,
recipients: mergedRecipients,
},
},
};
}
case action_types_1.PostTypes.BURN_ON_READ_ALL_REVEALED: {
const { postId, senderExpireAt } = action.data;
if (!state[postId]) {
return state;
}
const currentPost = state[postId];
const currentMetadata = currentPost.metadata || {};
// Set sender's expiration time to trigger timer display
return {
...state,
[postId]: {
...currentPost,
metadata: {
...currentMetadata,
expire_at: senderExpireAt,
},
},
};
}
case action_types_1.ChannelTypes.LEAVE_CHANNEL: {
const channelId = action.data.id;
return removePostsAndEmbedsForChannels(state, new Set([channelId]));
}
case action_types_1.TeamTypes.LEAVE_TEAM: {
const channelIds = action.data.channelIds || [];
if (channelIds.length === 0) {
return state;
}
return removePostsAndEmbedsForChannels(state, new Set(channelIds));
}
case action_types_1.ThreadTypes.FOLLOW_CHANGED_THREAD: {
const { id, following } = action.data;
const post = state[id];
return {
...state,
[id]: {
...post,
is_following: following,
},
};
}
case action_types_1.PostTypes.POST_TRANSLATION_UPDATED: {
const data = action.data;
if (!state[data.object_id]) {
return state;
}
const existingTranslations = state[data.object_id].metadata?.translations || {};
const newTranslations = {
...existingTranslations,
[data.language]: {
object: data.translation ? JSON.parse(data.translation) : undefined,
state: data.state,
source_lang: data.src_lang,
},
};
return {
...state,
[data.object_id]: {
...state[data.object_id],
metadata: {
...state[data.object_id].metadata,
translations: newTranslations,
},
},
};
}
case action_types_1.UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
function handlePostReceived(nextState, post, nestedPermalinkLevel) {
let currentState = nextState;
// Check if post already exists in state or if nested permalink
if (!(0, post_utils_1.shouldUpdatePost)(post, currentState[post.id]) || (nestedPermalinkLevel && nestedPermalinkLevel > 1)) {
return currentState;
}
// If post is a permalink and not nested (it links directly to the original message),
// and is missing embedded metadata, then update state with new post metadata
if (!nestedPermalinkLevel && (0, post_utils_1.isPermalink)(post) && currentState[post.id] && !currentState[post.id].metadata && post.metadata) {
currentState[post.id] = { ...currentState[post.id], ...post.metadata };
}
// Posts that don't have CRT fields specified should maintain existing state.
// This happens when posts are returned via GetPostsByIds (e.g. translation
// supplement) which doesn't JOIN the Threads/ThreadMemberships tables.
if (post.update_at > 0 && currentState[post.id]) {
if (post.is_following == null) {
post.is_following = currentState[post.id].is_following;
}
if (post.participants == null && currentState[post.id].participants) {
post.participants = currentState[post.id].participants;
}
if (!post.last_reply_at && currentState[post.id].last_reply_at) {
post.last_reply_at = currentState[post.id].last_reply_at;
}
}
if (post.delete_at > 0) {
// We've received a deleted post, so mark the post as deleted if we already have it
if (currentState[post.id]) {
currentState[post.id] = {
...removeUnneededMetadata(post),
state: constants_1.Posts.POST_DELETED,
file_ids: [],
has_reactions: false,
};
}
}
else if (post.metadata && post.metadata.embeds) {
post.metadata.embeds.forEach((embed) => {
if (embed.type === 'permalink') {
if (embed.data && 'post_id' in embed.data && embed.data.post) {
currentState = handlePostReceived(currentState, embed.data.post, nestedPermalinkLevel ? nestedPermalinkLevel + 1 : 1);
if ((0, post_utils_1.isPermalink)(embed.data.post)) {
currentState[post.id] = removeUnneededMetadata(post);
}
}
}
});
currentState[post.id] = post;
}
else {
currentState[post.id] = removeUnneededMetadata(post);
}
// Delete any pending post that existed for this post
if (post.pending_post_id && post.id !== post.pending_post_id && currentState[post.pending_post_id]) {
Reflect.deleteProperty(currentState, post.pending_post_id);
}
const rootPost = currentState[post.root_id];
if (post.root_id && rootPost) {
const participants = rootPost.participants || [];
const nextRootPost = { ...rootPost };
if (!participants.find((user) => user.id === post.user_id)) {
nextRootPost.participants = [...participants, { id: post.user_id }];
}
if (post.reply_count) {
nextRootPost.reply_count = post.reply_count;
}
currentState[post.root_id] = nextRootPost;
}
return currentState;
}
function handlePendingPosts(state = [], action) {
switch (action.type) {
case action_types_1.PostTypes.RECEIVED_NEW_POST: {
const post = action.data;
if (!post.pending_post_id) {
// This is not a pending post
return state;
}
const index = state.indexOf(post.pending_post_id);
if (index !== -1) {
// An entry already exists for this post
return state;
}
// Add the new pending post ID
const nextState = [...state];
nextState.push(post.pending_post_id);
return nextState;
}
case action_types_1.PostTypes.POST_REMOVED: {
const post = action.data;
const index = state.indexOf(post.id);
if (index === -1) {
// There's nothing to remove
return state;
}
// The post has been removed, so remove the entry for it
const nextState = [...state];
nextState.splice(index, 1);
return nextState;
}
case action_types_1.PostTypes.RECEIVED_POST: {
const post = action.data;
if (!post.pending_post_id) {
// This isn't a pending post
return state;
}
const index = state.indexOf(post.pending_post_id);
if (index === -1) {
// There's nothing to remove
return state;
}
// The post has actually been created, so remove the entry for it
const nextState = [...state];
nextState.splice(index, 1);
return nextState;
}
default:
return state;
}
}
function postsInChannel(state = {}, action, prevPosts, nextPosts) {
switch (action.type) {
case action_types_1.PostTypes.RESET_POSTS_IN_CHANNEL: {
const { channelId } = action;
if (!channelId) {
return {};
}
const nextState = { ...state };
Reflect.deleteProperty(nextState, channelId);
return nextState;
}
case action_types_1.PostTypes.RECEIVED_NEW_POST: {
const post = action.data;
if (action.features?.crtEnabled && post.root_id) {
return state;
}
const postsForChannel = state[post.channel_id];
if (!postsForChannel) {
// Don't save newly created posts until the channel has been loaded
return state;
}
const recentBlockIndex = postsForChannel.findIndex((block) => block.recent);
let nextRecentBlock;
if (recentBlockIndex === -1) {
nextRecentBlock = {
order: [],
recent: true,
};
}
else {
const recentBlock = postsForChannel[recentBlockIndex];
nextRecentBlock = {
...recentBlock,
order: [...recentBlock.order],
};
}
let changed = false;
// Add the new post to the channel
if (!nextRecentBlock.order.includes(post.id)) {
nextRecentBlock.order.unshift(post.id);
changed = true;
}
// If this is a newly created post, remove any pending post that exists for it
if (post.pending_post_id && post.id !== post.pending_post_id) {
const index = nextRecentBlock.order.indexOf(post.pending_post_id);
if (index !== -1) {
nextRecentBlock.order.splice(index, 1);
// Need to re-sort to make sure any other pending posts come first
nextRecentBlock.order.sort((a, b) => {
return (0, post_utils_1.comparePosts)(nextPosts[a], nextPosts[b]);
});
changed = true;
}
}
if (!changed) {
return state;
}
const nextPostsForChannel = [...postsForChannel];
if (recentBlockIndex === -1) {
nextPostsForChannel.push(nextRecentBlock);
}
else {
nextPostsForChannel[recentBlockIndex] = nextRecentBlock;
}
return {
...state,
[post.channel_id]: nextPostsForChannel,
};
}
case action_types_1.PostTypes.RECEIVED_POST: {
const post = action.data;
if (action.features?.crtEnabled && post.root_id) {
return state;
}
// Receiving a single post doesn't usually affect the order of posts in a channel, except for when we've
// received a newly created post that was previously stored as pending
if (!post.pending_post_id) {
return state;
}
const postsForChannel = state[post.channel_id] || [];
const recentBlockIndex = postsForChannel.findIndex((block) => block.recent);
if (recentBlockIndex === -1) {
// Nothing to do since there's no recent block and only the recent block should contain pending posts
return state;
}
const recentBlock = postsForChannel[recentBlockIndex];
// Replace the pending post with the newly created one
const index = recentBlock.order.indexOf(post.pending_post_id);
if (index === -1) {
// No pending post found to remove
return state;
}
const nextRecentBlock = {
...recentBlock,
order: [...recentBlock.order],
};
nextRecentBlock.order[index] = post.id;
const nextPostsForChannel = [...postsForChannel];
nextPostsForChannel[recentBlockIndex] = nextRecentBlock;
return {
...state,
[post.channel_id]: nextPostsForChannel,
};
}
case action_types_1.PostTypes.RECEIVED_POSTS_IN_CHANNEL: {
const { recent, oldest } = action;
const order = action.data.order;
if (order.length === 0 && state[action.channelId]) {
// No new posts received when we already have posts
return state;
}
const postsForChannel = state[action.channelId] || [];
let nextPostsForChannel = [...postsForChannel];
if (recent) {
// The newly received block is now the most recent, so unmark the current most recent block
const recentBlockIndex = postsForChannel.findIndex((block) => block.recent);
if (recentBlockIndex !== -1) {
const recentBlock = postsForChannel[recentBlockIndex];
if (recentBlock.order.length === order.length &&
recentBlock.order[0] === order[0] &&
recentBlock.order[recentBlock.order.length - 1] === order[order.length - 1]) {
// The newly received posts are identical to the most recent block, so there's nothing to do
return state;
}
// Unmark the most recent block since the new posts are more recent
const nextRecentBlock = {
...recentBlock,
recent: false,
};
nextPostsForChannel[recentBlockIndex] = nextRecentBlock;
}
}
// Add the new most recent block
nextPostsForChannel.push({
order,
recent,
oldest,
});
// Merge overlapping blocks
nextPostsForChannel = mergePostBlocks(nextPostsForChannel, nextPosts);
return {
...state,
[action.channelId]: nextPostsForChannel,
};
}
case action_types_1.PostTypes.RECEIVED_POSTS_AFTER: {
const order = action.data.order;
const afterPostId = action.afterPostId;
if (order.length === 0) {
// No posts received
return state;
}
const postsForChannel = state[action.channelId] || [];
// Add a new block including the previous post and then have mergePostBlocks sort out any overlap or duplicates
const newBlock = {
order: [...order, afterPostId],
recent: action.recent,
};
let nextPostsForChannel = [...postsForChannel, newBlock];
nextPostsForChannel = mergePostBlocks(nextPostsForChannel, nextPosts);
return {
...state,
[action.channelId]: nextPostsForChannel,
};
}
case action_types_1.PostTypes.RECEIVED_POSTS_BEFORE: {
const { order } = action.data;
const { beforePostId, oldest } = action;
if (order.length === 0) {
// No posts received
return state;
}
const postsForChannel = state[action.channelId] || [];
// Add a new block including the next post and then have mergePostBlocks sort out any overlap or duplicates
const newBlock = {
order: [beforePostId, ...order],
recent: false,
oldest,
};
let nextPostsForChannel = [...postsForChannel, newBlock];
nextPostsForChannel = mergePostBlocks(nextPostsForChannel, nextPosts);
return {
...state,
[action.channelId]: nextPostsForChannel,
};
}
case action_types_1.PostTypes.RECEIVED_POSTS_SINCE: {
const order = action.data.order;
if (order.length === 0 && state[action.channelId]) {
// No new posts received when we already have posts
return state;
}
const postsForChannel = state[action.channelId] || [];
const recentBlockIndex = postsForChannel.findIndex((block) => block.recent);
if (recentBlockIndex === -1) {
// Nothing to do since this shouldn't be dispatched if we haven't loaded the most recent posts yet
return state;
}
const recentBlock = postsForChannel[recentBlockIndex];
const mostOldestCreateAt = nextPosts[recentBlock.order[recentBlock.order.length - 1]].create_at;
const nextRecentBlock = {
...recentBlock,
order: [...recentBlock.order],
};
// Add any new posts to the most recent block while skipping ones that were only updated
for (let i = order.length - 1; i >= 0; i--) {
const postId = order[i];
if (!nextPosts[postId]) {
// the post was removed from the list
continue;
}
if (nextPosts[postId].create_at <= mostOldestCreateAt) {
// This is an old post
continue;
}
if (nextRecentBlock.order.indexOf(postId) !== -1) {
// This postId exists so no need to add it again
continue;
}
// This post is newer than what we have
nextRecentBlock.order.unshift(postId);
}
if (nextRecentBlock.order.length === recentBlock.order.length) {
// Nothing was added
return state;
}
nextRecentBlock.order.sort((a, b) => {
return (0, post_utils_1.comparePosts)(nextPosts[a], nextPosts[b]);
});
const nextPostsForChannel = [...postsForChannel];
nextPostsForChannel[recentBlockIndex] = nextRecentBlock;
return {
...state,
[action.channelId]: nextPostsForChannel,
};
}
case action_types_1.PostTypes.POST_DELETED: {
const post = action.data;
const postsForChannel = state[post.channel_id] || [];
if (postsForChannel.length === 0) {
return state;
}
let changed = false;
let nextPostsForChannel = [...postsForChannel];
const isBoRPost = prevPosts[post.id]?.type === posts_1.PostTypes.BURN_ON_READ;
const shouldRemovePost = (postId) => {
const isTheDeletedPost = postId === post.id;
const isReplyToDeletedPost = prevPosts[postId]?.root_id === post.id;
if (isBoRPost) {
return isTheDeletedPost;
}
return isReplyToDeletedPost;
};
for (let i = 0; i < nextPostsForChannel.length; i++) {
const block = nextPostsForChannel[i];
const nextOrder = block.order.filter((postId) => !shouldRemovePost(postId));
if (nextOrder.length !== block.order.length) {
nextPostsForChannel[i] = {
...block,
order: nextOrder,
};
changed = true;
}
}
if (!changed) {
// Nothing was removed
return state;
}
nextPostsForChannel = removeNonRecentEmptyPostBlocks(nextPostsForChannel);
return {
...state,
[post.channel_id]: nextPostsForChannel,
};
}
case action_types_1.PostTypes.POST_REMOVED: {
const post = action.data;
// Removing a post removes it as well as its comments
const postsForChannel = state[post.channel_id] || [];
if (postsForChannel.length === 0) {
return state;
}
let changed = false;
const isBoRPost = prevPosts[post.id]?.type === posts_1.PostTypes.BURN_ON_READ;
// Remove the post and its comments from the channel
let nextPostsForChannel = [...postsForChannel];
for (let i = 0; i < nextPostsForChannel.length; i++) {
const block = nextPostsForChannel[i];
// For BoR posts: only remove the post itself (BoR doesn't support threads)
// For regular posts: remove the post and its thread replies
const nextOrder = isBoRPost ? block.order.filter((postId) => postId !== post.id) : block.order.filter((postId) => postId !== post.id && prevPosts[postId]?.root_id !== post.id);
if (nextOrder.length !== block.order.length) {
nextPostsForChannel[i] = {
...block,
order: nextOrder,
};
changed = true;
}
}
if (!changed) {
// Nothing was removed
return state;
}
nextPostsForChannel = removeNonRecentEmptyPostBlocks(nextPostsForChannel);
return {
...state,
[post.channel_id]: nextPostsForChannel,
};
}
case action_types_1.ChannelTypes.LEAVE_CHANNEL: {
const channelId = action.data.id;
if (!state[channelId]) {
// Nothing to do since we have no posts for this channel
return state;
}
// Remove the entry for the deleted channel
const nextState = { ...state };
Reflect.deleteProperty(nextState, channelId);
return nextState;
}
case action_types_1.UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
function removeNonRecentEmptyPostBlocks(blocks) {
return blocks.filter((block) => block.order.length !== 0 || block.recent);
}
function mergePostBlocks(blocks, posts) {
let nextBlocks = [...blocks];
// Remove any blocks that may have become empty by removing posts
nextBlocks = removeNonRecentEmptyPostBlocks(blocks);
// If a channel does not have any posts(Experimental feature where join and leave messages don't exist)
// return the previous state i.e an empty block
if (!nextBlocks.length) {
return blocks;
}
// Sort blocks so that the most recent one comes first
nextBlocks.sort((a, b) => {
const aStartsAt = posts[a.order[0]].create_at;
const bStartsAt = posts[b.order[0]].create_at;
return bStartsAt - aStartsAt;
});
// Merge adjacent blocks
let i = 0;
while (i < nextBlocks.length - 1) {
// Since we know the start of a is more recent than the start of b, they'll overlap if the last post in a is
// older than the first post in b
const a = nextBlocks[i];
const aEndsAt = posts[a.order[a.order.length - 1]].create_at;
const b = nextBlocks[i + 1];
const bStartsAt = posts[b.order[0]].create_at;
if (aEndsAt <= bStartsAt) {
// The blocks overlap, so combine them and remove the second block
nextBlocks[i] = {
order: mergePostOrder(a.order, b.order, posts),
};
nextBlocks[i].recent = a.recent || b.recent;
nextBlocks[i].oldest = a.oldest || b.oldest;
nextBlocks.splice(i + 1, 1);
// Do another iteration on this index since it may need to be merged into the next
}
else {
// The blocks don't overlap, so move on to the next one
i += 1;
}
}
if (blocks.length === nextBlocks.length) {
// No changes were made
return blocks;
}
return nextBlocks;
}
function mergePostOrder(left, right, posts) {
const result = [...left];
// Add without duplicates
const seen = new Set(left);
for (const id of right) {
if (seen.has(id)) {
continue;
}
result.push(id);
}
if (result.length === left.length) {
// No new items added
return left;
}
// Re-sort so that the most recent post comes first
result.sort((a, b) => posts[b].create_at - posts[a].create_at);
return result;
}
function postsInThread(state = {}, action, prevPosts) {
switch (action.type) {
case action_types_1.PostTypes.RECEIVED_NEW_POST:
case action_types_1.PostTypes.RECEIVED_POST: {
const post = action.data;
if (!post.root_id) {
// Only store comments, not the root post
return state;
}
const postsForThread = state[post.root_id] || [];
const nextPostsForThread = [...postsForThread];
let changed = false;
if (!postsForThread.includes(post.id)) {
nextPostsForThread.push(post.id);
changed = true;
}
// If this is a new non-pending post, remove any pending post that exists for it
if (post.pending_post_id && post.id !== post.pending_post_id) {
const index = nextPostsForThread.indexOf(post.pending_post_id);
if (index !== -1) {
nextPostsForThread.splice(index, 1);
changed = true;
}
}
if (!changed) {
return state;
}
return {
...state,
[post.root_id]: nextPostsForThread,
};
}
case action_types_1.PostTypes.RECEIVED_POSTS_AFTER:
case action_types_1.PostTypes.RECEIVED_POSTS_BEFORE:
case action_types_1.PostTypes.RECEIVED_POSTS_IN_CHANNEL:
case action_types_1.PostTypes.RECEIVED_POSTS_SINCE: {
const newPosts = Object.values(action.data.posts);
if (newPosts.length === 0) {
// Nothing to add
return state;
}
const nextState = {};
for (const post of newPosts) {
if (!post.root_id) {
// Only store comments, not the root post
continue;
}
const postsForThread = state[post.root_id] || [];
const nextPostsForThread = nextState[post.root_id] || [...postsForThread];
// Add the post to the thread
if (!nextPostsForThread.includes(post.id)) {
nextPostsForThread.push(post.id);
}
nextState[post.root_id] = nextPostsForThread;
}
if (Object.keys(nextState).length === 0) {
return state;
}
return {
...state,
...nextState,
};
}
case action_types_1.PostTypes.RECEIVED_POSTS_IN_THREAD: {
const newPosts = Object.values(action.data.posts);
if (newPosts.length === 0) {
// Nothing to add
return state;
}
const postsForThread = state[action.rootId] || [];
const nextPostsForThread = [...postsForThread];
for (const post of newPosts) {
if (post.root_id !== action.rootId) {
// Only store comments
continue;
}
if (nextPostsForThread.includes(post.id)) {
// Don't store duplicates
continue;
}
nextPostsForThread.push(post.id);
}
return {
...state,
[action.rootId]: nextPostsForThread,
};
}
case action_types_1.PostTypes.POST_DELETED: {
const post = action.data;
const postsForThread = state[post.id];
if (!postsForThread) {
// Nothing to remove
return state;
}
const nextState = { ...state };
Reflect.deleteProperty(nextState, post.id);
return nextState;
}
case action_types_1.PostTypes.POST_REMOVED: {
const post = action.data;
if (post.root_id) {
// This is a comment, so remove it from the thread
const postsForThread = state[post.root_id];
if (!postsForThread) {
return state;
}
const index = postsForThread.findIndex((postId) => postId === post.id);
if (index === -1) {
return state;
}
const nextPostsForThread = [...postsForThread];
nextPostsForThread.splice(index, 1);
return {
...state,
[post.root_id]: nextPostsForThread,
};
}
// This is not a comment, so remove any comments on it
const postsForThread = state[post.id];
if (!postsForThread) {
return state;
}
const nextState = { ...state };
Reflect.deleteProperty(nextState, post.id);
return nextState;
}
case action_types_1.ChannelTypes.LEAVE_CHANNEL: {
const channelId = action.data.id;
let postDeleted = false;
// Remove entries for any thread in the channel
const nextState = { ...state };
for (const rootId of Object.keys(state)) {
if (prevPosts[rootId] && prevPosts[rootId].channel_id === channelId) {
Reflect.deleteProperty(nextState, rootId);
postDeleted = true;
}
}
if (!postDeleted) {
// Nothing was actually removed
return state;
}
return nextState;
}
case action_types_1.UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
function postEditHistory(state = [], action) {
switch (action.type) {
case action_types_1.PostTypes.RECEIVED_POST_HISTORY:
return action.data;
case action_types_1.UserTypes.LOGOUT_SUCCESS:
return [];
default:
return state;
}
}
function currentFocusedPostId(state = '', action) {
switch (action.type) {
case action_types_1.PostTypes.RECEIVED_FOCUSED_POST:
return action.data;
case action_types_1.UserTypes.LOGOUT_SUCCESS:
return '';
default:
return state;
}
}
function reactions(state = {}, action) {
switch (action.type) {
case action_types_1.PostTypes.RECEIVED_REACTION: {
const reaction = action.data;
const nextReactions = { ...(state[reaction.post_id] || {}) };
nextReactions[reaction.user_id + '-' + reaction.emoji_name] = reaction;
return {
...state,
[reaction.post_id]: nextReactions,
};
}
case action_types_1.PostTypes.REACTION_DELETED: {
const reaction = action.data;
const nextReactions = { ...(state[reaction.post_id] || {}) };
if (!nextReactions[reaction.user_id + '-' + reaction.emoji_name]) {
return state;
}
Reflect.deleteProperty(nextReactions, reaction.user_id + '-' + reaction.emoji_name);
return {
...state,
[reaction.post_id]: nextReactions,
};
}
case action_types_1.PostTypes.RECEIVED_NEW_POST:
case action_types_1.PostTypes.RECEIVED_POST: {
const post = action.data;
return storeReactionsForPost(state, post);
}
case action_types_1.PostTypes.RECEIVED_POSTS: {
const posts = Object.values(action.data.posts);
return posts.reduce(storeReactionsForPost, state);
}
case action_types_1.PostTypes.POST_DELETED:
case action_types_1.PostTypes.POST_REMOVED: {
const post = action.data;
if (post && state[post.id]) {
const nextState = { ...state };
Reflect.deleteProperty(nextState, post.id);
return nextState;
}
return state;
}
case action_types_1.UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
function acknowledgements(state = {}, action) {
switch (action.type) {
case action_types_1.PostTypes.CREATE_ACK_POST_SUCCESS: {
const ack = action.data;
const oldState = state[ack.post_id] || {};
return {
...state,
[ack.post_id]: {
...oldState,
[ack.user_id]: ack.acknowledged_at,
},
};
}
case action_types_1.PostTypes.DELETE_ACK_POST_SUCCESS: {
const ack = action.data;
if (!state[ack.post_id] || !state[ack.post_id][ack.user_id]) {
return state;
}
// avoid a race condition
const acknowledgedAt = state[ack.post_id][ack.user_id];
if (acknowledgedAt > ack.acknowledged_at) {
return state;
}
const nextState = { ...(state[ack.post_id]) };
Reflect.deleteProperty(nextState, ack.user_id);
return {
...state,
[ack.post_id]: {
...nextState,
},
};
}
case action_types_1.PostTypes.RECEIVED_POST: {
const post = action.data;
return storeAcknowledgementsForPost(state, post);
}
case action_types_1.PostTypes.RECEIVED_POSTS: {
const posts = Object.values(action.data.posts);
return posts.reduce(storeAcknowledgementsForPost, state);
}
case action_types_1.PostTypes.POST_DELETED:
case action_types_1.PostTypes.POST_REMOVED: {
const post = action.data;
if (post && state[post.id]) {
const nextState = { ...state };
Reflect.deleteProperty(nextState, post.id);
return nextState;
}
return state;
}
case action_types_1.UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
function storeReactionsForPost(state, post) {
if (!post.metadata || post.delete_at > 0) {
return state;
}
const reactionsForPost = {};
if (post.metadata.reactions && post.metadata.reactions.length > 0) {
for (const reaction of post.metadata.reactions) {
reactionsForPost[reaction.user_id + '-' + reaction.emoji_name] = reaction;
}
}
return {
...state,
[post.id]: reactionsForPost,
};
}
function storeAcknowledgementsForPost(state, post) {
if (!post.metadata ||
!post.metadata.acknowledgements ||
!post.metadata.acknowledgements.length ||
post.delete_at > 0) {
return state;
}
const acknowledgementsForPost = {};
if (post?.metadata?.acknowledgements && post.metadata.acknowledgements.length > 0) {
for (const ack of post.metadata.acknowledgements) {
acknowledgementsForPost[ack.user_id] = ack.acknowledged_at;
}
}
return {
...state,
[post.id]: acknowledgementsForPost,
};
}
function openGraph(state = {}, action) {
switch (action.type) {
case action_types_1.PostTypes.RECEIVED_NEW_POST:
case action_types_1.PostTypes.RECEIVED_POST: {
const post = action.data;
return storeOpenGraphForPost(state, post);
}
case action_types_1.PostTypes.RECEIVED_POSTS: {
const posts = Object.values(action.data.posts);
return posts.reduce(storeOpenGraphForPost, state);
}
case action_types_1.UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
function storeOpenGraphForPost(state, post) {
if (!post.metadata || !post.metadata.embeds) {
return state;
}
return post.metadata.embeds.reduce((nextState, embed) => {
// If post contains a permalink, we need to store opengraph data for the embedded message
if (embed.type === 'permalink' && embed.data && 'post' in embed.data && embed.data.post) {
const previewPost = embed.data.post;
if (previewPost.metadata && previewPost.metadata.embeds) {
return previewPost.metadata.embeds.reduce((nextState, embed) => {
if (embed.type !== 'opengraph' || !embed.data || nextState[previewPost.id]) {
return nextState;
}
return {
...nextState,
[previewPost.id]: { [embed.url]: embed.data },
};
}, nextState);
}
}
if (embed.type !== 'opengraph' || !embed.data) {
// Not an OpenGraph embed
return nextState;
}
const postIdState = nextState[post.id] ? { ...nextState[post.id], [embed.url]: embed.data } : { [embed.url]: embed.data };
return {
...nextState,
[post.id]: postIdState,