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:
parent
c784086595
commit
64223efafe
61 changed files with 3032 additions and 1242 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -166,6 +166,12 @@ describe('Actions.Views.Channel', () => {
|
|||
[currentTeamId]: {},
|
||||
},
|
||||
},
|
||||
general: {
|
||||
config: {
|
||||
EnableLegacySidebar: 'true',
|
||||
},
|
||||
serverVersion: '5.12.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
67
app/actions/websocket/categories.ts
Normal file
67
app/actions/websocket/categories.ts
Normal 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);
|
||||
}
|
||||
|
||||
|
|
@ -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};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ export const CategoryTypes: {[name: string]: ChannelCategoryType} = {
|
|||
PRIVATE: 'private',
|
||||
DIRECT_MESSAGES: 'direct_messages',
|
||||
CUSTOM: 'custom',
|
||||
CHANNELS: 'channels',
|
||||
};
|
||||
|
|
|
|||
14
app/mm-redux/constants/channels.ts
Normal file
14
app/mm-redux/constants/channels.ts
Normal 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',
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
80
app/mm-redux/utils/array_utils.test.ts
Normal file
80
app/mm-redux/utils/array_utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
app/mm-redux/utils/array_utils.ts
Normal file
51
app/mm-redux/utils/array_utils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ describe('Reducers.channel', () => {
|
|||
|
||||
const nextState = channelReducer(
|
||||
initialState,
|
||||
{},
|
||||
{
|
||||
type: '',
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(nextState).toEqual(initialState);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
22
app/utils/bottom_sheet/bottom_sheet.ts
Normal file
22
app/utils/bottom_sheet/bottom_sheet.ts
Normal 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});
|
||||
},
|
||||
};
|
||||
56
app/utils/categories.test.ts
Normal file
56
app/utils/categories.test.ts
Normal 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
37
app/utils/categories.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
13
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue