* Implement server changes for group messaging

* Majority of client-side implementation

* Some server updates

* Added new React multiselect component

* Fix style issues

* Add custom renderer for options

* Fix model test

* Update ENTER functionality for multiselect control

* Remove buttons from multiselect UI control

* Updating group messaging UI (#5524)

* Move filter controls up a component level

* Scroll with arrow keys

* Updating mobile layout for multiselect (#5534)

* Fix race condition when backspacing quickly

* Hidden or new GMs show up for regular messages

* Add overriding of number remaining text

* Add UI filtering for team if config setting set

* Add icon to channel switcher and class prop to status icon

* Minor updates per feedback

* Improving group messaging UI (#5563)

* UX changes per feedback

* Update email for group messages

* UI fixes for group messaging (#5587)

* Fix missing localization string

* Add maximum users message when adding members to GM

* Fix input clearing on Android

* Updating group messaging UI (#5603)

* Updating UI for group messaging (#5604)
Этот коммит содержится в:
Joram Wilander
2017-03-02 17:48:56 -05:00
коммит произвёл GitHub
родитель 8c5cee9521
Коммит 3a91d4e5e4
48 изменённых файлов: 1596 добавлений и 293 удалений

Просмотреть файл

@@ -76,4 +76,4 @@ MultiSelectSetting.propTypes = {
noResultText: React.PropTypes.node,
errorText: React.PropTypes.node,
notPresent: React.PropTypes.node
};
};

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

@@ -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,10 +140,17 @@ 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));
}
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({
@@ -118,72 +158,51 @@ export default class MoreDirectChannels extends React.Component {
});
}
toggleList(e) {
const listType = e.target.value;
let users;
if (listType === 'any') {
users = UserStore.getProfileList(true);
} else {
users = UserStore.getProfileListInTeam(TeamStore.getCurrentId(), true, true);
}
this.setState({
users,
listType
});
}
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>
</div>
);
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='Youve 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 youre 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
};
};

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

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

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