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
This commit is contained in:
Shaz Amjad 2021-09-21 04:11:57 +10:00 committed by GitHub
parent c784086595
commit 64223efafe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 3032 additions and 1242 deletions

View file

@ -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) {

View file

@ -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,

View file

@ -166,6 +166,12 @@ describe('Actions.Views.Channel', () => {
[currentTeamId]: {},
},
},
general: {
config: {
EnableLegacySidebar: 'true',
},
serverVersion: '5.12.0',
},
},
};

View file

@ -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));
}
}
};
}

View file

@ -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<ActionResult> => {
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<ActionResult> => {
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<ActionResult> => {
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);
}

View file

@ -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<ActionResult> => {
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<ActionResult> => {
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<ActionResult> => {
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};
};
}

View file

@ -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};

View file

@ -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<Channel[]>;
searchChannels: (teamId: string, term: string) => Promise<Channel[]>;
searchArchivedChannels: (teamId: string, term: string) => Promise<Channel[]>;
// Categories
getChannelCategories: (userId: string, teamId: string) => Promise<OrderedChannelCategories>;
getChannelCategory: () => Promise<ChannelCategory>;
updateChannelCategory: (userId: string, teamId: string, category: ChannelCategory) => Promise<OrderedChannelCategories>;
updateChannelCategories: (userId: string, teamId: string, categories: ChannelCategory[]) => Promise<OrderedChannelCategories>;
}
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;

View file

@ -45,7 +45,14 @@ exports[`EditChannelInfo should match snapshot 1`] = `
}
>
<View>
<View>
<View
style={
Object {
"flexDirection": "row",
"marginTop": 30,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="Name"
id="channel_modal.name"
@ -93,8 +100,6 @@ exports[`EditChannelInfo should match snapshot 1`] = `
value="display_name"
/>
</View>
</View>
<View>
<View
style={
Object {

View file

@ -1,10 +1,12 @@
// 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 {
Platform,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native';
@ -13,6 +15,7 @@ import {SafeAreaView} from 'react-native-safe-area-context';
import {popTopScreen, dismissModal} from '@actions/navigation';
import Autocomplete from '@components/autocomplete';
import CompassIcon from '@components/compass_icon';
import ErrorText from '@components/error_text';
import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
@ -39,9 +42,11 @@ export default class EditChannelInfo extends PureComponent {
channelURL: PropTypes.string,
purpose: PropTypes.string,
header: PropTypes.string,
type: PropTypes.string,
onDisplayNameChange: PropTypes.func,
onPurposeChange: PropTypes.func,
onHeaderChange: PropTypes.func,
onTypeChange: PropTypes.func,
oldDisplayName: PropTypes.string,
oldChannelURL: PropTypes.string,
oldHeader: PropTypes.string,
@ -151,6 +156,11 @@ export default class EditChannelInfo extends PureComponent {
}
};
onTypeSelect = (type) => {
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}
<TouchableWithoutFeedback onPress={this.blur}>
<View style={style.scrollView}>
{!displayHeaderOnly && (
{showSelector && (
<View>
<View>
<FormattedText
style={style.title}
id='channel_modal.channelType'
defaultMessage='Type'
/>
</View>
<View style={style.inputContainer}>
<TouchableOpacity
style={style.touchable}
onPress={() => {
this.onTypeSelect(General.OPEN_CHANNEL);
}}
>
<FormattedText
style={style.touchableText}
id='channel_modal.type.public'
defaultMessage='Public Channel'
/>
{this.props.type === General.OPEN_CHANNEL &&
<CompassIcon
style={style.touchableIcon}
color='#166de0'
name='check'
size={24}
/>
}
</TouchableOpacity>
<View
style={{borderBottomColor: '#ebebec',
borderBottomWidth: 1,
marginHorizontal: 15,
height: 0}}
/>
<TouchableOpacity
style={style.touchable}
onPress={() => {
this.onTypeSelect(General.PRIVATE_CHANNEL);
}}
>
<FormattedText
style={style.touchableText}
id='channel_modal.type.private'
defaultMessage='Private Channel'
/>
{this.props.type === General.PRIVATE_CHANNEL &&
<CompassIcon
style={style.touchableIcon}
color='#166de0'
name='check'
size={24}
/>
}
</TouchableOpacity>
</View>
</View>
)}
{!displayHeaderOnly && (
<View>
<View style={style.titleContainer30}>
<FormattedText
style={style.title}
id='channel_modal.name'
@ -279,10 +349,7 @@ export default class EditChannelInfo extends PureComponent {
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
/>
</View>
</View>
)}
{!displayHeaderOnly && (
<View>
<View style={style.titleContainer30}>
<FormattedText
style={style.title}
@ -449,5 +516,26 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
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',
},
};
});

View file

@ -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 ?
(
<CustomStatusEmoji
userID={this.props.teammateId}
style={[style.emoji, extraTextStyle]}
testID={displayName}
/>
) : null;
const customStatus = this.props.teammateId && this.props.customStatusEnabled ? (
<CustomStatusEmoji
userID={this.props.teammateId}
style={[style.emoji, extraTextStyle]}
testID={displayName}
/>
) : null;
return (
<TouchableHighlight

View file

@ -32,6 +32,7 @@ export default class ChannelsList extends PureComponent {
onSearchEnds: PropTypes.func.isRequired,
onSearchStart: PropTypes.func.isRequired,
onSelectChannel: PropTypes.func.isRequired,
onCollapseCategory: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
onShowTeams: PropTypes.func,
};
@ -116,6 +117,7 @@ export default class ChannelsList extends PureComponent {
<List
testID={listTestID}
onSelectChannel={this.onSelectChannel}
onCollapseCategory={this.props.onCollapseCategory}
styles={styles}
/>
);
@ -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',
},
};
});

View file

@ -121,3 +121,81 @@ exports[`ChannelsList List should match snapshot with collapsed threads enabled
/>
</View>
`;
exports[`ChannelsList List should match snapshot with unreads not on top 1`] = `
<View
onLayout={[Function]}
>
<SectionList
contentContainerStyle={
Object {
"paddingBottom": 44,
}
}
data={Array []}
disableVirtualization={false}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="always"
maxToRenderPerBatch={10}
onEndReachedThreshold={2}
onScrollBeginDrag={[Function]}
onViewableItemsChanged={[Function]}
removeClippedSubviews={false}
renderItem={[Function]}
renderSectionHeader={[Function]}
scrollEventThrottle={50}
sections={Array []}
stickySectionHeadersEnabled={true}
testID="main.sidebar.channels_list.list"
updateCellsBatchingPeriod={50}
viewabilityConfig={
Object {
"itemVisiblePercentThreshold": 100,
"waitForInteraction": true,
}
}
windowSize={21}
/>
<UnreadIndicatorIOS
onPress={[Function]}
style={
Array [
undefined,
]
}
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
visible={false}
/>
</View>
`;

View file

@ -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,
};
}

View file

@ -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 (
<ChannelItem
testID={channelItemTestID}
channelId={item}
isUnread={unreadChannelIds.includes(item)}
isFavorite={favoriteChannelIds.includes(item)}
onSelectChannel={this.onSelectChannel}
/>
);
};
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 = (
<View style={styles.titleContainer}>
{(type !== CategoryTypes.UNREADS && data.length > 0) &&
<CompassIcon
name={collapsed ? 'chevron-right' : 'chevron-down'}
ref={anchor ? this.combinedActionsRef : null}
style={styles.chevron}
/>
}
<Text style={styles.title}>
{title()}
</Text>
<View style={styles.separatorContainer}>
<Text> </Text>
</View>
{action && this.renderSectionAction(styles, action, anchor, id)}
</View>
);
if (type === CategoryTypes.UNREADS || data.length === 0) {
return header;
}
return (
<TouchableHighlight onPress={() => onCollapseCategory(id, !collapsed)}>
{header}
</TouchableHighlight>
);
}
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 {
)}
<SectionList
ref={this.setListRef}
sections={sections}
sections={showLegacySidebar ? sections : categorySections}
contentContainerStyle={{paddingBottom}}
removeClippedSubviews={Platform.OS === 'android'}
renderItem={this.renderItem}
renderSectionHeader={this.renderSectionHeader}
renderItem={showLegacySidebar ? this.renderItem : this.renderCategoryItem}
renderSectionHeader={showLegacySidebar ? this.renderSectionHeader : this.renderCategoryHeader}
keyboardShouldPersistTaps={'always'}
keyExtractor={this.keyExtractor}
onViewableItemsChanged={this.updateUnreadIndicators}

View file

@ -15,6 +15,7 @@ describe('ChannelsList List', () => {
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(
<List
{...baseProps}
unreadsOnTop={false}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot with collapsed threads enabled', () => {
const wrapper = shallow(
<List

View file

@ -7,6 +7,7 @@ import {bindActionCreators} from 'redux';
import {setChannelDisplayName, handleSelectChannel} from '@actions/views/channel';
import {makeDirectChannel} from '@actions/views/more_dms';
import {handleNotViewingGlobalThreadsScreen} from '@actions/views/threads';
import {setCategoryCollapsed} from '@mm-redux/actions/channel_categories';
import {joinChannel} from '@mm-redux/actions/channels';
import {getTeams} from '@mm-redux/actions/teams';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
@ -37,6 +38,7 @@ function mapDispatchToProps(dispatch) {
makeDirectChannel,
setChannelDisplayName,
handleSelectChannel,
setCategoryCollapsed,
handleNotViewingGlobalThreadsScreen,
}, dispatch),
};

View file

@ -17,6 +17,7 @@ describe('MainSidebar', () => {
setChannelDisplayName: jest.fn(),
setChannelLoading: jest.fn(),
joinChannel: jest.fn(),
setCategoryCollapsed: jest.fn(),
},
blurPostTextBox: jest.fn(),
currentTeamId: 'current-team-id',

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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<ChannelCategory>): 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<ChannelCategory>);
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<Channel, UserProfile> = getUserIdsInChannels(state);
const allUsersInGMChannel = Array.from(allUsersInChannels[channel.id] || []);
const usersInGMChannel: Array<string> = 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<string, string> = {};
// 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<Channel>> = []): 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};
};
}

View file

@ -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);

View file

@ -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<string>): 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],
});
};
}

View file

@ -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);

View file

@ -9,4 +9,5 @@ export const CategoryTypes: {[name: string]: ChannelCategoryType} = {
PRIVATE: 'private',
DIRECT_MESSAGES: 'direct_messages',
CUSTOM: 'custom',
CHANNELS: 'channels',
};

View file

@ -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',
};

View file

@ -33,6 +33,7 @@ const Preferences: Dictionary<any> = {
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,

View file

@ -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'],

View file

@ -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<ChannelCategory> = {}, 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<ChannelCategory>);
}
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<ChannelCategory> = {}, action: Gener
changed = true;
}
if (!changed) {
return state;
}
return nextState;
return changed ? nextState : state;
}
default:
@ -83,48 +96,31 @@ export function byId(state: IDMappedObjects<ChannelCategory> = {}, action: Gener
}
}
export function orderByTeam(state: RelationOneToOne<Team, $ID<ChannelCategory>[]> = {}, action: GenericAction) {
export function orderByTeam(state: RelationOneToOne<Team, Array<$ID<ChannelCategory>>> = {}, 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<ChannelCategory> = 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<Team, $ID<ChannelCategory>[]
}
}
function makeDefaultCategoryIds(teamId: string): $ID<ChannelCategory>[] {
return Object.keys(makeDefaultCategories(teamId));
}
function makeDefaultCategories(teamId: string): IDMappedObjects<ChannelCategory> {
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,

File diff suppressed because it is too large Load diff

View file

@ -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<Channel>, myMembers: RelationOneToOne<Channel, ChannelMembership>, 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<Channel, ChannelMembership>) {
return (a: Channel, b: Channel) => {
// Sort muted channels last
const aMuted = isChannelMuted(myMembers[a.id]);
const bMuted = isChannelMuted(myMembers[b.id]);
if (aMuted && !bMuted) {
return 1;
} else if (!aMuted && bMuted) {
return -1;
}
// And then sort alphabetically
return getDisplayName(a).localeCompare(getDisplayName(b), locale, {numeric: true});
};
}
export function makeSortChannelsByName(): (state: GlobalState, channels: Channel[]) => Channel[] {
return createSelector(
(state: GlobalState, channels: Channel[]) => channels,
getCurrentUserLocale,
(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<Channel, ChannelMembership>) => {
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<UserProfile>, teammateNameDisplay: string, locale: string) => {
(state: GlobalState) => getCurrentUserLocale(state),
getMyChannelMemberships,
(channels: Channel[], currentUserId: string, profiles: IDMappedObjects<UserProfile>, teammateNameDisplay: string, locale: string, myMembers: RelationOneToOne<Channel, ChannelMembership>) => {
const cachedNames: RelationOneToOne<Channel, string> = {};
const getDisplayName = (channel: Channel): string => {
@ -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<ChannelCategory, ReturnType<typeof makeGetChannelsForIds>>;
let filterAndSortChannels: RelationOneToOne<ChannelCategory, ReturnType<typeof makeFilterAndSortChannelsForCategory>>;
let lastCategoryIds: ReturnType<typeof getCategoryIdsForTeam> = [];
@ -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<ChannelCategory, Channel[]> = {};
for (const category of categories) {
const channels = getChannels[category.id](state, category.channel_ids);
channelsByCategory[category.id] = filterAndSortChannels[category.id](state, channels, category);
}

View file

@ -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,

View file

@ -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<Channel>, currentUser: UserProfile, myMembers: RelationOneToOne<Channel, ChannelMembership>, lastPosts: RelationOneToOne<Channel, Post>, sorting: SortingType, sortMentionsFirst = false): Array<string> => {
export const mapAndSortChannelIds = (
channels: Channel[],
currentUser: UserProfile,
myMembers: RelationOneToOne<Channel, ChannelMembership>,
lastPosts: RelationOneToOne<Channel, Post>,
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<Ch
});
return allUnreadChannels;
});
export const getMapAndSortedUnreadChannelIds: (c: GlobalState, b: Channel, a: SortingType) => Array<string> = createIdsSelector(getUnreadChannels, getCurrentUser, getMyChannelMemberships, getLastPostPerChannel, (state: GlobalState, lastUnreadChannel: Channel, sorting: SortingType = 'alpha') => sorting, (channels, currentUser, myMembers, lastPosts: RelationOneToOne<Channel, Post>, 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<string> = 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<string> = 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]);
},
);
}

View file

@ -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',
};
}

View file

@ -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<UserProfile>;
team_id: $ID<Team>;
type: ChannelCategoryType;
display_name: string;
sorting: CategorySorting;
channel_ids: Array<$ID<Channel>>;
muted: boolean;
collapsed: boolean;
};
// This will be added in phase 2 of Channel Sidebar Organization once the server provides the categories
// channel_ids: $ID<Channel>;
export type OrderedChannelCategories = {
categories: ChannelCategory[];
order: string[];
};
export type ChannelCategoriesState = {
byId: IDMappedObjects<ChannelCategory>;
orderByTeam: RelationOneToOne<Team, $ID<ChannelCategory>[]>;
};
orderByTeam: RelationOneToOne<Team, Array<$ID<ChannelCategory>>>;
}

View file

@ -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;

View file

@ -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);
});
});

View file

@ -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<T>(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<T>(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<T>(array: T[], item: T) {
const index = array.indexOf(item);
if (index === -1) {
return array;
}
const result = [...array];
result.splice(index, 1);
return result;
}

View file

@ -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';

View file

@ -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;

View file

@ -23,7 +23,10 @@ describe('Reducers.channel', () => {
const nextState = channelReducer(
initialState,
{},
{
type: '',
data: {},
},
);
expect(nextState).toEqual(initialState);

View file

@ -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}
/>
);
}

View file

@ -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(

View file

@ -1,144 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`OptionModalList should match snapshot for Android 1`] = `
<View
style={
Object {
"alignItems": "center",
"flex": 1,
"justifyContent": "center",
}
}
>
<View
style={
Object {
"alignSelf": "stretch",
"backgroundColor": "white",
"borderRadius": 2,
"marginHorizontal": 30,
}
}
>
<View
style={
Object {
"borderBottomColor": "rgba(0, 0, 0, 0.1)",
"borderBottomWidth": 1,
}
}
>
<ForwardRef
onPress={[Function]}
style={
Object {
"alignItems": "center",
"alignSelf": "stretch",
"flexDirection": "row",
"justifyContent": "space-between",
"padding": 15,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="Take Photo or Video"
id="mobile.file_upload.camera"
style={
Array [
Object {
"color": "#000",
"flex": 1,
"fontSize": 16,
},
undefined,
]
}
/>
<CompassIcon
name="camera"
size={18}
style={
Object {
"color": "#7f8180",
}
}
/>
</ForwardRef>
</View>
<View
style={
Object {
"borderBottomColor": "rgba(0, 0, 0, 0.1)",
"borderBottomWidth": 1,
}
}
>
<ForwardRef
onPress={[Function]}
style={
Object {
"alignItems": "center",
"alignSelf": "stretch",
"flexDirection": "row",
"justifyContent": "space-between",
"padding": 15,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="Photo Library"
id="mobile.file_upload.library"
style={
Array [
Object {
"color": "#000",
"flex": 1,
"fontSize": 16,
},
undefined,
]
}
/>
<CompassIcon
name="photo"
size={18}
style={
Object {
"color": "#7f8180",
}
}
/>
</ForwardRef>
</View>
<ForwardRef
onPress={[Function]}
style={
Object {
"alignItems": "center",
"alignSelf": "stretch",
"flexDirection": "row",
"justifyContent": "space-between",
"padding": 15,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="Cancel"
id="channel_modal.cancel"
style={
Object {
"color": "#000",
"flex": 1,
"fontSize": 16,
}
}
/>
</ForwardRef>
</View>
</View>
`;
exports[`OptionModalList should match snapshot for iOS 1`] = `
exports[`OptionModalList should match snapshot 1`] = `
<View
style={
Object {
@ -162,9 +24,10 @@ exports[`OptionModalList should match snapshot for iOS 1`] = `
Object {
"alignSelf": "stretch",
"backgroundColor": "white",
"borderRadius": 12,
"marginBottom": 20,
"marginHorizontal": 20,
"borderTopLeftRadius": 12,
"borderTopRightRadius": 12,
"paddingBottom": 25,
"paddingVertical": 10,
},
]
}
@ -177,12 +40,12 @@ exports[`OptionModalList should match snapshot for iOS 1`] = `
"alignSelf": "stretch",
"flexDirection": "row",
"justifyContent": "space-between",
"padding": 15,
"paddingHorizontal": 20,
"paddingVertical": 10,
"width": "100%",
},
Object {
"borderBottomColor": "rgba(0, 0, 0, 0.1)",
"borderBottomWidth": 1,
"paddingBottom": 10,
},
]
}
@ -190,25 +53,19 @@ exports[`OptionModalList should match snapshot for iOS 1`] = `
<Text
style={
Object {
"color": "#7f8180",
"flex": 1,
"textAlign": "center",
"color": "#3D3C40",
"fontSize": 24,
"fontWeight": "600",
"lineHeight": 32,
"textAlign": "left",
"width": "100%",
}
}
>
test
</Text>
</View>
<View
style={
Array [
Object {
"borderBottomColor": "rgba(0, 0, 0, 0.1)",
"borderBottomWidth": 1,
},
]
}
>
<View>
<ForwardRef
onPress={[Function]}
style={
@ -217,44 +74,42 @@ exports[`OptionModalList should match snapshot for iOS 1`] = `
"alignSelf": "stretch",
"flexDirection": "row",
"justifyContent": "space-between",
"padding": 15,
"paddingHorizontal": 20,
"paddingVertical": 10,
"width": "100%",
}
}
>
<CompassIcon
name="camera"
size={24}
style={
Object {
"color": "rgba(61, 60, 64, 0.64)",
"paddingRight": 10,
}
}
/>
<InjectIntl(FormattedText)
defaultMessage="Take Photo or Video"
id="mobile.file_upload.camera"
style={
Array [
Object {
"color": "#4E8ACC",
"color": "#3D3C40",
"flex": 1,
"fontSize": 20,
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
undefined,
false,
]
}
/>
<CompassIcon
name="camera"
size={24}
style={
Object {
"color": "#4E8ACC",
}
}
/>
</ForwardRef>
</View>
<View
style={
Array [
false,
]
}
>
<View>
<ForwardRef
onPress={[Function]}
style={
@ -263,76 +118,42 @@ exports[`OptionModalList should match snapshot for iOS 1`] = `
"alignSelf": "stretch",
"flexDirection": "row",
"justifyContent": "space-between",
"padding": 15,
"paddingHorizontal": 20,
"paddingVertical": 10,
"width": "100%",
}
}
>
<CompassIcon
name="photo"
size={24}
style={
Object {
"color": "rgba(61, 60, 64, 0.64)",
"paddingRight": 10,
}
}
/>
<InjectIntl(FormattedText)
defaultMessage="Photo Library"
id="mobile.file_upload.library"
style={
Array [
Object {
"color": "#4E8ACC",
"color": "#3D3C40",
"flex": 1,
"fontSize": 20,
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
undefined,
false,
]
}
/>
<CompassIcon
name="photo"
size={24}
style={
Object {
"color": "#4E8ACC",
}
}
/>
</ForwardRef>
</View>
</View>
<View
style={
Object {
"alignSelf": "stretch",
"backgroundColor": "white",
"borderRadius": 12,
"marginBottom": 20,
"marginHorizontal": 20,
}
}
>
<ForwardRef
onPress={[Function]}
style={
Object {
"alignItems": "center",
"alignSelf": "stretch",
"flexDirection": "row",
"justifyContent": "space-between",
"padding": 15,
"width": "100%",
}
}
>
<InjectIntl(FormattedText)
defaultMessage="Cancel"
id="channel_modal.cancel"
style={
Object {
"color": "#CC3239",
"flex": 1,
"fontSize": 20,
"textAlign": "center",
}
}
/>
</ForwardRef>
</View>
</View>
</View>
`;

View file

@ -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}
/>
</AnimatedView>
</View>

View file

@ -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 = (
<FormattedText
style={[style.optionText, item.textStyle]}
{...item.text}
/>
);
} else {
textComponent = <Text style={[style.optionText, item.textStyle]}>{item.text}</Text>;
}
return (
<View
key={index}
style={style.optionBorder}
>
<TouchableOpacity
onPress={() => this.handleItemPress(item.action)}
style={style.option}
>
{textComponent}
{item.icon &&
<CompassIcon
name={item.icon}
size={18}
style={optionIconStyle}
/>
}
</TouchableOpacity>
</View>
);
});
const cancel = (
<TouchableOpacity
key={items.length}
onPress={this.handleCancelPress}
style={style.option}
>
<FormattedText
id='channel_modal.cancel'
defaultMessage='Cancel'
style={style.optionText}
/>
</TouchableOpacity>
);
return [
...options,
cancel,
];
};
render() {
return (
<View style={style.wrapper}>
<View style={style.optionContainer}>
{this.renderOptions()}
</View>
</View>
);
}
}
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',
},
});

View file

@ -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 = (
<FormattedText
style={[style.optionText, item.textStyle, (!item.icon && {textAlign: 'center'})]}
style={[style.optionText, item.textStyle, (!item.icon && {textAlign: 'left'})]}
{...item.text}
/>
);
} else {
textComponent = <Text style={[style.optionText, item.textStyle, (!item.icon && {textAlign: 'center'})]}>{item.text}</Text>;
textComponent = <Text style={[style.optionText, item.textStyle, (!item.icon && {textAlign: 'left'})]}>{item.text}</Text>;
}
return (
<View
key={index}
style={[(index < items.length - 1 && style.optionBorder)]}
>
<View key={index}>
<TouchableOpacity
onPress={() => this.handleItemPress(item.action)}
style={style.option}
>
{textComponent}
{item.icon &&
<CompassIcon
name={item.icon}
@ -82,6 +80,8 @@ export default class OptionsModalList extends PureComponent {
style={optionIconStyle}
/>
}
{textComponent}
</TouchableOpacity>
</View>
);
@ -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 = <Text style={style.optionTitleText}>{this.props.title}</Text>;
}
if (this.props.subtitle) {
subtitleComponent = (
<Text
key='subtitle'
style={style.optionSubTitleText}
>{this.props.subtitle}</Text>
);
}
title = (
<View
key={items.length}
style={[style.option, style.optionBorder]}
style={[style.option, {paddingBottom: this.props.subtitle ? 0 : 10}]}
>
{titleComponent}
</View>
);
}
return [
title,
subtitleComponent,
...options,
];
};
@ -124,18 +136,6 @@ export default class OptionsModalList extends PureComponent {
<View style={[style.optionContainer]}>
{this.renderOptions()}
</View>
<View style={style.optionContainer}>
<TouchableOpacity
onPress={this.handleCancelPress}
style={style.option}
>
<FormattedText
id='channel_modal.cancel'
defaultMessage='Cancel'
style={style.optionCancelText}
/>
</TouchableOpacity>
</View>
</View>
</View>
);
@ -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,
},
});

View file

@ -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(
<OptionModalListIOS {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for Android', async () => {
const wrapper = shallow(
<OptionModalListAndroid {...baseProps}/>,
<OptionModalList {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});

View file

@ -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',

View file

@ -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});
},
};

View file

@ -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);
},
};

View file

@ -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});
},
};

View file

@ -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);
});
});

37
app/utils/categories.ts Normal file
View file

@ -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;
};

View file

@ -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",

View file

@ -698,7 +698,7 @@ SPEC CHECKSUMS:
BVLinearGradient: e3aad03778a456d77928f594a649e96995f1c872
DoubleConversion: cf9b38bf0b2d048436d9a82ad2abe1404f11e7de
FBLazyVector: e686045572151edef46010a6f819ade377dfeb4b
FBReactNativeSpec: cef0cc6d50abc92e8cf52f140aa22b5371cfec0b
FBReactNativeSpec: 72f4a51ca898aabb28a931cc0d6f458a9e22a59a
glog: 73c2498ac6884b13ede40eda8228cb1eee9d9d62
jail-monkey: 07b83767601a373db876e939b8dbf3f5eb15f073
libwebp: e90b9c01d99205d03b6bb8f2c8c415e5a4ef66f0

13
package-lock.json generated
View file

@ -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",

View file

@ -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",