From 64223efafe14b7e974757912da9f480126877742 Mon Sep 17 00:00:00 2001 From: Shaz Amjad Date: Tue, 21 Sep 2021 04:11:57 +1000 Subject: [PATCH] MM-28474: Custom Sidebar Categories (#5460) * Further cleanup and fixes Tests clean-up Tests fixed? Plays nicely with threads Tests fixed Fixes ESR and show experimental flags Failing test fixed DM Fix WIP: Bottom bar UX Fixes for unreads Failing test Always show current channel Create a channel in a category! * Unreads on top * Various fixes * Improves category collapsing * Passes correct ID through * Tests cleanup * Redo unreads and unread-button * Reverts to just using ids * More unreads back to using ids * Uses appropriate selectors for pref updates * Unreads sorted by recency * Fixes test for recency * Fixes re-rendering bug * Code review updates, websocket event debounced --- app/actions/navigation/index.js | 2 +- app/actions/views/channel.js | 8 + app/actions/views/channel.test.js | 6 + app/actions/views/create_channel.js | 7 +- app/actions/websocket/categories.ts | 67 + app/actions/websocket/channels.ts | 41 +- app/actions/websocket/index.ts | 12 +- app/client/rest/channels.ts | 40 + .../edit_channel_info.test.js.snap | 11 +- app/components/edit_channel_info/index.js | 98 +- .../channel_item/channel_item.js | 15 +- .../main/channels_list/channels_list.js | 22 +- .../list/__snapshots__/list.test.js.snap | 78 + .../sidebars/main/channels_list/list/index.js | 28 +- .../sidebars/main/channels_list/list/list.js | 202 ++- .../main/channels_list/list/list.test.js | 15 + app/components/sidebars/main/index.js | 2 + .../sidebars/main/main_sidebar.test.js | 1 + .../sidebars/main/main_sidebar_base.js | 8 + app/constants/websocket.ts | 4 + app/init/fetch.js | 2 - app/mm-redux/action_types/channels.ts | 10 +- app/mm-redux/actions/channel_categories.ts | 526 ++++++- app/mm-redux/actions/channels.test.js | 7 + app/mm-redux/actions/channels.ts | 78 +- app/mm-redux/actions/search.test.js | 2 + app/mm-redux/constants/channel_categories.ts | 1 + app/mm-redux/constants/channels.ts | 14 + app/mm-redux/constants/preferences.ts | 1 + .../entities/channel_categories.test.js | 96 -- .../reducers/entities/channel_categories.ts | 179 +-- .../entities/channel_categories.test.js | 1372 ++++++++++++----- .../selectors/entities/channel_categories.ts | 390 +++-- .../selectors/entities/channels.test.js | 10 +- app/mm-redux/selectors/entities/channels.ts | 25 +- .../selectors/entities/preferences.ts | 5 +- app/mm-redux/types/channel_categories.ts | 27 +- app/mm-redux/types/config.ts | 3 +- app/mm-redux/utils/array_utils.test.ts | 80 + app/mm-redux/utils/array_utils.ts | 51 + app/mm-redux/utils/channel_utils.ts | 1 + app/reducers/views/channel.js | 3 + app/reducers/views/channel.test.js | 5 +- app/screens/create_channel/create_channel.js | 11 +- app/screens/more_channels/more_channels.js | 4 +- .../options_modal_list.test.js.snap | 275 +--- app/screens/options_modal/options_modal.js | 3 + .../options_modal_list.android.js | 148 -- ...odal_list.ios.js => options_modal_list.js} | 99 +- .../options_modal/options_modal_list.test.js | 14 +- app/screens/settings/sidebar/sidebar.test.js | 1 + .../bottom_sheet/bottom_sheet.android.js | 19 - app/utils/bottom_sheet/bottom_sheet.ios.js | 10 - app/utils/bottom_sheet/bottom_sheet.ts | 22 + app/utils/bottom_sheet/{index.js => index.ts} | 0 app/utils/categories.test.ts | 56 + app/utils/categories.ts | 37 + assets/base/i18n/en.json | 4 + ios/Podfile.lock | 2 +- package-lock.json | 13 + package.json | 1 + 61 files changed, 3032 insertions(+), 1242 deletions(-) create mode 100644 app/actions/websocket/categories.ts create mode 100644 app/mm-redux/constants/channels.ts create mode 100644 app/mm-redux/utils/array_utils.test.ts create mode 100644 app/mm-redux/utils/array_utils.ts delete mode 100644 app/screens/options_modal/options_modal_list.android.js rename app/screens/options_modal/{options_modal_list.ios.js => options_modal_list.js} (72%) delete mode 100644 app/utils/bottom_sheet/bottom_sheet.android.js delete mode 100644 app/utils/bottom_sheet/bottom_sheet.ios.js create mode 100644 app/utils/bottom_sheet/bottom_sheet.ts rename app/utils/bottom_sheet/{index.js => index.ts} (100%) create mode 100644 app/utils/categories.test.ts create mode 100644 app/utils/categories.ts diff --git a/app/actions/navigation/index.js b/app/actions/navigation/index.js index 80ddf2d5b..358a4ab24 100644 --- a/app/actions/navigation/index.js +++ b/app/actions/navigation/index.js @@ -292,7 +292,7 @@ export function showModal(name, title, passProps = {}, options = {}) { } export function showModalOverCurrentContext(name, passProps = {}, options = {}) { - const title = ''; + const title = passProps.title || ''; let animations; switch (Platform.OS) { diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index dabd57db2..241474ea3 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ + import {batchActions} from 'redux-batched-actions'; import {lastChannelIdForTeam, loadSidebarDirectMessagesProfiles} from '@actions/helpers/channels'; @@ -10,6 +12,7 @@ import {ViewTypes} from '@constants'; import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from '@constants/post_draft'; import {ChannelTypes, RoleTypes, GroupTypes} from '@mm-redux/action_types'; import {fetchAppBindings} from '@mm-redux/actions/apps'; +import {fetchMyCategories} from '@mm-redux/actions/channel_categories'; import { fetchMyChannelsAndMembers, getChannelByName, @@ -37,6 +40,7 @@ import {getChannelReachable} from '@selectors/channel'; import {getViewingGlobalThreads} from '@selectors/threads'; import telemetry, {PERF_MARKERS} from '@telemetry'; import {appsEnabled} from '@utils/apps'; +import {shouldShowLegacySidebar} from '@utils/categories'; import {isDirectChannelVisible, isGroupChannelVisible, getChannelSinceValue, privateChannelJoinPrompt} from '@utils/channels'; import {isPendingPost} from '@utils/general'; @@ -743,6 +747,10 @@ export function loadChannelsForTeam(teamId, skipDispatch = false, isReconnect = } } + if (!shouldShowLegacySidebar(state)) { + await dispatch(fetchMyCategories(teamId)); + } + if (data.channels) { actions.push({ type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS, diff --git a/app/actions/views/channel.test.js b/app/actions/views/channel.test.js index 997f84641..b03a7da4d 100644 --- a/app/actions/views/channel.test.js +++ b/app/actions/views/channel.test.js @@ -166,6 +166,12 @@ describe('Actions.Views.Channel', () => { [currentTeamId]: {}, }, }, + general: { + config: { + EnableLegacySidebar: 'true', + }, + serverVersion: '5.12.0', + }, }, }; diff --git a/app/actions/views/create_channel.js b/app/actions/views/create_channel.js index 5e7143753..a968103d0 100644 --- a/app/actions/views/create_channel.js +++ b/app/actions/views/create_channel.js @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {addChannelToCategory} from '@mm-redux/actions/channel_categories'; import {createChannel} from '@mm-redux/actions/channels'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; @@ -19,7 +20,7 @@ export function generateChannelNameFromDisplayName(displayName) { return name; } -export function handleCreateChannel(displayName, purpose, header, type) { +export function handleCreateChannel(displayName, purpose, header, type, categoryId) { return async (dispatch, getState) => { const state = getState(); const currentUserId = getCurrentUserId(state); @@ -37,6 +38,10 @@ export function handleCreateChannel(displayName, purpose, header, type) { if (data && data.id) { dispatch(setChannelDisplayName(displayName)); dispatch(handleSelectChannel(data.id)); + + if (categoryId) { + dispatch(addChannelToCategory(categoryId, data.id)); + } } }; } diff --git a/app/actions/websocket/categories.ts b/app/actions/websocket/categories.ts new file mode 100644 index 000000000..17ff1ef2f --- /dev/null +++ b/app/actions/websocket/categories.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {debounce} from 'underscore'; + +import {fetchMyCategories, receivedCategoryOrder} from '@mm-redux/actions/channel_categories'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {ActionResult, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; + +const fetchCats = debounce((dispatch: DispatchFunc, teamId: string) => dispatch(fetchMyCategories(teamId)), 1000); + +export function handleSidebarCategoryCreated(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const state = getState(); + const currentTeamId = getCurrentTeamId(state); + + if (msg.broadcast.team_id !== currentTeamId) { + // The new category will be loaded when we switch teams. + return {data: false}; + } + + // Fetch all categories, including ones that weren't explicitly updated, in case any other categories had channels + // moved out of them. + dispatch(fetchMyCategories(msg.broadcast.team_id)); + + return {data: true}; + }; +} + +export function handleSidebarCategoryUpdated(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const state = getState(); + + if (msg.broadcast.team_id !== getCurrentTeamId(state)) { + // The updated categories will be loaded when we switch teams. + return {data: false}; + } + + // Fetch all categories in case any other categories had channels moved out of them. + // dispatch(fetchMyCategories(msg.broadcast.team_id)); + fetchCats(dispatch, msg.broadcast.team_id); + + return {data: true}; + }; +} + +export function handleSidebarCategoryDeleted(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const state = getState(); + + if (msg.broadcast.team_id !== getCurrentTeamId(state)) { + // The category will be removed when we switch teams. + return {data: false}; + } + + // Fetch all categories since any channels that were in the deleted category were moved to other categories. + dispatch(fetchMyCategories(msg.broadcast.team_id)); + + return {data: true}; + }; +} + +export function handleSidebarCategoryOrderUpdated(msg: WebSocketMessage) { + return receivedCategoryOrder(msg.broadcast.team_id, msg.data.order); +} + diff --git a/app/actions/websocket/channels.ts b/app/actions/websocket/channels.ts index 29c617208..a88feaa9e 100644 --- a/app/actions/websocket/channels.ts +++ b/app/actions/websocket/channels.ts @@ -6,6 +6,7 @@ import {loadChannelsForTeam} from '@actions/views/channel'; import {Client4} from '@client/rest'; import {WebsocketEvents} from '@constants'; import {ChannelTypes, TeamTypes, RoleTypes} from '@mm-redux/action_types'; +import {addChannelToInitialCategory} from '@mm-redux/actions/channel_categories'; import {markChannelAsRead} from '@mm-redux/actions/channels'; import {General} from '@mm-redux/constants'; import { @@ -22,6 +23,7 @@ import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} f import {WebSocketMessage} from '@mm-redux/types/websocket'; import {getChannelByName} from '@mm-redux/utils/channel_utils'; import EventEmitter from '@mm-redux/utils/event_emitter'; +import {shouldShowLegacySidebar} from '@utils/categories'; export function handleChannelConvertedEvent(msg: WebSocketMessage) { return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => { @@ -47,12 +49,9 @@ export function handleChannelCreatedEvent(msg: WebSocketMessage) { const currentTeamId = getCurrentTeamId(state); if (teamId === currentTeamId && !channels[channelId]) { - const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id); - if (channelActions.length) { - dispatch(batchActions(channelActions, 'BATCH_WS_CHANNEL_CREATED')); - } + return dispatch(fetchChannelAndAddToSidebar(msg.broadcast.channel_id, 'BATCH_WS_CHANNEL_CREATED')); } - return {data: true}; + return {data: false}; }; } @@ -118,11 +117,7 @@ export function handleChannelMemberUpdatedEvent(msg: WebSocketMessage) { export function handleChannelSchemeUpdatedEvent(msg: WebSocketMessage) { return async (dispatch: DispatchFunc): Promise => { - const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id); - if (channelActions.length) { - dispatch(batchActions(channelActions, 'BATCH_WS_SCHEME_UPDATE')); - } - return {data: true}; + return dispatch(fetchChannelAndAddToSidebar(msg.broadcast.channel_id, 'BATCH_WS_SCHEME_UPDATE')); }; } @@ -200,11 +195,7 @@ export function handleChannelViewedEvent(msg: WebSocketMessage) { export function handleDirectAddedEvent(msg: WebSocketMessage) { return async (dispatch: DispatchFunc): Promise => { - const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id); - if (channelActions.length) { - dispatch(batchActions(channelActions, 'BATCH_WS_DM_ADDED')); - } - return {data: true}; + return dispatch(fetchChannelAndAddToSidebar(msg.broadcast.channel_id, 'BATCH_WS_DM_ADDED')); }; } @@ -236,3 +227,23 @@ export function handleUpdateMemberRoleEvent(msg: WebSocketMessage) { }; } +export function fetchChannelAndAddToSidebar(channelId: string, type?: string) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const channelActions = await fetchChannelAndMyMember(channelId); + let channel; + + if (channelActions.length) { + channel = channelActions.find((el) => el.type === ChannelTypes.RECEIVED_CHANNEL); + dispatch(batchActions(channelActions, type)); + } + + const state = getState(); + + if (channel && !shouldShowLegacySidebar(state)) { + dispatch(addChannelToInitialCategory(channel.data)); + return {data: true}; + } + + return {data: false}; + }; +} diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 6385bc4cf..1d4c0478b 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -27,6 +27,7 @@ import {getChannelSinceValue} from '@utils/channels'; import websocketClient from '@websocket'; import {handleRefreshAppsBindings} from './apps'; +import {handleSidebarCategoryCreated, handleSidebarCategoryDeleted, handleSidebarCategoryOrderUpdated, handleSidebarCategoryUpdated} from './categories'; import { handleChannelConvertedEvent, handleChannelCreatedEvent, @@ -408,9 +409,16 @@ function handleEvent(msg: WebSocketMessage) { return dispatch(handleThreadReadChanged(msg)); case WebsocketEvents.THREAD_FOLLOW_CHANGED: return dispatch(handleThreadFollowChanged(msg)); - case WebsocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: { + case WebsocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: return dispatch(handleRefreshAppsBindings()); - } + case WebsocketEvents.SIDEBAR_CATEGORY_CREATED: + return dispatch(handleSidebarCategoryCreated(msg)); + case WebsocketEvents.SIDEBAR_CATEGORY_UPDATED: + return dispatch(handleSidebarCategoryUpdated(msg)); + case WebsocketEvents.SIDEBAR_CATEGORY_DELETED: + return dispatch(handleSidebarCategoryDeleted(msg)); + case WebsocketEvents.SIDEBAR_CATEGORY_ORDER_UPDATED: + return dispatch(handleSidebarCategoryOrderUpdated(msg)); } return {data: true}; diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index 0f90c5935..2f2d0fee3 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {analytics} from '@init/analytics'; +import {ChannelCategory, OrderedChannelCategories} from '@mm-redux/types/channel_categories'; import {Channel, ChannelMemberCountByGroup, ChannelMembership, ChannelNotifyProps, ChannelStats} from '@mm-redux/types/channels'; import {buildQueryString} from '@mm-redux/utils/helpers'; @@ -40,6 +41,12 @@ export interface ClientChannelsMix { autocompleteChannelsForSearch: (teamId: string, name: string) => Promise; searchChannels: (teamId: string, term: string) => Promise; searchArchivedChannels: (teamId: string, term: string) => Promise; + + // Categories + getChannelCategories: (userId: string, teamId: string) => Promise; + getChannelCategory: () => Promise; + updateChannelCategory: (userId: string, teamId: string, category: ChannelCategory) => Promise; + updateChannelCategories: (userId: string, teamId: string, categories: ChannelCategory[]) => Promise; } const ClientChannels = (superclass: any) => class extends superclass { @@ -306,6 +313,39 @@ const ClientChannels = (superclass: any) => class extends superclass { {method: 'post', body: JSON.stringify({term})}, ); }; + + // Channel Category Routes + getChannelCategoriesRoute(userId: string, teamId: string) { + return `${this.getUserRoute('me')}/teams/${teamId}/channels/categories`; + } + + getChannelCategories = async (userId: string, teamId: string) => { + return this.doFetch( + `${this.getChannelCategoriesRoute(userId, teamId)}`, + {method: 'get'}, + ); + }; + + getChannelCategory = async (userId: string, teamId: string, categoryId: string) => { + return this.doFetch( + `${this.getChannelCategoriesRoute(userId, teamId)}/${categoryId}`, + {method: 'get'}, + ); + }; + + updateChannelCategory = (userId: string, teamId: string, category: ChannelCategory) => { + return this.doFetch( + `${this.getChannelCategoriesRoute(userId, teamId)}/${category.id}`, + {method: 'put', body: JSON.stringify(category)}, + ); + }; + + updateChannelCategories = (userId: string, teamId: string, categories: ChannelCategory[]) => { + return this.doFetch( + `${this.getChannelCategoriesRoute(userId, teamId)}`, + {method: 'put', body: JSON.stringify(categories)}, + ); + }; }; export default ClientChannels; diff --git a/app/components/edit_channel_info/__snapshots__/edit_channel_info.test.js.snap b/app/components/edit_channel_info/__snapshots__/edit_channel_info.test.js.snap index 9ce58b735..8b0b0fb5a 100644 --- a/app/components/edit_channel_info/__snapshots__/edit_channel_info.test.js.snap +++ b/app/components/edit_channel_info/__snapshots__/edit_channel_info.test.js.snap @@ -45,7 +45,14 @@ exports[`EditChannelInfo should match snapshot 1`] = ` } > - + - - { + const {onTypeChange} = this.props; + onTypeChange(type); + }; + onHeaderLayout = ({nativeEvent}) => { this.setState({headerPosition: nativeEvent.layout.y}); } @@ -206,6 +216,7 @@ export default class EditChannelInfo extends PureComponent { }; const style = getStyleSheet(theme); + const showSelector = !displayHeaderOnly && this.props.onTypeChange; const displayHeaderOnly = channelType === General.DM_CHANNEL || channelType === General.GM_CHANNEL; @@ -253,9 +264,68 @@ export default class EditChannelInfo extends PureComponent { {displayError} - {!displayHeaderOnly && ( + {showSelector && ( + + + + { + this.onTypeSelect(General.OPEN_CHANNEL); + }} + > + + {this.props.type === General.OPEN_CHANNEL && + + } + + + { + this.onTypeSelect(General.PRIVATE_CHANNEL); + }} + > + + {this.props.type === General.PRIVATE_CHANNEL && + + } + + + + )} + {!displayHeaderOnly && ( + + - - )} - {!displayHeaderOnly && ( - + { headerHelpText: { zIndex: -1, }, + touchable: { + flex: 1, + flexDirection: 'row', + width: '100%', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + touchableText: { + flex: 1, + flexGrow: 1, + fontSize: 16, + lineHeight: 24, + color: '#3d3c40', + paddingVertical: 10, + marginLeft: 15, + }, + touchableIcon: { + flex: 1, + padding: 10, + textAlign: 'right', + }, }; }); diff --git a/app/components/sidebars/main/channels_list/channel_item/channel_item.js b/app/components/sidebars/main/channels_list/channel_item/channel_item.js index ef703cf99..a0c61dc6d 100644 --- a/app/components/sidebars/main/channels_list/channel_item/channel_item.js +++ b/app/components/sidebars/main/channels_list/channel_item/channel_item.js @@ -181,14 +181,13 @@ export default class ChannelItem extends PureComponent { const itemTestID = `${testID}.${channelId}`; const displayNameTestID = `${testID}.display_name`; - const customStatus = this.props.teammateId && this.props.customStatusEnabled ? - ( - - ) : null; + const customStatus = this.props.teammateId && this.props.customStatusEnabled ? ( + + ) : null; return ( ); @@ -264,6 +266,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { justifyContent: 'center', marginHorizontal: 16, }, + titleContainer: { // These aren't used by this component, but they are passed down to the list component + alignItems: 'center', + backgroundColor: theme.sidebarBg, + flex: 1, + flexDirection: 'row', + height: 40, + paddingLeft: 16, + }, title: { color: theme.sidebarText, opacity: 0.4, @@ -273,13 +283,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { lineHeight: 18, fontFamily: 'Open Sans', }, - titleContainer: { // These aren't used by this component, but they are passed down to the list component - alignItems: 'center', - backgroundColor: theme.sidebarBg, - flex: 1, - flexDirection: 'row', - height: 40, - paddingLeft: 16, + chevron: { + marginLeft: -14, + color: changeOpacity(theme.sidebarText, 0.4), + fontSize: 18, + fontWeight: '100', }, }; }); diff --git a/app/components/sidebars/main/channels_list/list/__snapshots__/list.test.js.snap b/app/components/sidebars/main/channels_list/list/__snapshots__/list.test.js.snap index cc031f284..7a6148a9c 100644 --- a/app/components/sidebars/main/channels_list/list/__snapshots__/list.test.js.snap +++ b/app/components/sidebars/main/channels_list/list/__snapshots__/list.test.js.snap @@ -121,3 +121,81 @@ exports[`ChannelsList List should match snapshot with collapsed threads enabled /> `; + +exports[`ChannelsList List should match snapshot with unreads not on top 1`] = ` + + + + +`; diff --git a/app/components/sidebars/main/channels_list/list/index.js b/app/components/sidebars/main/channels_list/list/index.js index 450700b91..919f536f7 100644 --- a/app/components/sidebars/main/channels_list/list/index.js +++ b/app/components/sidebars/main/channels_list/list/index.js @@ -1,15 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. - import {connect} from 'react-redux'; import {DeviceTypes, ViewTypes} from '@constants'; import {General} from '@mm-redux/constants'; import Permissions from '@mm-redux/constants/permissions'; +import {getCategoriesWithFilteredChannelIds} from '@mm-redux/selectors/entities/channel_categories'; import { getSortedFavoriteChannelIds, getSortedUnreadChannelIds, getOrderedChannelIds, + getCurrentChannelId, } from '@mm-redux/selectors/entities/channels'; import {getTheme, getFavoritesPreferences, getSidebarPreferences, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {haveITeamPermission} from '@mm-redux/selectors/entities/roles'; @@ -17,6 +18,7 @@ import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; import {getCurrentUserRoles} from '@mm-redux/selectors/entities/users'; import {showCreateOption} from '@mm-redux/utils/channel_utils'; import {memoizeResult} from '@mm-redux/utils/helpers'; +import {shouldShowLegacySidebar} from '@utils/categories'; import List from './list'; @@ -34,6 +36,15 @@ function mapStateToProps(state) { const currentTeamId = getCurrentTeamId(state); const sidebarPrefs = getSidebarPreferences(state); const lastUnreadChannel = DeviceTypes.IS_TABLET ? state.views.channel.keepChannelIdAsUnread : null; + + // Unreads should always be on top in mobile (for now) + /* + const unreadsOnTop = getBool(state, + Preferences.CATEGORY_SIDEBAR_SETTINGS, + 'show_unread_section'); + */ + const unreadsOnTop = true; + const unreadChannelIds = getSortedUnreadChannelIds(state, lastUnreadChannel); const favoriteChannelIds = getSortedFavoriteChannelIds(state); const orderedChannelIds = filterZeroUnreads(getOrderedChannelIds( @@ -45,6 +56,11 @@ function mapStateToProps(state) { sidebarPrefs.favorite_at_top === 'true' && favoriteChannelIds.length, )); + // Grab our categories and channels + const categories = getCategoriesWithFilteredChannelIds(state); + + const currentChannelId = getCurrentChannelId(state); + const canJoinPublicChannels = haveITeamPermission(state, { team: currentTeamId, permission: Permissions.JOIN_PUBLIC_CHANNELS, @@ -52,15 +68,21 @@ function mapStateToProps(state) { const canCreatePublicChannels = showCreateOption(state, currentTeamId, General.OPEN_CHANNEL); const canCreatePrivateChannels = showCreateOption(state, currentTeamId, General.PRIVATE_CHANNEL); + const showLegacySidebar = shouldShowLegacySidebar(state); + return { + theme: getTheme(state), canJoinPublicChannels, canCreatePrivateChannels, canCreatePublicChannels, collapsedThreadsEnabled, - favoriteChannelIds, - theme: getTheme(state), unreadChannelIds, + favoriteChannelIds, orderedChannelIds, + categories, + showLegacySidebar, + unreadsOnTop, + currentChannelId, }; } diff --git a/app/components/sidebars/main/channels_list/list/list.js b/app/components/sidebars/main/channels_list/list/list.js index 02bde1955..8f4298677 100644 --- a/app/components/sidebars/main/channels_list/list/list.js +++ b/app/components/sidebars/main/channels_list/list/list.js @@ -1,5 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ import PropTypes from 'prop-types'; import React, {PureComponent} from 'react'; @@ -14,6 +15,7 @@ import { TouchableHighlight, View, } from 'react-native'; +import {isEqual} from 'underscore'; import {showModal} from '@actions/navigation'; import CompassIcon from '@components/compass_icon'; @@ -23,6 +25,7 @@ import {DeviceTypes, ListTypes, NavigationTypes} from '@constants'; import {SidebarSectionTypes} from '@constants/view'; import {debounce} from '@mm-redux/actions/helpers'; import {General} from '@mm-redux/constants'; +import {CategoryTypes} from '@mm-redux/constants/channel_categories'; import EventEmitter from '@mm-redux/utils/event_emitter'; import BottomSheet from '@utils/bottom_sheet'; import {t} from '@utils/i18n'; @@ -38,16 +41,21 @@ let UnreadIndicator = null; export default class List extends PureComponent { static propTypes = { testID: PropTypes.string, + styles: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired, + onSelectChannel: PropTypes.func.isRequired, + onCollapseCategory: PropTypes.func.isRequired, canJoinPublicChannels: PropTypes.bool.isRequired, canCreatePrivateChannels: PropTypes.bool.isRequired, canCreatePublicChannels: PropTypes.bool.isRequired, collapsedThreadsEnabled: PropTypes.bool, - favoriteChannelIds: PropTypes.array.isRequired, - onSelectChannel: PropTypes.func.isRequired, unreadChannelIds: PropTypes.array.isRequired, - styles: PropTypes.object.isRequired, - theme: PropTypes.object.isRequired, + favoriteChannelIds: PropTypes.array.isRequired, orderedChannelIds: PropTypes.array.isRequired, + categories: PropTypes.array, + showLegacySidebar: PropTypes.bool.isRequired, + unreadsOnTop: PropTypes.bool.isRequired, + currentChannelId: PropTypes.string, }; static contextTypes = { @@ -60,7 +68,8 @@ export default class List extends PureComponent { this.combinedActionsRef = React.createRef(); this.state = { - sections: this.buildSections(props), + sections: this.props.showLegacySidebar ? this.buildSections(props) : [], + categorySections: this.props.showLegacySidebar ? [] : this.buildCategorySections(), showIndicator: false, width: 0, }; @@ -85,20 +94,35 @@ export default class List extends PureComponent { this.setState({sections}); } + setCategorySections(categorySections) { + this.setState({categorySections}); + } + componentDidUpdate(prevProps, prevState) { const { canCreatePrivateChannels, orderedChannelIds, unreadChannelIds, + categories, } = prevProps; - if (this.props.canCreatePrivateChannels !== canCreatePrivateChannels || + // If legacy sidebar, continue with legacy updates + if (this.props.showLegacySidebar) { + if (this.props.canCreatePrivateChannels !== canCreatePrivateChannels || this.props.unreadChannelIds !== unreadChannelIds || this.props.orderedChannelIds !== orderedChannelIds) { - this.setSections(this.buildSections(this.props)); + this.setSections(this.buildSections(this.props)); + } + } else if ( + !isEqual(this.props.categories, categories) || + this.props.unreadChannelIds !== unreadChannelIds) { + // Rebuild sections only if categories or unreads have changed + this.setCategorySections(this.buildCategorySections()); } - if (prevState.sections !== this.state.sections && this.listRef?._wrapperListRef?.getListRef()._viewabilityHelper) { //eslint-disable-line + if ((prevState.sections !== this.state.sections || + prevState.categorySections !== this.state.categorySections) + && this.listRef?._wrapperListRef?.getListRef()._viewabilityHelper) { //eslint-disable-line this.listRef.recordInteraction(); this.updateUnreadIndicators({ viewableItems: Array.from(this.listRef._wrapperListRef.getListRef()._viewabilityHelper._viewableItems.values()) //eslint-disable-line @@ -178,7 +202,7 @@ export default class List extends PureComponent { return sections; }; - showCreateChannelOptions = () => { + showCreateChannelOptions = (category) => { const {formatMessage} = this.context.intl; const { canJoinPublicChannels, @@ -186,31 +210,25 @@ export default class List extends PureComponent { canCreatePublicChannels, } = this.props; - const moreChannelsText = formatMessage({id: 'more_channels.title', defaultMessage: 'More Channels'}); - const newPublicChannelText = formatMessage({id: 'mobile.create_channel.public', defaultMessage: 'New Public Channel'}); - const newPrivateChannelText = formatMessage({id: 'mobile.create_channel.private', defaultMessage: 'New Private Channel'}); - const newDirectChannelText = formatMessage({id: 'mobile.more_dms.title', defaultMessage: 'New Conversation'}); + const moreChannelsText = formatMessage({id: 'more_channels.title', defaultMessage: 'Browse for a Channel'}); + const newChannelText = formatMessage({id: 'mobile.create_channel', defaultMessage: 'Create a new Channel'}); + const newDirectChannelText = formatMessage({id: 'mobile.more_dms.title', defaultMessage: 'Add a Conversation'}); const cancelText = formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'}); const options = []; const actions = []; if (canJoinPublicChannels) { - actions.push(this.goToMoreChannels); - options.push(moreChannelsText); + actions.push(() => this.goToMoreChannels(category.id)); + options.push({text: moreChannelsText, icon: 'globe'}); } - if (canCreatePublicChannels) { - actions.push(this.goToCreatePublicChannel); - options.push(newPublicChannelText); - } - - if (canCreatePrivateChannels) { - actions.push(this.goToCreatePrivateChannel); - options.push(newPrivateChannelText); + if (canCreatePrivateChannels || canCreatePublicChannels) { + actions.push(() => this.goToCreateChannel(category.id)); + options.push({text: newChannelText, icon: 'plus'}); } actions.push(this.goToDirectMessages); - options.push(newDirectChannelText); + options.push({text: newDirectChannelText, icon: 'account-plus-outline'}); options.push(cancelText); const cancelButtonIndex = options.length - 1; @@ -218,6 +236,8 @@ export default class List extends PureComponent { BottomSheet.showBottomSheetWithOptions({ anchor: this.combinedActionsRef?.current ? findNodeHandle(this.combinedActionsRef.current) : null, options, + title: 'Add Channels', + subtitle: `To the ${category.display_name} category`, cancelButtonIndex, }, (value) => { if (value !== cancelButtonIndex) { @@ -252,6 +272,20 @@ export default class List extends PureComponent { showModal(screen, title, passProps); }); + goToCreateChannel = preventDoubleTap((categoryId) => { + const {intl} = this.context; + const screen = 'CreateChannel'; + const title = intl.formatMessage({id: 'mobile.create_channel', defaultMessage: 'Create a new Channel'}); + const passProps = { + channelType: General.OPEN_CHANNEL, + closeButton: this.closeButton, + categoryId, + }; + + EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEBAR); + showModal(screen, title, passProps); + }); + goToDirectMessages = preventDoubleTap(() => { const {intl} = this.context; const screen = 'MoreDirectMessages'; @@ -271,12 +305,13 @@ export default class List extends PureComponent { showModal(screen, title, passProps, options); }); - goToMoreChannels = preventDoubleTap(() => { + goToMoreChannels = preventDoubleTap((categoryId) => { const {intl} = this.context; const screen = 'MoreChannels'; const title = intl.formatMessage({id: 'more_channels.title', defaultMessage: 'More Channels'}); const passProps = { closeButton: this.closeButton, + categoryId, }; EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEBAR); @@ -351,6 +386,106 @@ export default class List extends PureComponent { ); }; + renderCategoryItem = ({item, section}) => { + if ((section.collapsed && this.props.currentChannelId !== item)) { + return null; + } + + const {testID, favoriteChannelIds, unreadChannelIds} = this.props; + const channelItemTestID = `${testID}.channel_item`; + + return ( + + ); + }; + + renderCategoryHeader = ({section}) => { + const {styles, onCollapseCategory} = this.props; + const {action, id, name, collapsed, type, data} = section; + const {intl} = this.context; + const anchor = (id === 'sidebar.types.recent' || id === 'mobile.channel_list.channels'); + + const title = () => { + switch (type) { + case CategoryTypes.UNREADS: + return intl.formatMessage({id: 'mobile.channel_list.unreads', defaultMessage: 'unreads'}).toUpperCase(); + case CategoryTypes.FAVORITES: + return intl.formatMessage({id: 'sidebar.favorites', defaultMessage: 'favorites'}).toUpperCase(); + case CategoryTypes.CHANNELS: + return intl.formatMessage({id: 'mobile.channel_list.channels', defaultMessage: 'channels'}).toUpperCase(); + case CategoryTypes.DIRECT_MESSAGES: + return intl.formatMessage({id: 'sidebar.direct', defaultMessage: 'direct messages'}).toUpperCase(); + default: + return name.toUpperCase(); + } + }; + + const header = ( + + {(type !== CategoryTypes.UNREADS && data.length > 0) && + + } + + {title()} + + + + + {action && this.renderSectionAction(styles, action, anchor, id)} + + ); + + if (type === CategoryTypes.UNREADS || data.length === 0) { + return header; + } + + return ( + onCollapseCategory(id, !collapsed)}> + {header} + + ); + } + + buildCategorySections = () => { + const categoriesBySection = []; + + // Start with Unreads + if (this.props.unreadChannelIds.length && this.props.unreadsOnTop) { + categoriesBySection.push({ + id: 'unreads', + name: 'UNREADS', + data: this.props.unreadChannelIds, + type: CategoryTypes.UNREADS, + }); + } + + // Add the rest + if (this.props.categories) { + this.props.categories.reduce((prev, cat) => { + prev.push({ + name: cat.display_name, + action: cat.type === 'direct_messages' ? this.goToDirectMessages : () => this.showCreateChannelOptions(cat), + data: cat.channel_ids, + ...cat, + }); + + return prev; + }, categoriesBySection); + } + + return categoriesBySection; + } + scrollToTop = () => { //eslint-disable-next-line no-underscore-dangle if (this.listRef?._wrapperListRef) { @@ -369,9 +504,10 @@ export default class List extends PureComponent { updateUnreadIndicators = ({viewableItems}) => { const {unreadChannelIds} = this.props; - const firstUnread = unreadChannelIds.length && unreadChannelIds[0]; - if (firstUnread && viewableItems.length) { - const isVisible = viewableItems.find((v) => v.item === firstUnread); + const firstUnreadId = unreadChannelIds.length && unreadChannelIds[0]; + + if (firstUnreadId && viewableItems.length) { + const isVisible = viewableItems.find((v) => v.item === firstUnreadId); return this.emitUnreadIndicatorChange(!isVisible); } @@ -400,8 +536,8 @@ export default class List extends PureComponent { }; render() { - const {collapsedThreadsEnabled, styles, testID, theme} = this.props; - const {sections, showIndicator} = this.state; + const {testID, styles, theme, showLegacySidebar, collapsedThreadsEnabled} = this.props; + const {sections, categorySections, showIndicator} = this.state; const paddingBottom = this.listContentPadding(); const indicatorStyle = [styles.above]; @@ -419,11 +555,11 @@ export default class List extends PureComponent { )} { canJoinPublicChannels: true, canCreatePrivateChannels: true, canCreatePublicChannels: true, + showLegacySidebar: true, collapsedThreadsEnabled: false, favoriteChannelIds: [], unreadChannelIds: [], @@ -22,6 +23,9 @@ describe('ChannelsList List', () => { theme: Preferences.THEMES.denim, orderedChannelIds: [], isLandscape: false, + onCollapseCategory: jest.fn(), + unreadChannels: [], + unreadsOnTop: true, }; test('should match snapshot', () => { @@ -30,6 +34,17 @@ describe('ChannelsList List', () => { expect(wrapper.getElement()).toMatchSnapshot(); }); + test('should match snapshot with unreads not on top', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + test('should match snapshot with collapsed threads enabled', () => { const wrapper = shallow( { setChannelDisplayName: jest.fn(), setChannelLoading: jest.fn(), joinChannel: jest.fn(), + setCategoryCollapsed: jest.fn(), }, blurPostTextBox: jest.fn(), currentTeamId: 'current-team-id', diff --git a/app/components/sidebars/main/main_sidebar_base.js b/app/components/sidebars/main/main_sidebar_base.js index fac158675..5babe8e0f 100644 --- a/app/components/sidebars/main/main_sidebar_base.js +++ b/app/components/sidebars/main/main_sidebar_base.js @@ -28,6 +28,7 @@ export default class MainSidebarBase extends Component { joinChannel: PropTypes.func.isRequired, makeDirectChannel: PropTypes.func.isRequired, setChannelDisplayName: PropTypes.func.isRequired, + setCategoryCollapsed: PropTypes.func.isRequired, handleNotViewingGlobalThreadsScreen: PropTypes.func, }).isRequired, children: PropTypes.node, @@ -240,6 +241,7 @@ export default class MainSidebarBase extends Component { testID='main.sidebar.channels_list' ref={this.channelListRef} onSelectChannel={this.selectChannel} + onCollapseCategory={this.collapseCategory} onJoinChannel={this.joinChannel} onShowTeams={multipleTeams ? this.showTeams : undefined} onSearchStart={this.onSearchStart} @@ -272,6 +274,12 @@ export default class MainSidebarBase extends Component { ); }; + collapseCategory = (categoryId, collapse) => { + const {setCategoryCollapsed} = this.props.actions; + + setCategoryCollapsed(categoryId, collapse); + } + selectChannel = (channel, currentChannelId, closeDrawer = true) => { const {handleSelectChannel, handleNotViewingGlobalThreadsScreen} = this.props.actions; diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index d3944294a..f1b36fe8b 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -47,5 +47,9 @@ const WebsocketEvents = { THREAD_FOLLOW_CHANGED: 'thread_follow_changed', THREAD_READ_CHANGED: 'thread_read_changed', APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings', + SIDEBAR_CATEGORY_CREATED: 'sidebar_category_created', + SIDEBAR_CATEGORY_UPDATED: 'sidebar_category_updated', + SIDEBAR_CATEGORY_DELETED: 'sidebar_category_deleted', + SIDEBAR_CATEGORY_ORDER_UPDATED: 'sidebar_category_order_updated', }; export default WebsocketEvents; diff --git a/app/init/fetch.js b/app/init/fetch.js index adbe9ba3a..6aecc524c 100644 --- a/app/init/fetch.js +++ b/app/init/fetch.js @@ -45,8 +45,6 @@ const handleRedirectProtocol = (url, response) => { }; Client4.doFetchWithResponse = async (url, options) => { - // eslint-disable-next-line no-console - console.log('Request endpoint', url); const customHeaders = LocalConfig.CustomRequestHeaders; let waitsForConnectivity = false; let timeoutIntervalForResource = 30; diff --git a/app/mm-redux/action_types/channels.ts b/app/mm-redux/action_types/channels.ts index 27ad2bb2e..d16995109 100644 --- a/app/mm-redux/action_types/channels.ts +++ b/app/mm-redux/action_types/channels.ts @@ -1,7 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import keyMirror from '@mm-redux/utils/key_mirror'; - export default keyMirror({ CHANNEL_REQUEST: null, CHANNEL_SUCCESS: null, @@ -64,6 +63,8 @@ export default keyMirror({ CHANNEL_MEMBER_ADDED: null, CHANNEL_MEMBER_REMOVED: null, + SET_CHANNEL_MUTED: null, + INCREMENT_TOTAL_MSG_COUNT: null, INCREMENT_UNREAD_MSG_COUNT: null, DECREMENT_UNREAD_MSG_COUNT: null, @@ -71,6 +72,13 @@ export default keyMirror({ INCREMENT_UNREAD_MENTION_COUNT: null, DECREMENT_UNREAD_MENTION_COUNT: null, + UPDATED_CHANNEL_SCHEME: null, + UPDATED_CHANNEL_MEMBER_SCHEME_ROLES: null, + + RECEIVED_CHANNEL_MEMBERS_MINUS_GROUP_MEMBERS: null, + + RECEIVED_CHANNEL_MODERATIONS: null, + RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP: null, RECEIVED_TOTAL_CHANNEL_COUNT: null, diff --git a/app/mm-redux/actions/channel_categories.ts b/app/mm-redux/actions/channel_categories.ts index 7f82c7f9c..5a3d60851 100644 --- a/app/mm-redux/actions/channel_categories.ts +++ b/app/mm-redux/actions/channel_categories.ts @@ -1,18 +1,528 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {ChannelCategoryTypes} from '@mm-redux/action_types'; +/* eslint-disable max-lines */ +import {isEqual} from 'lodash'; + +import {Client4} from '@client/rest'; +import {getUser} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies'; +import {ChannelCategoryTypes, ChannelTypes} from '@mm-redux/action_types'; +import {General} from '@mm-redux/constants'; +import {CategoryTypes} from '@mm-redux/constants/channel_categories'; +import {getAllCategoriesByIds, getCategory, getCategoryIdsForTeam, getCategoryInTeamByType, getCategoryInTeamWithChannel} from '@mm-redux/selectors/entities/channel_categories'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/common'; +import {getUser as selectUser, getUserIdsInChannels} from '@mm-redux/selectors/entities/users'; +import {ActionFunc, batchActions, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; +import {CategorySorting, ChannelCategory, OrderedChannelCategories} from '@mm-redux/types/channel_categories'; +import {Channel} from '@mm-redux/types/channels'; +import {UserProfile} from '@mm-redux/types/users'; +import {$ID, IDMappedObjects, RelationOneToMany} from '@mm-redux/types/utilities'; +import {insertMultipleWithoutDuplicates, insertWithoutDuplicates, removeItem} from '@mm-redux/utils/array_utils'; +import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils'; + +import {favoriteChannel, getChannelMembersByIds, unfavoriteChannel} from './channels'; +import {logError} from './errors'; +import {forceLogoutIfNecessary} from './helpers'; export function expandCategory(categoryId: string) { - return { - type: ChannelCategoryTypes.CATEGORY_EXPANDED, - data: categoryId, - }; + return setCategoryCollapsed(categoryId, false); } export function collapseCategory(categoryId: string) { - return { - type: ChannelCategoryTypes.CATEGORY_COLLAPSED, - data: categoryId, + return setCategoryCollapsed(categoryId, true); +} + +export function setCategoryCollapsed(categoryId: string, collapsed: boolean) { + return patchCategory(categoryId, { + collapsed, + }); +} + +export function setCategorySorting(categoryId: string, sorting: CategorySorting) { + return patchCategory(categoryId, { + sorting, + }); +} + +export function patchCategory(categoryId: string, patch: Partial): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + const currentUserId = getCurrentUserId(state); + + const category = getCategory(state, categoryId); + const patchedCategory = { + ...category, + ...patch, + }; + + dispatch({ + type: ChannelCategoryTypes.RECEIVED_CATEGORY, + data: patchedCategory, + }); + + try { + Client4.updateChannelCategory(currentUserId, category.team_id, patchedCategory); + } catch (error) { + dispatch({ + type: ChannelCategoryTypes.RECEIVED_CATEGORY, + data: category, + }); + + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + return {data: patchedCategory}; + }; +} + +export function setCategoryMuted(categoryId: string, muted: boolean) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + const category = getCategory(state, categoryId); + + const result = await dispatch(updateCategory({ + ...category, + muted, + })); + + if ('error' in result) { + return result; + } + + const updated = result.data as ChannelCategory; + + return dispatch(batchActions([ + { + type: ChannelCategoryTypes.RECEIVED_CATEGORY, + data: updated, + }, + ...(updated.channel_ids.map((channelId) => ({ + type: ChannelTypes.SET_CHANNEL_MUTED, + data: { + channelId, + muted, + }, + }))), + ])); + }; +} + +function updateCategory(category: ChannelCategory) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + const currentUserId = getCurrentUserId(state); + + let updatedCategory; + try { + updatedCategory = await Client4.updateChannelCategory(currentUserId, category.team_id, category); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + // The updated category will be added to the state after receiving the corresponding websocket event. + + return {data: updatedCategory}; + }; +} + +export function fetchMyCategories(teamId: string) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const currentUserId = getCurrentUserId(getState()); + + let data: OrderedChannelCategories; + try { + data = await Client4.getChannelCategories(currentUserId, teamId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + /* + * Make sure that we don't dispatch an unnecessary update after fetching + */ + const categoriesInState = getState().entities.channelCategories.byId; + const mappedCats = data.order.reduce((prev, categoryId) => { + return { + ...prev, + [categoryId]: data.categories.find((category) => category.id === categoryId), + }; + }, {} as IDMappedObjects); + + if (isEqual(mappedCats, categoriesInState)) { + return {data: false}; + } + + return dispatch(batchActions([ + { + type: ChannelCategoryTypes.RECEIVED_CATEGORIES, + data: data.categories, + }, + { + type: ChannelCategoryTypes.RECEIVED_CATEGORY_ORDER, + data: { + teamId, + order: data.order, + }, + }, + ])); + }; +} + +// addChannelToInitialCategory returns an action that can be dispatched to add a newly-joined or newly-created channel +// to its either the Channels or Direct Messages category based on the type of channel. New DM and GM channels are +// added to the Direct Messages category on each team. +// +// Unless setOnServer is true, this only affects the categories on this client. If it is set to true, this updates +// categories on the server too. +export function addChannelToInitialCategory(channel: Channel, setOnServer = false): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + const categories = Object.values(getAllCategoriesByIds(state)); + + if (channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL) { + const currentUserId = getCurrentUserId(state); + if (channel.type === General.DM_CHANNEL) { + const otherUserId = getUserIdFromChannelName(currentUserId, channel.name); + const otherUser = selectUser(state, otherUserId); + + if (!otherUser) { + dispatch(getUser(otherUserId)); + } + } + + if (channel.type === General.GM_CHANNEL) { + // Get the user ids in the channel + const allUsersInChannels: RelationOneToMany = getUserIdsInChannels(state); + const allUsersInGMChannel = Array.from(allUsersInChannels[channel.id] || []); + const usersInGMChannel: Array = allUsersInGMChannel.filter((u: string) => u !== currentUserId); + + // Filter and see if there are any missing in our state + const missingUsers = usersInGMChannel.filter((id) => { + if (selectUser(state, id)) { + return false; + } + return true; + }); + + // Fetch them if there are missing members + if (missingUsers.length) { + dispatch(getChannelMembersByIds(channel.id, missingUsers)); + } + } + + const allDmCategories = categories.filter((category) => category.type === CategoryTypes.DIRECT_MESSAGES); + + // Get all the categories in which channel exists + const channelInCategories = categories.filter((category) => { + return category.channel_ids.findIndex((channelId) => channelId === channel.id) !== -1; + }); + + // Skip DM categories where channel already exists in a different category + const dmCategories = allDmCategories.filter((dmCategory) => { + return channelInCategories.findIndex((category) => dmCategory.team_id === category.team_id) === -1; + }); + + const data = dmCategories.map((category) => ({ + ...category, + channel_ids: insertWithoutDuplicates(category.channel_ids, channel.id, 0), + })); + + return dispatch({ + type: ChannelCategoryTypes.RECEIVED_CATEGORIES, + data, + }); + } + + // Add the new channel to the Channels category on the channel's team + if (categories.some((category) => category.channel_ids.some((channelId) => channelId === channel.id))) { + return {data: false}; + } + const channelsCategory = getCategoryInTeamByType(state, channel.team_id, CategoryTypes.CHANNELS); + + if (!channelsCategory) { + // No categories were found for this team, so the categories for this team haven't been loaded yet. + // The channel will have been added to the category by the server, so we'll get it once the categories + // are actually loaded. + return {data: false}; + } + + if (setOnServer) { + return dispatch(addChannelToCategory(channelsCategory.id, channel.id)); + } + + return dispatch({ + type: ChannelCategoryTypes.RECEIVED_CATEGORY, + data: { + ...channelsCategory, + channel_ids: insertWithoutDuplicates(channelsCategory.channel_ids, channel.id, 0), + }, + }); + }; +} + +// addChannelToCategory returns an action that can be dispatched to add a channel to a given category without specifying +// its order. The channel will be removed from its previous category (if any) on the given category's team and it will be +// placed first in its new category. +export function addChannelToCategory(categoryId: string, channelId: string): ActionFunc { + return moveChannelToCategory(categoryId, channelId, 0, false); +} + +// moveChannelToCategory returns an action that moves a channel into a category and puts it at the given index at the +// category. The channel will also be removed from its previous category (if any) on that category's team. The category's +// order will also be set to manual by default. +export function moveChannelToCategory(categoryId: string, channelId: string, newIndex: number, setManualSorting = true) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + const targetCategory = getCategory(state, categoryId); + const currentUserId = getCurrentUserId(state); + + // The default sorting needs to behave like alphabetical sorting until the point that the user rearranges their + // channels at which point, it becomes manual. Other than that, we never change the sorting method automatically. + let sorting = targetCategory.sorting; + if (setManualSorting && + targetCategory.type !== CategoryTypes.DIRECT_MESSAGES && + targetCategory.sorting === CategorySorting.Default) { + sorting = CategorySorting.Manual; + } + + // Add the channel to the new category + const categories = [{ + ...targetCategory, + sorting, + channel_ids: insertWithoutDuplicates(targetCategory.channel_ids, channelId, newIndex), + }]; + + // And remove it from the old category + const sourceCategory = getCategoryInTeamWithChannel(getState(), targetCategory.team_id, channelId); + if (sourceCategory && sourceCategory.id !== targetCategory.id) { + categories.push({ + ...sourceCategory, + channel_ids: removeItem(sourceCategory.channel_ids, channelId), + }); + } + + const result = dispatch({ + type: ChannelCategoryTypes.RECEIVED_CATEGORIES, + data: categories, + }); + + try { + await Client4.updateChannelCategories(currentUserId, targetCategory.team_id, categories); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + + const originalCategories = [targetCategory]; + if (sourceCategory && sourceCategory.id !== targetCategory.id) { + originalCategories.push(sourceCategory); + } + + dispatch({ + type: ChannelCategoryTypes.RECEIVED_CATEGORIES, + data: originalCategories, + }); + return {error}; + } + + // Update the favorite preferences locally on the client in case we have any logic relying on that + if (targetCategory.type === CategoryTypes.FAVORITES) { + await dispatch(favoriteChannel(channelId, false)); + } else if (sourceCategory && sourceCategory.type === CategoryTypes.FAVORITES) { + await dispatch(unfavoriteChannel(channelId, false)); + } + + return result; + }; +} + +export function moveChannelsToCategory(categoryId: string, channelIds: string[], newIndex: number, setManualSorting = true) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + const targetCategory = getCategory(state, categoryId); + const currentUserId = getCurrentUserId(state); + + // The default sorting needs to behave like alphabetical sorting until the point that the user rearranges their + // channels at which point, it becomes manual. Other than that, we never change the sorting method automatically. + let sorting = targetCategory.sorting; + if (setManualSorting && + targetCategory.type !== CategoryTypes.DIRECT_MESSAGES && + targetCategory.sorting === CategorySorting.Default) { + sorting = CategorySorting.Manual; + } + + // Add the channels to the new category + let categories = { + [targetCategory.id]: { + ...targetCategory, + sorting, + channel_ids: insertMultipleWithoutDuplicates(targetCategory.channel_ids, channelIds, newIndex), + }, + }; + + // Needed if we have to revert categories and for checking for favourites + let unmodifiedCategories = {[targetCategory.id]: targetCategory}; + let sourceCategories: Record = {}; + + // And remove it from the old categories + channelIds.forEach((channelId) => { + const sourceCategory = getCategoryInTeamWithChannel(getState(), targetCategory.team_id, channelId); + if (sourceCategory && sourceCategory.id !== targetCategory.id) { + unmodifiedCategories = { + ...unmodifiedCategories, + [sourceCategory.id]: sourceCategory, + }; + sourceCategories = {...sourceCategories, [channelId]: sourceCategory.id}; + categories = { + ...categories, + [sourceCategory.id]: { + ...(categories[sourceCategory.id] || sourceCategory), + channel_ids: removeItem((categories[sourceCategory.id] || sourceCategory).channel_ids, channelId), + }, + }; + } + }); + + const categoriesArray = Object.values(categories).reduce((allCategories: ChannelCategory[], category) => { + allCategories.push(category); + return allCategories; + }, []); + + const result = dispatch({ + type: ChannelCategoryTypes.RECEIVED_CATEGORIES, + data: categoriesArray, + }); + + try { + await Client4.updateChannelCategories(currentUserId, targetCategory.team_id, categoriesArray); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + + const originalCategories = Object.values(unmodifiedCategories).reduce((allCategories: ChannelCategory[], category) => { + allCategories.push(category); + return allCategories; + }, []); + + dispatch({ + type: ChannelCategoryTypes.RECEIVED_CATEGORIES, + data: originalCategories, + }); + return {error}; + } + + // Update the favorite preferences locally on the client in case we have any logic relying on that + await Promise.all(channelIds.map(async (channelId) => { + const sourceCategory = unmodifiedCategories[sourceCategories[channelId]]; + if (targetCategory.type === CategoryTypes.FAVORITES) { + await dispatch(favoriteChannel(channelId, false)); + } else if (sourceCategory && sourceCategory.type === CategoryTypes.FAVORITES) { + await dispatch(unfavoriteChannel(channelId, false)); + } + })); + return result; + }; +} + +export function moveCategory(teamId: string, categoryId: string, newIndex: number) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + const order = getCategoryIdsForTeam(state, teamId)!; + const currentUserId = getCurrentUserId(state); + + const newOrder = insertWithoutDuplicates(order, categoryId, newIndex); + + // Optimistically update the category order + const result = dispatch({ + type: ChannelCategoryTypes.RECEIVED_CATEGORY_ORDER, + data: { + teamId, + order: newOrder, + }, + }); + + try { + await Client4.updateChannelCategoryOrder(currentUserId, teamId, newOrder); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + + // Restore original order + dispatch({ + type: ChannelCategoryTypes.RECEIVED_CATEGORY_ORDER, + data: { + teamId, + order, + }, + }); + + return {error}; + } + + return result; + }; +} + +export function receivedCategoryOrder(teamId: string, order: string[]) { + return { + type: ChannelCategoryTypes.RECEIVED_CATEGORY_ORDER, + data: { + teamId, + order, + }, + }; +} + +export function createCategory(teamId: string, displayName: string, channelIds: Array<$ID> = []): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const currentUserId = getCurrentUserId(getState()); + + let newCategory; + try { + newCategory = await Client4.createChannelCategory(currentUserId, teamId, { + team_id: teamId, + user_id: currentUserId, + display_name: displayName, + channel_ids: channelIds, + }); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + // The new category will be added to the state after receiving the corresponding websocket event. + + return {data: newCategory}; + }; +} + +export function renameCategory(categoryId: string, displayName: string): ActionFunc { + return patchCategory(categoryId, { + display_name: displayName, + }); +} + +export function deleteCategory(categoryId: string): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + const category = getCategory(state, categoryId); + const currentUserId = getCurrentUserId(state); + + try { + await Client4.deleteChannelCategory(currentUserId, category.team_id, category.id); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + // The category will be deleted from the state after receiving the corresponding websocket event. + + return {data: true}; }; } diff --git a/app/mm-redux/actions/channels.test.js b/app/mm-redux/actions/channels.test.js index f46c4daf6..6eff3d4fa 100644 --- a/app/mm-redux/actions/channels.test.js +++ b/app/mm-redux/actions/channels.test.js @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ import assert from 'assert'; import nock from 'nock'; @@ -28,6 +29,12 @@ describe('Actions.Channels', () => { users: { currentUserId: TestHelper.basicUser.id, }, + general: { + config: { + EnableLegacySidebar: 'true', + }, + serverVersion: '5.30.0', + }, }, }; store = await configureStore(initialState); diff --git a/app/mm-redux/actions/channels.ts b/app/mm-redux/actions/channels.ts index c1abce177..8ad98b7fd 100644 --- a/app/mm-redux/actions/channels.ts +++ b/app/mm-redux/actions/channels.ts @@ -1,14 +1,19 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ import {Client4} from '@client/rest'; import {analytics} from '@init/analytics'; import {ChannelTypes, PreferenceTypes, TeamTypes, UserTypes} from '@mm-redux/action_types'; +import {CategoryTypes} from '@mm-redux/constants/channel_categories'; +import {getCategoryInTeamByType} from '@mm-redux/selectors/entities/channel_categories'; import { getChannelsNameMapInTeam, getMyChannelMember as getMyChannelMemberSelector, getRedirectChannelNameForTeam, isManuallyUnread, + getChannel as getChannelSelector, } from '@mm-redux/selectors/entities/channels'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/common'; import {getConfig} from '@mm-redux/selectors/entities/general'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; import {Action, ActionFunc, batchActions, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; @@ -19,6 +24,7 @@ import {compareNotifyProps, getChannelsIdForTeam, getChannelByName as selectChan import {General, Preferences} from '../constants'; +import {addChannelToCategory, addChannelToInitialCategory} from './channel_categories'; import {logError} from './errors'; import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; import {savePreferences, deletePreferences} from './preferences'; @@ -232,6 +238,9 @@ export function createGroupChannel(userIds: Array): ActionFunc { data: profilesInChannel, }, ])); + + dispatch(addChannelToInitialCategory(created, true)); + dispatch(loadRolesIfNeeded((member && member.roles && member.roles.split(' ')) || [])); return {data: created}; @@ -705,10 +714,10 @@ export function leaveChannel(channelId: string): ActionFunc { }; } -export function joinChannel(userId: string, teamId: string, channelId: string, channelName: string): ActionFunc { +export function joinChannel(userId: string, teamId: string, channelId: string, channelName: string, categoryId?: string): ActionFunc { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let member: ChannelMembership | undefined | null; - let channel; + let channel: Channel | undefined; try { if (channelId) { member = await Client4.addToChannel(userId, channelId); @@ -739,6 +748,13 @@ export function joinChannel(userId: string, teamId: string, channelId: string, c data: member, }, ])); + + if (categoryId) { + dispatch(addChannelToCategory(categoryId, channel!.id)); + } else { + dispatch(addChannelToInitialCategory(channel!)); + } + if (member) { dispatch(loadRolesIfNeeded(member.roles.split(' '))); } @@ -1413,9 +1429,12 @@ export function getMyChannelMember(channelId: string) { }); } -export function favoriteChannel(channelId: string): ActionFunc { +export function favoriteChannel(channelId: string, updateCategories = true): ActionFunc { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const {currentUserId} = getState().entities.users; + const state = getState(); + const config = getConfig(state); + const currentUserId = getCurrentUserId(state); + const preference: PreferenceType = { user_id: currentUserId, category: Preferences.CATEGORY_FAVORITE_CHANNEL, @@ -1423,15 +1442,34 @@ export function favoriteChannel(channelId: string): ActionFunc { value: 'true', }; - analytics.trackAction('action_channels_favorite'); + if (config.EnableLegacySidebar === 'true') { + // The old sidebar is enabled, so favorite the channel by calling the preferences API + return dispatch(savePreferences(currentUserId, [preference])); + } - return dispatch(savePreferences(currentUserId, [preference])); + // The new sidebar is enabled, so favorite the channel by moving it into the current team's Favorites category + if (updateCategories) { + const channel = getChannelSelector(state, channelId); + const category = getCategoryInTeamByType(state, channel.team_id || getCurrentTeamId(state), CategoryTypes.FAVORITES); + + if (category) { + await dispatch(addChannelToCategory(category.id, channelId)); + } + } + + return dispatch({ + type: PreferenceTypes.RECEIVED_PREFERENCES, + data: [preference], + }); }; } -export function unfavoriteChannel(channelId: string): ActionFunc { +export function unfavoriteChannel(channelId: string, updateCategories = true): ActionFunc { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const {currentUserId} = getState().entities.users; + const state = getState(); + const config = getConfig(state); + const currentUserId = getCurrentUserId(state); + const preference: PreferenceType = { user_id: currentUserId, category: Preferences.CATEGORY_FAVORITE_CHANNEL, @@ -1439,9 +1477,29 @@ export function unfavoriteChannel(channelId: string): ActionFunc { value: '', }; - analytics.trackAction('action_channels_unfavorite'); + if (config.EnableLegacySidebar === 'true') { + // The old sidebar is enabled, so unfavorite the channel by calling the preferences API + return dispatch(deletePreferences(currentUserId, [preference])); + } - return deletePreferences(currentUserId, [preference])(dispatch, getState); + // The new sidebar is enabled, so unfavorite the channel by moving it into the current team's Channels/DMs category + if (updateCategories) { + const channel = getChannelSelector(state, channelId); + const category = getCategoryInTeamByType( + state, + channel.team_id || getCurrentTeamId(state), + channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL ? CategoryTypes.DIRECT_MESSAGES : CategoryTypes.CHANNELS, + ); + + if (category) { + await dispatch(addChannelToCategory(category.id, channel.id)); + } + } + + return dispatch({ + type: PreferenceTypes.DELETED_PREFERENCES, + data: [preference], + }); }; } diff --git a/app/mm-redux/actions/search.test.js b/app/mm-redux/actions/search.test.js index 727ed0e3c..322b581d6 100644 --- a/app/mm-redux/actions/search.test.js +++ b/app/mm-redux/actions/search.test.js @@ -61,9 +61,11 @@ describe('Actions.Search', () => { const state = getState(); const {recent, results} = state.entities.search; + const {posts} = state.entities.posts; const current = state.entities.search.current[TestHelper.basicTeam.id]; assert.ok(recent[TestHelper.basicTeam.id]); + const searchIsPresent = recent[TestHelper.basicTeam.id].findIndex((r) => r.terms === search1); assert.ok(searchIsPresent !== -1); assert.equal(Object.keys(recent[TestHelper.basicTeam.id]).length, 1); diff --git a/app/mm-redux/constants/channel_categories.ts b/app/mm-redux/constants/channel_categories.ts index 6b1aac9a4..74a96da14 100644 --- a/app/mm-redux/constants/channel_categories.ts +++ b/app/mm-redux/constants/channel_categories.ts @@ -9,4 +9,5 @@ export const CategoryTypes: {[name: string]: ChannelCategoryType} = { PRIVATE: 'private', DIRECT_MESSAGES: 'direct_messages', CUSTOM: 'custom', + CHANNELS: 'channels', }; diff --git a/app/mm-redux/constants/channels.ts b/app/mm-redux/constants/channels.ts new file mode 100644 index 000000000..c56ba0674 --- /dev/null +++ b/app/mm-redux/constants/channels.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const NotificationLevel = { + DEFAULT: 'default', + ALL: 'all', + MENTION: 'mention', + NONE: 'none', +}; + +export const MarkUnread = { + ALL: 'all', + MENTION: 'mention', +}; diff --git a/app/mm-redux/constants/preferences.ts b/app/mm-redux/constants/preferences.ts index 6da4bc4f9..5b8412cd6 100644 --- a/app/mm-redux/constants/preferences.ts +++ b/app/mm-redux/constants/preferences.ts @@ -33,6 +33,7 @@ const Preferences: Dictionary = { NAME_CUSTOM_STATUS_TUTORIAL_STATE: 'custom_status_tutorial_state', NAME_RECENT_CUSTOM_STATUSES: 'recent_custom_statuses', CUSTOM_STATUS_MODAL_VIEWED: 'custom_status_modal_viewed', + LIMIT_VISIBLE_DMS_GMS: 'limit_visible_dms_gms', // "immediate" is a 30 second interval INTERVAL_NEVER: 0, diff --git a/app/mm-redux/reducers/entities/channel_categories.test.js b/app/mm-redux/reducers/entities/channel_categories.test.js index d25b37469..f38e3db18 100644 --- a/app/mm-redux/reducers/entities/channel_categories.test.js +++ b/app/mm-redux/reducers/entities/channel_categories.test.js @@ -8,49 +8,6 @@ import {CategoryTypes} from '../../constants/channel_categories'; import * as Reducers from './channel_categories'; describe('byId', () => { - test('default categories should be added when a member is received', () => { - const initialState = {}; - - const state = Reducers.byId( - initialState, - { - type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, - data: { - team_id: 'team1', - }, - }, - ); - - expect(state['team1-favorites']).toBeDefined(); - expect(state['team1-public']).toBeDefined(); - expect(state['team1-private']).toBeDefined(); - expect(state['team1-direct_messages']).toBeDefined(); - }); - - test('default categories should be added when multiple members are received', () => { - const initialState = {}; - - const state = Reducers.byId( - initialState, - { - type: TeamTypes.RECEIVED_MY_TEAM_MEMBERS, - data: [ - {team_id: 'team1'}, - {team_id: 'team2'}, - ], - }, - ); - - expect(state['team1-favorites']).toBeDefined(); - expect(state['team1-public']).toBeDefined(); - expect(state['team1-private']).toBeDefined(); - expect(state['team1-direct_messages']).toBeDefined(); - expect(state['team2-favorites']).toBeDefined(); - expect(state['team2-public']).toBeDefined(); - expect(state['team2-private']).toBeDefined(); - expect(state['team2-direct_messages']).toBeDefined(); - }); - test('should remove corresponding categories when leaving a team', () => { const initialState = { category1: {id: 'category1', team_id: 'team1', type: CategoryTypes.CUSTOM}, @@ -80,59 +37,6 @@ describe('byId', () => { }); describe('orderByTeam', () => { - test('default category order should be added when a member is received', () => { - const initialState = {}; - - const state = Reducers.orderByTeam( - initialState, - { - type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, - data: { - team_id: 'team1', - }, - }, - ); - - expect(state).toEqual({ - team1: [ - 'team1-favorites', - 'team1-public', - 'team1-private', - 'team1-direct_messages', - ], - }); - }); - - test('default category order should be added when multiple members are received', () => { - const initialState = {}; - - const state = Reducers.orderByTeam( - initialState, - { - type: TeamTypes.RECEIVED_MY_TEAM_MEMBERS, - data: [ - {team_id: 'team1'}, - {team_id: 'team2'}, - ], - }, - ); - - expect(state).toEqual({ - team1: [ - 'team1-favorites', - 'team1-public', - 'team1-private', - 'team1-direct_messages', - ], - team2: [ - 'team2-favorites', - 'team2-public', - 'team2-private', - 'team2-direct_messages', - ], - }); - }); - test('should remove correspoding order when leaving a team', () => { const initialState = { team1: ['category1', 'category2', 'dmCategory1'], diff --git a/app/mm-redux/reducers/entities/channel_categories.ts b/app/mm-redux/reducers/entities/channel_categories.ts index 37f716f88..950dd27ab 100644 --- a/app/mm-redux/reducers/entities/channel_categories.ts +++ b/app/mm-redux/reducers/entities/channel_categories.ts @@ -3,59 +3,76 @@ import {combineReducers} from 'redux'; -import {TeamTypes} from '@mm-redux/action_types'; +import {ChannelCategoryTypes, TeamTypes, ChannelTypes} from '@mm-redux/action_types'; import {GenericAction} from '@mm-redux/types/actions'; import {ChannelCategory} from '@mm-redux/types/channel_categories'; -import {Team, TeamMembership} from '@mm-redux/types/teams'; +import {Team} from '@mm-redux/types/teams'; import {$ID, IDMappedObjects, RelationOneToOne} from '@mm-redux/types/utilities'; - -import {CategoryTypes} from '../../constants/channel_categories'; +import {removeItem} from '@mm-redux/utils/array_utils'; export function byId(state: IDMappedObjects = {}, action: GenericAction) { switch (action.type) { - case TeamTypes.RECEIVED_MY_TEAM_MEMBER: { - // This will be removed once categories are sent by the server - const member: TeamMembership = action.data; + case ChannelCategoryTypes.RECEIVED_CATEGORIES: { + const categories: ChannelCategory[] = action.data; - // Note that this adds new categories before state to prevent overwriting existing categories - return { - ...makeDefaultCategories(member.team_id), - ...state, - }; - } - case TeamTypes.RECEIVED_MY_TEAM_MEMBERS: { - // This will be removed once categories are sent by the server - const members: TeamMembership[] = action.data; - - return members.reduce((nextState, member) => { - // Note that this adds new categories before state to prevent overwriting existing categories + return categories.reduce((prev, category) => { return { - ...makeDefaultCategories(member.team_id), - ...nextState, + ...prev, + [category.id]: { + ...prev[category.id], + ...category, + }, }; }, state); } + case ChannelCategoryTypes.RECEIVED_CATEGORY_ORDER: { + const order: string[] = action.data.order; - // This will be added in phase 2 of Channel Sidebar Organization once the server provides the categories - // case ChannelCategoryTypes.RECEIVED_CATEGORIES: { - // const categories: ChannelCategory[] = action.data; + return order.reduce((prev, categoryId) => { + return { + ...prev, + [categoryId]: state[categoryId], + }; + }, {} as IDMappedObjects); + } + case ChannelCategoryTypes.RECEIVED_CATEGORY: { + const category: ChannelCategory = action.data; - // return categories.reduce((nextState, category) => { - // return { - // ...nextState, - // [category.id]: category, - // }; - // }, state); - // } - // case ChannelCategoryTypes.RECEIVED_CATEGORY: { - // const category: ChannelCategory = action.data; + return { + ...state, + [category.id]: { + ...state[category.id], + ...category, + }, + }; + } - // return { - // ...state, - // [category.id]: category, - // }; - // } + case ChannelTypes.LEAVE_CHANNEL: { + const channelId: string = action.data.id; + const nextState = {...state}; + let changed = false; + + for (const category of Object.values(state)) { + const index = category.channel_ids.indexOf(channelId); + + if (index === -1) { + continue; + } + + const nextChannelIds = [...category.channel_ids]; + nextChannelIds.splice(index, 1); + + nextState[category.id] = { + ...category, + channel_ids: nextChannelIds, + }; + + changed = true; + } + + return changed ? nextState : state; + } case TeamTypes.LEAVE_TEAM: { const team: Team = action.data; @@ -71,11 +88,7 @@ export function byId(state: IDMappedObjects = {}, action: Gener changed = true; } - if (!changed) { - return state; - } - - return nextState; + return changed ? nextState : state; } default: @@ -83,48 +96,31 @@ export function byId(state: IDMappedObjects = {}, action: Gener } } -export function orderByTeam(state: RelationOneToOne[]> = {}, action: GenericAction) { +export function orderByTeam(state: RelationOneToOne>> = {}, action: GenericAction) { switch (action.type) { - case TeamTypes.RECEIVED_MY_TEAM_MEMBER: { - // This will be removed once categories are sent by the server - const member: TeamMembership = action.data; - - if (state[member.team_id]) { - return state; - } + case ChannelCategoryTypes.RECEIVED_CATEGORY_ORDER: { + const teamId: string = action.data.teamId; + const order: string[] = action.data.order; return { ...state, - [member.team_id]: makeDefaultCategoryIds(member.team_id), + [teamId]: order, }; } - case TeamTypes.RECEIVED_MY_TEAM_MEMBERS: { - // This will be removed once categories are sent by the server - const members: TeamMembership[] = action.data; - return members.reduce((nextState, member) => { - if (state[member.team_id]) { - return nextState; - } + case ChannelCategoryTypes.CATEGORY_DELETED: { + const categoryId: $ID = action.data; - return { - ...nextState, - [member.team_id]: makeDefaultCategoryIds(member.team_id), - }; - }, state); + const nextState = {...state}; + + for (const teamId of Object.keys(nextState)) { + // removeItem only modifies the array if it contains the category ID, so other teams' state won't be modified + nextState[teamId] = removeItem(state[teamId], categoryId); + } + + return nextState; } - // This will be added in phase 2 of Channel Sidebar Organization once the server provides the categories - // case ChannelCategoryTypes.RECEIVED_CATEGORY_ORDER: { - // const teamId: string = action.data.teamId; - // const categoryIds: string[] = action.data.categoryIds; - - // return { - // ...state, - // [teamId]: categoryIds, - // }; - // } - case TeamTypes.LEAVE_TEAM: { const team: Team = action.data; @@ -143,39 +139,6 @@ export function orderByTeam(state: RelationOneToOne[] } } -function makeDefaultCategoryIds(teamId: string): $ID[] { - return Object.keys(makeDefaultCategories(teamId)); -} - -function makeDefaultCategories(teamId: string): IDMappedObjects { - return { - [`${teamId}-favorites`]: { - id: `${teamId}-favorites`, - team_id: teamId, - type: CategoryTypes.FAVORITES, - display_name: 'Favorites', - }, - [`${teamId}-public`]: { - id: `${teamId}-public`, - team_id: teamId, - type: CategoryTypes.PUBLIC, - display_name: 'Public', - }, - [`${teamId}-private`]: { - id: `${teamId}-private`, - team_id: teamId, - type: CategoryTypes.PRIVATE, - display_name: 'Private', - }, - [`${teamId}-direct_messages`]: { - id: `${teamId}-direct_messages`, - team_id: teamId, - type: CategoryTypes.DIRECT_MESSAGES, - display_name: 'Direct Messages', - }, - }; -} - export default combineReducers({ byId, orderByTeam, diff --git a/app/mm-redux/selectors/entities/channel_categories.test.js b/app/mm-redux/selectors/entities/channel_categories.test.js index fdd3ace3b..416e6b259 100644 --- a/app/mm-redux/selectors/entities/channel_categories.test.js +++ b/app/mm-redux/selectors/entities/channel_categories.test.js @@ -1,11 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ +import {MarkUnread} from '@mm-redux/constants/channels'; import {getCurrentChannelId, getMyChannelMemberships} from '@mm-redux/selectors/entities/channels'; import {getConfig} from '@mm-redux/selectors/entities/general'; import {getLastPostPerChannel} from '@mm-redux/selectors/entities/posts'; import {getMyPreferences} from '@mm-redux/selectors/entities/preferences'; import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; +import {CategorySorting} from '@mm-redux/types/channel_categories'; import {isGroupOrDirectChannelVisible} from '@mm-redux/utils/channel_utils'; import {getPreferenceKey} from '@mm-redux/utils/preference_utils'; import mergeObjects from '@test/merge_objects'; @@ -15,6 +18,70 @@ import {CategoryTypes} from '../../constants/channel_categories'; import * as Selectors from './channel_categories'; +describe('getCategoryInTeamByType', () => { + const favoritesCategory1 = {id: 'favoritesCategory1', team_id: 'team1', type: CategoryTypes.FAVORITES}; + const channelsCategory1 = {id: 'channelsCategory1', team_id: 'team1', type: CategoryTypes.CHANNELS}; + const directMessagesCategory1 = {id: 'directMessagesCategory1', team_id: 'team1', type: CategoryTypes.DIRECT_MESSAGES}; + const channelsCategory2 = {id: 'channelsCategory2', team_id: 'team2', type: CategoryTypes.CHANNELS}; + + const state = { + entities: { + channelCategories: { + byId: { + channelsCategory1, + channelsCategory2, + directMessagesCategory1, + favoritesCategory1, + }, + }, + }, + }; + + test('should return categories from each team', () => { + expect(Selectors.getCategoryInTeamByType(state, 'team1', CategoryTypes.FAVORITES)).toBe(favoritesCategory1); + expect(Selectors.getCategoryInTeamByType(state, 'team1', CategoryTypes.CHANNELS)).toBe(channelsCategory1); + expect(Selectors.getCategoryInTeamByType(state, 'team1', CategoryTypes.DIRECT_MESSAGES)).toBe(directMessagesCategory1); + + expect(Selectors.getCategoryInTeamByType(state, 'team2', CategoryTypes.CHANNELS)).toBe(channelsCategory2); + }); + + test('should return null for a team that does not exist', () => { + expect(Selectors.getCategoryInTeamByType(state, 'team3', CategoryTypes.CHANNELS)).toBeUndefined(); + }); + + test('should return null for a category that does not exist', () => { + expect(Selectors.getCategoryInTeamByType(state, 'team2', CategoryTypes.FAVORITES)).toBeUndefined(); + }); +}); + +describe('getCategoryInTeamWithChannel', () => { + const category1 = {id: 'category1', team_id: 'team1', channel_ids: ['channel1', 'channel2']}; + const category2 = {id: 'category2', team_id: 'team1', channel_ids: ['dmChannel1']}; + const category3 = {id: 'category3', team_id: 'team2', channel_ids: ['dmChannel1']}; + + const state = { + entities: { + channelCategories: { + byId: { + category1, + category2, + category3, + }, + }, + }, + }; + + test('should return the category containing a given channel', () => { + expect(Selectors.getCategoryInTeamWithChannel(state, 'team1', 'channel1')).toBe(category1); + expect(Selectors.getCategoryInTeamWithChannel(state, 'team1', 'channel2')).toBe(category1); + }); + + test('should return the category on the correct team for a cross-team channel', () => { + expect(Selectors.getCategoryInTeamWithChannel(state, 'team1', 'dmChannel1')).toBe(category2); + expect(Selectors.getCategoryInTeamWithChannel(state, 'team2', 'dmChannel1')).toBe(category3); + }); +}); + describe('makeGetCategoriesForTeam', () => { const category1 = {id: 'category1', display_name: 'Category One', type: CategoryTypes.CUSTOM}; const category2 = {id: 'category2', display_name: 'Category Two', type: CategoryTypes.CUSTOM}; @@ -61,208 +128,7 @@ describe('makeGetCategoriesForTeam', () => { }); }); -describe('makeGetUnsortedUnfilteredChannels', () => { - const channel1 = {id: 'channel1', team_id: 'team1', delete_at: 0}; - const channel2 = {id: 'channel2', team_id: 'team1', delete_at: 0}; - const channel3 = {id: 'channel3', team_id: 'team2', delete_at: 0}; - const dmChannel1 = {id: 'dmChannel1', team_id: '', delete_at: 0}; - const gmChannel1 = {id: 'gmChannel1', team_id: '', delete_at: 0}; - - const baseState = { - entities: { - channels: { - channels: { - channel1, - channel2, - channel3, - dmChannel1, - gmChannel1, - }, - myMembers: { - [channel1.id]: {}, - [channel2.id]: {}, - [channel3.id]: {}, - [dmChannel1.id]: {}, - [gmChannel1.id]: {}, - }, - }, - }, - }; - - test('should return channels on the team and DMs/GMs', () => { - const getUnsortedUnfilteredChannels = Selectors.makeGetUnsortedUnfilteredChannels(); - - expect(getUnsortedUnfilteredChannels(baseState, 'team1')).toMatchObject([channel1, channel2, dmChannel1, gmChannel1]); - - expect(getUnsortedUnfilteredChannels(baseState, 'team2')).toMatchObject([channel3, dmChannel1, gmChannel1]); - }); - - test('should not return channels which the user is not a member of', () => { - const channel4 = {id: 'channel4', team_id: 'team1', delete_at: 0}; - - const getUnsortedUnfilteredChannels = Selectors.makeGetUnsortedUnfilteredChannels(); - - let state = { - entities: { - channels: { - channels: { - channel4, - }, - myMembers: {}, - }, - }, - }; - - expect(getUnsortedUnfilteredChannels(state, 'team1')).not.toContain(channel4); - - state = { - entities: { - channels: { - channels: { - channel4, - }, - myMembers: { - [channel4.id]: {}, - }, - }, - }, - }; - - expect(getUnsortedUnfilteredChannels(state, 'team1')).toContain(channel4); - }); - - test('should not return deleted channels', () => { - const channel = {id: 'channel', team_id: 'team1', delete_at: 0}; - const deletedChannel = {id: 'deletedChannel', team_id: 'team1', delete_at: 1000}; - - const getUnsortedUnfilteredChannels = Selectors.makeGetUnsortedUnfilteredChannels(); - - const state = { - entities: { - channels: { - channels: { - channel, - }, - myMembers: { - [channel.id]: {}, - }, - }, - }, - }; - - expect(getUnsortedUnfilteredChannels(state, 'team1')).toContain(channel); - expect(getUnsortedUnfilteredChannels(state, 'team1')).not.toContain(deletedChannel); - }); - - test('should memoize properly', () => { - const getUnsortedUnfilteredChannels = Selectors.makeGetUnsortedUnfilteredChannels(); - - const result = getUnsortedUnfilteredChannels(baseState, 'team1'); - - // Repeat calls should return the same array - expect(getUnsortedUnfilteredChannels(baseState, 'team1')).toBe(result); - - // Calls to a difference instance of the selector won't return the same array - expect(result).not.toBe(Selectors.makeGetUnsortedUnfilteredChannels()(baseState, 'team1')); - - // Calls with different arguments won't return the same array - expect(getUnsortedUnfilteredChannels(baseState, 'team2')).not.toBe(result); - - // Calls after different argumetns won't return the same array - expect(getUnsortedUnfilteredChannels(baseState, 'team1')).not.toBe(result); - }); -}); - -describe('makeFilterChannelsByFavorites', () => { - const channel1 = {id: 'channel1'}; - const channel2 = {id: 'channel2'}; - - const state = { - entities: { - channels: { - channels: { - channel1, - channel2, - }, - }, - preferences: { - myPreferences: { - [getPreferenceKey(Preferences.CATEGORY_FAVORITE_CHANNEL, channel1.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_FAVORITE_CHANNEL, channel2.id)]: {value: 'false'}, - }, - }, - }, - }; - - const channels = [channel1, channel2]; - - test('should return only favorited channels for the favorites category', () => { - const filterChanneldByFavorites = Selectors.makeFilterChannelsByFavorites(); - - expect(filterChanneldByFavorites(state, channels, CategoryTypes.FAVORITES)).toMatchObject([channel1]); - }); - - test('should not return favorited channels for other categories', () => { - const filterChanneldByFavorites = Selectors.makeFilterChannelsByFavorites(); - - expect(filterChanneldByFavorites(state, channels, CategoryTypes.CUSTOM)).toMatchObject([channel2]); - }); - - test('should memoize properly', () => { - const filterChanneldByFavorites = Selectors.makeFilterChannelsByFavorites(); - - const result = filterChanneldByFavorites(state, channels, CategoryTypes.CUSTOM); - - // Repeat calls should return the same array - expect(filterChanneldByFavorites(state, channels, CategoryTypes.CUSTOM)).toBe(result); - - // Calls to a difference instance of the selector won't return the same array - expect(result).not.toBe(Selectors.makeFilterChannelsByFavorites()(state, channels, CategoryTypes.CUSTOM)); - - // Calls with different arguments won't return the same array - expect(filterChanneldByFavorites(state, [channel1], CategoryTypes.CUSTOM)).not.toBe(result); - - // Calls after different argumetns won't return the same array - expect(filterChanneldByFavorites(state, channels, CategoryTypes.CUSTOM)).not.toBe(result); - }); -}); - -describe('makeFilterChannelsByType', () => { - const channel1 = {id: 'channel1', type: General.OPEN_CHANNEL}; - const channel2 = {id: 'channel2', type: General.PRIVATE_CHANNEL}; - const dmChannel1 = {id: 'dmChannel1', type: General.DM_CHANNEL}; - const gmChannel1 = {id: 'gmChannel1', type: General.GM_CHANNEL}; - - const state = {}; - - const channels = [channel1, channel2, dmChannel1, gmChannel1]; - - test('should filter out non-public channels for public category', () => { - const filterChannelsByType = Selectors.makeFilterChannelsByType(); - - expect(filterChannelsByType(state, channels, CategoryTypes.PUBLIC)).toMatchObject([channel1]); - }); - - test('should filter out non-private channels for private category', () => { - const filterChannelsByType = Selectors.makeFilterChannelsByType(); - - expect(filterChannelsByType(state, channels, CategoryTypes.PRIVATE)).toMatchObject([channel2]); - }); - - test('should filter out non-DM/GM channels for direct messages category', () => { - const filterChannelsByType = Selectors.makeFilterChannelsByType(); - - expect(filterChannelsByType(state, channels, CategoryTypes.DIRECT_MESSAGES)).toMatchObject([dmChannel1, gmChannel1]); - }); - - test('should not filter out channels for favorites category', () => { - const filterChannelsByType = Selectors.makeFilterChannelsByType(); - - expect(filterChannelsByType(state, channels, CategoryTypes.FAVORITES)).toBe(channels); - }); -}); - -describe('makeFilterAutoclosedDMs', () => { +describe('legacyMakeFilterAutoclosedDMs', () => { const currentUser = {id: 'currentUser'}; const baseState = { @@ -317,150 +183,150 @@ describe('makeFilterAutoclosedDMs', () => { } test('should hide an inactive GM channel', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); - const channel1 = {id: 'channel1', type: General.GM_CHANNEL}; + const gmChannel = {id: 'gmChannel', type: General.GM_CHANNEL}; const state = mergeObjects(baseState, { entities: { preferences: { myPreferences: { - [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel1.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff - 1}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel.id)]: {value: 'true'}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, }, }, }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(false); + expect(isChannelVisiblePrecondition(state, gmChannel)).toBe(false); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([]); + expect(filterAutoclosedDMs(state, [gmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([]); }); test('should show a GM channel if it was opened recently', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); - const channel1 = {id: 'channel1', type: General.GM_CHANNEL}; + const gmChannel = {id: 'gmChannel', type: General.GM_CHANNEL}; const state = mergeObjects(baseState, { entities: { preferences: { myPreferences: { - [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel1.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff + 1}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel.id)]: {value: 'true'}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, gmChannel.id)]: {value: `${cutoff + 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, }, }, }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(true); + expect(isChannelVisiblePrecondition(state, gmChannel)).toBe(true); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([channel1]); + expect(filterAutoclosedDMs(state, [gmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([gmChannel]); }); test('should show a GM channel if it was viewed recently', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); - const channel1 = {id: 'channel1', type: General.GM_CHANNEL}; + const gmChannel = {id: 'gmChannel', type: General.GM_CHANNEL}; const state = mergeObjects(baseState, { entities: { preferences: { myPreferences: { - [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel1.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff - 1}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff + 1}`}, + [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel.id)]: {value: 'true'}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, gmChannel.id)]: {value: `${cutoff + 1}`}, }, }, }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(true); + expect(isChannelVisiblePrecondition(state, gmChannel)).toBe(true); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([channel1]); + expect(filterAutoclosedDMs(state, [gmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([gmChannel]); }); test('should show a GM channel if it had an unloaded post made recently', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); - const channel1 = {id: 'channel1', type: General.GM_CHANNEL, last_post_at: cutoff + 1}; + const gmChannel = {id: 'gmChannel', type: General.GM_CHANNEL, last_post_at: cutoff + 1}; const state = mergeObjects(baseState, { entities: { preferences: { myPreferences: { - [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel1.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff - 1}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel.id)]: {value: 'true'}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, }, }, }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(true); + expect(isChannelVisiblePrecondition(state, gmChannel)).toBe(true); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([channel1]); + expect(filterAutoclosedDMs(state, [gmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([gmChannel]); }); test('should show a GM channel if it had a loaded post made recently', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); - const channel1 = {id: 'channel1', type: General.GM_CHANNEL}; + const gmChannel = {id: 'gmChannel', type: General.GM_CHANNEL}; const state = mergeObjects(baseState, { entities: { posts: { posts: { - post1: {id: 'post1', channel_id: channel1, create_at: cutoff + 1}, + post1: {id: 'post1', channel_id: gmChannel, create_at: cutoff + 1}, }, postsInChannel: { - channel1: [{order: ['post1'], recent: true}], + gmChannel: [{order: ['post1'], recent: true}], }, }, preferences: { myPreferences: { - [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel1.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff - 1}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel.id)]: {value: 'true'}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, }, }, }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(true); + expect(isChannelVisiblePrecondition(state, gmChannel)).toBe(true); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([channel1]); + expect(filterAutoclosedDMs(state, [gmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([gmChannel]); }); test('should show an inactive GM channel if autoclosing DMs is disabled for the user', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); - const channel1 = {id: 'channel1', type: General.GM_CHANNEL}; + const gmChannel = {id: 'gmChannel', type: General.GM_CHANNEL}; const state = mergeObjects(baseState, { entities: { preferences: { myPreferences: { [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.CHANNEL_SIDEBAR_AUTOCLOSE_DMS)]: {value: ''}, - [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel1.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff - 1}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel.id)]: {value: 'true'}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, }, }, }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(true); + expect(isChannelVisiblePrecondition(state, gmChannel)).toBe(true); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([channel1]); + expect(filterAutoclosedDMs(state, [gmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([gmChannel]); }); test('should show an inactive GM channel if autoclosing DMs is disabled for the server', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); - const channel1 = {id: 'channel1', type: General.GM_CHANNEL}; + const gmChannel = {id: 'gmChannel', type: General.GM_CHANNEL}; const state = mergeObjects(baseState, { entities: { @@ -472,60 +338,59 @@ describe('makeFilterAutoclosedDMs', () => { preferences: { myPreferences: { [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.CHANNEL_SIDEBAR_AUTOCLOSE_DMS)]: {value: Preferences.AUTOCLOSE_DMS_ENABLED}, - [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel1.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff - 1}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel.id)]: {value: 'true'}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, }, }, }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(true); + expect(isChannelVisiblePrecondition(state, gmChannel)).toBe(true); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([channel1]); + expect(filterAutoclosedDMs(state, [gmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([gmChannel]); }); test('should show a GM channel if it has unread messages', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); - const channel1 = {id: 'channel1', type: General.GM_CHANNEL, total_msg_count: 1}; + const gmChannel = {id: 'gmChannel', type: General.GM_CHANNEL, total_msg_count: 1}; const state = mergeObjects(baseState, { entities: { channels: { - currentChannelId: 'channel1', myMembers: { - channel1: {msg_count: 0}, + gmChannel: {msg_count: 0}, }, }, preferences: { myPreferences: { - [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel1.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff - 1}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel.id)]: {value: 'true'}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, gmChannel.id)]: {value: `${cutoff - 1}`}, }, }, }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(true); + expect(isChannelVisiblePrecondition(state, gmChannel)).toBe(true); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([channel1]); + expect(filterAutoclosedDMs(state, [gmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([gmChannel]); }); test('should hide an inactive DM channel', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); const otherUser = {id: 'otherUser', delete_at: 0}; - const channel1 = {id: 'channel1', name: `${currentUser.id}__${otherUser.id}`, type: General.DM_CHANNEL}; + const dmChannel = {id: 'dmChannel', name: `${currentUser.id}__${otherUser.id}`, type: General.DM_CHANNEL}; const state = mergeObjects(baseState, { entities: { preferences: { myPreferences: { [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff - 1}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, dmChannel.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, dmChannel.id)]: {value: `${cutoff - 1}`}, }, }, users: { @@ -536,24 +401,24 @@ describe('makeFilterAutoclosedDMs', () => { }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(false); + expect(isChannelVisiblePrecondition(state, dmChannel)).toBe(false); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([]); + expect(filterAutoclosedDMs(state, [dmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([]); }); test('should show a DM channel if it was opened recently', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); const otherUser = {id: 'otherUser', delete_at: 0}; - const channel1 = {id: 'channel1', name: `${currentUser.id}__${otherUser.id}`, type: General.DM_CHANNEL}; + const dmChannel = {id: 'dmChannel', name: `${currentUser.id}__${otherUser.id}`, type: General.DM_CHANNEL}; const state = mergeObjects(baseState, { entities: { preferences: { myPreferences: { [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff + 1}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, dmChannel.id)]: {value: `${cutoff + 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, dmChannel.id)]: {value: `${cutoff - 1}`}, }, }, users: { @@ -564,27 +429,27 @@ describe('makeFilterAutoclosedDMs', () => { }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(true); + expect(isChannelVisiblePrecondition(state, dmChannel)).toBe(true); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([channel1]); + expect(filterAutoclosedDMs(state, [dmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([dmChannel]); }); test('should show a DM channel with a deactivated user if its the current channel', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); const otherUser = {id: 'otherUser', delete_at: cutoff + 2}; - const channel1 = {id: 'channel1', name: `${currentUser.id}__${otherUser.id}`, type: General.DM_CHANNEL}; + const dmChannel = {id: 'dmChannel', name: `${currentUser.id}__${otherUser.id}`, type: General.DM_CHANNEL}; const state = mergeObjects(baseState, { entities: { channels: { - currentChannelId: 'channel1', + currentChannelId: 'dmChannel', }, preferences: { myPreferences: { [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff + 1}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, dmChannel.id)]: {value: `${cutoff + 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, dmChannel.id)]: {value: `${cutoff - 1}`}, }, }, users: { @@ -595,16 +460,16 @@ describe('makeFilterAutoclosedDMs', () => { }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(true); + expect(isChannelVisiblePrecondition(state, dmChannel)).toBe(true); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([channel1]); + expect(filterAutoclosedDMs(state, [dmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([dmChannel]); }); test('should hide a DM channel with a deactivated user if it is not the current channel', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); const otherUser = {id: 'otherUser', delete_at: cutoff + 2}; - const channel1 = {id: 'channel1', name: `${currentUser.id}__${otherUser.id}`, type: General.DM_CHANNEL}; + const dmChannel = {id: 'dmChannel', name: `${currentUser.id}__${otherUser.id}`, type: General.DM_CHANNEL}; const state = mergeObjects(baseState, { entities: { @@ -614,8 +479,8 @@ describe('makeFilterAutoclosedDMs', () => { preferences: { myPreferences: { [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff + 1}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, dmChannel.id)]: {value: `${cutoff + 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, dmChannel.id)]: {value: `${cutoff - 1}`}, }, }, users: { @@ -626,27 +491,27 @@ describe('makeFilterAutoclosedDMs', () => { }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(false); + expect(isChannelVisiblePrecondition(state, dmChannel)).toBe(false); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([]); + expect(filterAutoclosedDMs(state, [dmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([]); }); test('should show a DM channel with a deactivated user if it is not the current channel but it has been opened since the user was deactivated', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); const otherUser = {id: 'otherUser', delete_at: cutoff + 2}; - const channel1 = {id: 'channel1', name: `${currentUser.id}__${otherUser.id}`, type: General.DM_CHANNEL}; + const dmChannel = {id: 'dmChannel', name: `${currentUser.id}__${otherUser.id}`, type: General.DM_CHANNEL}; const state = mergeObjects(baseState, { entities: { channels: { - currentChannelId: 'channel1', + currentChannelId: 'dmChannel', }, preferences: { myPreferences: { [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, channel1.id)]: {value: `${cutoff + 3}`}, - [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, channel1.id)]: {value: `${cutoff - 1}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, dmChannel.id)]: {value: `${cutoff + 3}`}, + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, dmChannel.id)]: {value: `${cutoff - 1}`}, }, }, users: { @@ -657,13 +522,13 @@ describe('makeFilterAutoclosedDMs', () => { }, }); - expect(isChannelVisiblePrecondition(state, channel1)).toBe(true); + expect(isChannelVisiblePrecondition(state, dmChannel)).toBe(true); - expect(filterAutoclosedDMs(state, [channel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([channel1]); + expect(filterAutoclosedDMs(state, [dmChannel], CategoryTypes.DIRECT_MESSAGES)).toEqual([dmChannel]); }); test('should return the original array when no items are removed', () => { - const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(() => cutoff); + const filterAutoclosedDMs = Selectors.legacyMakeFilterAutoclosedDMs(() => cutoff); const channel1 = {id: 'channel1', type: General.PUBLIC_CHANNEL}; @@ -675,18 +540,263 @@ describe('makeFilterAutoclosedDMs', () => { }); }); +describe('makeFilterAutoclosedDMs', () => { + const currentUser = {id: 'currentUser'}; + + const tigerKing = {id: 'tigerKing'}; + const bojackHorseman = {id: 'bojackHorseman'}; + const jeffWinger = {id: 'jeffWinger'}; + + const baseState = { + entities: { + channels: { + currentChannelId: 'channel1', + myMembers: { + channel2: { + channel_id: 'channel2', + last_viewed_at: 0, + }, + channel1: {}, + channel3: {}, + }, + }, + general: { + config: { + CloseUnusedDirectMessages: 'true', + }, + }, + posts: { + posts: {}, + postsInChannel: { + channel1: [], + }, + }, + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.CHANNEL_SIDEBAR_AUTOCLOSE_DMS)]: {value: Preferences.AUTOCLOSE_DMS_ENABLED}, + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '0'}, + }, + }, + users: { + currentUserId: currentUser.id, + profiles: { + currentUser, + tigerKing, + bojackHorseman, + jeffWinger, + }, + }, + }, + }; + + test('Should always show an unread channel', () => { + const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(); + + const gmChannel1 = {id: 'gmChannel1', type: General.GM_CHANNEL, total_msg_count: 5}; + const gmChannel2 = {id: 'gmChannel2', type: General.GM_CHANNEL}; + + const state = mergeObjects(baseState, { + entities: { + channels: { + myMembers: { + gmChannel1: {msg_count: 1, notify_props: {mark_unread: MarkUnread.ALL}}, + gmChannel2: {msg_count: 0, notify_props: {mark_unread: MarkUnread.ALL}}, + }, + }, + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '1'}, + }, + }, + }, + }); + + expect(filterAutoclosedDMs(state, [gmChannel1, gmChannel2], CategoryTypes.DIRECT_MESSAGES)).toEqual([gmChannel1]); + }); + + test('Should always show the current channel', () => { + const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(); + + const dmChannel1 = {id: 'dmChannel1', type: General.DM_CHANNEL, name: `${currentUser.id}__${jeffWinger.id}`}; + const gmChannel1 = {id: 'gmChannel1', type: General.GM_CHANNEL}; + + let state = mergeObjects(baseState, { + entities: { + channels: { + currentChannelId: dmChannel1.id, + myMembers: { + [gmChannel1.id]: {last_viewed_at: 1000}, + }, + }, + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '1'}, + }, + }, + }, + }); + + expect(filterAutoclosedDMs(state, [dmChannel1, gmChannel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([dmChannel1]); + + state = mergeObjects(baseState, { + entities: { + channels: { + currentChannelId: gmChannel1.id, + myMembers: { + [dmChannel1.id]: {last_viewed_at: 1000}, + }, + }, + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '1'}, + }, + }, + }, + }); + + expect(filterAutoclosedDMs(state, [gmChannel1, dmChannel1], CategoryTypes.DIRECT_MESSAGES)).toEqual([gmChannel1]); + }); + + describe('Should always show the exact number of channels specified by the user', () => { + const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(); + + const dmChannel1 = {id: 'dmChannel1', type: General.DM_CHANNEL, name: `${currentUser.id}__${tigerKing.id}`}; + const gmChannel1 = {id: 'gmChannel1', type: General.GM_CHANNEL, name: 'WhatsApp'}; + const gmChannel2 = {id: 'gmChannel2', type: General.GM_CHANNEL, name: 'Telegram'}; + const dmChannel2 = {id: 'dmChannel2', type: General.DM_CHANNEL, name: `${currentUser.id}__${bojackHorseman.id}`}; + const dmChannel3 = {id: 'dmChannel3', type: General.DM_CHANNEL, name: `${currentUser.id}__${jeffWinger.id}`}; + + test('User specified 5 DMs to be shown', () => { + const state = mergeObjects(baseState, { + entities: { + channels: { + currentChannelId: dmChannel1.id, + myMembers: { + [dmChannel1.id]: {last_viewed_at: 1000}, + [dmChannel2.id]: {last_viewed_at: 500}, + [dmChannel3.id]: {last_viewed_at: 0}, + }, + }, + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '5'}, + }, + }, + }, + }); + + expect(filterAutoclosedDMs(state, [dmChannel1, gmChannel1, gmChannel2, dmChannel2, dmChannel3], CategoryTypes.DIRECT_MESSAGES)).toEqual([dmChannel1, gmChannel1, gmChannel2, dmChannel2, dmChannel3]); + }); + + test('User specified 2 DMs to be shown', () => { + const state = mergeObjects(baseState, { + entities: { + channels: { + currentChannelId: dmChannel1.id, + myMembers: { + [dmChannel1.id]: {last_viewed_at: 1000}, + [dmChannel2.id]: {last_viewed_at: 500}, + [dmChannel3.id]: {last_viewed_at: 0}, + }, + }, + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '2'}, + }, + }, + }, + }); + + expect(filterAutoclosedDMs(state, [dmChannel1, gmChannel1, gmChannel2, dmChannel2, dmChannel3], CategoryTypes.DIRECT_MESSAGES)).toEqual([dmChannel1, dmChannel2]); + }); + }); + + test('should consider approximate view time and open time preferences for most recently viewed channel', () => { + const filterAutoclosedDMs = Selectors.makeFilterAutoclosedDMs(); + + const dmChannel1 = {id: 'dmChannel1', type: General.DM_CHANNEL, name: `${currentUser.id}__${tigerKing.id}`}; + const dmChannel2 = {id: 'dmChannel2', type: General.DM_CHANNEL, name: `${currentUser.id}__${bojackHorseman.id}`}; + const dmChannel3 = {id: 'dmChannel3', type: General.DM_CHANNEL, name: `${currentUser.id}__${jeffWinger.id}`}; + + let state = mergeObjects(baseState, { + entities: { + channels: { + channels: { + dmChannel1, + dmChannel2, + dmChannel3, + }, + myMembers: { + [dmChannel1.id]: {last_viewed_at: 1000}, + [dmChannel2.id]: {last_viewed_at: 500}, + [dmChannel3.id]: {last_viewed_at: 0}, + }, + }, + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '2'}, + }, + }, + }, + }); + + expect(filterAutoclosedDMs(state, [dmChannel1, dmChannel2, dmChannel3], CategoryTypes.DIRECT_MESSAGES)).toEqual([dmChannel1, dmChannel2]); + + state = mergeObjects(state, { + entities: { + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_OPEN_TIME, dmChannel3.id)]: {value: '3000'}, + }, + }, + }, + }); + + expect(filterAutoclosedDMs(state, [dmChannel1, dmChannel2, dmChannel3], CategoryTypes.DIRECT_MESSAGES)).toEqual([dmChannel1, dmChannel3]); + + state = mergeObjects(state, { + entities: { + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, dmChannel2.id)]: {value: '2000'}, + }, + }, + }, + }); + + expect(filterAutoclosedDMs(state, [dmChannel1, dmChannel2, dmChannel3], CategoryTypes.DIRECT_MESSAGES)).toEqual([dmChannel2, dmChannel3]); + }); +}); + describe('makeFilterManuallyClosedDMs', () => { const currentUser = {id: 'currentUser'}; const otherUser1 = {id: 'otherUser1'}; const otherUser2 = {id: 'otherUser2'}; + const baseState = { + entities: { + general: { + config: {}, + }, + channels: { + myMembers: {}, + }, + preferences: { + myPreferences: {}, + }, + users: { + currentUserId: currentUser.id, + }, + }, + }; + test('should filter DMs based on preferences', () => { const filterManuallyClosedDMs = Selectors.makeFilterManuallyClosedDMs(); const dmChannel1 = {id: 'dmChannel1', type: General.DM_CHANNEL, name: `${currentUser.id}__${otherUser1.id}`}; const dmChannel2 = {id: 'dmChannel2', type: General.DM_CHANNEL, name: `${currentUser.id}__${otherUser2.id}`}; - const state = { + const state = mergeObjects(baseState, { entities: { preferences: { myPreferences: { @@ -694,22 +804,19 @@ describe('makeFilterManuallyClosedDMs', () => { [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser2.id)]: {value: 'true'}, }, }, - users: { - currentUserId: currentUser.id, - }, }, - }; + }); expect(filterManuallyClosedDMs(state, [dmChannel1, dmChannel2])).toMatchObject([dmChannel2]); }); - test('should filter DMs based on preferences', () => { + test('should filter GMs based on preferences', () => { const filterManuallyClosedDMs = Selectors.makeFilterManuallyClosedDMs(); const gmChannel1 = {id: 'gmChannel1', type: General.GM_CHANNEL}; const gmChannel2 = {id: 'gmChannel2', type: General.GM_CHANNEL}; - const state = { + const state = mergeObjects(baseState, { entities: { preferences: { myPreferences: { @@ -717,31 +824,91 @@ describe('makeFilterManuallyClosedDMs', () => { [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel2.id)]: {value: 'false'}, }, }, - users: { - currentUserId: currentUser.id, - }, }, - }; + }); expect(filterManuallyClosedDMs(state, [gmChannel1, gmChannel2])).toMatchObject([gmChannel1]); }); + test('should show unread DMs and GMs, regardless of preferences', () => { + const filterManuallyClosedDMs = Selectors.makeFilterManuallyClosedDMs(); + + const dmChannel1 = {id: 'dmChannel1', type: General.DM_CHANNEL, name: `${currentUser.id}__${otherUser1.id}`, total_msg_count: 1}; + const dmChannel2 = {id: 'dmChannel2', type: General.DM_CHANNEL, name: `${currentUser.id}__${otherUser2.id}`, total_msg_count: 0}; + const gmChannel1 = {id: 'gmChannel1', type: General.GM_CHANNEL, total_msg_count: 1}; + const gmChannel2 = {id: 'gmChannel2', type: General.GM_CHANNEL, total_msg_count: 0}; + + const state = mergeObjects(baseState, { + entities: { + channels: { + myMembers: { + dmChannel1: {msg_count: 0}, + dmChannel2: {msg_count: 0}, + gmChannel1: {msg_count: 0}, + gmChannel2: {msg_count: 0}, + }, + }, + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser1.id)]: {value: 'false'}, + [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser2.id)]: {value: 'false'}, + [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel1.id)]: {value: 'false'}, + [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel2.id)]: {value: 'false'}, + }, + }, + }, + }); + + expect(filterManuallyClosedDMs(state, [dmChannel1, dmChannel2, gmChannel1, gmChannel2])).toEqual([dmChannel1, gmChannel1]); + }); + + test('should show the current channel, regardless of preferences', () => { + const filterManuallyClosedDMs = Selectors.makeFilterManuallyClosedDMs(); + + const dmChannel1 = {id: 'dmChannel1', type: General.DM_CHANNEL, name: `${currentUser.id}__${otherUser1.id}`}; + const gmChannel1 = {id: 'gmChannel1', type: General.GM_CHANNEL}; + + let state = mergeObjects(baseState, { + entities: { + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser1.id)]: {value: 'false'}, + [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel1.id)]: {value: 'false'}, + }, + }, + }, + }); + + expect(filterManuallyClosedDMs(state, [dmChannel1, gmChannel1])).toEqual([]); + + state = mergeObjects(baseState, { + entities: { + channels: { + currentChannelId: dmChannel1.id, + }, + }, + }); + + expect(filterManuallyClosedDMs(state, [dmChannel1, gmChannel1])).toEqual([dmChannel1]); + + state = mergeObjects(baseState, { + entities: { + channels: { + currentChannelId: gmChannel1.id, + }, + }, + }); + + expect(filterManuallyClosedDMs(state, [dmChannel1, gmChannel1])).toEqual([gmChannel1]); + }); + test('should not filter other channels', () => { const filterManuallyClosedDMs = Selectors.makeFilterManuallyClosedDMs(); const channel1 = {id: 'channel1', type: General.OPEN_CHANNEL}; const channel2 = {id: 'channel2', type: General.PRIVATE_CHANNEL}; - const state = { - entities: { - preferences: { - myPreferences: {}, - }, - users: { - currentUserId: currentUser.id, - }, - }, - }; + const state = baseState; const channels = [channel1, channel2]; @@ -754,6 +921,9 @@ describe('makeSortChannelsByName', () => { const baseState = { entities: { + channels: { + myMembers: {}, + }, users: { currentUserId: currentUser.id, profiles: { @@ -785,6 +955,30 @@ describe('makeSortChannelsByName', () => { expect(sortChannelsByName(baseState, channels)).toEqual([channel2, channel4, channel1, channel3]); }); + + test('should sort muted channels last', () => { + const sortChannelsByName = Selectors.makeSortChannelsByName(); + + const state = mergeObjects(baseState, { + entities: { + channels: { + myMembers: { + channel1: {notify_props: {mark_unread: MarkUnread.MENTION}}, + channel3: {notify_props: {mark_unread: MarkUnread.MENTION}}, + channel4: {notify_props: {mark_unread: MarkUnread.ALL}}, + }, + }, + }, + }); + + const channel1 = {id: 'channel1', display_name: 'Carrot'}; + const channel2 = {id: 'channel2', display_name: 'Apple'}; + const channel3 = {id: 'channel3', display_name: 'Banana'}; + const channel4 = {id: 'channel4', display_name: 'Dragonfruit'}; + const channels = [channel1, channel2, channel3, channel4]; + + expect(sortChannelsByName(state, channels)).toEqual([channel2, channel4, channel3, channel1]); + }); }); describe('makeSortChannelsByNameWithDMs', () => { @@ -801,6 +995,9 @@ describe('makeSortChannelsByNameWithDMs', () => { const baseState = { entities: { + channels: { + myMembers: {}, + }, general: { config: {}, }, @@ -855,7 +1052,9 @@ describe('makeSortChannelsByNameWithDMs', () => { test('should sort GM channels by the display name of the other users', () => { const sortChannelsByNameWithDMs = Selectors.makeSortChannelsByNameWithDMs(); - expect(sortChannelsByNameWithDMs(baseState, [ + let state = baseState; + + expect(sortChannelsByNameWithDMs(state, [ channel1, channel2, channel3, @@ -867,16 +1066,13 @@ describe('makeSortChannelsByNameWithDMs', () => { channel1, // Zebra ]); - const state = { - ...baseState, + state = mergeObjects(state, { entities: { - ...baseState.entities, users: { - ...baseState.entities.users, currentUserId: otherUser2.id, }, }, - }; + }); expect(sortChannelsByNameWithDMs(state, [ channel1, @@ -890,9 +1086,88 @@ describe('makeSortChannelsByNameWithDMs', () => { channel1, // Zebra ]); }); + + test('should sort muted channels last', () => { + const sortChannelsByNameWithDMs = Selectors.makeSortChannelsByNameWithDMs(); + + const state = mergeObjects(baseState, { + entities: { + channels: { + myMembers: { + channel3: {notify_props: {mark_unread: MarkUnread.MENTION}}, + dmChannel1: {notify_props: {mark_unread: MarkUnread.MENTION}}, + dmChannel2: {notify_props: {mark_unread: MarkUnread.ALL}}, + gmChannel1: {notify_props: {mark_unread: MarkUnread.MENTION}}, + }, + }, + }, + }); + + expect(sortChannelsByNameWithDMs(state, [ + channel1, + channel2, + channel3, + dmChannel1, + dmChannel2, + gmChannel1, + ])).toMatchObject([ + channel2, // Aardvark + dmChannel2, // Another User + channel1, // Zebra + gmChannel1, // Another User, Other User (Muted) + channel3, // Bear (Muted) + dmChannel1, // Other User (Muted) + ]); + }); }); -describe('makeGetChannelsForCategory', () => { +describe('makeSortChannelsByRecency', () => { + const channel1 = {id: 'channel1', display_name: 'Apple', last_post_at: 1000}; + const channel2 = {id: 'channel2', display_name: 'Banana', last_post_at: 2000}; + const channel3 = {id: 'channel3', display_name: 'Zucchini', last_post_at: 3000}; + + const baseState = { + entities: { + posts: { + posts: {}, + postsInChannel: {}, + }, + }, + }; + + test('should sort channels by their last_post_at when no posts are loaded', () => { + const sortChannelsByRecency = Selectors.makeSortChannelsByRecency(); + + const state = baseState; + + expect(sortChannelsByRecency(state, [channel1, channel2, channel3])).toMatchObject([channel3, channel2, channel1]); + expect(sortChannelsByRecency(state, [channel3, channel2, channel1])).toMatchObject([channel3, channel2, channel1]); + }); + + test('should sort channels by their latest post when possible', () => { + const sortChannelsByRecency = Selectors.makeSortChannelsByRecency(); + + const state = mergeObjects(baseState, { + entities: { + posts: { + posts: { + post1: {id: 'post1', channel_id: 'channel1', create_at: 2500}, + }, + postsInChannel: { + channel1: [ + {order: ['post1'], recent: true}, + ], + }, + }, + }, + }); + + expect(sortChannelsByRecency(state, [channel1, channel2, channel3])).toMatchObject([channel3, channel1, channel2]); + expect(sortChannelsByRecency(state, [channel3, channel2, channel1])).toMatchObject([channel3, channel1, channel2]); + }); +}); + +describe('makeGetChannelIdsForCategory', () => { const currentUser = {id: 'currentUser', username: 'currentUser', first_name: 'Current', last_name: 'User', locale: 'en'}; const otherUser1 = {id: 'otherUser1', username: 'otherUser1', first_name: 'Other', last_name: 'User', locale: 'en'}; const otherUser2 = {id: 'otherUser2', username: 'otherUser2', first_name: 'Another', last_name: 'User', locale: 'en'}; @@ -900,25 +1175,12 @@ describe('makeGetChannelsForCategory', () => { const channel1 = {id: 'channel1', type: General.OPEN_CHANNEL, team_id: 'team1', display_name: 'Zebra', delete_at: 0}; const channel2 = {id: 'channel2', type: General.PRIVATE_CHANNEL, team_id: 'team1', display_name: 'Aardvark', delete_at: 0}; const channel3 = {id: 'channel3', type: General.OPEN_CHANNEL, team_id: 'team1', display_name: 'Bear', delete_at: 0}; - const dmChannel1 = {id: 'dmChannel1', type: General.DM_CHANNEL, team_id: '', display_name: '', name: `${currentUser.id}__${otherUser1.id}`, delete_at: 0}; + const dmChannel1 = {id: 'dmChannel1', type: General.DM_CHANNEL, team_id: '', display_name: '', name: `${currentUser.id}__${otherUser1.id}`, delete_at: 0, last_post_at: 2000}; const dmChannel2 = {id: 'dmChannel2', type: General.DM_CHANNEL, team_id: '', display_name: '', name: `${otherUser2.id}__${currentUser.id}`, delete_at: 0}; const gmChannel1 = {id: 'gmChannel1', type: General.GM_CHANNEL, team_id: '', display_name: `${currentUser.username}, ${otherUser1.username}, ${otherUser2.username}`, name: 'gmChannel1', delete_at: 0}; - const favoritesCategory = {id: 'favoritesCategory', team_id: 'team1', display_name: CategoryTypes.FAVORITES, type: CategoryTypes.FAVORITES}; - const publicCategory = {id: 'publicCategory', team_id: 'team1', display_name: 'Public Channels', type: CategoryTypes.PUBLIC}; - const privateCategory = {id: 'privateCategory', team_id: 'team1', display_name: 'Private Channels', type: CategoryTypes.PRIVATE}; - const directMessagesCategory = {id: 'directMessagesCategory', team_id: 'team1', display_name: 'Direct Messages', type: CategoryTypes.DIRECT_MESSAGES}; - - const state = { + const baseState = { entities: { - channelCategories: { - byId: { - favoritesCategory, - publicCategory, - privateCategory, - directMessagesCategory, - }, - }, channels: { channels: { channel1, @@ -949,8 +1211,6 @@ describe('makeGetChannelsForCategory', () => { [getPreferenceKey(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT)]: {value: Preferences.DISPLAY_PREFER_FULL_NAME}, [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser1.id)]: {value: 'true'}, [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser2.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_FAVORITE_CHANNEL, channel1.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_FAVORITE_CHANNEL, dmChannel2.id)]: {value: 'true'}, [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel1.id)]: {value: 'true'}, }, }, @@ -966,27 +1226,364 @@ describe('makeGetChannelsForCategory', () => { }; test('should return sorted and filtered channels for favorites category', () => { - const getChannelsForCategory = Selectors.makeGetChannelsForCategory(); + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); - expect(getChannelsForCategory(state, favoritesCategory)).toMatchObject([dmChannel2, channel1]); + const favoritesCategory = { + id: 'favoritesCategory', + team_id: 'team1', + display_name: CategoryTypes.FAVORITES, + type: CategoryTypes.FAVORITES, + sorting: CategorySorting.Default, + channel_ids: [dmChannel2.id, channel1.id], + }; + + expect(getChannelIdsForCategory(baseState, favoritesCategory)).toMatchObject([dmChannel2.id, channel1.id]); }); - test('should return sorted and filtered channels for public category', () => { - const getChannelsForCategory = Selectors.makeGetChannelsForCategory(); + test('should return sorted and filtered channels for channels category with manual sorting', () => { + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); - expect(getChannelsForCategory(state, publicCategory)).toMatchObject([channel3]); + const publicCategory = { + id: 'publicCategory', + team_id: 'team1', + display_name: 'Public Channels', + type: CategoryTypes.PUBLIC, + sorting: CategorySorting.Manual, + channel_ids: [channel3.id, channel2.id], + }; + + expect(getChannelIdsForCategory(baseState, publicCategory)).toMatchObject([channel3.id, channel2.id]); }); - test('should return sorted and filtered channels for private category', () => { - const getChannelsForCategory = Selectors.makeGetChannelsForCategory(); + test('should return sorted and filtered channels for channels category with alphabetical sorting', () => { + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); - expect(getChannelsForCategory(state, privateCategory)).toMatchObject([channel2]); + const publicCategory = { + id: 'publicCategory', + team_id: 'team1', + display_name: 'Public Channels', + type: CategoryTypes.PUBLIC, + sorting: CategorySorting.Alphabetical, + channel_ids: [channel3.id, channel2.id], + }; + + expect(getChannelIdsForCategory(baseState, publicCategory)).toMatchObject([channel2.id, channel3.id]); }); - test('should return sorted and filtered channels for direct messages category', () => { - const getChannelsForCategory = Selectors.makeGetChannelsForCategory(); + test('should return sorted and filtered channels for channels category with alphabetical sorting and a muted channel', () => { + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); - expect(getChannelsForCategory(state, directMessagesCategory)).toMatchObject([gmChannel1, dmChannel1]); + const state = mergeObjects(baseState, { + entities: { + channels: { + myMembers: { + [channel2.id]: {notify_props: {mark_unread: MarkUnread.MENTION}}, + }, + }, + }, + }); + + const publicCategory = { + id: 'publicCategory', + team_id: 'team1', + display_name: 'Public Channels', + type: CategoryTypes.PUBLIC, + sorting: CategorySorting.Alphabetical, + channel_ids: [channel2.id, channel3.id], + }; + + expect(getChannelIdsForCategory(state, publicCategory)).toMatchObject([channel3.id, channel2.id]); + }); + + test('should return sorted and filtered channels for direct messages category with alphabetical sorting', () => { + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); + + const state = mergeObjects(baseState, { + entities: { + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '2'}, + }, + }, + }, + }); + + const directMessagesCategory = { + id: 'directMessagesCategory', + team_id: 'team1', + display_name: 'Direct Messages', + type: CategoryTypes.DIRECT_MESSAGES, + sorting: CategorySorting.Alphabetical, + channel_ids: [gmChannel1.id, dmChannel1.id], + }; + + expect(getChannelIdsForCategory(state, directMessagesCategory)).toMatchObject([gmChannel1.id, dmChannel1.id]); + }); + + test('should return sorted and filtered channels for direct messages category with recency sorting', () => { + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); + + const otherUser3 = {id: 'otherUser3', username: 'otherUser3', first_name: 'Third', last_name: 'User', locale: 'en'}; + const gmChannel2 = {id: 'gmChannel2', type: General.GM_CHANNEL, team_id: '', display_name: `${currentUser.username}, ${otherUser1.username}, ${otherUser3.username}`, name: 'gmChannel2', delete_at: 0, last_post_at: 2000}; + + const directMessagesCategory = { + id: 'directMessagesCategory', + team_id: 'team1', + display_name: 'Direct Messages', + type: CategoryTypes.DIRECT_MESSAGES, + sorting: CategorySorting.Recency, + channel_ids: [gmChannel1.id, dmChannel1.id, gmChannel2.id], + }; + + const state = mergeObjects(baseState, { + entities: { + channels: { + channels: { + dmChannel1: {last_post_at: 3000}, + gmChannel1: {last_post_at: 1000}, + gmChannel2, + }, + myMembers: { + [gmChannel2.id]: {}, + }, + }, + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '3'}, + [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel2.id)]: {value: 'true'}, + }, + }, + users: { + profiles: { + otherUser3, + }, + }, + }, + }); + + expect(getChannelIdsForCategory(state, directMessagesCategory)).toMatchObject(['dmChannel1', 'gmChannel2', 'gmChannel1']); + }); + + describe('memoization', () => { + test('should return the same result when called twice with the same category', () => { + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); + + const favoritesCategory = { + id: 'favoritesCategory', + team_id: 'team1', + display_name: CategoryTypes.FAVORITES, + type: CategoryTypes.FAVORITES, + sorting: CategorySorting.Default, + channel_ids: [dmChannel1.id, channel1.id], + }; + + const originalResult = getChannelIdsForCategory(baseState, favoritesCategory); + + expect(getChannelIdsForCategory(baseState, favoritesCategory)).toBe(originalResult); + }); + + test('should return a different result when called twice with a different category', () => { + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); + + const favoritesCategory = { + id: 'favoritesCategory', + team_id: 'team1', + display_name: CategoryTypes.FAVORITES, + type: CategoryTypes.FAVORITES, + sorting: CategorySorting.Default, + channel_ids: [dmChannel1.id, channel1.id], + }; + const publicCategory = { + id: 'publicCategory', + team_id: 'team1', + display_name: 'Public Channels', + type: CategoryTypes.PUBLIC, + sorting: CategorySorting.Manual, + channel_ids: [channel3.id, channel2.id], + }; + + const originalResult = getChannelIdsForCategory(baseState, favoritesCategory); + + expect(getChannelIdsForCategory(baseState, publicCategory)).not.toBe(originalResult); + }); + + test('should return a different result when called with a different sorting method', () => { + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); + + const favoritesCategory = { + id: 'favoritesCategory', + team_id: 'team1', + display_name: CategoryTypes.FAVORITES, + type: CategoryTypes.FAVORITES, + sorting: CategorySorting.Default, + channel_ids: [dmChannel1.id, dmChannel2.id], + }; + + const originalResult = getChannelIdsForCategory(baseState, favoritesCategory); + + expect(getChannelIdsForCategory(baseState, { + ...favoritesCategory, + sorting: CategorySorting.Recency, + })).not.toBe(originalResult); + }); + + test('should return the same result when called with a different sorting method but only a single channel', () => { + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); + + const favoritesCategory = { + id: 'favoritesCategory', + team_id: 'team1', + display_name: CategoryTypes.FAVORITES, + type: CategoryTypes.FAVORITES, + sorting: CategorySorting.Default, + channel_ids: [dmChannel2.id], + }; + + const originalResult = getChannelIdsForCategory(baseState, favoritesCategory); + + expect(getChannelIdsForCategory(baseState, { + ...favoritesCategory, + sorting: CategorySorting.Alphabetical, + })).toBe(originalResult); + expect(getChannelIdsForCategory(baseState, { + ...favoritesCategory, + sorting: CategorySorting.Recency, + })).toBe(originalResult); + }); + + test('should return a new result when DM category limit changes', () => { + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); + + let state = mergeObjects(baseState, { + entities: { + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '2'}, + }, + }, + }, + }); + + const directMessagesCategory = { + id: 'directMessagesCategory', + team_id: 'team1', + display_name: 'Direct Messages', + type: CategoryTypes.DIRECT_MESSAGES, + sorting: CategorySorting.Alphabetical, + channel_ids: [gmChannel1.id, dmChannel1.id], + }; + + const originalResult = getChannelIdsForCategory(state, directMessagesCategory); + + state = mergeObjects(baseState, { + entities: { + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '1'}, + }, + }, + }, + }); + + expect(getChannelIdsForCategory(state, directMessagesCategory)).not.toBe(originalResult); + }); + + test('should return a different result for DMs only when a name change causes an order change', () => { + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); + + const directMessagesCategory = { + id: 'directMessagesCategory', + team_id: 'team1', + display_name: 'Direct Messages', + type: CategoryTypes.DIRECT_MESSAGES, + sorting: CategorySorting.Alphabetical, + channel_ids: [dmChannel1.id, dmChannel2.id], + }; + + // otherUser2 (Another User), otherUser1 (Other User) + let result = getChannelIdsForCategory(baseState, directMessagesCategory); + expect(result).toEqual([dmChannel2.id, dmChannel1.id]); + + let previousResult = result; + let state = mergeObjects(baseState, { + entities: { + users: { + profiles: { + otherUser2: {...otherUser2, first_name: 'User', last_name: 'User'}, + }, + }, + }, + }); + + // otherUser1 (Other User), otherUser2 (User User) + result = getChannelIdsForCategory(state, directMessagesCategory); + expect(result).toEqual([dmChannel1.id, dmChannel2.id]); + expect(result).not.toBe(previousResult); + + previousResult = result; + state = mergeObjects(state, { + entities: { + users: { + profiles: { + otherUser1: {...otherUser1, first_name: 'Zoo', last_name: 'User'}, + }, + }, + }, + }); + + // otherUser2 (User User), otherUser1 (Zoo User) + result = getChannelIdsForCategory(state, directMessagesCategory); + expect(result).toEqual([dmChannel2.id, dmChannel1.id]); + expect(result).not.toBe(previousResult); + + previousResult = result; + state = mergeObjects(state, { + entities: { + users: { + profiles: { + otherUser2: {...otherUser2, first_name: 'Some', last_name: 'User'}, + }, + }, + }, + }); + + // otherUser2 (Some User), otherUser1 (Zoo User) + result = getChannelIdsForCategory(state, directMessagesCategory); + expect(result).toEqual([dmChannel2.id, dmChannel1.id]); + expect(result).toBe(previousResult); + }); + + test('should return a different result for alphabetically sorted DMs when the display name setting causes an order change', () => { + const getChannelIdsForCategory = Selectors.makeGetChannelIdsForCategory(); + + const directMessagesCategory = { + id: 'directMessagesCategory', + team_id: 'team1', + display_name: 'Direct Messages', + type: CategoryTypes.DIRECT_MESSAGES, + sorting: CategorySorting.Alphabetical, + channel_ids: [dmChannel1.id, dmChannel2.id], + }; + + // otherUser2 (Another User), otherUser1 (Other User) + const originalResult = getChannelIdsForCategory(baseState, directMessagesCategory); + expect(originalResult).toEqual([dmChannel2.id, dmChannel1.id]); + + const state = mergeObjects(baseState, { + entities: { + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT)]: {value: Preferences.DISPLAY_PREFER_USERNAME}, + }, + }, + }, + }); + + // otherUser1, otherUser2 + const result = getChannelIdsForCategory(state, directMessagesCategory); + expect(result).toEqual([dmChannel1.id, dmChannel2.id]); + expect(result).not.toBe(originalResult); + }); }); }); @@ -998,29 +1595,47 @@ describe('makeGetChannelsByCategory', () => { const channel1 = {id: 'channel1', type: General.OPEN_CHANNEL, team_id: 'team1', display_name: 'Zebra', delete_at: 0}; const channel2 = {id: 'channel2', type: General.PRIVATE_CHANNEL, team_id: 'team1', display_name: 'Aardvark', delete_at: 0}; const channel3 = {id: 'channel3', type: General.OPEN_CHANNEL, team_id: 'team1', display_name: 'Bear', delete_at: 0}; - const dmChannel1 = {id: 'dmChannel1', type: General.DM_CHANNEL, team_id: '', display_name: '', name: `${currentUser.id}__${otherUser1.id}`, delete_at: 0}; + const dmChannel1 = {id: 'dmChannel1', type: General.DM_CHANNEL, team_id: '', display_name: '', name: `${currentUser.id}__${otherUser1.id}`, delete_at: 0, last_post_at: 2000}; const dmChannel2 = {id: 'dmChannel2', type: General.DM_CHANNEL, team_id: '', display_name: '', name: `${otherUser2.id}__${currentUser.id}`, delete_at: 0}; - const gmChannel1 = {id: 'gmChannel1', type: General.GM_CHANNEL, team_id: '', display_name: `${currentUser.username}, ${otherUser1.username}, ${otherUser2.username}`, name: 'gmChannel1', delete_at: 0}; + const gmChannel1 = {id: 'gmChannel1', type: General.GM_CHANNEL, team_id: '', display_name: `${currentUser.username}, ${otherUser1.username}, ${otherUser2.username}`, name: 'gmChannel1', delete_at: 0, last_post_at: 1000}; - const favoritesCategory = {id: 'favoritesCategory', team_id: 'team1', display_name: CategoryTypes.FAVORITES, type: CategoryTypes.FAVORITES}; - const publicCategory = {id: 'publicCategory', team_id: 'team1', display_name: 'Public Channels', type: CategoryTypes.PUBLIC}; - const privateCategory = {id: 'privateCategory', team_id: 'team1', display_name: 'Private Channels', type: CategoryTypes.PRIVATE}; - const directMessagesCategory = {id: 'directMessagesCategory', team_id: 'team1', display_name: 'Direct Messages', type: CategoryTypes.DIRECT_MESSAGES}; + const favoritesCategory = { + id: 'favoritesCategory', + team_id: 'team1', + display_name: CategoryTypes.FAVORITES, + type: CategoryTypes.FAVORITES, + sorting: CategorySorting.Alphabetical, + channel_ids: [channel1.id, dmChannel2.id], + }; + const channelsCategory = { + id: 'channelsCategory', + team_id: 'team1', + display_name: 'Channels', + type: CategoryTypes.CHANNELS, + sorting: CategorySorting.Default, + channel_ids: [channel2.id, channel3.id], + }; + const directMessagesCategory = { + id: 'directMessagesCategory', + team_id: 'team1', + display_name: 'Direct Messages', + type: CategoryTypes.DIRECT_MESSAGES, + sorting: CategorySorting.Recency, + channel_ids: [dmChannel1.id, gmChannel1.id], + }; const baseState = { entities: { channelCategories: { byId: { favoritesCategory, - publicCategory, - privateCategory, + channelsCategory, directMessagesCategory, }, orderByTeam: { team1: [ favoritesCategory.id, - publicCategory.id, - privateCategory.id, + channelsCategory.id, directMessagesCategory.id, ], }, @@ -1055,9 +1670,8 @@ describe('makeGetChannelsByCategory', () => { [getPreferenceKey(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT)]: {value: Preferences.DISPLAY_PREFER_FULL_NAME}, [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser1.id)]: {value: 'true'}, [getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUser2.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_FAVORITE_CHANNEL, channel1.id)]: {value: 'true'}, - [getPreferenceKey(Preferences.CATEGORY_FAVORITE_CHANNEL, dmChannel2.id)]: {value: 'true'}, [getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel1.id)]: {value: 'true'}, + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '3'}, }, }, users: { @@ -1074,12 +1688,19 @@ describe('makeGetChannelsByCategory', () => { test('should return channels for all categories', () => { const getChannelsByCategory = Selectors.makeGetChannelsByCategory(); - expect(getChannelsByCategory(baseState, 'team1')).toEqual({ - favoritesCategory: [dmChannel2, channel1], - publicCategory: [channel3], - privateCategory: [channel2], - directMessagesCategory: [gmChannel1, dmChannel1], + const state = mergeObjects(baseState, { + entities: { + preferences: { + myPreferences: { + [getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '2'}, + }, + }, + }, }); + const result = getChannelsByCategory(state, 'team1'); + expect(result.favoritesCategory).toEqual([dmChannel2, channel1]); + expect(result.channelsCategory).toEqual([channel2, channel3]); + expect(result.directMessagesCategory).toEqual([dmChannel1, gmChannel1]); }); describe('memoization', () => { @@ -1125,12 +1746,11 @@ describe('makeGetChannelsByCategory', () => { expect(result).not.toBe(previousResult); expect(result).toEqual(previousResult); - // Categories not containing DMs/GMs should still remain the same + // Categories not containing DMs/GMs and sorted alphabetically should still remain the same expect(result.favoritesCategory).not.toBe(previousResult.favoritesCategory); expect(result.favoritesCategory).toEqual(previousResult.favoritesCategory); - expect(result.publicCategory).toBe(previousResult.publicCategory); - expect(result.privateCategory).toBe(previousResult.privateCategory); - expect(result.directMessagesCategory).not.toBe(previousResult.directMessagesCategory); + expect(result.channelsCategory).toBe(previousResult.channelsCategory); + expect(result.directMessagesCategory).toEqual(previousResult.directMessagesCategory); expect(result.directMessagesCategory).toEqual(previousResult.directMessagesCategory); }); @@ -1153,7 +1773,7 @@ describe('makeGetChannelsByCategory', () => { expect(result).toBe(previousResult); }); - test('should return a new object when preferences change', () => { + test('should not return a new object when unrelated preferences change', () => { const getChannelsByCategory = Selectors.makeGetChannelsByCategory(); const state = mergeObjects(baseState, { @@ -1169,8 +1789,7 @@ describe('makeGetChannelsByCategory', () => { const previousResult = getChannelsByCategory(baseState, 'team1'); const result = getChannelsByCategory(state, 'team1'); - expect(result).not.toBe(previousResult); - expect(result).toEqual(previousResult); + expect(result).toBe(previousResult); }); test('should return a new object when a DM is closed', () => { @@ -1191,8 +1810,7 @@ describe('makeGetChannelsByCategory', () => { expect(result).not.toBe(previousResult); expect(result.favoritesCategory).toEqual(previousResult.favoritesCategory); - expect(result.publicCategory).toEqual(previousResult.publicCategory); - expect(result.privateCategory).toEqual(previousResult.privateCategory); + expect(result.channelsCategory).toEqual(previousResult.channelsCategory); expect(result.directMessagesCategory).not.toEqual(previousResult.directMessagesCategory); }); }); diff --git a/app/mm-redux/selectors/entities/channel_categories.ts b/app/mm-redux/selectors/entities/channel_categories.ts index b40f1c8c5..a2a10291e 100644 --- a/app/mm-redux/selectors/entities/channel_categories.ts +++ b/app/mm-redux/selectors/entities/channel_categories.ts @@ -1,27 +1,63 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ import {createSelector} from 'reselect'; import shallowEquals from 'shallow-equals'; -import {getCurrentChannelId, getMyChannelMemberships} from '@mm-redux/selectors/entities/channels'; +import {makeGetChannelsForIds, getCurrentChannelId, getMyChannelMemberships} from '@mm-redux/selectors/entities/channels'; import {getCurrentUserLocale} from '@mm-redux/selectors/entities/i18n'; import {getLastPostPerChannel} from '@mm-redux/selectors/entities/posts'; -import {getMyPreferences, getTeammateNameDisplaySetting, isCollapsedThreadsEnabled, shouldAutocloseDMs} from '@mm-redux/selectors/entities/preferences'; +import {getInt, getMyPreferences, getTeammateNameDisplaySetting, isCollapsedThreadsEnabled, shouldAutocloseDMs} from '@mm-redux/selectors/entities/preferences'; import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; -import {ChannelCategory} from '@mm-redux/types/channel_categories'; +import {CategorySorting, ChannelCategory, ChannelCategoryType} from '@mm-redux/types/channel_categories'; import {Channel, ChannelMembership} from '@mm-redux/types/channels'; import {GlobalState} from '@mm-redux/types/store'; import {UserProfile} from '@mm-redux/types/users'; import {IDMappedObjects, RelationOneToOne} from '@mm-redux/types/utilities'; -import {getUserIdFromChannelName, isFavoriteChannel, isUnreadChannel} from '@mm-redux/utils/channel_utils'; +import {getUserIdFromChannelName, isChannelMuted, isUnreadChannel} from '@mm-redux/utils/channel_utils'; import {getPreferenceKey} from '@mm-redux/utils/preference_utils'; import {displayUsername} from '@mm-redux/utils/user_utils'; import {General, Preferences} from '../../constants'; import {CategoryTypes} from '../../constants/channel_categories'; -export function getCategoryIdsForTeam(state: GlobalState, teamId: string): string[] | undefined { +import {getCurrentTeamId} from './teams'; + +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]; } @@ -39,56 +75,43 @@ export function makeGetCategoriesForTeam(): (state: GlobalState, teamId: string) ); } -export function makeGetUnsortedUnfilteredChannels(): (state: GlobalState, teamId: string) => Channel[] { - return createSelector( - (state: GlobalState) => state.entities.channels.channels, - getMyChannelMemberships, - (state: GlobalState, teamId: string) => teamId, - (allChannels: IDMappedObjects, myMembers: RelationOneToOne, teamId: string) => { - return Object.values(allChannels). - filter((channel) => channel.delete_at === 0). - filter((channel) => channel.team_id === teamId || channel.team_id === ''). - filter((channel) => myMembers.hasOwnProperty(channel.id)); - }, - ); -} +export const getCategoriesWithFilteredChannelIds: (state: GlobalState) => ChannelCategory[] = createSelector( + (state: GlobalState) => state, + getAllCategoriesByIds, + getCurrentTeamId, + makeGetChannelIdsForCategory, + (state, categoryIds, currentTeamId, getChannelIds) => { + const categories = Object.entries(categoryIds). + filter((cat) => cat[1].team_id === currentTeamId). + map((cat) => { + return {...cat[1], channel_ids: getChannelIds(state, cat[1])}; + }); -export function makeFilterChannelsByFavorites(): (state: GlobalState, channels: Channel[], categoryType: string) => Channel[] { + return categories; + }, +); + +export function makeFilterUnreadChannels():(state: GlobalState, channels: Channel[]) => Channel[] { return createSelector( (state: GlobalState, channels: Channel[]) => channels, - (state: GlobalState, channels: Channel[], categoryType: string) => categoryType, - getMyPreferences, - (channels, categoryType, myPreferences) => { - const filtered = channels.filter((channel) => { - if (categoryType === CategoryTypes.FAVORITES) { - return isFavoriteChannel(myPreferences, channel.id); - } - - return !isFavoriteChannel(myPreferences, channel.id); - }); + getMyChannelMemberships, + isCollapsedThreadsEnabled, + (channels: Channel[], myMemberships, threads) => { + const filtered = channels.filter((channel) => !isUnreadChannel(myMemberships, channel, threads)); return filtered.length === channels.length ? channels : filtered; }, ); } -export function makeFilterChannelsByType(): (state: GlobalState, channels: Channel[], categoryType: string) => Channel[] { - // This doesn't need to be a selector, but make it as one to keep it consistent +// 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, - (state: GlobalState, channels: Channel[], categoryType: string) => categoryType, - (channels, categoryType) => { - const filtered = channels.filter((channel) => { - if (categoryType === CategoryTypes.PUBLIC) { - return channel.type === General.OPEN_CHANNEL; - } else if (categoryType === CategoryTypes.PRIVATE) { - return channel.type === General.PRIVATE_CHANNEL; - } else if (categoryType === CategoryTypes.DIRECT_MESSAGES) { - return channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL; - } - - return true; - }); + 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; }, @@ -99,7 +122,11 @@ function getDefaultAutocloseCutoff() { return Date.now() - (7 * 24 * 60 * 60 * 1000); } -export function makeFilterAutoclosedDMs(getAutocloseCutoff = getDefaultAutocloseCutoff): (state: GlobalState, channels: Channel[], categoryType: string) => Channel[] { +// 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, @@ -111,7 +138,7 @@ export function makeFilterAutoclosedDMs(getAutocloseCutoff = getDefaultAutoclose getMyChannelMemberships, getLastPostPerChannel, isCollapsedThreadsEnabled, - (channels, categoryType, myPreferences, autocloseDMs, currentChannelId, profiles, currentUserId, myChannelMembers, lastPosts, collapsedThreadsEnabled) => { + (channels, categoryType, myPreferences, autocloseDMs, currentChannelId, profiles, currentUserId, myMembers, lastPosts, collapsedThreads) => { if (categoryType !== CategoryTypes.DIRECT_MESSAGES) { // Only autoclose DMs that haven't been assigned to a category return channels; @@ -125,8 +152,13 @@ export function makeFilterAutoclosedDMs(getAutocloseCutoff = getDefaultAutoclose return true; } - // Unread channels will never be hidden - if (isUnreadChannel(myChannelMembers, channel, collapsedThreadsEnabled)) { + if (isUnreadChannel(myMembers, channel, collapsedThreads)) { + // Unread DMs/GMs are always visible + return true; + } + + if (currentChannelId === channel.id) { + // The current channel is always visible return true; } @@ -145,7 +177,7 @@ export function makeFilterAutoclosedDMs(getAutocloseCutoff = getDefaultAutoclose // 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 && channel.id !== currentChannelId) { + if (channel.type === General.DM_CHANNEL) { const teammateId = getUserIdFromChannelName(currentUserId, channel.name); const teammate = profiles[teammateId]; @@ -184,12 +216,115 @@ export function makeFilterAutoclosedDMs(getAutocloseCutoff = getDefaultAutoclose ); } +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, + isCollapsedThreadsEnabled, + (channels, categoryType, currentChannelId, profiles, currentUserId, myMembers, limitPref, myPreferences, collapsedThreads) => { + 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, collapsedThreads)) { + 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, collapsedThreads) && !isUnreadChannel(myMembers, channelB, collapsedThreads)) { + return -1; + } else if (!isUnreadChannel(myMembers, channelA, collapsedThreads) && isUnreadChannel(myMembers, channelB, collapsedThreads)) { + 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, - (channels, myPreferences, currentUserId) => { + getMyChannelMemberships, + isCollapsedThreadsEnabled, + (channels, myPreferences, currentChannelId, currentUserId, myMembers, collapsedThreads) => { const filtered = channels.filter((channel) => { let preference; @@ -197,6 +332,16 @@ export function makeFilterManuallyClosedDMs(): (state: GlobalState, channels: Ch return true; } + if (isUnreadChannel(myMembers, channel, collapsedThreads)) { + // 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); @@ -214,14 +359,32 @@ export function makeFilterManuallyClosedDMs(): (state: GlobalState, channels: Ch ); } +export function makeCompareChannels(getDisplayName: (channel: Channel) => string, locale: string, myMembers: RelationOneToOne) { + 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, - (channels: Channel[], locale: string) => { - const sorted = [...channels]; - sorted.sort((a, b) => a.display_name.localeCompare(b.display_name, locale, {numeric: true})); - return sorted; + (state: GlobalState) => getCurrentUserLocale(state), + getMyChannelMemberships, + (channels: Channel[], locale: string, myMembers: RelationOneToOne) => { + const getDisplayName = (channel: Channel) => channel.display_name; + + return [...channels].sort(makeCompareChannels(getDisplayName, locale, myMembers)); }, ); } @@ -232,8 +395,9 @@ export function makeSortChannelsByNameWithDMs(): (state: GlobalState, channels: getCurrentUserId, (state: GlobalState) => state.entities.users.profiles, getTeammateNameDisplaySetting, - getCurrentUserLocale, - (channels: Channel[], currentUserId: string, profiles: IDMappedObjects, teammateNameDisplay: string, locale: string) => { + (state: GlobalState) => getCurrentUserLocale(state), + getMyChannelMemberships, + (channels: Channel[], currentUserId: string, profiles: IDMappedObjects, teammateNameDisplay: string, locale: string, myMembers: RelationOneToOne) => { const cachedNames: RelationOneToOne = {}; const getDisplayName = (channel: Channel): string => { @@ -277,59 +441,109 @@ export function makeSortChannelsByNameWithDMs(): (state: GlobalState, channels: return displayName; }; - const sorted = [...channels]; - sorted.sort((a, b) => getDisplayName(a).localeCompare(getDisplayName(b), locale, {numeric: true})); - return sorted; + return [...channels].sort(makeCompareChannels(getDisplayName, locale, myMembers)); }, ); } -export function makeGetChannelsForCategory() { - const getUnsortedUnfilteredChannels = makeGetUnsortedUnfilteredChannels(); - const filterAndSortChannelsForCategory = makeFilterAndSortChannelsForCategory(); +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. - return (state: GlobalState, category: ChannelCategory) => { - const channels = getUnsortedUnfilteredChannels(state, category.team_id); + 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; + } - return filterAndSortChannelsForCategory(state, channels, category); - }; + 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 makeFilterAndSortChannelsForCategory() { - const filterChannelsByFavorites = makeFilterChannelsByFavorites(); - const filterChannelsByType = makeFilterChannelsByType(); - - const filterAutoclosedDMs = makeFilterAutoclosedDMs(); - const filterManuallyClosedDMs = makeFilterManuallyClosedDMs(); - +export function makeSortChannels() { const sortChannelsByName = makeSortChannelsByName(); const sortChannelsByNameWithDMs = makeSortChannelsByNameWithDMs(); + const sortChannelsByRecency = makeSortChannelsByRecency(); return (state: GlobalState, originalChannels: Channel[], category: ChannelCategory) => { let channels = originalChannels; - channels = filterChannelsByFavorites(state, channels, category.type); - channels = filterChannelsByType(state, channels, category.type); + // While this function isn't memoized, sortChannelsByX should be since they know what parts of state + // will affect sort order. - channels = filterAutoclosedDMs(state, channels, category.type); - channels = filterManuallyClosedDMs(state, channels); - - if (channels.some((channel) => channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL)) { - channels = sortChannelsByNameWithDMs(state, channels); - } else { - channels = sortChannelsByName(state, channels); + 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 makeGetChannelIdsForCategory() { + const getChannels = makeGetChannelsForIds(); + const filterAndSortChannelsForCategory = makeFilterAndSortChannelsForCategory(); + + let lastChannelIds: string[] = []; + + return (state: GlobalState, category: ChannelCategory) => { + const channels = getChannels(state, category.channel_ids); + + const filteredChannelIds = filterAndSortChannelsForCategory(state, channels, category).map((channel) => channel.id); + + if (shallowEquals(filteredChannelIds, lastChannelIds)) { + return lastChannelIds; + } + + lastChannelIds = filteredChannelIds; + return lastChannelIds; + }; +} + +// 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(); + const filterUnreadChannels = makeFilterUnreadChannels(); + + 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); + channels = filterUnreadChannels(state, channels); + + return channels; + }; +} + export function makeGetChannelsByCategory() { const getCategoriesForTeam = makeGetCategoriesForTeam(); - const getUnsortedUnfilteredChannels = makeGetUnsortedUnfilteredChannels(); - // Memoize filterAndSortChannels by category. As long as the categories don't change, we can keep using the same - // selector for each category. + // Memoize by category. As long as the categories don't change, we can keep using the same selectors for each category. + let getChannels: RelationOneToOne>; let filterAndSortChannels: RelationOneToOne>; let lastCategoryIds: ReturnType = []; @@ -345,21 +559,23 @@ export function makeGetChannelsByCategory() { lastCategoryIds = categoryIds; lastChannelsByCategory = {}; + getChannels = {}; filterAndSortChannels = {}; if (categoryIds) { for (const categoryId of categoryIds) { + getChannels[categoryId] = makeGetChannelsForIds(); filterAndSortChannels[categoryId] = makeFilterAndSortChannelsForCategory(); } } } const categories = getCategoriesForTeam(state, teamId); - const channels = getUnsortedUnfilteredChannels(state, teamId); const channelsByCategory: RelationOneToOne = {}; for (const category of categories) { + const channels = getChannels[category.id](state, category.channel_ids); channelsByCategory[category.id] = filterAndSortChannels[category.id](state, channels, category); } diff --git a/app/mm-redux/selectors/entities/channels.test.js b/app/mm-redux/selectors/entities/channels.test.js index 2ba57f6d9..17acf48dd 100644 --- a/app/mm-redux/selectors/entities/channels.test.js +++ b/app/mm-redux/selectors/entities/channels.test.js @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ import assert from 'assert'; import * as Selectors from '@mm-redux/selectors/entities/channels'; @@ -2711,12 +2712,11 @@ describe('Selectors.Channels.getSortedUnreadChannelIds', () => { assert.ok(fromOriginalState === fromModifiedState); assert.ok(fromMentionState !== fromModifiedState); - // channel3 and channel1 are above all others - // since default order is "alpha", channel3 with display_name "ABC" should come first - assert.ok(fromMentionState[0] === channel3.id); + // Recency by default, so channel1 first + assert.ok(fromMentionState[0] === channel1.id); - // followed by channel1 with display_name "DEF" - assert.ok(fromMentionState[1] === channel1.id); + // followed by channel3 + assert.ok(fromMentionState[1] === channel3.id); const hasMentionMutedChannelState = { ...mentionState, diff --git a/app/mm-redux/selectors/entities/channels.ts b/app/mm-redux/selectors/entities/channels.ts index f3e7a0fd8..714d13997 100644 --- a/app/mm-redux/selectors/entities/channels.ts +++ b/app/mm-redux/selectors/entities/channels.ts @@ -70,7 +70,14 @@ function sortChannelsByRecencyOrAlpha(locale: string, lastPosts: RelationOneToOn // c. Remaining unread channels // And then secondary by alphabetical ("alpha") or chronological ("recency") order -export const mapAndSortChannelIds = (channels: Array, currentUser: UserProfile, myMembers: RelationOneToOne, lastPosts: RelationOneToOne, sorting: SortingType, sortMentionsFirst = false): Array => { +export const mapAndSortChannelIds = ( + channels: Channel[], + currentUser: UserProfile, + myMembers: RelationOneToOne, + lastPosts: RelationOneToOne, + sorting: SortingType, + sortMentionsFirst = false, +): string[] => { const locale = currentUser.locale || General.DEFAULT_LOCALE; const mutedChannelIds = channels. @@ -589,10 +596,11 @@ export const getUnreadChannels: (b: GlobalState, a?: Channel | null) => Array Array = createIdsSelector(getUnreadChannels, getCurrentUser, getMyChannelMemberships, getLastPostPerChannel, (state: GlobalState, lastUnreadChannel: Channel, sorting: SortingType = 'alpha') => sorting, (channels, currentUser, myMembers, lastPosts: RelationOneToOne, sorting: SortingType) => { return mapAndSortChannelIds(channels, currentUser, myMembers, lastPosts, sorting, true); }); -export const getSortedUnreadChannelIds: (e: GlobalState, d: Channel|null, c: boolean, b: boolean, a: SortingType) => Array = createIdsSelector(getUnreadChannelIds, (state: GlobalState, lastUnreadChannel: Channel, unreadsAtTop: boolean, favoritesAtTop: boolean, sorting: SortingType = 'alpha') => { +export const getSortedUnreadChannelIds: (e: GlobalState, d: Channel|null, c: boolean, b: boolean, a: SortingType) => Array = createIdsSelector(getUnreadChannelIds, (state: GlobalState, lastUnreadChannel: Channel, unreadsAtTop: boolean, favoritesAtTop: boolean, sorting: SortingType = 'recent') => { return getMapAndSortedUnreadChannelIds(state, lastUnreadChannel, sorting); }, (unreadChannelIds, mappedAndSortedUnreadChannelIds) => mappedAndSortedUnreadChannelIds); // Favorites @@ -972,3 +980,16 @@ export function isManuallyUnread(state: GlobalState, channelId?: string): boolea export function getChannelMemberCountsByGroup(state: GlobalState, channelId: string): ChannelMemberCountsByGroup { return state.entities.channels.channelMemberCountsByGroup[channelId] || {}; } + +// makeGetChannelsForIds returns a selector that, given an array of channel IDs, returns a list of the corresponding +// channels. Channels are returned in the same order as the given IDs with undefined entries replacing any invalid IDs. +// Note that memoization will fail if an array literal is passed in. +export function makeGetChannelsForIds(): (state: GlobalState, ids: string[]) => Channel[] { + return createSelector( + getAllChannels, + (state: GlobalState, ids: string[]) => ids, + (allChannels, ids) => { + return ids.map((id) => allChannels[id]); + }, + ); +} diff --git a/app/mm-redux/selectors/entities/preferences.ts b/app/mm-redux/selectors/entities/preferences.ts index 3367ade53..129b242ef 100644 --- a/app/mm-redux/selectors/entities/preferences.ts +++ b/app/mm-redux/selectors/entities/preferences.ts @@ -171,12 +171,10 @@ const defaultSidebarPrefs = { export const getSidebarPreferences = reselect.createSelector( (state: GlobalState) => { - const config = getConfig(state); - return config.ExperimentalGroupUnreadChannels !== General.DISABLED && getBool( + return getBool( state, Preferences.CATEGORY_SIDEBAR_SETTINGS, 'show_unread_section', - config.ExperimentalGroupUnreadChannels === General.DEFAULT_ON, ); }, (state) => { @@ -193,6 +191,7 @@ export const getSidebarPreferences = reselect.createSelector( // Support unread settings for old implementation sidebarPrefs = { ...defaultSidebarPrefs, + unreads_at_top: showUnreadSection ? 'true' : 'false', }; } diff --git a/app/mm-redux/types/channel_categories.ts b/app/mm-redux/types/channel_categories.ts index 0d1c7a382..d8e29d0cb 100644 --- a/app/mm-redux/types/channel_categories.ts +++ b/app/mm-redux/types/channel_categories.ts @@ -1,22 +1,39 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Channel} from './channels'; import {Team} from './teams'; +import {UserProfile} from './users'; import {$ID, IDMappedObjects, RelationOneToOne} from './utilities'; -export type ChannelCategoryType = 'favorites' | 'public' | 'private' | 'direct_messages' | 'custom'; +export type ChannelCategoryType = 'unreads' | 'favorites' | 'channels' | 'direct_messages' | 'custom' | 'public' | 'private'; + +// eslint-disable-next-line no-shadow +export enum CategorySorting { + Alphabetical = 'alpha', + Default = '', // behaves the same as manual + Recency = 'recent', + Manual = 'manual', +} export type ChannelCategory = { id: string; + user_id?: $ID; team_id: $ID; type: ChannelCategoryType; display_name: string; + sorting: CategorySorting; + channel_ids: Array<$ID>; + muted: boolean; + collapsed: boolean; +}; - // This will be added in phase 2 of Channel Sidebar Organization once the server provides the categories - // channel_ids: $ID; +export type OrderedChannelCategories = { + categories: ChannelCategory[]; + order: string[]; }; export type ChannelCategoriesState = { byId: IDMappedObjects; - orderByTeam: RelationOneToOne[]>; -}; + orderByTeam: RelationOneToOne>>; +} diff --git a/app/mm-redux/types/config.ts b/app/mm-redux/types/config.ts index 7d32ec0a8..4b074eba4 100644 --- a/app/mm-redux/types/config.ts +++ b/app/mm-redux/types/config.ts @@ -63,6 +63,7 @@ export type Config = { EnableIncomingWebhooks: string; EnableLatex: string; EnableLdap: string; + EnableLegacySidebar: string; EnableLinkPreviews: string; EnableMarketplace: string; EnableMetrics: string; @@ -96,7 +97,7 @@ export type Config = { EnableUserTypingMessages: string; EnforceMultifactorAuthentication: string; ExperimentalChannelOrganization: string; - ExperimentalChannelSidebarOrganization: string; + ExperimentalChannelSidebarOrganization?: string; ExperimentalClientSideCertCheck: string; ExperimentalClientSideCertEnable: string; ExperimentalEnableAuthenticationTransfer: string; diff --git a/app/mm-redux/utils/array_utils.test.ts b/app/mm-redux/utils/array_utils.test.ts new file mode 100644 index 000000000..13c26fb61 --- /dev/null +++ b/app/mm-redux/utils/array_utils.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {insertWithoutDuplicates, insertMultipleWithoutDuplicates, removeItem} from './array_utils'; + +describe('insertWithoutDuplicates', () => { + test('should add the item at the given location', () => { + expect(insertWithoutDuplicates(['a', 'b', 'c', 'd'], 'z', 0)).toEqual(['z', 'a', 'b', 'c', 'd']); + expect(insertWithoutDuplicates(['a', 'b', 'c', 'd'], 'z', 1)).toEqual(['a', 'z', 'b', 'c', 'd']); + expect(insertWithoutDuplicates(['a', 'b', 'c', 'd'], 'z', 2)).toEqual(['a', 'b', 'z', 'c', 'd']); + expect(insertWithoutDuplicates(['a', 'b', 'c', 'd'], 'z', 3)).toEqual(['a', 'b', 'c', 'z', 'd']); + expect(insertWithoutDuplicates(['a', 'b', 'c', 'd'], 'z', 4)).toEqual(['a', 'b', 'c', 'd', 'z']); + }); + + test('should move an item if it already exists', () => { + expect(insertWithoutDuplicates(['a', 'b', 'c', 'd'], 'a', 0)).toEqual(['a', 'b', 'c', 'd']); + expect(insertWithoutDuplicates(['a', 'b', 'c', 'd'], 'a', 1)).toEqual(['b', 'a', 'c', 'd']); + expect(insertWithoutDuplicates(['a', 'b', 'c', 'd'], 'a', 2)).toEqual(['b', 'c', 'a', 'd']); + expect(insertWithoutDuplicates(['a', 'b', 'c', 'd'], 'a', 3)).toEqual(['b', 'c', 'd', 'a']); + }); + + test('should return the original array if nothing changed', () => { + const input = ['a', 'b', 'c', 'd']; + + expect(insertWithoutDuplicates(input, 'a', 0)).toBe(input); + }); +}); + +describe('insertMultipleWithoutDuplicates', () => { + test('should add the item at the given location', () => { + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['z', 'y', 'x'], 0)).toEqual(['z', 'y', 'x', 'a', 'b', 'c', 'd']); + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['z', 'y', 'x'], 1)).toEqual(['a', 'z', 'y', 'x', 'b', 'c', 'd']); + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['z', 'y', 'x'], 2)).toEqual(['a', 'b', 'z', 'y', 'x', 'c', 'd']); + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['z', 'y', 'x'], 3)).toEqual(['a', 'b', 'c', 'z', 'y', 'x', 'd']); + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['z', 'y', 'x'], 4)).toEqual(['a', 'b', 'c', 'd', 'z', 'y', 'x']); + }); + + test('should move an item if it already exists', () => { + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['a', 'c'], 0)).toEqual(['a', 'c', 'b', 'd']); + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['a', 'c'], 1)).toEqual(['b', 'a', 'c', 'd']); + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['a', 'c'], 2)).toEqual(['b', 'd', 'a', 'c']); + }); + + test('should properly place new and existing items', () => { + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['z', 'y', 'x', 'a', 'c'], 0)).toEqual(['z', 'y', 'x', 'a', 'c', 'b', 'd']); + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['z', 'y', 'x', 'a', 'c'], 1)).toEqual(['b', 'z', 'y', 'x', 'a', 'c', 'd']); + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['z', 'y', 'x', 'a', 'c'], 2)).toEqual(['b', 'd', 'z', 'y', 'x', 'a', 'c']); + }); + + test('should return the original array if nothing changed', () => { + const input = ['a', 'b', 'c', 'd']; + + expect(insertMultipleWithoutDuplicates(input, ['a', 'b', 'c'], 0)).toStrictEqual(input); + }); + + test('should just return the array if either the input or items to insert is blank', () => { + expect(insertMultipleWithoutDuplicates([], ['a', 'b', 'c'], 0)).toStrictEqual(['a', 'b', 'c']); + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c'], [], 0)).toStrictEqual(['a', 'b', 'c']); + }); + + test('should handle invalid index inputs', () => { + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['e', 'f'], 10)).toStrictEqual(['a', 'b', 'c', 'd', 'e', 'f']); + expect(insertMultipleWithoutDuplicates(['a', 'b', 'c', 'd'], ['e', 'f'], -2)).toStrictEqual(['a', 'b', 'e', 'f', 'c', 'd']); + }); +}); + +describe('removeItem', () => { + test('should remove the given item', () => { + expect(removeItem(['a', 'b', 'c', 'd'], 'a')).toEqual(['b', 'c', 'd']); + expect(removeItem(['a', 'b', 'c', 'd'], 'b')).toEqual(['a', 'c', 'd']); + expect(removeItem(['a', 'b', 'c', 'd'], 'c')).toEqual(['a', 'b', 'd']); + expect(removeItem(['a', 'b', 'c', 'd'], 'd')).toEqual(['a', 'b', 'c']); + }); + + test('should return the original array if nothing changed', () => { + const input = ['a', 'b', 'c', 'd']; + + expect(removeItem(input, 'e')).toBe(input); + }); +}); diff --git a/app/mm-redux/utils/array_utils.ts b/app/mm-redux/utils/array_utils.ts new file mode 100644 index 000000000..0a12f14b6 --- /dev/null +++ b/app/mm-redux/utils/array_utils.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// insertWithoutDuplicates inserts an item into an array and returns the result. The provided array is not modified. +// If the array already contains the given item, that item is moved to the new location instead of adding a duplicate. +// If the array already had the given item at the given index, the origianl array is returned. +export function insertWithoutDuplicates(array: T[], item: T, newIndex: number) { + const index = array.indexOf(item); + if (newIndex === index) { + // The item doesn't need to be moved since its location hasn't changed + return array; + } + + const newArray = [...array]; + + // Remove the item from its old location if it already exists in the array + if (index !== -1) { + newArray.splice(index, 1); + } + + // And re-add it in its new location + newArray.splice(newIndex, 0, item); + + return newArray; +} + +export function insertMultipleWithoutDuplicates(array: T[], items: T[], newIndex: number) { + let newArray = [...array]; + + items.forEach((item) => { + newArray = removeItem(newArray, item); + }); + + // And re-add it in its new location + newArray.splice(newIndex, 0, ...items); + + return newArray; +} + +// removeItem removes an item from an array and returns the result. The provided array is not modified. If the array +// did not originally contain the given item, the original array is returned. +export function removeItem(array: T[], item: T) { + const index = array.indexOf(item); + if (index === -1) { + return array; + } + + const result = [...array]; + result.splice(index, 1); + return result; +} diff --git a/app/mm-redux/utils/channel_utils.ts b/app/mm-redux/utils/channel_utils.ts index 08ff1acda..778ea791b 100644 --- a/app/mm-redux/utils/channel_utils.ts +++ b/app/mm-redux/utils/channel_utils.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ import {haveITeamPermission, haveIChannelPermission} from '@mm-redux/selectors/entities/roles'; import {Channel, ChannelMembership, ChannelType, ChannelNotifyProps} from '@mm-redux/types/channels'; import {Post} from '@mm-redux/types/posts'; diff --git a/app/reducers/views/channel.js b/app/reducers/views/channel.js index 236511265..ec5fcf187 100644 --- a/app/reducers/views/channel.js +++ b/app/reducers/views/channel.js @@ -370,6 +370,9 @@ function keepChannelIdAsUnread(state = null, action) { } function unreadMessageCount(state = {}, action) { + if (!action || !action.data) { + return state; + } switch (action.type) { case ChannelTypes.SET_UNREAD_MSG_COUNT: { const {channelId, count} = action.data; diff --git a/app/reducers/views/channel.test.js b/app/reducers/views/channel.test.js index 2f63834d7..5cd108b85 100644 --- a/app/reducers/views/channel.test.js +++ b/app/reducers/views/channel.test.js @@ -23,7 +23,10 @@ describe('Reducers.channel', () => { const nextState = channelReducer( initialState, - {}, + { + type: '', + data: {}, + }, ); expect(nextState).toEqual(initialState); diff --git a/app/screens/create_channel/create_channel.js b/app/screens/create_channel/create_channel.js index 5e2d66710..9ec4e0c9a 100644 --- a/app/screens/create_channel/create_channel.js +++ b/app/screens/create_channel/create_channel.js @@ -26,6 +26,7 @@ export default class CreateChannel extends PureComponent { actions: PropTypes.shape({ handleCreateChannel: PropTypes.func.isRequired, }), + categoryId: PropTypes.string, }; static contextTypes = { @@ -56,6 +57,7 @@ export default class CreateChannel extends PureComponent { displayName: '', purpose: '', header: '', + type: this.props.channelType, }; this.rightButton.text = context.intl.formatMessage({id: 'mobile.create_channel', defaultMessage: 'Create'}); @@ -151,7 +153,7 @@ export default class CreateChannel extends PureComponent { onCreateChannel = () => { Keyboard.dismiss(); const {displayName, purpose, header} = this.state; - this.props.actions.handleCreateChannel(displayName, purpose, header, this.props.channelType); + this.props.actions.handleCreateChannel(displayName, purpose, header, this.state.type, this.props.categoryId); }; onDisplayNameChange = (displayName) => { @@ -166,6 +168,10 @@ export default class CreateChannel extends PureComponent { this.setState({header}); }; + onTypeChange = (type) => { + this.setState({type}); + } + render() { const {theme} = this.props; const { @@ -174,6 +180,7 @@ export default class CreateChannel extends PureComponent { displayName, purpose, header, + type, } = this.state; return ( @@ -186,9 +193,11 @@ export default class CreateChannel extends PureComponent { onDisplayNameChange={this.onDisplayNameChange} onPurposeChange={this.onPurposeChange} onHeaderChange={this.onHeaderChange} + onTypeChange={this.onTypeChange} displayName={displayName} purpose={purpose} header={header} + type={type} /> ); } diff --git a/app/screens/more_channels/more_channels.js b/app/screens/more_channels/more_channels.js index b1eb13a9d..f35cd69c5 100644 --- a/app/screens/more_channels/more_channels.js +++ b/app/screens/more_channels/more_channels.js @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ import PropTypes from 'prop-types'; import React, {PureComponent} from 'react'; import {intlShape} from 'react-intl'; @@ -51,6 +52,7 @@ export default class MoreChannels extends PureComponent { currentTeamId: PropTypes.string.isRequired, theme: PropTypes.object.isRequired, canShowArchivedChannels: PropTypes.bool.isRequired, + categoryId: PropTypes.string, }; static defaultProps = { @@ -267,7 +269,7 @@ export default class MoreChannels extends PureComponent { this.setState({adding: true}); const channel = channels.find((c) => c.id === id); - const result = await actions.joinChannel(currentUserId, currentTeamId, id); + const result = await actions.joinChannel(currentUserId, currentTeamId, id, '', this.props.categoryId); if (result.error) { alertErrorWithFallback( diff --git a/app/screens/options_modal/__snapshots__/options_modal_list.test.js.snap b/app/screens/options_modal/__snapshots__/options_modal_list.test.js.snap index bf0cfa932..0dd8ec5b1 100644 --- a/app/screens/options_modal/__snapshots__/options_modal_list.test.js.snap +++ b/app/screens/options_modal/__snapshots__/options_modal_list.test.js.snap @@ -1,144 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`OptionModalList should match snapshot for Android 1`] = ` - - - - - - - - - - - - - - - - - - - -`; - -exports[`OptionModalList should match snapshot for iOS 1`] = ` +exports[`OptionModalList should match snapshot 1`] = ` test - + + - - + + - - - - - - `; diff --git a/app/screens/options_modal/options_modal.js b/app/screens/options_modal/options_modal.js index 543617563..af4ddf345 100644 --- a/app/screens/options_modal/options_modal.js +++ b/app/screens/options_modal/options_modal.js @@ -31,6 +31,7 @@ export default class OptionsModal extends PureComponent { PropTypes.string, PropTypes.object, ]), + subtitle: PropTypes.string, }; static defaultProps = { @@ -85,6 +86,7 @@ export default class OptionsModal extends PureComponent { const { items, title, + subtitle, } = this.props; return ( @@ -96,6 +98,7 @@ export default class OptionsModal extends PureComponent { onCancelPress={this.handleCancel} onItemPress={this.onItemPress} title={title} + subtitle={subtitle} /> diff --git a/app/screens/options_modal/options_modal_list.android.js b/app/screens/options_modal/options_modal_list.android.js deleted file mode 100644 index a81105395..000000000 --- a/app/screens/options_modal/options_modal_list.android.js +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import PropTypes from 'prop-types'; -import React, {PureComponent} from 'react'; -import { - StyleSheet, - Text, - TouchableOpacity, - View, -} from 'react-native'; - -import CompassIcon from '@components/compass_icon'; -import FormattedText from '@components/formatted_text'; -import {preventDoubleTap} from '@utils/tap'; - -export default class OptionsModalList extends PureComponent { - static propTypes = { - items: PropTypes.array.isRequired, - onCancelPress: PropTypes.func, - onItemPress: PropTypes.func, - }; - - static defaultProps = { - items: [], - }; - - handleCancelPress = preventDoubleTap(() => { - if (this.props.onCancelPress) { - this.props.onCancelPress(); - } - }); - - handleItemPress = preventDoubleTap((action) => { - this.props.onItemPress(); - setTimeout(() => { - if (typeof action === 'function') { - action(); - } - }, 250); - }); - - renderOptions = () => { - const {items} = this.props; - - const options = items.map((item, index) => { - let textComponent; - let optionIconStyle = style.optionIcon; - if (typeof item.iconStyle !== 'undefined') { - optionIconStyle = item.iconStyle; - } - - if (item.text.hasOwnProperty('id')) { - textComponent = ( - - ); - } else { - textComponent = {item.text}; - } - - return ( - - this.handleItemPress(item.action)} - style={style.option} - > - {textComponent} - {item.icon && - - } - - - ); - }); - - const cancel = ( - - - - ); - - return [ - ...options, - cancel, - ]; - }; - - render() { - return ( - - - {this.renderOptions()} - - - ); - } -} - -const style = StyleSheet.create({ - option: { - alignSelf: 'stretch', - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - padding: 15, - }, - optionBorder: { - borderBottomWidth: 1, - borderBottomColor: 'rgba(0, 0, 0, 0.1)', - }, - optionContainer: { - alignSelf: 'stretch', - backgroundColor: 'white', - borderRadius: 2, - marginHorizontal: 30, - }, - optionIcon: { - color: '#7f8180', - }, - optionText: { - color: '#000', - flex: 1, - fontSize: 16, - }, - wrapper: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, -}); diff --git a/app/screens/options_modal/options_modal_list.ios.js b/app/screens/options_modal/options_modal_list.js similarity index 72% rename from app/screens/options_modal/options_modal_list.ios.js rename to app/screens/options_modal/options_modal_list.js index 8c3bda392..fcccd9073 100644 --- a/app/screens/options_modal/options_modal_list.ios.js +++ b/app/screens/options_modal/options_modal_list.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import React, {PureComponent} from 'react'; import { + Platform, StyleSheet, Text, TouchableOpacity, @@ -23,6 +24,7 @@ export default class OptionsModalList extends PureComponent { PropTypes.string, PropTypes.object, ]), + subtitle: PropTypes.string, }; static defaultProps = { @@ -57,24 +59,20 @@ export default class OptionsModalList extends PureComponent { if (item.text.hasOwnProperty('id')) { textComponent = ( ); } else { - textComponent = {item.text}; + textComponent = {item.text}; } return ( - + this.handleItemPress(item.action)} style={style.option} > - {textComponent} {item.icon && } + {textComponent} + ); @@ -89,6 +89,7 @@ export default class OptionsModalList extends PureComponent { let title; let titleComponent; + let subtitleComponent; if (this.props.title) { if (this.props.title.hasOwnProperty('id')) { titleComponent = ( @@ -101,18 +102,29 @@ export default class OptionsModalList extends PureComponent { titleComponent = {this.props.title}; } + if (this.props.subtitle) { + subtitleComponent = ( + {this.props.subtitle} + ); + } + title = ( {titleComponent} + ); } return [ title, + subtitleComponent, ...options, ]; }; @@ -124,18 +136,6 @@ export default class OptionsModalList extends PureComponent { {this.renderOptions()} - - - - - ); @@ -148,38 +148,53 @@ const style = StyleSheet.create({ alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', - padding: 15, + paddingHorizontal: 20, + paddingVertical: 10, width: '100%', }, - optionBorder: { - borderBottomWidth: 1, - borderBottomColor: 'rgba(0, 0, 0, 0.1)', - }, - optionCancelText: { - color: '#CC3239', - flex: 1, - fontSize: 20, - textAlign: 'center', - }, optionContainer: { alignSelf: 'stretch', backgroundColor: 'white', - borderRadius: 12, - marginBottom: 20, - marginHorizontal: 20, + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + paddingVertical: 10, + ...Platform.select({ + ios: { + paddingBottom: 25, + }, + android: { + marginBottom: -10, + }, + }), + }, optionIcon: { - color: '#4E8ACC', + color: 'rgba(61, 60, 64, 0.64)', + paddingRight: 10, }, optionText: { - color: '#4E8ACC', + color: '#3D3C40', flex: 1, - fontSize: 20, + fontSize: 16, + lineHeight: 24, + fontWeight: '400', }, optionTitleText: { - color: '#7f8180', - flex: 1, - textAlign: 'center', + fontSize: 24, + lineHeight: 32, + fontWeight: '600', + color: '#3D3C40', + width: '100%', + textAlign: 'left', + }, + optionSubTitleText: { + width: '100%', + fontSize: 16, + lineHeight: 24, + color: 'rgba(61, 60, 64, 0.64)', + textAlign: 'left', + paddingHorizontal: 20, + paddingBottom: 10, }, container: { flex: 1, @@ -190,4 +205,8 @@ const style = StyleSheet.create({ maxWidth: 450, width: '100%', }, + break: { + flexBasis: '100%', + height: 0, + }, }); diff --git a/app/screens/options_modal/options_modal_list.test.js b/app/screens/options_modal/options_modal_list.test.js index 53635dbb1..8214b140c 100644 --- a/app/screens/options_modal/options_modal_list.test.js +++ b/app/screens/options_modal/options_modal_list.test.js @@ -3,8 +3,7 @@ import {shallow} from 'enzyme'; import React from 'react'; -import OptionModalListAndroid from './options_modal_list.android'; -import OptionModalListIOS from './options_modal_list.ios'; +import OptionModalList from './options_modal_list'; describe('OptionModalList', () => { const baseProps = { @@ -27,16 +26,9 @@ describe('OptionModalList', () => { title: 'test', }; - test('should match snapshot for iOS', async () => { + test('should match snapshot', async () => { const wrapper = shallow( - , - ); - expect(wrapper.getElement()).toMatchSnapshot(); - }); - - test('should match snapshot for Android', async () => { - const wrapper = shallow( - , + , ); expect(wrapper.getElement()).toMatchSnapshot(); }); diff --git a/app/screens/settings/sidebar/sidebar.test.js b/app/screens/settings/sidebar/sidebar.test.js index 83fd81624..6cab8919e 100644 --- a/app/screens/settings/sidebar/sidebar.test.js +++ b/app/screens/settings/sidebar/sidebar.test.js @@ -54,6 +54,7 @@ describe('SidebarSettings', () => { setChannelDisplayName: jest.fn(), setChannelLoading: jest.fn(), joinChannel: jest.fn(), + setCategoryCollapsed: jest.fn(), }, blurPostTextBox: jest.fn(), currentTeamId: 'current-team-id', diff --git a/app/utils/bottom_sheet/bottom_sheet.android.js b/app/utils/bottom_sheet/bottom_sheet.android.js deleted file mode 100644 index e77016f31..000000000 --- a/app/utils/bottom_sheet/bottom_sheet.android.js +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {showModalOverCurrentContext} from '@actions/navigation'; - -export default { - showBottomSheetWithOptions: (options, callback) => { - function itemAction(index) { - callback(index); - } - - const items = options.options.splice(0, options.cancelButtonIndex).map((o, index) => ({ - action: () => itemAction(index), - text: o, - })); - - showModalOverCurrentContext('OptionsModal', {title: '', items}); - }, -}; diff --git a/app/utils/bottom_sheet/bottom_sheet.ios.js b/app/utils/bottom_sheet/bottom_sheet.ios.js deleted file mode 100644 index e4d946266..000000000 --- a/app/utils/bottom_sheet/bottom_sheet.ios.js +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {ActionSheetIOS} from 'react-native'; - -export default { - showBottomSheetWithOptions: (options, callback) => { - return ActionSheetIOS.showActionSheetWithOptions(options, callback); - }, -}; diff --git a/app/utils/bottom_sheet/bottom_sheet.ts b/app/utils/bottom_sheet/bottom_sheet.ts new file mode 100644 index 000000000..f5ce4d136 --- /dev/null +++ b/app/utils/bottom_sheet/bottom_sheet.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {showModalOverCurrentContext} from '@actions/navigation'; + +export default { + showBottomSheetWithOptions: (options: any, callback: any) => { + function itemAction(index: any) { + callback(index); + } + + const items = options.options.splice(0, options.cancelButtonIndex).map((o: string | {icon: string; text: string}, index: any) => ({ + action: () => itemAction(index), + text: typeof o === 'string' ? o : o.text, + icon: typeof o === 'string' ? null : o.icon, + })); + + showModalOverCurrentContext('OptionsModal', {title: options.title || '', items, subtitle: options.subtitle}); + }, +}; diff --git a/app/utils/bottom_sheet/index.js b/app/utils/bottom_sheet/index.ts similarity index 100% rename from app/utils/bottom_sheet/index.js rename to app/utils/bottom_sheet/index.ts diff --git a/app/utils/categories.test.ts b/app/utils/categories.test.ts new file mode 100644 index 000000000..ecbfb1f52 --- /dev/null +++ b/app/utils/categories.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {GlobalState} from '@mm-redux/types/store'; + +import {shouldShowLegacySidebar} from './categories'; + +describe('Show Legacy Sidebar', () => { + const state = { + entities: { + general: { + config: { + Version: '5.31.0', + ExperimentalChannelSidebarOrganization: '', + EnableLegacySidebar: '', + }, + }, + preferences: { + myPreferences: { + sidebar_settings: { + channel_sidebar_organization: 'true', + }, + }, + }, + }, + }; + + it('should show on servers < v5.32.0', () => { + expect(shouldShowLegacySidebar(state as unknown as GlobalState)).toBe(true); + + state.entities.general.config.Version = '5.30.0'; + expect(shouldShowLegacySidebar(state as unknown as GlobalState)).toBe(true); + + state.entities.general.config.Version = '5.31.100'; + expect(shouldShowLegacySidebar(state as unknown as GlobalState)).toBe(true); + }); + + it('should not show on servers >= v5.32.0', () => { + state.entities.general.config.Version = '5.32.0'; + expect(shouldShowLegacySidebar(state as unknown as GlobalState)).toBe(false); + + state.entities.general.config.Version = '5.35.5'; + expect(shouldShowLegacySidebar(state as unknown as GlobalState)).toBe(false); + }); + + it('should not show on older servers if ExperimentalChannelSidebarOrganization is true', () => { + state.entities.general.config.ExperimentalChannelSidebarOrganization = 'true'; + expect(shouldShowLegacySidebar(state as unknown as GlobalState)).toBe(false); + }); + + it('should show on newer servers if EnableLegacySidebar is true', () => { + state.entities.general.config.EnableLegacySidebar = 'true'; + state.entities.general.config.Version = '5.32.0'; + expect(shouldShowLegacySidebar(state as unknown as GlobalState)).toBe(true); + }); +}); diff --git a/app/utils/categories.ts b/app/utils/categories.ts new file mode 100644 index 000000000..01b87dfd1 --- /dev/null +++ b/app/utils/categories.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {gte, lt} from 'semver'; + +import {Client4} from '@client/rest'; +import {getConfig} from '@mm-redux/selectors/entities/general'; +import {getNewSidebarPreference} from '@mm-redux/selectors/entities/preferences'; +import {GlobalState} from '@mm-redux/types/store'; + +export const shouldShowLegacySidebar = (state: GlobalState) => { + const config = getConfig(state); + const serverVersion = config.Version || Client4.getServerVersion(); + + // No server version? Default to legacy. + if (!serverVersion) { + return true; + } + + // Older servers default to Legacy unless experimental flag is set + if (lt(serverVersion, '5.32.0')) { + const experimentalSidebarPref = getNewSidebarPreference(state); + + if (experimentalSidebarPref) { + return false; + } + return true; + } + + // Newer servers only show legacy if legacy flag is set + if (gte(serverVersion, '5.32.0') && config.EnableLegacySidebar === 'true') { + return true; + } + + // Default to showing categories + return false; +}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 556036ae0..d95b8cd04 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -75,6 +75,7 @@ "channel_loader.someone": "Someone", "channel_members_modal.remove": "Remove", "channel_modal.cancel": "Cancel", + "channel_modal.channelType": "Type", "channel_modal.descriptionHelp": "Describe how this channel should be used.", "channel_modal.header": "Header", "channel_modal.headerEx": "E.g.: \"[Link Title](http://example.com)\"", @@ -84,6 +85,8 @@ "channel_modal.optional": "(optional)", "channel_modal.purpose": "Purpose", "channel_modal.purposeEx": "E.g.: \"A channel to file bugs and improvements\"", + "channel_modal.type.private": "Private Channel", + "channel_modal.type.public": "Public Channel", "channel_notifications.ignoreChannelMentions.settings": "Ignore @channel, @here, @all", "channel_notifications.muteChannel.settings": "Mute channel", "channel_notifications.preference.all_activity": "For all activity", @@ -688,6 +691,7 @@ "sidebar.channels": "PUBLIC CHANNELS", "sidebar.direct": "DIRECT MESSAGES", "sidebar.favorite": "FAVORITE CHANNELS", + "sidebar.favorites": "Favorites", "sidebar.pg": "PRIVATE CHANNELS", "sidebar.types.recent": "RECENT ACTIVITY", "sidebar.unreads": "More unreads", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ee3d01650..a2f115bc6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -698,7 +698,7 @@ SPEC CHECKSUMS: BVLinearGradient: e3aad03778a456d77928f594a649e96995f1c872 DoubleConversion: cf9b38bf0b2d048436d9a82ad2abe1404f11e7de FBLazyVector: e686045572151edef46010a6f819ade377dfeb4b - FBReactNativeSpec: cef0cc6d50abc92e8cf52f140aa22b5371cfec0b + FBReactNativeSpec: 72f4a51ca898aabb28a931cc0d6f458a9e22a59a glog: 73c2498ac6884b13ede40eda8228cb1eee9d9d62 jail-monkey: 07b83767601a373db876e939b8dbf3f5eb15f073 libwebp: e90b9c01d99205d03b6bb8f2c8c415e5a4ef66f0 diff --git a/package-lock.json b/package-lock.json index c474d4b5c..7104c545e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -121,6 +121,7 @@ "@types/react-test-renderer": "17.0.1", "@types/shallow-equals": "1.0.0", "@types/tinycolor2": "1.4.3", + "@types/underscore": "1.11.3", "@types/url-parse": "1.4.3", "@typescript-eslint/eslint-plugin": "4.28.5", "@typescript-eslint/parser": "4.28.5", @@ -8872,6 +8873,12 @@ "node": ">=0.10.0" } }, + "node_modules/@types/underscore": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.11.3.tgz", + "integrity": "sha512-Fl1TX1dapfXyDqFg2ic9M+vlXRktcPJrc4PR7sRc7sdVrjavg/JHlbUXBt8qWWqhJrmSqg3RNAkAPRiOYw6Ahw==", + "dev": true + }, "node_modules/@types/unist": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", @@ -46163,6 +46170,12 @@ } } }, + "@types/underscore": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.11.3.tgz", + "integrity": "sha512-Fl1TX1dapfXyDqFg2ic9M+vlXRktcPJrc4PR7sRc7sdVrjavg/JHlbUXBt8qWWqhJrmSqg3RNAkAPRiOYw6Ahw==", + "dev": true + }, "@types/unist": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", diff --git a/package.json b/package.json index 518c4c16b..8e1a322fa 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "@types/react-test-renderer": "17.0.1", "@types/shallow-equals": "1.0.0", "@types/tinycolor2": "1.4.3", + "@types/underscore": "1.11.3", "@types/url-parse": "1.4.3", "@typescript-eslint/eslint-plugin": "4.28.5", "@typescript-eslint/parser": "4.28.5",