diff --git a/app/actions/views/more_dms.js b/app/actions/views/more_dms.js index 4934e5eeb..22c8ee8e2 100644 --- a/app/actions/views/more_dms.js +++ b/app/actions/views/more_dms.js @@ -2,9 +2,9 @@ // See License.txt for license information. import {getDirectChannelName} from 'mattermost-redux/utils/channel_utils'; -import {createDirectChannel} from 'mattermost-redux/actions/channels'; +import {createDirectChannel, createGroupChannel} from 'mattermost-redux/actions/channels'; import {getProfilesByIds, getStatusesByIds} from 'mattermost-redux/actions/users'; -import {handleSelectChannel, toggleDMChannel} from 'app/actions/views/channel'; +import {handleSelectChannel, toggleDMChannel, toggleGMChannel} from 'app/actions/views/channel'; export function makeDirectChannel(otherUserId) { return async (dispatch, getState) => { @@ -12,21 +12,45 @@ export function makeDirectChannel(otherUserId) { const {currentUserId} = state.entities.users; const channelName = getDirectChannelName(currentUserId, otherUserId); const {channels, myMembers} = state.entities.channels; - const channel = Object.values(channels).find((c) => c.name === channelName); getProfilesByIds([otherUserId])(dispatch, getState); getStatusesByIds([otherUserId])(dispatch, getState); + let result; + let channel = Object.values(channels).find((c) => c.name === channelName); if (channel && myMembers[channel.id]) { + result = {data: channel}; + toggleDMChannel(otherUserId, 'true')(dispatch, getState); - handleSelectChannel(channel.id)(dispatch, getState); - return true; - } - const created = await createDirectChannel(currentUserId, otherUserId)(dispatch, getState); - if (created.data) { - handleSelectChannel(created.data.id)(dispatch, getState); + } else { + result = await createDirectChannel(currentUserId, otherUserId)(dispatch, getState); + channel = result.data; } - return created; + if (channel) { + handleSelectChannel(channel.id)(dispatch, getState); + } + + return result; + }; +} + +export function makeGroupChannel(otherUserIds) { + return async (dispatch, getState) => { + const state = getState(); + const {currentUserId} = state.entities.users; + + getProfilesByIds(otherUserIds)(dispatch, getState); + getStatusesByIds(otherUserIds)(dispatch, getState); + + const result = await createGroupChannel([currentUserId, ...otherUserIds])(dispatch, getState); + const channel = result.data; + + if (channel) { + toggleGMChannel(channel.id, 'true')(dispatch, getState); + handleSelectChannel(channel.id)(dispatch, getState); + } + + return result; }; } diff --git a/app/components/channel_drawer/channels_list/list/list.js b/app/components/channel_drawer/channels_list/list/list.js index 3c24452c7..d93140896 100644 --- a/app/components/channel_drawer/channels_list/list/list.js +++ b/app/components/channel_drawer/channels_list/list/list.js @@ -47,8 +47,7 @@ class List extends Component { showAbove: false }; - MaterialIcon.getImageSource('close', 20, this.props.theme.sidebarHeaderTextColor). - then((source) => { + MaterialIcon.getImageSource('close', 20, this.props.theme.sidebarHeaderTextColor).then((source) => { this.closeButton = source; }); } @@ -239,7 +238,7 @@ class List extends Component { navigator.showModal({ screen: 'MoreDirectMessages', - title: intl.formatMessage({id: 'more_direct_channels.title', defaultMessage: 'Direct Messages'}), + title: intl.formatMessage({id: 'mobile.more_dms.title', defaultMessage: 'New Conversation'}), animationType: 'slide-up', animated: true, backButtonTitle: '', diff --git a/app/components/conditional_touchable.js b/app/components/conditional_touchable.js new file mode 100644 index 000000000..9890609b3 --- /dev/null +++ b/app/components/conditional_touchable.js @@ -0,0 +1,29 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import {TouchableOpacity} from 'react-native'; + +import CustomPropTypes from 'app/constants/custom_prop_types'; + +export default class ConditionalTouchable extends React.PureComponent { + static propTypes = { + touchable: PropTypes.bool, + children: CustomPropTypes.Children.isRequired + }; + + render() { + const {touchable, children, ...otherProps} = this.props; + + if (touchable) { + return ( + + {children} + + ); + } + + return React.Children.only(children); + } +} diff --git a/app/components/custom_list/channel_list_row.js b/app/components/custom_list/channel_list_row.js deleted file mode 100644 index ceb0199a2..000000000 --- a/app/components/custom_list/channel_list_row.js +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import React from 'react'; -import PropTypes from 'prop-types'; -import { - StyleSheet, - Text, - TouchableOpacity, - TouchableWithoutFeedback, - View -} from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; - -function createTouchableComponent(children, action) { - return ( - - {children} - - ); -} - -function ChannelListRow(props) { - const {id, displayName, purpose, onPress, theme} = props; - const style = getStyleFromTheme(theme); - - let purposeComponent; - if (purpose) { - purposeComponent = ( - - - {purpose} - - - ); - } - - const RowComponent = ( - - - {props.selectable && - - - - {props.selected && - - } - - - - } - - - - - {displayName} - - - - - {purposeComponent} - - ); - - if (typeof onPress === 'function') { - return createTouchableComponent(RowComponent, () => onPress(id)); - } - - return RowComponent; -} - -ChannelListRow.propTypes = { - id: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, - purpose: PropTypes.string.isRequired, - theme: PropTypes.object.isRequired, - onPress: PropTypes.func, - selectable: PropTypes.bool, - onRowSelect: PropTypes.func, - selected: PropTypes.bool -}; - -const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { - return StyleSheet.create({ - container: { - flexDirection: 'column', - height: 65, - paddingHorizontal: 15, - justifyContent: 'center', - backgroundColor: theme.centerChannelBg - }, - titleContainer: { - alignItems: 'center', - flexDirection: 'row' - }, - displayName: { - fontSize: 16, - color: theme.centerChannelColor - }, - icon: { - fontSize: 16, - color: theme.centerChannelColor - }, - textContainer: { - flex: 1, - flexDirection: 'row', - marginLeft: 5 - }, - purpose: { - marginTop: 7, - fontSize: 13, - color: changeOpacity(theme.centerChannelColor, 0.5) - }, - selector: { - height: 28, - width: 28, - borderRadius: 14, - borderWidth: 1, - borderColor: '#888', - alignItems: 'center', - justifyContent: 'center' - }, - selectorContainer: { - height: 50, - paddingRight: 15, - alignItems: 'center', - justifyContent: 'center' - }, - selectorFilled: { - backgroundColor: '#378FD2', - borderWidth: 0 - } - }); -}); - -export default ChannelListRow; diff --git a/app/components/custom_list/channel_list_row/channel_list_row.js b/app/components/custom_list/channel_list_row/channel_list_row.js new file mode 100644 index 000000000..792b8bb38 --- /dev/null +++ b/app/components/custom_list/channel_list_row/channel_list_row.js @@ -0,0 +1,95 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; +import PropTypes from 'prop-types'; +import { + StyleSheet, + Text, + View +} from 'react-native'; +import Icon from 'react-native-vector-icons/FontAwesome'; +import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; + +import CustomListRow from 'app/components/custom_list/custom_list_row'; + +export default class ChannelListRow extends React.PureComponent { + static propTypes = { + id: PropTypes.string.isRequired, + theme: PropTypes.object.isRequired, + channel: PropTypes.object.isRequired, + ...CustomListRow.propTypes + }; + + onPress = () => { + this.props.onPress(this.props.id); + }; + + render() { + const style = getStyleFromTheme(this.props.theme); + + let purpose; + if (this.props.channel.purpose) { + purpose = ( + + {this.props.channel.purpose} + + ); + } + + return ( + + + + + + {this.props.channel.display_name} + + + {purpose} + + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + titleContainer: { + alignItems: 'center', + flexDirection: 'row' + }, + displayName: { + fontSize: 16, + color: theme.centerChannelColor, + marginLeft: 5 + }, + icon: { + fontSize: 16, + color: theme.centerChannelColor + }, + container: { + flex: 1, + flexDirection: 'column' + }, + purpose: { + marginTop: 7, + fontSize: 13, + color: changeOpacity(theme.centerChannelColor, 0.5) + } + }); +}); diff --git a/app/components/custom_list/channel_list_row/index.js b/app/components/custom_list/channel_list_row/index.js new file mode 100644 index 000000000..c5d27a479 --- /dev/null +++ b/app/components/custom_list/channel_list_row/index.js @@ -0,0 +1,24 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getTheme} from 'app/selectors/preferences'; + +import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; + +import ChannelListRow from './channel_list_row'; + +function makeMapStateToProps() { + const getChannel = makeGetChannel(); + + return (state, ownProps) => { + return { + theme: getTheme(state), + channel: getChannel(state, ownProps), + ...ownProps + }; + }; +} + +export default connect(makeMapStateToProps)(ChannelListRow); diff --git a/app/components/custom_list/custom_list_row.js b/app/components/custom_list/custom_list_row.js new file mode 100644 index 000000000..26ea9a384 --- /dev/null +++ b/app/components/custom_list/custom_list_row.js @@ -0,0 +1,95 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import { + StyleSheet, + View +} from 'react-native'; +import Icon from 'react-native-vector-icons/FontAwesome'; + +import ConditionalTouchable from 'app/components/conditional_touchable'; +import CustomPropTypes from 'app/constants/custom_prop_types'; +import {makeStyleSheetFromTheme} from 'app/utils/theme'; + +export default class CustomListRow extends React.PureComponent { + static propTypes = { + theme: PropTypes.object.isRequired, + onPress: PropTypes.func, + enabled: PropTypes.bool, + selectable: PropTypes.bool, + selected: PropTypes.bool, + children: CustomPropTypes.Children + }; + + static defaultProps = { + enabled: true + }; + + render() { + const style = getStyleFromTheme(this.props.theme); + + return ( + + + {this.props.selectable && + + + {this.props.selected && + + } + + + } + {this.props.children} + + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + container: { + flexDirection: 'row', + height: 65, + paddingHorizontal: 15, + alignItems: 'center', + backgroundColor: theme.centerChannelBg + }, + displayName: { + fontSize: 15, + color: theme.centerChannelColor + }, + selector: { + height: 28, + width: 28, + borderRadius: 14, + borderWidth: 1, + borderColor: '#888', + alignItems: 'center', + justifyContent: 'center' + }, + selectorContainer: { + height: 50, + paddingRight: 15, + alignItems: 'center', + justifyContent: 'center' + }, + selectorDisabled: { + backgroundColor: '#888' + }, + selectorFilled: { + backgroundColor: '#378FD2', + borderWidth: 0 + } + }); +}); diff --git a/app/components/custom_list/index.js b/app/components/custom_list/index.js index 865019fac..e9791a64a 100644 --- a/app/components/custom_list/index.js +++ b/app/components/custom_list/index.js @@ -13,16 +13,15 @@ export default class CustomList extends PureComponent { data: PropTypes.array.isRequired, theme: PropTypes.object.isRequired, searching: PropTypes.bool, - onRowPress: PropTypes.func, onListEndReached: PropTypes.func, onListEndReachedThreshold: PropTypes.number, - teammateNameDisplay: PropTypes.string, loading: PropTypes.bool, loadingText: PropTypes.object, listPageSize: PropTypes.number, listInitialSize: PropTypes.number, listScrollRenderAheadDistance: PropTypes.number, showSections: PropTypes.bool, + onRowPress: PropTypes.func, selectable: PropTypes.bool, onRowSelect: PropTypes.func, renderRow: PropTypes.func.isRequired, @@ -37,9 +36,8 @@ export default class CustomList extends PureComponent { listPageSize: 10, listInitialSize: 10, listScrollRenderAheadDistance: 200, - selectable: false, loadingText: null, - onRowSelect: () => true, + selectable: false, createSections: () => true, showSections: true, showNoResults: true @@ -114,17 +112,32 @@ export default class CustomList extends PureComponent { ); }; - renderRow = (rowData, sectionId, rowId) => { - return this.props.renderRow( - rowData, - sectionId, - rowId, - this.props.teammateNameDisplay, - this.props.theme, - this.props.selectable, - this.props.onRowPress, - this.handleRowSelect - ); + renderRow = (item, sectionId, rowId) => { + const props = { + id: item.id, + item, + selected: item.selected, + selectable: this.props.selectable, + onPress: this.props.onRowPress + }; + + if ('disableSelect' in item) { + props.enabled = !item.disableSelect; + } + + if (this.props.onRowSelect) { + props.onPress = this.handleRowSelect.bind(this, sectionId, rowId); + } else { + props.onPress = this.props.onRowPress; + } + + // Allow passing in a component like UserListRow or ChannelListRow + if (this.props.renderRow.prototype.isReactComponent) { + const RowComponent = this.props.renderRow; + return ; + } + + return this.props.renderRow(props); }; renderSeparator = (sectionId, rowId) => { diff --git a/app/components/custom_list/member_list_row.js b/app/components/custom_list/member_list_row.js deleted file mode 100644 index 8b8d805cb..000000000 --- a/app/components/custom_list/member_list_row.js +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import React from 'react'; -import PropTypes from 'prop-types'; -import { - StyleSheet, - Text, - TouchableOpacity, - TouchableWithoutFeedback, - View -} from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import ProfilePicture from 'app/components/profile_picture'; -import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; - -function createTouchableComponent(children, action) { - return ( - - {children} - - ); -} - -function MemberListRow(props) { - const {id, displayName, username, onPress, theme, user, disableSelect} = props; - const style = getStyleFromTheme(theme); - - const RowComponent = ( - - {props.selectable && - false : props.onRowSelect}> - - - {props.selected && - - } - - - - } - - - - - {displayName} - - - - - {`(@${username})`} - - - - - ); - - if (typeof onPress === 'function') { - return createTouchableComponent(RowComponent, () => onPress(id)); - } else if (typeof props.onRowSelect === 'function') { - return createTouchableComponent(RowComponent, disableSelect ? () => false : props.onRowSelect); - } - - return RowComponent; -} - -MemberListRow.propTypes = { - id: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, - pictureURL: PropTypes.string, - username: PropTypes.string.isRequired, - theme: PropTypes.object.isRequired, - onPress: PropTypes.func, - selectable: PropTypes.bool, - onRowSelect: PropTypes.func, - selected: PropTypes.bool, - disableSelect: PropTypes.bool -}; - -const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { - return StyleSheet.create({ - container: { - flexDirection: 'row', - height: 65, - paddingHorizontal: 15, - alignItems: 'center', - backgroundColor: theme.centerChannelBg - }, - displayName: { - fontSize: 15, - color: theme.centerChannelColor - }, - icon: { - fontSize: 20, - color: theme.centerChannelColor - }, - textContainer: { - flexDirection: 'row', - marginLeft: 5 - }, - username: { - marginLeft: 5, - fontSize: 15, - color: changeOpacity(theme.centerChannelColor, 0.5) - }, - selector: { - height: 28, - width: 28, - borderRadius: 14, - borderWidth: 1, - borderColor: '#888', - alignItems: 'center', - justifyContent: 'center' - }, - selectorContainer: { - height: 50, - paddingRight: 15, - alignItems: 'center', - justifyContent: 'center' - }, - selectorDisabled: { - backgroundColor: '#888' - }, - selectorFilled: { - backgroundColor: '#378FD2', - borderWidth: 0 - } - }); -}); - -export default MemberListRow; diff --git a/app/components/custom_list/user_list_row/index.js b/app/components/custom_list/user_list_row/index.js new file mode 100644 index 000000000..b693da27b --- /dev/null +++ b/app/components/custom_list/user_list_row/index.js @@ -0,0 +1,22 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getTheme} from 'app/selectors/preferences'; + +import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences'; +import {getUser} from 'mattermost-redux/selectors/entities/users'; + +import UserListRow from './user_list_row'; + +function mapStateToProps(state, ownProps) { + return { + theme: getTheme(state), + user: getUser(state, ownProps.id), + teammateNameDisplay: getTeammateNameDisplaySetting(state), + ...ownProps + }; +} + +export default connect(mapStateToProps)(UserListRow); diff --git a/app/components/custom_list/user_list_row/user_list_row.js b/app/components/custom_list/user_list_row/user_list_row.js new file mode 100644 index 000000000..a344d24c1 --- /dev/null +++ b/app/components/custom_list/user_list_row/user_list_row.js @@ -0,0 +1,117 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; +import PropTypes from 'prop-types'; +import { + StyleSheet, + Text, + View +} from 'react-native'; +import ProfilePicture from 'app/components/profile_picture'; +import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; + +import CustomListRow from 'app/components/custom_list/custom_list_row'; + +import {displayUsername} from 'mattermost-redux/utils/user_utils'; + +export default class UserListRow extends React.PureComponent { + static propTypes = { + id: PropTypes.string.isRequired, + theme: PropTypes.object.isRequired, + user: PropTypes.object.isRequired, + teammateNameDisplay: PropTypes.string.isRequired, + ...CustomListRow.propTypes + }; + + onPress = () => { + this.props.onPress(this.props.id); + }; + + render() { + const style = getStyleFromTheme(this.props.theme); + + return ( + + + + + + {displayUsername(this.props.user, this.props.teammateNameDisplay)} + + + + + {`(@${this.props.user.username})`} + + + + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + container: { + flexDirection: 'row', + height: 65, + paddingHorizontal: 15, + alignItems: 'center', + backgroundColor: theme.centerChannelBg + }, + displayName: { + fontSize: 15, + color: theme.centerChannelColor + }, + icon: { + fontSize: 20, + color: theme.centerChannelColor + }, + textContainer: { + flexDirection: 'row', + marginLeft: 5 + }, + username: { + marginLeft: 5, + fontSize: 15, + color: changeOpacity(theme.centerChannelColor, 0.5) + }, + selector: { + height: 28, + width: 28, + borderRadius: 14, + borderWidth: 1, + borderColor: '#888', + alignItems: 'center', + justifyContent: 'center' + }, + selectorContainer: { + height: 50, + paddingRight: 15, + alignItems: 'center', + justifyContent: 'center' + }, + selectorDisabled: { + backgroundColor: '#888' + }, + selectorFilled: { + backgroundColor: '#378FD2', + borderWidth: 0 + } + }); +}); diff --git a/app/components/custom_section_list.js b/app/components/custom_section_list.js new file mode 100644 index 000000000..d28f9f565 --- /dev/null +++ b/app/components/custom_section_list.js @@ -0,0 +1,310 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Platform, + SectionList, + StyleSheet, + Text, + View +} from 'react-native'; + +import Loading from 'app/components/loading'; +import FormattedText from 'app/components/formatted_text'; +import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; + +export default class CustomSectionList extends React.PureComponent { + static propTypes = { + + /* + * The current theme. + */ + theme: PropTypes.object.isRequired, + + /* + * An array of items to be rendered. + */ + items: PropTypes.array.isRequired, + + /* + * A function or React element used to render the items in the list. + */ + renderItem: PropTypes.func, + + /* + * Whether or not to render "No results" when the list contains no items. + */ + showNoResults: PropTypes.bool, + + /* + * A function to get a section identifier for each item in the list. Items sharing a section identifier will be grouped together. + */ + sectionKeyExtractor: PropTypes.func.isRequired, + + /* + * A comparison function used when sorting items in the list. + */ + compareItems: PropTypes.func.isRequired, + + /* + * A function to get a unique key for each item in the list. If not provided, the id field of the item will be used as the key. + */ + keyExtractor: PropTypes.func, + + /* + * Any extra data needed to render the list. If this value changes, all items of a list will be re-rendered. + */ + extraData: PropTypes.object, + + /* + * A function called when an item in the list is pressed. Receives the item that was pressed as an argument. + */ + onRowPress: PropTypes.func, + + /* + * A function called when the end of the list is reached. This can be triggered before this list end is reached + * by changing onListEndReachedThreshold. + */ + onListEndReached: PropTypes.func, + + /* + * How soon before the end of the list onListEndReached should be called. + */ + onListEndReachedThreshold: PropTypes.number, + + /* + * Whether or not to display the loading text. + */ + loading: PropTypes.bool, + + /* + * The text displayed when loading is set to true. + */ + loadingText: PropTypes.object, + + /* + * How many items to render when the list is first rendered. + */ + initialNumToRender: PropTypes.number + }; + + static defaultKeyExtractor = (item) => { + return item.id; + }; + + static defaultProps = { + showNoResults: true, + keyExtractor: CustomSectionList.defaultKeyExtractor, + onListEndReached: () => true, + onListEndReachedThreshold: 50, + loadingText: null, + initialNumToRender: 10 + }; + + constructor(props) { + super(props); + + this.state = { + sections: this.extractSections(props.items) + }; + } + + componentWillReceiveProps(nextProps) { + if (nextProps.items !== this.props.items) { + this.setState({ + sections: this.extractSections(nextProps.items) + }); + } + } + + extractSections = (items) => { + const sections = {}; + const sectionKeys = []; + for (const item of items) { + const sectionKey = this.props.sectionKeyExtractor(item); + + if (!sections[sectionKey]) { + sections[sectionKey] = []; + sectionKeys.push(sectionKey); + } + + sections[sectionKey].push(item); + } + + sectionKeys.sort(); + + return sectionKeys.map((sectionKey) => { + return { + key: sectionKey, + data: sections[sectionKey].sort(this.props.compareItems) + }; + }); + } + + renderSectionHeader = ({section}) => { + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + return ( + + + {section.key} + + + ); + }; + + renderItem = ({item}) => { + const props = { + id: item.id, + item, + onPress: this.props.onRowPress + }; + + // Allow passing in a component like UserListRow or ChannelListRow + if (this.props.renderItem.prototype.isReactElement) { + const RowComponent = this.props.renderItem; + return ; + } + + return this.props.renderItem(props); + }; + + renderItemSeparator = () => { + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + return ( + + ); + }; + + renderFooter = () => { + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + if (!this.props.loading || !this.props.loadingText) { + return null; + } + + if (this.props.items.length === 0) { + return null; + } + + return ( + + + + ); + }; + + renderEmptyList = () => { + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + if (this.props.loading) { + return ( + + + + ); + } + + if (this.props.showNoResults) { + return ( + + + + ); + } + + return null; + } + + render() { + const style = getStyleFromTheme(this.props.theme); + + return ( + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + listView: { + flex: 1, + backgroundColor: theme.centerChannelBg, + ...Platform.select({ + android: { + marginBottom: 20 + } + }) + }, + loading: { + height: 70, + backgroundColor: theme.centerChannelBg, + alignItems: 'center', + justifyContent: 'center' + }, + loadingText: { + color: changeOpacity(theme.centerChannelColor, 0.6) + }, + searching: { + backgroundColor: theme.centerChannelBg, + height: '100%', + position: 'absolute', + width: '100%' + }, + sectionContainer: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.07), + paddingLeft: 10, + paddingVertical: 2 + }, + sectionWrapper: { + backgroundColor: theme.centerChannelBg + }, + sectionText: { + fontWeight: '600', + color: theme.centerChannelColor + }, + separator: { + height: 1, + flex: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1) + }, + noResultContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center' + }, + noResultText: { + fontSize: 26, + color: changeOpacity(theme.centerChannelColor, 0.5) + } + }); +}); diff --git a/app/screens/channel_add_members/channel_add_members.js b/app/screens/channel_add_members/channel_add_members.js index 114c88d3b..f73fa5970 100644 --- a/app/screens/channel_add_members/channel_add_members.js +++ b/app/screens/channel_add_members/channel_add_members.js @@ -12,10 +12,11 @@ import { } from 'react-native'; import Loading from 'app/components/loading'; -import MemberList from 'app/components/custom_list'; +import CustomList from 'app/components/custom_list'; +import UserListRow from 'app/components/custom_list/user_list_row'; import SearchBar from 'app/components/search_bar'; import StatusBar from 'app/components/status_bar'; -import {createMembersSections, loadingText, markSelectedProfiles, renderMemberRow} from 'app/utils/member_list'; +import {createMembersSections, loadingText, markSelectedProfiles} from 'app/utils/member_list'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import {General, RequestStatus} from 'mattermost-redux/constants'; @@ -177,8 +178,7 @@ class ChannelAddMembers extends PureComponent { let {page} = this.state; if (loadMoreRequestStatus !== RequestStatus.STARTED && next && !searching) { page = page + 1; - actions.getProfilesNotInChannel(currentTeam.id, currentChannel.id, page, General.PROFILE_CHUNK_SIZE). - then((data) => { + actions.getProfilesNotInChannel(currentTeam.id, currentChannel.id, page, General.PROFILE_CHUNK_SIZE).then((data) => { if (data && data.length) { this.setState({ page @@ -192,7 +192,7 @@ class ChannelAddMembers extends PureComponent { onNavigatorEvent = (event) => { if (event.type === 'NavBarButtonPress') { - if (event.id === 'add-members') { + if (event.id === this.addButton.id) { this.handleAddMembersPress(); } } @@ -259,7 +259,7 @@ class ChannelAddMembers extends PureComponent { value={term} /> - diff --git a/app/screens/channel_members/channel_members.js b/app/screens/channel_members/channel_members.js index 0f9689960..428268887 100644 --- a/app/screens/channel_members/channel_members.js +++ b/app/screens/channel_members/channel_members.js @@ -12,15 +12,15 @@ import { import {injectIntl, intlShape} from 'react-intl'; import Loading from 'app/components/loading'; -import MemberList from 'app/components/custom_list'; +import CustomList from 'app/components/custom_list'; import SearchBar from 'app/components/search_bar'; import StatusBar from 'app/components/status_bar'; import {createMembersSections, loadingText, markSelectedProfiles} from 'app/utils/member_list'; -import MemberListRow from 'app/components/custom_list/member_list_row'; +import UserListRow from 'app/components/custom_list/user_list_row'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import {General, RequestStatus} from 'mattermost-redux/constants'; -import {displayUsername, filterProfilesMatchingTerm} from 'mattermost-redux/utils/user_utils'; +import {filterProfilesMatchingTerm} from 'mattermost-redux/utils/user_utils'; class ChannelMembers extends PureComponent { static propTypes = { @@ -30,7 +30,6 @@ class ChannelMembers extends PureComponent { currentChannelMembers: PropTypes.array.isRequired, currentUserId: PropTypes.string.isRequired, navigator: PropTypes.object, - teammateNameDisplay: PropTypes.string, requestStatus: PropTypes.string, searchRequestStatus: PropTypes.string, removeMembersStatus: PropTypes.string, @@ -235,28 +234,14 @@ class ChannelMembers extends PureComponent { actions.handleRemoveChannelMembers(currentChannel.id, membersToRemove); }; - renderMemberRow = (user, sectionId, rowId, teammateNameDisplay, theme, selectable, onPress, onSelect) => { - const {id, username} = user; - const displayName = displayUsername(user, teammateNameDisplay); - let onRowSelect = null; - if (selectable) { - onRowSelect = () => onSelect(sectionId, rowId); - } - - const disableSelect = user.id === this.props.currentUserId; + renderMemberRow = (props) => { + const enabled = props.id !== this.props.currentUserId; return ( - ); }; @@ -277,7 +262,7 @@ class ChannelMembers extends PureComponent { }; render() { - const {canManageUsers, intl, teammateNameDisplay, requestStatus, searchRequestStatus, theme} = this.props; + const {canManageUsers, intl, requestStatus, searchRequestStatus, theme} = this.props; const {formatMessage} = intl; const {profiles, removing, searching, showNoResults, term} = this.state; const isLoading = (requestStatus === RequestStatus.STARTED) || (requestStatus.status === RequestStatus.NOT_STARTED) || @@ -321,17 +306,15 @@ class ChannelMembers extends PureComponent { value={term} /> - { + General.CHANNELS_CHUNK_SIZE + ).then((data) => { if (data && data.length) { this.setState({ page @@ -175,27 +175,6 @@ class MoreChannels extends PureComponent { } }; - renderChannelRow = (channel, sectionId, rowId, preferences, theme, selectable, onPress, onSelect) => { - const {id, display_name: displayName, purpose} = channel; - let onRowSelect = null; - if (selectable) { - onRowSelect = () => onSelect(sectionId, rowId); - } - - return ( - - ); - }; - onNavigatorEvent = (event) => { if (event.type === 'NavBarButtonPress') { switch (event.id) { @@ -307,16 +286,15 @@ class MoreChannels extends PureComponent { value={term} /> - { + if (state.loadingChannel) { + return false; + } + + return state.selectedCount >= 1 && state.selectedCount <= General.MAX_USERS_IN_GM - 1; + }; + + updateNavigationButtons = (startEnabled) => { + this.props.navigator.setButtons({ + rightButtons: [{ + id: START_BUTTON, + title: this.props.intl.formatMessage({id: 'mobile.more_dms.start', defaultMessage: 'Start'}), + showAsAction: 'always', + disabled: !startEnabled + }] + }); + }; + close = () => { this.props.navigator.dismissModal({ animationType: 'slide-down' @@ -90,7 +136,9 @@ class MoreDirectMessages extends PureComponent { onNavigatorEvent = (event) => { if (event.type === 'NavBarButtonPress') { - if (event.id === 'close-dms') { + if (event.id === START_BUTTON) { + this.startConversation(); + } else if (event.id === CLOSE_BUTTON) { this.close(); } } @@ -137,6 +185,10 @@ class MoreDirectMessages extends PureComponent { }; loadMoreProfiles = () => { + if (this.state.searching) { + return; + } + let {page} = this.state; if (this.props.getRequest.status !== RequestStatus.STARTED && this.state.next && !this.state.searching) { page = page + 1; @@ -152,126 +204,224 @@ class MoreDirectMessages extends PureComponent { } }; - onSelectMember = async (id) => { - const {actions, currentDisplayName, intl, teammateNameDisplay, profiles} = this.props; - const user = profiles.find((p) => p.id === id); + handleSelectUser = (id) => { + this.setState((prevState) => { + const wasSelected = prevState.selectedIds[id]; - this.setState({adding: true}); + // Prevent selecting too many users + if (!wasSelected && Object.keys(prevState.selectedIds).length >= General.MAX_USERS_IN_GM - 1) { + return {}; + } - // save the current channel display name in case it fails - const currentChannelDisplayName = currentDisplayName; + const selectedIds = {...prevState.selectedIds}; - const userDisplayName = displayUsername(user, teammateNameDisplay); + if (wasSelected) { + Reflect.deleteProperty(selectedIds, id); + } else { + selectedIds[id] = true; + } - if (user) { - actions.setChannelDisplayName(userDisplayName); - } else { - actions.setChannelDisplayName(''); + return { + selectedIds, + selectedCount: Object.keys(selectedIds).length + }; + }); + }; + + handleRemoveUser = (id) => { + this.setState((prevState) => { + const selectedIds = {...prevState.selectedIds}; + Reflect.deleteProperty(selectedIds, id); + + return { + selectedIds, + selectedCount: Object.keys(selectedIds).length + }; + }); + } + + startConversation = async () => { + if (this.state.loadingChannel) { + return; } - const result = await actions.makeDirectChannel(id); + this.setState({ + loadingChannel: true + }); + + // Save the current channel display name in case it fails + const currentChannelDisplayName = this.props.currentDisplayName; + + const selectedIds = Object.keys(this.state.selectedIds); + let success; + if (selectedIds.length === 0) { + success = false; + } else if (selectedIds.length > 1) { + success = await this.makeGroupChannel(selectedIds); + } else { + success = await this.makeDirectChannel(selectedIds[0]); + } + + if (success) { + EventEmitter.emit('close_channel_drawer'); + InteractionManager.runAfterInteractions(() => { + this.close(); + }); + } else { + this.setState({ + loadingChannel: false + }); + + this.props.actions.setChannelDisplayName(currentChannelDisplayName); + } + }; + + makeGroupChannel = async (ids) => { + const result = await this.props.actions.makeGroupChannel(ids); + + const displayName = getGroupDisplayNameFromUserIds(ids, this.props.allProfiles, this.props.currentUserId, this.props.teammateNameDisplay); + this.props.actions.setChannelDisplayName(displayName); if (result.error) { - actions.setChannelDisplayName(currentChannelDisplayName); alertErrorWithFallback( - intl, + this.props.intl, + result.error, + { + id: 'mobile.open_gm.error', + defaultMessage: "We couldn't open a group message with those users. Please check your connection and try again." + } + ); + } + + return !result.error; + }; + + makeDirectChannel = async (id) => { + const user = this.state.profiles[id]; + + const displayName = displayUsername(user, this.props.teammateNameDisplay); + this.props.actions.setChannelDisplayName(displayName); + + const result = await this.props.actions.makeDirectChannel(id); + + if (result.error) { + alertErrorWithFallback( + this.props.intl, result.error, { id: 'mobile.open_dm.error', defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again." }, { - displayName: userDisplayName + displayName } ); - this.setState({adding: false}); - } else { - EventEmitter.emit('close_channel_drawer'); - InteractionManager.runAfterInteractions(() => { - this.close(); - }); } + + return !result.error; + }; + + sectionKeyExtractor = (user) => { + // Group items alphabetically by first letter + return displayUsername(user, this.props.teammateNameDisplay)[0].toUpperCase(); + } + + compareItems = (a, b) => { + const aName = displayUsername(a, this.props.teammateNameDisplay); + const bName = displayUsername(b, this.props.teammateNameDisplay); + + return aName.localeCompare(bName); + }; + + renderItem = (props) => { + // The list will re-render when the selection changes because it's passed into the list as extraData + const selected = this.state.selectedIds[props.id]; + const enabled = selected || this.state.selectedCount < General.MAX_USERS_IN_GM - 1; + + return ( + + ); }; render() { const { - intl, - teammateNameDisplay, getRequest, searchRequest, theme } = this.props; const { - adding, - profiles, - searching, + loadingChannel, showNoResults, term } = this.state; - const {formatMessage} = intl; const isLoading = ( getRequest.status === RequestStatus.STARTED) || (getRequest.status === RequestStatus.NOT_STARTED) || (searchRequest.status === RequestStatus.STARTED); const style = getStyleFromTheme(theme); - const more = this.state.searching ? () => true : this.loadMoreProfiles; - let content; - if (adding) { - content = ( + if (loadingChannel) { + return ( ); - } else { - content = ( - - - - - - - - ); } - return content; + return ( + + + + + + + + + ); } } @@ -280,6 +430,9 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { container: { flex: 1, backgroundColor: theme.centerChannelBg + }, + searchContainer: { + marginVertical: 5 } }); }); diff --git a/app/screens/more_dms/selected_users/index.js b/app/screens/more_dms/selected_users/index.js new file mode 100644 index 000000000..dfa68f826 --- /dev/null +++ b/app/screens/more_dms/selected_users/index.js @@ -0,0 +1,22 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getTheme} from 'app/selectors/preferences'; + +import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences'; +import {getUsers} from 'mattermost-redux/selectors/entities/users'; + +import SelectedUsers from './selected_users'; + +function mapStateToProps(state, ownProps) { + return { + ...ownProps, + theme: getTheme(state), + profiles: getUsers(state), + teammateNameDisplay: getTeammateNameDisplaySetting(state) + }; +} + +export default connect(mapStateToProps)(SelectedUsers); diff --git a/app/screens/more_dms/selected_users/selected_user.js b/app/screens/more_dms/selected_users/selected_user.js new file mode 100644 index 000000000..dac019ea2 --- /dev/null +++ b/app/screens/more_dms/selected_users/selected_user.js @@ -0,0 +1,90 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import { + StyleSheet, + Text, + TouchableOpacity, + View +} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialIcons'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +import {displayUsername} from 'mattermost-redux/utils/user_utils'; + +export default class SelectedUser extends React.PureComponent { + static propTypes = { + + /* + * The current theme. + */ + theme: PropTypes.object.isRequired, + + /* + * How to display the names of users. + */ + teammateNameDisplay: PropTypes.string.isRequired, + + /* + * The user that this component represents. + */ + user: PropTypes.object.isRequired, + + /* + * A handler function that will deselect a user when clicked on. + */ + onRemove: PropTypes.func.isRequired + }; + + onRemove = () => { + this.props.onRemove(this.props.user.id); + }; + + render() { + const style = getStyleFromTheme(this.props.theme); + + return ( + + + {displayUsername(this.props.user, this.props.teammateNameDisplay)} + + + + + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + height: 27, + borderRadius: 3, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + marginBottom: 2, + marginRight: 10, + marginTop: 10, + paddingLeft: 10 + }, + remove: { + paddingHorizontal: 10 + }, + text: { + color: theme.centerChannelColor, + fontSize: 13 + } + }); +}); diff --git a/app/screens/more_dms/selected_users/selected_users.js b/app/screens/more_dms/selected_users/selected_users.js new file mode 100644 index 000000000..df79ae753 --- /dev/null +++ b/app/screens/more_dms/selected_users/selected_users.js @@ -0,0 +1,136 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +import FormattedText from 'app/components/formatted_text'; +import SelectedUser from 'app/screens/more_dms/selected_users/selected_user'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +export default class SelectedUsers extends React.PureComponent { + static propTypes = { + + /* + * The current theme. + */ + theme: PropTypes.object.isRequired, + + /* + * An object mapping user ids to a falsey value indicating whether or not they've been selected. + */ + selectedIds: PropTypes.object.isRequired, + + /* + * An object mapping user ids to users. + */ + profiles: PropTypes.object.isRequired, + + /* + * How to display the names of users. + */ + teammateNameDisplay: PropTypes.string.isRequired, + + /* + * The number of users that will be selected when we start to display a message indicating + * the remaining number of users that can be selected. + */ + warnCount: PropTypes.number.isRequired, + + /* + * An i18n string displaying how many more users can be selected. + */ + warnMessage: PropTypes.object.isRequired, + + /* + * The maximum number of users that can be selected. + */ + maxCount: PropTypes.number.isRequired, + + /* + * An i18n string displayed when no more users can be selected. + */ + maxMessage: PropTypes.object.isRequired, + + /* + * A handler function that will deselect a user when clicked on. + */ + onRemove: PropTypes.func.isRequired + }; + + render() { + const users = []; + for (const id of Object.keys(this.props.selectedIds)) { + if (!this.props.selectedIds[id]) { + continue; + } + + users.push( + + ); + } + + if (users.length === 0) { + return null; + } + + const style = getStyleFromTheme(this.props.theme); + + let message = null; + if (users.length >= this.props.maxCount) { + message = ( + + ); + } else if (users.length >= this.props.warnCount) { + message = ( + + ); + } + + return ( + + + {users} + + {message} + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + container: { + marginLeft: 5, + marginBottom: 5 + }, + users: { + alignItems: 'flex-start', + flexDirection: 'row', + flexWrap: 'wrap' + }, + message: { + color: changeOpacity(theme.centerChannelColor, 0.6), + fontSize: 12, + marginRight: 5, + marginTop: 10, + marginBottom: 2 + } + }); +}); diff --git a/app/utils/member_list.js b/app/utils/member_list.js index 0e7d1ae0d..1b85d19ce 100644 --- a/app/utils/member_list.js +++ b/app/utils/member_list.js @@ -1,10 +1,6 @@ // Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React from 'react'; -import MemberListRow from 'app/components/custom_list/member_list_row'; -import {displayUsername} from 'mattermost-redux/utils/user_utils'; - export const loadingText = { id: 'mobile.loading_members', defaultMessage: 'Loading Members...' @@ -26,29 +22,6 @@ export function createMembersSections(data) { return sections; } -export function renderMemberRow(user, sectionId, rowId, teammateNameDisplay, theme, selectable, onPress, onSelect) { - const {id, username} = user; - const displayName = displayUsername(user, teammateNameDisplay); - let onRowSelect = null; - if (selectable) { - onRowSelect = () => onSelect(sectionId, rowId); - } - - return ( - - ); -} - export function markSelectedProfiles(profiles, selectedProfiles) { return profiles.map((p) => { const profile = {...p}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index e08cf0096..99eb35bbe 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1766,11 +1766,14 @@ "mobile.loading_members": "Loading Members...", "mobile.loading_posts": "Loading Messages...", "mobile.login_options.choose_title": "Choose your login method", + "mobile.more_dms.start": "Start", + "mobile.more_dms.title": "New Conversation", "mobile.notification.in": " in ", "mobile.offlineIndicator.connected": "Connected", "mobile.offlineIndicator.connecting": "Connecting...", "mobile.offlineIndicator.offline": "No internet connection", "mobile.open_dm.error": "We couldn't open a direct message with {displayName}. Please check your connection and try again.", + "mobile.open_gm.error": "We couldn't open a group message with those users. Please check your connection and try again.", "mobile.post.cancel": "Cancel", "mobile.post.delete_question": "Are you sure you want to delete this post?", "mobile.post.delete_title": "Delete Post", diff --git a/yarn.lock b/yarn.lock index d9ba6892a..9f60f0fb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3645,7 +3645,7 @@ makeerror@1.0.x: mattermost-redux@mattermost/mattermost-redux#master: version "0.0.1" - resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/81416da8fd9f2df12286a732c7e98fd031dfd329" + resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/9797cb8bd8fa61252336a7c6150bd364f7ca28b1" dependencies: deep-equal "1.0.1" harmony-reflect "1.5.1"