diff --git a/app/components/autocomplete/at_mention/at_mention.js b/app/components/autocomplete/at_mention/at_mention.js index f14c3d888..d37f26db3 100644 --- a/app/components/autocomplete/at_mention/at_mention.js +++ b/app/components/autocomplete/at_mention/at_mention.js @@ -1,47 +1,39 @@ // Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React, {Component} from 'react'; +import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import { - ListView, - Text, - TouchableOpacity, - View -} from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; - -import {sortByUsername} from 'mattermost-redux/utils/user_utils'; - -import FormattedText from 'app/components/formatted_text'; -import ProfilePicture from 'app/components/profile_picture'; -import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; +import {SectionList} from 'react-native'; import {RequestStatus} from 'mattermost-redux/constants'; -const AT_MENTION_REGEX = /\B(@([^@\r\n\s]*))$/i; -const FROM_REGEX = /\bfrom:\s*(\S*)$/i; +import {AT_MENTION_REGEX, AT_MENTION_SEARCH_REGEX} from 'app/constants/autocomplete'; +import AtMentionItem from 'app/components/autocomplete/at_mention_item'; +import AutocompleteSectionHeader from 'app/components/autocomplete/autocomplete_section_header'; +import SpecialMentionItem from 'app/components/autocomplete/special_mention_item'; +import {makeStyleSheetFromTheme} from 'app/utils/theme'; -export default class AtMention extends Component { +export default class AtMention extends PureComponent { static propTypes = { - currentUserId: PropTypes.string.isRequired, - currentChannelId: PropTypes.string.isRequired, - currentTeamId: PropTypes.string.isRequired, - cursorPosition: PropTypes.number.isRequired, - defaultChannel: PropTypes.object.isRequired, - autocompleteUsers: PropTypes.object.isRequired, - isSearch: PropTypes.bool, - postDraft: PropTypes.string, - requestStatus: PropTypes.string.isRequired, - theme: PropTypes.object.isRequired, - onChangeText: PropTypes.func.isRequired, actions: PropTypes.shape({ autocompleteUsers: PropTypes.func.isRequired - }) + }).isRequired, + currentChannelId: PropTypes.string, + currentTeamId: PropTypes.string.isRequired, + cursorPosition: PropTypes.number.isRequired, + defaultChannel: PropTypes.object, + inChannel: PropTypes.array, + isSearch: PropTypes.bool, + matchTerm: PropTypes.string, + onChangeText: PropTypes.func.isRequired, + outChannel: PropTypes.array, + postDraft: PropTypes.string, + requestStatus: PropTypes.string.isRequired, + teamMembers: PropTypes.array, + theme: PropTypes.object.isRequired }; static defaultProps = { - autocompleteUsers: {}, defaultChannel: {}, postDraft: '', isSearch: false @@ -50,74 +42,81 @@ export default class AtMention extends Component { constructor(props) { super(props); - const ds = new ListView.DataSource({ - sectionHeaderHasChanged: (s1, s2) => s1 !== s2, - rowHasChanged: (r1, r2) => r1 !== r2 - }); - const data = {}; this.state = { - active: false, - dataSource: ds.cloneWithRowsAndSections(data) + sections: [] }; } componentWillReceiveProps(nextProps) { - const {isSearch} = nextProps; - const regex = isSearch ? FROM_REGEX : AT_MENTION_REGEX; - const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(regex); - - if (!match || this.state.mentionComplete) { + const {inChannel, outChannel, teamMembers, isSearch, matchTerm, requestStatus} = nextProps; + if ((matchTerm !== this.props.matchTerm && matchTerm === null) || this.state.mentionComplete) { + // if the term changes but is null or the mention has been completed we render this component as null this.setState({ - active: false, - matchTerm: null, - mentionComplete: false + mentionComplete: false, + sections: [] }); return; + } else if (matchTerm === null) { + // if the terms did not change but is null then we don't need to do anything + return; } - const matchTerm = isSearch ? match[1] : match[2]; - if (matchTerm !== this.state.matchTerm) { - this.setState({ - matchTerm - }); - + if (matchTerm !== this.props.matchTerm) { + // if the term changed and we haven't made the request do that first const {currentTeamId, currentChannelId} = this.props; - this.props.actions.autocompleteUsers(matchTerm, currentTeamId, currentChannelId); + this.props.actions.autocompleteUsers(matchTerm, currentTeamId, isSearch ? currentChannelId : ''); + return; } - if (nextProps.requestStatus !== RequestStatus.STARTED) { - const membersInChannel = this.filter(nextProps.autocompleteUsers.inChannel, matchTerm) || []; - const membersOutOfChannel = this.filter(nextProps.autocompleteUsers.outChannel, matchTerm) || []; - - let data = {}; + if (requestStatus !== RequestStatus.STARTED && + (inChannel !== this.props.inChannel || outChannel !== this.props.outChannel || teamMembers !== this.props.teamMembers)) { + // if the request is complete and the term is not null we show the autocomplete + const sections = []; if (isSearch) { - data = {members: membersInChannel.concat(membersOutOfChannel).sort(sortByUsername)}; + sections.push({ + id: 'mobile.suggestion.members', + defaultMessage: 'Members', + data: teamMembers, + key: 'teamMembers' + }); } else { - if (membersInChannel.length > 0) { - data = Object.assign({}, data, {inChannel: membersInChannel}); + if (inChannel.length) { + sections.push({ + id: 'suggestion.mention.members', + defaultMessage: 'Channel Members', + data: inChannel, + key: 'inChannel' + }); } - if (this.checkSpecialMentions(matchTerm) && !isSearch) { - data = Object.assign({}, data, {specialMentions: this.getSpecialMentions()}); + + if (this.checkSpecialMentions(matchTerm)) { + sections.push({ + id: 'suggestion.mention.special', + defaultMessage: 'Special Mentions', + data: this.getSpecialMentions(), + key: 'special', + renderItem: this.renderSpecialMentions + }); } - if (membersOutOfChannel.length > 0) { - data = Object.assign({}, data, {notInChannel: membersOutOfChannel}); + + if (outChannel.length) { + sections.push({ + id: 'suggestion.mention.nonmembers', + defaultMessage: 'Not in Channel', + data: outChannel, + key: 'outChannel' + }); } } this.setState({ - active: data.hasOwnProperty('inChannel') || data.hasOwnProperty('specialMentions') || data.hasOwnProperty('notInChannel') || data.hasOwnProperty('members'), - dataSource: this.state.dataSource.cloneWithRowsAndSections(data) + sections }); } } - filter = (profiles, matchTerm) => { - const {isSearch} = this.props; - return profiles.filter((p) => { - return ((p.id !== this.props.currentUserId || isSearch) && ( - p.username.toLowerCase().includes(matchTerm) || p.email.toLowerCase().includes(matchTerm) || - p.first_name.toLowerCase().includes(matchTerm) || p.last_name.toLowerCase().includes(matchTerm))); - }); + keyExtractor = (item) => { + return item.id || item; }; getSpecialMentions = () => { @@ -149,7 +148,7 @@ export default class AtMention extends Component { let completedDraft; if (isSearch) { - completedDraft = mentionPart.replace(FROM_REGEX, `from: ${mention} `); + completedDraft = mentionPart.replace(AT_MENTION_SEARCH_REGEX, `from: ${mention} `); } else { completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `); } @@ -159,140 +158,62 @@ export default class AtMention extends Component { } onChangeText(completedDraft, true); - this.setState({ - active: false, - mentionComplete: true - }); + this.setState({mentionComplete: true}); }; - renderSectionHeader = (sectionData, sectionId) => { - const style = getStyleFromTheme(this.props.theme); - - const localization = { - inChannel: { - id: 'suggestion.mention.members', - defaultMessage: 'Channel Members' - }, - notInChannel: { - id: 'suggestion.mention.nonmembers', - defaultMessage: 'Not in Channel' - }, - specialMentions: { - id: 'suggestion.mention.special', - defaultMessage: 'Special Mentions' - }, - members: { - id: 'mobile.suggestion.members', - defaultMessage: 'Members' - } - }; - + renderSectionHeader = ({section}) => { return ( - - - - - + ); }; - renderRow = (data, sectionId) => { - if (sectionId === 'specialMentions') { - return this.renderSpecialMentions(data); - } - - const style = getStyleFromTheme(this.props.theme); - const hasFullName = data.first_name.length > 0 && data.last_name.length > 0; - + renderItem = ({item}) => { return ( - this.completeMention(data.username)} - style={style.row} - > - - - - {`@${data.username}`} - {hasFullName && {' - '}} - {hasFullName && {`${data.first_name} ${data.last_name}`}} - + ); }; - renderSpecialMentions = (data) => { - const style = getStyleFromTheme(this.props.theme); - + renderSpecialMentions = ({item}) => { return ( - this.completeMention(data.completeHandle)} - style={style.row} - > - - - - - {`@${data.completeHandle}`} - {' - '} - - - + ); }; render() { - const {autocompleteUsers, requestStatus} = this.props; - if ((!this.state.active && (requestStatus !== RequestStatus.STARTED || requestStatus !== RequestStatus.SUCCESS)) || this.state.mentionComplete) { + const {isSearch, theme} = this.props; + const {mentionComplete, sections} = this.state; + + if (sections.length === 0 || mentionComplete) { // If we are not in an active state or the mention has been completed return null so nothing is rendered // other components are not blocked. return null; } - const style = getStyleFromTheme(this.props.theme); - - if ( - !autocompleteUsers.inChannel && - !autocompleteUsers.outChannel && - requestStatus === RequestStatus.STARTED - ) { - return ( - - - - ); - } + const style = getStyleFromTheme(theme); return ( - ); } @@ -300,72 +221,11 @@ export default class AtMention extends Component { const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { return { - section: { - justifyContent: 'center', - paddingLeft: 8, - backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), - borderTopWidth: 1, - borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), - borderLeftWidth: 1, - borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2), - borderRightWidth: 1, - borderRightColor: changeOpacity(theme.centerChannelColor, 0.2) - }, - sectionText: { - fontSize: 12, - color: changeOpacity(theme.centerChannelColor, 0.7), - paddingVertical: 7 - }, - sectionWrapper: { - backgroundColor: theme.centerChannelBg - }, listView: { - flex: 1, backgroundColor: theme.centerChannelBg }, - loading: { - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 20, - backgroundColor: theme.centerChannelBg, - borderWidth: 1, - borderColor: changeOpacity(theme.centerChannelColor, 0.2), - borderBottomWidth: 0 - }, - row: { - paddingVertical: 8, - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.centerChannelBg, - borderTopWidth: 1, - borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), - borderLeftWidth: 1, - borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2), - borderRightWidth: 1, - borderRightColor: changeOpacity(theme.centerChannelColor, 0.2) - }, - rowIcon: { - color: changeOpacity(theme.centerChannelColor, 0.7), - fontSize: 14 - }, - rowPicture: { - marginHorizontal: 8, - width: 20, - alignItems: 'center', - justifyContent: 'center' - }, - rowUsername: { - fontSize: 13, - color: theme.centerChannelColor - }, - rowFullname: { - color: theme.centerChannelColor, - opacity: 0.6 - }, - textWrapper: { - flex: 1, - flexWrap: 'wrap', - paddingRight: 8 + search: { + height: 250 } }; }); diff --git a/app/components/autocomplete/at_mention/index.js b/app/components/autocomplete/at_mention/index.js index 030be6fe6..d6efb75a0 100644 --- a/app/components/autocomplete/at_mention/index.js +++ b/app/components/autocomplete/at_mention/index.js @@ -4,21 +4,29 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {getTheme} from 'app/selectors/preferences'; import {autocompleteUsers} from 'mattermost-redux/actions/users'; -import {getDefaultChannel} from 'mattermost-redux/selectors/entities/channels'; -import {getProfilesInCurrentChannel, getProfilesNotInCurrentChannel} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentChannelId, getDefaultChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; + +import { + filterMembersInChannel, + filterMembersNotInChannel, + filterMembersInCurrentTeam, + getMatchTermForAtMention +} from 'app/selectors/autocomplete'; +import {getTheme} from 'app/selectors/preferences'; import AtMention from './at_mention'; function mapStateToProps(state, ownProps) { - const {currentChannelId} = state.entities.channels; + const {cursorPosition, isSearch, rootId} = ownProps; + const currentChannelId = getCurrentChannelId(state); let postDraft; - if (ownProps.isSearch) { + if (isSearch) { postDraft = state.views.search; } else if (ownProps.rootId) { - const threadDraft = state.views.thread.drafts[ownProps.rootId]; + const threadDraft = state.views.thread.drafts[rootId]; if (threadDraft) { postDraft = threadDraft.draft; } @@ -29,16 +37,28 @@ function mapStateToProps(state, ownProps) { } } + const value = postDraft.substring(0, cursorPosition); + const matchTerm = getMatchTermForAtMention(value, isSearch); + + let teamMembers; + let inChannel; + let outChannel; + if (isSearch) { + teamMembers = filterMembersInCurrentTeam(state, matchTerm); + } else { + inChannel = filterMembersInChannel(state, matchTerm); + outChannel = filterMembersNotInChannel(state, matchTerm); + } + return { - currentUserId: state.entities.users.currentUserId, currentChannelId, - currentTeamId: state.entities.teams.currentTeamId, + currentTeamId: getCurrentTeamId(state), defaultChannel: getDefaultChannel(state), postDraft, - autocompleteUsers: { - inChannel: getProfilesInCurrentChannel(state), - outChannel: getProfilesNotInCurrentChannel(state) - }, + matchTerm, + teamMembers, + inChannel, + outChannel, requestStatus: state.requests.users.autocompleteUsers.status, theme: getTheme(state), ...ownProps diff --git a/app/components/autocomplete/at_mention_item/at_mention_item.js b/app/components/autocomplete/at_mention_item/at_mention_item.js new file mode 100644 index 000000000..5aa6b2659 --- /dev/null +++ b/app/components/autocomplete/at_mention_item/at_mention_item.js @@ -0,0 +1,92 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import { + Text, + TouchableOpacity, + View +} from 'react-native'; + +import ProfilePicture from 'app/components/profile_picture'; +import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; + +export default class AtMentionItem extends PureComponent { + static propTypes = { + firstName: PropTypes.string, + lastName: PropTypes.string, + onPress: PropTypes.func.isRequired, + userId: PropTypes.string.isRequired, + username: PropTypes.string, + theme: PropTypes.object.isRequired + }; + + completeMention = () => { + const {onPress, username} = this.props; + onPress(username); + }; + + render() { + const { + firstName, + lastName, + userId, + username, + theme + } = this.props; + + const style = getStyleFromTheme(theme); + const hasFullName = firstName.length > 0 && lastName.length > 0; + + return ( + + + + + {`@${username}`} + {hasFullName && {' - '}} + {hasFullName && {`${firstName} ${lastName}`}} + + ); + } +} +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + row: { + paddingVertical: 8, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + borderTopWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), + borderLeftWidth: 1, + borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRightWidth: 1, + borderRightColor: changeOpacity(theme.centerChannelColor, 0.2) + }, + rowPicture: { + marginHorizontal: 8, + width: 20, + alignItems: 'center', + justifyContent: 'center' + }, + rowUsername: { + fontSize: 13, + color: theme.centerChannelColor + }, + rowFullname: { + color: theme.centerChannelColor, + opacity: 0.6 + } + }; +}); diff --git a/app/components/autocomplete/at_mention_item/index.js b/app/components/autocomplete/at_mention_item/index.js new file mode 100644 index 000000000..93f0de737 --- /dev/null +++ b/app/components/autocomplete/at_mention_item/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 {getUser} from 'mattermost-redux/selectors/entities/users'; + +import {getTheme} from 'app/selectors/preferences'; + +import AtMentionItem from './at_mention_item'; + +function mapStateToProps(state, ownProps) { + const user = getUser(state, ownProps.userId); + + return { + firstName: user.first_name, + lastName: user.last_name, + username: user.username, + theme: getTheme(state), + ...ownProps + }; +} + +export default connect(mapStateToProps)(AtMentionItem); diff --git a/app/components/autocomplete/autocomplete_section_header.js b/app/components/autocomplete/autocomplete_section_header.js new file mode 100644 index 000000000..041b87be0 --- /dev/null +++ b/app/components/autocomplete/autocomplete_section_header.js @@ -0,0 +1,58 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; + +import FormattedText from 'app/components/formatted_text'; +import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; + +export default class AutocompleteSectionHeader extends PureComponent { + static propTypes = { + defaultMessage: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + theme: PropTypes.object.isRequired + }; + + render() { + const {defaultMessage, id, theme} = this.props; + const style = getStyleFromTheme(theme); + + return ( + + + + + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + section: { + justifyContent: 'center', + paddingLeft: 8, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + borderTopWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), + borderLeftWidth: 1, + borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRightWidth: 1, + borderRightColor: changeOpacity(theme.centerChannelColor, 0.2) + }, + sectionText: { + fontSize: 12, + color: changeOpacity(theme.centerChannelColor, 0.7), + paddingVertical: 7 + }, + sectionWrapper: { + backgroundColor: theme.centerChannelBg + } + }; +}); diff --git a/app/components/autocomplete/channel_mention/channel_mention.js b/app/components/autocomplete/channel_mention/channel_mention.js index c13aa9892..af898187c 100644 --- a/app/components/autocomplete/channel_mention/channel_mention.js +++ b/app/components/autocomplete/channel_mention/channel_mention.js @@ -1,37 +1,34 @@ // Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React, {Component} from 'react'; +import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import { - ListView, - Text, - TouchableOpacity, - View -} from 'react-native'; - -import FormattedText from 'app/components/formatted_text'; -import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; +import {SectionList} from 'react-native'; import {RequestStatus} from 'mattermost-redux/constants'; -const CHANNEL_MENTION_REGEX = /\B(~([^~\r\n]*))$/i; -const CHANNEL_SEARCH_REGEX = /\b(?:in|channel):\s*(\S*)$/i; +import {CHANNEL_MENTION_REGEX, CHANNEL_MENTION_SEARCH_REGEX} from 'app/constants/autocomplete'; +import AutocompleteSectionHeader from 'app/components/autocomplete/autocomplete_section_header'; +import ChannelMentionItem from 'app/components/autocomplete/channel_mention_item'; +import {makeStyleSheetFromTheme} from 'app/utils/theme'; -export default class ChannelMention extends Component { +export default class ChannelMention extends PureComponent { static propTypes = { - currentChannelId: PropTypes.string.isRequired, - currentTeamId: PropTypes.string.isRequired, - cursorPosition: PropTypes.number.isRequired, - autocompleteChannels: PropTypes.object.isRequired, - postDraft: PropTypes.string, - isSearch: PropTypes.bool, - requestStatus: PropTypes.string.isRequired, - theme: PropTypes.object.isRequired, - onChangeText: PropTypes.func.isRequired, actions: PropTypes.shape({ searchChannels: PropTypes.func.isRequired - }) + }).isRequired, + currentTeamId: PropTypes.string.isRequired, + cursorPosition: PropTypes.number.isRequired, + isSearch: PropTypes.bool, + matchTerm: PropTypes.string, + myChannels: PropTypes.array, + otherChannels: PropTypes.array, + onChangeText: PropTypes.func.isRequired, + postDraft: PropTypes.string, + privateChannels: PropTypes.array, + publicChannels: PropTypes.array, + requestStatus: PropTypes.string.isRequired, + theme: PropTypes.object.isRequired }; static defaultProps = { @@ -42,97 +39,82 @@ export default class ChannelMention extends Component { constructor(props) { super(props); - const ds = new ListView.DataSource({ - sectionHeaderHasChanged: (s1, s2) => s1 !== s2, - rowHasChanged: (r1, r2) => r1 !== r2 - }); - this.state = { - active: false, - dataSource: ds.cloneWithRowsAndSections(props.autocompleteChannels) + sections: [] }; } componentWillReceiveProps(nextProps) { - const {isSearch} = nextProps; - const regex = isSearch ? CHANNEL_SEARCH_REGEX : CHANNEL_MENTION_REGEX; - const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(regex); + const {isSearch, matchTerm, myChannels, otherChannels, privateChannels, publicChannels, requestStatus} = nextProps; - // If not match or if user clicked on a channel - if (!match || this.state.mentionComplete) { - const nextState = { - active: false, - mentionComplete: false - }; - - // Handle the case where the user typed a ~ first and then backspaced - if (nextProps.postDraft.length < this.props.postDraft.length) { - nextState.matchTerm = null; - } - - this.setState(nextState); + if ((matchTerm !== this.props.matchTerm && matchTerm === null) || this.state.mentionComplete) { + // if the term changes but is null or the mention has been completed we render this component as null + this.setState({ + mentionComplete: false, + sections: [] + }); + return; + } else if (matchTerm === null) { + // if the terms did not change but is null then we don't need to do anything return; } - const matchTerm = isSearch ? match[1] : match[2]; - const myChannels = this.filter(nextProps.autocompleteChannels.myChannels, matchTerm); - const otherChannels = this.filter(nextProps.autocompleteChannels.otherChannels, matchTerm); - - // Show loading indicator on first pull for channels - if (nextProps.requestStatus === RequestStatus.STARTED && ((myChannels.length === 0 && otherChannels.length === 0) || matchTerm === '')) { - this.setState({ - active: true, - loading: true - }); - return; - } - - // Still matching the same term that didn't return any results - let startsWith; - if (isSearch) { - startsWith = match[0].startsWith(`in:${this.state.matchTerm}`) || match[0].startsWith(`channel:${this.state.matchTerm}`); - } else { - startsWith = match[0].startsWith(`~${this.state.matchTerm}`); - } - - if (startsWith && (myChannels.length === 0 && otherChannels.length === 0)) { - this.setState({ - active: false - }); - return; - } - - if (matchTerm !== this.state.matchTerm) { - this.setState({ - matchTerm - }); - + if (matchTerm !== this.props.matchTerm) { + // if the term changed and we haven't made the request do that first const {currentTeamId} = this.props; this.props.actions.searchChannels(currentTeamId, matchTerm); return; } - if (nextProps.requestStatus !== RequestStatus.STARTED && this.props.autocompleteChannels !== nextProps.autocompleteChannels) { - let data = {}; - if (myChannels.length > 0) { - data = Object.assign({}, data, {myChannels}); - } - if (otherChannels.length > 0) { - data = Object.assign({}, data, {otherChannels}); + if (requestStatus !== RequestStatus.STARTED && + (myChannels !== this.props.myChannels || otherChannels !== this.props.otherChannels || + privateChannels !== this.props.privateChannels || publicChannels !== this.props.publicChannels)) { + // if the request is complete and the term is not null we show the autocomplete + const sections = []; + if (isSearch) { + if (publicChannels.length) { + sections.push({ + id: 'suggestion.search.public', + defaultMessage: 'Public Channels', + data: publicChannels, + key: 'publicChannels' + }); + } + + if (privateChannels.length) { + sections.push({ + id: 'suggestion.search.private', + defaultMessage: 'Private Channels', + data: privateChannels, + key: 'privateChannels' + }); + } + } else { + if (myChannels.length) { + sections.push({ + id: 'suggestion.mention.channels', + defaultMessage: 'My Channels', + data: myChannels, + key: 'myChannels' + }); + } + + if (otherChannels.length) { + sections.push({ + id: 'suggestion.mention.morechannels', + defaultMessage: 'Other Channels', + data: otherChannels, + key: 'otherChannels' + }); + } } this.setState({ - active: true, - loading: false, - dataSource: this.state.dataSource.cloneWithRowsAndSections(data) + sections }); } } - filter = (channels, matchTerm) => { - return channels.filter((c) => c.name.includes(matchTerm) || c.display_name.includes(matchTerm)); - }; - completeMention = (mention) => { const {cursorPosition, isSearch, onChangeText, postDraft} = this.props; const mentionPart = postDraft.substring(0, cursorPosition); @@ -140,7 +122,7 @@ export default class ChannelMention extends Component { let completedDraft; if (isSearch) { const channelOrIn = mentionPart.includes('in:') ? 'in:' : 'channel:'; - completedDraft = mentionPart.replace(CHANNEL_SEARCH_REGEX, `${channelOrIn} ${mention} `); + completedDraft = mentionPart.replace(CHANNEL_MENTION_SEARCH_REGEX, `${channelOrIn} ${mention} `); } else { completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~${mention} `); } @@ -150,87 +132,53 @@ export default class ChannelMention extends Component { } onChangeText(completedDraft, true); - this.setState({ - active: false, - mentionComplete: true, - matchTerm: `${mention} ` - }); + this.setState({mentionComplete: true}); }; - renderSectionHeader = (sectionData, sectionId) => { - const style = getStyleFromTheme(this.props.theme); - - const localization = { - myChannels: { - id: 'suggestion.mention.channels', - defaultMessage: 'My Channels' - }, - otherChannels: { - id: 'suggestion.mention.morechannels', - defaultMessage: 'Other Channels' - } - }; + keyExtractor = (item) => { + return item.id || item; + }; + renderSectionHeader = ({section}) => { return ( - - - - - + ); }; - renderRow = (data) => { - const style = getStyleFromTheme(this.props.theme); - + renderItem = ({item}) => { return ( - this.completeMention(data.name)} - style={style.row} - > - {data.display_name} - {` (~${data.name})`} - + ); }; render() { - if (!this.state.active || this.state.mentionComplete) { + const {isSearch, theme} = this.props; + const {mentionComplete, sections} = this.state; + + if (sections.length === 0 || mentionComplete) { // If we are not in an active state or the mention has been completed return null so nothing is rendered // other components are not blocked. return null; } - const {requestStatus, theme} = this.props; - const style = getStyleFromTheme(theme); - if (this.state.loading && requestStatus === RequestStatus.STARTED) { - return ( - - - - ); - } - return ( - ); } @@ -238,57 +186,11 @@ export default class ChannelMention extends Component { const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { return { - section: { - justifyContent: 'center', - paddingLeft: 8, - backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), - borderTopWidth: 1, - borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), - borderLeftWidth: 1, - borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2), - borderRightWidth: 1, - borderRightColor: changeOpacity(theme.centerChannelColor, 0.2) - }, - sectionText: { - fontSize: 12, - color: changeOpacity(theme.centerChannelColor, 0.7), - paddingVertical: 7 - }, - sectionWrapper: { - backgroundColor: theme.centerChannelBg - }, listView: { - flex: 1, backgroundColor: theme.centerChannelBg }, - loading: { - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 20, - backgroundColor: theme.centerChannelBg, - borderWidth: 1, - borderColor: changeOpacity(theme.centerChannelColor, 0.2), - borderBottomWidth: 0 - }, - row: { - padding: 8, - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.centerChannelBg, - borderTopWidth: 1, - borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), - borderLeftWidth: 1, - borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2), - borderRightWidth: 1, - borderRightColor: changeOpacity(theme.centerChannelColor, 0.2) - }, - rowDisplayName: { - fontSize: 13, - color: theme.centerChannelColor - }, - rowName: { - color: theme.centerChannelColor, - opacity: 0.6 + search: { + height: 250 } }; }); diff --git a/app/components/autocomplete/channel_mention/index.js b/app/components/autocomplete/channel_mention/index.js index bea3809a3..c2ade4959 100644 --- a/app/components/autocomplete/channel_mention/index.js +++ b/app/components/autocomplete/channel_mention/index.js @@ -5,21 +5,29 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import {searchChannels} from 'mattermost-redux/actions/channels'; -import {General} from 'mattermost-redux/constants'; -import {getMyChannels, getOtherChannels} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import { + filterMyChannels, + filterOtherChannels, + filterPublicChannels, + filterPrivateChannels, + getMatchTermForChannelMention +} from 'app/selectors/autocomplete'; import {getTheme} from 'app/selectors/preferences'; import ChannelMention from './channel_mention'; function mapStateToProps(state, ownProps) { - const {currentChannelId} = state.entities.channels; + const {cursorPosition, isSearch, rootId} = ownProps; + const currentChannelId = getCurrentChannelId(state); let postDraft; - if (ownProps.isSearch) { + if (isSearch) { postDraft = state.views.search; - } else if (ownProps.rootId) { - const threadDraft = state.views.thread.drafts[ownProps.rootId]; + } else if (rootId) { + const threadDraft = state.views.thread.drafts[rootId]; if (threadDraft) { postDraft = threadDraft.draft; } @@ -30,17 +38,30 @@ function mapStateToProps(state, ownProps) { } } - const autocompleteChannels = { - myChannels: getMyChannels(state).filter((c) => c.type !== General.DM_CHANNEL && c.type !== General.GM_CHANNEL), - otherChannels: getOtherChannels(state) - }; + const value = postDraft.substring(0, cursorPosition); + const matchTerm = getMatchTermForChannelMention(value, isSearch); + + let myChannels; + let otherChannels; + let publicChannels; + let privateChannels; + if (isSearch) { + publicChannels = filterPublicChannels(state, matchTerm); + privateChannels = filterPrivateChannels(state, matchTerm); + } else { + myChannels = filterMyChannels(state, matchTerm); + otherChannels = filterOtherChannels(state, matchTerm); + } return { ...ownProps, - currentChannelId, - currentTeamId: state.entities.teams.currentTeamId, + myChannels, + otherChannels, + publicChannels, + privateChannels, + currentTeamId: getCurrentTeamId(state), + matchTerm, postDraft, - autocompleteChannels, requestStatus: state.requests.channels.getChannels.status, theme: getTheme(state) }; diff --git a/app/components/autocomplete/channel_mention_item/channel_mention_item.js b/app/components/autocomplete/channel_mention_item/channel_mention_item.js new file mode 100644 index 000000000..119ff5ecd --- /dev/null +++ b/app/components/autocomplete/channel_mention_item/channel_mention_item.js @@ -0,0 +1,72 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import { + Text, + TouchableOpacity +} from 'react-native'; + +import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; + +export default class ChannelMentionItem extends PureComponent { + static propTypes = { + channelId: PropTypes.string.isRequired, + displayName: PropTypes.string, + name: PropTypes.string, + onPress: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired + }; + + completeMention = () => { + const {onPress, name} = this.props; + onPress(name); + }; + + render() { + const { + channelId, + displayName, + name, + theme + } = this.props; + + const style = getStyleFromTheme(theme); + + return ( + + {displayName} + {` (~${name})`} + + ); + } +} +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + row: { + padding: 8, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + borderTopWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), + borderLeftWidth: 1, + borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRightWidth: 1, + borderRightColor: changeOpacity(theme.centerChannelColor, 0.2) + }, + rowDisplayName: { + fontSize: 13, + color: theme.centerChannelColor + }, + rowName: { + color: theme.centerChannelColor, + opacity: 0.6 + } + }; +}); diff --git a/app/components/autocomplete/channel_mention_item/index.js b/app/components/autocomplete/channel_mention_item/index.js new file mode 100644 index 000000000..dc248578b --- /dev/null +++ b/app/components/autocomplete/channel_mention_item/index.js @@ -0,0 +1,23 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getChannel} from 'mattermost-redux/selectors/entities/channels'; + +import {getTheme} from 'app/selectors/preferences'; + +import ChannelMentionItem from './channel_mention_item'; + +function mapStateToProps(state, ownProps) { + const channel = getChannel(state, ownProps.channelId); + + return { + displayName: channel.display_name, + name: channel.name, + theme: getTheme(state), + ...ownProps + }; +} + +export default connect(mapStateToProps)(ChannelMentionItem); diff --git a/app/components/autocomplete/index.js b/app/components/autocomplete/index.js index 6f365bf76..96001bdfb 100644 --- a/app/components/autocomplete/index.js +++ b/app/components/autocomplete/index.js @@ -68,10 +68,14 @@ const style = StyleSheet.create({ overflow: 'hidden' }, searchContainer: { - backgroundColor: 'white', elevation: 5, flex: 1, + left: 0, + maxHeight: 250, + overflow: 'hidden', position: 'absolute', + right: 0, + zIndex: 5, ...Platform.select({ android: { top: 47 @@ -79,11 +83,6 @@ const style = StyleSheet.create({ ios: { top: 64 } - }), - left: 0, - right: 0, - maxHeight: 250, - overflow: 'hidden', - zIndex: 5 + }) } }); diff --git a/app/components/autocomplete/special_mention_item.js b/app/components/autocomplete/special_mention_item.js new file mode 100644 index 000000000..ebba3ab91 --- /dev/null +++ b/app/components/autocomplete/special_mention_item.js @@ -0,0 +1,106 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import { + Text, + TouchableOpacity, + View +} from 'react-native'; +import Icon from 'react-native-vector-icons/FontAwesome'; + +import FormattedText from 'app/components/formatted_text'; +import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; + +export default class SpecialMentionItem extends PureComponent { + static propTypes = { + completeHandle: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, + values: PropTypes.object + }; + + completeMention = () => { + const {onPress, completeHandle} = this.props; + onPress(completeHandle); + }; + + render() { + const { + defaultMessage, + id, + completeHandle, + theme, + values + } = this.props; + + const style = getStyleFromTheme(theme); + + return ( + + + + + + {`@${completeHandle}`} + {' - '} + + + + ); + } +} +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + row: { + paddingVertical: 8, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + borderTopWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), + borderLeftWidth: 1, + borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRightWidth: 1, + borderRightColor: changeOpacity(theme.centerChannelColor, 0.2) + }, + rowPicture: { + marginHorizontal: 8, + width: 20, + alignItems: 'center', + justifyContent: 'center' + }, + rowIcon: { + color: changeOpacity(theme.centerChannelColor, 0.7), + fontSize: 14 + }, + rowUsername: { + fontSize: 13, + color: theme.centerChannelColor + }, + rowFullname: { + color: theme.centerChannelColor, + flex: 1, + opacity: 0.6 + }, + textWrapper: { + flex: 1, + flexWrap: 'wrap', + paddingRight: 8 + } + }; +}); diff --git a/app/components/channel_intro/channel_intro.js b/app/components/channel_intro/channel_intro.js index 1d62f9500..5c79d5f71 100644 --- a/app/components/channel_intro/channel_intro.js +++ b/app/components/channel_intro/channel_intro.js @@ -71,7 +71,7 @@ class ChannelIntro extends PureComponent { style={style.profile} > diff --git a/app/components/post_profile_picture/post_profile_picture.js b/app/components/post_profile_picture/post_profile_picture.js index 73ba0b672..79a2e15a8 100644 --- a/app/components/post_profile_picture/post_profile_picture.js +++ b/app/components/post_profile_picture/post_profile_picture.js @@ -51,7 +51,7 @@ function PostProfilePicture(props) { return ( ); @@ -60,7 +60,7 @@ function PostProfilePicture(props) { return ( diff --git a/app/components/profile_picture/index.js b/app/components/profile_picture/index.js index d68a54a1d..15e940559 100644 --- a/app/components/profile_picture/index.js +++ b/app/components/profile_picture/index.js @@ -6,19 +6,21 @@ import {connect} from 'react-redux'; import {getTheme} from 'app/selectors/preferences'; import {getStatusesByIdsBatchedDebounced} from 'mattermost-redux/actions/users'; -import {getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; +import {getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users'; import ProfilePicture from './profile_picture'; function mapStateToProps(state, ownProps) { let status = ownProps.status; - if (!status && ownProps.user) { - status = ownProps.user.status || getStatusForUserId(state, ownProps.user.id); + const user = getUser(state, ownProps.userId); + if (!status && ownProps.userId) { + status = getStatusForUserId(state, ownProps.userId); } return { theme: ownProps.theme || getTheme(state), status, + user, ...ownProps }; } diff --git a/app/constants/autocomplete.js b/app/constants/autocomplete.js new file mode 100644 index 000000000..c72599a85 --- /dev/null +++ b/app/constants/autocomplete.js @@ -0,0 +1,10 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export const AT_MENTION_REGEX = /\B(@([^@\r\n\s]*))$/i; + +export const AT_MENTION_SEARCH_REGEX = /\bfrom:\s*(\S*)$/i; + +export const CHANNEL_MENTION_REGEX = /\B(~([^~\r\n]*))$/i; + +export const CHANNEL_MENTION_SEARCH_REGEX = /\b(?:in|channel):\s*(\S*)$/i; diff --git a/app/screens/notification/notification.js b/app/screens/notification/notification.js index 4b2966797..88ba223c7 100644 --- a/app/screens/notification/notification.js +++ b/app/screens/notification/notification.js @@ -80,7 +80,7 @@ export default class Notification extends PureComponent { } else if (user) { icon = ( ); diff --git a/app/screens/user_profile/user_profile.js b/app/screens/user_profile/user_profile.js index a0a7ecf47..53006eefe 100644 --- a/app/screens/user_profile/user_profile.js +++ b/app/screens/user_profile/user_profile.js @@ -115,7 +115,7 @@ class UserProfile extends PureComponent { > { + let lastMatchTerm = null; + let lastValue; + let lastIsSearch; + return (value, isSearch) => { + if (value !== lastValue || isSearch !== lastIsSearch) { + const regex = isSearch ? Autocomplete.AT_MENTION_SEARCH_REGEX : Autocomplete.AT_MENTION_REGEX; + const match = value.match(regex); + lastValue = value; + lastIsSearch = isSearch; + if (match) { + lastMatchTerm = isSearch ? match[1] : match[2]; + } else { + lastMatchTerm = null; + } + } + return lastMatchTerm; + }; +})(); + +export const getMatchTermForChannelMention = (() => { + let lastMatchTerm = null; + let lastValue; + let lastIsSearch; + return (value, isSearch) => { + if (value !== lastValue || isSearch !== lastIsSearch) { + const regex = isSearch ? Autocomplete.CHANNEL_MENTION_SEARCH_REGEX : Autocomplete.CHANNEL_MENTION_REGEX; + const match = value.match(regex); + lastValue = value; + lastIsSearch = isSearch; + if (match) { + lastMatchTerm = isSearch ? match[1] : match[2]; + } else { + lastMatchTerm = null; + } + } + return lastMatchTerm; + }; +})(); + +export const filterMembersInChannel = createSelector( + getProfilesInCurrentChannel, + getCurrentUserId, + (state, matchTerm) => matchTerm, + (profilesInChannel, currentUserId, matchTerm) => { + let profiles; + if (matchTerm) { + profiles = profilesInChannel.filter((p) => { + return ((p.id !== currentUserId) && ( + p.username.toLowerCase().includes(matchTerm) || p.email.toLowerCase().includes(matchTerm) || + p.first_name.toLowerCase().includes(matchTerm) || p.last_name.toLowerCase().includes(matchTerm))); + }); + } else { + profiles = profilesInChannel.filter((p) => p.id !== currentUserId); + } + + // already sorted + return profiles.map((p) => p.id); + } +); + +export const filterMembersNotInChannel = createSelector( + getProfilesNotInCurrentChannel, + getCurrentUserId, + (state, matchTerm) => matchTerm, + (profilesNotInChannel, currentUserId, matchTerm) => { + let profiles; + if (matchTerm) { + profiles = profilesNotInChannel.filter((p) => { + return ((p.id !== currentUserId) && ( + p.username.toLowerCase().includes(matchTerm) || p.email.toLowerCase().includes(matchTerm) || + p.first_name.toLowerCase().includes(matchTerm) || p.last_name.toLowerCase().includes(matchTerm))); + }); + } else { + profiles = profilesNotInChannel; + } + + return profiles.map((p) => p.id); + } +); + +export const filterMembersInCurrentTeam = createSelector( + getProfilesInCurrentTeam, + getCurrentUser, + (state, matchTerm) => matchTerm, + (profilesInTeam, currentUser, matchTerm) => { + // FIXME: We need to include the currentUser here as is not in profilesInTeam on the redux store + let profiles; + if (matchTerm) { + profiles = [...profilesInTeam, currentUser].filter((p) => { + return (p.username.toLowerCase().includes(matchTerm) || p.email.toLowerCase().includes(matchTerm) || + p.first_name.toLowerCase().includes(matchTerm) || p.last_name.toLowerCase().includes(matchTerm)); + }); + } else { + profiles = [...profilesInTeam, currentUser]; + } + + return profiles.sort(sortByUsername).map((p) => p.id); + } +); + +export const filterMyChannels = createSelector( + getMyChannels, + (state, opts) => opts, + (myChannels, matchTerm) => { + let channels; + if (matchTerm) { + channels = myChannels.filter((c) => { + return (c.type === General.OPEN_CHANNEL || c.type === General.PRIVATE_CHANNEL) && + (c.name.startsWith(matchTerm) || c.display_name.startsWith(matchTerm)); + }); + } else { + channels = myChannels.filter((c) => { + return (c.type === General.OPEN_CHANNEL || c.type === General.PRIVATE_CHANNEL); + }); + } + + return channels.map((c) => c.id); + } +); + +export const filterOtherChannels = createSelector( + getOtherChannels, + (state, matchTerm) => matchTerm, + (otherChannels, matchTerm) => { + let channels; + if (matchTerm) { + channels = otherChannels.filter((c) => { + return (c.name.startsWith(matchTerm) || c.display_name.startsWith(matchTerm)); + }); + } else { + channels = otherChannels; + } + + return channels.map((c) => c.id); + } +); + +export const filterPublicChannels = createSelector( + getMyChannels, + getOtherChannels, + getCurrentLocale, + (state, matchTerm) => matchTerm, + (myChannels, otherChannels, locale, matchTerm) => { + let channels; + if (matchTerm) { + channels = myChannels.filter((c) => { + return c.type === General.OPEN_CHANNEL && + (c.name.startsWith(matchTerm) || c.display_name.startsWith(matchTerm)); + }).concat( + otherChannels.filter((c) => c.name.startsWith(matchTerm) || c.display_name.startsWith(matchTerm)) + ); + } else { + channels = myChannels.filter((c) => { + return (c.type === General.OPEN_CHANNEL || c.type === General.PRIVATE_CHANNEL); + }).concat(otherChannels); + } + + return channels.sort(sortChannelsByDisplayName.bind(null, locale)).map((c) => c.id); + } +); + +export const filterPrivateChannels = createSelector( + getMyChannels, + (state, matchTerm) => matchTerm, + (myChannels, matchTerm) => { + let channels; + if (matchTerm) { + channels = myChannels.filter((c) => { + return c.type === General.PRIVATE_CHANNEL && + (c.name.startsWith(matchTerm) || c.display_name.startsWith(matchTerm)); + }); + } else { + channels = myChannels.filter((c) => { + return c.type === General.PRIVATE_CHANNEL; + }); + } + + return channels.map((c) => c.id); + } +);