mattermost-redux
Version:
Common code (API client, Redux stores, logic, utility functions) for building a Mattermost client
555 lines (445 loc) • 24.2 kB
text/typescript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import shallowEquals from 'shallow-equals';
import {General, Preferences} from '../../constants';
import {CategoryTypes} from '../../constants/channel_categories';
import {getCurrentChannelId, getMyChannelMemberships, makeGetChannelsForIds} from 'selectors/entities/channels';
import {getCurrentUserLocale} from 'selectors/entities/i18n';
import {getLastPostPerChannel} from 'selectors/entities/posts';
import {getMyPreferences, getTeammateNameDisplaySetting, shouldAutocloseDMs, getInt} from 'selectors/entities/preferences';
import {getCurrentUserId} from 'selectors/entities/users';
import {Channel, ChannelMembership} from 'types/channels';
import {ChannelCategory, ChannelCategoryType, CategorySorting} from 'types/channel_categories';
import {GlobalState} from 'types/store';
import {UserProfile} from 'types/users';
import {IDMappedObjects, RelationOneToOne} from 'types/utilities';
import {
getUserIdFromChannelName,
isChannelMuted,
isUnreadChannel,
} from 'utils/channel_utils';
import {getPreferenceKey} from 'utils/preference_utils';
import {displayUsername} from 'utils/user_utils';
export function getAllCategoriesByIds(state: GlobalState) {
return state.entities.channelCategories.byId;
}
export function getCategory(state: GlobalState, categoryId: string) {
return getAllCategoriesByIds(state)[categoryId];
}
// getCategoryInTeamByType returns the first category found of the given type on the given team. This is intended for use
// with only non-custom types of categories.
export function getCategoryInTeamByType(state: GlobalState, teamId: string, categoryType: ChannelCategoryType) {
return getCategoryWhere(
state,
(category) => category.type === categoryType && category.team_id === teamId,
);
}
// getCategoryInTeamWithChannel returns the category on a given team containing the given channel ID.
export function getCategoryInTeamWithChannel(state: GlobalState, teamId: string, channelId: string) {
return getCategoryWhere(
state,
(category) => category.team_id === teamId && category.channel_ids.includes(channelId),
);
}
// getCategoryWhere returns the first category meeting the given condition. This should not be used with a condition
// that matches multiple categories.
export function getCategoryWhere(state: GlobalState, condition: (category: ChannelCategory) => boolean) {
const categoriesByIds = getAllCategoriesByIds(state);
return Object.values(categoriesByIds).find(condition);
}
export function getCategoryIdsForTeam(state: GlobalState, teamId: string): string[] {
return state.entities.channelCategories.orderByTeam[teamId];
}
export function makeGetCategoriesForTeam(): (state: GlobalState, teamId: string) => ChannelCategory[] {
return createSelector(
getCategoryIdsForTeam,
(state: GlobalState) => state.entities.channelCategories.byId,
(categoryIds, categoriesById) => {
if (!categoryIds) {
return [];
}
return categoryIds.map((id) => categoriesById[id]);
},
);
}
// makeFilterArchivedChannels returns a selector that filters a given list of channels based on whether or not the channel
// is archived or is currently being viewed. The selector returns the original array if no channels are filtered out.
export function makeFilterArchivedChannels(): (state: GlobalState, channels: Channel[]) => Channel[] {
return createSelector(
(state: GlobalState, channels: Channel[]) => channels,
getCurrentChannelId,
(channels: Channel[], currentChannelId: string) => {
const filtered = channels.filter((channel) => channel && (channel.id === currentChannelId || channel.delete_at === 0));
return filtered.length === channels.length ? channels : filtered;
},
);
}
function getDefaultAutocloseCutoff() {
return Date.now() - (7 * 24 * 60 * 60 * 1000);
}
// legacyMakeFilterAutoclosedDMs returns a selector that filters a given list of channels based on whether or not the channel has
// been autoclosed by either being an inactive DM/GM or a DM with a deactivated user. The exact requirements for being
// inactive are complicated, but they are intended to include the channel not having been opened, posted in, or viewed
// recently. The selector returns the original array if no channels are filtered out.
export function legacyMakeFilterAutoclosedDMs(getAutocloseCutoff = getDefaultAutocloseCutoff): (state: GlobalState, channels: Channel[], categoryType: string) => Channel[] {
return createSelector(
(state: GlobalState, channels: Channel[]) => channels,
(state: GlobalState, channels: Channel[], categoryType: string) => categoryType,
getMyPreferences,
shouldAutocloseDMs,
getCurrentChannelId,
(state: GlobalState) => state.entities.users.profiles,
getCurrentUserId,
getMyChannelMemberships,
getLastPostPerChannel,
(channels, categoryType, myPreferences, autocloseDMs, currentChannelId, profiles, currentUserId, myMembers, lastPosts) => {
if (categoryType !== CategoryTypes.DIRECT_MESSAGES) {
// Only autoclose DMs that haven't been assigned to a category
return channels;
}
// Ideally, this would come from a selector, but that would cause the filter to recompute too often
const cutoff = getAutocloseCutoff();
const filtered = channels.filter((channel) => {
if (channel.type !== General.DM_CHANNEL && channel.type !== General.GM_CHANNEL) {
return true;
}
if (isUnreadChannel(myMembers, channel)) {
// Unread DMs/GMs are always visible
return true;
}
if (currentChannelId === channel.id) {
// The current channel is always visible
return true;
}
// viewTime is the time the channel was last viewed by the user
const viewTimePref = myPreferences[getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel.id)];
const viewTime = parseInt(viewTimePref ? viewTimePref.value! : '0', 10);
// Recently viewed channels will never be hidden. Note that viewTime is not set correctly at the time of writing.
if (viewTime > cutoff) {
return true;
}
// openTime is the time the channel was last opened (like from the More DMs list) after having been closed
const openTimePref = myPreferences[getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel.id)];
const openTime = parseInt(openTimePref ? openTimePref.value! : '0', 10);
// DMs with deactivated users will be visible if you're currently viewing them and they were opened
// since the user was deactivated
if (channel.type === General.DM_CHANNEL) {
const teammateId = getUserIdFromChannelName(currentUserId, channel.name);
const teammate = profiles[teammateId];
if (!teammate || teammate.delete_at > openTime) {
return false;
}
}
// Skip the rest of the checks if autoclosing inactive DMs is disabled
if (!autocloseDMs) {
return true;
}
// Keep the channel open if it had a recent post. If we have posts loaded for the channel, use the create_at
// of the last post in the channel since channel.last_post_at isn't kept up to date on the client. If we don't
// have posts loaded, then fall back to the last_post_at.
const lastPost = lastPosts[channel.id];
if (lastPost && lastPost.create_at > cutoff) {
return true;
}
if (openTime > cutoff) {
return true;
}
if (channel.last_post_at && channel.last_post_at > cutoff) {
return true;
}
return false;
});
return filtered.length === channels.length ? channels : filtered;
},
);
}
export function makeFilterAutoclosedDMs(): (state: GlobalState, channels: Channel[], categoryType: string) => Channel[] {
return createSelector(
(state: GlobalState, channels: Channel[]) => channels,
(state: GlobalState, channels: Channel[], categoryType: string) => categoryType,
getCurrentChannelId,
(state: GlobalState) => state.entities.users.profiles,
getCurrentUserId,
getMyChannelMemberships,
(state: GlobalState) => getInt(state, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS, 20),
getMyPreferences,
(channels, categoryType, currentChannelId, profiles, currentUserId, myMembers, limitPref, myPreferences) => {
if (categoryType !== CategoryTypes.DIRECT_MESSAGES) {
// Only autoclose DMs that haven't been assigned to a category
return channels;
}
const getTimestampFromPrefs = (category: string, name: string) => {
const pref = myPreferences[getPreferenceKey(category, name)];
return parseInt(pref ? pref.value! : '0', 10);
};
const getLastViewedAt = (channel: Channel) => {
// The server only ever sets the last_viewed_at to the time of the last post in channel, so we may need
// to use the preferences added for the previous version of autoclosing DMs.
return Math.max(
myMembers[channel.id]?.last_viewed_at,
getTimestampFromPrefs(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel.id),
getTimestampFromPrefs(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel.id),
);
};
let unreadCount = 0;
let visibleChannels = channels.filter((channel) => {
if (isUnreadChannel(myMembers, channel)) {
unreadCount++;
// Unread DMs/GMs are always visible
return true;
}
if (channel.id === currentChannelId) {
return true;
}
// DMs with deactivated users will be visible if you're currently viewing them and they were opened
// since the user was deactivated
if (channel.type === General.DM_CHANNEL) {
const teammateId = getUserIdFromChannelName(currentUserId, channel.name);
const teammate = profiles[teammateId];
const lastViewedAt = getLastViewedAt(channel);
if (!teammate || teammate.delete_at > lastViewedAt) {
return false;
}
}
return true;
});
visibleChannels.sort((channelA, channelB) => {
// Should always prioritise the current channel
if (channelA.id === currentChannelId) {
return -1;
} else if (channelB.id === currentChannelId) {
return 1;
}
// Second priority is for unread channels
if (isUnreadChannel(myMembers, channelA) && !isUnreadChannel(myMembers, channelB)) {
return -1;
} else if (!isUnreadChannel(myMembers, channelA) && isUnreadChannel(myMembers, channelB)) {
return 1;
}
// Third priority is last_viewed_at
const channelAlastViewed = getLastViewedAt(channelA) || 0;
const channelBlastViewed = getLastViewedAt(channelB) || 0;
if (channelAlastViewed > channelBlastViewed) {
return -1;
} else if (channelBlastViewed > channelAlastViewed) {
return 1;
}
return 0;
});
// The limit of DMs user specifies to be rendered in the sidebar
const remaining = Math.max(limitPref, unreadCount);
visibleChannels = visibleChannels.slice(0, remaining);
const visibleChannelsSet = new Set(visibleChannels);
const filteredChannels = channels.filter((channel) => visibleChannelsSet.has(channel));
return filteredChannels.length === channels.length ? channels : filteredChannels;
},
);
}
export function makeFilterManuallyClosedDMs(): (state: GlobalState, channels: Channel[]) => Channel[] {
return createSelector(
(state: GlobalState, channels: Channel[]) => channels,
getMyPreferences,
getCurrentChannelId,
getCurrentUserId,
getMyChannelMemberships,
(channels, myPreferences, currentChannelId, currentUserId, myMembers) => {
const filtered = channels.filter((channel) => {
let preference;
if (channel.type !== General.DM_CHANNEL && channel.type !== General.GM_CHANNEL) {
return true;
}
if (isUnreadChannel(myMembers, channel)) {
// Unread DMs/GMs are always visible
return true;
}
if (currentChannelId === channel.id) {
// The current channel is always visible
return true;
}
if (channel.type === General.DM_CHANNEL) {
const teammateId = getUserIdFromChannelName(currentUserId, channel.name);
preference = myPreferences[getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammateId)];
} else {
preference = myPreferences[getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel.id)];
}
return preference && preference.value !== 'false';
});
// Only return a new array if anything was removed
return filtered.length === channels.length ? channels : filtered;
},
);
}
export function makeCompareChannels(getDisplayName: (channel: Channel) => string, locale: string, myMembers: RelationOneToOne<Channel, ChannelMembership>) {
return (a: Channel, b: Channel) => {
// Sort muted channels last
const aMuted = isChannelMuted(myMembers[a.id]);
const bMuted = isChannelMuted(myMembers[b.id]);
if (aMuted && !bMuted) {
return 1;
} else if (!aMuted && bMuted) {
return -1;
}
// And then sort alphabetically
return getDisplayName(a).localeCompare(getDisplayName(b), locale, {numeric: true});
};
}
export function makeSortChannelsByName(): (state: GlobalState, channels: Channel[]) => Channel[] {
return createSelector(
(state: GlobalState, channels: Channel[]) => channels,
getCurrentUserLocale,
getMyChannelMemberships,
(channels: Channel[], locale: string, myMembers: RelationOneToOne<Channel, ChannelMembership>) => {
const getDisplayName = (channel: Channel) => channel.display_name;
return [...channels].sort(makeCompareChannels(getDisplayName, locale, myMembers));
},
);
}
export function makeSortChannelsByNameWithDMs(): (state: GlobalState, channels: Channel[]) => Channel[] {
return createSelector(
(state: GlobalState, channels: Channel[]) => channels,
getCurrentUserId,
(state: GlobalState) => state.entities.users.profiles,
getTeammateNameDisplaySetting,
getCurrentUserLocale,
getMyChannelMemberships,
(channels: Channel[], currentUserId: string, profiles: IDMappedObjects<UserProfile>, teammateNameDisplay: string, locale: string, myMembers: RelationOneToOne<Channel, ChannelMembership>) => {
const cachedNames: RelationOneToOne<Channel, string> = {};
const getDisplayName = (channel: Channel): string => {
if (cachedNames[channel.id]) {
return cachedNames[channel.id];
}
let displayName;
// TODO it might be easier to do this by using channel members to find the users
if (channel.type === General.DM_CHANNEL) {
const teammateId = getUserIdFromChannelName(currentUserId, channel.name);
const teammate = profiles[teammateId];
displayName = displayUsername(teammate, teammateNameDisplay, false);
} else if (channel.type === General.GM_CHANNEL) {
const usernames = channel.display_name.split(', ');
const userDisplayNames = [];
for (const username of usernames) {
const user = Object.values(profiles).find((profile) => profile.username === username);
if (!user) {
continue;
}
if (user.id === currentUserId) {
continue;
}
userDisplayNames.push(displayUsername(user, teammateNameDisplay, false));
}
displayName = userDisplayNames.sort((a, b) => a.localeCompare(b, locale, {numeric: true})).join(', ');
} else {
displayName = channel.display_name;
}
cachedNames[channel.id] = displayName;
return displayName;
};
return [...channels].sort(makeCompareChannels(getDisplayName, locale, myMembers));
},
);
}
export function makeSortChannelsByRecency(): (state: GlobalState, channels: Channel[]) => Channel[] {
return createSelector(
(state: GlobalState, channels: Channel[]) => channels,
getLastPostPerChannel,
(channels, lastPosts) => {
return [...channels].sort((a, b) => {
// If available, get the last post time from the loaded posts for the channel, but fall back to the
// channel's last_post_at if that's not available. The last post time from the loaded posts is more
// accurate because channel.last_post_at is not updated on the client as new messages come in.
let aLastPostAt = a.last_post_at;
if (lastPosts[a.id] && lastPosts[a.id].create_at > a.last_post_at) {
aLastPostAt = lastPosts[a.id].create_at;
}
let bLastPostAt = b.last_post_at;
if (lastPosts[b.id] && lastPosts[b.id].create_at > b.last_post_at) {
bLastPostAt = lastPosts[b.id].create_at;
}
return bLastPostAt - aLastPostAt;
});
},
);
}
export function makeSortChannels() {
const sortChannelsByName = makeSortChannelsByName();
const sortChannelsByNameWithDMs = makeSortChannelsByNameWithDMs();
const sortChannelsByRecency = makeSortChannelsByRecency();
return (state: GlobalState, originalChannels: Channel[], category: ChannelCategory) => {
let channels = originalChannels;
// While this function isn't memoized, sortChannelsByX should be since they know what parts of state
// will affect sort order.
if (category.sorting === CategorySorting.Recency) {
channels = sortChannelsByRecency(state, channels);
} else if (category.sorting === CategorySorting.Alphabetical || category.sorting === CategorySorting.Default) {
if (channels.some((channel) => channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL)) {
channels = sortChannelsByNameWithDMs(state, channels);
} else {
channels = sortChannelsByName(state, channels);
}
}
return channels;
};
}
export function makeGetChannelsForCategory() {
const getChannels = makeGetChannelsForIds();
const filterAndSortChannelsForCategory = makeFilterAndSortChannelsForCategory();
return (state: GlobalState, category: ChannelCategory) => {
const channels = getChannels(state, category.channel_ids);
return filterAndSortChannelsForCategory(state, channels, category);
};
}
// Returns a selector that takes an array of channels and the category they belong to and returns the array sorted and
// with inactive DMs/GMs and archived channels filtered out.
export function makeFilterAndSortChannelsForCategory() {
const filterArchivedChannels = makeFilterArchivedChannels();
const filterAutoclosedDMs = makeFilterAutoclosedDMs();
const filterManuallyClosedDMs = makeFilterManuallyClosedDMs();
const sortChannels = makeSortChannels();
return (state: GlobalState, originalChannels: Channel[], category: ChannelCategory) => {
let channels = originalChannels;
channels = filterArchivedChannels(state, channels);
channels = filterManuallyClosedDMs(state, channels);
channels = filterAutoclosedDMs(state, channels, category.type);
channels = sortChannels(state, channels, category);
return channels;
};
}
export function makeGetChannelsByCategory() {
const getCategoriesForTeam = makeGetCategoriesForTeam();
// Memoize by category. As long as the categories don't change, we can keep using the same selectors for each category.
let getChannels: RelationOneToOne<ChannelCategory, ReturnType<typeof makeGetChannelsForIds>>;
let filterAndSortChannels: RelationOneToOne<ChannelCategory, ReturnType<typeof makeFilterAndSortChannelsForCategory>>;
let lastCategoryIds: ReturnType<typeof getCategoryIdsForTeam> = [];
let lastChannelsByCategory: RelationOneToOne<ChannelCategory, Channel[]> = {};
return (state: GlobalState, teamId: string) => {
const categoryIds = getCategoryIdsForTeam(state, teamId);
// Create an instance of filterAndSortChannels for each category. As long as we don't add or remove new categories,
// we can reuse these selectors to memoize the results of each category. This will also create new selectors when
// categories are reordered, but that should be rare enough that it won't meaningfully affect performance.
if (categoryIds !== lastCategoryIds) {
lastCategoryIds = categoryIds;
lastChannelsByCategory = {};
getChannels = {};
filterAndSortChannels = {};
if (categoryIds) {
for (const categoryId of categoryIds) {
getChannels[categoryId] = makeGetChannelsForIds();
filterAndSortChannels[categoryId] = makeFilterAndSortChannelsForCategory();
}
}
}
const categories = getCategoriesForTeam(state, teamId);
const channelsByCategory: RelationOneToOne<ChannelCategory, Channel[]> = {};
for (const category of categories) {
const channels = getChannels[category.id](state, category.channel_ids);
channelsByCategory[category.id] = filterAndSortChannels[category.id](state, channels, category);
}
// Do a shallow equality check of channelsByCategory to avoid returning a new object containing the same data
if (shallowEquals(channelsByCategory, lastChannelsByCategory)) {
return lastChannelsByCategory;
}
lastChannelsByCategory = channelsByCategory;
return channelsByCategory;
};
}