diff options
Diffstat (limited to 'webapp')
25 files changed, 614 insertions, 44 deletions
diff --git a/webapp/actions/post_actions.jsx b/webapp/actions/post_actions.jsx index cbcddfc7c..0c837621f 100644 --- a/webapp/actions/post_actions.jsx +++ b/webapp/actions/post_actions.jsx @@ -68,6 +68,14 @@ export function handleNewPost(post, msg) { }); } +export function pinPost(channelId, postId) { + AsyncClient.pinPost(channelId, postId); +} + +export function unpinPost(channelId, postId) { + AsyncClient.unpinPost(channelId, postId); +} + export function flagPost(postId) { trackEvent('api', 'api_posts_flagged'); AsyncClient.savePreference(Preferences.CATEGORY_FLAGGED_POST, postId, 'true'); @@ -96,7 +104,8 @@ export function getFlaggedPosts() { AppDispatcher.handleServerAction({ type: ActionTypes.RECEIVED_SEARCH, results: data, - is_flagged_posts: true + is_flagged_posts: true, + is_pinned_posts: false }); loadProfilesForPosts(data.posts); @@ -107,6 +116,31 @@ export function getFlaggedPosts() { ); } +export function getPinnedPosts(channelId) { + Client.getPinnedPosts(channelId, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_SEARCH_TERM, + term: null, + do_search: false, + is_mention_search: false + }); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_SEARCH, + results: data, + is_flagged_posts: false, + is_pinned_posts: true + }); + + loadProfilesForPosts(data.posts); + }, + (err) => { + AsyncClient.dispatchError(err, 'getPinnedPosts'); + } + ); +} + export function loadPosts(channelId = ChannelStore.getCurrentId(), isPost = false) { const postList = PostStore.getAllPosts(channelId); const latestPostTime = PostStore.getLatestPostFromPageTime(channelId); diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx index eaffd9ff4..a95049f93 100644 --- a/webapp/client/client.jsx +++ b/webapp/client/client.jsx @@ -1802,6 +1802,15 @@ export default class Client { this.trackEvent('api', 'api_posts_get_flagged', {team_id: this.getTeamId()}); } + getPinnedPosts(channelId, success, error) { + request. + get(`${this.getChannelNeededRoute(channelId)}/pinned`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getPinnedPosts', success, error)); + } + getFileInfosForPost(channelId, postId, success, error) { request. get(`${this.getChannelNeededRoute(channelId)}/posts/${postId}/get_file_infos`). @@ -2187,6 +2196,24 @@ export default class Client { }); } + pinPost(channelId, postId, success, error) { + request. + post(`${this.getChannelNeededRoute(channelId)}/posts/${postId}/pin`). + set(this.defaultHeaders). + accept('application/json'). + send(). + end(this.handleResponse.bind(this, 'pinPost', success, error)); + } + + unpinPost(channelId, postId, success, error) { + request. + post(`${this.getChannelNeededRoute(channelId)}/posts/${postId}/unpin`). + set(this.defaultHeaders). + accept('application/json'). + send(). + end(this.handleResponse.bind(this, 'unpinPost', success, error)); + } + saveReaction(channelId, reaction, success, error) { request. post(`${this.getChannelNeededRoute(channelId)}/posts/${reaction.post_id}/reactions/save`). diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx index 01e1e98cf..556e49863 100644 --- a/webapp/components/channel_header.jsx +++ b/webapp/components/channel_header.jsx @@ -30,7 +30,7 @@ import * as Utils from 'utils/utils.jsx'; import * as ChannelUtils from 'utils/channel_utils.jsx'; import {getSiteURL} from 'utils/url.jsx'; import * as TextFormatting from 'utils/text_formatting.jsx'; -import {getFlaggedPosts} from 'actions/post_actions.jsx'; +import {getFlaggedPosts, getPinnedPosts} from 'actions/post_actions.jsx'; import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; @@ -53,6 +53,7 @@ export default class ChannelHeader extends React.Component { this.hideRenameChannelModal = this.hideRenameChannelModal.bind(this); this.handleShortcut = this.handleShortcut.bind(this); this.getFlagged = this.getFlagged.bind(this); + this.getPinnedPosts = this.getPinnedPosts.bind(this); this.initWebrtc = this.initWebrtc.bind(this); this.onBusy = this.onBusy.bind(this); this.openDirectMessageModal = this.openDirectMessageModal.bind(this); @@ -158,6 +159,15 @@ export default class ChannelHeader extends React.Component { } } + getPinnedPosts(e) { + e.preventDefault(); + if (SearchStore.isPinnedPosts) { + GlobalActions.toggleSideBarAction(false); + } else { + getPinnedPosts(this.props.channelId); + } + } + getFlagged(e) { e.preventDefault(); if (SearchStore.isFlaggedPosts) { @@ -211,6 +221,7 @@ export default class ChannelHeader extends React.Component { render() { const flagIcon = Constants.FLAG_ICON_SVG; + const pinIcon = Constants.PIN_ICON; if (!this.validState()) { // Use an empty div to make sure the header's height stays constant @@ -762,8 +773,20 @@ export default class ChannelHeader extends React.Component { </OverlayTrigger> </div> </th> - <th className='header-list__members'> + <th className='header-list__right'> {popoverListMembers} + <a + href='#' + type='button' + id='pinned-posts-button' + className='pinned-posts-button' + onClick={this.getPinnedPosts} + > + <span + dangerouslySetInnerHTML={{__html: pinIcon}} + aria-hidden='true' + /> + </a> </th> <th className='search-bar__container'> <NavbarSearchBox diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx index c945a0b9c..1ad2f916d 100644 --- a/webapp/components/navbar.jsx +++ b/webapp/components/navbar.jsx @@ -19,12 +19,15 @@ 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 SearchStore from 'stores/search_store.jsx'; import ChannelSwitchModal from './channel_switch_modal.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 * as GlobalActions from 'actions/global_actions.jsx'; +import {getPinnedPosts} from 'actions/post_actions.jsx'; import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; @@ -62,6 +65,7 @@ export default class Navbar extends React.Component { this.hideChannelSwitchModal = this.hideChannelSwitchModal.bind(this); this.openDirectMessageModal = this.openDirectMessageModal.bind(this); + this.getPinnedPosts = this.getPinnedPosts.bind(this); const state = this.getStateFromStores(); state.showEditChannelPurposeModal = false; @@ -216,6 +220,15 @@ export default class Navbar extends React.Component { }); } + getPinnedPosts(e) { + e.preventDefault(); + if (SearchStore.isPinnedPosts) { + GlobalActions.toggleSideBarAction(false); + } else { + getPinnedPosts(this.state.channel.id); + } + } + toggleFavorite = (e) => { e.preventDefault(); @@ -244,6 +257,7 @@ export default class Navbar extends React.Component { } let viewInfoOption; + let viewPinnedPostsOption; let addMembersOption; let manageMembersOption; let setChannelHeaderOption; @@ -335,6 +349,21 @@ export default class Navbar extends React.Component { </li> ); + viewPinnedPostsOption = ( + <li role='presentation'> + <a + role='menuitem' + href='#' + onClick={this.getPinnedPosts} + > + <FormattedMessage + id='navbar.viewPinnedPosts' + defaultMessage='View Pinned Posts' + /> + </a> + </li> + ); + if (!ChannelStore.isDefault(channel)) { addMembersOption = ( <li role='presentation'> @@ -561,6 +590,7 @@ export default class Navbar extends React.Component { role='menu' > {viewInfoOption} + {viewPinnedPostsOption} {notificationPreferenceOption} {addMembersOption} {manageMembersOption} diff --git a/webapp/components/needs_team.jsx b/webapp/components/needs_team.jsx index fb6029c2b..f589136b5 100644 --- a/webapp/components/needs_team.jsx +++ b/webapp/components/needs_team.jsx @@ -12,6 +12,7 @@ import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; +import PostStore from 'stores/post_store.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import {startPeriodicStatusUpdates, stopPeriodicStatusUpdates} from 'actions/status_actions.jsx'; import Constants from 'utils/constants.jsx'; @@ -173,12 +174,25 @@ export default class NeedsTeam extends React.Component { </div> ); } + + let channel = ChannelStore.getByName(this.props.params.channel); + if (channel == null) { + // the permalink view is not really tied to a particular channel but still needs it + const postId = PostStore.getFocusedPostId(); + const post = PostStore.getEarliestPostFromPage(postId); + + // the post take some time before being available on page load + if (post != null) { + channel = ChannelStore.get(post.channel_id); + } + } + return ( <div className='channel-view'> <ErrorBar/> <WebrtcNotification/> <div className='container-fluid'> - <SidebarRight/> + <SidebarRight channel={channel}/> <SidebarRightMenu teamType={this.state.team.type}/> <WebrtcSidebar/> {content} diff --git a/webapp/components/popover_list_members.jsx b/webapp/components/popover_list_members.jsx index bd2f744c7..1518b1ebf 100644 --- a/webapp/components/popover_list_members.jsx +++ b/webapp/components/popover_list_members.jsx @@ -233,7 +233,7 @@ export default class PopoverListMembers extends React.Component { } return ( - <div> + <div className='member-popover__container'> <div id='member_popover' className='member-popover__trigger' @@ -243,13 +243,11 @@ export default class PopoverListMembers extends React.Component { AsyncClient.getProfilesInChannel(this.props.channel.id, 0); }} > - <div> - {countText} - <span - className='fa fa-user' - aria-hidden='true' - /> - </div> + {countText} + <span + className='fa fa-user' + aria-hidden='true' + /> </div> <Overlay rootClose={true} diff --git a/webapp/components/post_view/components/post_info.jsx b/webapp/components/post_view/components/post_info.jsx index 331fdeb00..5318ec272 100644 --- a/webapp/components/post_view/components/post_info.jsx +++ b/webapp/components/post_view/components/post_info.jsx @@ -26,6 +26,8 @@ export default class PostInfo extends React.Component { this.removePost = this.removePost.bind(this); this.flagPost = this.flagPost.bind(this); this.unflagPost = this.unflagPost.bind(this); + this.pinPost = this.pinPost.bind(this); + this.unpinPost = this.unpinPost.bind(this); this.canEdit = false; this.canDelete = false; @@ -148,6 +150,42 @@ export default class PostInfo extends React.Component { ); } + if (this.props.post.is_pinned) { + dropdownContents.push( + <li + key='unpinLink' + role='presentation' + > + <a + href='#' + onClick={this.unpinPost} + > + <FormattedMessage + id='post_info.unpin' + defaultMessage='Un-pin from channel' + /> + </a> + </li> + ); + } else { + dropdownContents.push( + <li + key='pinLink' + role='presentation' + > + <a + href='#' + onClick={this.pinPost} + > + <FormattedMessage + id='post_info.pin' + defaultMessage='Pin to channel' + /> + </a> + </li> + ); + } + if (this.canDelete) { dropdownContents.push( <li @@ -250,6 +288,16 @@ export default class PostInfo extends React.Component { ); } + pinPost(e) { + e.preventDefault(); + PostActions.pinPost(this.props.post.channel_id, this.props.post.id); + } + + unpinPost(e) { + e.preventDefault(); + PostActions.unpinPost(this.props.post.channel_id, this.props.post.id); + } + flagPost(e) { e.preventDefault(); PostActions.flagPost(this.props.post.id); @@ -374,6 +422,18 @@ export default class PostInfo extends React.Component { ); } + let pinnedBadge; + if (post.is_pinned) { + pinnedBadge = ( + <span className='post__pinned-badge'> + <FormattedMessage + id='post_info.pinned' + defaultMessage='Pinned' + /> + </span> + ); + } + return ( <ul className='post__header--info'> <li className='col'> @@ -384,6 +444,7 @@ export default class PostInfo extends React.Component { useMilitaryTime={this.props.useMilitaryTime} postId={post.id} /> + {pinnedBadge} {flagTrigger} </li> {options} diff --git a/webapp/components/rhs_comment.jsx b/webapp/components/rhs_comment.jsx index cb527d850..c9a582877 100644 --- a/webapp/components/rhs_comment.jsx +++ b/webapp/components/rhs_comment.jsx @@ -10,7 +10,7 @@ import ReactionListContainer from 'components/post_view/components/reaction_list import RhsDropdown from 'components/rhs_dropdown.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; -import {flagPost, unflagPost} from 'actions/post_actions.jsx'; +import {flagPost, unflagPost, pinPost, unpinPost} from 'actions/post_actions.jsx'; import TeamStore from 'stores/team_store.jsx'; @@ -36,6 +36,8 @@ export default class RhsComment extends React.Component { this.removePost = this.removePost.bind(this); this.flagPost = this.flagPost.bind(this); this.unflagPost = this.unflagPost.bind(this); + this.pinPost = this.pinPost.bind(this); + this.unpinPost = this.unpinPost.bind(this); this.canEdit = false; this.canDelete = false; @@ -128,6 +130,16 @@ export default class RhsComment extends React.Component { unflagPost(this.props.post.id); } + pinPost(e) { + e.preventDefault(); + pinPost(this.props.post.channel_id, this.props.post.id); + } + + unpinPost(e) { + e.preventDefault(); + unpinPost(this.props.post.channel_id, this.props.post.id); + } + createDropdown() { const post = this.props.post; @@ -195,6 +207,42 @@ export default class RhsComment extends React.Component { </li> ); + if (post.is_pinned) { + dropdownContents.push( + <li + key='rhs-comment-unpin' + role='presentation' + > + <a + href='#' + onClick={this.unpinPost} + > + <FormattedMessage + id='rhs_root.unpin' + defaultMessage='Un-pin from channel' + /> + </a> + </li> + ); + } else { + dropdownContents.push( + <li + key='rhs-comment-pin' + role='presentation' + > + <a + href='#' + onClick={this.pinPost} + > + <FormattedMessage + id='rhs_root.pin' + defaultMessage='Pin to channel' + /> + </a> + </li> + ); + } + if (this.canDelete) { dropdownContents.push( <li @@ -503,6 +551,18 @@ export default class RhsComment extends React.Component { ); } + let pinnedBadge; + if (post.is_pinned) { + pinnedBadge = ( + <span className='post__pinned-badge'> + <FormattedMessage + id='post_info.pinned' + defaultMessage='Pinned' + /> + </span> + ); + } + const timeOptions = { day: 'numeric', month: 'short', @@ -524,6 +584,7 @@ export default class RhsComment extends React.Component { {botIndicator} <li className='col'> {this.renderTimeTag(post, timeOptions)} + {pinnedBadge} {flagTrigger} </li> {options} diff --git a/webapp/components/rhs_root_post.jsx b/webapp/components/rhs_root_post.jsx index 0c1037501..6a6b01a7f 100644 --- a/webapp/components/rhs_root_post.jsx +++ b/webapp/components/rhs_root_post.jsx @@ -14,7 +14,7 @@ import UserStore from 'stores/user_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; -import {flagPost, unflagPost} from 'actions/post_actions.jsx'; +import {flagPost, unflagPost, pinPost, unpinPost} from 'actions/post_actions.jsx'; import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; @@ -35,6 +35,8 @@ export default class RhsRootPost extends React.Component { this.handlePermalink = this.handlePermalink.bind(this); this.flagPost = this.flagPost.bind(this); this.unflagPost = this.unflagPost.bind(this); + this.pinPost = this.pinPost.bind(this); + this.unpinPost = this.unpinPost.bind(this); this.canEdit = false; this.canDelete = false; @@ -143,6 +145,16 @@ export default class RhsRootPost extends React.Component { ); } + pinPost(e) { + e.preventDefault(); + pinPost(this.props.post.channel_id, this.props.post.id); + } + + unpinPost(e) { + e.preventDefault(); + unpinPost(this.props.post.channel_id, this.props.post.id); + } + render() { const post = this.props.post; const user = this.props.user; @@ -240,6 +252,42 @@ export default class RhsRootPost extends React.Component { </li> ); + if (post.is_pinned) { + dropdownContents.push( + <li + key='rhs-root-unpin' + role='presentation' + > + <a + href='#' + onClick={this.unpinPost} + > + <FormattedMessage + id='rhs_root.unpin' + defaultMessage='Un-pin from channel' + /> + </a> + </li> + ); + } else { + dropdownContents.push( + <li + key='rhs-root-pin' + role='presentation' + > + <a + href='#' + onClick={this.pinPost} + > + <FormattedMessage + id='rhs_root.pin' + defaultMessage='Pin to channel' + /> + </a> + </li> + ); + } + if (this.canDelete) { dropdownContents.push( <li @@ -450,6 +498,18 @@ export default class RhsRootPost extends React.Component { flagFunc = this.flagPost; } + let pinnedBadge; + if (post.is_pinned) { + pinnedBadge = ( + <span className='post__pinned-badge'> + <FormattedMessage + id='post_info.pinned' + defaultMessage='Pinned' + /> + </span> + ); + } + const timeOptions = { day: 'numeric', month: 'short', @@ -470,6 +530,7 @@ export default class RhsRootPost extends React.Component { {botIndicator} <li className='col'> {this.renderTimeTag(post, timeOptions)} + {pinnedBadge} <OverlayTrigger key={'rootpostflagtooltipkey' + flagVisible} delayShow={Constants.OVERLAY_TIME_DELAY} diff --git a/webapp/components/search_results.jsx b/webapp/components/search_results.jsx index 4c0105738..ceafd766c 100644 --- a/webapp/components/search_results.jsx +++ b/webapp/components/search_results.jsx @@ -213,6 +213,31 @@ export default class SearchResults extends React.Component { </ul> </div> ); + } else if (this.props.isPinnedPosts && noResults) { + ctls = ( + <div className='sidebar--right__subheader'> + <ul> + <li> + <FormattedHTMLMessage + id='search_results.usagePin1' + defaultMessage='There are no pinned messages yet.' + /> + </li> + <li> + <FormattedHTMLMessage + id='search_results.usagePin2' + defaultMessage={'You can pin a message by clicking the "Pin to channel" option from the message\'s menu.'} + /> + </li> + <li> + <FormattedHTMLMessage + id='search_results.usagePin3' + defaultMessage='Pinned messages are accessible by all channel members and are a way to mark messages for future reference.' + /> + </li> + </ul> + </div> + ); } else if (!searchTerm && noResults) { ctls = ( <div className='sidebar--right__subheader'> @@ -289,6 +314,8 @@ export default class SearchResults extends React.Component { toggleSize={this.props.toggleSize} shrink={this.props.shrink} isFlaggedPosts={this.props.isFlaggedPosts} + isPinnedPosts={this.props.isPinnedPosts} + channelDisplayName={this.props.channelDisplayName} /> <div id='search-items-container' @@ -307,5 +334,7 @@ SearchResults.propTypes = { useMilitaryTime: React.PropTypes.bool.isRequired, toggleSize: React.PropTypes.func, shrink: React.PropTypes.func, - isFlaggedPosts: React.PropTypes.bool + isFlaggedPosts: React.PropTypes.bool, + isPinnedPosts: React.PropTypes.bool, + channelDisplayName: React.PropTypes.string.isRequired }; diff --git a/webapp/components/search_results_header.jsx b/webapp/components/search_results_header.jsx index 1f2818e98..288d883ee 100644 --- a/webapp/components/search_results_header.jsx +++ b/webapp/components/search_results_header.jsx @@ -79,6 +79,16 @@ export default class SearchResultsHeader extends React.Component { defaultMessage='Flagged Posts' /> ); + } else if (this.props.isPinnedPosts) { + title = ( + <FormattedMessage + id='search_header.title4' + defaultMessage='Pinned posts in {channelDisplayName}' + values={{ + channelDisplayName: this.props.channelDisplayName + }} + /> + ); } return ( @@ -131,5 +141,7 @@ SearchResultsHeader.propTypes = { isMentionSearch: React.PropTypes.bool, toggleSize: React.PropTypes.func, shrink: React.PropTypes.func, - isFlaggedPosts: React.PropTypes.bool + isFlaggedPosts: React.PropTypes.bool, + isPinnedPosts: React.PropTypes.bool, + channelDisplayName: React.PropTypes.string.isRequired }; diff --git a/webapp/components/search_results_item.jsx b/webapp/components/search_results_item.jsx index b3de3492c..1c7309f51 100644 --- a/webapp/components/search_results_item.jsx +++ b/webapp/components/search_results_item.jsx @@ -289,6 +289,18 @@ export default class SearchResultsItem extends React.Component { ); } + let pinnedBadge; + if (post.is_pinned) { + pinnedBadge = ( + <span className='post__pinned-badge'> + <FormattedMessage + id='post_info.pinned' + defaultMessage='Pinned' + /> + </span> + ); + } + return ( <div className='search-item__container'> <div className='date-separator'> @@ -322,6 +334,7 @@ export default class SearchResultsItem extends React.Component { {botIndicator} <li className='col'> {this.renderTimeTag(post)} + {pinnedBadge} {flagContent} </li> {rhsControls} diff --git a/webapp/components/sidebar_right.jsx b/webapp/components/sidebar_right.jsx index fb120337a..42b7381f4 100644 --- a/webapp/components/sidebar_right.jsx +++ b/webapp/components/sidebar_right.jsx @@ -11,13 +11,13 @@ import UserStore from 'stores/user_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import WebrtcStore from 'stores/webrtc_store.jsx'; -import {getFlaggedPosts} from 'actions/post_actions.jsx'; +import {getFlaggedPosts, getPinnedPosts} from 'actions/post_actions.jsx'; import {trackEvent} from 'actions/diagnostics_actions.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; -import React from 'react'; +import React, {PropTypes} from 'react'; export default class SidebarRight extends React.Component { constructor(props) { @@ -27,6 +27,7 @@ export default class SidebarRight extends React.Component { this.onPreferenceChange = this.onPreferenceChange.bind(this); this.onSelectedChange = this.onSelectedChange.bind(this); + this.onPostPinnedChange = this.onPostPinnedChange.bind(this); this.onSearchChange = this.onSearchChange.bind(this); this.onUserChange = this.onUserChange.bind(this); this.onShowSearch = this.onShowSearch.bind(this); @@ -39,6 +40,7 @@ export default class SidebarRight extends React.Component { searchVisible: SearchStore.getSearchResults() !== null, isMentionSearch: SearchStore.getIsMentionSearch(), isFlaggedPosts: SearchStore.getIsFlaggedPosts(), + isPinnedPosts: SearchStore.getIsPinnedPosts(), postRightVisible: Boolean(PostStore.getSelectedPost()), expanded: false, fromSearch: false, @@ -50,6 +52,7 @@ export default class SidebarRight extends React.Component { componentDidMount() { SearchStore.addSearchChangeListener(this.onSearchChange); PostStore.addSelectedPostChangeListener(this.onSelectedChange); + PostStore.addPostPinnedChangeListener(this.onPostPinnedChange); SearchStore.addShowSearchListener(this.onShowSearch); UserStore.addChangeListener(this.onUserChange); PreferenceStore.addChangeListener(this.onPreferenceChange); @@ -59,6 +62,7 @@ export default class SidebarRight extends React.Component { componentWillUnmount() { SearchStore.removeSearchChangeListener(this.onSearchChange); PostStore.removeSelectedPostChangeListener(this.onSelectedChange); + PostStore.removePostPinnedChangeListener(this.onPostPinnedChange); SearchStore.removeShowSearchListener(this.onShowSearch); UserStore.removeChangeListener(this.onUserChange); PreferenceStore.removeChangeListener(this.onPreferenceChange); @@ -137,6 +141,12 @@ export default class SidebarRight extends React.Component { }); } + onPostPinnedChange() { + if (this.props.channel && this.state.isPinnedPosts) { + getPinnedPosts(this.props.channel.id); + } + } + onShrink() { this.setState({ expanded: false @@ -147,7 +157,8 @@ export default class SidebarRight extends React.Component { this.setState({ searchVisible: SearchStore.getSearchResults() !== null, isMentionSearch: SearchStore.getIsMentionSearch(), - isFlaggedPosts: SearchStore.getIsFlaggedPosts() + isFlaggedPosts: SearchStore.getIsFlaggedPosts(), + isPinnedPosts: SearchStore.getIsPinnedPosts() }); } @@ -182,9 +193,11 @@ export default class SidebarRight extends React.Component { <SearchResults isMentionSearch={this.state.isMentionSearch} isFlaggedPosts={this.state.isFlaggedPosts} + isPinnedPosts={this.state.isPinnedPosts} useMilitaryTime={this.state.useMilitaryTime} toggleSize={this.toggleSize} shrink={this.onShrink} + channelDisplayName={this.props.channel ? this.props.channel.display_name : ''} /> ); } else if (this.state.postRightVisible) { @@ -222,3 +235,7 @@ export default class SidebarRight extends React.Component { ); } } + +SidebarRight.propTypes = { + channel: PropTypes.object +}; diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index c6d540ae3..33b905606 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -1670,6 +1670,7 @@ "navbar.toggle1": "Toggle sidebar", "navbar.toggle2": "Toggle sidebar", "navbar.viewInfo": "View Info", + "navbar.viewPinnedPosts": "View Pinned Posts", "navbar_dropdown.about": "About Mattermost", "navbar_dropdown.accountSettings": "Account Settings", "navbar_dropdown.console": "System Console", @@ -1726,6 +1727,9 @@ "post_info.permalink": "Permalink", "post_info.reply": "Reply", "post_info.system": "System", + "post_info.pin": "Pin to channel", + "post_info.unpin": "Un-pin from channel", + "post_info.pinned": "Pinned", "post_message_view.edited": "(edited)", "posts_view.loadMore": "Load more messages", "posts_view.newMsg": "New Messages", @@ -1778,11 +1782,14 @@ "rhs_root.mobile.flag": "Flag", "rhs_root.mobile.unflag": "Unflag", "rhs_root.permalink": "Permalink", + "rhs_root.pin": "Pin to channel", + "rhs_root.unpin": "Un-pin from channel", "search_bar.search": "Search", "search_bar.usage": "<h4>Search Options</h4><ul><li><span>Use </span><b>\"quotation marks\"</b><span> to search for phrases</span></li><li><span>Use </span><b>from:</b><span> to find posts from specific users and </span><b>in:</b><span> to find posts in specific channels</span></li></ul>", "search_header.results": "Search Results", "search_header.title2": "Recent Mentions", "search_header.title3": "Flagged Posts", + "search_header.title4": "Pinned posts in {channelDisplayName}", "search_item.direct": "Direct Message (with {username})", "search_item.jump": "Jump", "search_results.because": "<ul><li>If you're searching a partial phrase (ex. searching \"rea\", looking for \"reach\" or \"reaction\"), append a * to your search term.</li><li>Two letter searches and common words like \"this\", \"a\" and \"is\" won't appear in search results due to excessive results returned.</li></ul>", @@ -1792,6 +1799,9 @@ "search_results.usageFlag2": "You can add a flag to messages and comments by clicking the ", "search_results.usageFlag3": " icon next to the timestamp.", "search_results.usageFlag4": "Flags are a way to mark messages for follow up. Your flags are personal, and cannot be seen by other users.", + "search_results.usagePin1": "There are no pinned messages yet.", + "search_results.usagePin2": "You can pin a message by clicking the \"Pin to channel\" option from the message's menu.", + "search_results.usagePin3": "Pinned messages are accessible by all channel members and are a way to mark messages for future reference.", "setting_item_max.cancel": "Cancel", "setting_item_max.save": "Save", "setting_item_min.edit": "Edit", diff --git a/webapp/sass/layout/_content.scss b/webapp/sass/layout/_content.scss index 02f063573..b6fe98eb4 100644 --- a/webapp/sass/layout/_content.scss +++ b/webapp/sass/layout/_content.scss @@ -9,10 +9,20 @@ .search-btns { display: none; } - .header-list__members { + .header-list__right { + // the negative margin-right is used + // to prevent the icons in the header from + // moving to the left when the RHS is open + // + // the below z-index is used to ensure the icons + // stays on the top and don't get hidden by the + // search's input block + position: relative; + z-index: 6; + margin-right: -18px; - float: right; padding-right: 0px !important; + float: right; } } @@ -23,10 +33,20 @@ .search-btns { display: none; } - .header-list__members { + .header-list__right { + // the negative margin-right is used + // to prevent the icons in the header from + // moving to the left when the RHS is open + // + // the below z-index is used to ensure the icons + // stays on the top and don't get hidden by the + // search's input block + position: relative; + z-index: 6; + margin-right: -18px; - float: right; padding-right: 0px !important; + float: right } } } diff --git a/webapp/sass/layout/_headers.scss b/webapp/sass/layout/_headers.scss index 8ee6e8fdc..d38819d03 100644 --- a/webapp/sass/layout/_headers.scss +++ b/webapp/sass/layout/_headers.scss @@ -7,26 +7,43 @@ line-height: 56px; width: 100%; - .member-popover__trigger { + .member-popover__trigger, + .pinned-posts-button { + display: inline-block; + min-width: 30px; cursor: pointer; - min-width: 60px; - padding-right: 10px; - text-align: right; + margin-left: 10px; + text-align: center; white-space: nowrap; .fa { font-size: 16px; + } + } + + .member-popover__container, + .member-popover__trigger { + display: inline; + } + + .member-popover__trigger { + .fa { margin-left: 4px; } } + .pinned-posts-button svg { + position: relative; + top: 2px; + } + &.alt { margin: 0; th { font-weight: normal !important; - &.header-list__members { + &.header-list__right { padding-right: 4px; } } diff --git a/webapp/sass/layout/_post.scss b/webapp/sass/layout/_post.scss index de45eedd5..debcd70e7 100644 --- a/webapp/sass/layout/_post.scss +++ b/webapp/sass/layout/_post.scss @@ -1343,15 +1343,23 @@ } } -.bot-indicator { +.bot-indicator, +.post__pinned-badge { border-radius: 2px; font-family: inherit; font-size: 10px; font-weight: 600; - margin: 2px 10px 0 -4px; padding: 1px 4px; } +.bot-indicator { + margin: 2px 10px 0 -4px; +} + +.post__pinned-badge { + margin-left: 7px; +} + .permalink-text { overflow: hidden; } diff --git a/webapp/sass/responsive/_desktop.scss b/webapp/sass/responsive/_desktop.scss index 891431f20..f671104e1 100644 --- a/webapp/sass/responsive/_desktop.scss +++ b/webapp/sass/responsive/_desktop.scss @@ -76,6 +76,23 @@ } } } + + &.move--left { + .post { + &.post--root, + &.other--root { + .post__header { + padding-right: 70px; + } + } + + &.post--comment { + .post__header { + padding-right: 70px; + } + } + } + } } } diff --git a/webapp/sass/responsive/_mobile.scss b/webapp/sass/responsive/_mobile.scss index 37c91846e..ec406c405 100644 --- a/webapp/sass/responsive/_mobile.scss +++ b/webapp/sass/responsive/_mobile.scss @@ -252,6 +252,7 @@ } } } + blockquote { margin-top: 0; } @@ -273,6 +274,7 @@ .post__header { margin-bottom: 0; + padding-right: 70px; .col__reply { top: -3px; diff --git a/webapp/sass/responsive/_tablet.scss b/webapp/sass/responsive/_tablet.scss index 06a725a31..3bafc38d4 100644 --- a/webapp/sass/responsive/_tablet.scss +++ b/webapp/sass/responsive/_tablet.scss @@ -127,6 +127,15 @@ top: auto; } } + + &.move--left, + &.webrtc--show, + &.move--right { + .header-list__right { + // hide it behind the RHS + z-index: -1; + } + } } .post { .attachment { @@ -182,6 +191,14 @@ } } } + + .sidebar--right__title { + display: inline-block; + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } } .inner-wrap { @@ -213,6 +230,11 @@ } } + .post__pinned-badge { + margin-left: 0; + margin-right: 5px; + } + &:not(.post--thread) { padding: 5px .5em 0 77px; @@ -359,9 +381,16 @@ } .post__header { + float: left; + padding-top: 3px; + .col__reply { top: -21px; } + + .post__pinned-badge { + margin-right: 5px; + } } &:not(.post--compact) { @@ -381,6 +410,12 @@ } } } + + &.post--comment:not(.post--compact) { + .post__pinned-badge { + margin-left: 10px; + } + } } } } diff --git a/webapp/stores/post_store.jsx b/webapp/stores/post_store.jsx index 6e312f67a..6f81619c2 100644 --- a/webapp/stores/post_store.jsx +++ b/webapp/stores/post_store.jsx @@ -16,6 +16,7 @@ const FOCUSED_POST_CHANGE = 'focused_post_change'; const EDIT_POST_EVENT = 'edit_post'; const POSTS_VIEW_JUMP_EVENT = 'post_list_jump'; const SELECTED_POST_CHANGE_EVENT = 'selected_post_change'; +const POST_PINNED_CHANGE_EVENT = 'post_pinned_change'; class PostStoreClass extends EventEmitter { constructor() { @@ -259,22 +260,42 @@ class PostStoreClass extends EventEmitter { this.postsInfo[id].postList = combinedPosts; } + focusedPostListHasPost(id) { + const focusedPostId = this.getFocusedPostId(); + if (focusedPostId == null) { + return false; + } + + const focusedPostList = makePostListNonNull(this.getAllPosts(focusedPostId)); + return focusedPostList.posts.hasOwnProperty(id); + } + storePost(post, isNewPost = false) { - const postList = makePostListNonNull(this.getAllPosts(post.channel_id)); + const ids = [ + post.channel_id + ]; - if (post.pending_post_id !== '') { - this.removePendingPost(post.channel_id, post.pending_post_id); + // update the post in the permalink view if it's there + if (!isNewPost && this.focusedPostListHasPost(post.id)) { + ids.push(this.getFocusedPostId()); } - post.pending_post_id = ''; + ids.forEach((id) => { + const postList = makePostListNonNull(this.getAllPosts(id)); + if (post.pending_post_id !== '') { + this.removePendingPost(post.channel_id, post.pending_post_id); + } - postList.posts[post.id] = post; - if (isNewPost && postList.order.indexOf(post.id) === -1) { - postList.order.unshift(post.id); - } + post.pending_post_id = ''; + + postList.posts[post.id] = post; + if (isNewPost && postList.order.indexOf(post.id) === -1) { + postList.order.unshift(post.id); + } - this.makePostsInfo(post.channel_id); - this.postsInfo[post.channel_id].postList = postList; + this.makePostsInfo(post.channel_id); + this.postsInfo[id].postList = postList; + }); } storeFocusedPost(postId, channelId, postList) { @@ -500,6 +521,18 @@ class PostStoreClass extends EventEmitter { this.removeListener(SELECTED_POST_CHANGE_EVENT, callback); } + emitPostPinnedChange() { + this.emit(POST_PINNED_CHANGE_EVENT); + } + + addPostPinnedChangeListener(callback) { + this.on(POST_PINNED_CHANGE_EVENT, callback); + } + + removePostPinnedChangeListener(callback) { + this.removeListener(POST_PINNED_CHANGE_EVENT, callback); + } + getCurrentUsersLatestPost(channelId, rootId) { const userId = UserStore.getCurrentId(); @@ -686,6 +719,10 @@ PostStore.dispatchToken = AppDispatcher.register((payload) => { PostStore.storeSelectedPostId(action.postId); PostStore.emitSelectedPostChange(action.from_search, action.from_flagged_posts); break; + case ActionTypes.RECEIVED_POST_PINNED: + case ActionTypes.RECEIVED_POST_UNPINNED: + PostStore.emitPostPinnedChange(); + break; default: } }); diff --git a/webapp/stores/search_store.jsx b/webapp/stores/search_store.jsx index 46a086ddb..49f8b3c2f 100644 --- a/webapp/stores/search_store.jsx +++ b/webapp/stores/search_store.jsx @@ -19,6 +19,7 @@ class SearchStoreClass extends EventEmitter { this.searchResults = null; this.isMentionSearch = false; this.isFlaggedPosts = false; + this.isPinnedPosts = false; this.isVisible = false; this.searchTerm = ''; } @@ -83,6 +84,10 @@ class SearchStoreClass extends EventEmitter { return this.isFlaggedPosts; } + getIsPinnedPosts() { + return this.isPinnedPosts; + } + storeSearchTerm(term) { this.searchTerm = term; } @@ -91,10 +96,11 @@ class SearchStoreClass extends EventEmitter { return this.searchTerm; } - storeSearchResults(results, isMentionSearch, isFlaggedPosts) { + storeSearchResults(results, isMentionSearch, isFlaggedPosts, isPinnedPosts) { this.searchResults = results; this.isMentionSearch = isMentionSearch; this.isFlaggedPosts = isFlaggedPosts; + this.isPinnedPosts = isPinnedPosts; } deletePost(post) { @@ -120,7 +126,7 @@ SearchStore.dispatchToken = AppDispatcher.register((payload) => { switch (action.type) { case ActionTypes.RECEIVED_SEARCH: - SearchStore.storeSearchResults(action.results, action.is_mention_search, action.is_flagged_posts); + SearchStore.storeSearchResults(action.results, action.is_mention_search, action.is_flagged_posts, action.is_pinned_posts); SearchStore.emitSearchChange(); break; case ActionTypes.RECEIVED_SEARCH_TERM: diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx index e1449e3c5..f4faba934 100644 --- a/webapp/utils/async_client.jsx +++ b/webapp/utils/async_client.jsx @@ -1607,6 +1607,40 @@ export function deleteEmoji(id) { ); } +export function pinPost(channelId, reaction) { + Client.pinPost( + channelId, + reaction, + () => { + // the "post_edited" websocket event take cares of updating the posts + // the action below is mostly dispatched for the RHS to update + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POST_PINNED + }); + }, + (err) => { + dispatchError(err, 'pinPost'); + } + ); +} + +export function unpinPost(channelId, reaction) { + Client.unpinPost( + channelId, + reaction, + () => { + // the "post_edited" websocket event take cares of updating the posts + // the action below is mostly dispatched for the RHS to update + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POST_UNPINNED + }); + }, + (err) => { + dispatchError(err, 'unpinPost'); + } + ); +} + export function saveReaction(channelId, reaction) { Client.saveReaction( channelId, diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index cb55da9b8..39dca99b3 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -90,6 +90,8 @@ export const ActionTypes = keyMirror({ RECEIVED_POST_SELECTED: null, RECEIVED_MENTION_DATA: null, RECEIVED_ADD_MENTION: null, + RECEIVED_POST_PINNED: null, + RECEIVED_POST_UNPINNED: null, RECEIVED_PROFILES: null, RECEIVED_PROFILES_IN_TEAM: null, @@ -419,6 +421,7 @@ export const Constants = { REPLY_ICON: "<svg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'viewBox='-158 242 18 18' style='enable-background:new -158 242 18 18;' xml:space='preserve'> <path d='M-142.2,252.6c-2-3-4.8-4.7-8.3-4.8v-3.3c0-0.2-0.1-0.3-0.2-0.3s-0.3,0-0.4,0.1l-6.9,6.2c-0.1,0.1-0.1,0.2-0.1,0.3 c0,0.1,0,0.2,0.1,0.3l6.9,6.4c0.1,0.1,0.3,0.1,0.4,0.1c0.1-0.1,0.2-0.2,0.2-0.4v-3.8c4.2,0,7.4,0.4,9.6,4.4c0.1,0.1,0.2,0.2,0.3,0.2 c0,0,0.1,0,0.1,0c0.2-0.1,0.3-0.3,0.2-0.4C-140.2,257.3-140.6,255-142.2,252.6z M-150.8,252.5c-0.2,0-0.4,0.2-0.4,0.4v3.3l-6-5.5 l6-5.3v2.8c0,0.2,0.2,0.4,0.4,0.4c3.3,0,6,1.5,8,4.5c0.5,0.8,0.9,1.6,1.2,2.3C-144,252.8-147.1,252.5-150.8,252.5z'/> </svg>", SCROLL_BOTTOM_ICON: "<svg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'viewBox='-239 239 21 23' style='enable-background:new -239 239 21 23;' xml:space='preserve'> <path d='M-239,241.4l2.4-2.4l8.1,8.2l8.1-8.2l2.4,2.4l-10.5,10.6L-239,241.4z M-228.5,257.2l8.1-8.2l2.4,2.4l-10.5,10.6l-10.5-10.6 l2.4-2.4L-228.5,257.2z'/> </svg>", VIDEO_ICON: "<svg width='55%'height='100%'viewBox='0 0 13 8'> <g transform='matrix(1,0,0,1,-507,-146)'> <g transform='matrix(0.0133892,0,0,0.014499,500.635,142.838)'> <path d='M1158,547.286L1158,644.276C1158,684.245 1125.55,716.694 1085.58,716.694L579.341,716.694C539.372,716.694 506.922,684.245 506.922,644.276L506.922,306.322C506.922,266.353 539.371,233.904 579.341,233.903L1085.58,233.903C1125.55,233.904 1158,266.353 1158,306.322L1158,402.939L1359.75,253.14C1365.83,248.362 1373.43,245.973 1382.56,245.973C1386.61,245.973 1390.83,246.602 1395.22,247.859C1408.4,252.134 1414.99,259.552 1414.99,270.113L1414.99,680.485C1414.99,691.046 1408.4,698.464 1395.22,702.739C1390.83,703.996 1386.61,704.624 1382.56,704.624C1373.43,704.624 1365.83,702.236 1359.75,697.458L1158,547.286Z'/> </g> </g> </svg>", + PIN_ICON: "<svg width='16px' height='16px' viewBox='0 0 25 25' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' clip-rule='evenodd' stroke-linejoin='round' stroke-miterlimit='1.414'><path d='M24.78 9.236L15.863.316l-1.487 4.46-4.46 4.46L8.43 7.75 3.972 9.235l4.458 4.458L.776 24.388l10.627-7.72 4.46 4.46 1.485-4.46-1.486-1.485 4.46-4.46 4.46-1.487z' fill-rule='nonzero'/></svg>", THEMES: { default: { type: 'Organization', diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx index 58278c893..34573a2e5 100644 --- a/webapp/utils/utils.jsx +++ b/webapp/utils/utils.jsx @@ -591,6 +591,7 @@ export function applyTheme(theme) { changeCss('.app__body .markdown__table tbody tr:nth-child(2n)', 'background:' + changeOpacity(theme.centerChannelColor, 0.07)); changeCss('.app__body .channel-header__info>div.dropdown .header-dropdown__icon', 'color:' + changeOpacity(theme.centerChannelColor, 0.8)); changeCss('.app__body .channel-header #member_popover', 'color:' + changeOpacity(theme.centerChannelColor, 0.8)); + changeCss('.app__body .channel-header #pinned-posts-button', 'fill:' + changeOpacity(theme.centerChannelColor, 0.8)); changeCss('.app__body .custom-textarea, .app__body .custom-textarea:focus, .app__body .file-preview, .app__body .post-image__details, .app__body .sidebar--right .sidebar-right__body, .app__body .markdown__table th, .app__body .markdown__table td, .app__body .suggestion-list__content, .app__body .modal .modal-content, .app__body .modal .settings-modal .settings-table .settings-content .divider-light, .app__body .webhooks__container, .app__body .dropdown-menu, .app__body .modal .modal-header, .app__body .popover', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2)); changeCss('.app__body .popover.bottom>.arrow', 'border-bottom-color:' + changeOpacity(theme.centerChannelColor, 0.25)); changeCss('.app__body .search-help-popover .search-autocomplete__divider span, .app__body .suggestion-list__divider > span', 'color:' + changeOpacity(theme.centerChannelColor, 0.7)); @@ -659,12 +660,12 @@ export function applyTheme(theme) { } if (theme.buttonBg) { - changeCss('.app__body .btn.btn-primary, .app__body .tutorial__circles .circle.active', 'background:' + theme.buttonBg); + changeCss('.app__body .btn.btn-primary, .app__body .tutorial__circles .circle.active, .app__body .post__pinned-badge', 'background:' + theme.buttonBg); changeCss('.app__body .btn.btn-primary:hover, .app__body .btn.btn-primary:active, .app__body .btn.btn-primary:focus', 'background:' + changeColor(theme.buttonBg, -0.25)); } if (theme.buttonColor) { - changeCss('.app__body .btn.btn-primary', 'color:' + theme.buttonColor); + changeCss('.app__body .btn.btn-primary, .app__body .post__pinned-badge', 'color:' + theme.buttonColor); } if (theme.mentionHighlightBg) { |