UNPKG

mattermost-redux

Version:

Common code (API client, Redux stores, logic, utility functions) for building a Mattermost client

555 lines (445 loc) 24.2 kB
// 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; }; }