From 12044aa0a61dbfc26fddca66a2802ee3190a7155 Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Thu, 2 Mar 2017 17:48:56 -0500 Subject: [PATCH] PLT-3077 Add group messaging (#5489) * 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) --- actions/channel_actions.jsx | 63 +++- actions/global_actions.jsx | 4 +- actions/post_actions.jsx | 10 +- actions/user_actions.jsx | 193 +++++++---- actions/websocket_actions.jsx | 4 +- client/client.jsx | 10 + .../admin_console/multiselect_settings.jsx | 2 +- components/channel_header.jsx | 78 ++++- components/channel_switch_modal.jsx | 4 +- components/more_direct_channels.jsx | 308 +++++++++++------- components/multiselect/multiselect.jsx | 257 +++++++++++++++ components/multiselect/multiselect_list.jsx | 169 ++++++++++ components/navbar.jsx | 69 +++- components/popover_list_members.jsx | 2 +- components/profile_popover.jsx | 2 +- components/sidebar.jsx | 77 +++-- components/status_icon.jsx | 7 +- .../suggestion/switch_channel_provider.jsx | 13 +- i18n/en.json | 8 + package.json | 2 +- routes/route_team.jsx | 6 +- sass/components/_modal.scss | 27 ++ sass/components/_module.scss | 1 + sass/components/_multi-select.scss | 59 ++++ sass/components/_status-icon.scss | 22 ++ sass/layout/_headers.scss | 12 +- sass/responsive/_mobile.scss | 17 + stores/modal_store.jsx | 1 + stores/notification_store.jsx | 3 + stores/user_store.jsx | 7 +- tests/client_channel.test.jsx | 31 ++ utils/channel_intro_messages.jsx | 63 +++- utils/channel_utils.jsx | 54 +-- utils/constants.jsx | 5 + utils/utils.jsx | 4 +- 35 files changed, 1322 insertions(+), 272 deletions(-) create mode 100644 components/multiselect/multiselect.jsx create mode 100644 components/multiselect/multiselect_list.jsx create mode 100644 sass/components/_multi-select.scss diff --git a/actions/channel_actions.jsx b/actions/channel_actions.jsx index 582de54cc6b6..df7bacac68bc 100644 --- a/actions/channel_actions.jsx +++ b/actions/channel_actions.jsx @@ -9,7 +9,7 @@ import ChannelStore from 'stores/channel_store.jsx'; import * as ChannelUtils from 'utils/channel_utils.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; -import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx'; +import {loadProfilesForSidebar} from 'actions/user_actions.jsx'; import {trackEvent} from 'actions/diagnostics_actions.jsx'; import Client from 'client/web_client.jsx'; @@ -22,8 +22,12 @@ import {browserHistory} from 'react-router/es6'; export function goToChannel(channel) { if (channel.fake) { + const user = UserStore.getProfileByUsername(channel.display_name); + if (!user) { + return; + } openDirectChannelToUser( - UserStore.getProfileByUsername(channel.display_name), + user.id, () => { browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + channel.name); }, @@ -167,18 +171,18 @@ export function makeUserChannelMember(channelId, userId, success, error) { ); } -export function openDirectChannelToUser(user, success, error) { - const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), user.id); +export function openDirectChannelToUser(userId, success, error) { + const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), userId); const channel = ChannelStore.getByName(channelName); if (channel) { trackEvent('api', 'api_channels_join_direct'); - PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true'); - loadProfilesAndTeamMembersForDMSidebar(); + PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, 'true'); + loadProfilesForSidebar(); AsyncClient.savePreference( Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, - user.id, + userId, 'true' ); @@ -190,7 +194,7 @@ export function openDirectChannelToUser(user, success, error) { } Client.createDirectChannel( - user.id, + userId, (data) => { Client.getChannel( data.id, @@ -201,12 +205,12 @@ export function openDirectChannelToUser(user, success, error) { member: data2.member }); - PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true'); - loadProfilesAndTeamMembersForDMSidebar(); + PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, 'true'); + loadProfilesForSidebar(); AsyncClient.savePreference( Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, - user.id, + userId, 'true' ); @@ -225,6 +229,43 @@ export function openDirectChannelToUser(user, success, error) { ); } +export function openGroupChannelToUsers(userIds, success, error) { + Client.createGroupChannel( + userIds, + (data) => { + Client.getChannelMember( + data.id, + UserStore.getCurrentId(), + (data2) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_CHANNEL, + channel: data, + member: data2 + }); + + PreferenceStore.setPreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, data.id, 'true'); + loadProfilesForSidebar(); + + AsyncClient.savePreference( + Preferences.CATEGORY_GROUP_CHANNEL_SHOW, + data.id, + 'true' + ); + + if (success) { + success(data); + } + } + ); + }, + () => { + if (error) { + error(); + } + } + ); +} + export function markFavorite(channelId) { trackEvent('api', 'api_channels_favorited'); AsyncClient.savePreference(Preferences.CATEGORY_FAVORITE_CHANNEL, channelId, 'true'); diff --git a/actions/global_actions.jsx b/actions/global_actions.jsx index 37020f500dde..90805d057662 100644 --- a/actions/global_actions.jsx +++ b/actions/global_actions.jsx @@ -13,7 +13,7 @@ import PreferenceStore from 'stores/preference_store.jsx'; import SearchStore from 'stores/search_store.jsx'; import {handleNewPost, loadPosts, loadPostsBefore, loadPostsAfter} from 'actions/post_actions.jsx'; -import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx'; +import {loadProfilesForSidebar} from 'actions/user_actions.jsx'; import {loadChannelsForCurrentUser} from 'actions/channel_actions.jsx'; import {stopPeriodicStatusUpdates} from 'actions/status_actions.jsx'; import * as WebsocketActions from 'actions/websocket_actions.jsx'; @@ -387,7 +387,7 @@ export function emitPreferenceChangedEvent(preference) { }); if (preference.category === Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW) { - loadProfilesAndTeamMembersForDMSidebar(); + loadProfilesForSidebar(); } } diff --git a/actions/post_actions.jsx b/actions/post_actions.jsx index ad05a69db8a6..cbcddfc7c180 100644 --- a/actions/post_actions.jsx +++ b/actions/post_actions.jsx @@ -8,7 +8,7 @@ import PostStore from 'stores/post_store.jsx'; import UserStore from 'stores/user_store.jsx'; import {loadStatusesForChannel} from 'actions/status_actions.jsx'; -import {loadNewDMIfNeeded} from 'actions/user_actions.jsx'; +import {loadNewDMIfNeeded, loadNewGMIfNeeded} from 'actions/user_actions.jsx'; import {trackEvent} from 'actions/diagnostics_actions.jsx'; import Client from 'client/web_client.jsx'; @@ -24,8 +24,12 @@ export function handleNewPost(post, msg) { websocketMessageProps = msg.data; } - if (msg && msg.data && msg.data.channel_type === Constants.DM_CHANNEL) { - loadNewDMIfNeeded(post.user_id); + if (msg && msg.data) { + if (msg.data.channel_type === Constants.DM_CHANNEL) { + loadNewDMIfNeeded(post.user_id); + } else if (msg.data.channel_type === Constants.GM_CHANNEL) { + loadNewGMIfNeeded(post.channel_id, post.user_id); + } } if (post.root_id && PostStore.getPost(post.channel_id, post.root_id) == null) { diff --git a/actions/user_actions.jsx b/actions/user_actions.jsx index cf52415116fe..0f6ac3e9fa9c 100644 --- a/actions/user_actions.jsx +++ b/actions/user_actions.jsx @@ -225,63 +225,150 @@ function populateDMChannelsWithProfiles(userIds) { } } +function populateChannelWithProfiles(channelId, userIds) { + for (let i = 0; i < userIds.length; i++) { + UserStore.saveUserIdInChannel(channelId, userIds[i]); + } + UserStore.emitInChannelChange(); +} + export function loadNewDMIfNeeded(userId) { if (userId === UserStore.getCurrentId()) { return; } - const pref = PreferenceStore.get(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, 'false'); - if (pref === 'false') { + const pref = PreferenceStore.getBool(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, false); + if (pref === false) { PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, 'true'); AsyncClient.savePreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, 'true'); - loadProfilesAndTeamMembersForDMSidebar(); + loadProfilesForDM(); } } -export function loadProfilesAndTeamMembersForDMSidebar() { - const dmPrefs = PreferenceStore.getCategory(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); - const teamId = TeamStore.getCurrentId(); - const profilesToLoad = []; - const membersToLoad = []; +export function loadNewGMIfNeeded(channelId, userId) { + if (userId === UserStore.getCurrentId()) { + return; + } - for (const [key, value] of dmPrefs) { - if (value === 'true') { - if (!UserStore.hasProfile(key)) { - profilesToLoad.push(key); - } - membersToLoad.push(key); + function checkPreference() { + const pref = PreferenceStore.getBool(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId, false); + if (pref === false) { + PreferenceStore.setPreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId, 'true'); + AsyncClient.savePreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId, 'true'); + loadProfilesForGM(); } } - const channelMembers = ChannelStore.getMyMembers(); + const channel = ChannelStore.get(channelId); + if (channel) { + checkPreference(); + } else { + Client.getChannel( + channelId, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_CHANNEL, + channel: data.channel, + member: data.member + }); + + checkPreference(); + }, + (err) => { + AsyncClient.dispatchError(err, 'getChannel'); + } + ); + } +} + +export function loadProfilesForSidebar() { + loadProfilesForDM(); + loadProfilesForGM(); +} + +export function loadProfilesForGM() { const channels = ChannelStore.getChannels(); const newPreferences = []; + for (let i = 0; i < channels.length; i++) { const channel = channels[i]; - if (channel.type !== Constants.DM_CHANNEL) { + if (channel.type !== Constants.GM_CHANNEL) { continue; } - const member = channelMembers[channel.id]; - if (!member) { + if (UserStore.getProfileListInChannel(channel.id).length >= Constants.MIN_USERS_IN_GM) { continue; } - const teammateId = channel.name.replace(member.user_id, '').replace('__', ''); + const isVisible = PreferenceStore.getBool(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel.id); + + if (!isVisible) { + const member = ChannelStore.getMyMember(channel.id); + if (!member || (member.mention_count === 0 && member.msg_count < member.total_msg_count)) { + continue; + } + + newPreferences.push({ + user_id: UserStore.getCurrentId(), + category: Preferences.CATEGORY_GROUP_CHANNEL_SHOW, + name: channel.id, + value: 'true' + }); + } + + Client.getProfilesInChannel( + channel.id, + 0, + Constants.MAX_USERS_IN_GM, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_PROFILES, + profiles: data + }); + + populateChannelWithProfiles(channel.id, Object.keys(data)); + } + ); + } + + if (newPreferences.length > 0) { + AsyncClient.savePreferences(newPreferences); + } +} + +export function loadProfilesForDM() { + const channels = ChannelStore.getChannels(); + const newPreferences = []; + const profilesToLoad = []; + const profileIds = []; + + for (let i = 0; i < channels.length; i++) { + const channel = channels[i]; + if (channel.type !== Constants.DM_CHANNEL) { + continue; + } + + const teammateId = channel.name.replace(UserStore.getCurrentId(), '').replace('__', ''); + const isVisible = PreferenceStore.getBool(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammateId); + + if (!isVisible) { + const member = ChannelStore.getMyMember(channel.id); + if (!member || member.mention_count === 0) { + continue; + } - if (member.mention_count > 0 && membersToLoad.indexOf(teammateId) === -1) { - membersToLoad.push(teammateId); newPreferences.push({ user_id: UserStore.getCurrentId(), category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, name: teammateId, value: 'true' }); + } - if (!UserStore.hasProfile(teammateId)) { - profilesToLoad.push(teammateId); - } + if (!UserStore.hasProfile(teammateId)) { + profilesToLoad.push(teammateId); } + profileIds.push(teammateId); } if (newPreferences.length > 0) { @@ -298,44 +385,14 @@ export function loadProfilesAndTeamMembersForDMSidebar() { }); // Use membersToLoad so we get all the DM profiles even if they were already loaded - populateDMChannelsWithProfiles(membersToLoad); + populateDMChannelsWithProfiles(profileIds); }, (err) => { AsyncClient.dispatchError(err, 'getProfilesByIds'); } ); } else { - populateDMChannelsWithProfiles(membersToLoad); - } - - if (membersToLoad.length > 0) { - Client.getTeamMembersByIds( - teamId, - membersToLoad, - (data) => { - const memberMap = {}; - for (let i = 0; i < data.length; i++) { - memberMap[data[i].user_id] = data[i]; - } - - const nonMembersMap = {}; - for (let i = 0; i < membersToLoad.length; i++) { - if (!memberMap[membersToLoad[i]]) { - nonMembersMap[membersToLoad[i]] = true; - } - } - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_MEMBERS_IN_TEAM, - team_id: teamId, - team_members: memberMap, - non_team_members: nonMembersMap - }); - }, - (err) => { - AsyncClient.dispatchError(err, 'getTeamMembersByIds'); - } - ); + populateDMChannelsWithProfiles(profileIds); } } @@ -801,3 +858,27 @@ export function uploadProfileImage(userPicture, success, error) { } ); } + +export function loadProfiles(offset = UserStore.getPagingOffset(), limit = Constants.PROFILE_CHUNK_SIZE, success, error) { + Client.getProfiles( + offset, + limit, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_PROFILES, + profiles: data + }); + + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'getProfiles'); + + if (error) { + error(err); + } + } + ); +} diff --git a/actions/websocket_actions.jsx b/actions/websocket_actions.jsx index c181a5e3a714..b442b90831d7 100644 --- a/actions/websocket_actions.jsx +++ b/actions/websocket_actions.jsx @@ -22,7 +22,7 @@ import {getSiteURL} from 'utils/url.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import {handleNewPost, loadPosts, loadProfilesForPosts} from 'actions/post_actions.jsx'; -import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx'; +import {loadProfilesForSidebar} from 'actions/user_actions.jsx'; import {loadChannelsForCurrentUser} from 'actions/channel_actions.jsx'; import * as StatusActions from 'actions/status_actions.jsx'; @@ -244,7 +244,7 @@ function handleUpdateTeamEvent(msg) { function handleDirectAddedEvent(msg) { AsyncClient.getChannel(msg.broadcast.channel_id); PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, msg.data.teammate_id, 'true'); - loadProfilesAndTeamMembersForDMSidebar(); + loadProfilesForSidebar(); } function handleUserAddedEvent(msg) { diff --git a/client/client.jsx b/client/client.jsx index 390c07d139bd..eaffd9ff4c67 100644 --- a/client/client.jsx +++ b/client/client.jsx @@ -1278,6 +1278,16 @@ export default class Client { this.trackEvent('api', 'api_channels_create_direct', {team_id: this.getTeamId()}); } + createGroupChannel(userIds, success, error) { + request. + post(`${this.getChannelsRoute()}/create_group`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(userIds). + end(this.handleResponse.bind(this, 'createGroupChannel', success, error)); + } + updateChannel(channel, success, error) { request. post(`${this.getChannelsRoute()}/update`). diff --git a/components/admin_console/multiselect_settings.jsx b/components/admin_console/multiselect_settings.jsx index 8aad5d6eb2ee..2beebb337822 100644 --- a/components/admin_console/multiselect_settings.jsx +++ b/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/components/channel_header.jsx b/components/channel_header.jsx index 341c9be1b2d5..9be2d5b5872e 100644 --- a/components/channel_header.jsx +++ b/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 = ( ); + } else if (isGroup) { + dropdownContents.push( +
  • + + + +
  • + ); + + dropdownContents.push( +
  • + + + +
  • + ); + + dropdownContents.push( +
  • + + + +
  • + ); } else { dropdownContents.push(
  • { 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/components/more_direct_channels.jsx b/components/more_direct_channels.jsx index 13ee50b4d562..c4a3a3526bd0 100644 --- a/components/more_direct_channels.jsx +++ b/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 ( - - - - ); - } - - 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 = ( -
    - - - - + handleDelete(values) { + this.setState({values}); + } + + renderOption(option, isSelected, onAdd) { + var rowSelected = ''; + if (isSelected) { + rowSelected = 'more-modal__row--selected'; + } + + return ( +
    onAdd(option)} + > + +
    +
    + {displayUsernameForUser(option)} +
    +
    + {option.email} +
    - ); +
    +
    + +
    +
    +
    + ); + } + + renderValue(user) { + return user.username; + } + + render() { + let note; + if (this.props.startingUsers) { + if (this.state.values && this.state.values.length >= MAX_SELECTABLE_VALUES) { + note = ( + + ); + } else { + note = ( + + ); + } } + const numRemainingText = ( + + ); + return ( - {teamToggle} - @@ -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/components/multiselect/multiselect.jsx b/components/multiselect/multiselect.jsx new file mode 100644 index 000000000000..a3e32dccf7ee --- /dev/null +++ b/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 = ( + + ); + } + + let optionsToDisplay = []; + let nextButton; + let previousButton; + let noteTextContainer; + + if (this.props.noteText) { + noteTextContainer = ( +
    +
    +
    {this.props.noteText}
    +
    + ); + } + + 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 = ( + + ); + } + + if (this.state.page > 0) { + previousButton = ( + + ); + } + } else { + optionsToDisplay = options; + } + + return ( +
    +
    +
    + null} + arrowRenderer={() => null} + noResultsText={null} + placeholder={localizeMessage('multiselect.placeholder', 'Search and add members')} + /> + +
    +
    +
    + +
    + {numRemainingText} + {noteTextContainer} +
    +
    + +
    + {previousButton} + {nextButton} +
    +
    + ); + } +} + +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/components/multiselect/multiselect_list.jsx b/components/multiselect/multiselect_list.jsx new file mode 100644 index 000000000000..ff9f68bf802d --- /dev/null +++ b/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 ( +
    onAdd(option)} + > + {option.label} +
    + ); + } + + render() { + const options = this.props.options; + + if (options == null || options.length === 0) { + return ( +
    +

    + +

    +
    + ); + } + + 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 ( +
    +
    + {optionControls} +
    +
    + ); + } +} + +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/components/navbar.jsx b/components/navbar.jsx index a805a9de4f47..dee32416b379 100644 --- a/components/navbar.jsx +++ b/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 = (
  • ); + } else if (isGroup) { + setChannelHeaderOption = ( +
  • + + + +
  • + ); + + notificationPreferenceOption = ( +
  • + + + +
  • + ); + + addMembersOption = ( +
  • + + + +
  • + ); } else { viewInfoOption = (
  • @@ -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 { ); - var channelMenuDropdown = this.createDropdown(channel, channelTitle, isAdmin, isSystemAdmin, isChannelAdmin, isDirect, popoverContent); + var channelMenuDropdown = this.createDropdown(channel, channelTitle, isAdmin, isSystemAdmin, isChannelAdmin, isDirect, isGroup, popoverContent); return (
    diff --git a/components/popover_list_members.jsx b/components/popover_list_members.jsx index 5ffcb687ab10..6d4ed056cfdd 100644 --- a/components/popover_list_members.jsx +++ b/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/components/profile_popover.jsx b/components/profile_popover.jsx index fc22c1314a03..c7d45474f709 100644 --- a/components/profile_popover.jsx +++ b/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/components/sidebar.jsx b/components/sidebar.jsx index c4c5f051744f..ce584d4772c7 100644 --- a/components/sidebar.jsx +++ b/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 =
    ; } else if (channel.type === Constants.PRIVATE_CHANNEL) { icon =
    ; + } else if (channel.type === Constants.GM_CHANNEL) { + displayName = ChannelUtils.buildGroupChannelName(channel.id); + icon =
    {UserStore.getProfileListInChannel(channel.id, true).length}
    ; } 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} @@ -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 = - (
    -
    - -
    -
    ); - } - // 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 {
  • this.showMoreDirectChannelsModal()} > ); } @@ -866,8 +883,6 @@ export default class Sidebar extends React.Component {
  • {directMessageItems} - {directDivider} - {directMessageNonTeamItems} {directMessageMore} diff --git a/components/status_icon.jsx b/components/status_icon.jsx index 3e71344d9497..cf5ef69472fc 100644 --- a/components/status_icon.jsx +++ b/components/status_icon.jsx @@ -33,7 +33,7 @@ export default class StatusIcon extends React.Component { return ( ); @@ -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/components/suggestion/switch_channel_provider.jsx b/components/suggestion/switch_channel_provider.jsx index 3b7bec319687..6d43407800d9 100644 --- a/components/suggestion/switch_channel_provider.jsx +++ b/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 =
    ; } else if (item.type === Constants.PRIVATE_CHANNEL) { icon =
    ; + } else if (item.type === Constants.GM_CHANNEL) { + displayName = buildGroupChannelName(item.id); + icon =
    {UserStore.getProfileListInChannel(item.id, true).length}
    ; } else { icon = (
    @@ -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); } } diff --git a/i18n/en.json b/i18n/en.json index a0ae76c72443..8196d1aa111d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,4 +1,10 @@ { + "multiselect.go": "Go", + "multiselect.instructions": "Use up/down arrows to navigate and enter to select", + "multiselect.placeholder": "Search and add members", + "multiselect.numRemaining": "You can add {num, number} more", + "multiselect.numPeopleRemaining": "You can add {num, number} more {num, plural, =0 {people} one {person} other {people}}. ", + "filtered_channels_list.count": "{count} {count, plural, =0 {0 channels} one {channel} other {channels}}", "about.close": "Close", "about.copyright": "Copyright 2016 Mattermost, Inc. All rights reserved", "about.database": "Database:", @@ -1638,6 +1644,8 @@ "more_channels.prev": "Previous", "more_channels.title": "More Channels", "more_direct_channels.close": "Close", + "more_direct_channels.new_convo_note": "This will start a new conversation. If you’re adding a lot of people, consider creating a private group instead.", + "more_direct_channels.new_convo_note.full": "You’ve reached the maximum number of people for this conversation. Consider creating a private group instead.", "more_direct_channels.message": "Message", "more_direct_channels.title": "Direct Messages", "msg_typing.areTyping": "{users} and {last} are typing...", diff --git a/package.json b/package.json index b667a187cef7..08ea8a8824a8 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "react-dom": "15.4.2", "react-intl": "2.2.3", "react-router": "2.8.1", - "react-select": "1.0.0-rc.3", + "react-select": "1.0.0-rc.2", "superagent": "3.4.1", "twemoji": "2.2.3", "velocity-animate": "1.4.2", diff --git a/routes/route_team.jsx b/routes/route_team.jsx index f7d12f40f5f5..768d84dba3d7 100644 --- a/routes/route_team.jsx +++ b/routes/route_team.jsx @@ -21,7 +21,7 @@ import BrowserStore from 'stores/browser_store.jsx'; import emojiRoute from 'routes/route_emoji.jsx'; import integrationsRoute from 'routes/route_integrations.jsx'; -import {loadNewDMIfNeeded, loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx'; +import {loadNewDMIfNeeded, loadNewGMIfNeeded, loadProfilesForSidebar} from 'actions/user_actions.jsx'; function onChannelEnter(nextState, replace, callback) { doChannelChange(nextState, replace, callback); @@ -36,6 +36,8 @@ function doChannelChange(state, replace, callback) { if (channel && channel.type === Constants.DM_CHANNEL) { loadNewDMIfNeeded(Utils.getUserIdFromChannelName(channel)); + } else if (channel && channel.type === Constants.GM_CHANNEL) { + loadNewGMIfNeeded(channel.id); } if (!channel) { @@ -120,7 +122,7 @@ function preNeedsTeam(nextState, replace, callback) { }); loadStatusesForChannelAndSidebar(); - loadProfilesAndTeamMembersForDMSidebar(); + loadProfilesForSidebar(); d1.resolve(); }, diff --git a/sass/components/_modal.scss b/sass/components/_modal.scss index 0078ddec47fb..03a8ad1e8cee 100644 --- a/sass/components/_modal.scss +++ b/sass/components/_modal.scss @@ -170,6 +170,10 @@ float: left; font-size: 17px; line-height: 27px; + max-width: calc(100% - 80px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; .name { color: $white; @@ -479,6 +483,10 @@ .filter-row { margin: 5px 0 10px; width: 300px; + + &.filter-row--full { + width: 100%; + } } .member-count { @@ -582,6 +590,24 @@ border-bottom: 1px solid $light-gray; display: flex; padding: 10px 15px; + + &.clickable { + cursor: pointer; + } + + &:hover { + .more-modal__actions--round { + opacity: .5; + } + } + } + + .more-modal__actions--round { + height: 32px; + line-height: 32px; + opacity: 0; + text-align: center; + width: 32px; } p { @@ -611,6 +637,7 @@ .more-modal__list { flex-grow: 500; + height: 1px; } .filter-button { diff --git a/sass/components/_module.scss b/sass/components/_module.scss index 3e587707d51d..c1114bbcb2b6 100644 --- a/sass/components/_module.scss +++ b/sass/components/_module.scss @@ -9,6 +9,7 @@ @import 'links'; @import 'mentions'; @import 'modal'; +@import 'multi-select'; @import 'oauth'; @import 'popover'; @import 'save-button'; diff --git a/sass/components/_multi-select.scss b/sass/components/_multi-select.scss new file mode 100644 index 000000000000..a33116aa466f --- /dev/null +++ b/sass/components/_multi-select.scss @@ -0,0 +1,59 @@ +@charset 'UTF-8'; + +.multi-select__container { + display: table; + padding: 0 15px; + width: 100%; + + .Select { + display: table-cell; + padding-right: 15px; + vertical-align: top; + width: 100%; + } + + .btn { + display: table-cell; + height: 36px; + min-width: 60px; + vertical-align: top; + } + + .Select-control { + border-radius: 1px; + } + + .Select-value { + white-space: nowrap; + } + + .Select-value-label { + overflow: hidden; + text-overflow: ellipsis; + } +} + +.multi-select__help { + padding: 10px 15px 0; + + > div:not(.multi-select__note), + > span { + @include opacity(.6); + } +} + +.multi-select__note { + @include border-radius(3px); + display: table; + margin-top: 5px; + padding: 8px 13px; + width: 100%; + + > div { + display: table-cell; + + &.note__icon { + width: 15px; + } + } +} diff --git a/sass/components/_status-icon.scss b/sass/components/_status-icon.scss index 5bd68f362457..c2f8bca5ba67 100644 --- a/sass/components/_status-icon.scss +++ b/sass/components/_status-icon.scss @@ -36,6 +36,28 @@ top: 1px; width: 12px; + &.status--group { + border-radius: 2px; + font-size: 11px; + font-weight: 600; + height: 16px; + left: 1px; + line-height: 15px; + margin-left: -3px; + text-align: center; + top: -1px; + width: 16px; + + .mentions__name & { + height: 20px; + left: 0; + line-height: 20px; + margin-left: 0; + text-align: center; + width: 20px; + } + } + svg { max-height: 14px; } diff --git a/sass/layout/_headers.scss b/sass/layout/_headers.scss index e6dc6bb68350..8ee6e8fdc64e 100644 --- a/sass/layout/_headers.scss +++ b/sass/layout/_headers.scss @@ -121,7 +121,7 @@ &.dropdown { float: left; - max-width: 100%; + max-width: 90%; padding-right: 1em; .header-dropdown__icon { @@ -182,7 +182,6 @@ } .channel-intro-profile { - margin-left: 63px; margin-top: 5px; .user-popover { @@ -193,7 +192,10 @@ } .channel-intro-img { - float: left; + .status-wrapper { + height: 50px; + margin: 0 10px 10px 0; + } img { @include border-radius(100px); @@ -211,10 +213,6 @@ background: $bg--gray; padding: 10px 15px; } - - .channel-intro-text { - margin-top: 35px; - } } // Team Header in Sidebar diff --git a/sass/responsive/_mobile.scss b/sass/responsive/_mobile.scss index 3170fb0d4c6b..ee26045acb74 100644 --- a/sass/responsive/_mobile.scss +++ b/sass/responsive/_mobile.scss @@ -1,6 +1,23 @@ @charset 'UTF-8'; @media screen and (max-width: 768px) { + .multi-select__container { + .btn { + display: block; + min-width: 50px; + } + + .Select-value-label { + max-width: 190px; + } + } + + .more-modal__list { + .more-modal__actions--round { + @include opacity(.5); + } + } + .post-create__container { padding-bottom: 10px; diff --git a/stores/modal_store.jsx b/stores/modal_store.jsx index 9961475b224e..b1c2887df5b4 100644 --- a/stores/modal_store.jsx +++ b/stores/modal_store.jsx @@ -38,6 +38,7 @@ class ModalStoreClass extends EventEmitter { case ActionTypes.TOGGLE_GET_POST_LINK_MODAL: case ActionTypes.TOGGLE_GET_TEAM_INVITE_LINK_MODAL: case ActionTypes.TOGGLE_GET_PUBLIC_LINK_MODAL: + case ActionTypes.TOGGLE_DM_MODAL: this.emit(type, value, args); break; } diff --git a/stores/notification_store.jsx b/stores/notification_store.jsx index 58e4433ec27c..28e5ea65dcb3 100644 --- a/stores/notification_store.jsx +++ b/stores/notification_store.jsx @@ -8,6 +8,7 @@ import UserStore from './user_store.jsx'; import ChannelStore from './channel_store.jsx'; import * as UserAgent from 'utils/user_agent.jsx'; import * as Utils from 'utils/utils.jsx'; +import {buildGroupChannelName} from 'utils/channel_utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; const ActionTypes = Constants.ActionTypes; @@ -74,6 +75,8 @@ class NotificationStoreClass extends EventEmitter { }; } else if (channel.type === Constants.DM_CHANNEL) { title = Utils.localizeMessage('notification.dm', 'Direct Message'); + } else if (channel.type === Constants.GM_CHANNEL) { + title = buildGroupChannelName(channel.id); } else { title = channel.display_name; } diff --git a/stores/user_store.jsx b/stores/user_store.jsx index bcc59b690295..007d8a5a72ed 100644 --- a/stores/user_store.jsx +++ b/stores/user_store.jsx @@ -414,13 +414,18 @@ class UserStoreClass extends EventEmitter { userIds.splice(index, 1); } - getProfileListInChannel(channelId = ChannelStore.getCurrentId()) { + getProfileListInChannel(channelId = ChannelStore.getCurrentId(), skipCurrent = false) { const userIds = this.profiles_in_channel[channelId] || []; + const currentId = this.getCurrentId(); const profiles = []; for (let i = 0; i < userIds.length; i++) { const profile = this.getProfile(userIds[i]); if (profile) { + if (skipCurrent && profile.id === currentId) { + continue; + } + profiles.push(profile); } } diff --git a/tests/client_channel.test.jsx b/tests/client_channel.test.jsx index 154f70fef1ed..e1ee6cf6d67c 100644 --- a/tests/client_channel.test.jsx +++ b/tests/client_channel.test.jsx @@ -48,6 +48,37 @@ describe('Client.Channels', function() { }); }); + it('createGroupChannel', function(done) { + TestHelper.initBasic(() => { + TestHelper.basicClient().createUser( + TestHelper.fakeUser(), + (user1) => { + TestHelper.basicClient().createUser( + TestHelper.fakeUser(), + function(user2) { + TestHelper.basicClient().createGroupChannel( + [user2.id, user1.id], + function(data) { + assert.equal(data.id.length > 0, true); + done(); + }, + function(err) { + done(new Error(err.message)); + } + ); + }, + function(err) { + done(new Error(err.message)); + } + ); + }, + function(err) { + done(new Error(err.message)); + } + ); + }); + }); + it('updateChannel', function(done) { TestHelper.initBasic(() => { var channel = TestHelper.basicChannel(); diff --git a/utils/channel_intro_messages.jsx b/utils/channel_intro_messages.jsx index 991bf54e8149..390ce6d280bf 100644 --- a/utils/channel_intro_messages.jsx +++ b/utils/channel_intro_messages.jsx @@ -23,8 +23,10 @@ export function createChannelIntroMessage(channel, fullWidthIntro) { centeredIntro = 'channel-intro--centered'; } - if (channel.type === 'D') { + if (channel.type === Constants.DM_CHANNEL) { return createDMIntroMessage(channel, centeredIntro); + } else if (channel.type === Constants.GM_CHANNEL) { + return createGMIntroMessage(channel, centeredIntro); } else if (ChannelStore.isDefault(channel)) { return createDefaultIntroMessage(channel, centeredIntro); } else if (channel.name === Constants.OFFTOPIC_CHANNEL) { @@ -35,6 +37,65 @@ export function createChannelIntroMessage(channel, fullWidthIntro) { return null; } +export function createGMIntroMessage(channel, centeredIntro) { + const profiles = UserStore.getProfileListInChannel(channel.id, true); + + if (profiles.length > 0) { + const pictures = []; + let names = ''; + for (let i = 0; i < profiles.length; i++) { + const profile = profiles[i]; + + pictures.push( + + ); + + if (i === profiles.length - 1) { + names += Utils.displayUsernameForUser(profile); + } else if (i === profiles.length - 2) { + names += Utils.displayUsernameForUser(profile) + ' and '; + } else { + names += Utils.displayUsernameForUser(profile) + ', '; + } + } + + return ( +
    +
    + {pictures} +
    +

    + +

    + {createSetHeaderButton(channel)} +
    + ); + } + + return ( +
    +

    + +

    +
    + ); +} + export function createDMIntroMessage(channel, centeredIntro) { var teammate = Utils.getDirectTeammate(channel.id); diff --git a/utils/channel_utils.jsx b/utils/channel_utils.jsx index 22c428cb86e9..2bb30af5c78e 100644 --- a/utils/channel_utils.jsx +++ b/utils/channel_utils.jsx @@ -5,7 +5,6 @@ const Preferences = Constants.Preferences; import * as Utils from 'utils/utils.jsx'; import UserStore from 'stores/user_store.jsx'; -import TeamStore from 'stores/team_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import LocalizationStore from 'stores/localization_store.jsx'; @@ -15,30 +14,28 @@ import LocalizationStore from 'stores/localization_store.jsx'; * Example: { * publicChannels: [...], * privateChannels: [...], - * directChannels: [...], - * directNonTeamChannels: [...], + * directAndGroupChannels: [...], * favoriteChannels: [...] * } */ export function buildDisplayableChannelList(persistentChannels) { - const missingDMChannels = createMissingDirectChannels(persistentChannels); + const missingDirectChannels = createMissingDirectChannels(persistentChannels); const channels = persistentChannels. - concat(missingDMChannels). + concat(missingDirectChannels). map(completeDirectChannelInfo). filter(isNotDeletedChannel). sort(sortChannelsByDisplayName); const favoriteChannels = channels.filter(isFavoriteChannel); const notFavoriteChannels = channels.filter(not(isFavoriteChannel)); - const directChannels = notFavoriteChannels.filter(andX(isDirectChannel, isDirectChannelVisible)); + const directAndGroupChannels = notFavoriteChannels.filter(orX(andX(isGroupChannel, isGroupChannelVisible), andX(isDirectChannel, isDirectChannelVisible))); return { favoriteChannels, publicChannels: notFavoriteChannels.filter(isOpenChannel), privateChannels: notFavoriteChannels.filter(isPrivateChannel), - directChannels: directChannels.filter(isConnectedToTeamMember), - directNonTeamChannels: directChannels.filter(isNotConnectedToTeamMember) + directAndGroupChannels }; } @@ -62,6 +59,14 @@ export function isPrivateChannel(channel) { return channel.type === Constants.PRIVATE_CHANNEL; } +export function isGroupChannel(channel) { + return channel.type === Constants.GM_CHANNEL; +} + +export function isGroupChannelVisible(channel) { + return PreferenceStore.getBool(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel.id); +} + export function isDirectChannel(channel) { return channel.type === Constants.DM_CHANNEL; } @@ -88,12 +93,12 @@ export function completeDirectChannelInfo(channel) { } const defaultPrefix = 'D'; // fallback for future types -const typeToPrefixMap = {[Constants.OPEN_CHANNEL]: 'A', [Constants.PRIVATE_CHANNEL]: 'B', [Constants.DM_CHANNEL]: 'C'}; +const typeToPrefixMap = {[Constants.OPEN_CHANNEL]: 'A', [Constants.PRIVATE_CHANNEL]: 'B', [Constants.DM_CHANNEL]: 'C', [Constants.GM_CHANNEL]: 'C'}; export function sortChannelsByDisplayName(a, b) { const locale = LocalizationStore.getLocale(); - if (a.type !== b.type) { + if (a.type !== b.type && typeToPrefixMap[a.type] !== typeToPrefixMap[b.type]) { return (typeToPrefixMap[a.type] || defaultPrefix).localeCompare((typeToPrefixMap[b.type] || defaultPrefix), locale); } @@ -186,6 +191,19 @@ export function showDeleteOption(channel, isAdmin, isSystemAdmin, isChannelAdmin return true; } +export function buildGroupChannelName(channelId) { + const profiles = UserStore.getProfileListInChannel(channelId, true); + let displayName = ''; + for (let i = 0; i < profiles.length; i++) { + displayName += Utils.displayUsernameForUser(profiles[i]); + if (i !== profiles.length - 1) { + displayName += ', '; + } + } + + return displayName; +} + /* * not exported helpers */ @@ -215,22 +233,14 @@ function createFakeChannelCurried(userId) { return (otherUserId) => createFakeChannel(userId, otherUserId); } -function isConnectedToTeamMember(channel) { - return isTeamMember(channel.teammate_id); -} - -function isTeamMember(userId) { - return TeamStore.hasActiveMemberInTeam(TeamStore.getCurrentId(), userId); -} - -function isNotConnectedToTeamMember(channel) { - return TeamStore.hasMemberNotInTeam(TeamStore.getCurrentId(), channel.teammate_id); -} - function not(f) { return (...args) => !f(...args); } +function orX(...fns) { + return (...args) => fns.some((f) => f(...args)); +} + function andX(...fns) { return (...args) => fns.every((f) => f(...args)); } diff --git a/utils/constants.jsx b/utils/constants.jsx index 0f3e217b9eba..fafad9f44350 100644 --- a/utils/constants.jsx +++ b/utils/constants.jsx @@ -35,6 +35,7 @@ import windows10ThemeImage from 'images/themes/windows_dark.png'; export const Preferences = { CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show', + CATEGORY_GROUP_CHANNEL_SHOW: 'group_channel_show', CATEGORY_DISPLAY_SETTINGS: 'display_settings', DISPLAY_PREFER_NICKNAME: 'nickname_full_name', DISPLAY_PREFER_FULL_NAME: 'full_name', @@ -164,6 +165,7 @@ export const ActionTypes = keyMirror({ TOGGLE_GET_POST_LINK_MODAL: null, TOGGLE_GET_TEAM_INVITE_LINK_MODAL: null, TOGGLE_GET_PUBLIC_LINK_MODAL: null, + TOGGLE_DM_MODAL: null, SUGGESTION_PRETEXT_CHANGED: null, SUGGESTION_RECEIVED_SUGGESTIONS: null, @@ -390,8 +392,11 @@ export const Constants = { ], MONTHS: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], MAX_DMS: 20, + MAX_USERS_IN_GM: 8, + MIN_USERS_IN_GM: 3, MAX_CHANNEL_POPOVER_COUNT: 100, DM_CHANNEL: 'D', + GM_CHANNEL: 'G', OPEN_CHANNEL: 'O', PRIVATE_CHANNEL: 'P', INVITE_TEAM: 'I', diff --git a/utils/utils.jsx b/utils/utils.jsx index 3d7941158e93..7573eb887257 100644 --- a/utils/utils.jsx +++ b/utils/utils.jsx @@ -477,6 +477,7 @@ export function applyTheme(theme) { changeCss('.sidebar--left .nav-pills__container li>h4, .sidebar--left .add-channel-btn', 'color:' + changeOpacity(theme.sidebarText, 0.6)); changeCss('.sidebar--left .add-channel-btn:hover, .sidebar--left .add-channel-btn:focus', 'color:' + theme.sidebarText); changeCss('.sidebar--left .status .offline--icon', 'fill:' + theme.sidebarText); + changeCss('.sidebar--left .status.status--group', 'background:' + changeOpacity(theme.sidebarText, 0.3)); changeCss('@media(max-width: 768px){.app__body .modal .settings-modal .settings-table .nav>li>a, .app__body .sidebar--menu .divider', 'border-color:' + changeOpacity(theme.sidebarText, 0.2)); changeCss('@media(max-width: 768px){.sidebar--left .add-channel-btn:hover, .sidebar--left .add-channel-btn:focus', 'color:' + changeOpacity(theme.sidebarText, 0.6)); } @@ -569,6 +570,7 @@ export function applyTheme(theme) { } if (theme.centerChannelColor) { + changeCss('.app__body .mentions__name .status.status--group, .app__body .multi-select__note', 'background:' + changeOpacity(theme.centerChannelColor, 0.12)); changeCss('.app__body .post-list__arrows, .app__body .post .flag-icon__container', 'fill:' + changeOpacity(theme.centerChannelColor, 0.3)); changeCss('.app__body .modal .status .offline--icon, .app__body .channel-header__links .icon, .app__body .sidebar--right .sidebar--right__subheader .usage__icon', 'fill:' + theme.centerChannelColor); changeCss('@media(min-width: 768px){.app__body .post:hover .post__header .col__reply, .app__body .post.post--hovered .post__header .col__reply', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2)); @@ -618,7 +620,7 @@ export function applyTheme(theme) { changeCss('@media(max-width: 1800px){.app__body .inner-wrap.move--left .post.post--comment.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07)); changeCss('.app__body .post.post--hovered', 'background:' + changeOpacity(theme.centerChannelColor, 0.08)); changeCss('@media(min-width: 768px){.app__body .post:hover, .app__body .more-modal__list .more-modal__row:hover, .app__body .modal .settings-modal .settings-table .settings-content .section-min:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.08)); - changeCss('.app__body .date-separator.hovered--before:after, .app__body .date-separator.hovered--after:before, .app__body .new-separator.hovered--after:before, .app__body .new-separator.hovered--before:after', 'background:' + changeOpacity(theme.centerChannelColor, 0.07)); + changeCss('.app__body .more-modal__row.more-modal__row--selected, .app__body .date-separator.hovered--before:after, .app__body .date-separator.hovered--after:before, .app__body .new-separator.hovered--after:before, .app__body .new-separator.hovered--before:after', 'background:' + changeOpacity(theme.centerChannelColor, 0.07)); changeCss('@media(min-width: 768px){.app__body .suggestion-list__content .command:hover, .app__body .mentions__name:hover, .app__body .dropdown-menu>li>a:focus, .app__body .dropdown-menu>li>a:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.15)); changeCss('.app__body .suggestion--selected, .app__body .bot-indicator', 'background:' + changeOpacity(theme.centerChannelColor, 0.15), 1); changeCss('code, .app__body .form-control[disabled], .app__body .form-control[readonly], .app__body fieldset[disabled] .form-control', 'background:' + changeOpacity(theme.centerChannelColor, 0.1));