From ab67f6e257f6e8f08145a02a7b93550f99641be4 Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Sun, 18 Jun 2017 14:42:32 -0400 Subject: [PATCH] PLT-6215 Major post list refactor (#6501) * Major post list refactor * Fix post and thread deletion * Fix preferences not selecting correctly * Fix military time displaying * Fix UP key for editing posts * Fix ESLint error * Various fixes and updates per feedback * Fix for permalink view * Revert to old scrolling method and various fixes * Add floating timestamp, new message indicator, scroll arrows * Update post loading for focus mode and add visibility limit * Fix pinning posts and a react warning * Add loading UI updates from Asaad * Fix refreshing loop * Temporarily bump post visibility limit * Update infinite scrolling * Remove infinite scrolling --- .gitignore | 1 + webapp/.eslintrc.json | 1 - webapp/actions/file_actions.jsx | 31 +- webapp/actions/global_actions.jsx | 54 +- webapp/actions/post_actions.jsx | 394 +++------- webapp/actions/websocket_actions.jsx | 27 +- webapp/client/browser_web_client.jsx | 11 + webapp/components/channel_view.jsx | 6 +- webapp/components/create_comment.jsx | 3 +- webapp/components/create_post.jsx | 35 +- webapp/components/dot_menu/dot_menu.jsx | 33 +- webapp/components/dot_menu/dot_menu_flag.jsx | 21 +- webapp/components/dot_menu/dot_menu_item.jsx | 20 +- webapp/components/dot_menu/index.js | 26 + webapp/components/edit_post_modal.jsx | 12 +- webapp/components/file_attachment.jsx | 8 +- .../file_attachment_list.jsx | 47 +- .../components/file_attachment_list/index.js | 39 + .../file_attachment_list_container.jsx | 92 --- webapp/components/file_preview.jsx | 4 +- webapp/components/needs_team/needs_team.jsx | 11 +- webapp/components/permalink_view.jsx | 24 +- .../commented_on_files_message.jsx | 51 ++ .../commented_on_files_message/index.js | 36 + .../commented_on_files_message_container.jsx | 90 --- .../post_view/components/date_separator.jsx | 26 - .../components/post_attachment_list.jsx | 30 - .../post_view/components/post_list.jsx | 690 ------------------ .../components/post_message_container.jsx | 106 --- .../components/reaction_container.jsx | 90 --- .../components/reaction_list_container.jsx | 94 --- .../components/post_view/date_separator.jsx | 32 + .../failed_post_options.jsx} | 53 +- .../post_view/failed_post_options/index.js | 24 + .../{components => }/floating_timestamp.jsx | 22 +- webapp/components/post_view/index.js | 44 +- .../new_message_indicator.jsx | 19 +- webapp/components/post_view/post/index.js | 33 + .../post_view/{components => post}/post.jsx | 222 +++--- .../{components => }/post_attachment.jsx | 46 +- .../post_view/post_attachment_list.jsx | 35 + .../post_attachment_opengraph/index.js | 26 + .../post_attachment_opengraph.jsx | 96 +-- .../components/post_view/post_body/index.js | 30 + .../{components => post_body}/post_body.jsx | 132 ++-- .../post_body_additional_content.jsx | 61 +- .../{common => post_view}/post_flag_icon.jsx | 3 +- .../post_view/post_focus_view_controller.jsx | 212 ------ .../components/post_view/post_header/index.js | 18 + .../post_header.jsx | 103 ++- .../post_view/{components => }/post_image.jsx | 33 +- .../components/post_view/post_info/index.js | 31 + .../{components => post_info}/post_info.jsx | 119 +-- webapp/components/post_view/post_list.jsx | 523 +++++++++++++ .../post_view/post_message_view/index.js | 41 ++ .../post_message_view.jsx | 108 +-- .../system_message_helpers.jsx | 0 .../post_view/{components => }/post_time.jsx | 71 +- .../components/post_view/post_view_cache.jsx | 98 --- .../post_view/post_view_controller.jsx | 404 ---------- webapp/components/post_view/reaction/index.js | 47 ++ .../{components => reaction}/reaction.jsx | 80 +- .../post_view/reaction_list/index.js | 33 + .../reaction_list.jsx} | 36 +- .../scroll_to_bottom_arrows.jsx | 0 webapp/components/rhs_comment.jsx | 71 +- webapp/components/rhs_root_post.jsx | 63 +- webapp/components/rhs_thread/index.js | 27 + .../{ => rhs_thread}/rhs_thread.jsx | 143 ++-- webapp/components/search_results_item.jsx | 12 +- webapp/components/sidebar_right/index.js | 17 + .../{ => sidebar_right}/sidebar_right.jsx | 56 +- webapp/components/view_image.jsx | 11 +- webapp/components/youtube_video/index.js | 16 + .../{ => youtube_video}/youtube_video.jsx | 32 +- webapp/i18n/en.json | 1 + webapp/reducers/index.js | 8 + webapp/reducers/views/channel.js | 69 ++ webapp/reducers/views/index.js | 12 + webapp/reducers/views/rhs.js | 63 ++ webapp/sass/layout/_post.scss | 20 + webapp/store/index.js | 9 +- webapp/stores/channel_store.jsx | 3 +- webapp/stores/emoji_store.jsx | 2 +- webapp/stores/file_store.jsx | 73 -- webapp/stores/post_store.jsx | 684 ++--------------- webapp/stores/reaction_store.jsx | 92 --- webapp/utils/constants.jsx | 6 +- webapp/utils/utils.jsx | 3 +- webapp/yarn.lock | 9 +- 90 files changed, 2464 insertions(+), 3986 deletions(-) create mode 100644 webapp/components/dot_menu/index.js rename webapp/components/{ => file_attachment_list}/file_attachment_list.jsx (68%) create mode 100644 webapp/components/file_attachment_list/index.js delete mode 100644 webapp/components/file_attachment_list_container.jsx create mode 100644 webapp/components/post_view/commented_on_files_message/commented_on_files_message.jsx create mode 100644 webapp/components/post_view/commented_on_files_message/index.js delete mode 100644 webapp/components/post_view/components/commented_on_files_message_container.jsx delete mode 100644 webapp/components/post_view/components/date_separator.jsx delete mode 100644 webapp/components/post_view/components/post_attachment_list.jsx delete mode 100644 webapp/components/post_view/components/post_list.jsx delete mode 100644 webapp/components/post_view/components/post_message_container.jsx delete mode 100644 webapp/components/post_view/components/reaction_container.jsx delete mode 100644 webapp/components/post_view/components/reaction_list_container.jsx create mode 100644 webapp/components/post_view/date_separator.jsx rename webapp/components/post_view/{components/pending_post_options.jsx => failed_post_options/failed_post_options.jsx} (71%) create mode 100644 webapp/components/post_view/failed_post_options/index.js rename webapp/components/post_view/{components => }/floating_timestamp.jsx (70%) rename webapp/components/post_view/{components => }/new_message_indicator.jsx (90%) create mode 100644 webapp/components/post_view/post/index.js rename webapp/components/post_view/{components => post}/post.jsx (63%) rename webapp/components/post_view/{components => }/post_attachment.jsx (91%) create mode 100644 webapp/components/post_view/post_attachment_list.jsx create mode 100644 webapp/components/post_view/post_attachment_opengraph/index.js rename webapp/components/post_view/{components => post_attachment_opengraph}/post_attachment_opengraph.jsx (78%) create mode 100644 webapp/components/post_view/post_body/index.js rename webapp/components/post_view/{components => post_body}/post_body.jsx (59%) rename webapp/components/post_view/{components => }/post_body_additional_content.jsx (82%) rename webapp/components/{common => post_view}/post_flag_icon.jsx (99%) delete mode 100644 webapp/components/post_view/post_focus_view_controller.jsx create mode 100644 webapp/components/post_view/post_header/index.js rename webapp/components/post_view/{components => post_header}/post_header.jsx (63%) rename webapp/components/post_view/{components => }/post_image.jsx (81%) create mode 100644 webapp/components/post_view/post_info/index.js rename webapp/components/post_view/{components => post_info}/post_info.jsx (70%) create mode 100644 webapp/components/post_view/post_list.jsx create mode 100644 webapp/components/post_view/post_message_view/index.js rename webapp/components/post_view/{components => post_message_view}/post_message_view.jsx (58%) rename webapp/components/post_view/{components => post_message_view}/system_message_helpers.jsx (100%) rename webapp/components/post_view/{components => }/post_time.jsx (63%) delete mode 100644 webapp/components/post_view/post_view_cache.jsx delete mode 100644 webapp/components/post_view/post_view_controller.jsx create mode 100644 webapp/components/post_view/reaction/index.js rename webapp/components/post_view/{components => reaction}/reaction.jsx (76%) create mode 100644 webapp/components/post_view/reaction_list/index.js rename webapp/components/post_view/{components/reaction_list_view.jsx => reaction_list/reaction_list.jsx} (64%) rename webapp/components/post_view/{components => }/scroll_to_bottom_arrows.jsx (100%) create mode 100644 webapp/components/rhs_thread/index.js rename webapp/components/{ => rhs_thread}/rhs_thread.jsx (78%) create mode 100644 webapp/components/sidebar_right/index.js rename webapp/components/{ => sidebar_right}/sidebar_right.jsx (85%) create mode 100644 webapp/components/youtube_video/index.js rename webapp/components/{ => youtube_video}/youtube_video.jsx (92%) create mode 100644 webapp/reducers/index.js create mode 100644 webapp/reducers/views/channel.js create mode 100644 webapp/reducers/views/index.js create mode 100644 webapp/reducers/views/rhs.js delete mode 100644 webapp/stores/file_store.jsx delete mode 100644 webapp/stores/reaction_store.jsx diff --git a/.gitignore b/.gitignore index 66e2b5d582..2db234ffb4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules /webapp/dist jobserver npm-debug.log +webapp/yarn-error.log mattermost.mattermost-license config/mattermost.mattermost-license diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index bb4721c229..6818bcacf3 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -251,7 +251,6 @@ "react/self-closing-comp": 2, "react/sort-comp": 0, "react/style-prop-object": 2, - "require-await": 2, "require-yield": 2, "rest-spread-spacing": [2, "never"], "semi": [2, "always"], diff --git a/webapp/actions/file_actions.jsx b/webapp/actions/file_actions.jsx index 204f452d82..628144676d 100644 --- a/webapp/actions/file_actions.jsx +++ b/webapp/actions/file_actions.jsx @@ -1,25 +1,24 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import * as AsyncClient from 'utils/async_client.jsx'; -import Client from 'client/web_client.jsx'; +import store from 'stores/redux_store.jsx'; +const dispatch = store.dispatch; +const getState = store.getState; +import {uploadFile as uploadFileRedux} from 'mattermost-redux/actions/files'; export function uploadFile(file, name, channelId, clientId, success, error) { - Client.uploadFile( - file, - name, - channelId, - clientId, - (data) => { - if (success) { - success(data); - } - }, - (err) => { - AsyncClient.dispatchError(err, 'uploadFile'); + const fileFormData = new FormData(); + fileFormData.append('files', file, name); + fileFormData.append('channel_id', channelId); + fileFormData.append('client_ids', clientId); - if (error) { - error(err); + uploadFileRedux(channelId, null, [clientId], fileFormData)(dispatch, getState).then( + (data) => { + if (data && success) { + success(data); + } else if (data == null && error) { + const serverError = getState().requests.files.uploadFiles.error; + error({id: serverError.server_error_id, ...serverError}); } } ); diff --git a/webapp/actions/global_actions.jsx b/webapp/actions/global_actions.jsx index a1b178d678..13d74c8451 100644 --- a/webapp/actions/global_actions.jsx +++ b/webapp/actions/global_actions.jsx @@ -4,14 +4,13 @@ import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import ChannelStore from 'stores/channel_store.jsx'; -import PostStore from 'stores/post_store.jsx'; import UserStore from 'stores/user_store.jsx'; import BrowserStore from 'stores/browser_store.jsx'; import ErrorStore from 'stores/error_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import SearchStore from 'stores/search_store.jsx'; -import {handleNewPost, loadPosts, loadPostsBefore, loadPostsAfter} from 'actions/post_actions.jsx'; +import {handleNewPost} from 'actions/post_actions.jsx'; import {loadProfilesForSidebar} from 'actions/user_actions.jsx'; import {loadChannelsForCurrentUser} from 'actions/channel_actions.jsx'; import {stopPeriodicStatusUpdates} from 'actions/status_actions.jsx'; @@ -59,7 +58,6 @@ export function emitChannelClickEvent(channel) { getMyChannelMemberPromise.then(() => { getChannelStats(chan.id)(dispatch, getState); viewChannel(chan.id, oldChannelId)(dispatch, getState); - loadPosts(chan.id); // Mark previous and next channel as read ChannelStore.resetCounts([chan.id, oldChannelId]); @@ -106,10 +104,15 @@ export function doFocusPost(channelId, postId, data) { channelId, post_list: data }); + + dispatch({ + type: ActionTypes.RECEIVED_FOCUSED_POST, + data: postId, + channelId + }); + loadChannelsForCurrentUser(); getChannelStats(channelId)(dispatch, getState); - loadPostsBefore(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true); - loadPostsAfter(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true); } export function emitPostFocusEvent(postId, onSuccess) { @@ -148,8 +151,10 @@ export function emitCloseRightHandSide() { SearchStore.storeSearchResults(null, false, false); SearchStore.emitSearchChange(); - PostStore.storeSelectedPostId(null); - PostStore.emitSelectedPostChange(false, false); + dispatch({ + type: ActionTypes.SELECT_POST, + postId: '' + }); } export function emitPostFocusRightHandSideFromSearch(post, isMentionSearch) { @@ -188,29 +193,6 @@ export function emitLeaveTeam() { removeUserFromTeam(TeamStore.getCurrentId(), UserStore.getCurrentId())(dispatch, getState); } -export function emitLoadMorePostsEvent() { - const id = ChannelStore.getCurrentId(); - loadMorePostsTop(id, false); -} - -export function emitLoadMorePostsFocusedTopEvent() { - const id = PostStore.getFocusedPostId(); - loadMorePostsTop(id, true); -} - -export function loadMorePostsTop(id, isFocusPost) { - const earliestPostId = PostStore.getEarliestPostFromPage(id).id; - if (PostStore.requestVisibilityIncrease(id, Constants.POST_CHUNK_SIZE)) { - loadPostsBefore(earliestPostId, 0, Constants.POST_CHUNK_SIZE, isFocusPost); - } -} - -export function emitLoadMorePostsFocusedBottomEvent() { - const id = PostStore.getFocusedPostId(); - const latestPostId = PostStore.getLatestPost(id).id; - loadPostsAfter(latestPostId, 0, Constants.POST_CHUNK_SIZE, Boolean(id)); -} - export function emitUserPostedEvent(post) { AppDispatcher.handleServerAction({ type: ActionTypes.CREATE_POST, @@ -225,13 +207,6 @@ export function emitUserCommentedEvent(post) { }); } -export function emitPostDeletedEvent(post) { - AppDispatcher.handleServerAction({ - type: ActionTypes.POST_DELETED, - post - }); -} - export function showDeletePostModal(post, commentCount = 0) { AppDispatcher.handleViewAction({ type: ActionTypes.TOGGLE_DELETE_POST_MODAL, @@ -421,11 +396,6 @@ export function loadDefaultLocale() { return newLocalizationSelected(locale); } -export function viewLoggedIn() { - // Clear pending posts (shouldn't have pending posts if we are loading) - PostStore.clearPendingPosts(); -} - let lastTimeTypingSent = 0; export function emitLocalUserTypingEvent(channelId, parentId) { const t = Date.now(); diff --git a/webapp/actions/post_actions.jsx b/webapp/actions/post_actions.jsx index d55a0d5784..1eb1f4feb6 100644 --- a/webapp/actions/post_actions.jsx +++ b/webapp/actions/post_actions.jsx @@ -4,14 +4,12 @@ import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import ChannelStore from 'stores/channel_store.jsx'; -import PostStore from 'stores/post_store.jsx'; import UserStore from 'stores/user_store.jsx'; +import PostStore from 'stores/post_store.jsx'; -import {loadStatusesForChannel} from 'actions/status_actions.jsx'; import {loadNewDMIfNeeded, loadNewGMIfNeeded} from 'actions/user_actions.jsx'; import {trackEvent} from 'actions/diagnostics_actions.jsx'; import {sendDesktopNotification} from 'actions/notification_actions.jsx'; -import * as GlobalActions from 'actions/global_actions.jsx'; import Client from 'client/web_client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; @@ -25,7 +23,20 @@ import store from 'stores/redux_store.jsx'; const dispatch = store.dispatch; const getState = store.getState; import {getProfilesByIds} from 'mattermost-redux/actions/users'; +import { + createPost as createPostRedux, + getPostThread, + editPost, + deletePost as deletePostRedux, + getPosts, + getPostsBefore, + addReaction as addReactionRedux, + removeReaction as removeReactionRedux +} from 'mattermost-redux/actions/posts'; import {getMyChannelMember} from 'mattermost-redux/actions/channels'; +import {PostTypes} from 'mattermost-redux/action_types'; +import * as Selectors from 'mattermost-redux/selectors/entities/posts'; +import {batchActions} from 'redux-batched-actions'; export function handleNewPost(post, msg) { let websocketMessageProps = {}; @@ -54,19 +65,22 @@ export function handleNewPost(post, msg) { } function completePostReceive(post, websocketMessageProps) { - if (post.root_id && PostStore.getPost(post.channel_id, post.root_id) == null) { - Client.getPost( - post.channel_id, - post.root_id, + if (post.root_id && Selectors.getPost(getState(), post.root_id) != null) { + getPostThread(post.root_id)(dispatch, getState).then( (data) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POSTS, - id: post.channel_id, - numRequested: 0, - post_list: data + // Need manual dispatch to remove pending post + dispatch({ + type: PostTypes.RECEIVED_POSTS, + data: { + order: [], + posts: { + [post.id]: post + } + }, + channelId: post.channel_id }); - // Required to update order + // Still needed to update unreads AppDispatcher.handleServerAction({ type: ActionTypes.RECEIVED_POST, post, @@ -74,17 +88,25 @@ function completePostReceive(post, websocketMessageProps) { }); sendDesktopNotification(post, websocketMessageProps); - loadProfilesForPosts(data.posts); - }, - (err) => { - AsyncClient.dispatchError(err, 'getPost'); } ); return; } + dispatch({ + type: PostTypes.RECEIVED_POSTS, + data: { + order: [], + posts: { + [post.id]: post + } + }, + channelId: post.channel_id + }); + + // Still needed to update unreads AppDispatcher.handleServerAction({ type: ActionTypes.RECEIVED_POST, post, @@ -167,138 +189,6 @@ export function getPinnedPosts(channelId = ChannelStore.getCurrentId()) { ); } -export function loadPosts(channelId = ChannelStore.getCurrentId(), isPost = false) { - const postList = PostStore.getAllPosts(channelId); - const latestPostTime = PostStore.getLatestPostFromPageTime(channelId); - - if ( - !postList || Object.keys(postList).length === 0 || - (!isPost && postList.order.length < Constants.POST_CHUNK_SIZE) || - latestPostTime === 0 - ) { - loadPostsPage(channelId, Constants.POST_CHUNK_SIZE, isPost); - return; - } - - Client.getPosts( - channelId, - latestPostTime, - (data) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POSTS, - id: channelId, - before: true, - numRequested: 0, - post_list: data, - isPost - }); - - loadProfilesForPosts(data.posts); - loadStatusesForChannel(channelId); - }, - (err) => { - AsyncClient.dispatchError(err, 'loadPosts'); - } - ); -} - -export function loadPostsPage(channelId = ChannelStore.getCurrentId(), max = Constants.POST_CHUNK_SIZE, isPost = false) { - const postList = PostStore.getAllPosts(channelId); - - // if we already have more than POST_CHUNK_SIZE posts, - // let's get the amount we have but rounded up to next multiple of POST_CHUNK_SIZE, - // with a max - let numPosts = Math.min(max, Constants.POST_CHUNK_SIZE); - if (postList && postList.order.length > 0) { - numPosts = Math.min(max, Constants.POST_CHUNK_SIZE * Math.ceil(postList.order.length / Constants.POST_CHUNK_SIZE)); - } - - Client.getPostsPage( - channelId, - 0, - numPosts, - (data) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POSTS, - id: channelId, - before: true, - numRequested: numPosts, - checkLatest: true, - checkEarliest: true, - post_list: data, - isPost - }); - - loadProfilesForPosts(data.posts); - loadStatusesForChannel(channelId); - }, - (err) => { - AsyncClient.dispatchError(err, 'loadPostsPage'); - } - ); -} - -export function loadPostsBefore(postId, offset, numPost, isPost) { - const channelId = ChannelStore.getCurrentId(); - if (channelId == null) { - return; - } - - Client.getPostsBefore( - channelId, - postId, - offset, - numPost, - (data) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POSTS, - id: channelId, - before: true, - checkEarliest: true, - numRequested: numPost, - post_list: data, - isPost - }); - - loadProfilesForPosts(data.posts); - loadStatusesForChannel(channelId); - }, - (err) => { - AsyncClient.dispatchError(err, 'loadPostsBefore'); - } - ); -} - -export function loadPostsAfter(postId, offset, numPost, isPost) { - const channelId = ChannelStore.getCurrentId(); - if (channelId == null) { - return; - } - - Client.getPostsAfter( - channelId, - postId, - offset, - numPost, - (data) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POSTS, - id: channelId, - before: false, - numRequested: numPost, - post_list: data, - isPost - }); - - loadProfilesForPosts(data.posts); - loadStatusesForChannel(channelId); - }, - (err) => { - AsyncClient.dispatchError(err, 'loadPostsAfter'); - } - ); -} - export function loadProfilesForPosts(posts) { const profilesToLoad = {}; for (const pid in posts) { @@ -321,124 +211,37 @@ export function loadProfilesForPosts(posts) { } export function addReaction(channelId, postId, emojiName) { - const reaction = { - post_id: postId, - user_id: UserStore.getCurrentId(), - emoji_name: emojiName - }; - emitEmojiPosted(emojiName); - - AsyncClient.saveReaction(channelId, reaction); + addReactionRedux(postId, emojiName)(dispatch, getState); } export function removeReaction(channelId, postId, emojiName) { - const reaction = { - post_id: postId, - user_id: UserStore.getCurrentId(), - emoji_name: emojiName - }; - - AsyncClient.deleteReaction(channelId, reaction); + removeReactionRedux(postId, emojiName)(dispatch, getState); } -const postQueue = []; +export function createPost(post, files, success) { + createPostRedux(post, files)(dispatch, getState).then(() => { + if (post.root_id) { + PostStore.storeCommentDraft(post.root_id, null); + } else { + PostStore.storeDraft(post.channel_id, null); + } -export function queuePost(post, doLoadPost, success, error) { - postQueue.push( - createPost.bind( - this, - post, - doLoadPost, - (data) => { - if (success) { - success(data); - } - - postSendComplete(); - }, - (err) => { - if (error) { - error(err); - } - - postSendComplete(); - } - ) - ); - - sendFirstPostInQueue(); + if (success) { + success(); + } + }); } -// Remove the completed post from the queue and send the next one -function postSendComplete() { - postQueue.shift(); - sendNextPostInQueue(); -} - -// Start sending posts if a new queue has started -function sendFirstPostInQueue() { - if (postQueue.length === 1) { - sendNextPostInQueue(); - } -} - -// Send the next post in the queue if there is one -function sendNextPostInQueue() { - const nextPostAction = postQueue[0]; - if (nextPostAction) { - nextPostAction(); - } -} - -export function createPost(post, doLoadPost, success, error) { - Client.createPost(post, +export function updatePost(post, success) { + editPost(post)(dispatch, getState).then( (data) => { - if (doLoadPost) { - loadPosts(post.channel_id); - } else { - PostStore.removePendingPost(post.pending_post_id); - } - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POST, - post: data - }); - - if (success) { - success(data); - } - }, - - (err) => { - if (err.id === 'api.post.create_post.root_id.app_error') { - PostStore.removePendingPost(post.pending_post_id); - } else { - post.state = Constants.POST_FAILED; - PostStore.updatePendingPost(post); - } - - if (error) { - error(err); + if (data && success) { + success(); } } ); } -export function updatePost(post, success, isPost) { - Client.updatePost( - post, - () => { - loadPosts(post.channel_id, isPost); - - if (success) { - success(); - } - }, - (err) => { - AsyncClient.dispatchError(err, 'updatePost'); - }); -} - export function emitEmojiPosted(emoji) { AppDispatcher.handleServerAction({ type: ActionTypes.EMOJI_POSTED, @@ -446,29 +249,31 @@ export function emitEmojiPosted(emoji) { }); } -export function deletePost(channelId, post, success, error) { - Client.deletePost( - channelId, - post.id, +export function deletePost(channelId, post, success) { + const {currentUserId} = getState().entities.users; + + let hardDelete = false; + if (post.user_id === currentUserId) { + hardDelete = true; + } + + deletePostRedux(post, hardDelete)(dispatch, getState).then( () => { - GlobalActions.emitRemovePost(post); - if (post.id === PostStore.getSelectedPostId()) { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POST_SELECTED, - postId: null + if (post.id === getState().views.rhs.selectedPostId) { + dispatch({ + type: ActionTypes.SELECT_POST, + postId: '' }); } + dispatch({ + type: PostTypes.REMOVE_POST, + data: post + }); + if (success) { success(); } - }, - (err) => { - AsyncClient.dispatchError(err, 'deletePost'); - - if (error) { - error(err); - } } ); } @@ -500,10 +305,49 @@ export function performSearch(terms, isMentionSearch, success, error) { ); } -export function storePostDraft(channelId, draft) { - AppDispatcher.handleViewAction({ - type: ActionTypes.POST_DRAFT_CHANGED, - channelId, - draft - }); +const POST_INCREASE_AMOUNT = Constants.POST_CHUNK_SIZE / 2; + +// Returns true if there are more posts to load +export function increasePostVisibility(channelId, focusedPostId) { + return async (doDispatch, doGetState) => { + if (doGetState().views.channel.loadingPosts[channelId]) { + return true; + } + + const currentPostVisibility = doGetState().views.channel.postVisibility[channelId]; + + if (currentPostVisibility >= Constants.MAX_POST_VISIBILITY) { + return true; + } + + doDispatch(batchActions([ + { + type: ActionTypes.LOADING_POSTS, + data: true, + channelId + }, + { + type: ActionTypes.INCREASE_POST_VISIBILITY, + data: channelId, + amount: POST_INCREASE_AMOUNT + } + ])); + + const page = Math.floor(currentPostVisibility / POST_INCREASE_AMOUNT); + + let posts; + if (focusedPostId) { + posts = await getPostsBefore(channelId, focusedPostId, page, POST_INCREASE_AMOUNT)(dispatch, getState); + } else { + posts = await getPosts(channelId, page, POST_INCREASE_AMOUNT)(doDispatch, doGetState); + } + + doDispatch({ + type: ActionTypes.LOADING_POSTS, + data: false, + channelId + }); + + return posts.order.length >= POST_INCREASE_AMOUNT; + }; } diff --git a/webapp/actions/websocket_actions.jsx b/webapp/actions/websocket_actions.jsx index b7a0b12a86..1aaecfb711 100644 --- a/webapp/actions/websocket_actions.jsx +++ b/webapp/actions/websocket_actions.jsx @@ -5,7 +5,6 @@ import $ from 'jquery'; import UserStore from 'stores/user_store.jsx'; import TeamStore from 'stores/team_store.jsx'; -import PostStore from 'stores/post_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import BrowserStore from 'stores/browser_store.jsx'; @@ -21,7 +20,7 @@ import * as AsyncClient from 'utils/async_client.jsx'; import {getSiteURL} from 'utils/url.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; -import {handleNewPost, loadPosts, loadProfilesForPosts} from 'actions/post_actions.jsx'; +import {handleNewPost, loadProfilesForPosts} from 'actions/post_actions.jsx'; import {loadProfilesForSidebar} from 'actions/user_actions.jsx'; import {loadChannelsForCurrentUser} from 'actions/channel_actions.jsx'; import * as StatusActions from 'actions/status_actions.jsx'; @@ -36,8 +35,9 @@ const dispatch = store.dispatch; const getState = store.getState; import {batchActions} from 'redux-batched-actions'; import {viewChannel, getChannelAndMyMember, getChannelStats} from 'mattermost-redux/actions/channels'; +import {getPosts} from 'mattermost-redux/actions/posts'; import {setServerVersion} from 'mattermost-redux/actions/general'; -import {ChannelTypes, TeamTypes, UserTypes} from 'mattermost-redux/action_types'; +import {ChannelTypes, TeamTypes, UserTypes, PostTypes} from 'mattermost-redux/action_types'; const MAX_WEBSOCKET_FAILS = 7; @@ -97,7 +97,7 @@ export function reconnect(includeWebSocket = true) { if (Client.teamId) { loadChannelsForCurrentUser(); - loadPosts(ChannelStore.getCurrentId()); + getPosts(ChannelStore.getCurrentId())(dispatch, getState); StatusActions.loadStatusesForChannelAndSidebar(); } @@ -246,8 +246,7 @@ function handleNewPostEvent(msg) { function handlePostEditEvent(msg) { // Store post const post = JSON.parse(msg.data.post); - PostStore.storePost(post, false); - PostStore.emitChange(); + dispatch({type: PostTypes.RECEIVED_POST, data: post}); // Update channel state if (ChannelStore.getCurrentId() === msg.broadcast.channel_id) { @@ -259,7 +258,7 @@ function handlePostEditEvent(msg) { function handlePostDeleteEvent(msg) { const post = JSON.parse(msg.data.post); - GlobalActions.emitPostDeletedEvent(post); + dispatch({type: PostTypes.POST_DELETED, data: post}); } function handleTeamAddedEvent(msg) { @@ -424,19 +423,17 @@ function handleWebrtc(msg) { function handleReactionAddedEvent(msg) { const reaction = JSON.parse(msg.data.reaction); - AppDispatcher.handleServerAction({ - type: ActionTypes.ADDED_REACTION, - postId: reaction.post_id, - reaction + dispatch({ + type: PostTypes.RECEIVED_REACTION, + data: reaction }); } function handleReactionRemovedEvent(msg) { const reaction = JSON.parse(msg.data.reaction); - AppDispatcher.handleServerAction({ - type: ActionTypes.REMOVED_REACTION, - postId: reaction.post_id, - reaction + dispatch({ + type: PostTypes.REACTION_DELETED, + data: reaction }); } diff --git a/webapp/client/browser_web_client.jsx b/webapp/client/browser_web_client.jsx index 398261758e..4a7b95f63a 100644 --- a/webapp/client/browser_web_client.jsx +++ b/webapp/client/browser_web_client.jsx @@ -137,6 +137,17 @@ class WebClientClass extends Client { return success(res.body); }); } + + uploadFileV4(file, filename, channelId, clientId, success, error) { + return request. + post(`${this.url}/api/v4/files`). + set(this.defaultHeaders). + attach('files', file, filename). + field('channel_id', channelId). + field('client_ids', clientId). + accept('application/json'). + end(this.handleResponse.bind(this, 'uploadFile', success, error)); + } } var WebClient = new WebClientClass(); diff --git a/webapp/components/channel_view.jsx b/webapp/components/channel_view.jsx index 97275d37d4..3f6edbd2e0 100644 --- a/webapp/components/channel_view.jsx +++ b/webapp/components/channel_view.jsx @@ -9,7 +9,7 @@ import * as UserAgent from 'utils/user_agent.jsx'; import ChannelHeader from 'components/channel_header.jsx'; import FileUploadOverlay from 'components/file_upload_overlay.jsx'; import CreatePost from 'components/create_post.jsx'; -import PostViewCache from 'components/post_view'; +import PostView from 'components/post_view'; import ChannelStore from 'stores/channel_store.jsx'; @@ -77,7 +77,9 @@ export default class ChannelView extends React.Component { - +
info.id); post.pending_post_id = `${userId}:${time}`; post.user_id = userId; post.create_at = time; @@ -244,7 +243,7 @@ export default class CreateComment extends React.Component { }); } - PostActions.queuePost(post, false, null, + PostActions.createPost(post, this.state.fileInfos, null, (err) => { if (err.id === 'api.post.create_post.root_id.app_error') { this.showPostDeletedModal(); diff --git a/webapp/components/create_post.jsx b/webapp/components/create_post.jsx index d2f64a2669..124728c3d8 100644 --- a/webapp/components/create_post.jsx +++ b/webapp/components/create_post.jsx @@ -77,7 +77,7 @@ export default class CreatePost extends React.Component { PostStore.clearDraftUploads(); const channelId = ChannelStore.getCurrentId(); - const draft = PostStore.getPostDraft(channelId); + const draft = PostStore.getDraft(channelId); const stats = ChannelStore.getCurrentStats(); const members = stats.member_count - 1; @@ -141,7 +141,7 @@ export default class CreatePost extends React.Component { const isReaction = REACTION_PATTERN.exec(post.message); if (post.message.indexOf('/') === 0) { - PostActions.storePostDraft(this.state.channelId, null); + PostStore.storeDraft(this.state.channelId, null); this.setState({message: '', postError: null, fileInfos: [], enableSendButton: false}); const args = {}; @@ -228,7 +228,6 @@ export default class CreatePost extends React.Component { sendMessage(post) { post.channel_id = this.state.channelId; - post.file_ids = this.state.fileInfos.map((info) => info.id); const time = Utils.getTimestamp(); const userId = UserStore.getCurrentId(); @@ -247,7 +246,7 @@ export default class CreatePost extends React.Component { }); } - PostActions.queuePost(post, false, null, + PostActions.createPost(post, this.state.fileInfos, null, (err) => { if (err.id === 'api.post.create_post.root_id.app_error') { // this should never actually happen since you can't reply from this textbox @@ -267,7 +266,7 @@ export default class CreatePost extends React.Component { const action = isReaction[1]; const emojiName = isReaction[2]; - const postId = PostStore.getLatestNonEphemeralPost(this.state.channelId).id; + const postId = PostStore.getLatestPostId(this.state.channelId); if (postId && action === '+') { PostActions.addReaction(this.state.channelId, postId, emojiName); @@ -275,7 +274,7 @@ export default class CreatePost extends React.Component { PostActions.removeReaction(this.state.channelId, postId, emojiName); } - PostActions.storePostDraft(this.state.channelId, null); + PostStore.storeDraft(this.state.channelId, null); } focusTextbox(keepFocus = false) { @@ -305,9 +304,9 @@ export default class CreatePost extends React.Component { enableSendButton }); - const draft = PostStore.getPostDraft(this.state.channelId); + const draft = PostStore.getDraft(this.state.channelId); draft.message = message; - PostActions.storePostDraft(this.state.channelId, draft); + PostStore.storeDraft(this.state.channelId, draft); } handleFileUploadChange() { @@ -315,10 +314,10 @@ export default class CreatePost extends React.Component { } handleUploadStart(clientIds, channelId) { - const draft = PostStore.getPostDraft(channelId); + const draft = PostStore.getDraft(channelId); draft.uploadsInProgress = draft.uploadsInProgress.concat(clientIds); - PostActions.storePostDraft(channelId, draft); + PostStore.storeDraft(channelId, draft); this.setState({uploadsInProgress: draft.uploadsInProgress}); @@ -328,7 +327,7 @@ export default class CreatePost extends React.Component { } handleFileUploadComplete(fileInfos, clientIds, channelId) { - const draft = PostStore.getPostDraft(channelId); + const draft = PostStore.getDraft(channelId); // remove each finished file from uploads for (let i = 0; i < clientIds.length; i++) { @@ -340,7 +339,7 @@ export default class CreatePost extends React.Component { } draft.fileInfos = draft.fileInfos.concat(fileInfos); - PostActions.storePostDraft(channelId, draft); + PostStore.storeDraft(channelId, draft); if (channelId === this.state.channelId) { this.setState({ @@ -359,14 +358,14 @@ export default class CreatePost extends React.Component { } if (clientId !== -1) { - const draft = PostStore.getPostDraft(channelId); + const draft = PostStore.getDraft(channelId); const index = draft.uploadsInProgress.indexOf(clientId); if (index !== -1) { draft.uploadsInProgress.splice(index, 1); } - PostActions.storePostDraft(channelId, draft); + PostStore.storeDraft(channelId, draft); if (channelId === this.state.channelId) { this.setState({uploadsInProgress: draft.uploadsInProgress}); @@ -396,10 +395,10 @@ export default class CreatePost extends React.Component { fileInfos.splice(index, 1); } - const draft = PostStore.getPostDraft(this.state.channelId); + const draft = PostStore.getDraft(this.state.channelId); draft.fileInfos = fileInfos; draft.uploadsInProgress = uploadsInProgress; - PostActions.storePostDraft(this.state.channelId, draft); + PostStore.storeDraft(this.state.channelId, draft); const enableSendButton = this.handleEnableSendButton(this.state.message, fileInfos); this.setState({fileInfos, uploadsInProgress, enableSendButton}); @@ -462,7 +461,7 @@ export default class CreatePost extends React.Component { onChange() { const channelId = ChannelStore.getCurrentId(); if (this.state.channelId !== channelId) { - const draft = PostStore.getPostDraft(channelId); + const draft = PostStore.getDraft(channelId); this.setState({channelId, message: draft.message, submitting: false, serverError: null, postError: null, fileInfos: draft.fileInfos, uploadsInProgress: draft.uploadsInProgress}); } @@ -483,7 +482,7 @@ export default class CreatePost extends React.Component { return this.state.fileInfos.length + this.state.uploadsInProgress.length; } - const draft = PostStore.getPostDraft(channelId); + const draft = PostStore.getDraft(channelId); return draft.fileInfos.length + draft.uploadsInProgress.length; } diff --git a/webapp/components/dot_menu/dot_menu.jsx b/webapp/components/dot_menu/dot_menu.jsx index b5f9fde452..eb6a6c0057 100644 --- a/webapp/components/dot_menu/dot_menu.jsx +++ b/webapp/components/dot_menu/dot_menu.jsx @@ -22,7 +22,30 @@ export default class DotMenu extends Component { commentCount: PropTypes.number, isFlagged: PropTypes.bool, handleCommentClick: PropTypes.func, - handleDropdownOpened: PropTypes.func + handleDropdownOpened: PropTypes.func, + + actions: PropTypes.shape({ + + /* + * Function flag the post + */ + flagPost: PropTypes.func.isRequired, + + /* + * Function to unflag the post + */ + unflagPost: PropTypes.func.isRequired, + + /* + * Function to pin the post + */ + pinPost: PropTypes.func.isRequired, + + /* + * Function to unpin the post + */ + unpinPost: PropTypes.func.isRequired + }).isRequired } static defaultProps = { @@ -90,6 +113,10 @@ export default class DotMenu extends Component { idCount={this.props.idCount} postId={this.props.post.id} isFlagged={this.props.isFlagged} + actions={{ + flagPost: this.props.actions.flagPost, + unflagPost: this.props.actions.unflagPost + }} /> ); } @@ -121,6 +148,10 @@ export default class DotMenu extends Component { idPrefix={idPrefix + 'Pin'} idCount={this.props.idCount} post={this.props.post} + actions={{ + pinPost: this.props.actions.pinPost, + unpinPost: this.props.actions.unpinPost + }} /> ); } diff --git a/webapp/components/dot_menu/dot_menu_flag.jsx b/webapp/components/dot_menu/dot_menu_flag.jsx index 1053632111..11546ee795 100644 --- a/webapp/components/dot_menu/dot_menu_flag.jsx +++ b/webapp/components/dot_menu/dot_menu_flag.jsx @@ -5,7 +5,6 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; import PropTypes from 'prop-types'; -import {flagPost, unflagPost} from 'actions/post_actions.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; @@ -21,12 +20,12 @@ function formatMessage(isFlagged) { export default function DotMenuFlag(props) { function onFlagPost(e) { e.preventDefault(); - flagPost(props.postId); + props.actions.flagPost(props.postId); } function onUnflagPost(e) { e.preventDefault(); - unflagPost(props.postId); + props.actions.unflagPost(props.postId); } const flagFunc = props.isFlagged ? onUnflagPost : onFlagPost; @@ -60,7 +59,21 @@ DotMenuFlag.propTypes = { idCount: PropTypes.number, idPrefix: PropTypes.string.isRequired, postId: PropTypes.string.isRequired, - isFlagged: PropTypes.bool.isRequired + isFlagged: PropTypes.bool.isRequired, + + actions: PropTypes.shape({ + + /* + * Function flag the post + */ + flagPost: PropTypes.func.isRequired, + + /* + * Function to unflag the post + */ + unflagPost: PropTypes.func.isRequired + + }).isRequired }; DotMenuFlag.defaultProps = { diff --git a/webapp/components/dot_menu/dot_menu_item.jsx b/webapp/components/dot_menu/dot_menu_item.jsx index ceda0a1a4c..6411beafb0 100644 --- a/webapp/components/dot_menu/dot_menu_item.jsx +++ b/webapp/components/dot_menu/dot_menu_item.jsx @@ -5,7 +5,6 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; import PropTypes from 'prop-types'; -import {unpinPost, pinPost} from 'actions/post_actions.jsx'; import {showGetPostLinkModal, showDeletePostModal} from 'actions/global_actions.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; @@ -18,12 +17,12 @@ export default function DotMenuItem(props) { function handleUnpinPost(e) { e.preventDefault(); - unpinPost(props.post.channel_id, props.post.id); + props.actions.unpinPost(props.post.id); } function handlePinPost(e) { e.preventDefault(); - pinPost(props.post.channel_id, props.post.id); + props.actions.pinPost(props.post.id); } function handleDeletePost(e) { @@ -98,7 +97,20 @@ DotMenuItem.propTypes = { post: PropTypes.object, handleOnClick: PropTypes.func, type: PropTypes.string, - commentCount: PropTypes.number + commentCount: PropTypes.number, + + actions: PropTypes.shape({ + + /* + * Function to pin the post + */ + pinPost: PropTypes.func, + + /* + * Function to unpin the post + */ + unpinPost: PropTypes.func + }) }; DotMenuItem.defaultProps = { diff --git a/webapp/components/dot_menu/index.js b/webapp/components/dot_menu/index.js new file mode 100644 index 0000000000..eaa1e8d2cc --- /dev/null +++ b/webapp/components/dot_menu/index.js @@ -0,0 +1,26 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {flagPost, unflagPost, pinPost, unpinPost} from 'mattermost-redux/actions/posts'; + +import DotMenu from './dot_menu.jsx'; + +function mapStateToProps(state, ownProps) { + return ownProps; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + flagPost, + unflagPost, + pinPost, + unpinPost + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(DotMenu); + diff --git a/webapp/components/edit_post_modal.jsx b/webapp/components/edit_post_modal.jsx index 3ec7fedccb..683371d231 100644 --- a/webapp/components/edit_post_modal.jsx +++ b/webapp/components/edit_post_modal.jsx @@ -21,6 +21,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; import {FormattedMessage} from 'react-intl'; +import store from 'stores/redux_store.jsx'; +const getState = store.getState; + +import * as Selectors from 'mattermost-redux/selectors/entities/posts'; + export default class EditPostModal extends React.Component { constructor(props) { super(props); @@ -85,7 +90,7 @@ export default class EditPostModal extends React.Component { Reflect.deleteProperty(tempState, 'editText'); BrowserStore.setItem('edit_state_transfer', tempState); $('#edit_post').modal('hide'); - GlobalActions.showDeletePostModal(PostStore.getPost(this.state.channel_id, this.state.post_id), this.state.comments); + GlobalActions.showDeletePostModal(Selectors.getPost(getState(), this.state.post_id), this.state.comments); return; } @@ -93,8 +98,7 @@ export default class EditPostModal extends React.Component { updatedPost, () => { window.scrollTo(0, 0); - }, - Boolean(PostStore.getFocusedPostId()) // If there is focused post we need to update that post's store too. + } ); $('#edit_post').modal('hide'); @@ -120,7 +124,7 @@ export default class EditPostModal extends React.Component { } handleEditPostEvent(options) { - var post = PostStore.getPost(options.channelId, options.postId); + const post = Selectors.getPost(getState(), options.postId); if (global.window.mm_license.IsLicensed === 'true') { if (global.window.mm_config.AllowEditPost === Constants.ALLOW_EDIT_POST_NEVER) { return; diff --git a/webapp/components/file_attachment.jsx b/webapp/components/file_attachment.jsx index 0b8bd1042f..f14718e642 100644 --- a/webapp/components/file_attachment.jsx +++ b/webapp/components/file_attachment.jsx @@ -2,7 +2,7 @@ // See License.txt for license information. import Constants from 'utils/constants.jsx'; -import FileStore from 'stores/file_store.jsx'; +import {getFileUrl, getFileThumbnailUrl} from 'mattermost-redux/utils/file_utils'; import * as Utils from 'utils/utils.jsx'; import {Tooltip, OverlayTrigger} from 'react-bootstrap'; @@ -46,7 +46,7 @@ export default class FileAttachment extends React.Component { const fileType = Utils.getFileType(fileInfo.extension); if (fileType === 'image') { - const thumbnailUrl = FileStore.getFileThumbnailUrl(fileInfo.id); + const thumbnailUrl = getFileThumbnailUrl(fileInfo.id); const img = new Image(); img.onload = () => { @@ -64,7 +64,7 @@ export default class FileAttachment extends React.Component { render() { const fileInfo = this.props.fileInfo; const fileName = fileInfo.name; - const fileUrl = FileStore.getFileUrl(fileInfo.id); + const fileUrl = getFileUrl(fileInfo.id); let thumbnail; if (this.state.loaded) { @@ -83,7 +83,7 @@ export default class FileAttachment extends React.Component {
); diff --git a/webapp/components/file_attachment_list.jsx b/webapp/components/file_attachment_list/file_attachment_list.jsx similarity index 68% rename from webapp/components/file_attachment_list.jsx rename to webapp/components/file_attachment_list/file_attachment_list.jsx index 9beacf94ca..31b1ac424b 100644 --- a/webapp/components/file_attachment_list.jsx +++ b/webapp/components/file_attachment_list/file_attachment_list.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import ViewImageModal from './view_image.jsx'; -import FileAttachment from './file_attachment.jsx'; +import ViewImageModal from 'components/view_image.jsx'; +import FileAttachment from 'components/file_attachment.jsx'; import Constants from 'utils/constants.jsx'; import PropTypes from 'prop-types'; @@ -10,6 +10,37 @@ import PropTypes from 'prop-types'; import React from 'react'; export default class FileAttachmentList extends React.Component { + static propTypes = { + + /* + * The post the files are attached to + */ + post: PropTypes.object.isRequired, + + /* + * The number of files attached to the post + */ + fileCount: PropTypes.number.isRequired, + + /* + * Array of metadata for each file attached to the post + */ + fileInfos: PropTypes.arrayOf(PropTypes.object), + + /* + * Set to render compactly + */ + compactDisplay: PropTypes.bool, + + actions: PropTypes.shape({ + + /* + * Function to get file metadata for a post + */ + getMissingFilesForPost: PropTypes.func.isRequired + }).isRequired + } + constructor(props) { super(props); @@ -18,6 +49,12 @@ export default class FileAttachmentList extends React.Component { this.state = {showPreviewModal: false, startImgIndex: 0}; } + componentDidMount() { + if (this.props.post.file_ids || this.props.post.filenames) { + this.props.actions.getMissingFilesForPost(this.props.post.id); + } + } + handleImageClick(indexClicked) { this.setState({showPreviewModal: true, startImgIndex: indexClicked}); } @@ -65,9 +102,3 @@ export default class FileAttachmentList extends React.Component { ); } } - -FileAttachmentList.propTypes = { - fileCount: PropTypes.number.isRequired, - fileInfos: PropTypes.arrayOf(PropTypes.object), - compactDisplay: PropTypes.bool -}; diff --git a/webapp/components/file_attachment_list/index.js b/webapp/components/file_attachment_list/index.js new file mode 100644 index 0000000000..4081e4220d --- /dev/null +++ b/webapp/components/file_attachment_list/index.js @@ -0,0 +1,39 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {getMissingFilesForPost} from 'mattermost-redux/actions/files'; +import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files'; + +import FileAttachmentList from './file_attachment_list.jsx'; + +function makeMapStateToProps() { + const selectFilesForPost = makeGetFilesForPost(); + return function mapStateToProps(state, ownProps) { + const fileInfos = selectFilesForPost(state, ownProps.post); + + let fileCount = 0; + if (ownProps.post.file_ids) { + fileCount = ownProps.post.file_ids.length; + } else if (this.props.post.filenames) { + fileCount = ownProps.post.filenames.length; + } + + return { + ...ownProps, + fileInfos, + fileCount + }; + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + getMissingFilesForPost + }, dispatch) + }; +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(FileAttachmentList); diff --git a/webapp/components/file_attachment_list_container.jsx b/webapp/components/file_attachment_list_container.jsx deleted file mode 100644 index 4b05e392cd..0000000000 --- a/webapp/components/file_attachment_list_container.jsx +++ /dev/null @@ -1,92 +0,0 @@ -import PropTypes from 'prop-types'; - -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import React from 'react'; - -import * as AsyncClient from 'utils/async_client.jsx'; -import FileStore from 'stores/file_store.jsx'; - -import FileAttachmentList from './file_attachment_list.jsx'; - -export default class FileAttachmentListContainer extends React.Component { - static propTypes = { - post: PropTypes.object.isRequired, - compactDisplay: PropTypes.bool.isRequired - } - - constructor(props) { - super(props); - - this.handleFileChange = this.handleFileChange.bind(this); - - this.state = { - fileInfos: FileStore.getInfosForPost(props.post.id) - }; - } - - componentDidMount() { - FileStore.addChangeListener(this.handleFileChange); - - if (this.props.post.id && !FileStore.hasInfosForPost(this.props.post.id)) { - AsyncClient.getFileInfosForPost(this.props.post.channel_id, this.props.post.id); - } - } - - componentWillReceiveProps(nextProps) { - if (nextProps.post.id !== this.props.post.id) { - this.setState({ - fileInfos: FileStore.getInfosForPost(nextProps.post.id) - }); - - if (nextProps.post.id && !FileStore.hasInfosForPost(nextProps.post.id)) { - AsyncClient.getFileInfosForPost(nextProps.post.channel_id, nextProps.post.id); - } - } - } - - shouldComponentUpdate(nextProps, nextState) { - if (this.props.post.id !== nextProps.post.id) { - return true; - } - - if (this.props.compactDisplay !== nextProps.compactDisplay) { - return true; - } - - // fileInfos are treated as immutable by the FileStore - if (nextState.fileInfos !== this.state.fileInfos) { - return true; - } - - return false; - } - - handleFileChange() { - this.setState({ - fileInfos: FileStore.getInfosForPost(this.props.post.id) - }); - } - - componentWillUnmount() { - FileStore.removeChangeListener(this.handleFileChange); - } - - render() { - let fileCount = 0; - if (this.props.post.file_ids) { - fileCount = this.props.post.file_ids.length; - } else if (this.props.post.filenames) { - fileCount = this.props.post.filenames.length; - } - - return ( - - ); - } -} diff --git a/webapp/components/file_preview.jsx b/webapp/components/file_preview.jsx index 3bf05744f6..65a71c047f 100644 --- a/webapp/components/file_preview.jsx +++ b/webapp/components/file_preview.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import FileStore from 'stores/file_store.jsx'; import ReactDOM from 'react-dom'; import * as Utils from 'utils/utils.jsx'; +import {getFileUrl} from 'mattermost-redux/utils/file_utils'; import PropTypes from 'prop-types'; @@ -39,7 +39,7 @@ export default class FilePreview extends React.Component { previewImage = ( ); } else { diff --git a/webapp/components/needs_team/needs_team.jsx b/webapp/components/needs_team/needs_team.jsx index 4f5188a47e..6fd2d32088 100644 --- a/webapp/components/needs_team/needs_team.jsx +++ b/webapp/components/needs_team/needs_team.jsx @@ -13,7 +13,6 @@ 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 {startPeriodicSync, stopPeriodicSync} from 'actions/websocket_actions.jsx'; import {loadProfilesForSidebar} from 'actions/user_actions.jsx'; @@ -23,13 +22,16 @@ const TutorialSteps = Constants.TutorialSteps; const Preferences = Constants.Preferences; import AnnouncementBar from 'components/announcement_bar'; -import SidebarRight from 'components/sidebar_right.jsx'; +import SidebarRight from 'components/sidebar_right'; import SidebarRightMenu from 'components/sidebar_right_menu.jsx'; import Navbar from 'components/navbar.jsx'; import WebrtcSidebar from 'components/webrtc/components/webrtc_sidebar.jsx'; import WebrtcNotification from 'components/webrtc/components/webrtc_notification.jsx'; +import store from 'stores/redux_store.jsx'; +import {getPost} from 'mattermost-redux/selectors/entities/posts'; + // Modals import GetPostLinkModal from 'components/get_post_link_modal.jsx'; import GetPublicLinkModal from 'components/get_public_link_modal.jsx'; @@ -111,9 +113,6 @@ export default class NeedsTeam extends React.Component { TeamStore.addChangeListener(this.onTeamChanged); PreferenceStore.addChangeListener(this.onPreferencesChanged); - // Emit view action - GlobalActions.viewLoggedIn(); - startPeriodicStatusUpdates(); startPeriodicSync(); @@ -201,7 +200,7 @@ export default class NeedsTeam extends React.Component { 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); + const post = getPost(store.getState(), postId); // the post take some time before being available on page load if (post != null) { diff --git a/webapp/components/permalink_view.jsx b/webapp/components/permalink_view.jsx index ebcd839162..237ad8f441 100644 --- a/webapp/components/permalink_view.jsx +++ b/webapp/components/permalink_view.jsx @@ -6,7 +6,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import ChannelHeader from 'components/channel_header.jsx'; -import PostFocusViewController from 'components/post_view/post_focus_view_controller.jsx'; +import PostView from 'components/post_view'; import ChannelStore from 'stores/channel_store.jsx'; import TeamStore from 'stores/team_store.jsx'; @@ -14,7 +14,11 @@ import TeamStore from 'stores/team_store.jsx'; import {Link} from 'react-router/es6'; import {FormattedMessage} from 'react-intl'; -export default class PermalinkView extends React.Component { +export default class PermalinkView extends React.PureComponent { + static propTypes = { + params: PropTypes.object.isRequired + } + constructor(props) { super(props); @@ -24,6 +28,7 @@ export default class PermalinkView extends React.Component { this.state = this.getStateFromStores(props); } + getStateFromStores(props) { const postId = props.params.postid; const channel = ChannelStore.getCurrent(); @@ -38,27 +43,33 @@ export default class PermalinkView extends React.Component { postId }; } + isStateValid() { return this.state.channelId !== '' && this.state.teamName; } + updateState() { this.setState(this.getStateFromStores(this.props)); } + componentDidMount() { ChannelStore.addChangeListener(this.updateState); TeamStore.addChangeListener(this.updateState); $('body').addClass('app__body'); } + componentWillUnmount() { ChannelStore.removeChangeListener(this.updateState); TeamStore.removeChangeListener(this.updateState); $('body').removeClass('app__body'); } + componentWillReceiveProps(nextProps) { this.setState(this.getStateFromStores(nextProps)); } + render() { if (!this.isStateValid()) { return null; @@ -71,7 +82,10 @@ export default class PermalinkView extends React.Component { - +
diff --git a/webapp/components/post_view/components/post_attachment.jsx b/webapp/components/post_view/post_attachment.jsx similarity index 91% rename from webapp/components/post_view/components/post_attachment.jsx rename to webapp/components/post_view/post_attachment.jsx index e873ef9c72..b7bd1ade9b 100644 --- a/webapp/components/post_view/components/post_attachment.jsx +++ b/webapp/components/post_view/post_attachment.jsx @@ -1,27 +1,22 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import $ from 'jquery'; import * as TextFormatting from 'utils/text_formatting.jsx'; +import {localizeMessage} from 'utils/utils.jsx'; -import {intlShape, injectIntl, defineMessages} from 'react-intl'; - -const holders = defineMessages({ - collapse: { - id: 'post_attachment.collapse', - defaultMessage: 'Show less...' - }, - more: { - id: 'post_attachment.more', - defaultMessage: 'Show more...' - } -}); - +import $ from 'jquery'; +import React from 'react'; import PropTypes from 'prop-types'; -import React from 'react'; +export default class PostAttachment extends React.PureComponent { + static propTypes = { + + /** + * The attachment to render + */ + attachment: PropTypes.object.isRequired + } -class PostAttachment extends React.Component { constructor(props) { super(props); @@ -46,7 +41,7 @@ class PostAttachment extends React.Component { getInitState() { const shouldCollapse = this.shouldCollapse(); const text = TextFormatting.formatText(this.props.attachment.text || ''); - const uncollapsedText = text + (shouldCollapse ? `` : ''); + const uncollapsedText = text + (shouldCollapse ? `` : ''); const collapsedText = shouldCollapse ? this.getCollapsedText() : text; return { @@ -61,10 +56,10 @@ class PostAttachment extends React.Component { toggleCollapseState(e) { e.preventDefault(); - const state = this.state; - state.text = state.collapsed ? state.uncollapsedText : state.collapsedText; - state.collapsed = !state.collapsed; - this.setState(state); + this.setState({ + text: this.state.collapsed ? this.state.uncollapsedText : this.state.collapsedText, + collapsed: !this.state.collapsed + }); } shouldCollapse() { @@ -80,7 +75,7 @@ class PostAttachment extends React.Component { text = text.substr(0, 700); } - return TextFormatting.formatText(text) + ``; + return TextFormatting.formatText(text) + ``; } getFieldsTable() { @@ -314,10 +309,3 @@ class PostAttachment extends React.Component { ); } } - -PostAttachment.propTypes = { - intl: intlShape.isRequired, - attachment: PropTypes.object.isRequired -}; - -export default injectIntl(PostAttachment); diff --git a/webapp/components/post_view/post_attachment_list.jsx b/webapp/components/post_view/post_attachment_list.jsx new file mode 100644 index 0000000000..cfd2f81f8d --- /dev/null +++ b/webapp/components/post_view/post_attachment_list.jsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PostAttachment from './post_attachment.jsx'; + +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class PostAttachmentList extends React.PureComponent { + static propTypes = { + + /** + * Array of attachments to render + */ + attachments: PropTypes.array.isRequired + } + + render() { + const content = []; + this.props.attachments.forEach((attachment, i) => { + content.push( + + ); + }); + + return ( +
+ {content} +
+ ); + } +} diff --git a/webapp/components/post_view/post_attachment_opengraph/index.js b/webapp/components/post_view/post_attachment_opengraph/index.js new file mode 100644 index 0000000000..e0bec8f369 --- /dev/null +++ b/webapp/components/post_view/post_attachment_opengraph/index.js @@ -0,0 +1,26 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {getOpenGraphMetadata} from 'mattermost-redux/actions/posts'; +import {getOpenGraphMetadataForUrl} from 'mattermost-redux/selectors/entities/posts'; + +import PostAttachmentOpenGraph from './post_attachment_opengraph.jsx'; + +function mapStateToProps(state, ownProps) { + return { + ...ownProps, + openGraphData: getOpenGraphMetadataForUrl(state, ownProps.link) + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + getOpenGraphMetadata + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(PostAttachmentOpenGraph); diff --git a/webapp/components/post_view/components/post_attachment_opengraph.jsx b/webapp/components/post_view/post_attachment_opengraph/post_attachment_opengraph.jsx similarity index 78% rename from webapp/components/post_view/components/post_attachment_opengraph.jsx rename to webapp/components/post_view/post_attachment_opengraph/post_attachment_opengraph.jsx index 129111800b..dbf8f60494 100644 --- a/webapp/components/post_view/components/post_attachment_opengraph.jsx +++ b/webapp/components/post_view/post_attachment_opengraph/post_attachment_opengraph.jsx @@ -1,16 +1,38 @@ -import PropTypes from 'prop-types'; - // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. import React from 'react'; +import PropTypes from 'prop-types'; -import OpenGraphStore from 'stores/opengraph_store.jsx'; import * as Utils from 'utils/utils.jsx'; import * as CommonUtils from 'utils/commons.jsx'; -import {requestOpenGraphMetadata} from 'actions/global_actions.jsx'; -export default class PostAttachmentOpenGraph extends React.Component { +export default class PostAttachmentOpenGraph extends React.PureComponent { + static propTypes = { + + /** + * The link to display the open graph data for + */ + link: PropTypes.string.isRequired, + + /** + * The open graph data to render + */ + openGraphData: PropTypes.object.isRequired, + + /** + * Set to collapse the preview + */ + previewCollapsed: PropTypes.string, + actions: PropTypes.shape({ + + /** + * The function to get open graph data for a link + */ + getOpenGraphMetadata: PropTypes.func.isRequired + }).isRequired + } + constructor(props) { super(props); this.largeImageMinWidth = 150; @@ -29,7 +51,6 @@ export default class PostAttachmentOpenGraph extends React.Component { this.smallImageElement = null; this.fetchData = this.fetchData.bind(this); - this.onOpenGraphMetadataChange = this.onOpenGraphMetadataChange.bind(this); this.toggleImageVisibility = this.toggleImageVisibility.bind(this); this.onImageLoad = this.onImageLoad.bind(this); this.onImageError = this.onImageError.bind(this); @@ -44,7 +65,6 @@ export default class PostAttachmentOpenGraph extends React.Component { componentWillMount() { this.setState({ - data: {}, imageLoaded: this.IMAGE_LOADED.LOADING, imageVisible: this.props.previewCollapsed.startsWith('false'), hasLargeImage: false @@ -53,61 +73,23 @@ export default class PostAttachmentOpenGraph extends React.Component { } componentWillReceiveProps(nextProps) { - if (!Utils.areObjectsEqual(nextProps.link, this.props.link)) { + if (nextProps.link !== this.props.link) { this.fetchData(nextProps.link); } } - shouldComponentUpdate(nextProps, nextState) { - if (nextState.imageVisible !== this.state.imageVisible) { - return true; - } - if (nextState.hasLargeImage !== this.state.hasLargeImage) { - return true; - } - if (nextState.imageLoaded !== this.state.imageLoaded) { - return true; - } - if (!Utils.areObjectsEqual(nextState.data, this.state.data)) { - return true; - } - return false; - } - - componentDidMount() { - OpenGraphStore.addUrlDataChangeListener(this.onOpenGraphMetadataChange); - } - - componentDidUpdate() { - if (this.props.childComponentDidUpdateFunction) { - this.props.childComponentDidUpdateFunction(); - } - } - - componentWillUnmount() { - OpenGraphStore.removeUrlDataChangeListener(this.onOpenGraphMetadataChange); - } - - onOpenGraphMetadataChange(url) { - if (url === this.props.link) { - this.fetchData(url); - } - } - fetchData(url) { - const data = OpenGraphStore.getOgInfo(url); - this.setState({data, imageLoaded: this.IMAGE_LOADED.LOADING}); - if (Utils.isEmptyObject(data)) { - requestOpenGraphMetadata(url); + if (!this.props.openGraphData) { + this.props.actions.getOpenGraphMetadata(url); } } getBestImageUrl() { - if (Utils.isEmptyObject(this.state.data.images)) { + if (Utils.isEmptyObject(this.props.openGraphData.images)) { return null; } - const bestImage = CommonUtils.getNearestPoint(this.imageDimentions, this.state.data.images, 'width', 'height'); + const bestImage = CommonUtils.getNearestPoint(this.imageDimentions, this.props.openGraphData.images, 'width', 'height'); return bestImage.secure_url || bestImage.url; } @@ -217,11 +199,11 @@ export default class PostAttachmentOpenGraph extends React.Component { } render() { - if (Utils.isEmptyObject(this.state.data) || Utils.isEmptyObject(this.state.data.description)) { + if (!this.props.openGraphData || Utils.isEmptyObject(this.props.openGraphData.description)) { return null; } - const data = this.state.data; + const data = this.props.openGraphData; const imageUrl = this.getBestImageUrl(); if (imageUrl) { @@ -275,13 +257,3 @@ export default class PostAttachmentOpenGraph extends React.Component { ); } } - -PostAttachmentOpenGraph.defaultProps = { - previewCollapsed: 'false' -}; - -PostAttachmentOpenGraph.propTypes = { - link: PropTypes.string.isRequired, - childComponentDidUpdateFunction: PropTypes.func, - previewCollapsed: PropTypes.string -}; diff --git a/webapp/components/post_view/post_body/index.js b/webapp/components/post_view/post_body/index.js new file mode 100644 index 0000000000..37cf114b07 --- /dev/null +++ b/webapp/components/post_view/post_body/index.js @@ -0,0 +1,30 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getUser} from 'mattermost-redux/selectors/entities/users'; +import {get} from 'mattermost-redux/selectors/entities/preferences'; +import {getPost} from 'mattermost-redux/selectors/entities/posts'; + +import {Preferences} from 'utils/constants.jsx'; + +import PostBody from './post_body.jsx'; + +function mapStateToProps(state, ownProps) { + let parentPost; + let parentPostUser; + if (ownProps.post.root_id) { + parentPost = getPost(state, ownProps.post.root_id); + parentPostUser = getUser(state, parentPost.user_id); + } + + return { + ...ownProps, + parentPost, + parentPostUser, + previewCollapsed: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.COLLAPSE_DISPLAY, 'false') + }; +} + +export default connect(mapStateToProps)(PostBody); diff --git a/webapp/components/post_view/components/post_body.jsx b/webapp/components/post_view/post_body/post_body.jsx similarity index 59% rename from webapp/components/post_view/components/post_body.jsx rename to webapp/components/post_view/post_body/post_body.jsx index 0f481ec026..a60d25760f 100644 --- a/webapp/components/post_view/components/post_body.jsx +++ b/webapp/components/post_view/post_body/post_body.jsx @@ -1,67 +1,63 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import UserStore from 'stores/user_store.jsx'; import * as Utils from 'utils/utils.jsx'; -import * as GlobalActions from 'actions/global_actions.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; -import Constants from 'utils/constants.jsx'; -import CommentedOnFilesMessageContainer from './commented_on_files_message_container.jsx'; -import FileAttachmentListContainer from 'components/file_attachment_list_container.jsx'; -import PostBodyAdditionalContent from './post_body_additional_content.jsx'; -import PostMessageContainer from './post_message_container.jsx'; -import PendingPostOptions from './pending_post_options.jsx'; -import ReactionListContainer from './reaction_list_container.jsx'; +import {Posts} from 'mattermost-redux/constants'; -import {FormattedMessage} from 'react-intl'; - -import loadingGif from 'images/load.gif'; - -import PropTypes from 'prop-types'; +import CommentedOnFilesMessage from 'components/post_view/commented_on_files_message'; +import FileAttachmentListContainer from 'components/file_attachment_list'; +import PostBodyAdditionalContent from 'components/post_view/post_body_additional_content.jsx'; +import PostMessageContainer from 'components/post_view/post_message_view'; +import ReactionListContainer from 'components/post_view/reaction_list'; +import FailedPostOptions from 'components/post_view/failed_post_options'; import React from 'react'; +import PropTypes from 'prop-types'; +import {FormattedMessage} from 'react-intl'; -export default class PostBody extends React.Component { - constructor(props) { - super(props); +export default class PostBody extends React.PureComponent { + static propTypes = { - this.removePost = this.removePost.bind(this); - } + /** + * The post to render the body of + */ + post: PropTypes.object.isRequired, - shouldComponentUpdate(nextProps) { - if (nextProps.isCommentMention !== this.props.isCommentMention) { - return true; - } + /** + * The parent post of the thread this post is in + */ + parentPost: PropTypes.object, - if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) { - return true; - } + /** + * The poster of the parent post, if exists + */ + parentPostUser: PropTypes.object, - if (!Utils.areObjectsEqual(nextProps.parentPost, this.props.parentPost)) { - return true; - } + /** + * The function called when the comment icon is clicked + */ + handleCommentClick: PropTypes.func.isRequired, - if (nextProps.compactDisplay !== this.props.compactDisplay) { - return true; - } + /** + * Set to render post body compactly + */ + compactDisplay: PropTypes.bool, - if (nextProps.previewCollapsed !== this.props.previewCollapsed) { - return true; - } + /** + * Set to highlight comment as a mention + */ + isCommentMention: PropTypes.bool, - if (nextProps.handleCommentClick.toString() !== this.props.handleCommentClick.toString()) { - return true; - } + /** + * Set to collapse image and video previews + */ + previewCollapsed: PropTypes.string, - if (nextProps.lastPostCount !== this.props.lastPostCount) { - return true; - } - - return false; - } - - removePost() { - GlobalActions.emitRemovePost(this.props.post); + /** + * Post identifiers for selenium tests + */ + lastPostCount: PropTypes.number } render() { @@ -71,8 +67,8 @@ export default class PostBody extends React.Component { let comment = ''; let postClass = ''; - if (parentPost) { - const profile = UserStore.getProfile(parentPost.user_id); + if (parentPost && this.props.parentPostUser) { + const profile = this.props.parentPostUser; let apostrophe = ''; let name = '...'; @@ -105,8 +101,7 @@ export default class PostBody extends React.Component { message = Utils.replaceHtmlEntities(parentPost.message); } else if (parentPost.file_ids && parentPost.file_ids.length > 0) { message = ( - ); @@ -134,18 +129,10 @@ export default class PostBody extends React.Component { ); } - let loading; - if (post.state === Constants.POST_FAILED) { + let failedOptions; + if (this.props.post.failed) { postClass += ' post--fail'; - loading = ; - } else if (post.state === Constants.POST_LOADING) { - postClass += ' post-waiting'; - loading = ( - - ); + failedOptions = ; } if (PostUtils.isEdited(this.props.post)) { @@ -153,7 +140,7 @@ export default class PostBody extends React.Component { } let fileAttachmentHolder = null; - if (((post.file_ids && post.file_ids.length > 0) || (post.filenames && post.filenames.length > 0)) && this.props.post.state !== Constants.POST_DELETED) { + if (((post.file_ids && post.file_ids.length > 0) || (post.filenames && post.filenames.length > 0)) && this.props.post.state !== Posts.POST_DELETED) { fileAttachmentHolder = ( - {loading} + {failedOptions} ); } @@ -208,16 +193,3 @@ export default class PostBody extends React.Component { ); } } - -PostBody.propTypes = { - post: PropTypes.object.isRequired, - currentUser: PropTypes.object.isRequired, - parentPost: PropTypes.object, - retryPost: PropTypes.func, - lastPostCount: PropTypes.number, - handleCommentClick: PropTypes.func.isRequired, - compactDisplay: PropTypes.bool, - previewCollapsed: PropTypes.string, - isCommentMention: PropTypes.bool, - childComponentDidUpdateFunction: PropTypes.func -}; diff --git a/webapp/components/post_view/components/post_body_additional_content.jsx b/webapp/components/post_view/post_body_additional_content.jsx similarity index 82% rename from webapp/components/post_view/components/post_body_additional_content.jsx rename to webapp/components/post_view/post_body_additional_content.jsx index 1806811000..bf83809127 100644 --- a/webapp/components/post_view/components/post_body_additional_content.jsx +++ b/webapp/components/post_view/post_body_additional_content.jsx @@ -2,18 +2,39 @@ // See License.txt for license information. import PostAttachmentList from './post_attachment_list.jsx'; -import PostAttachmentOpenGraph from './post_attachment_opengraph.jsx'; +import PostAttachmentOpenGraph from './post_attachment_opengraph'; import PostImage from './post_image.jsx'; -import YoutubeVideo from 'components/youtube_video.jsx'; +import YoutubeVideo from 'components/youtube_video'; import Constants from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; +import React from 'react'; import PropTypes from 'prop-types'; -import React from 'react'; +export default class PostBodyAdditionalContent extends React.PureComponent { + static propTypes = { + + /** + * The post to render the content of + */ + post: PropTypes.object.isRequired, + + /** + * The post's message + */ + message: PropTypes.element.isRequired, + + /** + * Set to collapse image and video previews + */ + previewCollapsed: PropTypes.string + } + + static defaultProps = { + previewCollapsed: '' + } -export default class PostBodyAdditionalContent extends React.Component { constructor(props) { super(props); @@ -40,25 +61,6 @@ export default class PostBodyAdditionalContent extends React.Component { }); } - shouldComponentUpdate(nextProps, nextState) { - if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) { - return true; - } - if (!Utils.areObjectsEqual(nextProps.message, this.props.message)) { - return true; - } - if (nextState.embedVisible !== this.state.embedVisible) { - return true; - } - if (nextState.linkLoadError !== this.state.linkLoadError) { - return true; - } - if (nextState.linkLoaded !== this.state.linkLoaded) { - return true; - } - return false; - } - toggleEmbedVisibility() { this.setState({embedVisible: !this.state.embedVisible}); } @@ -138,7 +140,6 @@ export default class PostBodyAdditionalContent extends React.Component { link={link} onLinkLoadError={this.handleLinkLoadError} onLinkLoaded={this.handleLinkLoaded} - childComponentDidUpdateFunction={this.props.childComponentDidUpdateFunction} /> ); } @@ -156,7 +157,6 @@ export default class PostBodyAdditionalContent extends React.Component { return ( ); @@ -227,14 +227,3 @@ export default class PostBodyAdditionalContent extends React.Component { return this.props.message; } } - -PostBodyAdditionalContent.defaultProps = { - previewCollapsed: 'false' -}; -PostBodyAdditionalContent.propTypes = { - post: PropTypes.object.isRequired, - message: PropTypes.element.isRequired, - compactDisplay: PropTypes.bool, - previewCollapsed: PropTypes.string, - childComponentDidUpdateFunction: PropTypes.func -}; diff --git a/webapp/components/common/post_flag_icon.jsx b/webapp/components/post_view/post_flag_icon.jsx similarity index 99% rename from webapp/components/common/post_flag_icon.jsx rename to webapp/components/post_view/post_flag_icon.jsx index 533b38bff9..295bdd1162 100644 --- a/webapp/components/common/post_flag_icon.jsx +++ b/webapp/components/post_view/post_flag_icon.jsx @@ -1,9 +1,8 @@ -import PropTypes from 'prop-types'; - // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. import React from 'react'; +import PropTypes from 'prop-types'; import {FormattedMessage} from 'react-intl'; import {Tooltip, OverlayTrigger} from 'react-bootstrap'; diff --git a/webapp/components/post_view/post_focus_view_controller.jsx b/webapp/components/post_view/post_focus_view_controller.jsx deleted file mode 100644 index dadc6b80ef..0000000000 --- a/webapp/components/post_view/post_focus_view_controller.jsx +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import PostList from './components/post_list.jsx'; -import LoadingScreen from 'components/loading_screen.jsx'; - -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 WebrtcStore from 'stores/webrtc_store.jsx'; - -import * as Utils from 'utils/utils.jsx'; - -import Constants from 'utils/constants.jsx'; -const Preferences = Constants.Preferences; -const ScrollTypes = Constants.ScrollTypes; - -import React from 'react'; - -export default class PostFocusView extends React.Component { - constructor(props) { - super(props); - - this.onChannelChange = this.onChannelChange.bind(this); - this.onPostsChange = this.onPostsChange.bind(this); - this.onUserChange = this.onUserChange.bind(this); - this.onEmojiChange = this.onEmojiChange.bind(this); - this.onStatusChange = this.onStatusChange.bind(this); - this.onPreferenceChange = this.onPreferenceChange.bind(this); - this.onPostListScroll = this.onPostListScroll.bind(this); - this.onBusy = this.onBusy.bind(this); - - const focusedPostId = PostStore.getFocusedPostId(); - - const channel = ChannelStore.getCurrent(); - const profiles = UserStore.getProfiles(); - - const joinLeaveEnabled = PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'join_leave', true); - - let statuses; - if (channel) { - statuses = Object.assign({}, UserStore.getStatuses()); - } - - this.state = { - postList: PostStore.filterPosts(focusedPostId, joinLeaveEnabled), - currentUser: UserStore.getCurrentUser(), - isBusy: WebrtcStore.isBusy(), - profiles, - statuses, - scrollType: ScrollTypes.POST, - currentChannel: ChannelStore.getCurrentId().slice(), - scrollPostId: focusedPostId, - atTop: PostStore.getVisibilityAtTop(focusedPostId), - atBottom: PostStore.getVisibilityAtBottom(focusedPostId), - emojis: EmojiStore.getEmojis(), - displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'), - 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'), - useMilitaryTime: PreferenceStore.getBool(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false), - flaggedPosts: PreferenceStore.getCategory(Constants.Preferences.CATEGORY_FLAGGED_POST) - }; - } - - componentDidMount() { - ChannelStore.addChangeListener(this.onChannelChange); - PostStore.addChangeListener(this.onPostsChange); - UserStore.addChangeListener(this.onUserChange); - UserStore.addStatusesChangeListener(this.onStatusChange); - EmojiStore.addChangeListener(this.onEmojiChange); - PreferenceStore.addChangeListener(this.onPreferenceChange); - WebrtcStore.addBusyListener(this.onBusy); - } - - componentWillUnmount() { - ChannelStore.removeChangeListener(this.onChannelChange); - PostStore.removeChangeListener(this.onPostsChange); - UserStore.removeChangeListener(this.onUserChange); - UserStore.removeStatusesChangeListener(this.onStatusChange); - EmojiStore.removeChangeListener(this.onEmojiChange); - PreferenceStore.removeChangeListener(this.onPreferenceChange); - WebrtcStore.removeBusyListener(this.onBusy); - } - - onChannelChange() { - const currentChannel = ChannelStore.getCurrentId(); - if (this.state.currentChannel !== currentChannel) { - this.setState({ - currentChannel: currentChannel.slice(), - scrollType: ScrollTypes.POST - }); - } - } - - onPostsChange() { - const focusedPostId = PostStore.getFocusedPostId(); - if (focusedPostId == null) { - return; - } - - const joinLeaveEnabled = PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'join_leave', true); - - this.setState({ - scrollPostId: focusedPostId, - postList: PostStore.filterPosts(focusedPostId, joinLeaveEnabled), - atTop: PostStore.getVisibilityAtTop(focusedPostId), - atBottom: PostStore.getVisibilityAtBottom(focusedPostId) - }); - } - - onUserChange() { - this.setState({currentUser: UserStore.getCurrentUser(), profiles: JSON.parse(JSON.stringify(UserStore.getProfiles()))}); - } - - onStatusChange() { - const channel = ChannelStore.getCurrent(); - let statuses; - if (channel) { - statuses = Object.assign({}, UserStore.getStatuses()); - } - - this.setState({statuses}); - } - - onEmojiChange() { - this.setState({ - emojis: EmojiStore.getEmojis() - }); - } - - onPreferenceChange(category) { - // Bit of a hack to force render when this setting is updated - // regardless of change - let previewSuffix = ''; - if (category === Preferences.CATEGORY_DISPLAY_SETTINGS) { - previewSuffix = '_' + Utils.generateId(); - } - - const focusedPostId = PostStore.getFocusedPostId(); - if (focusedPostId == null) { - return; - } - - const joinLeaveEnabled = PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'join_leave', true); - - this.setState({ - postList: PostStore.filterPosts(focusedPostId, joinLeaveEnabled), - displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'), - 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), - flaggedPosts: PreferenceStore.getCategory(Constants.Preferences.CATEGORY_FLAGGED_POST) - }); - } - - onPostListScroll() { - this.setState({scrollType: ScrollTypes.FREE}); - } - - onBusy(isBusy) { - this.setState({isBusy}); - } - - render() { - const postsToHighlight = {}; - postsToHighlight[this.state.scrollPostId] = true; - - let content; - if (this.state.postList == null) { - content = ( - - ); - } else { - content = ( - - ); - } - - return ( -
- {content} -
- ); - } -} diff --git a/webapp/components/post_view/post_header/index.js b/webapp/components/post_view/post_header/index.js new file mode 100644 index 0000000000..d7aaef1d52 --- /dev/null +++ b/webapp/components/post_view/post_header/index.js @@ -0,0 +1,18 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {get} from 'mattermost-redux/selectors/entities/preferences'; +import {Preferences} from 'mattermost-redux/constants'; + +import PostHeader from './post_header.jsx'; + +function mapStateToProps(state, ownProps) { + return { + ...ownProps, + displayNameType: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false') + }; +} + +export default connect(mapStateToProps)(PostHeader); diff --git a/webapp/components/post_view/components/post_header.jsx b/webapp/components/post_view/post_header/post_header.jsx similarity index 63% rename from webapp/components/post_view/components/post_header.jsx rename to webapp/components/post_view/post_header/post_header.jsx index eab2d4629c..562bd2b820 100644 --- a/webapp/components/post_view/components/post_header.jsx +++ b/webapp/components/post_view/post_header/post_header.jsx @@ -2,18 +2,80 @@ // See License.txt for license information. import UserProfile from 'components/user_profile.jsx'; -import PostInfo from './post_info.jsx'; +import PostInfo from 'components/post_view/post_info'; import {FormattedMessage} from 'react-intl'; import * as PostUtils from 'utils/post_utils.jsx'; import Constants from 'utils/constants.jsx'; +import React from 'react'; import PropTypes from 'prop-types'; -import React from 'react'; +export default class PostHeader extends React.PureComponent { + static propTypes = { + + /* + * The post to render the header for + */ + post: PropTypes.object.isRequired, + + /* + * The user who created the post + */ + user: PropTypes.object, + + /* + * Function called when the comment icon is clicked + */ + handleCommentClick: PropTypes.func.isRequired, + + /* + * Function called when the post options dropdown is opened + */ + handleDropdownOpened: PropTypes.func.isRequired, + + /* + * Set to render compactly + */ + compactDisplay: PropTypes.bool, + + /* + * Set to render the post as if it was part of the previous post + */ + consecutivePostByUser: PropTypes.bool, + + /* + * The method for displaying the post creator's name + */ + displayNameType: PropTypes.string, + + /* + * The status of the user who created the post + */ + status: PropTypes.string, + + /* + * Set if the post creator is currenlty in a WebRTC call + */ + isBusy: PropTypes.bool, + + /* + * The number of replies in the same thread as this post + */ + replyCount: PropTypes.number, + + /* + * Post identifiers for selenium tests + */ + lastPostCount: PropTypes.number, + + /** + * Function to get the post list HTML element + */ + getPostList: PropTypes.func.isRequired + } -export default class PostHeader extends React.Component { constructor(props) { super(props); this.state = {}; @@ -81,16 +143,12 @@ export default class PostHeader extends React.Component {
@@ -98,28 +156,3 @@ export default class PostHeader extends React.Component { ); } } - -PostHeader.defaultProps = { - post: null, - commentCount: 0, - isLastComment: false, - sameUser: false -}; -PostHeader.propTypes = { - post: PropTypes.object.isRequired, - user: PropTypes.object, - currentUser: PropTypes.object.isRequired, - lastPostCount: PropTypes.number, - commentCount: PropTypes.number.isRequired, - isLastComment: PropTypes.bool.isRequired, - handleCommentClick: PropTypes.func.isRequired, - handleDropdownOpened: PropTypes.func.isRequired, - sameUser: PropTypes.bool.isRequired, - compactDisplay: PropTypes.bool, - displayNameType: PropTypes.string, - useMilitaryTime: PropTypes.bool.isRequired, - isFlagged: PropTypes.bool.isRequired, - status: PropTypes.string, - isBusy: PropTypes.bool, - getPostList: PropTypes.func.isRequired -}; diff --git a/webapp/components/post_view/components/post_image.jsx b/webapp/components/post_view/post_image.jsx similarity index 81% rename from webapp/components/post_view/components/post_image.jsx rename to webapp/components/post_view/post_image.jsx index 1268c9df27..5feb01db44 100644 --- a/webapp/components/post_view/components/post_image.jsx +++ b/webapp/components/post_view/post_image.jsx @@ -1,11 +1,28 @@ -import PropTypes from 'prop-types'; - // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. import React from 'react'; +import PropTypes from 'prop-types'; + +export default class PostImageEmbed extends React.PureComponent { + static propTypes = { + + /** + * The link to load the image from + */ + link: PropTypes.string.isRequired, + + /** + * Function to call when image is loaded + */ + onLinkLoaded: PropTypes.func, + + /** + * The function to call if image load fails + */ + onLinkLoadError: PropTypes.func + } -export default class PostImageEmbed extends React.Component { constructor(props) { super(props); @@ -32,9 +49,6 @@ export default class PostImageEmbed extends React.Component { } componentDidUpdate(prevProps) { - if (this.state.loaded && this.props.childComponentDidUpdateFunction) { - this.props.childComponentDidUpdateFunction(); - } if (!this.state.loaded && prevProps.link !== this.props.link) { this.loadImg(this.props.link); } @@ -84,10 +98,3 @@ export default class PostImageEmbed extends React.Component { ); } } - -PostImageEmbed.propTypes = { - link: PropTypes.string.isRequired, - onLinkLoadError: PropTypes.func, - onLinkLoaded: PropTypes.func, - childComponentDidUpdateFunction: PropTypes.func -}; diff --git a/webapp/components/post_view/post_info/index.js b/webapp/components/post_view/post_info/index.js new file mode 100644 index 0000000000..749ec5aba4 --- /dev/null +++ b/webapp/components/post_view/post_info/index.js @@ -0,0 +1,31 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {removePost, addReaction} from 'mattermost-redux/actions/posts'; + +import {getBool} from 'mattermost-redux/selectors/entities/preferences'; + +import {Preferences} from 'utils/constants.jsx'; + +import PostInfo from './post_info.jsx'; + +function mapStateToProps(state, ownProps) { + return { + ...ownProps, + useMilitaryTime: getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false), + isFlagged: getBool(state, Preferences.CATEGORY_FLAGGED_POST, ownProps.post.id) + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + removePost, + addReaction + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(PostInfo); diff --git a/webapp/components/post_view/components/post_info.jsx b/webapp/components/post_view/post_info/post_info.jsx similarity index 70% rename from webapp/components/post_view/components/post_info.jsx rename to webapp/components/post_view/post_info/post_info.jsx index 5025657528..f037bf03bf 100644 --- a/webapp/components/post_view/components/post_info.jsx +++ b/webapp/components/post_view/post_info/post_info.jsx @@ -1,26 +1,77 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import PostTime from './post_time.jsx'; -import PostFlagIcon from 'components/common/post_flag_icon.jsx'; -import DotMenu from 'components/dot_menu/dot_menu.jsx'; - -import * as GlobalActions from 'actions/global_actions.jsx'; -import * as PostActions from 'actions/post_actions.jsx'; +import PostTime from 'components/post_view/post_time.jsx'; +import PostFlagIcon from 'components/post_view/post_flag_icon.jsx'; import CommentIcon from 'components/common/comment_icon.jsx'; +import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay.jsx'; +import DotMenu from 'components/dot_menu'; import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; import Constants from 'utils/constants.jsx'; -import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay.jsx'; -import ChannelStore from 'stores/channel_store.jsx'; - -import PropTypes from 'prop-types'; import React from 'react'; +import PropTypes from 'prop-types'; import {FormattedMessage} from 'react-intl'; -export default class PostInfo extends React.Component { +export default class PostInfo extends React.PureComponent { + static propTypes = { + + /* + * The post to render the info for + */ + post: PropTypes.object.isRequired, + + /* + * Function called when the comment icon is clicked + */ + handleCommentClick: PropTypes.func.isRequired, + + /* + * Funciton called when the post options dropdown is opened + */ + handleDropdownOpened: PropTypes.func.isRequired, + + /* + * Set to display in 24 hour format + */ + useMilitaryTime: PropTypes.bool.isRequired, + + /* + * Set to mark the post as flagged + */ + isFlagged: PropTypes.bool, + + /* + * The number of replies in the same thread as this post + */ + replyCount: PropTypes.number, + + /* + * Post identifiers for selenium tests + */ + lastPostCount: PropTypes.number, + + /** + * Function to get the post list HTML element + */ + getPostList: PropTypes.func.isRequired, + + actions: PropTypes.shape({ + + /* + * Function to remove the post + */ + removePost: PropTypes.func.isRequired, + + /* + * Function to add a reaction to the post + */ + addReaction: PropTypes.func.isRequired + }).isRequired + } + constructor(props) { super(props); @@ -29,7 +80,8 @@ export default class PostInfo extends React.Component { this.state = { showEmojiPicker: false, - reactionPickerOffset: 21 + reactionPickerOffset: 21, + canEdit: PostUtils.canEditPost(props.post, this.editDisableAction) }; } @@ -46,7 +98,7 @@ export default class PostInfo extends React.Component { } removePost() { - GlobalActions.emitRemovePost(this.props.post); + this.props.actions.removePost(this.props.post); } createRemovePostButton() { @@ -66,7 +118,7 @@ export default class PostInfo extends React.Component { const pickerOffset = 21; this.setState({showEmojiPicker: false, reactionPickerOffset: pickerOffset}); const emojiName = emoji.name || emoji.aliases[0]; - PostActions.addReaction(this.props.post.channel_id, this.props.post.id, emojiName); + this.props.actions.addReaction(this.props.post.id, emojiName); } getDotMenu = () => { @@ -74,7 +126,7 @@ export default class PostInfo extends React.Component { } render() { - var post = this.props.post; + const post = this.props.post; let idCount = -1; if (this.props.lastPostCount >= 0 && this.props.lastPostCount < Constants.TEST_ID_COUNT) { @@ -82,19 +134,18 @@ export default class PostInfo extends React.Component { } const isEphemeral = Utils.isPostEphemeral(post); - const isPending = post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING; const isSystemMessage = PostUtils.isSystemMessage(post); let comments = null; let react = null; - if (!isEphemeral && !isPending && !isSystemMessage) { + if (!isEphemeral && !post.failed && !isSystemMessage) { comments = ( ); @@ -116,6 +167,7 @@ export default class PostInfo extends React.Component { + ); } } @@ -127,13 +179,13 @@ export default class PostInfo extends React.Component { {this.createRemovePostButton()}
); - } else if (!isPending) { + } else if (!post.failed) { const dotMenu = ( @@ -191,24 +241,3 @@ export default class PostInfo extends React.Component { ); } } - -PostInfo.defaultProps = { - post: null, - commentCount: 0, - isLastComment: false, - sameUser: false -}; -PostInfo.propTypes = { - post: PropTypes.object.isRequired, - lastPostCount: PropTypes.number, - commentCount: PropTypes.number.isRequired, - isLastComment: PropTypes.bool.isRequired, - handleCommentClick: PropTypes.func.isRequired, - handleDropdownOpened: PropTypes.func.isRequired, - sameUser: PropTypes.bool.isRequired, - currentUser: PropTypes.object.isRequired, - compactDisplay: PropTypes.bool, - useMilitaryTime: PropTypes.bool.isRequired, - isFlagged: PropTypes.bool, - getPostList: PropTypes.func.isRequired -}; diff --git a/webapp/components/post_view/post_list.jsx b/webapp/components/post_view/post_list.jsx new file mode 100644 index 0000000000..bf0ee079df --- /dev/null +++ b/webapp/components/post_view/post_list.jsx @@ -0,0 +1,523 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Post from './post'; +import LoadingScreen from 'components/loading_screen.jsx'; +import FloatingTimestamp from './floating_timestamp.jsx'; +import ScrollToBottomArrows from './scroll_to_bottom_arrows.jsx'; +import NewMessageIndicator from './new_message_indicator.jsx'; + +import * as UserAgent from 'utils/user_agent.jsx'; +import * as Utils from 'utils/utils.jsx'; +import Constants from 'utils/constants.jsx'; +import {createChannelIntroMessage} from 'utils/channel_intro_messages.jsx'; +import DelayedAction from 'utils/delayed_action.jsx'; + +import {FormattedDate, FormattedMessage} from 'react-intl'; + +import React from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; + +const CLOSE_TO_BOTTOM_SCROLL_MARGIN = 10; +const POSTS_PER_PAGE = Constants.POST_CHUNK_SIZE / 2; + +export default class PostList extends React.PureComponent { + static propTypes = { + + /** + * Array of posts in the channel, ordered from oldest to newest + */ + posts: PropTypes.array, + + /** + * The number of posts that should be rendered + */ + postVisibility: PropTypes.number, + + /** + * The channel the posts are in + */ + channel: PropTypes.object, + + /** + * The last time the channel was viewed, sets the new message separator + */ + lastViewedAt: PropTypes.number, + + /** + * Set if more posts are being loaded + */ + loadingPosts: PropTypes.bool, + + /** + * The user id of the logged in user + */ + currentUserId: PropTypes.string, + + /** + * Set to focus this post + */ + focusedPostId: PropTypes.array, + + /** + * Whether to display the channel intro at full width + */ + fullWidth: PropTypes.bool, + + actions: PropTypes.shape({ + + /** + * Function to get posts in the channel + */ + getPosts: PropTypes.func.isRequired, + + /** + * Function to get posts in the channel older than the focused post + */ + getPostsBefore: PropTypes.func.isRequired, + + /** + * Function to get posts in the channel newer than the focused post + */ + getPostsAfter: PropTypes.func.isRequired, + + /** + * Function to get the post thread for the focused post + */ + getPostThread: PropTypes.func.isRequired, + + /** + * Function to increase the number of posts being rendered + */ + increasePostVisibility: PropTypes.func.isRequired + }).isRequired + } + + constructor(props) { + super(props); + + this.scrollStopAction = new DelayedAction(this.handleScrollStop); + + this.previousScrollTop = Number.MAX_SAFE_INTEGER; + this.previousScrollHeight = 0; + this.previousClientHeight = 0; + + this.state = { + atEnd: false, + unViewedCount: 0, + isScrolling: false, + lastViewed: Number.MAX_SAFE_INTEGER + }; + } + + componentDidMount() { + this.loadPosts(this.props.channel.id, this.props.focusedPostId); + + window.addEventListener('resize', this.handleResize); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.handleResize); + } + + componentWillReceiveProps(nextProps) { + // Focusing on a new post so load posts around it + if (nextProps.focusedPostId && this.props.focusedPostId !== nextProps.focusedPostId) { + this.hasScrolledToFocusedPost = false; + this.hasScrolledToNewMessageSeparator = false; + this.setState({atEnd: false}); + this.loadPosts(nextProps.channel.id, nextProps.focusedPostId); + return; + } + + const channel = this.props.channel || {}; + const nextChannel = nextProps.channel || {}; + + if (nextProps.focusedPostId == null) { + // Channel changed so load posts for new channel + if (channel.id !== nextChannel.id) { + this.hasScrolled = false; + this.hasScrolledToFocusedPost = false; + this.hasScrolledToNewMessageSeparator = false; + this.setState({atEnd: false}); + + if (nextChannel.id) { + this.loadPosts(nextChannel.id); + } + return; + } + + if (!this.wasAtBottom() && this.props.posts !== nextProps.posts) { + const unViewedCount = nextProps.posts.reduce((count, post) => { + if (post.create_at > this.state.lastViewed && + post.user_id !== nextProps.currentUserId && + post.state !== Constants.POST_DELETED) { + return count + 1; + } + return count; + }, 0); + this.setState({unViewedCount}); + } + } + } + + componentWillUpdate() { + if (this.refs.postlist) { + this.previousScrollTop = this.refs.postlist.scrollTop; + this.previousScrollHeight = this.refs.postlist.scrollHeight; + this.previousClientHeight = this.refs.postlist.clientHeight; + } + } + + componentDidUpdate(prevProps) { + // Scroll to focused post on first load + const focusedPost = this.refs[this.props.focusedPostId]; + if (focusedPost) { + if (!this.hasScrolledToFocusedPost && this.props.posts) { + const element = ReactDOM.findDOMNode(focusedPost); + const rect = element.getBoundingClientRect(); + const listHeight = this.refs.postlist.clientHeight / 2; + this.refs.postlist.scrollTop = this.refs.postlist.scrollTop + (rect.top - listHeight); + } + return; + } + + // Scroll to new message indicator or bottom on first load + const messageSeparator = this.refs.newMessageSeparator; + if (messageSeparator && !this.hasScrolledToNewMessageSeparator) { + const element = ReactDOM.findDOMNode(messageSeparator); + element.scrollIntoView(); + return; + } else if (this.refs.postlist && !this.hasScrolledToNewMessageSeparator) { + this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; + return; + } + + const prevPosts = prevProps.posts; + const posts = this.props.posts; + const postList = this.refs.postlist; + + if (postList && prevPosts && posts && posts[0] && prevPosts[0]) { + // A new message was posted, so scroll to bottom if it was from current user + // or if user was already scrolled close to bottom + let doScrollToBottom = false; + if (posts[0].id !== prevPosts[0].id && posts[0].pending_post_id !== prevPosts[0].pending_post_id) { + // If already scrolled to bottom + if (this.wasAtBottom()) { + doScrollToBottom = true; + } + + // If new post was by current user + if (posts[0].user_id === this.props.currentUserId) { + doScrollToBottom = true; + } + + // If new post was ephemeral + if (Utils.isPostEphemeral(posts[0])) { + doScrollToBottom = true; + } + } + + if (doScrollToBottom) { + postList.scrollTop = postList.scrollHeight; + return; + } + + // New posts added at the top, maintain scroll position + if (this.previousScrollHeight !== this.refs.postlist.scrollHeight && posts[0].id === prevPosts[0].id) { + this.refs.postlist.scrollTop = this.previousScrollTop + (this.refs.postlist.scrollHeight - this.previousScrollHeight); + } + } + } + + handleScrollStop = () => { + this.setState({ + isScrolling: false + }); + } + + wasAtBottom = () => { + return this.previousClientHeight + this.previousScrollTop >= this.previousScrollHeight - CLOSE_TO_BOTTOM_SCROLL_MARGIN; + } + + handleResize = () => { + const postList = this.refs.postlist; + + if (postList && this.wasAtBottom()) { + postList.scrollTop = postList.scrollHeight; + + this.previousScrollHeight = postList.scrollHeight; + this.previousScrollTop = postList.scrollTop; + this.previousClientHeight = postList.clientHeight; + } + } + + loadPosts = async (channelId, focusedPostId) => { + let posts; + if (focusedPostId) { + const getPostThreadAsync = this.props.actions.getPostThread(focusedPostId); + const getPostsBeforeAsync = this.props.actions.getPostsBefore(channelId, focusedPostId, 0, POSTS_PER_PAGE); + const getPostsAfterAsync = this.props.actions.getPostsAfter(channelId, focusedPostId, 0, POSTS_PER_PAGE); + + posts = await getPostsBeforeAsync; + await getPostsAfterAsync; + await getPostThreadAsync; + + this.hasScrolledToFocusedPost = true; + } else { + posts = await this.props.actions.getPosts(channelId, 0, POSTS_PER_PAGE); + this.hasScrolledToNewMessageSeparator = true; + } + + if (posts && posts.order.length < POSTS_PER_PAGE) { + this.setState({atEnd: true}); + } + } + + loadMorePosts = (e) => { + if (e) { + e.preventDefault(); + } + + this.props.actions.increasePostVisibility(this.props.channel.id, this.props.focusedPostId).then((moreToLoad) => { + this.setState({atEnd: !moreToLoad && this.props.posts.length < this.props.postVisibility}); + }); + } + + handleScroll = () => { + this.hasScrolledToFocusedPost = true; + this.hasScrolled = true; + this.previousScrollTop = this.refs.postlist.scrollTop; + + this.updateFloatingTimestamp(); + + if (!this.state.isScrolling) { + this.setState({ + isScrolling: true + }); + } + + if (this.wasAtBottom()) { + this.setState({ + lastViewed: new Date().getTime(), + unViewedCount: 0, + isScrolling: false + }); + } + + this.scrollStopAction.fireAfter(Constants.SCROLL_DELAY); + } + + updateFloatingTimestamp = () => { + // skip this in non-mobile view since that's when the timestamp is visible + if (!Utils.isMobile()) { + return; + } + + if (this.props.posts) { + // iterate through posts starting at the bottom since users are more likely to be viewing newer posts + for (let i = 0; i < this.props.posts.length; i++) { + const post = this.props.posts[i]; + const element = this.refs[post.id]; + + if (!element || !element.domNode || element.domNode.offsetTop + element.domNode.clientHeight <= this.refs.postlist.scrollTop) { + // this post is off the top of the screen so the last one is at the top of the screen + let topPost; + + if (i > 0) { + topPost = this.props.posts[i - 1]; + } else { + // the first post we look at should always be on the screen, but handle that case anyway + topPost = post; + } + + if (!this.state.topPost || topPost.id !== this.state.topPost.id) { + this.setState({ + topPost + }); + } + + break; + } + } + } + } + + scrollToBottom = () => { + this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; + } + + createPosts = (posts) => { + const postCtls = []; + let previousPostDay = new Date(0); + const currentUserId = this.props.currentUserId; + const lastViewed = this.props.lastViewedAt || 0; + + let renderedLastViewed = false; + + for (let i = posts.length - 1; i >= 0; i--) { + const post = posts[i]; + + const postCtl = ( + = 0 && i < Constants.TEST_ID_COUNT) ? i : -1} + getPostList={this.getPostList} + /> + ); + + const currentPostDay = Utils.getDateForUnixTicks(post.create_at); + if (currentPostDay.toDateString() !== previousPostDay.toDateString()) { + postCtls.push( +
+
+
+ +
+
+ ); + } + + if (post.user_id !== currentUserId && + lastViewed !== 0 && + post.create_at > lastViewed && + !Utils.isPostEphemeral(post) && + !renderedLastViewed) { + renderedLastViewed = true; + + // Temporary fix to solve ie11 rendering issue + let newSeparatorId = ''; + if (!UserAgent.isInternetExplorer()) { + newSeparatorId = 'new_message_' + post.id; + } + postCtls.push( +
+
+
+ +
+
+ ); + } + + postCtls.push(postCtl); + previousPostDay = currentPostDay; + } + + return postCtls; + } + + getPostList = () => { + return this.refs.postlist; + } + + render() { + const posts = this.props.posts; + const channel = this.props.channel; + + if (posts == null || channel == null) { + return ( +
+ +
+ ); + } + + let topRow; + if (this.state.atEnd) { + topRow = createChannelIntroMessage(channel, this.props.fullWidth); + } else if (this.props.postVisibility >= Constants.MAX_POST_VISIBILITY) { + topRow = ( +
+ +
+ ); + } else { + topRow = ( + + + + ); + } + + const topPostCreateAt = this.state.topPost ? this.state.topPost.create_at : 0; + + let postVisibility = this.props.postVisibility; + + // In focus mode there's an extra (Constants.POST_CHUNK_SIZE / 2) posts to show + if (this.props.focusedPostId) { + postVisibility += Constants.POST_CHUNK_SIZE / 2; + } + + return ( +
+ + + +
+
+
+ {topRow} + {this.createPosts(posts.slice(0, postVisibility))} +
+
+
+
+ ); + } +} diff --git a/webapp/components/post_view/post_message_view/index.js b/webapp/components/post_view/post_message_view/index.js new file mode 100644 index 0000000000..cf457a5087 --- /dev/null +++ b/webapp/components/post_view/post_message_view/index.js @@ -0,0 +1,41 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {getCustomEmojisAsMap} from 'mattermost-redux/selectors/entities/emojis'; +import {getBool} from 'mattermost-redux/selectors/entities/preferences'; +import {getCurrentUserMentionKeys, getUsersByUsername} from 'mattermost-redux/selectors/entities/users'; + +import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; + +import {Preferences} from 'mattermost-redux/constants'; +import {getSiteURL} from 'utils/url.jsx'; + +import {EmojiMap} from 'stores/emoji_store.jsx'; + +import PostMessageView from './post_message_view.jsx'; + +function makeMapStateToProps() { + let emojiMap; + let oldCustomEmoji; + + return function mapStateToProps(state, ownProps) { + const newCustomEmoji = getCustomEmojisAsMap(state); + if (newCustomEmoji !== oldCustomEmoji) { + emojiMap = new EmojiMap(newCustomEmoji); + } + oldCustomEmoji = newCustomEmoji; + + return { + ...ownProps, + emojis: emojiMap, + enableFormatting: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true), + mentionKeys: getCurrentUserMentionKeys(state), + usernameMap: getUsersByUsername(state), + team: getCurrentTeam(state), + siteUrl: getSiteURL() + }; + }; +} + +export default connect(makeMapStateToProps)(PostMessageView); diff --git a/webapp/components/post_view/components/post_message_view.jsx b/webapp/components/post_view/post_message_view/post_message_view.jsx similarity index 58% rename from webapp/components/post_view/components/post_message_view.jsx rename to webapp/components/post_view/post_message_view/post_message_view.jsx index 938b5a8db0..66a8d01f8e 100644 --- a/webapp/components/post_view/components/post_message_view.jsx +++ b/webapp/components/post_view/post_message_view/post_message_view.jsx @@ -1,72 +1,74 @@ -import PropTypes from 'prop-types'; - // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. import React from 'react'; +import PropTypes from 'prop-types'; import {FormattedMessage} from 'react-intl'; -import Constants from 'utils/constants.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; import * as TextFormatting from 'utils/text_formatting.jsx'; import * as Utils from 'utils/utils.jsx'; -import {getSiteURL} from 'utils/url.jsx'; + +import {getChannelsNameMapInCurrentTeam} from 'mattermost-redux/selectors/entities/channels'; +import {Posts} from 'mattermost-redux/constants'; +import store from 'stores/redux_store.jsx'; import {renderSystemMessage} from './system_message_helpers.jsx'; -export default class PostMessageView extends React.Component { +export default class PostMessageView extends React.PureComponent { static propTypes = { - options: PropTypes.object.isRequired, + + /* + * The post to render the message for + */ post: PropTypes.object.isRequired, + + /* + * Object using emoji names as keys with custom emojis as the values + */ emojis: PropTypes.object.isRequired, - enableFormatting: PropTypes.bool.isRequired, - mentionKeys: PropTypes.arrayOf(PropTypes.string).isRequired, - usernameMap: PropTypes.object.isRequired, - channelNamesMap: PropTypes.object.isRequired, + + /* + * The team the post was made in + */ team: PropTypes.object.isRequired, + + /* + * Set to enable Markdown formatting + */ + enableFormatting: PropTypes.bool, + + /* + * An array of words that can be used to mention a user + */ + mentionKeys: PropTypes.arrayOf(PropTypes.string), + + /* + * Object mapping usernames to users + */ + usernameMap: PropTypes.object, + + /* + * The URL that the app is hosted on + */ + siteUrl: PropTypes.string, + + /* + * Options specific to text formatting + */ + options: PropTypes.object, + + /* + * Post identifiers for selenium tests + */ lastPostCount: PropTypes.number }; - shouldComponentUpdate(nextProps) { - if (!Utils.areObjectsEqual(nextProps.options, this.props.options)) { - return true; - } - - if (nextProps.post.message !== this.props.post.message) { - return true; - } - - if (nextProps.post.state !== this.props.post.state) { - return true; - } - - if (nextProps.post.type !== this.props.post.type) { - return true; - } - - // emojis are immutable - if (nextProps.emojis !== this.props.emojis) { - return true; - } - - if (nextProps.enableFormatting !== this.props.enableFormatting) { - return true; - } - - if (!Utils.areObjectsEqual(nextProps.mentionKeys, this.props.mentionKeys)) { - return true; - } - - if (nextProps.lastPostCount !== this.props.lastPostCount) { - return true; - } - - // Don't check if props.usernameMap changes since it is very large and inefficient to do so. - // This mimics previous behaviour, but could be changed if we decide it's worth it. - // The same choice (and reasoning) is also applied to the this.props.channelNamesMap. - - return false; - } + static defaultProps = { + options: {}, + mentionKeys: [], + usernameMap: {} + }; renderDeletedPost() { return ( @@ -95,7 +97,7 @@ export default class PostMessageView extends React.Component { } render() { - if (this.props.post.state === Constants.POST_DELETED) { + if (this.props.post.state === Posts.POST_DELETED) { return this.renderDeletedPost(); } @@ -105,10 +107,10 @@ export default class PostMessageView extends React.Component { const options = Object.assign({}, this.props.options, { emojis: this.props.emojis, - siteURL: getSiteURL(), + siteURL: this.props.siteUrl, mentionKeys: this.props.mentionKeys, usernameMap: this.props.usernameMap, - channelNamesMap: this.props.channelNamesMap, + channelNamesMap: getChannelsNameMapInCurrentTeam(store.getState()), team: this.props.team }); diff --git a/webapp/components/post_view/components/system_message_helpers.jsx b/webapp/components/post_view/post_message_view/system_message_helpers.jsx similarity index 100% rename from webapp/components/post_view/components/system_message_helpers.jsx rename to webapp/components/post_view/post_message_view/system_message_helpers.jsx diff --git a/webapp/components/post_view/components/post_time.jsx b/webapp/components/post_view/post_time.jsx similarity index 63% rename from webapp/components/post_view/components/post_time.jsx rename to webapp/components/post_view/post_time.jsx index 9f6ef51ccc..133b6b5a36 100644 --- a/webapp/components/post_view/components/post_time.jsx +++ b/webapp/components/post_view/post_time.jsx @@ -1,23 +1,41 @@ -import PropTypes from 'prop-types'; - // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React from 'react'; - import Constants from 'utils/constants.jsx'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; - import {getDateForUnixTicks, isMobile, updateWindowDimensions} from 'utils/utils.jsx'; +import React from 'react'; +import PropTypes from 'prop-types'; import {Link} from 'react-router/es6'; import TeamStore from 'stores/team_store.jsx'; -export default class PostTime extends React.Component { +export default class PostTime extends React.PureComponent { + static propTypes = { + + /* + * The time to display + */ + eventTime: PropTypes.number.isRequired, + + /* + * Set to display using 24 hour format + */ + useMilitaryTime: PropTypes.bool, + + /* + * The post id of posting being rendered + */ + postId: PropTypes.string + } + + static defaultProps = { + eventTime: 0, + useMilitaryTime: false + } + constructor(props) { super(props); - this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this); this.state = { currentTeamDisplayName: TeamStore.getCurrent().name, width: '', @@ -56,29 +74,18 @@ export default class PostTime extends React.Component { } render() { - return isMobile() ? - this.renderTimeTag() : - ( - - {this.renderTimeTag()} - - ); + if (isMobile()) { + return this.renderTimeTag(); + } + + return ( + + {this.renderTimeTag()} + + ); } } - -PostTime.defaultProps = { - eventTime: 0, - sameUser: false -}; - -PostTime.propTypes = { - eventTime: PropTypes.number.isRequired, - sameUser: PropTypes.bool, - compactDisplay: PropTypes.bool, - useMilitaryTime: PropTypes.bool.isRequired, - postId: PropTypes.string -}; diff --git a/webapp/components/post_view/post_view_cache.jsx b/webapp/components/post_view/post_view_cache.jsx deleted file mode 100644 index b8ae39e4ab..0000000000 --- a/webapp/components/post_view/post_view_cache.jsx +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information - -import PostViewController from './post_view_controller.jsx'; - -import ChannelStore from 'stores/channel_store.jsx'; -import UserStore from 'stores/user_store.jsx'; - -import PropTypes from 'prop-types'; - -import React from 'react'; - -const MAXIMUM_CACHED_VIEWS = 5; - -export default class PostViewCache extends React.Component { - static propTypes = { - actions: PropTypes.shape({ - viewChannel: PropTypes.func.isRequired - }).isRequired - } - - constructor(props) { - super(props); - - this.onChannelChange = this.onChannelChange.bind(this); - - const currentChannelId = ChannelStore.getCurrentId(); - const channel = ChannelStore.getCurrent(); - - this.state = { - currentChannelId, - channels: channel ? [channel] : [] - }; - } - - componentDidMount() { - ChannelStore.addChangeListener(this.onChannelChange); - } - - componentWillUnmount() { - if (UserStore.getCurrentUser()) { - this.props.actions.viewChannel('', this.state.currentChannelId || ''); - } - ChannelStore.removeChangeListener(this.onChannelChange); - } - - onChannelChange() { - const channels = Object.assign([], this.state.channels); - const currentChannel = ChannelStore.getCurrent(); - - if (!currentChannel) { - return; - } - - // make sure current channel really changed - if (currentChannel.id === this.state.currentChannelId) { - return; - } - - if (channels.length > MAXIMUM_CACHED_VIEWS) { - channels.shift(); - } - - const index = channels.map((c) => c.id).indexOf(currentChannel.id); - if (index !== -1) { - channels.splice(index, 1); - } - - channels.push(currentChannel); - - this.setState({ - currentChannelId: currentChannel.id, - channels - }); - } - - render() { - const channels = this.state.channels; - const currentChannelId = this.state.currentChannelId; - - const postViews = []; - for (let i = 0; i < channels.length; i++) { - postViews.push( - - ); - } - - return ( -
- {postViews} -
- ); - } -} diff --git a/webapp/components/post_view/post_view_controller.jsx b/webapp/components/post_view/post_view_controller.jsx deleted file mode 100644 index 12112ac105..0000000000 --- a/webapp/components/post_view/post_view_controller.jsx +++ /dev/null @@ -1,404 +0,0 @@ -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import PostList from './components/post_list.jsx'; -import LoadingScreen from 'components/loading_screen.jsx'; - -import PreferenceStore from 'stores/preference_store.jsx'; -import UserStore from 'stores/user_store.jsx'; -import PostStore from 'stores/post_store.jsx'; -import ChannelStore from 'stores/channel_store.jsx'; -import TeamStore from 'stores/team_store.jsx'; -import WebrtcStore from 'stores/webrtc_store.jsx'; - -import * as Utils from 'utils/utils.jsx'; - -import Constants from 'utils/constants.jsx'; -const Preferences = Constants.Preferences; -const ScrollTypes = Constants.ScrollTypes; - -import PropTypes from 'prop-types'; - -import React from 'react'; - -export default class PostViewController extends React.Component { - constructor(props) { - super(props); - - this.onPreferenceChange = this.onPreferenceChange.bind(this); - this.onUserChange = this.onUserChange.bind(this); - this.onPostsChange = this.onPostsChange.bind(this); - this.onTeamChange = this.onTeamChange.bind(this); - this.onStatusChange = this.onStatusChange.bind(this); - this.onPostsViewJumpRequest = this.onPostsViewJumpRequest.bind(this); - this.onSetNewMessageIndicator = this.onSetNewMessageIndicator.bind(this); - this.onPostListScroll = this.onPostListScroll.bind(this); - this.onActivate = this.onActivate.bind(this); - this.onDeactivate = this.onDeactivate.bind(this); - this.onBusy = this.onBusy.bind(this); - - const channel = props.channel; - const profiles = UserStore.getProfiles(); - - let lastViewed = Number.MAX_VALUE; - let lastViewedBottom = Number.MAX_VALUE; - const member = ChannelStore.getMyMember(channel.id); - if (member != null) { - lastViewed = member.last_viewed_at; - lastViewedBottom = member.last_viewed_at; - } - - const joinLeaveEnabled = PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'join_leave', true); - - const statuses = Object.assign({}, UserStore.getStatuses()); - - // If we haven't received a page time then we aren't done loading the posts yet - const loading = PostStore.getLatestPostFromPageTime(channel.id) === 0; - - this.state = { - channel, - postList: PostStore.filterPosts(channel.id, joinLeaveEnabled), - currentUser: UserStore.getCurrentUser(), - currentTeamId: TeamStore.getCurrentId(), - isBusy: WebrtcStore.isBusy(), - profiles, - statuses, - atTop: PostStore.getVisibilityAtTop(channel.id), - lastViewed, - lastViewedBottom, - ownNewMessage: false, - loading, - scrollType: ScrollTypes.NEW_MESSAGE, - displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'), - 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'), - useMilitaryTime: PreferenceStore.getBool(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false), - flaggedPosts: PreferenceStore.getCategory(Constants.Preferences.CATEGORY_FLAGGED_POST) - }; - } - - componentDidMount() { - if (this.props.active) { - this.onActivate(); - } - } - - componentWillUnmount() { - if (this.props.active) { - this.onDeactivate(); - } - } - - onPreferenceChange(category) { - // Bit of a hack to force render when this setting is updated - // regardless of change - let previewSuffix = ''; - if (category === Preferences.CATEGORY_DISPLAY_SETTINGS) { - previewSuffix = '_' + Utils.generateId(); - } - - const joinLeaveEnabled = PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'join_leave', true); - - this.setState({ - postList: PostStore.filterPosts(this.state.channel.id, joinLeaveEnabled), - displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'), - 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), - flaggedPosts: PreferenceStore.getCategory(Constants.Preferences.CATEGORY_FLAGGED_POST) - }); - } - - onUserChange() { - this.setState({currentUser: UserStore.getCurrentUser(), profiles: JSON.parse(JSON.stringify(UserStore.getProfiles()))}); - } - - onPostsChange() { - const joinLeaveEnabled = PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'join_leave', true); - const loading = PostStore.getLatestPostFromPageTime(this.state.channel.id) === 0; - - const newState = { - postList: PostStore.filterPosts(this.state.channel.id, joinLeaveEnabled), - atTop: PostStore.getVisibilityAtTop(this.state.channel.id), - loading - }; - - if (this.state.loading && !loading) { - newState.scrollType = ScrollTypes.NEW_MESSAGE; - } - - this.setState(newState); - } - - onStatusChange() { - this.setState({statuses: Object.assign({}, UserStore.getStatuses())}); - } - - onTeamChange() { - const currentTeamId = TeamStore.getCurrentId(); - if ((this.state.channel.type === Constants.OPEN_CHANNEL || this.state.channel.type === Constants.PRIVATE_CHANNEL) && this.state.channel.team_id !== currentTeamId) { - this.setState({ - currentTeamId, - loading: true - }); - } - } - - onActivate() { - PreferenceStore.addChangeListener(this.onPreferenceChange); - UserStore.addChangeListener(this.onUserChange); - TeamStore.addChangeListener(this.onTeamChange); - UserStore.addStatusesChangeListener(this.onStatusChange); - PostStore.addChangeListener(this.onPostsChange); - PostStore.addPostsViewJumpListener(this.onPostsViewJumpRequest); - ChannelStore.addLastViewedListener(this.onSetNewMessageIndicator); - WebrtcStore.addBusyListener(this.onBusy); - } - - onDeactivate() { - PreferenceStore.removeChangeListener(this.onPreferenceChange); - UserStore.removeChangeListener(this.onUserChange); - TeamStore.removeChangeListener(this.onTeamChange); - UserStore.removeStatusesChangeListener(this.onStatusChange); - PostStore.removeChangeListener(this.onPostsChange); - PostStore.removePostsViewJumpListener(this.onPostsViewJumpRequest); - ChannelStore.removeLastViewedListener(this.onSetNewMessageIndicator); - WebrtcStore.removeBusyListener(this.onBusy); - } - - componentWillReceiveProps(nextProps) { - if (this.props.active && !nextProps.active) { - this.onDeactivate(); - } else if (!this.props.active && nextProps.active) { - this.onActivate(); - - const channel = nextProps.channel; - - let lastViewed = Number.MAX_VALUE; - const member = ChannelStore.getMyMember(channel.id); - if (member != null) { - lastViewed = member.last_viewed_at; - } - - const profiles = UserStore.getProfiles(); - - const joinLeaveEnabled = PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'join_leave', true); - - const statuses = Object.assign({}, UserStore.getStatuses()); - - this.setState({ - channel, - lastViewed, - ownNewMessage: false, - profiles: JSON.parse(JSON.stringify(profiles)), - statuses, - postList: PostStore.filterPosts(channel.id, joinLeaveEnabled), - displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'), - 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'), - useMilitaryTime: PreferenceStore.getBool(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false), - scrollType: ScrollTypes.NEW_MESSAGE - }); - } - } - - onPostsViewJumpRequest(type, postId) { - switch (type) { - case Constants.PostsViewJumpTypes.BOTTOM: { - let lastViewedBottom; - const lastPost = PostStore.getLatestPost(this.state.channel.id); - - if (lastPost && lastPost.create_at) { - lastViewedBottom = lastPost.create_at; - } else { - lastViewedBottom = new Date().getTime(); - } - - this.setState({ - scrollType: ScrollTypes.BOTTOM, - lastViewedBottom - }); - break; - } - case Constants.PostsViewJumpTypes.POST: - this.setState({ - scrollType: ScrollTypes.POST, - scrollPostId: postId - }); - break; - case Constants.PostsViewJumpTypes.SIDEBAR_OPEN: - this.setState({scrollType: ScrollTypes.SIDEBAR_OPEN}); - break; - } - } - - onSetNewMessageIndicator() { - let lastViewed = Number.MAX_VALUE; - const member = ChannelStore.getMyMember(this.props.channel.id); - if (member != null) { - lastViewed = member.last_viewed_at; - } - this.setState({lastViewed}); - } - - onPostListScroll(atBottom) { - if (atBottom) { - let lastViewedBottom; - const lastPost = PostStore.getLatestPost(this.state.channel.id); - - if (lastPost && lastPost.create_at) { - lastViewedBottom = lastPost.create_at; - } else { - lastViewedBottom = new Date().getTime(); - } - - this.setState({scrollType: ScrollTypes.BOTTOM, lastViewedBottom}); - } else { - this.setState({scrollType: ScrollTypes.FREE}); - } - } - - onBusy(isBusy) { - this.setState({isBusy}); - } - - shouldComponentUpdate(nextProps, nextState) { - if (nextProps.active !== this.props.active) { - return true; - } - - if (nextState.loading !== this.state.loading) { - return true; - } - - if (nextState.atTop !== this.state.atTop) { - return true; - } - - if (nextState.displayNameType !== this.state.displayNameType) { - return true; - } - - if (nextState.displayPostsInCenter !== this.state.displayPostsInCenter) { - return true; - } - - if (nextState.compactDisplay !== this.state.compactDisplay) { - return true; - } - - if (nextState.previewsCollapsed !== this.state.previewsCollapsed) { - return true; - } - - if (nextState.useMilitaryTime !== this.state.useMilitaryTime) { - return true; - } - - if (!Utils.areObjectsEqual(nextState.flaggedPosts, this.state.flaggedPosts)) { - return true; - } - - if (nextState.lastViewed !== this.state.lastViewed) { - return true; - } - - if (nextState.ownNewMessage !== this.state.ownNewMessage) { - return true; - } - - if (nextState.showMoreMessagesTop !== this.state.showMoreMessagesTop) { - return true; - } - - if (nextState.scrollType !== this.state.scrollType) { - return true; - } - - if (nextState.scrollPostId !== this.state.scrollPostId) { - return true; - } - - if (nextProps.channel.id !== this.props.channel.id) { - return true; - } - - if (!Utils.areObjectsEqual(nextState.currentUser, this.state.currentUser)) { - return true; - } - - if (!Utils.areObjectsEqual(nextState.statuses, this.state.statuses)) { - return true; - } - - if (!Utils.areObjectsEqual(nextState.postList, this.state.postList)) { - return true; - } - - if (!Utils.areObjectsEqual(nextState.profiles, this.state.profiles)) { - return true; - } - - if (nextState.isBusy !== this.state.isBusy) { - return true; - } - - return false; - } - - render() { - let content; - if (this.state.postList == null || this.state.loading) { - content = ( - - ); - } else { - content = ( - - ); - } - - let activeClass = ''; - if (!this.props.active) { - activeClass = 'inactive'; - } - - return ( -
- {content} -
- ); - } -} - -PostViewController.propTypes = { - channel: PropTypes.object, - active: PropTypes.bool -}; diff --git a/webapp/components/post_view/reaction/index.js b/webapp/components/post_view/reaction/index.js new file mode 100644 index 0000000000..9bb2524a11 --- /dev/null +++ b/webapp/components/post_view/reaction/index.js @@ -0,0 +1,47 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {getCurrentUserId, makeGetProfilesForReactions} from 'mattermost-redux/selectors/entities/users'; +import {getMissingProfilesByIds} from 'mattermost-redux/actions/users'; +import {addReaction, removeReaction} from 'mattermost-redux/actions/posts'; +import {getEmojiImageUrl} from 'mattermost-redux/utils/emoji_utils'; +import * as Emoji from 'utils/emoji.jsx'; + +import Reaction from './reaction.jsx'; + +function makeMapStateToProps() { + const getProfilesForReactions = makeGetProfilesForReactions(); + + return function mapStateToProps(state, ownProps) { + const profiles = getProfilesForReactions(state, ownProps.reactions); + let emoji; + if (Emoji.EmojiIndicesByAlias.has(ownProps.emojiName)) { + emoji = Emoji.Emojis[Emoji.EmojiIndicesByAlias.get(ownProps.emojiName)]; + } else { + emoji = ownProps.emojis[ownProps.emojiName]; + } + + return { + ...ownProps, + profiles, + otherUsersCount: ownProps.reactions.length - profiles.length, + currentUserId: getCurrentUserId(state), + reactionCount: ownProps.reactions.length, + emojiImageUrl: getEmojiImageUrl(emoji) + }; + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + addReaction, + removeReaction, + getMissingProfilesByIds + }, dispatch) + }; +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(Reaction); diff --git a/webapp/components/post_view/components/reaction.jsx b/webapp/components/post_view/reaction/reaction.jsx similarity index 76% rename from webapp/components/post_view/components/reaction.jsx rename to webapp/components/post_view/reaction/reaction.jsx index d79e9e0925..5b65e604f6 100644 --- a/webapp/components/post_view/components/reaction.jsx +++ b/webapp/components/post_view/reaction/reaction.jsx @@ -1,28 +1,66 @@ -import PropTypes from 'prop-types'; - // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. import React from 'react'; +import PropTypes from 'prop-types'; import {OverlayTrigger, Tooltip} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; -import EmojiStore from 'stores/emoji_store.jsx'; - import * as Utils from 'utils/utils.jsx'; -export default class Reaction extends React.Component { +export default class Reaction extends React.PureComponent { static propTypes = { + + /* + * The post to render the reaction for + */ post: PropTypes.object.isRequired, + + /* + * The user id of the logged in user + */ currentUserId: PropTypes.string.isRequired, + + /* + * The name of the emoji for the reaction + */ emojiName: PropTypes.string.isRequired, - reactions: PropTypes.arrayOf(PropTypes.object), - emojis: PropTypes.object.isRequired, + + /* + * The number of reactions to this post for this emoji + */ + reactionCount: PropTypes.number.isRequired, + + /* + * Array of users who reacted to this post + */ profiles: PropTypes.array.isRequired, - otherUsers: PropTypes.number.isRequired, + + /* + * The number of users not in the profile list who have reacted with this emoji + */ + otherUsersCount: PropTypes.number.isRequired, + + /* + * The URL of the emoji image + */ + emojiImageUrl: PropTypes.string.isRequired, + actions: PropTypes.shape({ + + /* + * Function to add a reaction to a post + */ addReaction: PropTypes.func.isRequired, - getMissingProfiles: PropTypes.func.isRequired, + + /* + * Function to get non-loaded profiles by id + */ + getMissingProfilesByIds: PropTypes.func.isRequired, + + /* + * Function to remove a reaction from a post + */ removeReaction: PropTypes.func.isRequired }) } @@ -36,22 +74,18 @@ export default class Reaction extends React.Component { addReaction(e) { e.preventDefault(); - this.props.actions.addReaction(this.props.post.channel_id, this.props.post.id, this.props.emojiName); + this.props.actions.addReaction(this.props.post.id, this.props.emojiName); } removeReaction(e) { e.preventDefault(); - this.props.actions.removeReaction(this.props.post.channel_id, this.props.post.id, this.props.emojiName); + this.props.actions.removeReaction(this.props.post.id, this.props.emojiName); } render() { - if (!this.props.emojis.has(this.props.emojiName)) { - return null; - } - let currentUserReacted = false; const users = []; - const otherUsers = this.props.otherUsers; + const otherUsersCount = this.props.otherUsersCount; for (const user of this.props.profiles) { if (user.id === this.props.currentUserId) { currentUserReacted = true; @@ -67,7 +101,7 @@ export default class Reaction extends React.Component { } let names; - if (otherUsers > 0) { + if (otherUsersCount > 0) { if (users.length > 0) { names = ( ); @@ -85,7 +119,7 @@ export default class Reaction extends React.Component { id='reaction.othersReacted' defaultMessage='{otherUsers, number} {otherUsers, plural, one {user} other {users}}' values={{ - otherUsers + otherUsers: otherUsersCount }} /> ); @@ -106,7 +140,7 @@ export default class Reaction extends React.Component { } let reactionVerb; - if (users.length + otherUsers > 1) { + if (users.length + otherUsersCount > 1) { if (currentUserReacted) { reactionVerb = ( } - onEnter={this.props.actions.getMissingProfiles} + onEnter={this.props.actions.getMissingProfilesByIds} >
- {this.props.reactions.length} + {this.props.reactionCount}
diff --git a/webapp/components/post_view/reaction_list/index.js b/webapp/components/post_view/reaction_list/index.js new file mode 100644 index 0000000000..4fc9355d92 --- /dev/null +++ b/webapp/components/post_view/reaction_list/index.js @@ -0,0 +1,33 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {makeGetReactionsForPost} from 'mattermost-redux/selectors/entities/posts'; +import {getCustomEmojisAsMap} from 'mattermost-redux/selectors/entities/emojis'; + +import * as Actions from 'mattermost-redux/actions/posts'; + +import ReactionList from './reaction_list.jsx'; + +function makeMapStateToProps() { + const getReactionsForPost = makeGetReactionsForPost(); + + return function mapStateToProps(state, ownProps) { + return { + ...ownProps, + reactions: getReactionsForPost(state, ownProps.post.id), + emojis: getCustomEmojisAsMap(state) + }; + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + getReactionsForPost: Actions.getReactionsForPost + }, dispatch) + }; +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(ReactionList); diff --git a/webapp/components/post_view/components/reaction_list_view.jsx b/webapp/components/post_view/reaction_list/reaction_list.jsx similarity index 64% rename from webapp/components/post_view/components/reaction_list_view.jsx rename to webapp/components/post_view/reaction_list/reaction_list.jsx index 4379453a30..516f5332f6 100644 --- a/webapp/components/post_view/components/reaction_list_view.jsx +++ b/webapp/components/post_view/reaction_list/reaction_list.jsx @@ -1,17 +1,41 @@ -import PropTypes from 'prop-types'; - // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. import React from 'react'; +import PropTypes from 'prop-types'; -import Reaction from './reaction_container.jsx'; +import Reaction from 'components/post_view/reaction'; -export default class ReactionListView extends React.Component { +export default class ReactionListView extends React.PureComponent { static propTypes = { + + /** + * The post to render reactions for + */ post: PropTypes.object.isRequired, + + /** + * The reactions to render + */ reactions: PropTypes.arrayOf(PropTypes.object), - emojis: PropTypes.object.isRequired + + /** + * The emojis for the different reactions + */ + emojis: PropTypes.object.isRequired, + actions: PropTypes.shape({ + + /** + * Function to get reactions for a post + */ + getReactionsForPost: PropTypes.func.isRequired + }) + } + + componentDidMount() { + if (this.props.post.has_reactions) { + this.props.actions.getReactionsForPost(this.props.post.id); + } } render() { @@ -41,7 +65,7 @@ export default class ReactionListView extends React.Component { key={emojiName} post={this.props.post} emojiName={emojiName} - reactions={reactionsByName.get(emojiName)} + reactions={reactionsByName.get(emojiName) || []} emojis={this.props.emojis} /> ); diff --git a/webapp/components/post_view/components/scroll_to_bottom_arrows.jsx b/webapp/components/post_view/scroll_to_bottom_arrows.jsx similarity index 100% rename from webapp/components/post_view/components/scroll_to_bottom_arrows.jsx rename to webapp/components/post_view/scroll_to_bottom_arrows.jsx diff --git a/webapp/components/rhs_comment.jsx b/webapp/components/rhs_comment.jsx index 73ae705985..11d64f871f 100644 --- a/webapp/components/rhs_comment.jsx +++ b/webapp/components/rhs_comment.jsx @@ -2,41 +2,35 @@ // See License.txt for license information. import UserProfile from './user_profile.jsx'; -import FileAttachmentListContainer from './file_attachment_list_container.jsx'; -import PendingPostOptions from 'components/post_view/components/pending_post_options.jsx'; -import PostMessageContainer from 'components/post_view/components/post_message_container.jsx'; +import FileAttachmentListContainer from 'components/file_attachment_list'; +import PostMessageContainer from 'components/post_view/post_message_view'; import ProfilePicture from 'components/profile_picture.jsx'; -import ReactionListContainer from 'components/post_view/components/reaction_list_container.jsx'; -import PostFlagIcon from 'components/common/post_flag_icon.jsx'; -import DotMenu from 'components/dot_menu/dot_menu.jsx'; +import ReactionListContainer from 'components/post_view/reaction_list'; +import PostFlagIcon from 'components/post_view/post_flag_icon.jsx'; +import FailedPostOptions from 'components/post_view/failed_post_options'; +import DotMenu from 'components/dot_menu'; import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; -import {flagPost, unflagPost, pinPost, unpinPost, addReaction} from 'actions/post_actions.jsx'; +import {addReaction} from 'actions/post_actions.jsx'; import TeamStore from 'stores/team_store.jsx'; import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; -import Constants from 'utils/constants.jsx'; -import loadingGif from 'images/load.gif'; +import Constants from 'utils/constants.jsx'; import React from 'react'; import PropTypes from 'prop-types'; -import {FormattedMessage} from 'react-intl'; import {Link} from 'react-router/es6'; +import {FormattedMessage} from 'react-intl'; export default class RhsComment extends React.Component { constructor(props) { super(props); - this.handlePermalink = this.handlePermalink.bind(this); 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.reactEmojiClick = this.reactEmojiClick.bind(this); this.handleDropdownOpened = this.handleDropdownOpened.bind(this); @@ -61,11 +55,6 @@ export default class RhsComment extends React.Component { }); } - handlePermalink(e) { - e.preventDefault(); - GlobalActions.showGetPostLinkModal(this.props.post); - } - removePost() { GlobalActions.emitRemovePost(this.props.post); } @@ -127,26 +116,6 @@ 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); - } - - 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); - } - timeTag(post, timeOptions) { return (