diff options
-rw-r--r-- | webapp/actions/channel_actions.jsx | 14 | ||||
-rw-r--r-- | webapp/components/channel_header.jsx | 63 | ||||
-rw-r--r-- | webapp/components/navbar.jsx | 58 | ||||
-rw-r--r-- | webapp/components/sidebar.jsx | 97 | ||||
-rw-r--r-- | webapp/i18n/en.json | 2 | ||||
-rw-r--r-- | webapp/sass/layout/_headers.scss | 5 | ||||
-rw-r--r-- | webapp/utils/channel_utils.jsx | 135 | ||||
-rw-r--r-- | webapp/utils/constants.jsx | 1 |
8 files changed, 297 insertions, 78 deletions
diff --git a/webapp/actions/channel_actions.jsx b/webapp/actions/channel_actions.jsx index 61c839652..c9c4e6883 100644 --- a/webapp/actions/channel_actions.jsx +++ b/webapp/actions/channel_actions.jsx @@ -172,3 +172,17 @@ export function openDirectChannelToUser(user, success, error) { } ); } + +export function markFavorite(channelId) { + AsyncClient.savePreference(Preferences.CATEGORY_FAVORITE_CHANNEL, channelId, 'true'); +} + +export function unmarkFavorite(channelId) { + const pref = { + user_id: UserStore.getCurrentId(), + category: Preferences.CATEGORY_FAVORITE_CHANNEL, + name: channelId + }; + + AsyncClient.deletePreferences([pref]); +} diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx index 1a8625cd2..2d3de5998 100644 --- a/webapp/components/channel_header.jsx +++ b/webapp/components/channel_header.jsx @@ -26,7 +26,9 @@ import WebrtcStore from 'stores/webrtc_store.jsx'; import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import * as WebrtcActions from 'actions/webrtc_actions.jsx'; +import * as ChannelActions from 'actions/channel_actions.jsx'; import * as Utils from 'utils/utils.jsx'; +import * as ChannelUtils from 'utils/channel_utils.jsx'; import * as TextFormatting from 'utils/text_formatting.jsx'; import Client from 'client/web_client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; @@ -63,18 +65,19 @@ export default class ChannelHeader extends React.Component { } getStateFromStores() { + const channel = ChannelStore.get(this.props.channelId); const stats = ChannelStore.getStats(this.props.channelId); - const users = UserStore.getProfileListInChannel(this.props.channelId); return { - channel: ChannelStore.get(this.props.channelId), + channel, memberChannel: ChannelStore.getMyMember(this.props.channelId), users, userCount: stats.member_count, currentUser: UserStore.getCurrentUser(), enableFormatting: PreferenceStore.getBool(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true), - isBusy: WebrtcStore.isBusy() + isBusy: WebrtcStore.isBusy(), + isFavorite: channel && ChannelUtils.isFavoriteChannel(channel) }; } @@ -125,11 +128,17 @@ export default class ChannelHeader extends React.Component { handleLeave() { Client.leaveChannel(this.state.channel.id, () => { + const channelId = this.state.channel.id; + AppDispatcher.handleViewAction({ type: ActionTypes.LEAVE_CHANNEL, - id: this.state.channel.id + id: channelId }); + if (this.state.isFavorite) { + ChannelActions.unmarkFavorite(channelId); + } + const townsquare = ChannelStore.getByName('town-square'); browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + townsquare.name); }, @@ -139,6 +148,16 @@ export default class ChannelHeader extends React.Component { ); } + toggleFavorite = (e) => { + e.preventDefault(); + + if (this.state.isFavorite) { + ChannelActions.unmarkFavorite(this.state.channel.id); + } else { + ChannelActions.markFavorite(this.state.channel.id); + } + }; + searchMentions(e) { e.preventDefault(); const user = this.state.currentUser; @@ -272,9 +291,9 @@ export default class ChannelHeader extends React.Component { if (isDirect) { const userMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; const contact = this.state.users[0]; - if (contact) { - channelTitle = Utils.displayUsername(contact.id); - } + + const teammateId = Utils.getUserIdFromChannelName(channel); + channelTitle = Utils.displayUsername(teammateId); const webrtcEnabled = global.mm_config.EnableWebrtc === 'true' && global.mm_license.Webrtc === 'true' && global.mm_config.EnableDeveloper === 'true' && userMedia && Utils.isFeatureEnabled(PreReleaseFeatures.WEBRTC_PREVIEW); @@ -607,6 +626,35 @@ export default class ChannelHeader extends React.Component { headerText = channel.header; } + const toggleFavoriteTooltip = ( + <Tooltip id='favoriteTooltip'> + {this.state.isFavorite ? + <FormattedMessage + id='channelHeader.removeFromFavorites' + defaultMessage='Remove from Favorites' + /> : + <FormattedMessage + id='channelHeader.addToFavorites' + defaultMessage='Add to Favorites' + />} + </Tooltip> + ); + const toggleFavorite = ( + <OverlayTrigger + delayShow={Constants.OVERLAY_TIME_DELAY} + placement='bottom' + overlay={toggleFavoriteTooltip} + > + <a + href='#' + onClick={this.toggleFavorite} + className='channel-header__favorites' + > + <i className={'icon fa ' + (this.state.isFavorite ? 'fa-star' : 'fa-star-o')}/> + </a> + </OverlayTrigger> + ); + return ( <div id='channel-header' @@ -618,6 +666,7 @@ export default class ChannelHeader extends React.Component { <th> <div className='channel-header__info'> {webrtc} + {toggleFavorite} <div className='dropdown'> <a href='#' diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx index 865e2ac78..18ea84376 100644 --- a/webapp/components/navbar.jsx +++ b/webapp/components/navbar.jsx @@ -18,15 +18,19 @@ import StatusIcon from './status_icon.jsx'; import UserStore from 'stores/user_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import TeamStore from 'stores/team_store.jsx'; +import PreferenceStore from 'stores/preference_store.jsx'; import ChannelSwitchModal from './channel_switch_modal.jsx'; import Client from 'client/web_client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; +import * as ChannelUtils from 'utils/channel_utils.jsx'; +import * as ChannelActions from 'actions/channel_actions.jsx'; import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; + import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import {FormattedMessage} from 'react-intl'; @@ -66,12 +70,15 @@ export default class Navbar extends React.Component { } getStateFromStores() { + const channel = ChannelStore.getCurrent(); + return { - channel: ChannelStore.getCurrent(), + channel, member: ChannelStore.getCurrentMember(), users: [], userCount: ChannelStore.getCurrentStats().member_count, - currentUser: UserStore.getCurrentUser() + currentUser: UserStore.getCurrentUser(), + isFavorite: channel && ChannelUtils.isFavoriteChannel(channel) }; } @@ -83,6 +90,7 @@ export default class Navbar extends React.Component { ChannelStore.addChangeListener(this.onChange); ChannelStore.addStatsChangeListener(this.onChange); UserStore.addStatusesChangeListener(this.onChange); + PreferenceStore.addChangeListener(this.onChange); $('.inner-wrap').click(this.hideSidebars); document.addEventListener('keydown', this.showChannelSwitchModal); } @@ -91,6 +99,7 @@ export default class Navbar extends React.Component { ChannelStore.removeChangeListener(this.onChange); ChannelStore.removeStatsChangeListener(this.onChange); UserStore.removeStatusesChangeListener(this.onChange); + PreferenceStore.removeChangeListener(this.onChange); document.removeEventListener('keydown', this.showChannelSwitchModal); } @@ -99,10 +108,17 @@ export default class Navbar extends React.Component { } handleLeave() { - Client.leaveChannel(this.state.channel.id, + var channelId = this.state.channel.id; + + Client.leaveChannel(channelId, () => { AsyncClient.getChannels(true); - browserHistory.push(TeamStore.getCurrentTeamUrl() + '/channels/town-square'); + if (this.state.isFavorite) { + ChannelActions.unmarkFavorite(channelId); + } + + const townsquare = ChannelStore.getByName('town-square'); + browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + townsquare.name); }, (err) => { AsyncClient.dispatchError(err, 'handleLeave'); @@ -214,6 +230,16 @@ export default class Navbar extends React.Component { return true; } + toggleFavorite = (e) => { + e.preventDefault(); + + if (this.state.isFavorite) { + ChannelActions.unmarkFavorite(this.state.channel.id); + } else { + ChannelActions.markFavorite(this.state.channel.id); + } + }; + createDropdown(channel, channelTitle, isAdmin, isSystemAdmin, isDirect, popoverContent) { if (channel) { let channelTerm = ( @@ -425,6 +451,29 @@ export default class Navbar extends React.Component { } } + const toggleFavoriteOption = ( + <li + key='toggle_favorite' + role='presentation' + > + <a + role='menuitem' + href='#' + onClick={this.toggleFavorite} + > + {this.state.isFavorite ? + <FormattedMessage + id='channelHeader.removeFromFavorites' + defaultMessage='Remove from Favorites' + /> : + <FormattedMessage + id='channelHeader.addToFavorites' + defaultMessage='Add to Favorites' + />} + </a> + </li> + ); + return ( <div className='navbar-brand'> <div className='dropdown'> @@ -461,6 +510,7 @@ export default class Navbar extends React.Component { {renameChannelOption} {deleteChannelOption} {leaveChannelOption} + {toggleFavoriteOption} </ul> </div> </div> diff --git a/webapp/components/sidebar.jsx b/webapp/components/sidebar.jsx index c8a7e1eb9..2a589b996 100644 --- a/webapp/components/sidebar.jsx +++ b/webapp/components/sidebar.jsx @@ -14,10 +14,10 @@ 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 LocalizationStore from 'stores/localization_store.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; +import * as ChannelUtils from 'utils/channel_utils.jsx'; import * as ChannelActions from 'actions/channel_actions.jsx'; import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx'; @@ -96,63 +96,13 @@ export default class Sidebar extends React.Component { getStateFromStores() { const members = ChannelStore.getMyMembers(); const currentChannelId = ChannelStore.getCurrentId(); - const currentUserId = UserStore.getCurrentId(); - - const channels = Object.assign([], ChannelStore.getAll()); - channels.sort(this.sortChannelsByDisplayName); - - const publicChannels = channels.filter((channel) => channel.type === Constants.OPEN_CHANNEL); - const privateChannels = channels.filter((channel) => channel.type === Constants.PRIVATE_CHANNEL); - - const preferences = PreferenceStore.getCategory(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); - - const directChannels = []; - const directNonTeamChannels = []; - for (const [name, value] of preferences) { - if (value !== 'true') { - continue; - } - - const teammateId = name; - - let directChannel = channels.find(Utils.isDirectChannelForUser.bind(null, teammateId)); - - // a direct channel doesn't exist yet so create a fake one - if (directChannel == null) { - directChannel = { - name: Utils.getDirectChannelName(currentUserId, teammateId), - last_post_at: 0, - total_msg_count: 0, - type: Constants.DM_CHANNEL, - fake: true - }; - } else { - directChannel = JSON.parse(JSON.stringify(directChannel)); - } - - directChannel.display_name = Utils.displayUsername(teammateId); - directChannel.teammate_id = teammateId; - directChannel.status = UserStore.getStatus(teammateId) || 'offline'; - - if (TeamStore.hasActiveMemberInTeam(TeamStore.getCurrentId(), teammateId)) { - directChannels.push(directChannel); - } else if (TeamStore.hasMemberNotInTeam(TeamStore.getCurrentId(), teammateId)) { - directNonTeamChannels.push(directChannel); - } - } - - directChannels.sort(this.sortChannelsByDisplayName); - directNonTeamChannels.sort(this.sortChannelsByDisplayName); - const tutorialStep = PreferenceStore.getInt(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), 999); + const channelList = ChannelUtils.buildDisplayableChannelList(Object.assign([], ChannelStore.getAll())); return { activeId: currentChannelId, members, - publicChannels, - privateChannels, - directChannels, - directNonTeamChannels, + ...channelList, unreadCounts: JSON.parse(JSON.stringify(ChannelStore.getUnreadCounts())), showTutorialTip: tutorialStep === TutorialSteps.CHANNEL_POPOVER, currentTeam: TeamStore.getCurrent(), @@ -379,6 +329,10 @@ export default class Sidebar extends React.Component { } ); + if (ChannelUtils.isFavoriteChannel(channel)) { + ChannelActions.unmarkFavorite(channel.id); + } + this.setState(this.getStateFromStores()); } @@ -387,16 +341,6 @@ export default class Sidebar extends React.Component { } } - sortChannelsByDisplayName(a, b) { - const locale = LocalizationStore.getLocale(); - - if (a.display_name === b.display_name) { - return a.name.localeCompare(b.name, locale, {numeric: true}); - } - - return a.display_name.localeCompare(b.display_name, locale, {numeric: true}); - } - showMoreChannelsModal() { // manually show the modal because using data-toggle messes with keyboard focus when the modal is dismissed $('#more_channels').modal({'data-channeltype': 'O'}).modal('show'); @@ -522,7 +466,7 @@ export default class Sidebar extends React.Component { badge = <span className='badge pull-right small'>{unreadCount.mentions}</span>; this.badgesActive = true; } - } else if (this.state.loadingDMChannel === index && channel.type === 'D') { + } else if (this.state.loadingDMChannel === index && channel.type === Constants.DM_CHANNEL) { badge = ( <img className='channel-loading-gif pull-right' @@ -536,9 +480,9 @@ export default class Sidebar extends React.Component { } var icon = null; - if (channel.type === 'O') { + if (channel.type === Constants.OPEN_CHANNEL) { icon = <div className='status'><i className='fa fa-globe'/></div>; - } else if (channel.type === 'P') { + } else if (channel.type === Constants.PRIVATE_CHANNEL) { icon = <div className='status'><i className='fa fa-lock'/></div>; } else { // set up status icon for direct message channels (status is null for other channel types) @@ -618,7 +562,15 @@ export default class Sidebar extends React.Component { this.firstUnreadChannel = null; this.lastUnreadChannel = null; - // create elements for all 3 types of channels + // create elements for all 4 types of channels + const favoriteItems = this.state.favoriteChannels.map((channel, index, arr) => { + if (channel.type === Constants.DM_CHANNEL) { + return this.createChannelElement(channel, index, arr, this.handleLeaveDirectChannel); + } + + return this.createChannelElement(channel); + }); + const publicChannelItems = this.state.publicChannels.map(this.createChannelElement); const privateChannelItems = this.state.privateChannels.map(this.createChannelElement); @@ -801,6 +753,17 @@ export default class Sidebar extends React.Component { className='nav-pills__container' onScroll={this.onScroll} > + {favoriteItems.length !== 0 && <ul className='nav nav-pills nav-stacked'> + <li> + <h4> + <FormattedMessage + id='sidebar.favorite' + defaultMessage='Favorites' + /> + </h4> + </li> + {favoriteItems} + </ul>} <ul className='nav nav-pills nav-stacked'> <li> <h4> diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 9b78b2803..24d61e5f3 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -986,6 +986,8 @@ "channel_header.flagged": "Flagged Posts", "channel_header.group": "Group", "channel_header.leave": "Leave {term}", + "channel_header.addToFavorites": "Add to Favorites", + "channel_header.removeFromFavorites": "Remove from Favorites", "channel_header.manageMembers": "Manage Members", "channel_header.notificationPreferences": "Notification Preferences", "channel_header.recentMentions": "Recent Mentions", diff --git a/webapp/sass/layout/_headers.scss b/webapp/sass/layout/_headers.scss index 58ca512b6..579875b47 100644 --- a/webapp/sass/layout/_headers.scss +++ b/webapp/sass/layout/_headers.scss @@ -378,6 +378,11 @@ } } +.channel-header__favorites { + float: left; + margin: 1px 10px 0 0; +} + .app__body { .channel-header__links { diff --git a/webapp/utils/channel_utils.jsx b/webapp/utils/channel_utils.jsx new file mode 100644 index 000000000..119021fce --- /dev/null +++ b/webapp/utils/channel_utils.jsx @@ -0,0 +1,135 @@ + +import Constants from 'utils/constants.jsx'; +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'; + +/** + * Returns list of sorted channels grouped by type. Favorites here is considered as separated type. + * + * Example: { + * publicChannels: [...], + * privateChannels: [...], + * directChannels: [...], + * directNonTeamChannels: [...], + * favoriteChannels: [...] + * } + */ +export function buildDisplayableChannelList(persistentChannels) { + const missingDMChannels = createMissingDirectChannels(persistentChannels); + + const channels = persistentChannels.concat(missingDMChannels).map(completeDirectChannelInfo); + channels.sort(sortChannelsByDisplayName); + + const favoriteChannels = channels.filter(isFavoriteChannel); + const notFavoriteChannels = channels.filter(not(isFavoriteChannel)); + const directChannels = notFavoriteChannels.filter(andX(isDirectChannel, isDirectChannelVisible)); + + return { + favoriteChannels, + publicChannels: notFavoriteChannels.filter(isOpenChannel), + privateChannels: notFavoriteChannels.filter(isPrivateChannel), + directChannels: directChannels.filter(isConnectedToTeamMember), + directNonTeamChannels: directChannels.filter(not(isConnectedToTeamMember)) + }; +} + +export function isFavoriteChannel(channel) { + return PreferenceStore.getBool(Preferences.CATEGORY_FAVORITE_CHANNEL, channel.id); +} + +export function isOpenChannel(channel) { + return channel.type === Constants.OPEN_CHANNEL; +} + +export function isPrivateChannel(channel) { + return channel.type === Constants.PRIVATE_CHANNEL; +} + +export function isDirectChannel(channel) { + return channel.type === Constants.DM_CHANNEL; +} + +export function isDirectChannelVisible(channel) { + const channelId = Utils.getUserIdFromChannelName(channel); + + return PreferenceStore.getBool(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, channelId); +} + +export function completeDirectChannelInfo(channel) { + if (!isDirectChannel(channel)) { + return channel; + } + + const dmChannelClone = JSON.parse(JSON.stringify(channel)); + const teammateId = Utils.getUserIdFromChannelName(channel); + + return Object.assign(dmChannelClone, { + display_name: Utils.displayUsername(teammateId), + teammate_id: teammateId, + status: UserStore.getStatus(teammateId) || 'offline' + }); +} + +export function sortChannelsByDisplayName(a, b) { + const locale = LocalizationStore.getLocale(); + + return buildDisplayNameAndTypeComparable(a).localeCompare(buildDisplayNameAndTypeComparable(b), locale, {numeric: true}); +} + +/* + * not exported helpers + */ + +function createMissingDirectChannels(channels) { + const directChannelsDisplayPreferences = PreferenceStore.getCategory(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); + + return Array. + from(directChannelsDisplayPreferences). + filter((entry) => entry[1] === 'true'). + map((entry) => entry[0]). + filter((teammateId) => !channels.some(Utils.isDirectChannelForUser.bind(null, teammateId))). + map(createFakeChannelCurried(UserStore.getCurrentId())); +} + +function createFakeChannel(userId, otherUserId) { + return { + name: Utils.getDirectChannelName(userId, otherUserId), + last_post_at: 0, + total_msg_count: 0, + type: Constants.DM_CHANNEL, + fake: true + }; +} + +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 not(f) { + return (...args) => !f(...args); +} + +function andX(...fns) { + return (...args) => fns.every((f) => f(...args)); +} + +const defaultPrefix = 'D'; // fallback for future types +const typeToPrefixMap = {[Constants.OPEN_CHANNEL]: 'A', [Constants.PRIVATE_CHANNEL]: 'B', [Constants.DM_CHANNEL]: 'C'}; + +function buildDisplayNameAndTypeComparable(channel) { + return (typeToPrefixMap[channel.type] || defaultPrefix) + channel.display_name + channel.name; +} diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index 2dae06282..f87b36fc8 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -54,6 +54,7 @@ export const Preferences = { CATEGORY_THEME: 'theme', CATEGORY_FLAGGED_POST: 'flagged_post', CATEGORY_NOTIFICATIONS: 'notifications', + CATEGORY_FAVORITE_CHANNEL: 'favorite_channel', EMAIL_INTERVAL: 'email_interval' }; |