From 0184d6059bb1943fb74bf33d1d200a423c5bf5e6 Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Thu, 4 Aug 2016 11:38:09 -0400 Subject: PLT-3506 Added flagged posts functionality (#3679) * Added flagged posts functionality * UI Improvements to flags (#3697) * Added flag functionality for mobile * Updating flagged text (#3699) * Add back button to RHS thread when coming from flagged posts * Updating position of flags (#3708) * Plt 3506 - Reverting flag position (#3724) * Revert "Updating position of flags (#3708)" This reverts commit aaa05632c5d9eda35a048300a5bd7e99584c5b58. * Fixing the icon in search * Help text and white space improvements (#3730) * Updatng help text and some white spacing. * Updating help text --- api/post.go | 28 +++++ api/post_test.go | 35 ++++++ model/client.go | 12 ++ model/preference.go | 1 + store/sql_post_store.go | 25 ++++ store/sql_post_store_test.go | 64 ++++++++++ store/store.go | 1 + webapp/actions/global_actions.jsx | 3 +- webapp/actions/post_actions.jsx | 42 ++++++- webapp/client/client.jsx | 9 ++ webapp/components/channel_header.jsx | 49 +++++++- webapp/components/post_view/components/post.jsx | 8 +- .../post_view/components/post_header.jsx | 4 +- .../components/post_view/components/post_info.jsx | 119 ++++++++++++++++++- .../components/post_view/components/post_list.jsx | 9 +- .../post_view/post_focus_view_controller.jsx | 13 ++- .../components/post_view/post_view_controller.jsx | 11 +- webapp/components/rhs_comment.jsx | 116 +++++++++++++++++- webapp/components/rhs_header_post.jsx | 59 ++++++---- webapp/components/rhs_root_post.jsx | 129 +++++++++++++++++++-- webapp/components/rhs_thread.jsx | 22 +++- webapp/components/search_results.jsx | 42 ++++++- webapp/components/search_results_header.jsx | 10 +- webapp/components/search_results_item.jsx | 86 +++++++++++--- webapp/components/sidebar_right.jsx | 20 +++- webapp/components/sidebar_right_menu.jsx | 19 +++ webapp/i18n/en.json | 11 ++ webapp/sass/components/_search.scss | 28 +++-- webapp/sass/layout/_headers.scss | 23 +++- webapp/sass/layout/_post.scss | 47 +++++++- webapp/sass/layout/_sidebar-right.scss | 9 +- webapp/sass/responsive/_mobile.scss | 26 +++++ webapp/sass/responsive/_tablet.scss | 40 ++++++- webapp/stores/post_store.jsx | 6 +- webapp/stores/search_store.jsx | 10 +- webapp/tests/client_post.test.jsx | 33 ++++++ webapp/utils/constants.jsx | 5 +- webapp/utils/utils.jsx | 6 +- 38 files changed, 1077 insertions(+), 103 deletions(-) diff --git a/api/post.go b/api/post.go index 223e8ee15..46c0284d0 100644 --- a/api/post.go +++ b/api/post.go @@ -34,6 +34,7 @@ func InitPost() { l4g.Debug(utils.T("api.post.init.debug")) BaseRoutes.NeedTeam.Handle("/posts/search", ApiUserRequired(searchPosts)).Methods("POST") + BaseRoutes.NeedTeam.Handle("/posts/flagged/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequiredActivity(getFlaggedPosts, false)).Methods("GET") BaseRoutes.NeedTeam.Handle("/posts/{post_id}", ApiUserRequired(getPostById)).Methods("GET") BaseRoutes.NeedTeam.Handle("/pltmp/{post_id}", ApiUserRequired(getPermalinkTmp)).Methods("GET") @@ -1034,6 +1035,33 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) { } } +func getFlaggedPosts(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + + offset, err := strconv.Atoi(params["offset"]) + if err != nil { + c.SetInvalidParam("getFlaggedPosts", "offset") + return + } + + limit, err := strconv.Atoi(params["limit"]) + if err != nil { + c.SetInvalidParam("getFlaggedPosts", "limit") + return + } + + posts := &model.PostList{} + + if result := <-Srv.Store.Post().GetFlaggedPosts(c.Session.UserId, offset, limit); result.Err != nil { + c.Err = result.Err + return + } else { + posts = result.Data.(*model.PostList) + } + + w.Write([]byte(posts.ToJson())) +} + func getPosts(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) diff --git a/api/post_test.go b/api/post_test.go index 29fe63ddc..2a2a9f41b 100644 --- a/api/post_test.go +++ b/api/post_test.go @@ -924,3 +924,38 @@ func TestGetOutOfChannelMentions(t *testing.T) { t.Fatalf("getOutOfChannelMentions returned %v when two users on a different team were mentioned", mentioned) } } + +func TestGetFlaggedPosts(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + user1 := th.BasicUser + post1 := th.BasicPost + + preferences := &model.Preferences{ + { + UserId: user1.Id, + Category: model.PREFERENCE_CATEGORY_FLAGGED_POST, + Name: post1.Id, + Value: "true", + }, + } + Client.Must(Client.SetPreferences(preferences)) + + r1 := Client.Must(Client.GetFlaggedPosts(0, 2)).Data.(*model.PostList) + + if len(r1.Order) == 0 { + t.Fatal("should have gotten a flagged post") + } + + if _, ok := r1.Posts[post1.Id]; !ok { + t.Fatal("missing flagged post") + } + + Client.DeletePreferences(preferences) + + r2 := Client.Must(Client.GetFlaggedPosts(0, 2)).Data.(*model.PostList) + + if len(r2.Order) != 0 { + t.Fatal("should not have gotten a flagged post") + } +} diff --git a/model/client.go b/model/client.go index cad551613..b9a5d8830 100644 --- a/model/client.go +++ b/model/client.go @@ -1228,6 +1228,18 @@ func (c *Client) SearchPosts(terms string, isOrSearch bool) (*Result, *AppError) } } +// GetFlaggedPosts will return a post list of posts that have been flagged by the user. +// The page is set by the integer parameters offset and limit. +func (c *Client) GetFlaggedPosts(offset int, limit int) (*Result, *AppError) { + if r, err := c.DoApiGet(c.GetTeamRoute()+fmt.Sprintf("/posts/flagged/%v/%v", offset, limit), "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), PostListFromJson(r.Body)}, nil + } +} + func (c *Client) UploadProfileFile(data []byte, contentType string) (*Result, *AppError) { return c.uploadFile(c.ApiUrl+"/users/newimage", data, contentType) } diff --git a/model/preference.go b/model/preference.go index b74e25d81..5787fe6ef 100644 --- a/model/preference.go +++ b/model/preference.go @@ -15,6 +15,7 @@ const ( PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW = "direct_channel_show" PREFERENCE_CATEGORY_TUTORIAL_STEPS = "tutorial_step" PREFERENCE_CATEGORY_ADVANCED_SETTINGS = "advanced_settings" + PREFERENCE_CATEGORY_FLAGGED_POST = "flagged_post" PREFERENCE_CATEGORY_DISPLAY_SETTINGS = "display_settings" PREFERENCE_NAME_COLLAPSE_SETTING = "collapse_previews" diff --git a/store/sql_post_store.go b/store/sql_post_store.go index 57bb2a512..07192b4a6 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -142,6 +142,31 @@ func (s SqlPostStore) Update(oldPost *model.Post, newMessage string, newHashtags return storeChannel } +func (s SqlPostStore) GetFlaggedPosts(userId string, offset int, limit int) StoreChannel { + storeChannel := make(StoreChannel) + go func() { + result := StoreResult{} + pl := &model.PostList{} + + var posts []*model.Post + if _, err := s.GetReplica().Select(&posts, "SELECT * FROM Posts WHERE Id IN (SELECT Name FROM Preferences WHERE UserId = :UserId AND Category = :Category) ORDER BY CreateAt ASC LIMIT :Limit OFFSET :Offset", map[string]interface{}{"UserId": userId, "Category": model.PREFERENCE_CATEGORY_FLAGGED_POST, "Offset": offset, "Limit": limit}); err != nil { + result.Err = model.NewLocAppError("SqlPostStore.GetFlaggedPosts", "store.sql_post.get_flagged_posts.app_error", nil, err.Error()) + } else { + for _, post := range posts { + pl.AddPost(post) + pl.AddOrder(post.Id) + } + } + + result.Data = pl + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (s SqlPostStore) Get(id string) StoreChannel { storeChannel := make(StoreChannel) diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go index 3c317b926..d8f8c2e6b 100644 --- a/store/sql_post_store_test.go +++ b/store/sql_post_store_test.go @@ -905,3 +905,67 @@ func TestPostCountsByDay(t *testing.T) { } } } + +func TestPostStoreGetFlaggedPosts(t *testing.T) { + Setup() + + o1 := &model.Post{} + o1.ChannelId = model.NewId() + o1.UserId = model.NewId() + o1.Message = "a" + model.NewId() + "b" + o1 = (<-store.Post().Save(o1)).Data.(*model.Post) + time.Sleep(2 * time.Millisecond) + + o2 := &model.Post{} + o2.ChannelId = o1.ChannelId + o2.UserId = model.NewId() + o2.Message = "a" + model.NewId() + "b" + o2 = (<-store.Post().Save(o2)).Data.(*model.Post) + time.Sleep(2 * time.Millisecond) + + r1 := (<-store.Post().GetFlaggedPosts(o1.ChannelId, 0, 2)).Data.(*model.PostList) + + if len(r1.Order) != 0 { + t.Fatal("should be empty") + } + + preferences := model.Preferences{ + { + UserId: o1.UserId, + Category: model.PREFERENCE_CATEGORY_FLAGGED_POST, + Name: o1.Id, + Value: "true", + }, + } + + Must(store.Preference().Save(&preferences)) + + r2 := (<-store.Post().GetFlaggedPosts(o1.UserId, 0, 2)).Data.(*model.PostList) + + if len(r2.Order) != 1 { + t.Fatal("should have 1 post") + } + + preferences = model.Preferences{ + { + UserId: o1.UserId, + Category: model.PREFERENCE_CATEGORY_FLAGGED_POST, + Name: o2.Id, + Value: "true", + }, + } + + Must(store.Preference().Save(&preferences)) + + r3 := (<-store.Post().GetFlaggedPosts(o1.UserId, 0, 1)).Data.(*model.PostList) + + if len(r3.Order) != 1 { + t.Fatal("should have 1 post") + } + + r4 := (<-store.Post().GetFlaggedPosts(o1.UserId, 0, 2)).Data.(*model.PostList) + + if len(r4.Order) != 2 { + t.Fatal("should have 2 posts") + } +} diff --git a/store/store.go b/store/store.go index 66cc05214..b9a55fa2e 100644 --- a/store/store.go +++ b/store/store.go @@ -112,6 +112,7 @@ type PostStore interface { Delete(postId string, time int64) StoreChannel PermanentDeleteByUser(userId string) StoreChannel GetPosts(channelId string, offset int, limit int) StoreChannel + GetFlaggedPosts(userId string, offset int, limit int) StoreChannel GetPostsBefore(channelId string, postId string, numPosts int, offset int) StoreChannel GetPostsAfter(channelId string, postId string, numPosts int, offset int) StoreChannel GetPostsSince(channelId string, time int64) StoreChannel diff --git a/webapp/actions/global_actions.jsx b/webapp/actions/global_actions.jsx index 829424c1f..9fc9c7b63 100644 --- a/webapp/actions/global_actions.jsx +++ b/webapp/actions/global_actions.jsx @@ -190,7 +190,8 @@ export function emitPostFocusRightHandSideFromSearch(post, isMentionSearch) { AppDispatcher.handleServerAction({ type: ActionTypes.RECEIVED_POST_SELECTED, postId: Utils.getRootId(post), - from_search: SearchStore.getSearchTerm() + from_search: SearchStore.getSearchTerm(), + from_flagged_posts: SearchStore.getIsFlaggedPosts() }); AppDispatcher.handleServerAction({ diff --git a/webapp/actions/post_actions.jsx b/webapp/actions/post_actions.jsx index 7d830d11b..fd413dfe1 100644 --- a/webapp/actions/post_actions.jsx +++ b/webapp/actions/post_actions.jsx @@ -9,12 +9,13 @@ import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; -import Constants from 'utils/constants.jsx'; -const ActionTypes = Constants.ActionTypes; - import Client from 'client/web_client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; +import Constants from 'utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; +const Preferences = Constants.Preferences; + export function handleNewPost(post, msg) { if (ChannelStore.getCurrentId() === post.channel_id) { if (window.isActive) { @@ -116,3 +117,38 @@ export function setUnreadPost(channelId, postId) { ChannelStore.emitLastViewed(lastViewed, ownNewMessage); } } + +export function flagPost(postId) { + AsyncClient.savePreference(Preferences.CATEGORY_FLAGGED_POST, postId, 'true'); +} + +export function unflagPost(postId, success) { + const pref = { + user_id: UserStore.getCurrentId(), + category: Preferences.CATEGORY_FLAGGED_POST, + name: postId + }; + AsyncClient.deletePreferences([pref], success); +} + +export function getFlaggedPosts() { + Client.getFlaggedPosts(0, Constants.POST_CHUNK_SIZE, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_SEARCH, + results: data, + is_flagged_posts: true + }); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_SEARCH_TERM, + term: null, + do_search: false, + is_mention_search: false + }); + }, + (err) => { + AsyncClient.dispatchError(err, 'getFlaggedPosts'); + } + ); +} diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx index b200b2379..598871002 100644 --- a/webapp/client/client.jsx +++ b/webapp/client/client.jsx @@ -1434,6 +1434,15 @@ export default class Client { end(this.handleResponse.bind(this, 'getPostsAfter', success, error)); } + getFlaggedPosts(offset, limit, success, error) { + request. + get(`${this.getTeamNeededRoute()}/posts/flagged/${offset}/${limit}`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getFlaggedPosts', success, error)); + } + // Routes for Files getFileInfo(filename, success, error) { diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx index f26105c7a..66cd61245 100644 --- a/webapp/components/channel_header.jsx +++ b/webapp/components/channel_header.jsx @@ -26,20 +26,19 @@ import PreferenceStore from 'stores/preference_store.jsx'; import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import * as Utils from 'utils/utils.jsx'; import * as TextFormatting from 'utils/text_formatting.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; import Client from 'client/web_client.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; +import {getFlaggedPosts} from 'actions/post_actions.jsx'; + import Constants from 'utils/constants.jsx'; const UserStatuses = Constants.UserStatuses; +const ActionTypes = Constants.ActionTypes; +import React from 'react'; import {FormattedMessage} from 'react-intl'; import {browserHistory} from 'react-router/es6'; - -const ActionTypes = Constants.ActionTypes; - import {Tooltip, OverlayTrigger, Popover} from 'react-bootstrap'; -import React from 'react'; - export default class ChannelHeader extends React.Component { constructor(props) { super(props); @@ -50,6 +49,7 @@ export default class ChannelHeader extends React.Component { this.showRenameChannelModal = this.showRenameChannelModal.bind(this); this.hideRenameChannelModal = this.hideRenameChannelModal.bind(this); this.openRecentMentions = this.openRecentMentions.bind(this); + this.getFlagged = this.getFlagged.bind(this); const state = this.getStateFromStores(); state.showEditChannelPurposeModal = false; @@ -159,6 +159,11 @@ export default class ChannelHeader extends React.Component { }); } + getFlagged(e) { + e.preventDefault(); + getFlaggedPosts(); + } + openRecentMentions(e) { if (Utils.cmdOrCtrlPressed(e) && e.shiftKey && e.keyCode === Constants.KeyCodes.M) { e.preventDefault(); @@ -220,6 +225,8 @@ export default class ChannelHeader extends React.Component { } render() { + const flagIcon = Constants.FLAG_ICON_OUTLINE_SVG; + if (!this.validState()) { return null; } @@ -233,6 +240,16 @@ export default class ChannelHeader extends React.Component { /> ); + + const flaggedTooltip = ( + + + + ); + const popoverContent = ( + +
+ + + + + +
+ diff --git a/webapp/components/post_view/components/post.jsx b/webapp/components/post_view/components/post.jsx index 3fdd8094e..038bcab78 100644 --- a/webapp/components/post_view/components/post.jsx +++ b/webapp/components/post_view/components/post.jsx @@ -100,6 +100,10 @@ export default class Post extends React.Component { return true; } + if (nextProps.isFlagged !== this.props.isFlagged) { + return true; + } + if (!Utils.areObjectsEqual(nextProps.user, this.props.user)) { return true; } @@ -245,6 +249,7 @@ export default class Post extends React.Component { compactDisplay={this.props.compactDisplay} displayNameType={this.props.displayNameType} useMilitaryTime={this.props.useMilitaryTime} + isFlagged={this.props.isFlagged} /> @@ -97,5 +98,6 @@ PostHeader.propTypes = { sameUser: React.PropTypes.bool.isRequired, compactDisplay: React.PropTypes.bool, displayNameType: React.PropTypes.string, - useMilitaryTime: React.PropTypes.bool.isRequired + useMilitaryTime: React.PropTypes.bool.isRequired, + isFlagged: React.PropTypes.bool.isRequired }; diff --git a/webapp/components/post_view/components/post_info.jsx b/webapp/components/post_view/components/post_info.jsx index ba6a9a982..d48d97ba1 100644 --- a/webapp/components/post_view/components/post_info.jsx +++ b/webapp/components/post_view/components/post_info.jsx @@ -2,17 +2,21 @@ // See License.txt for license information. import $ from 'jquery'; -import * as Utils from 'utils/utils.jsx'; + import PostTime from './post_time.jsx'; + import * as GlobalActions from 'actions/global_actions.jsx'; +import * as PostActions from 'actions/post_actions.jsx'; + import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; +import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; - -import {FormattedMessage} from 'react-intl'; +import {Tooltip, OverlayTrigger} from 'react-bootstrap'; import React from 'react'; +import {FormattedMessage} from 'react-intl'; export default class PostInfo extends React.Component { constructor(props) { @@ -21,7 +25,10 @@ export default class PostInfo extends React.Component { this.handleDropdownClick = this.handleDropdownClick.bind(this); this.handlePermalink = this.handlePermalink.bind(this); this.removePost = this.removePost.bind(this); + this.flagPost = this.flagPost.bind(this); + this.unflagPost = this.unflagPost.bind(this); } + handleDropdownClick(e) { var position = $('#post-list').height() - $(e.target).offset().top; var dropdown = $(e.target).closest('.col__reply').find('.dropdown-menu'); @@ -29,10 +36,12 @@ export default class PostInfo extends React.Component { dropdown.addClass('bottom'); } } + componentDidMount() { $('#post_dropdown' + this.props.post.id).on('shown.bs.dropdown', () => this.props.handleDropdownOpened(true)); $('#post_dropdown' + this.props.post.id).on('hidden.bs.dropdown', () => this.props.handleDropdownOpened(false)); } + createDropdown() { var post = this.props.post; var isOwner = this.props.currentUser.id === post.user_id; @@ -74,6 +83,44 @@ export default class PostInfo extends React.Component { ); } + if (Utils.isMobile()) { + if (this.props.isFlagged) { + dropdownContents.push( +
  • + + + +
  • + ); + } else { + dropdownContents.push( +
  • + + + +
  • + ); + } + } + dropdownContents.push(
  • = 1) { showCommentClass = ' icon--show'; @@ -240,6 +298,44 @@ export default class PostInfo extends React.Component { ); } + let flag; + let flagFunc; + let flagVisible = ''; + let flagTooltip = ( + + + + ); + if (this.props.isFlagged) { + flagVisible = 'visible'; + flag = ( + + ); + flagFunc = this.unflagPost; + flagTooltip = ( + + + + ); + } else { + flag = ( + + ); + flagFunc = this.flagPost; + } + return (
    • @@ -249,6 +345,20 @@ export default class PostInfo extends React.Component { compactDisplay={this.props.compactDisplay} useMilitaryTime={this.props.useMilitaryTime} /> + + + {flag} + +
    • {options}
    @@ -274,5 +384,6 @@ PostInfo.propTypes = { sameUser: React.PropTypes.bool.isRequired, currentUser: React.PropTypes.object.isRequired, compactDisplay: React.PropTypes.bool, - useMilitaryTime: React.PropTypes.bool.isRequired + useMilitaryTime: React.PropTypes.bool.isRequired, + isFlagged: React.PropTypes.bool }; diff --git a/webapp/components/post_view/components/post_list.jsx b/webapp/components/post_view/components/post_list.jsx index befd1a10d..95b30a9d7 100644 --- a/webapp/components/post_view/components/post_list.jsx +++ b/webapp/components/post_view/components/post_list.jsx @@ -284,6 +284,11 @@ export default class PostList extends React.Component { } } + let isFlagged = false; + if (this.props.flaggedPosts) { + isFlagged = this.props.flaggedPosts.get(post.id) === 'true'; + } + const postCtl = ( ); @@ -572,5 +578,6 @@ PostList.propTypes = { previewsCollapsed: React.PropTypes.string, useMilitaryTime: React.PropTypes.bool.isRequired, isFocusPost: React.PropTypes.bool, - emojis: React.PropTypes.object.isRequired + emojis: React.PropTypes.object.isRequired, + flaggedPosts: React.PropTypes.object }; diff --git a/webapp/components/post_view/post_focus_view_controller.jsx b/webapp/components/post_view/post_focus_view_controller.jsx index f8738e056..4a7d312f5 100644 --- a/webapp/components/post_view/post_focus_view_controller.jsx +++ b/webapp/components/post_view/post_focus_view_controller.jsx @@ -8,6 +8,7 @@ import EmojiStore from 'stores/emoji_store.jsx'; import PostStore from 'stores/post_store.jsx'; import UserStore from 'stores/user_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; +import PreferenceStore from 'stores/preference_store.jsx'; import Constants from 'utils/constants.jsx'; const ScrollTypes = Constants.ScrollTypes; @@ -22,6 +23,7 @@ export default class PostFocusView extends React.Component { this.onPostsChange = this.onPostsChange.bind(this); this.onUserChange = this.onUserChange.bind(this); this.onEmojiChange = this.onEmojiChange.bind(this); + this.onPreferenceChange = this.onPreferenceChange.bind(this); this.onPostListScroll = this.onPostListScroll.bind(this); const focusedPostId = PostStore.getFocusedPostId(); @@ -41,7 +43,8 @@ export default class PostFocusView extends React.Component { scrollPostId: focusedPostId, atTop: PostStore.getVisibilityAtTop(focusedPostId), atBottom: PostStore.getVisibilityAtBottom(focusedPostId), - emojis: EmojiStore.getEmojis() + emojis: EmojiStore.getEmojis(), + flaggedPosts: PreferenceStore.getCategory(Constants.Preferences.CATEGORY_FLAGGED_POST) }; } @@ -50,6 +53,7 @@ export default class PostFocusView extends React.Component { PostStore.addChangeListener(this.onPostsChange); UserStore.addChangeListener(this.onUserChange); EmojiStore.addChangeListener(this.onEmojiChange); + PreferenceStore.addChangeListener(this.onPreferenceChange); } componentWillUnmount() { @@ -98,6 +102,12 @@ export default class PostFocusView extends React.Component { }); } + onPreferenceChange() { + this.setState({ + flaggedPosts: PreferenceStore.getCategory(Constants.Preferences.CATEGORY_FLAGGED_POST) + }); + } + onPostListScroll() { this.setState({scrollType: ScrollTypes.FREE}); } @@ -128,6 +138,7 @@ export default class PostFocusView extends React.Component { postsToHighlight={postsToHighlight} isFocusPost={true} emojis={this.state.emojis} + flaggedPosts={this.state.flaggedPosts} /> ); } diff --git a/webapp/components/post_view/post_view_controller.jsx b/webapp/components/post_view/post_view_controller.jsx index a7583fa38..1dd5e9176 100644 --- a/webapp/components/post_view/post_view_controller.jsx +++ b/webapp/components/post_view/post_view_controller.jsx @@ -58,7 +58,8 @@ export default class PostViewController extends React.Component { compactDisplay: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT, previewsCollapsed: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.COLLAPSE_DISPLAY, 'false'), useMilitaryTime: PreferenceStore.getBool(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false), - emojis: EmojiStore.getEmojis() + emojis: EmojiStore.getEmojis(), + flaggedPosts: PreferenceStore.getCategory(Constants.Preferences.CATEGORY_FLAGGED_POST) }; } @@ -87,7 +88,8 @@ export default class PostViewController extends React.Component { displayPostsInCenter: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_CENTERED, compactDisplay: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT, previewsCollapsed: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.COLLAPSE_DISPLAY, 'false') + previewSuffix, - useMilitaryTime: PreferenceStore.getBool(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false) + useMilitaryTime: PreferenceStore.getBool(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false), + flaggedPosts: PreferenceStore.getCategory(Constants.Preferences.CATEGORY_FLAGGED_POST) }); } @@ -224,6 +226,10 @@ export default class PostViewController extends React.Component { return true; } + if (!Utils.areObjectsEqual(nextState.flaggedPosts, this.state.flaggedPosts)) { + return true; + } + if (nextState.lastViewed !== this.state.lastViewed) { return true; } @@ -292,6 +298,7 @@ export default class PostViewController extends React.Component { compactDisplay={this.state.compactDisplay} previewsCollapsed={this.state.previewsCollapsed} useMilitaryTime={this.state.useMilitaryTime} + flaggedPosts={this.state.flaggedPosts} lastViewed={this.state.lastViewed} emojis={this.state.emojis} ownNewMessage={this.state.ownNewMessage} diff --git a/webapp/components/rhs_comment.jsx b/webapp/components/rhs_comment.jsx index ed1f71b1e..a90380510 100644 --- a/webapp/components/rhs_comment.jsx +++ b/webapp/components/rhs_comment.jsx @@ -9,12 +9,14 @@ import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; +import {flagPost, unflagPost} from 'actions/post_actions.jsx'; import * as TextFormatting from 'utils/text_formatting.jsx'; import * as Utils from 'utils/utils.jsx'; import Client from 'client/web_client.jsx'; import Constants from 'utils/constants.jsx'; +import {Tooltip, OverlayTrigger} from 'react-bootstrap'; import {FormattedMessage, FormattedDate} from 'react-intl'; @@ -27,13 +29,17 @@ export default class RhsComment extends React.Component { super(props); this.handlePermalink = this.handlePermalink.bind(this); + this.flagPost = this.flagPost.bind(this); + this.unflagPost = this.unflagPost.bind(this); this.state = {}; } + handlePermalink(e) { e.preventDefault(); GlobalActions.showGetPostLinkModal(this.props.post); } + shouldComponentUpdate(nextProps) { if (nextProps.compactDisplay !== this.props.compactDisplay) { return true; @@ -43,6 +49,10 @@ export default class RhsComment extends React.Component { return true; } + if (nextProps.isFlagged !== this.props.isFlagged) { + return true; + } + if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) { return true; } @@ -53,6 +63,17 @@ export default class RhsComment extends React.Component { return false; } + + flagPost(e) { + e.preventDefault(); + flagPost(this.props.post.id); + } + + unflagPost(e) { + e.preventDefault(); + unflagPost(this.props.post.id); + } + createDropdown() { var post = this.props.post; @@ -66,6 +87,44 @@ export default class RhsComment extends React.Component { var dropdownContents = []; + if (Utils.isMobile()) { + if (this.props.isFlagged) { + dropdownContents.push( +
  • + + + +
  • + ); + } else { + dropdownContents.push( +
  • + + + +
  • + ); + } + } + dropdownContents.push(
  • ); } + render() { var post = this.props.post; + const flagIcon = Constants.FLAG_ICON_SVG; var currentUserCss = ''; if (this.props.currentUser === post.user_id) { @@ -225,6 +286,44 @@ export default class RhsComment extends React.Component { ); } + let flag; + let flagFunc; + let flagVisible = ''; + let flagTooltip = ( + + + + ); + if (this.props.isFlagged) { + flagVisible = 'visible'; + flag = ( + + ); + flagFunc = this.unflagPost; + flagTooltip = ( + + + + ); + } else { + flag = ( + + ); + flagFunc = this.flagPost; + } + return (
    @@ -247,6 +346,20 @@ export default class RhsComment extends React.Component { minute='2-digit' /> + + + {flag} + +
  • {dropdown} @@ -271,5 +384,6 @@ RhsComment.propTypes = { user: React.PropTypes.object.isRequired, currentUser: React.PropTypes.object.isRequired, compactDisplay: React.PropTypes.bool, - useMilitaryTime: React.PropTypes.bool.isRequired + useMilitaryTime: React.PropTypes.bool.isRequired, + isFlagged: React.PropTypes.bool }; diff --git a/webapp/components/rhs_header_post.jsx b/webapp/components/rhs_header_post.jsx index 8e54016fb..7b71bd7cc 100644 --- a/webapp/components/rhs_header_post.jsx +++ b/webapp/components/rhs_header_post.jsx @@ -4,7 +4,9 @@ import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import Constants from 'utils/constants.jsx'; import {Tooltip, OverlayTrigger} from 'react-bootstrap'; + import * as GlobalActions from 'actions/global_actions.jsx'; +import {getFlaggedPosts} from 'actions/post_actions.jsx'; import {FormattedMessage} from 'react-intl'; @@ -34,17 +36,21 @@ export default class RhsHeaderPost extends React.Component { handleBack(e) { e.preventDefault(); - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_SEARCH_TERM, - term: this.props.fromSearch, - do_search: true, - is_mention_search: this.props.isMentionSearch - }); - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POST_SELECTED, - postId: null - }); + if (this.props.fromSearch) { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_SEARCH_TERM, + term: this.props.fromSearch, + do_search: true, + is_mention_search: this.props.isMentionSearch + }); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POST_SELECTED, + postId: null + }); + } else if (this.props.fromFlaggedPosts) { + getFlaggedPosts(); + } } render() { let back; @@ -57,14 +63,26 @@ export default class RhsHeaderPost extends React.Component { ); - const backToResultsTooltip = ( - - - - ); + let backToResultsTooltip; + if (this.props.fromSearch) { + backToResultsTooltip = ( + + + + ); + } else if (this.props.fromFlaggedPosts) { + backToResultsTooltip = ( + + + + ); + } const expandSidebarTooltip = ( @@ -84,7 +102,7 @@ export default class RhsHeaderPost extends React.Component { ); - if (this.props.fromSearch) { + if (this.props.fromSearch || this.props.fromFlaggedPosts) { back = ( 0) { @@ -91,6 +115,44 @@ export default class RhsRootPost extends React.Component { var dropdownContents = []; + if (Utils.isMobile()) { + if (this.props.isFlagged) { + dropdownContents.push( +
  • + + + +
  • + ); + } else { + dropdownContents.push( +
  • + + + +
  • + ); + } + } + dropdownContents.push(
  • ); + let flag; + let flagFunc; + let flagVisible = ''; + let flagTooltip = ( + + + + ); + if (this.props.isFlagged) { + flagVisible = 'visible'; + flag = ( + + ); + flagFunc = this.unflagPost; + flagTooltip = ( + + + + ); + } else { + flag = ( + + ); + flagFunc = this.flagPost; + } + return (
    {channelName}
    @@ -267,11 +367,23 @@ export default class RhsRootPost extends React.Component { minute='2-digit' /> + + + {flag} + +
  • -
    - {rootOptions} -
    + {rootOptions}
  • @@ -297,5 +409,6 @@ RhsRootPost.propTypes = { currentUser: React.PropTypes.object.isRequired, commentCount: React.PropTypes.number, compactDisplay: React.PropTypes.bool, - useMilitaryTime: React.PropTypes.bool.isRequired + useMilitaryTime: React.PropTypes.bool.isRequired, + isFlagged: React.PropTypes.bool }; diff --git a/webapp/components/rhs_thread.jsx b/webapp/components/rhs_thread.jsx index d99ace6d4..856a686cb 100644 --- a/webapp/components/rhs_thread.jsx +++ b/webapp/components/rhs_thread.jsx @@ -62,6 +62,7 @@ export default class RhsThread extends React.Component { state.windowHeight = Utils.windowHeight(); state.profiles = JSON.parse(JSON.stringify(UserStore.getProfiles())); state.compactDisplay = PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT; + state.flaggedPosts = PreferenceStore.getCategory(Constants.Preferences.CATEGORY_FLAGGED_POST); this.state = state; } @@ -121,6 +122,10 @@ export default class RhsThread extends React.Component { return true; } + if (!Utils.areObjectsEqual(nextState.flaggedPosts, this.state.flaggedPosts)) { + return true; + } + if (!Utils.areObjectsEqual(nextState.profiles, this.state.profiles)) { return true; } @@ -151,7 +156,8 @@ export default class RhsThread extends React.Component { onPreferenceChange() { this.setState({ - compactDisplay: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT + compactDisplay: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT, + flaggedPosts: PreferenceStore.getCategory(Constants.Preferences.CATEGORY_FLAGGED_POST) }); this.forceUpdateInfo(); } @@ -240,12 +246,18 @@ export default class RhsThread extends React.Component { profile = profiles[selected.user_id]; } + let isRootFlagged = false; + if (this.state.flaggedPosts) { + isRootFlagged = this.state.flaggedPosts.get(selected.id) === 'true'; + } + return (
    {searchForm}
    {postsArray.map((comPost) => { @@ -277,6 +290,11 @@ export default class RhsThread extends React.Component { } else { p = profiles[comPost.user_id]; } + + let isFlagged = false; + if (this.state.flaggedPosts) { + isFlagged = this.state.flaggedPosts.get(comPost.id) === 'true'; + } return ( ); })} @@ -311,6 +330,7 @@ RhsThread.defaultProps = { RhsThread.propTypes = { fromSearch: React.PropTypes.string, + fromFlaggedPosts: React.PropTypes.bool, isMentionSearch: React.PropTypes.bool, currentUser: React.PropTypes.object.isRequired, useMilitaryTime: React.PropTypes.bool.isRequired, diff --git a/webapp/components/search_results.jsx b/webapp/components/search_results.jsx index 6431ff2c2..9e3092cca 100644 --- a/webapp/components/search_results.jsx +++ b/webapp/components/search_results.jsx @@ -7,6 +7,7 @@ import SearchStore from 'stores/search_store.jsx'; import UserStore from 'stores/user_store.jsx'; import SearchBox from './search_bar.jsx'; import * as Utils from 'utils/utils.jsx'; +import Constants from 'utils/constants.jsx'; import SearchResultsHeader from './search_results_header.jsx'; import SearchResultsItem from './search_results_item.jsx'; @@ -122,10 +123,44 @@ export default class SearchResults extends React.Component { var noResults = (!results || !results.order || !results.order.length); const searchTerm = this.state.searchTerm; const profiles = this.state.profiles || {}; + const flagIcon = Constants.FLAG_ICON_SVG; var ctls = null; - if (!searchTerm && noResults) { + if (this.props.isFlaggedPosts && noResults) { + ctls = ( +
    +
      +
    • + +
    • +
    • + + + +
    • +
    • + +
    • +
    +
    + ); + } else if (!searchTerm && noResults) { ctls = (
    ); }, this); @@ -185,6 +221,7 @@ export default class SearchResults extends React.Component { isMentionSearch={this.props.isMentionSearch} toggleSize={this.props.toggleSize} shrink={this.props.shrink} + isFlaggedPosts={this.props.isFlaggedPosts} />
    ); + } else if (this.props.isFlaggedPosts) { + title = ( + + ); } return ( @@ -140,5 +147,6 @@ export default class SearchResultsHeader extends React.Component { SearchResultsHeader.propTypes = { isMentionSearch: React.PropTypes.bool, toggleSize: React.PropTypes.function, - shrink: React.PropTypes.function + shrink: React.PropTypes.function, + isFlaggedPosts: React.PropTypes.bool }; diff --git a/webapp/components/search_results_item.jsx b/webapp/components/search_results_item.jsx index fb8b23a7f..db64463a9 100644 --- a/webapp/components/search_results_item.jsx +++ b/webapp/components/search_results_item.jsx @@ -7,16 +7,20 @@ import UserProfile from './user_profile.jsx'; import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; -import * as GlobalActions from 'actions/global_actions.jsx'; import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import * as GlobalActions from 'actions/global_actions.jsx'; +import {unflagPost, getFlaggedPosts} from 'actions/post_actions.jsx'; + import * as TextFormatting from 'utils/text_formatting.jsx'; import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; + import Constants from 'utils/constants.jsx'; +import {Tooltip, OverlayTrigger} from 'react-bootstrap'; const ActionTypes = Constants.ActionTypes; -import {FormattedMessage, FormattedDate} from 'react-intl'; import React from 'react'; +import {FormattedMessage, FormattedDate} from 'react-intl'; import {browserHistory} from 'react-router/es6'; export default class SearchResultsItem extends React.Component { @@ -25,6 +29,7 @@ export default class SearchResultsItem extends React.Component { this.handleFocusRHSClick = this.handleFocusRHSClick.bind(this); this.shrinkSidebar = this.shrinkSidebar.bind(this); + this.unflagPost = this.unflagPost.bind(this); } hideSidebar() { @@ -42,12 +47,20 @@ export default class SearchResultsItem extends React.Component { GlobalActions.emitPostFocusRightHandSideFromSearch(this.props.post, this.props.isMentionSearch); } + unflagPost(e) { + e.preventDefault(); + unflagPost(this.props.post.id, + () => getFlaggedPosts() + ); + } + render() { let channelName = null; const channel = this.props.channel; const timestamp = UserStore.getCurrentUser().update_at; const user = this.props.user || {}; const post = this.props.post; + const flagIcon = Constants.FLAG_ICON_SVG; if (channel) { channelName = channel.display_name; @@ -77,11 +90,50 @@ export default class SearchResultsItem extends React.Component { } let botIndicator; - if (post.props && post.props.from_webhook) { botIndicator =
  • {Constants.BOT_NAME}
  • ; } + let flag; + let flagVisible = ''; + let flagTooltip = ( + + + + ); + if (this.props.isFlagged) { + flagVisible = 'visible'; + flagTooltip = ( + + + + ); + flag = ( + + + + + + ); + } + return (
    @@ -126,8 +178,19 @@ export default class SearchResultsItem extends React.Component { minute='2-digit' /> + {flag} -
  • +
  • + + + { @@ -163,18 +226,6 @@ export default class SearchResultsItem extends React.Component { />
  • -
  • - - - -
  • +
  • + + + + +
  • Sending Messages

    Type here to write a message and press Enter to post it.

    Click the Attachment button to upload an image or a file.

    ", "create_post.write": "Write a message...", "create_team.agreement": "By proceeding to create your account and use {siteName}, you agree to our
    Terms of Service and Privacy Policy. If you do not agree, you cannot use {siteName}.", @@ -1370,6 +1372,8 @@ "post_delete.someone": "Someone deleted the message on which you tried to post a comment.", "post_focus_view.beginning": "Beginning of Channel Archives", "post_info.del": "Delete", + "post_info.mobile.flag": "Flag", + "post_info.mobile.unflag": "Unflag", "post_info.edit": "Edit", "post_info.permalink": "Permalink", "post_info.reply": "Reply", @@ -1393,14 +1397,19 @@ "rename_channel.title": "Rename Channel", "rhs_comment.comment": "Comment", "rhs_comment.del": "Delete", + "rhs_comment.mobile.flag": "Flag", + "rhs_comment.mobile.unflag": "Unflag", "rhs_comment.edit": "Edit", "rhs_comment.permalink": "Permalink", "rhs_header.backToResultsTooltip": "Back to Search Results", + "rhs_header.backToFlaggedTooltip": "Back to Flagged Posts", "rhs_header.closeSidebarTooltip": "Close Sidebar", "rhs_header.details": "Message Details", "rhs_header.expandSidebarTooltip": "Expand Sidebar", "rhs_header.shrinkSidebarTooltip": "Shrink Sidebar", "rhs_root.del": "Delete", + "rhs_root.mobile.flag": "Flag", + "rhs_root.mobile.unflag": "Unflag", "rhs_root.direct": "Direct Message", "rhs_root.edit": "Edit", "rhs_root.permalink": "Permalink", @@ -1409,6 +1418,7 @@ "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_item.direct": "Direct Message", "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.
    ", @@ -1440,6 +1450,7 @@ "sidebar.unreadBelow": "Unread post(s) below", "sidebar_header.tutorial": "

    Main Menu

    The Main Menu is where you can Invite New Members, access your Account Settings and set your Theme Color.

    Team administrators can also access their Team Settings from this menu.

    System administrators will find a System Console option to administrate the entire system.

    ", "sidebar_right_menu.accountSettings": "Account Settings", + "sidebar_right_menu.flagged": "Flagged Posts", "sidebar_right_menu.console": "System Console", "sidebar_right_menu.help": "Help", "sidebar_right_menu.inviteNew": "Invite New Member", diff --git a/webapp/sass/components/_search.scss b/webapp/sass/components/_search.scss index d259cfc20..11bcdb92d 100644 --- a/webapp/sass/components/_search.scss +++ b/webapp/sass/components/_search.scss @@ -137,23 +137,31 @@ } } -.search-item__jump { - @include opacity(.8); +.col__controls { font-size: 13px; position: absolute; right: 0; top: 0; - &:hover { - @include opacity(1); + a { + @include opacity(.8); + vertical-align: top; + + &:hover { + @include opacity(1); + } } -} -.search-item__comment { - margin-right: 35px; - position: absolute; - right: 0; - top: 0; + + .search-item__jump { + font-size: 13px; + position: relative; + top: 1px; + } + + .search-item__comment { + margin-right: 5px; + } } .search-item-time { diff --git a/webapp/sass/layout/_headers.scss b/webapp/sass/layout/_headers.scss index f2705fc0a..832bed50e 100644 --- a/webapp/sass/layout/_headers.scss +++ b/webapp/sass/layout/_headers.scss @@ -43,6 +43,7 @@ } &:last-child { + padding-right: 8px; width: 8.9%; } } @@ -375,8 +376,17 @@ font-size: 22px; height: 30px; line-height: 26px; - margin-right: 9px; - width: 24px; + margin-right: 3px; + text-align: center; + width: 30px; + + th { + &:last-child { + div { + margin-right: 10px; + } + } + } .channel__wrap.move--left & { position: absolute; @@ -384,6 +394,15 @@ top: 14px; } + .icon__flag { + svg { + height: 18px; + position: relative; + top: 2px; + width: 19px; + } + } + > a { @include opacity(.6); @include single-transition(all, .1s, ease-in); diff --git a/webapp/sass/layout/_post.scss b/webapp/sass/layout/_post.scss index 605c03658..8513f779a 100644 --- a/webapp/sass/layout/_post.scss +++ b/webapp/sass/layout/_post.scss @@ -486,6 +486,7 @@ body.ios { &:hover { .dropdown, .comment-icon__container, + .flag-icon__container, .post__reply, .post__remove { visibility: visible; @@ -561,7 +562,7 @@ body.ios { .img-div { max-height: 150px; max-width: 150px; - } + } p { line-height: inherit; @@ -573,7 +574,7 @@ body.ios { ol, ul { - clear: both; + clear: both; padding-left: 20px; } } @@ -694,6 +695,13 @@ body.ios { } } + .flag-icon__container { + left: 36px; + margin-left: 5px; + position: absolute; + top: 8px; + } + .post__img { img { display: none; @@ -835,7 +843,9 @@ body.ios { } .post__img { - width: 42px; + padding-right: 10px; + text-align: right; + width: 53px; svg { height: 32px; @@ -1076,6 +1086,7 @@ body.ios { display: inline-block; margin-right: 6px; visibility: hidden; + svg { fill: inherit; position: relative; @@ -1115,6 +1126,36 @@ body.ios { } } + .flag-icon__container { + display: inline-block; + font-size: 12px; + margin-left: 7px; + position: relative; + top: 1px; + visibility: hidden; + + &.visible { + visibility: visible; + } + + path { + fill: inherit; + } + + .fa-star-o { + @include opacity(.8); + } + + &:focus { + outline: none; + } + + &.icon--visible { + visibility: visible; + } + + } + .web-embed-data { @include border-radius(2px); @include alpha-property(background, $black, 0.05); diff --git a/webapp/sass/layout/_sidebar-right.scss b/webapp/sass/layout/_sidebar-right.scss index fb57b6146..497cd3cea 100644 --- a/webapp/sass/layout/_sidebar-right.scss +++ b/webapp/sass/layout/_sidebar-right.scss @@ -161,7 +161,7 @@ .sidebar--right__subheader { font-size: 1em; - padding: 1em 1em 0; + padding: 0.5em 1em 0; h4 { font-size: 1em; @@ -176,6 +176,13 @@ font-size: .95em; padding-bottom: 10px; } + + .usage__icon { + @include opacity(.6); + margin: 0 3px; + position: relative; + top: 1px; + } } .suggestion-list__content { diff --git a/webapp/sass/responsive/_mobile.scss b/webapp/sass/responsive/_mobile.scss index df615aa13..c60233ae8 100644 --- a/webapp/sass/responsive/_mobile.scss +++ b/webapp/sass/responsive/_mobile.scss @@ -94,6 +94,16 @@ } } + &.same--root { + &.same--user { + .flag-icon__container { + left: auto; + position: relative; + top: 1px; + } + } + } + .post__content { padding: 0 10px 0 0; } @@ -182,7 +192,23 @@ } } + .star-icon__container { + left: auto; + position: relative; + top: auto; + + &:not(.visible) { + display: none; + } + } + &.same--root { + .star-icon__container { + left: auto; + position: relative; + top: auto; + } + &.same--user { .post__header { height: auto; diff --git a/webapp/sass/responsive/_tablet.scss b/webapp/sass/responsive/_tablet.scss index e6cb898fd..bb3d78652 100644 --- a/webapp/sass/responsive/_tablet.scss +++ b/webapp/sass/responsive/_tablet.scss @@ -116,7 +116,15 @@ .channel__wrap & { .post__time { font-size: .85em; - left: -70px; + left: -79px; + position: absolute; + text-align: right; + top: 6px; + width: 60px; + } + + .star-icon__container { + left: -65px; position: absolute; text-align: right; top: 6px; @@ -125,7 +133,7 @@ } &:not(.post--thread) { - padding: 5px .5em 0 70px; + padding: 5px .5em 0 72px; .post__link { margin: 4px 0 7px; @@ -197,11 +205,29 @@ } } + .flag-icon__container { + left: -21px; + position: absolute; + top: 7px; + } + + .sidebar--right & .flag-icon__container { + left: auto; + position: relative; + top: 1px; + } + &.same--root { &.same--user { - padding-left: 70px; + padding-left: 72px; padding-top: 0; + .flag-icon__container { + left: -19px; + position: absolute; + top: 7px; + } + .post__header { .col__reply { top: -1px; @@ -265,12 +291,16 @@ &:not(.post--compact) { .post__time { + display: inline-block; font-size: 11px; - left: -4px; - line-height: 37px; + left: -14px; + line-height: 34px; position: absolute; + text-align: right; text-rendering: auto; top: -2px; + width: 51px; + } } } diff --git a/webapp/stores/post_store.jsx b/webapp/stores/post_store.jsx index 1c9a877cf..135563866 100644 --- a/webapp/stores/post_store.jsx +++ b/webapp/stores/post_store.jsx @@ -441,8 +441,8 @@ class PostStoreClass extends EventEmitter { return threadPosts; } - emitSelectedPostChange(fromSearch) { - this.emit(SELECTED_POST_CHANGE_EVENT, fromSearch); + emitSelectedPostChange(fromSearch, fromFlaggedPosts) { + this.emit(SELECTED_POST_CHANGE_EVENT, fromSearch, fromFlaggedPosts); } addSelectedPostChangeListener(callback) { @@ -599,7 +599,7 @@ PostStore.dispatchToken = AppDispatcher.register((payload) => { break; case ActionTypes.RECEIVED_POST_SELECTED: PostStore.storeSelectedPostId(action.postId); - PostStore.emitSelectedPostChange(action.from_search); + PostStore.emitSelectedPostChange(action.from_search, action.from_flagged_posts); break; default: } diff --git a/webapp/stores/search_store.jsx b/webapp/stores/search_store.jsx index dc08ca3a6..741590895 100644 --- a/webapp/stores/search_store.jsx +++ b/webapp/stores/search_store.jsx @@ -18,6 +18,7 @@ class SearchStoreClass extends EventEmitter { this.searchResults = null; this.isMentionSearch = false; + this.isFlaggedPosts = false; this.searchTerm = ''; } @@ -77,6 +78,10 @@ class SearchStoreClass extends EventEmitter { return this.isMentionSearch; } + getIsFlaggedPosts() { + return this.isFlaggedPosts; + } + storeSearchTerm(term) { this.searchTerm = term; } @@ -85,9 +90,10 @@ class SearchStoreClass extends EventEmitter { return this.searchTerm; } - storeSearchResults(results, isMentionSearch) { + storeSearchResults(results, isMentionSearch, isFlaggedPosts) { this.searchResults = results; this.isMentionSearch = isMentionSearch; + this.isFlaggedPosts = isFlaggedPosts; } } @@ -98,7 +104,7 @@ SearchStore.dispatchToken = AppDispatcher.register((payload) => { switch (action.type) { case ActionTypes.RECEIVED_SEARCH: - SearchStore.storeSearchResults(action.results, action.is_mention_search); + SearchStore.storeSearchResults(action.results, action.is_mention_search, action.is_flagged_posts); SearchStore.emitSearchChange(); break; case ActionTypes.RECEIVED_SEARCH_TERM: diff --git a/webapp/tests/client_post.test.jsx b/webapp/tests/client_post.test.jsx index 3c6f05c9f..3b9802fb4 100644 --- a/webapp/tests/client_post.test.jsx +++ b/webapp/tests/client_post.test.jsx @@ -197,5 +197,38 @@ describe('Client.Posts', function() { ); }); }); + + it('getFlaggedPosts', function(done) { + TestHelper.initBasic(() => { + var pref = {}; + pref.user_id = TestHelper.basicUser().id; + pref.category = 'flagged_post'; + pref.name = TestHelper.basicPost().id; + pref.value = 'true'; + + var prefs = []; + prefs.push(pref); + + TestHelper.basicClient().savePreferences( + prefs, + function() { + TestHelper.basicClient().getFlaggedPosts( + 0, + 2, + function(data) { + assert.equal(data.order[0], TestHelper.basicPost().id); + done(); + }, + function(err) { + done(new Error(err.message)); + } + ); + }, + function(err) { + done(new Error(err.message)); + } + ); + }); + }); }); diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index 812796ebb..dbdc3e9f1 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -51,7 +51,8 @@ export const Preferences = { COLLAPSE_DISPLAY: 'collapse_previews', COLLAPSE_DISPLAY_DEFAULT: 'false', USE_MILITARY_TIME: 'use_military_time', - CATEGORY_THEME: 'theme' + CATEGORY_THEME: 'theme', + CATEGORY_FLAGGED_POST: 'flagged_post' }; export const ActionTypes = keyMirror({ @@ -326,6 +327,8 @@ export const Constants = { OPEN_TEAM: 'O', MAX_POST_LEN: 4000, EMOJI_SIZE: 16, + FLAG_ICON_OUTLINE_SVG: " ", + FLAG_ICON_SVG: " ", ATTACHMENT_ICON_SVG: "", MATTERMOST_ICON_SVG: " ", ONLINE_ICON_SVG: " ", diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx index 187c7d7f4..9f8b1ef6c 100644 --- a/webapp/utils/utils.jsx +++ b/webapp/utils/utils.jsx @@ -566,7 +566,8 @@ export function applyTheme(theme) { } if (theme.centerChannelColor) { - changeCss('.app__body .post-list__arrows', 'fill:' + changeOpacity(theme.centerChannelColor, 0.3), 1); + changeCss('.app__body .post-list__arrows, .app__body .post .flag-icon__container', 'fill:' + changeOpacity(theme.centerChannelColor, 0.3), 1); + changeCss('.app__body .channel-header__links .icon', 'stroke:' + theme.centerChannelColor, 1); changeCss('@media(min-width: 768px){.app__body .post:hover .post__header .col__reply, .app__body .post.post--hovered .post__header .col__reply', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 2); changeCss('.app__body .sidebar--left, .app__body .sidebar--right .sidebar--right__header, .app__body .suggestion-list__content .command', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); changeCss('.app__body .post.post--system .post__body', 'color:' + changeOpacity(theme.centerChannelColor, 0.6), 1); @@ -638,7 +639,8 @@ export function applyTheme(theme) { if (theme.linkColor) { changeCss('.app__body a, .app__body a:focus, .app__body a:hover, .app__body .btn, .app__body .btn:focus, .app__body .btn:hover', 'color:' + theme.linkColor, 1); changeCss('.app__body .attachment .attachment__container', 'border-left-color:' + changeOpacity(theme.linkColor, 0.5), 1); - changeCss('.app__body .post .comment-icon__container, .app__body .post .post__reply', 'fill:' + theme.linkColor, 1); + changeCss('.app__body .channel-header__links .icon:hover, .app__body .post .flag-icon__container.visible, .app__body .post .comment-icon__container, .app__body .post .post__reply', 'fill:' + theme.linkColor, 1); + changeCss('.app__body .channel-header__links .icon:hover', 'stroke:' + theme.linkColor, 1); } if (theme.buttonBg) { -- cgit v1.2.3-1-g7c22