diff options
Diffstat (limited to 'webapp/components')
-rw-r--r-- | webapp/components/admin_console/multiselect_settings.jsx | 2 | ||||
-rw-r--r-- | webapp/components/channel_header.jsx | 78 | ||||
-rw-r--r-- | webapp/components/channel_switch_modal.jsx | 4 | ||||
-rw-r--r-- | webapp/components/more_direct_channels.jsx | 308 | ||||
-rw-r--r-- | webapp/components/multiselect/multiselect.jsx | 257 | ||||
-rw-r--r-- | webapp/components/multiselect/multiselect_list.jsx | 169 | ||||
-rw-r--r-- | webapp/components/navbar.jsx | 69 | ||||
-rw-r--r-- | webapp/components/popover_list_members.jsx | 2 | ||||
-rw-r--r-- | webapp/components/profile_popover.jsx | 2 | ||||
-rw-r--r-- | webapp/components/sidebar.jsx | 77 | ||||
-rw-r--r-- | webapp/components/status_icon.jsx | 7 | ||||
-rw-r--r-- | webapp/components/suggestion/switch_channel_provider.jsx | 13 |
12 files changed, 825 insertions, 163 deletions
diff --git a/webapp/components/admin_console/multiselect_settings.jsx b/webapp/components/admin_console/multiselect_settings.jsx index 8aad5d6eb..2beebb337 100644 --- a/webapp/components/admin_console/multiselect_settings.jsx +++ b/webapp/components/admin_console/multiselect_settings.jsx @@ -76,4 +76,4 @@ MultiSelectSetting.propTypes = { noResultText: React.PropTypes.node, errorText: React.PropTypes.node, notPresent: React.PropTypes.node -};
\ No newline at end of file +}; diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx index 341c9be1b..9be2d5b58 100644 --- a/webapp/components/channel_header.jsx +++ b/webapp/components/channel_header.jsx @@ -32,7 +32,9 @@ import {getSiteURL} from 'utils/url.jsx'; import * as TextFormatting from 'utils/text_formatting.jsx'; import {getFlaggedPosts} from 'actions/post_actions.jsx'; -import {Constants, Preferences, UserStatuses} from 'utils/constants.jsx'; +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; + +import {Constants, Preferences, UserStatuses, ActionTypes} from 'utils/constants.jsx'; import React from 'react'; import {FormattedMessage} from 'react-intl'; @@ -53,6 +55,7 @@ export default class ChannelHeader extends React.Component { this.getFlagged = this.getFlagged.bind(this); this.initWebrtc = this.initWebrtc.bind(this); this.onBusy = this.onBusy.bind(this); + this.openDirectMessageModal = this.openDirectMessageModal.bind(this); const state = this.getStateFromStores(); state.showEditChannelPurposeModal = false; @@ -198,6 +201,14 @@ export default class ChannelHeader extends React.Component { this.setState({isBusy}); } + openDirectMessageModal() { + AppDispatcher.handleViewAction({ + type: ActionTypes.TOGGLE_DM_MODAL, + value: true, + startingUsers: UserStore.getProfileListInChannel(this.props.channelId, true) + }); + } + render() { const flagIcon = Constants.FLAG_ICON_SVG; @@ -246,7 +257,8 @@ export default class ChannelHeader extends React.Component { const isAdmin = TeamStore.isTeamAdminForCurrentTeam() || UserStore.isSystemAdminForCurrentUser(); const isSystemAdmin = UserStore.isSystemAdminForCurrentUser(); const isChannelAdmin = ChannelStore.isChannelAdminForCurrentChannel(); - const isDirect = (this.state.channel.type === 'D'); + const isDirect = (this.state.channel.type === Constants.DM_CHANNEL); + const isGroup = (this.state.channel.type === Constants.GM_CHANNEL); let webrtc; if (isDirect) { @@ -319,6 +331,10 @@ export default class ChannelHeader extends React.Component { } } + if (isGroup) { + channelTitle = ChannelUtils.buildGroupChannelName(channel.id); + } + let channelTerm = ( <FormattedMessage id='channel_header.channel' @@ -364,6 +380,64 @@ export default class ChannelHeader extends React.Component { </ToggleModalButton> </li> ); + } else if (isGroup) { + dropdownContents.push( + <li + key='edit_header_direct' + role='presentation' + > + <ToggleModalButton + role='menuitem' + dialogType={EditChannelHeaderModal} + dialogProps={{channel}} + > + <FormattedMessage + id='channel_header.channelHeader' + defaultMessage='Edit Channel Header' + /> + </ToggleModalButton> + </li> + ); + + dropdownContents.push( + <li + key='notification_preferences' + role='presentation' + > + <ToggleModalButton + role='menuitem' + dialogType={ChannelNotificationsModal} + dialogProps={{ + channel, + channelMember: this.state.memberChannel, + currentUser: this.state.currentUser + }} + > + <FormattedMessage + id='channel_header.notificationPreferences' + defaultMessage='Notification Preferences' + /> + </ToggleModalButton> + </li> + ); + + dropdownContents.push( + <li + key='add_members' + role='presentation' + > + <a + role='menuitem' + href='#' + onClick={this.openDirectMessageModal} + > + <FormattedMessage + id='channel_header.addMembers' + defaultMessage='Add Members' + /> + </a> + </li> + ); } else { dropdownContents.push( <li diff --git a/webapp/components/channel_switch_modal.jsx b/webapp/components/channel_switch_modal.jsx index fc66e06b1..2b08ee239 100644 --- a/webapp/components/channel_switch_modal.jsx +++ b/webapp/components/channel_switch_modal.jsx @@ -105,7 +105,7 @@ export default class SwitchChannelModal extends React.Component { if (user) { openDirectChannelToUser( - user, + user.id, (ch) => { channel = ch; this.switchToChannel(channel); @@ -117,7 +117,7 @@ export default class SwitchChannelModal extends React.Component { ); } } else { - channel = ChannelStore.getByName(this.selected.name); + channel = ChannelStore.get(this.selected.id); this.switchToChannel(channel); } } diff --git a/webapp/components/more_direct_channels.jsx b/webapp/components/more_direct_channels.jsx index 13ee50b4d..c4a3a3526 100644 --- a/webapp/components/more_direct_channels.jsx +++ b/webapp/components/more_direct_channels.jsx @@ -1,19 +1,19 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import SearchableUserList from 'components/searchable_user_list.jsx'; -import SpinnerButton from 'components/spinner_button.jsx'; +import MultiSelect from 'components/multiselect/multiselect.jsx'; +import ProfilePicture from 'components/profile_picture.jsx'; import {searchUsers} from 'actions/user_actions.jsx'; -import {openDirectChannelToUser} from 'actions/channel_actions.jsx'; +import {openDirectChannelToUser, openGroupChannelToUsers} from 'actions/channel_actions.jsx'; import UserStore from 'stores/user_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; -import * as UserAgent from 'utils/user_agent.jsx'; -import {localizeMessage} from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; +import {displayUsernameForUser} from 'utils/utils.jsx'; +import Client from 'client/web_client.jsx'; import React from 'react'; import {Modal} from 'react-bootstrap'; @@ -21,6 +21,7 @@ import {FormattedMessage} from 'react-intl'; import {browserHistory} from 'react-router/es6'; const USERS_PER_PAGE = 50; +const MAX_SELECTABLE_VALUES = Constants.MAX_USERS_IN_GM - 1; export default class MoreDirectChannels extends React.Component { constructor(props) { @@ -28,21 +29,31 @@ export default class MoreDirectChannels extends React.Component { this.handleHide = this.handleHide.bind(this); this.handleExit = this.handleExit.bind(this); - this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleDelete = this.handleDelete.bind(this); this.onChange = this.onChange.bind(this); - this.createJoinDirectChannelButton = this.createJoinDirectChannelButton.bind(this); - this.toggleList = this.toggleList.bind(this); - this.nextPage = this.nextPage.bind(this); this.search = this.search.bind(this); + this.addValue = this.addValue.bind(this); this.searchTimeoutId = 0; + this.listType = global.window.mm_config.RestrictDirectMessage; + + const values = []; + if (props.startingUsers) { + for (let i = 0; i < props.startingUsers.length; i++) { + const user = Object.assign({}, props.startingUsers[i]); + user.value = user.id; + user.label = '@' + user.username; + values.push(user); + } + } this.state = { users: null, - loadingDMChannel: -1, - listType: 'team', + values, show: true, - search: false + search: false, + loadingChannel: -1 }; } @@ -50,17 +61,18 @@ export default class MoreDirectChannels extends React.Component { UserStore.addChangeListener(this.onChange); UserStore.addInTeamChangeListener(this.onChange); UserStore.addStatusesChangeListener(this.onChange); - TeamStore.addChangeListener(this.onChange); - AsyncClient.getProfiles(0, Constants.PROFILE_CHUNK_SIZE); - AsyncClient.getProfilesInTeam(TeamStore.getCurrentId(), 0, Constants.PROFILE_CHUNK_SIZE); + if (this.listType === 'any') { + AsyncClient.getProfiles(0, USERS_PER_PAGE * 2); + } else { + AsyncClient.getProfilesInTeam(TeamStore.getCurrentId(), 0, USERS_PER_PAGE * 2); + } } componentWillUnmount() { UserStore.removeChangeListener(this.onChange); UserStore.removeInTeamChangeListener(this.onChange); UserStore.removeStatusesChangeListener(this.onChange); - TeamStore.removeChangeListener(this.onChange); } handleHide() { @@ -68,8 +80,8 @@ export default class MoreDirectChannels extends React.Component { } handleExit() { - if (this.exitToDirectChannel) { - browserHistory.push(this.exitToDirectChannel); + if (this.exitToChannel) { + browserHistory.push(this.exitToChannel); } if (this.props.onModalDismissed) { @@ -77,28 +89,49 @@ export default class MoreDirectChannels extends React.Component { } } - handleShowDirectChannel(teammate, e) { - e.preventDefault(); + handleSubmit(e) { + if (e) { + e.preventDefault(); + } - if (this.state.loadingDMChannel !== -1) { + if (this.state.loadingChannel !== -1) { return; } - this.setState({loadingDMChannel: teammate.id}); - openDirectChannelToUser( - teammate, - (channel) => { - // Due to how react-overlays Modal handles focus, we delay pushing - // the new channel information until the modal is fully exited. - // The channel information will be pushed in `handleExit` - this.exitToDirectChannel = TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + channel.name; - this.setState({loadingDMChannel: -1}); - this.handleHide(); - }, - () => { - this.setState({loadingDMChannel: -1}); - } - ); + const userIds = this.state.values.map((v) => v.id); + if (userIds.length === 0) { + return; + } + + this.setState({loadingChannel: 1}); + + const success = (channel) => { + // Due to how react-overlays Modal handles focus, we delay pushing + // the new channel information until the modal is fully exited. + // The channel information will be pushed in `handleExit` + this.exitToChannel = TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + channel.name; + this.setState({loadingChannel: -1}); + this.handleHide(); + }; + + const error = () => { + this.setState({loadingChannel: -1}); + }; + + if (userIds.length === 1) { + openDirectChannelToUser(userIds[0], success, error); + } else { + openGroupChannelToUsers(userIds, success, error); + } + } + + addValue(value) { + const values = Object.assign([], this.state.values); + if (values.indexOf(value) === -1) { + values.push(value); + } + + this.setState({values}); } onChange(force) { @@ -107,83 +140,69 @@ export default class MoreDirectChannels extends React.Component { } let users; - if (this.state.listType === 'any') { - users = UserStore.getProfileList(true); + if (this.listType === 'any') { + users = Object.assign([], UserStore.getProfileList(true)); } else { - users = UserStore.getProfileListInTeam(TeamStore.getCurrentId(), true, true); + users = Object.assign([], UserStore.getProfileListInTeam(TeamStore.getCurrentId(), true)); } - this.setState({ - users - }); - } - - toggleList(e) { - const listType = e.target.value; - let users; - if (listType === 'any') { - users = UserStore.getProfileList(true); - } else { - users = UserStore.getProfileListInTeam(TeamStore.getCurrentId(), true, true); + for (let i = 0; i < users.length; i++) { + const user = Object.assign({}, users[i]); + user.value = user.id; + user.label = '@' + user.username; + users[i] = user; } this.setState({ - users, - listType + users }); } - createJoinDirectChannelButton({user}) { - return ( - <SpinnerButton - className='btn btm-sm btn-primary' - spinning={this.state.loadingDMChannel === user.id} - onClick={this.handleShowDirectChannel.bind(this, user)} - > - <FormattedMessage - id='more_direct_channels.message' - defaultMessage='Message' - /> - </SpinnerButton> - ); - } - - nextPage(page) { - if (this.state.listType === 'any') { + handlePageChange(page, prevPage) { + if (page > prevPage) { AsyncClient.getProfiles((page + 1) * USERS_PER_PAGE, USERS_PER_PAGE); - } else { - AsyncClient.getProfilesInTeam(TeamStore.getCurrentId(), (page + 1) * USERS_PER_PAGE, USERS_PER_PAGE); } } search(term) { + clearTimeout(this.searchTimeoutId); + if (term === '') { this.onChange(true); this.setState({search: false}); + this.searchTimeoutId = ''; return; } let teamId; - if (this.state.listType === 'any') { + if (this.listType === 'any') { teamId = ''; } else { teamId = TeamStore.getCurrentId(); } - clearTimeout(this.searchTimeoutId); - - this.searchTimeoutId = setTimeout( + const searchTimeoutId = setTimeout( () => { searchUsers( term, teamId, {}, (users) => { + if (searchTimeoutId !== this.searchTimeoutId) { + return; + } + + let indexToDelete = -1; for (let i = 0; i < users.length; i++) { if (users[i].id === UserStore.getCurrentId()) { - users.splice(i, 1); - break; + indexToDelete = i; } + users[i].value = users[i].id; + users[i].label = '@' + users[i].username; + } + + if (indexToDelete !== -1) { + users.splice(indexToDelete, 1); } this.setState({search: true, users}); } @@ -191,44 +210,88 @@ export default class MoreDirectChannels extends React.Component { }, Constants.SEARCH_TIMEOUT_MILLISECONDS ); + + this.searchTimeoutId = searchTimeoutId; } - render() { - let teamToggle; - let memberClass = ''; - if (global.window.mm_config.RestrictDirectMessage === 'any') { - memberClass = 'more-system-members'; - teamToggle = ( - <div className='member-select__container'> - <select - className='form-control' - id='restrictList' - ref='restrictList' - defaultValue='team' - onChange={this.toggleList} - > - <option value='any'> - {localizeMessage('filtered_user_list.any_team', 'All Users')} - </option> - <option value='team'> - {localizeMessage('filtered_user_list.team_only', 'Members of this Team')} - </option> - </select> - <span - className='member-show' - > - <FormattedMessage - id='filtered_user_list.show' - defaultMessage='Filter:' - /> - </span> + handleDelete(values) { + this.setState({values}); + } + + renderOption(option, isSelected, onAdd) { + var rowSelected = ''; + if (isSelected) { + rowSelected = 'more-modal__row--selected'; + } + + return ( + <div + key={option.id} + ref={isSelected ? 'selected' : option.id} + className={'more-modal__row clickable ' + rowSelected} + onClick={() => onAdd(option)} + > + <ProfilePicture + src={`${Client.getUsersRoute()}/${option.id}/image?time=${option.last_picture_update}`} + width='32' + height='32' + /> + <div + className='more-modal__details' + > + <div className='more-modal__name'> + {displayUsernameForUser(option)} + </div> + <div className='more-modal__description'> + {option.email} + </div> </div> - ); + <div className='more-modal__actions'> + <div className='more-modal__actions--round'> + <i className='fa fa-plus'/> + </div> + </div> + </div> + ); + } + + renderValue(user) { + return user.username; + } + + render() { + let note; + if (this.props.startingUsers) { + if (this.state.values && this.state.values.length >= MAX_SELECTABLE_VALUES) { + note = ( + <FormattedMessage + id='more_direct_channels.new_convo_note.full' + defaultMessage='You’ve reached the maximum number of people for this conversation. Consider creating a private group instead.' + /> + ); + } else { + note = ( + <FormattedMessage + id='more_direct_channels.new_convo_note' + defaultMessage='This will start a new conversation. If you’re adding a lot of people, consider creating a private group instead.' + /> + ); + } } + const numRemainingText = ( + <FormattedMessage + id='multiselect.numPeopleRemaining' + defaultMessage='You can add {num, number} more {num, plural, =0 {people} one {person} other {people}}. ' + values={{ + num: MAX_SELECTABLE_VALUES - this.state.values.length + }} + /> + ); + return ( <Modal - dialogClassName={'more-modal more-direct-channels ' + memberClass} + dialogClassName={'more-modal more-direct-channels'} show={this.state.show} onHide={this.handleHide} onExited={this.handleExit} @@ -242,15 +305,21 @@ export default class MoreDirectChannels extends React.Component { </Modal.Title> </Modal.Header> <Modal.Body> - {teamToggle} - <SearchableUserList - key={'moreDirectChannelsList_' + this.state.listType} - users={this.state.users} - usersPerPage={USERS_PER_PAGE} - nextPage={this.nextPage} - search={this.search} - actions={[this.createJoinDirectChannelButton]} - focusOnMount={!UserAgent.isMobile()} + <MultiSelect + key='moreDirectChannelsList' + options={this.state.users} + optionRenderer={this.renderOption} + values={this.state.values} + valueRenderer={this.renderValue} + perPage={USERS_PER_PAGE} + handlePageChange={this.handlePageChange} + handleInput={this.search} + handleDelete={this.handleDelete} + handleAdd={this.addValue} + handleSubmit={this.handleSubmit} + noteText={note} + maxValues={MAX_SELECTABLE_VALUES} + numRemainingText={numRemainingText} /> </Modal.Body> </Modal> @@ -259,5 +328,6 @@ export default class MoreDirectChannels extends React.Component { } MoreDirectChannels.propTypes = { + startingUsers: React.PropTypes.arrayOf(React.PropTypes.object), onModalDismissed: React.PropTypes.func -};
\ No newline at end of file +}; diff --git a/webapp/components/multiselect/multiselect.jsx b/webapp/components/multiselect/multiselect.jsx new file mode 100644 index 000000000..a3e32dccf --- /dev/null +++ b/webapp/components/multiselect/multiselect.jsx @@ -0,0 +1,257 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import MultiSelectList from './multiselect_list.jsx'; + +import {localizeMessage} from 'utils/utils.jsx'; +import Constants from 'utils/constants.jsx'; +const KeyCodes = Constants.KeyCodes; + +import React from 'react'; +import ReactSelect from 'react-select'; +import {FormattedMessage} from 'react-intl'; + +export default class MultiSelect extends React.Component { + constructor(props) { + super(props); + + this.onChange = this.onChange.bind(this); + this.onSelect = this.onSelect.bind(this); + this.onAdd = this.onAdd.bind(this); + this.onInput = this.onInput.bind(this); + this.handleEnterPress = this.handleEnterPress.bind(this); + this.nextPage = this.nextPage.bind(this); + this.prevPage = this.prevPage.bind(this); + + this.selected = null; + + this.state = { + page: 0 + }; + } + + componentDidMount() { + document.addEventListener('keydown', this.handleEnterPress); + this.refs.select.focus(); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.handleEnterPress); + } + + nextPage() { + if (this.props.handlePageChange) { + this.props.handlePageChange(this.state.page + 1, this.state.page); + } + this.refs.list.setSelected(0); + this.setState({page: this.state.page + 1}); + } + + prevPage() { + if (this.state.page === 0) { + return; + } + + if (this.props.handlePageChange) { + this.props.handlePageChange(this.state.page - 1, this.state.page); + } + this.refs.list.setSelected(0); + this.setState({page: this.state.page - 1}); + } + + onSelect(selected) { + this.selected = selected; + } + + onAdd(value) { + if (this.props.maxValues && this.props.values.length >= this.props.maxValues) { + return; + } + + for (let i = 0; i < this.props.values.length; i++) { + if (this.props.values[i].value === value.value) { + return; + } + } + + this.props.handleAdd(value); + this.selected = null; + this.refs.select.handleInputChange({target: {value: ''}}); + this.onInput(''); + this.refs.select.focus(); + } + + onInput(input) { + if (input === '') { + this.refs.list.setSelected(-1); + } else { + this.refs.list.setSelected(0); + } + this.selected = null; + + this.props.handleInput(input); + } + + handleEnterPress(e) { + switch (e.keyCode) { + case KeyCodes.ENTER: + if (this.selected == null) { + this.props.handleSubmit(); + return; + } + this.onAdd(this.selected); + break; + } + } + + onChange(values) { + if (values.length < this.props.values.length) { + this.props.handleDelete(values); + } + } + + render() { + const options = this.props.options; + + let numRemainingText; + if (this.props.numRemainingText) { + numRemainingText = this.props.numRemainingText; + } else if (this.props.maxValues != null) { + numRemainingText = ( + <FormattedMessage + id='multiselect.numRemaining' + defaultMessage='You can add {num, number} more. ' + values={{ + num: this.props.maxValues - this.props.values.length + }} + /> + ); + } + + let optionsToDisplay = []; + let nextButton; + let previousButton; + let noteTextContainer; + + if (this.props.noteText) { + noteTextContainer = ( + <div className='multi-select__note'> + <div className='note__icon'><span className='fa fa-info'/></div> + <div>{this.props.noteText}</div> + </div> + ); + } + + if (options && options.length > this.props.perPage) { + const pageStart = this.state.page * this.props.perPage; + const pageEnd = pageStart + this.props.perPage; + optionsToDisplay = options.slice(pageStart, pageEnd); + + if (options.length > pageEnd) { + nextButton = ( + <button + className='btn btn-default filter-control filter-control__next' + onClick={this.nextPage} + > + <FormattedMessage + id='filtered_user_list.next' + defaultMessage='Next' + /> + </button> + ); + } + + if (this.state.page > 0) { + previousButton = ( + <button + className='btn btn-default filter-control filter-control__prev' + onClick={this.prevPage} + > + <FormattedMessage + id='filtered_user_list.prev' + defaultMessage='Previous' + /> + </button> + ); + } + } else { + optionsToDisplay = options; + } + + return ( + <div className='filtered-user-list'> + <div className='filter-row filter-row--full'> + <div className='multi-select__container'> + <ReactSelect + ref='select' + multi={true} + options={this.props.options} + joinValues={true} + clearable={false} + openOnFocus={true} + onInputChange={this.onInput} + onBlurResetsInput={false} + onCloseResetsInput={false} + onChange={this.onChange} + value={this.props.values} + valueRenderer={this.props.valueRenderer} + menuRenderer={() => null} + arrowRenderer={() => null} + noResultsText={null} + placeholder={localizeMessage('multiselect.placeholder', 'Search and add members')} + /> + <button + className='btn btn-primary btn-sm' + onClick={this.props.handleSubmit} + > + <FormattedMessage + id='multiselect.go' + defaultMessage='Go' + /> + </button> + </div> + <div className='multi-select__help'> + <div className='hidden-xs'> + <FormattedMessage + id='multiselect.instructions' + defaultMessage='Use up/down arrows to navigate and enter to select' + /> + </div> + {numRemainingText} + {noteTextContainer} + </div> + </div> + <MultiSelectList + ref='list' + options={optionsToDisplay} + optionRenderer={this.props.optionRenderer} + page={this.state.page} + perPage={this.props.perPage} + onPageChange={this.props.handlePageChange} + onAdd={this.onAdd} + onSelect={this.onSelect} + /> + <div className='filter-controls'> + {previousButton} + {nextButton} + </div> + </div> + ); + } +} + +MultiSelect.propTypes = { + options: React.PropTypes.arrayOf(React.PropTypes.object), + optionRenderer: React.PropTypes.func, + values: React.PropTypes.arrayOf(React.PropTypes.object), + valueRenderer: React.PropTypes.func, + handleInput: React.PropTypes.func, + handleDelete: React.PropTypes.func, + perPage: React.PropTypes.number, + handlePageChange: React.PropTypes.func, + handleAdd: React.PropTypes.func, + handleSubmit: React.PropTypes.func, + noteText: React.PropTypes.node, + maxValues: React.PropTypes.number, + numRemainingText: React.PropTypes.node +}; diff --git a/webapp/components/multiselect/multiselect_list.jsx b/webapp/components/multiselect/multiselect_list.jsx new file mode 100644 index 000000000..ff9f68bf8 --- /dev/null +++ b/webapp/components/multiselect/multiselect_list.jsx @@ -0,0 +1,169 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {cmdOrCtrlPressed} from 'utils/utils.jsx'; +import Constants from 'utils/constants.jsx'; +const KeyCodes = Constants.KeyCodes; + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +export default class MultiSelectList extends React.Component { + constructor(props) { + super(props); + + this.defaultOptionRenderer = this.defaultOptionRenderer.bind(this); + this.handleArrowPress = this.handleArrowPress.bind(this); + this.setSelected = this.setSelected.bind(this); + + this.toSelect = -1; + + this.state = { + selected: -1 + }; + } + + componentDidMount() { + document.addEventListener('keydown', this.handleArrowPress); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.handleArrowPress); + } + + componentWillReceiveProps(nextProps) { + this.setState({selected: this.toSelect}); + + const options = nextProps.options; + + if (options && options.length > 0 && this.toSelect >= 0) { + this.props.onSelect(options[this.toSelect]); + } + } + + componentDidUpdate() { + if (this.refs.list && this.refs.selected) { + const elemTop = this.refs.selected.getBoundingClientRect().top; + const elemBottom = this.refs.selected.getBoundingClientRect().bottom; + const listTop = this.refs.list.getBoundingClientRect().top; + const listBottom = this.refs.list.getBoundingClientRect().bottom; + if (elemBottom > listBottom) { + this.refs.selected.scrollIntoView(false); + } else if (elemTop < listTop) { + this.refs.selected.scrollIntoView(true); + } + } + } + + setSelected(selected) { + this.toSelect = selected; + } + + handleArrowPress(e) { + if (cmdOrCtrlPressed(e) && e.shiftKey) { + return; + } + + const options = this.props.options; + if (options.length === 0) { + return; + } + + let selected; + switch (e.keyCode) { + case KeyCodes.DOWN: + if (this.state.selected === -1) { + selected = 0; + break; + } + selected = Math.min(this.state.selected + 1, options.length - 1); + break; + case KeyCodes.UP: + if (this.state.selected === -1) { + selected = 0; + break; + } + selected = Math.max(this.state.selected - 1, 0); + break; + default: + return; + } + + e.preventDefault(); + this.setState({selected}); + this.props.onSelect(options[selected]); + } + + defaultOptionRenderer(option, isSelected, onAdd) { + var rowSelected = ''; + if (isSelected) { + rowSelected = 'more-modal__row--selected'; + } + + return ( + <div + ref={isSelected ? 'selected' : option.value} + className={rowSelected} + key={'multiselectoption' + option.value} + onClick={() => onAdd(option)} + > + {option.label} + </div> + ); + } + + render() { + const options = this.props.options; + + if (options == null || options.length === 0) { + return ( + <div + key='no-users-found' + className='no-channel-message' + > + <p className='primary-message'> + <FormattedMessage + id='multiselect.list.notFound' + defaultMessage='No items found' + /> + </p> + </div> + ); + } + + let renderer; + if (this.props.optionRenderer) { + renderer = this.props.optionRenderer; + } else { + renderer = this.defaultOptionRenderer; + } + + const optionControls = options.map((o, i) => renderer(o, this.state.selected === i, this.props.onAdd)); + + return ( + <div className='more-modal__list'> + <div + ref='list' + > + {optionControls} + </div> + </div> + ); + } +} + +MultiSelectList.defaultProps = { + options: [], + perPage: 50, + onAction: () => null +}; + +MultiSelectList.propTypes = { + options: React.PropTypes.arrayOf(React.PropTypes.object), + optionRenderer: React.PropTypes.func, + page: React.PropTypes.number, + perPage: React.PropTypes.number, + onPageChange: React.PropTypes.func, + onAdd: React.PropTypes.func, + onSelect: React.PropTypes.func +}; diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx index a805a9de4..dee32416b 100644 --- a/webapp/components/navbar.jsx +++ b/webapp/components/navbar.jsx @@ -61,6 +61,8 @@ export default class Navbar extends React.Component { this.showChannelSwitchModal = this.showChannelSwitchModal.bind(this); this.hideChannelSwitchModal = this.hideChannelSwitchModal.bind(this); + this.openDirectMessageModal = this.openDirectMessageModal.bind(this); + const state = this.getStateFromStores(); state.showEditChannelPurposeModal = false; state.showEditChannelHeaderModal = false; @@ -206,6 +208,14 @@ export default class Navbar extends React.Component { }); } + openDirectMessageModal() { + AppDispatcher.handleViewAction({ + type: ActionTypes.TOGGLE_DM_MODAL, + value: true, + startingUsers: UserStore.getProfileListInChannel(this.state.channel.id, true) + }); + } + toggleFavorite = (e) => { e.preventDefault(); @@ -216,7 +226,7 @@ export default class Navbar extends React.Component { } }; - createDropdown(channel, channelTitle, isAdmin, isSystemAdmin, isChannelAdmin, isDirect, popoverContent) { + createDropdown(channel, channelTitle, isAdmin, isSystemAdmin, isChannelAdmin, isDirect, isGroup, popoverContent) { if (channel) { let channelTerm = ( <FormattedMessage @@ -258,6 +268,57 @@ export default class Navbar extends React.Component { </a> </li> ); + } else if (isGroup) { + setChannelHeaderOption = ( + <li role='presentation'> + <a + role='menuitem' + href='#' + onClick={this.showEditChannelHeaderModal} + > + <FormattedMessage + id='channel_header.channelHeader' + defaultMessage='Set Channel Header...' + /> + </a> + </li> + ); + + notificationPreferenceOption = ( + <li role='presentation'> + <ToggleModalButton + role='menuitem' + dialogType={ChannelNotificationsModal} + dialogProps={{ + channel, + channelMember: this.state.member, + currentUser: this.state.currentUser + }} + > + <FormattedMessage + id='navbar.preferences' + defaultMessage='Notification Preferences' + /> + </ToggleModalButton> + </li> + ); + + addMembersOption = ( + <li + role='presentation' + > + <a + role='menuitem' + href='#' + onClick={this.openDirectMessageModal} + > + <FormattedMessage + id='navbar.addMembers' + defaultMessage='Add Members' + /> + </a> + </li> + ); } else { viewInfoOption = ( <li role='presentation'> @@ -621,6 +682,7 @@ export default class Navbar extends React.Component { var isSystemAdmin = false; var isChannelAdmin = false; var isDirect = false; + let isGroup = false; var editChannelHeaderModal = null; var editChannelPurposeModal = null; @@ -660,6 +722,9 @@ export default class Navbar extends React.Component { isDirect = true; const teammateId = Utils.getUserIdFromChannelName(channel); channelTitle = Utils.displayUsername(teammateId); + } else if (channel.type === Constants.GM_CHANNEL) { + isGroup = true; + channelTitle = ChannelUtils.buildGroupChannelName(channel.id); } if (channel.header.length === 0) { @@ -757,7 +822,7 @@ export default class Navbar extends React.Component { </button> ); - var channelMenuDropdown = this.createDropdown(channel, channelTitle, isAdmin, isSystemAdmin, isChannelAdmin, isDirect, popoverContent); + var channelMenuDropdown = this.createDropdown(channel, channelTitle, isAdmin, isSystemAdmin, isChannelAdmin, isDirect, isGroup, popoverContent); return ( <div> diff --git a/webapp/components/popover_list_members.jsx b/webapp/components/popover_list_members.jsx index 5ffcb687a..6d4ed056c 100644 --- a/webapp/components/popover_list_members.jsx +++ b/webapp/components/popover_list_members.jsx @@ -48,7 +48,7 @@ export default class PopoverListMembers extends React.Component { e.preventDefault(); openDirectChannelToUser( - teammate, + teammate.id, (channel, channelAlreadyExisted) => { browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + channel.name); if (channelAlreadyExisted) { diff --git a/webapp/components/profile_popover.jsx b/webapp/components/profile_popover.jsx index fc22c1314..c7d45474f 100644 --- a/webapp/components/profile_popover.jsx +++ b/webapp/components/profile_popover.jsx @@ -81,7 +81,7 @@ export default class ProfilePopover extends React.Component { this.setState({loadingDMChannel: user.id}); openDirectChannelToUser( - user, + user.id, (channel) => { if (Utils.isMobile()) { GlobalActions.emitCloseRightHandSide(); diff --git a/webapp/components/sidebar.jsx b/webapp/components/sidebar.jsx index c4c5f0517..ce584d477 100644 --- a/webapp/components/sidebar.jsx +++ b/webapp/components/sidebar.jsx @@ -15,6 +15,7 @@ import ChannelStore from 'stores/channel_store.jsx'; import UserStore from 'stores/user_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; +import ModalStore from 'stores/modal_store.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; @@ -22,7 +23,7 @@ import * as ChannelUtils from 'utils/channel_utils.jsx'; import * as ChannelActions from 'actions/channel_actions.jsx'; import {trackEvent} from 'actions/diagnostics_actions.jsx'; -import Constants from 'utils/constants.jsx'; +import {ActionTypes, Constants} from 'utils/constants.jsx'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; @@ -49,6 +50,8 @@ export default class Sidebar extends React.Component { this.getStateFromStores = this.getStateFromStores.bind(this); this.onChange = this.onChange.bind(this); + this.onModalChange = this.onModalChange.bind(this); + this.onInChannelChange = this.onInChannelChange.bind(this); this.onScroll = this.onScroll.bind(this); this.updateUnreadIndicators = this.updateUnreadIndicators.bind(this); this.handleLeaveDirectChannel = this.handleLeaveDirectChannel.bind(this); @@ -77,6 +80,7 @@ export default class Sidebar extends React.Component { state.showDirectChannelsModal = false; state.showMoreChannelsModal = false; state.loadingDMChannel = -1; + state.inChannelChange = false; this.state = state; } @@ -96,7 +100,7 @@ export default class Sidebar extends React.Component { Object.keys(unreadCounts).forEach((chId) => { const channel = ChannelStore.get(chId); - if (channel && (channel.type === 'D' || channel.team_id === this.state.currentTeam.id)) { + if (channel && (channel.type === Constants.DM_CHANNEL || channel.type === Constants.GM_CHANNEL || channel.team_id === this.state.currentTeam.id)) { msgs += unreadCounts[chId].msgs; mentions += unreadCounts[chId].mentions; } @@ -128,13 +132,19 @@ export default class Sidebar extends React.Component { }; } + onInChannelChange() { + this.setState({inChannelChange: !this.state.inChannelChange}); + } + componentDidMount() { ChannelStore.addChangeListener(this.onChange); UserStore.addChangeListener(this.onChange); UserStore.addInTeamChangeListener(this.onChange); + UserStore.addInChannelChangeListener(this.onInChannelChange); UserStore.addStatusesChangeListener(this.onChange); TeamStore.addChangeListener(this.onChange); PreferenceStore.addChangeListener(this.onChange); + ModalStore.addModalListener(ActionTypes.TOGGLE_DM_MODAL, this.onModalChange); this.updateTitle(); this.updateUnreadIndicators(); @@ -179,13 +189,19 @@ export default class Sidebar extends React.Component { ChannelStore.removeChangeListener(this.onChange); UserStore.removeChangeListener(this.onChange); UserStore.removeInTeamChangeListener(this.onChange); + UserStore.removeInChannelChangeListener(this.onChange); UserStore.removeStatusesChangeListener(this.onChange); TeamStore.removeChangeListener(this.onChange); PreferenceStore.removeChangeListener(this.onChange); + ModalStore.removeModalListener(ActionTypes.TOGGLE_DM_MODAL, this.onModalChange); document.removeEventListener('keydown', this.navigateChannelShortcut); document.removeEventListener('keydown', this.navigateUnreadChannelShortcut); } + onModalChange(value, args) { + this.showMoreDirectChannelsModal(args.startingUsers); + } + onChange() { if (this.state.currentTeam.id !== TeamStore.getCurrentId()) { ChannelStore.clear(); @@ -203,11 +219,13 @@ export default class Sidebar extends React.Component { } let currentChannelName = channel.display_name; - if (channel.type === 'D') { + if (channel.type === Constants.DM_CHANNEL) { const teammate = Utils.getDirectTeammate(channel.id); if (teammate != null) { currentChannelName = teammate.username; } + } else if (channel.type === Constants.GM_CHANNEL) { + currentChannelName = ChannelUtils.buildGroupChannelName(channel.id); } const unread = this.getTotalUnreadCount(); @@ -331,7 +349,7 @@ export default class Sidebar extends React.Component { } getDisplayedChannels() { - return this.state.favoriteChannels.concat(this.state.publicChannels).concat(this.state.privateChannels).concat(this.state.directChannels).concat(this.state.directNonTeamChannels); + return this.state.favoriteChannels.concat(this.state.publicChannels).concat(this.state.privateChannels).concat(this.state.directAndGroupChannels).concat(this.state.directNonTeamChannels); } handleLeaveDirectChannel(e, channel) { @@ -340,9 +358,19 @@ export default class Sidebar extends React.Component { if (!this.isLeaving.get(channel.id)) { this.isLeaving.set(channel.id, true); + let id; + let category; + if (channel.type === Constants.DM_CHANNEL) { + id = channel.teammate_id; + category = Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW; + } else { + id = channel.id; + category = Constants.Preferences.CATEGORY_GROUP_CHANNEL_SHOW; + } + AsyncClient.savePreference( - Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, - channel.teammate_id, + category, + id, 'false', () => { this.isLeaving.set(channel.id, false); @@ -382,13 +410,13 @@ export default class Sidebar extends React.Component { this.setState({newChannelModalType: ''}); } - showMoreDirectChannelsModal() { + showMoreDirectChannelsModal(startingUsers) { trackEvent('ui', 'ui_channels_more_direct'); - this.setState({showDirectChannelsModal: true}); + this.setState({showDirectChannelsModal: true, startingUsers}); } hideMoreDirectChannelsModal() { - this.setState({showDirectChannelsModal: false}); + this.setState({showDirectChannelsModal: false, startingUsers: null}); } openLeftSidebar() { @@ -509,11 +537,16 @@ export default class Sidebar extends React.Component { rowClass += ' has-badge'; } + let displayName = channel.display_name; + var icon = null; if (channel.type === Constants.OPEN_CHANNEL) { icon = <div className='status'><i className='fa fa-globe'/></div>; } else if (channel.type === Constants.PRIVATE_CHANNEL) { icon = <div className='status'><i className='fa fa-lock'/></div>; + } else if (channel.type === Constants.GM_CHANNEL) { + displayName = ChannelUtils.buildGroupChannelName(channel.id); + icon = <div className='status status--group'>{UserStore.getProfileListInChannel(channel.id, true).length}</div>; } else { // set up status icon for direct message channels (status is null for other channel types) icon = ( @@ -576,7 +609,7 @@ export default class Sidebar extends React.Component { onClick={this.trackChannelSelectedEvent} > {icon} - {channel.display_name} + {displayName} {badge} {closeButton} </Link> @@ -615,27 +648,10 @@ export default class Sidebar extends React.Component { const privateChannelItems = this.state.privateChannels.map(this.createChannelElement); - const directMessageItems = this.state.directChannels.map((channel, index, arr) => { + const directMessageItems = this.state.directAndGroupChannels.map((channel, index, arr) => { return this.createChannelElement(channel, index, arr, this.handleLeaveDirectChannel); }); - const directMessageNonTeamItems = this.state.directNonTeamChannels.map((channel, index, arr) => { - return this.createChannelElement(channel, index, arr, this.handleLeaveDirectChannel); - }); - - let directDivider; - if (directMessageNonTeamItems.length !== 0) { - directDivider = - (<div className='sidebar__divider'> - <div className='sidebar__divider__text'> - <FormattedMessage - id='sidebar.otherMembers' - defaultMessage='Outside this team' - /> - </div> - </div>); - } - // update the favicon to show if there are any notifications if (this.lastBadgesActive !== this.badgesActive) { var link = document.createElement('link'); @@ -659,7 +675,7 @@ export default class Sidebar extends React.Component { <li key='more'> <a href='#' - onClick={this.showMoreDirectChannelsModal} + onClick={() => this.showMoreDirectChannelsModal()} > <FormattedMessage id='sidebar.moreElips' @@ -753,6 +769,7 @@ export default class Sidebar extends React.Component { moreDirectChannelsModal = ( <MoreDirectChannels onModalDismissed={this.hideMoreDirectChannelsModal} + startingUsers={this.state.startingUsers} /> ); } @@ -866,8 +883,6 @@ export default class Sidebar extends React.Component { </h4> </li> {directMessageItems} - {directDivider} - {directMessageNonTeamItems} {directMessageMore} </ul> </div> diff --git a/webapp/components/status_icon.jsx b/webapp/components/status_icon.jsx index 3e71344d9..cf5ef6947 100644 --- a/webapp/components/status_icon.jsx +++ b/webapp/components/status_icon.jsx @@ -33,7 +33,7 @@ export default class StatusIcon extends React.Component { return ( <span - className='status' + className={'status ' + this.props.className} dangerouslySetInnerHTML={{__html: statusIcon}} /> ); @@ -41,7 +41,12 @@ export default class StatusIcon extends React.Component { } +StatusIcon.defaultProps = { + className: '' +}; + StatusIcon.propTypes = { status: React.PropTypes.string, + className: React.PropTypes.string, type: React.PropTypes.string }; diff --git a/webapp/components/suggestion/switch_channel_provider.jsx b/webapp/components/suggestion/switch_channel_provider.jsx index 3b7bec319..6d4340780 100644 --- a/webapp/components/suggestion/switch_channel_provider.jsx +++ b/webapp/components/suggestion/switch_channel_provider.jsx @@ -12,7 +12,7 @@ import Client from 'client/web_client.jsx'; import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import {Constants, ActionTypes} from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; -import {sortChannelsByDisplayName} from 'utils/channel_utils.jsx'; +import {sortChannelsByDisplayName, buildGroupChannelName} from 'utils/channel_utils.jsx'; import React from 'react'; @@ -25,12 +25,15 @@ class SwitchChannelSuggestion extends Suggestion { className += ' suggestion--selected'; } - const displayName = item.display_name; + let displayName = item.display_name; let icon = null; if (item.type === Constants.OPEN_CHANNEL) { icon = <div className='status'><i className='fa fa-globe'/></div>; } else if (item.type === Constants.PRIVATE_CHANNEL) { icon = <div className='status'><i className='fa fa-lock'/></div>; + } else if (item.type === Constants.GM_CHANNEL) { + displayName = buildGroupChannelName(item.id); + icon = <div className='status status--group'>{UserStore.getProfileListInChannel(item.id, true).length}</div>; } else { icon = ( <div className='pull-left'> @@ -74,7 +77,11 @@ export default class SwitchChannelProvider extends Provider { for (const id of Object.keys(allChannels)) { const channel = allChannels[id]; if (channel.display_name.toLowerCase().indexOf(channelPrefix.toLowerCase()) !== -1) { - channels.push(channel); + const newChannel = Object.assign({}, channel); + if (newChannel.type === Constants.GM_CHANNEL) { + newChannel.name = buildGroupChannelName(newChannel.id); + } + channels.push(newChannel); } } |