From fe38d6d5bb36e18ddefbe490cc21f48f4f4c8d81 Mon Sep 17 00:00:00 2001 From: Gabin Aureche Date: Mon, 13 Mar 2017 13:25:08 +0100 Subject: Add pinned posts (#4217) --- webapp/actions/post_actions.jsx | 36 ++++++++++++- webapp/client/client.jsx | 27 ++++++++++ webapp/components/channel_header.jsx | 27 +++++++++- webapp/components/navbar.jsx | 30 +++++++++++ webapp/components/needs_team.jsx | 16 +++++- webapp/components/popover_list_members.jsx | 14 +++-- .../components/post_view/components/post_info.jsx | 61 +++++++++++++++++++++ webapp/components/rhs_comment.jsx | 63 +++++++++++++++++++++- webapp/components/rhs_root_post.jsx | 63 +++++++++++++++++++++- webapp/components/search_results.jsx | 31 ++++++++++- webapp/components/search_results_header.jsx | 14 ++++- webapp/components/search_results_item.jsx | 13 +++++ webapp/components/sidebar_right.jsx | 23 ++++++-- webapp/i18n/en.json | 10 ++++ webapp/sass/layout/_content.scss | 28 ++++++++-- webapp/sass/layout/_headers.scss | 27 ++++++++-- webapp/sass/layout/_post.scss | 12 ++++- webapp/sass/responsive/_desktop.scss | 17 ++++++ webapp/sass/responsive/_mobile.scss | 2 + webapp/sass/responsive/_tablet.scss | 35 ++++++++++++ webapp/stores/post_store.jsx | 57 ++++++++++++++++---- webapp/stores/search_store.jsx | 10 +++- webapp/utils/async_client.jsx | 34 ++++++++++++ webapp/utils/constants.jsx | 3 ++ webapp/utils/utils.jsx | 5 +- 25 files changed, 614 insertions(+), 44 deletions(-) (limited to 'webapp') 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 { - + {popoverListMembers} + + { 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 { ); + viewPinnedPostsOption = ( +
  • + + + +
  • + ); + if (!ChannelStore.isDefault(channel)) { addMembersOption = (
  • @@ -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 { ); } + + 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 (
    - + {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 ( -
    +
    -
    - {countText} -
    + {countText} +
    + + + +
  • + ); + } else { + dropdownContents.push( +
  • + + + +
  • + ); + } + if (this.canDelete) { dropdownContents.push(
  • + + + ); + } + return (
    • @@ -384,6 +444,7 @@ export default class PostInfo extends React.Component { useMilitaryTime={this.props.useMilitaryTime} postId={post.id} /> + {pinnedBadge} {flagTrigger}
    • {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 { ); + if (post.is_pinned) { + dropdownContents.push( +
    • + + + +
    • + ); + } else { + dropdownContents.push( +
    • + + + +
    • + ); + } + if (this.canDelete) { dropdownContents.push(
    • + + + ); + } + const timeOptions = { day: 'numeric', month: 'short', @@ -524,6 +584,7 @@ export default class RhsComment extends React.Component { {botIndicator}
    • {this.renderTimeTag(post, timeOptions)} + {pinnedBadge} {flagTrigger}
    • {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 { ); + if (post.is_pinned) { + dropdownContents.push( +
    • + + + +
    • + ); + } else { + dropdownContents.push( +
    • + + + +
    • + ); + } + if (this.canDelete) { dropdownContents.push(
    • + + + ); + } + const timeOptions = { day: 'numeric', month: 'short', @@ -470,6 +530,7 @@ export default class RhsRootPost extends React.Component { {botIndicator}
    • {this.renderTimeTag(post, timeOptions)} + {pinnedBadge} ); + } else if (this.props.isPinnedPosts && noResults) { + ctls = ( +
      +
        +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      +
      + ); } else if (!searchTerm && noResults) { ctls = (
      @@ -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} />
      ); + } else if (this.props.isPinnedPosts) { + title = ( + + ); } 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 = ( + + + + ); + } + return (
      @@ -322,6 +334,7 @@ export default class SearchResultsItem extends React.Component { {botIndicator}
    • {this.renderTimeTag(post)} + {pinnedBadge} {flagContent}
    • {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 { ); } 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": "

      Search Options

      • Use \"quotation marks\" to search for phrases
      • Use from: to find posts from specific users and in: to find posts in specific channels
      ", "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": "
      • If you're searching a partial phrase (ex. searching \"rea\", looking for \"reach\" or \"reaction\"), append a * to your search term.
      • Two letter searches and common words like \"this\", \"a\" and \"is\" won't appear in search results due to excessive results returned.
      ", @@ -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: " ", SCROLL_BOTTOM_ICON: " ", VIDEO_ICON: " ", + PIN_ICON: "", 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) { -- cgit v1.2.3-1-g7c22