diff options
Diffstat (limited to 'webapp')
89 files changed, 2450 insertions, 3973 deletions
diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index bb4721c22..6818bcacf 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 204f452d8..628144676 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, + const fileFormData = new FormData(); + fileFormData.append('files', file, name); + fileFormData.append('channel_id', channelId); + fileFormData.append('client_ids', clientId); + + uploadFileRedux(channelId, null, [clientId], fileFormData)(dispatch, getState).then( (data) => { - if (success) { + if (data && success) { success(data); - } - }, - (err) => { - AsyncClient.dispatchError(err, 'uploadFile'); - - if (error) { - error(err); + } 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 a1b178d67..13d74c845 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 d55a0d578..1eb1f4feb 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 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(); -} - -// 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(); - } -} +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); + } -// Send the next post in the queue if there is one -function sendNextPostInQueue() { - const nextPostAction = postQueue[0]; - if (nextPostAction) { - nextPostAction(); - } + if (success) { + success(); + } + }); } -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 b7a0b12a8..1aaecfb71 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 398261758..4a7b95f63 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 97275d37d..3f6edbd2e 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 { <ChannelHeader channelId={this.state.channelId} /> - <PostViewCache/> + <PostView + channelId={this.state.channelId} + /> <div className='post-create__container' id='post-create' diff --git a/webapp/components/create_comment.jsx b/webapp/components/create_comment.jsx index 175eb03be..56e9eb88f 100644 --- a/webapp/components/create_comment.jsx +++ b/webapp/components/create_comment.jsx @@ -229,7 +229,6 @@ export default class CreateComment extends React.Component { post.channel_id = this.props.channelId; post.root_id = this.props.rootId; post.parent_id = this.props.rootId; - post.file_ids = this.state.fileInfos.map((info) => 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 d2f64a266..124728c3d 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 b5f9fde45..eb6a6c005 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 105363211..11546ee79 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 ceda0a1a4..6411beafb 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 000000000..eaa1e8d2c --- /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 3ec7fedcc..683371d23 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 0b8bd1042..f14718e64 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 { <div className={className} style={{ - backgroundImage: `url(${FileStore.getFileThumbnailUrl(fileInfo.id)})` + backgroundImage: `url(${getFileThumbnailUrl(fileInfo.id)})` }} /> ); diff --git a/webapp/components/file_attachment_list.jsx b/webapp/components/file_attachment_list/file_attachment_list.jsx index 9beacf94c..31b1ac424 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 000000000..4081e4220 --- /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 4b05e392c..000000000 --- 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 ( - <FileAttachmentList - fileCount={fileCount} - fileInfos={this.state.fileInfos} - compactDisplay={this.props.compactDisplay} - /> - ); - } -} diff --git a/webapp/components/file_preview.jsx b/webapp/components/file_preview.jsx index 3bf05744f..65a71c047 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 = ( <img className='file-preview__image' - src={FileStore.getFileUrl(info.id)} + src={getFileUrl(info.id)} /> ); } else { diff --git a/webapp/components/needs_team/needs_team.jsx b/webapp/components/needs_team/needs_team.jsx index 4f5188a47..6fd2d3208 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 ebcd83916..237ad8f44 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 { <ChannelHeader channelId={this.state.channelId} /> - <PostFocusViewController/> + <PostView + channelId={this.state.channelId} + focusedPostId={this.state.postId} + /> <div id='archive-link-home' > @@ -89,7 +103,3 @@ export default class PermalinkView extends React.Component { ); } } - -PermalinkView.propTypes = { - params: PropTypes.object.isRequired -}; diff --git a/webapp/components/post_view/commented_on_files_message/commented_on_files_message.jsx b/webapp/components/post_view/commented_on_files_message/commented_on_files_message.jsx new file mode 100644 index 000000000..a09b2b156 --- /dev/null +++ b/webapp/components/post_view/commented_on_files_message/commented_on_files_message.jsx @@ -0,0 +1,51 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import * as Utils from 'utils/utils.jsx'; + +export default class CommentedOnFilesMessage extends React.PureComponent { + static propTypes = { + + /* + * The id of the post that was commented on + */ + parentPostId: React.PropTypes.string.isRequired, + + /* + * An array of file metadata for the parent post + */ + fileInfos: React.PropTypes.arrayOf(React.PropTypes.object), + + actions: React.PropTypes.shape({ + + /* + * Function to get file metadata for a post + */ + getFilesForPost: React.PropTypes.func.isRequired + }).isRequired + } + + componentDidMount() { + if (!this.props.fileInfos || this.props.fileInfos.length === 0) { + this.props.actions.getFilesForPost(this.props.parentPostId); + } + } + + render() { + let message = ' '; + + if (this.props.fileInfos && this.props.fileInfos.length > 0) { + message = this.props.fileInfos[0].name; + + if (this.props.fileInfos.length === 2) { + message += Utils.localizeMessage('post_body.plusOne', ' plus 1 other file'); + } else if (this.props.fileInfos.length > 2) { + message += Utils.localizeMessage('post_body.plusMore', ' plus {count} other files').replace('{count}', (this.props.fileInfos.length - 1).toString()); + } + } + + return <span>{message}</span>; + } +} diff --git a/webapp/components/post_view/commented_on_files_message/index.js b/webapp/components/post_view/commented_on_files_message/index.js new file mode 100644 index 000000000..fd6aa7193 --- /dev/null +++ b/webapp/components/post_view/commented_on_files_message/index.js @@ -0,0 +1,36 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {getFilesForPost} from 'mattermost-redux/actions/files'; + +import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files'; + +import CommentedOnFilesMessage from './commented_on_files_message.jsx'; + +function makeMapStateToProps() { + const selectFileInfosForPost = makeGetFilesForPost(); + + return function mapStateToProps(state, ownProps) { + let fileInfos; + if (ownProps.parentPostId) { + fileInfos = selectFileInfosForPost(state, {id: ownProps.parentPostId}); + } + + return { + ...ownProps, + fileInfos + }; + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + getFilesForPost + }, dispatch) + }; +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(CommentedOnFilesMessage); diff --git a/webapp/components/post_view/components/commented_on_files_message_container.jsx b/webapp/components/post_view/components/commented_on_files_message_container.jsx deleted file mode 100644 index 6ba1de3de..000000000 --- a/webapp/components/post_view/components/commented_on_files_message_container.jsx +++ /dev/null @@ -1,90 +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 * as Utils from 'utils/utils.jsx'; - -export default class CommentedOnFilesMessageContainer extends React.Component { - static propTypes = { - parentPostChannelId: PropTypes.string.isRequired, - parentPostId: PropTypes.string.isRequired - } - - constructor(props) { - super(props); - - this.handleFileChange = this.handleFileChange.bind(this); - - this.state = { - fileInfos: FileStore.getInfosForPost(this.props.parentPostId) - }; - } - - componentDidMount() { - FileStore.addChangeListener(this.handleFileChange); - - if (!FileStore.hasInfosForPost(this.props.parentPostId)) { - AsyncClient.getFileInfosForPost(this.props.parentPostChannelId, this.props.parentPostId); - } - } - - componentWillReceiveProps(nextProps) { - if (nextProps.parentPostId !== this.props.parentPostId) { - this.setState({ - fileInfos: FileStore.getInfosForPost(this.props.parentPostId) - }); - - if (!FileStore.hasInfosForPost(this.props.parentPostId)) { - AsyncClient.getFileInfosForPost(this.props.parentPostChannelId, this.props.parentPostId); - } - } - } - - shouldComponentUpdate(nextProps, nextState) { - if (nextProps.parentPostId !== this.props.parentPostId) { - return true; - } - - if (nextProps.parentPostChannelId !== this.props.parentPostChannelId) { - 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.parentPostId) - }); - } - - componentWillUnmount() { - FileStore.removeChangeListener(this.handleFileChange); - } - - render() { - let message = ' '; - - if (this.state.fileInfos && this.state.fileInfos.length > 0) { - message = this.state.fileInfos[0].name; - - if (this.state.fileInfos.length === 2) { - message += Utils.localizeMessage('post_body.plusOne', ' plus 1 other file'); - } else if (this.state.fileInfos.length > 2) { - message += Utils.localizeMessage('post_body.plusMore', ' plus {count} other files').replace('{count}', (this.state.fileInfos.length - 1).toString()); - } - } - - return <span>{message}</span>; - } -} diff --git a/webapp/components/post_view/components/date_separator.jsx b/webapp/components/post_view/components/date_separator.jsx deleted file mode 100644 index 4648f456c..000000000 --- a/webapp/components/post_view/components/date_separator.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {FormattedDate} from 'react-intl'; - -export default function DateSeparator(props) { - return ( - <div - className='date-separator' - > - <hr className='separator__hr'/> - <div className='separator__text'> - <FormattedDate - value={props.date} - weekday='short' - month='short' - day='2-digit' - year='numeric' - /> - </div> - </div> - ); -} - -DateSeparator.propTypes = { - date: PropTypes.instanceOf(Date) -}; diff --git a/webapp/components/post_view/components/post_attachment_list.jsx b/webapp/components/post_view/components/post_attachment_list.jsx deleted file mode 100644 index 3d7c0e4cd..000000000 --- a/webapp/components/post_view/components/post_attachment_list.jsx +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import PostAttachment from './post_attachment.jsx'; - -import PropTypes from 'prop-types'; - -import React from 'react'; - -export default function PostAttachmentList(props) { - const content = []; - props.attachments.forEach((attachment, i) => { - content.push( - <PostAttachment - attachment={attachment} - key={'att_' + i} - /> - ); - }); - - return ( - <div className='attachment_list'> - {content} - </div> - ); -} - -PostAttachmentList.propTypes = { - attachments: PropTypes.array.isRequired -}; diff --git a/webapp/components/post_view/components/post_list.jsx b/webapp/components/post_view/components/post_list.jsx deleted file mode 100644 index ec3c8dc6a..000000000 --- a/webapp/components/post_view/components/post_list.jsx +++ /dev/null @@ -1,690 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. -import $ from 'jquery'; - -import Post from './post.jsx'; -import FloatingTimestamp from './floating_timestamp.jsx'; -import ScrollToBottomArrows from './scroll_to_bottom_arrows.jsx'; -import NewMessageIndicator from './new_message_indicator.jsx'; - -import * as GlobalActions from 'actions/global_actions.jsx'; - -import {createChannelIntroMessage} from 'utils/channel_intro_messages.jsx'; - -import * as UserAgent from 'utils/user_agent.jsx'; -import * as Utils from 'utils/utils.jsx'; -import * as PostUtils from 'utils/post_utils.jsx'; -import DelayedAction from 'utils/delayed_action.jsx'; - -import * as ChannelActions from 'actions/channel_actions.jsx'; - -import Constants from 'utils/constants.jsx'; -const ScrollTypes = Constants.ScrollTypes; - -import PostStore from 'stores/post_store.jsx'; -import PreferenceStore from 'stores/preference_store.jsx'; -import ScrollStore from 'stores/scroll_store.jsx'; -import {FormattedDate, FormattedMessage} from 'react-intl'; - -import PropTypes from 'prop-types'; - -import React from 'react'; -import ReactDOM from 'react-dom'; - -const Preferences = Constants.Preferences; - -export default class PostList extends React.Component { - constructor(props) { - super(props); - - this.handleScroll = this.handleScroll.bind(this); - this.handleScrollStop = this.handleScrollStop.bind(this); - this.isAtBottom = this.isAtBottom.bind(this); - this.loadMorePostsTop = this.loadMorePostsTop.bind(this); - this.loadMorePostsBottom = this.loadMorePostsBottom.bind(this); - this.createPosts = this.createPosts.bind(this); - this.updateScrolling = this.updateScrolling.bind(this); - this.handleResize = this.handleResize.bind(this); - this.scrollToBottom = this.scrollToBottom.bind(this); - this.scrollToBottomAnimated = this.scrollToBottomAnimated.bind(this); - this.handleKeyDown = this.handleKeyDown.bind(this); - this.childComponentDidUpdate = this.childComponentDidUpdate.bind(this); - this.checkAndUpdateScrolling = this.checkAndUpdateScrolling.bind(this); - - this.jumpToPostNode = null; - this.wasAtBottom = true; - this.scrollHeight = 0; - this.animationFrameId = 0; - - this.scrollStopAction = new DelayedAction(this.handleScrollStop); - - this.state = { - isScrolling: false, - fullWidthIntro: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN, - topPostId: null, - unViewedCount: 0 - }; - - if (props.channel) { - this.introText = createChannelIntroMessage(props.channel, this.state.fullWidthIntro); - } else { - this.introText = this.getArchivesIntroMessage(); - } - } - - componentWillReceiveProps(nextProps) { - if (this.props.channel && this.props.channel.type === Constants.DM_CHANNEL) { - const teammateId = Utils.getUserIdFromChannelName(this.props.channel); - if (!this.props.profiles[teammateId] && nextProps.profiles[teammateId]) { - this.introText = createChannelIntroMessage(this.props.channel, this.state.fullWidthIntro); - } - } - - const posts = nextProps.postList.posts; - const order = nextProps.postList.order; - let unViewedCount = 0; - - // Only count if we're not at the bottom, not in highlight view, - // or anything else - if (nextProps.scrollType === Constants.ScrollTypes.FREE) { - unViewedCount = order.reduce((count, orderId) => { - const post = posts[orderId]; - if (post.create_at > nextProps.lastViewedBottom && - post.user_id !== nextProps.currentUser.id && - post.state !== Constants.POST_DELETED) { - return count + 1; - } - return count; - }, 0); - } - this.setState({unViewedCount}); - - if (this.props.channelId !== nextProps.channelId) { - PostStore.removePostDraftChangeListener(this.props.channelId, this.handlePostDraftChange); - PostStore.addPostDraftChangeListener(nextProps.channelId, this.handlePostDraftChange); - } - } - - handleKeyDown(e) { - if (e.which === Constants.KeyCodes.ESCAPE && $('.popover.in,.modal.in').length === 0) { - e.preventDefault(); - ChannelActions.setChannelAsRead(); - } - } - - isAtBottom() { - if (!this.refs.postlist) { - return this.wasAtBottom; - } - - // consider the view to be at the bottom if it's within this many pixels of the bottom - const atBottomMargin = 10; - - return this.refs.postlist.clientHeight + this.refs.postlist.scrollTop >= this.refs.postlist.scrollHeight - atBottomMargin; - } - - handleScroll() { - // HACK FOR RHS -- REMOVE WHEN RHS DIES - const childNodes = this.refs.postlistcontent.childNodes; - for (let i = 0; i < childNodes.length; i++) { - // If the node is 1/3 down the page - if (childNodes[i].offsetTop >= (this.refs.postlist.scrollTop + (this.refs.postlist.offsetHeight / Constants.SCROLL_PAGE_FRACTION))) { - this.jumpToPostNode = childNodes[i]; - break; - } - } - if (!this.jumpToPostNode && childNodes.length > 0) { - this.jumpToPostNode = childNodes[childNodes.length - 1]; - } - - this.updateFloatingTimestamp(); - - if (!this.state.isScrolling) { - this.setState({ - isScrolling: true - }); - } - - // Postpone all DOM related calculations to next frame. - // scrollHeight etc might return wrong data at this point - setTimeout(() => { - if (!this.refs.postlist) { - return; - } - - this.wasAtBottom = this.isAtBottom(); - this.props.postListScrolled(this.isAtBottom()); - this.prevScrollHeight = this.refs.postlist.scrollHeight; - this.prevOffsetTop = this.jumpToPostNode.offsetTop; - }, 0); - - this.scrollStopAction.fireAfter(Constants.SCROLL_DELAY); - } - - handleScrollStop() { - this.setState({ - isScrolling: false - }); - } - - updateFloatingTimestamp() { - // skip this in non-mobile view since that's when the timestamp is visible - if (!Utils.isMobile()) { - return; - } - - if (this.props.postList) { - // iterate through posts starting at the bottom since users are more likely to be viewing newer posts - for (let i = 0; i < this.props.postList.order.length; i++) { - const id = this.props.postList.order[i]; - const element = this.refs[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 topPostId; - - if (i > 0) { - topPostId = this.props.postList.order[i - 1]; - } else { - // the first post we look at should always be on the screen, but handle that case anyway - topPostId = id; - } - - if (topPostId !== this.state.topPostId) { - this.setState({ - topPostId - }); - } - - break; - } - } - } - } - - loadMorePostsTop(e) { - e.preventDefault(); - - if (this.props.isFocusPost) { - return GlobalActions.emitLoadMorePostsFocusedTopEvent(); - } - return GlobalActions.emitLoadMorePostsEvent(); - } - - loadMorePostsBottom() { - GlobalActions.emitLoadMorePostsFocusedBottomEvent(); - } - - createPosts(posts, order) { - const postCtls = []; - let previousPostDay = new Date(0); - const userId = this.props.currentUser.id; - const profiles = this.props.profiles || {}; - - let renderedLastViewed = false; - - for (let i = order.length - 1; i >= 0; i--) { - const post = posts[order[i]]; - const parentPost = posts[post.parent_id]; - const prevPost = posts[order[i + 1]]; - const postUserId = PostUtils.isSystemMessage(post) ? '' : post.user_id; - - // If the post is a comment whose parent has been deleted, don't add it to the list. - if (parentPost && parentPost.state === Constants.POST_DELETED) { - continue; - } - - let sameUser = false; - let sameRoot = false; - let hideProfilePic = false; - - if (prevPost) { - const postIsComment = PostUtils.isComment(post); - const prevPostIsComment = PostUtils.isComment(prevPost); - const postFromWebhook = Boolean(post.props && post.props.from_webhook); - const prevPostFromWebhook = Boolean(prevPost.props && prevPost.props.from_webhook); - const prevPostUserId = PostUtils.isSystemMessage(prevPost) ? '' : prevPost.user_id; - - // consider posts from the same user if: - // the previous post was made by the same user as the current post, - // the previous post was made within 5 minutes of the current post, - // the current post is not from a webhook - // the previous post is not from a webhook - if (prevPostUserId === postUserId && - post.create_at - prevPost.create_at <= Constants.POST_COLLAPSE_TIMEOUT && - !postFromWebhook && !prevPostFromWebhook) { - sameUser = true; - } - - // consider posts from the same root if: - // the current post is a comment, - // the current post has the same root as the previous post - if (postIsComment && (prevPost.id === post.root_id || prevPost.root_id === post.root_id)) { - sameRoot = true; - } - - // consider posts from the same root if: - // the current post is not a comment, - // the previous post is not a comment, - // the previous post is from the same user - if (!postIsComment && !prevPostIsComment && sameUser) { - sameRoot = true; - } - - // hide the profile pic if: - // the previous post was made by the same user as the current post, - // the previous post is not a comment, - // the current post is not a comment, - // the previous post is not from a webhook - // the current post is not from a webhook - if (prevPostUserId === postUserId && - !prevPostIsComment && - !postIsComment && - !prevPostFromWebhook && - !postFromWebhook) { - hideProfilePic = true; - } - } - - // check if it's the last comment in a consecutive string of comments on the same post - // it is the last comment if it is last post in the channel or the next post has a different root post - const isLastComment = PostUtils.isComment(post) && (i === 0 || posts[order[i - 1]].root_id !== post.root_id); - - const keyPrefix = post.id ? post.id : i; - - const shouldHighlight = this.props.postsToHighlight && this.props.postsToHighlight.hasOwnProperty(post.id); - - let profile; - if (userId === post.user_id) { - profile = this.props.currentUser; - } else { - profile = profiles[post.user_id]; - } - - let commentCount = 0; - let isCommentMention = false; - let shouldHighlightThreads = false; - let commentRootId; - if (parentPost) { - commentRootId = post.root_id; - } else { - commentRootId = post.id; - } - - if (commentRootId) { - for (const postId in posts) { - if (posts[postId].root_id === commentRootId && !PostUtils.isSystemMessage(posts[postId])) { - commentCount += 1; - if (posts[postId].user_id === userId) { - shouldHighlightThreads = true; - } - } - } - } - - if (parentPost && commentRootId) { - const commentsNotifyLevel = this.props.currentUser.notify_props.comments || 'never'; - const notCurrentUser = post.user_id !== userId || (post.props && post.props.from_webhook); - if (notCurrentUser) { - if (commentsNotifyLevel === 'any' && (posts[commentRootId].user_id === userId || shouldHighlightThreads)) { - isCommentMention = true; - } else if (commentsNotifyLevel === 'root' && posts[commentRootId].user_id === userId) { - isCommentMention = true; - } - } - } - - let isFlagged = false; - if (this.props.flaggedPosts) { - isFlagged = this.props.flaggedPosts.get(post.id) === 'true'; - } - - let status = ''; - if (this.props.statuses && profile) { - status = this.props.statuses[profile.id] || 'offline'; - } - - const postCtl = ( - <Post - key={keyPrefix + 'postKey'} - ref={post.id} - lastPostCount={(i >= 0 && i < Constants.TEST_ID_COUNT) ? i : -1} - sameUser={sameUser} - sameRoot={sameRoot} - post={post} - parentPost={parentPost} - hideProfilePic={hideProfilePic} - isLastComment={isLastComment} - shouldHighlight={shouldHighlight} - displayNameType={this.props.displayNameType} - user={profile} - currentUser={this.props.currentUser} - center={this.props.displayPostsInCenter} - commentCount={commentCount} - isCommentMention={isCommentMention} - compactDisplay={this.props.compactDisplay} - previewCollapsed={this.props.previewsCollapsed} - useMilitaryTime={this.props.useMilitaryTime} - isFlagged={isFlagged} - status={status} - isBusy={this.props.isBusy} - childComponentDidUpdateFunction={this.childComponentDidUpdate} - getPostList={this.getPostList} - /> - ); - - const currentPostDay = Utils.getDateForUnixTicks(post.create_at); - if (currentPostDay.toDateString() !== previousPostDay.toDateString()) { - postCtls.push( - <div - key={currentPostDay.toDateString()} - className='date-separator' - > - <hr className='separator__hr'/> - <div className='separator__text'> - <FormattedDate - value={currentPostDay} - weekday='short' - month='short' - day='2-digit' - year='numeric' - /> - </div> - </div> - ); - } - - if ((postUserId !== userId || this.props.ownNewMessage) && - this.props.lastViewed !== 0 && - post.create_at > this.props.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( - <div - id={newSeparatorId} - key='unviewed' - ref='newMessageSeparator' - className='new-separator' - > - <hr - className='separator__hr' - /> - <div className='separator__text'> - <FormattedMessage - id='posts_view.newMsg' - defaultMessage='New Messages' - /> - </div> - </div> - ); - } - postCtls.push(postCtl); - previousPostDay = currentPostDay; - } - - return postCtls; - } - - updateScrolling() { - if (this.props.scrollType === ScrollTypes.BOTTOM) { - this.scrollToBottom(); - } else if (this.props.scrollType === ScrollTypes.NEW_MESSAGE) { - window.requestAnimationFrame(() => { - // If separator exists scroll to it. Otherwise scroll to bottom. - if (this.refs.newMessageSeparator) { - var objDiv = this.refs.postlist; - objDiv.scrollTop = this.refs.newMessageSeparator.offsetTop; //scrolls node to top of Div - } else if (this.refs.postlist) { - this.scrollToBottom(); - } - }); - - // This avoids the scroll jumping from top to bottom after the page has rendered (PLT-5025). - if (!this.refs.newMessageSeparator) { - this.scrollToBottom(); - } - } else if (this.props.scrollType === ScrollTypes.POST && this.props.scrollPostId) { - window.requestAnimationFrame(() => { - const postNode = ReactDOM.findDOMNode(this.refs[this.props.scrollPostId]); - if (postNode == null) { - return; - } - postNode.scrollIntoView(); - if (this.refs.postlist.scrollTop === postNode.offsetTop) { - this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / Constants.SCROLL_PAGE_FRACTION); - } else { - this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / Constants.SCROLL_PAGE_FRACTION) + (this.refs.postlist.scrollTop - postNode.offsetTop); - } - }); - } else if (this.props.scrollType === ScrollTypes.SIDEBAR_OPEN) { - // If we are at the bottom then stay there - if (this.wasAtBottom) { - this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; - } else { - window.requestAnimationFrame(() => { - this.jumpToPostNode.scrollIntoView(); - if (this.refs.postlist.scrollTop === this.jumpToPostNode.offsetTop) { - this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / Constants.SCROLL_PAGE_FRACTION); - } else { - this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / Constants.SCROLL_PAGE_FRACTION) + (this.refs.postlist.scrollTop - this.jumpToPostNode.offsetTop); - } - }); - } - } else if (this.refs.postlist.scrollHeight !== this.prevScrollHeight) { - window.requestAnimationFrame(() => { - if (this.jumpToPostNode && this.refs.postlist) { - this.refs.postlist.scrollTop += (this.jumpToPostNode.offsetTop - this.prevOffsetTop); - } - }); - } - } - - handleResize() { - this.updateScrolling(); - } - - scrollToBottom() { - this.animationFrameId = window.requestAnimationFrame(() => { - if (this.refs.postlist) { - this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; - } - }); - } - - scrollToBottomAnimated() { - if (UserAgent.isIos()) { - // JQuery animation doesn't work on iOS - this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; - } else { - var postList = $(this.refs.postlist); - - postList.animate({scrollTop: this.refs.postlist.scrollHeight}, '500'); - } - } - - getArchivesIntroMessage() { - return ( - <div className={'channel-intro'}> - <h4 className='channel-intro__title'> - <FormattedMessage - id='post_focus_view.beginning' - defaultMessage='Beginning of Channel Archives' - /> - </h4> - </div> - ); - } - - checkAndUpdateScrolling() { - if (this.props.postList != null && this.refs.postlist) { - this.updateScrolling(); - } - } - - componentDidMount() { - if (this.props.postList != null) { - this.updateScrolling(); - } - - window.addEventListener('resize', this.handleResize); - window.addEventListener('keydown', this.handleKeyDown); - - PostStore.addPostDraftChangeListener(this.props.channelId, this.handlePostDraftChange); - ScrollStore.addPostScrollListener(this.checkAndUpdateScrolling); - } - - handlePostDraftChange = (draft) => { - // this.state.draft isn't used anywhere, but this will cause an update to the scroll position - // without causing two updates to trigger when something else changes - this.setState({ - draft - }); - } - - componentWillUnmount() { - window.cancelAnimationFrame(this.animationFrameId); - window.removeEventListener('resize', this.handleResize); - window.removeEventListener('keydown', this.handleKeyDown); - ScrollStore.removePostScrollListener(this.checkAndUpdateScrolling); - this.scrollStopAction.cancel(); - - PostStore.removePostDraftChangeListener(this.props.channelId, this.handlePostDraftChange); - } - - componentDidUpdate() { - this.checkAndUpdateScrolling(); - } - - childComponentDidUpdate() { - this.checkAndUpdateScrolling(); - } - - getPostList = () => { - return this.refs.postlist; - } - - render() { - // Create intro message or top loadmore link - let moreMessagesTop; - if (this.props.showMoreMessagesTop) { - moreMessagesTop = ( - <a - ref='loadmoretop' - className='more-messages-text theme' - href='#' - onClick={this.loadMorePostsTop} - > - <FormattedMessage - id='posts_view.loadMore' - defaultMessage='Load more messages' - /> - </a> - ); - } else { - moreMessagesTop = this.introText; - } - - // Give option to load more posts at bottom if necessary - let moreMessagesBottom; - if (this.props.showMoreMessagesBottom) { - moreMessagesBottom = ( - <a - ref='loadmorebottom' - className='more-messages-text theme' - href='#' - onClick={this.loadMorePostsBottom} - > - <FormattedMessage id='posts_view.loadMore'/> - </a> - ); - } - - // Create post elements - let postElements = null; - let topPostCreateAt = 0; - if (this.props.postList) { - const posts = this.props.postList.posts; - const order = this.props.postList.order; - - postElements = this.createPosts(posts, order); - - if (this.state.topPostId && this.props.postList.posts[this.state.topPostId]) { - topPostCreateAt = this.props.postList.posts[this.state.topPostId].create_at; - } - } - - return ( - <div> - <FloatingTimestamp - isScrolling={this.state.isScrolling} - isMobile={Utils.isMobile()} - createAt={topPostCreateAt} - /> - <ScrollToBottomArrows - isScrolling={this.state.isScrolling} - atBottom={this.wasAtBottom} - onClick={this.scrollToBottomAnimated} - /> - <NewMessageIndicator - newMessages={this.state.unViewedCount} - onClick={this.scrollToBottomAnimated} - /> - <div - ref='postlist' - className='post-list-holder-by-time' - onScroll={this.handleScroll} - > - <div className='post-list__table'> - <div - ref='postlistcontent' - className='post-list__content' - > - {moreMessagesTop} - {postElements} - {moreMessagesBottom} - </div> - </div> - </div> - </div> - ); - } -} - -PostList.defaultProps = { - lastViewed: 0, - lastViewedBottom: Number.MAX_VALUE, - ownNewMessage: false -}; - -PostList.propTypes = { - postList: PropTypes.object, - profiles: PropTypes.object, - channel: PropTypes.object, - channelId: PropTypes.string.isRequired, - currentUser: PropTypes.object, - scrollPostId: PropTypes.string, - scrollType: PropTypes.number, - postListScrolled: PropTypes.func.isRequired, - showMoreMessagesTop: PropTypes.bool, - showMoreMessagesBottom: PropTypes.bool, - lastViewed: PropTypes.number, - lastViewedBottom: PropTypes.number, - ownNewMessage: PropTypes.bool, - postsToHighlight: PropTypes.object, - displayNameType: PropTypes.string, - displayPostsInCenter: PropTypes.bool, - compactDisplay: PropTypes.bool, - previewsCollapsed: PropTypes.string, - useMilitaryTime: PropTypes.bool.isRequired, - isFocusPost: PropTypes.bool, - flaggedPosts: PropTypes.object, - statuses: PropTypes.object, - isBusy: PropTypes.bool -}; diff --git a/webapp/components/post_view/components/post_message_container.jsx b/webapp/components/post_view/components/post_message_container.jsx deleted file mode 100644 index 91ca03828..000000000 --- a/webapp/components/post_view/components/post_message_container.jsx +++ /dev/null @@ -1,106 +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 ChannelStore from 'stores/channel_store.jsx'; -import EmojiStore from 'stores/emoji_store.jsx'; -import PreferenceStore from 'stores/preference_store.jsx'; -import {Preferences} from 'utils/constants.jsx'; -import TeamStore from 'stores/team_store.jsx'; -import UserStore from 'stores/user_store.jsx'; - -import PostMessageView from './post_message_view.jsx'; - -export default class PostMessageContainer extends React.Component { - static propTypes = { - post: PropTypes.object.isRequired, - options: PropTypes.object, - lastPostCount: PropTypes.number - }; - - static defaultProps = { - options: {} - }; - - constructor(props) { - super(props); - - this.onEmojiChange = this.onEmojiChange.bind(this); - this.onPreferenceChange = this.onPreferenceChange.bind(this); - this.onUserChange = this.onUserChange.bind(this); - this.onChannelChange = this.onChannelChange.bind(this); - - const mentionKeys = UserStore.getCurrentMentionKeys(); - mentionKeys.push('@here'); - - this.state = { - emojis: EmojiStore.getEmojis(), - enableFormatting: PreferenceStore.getBool(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true), - mentionKeys, - usernameMap: UserStore.getProfilesUsernameMap(), - channelNamesMap: ChannelStore.getChannelNamesMap(), - team: TeamStore.getCurrent() - }; - } - - componentDidMount() { - EmojiStore.addChangeListener(this.onEmojiChange); - PreferenceStore.addChangeListener(this.onPreferenceChange); - UserStore.addChangeListener(this.onUserChange); - ChannelStore.addChangeListener(this.onChannelChange); - } - - componentWillUnmount() { - EmojiStore.removeChangeListener(this.onEmojiChange); - PreferenceStore.removeChangeListener(this.onPreferenceChange); - UserStore.removeChangeListener(this.onUserChange); - ChannelStore.removeChangeListener(this.onChannelChange); - } - - onEmojiChange() { - this.setState({ - emojis: EmojiStore.getEmojis() - }); - } - - onPreferenceChange() { - this.setState({ - enableFormatting: PreferenceStore.getBool(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true) - }); - } - - onUserChange() { - const mentionKeys = UserStore.getCurrentMentionKeys(); - mentionKeys.push('@here'); - - this.setState({ - mentionKeys, - usernameMap: UserStore.getProfilesUsernameMap() - }); - } - - onChannelChange() { - this.setState({ - channelNamesMap: ChannelStore.getChannelNamesMap() - }); - } - - render() { - return ( - <PostMessageView - options={this.props.options} - post={this.props.post} - lastPostCount={this.props.lastPostCount} - emojis={this.state.emojis} - enableFormatting={this.state.enableFormatting} - mentionKeys={this.state.mentionKeys} - usernameMap={this.state.usernameMap} - channelNamesMap={this.state.channelNamesMap} - team={this.state.team} - /> - ); - } -} diff --git a/webapp/components/post_view/components/reaction_container.jsx b/webapp/components/post_view/components/reaction_container.jsx deleted file mode 100644 index 29936c60a..000000000 --- a/webapp/components/post_view/components/reaction_container.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import PropTypes from 'prop-types'; - -// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import React from 'react'; - -import {addReaction, removeReaction} from 'actions/post_actions.jsx'; -import * as UserActions from 'actions/user_actions.jsx'; - -import UserStore from 'stores/user_store.jsx'; - -import Reaction from './reaction.jsx'; - -export default class ReactionContainer extends React.Component { - static propTypes = { - post: PropTypes.object.isRequired, - emojiName: PropTypes.string.isRequired, - reactions: PropTypes.arrayOf(PropTypes.object), - emojis: PropTypes.object.isRequired - } - - constructor(props) { - super(props); - - this.handleUsersChanged = this.handleUsersChanged.bind(this); - - this.getStateFromStore = this.getStateFromStore.bind(this); - - this.getProfilesForReactions = this.getProfilesForReactions.bind(this); - this.getMissingProfiles = this.getMissingProfiles.bind(this); - - this.state = this.getStateFromStore(props); - } - - componentDidMount() { - UserStore.addChangeListener(this.handleUsersChanged); - } - - componentWillReceiveProps(nextProps) { - if (nextProps.reactions !== this.props.reactions) { - this.setState(this.getStateFromStore(nextProps)); - } - } - - componentWillUnmount() { - UserStore.removeChangeListener(this.handleUsersChanged); - } - - handleUsersChanged() { - this.setState(this.getStateFromStore()); - } - - getStateFromStore(props = this.props) { - const profiles = this.getProfilesForReactions(props.reactions); - const otherUsers = props.reactions.length - profiles.length; - - return { - profiles, - otherUsers, - currentUserId: UserStore.getCurrentId() - }; - } - - getProfilesForReactions(reactions) { - return reactions.map((reaction) => { - return UserStore.getProfile(reaction.user_id); - }).filter((profile) => Boolean(profile)); - } - - getMissingProfiles() { - const ids = this.props.reactions.map((reaction) => reaction.user_id); - - UserActions.getMissingProfiles(ids); - } - - render() { - return ( - <Reaction - {...this.props} - {...this.state} - actions={{ - addReaction, - getMissingProfiles: this.getMissingProfiles, - removeReaction - }} - /> - ); - } -} diff --git a/webapp/components/post_view/components/reaction_list_container.jsx b/webapp/components/post_view/components/reaction_list_container.jsx deleted file mode 100644 index fbc5f683c..000000000 --- a/webapp/components/post_view/components/reaction_list_container.jsx +++ /dev/null @@ -1,94 +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 EmojiStore from 'stores/emoji_store.jsx'; -import ReactionStore from 'stores/reaction_store.jsx'; - -import ReactionListView from './reaction_list_view.jsx'; - -export default class ReactionListContainer extends React.Component { - static propTypes = { - post: PropTypes.object.isRequired - } - - constructor(props) { - super(props); - - this.handleReactionsChanged = this.handleReactionsChanged.bind(this); - this.handleEmojisChanged = this.handleEmojisChanged.bind(this); - - this.state = { - reactions: ReactionStore.getReactions(this.props.post.id), - emojis: EmojiStore.getEmojis() - }; - } - - componentDidMount() { - ReactionStore.addChangeListener(this.props.post.id, this.handleReactionsChanged); - EmojiStore.addChangeListener(this.handleEmojisChanged); - - if (this.props.post.has_reactions) { - AsyncClient.listReactions(this.props.post.channel_id, this.props.post.id); - } - } - - componentWillReceiveProps(nextProps) { - if (nextProps.post.id !== this.props.post.id) { - ReactionStore.removeChangeListener(this.props.post.id, this.handleReactionsChanged); - ReactionStore.addChangeListener(nextProps.post.id, this.handleReactionsChanged); - - this.setState({ - reactions: ReactionStore.getReactions(nextProps.post.id) - }); - } - } - - shouldComponentUpdate(nextProps, nextState) { - if (nextProps.post.has_reactions !== this.props.post.has_reactions) { - return true; - } - - if (nextState.reactions !== this.state.reactions) { - // this will only work so long as the entries in the ReactionStore are never mutated - return true; - } - - if (nextState.emojis !== this.state.emojis) { - return true; - } - - return false; - } - - componentWillUnmount() { - ReactionStore.removeChangeListener(this.props.post.id, this.handleReactionsChanged); - EmojiStore.removeChangeListener(this.handleEmojisChanged); - } - - handleReactionsChanged() { - this.setState({ - reactions: ReactionStore.getReactions(this.props.post.id) - }); - } - - handleEmojisChanged() { - this.setState({ - emojis: EmojiStore.getEmojis() - }); - } - - render() { - return ( - <ReactionListView - post={this.props.post} - reactions={this.state.reactions} - emojis={this.state.emojis} - /> - ); - } -} diff --git a/webapp/components/post_view/date_separator.jsx b/webapp/components/post_view/date_separator.jsx new file mode 100644 index 000000000..3f5184dbf --- /dev/null +++ b/webapp/components/post_view/date_separator.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {FormattedDate} from 'react-intl'; + +export default class DateSeparator extends React.PureComponent { + static propTypes = { + + /* + * The date to display in the separator + */ + date: PropTypes.instanceOf(Date) + } + + render() { + return ( + <div + className='date-separator' + > + <hr className='separator__hr'/> + <div className='separator__text'> + <FormattedDate + value={this.props.date} + weekday='short' + month='short' + day='2-digit' + year='numeric' + /> + </div> + </div> + ); + } +} diff --git a/webapp/components/post_view/components/pending_post_options.jsx b/webapp/components/post_view/failed_post_options/failed_post_options.jsx index 9742a74bf..f28de343b 100644 --- a/webapp/components/post_view/components/pending_post_options.jsx +++ b/webapp/components/post_view/failed_post_options/failed_post_options.jsx @@ -1,19 +1,28 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import PostStore from 'stores/post_store.jsx'; - -import {queuePost} from 'actions/post_actions.jsx'; - -import Constants from 'utils/constants.jsx'; +import {createPost} from 'actions/post_actions.jsx'; +import React from 'react'; +import PropTypes from 'prop-types'; import {FormattedMessage} from 'react-intl'; -import PropTypes from 'prop-types'; +export default class FailedPostOptions extends React.Component { + static propTypes = { -import React from 'react'; + /* + * The failed post + */ + post: PropTypes.object.isRequired, + actions: PropTypes.shape({ + + /** + * The function to delete the post + */ + removePost: PropTypes.func.isRequired + }).isRequired + } -export default class PendingPostOptions extends React.Component { constructor(props) { super(props); @@ -24,6 +33,7 @@ export default class PendingPostOptions extends React.Component { this.state = {}; } + retryPost(e) { e.preventDefault(); @@ -33,8 +43,12 @@ export default class PendingPostOptions extends React.Component { this.submitting = true; - var post = this.props.post; - queuePost(post, true, null, + const post = {...this.props.post}; + Reflect.deleteProperty(post, 'id'); + createPost(post, + () => { + this.submitting = false; + }, (err) => { if (err.id === 'api.post.create_post.root_id.app_error') { this.showPostDeletedModal(); @@ -45,18 +59,13 @@ export default class PendingPostOptions extends React.Component { this.submitting = false; } ); - - post.state = Constants.POST_LOADING; - PostStore.updatePendingPost(post); - this.forceUpdate(); } + cancelPost(e) { e.preventDefault(); - - var post = this.props.post; - PostStore.removePendingPost(post.channel_id, post.pending_post_id); - this.forceUpdate(); + this.props.actions.removePost(this.props.post); } + render() { return (<span className='pending-post-actions'> <a @@ -83,7 +92,3 @@ export default class PendingPostOptions extends React.Component { </span>); } } - -PendingPostOptions.propTypes = { - post: PropTypes.object -}; diff --git a/webapp/components/post_view/failed_post_options/index.js b/webapp/components/post_view/failed_post_options/index.js new file mode 100644 index 000000000..bb8dde893 --- /dev/null +++ b/webapp/components/post_view/failed_post_options/index.js @@ -0,0 +1,24 @@ +// 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} from 'mattermost-redux/actions/posts'; + +import FailedPostOptions from './failed_post_options.jsx'; + +function mapStateToProps(state, ownProps) { + return { + ...ownProps + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + removePost + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(FailedPostOptions); diff --git a/webapp/components/post_view/components/floating_timestamp.jsx b/webapp/components/post_view/floating_timestamp.jsx index 34e6ce006..f0f6af60e 100644 --- a/webapp/components/post_view/components/floating_timestamp.jsx +++ b/webapp/components/post_view/floating_timestamp.jsx @@ -3,16 +3,15 @@ import {FormattedDate} from 'react-intl'; -import PropTypes from 'prop-types'; - import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; - -export default class FloatingTimestamp extends React.Component { - constructor(props) { - super(props); +import PropTypes from 'prop-types'; - this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this); +export default class FloatingTimestamp extends React.PureComponent { + static propTypes = { + isScrolling: PropTypes.bool.isRequired, + isMobile: PropTypes.bool, + createAt: PropTypes.number, + isRhsPost: PropTypes.bool } render() { @@ -52,10 +51,3 @@ export default class FloatingTimestamp extends React.Component { ); } } - -FloatingTimestamp.propTypes = { - isScrolling: PropTypes.bool.isRequired, - isMobile: PropTypes.bool, - createAt: PropTypes.number, - isRhsPost: PropTypes.bool -}; diff --git a/webapp/components/post_view/index.js b/webapp/components/post_view/index.js index b42b486ab..ad0270cdd 100644 --- a/webapp/components/post_view/index.js +++ b/webapp/components/post_view/index.js @@ -3,22 +3,52 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {viewChannel} from 'mattermost-redux/actions/channels'; -import PostViewCache from './post_view_cache.jsx'; +import {makeGetPostsInChannel, makeGetPostsAroundPost} from 'mattermost-redux/selectors/entities/posts'; +import {get} from 'mattermost-redux/selectors/entities/preferences'; +import {getChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {getPosts, getPostsBefore, getPostsAfter, getPostThread} from 'mattermost-redux/actions/posts'; +import {increasePostVisibility} from 'actions/post_actions.jsx'; +import {Preferences} from 'utils/constants.jsx'; -function mapStateToProps(state, ownProps) { - return { - ...ownProps +import PostList from './post_list.jsx'; + +function makeMapStateToProps() { + const getPostsInChannel = makeGetPostsInChannel(); + const getPostsAroundPost = makeGetPostsAroundPost(); + + return function mapStateToProps(state, ownProps) { + let posts; + if (ownProps.focusedPostId) { + posts = getPostsAroundPost(state, ownProps.focusedPostId, ownProps.channelId); + } else { + posts = getPostsInChannel(state, ownProps.channelId); + } + + return { + channel: getChannel(state, ownProps.channelId), + lastViewedAt: state.views.channel.lastChannelViewTime[ownProps.channelId], + posts, + postVisibility: state.views.channel.postVisibility[ownProps.channelId], + loadingPosts: state.views.channel.loadingPosts[ownProps.channelId], + focusedPostId: ownProps.focusedPostId, + currentUserId: getCurrentUserId(state), + fullWidth: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN + }; }; } function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ - viewChannel + getPosts, + getPostsBefore, + getPostsAfter, + getPostThread, + increasePostVisibility }, dispatch) }; } -export default connect(mapStateToProps, mapDispatchToProps)(PostViewCache); +export default connect(makeMapStateToProps, mapDispatchToProps)(PostList); diff --git a/webapp/components/post_view/components/new_message_indicator.jsx b/webapp/components/post_view/new_message_indicator.jsx index cafdc128a..d5fb6c1d3 100644 --- a/webapp/components/post_view/components/new_message_indicator.jsx +++ b/webapp/components/post_view/new_message_indicator.jsx @@ -1,11 +1,16 @@ -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'; -export default class NewMessageIndicator extends React.Component { +export default class NewMessageIndicator extends React.PureComponent { + static propTypes = { + onClick: PropTypes.func.isRequired, + newMessages: PropTypes.number + } + constructor(props) { super(props); this.state = { @@ -13,6 +18,7 @@ export default class NewMessageIndicator extends React.Component { rendered: false }; } + componentWillReceiveProps(nextProps) { if (nextProps.newMessages > 0) { this.setState({rendered: true}, () => { @@ -22,6 +28,7 @@ export default class NewMessageIndicator extends React.Component { this.setState({visible: false}); } } + render() { let className = 'new-messages__button'; if (this.state.visible > 0) { @@ -56,11 +63,7 @@ export default class NewMessageIndicator extends React.Component { this.setState({rendered: this.state.visible}); } } + NewMessageIndicator.defaultProps = { newMessages: 0 }; - -NewMessageIndicator.propTypes = { - onClick: PropTypes.func.isRequired, - newMessages: PropTypes.number -}; diff --git a/webapp/components/post_view/post/index.js b/webapp/components/post_view/post/index.js new file mode 100644 index 000000000..1e195f920 --- /dev/null +++ b/webapp/components/post_view/post/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 {getCurrentUser, getUser, getStatusForUserId} 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 Post from './post.jsx'; + +function mapStateToProps(state, ownProps) { + const detailedPost = ownProps.post; + return { + post: getPost(state, detailedPost.id), + lastPostCount: ownProps.lastPostCount, + user: getUser(state, ownProps.post.user_id), + status: getStatusForUserId(state, ownProps.post.user_id), + currentUser: getCurrentUser(state), + isFirstReply: Boolean(detailedPost.isFirstReply && detailedPost.commentedOnPost), + highlight: detailedPost.highlight, + consecutivePostByUser: detailedPost.consecutivePostByUser, + previousPostIsComment: detailedPost.previousPostIsComment, + replyCount: detailedPost.replyCount, + isCommentMention: detailedPost.isCommentMention, + center: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_CENTERED, + compactDisplay: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT + }; +} + +export default connect(mapStateToProps)(Post); diff --git a/webapp/components/post_view/components/post.jsx b/webapp/components/post_view/post/post.jsx index b9823396d..eda4405bb 100644 --- a/webapp/components/post_view/components/post.jsx +++ b/webapp/components/post_view/post/post.jsx @@ -1,46 +1,99 @@ -import PropTypes from 'prop-types'; - // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React, {Component} from 'react'; - +import PostHeader from 'components/post_view/post_header'; +import PostBody from 'components/post_view/post_body'; import ProfilePicture from 'components/profile_picture.jsx'; -import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; +import Constants from 'utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; +import {Posts} from 'mattermost-redux/constants'; -import Constants, {ActionTypes} from 'utils/constants.jsx'; -import * as PostUtils from 'utils/post_utils.jsx'; import * as Utils from 'utils/utils.jsx'; +import * as PostUtils from 'utils/post_utils.jsx'; +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; -import PostBody from './post_body.jsx'; -import PostHeader from './post_header.jsx'; +import React from 'react'; +import PropTypes from 'prop-types'; -export default class Post extends Component { +export default class Post extends React.PureComponent { static propTypes = { + + /** + * The post to render + */ post: PropTypes.object.isRequired, - parentPost: PropTypes.object, + + /** + * The user who created the post + */ user: PropTypes.object, - sameUser: PropTypes.bool, - sameRoot: PropTypes.bool, - hideProfilePic: PropTypes.bool, - lastPostCount: PropTypes.number, - isLastComment: PropTypes.bool, - shouldHighlight: PropTypes.bool, - displayNameType: PropTypes.string, + + /** + * The status of the poster + */ + status: PropTypes.string, + + /** + * The logged in user + */ currentUser: PropTypes.object.isRequired, + + /** + * Set to center the post + */ center: PropTypes.bool, + + /** + * Set to render post compactly + */ compactDisplay: PropTypes.bool, - previewCollapsed: PropTypes.string, - commentCount: PropTypes.number, + + /** + * Set to render a preview of the parent post above this reply + */ + isFirstReply: PropTypes.bool, + + /** + * Set to highlight the background of the post + */ + highlight: PropTypes.bool, + + /** + * Set to render this post as if it was attached to the previous post + */ + consecutivePostByUser: PropTypes.bool, + + /** + * Set if the previous post is a comment + */ + previousPostIsComment: PropTypes.bool, + + /** + * Set to render this comment as a mention + */ isCommentMention: PropTypes.bool, - useMilitaryTime: PropTypes.bool.isRequired, - isFlagged: PropTypes.bool, - status: PropTypes.string, + + /** + * The number of replies in the same thread as this post + */ + replyCount: PropTypes.number, + + /** + * Set to mark the poster as in a webrtc call + */ isBusy: PropTypes.bool, - childComponentDidUpdateFunction: PropTypes.func, + + /** + * The post count used for selenium tests + */ + lastPostCount: PropTypes.number, + + /** + * Function to get the post list HTML element + */ getPostList: PropTypes.func.isRequired - }; + } constructor(props) { super(props); @@ -75,91 +128,23 @@ export default class Post extends Component { this.refs.header.forceUpdate(); } - shouldComponentUpdate(nextProps, nextState) { - if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) { - return true; - } - - if (nextProps.sameRoot !== this.props.sameRoot) { - return true; - } - - if (nextProps.sameUser !== this.props.sameUser) { - return true; - } - - if (nextProps.displayNameType !== this.props.displayNameType) { - return true; - } - - if (nextProps.commentCount !== this.props.commentCount) { - return true; - } - - if (nextProps.isCommentMention !== this.props.isCommentMention) { - return true; - } - - if (nextProps.shouldHighlight !== this.props.shouldHighlight) { - return true; - } - - if (nextProps.center !== this.props.center) { - return true; - } - - if (nextProps.compactDisplay !== this.props.compactDisplay) { - return true; - } - - if (nextProps.previewCollapsed !== this.props.previewCollapsed) { - return true; - } - - if (nextProps.useMilitaryTime !== this.props.useMilitaryTime) { - return true; - } - - if (nextProps.isFlagged !== this.props.isFlagged) { - return true; - } - - if (nextProps.status !== this.props.status) { - return true; - } - - if (!Utils.areObjectsEqual(nextProps.user, this.props.user)) { - return true; - } - - if (nextState.dropdownOpened !== this.state.dropdownOpened) { - return true; - } - - if (nextProps.isBusy !== this.props.isBusy) { - return true; - } - - if (nextProps.lastPostCount !== this.props.lastPostCount) { - return true; - } - - return false; - } - getClassName = (post, isSystemMessage, fromWebhook) => { let className = 'post'; - if (post.state === Constants.POST_DELETED || post.state === Constants.POST_FAILED) { + if (post.failed || post.state === Posts.POST_DELETED) { className += ' post--hide-controls'; } - if (this.props.shouldHighlight) { + if (this.props.highlight) { className += ' post--highlight'; } - let rootUser; - if (this.props.sameRoot) { + let rootUser = ''; + if (this.props.isFirstReply) { + rootUser = 'other--root'; + } else if (!post.root_id && !this.props.previousPostIsComment && this.props.consecutivePostByUser) { + rootUser = 'same--root'; + } else if (post.root_id) { rootUser = 'same--root'; } else { rootUser = 'other--root'; @@ -171,14 +156,14 @@ export default class Post extends Component { } let sameUserClass = ''; - if (this.props.sameUser) { + if (this.props.consecutivePostByUser) { sameUserClass = 'same--user'; } let postType = ''; if (post.root_id && post.root_id.length > 0) { postType = 'post--comment'; - } else if (this.props.commentCount > 0) { + } else if (this.props.replyCount > 0) { postType = 'post--root'; sameUserClass = ''; rootUser = ''; @@ -209,7 +194,6 @@ export default class Post extends Component { render() { const post = this.props.post; - const parentPost = this.props.parentPost; const mattermostLogo = Constants.MATTERMOST_ICON_SVG; const isSystemMessage = PostUtils.isSystemMessage(post); @@ -242,7 +226,7 @@ export default class Post extends Component { src={PostUtils.getProfilePicSrcForPost(post, timestamp)} /> ); - } else if (isSystemMessage) { + } else if (PostUtils.isSystemMessage(post)) { profilePic = ( <span className='icon' @@ -257,7 +241,7 @@ export default class Post extends Component { } if (this.props.compactDisplay) { - if (post.props && post.props.from_webhook) { + if (fromWebhook) { profilePic = ( <ProfilePicture src='' @@ -294,34 +278,24 @@ export default class Post extends Component { <PostHeader ref='header' post={post} - sameRoot={this.props.sameRoot} - lastPostCount={this.props.lastPostCount} - commentCount={this.props.commentCount} handleCommentClick={this.handleCommentClick} handleDropdownOpened={this.handleDropdownOpened} - isLastComment={this.props.isLastComment} - sameUser={this.props.sameUser} user={this.props.user} currentUser={this.props.currentUser} compactDisplay={this.props.compactDisplay} - displayNameType={this.props.displayNameType} - useMilitaryTime={this.props.useMilitaryTime} - isFlagged={this.props.isFlagged} status={this.props.status} isBusy={this.props.isBusy} + lastPostCount={this.props.lastPostCount} + replyCount={this.props.replyCount} + consecutivePostByUser={this.props.consecutivePostByUser} getPostList={this.props.getPostList} /> <PostBody post={post} - currentUser={this.props.currentUser} - sameRoot={this.props.sameRoot} - lastPostCount={this.props.lastPostCount} - parentPost={parentPost} handleCommentClick={this.handleCommentClick} compactDisplay={this.props.compactDisplay} - previewCollapsed={this.props.previewCollapsed} + lastPostCount={this.props.lastPostCount} isCommentMention={this.props.isCommentMention} - childComponentDidUpdateFunction={this.props.childComponentDidUpdateFunction} /> </div> </div> diff --git a/webapp/components/post_view/components/post_attachment.jsx b/webapp/components/post_view/post_attachment.jsx index e873ef9c7..b7bd1ade9 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 ? `<div><a class="attachment-link-more" href="#">${this.props.intl.formatMessage(holders.collapse)}</a></div>` : ''); + const uncollapsedText = text + (shouldCollapse ? `<div><a class="attachment-link-more" href="#">${localizeMessage('post_attachment.collapse', 'Show less...')}</a></div>` : ''); 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) + `<div><a class="attachment-link-more" href="#">${this.props.intl.formatMessage(holders.more)}</a></div>`; + return TextFormatting.formatText(text) + `<div><a class="attachment-link-more" href="#">${localizeMessage('post_attachment.more', 'Show more...')}</a></div>`; } 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 000000000..cfd2f81f8 --- /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( + <PostAttachment + attachment={attachment} + key={'att_' + i} + /> + ); + }); + + return ( + <div className='attachment_list'> + {content} + </div> + ); + } +} 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 000000000..e0bec8f36 --- /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 index 129111800..dbf8f6049 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 000000000..37cf114b0 --- /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 index 0f481ec02..a60d25760 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); - - this.removePost = this.removePost.bind(this); - } - - shouldComponentUpdate(nextProps) { - if (nextProps.isCommentMention !== this.props.isCommentMention) { - return true; - } - - if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) { - return true; - } - - if (!Utils.areObjectsEqual(nextProps.parentPost, this.props.parentPost)) { - return true; - } - - if (nextProps.compactDisplay !== this.props.compactDisplay) { - return true; - } - - if (nextProps.previewCollapsed !== this.props.previewCollapsed) { - return true; - } - - if (nextProps.handleCommentClick.toString() !== this.props.handleCommentClick.toString()) { - return true; - } - - if (nextProps.lastPostCount !== this.props.lastPostCount) { - return true; - } - - return false; - } - - removePost() { - GlobalActions.emitRemovePost(this.props.post); +export default class PostBody extends React.PureComponent { + static propTypes = { + + /** + * The post to render the body of + */ + post: PropTypes.object.isRequired, + + /** + * The parent post of the thread this post is in + */ + parentPost: PropTypes.object, + + /** + * The poster of the parent post, if exists + */ + parentPostUser: PropTypes.object, + + /** + * The function called when the comment icon is clicked + */ + handleCommentClick: PropTypes.func.isRequired, + + /** + * Set to render post body compactly + */ + compactDisplay: PropTypes.bool, + + /** + * Set to highlight comment as a mention + */ + isCommentMention: PropTypes.bool, + + /** + * Set to collapse image and video previews + */ + previewCollapsed: PropTypes.string, + + /** + * 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 = ( - <CommentedOnFilesMessageContainer - parentPostChannelId={parentPost.channel_id} + <CommentedOnFilesMessage parentPostId={parentPost.id} /> ); @@ -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 = <PendingPostOptions post={this.props.post}/>; - } else if (post.state === Constants.POST_LOADING) { - postClass += ' post-waiting'; - loading = ( - <img - className='post-loading-gif pull-right' - src={loadingGif} - /> - ); + failedOptions = <FailedPostOptions post={this.props.post}/>; } 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 = ( <FileAttachmentListContainer post={post} @@ -168,7 +155,7 @@ export default class PostBody extends React.Component { id={`${post.id}_message`} className={postClass} > - {loading} + {failedOptions} <PostMessageContainer lastPostCount={this.props.lastPostCount} post={this.props.post} @@ -177,16 +164,14 @@ export default class PostBody extends React.Component { ); let messageWithAdditionalContent; - if (this.props.post.state === Constants.POST_DELETED) { + if (this.props.post.state === Posts.POST_DELETED) { messageWithAdditionalContent = messageWrapper; } else { messageWithAdditionalContent = ( <PostBodyAdditionalContent post={this.props.post} message={messageWrapper} - compactDisplay={this.props.compactDisplay} previewCollapsed={this.props.previewCollapsed} - childComponentDidUpdateFunction={this.props.childComponentDidUpdateFunction} /> ); } @@ -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 index 180681100..bf8380912 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 ( <PostAttachmentOpenGraph link={link} - childComponentDidUpdateFunction={this.props.childComponentDidUpdateFunction} previewCollapsed={this.props.previewCollapsed} /> ); @@ -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 index 533b38bff..295bdd116 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 dadc6b80e..000000000 --- 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 = ( - <LoadingScreen - position='absolute' - key='loading' - /> - ); - } else { - content = ( - <PostList - postList={this.state.postList} - currentUser={this.state.currentUser} - profiles={this.state.profiles} - scrollType={this.state.scrollType} - scrollPostId={this.state.scrollPostId} - postListScrolled={this.onPostListScroll} - displayNameType={this.state.displayNameType} - displayPostsInCenter={this.state.displayPostsInCenter} - compactDisplay={this.state.compactDisplay} - previewsCollapsed={this.state.previewsCollapsed} - useMilitaryTime={this.state.useMilitaryTime} - showMoreMessagesTop={!this.state.atTop} - showMoreMessagesBottom={!this.state.atBottom} - postsToHighlight={postsToHighlight} - isFocusPost={true} - emojis={this.state.emojis} - flaggedPosts={this.state.flaggedPosts} - statuses={this.state.statuses} - isBusy={this.state.isBusy} - /> - ); - } - - return ( - <div id='post-list'> - {content} - </div> - ); - } -} 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 000000000..d7aaef1d5 --- /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 index eab2d4629..562bd2b82 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 { <div className='col'> <PostInfo post={post} - lastPostCount={this.props.lastPostCount} - commentCount={this.props.commentCount} handleCommentClick={this.props.handleCommentClick} handleDropdownOpened={this.props.handleDropdownOpened} - isLastComment={this.props.isLastComment} - sameUser={this.props.sameUser} - currentUser={this.props.currentUser} compactDisplay={this.props.compactDisplay} - useMilitaryTime={this.props.useMilitaryTime} - isFlagged={this.props.isFlagged} + lastPostCount={this.props.lastPostCount} + replyCount={this.props.replyCount} + consecutivePostByUser={this.props.consecutivePostByUser} getPostList={this.props.getPostList} /> </div> @@ -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 index 1268c9df2..5feb01db4 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 000000000..749ec5aba --- /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 index 502565752..f037bf03b 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 = ( <CommentIcon - idPrefix={'commentIcon'} + idPrefix='commentIcon' idCount={idCount} handleCommentClick={this.props.handleCommentClick} - commentCount={this.props.commentCount} - id={ChannelStore.getCurrentId() + '_' + post.id} + commentCount={this.props.replyCount} + id={post.channel_id + '_' + post.id} /> ); @@ -116,6 +167,7 @@ export default class PostInfo extends React.Component { <i className='fa fa-smile-o'/> </a> </span> + ); } } @@ -127,13 +179,13 @@ export default class PostInfo extends React.Component { {this.createRemovePostButton()} </div> ); - } else if (!isPending) { + } else if (!post.failed) { const dotMenu = ( <DotMenu idPrefix={Constants.CENTER} idCount={idCount} post={this.props.post} - commentCount={this.props.commentCount} + commentCount={this.props.replyCount} isFlagged={this.props.isFlagged} handleCommentClick={this.props.handleCommentClick} handleDropdownOpened={this.props.handleDropdownOpened} @@ -171,8 +223,6 @@ export default class PostInfo extends React.Component { <div className='col'> <PostTime eventTime={post.create_at} - sameUser={this.props.sameUser} - compactDisplay={this.props.compactDisplay} useMilitaryTime={this.props.useMilitaryTime} postId={post.id} /> @@ -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 000000000..bf0ee079d --- /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 = ( + <Post + ref={post.id} + key={'post ' + (post.id || post.pending_post_id)} + post={post} + lastPostCount={(i >= 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( + <div + key={currentPostDay.toDateString()} + className='date-separator' + > + <hr className='separator__hr'/> + <div className='separator__text'> + <FormattedDate + value={currentPostDay} + weekday='short' + month='short' + day='2-digit' + year='numeric' + /> + </div> + </div> + ); + } + + 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( + <div + id={newSeparatorId} + key='unviewed' + ref='newMessageSeparator' + className='new-separator' + > + <hr + className='separator__hr' + /> + <div className='separator__text'> + <FormattedMessage + id='posts_view.newMsg' + defaultMessage='New Messages' + /> + </div> + </div> + ); + } + + 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 ( + <div id='post-list'> + <LoadingScreen + position='absolute' + key='loading' + /> + </div> + ); + } + + let topRow; + if (this.state.atEnd) { + topRow = createChannelIntroMessage(channel, this.props.fullWidth); + } else if (this.props.postVisibility >= Constants.MAX_POST_VISIBILITY) { + topRow = ( + <div className='post-list__loading post-list__loading-search'> + <FormattedMessage + id='posts_view.maxLoaded' + defaultMessage='Looking for a specific message? Try searching for it' + /> + </div> + ); + } else { + topRow = ( + <a + ref='loadmoretop' + className='more-messages-text theme' + href='#' + onClick={this.loadMorePosts} + > + <FormattedMessage + id='posts_view.loadMore' + defaultMessage='Load more messages' + /> + </a> + ); + } + + 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 ( + <div id='post-list'> + <FloatingTimestamp + isScrolling={this.state.isScrolling} + isMobile={Utils.isMobile()} + createAt={topPostCreateAt} + /> + <ScrollToBottomArrows + isScrolling={this.state.isScrolling} + atBottom={this.wasAtBottom()} + onClick={this.scrollToBottom} + /> + <NewMessageIndicator + newMessages={this.state.unViewedCount} + onClick={this.scrollToBottom} + /> + <div + ref='postlist' + className='post-list-holder-by-time' + key={'postlist-' + channel.id} + onScroll={this.handleScroll} + > + <div className='post-list__table'> + <div + ref='postlistcontent' + className='post-list__content' + > + {topRow} + {this.createPosts(posts.slice(0, postVisibility))} + </div> + </div> + </div> + </div> + ); + } +} 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 000000000..cf457a508 --- /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 index 938b5a8db..66a8d01f8 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 index c134e1a7a..c134e1a7a 100644 --- a/webapp/components/post_view/components/system_message_helpers.jsx +++ b/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 index 9f6ef51cc..133b6b5a3 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() : - ( - <Link - to={`/${this.state.currentTeamDisplayName}/pl/${this.props.postId}`} - target='_blank' - className='post__permalink' - > - {this.renderTimeTag()} - </Link> - ); + if (isMobile()) { + return this.renderTimeTag(); + } + + return ( + <Link + to={`/${this.state.currentTeamDisplayName}/pl/${this.props.postId}`} + target='_blank' + className='post__permalink' + > + {this.renderTimeTag()} + </Link> + ); } } - -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 b8ae39e4a..000000000 --- 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( - <PostViewController - key={'postviewcontroller_' + channels[i].id} - channel={channels[i]} - active={channels[i].id === currentChannelId} - /> - ); - } - - return ( - <div id='post-list'> - {postViews} - </div> - ); - } -} 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 12112ac10..000000000 --- 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 = ( - <LoadingScreen - position='absolute' - key='loading' - /> - ); - } else { - content = ( - <PostList - postList={this.state.postList} - profiles={this.state.profiles} - channelId={this.state.channel.id} - channel={this.state.channel} - currentUser={this.state.currentUser} - showMoreMessagesTop={!this.state.atTop} - scrollType={this.state.scrollType} - scrollPostId={this.state.scrollPostId} - postListScrolled={this.onPostListScroll} - displayNameType={this.state.displayNameType} - displayPostsInCenter={this.state.displayPostsInCenter} - compactDisplay={this.state.compactDisplay} - previewsCollapsed={this.state.previewsCollapsed} - useMilitaryTime={this.state.useMilitaryTime} - flaggedPosts={this.state.flaggedPosts} - lastViewed={this.state.lastViewed} - lastViewedBottom={this.state.lastViewedBottom} - ownNewMessage={this.state.ownNewMessage} - statuses={this.state.statuses} - isBusy={this.state.isBusy} - /> - ); - } - - let activeClass = ''; - if (!this.props.active) { - activeClass = 'inactive'; - } - - return ( - <div className={activeClass}> - {content} - </div> - ); - } -} - -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 000000000..9bb2524a1 --- /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 index d79e9e092..5b65e604f 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 = ( <FormattedMessage @@ -75,7 +109,7 @@ export default class Reaction extends React.Component { defaultMessage='{users} and {otherUsers, number} other {otherUsers, plural, one {user} other {users}}' values={{ users: users.join(', '), - otherUsers + otherUsers: otherUsersCount }} /> ); @@ -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 = ( <FormattedMessage @@ -185,7 +219,7 @@ export default class Reaction extends React.Component { {clickTooltip} </Tooltip> } - onEnter={this.props.actions.getMissingProfiles} + onEnter={this.props.actions.getMissingProfilesByIds} > <div className={className} @@ -193,10 +227,10 @@ export default class Reaction extends React.Component { > <span className='post-reaction__emoji emoticon' - style={{backgroundImage: 'url(' + EmojiStore.getEmojiImageUrl(this.props.emojis.get(this.props.emojiName)) + ')'}} + style={{backgroundImage: 'url(' + this.props.emojiImageUrl + ')'}} /> <span className='post-reaction__count'> - {this.props.reactions.length} + {this.props.reactionCount} </span> </div> </OverlayTrigger> 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 000000000..4fc9355d9 --- /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 index 4379453a3..516f5332f 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 index 73f8e6527..73f8e6527 100644 --- a/webapp/components/post_view/components/scroll_to_bottom_arrows.jsx +++ b/webapp/components/post_view/scroll_to_bottom_arrows.jsx diff --git a/webapp/components/rhs_comment.jsx b/webapp/components/rhs_comment.jsx index 73ae70598..11d64f871 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 ( <time @@ -229,7 +198,6 @@ export default class RhsComment 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); var timestamp = this.props.currentUser.last_picture_update; @@ -283,20 +251,12 @@ export default class RhsComment extends React.Component { ); } - let loading; + let failedPostOptions; let postClass = ''; - if (post.state === Constants.POST_FAILED) { - postClass += ' post-fail'; - loading = <PendingPostOptions post={this.props.post}/>; - } else if (post.state === Constants.POST_LOADING) { - postClass += ' post-waiting'; - loading = ( - <img - className='post-loading-gif pull-right' - src={loadingGif} - /> - ); + if (post.failed) { + postClass += ' post-failed'; + failedPostOptions = <FailedPostOptions post={this.props.post}/>; } if (PostUtils.isEdited(this.props.post)) { @@ -365,7 +325,8 @@ export default class RhsComment extends React.Component { } let react; - if (!isEphemeral && !isPending && !isSystemMessage && Utils.isFeatureEnabled(Constants.PRE_RELEASE_FEATURES.EMOJI_PICKER_PREVIEW)) { + + if (!isEphemeral && !post.failed && !isSystemMessage && Utils.isFeatureEnabled(Constants.PRE_RELEASE_FEATURES.EMOJI_PICKER_PREVIEW)) { react = ( <span> <EmojiPickerOverlay @@ -462,7 +423,7 @@ export default class RhsComment extends React.Component { </div> <div className='post__body' > <div className={postClass}> - {loading} + {failedPostOptions} <PostMessageContainer post={post}/> </div> {fileAttachment} diff --git a/webapp/components/rhs_root_post.jsx b/webapp/components/rhs_root_post.jsx index c617477af..8f464056b 100644 --- a/webapp/components/rhs_root_post.jsx +++ b/webapp/components/rhs_root_post.jsx @@ -2,50 +2,38 @@ // See License.txt for license information. import UserProfile from './user_profile.jsx'; -import PostBodyAdditionalContent from 'components/post_view/components/post_body_additional_content.jsx'; -import PostMessageContainer from 'components/post_view/components/post_message_container.jsx'; -import FileAttachmentListContainer from './file_attachment_list_container.jsx'; +import PostBodyAdditionalContent from 'components/post_view/post_body_additional_content.jsx'; +import PostMessageContainer from 'components/post_view/post_message_view'; +import FileAttachmentListContainer from 'components/file_attachment_list'; 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 DotMenu from 'components/dot_menu'; +import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import UserStore from 'stores/user_store.jsx'; import TeamStore from 'stores/team_store.jsx'; -import * as GlobalActions from 'actions/global_actions.jsx'; -import {flagPost, unflagPost, pinPost, unpinPost, addReaction} from 'actions/post_actions.jsx'; +import {addReaction} from 'actions/post_actions.jsx'; import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; -import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay.jsx'; - import Constants from 'utils/constants.jsx'; -import DelayedAction from 'utils/delayed_action.jsx'; - -import {FormattedMessage} from 'react-intl'; - -import PropTypes from 'prop-types'; import React from 'react'; +import PropTypes from 'prop-types'; import {Link} from 'react-router/es6'; +import {FormattedMessage} from 'react-intl'; export default class RhsRootPost extends React.Component { constructor(props) { super(props); - this.handlePermalink = this.handlePermalink.bind(this); - this.flagPost = this.flagPost.bind(this); - this.unflagPost = this.unflagPost.bind(this); - this.pinPost = this.pinPost.bind(this); - this.unpinPost = this.unpinPost.bind(this); this.reactEmojiClick = this.reactEmojiClick.bind(this); this.handleDropdownOpened = this.handleDropdownOpened.bind(this); - this.editDisableAction = new DelayedAction(this.handleEditDisable); - this.state = { currentTeamDisplayName: TeamStore.getCurrent().name, width: '', @@ -68,11 +56,6 @@ export default class RhsRootPost extends React.Component { }); } - handlePermalink(e) { - e.preventDefault(); - GlobalActions.showGetPostLinkModal(this.props.post); - } - shouldComponentUpdate(nextProps, nextState) { if (nextProps.status !== this.props.status) { return true; @@ -121,16 +104,6 @@ export default class RhsRootPost extends React.Component { return false; } - flagPost(e) { - e.preventDefault(); - flagPost(this.props.post.id); - } - - unflagPost(e) { - e.preventDefault(); - unflagPost(this.props.post.id); - } - timeTag(post, timeOptions) { return ( <time @@ -156,16 +129,6 @@ export default class RhsRootPost extends React.Component { ); } - pinPost(e) { - e.preventDefault(); - pinPost(this.props.post.channel_id, this.props.post.id); - } - - unpinPost(e) { - e.preventDefault(); - unpinPost(this.props.post.channel_id, this.props.post.id); - } - toggleEmojiPicker = () => { const showEmojiPicker = !this.state.showEmojiPicker; @@ -220,7 +183,6 @@ export default class RhsRootPost extends React.Component { var channel = ChannelStore.get(post.channel_id); const isEphemeral = Utils.isPostEphemeral(post); - const isPending = post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING; const isSystemMessage = PostUtils.isSystemMessage(post); var channelName; @@ -238,7 +200,8 @@ export default class RhsRootPost extends React.Component { } let react; - if (!isEphemeral && !isPending && !isSystemMessage && Utils.isFeatureEnabled(Constants.PRE_RELEASE_FEATURES.EMOJI_PICKER_PREVIEW)) { + + if (!isEphemeral && !post.failed && !isSystemMessage && Utils.isFeatureEnabled(Constants.PRE_RELEASE_FEATURES.EMOJI_PICKER_PREVIEW)) { react = ( <span> <EmojiPickerOverlay @@ -454,10 +417,8 @@ RhsRootPost.defaultProps = { }; RhsRootPost.propTypes = { post: PropTypes.object.isRequired, - lastPostCount: PropTypes.number, user: PropTypes.object.isRequired, currentUser: PropTypes.object.isRequired, - commentCount: PropTypes.number, compactDisplay: PropTypes.bool, useMilitaryTime: PropTypes.bool.isRequired, isFlagged: PropTypes.bool, diff --git a/webapp/components/rhs_thread/index.js b/webapp/components/rhs_thread/index.js new file mode 100644 index 000000000..c4465cafd --- /dev/null +++ b/webapp/components/rhs_thread/index.js @@ -0,0 +1,27 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {getPost, makeGetPostsForThread} from 'mattermost-redux/selectors/entities/posts'; + +import RhsThread from './rhs_thread.jsx'; + +function makeMapStateToProps() { + const getPostsForThread = makeGetPostsForThread(); + + return function mapStateToProps(state, ownProps) { + const selected = getPost(state, state.views.rhs.selectedPostId); + let posts = []; + if (selected) { + posts = getPostsForThread(state, {rootId: selected.id, channelId: selected.channel_id}); + } + + return { + ...ownProps, + selected, + posts + }; + }; +} + +export default connect(makeMapStateToProps)(RhsThread); diff --git a/webapp/components/rhs_thread.jsx b/webapp/components/rhs_thread/rhs_thread.jsx index a532119b8..f4e7b33fa 100644 --- a/webapp/components/rhs_thread.jsx +++ b/webapp/components/rhs_thread/rhs_thread.jsx @@ -1,14 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import CreateComment from './create_comment.jsx'; -import RhsHeaderPost from './rhs_header_post.jsx'; -import RootPost from './rhs_root_post.jsx'; -import Comment from './rhs_comment.jsx'; -import FloatingTimestamp from './post_view/components/floating_timestamp.jsx'; -import DateSeparator from './post_view/components/date_separator.jsx'; - -import PostStore from 'stores/post_store.jsx'; +import CreateComment from 'components/create_comment.jsx'; +import RhsHeaderPost from 'components/rhs_header_post.jsx'; +import RootPost from 'components/rhs_root_post.jsx'; +import Comment from 'components/rhs_comment.jsx'; +import FloatingTimestamp from 'components/post_view/floating_timestamp.jsx'; +import DateSeparator from 'components/post_view/date_separator.jsx'; + import UserStore from 'stores/user_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import WebrtcStore from 'stores/webrtc_store.jsx'; @@ -49,14 +48,31 @@ export function renderThumbVertical(props) { } export default class RhsThread extends React.Component { + static propTypes = { + posts: PropTypes.arrayOf(PropTypes.object).isRequired, + selected: PropTypes.object.isRequired, + fromSearch: PropTypes.string, + fromFlaggedPosts: PropTypes.bool, + fromPinnedPosts: PropTypes.bool, + isWebrtc: PropTypes.bool, + isMentionSearch: PropTypes.bool, + currentUser: PropTypes.object.isRequired, + useMilitaryTime: PropTypes.bool.isRequired, + toggleSize: PropTypes.func, + shrink: PropTypes.func + } + + static defaultProps = { + fromSearch: '', + isMentionSearch: false + } + constructor(props) { super(props); this.mounted = false; - this.onPostChange = this.onPostChange.bind(this); this.onUserChange = this.onUserChange.bind(this); - this.onSelectedChange = this.onSelectedChange.bind(this); this.forceUpdateInfo = this.forceUpdateInfo.bind(this); this.onPreferenceChange = this.onPreferenceChange.bind(this); this.onStatusChange = this.onStatusChange.bind(this); @@ -67,7 +83,7 @@ export default class RhsThread extends React.Component { this.scrollStopAction = new DelayedAction(this.handleScrollStop); const openTime = (new Date()).getTime(); - const state = this.getPosts(openTime); + const state = {}; state.windowWidth = Utils.windowWidth(); state.windowHeight = Utils.windowHeight(); state.profiles = JSON.parse(JSON.stringify(UserStore.getProfiles())); @@ -86,9 +102,6 @@ export default class RhsThread extends React.Component { } componentDidMount() { - PostStore.addSelectedPostChangeListener(this.onSelectedChange); - PostStore.addSelectedPostChangeListener(this.onPostChange); - PostStore.addChangeListener(this.onPostChange); PreferenceStore.addChangeListener(this.onPreferenceChange); UserStore.addChangeListener(this.onUserChange); UserStore.addStatusesChangeListener(this.onStatusChange); @@ -101,9 +114,6 @@ export default class RhsThread extends React.Component { } componentWillUnmount() { - PostStore.addSelectedPostChangeListener(this.onSelectedChange); - PostStore.removeSelectedPostChangeListener(this.onPostChange); - PostStore.removeChangeListener(this.onPostChange); PreferenceStore.removeChangeListener(this.onPreferenceChange); UserStore.removeChangeListener(this.onUserChange); UserStore.removeStatusesChangeListener(this.onStatusChange); @@ -116,7 +126,7 @@ export default class RhsThread extends React.Component { componentDidUpdate(prevProps, prevState) { const prevPostsArray = prevState.postsArray || []; - const curPostsArray = this.state.postsArray || []; + const curPostsArray = this.props.posts || []; if (prevPostsArray.length >= curPostsArray.length) { return; @@ -134,11 +144,11 @@ export default class RhsThread extends React.Component { return true; } - if (!Utils.areObjectsEqual(nextState.postsArray, this.state.postsArray)) { + if (!Utils.areObjectsEqual(nextState.postsArray, this.props.posts)) { return true; } - if (!Utils.areObjectsEqual(nextState.selected, this.state.selected)) { + if (!Utils.areObjectsEqual(nextState.selected, this.props.selected)) { return true; } @@ -198,10 +208,16 @@ export default class RhsThread extends React.Component { }); } - onSelectedChange() { - this.setState({ - openTime: (new Date()).getTime() - }); + componentWillReceiveProps(nextProps) { + if (!this.props.selected || !nextProps.selected) { + return; + } + + if (this.props.selected.id !== nextProps.selected.id) { + this.setState({ + openTime: (new Date()).getTime() + }); + } } onPreferenceChange(category) { @@ -218,12 +234,6 @@ export default class RhsThread extends React.Component { this.forceUpdateInfo(); } - onPostChange() { - if (this.mounted) { - this.setState(this.getPosts(this.state.openTime)); - } - } - onStatusChange() { this.setState({statuses: Object.assign({}, UserStore.getStatuses())}); } @@ -232,53 +242,21 @@ export default class RhsThread extends React.Component { this.setState({isBusy}); } - getPosts(openTime) { - const selected = PostStore.getSelectedPost(); - const posts = PostStore.getSelectedPostThread(); - + filterPosts(posts, selected, openTime) { const postsArray = []; - for (const id in posts) { - if (posts.hasOwnProperty(id)) { - const cpost = posts[id]; - - // Do not show empherals created before sidebar has been opened - if (cpost.type === 'system_ephemeral' && cpost.create_at < openTime) { - continue; - } - - if (cpost.root_id === selected.id) { - postsArray.push(cpost); - } + posts.forEach((cpost) => { + // Do not show empherals created before sidebar has been opened + if (cpost.type === 'system_ephemeral' && cpost.create_at < openTime) { + return; } - } - // sort failed posts to bottom, followed by pending, and then regular posts - postsArray.sort((a, b) => { - if ((a.state === Constants.POST_LOADING || a.state === Constants.POST_FAILED) && (b.state !== Constants.POST_LOADING && b.state !== Constants.POST_FAILED)) { - return 1; - } - if ((a.state !== Constants.POST_LOADING && a.state !== Constants.POST_FAILED) && (b.state === Constants.POST_LOADING || b.state === Constants.POST_FAILED)) { - return -1; + if (cpost.root_id === selected.id) { + postsArray.unshift(cpost); } - - if (a.state === Constants.POST_LOADING && b.state === Constants.POST_FAILED) { - return -1; - } - if (a.state === Constants.POST_FAILED && b.state === Constants.POST_LOADING) { - return 1; - } - - if (a.create_at < b.create_at) { - return -1; - } - if (a.create_at > b.create_at) { - return 1; - } - return 0; }); - return {postsArray, selected}; + return postsArray; } onUserChange() { @@ -298,7 +276,7 @@ export default class RhsThread extends React.Component { return; } - if (this.state.postsArray) { + if (this.props.posts) { const childNodes = this.refs.rhspostlist.childNodes; const viewPort = this.refs.rhspostlist.getBoundingClientRect(); let topRhsPostCreateAt = 0; @@ -307,7 +285,7 @@ export default class RhsThread extends React.Component { // determine the top rhs comment assuming that childNodes and postsArray are of same length for (let i = 0; i < childNodes.length; i++) { if ((childNodes[i].offsetTop + viewPort.top) - offset > 0) { - topRhsPostCreateAt = this.state.postsArray[i].create_at; + topRhsPostCreateAt = this.props.posts[i].create_at; break; } } @@ -343,14 +321,14 @@ export default class RhsThread extends React.Component { } render() { - if (this.state.postsArray == null || this.state.selected == null) { + if (this.props.posts == null || this.props.selected == null) { return ( <div/> ); } - const postsArray = this.state.postsArray; - const selected = this.state.selected; + const postsArray = this.filterPosts(this.props.posts, this.props.selected, this.state.openTime); + const selected = this.props.selected; const profiles = this.state.profiles || {}; let profile; @@ -490,20 +468,3 @@ export default class RhsThread extends React.Component { ); } } - -RhsThread.defaultProps = { - fromSearch: '', - isMentionSearch: false -}; - -RhsThread.propTypes = { - fromSearch: PropTypes.string, - fromFlaggedPosts: PropTypes.bool, - fromPinnedPosts: PropTypes.bool, - isWebrtc: PropTypes.bool, - isMentionSearch: PropTypes.bool, - currentUser: PropTypes.object.isRequired, - useMilitaryTime: PropTypes.bool.isRequired, - toggleSize: PropTypes.func, - shrink: PropTypes.func -}; diff --git a/webapp/components/search_results_item.jsx b/webapp/components/search_results_item.jsx index 4eb738065..eae384f0d 100644 --- a/webapp/components/search_results_item.jsx +++ b/webapp/components/search_results_item.jsx @@ -2,9 +2,9 @@ // See License.txt for license information. import $ from 'jquery'; -import PostMessageContainer from 'components/post_view/components/post_message_container.jsx'; +import PostMessageContainer from 'components/post_view/post_message_view'; import UserProfile from './user_profile.jsx'; -import FileAttachmentListContainer from './file_attachment_list_container.jsx'; +import FileAttachmentListContainer from 'components/file_attachment_list'; import ProfilePicture from './profile_picture.jsx'; import CommentIcon from 'components/common/comment_icon.jsx'; @@ -14,17 +14,17 @@ import UserStore from 'stores/user_store.jsx'; import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import {flagPost, unflagPost} from 'actions/post_actions.jsx'; -import PostFlagIcon from 'components/common/post_flag_icon.jsx'; +import PostFlagIcon from 'components/post_view/post_flag_icon.jsx'; import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; - -import PropTypes from 'prop-types'; +import {Posts} from 'mattermost-redux/constants'; import React from 'react'; +import PropTypes from 'prop-types'; import {FormattedMessage, FormattedDate} from 'react-intl'; import {browserHistory, Link} from 'react-router/es6'; @@ -187,7 +187,7 @@ export default class SearchResultsItem extends React.Component { let message; let flagContent; let rhsControls; - if (post.state === Constants.POST_DELETED) { + if (post.state === Posts.POST_DELETED) { message = ( <p> <FormattedMessage diff --git a/webapp/components/sidebar_right/index.js b/webapp/components/sidebar_right/index.js new file mode 100644 index 000000000..126ffc776 --- /dev/null +++ b/webapp/components/sidebar_right/index.js @@ -0,0 +1,17 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import SidebarRight from './sidebar_right.jsx'; + +function mapStateToProps(state, ownProps) { + return { + ...ownProps, + postRightVisible: Boolean(state.views.rhs.selectedPostId), + fromSearch: state.views.rhs.fromSearch, + fromFlaggedPosts: state.views.rhs.fromFlaggedPosts, + fromPinnedPosts: state.views.rhs.fromPinnedPosts + }; +} + +export default connect(mapStateToProps)(SidebarRight); diff --git a/webapp/components/sidebar_right.jsx b/webapp/components/sidebar_right/sidebar_right.jsx index 3ceab3a18..00a7d2d25 100644 --- a/webapp/components/sidebar_right.jsx +++ b/webapp/components/sidebar_right/sidebar_right.jsx @@ -3,10 +3,10 @@ import $ from 'jquery'; -import SearchResults from './search_results.jsx'; -import RhsThread from './rhs_thread.jsx'; -import SearchBox from './search_bar.jsx'; -import FileUploadOverlay from './file_upload_overlay.jsx'; +import SearchResults from 'components/search_results.jsx'; +import RhsThread from 'components/rhs_thread'; +import SearchBox from 'components/search_bar.jsx'; +import FileUploadOverlay from 'components/file_upload_overlay.jsx'; import SearchStore from 'stores/search_store.jsx'; import PostStore from 'stores/post_store.jsx'; import UserStore from 'stores/user_store.jsx'; @@ -19,18 +19,24 @@ import {trackEvent} from 'actions/diagnostics_actions.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; -import PropTypes from 'prop-types'; - import React from 'react'; +import PropTypes from 'prop-types'; export default class SidebarRight extends React.Component { + static propTypes = { + channel: PropTypes.object, + postRightVisible: PropTypes.bool, + fromSearch: PropTypes.string, + fromFlaggedPosts: PropTypes.bool, + fromPinnedPosts: PropTypes.bool + } + constructor(props) { super(props); this.plScrolledToBottom = true; this.onPreferenceChange = this.onPreferenceChange.bind(this); - this.onSelectedChange = this.onSelectedChange.bind(this); this.onPostPinnedChange = this.onPostPinnedChange.bind(this); this.onSearchChange = this.onSearchChange.bind(this); this.onUserChange = this.onUserChange.bind(this); @@ -45,7 +51,6 @@ export default class SidebarRight extends React.Component { isMentionSearch: SearchStore.getIsMentionSearch(), isFlaggedPosts: SearchStore.getIsFlaggedPosts(), isPinnedPosts: SearchStore.getIsPinnedPosts(), - postRightVisible: Boolean(PostStore.getSelectedPost()), expanded: false, fromSearch: false, currentUser: UserStore.getCurrentUser(), @@ -55,7 +60,6 @@ export default class SidebarRight extends React.Component { componentDidMount() { SearchStore.addSearchChangeListener(this.onSearchChange); - PostStore.addSelectedPostChangeListener(this.onSelectedChange); PostStore.addPostPinnedChangeListener(this.onPostPinnedChange); SearchStore.addShowSearchListener(this.onShowSearch); UserStore.addChangeListener(this.onUserChange); @@ -65,7 +69,6 @@ export default class SidebarRight extends React.Component { componentWillUnmount() { SearchStore.removeSearchChangeListener(this.onSearchChange); - PostStore.removeSelectedPostChangeListener(this.onSelectedChange); PostStore.removePostPinnedChangeListener(this.onPostPinnedChange); SearchStore.removeShowSearchListener(this.onShowSearch); UserStore.removeChangeListener(this.onUserChange); @@ -73,21 +76,17 @@ export default class SidebarRight extends React.Component { } shouldComponentUpdate(nextProps, nextState) { - return !Utils.areObjectsEqual(nextState, this.state); + return !Utils.areObjectsEqual(nextState, this.state) || !Utils.areObjectsEqual(nextProps, this.props); } componentWillUpdate(nextProps, nextState) { - const isOpen = this.state.searchVisible || this.state.postRightVisible; + const isOpen = this.state.searchVisible || this.props.postRightVisible; const willOpen = nextState.searchVisible || nextState.postRightVisible; if (!isOpen && willOpen) { trackEvent('ui', 'ui_rhs_opened'); } - if (isOpen !== willOpen) { - PostStore.jumpPostsViewSidebarOpen(); - } - if (!isOpen && willOpen) { this.setState({ expanded: false @@ -105,7 +104,7 @@ export default class SidebarRight extends React.Component { $('.app__body .sidebar--right').addClass('move--left'); //$('.sidebar--right').prepend('<div class="sidebar__overlay"></div>'); - if (!this.state.searchVisible && !this.state.postRightVisible) { + if (!this.state.searchVisible && !this.props.postRightVisible) { $('.app__body .inner-wrap').removeClass('move--left').removeClass('move--right'); $('.app__body .sidebar--right').removeClass('move--left'); return ( @@ -122,7 +121,7 @@ export default class SidebarRight extends React.Component { } componentDidUpdate() { - const isOpen = this.state.searchVisible || this.state.postRightVisible; + const isOpen = this.state.searchVisible || this.props.postRightVisible; WebrtcStore.emitRhsChanged(isOpen); this.doStrangeThings(); } @@ -137,15 +136,6 @@ export default class SidebarRight extends React.Component { }); } - onSelectedChange(fromSearch, fromFlaggedPosts, fromPinnedPosts) { - this.setState({ - postRightVisible: Boolean(PostStore.getSelectedPost()), - fromSearch, - fromFlaggedPosts, - fromPinnedPosts - }); - } - onPostPinnedChange() { if (this.props.channel && this.state.isPinnedPosts) { getPinnedPosts(this.props.channel.id); @@ -225,15 +215,15 @@ export default class SidebarRight extends React.Component { /> </div> ); - } else if (this.state.postRightVisible) { + } else if (this.props.postRightVisible) { content = ( <div className='post-right__container'> <FileUploadOverlay overlayType='right'/> <div className='search-bar__container sidebar--right__search-header'>{searchForm}</div> <RhsThread - fromFlaggedPosts={this.state.fromFlaggedPosts} - fromSearch={this.state.fromSearch} - fromPinnedPosts={this.state.fromPinnedPosts} + fromFlaggedPosts={this.props.fromFlaggedPosts} + fromSearch={this.props.fromSearch} + fromPinnedPosts={this.props.fromPinnedPosts} isWebrtc={WebrtcStore.isBusy()} isMentionSearch={this.state.isMentionSearch} currentUser={this.state.currentUser} @@ -265,7 +255,3 @@ export default class SidebarRight extends React.Component { ); } } - -SidebarRight.propTypes = { - channel: PropTypes.object -}; diff --git a/webapp/components/view_image.jsx b/webapp/components/view_image.jsx index b2cd41810..04ace6c55 100644 --- a/webapp/components/view_image.jsx +++ b/webapp/components/view_image.jsx @@ -9,9 +9,8 @@ import ViewImagePopoverBar from './view_image_popover_bar.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; -import FileStore from 'stores/file_store.jsx'; - import * as Utils from 'utils/utils.jsx'; +import {getFileUrl, getFilePreviewUrl} from 'mattermost-redux/utils/file_utils'; import Constants from 'utils/constants.jsx'; const KeyCodes = Constants.KeyCodes; @@ -127,10 +126,10 @@ export default class ViewImageModal extends React.Component { if (fileType === 'image') { let previewUrl; if (fileInfo.has_image_preview) { - previewUrl = FileStore.getFilePreviewUrl(fileInfo.id); + previewUrl = getFilePreviewUrl(fileInfo.id); } else { // some images (eg animated gifs) just show the file itself and not a preview - previewUrl = FileStore.getFileUrl(fileInfo.id); + previewUrl = getFileUrl(fileInfo.id); } const img = new Image(); @@ -175,7 +174,7 @@ export default class ViewImageModal extends React.Component { } const fileInfo = this.props.fileInfos[this.state.imgId]; - const fileUrl = FileStore.getFileUrl(fileInfo.id); + const fileUrl = getFileUrl(fileInfo.id); let content; if (this.state.loaded[this.state.imgId]) { @@ -346,7 +345,7 @@ LoadingImagePreview.propTypes = { function ImagePreview({fileInfo, fileUrl}) { let previewUrl; if (fileInfo.has_preview_image) { - previewUrl = FileStore.getFilePreviewUrl(fileInfo.id); + previewUrl = getFilePreviewUrl(fileInfo.id); } else { previewUrl = fileUrl; } diff --git a/webapp/components/youtube_video/index.js b/webapp/components/youtube_video/index.js new file mode 100644 index 000000000..592e52240 --- /dev/null +++ b/webapp/components/youtube_video/index.js @@ -0,0 +1,16 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; + +import YoutubeVideo from './youtube_video.jsx'; + +function mapStateToProps(state, ownProps) { + return { + ...ownProps, + currentChannelId: getCurrentChannelId(state) + }; +} + +export default connect(mapStateToProps)(YoutubeVideo); diff --git a/webapp/components/youtube_video.jsx b/webapp/components/youtube_video/youtube_video.jsx index 49f490bda..5151e6576 100644 --- a/webapp/components/youtube_video.jsx +++ b/webapp/components/youtube_video/youtube_video.jsx @@ -1,17 +1,22 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import ChannelStore from 'stores/channel_store.jsx'; import WebClient from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; const ytRegex = /(?:http|https):\/\/(?:www\.|m\.)?(?:(?:youtube\.com\/(?:(?:v\/)|(?:(?:watch|embed\/watch)(?:\/|.*v=))|(?:embed\/)|(?:user\/[^/]+\/u\/[0-9]\/)))|(?:youtu\.be\/))([^#&?]*)/; +import React from 'react'; import PropTypes from 'prop-types'; -import React from 'react'; +export default class YoutubeVideo extends React.PureComponent { + static propTypes = { + channelId: PropTypes.string.isRequired, + currentChannelId: PropTypes.string.isRequired, + link: PropTypes.string.isRequired, + show: PropTypes.bool.isRequired + } -export default class YoutubeVideo extends React.Component { constructor(props) { super(props); @@ -22,7 +27,6 @@ export default class YoutubeVideo extends React.Component { this.play = this.play.bind(this); this.stop = this.stop.bind(this); - this.stopOnChannelChange = this.stopOnChannelChange.bind(this); this.state = { loaded: false, @@ -52,6 +56,10 @@ export default class YoutubeVideo extends React.Component { this.stop(); } + if (props.channelId !== props.currentChannelId) { + this.stop(); + } + this.setState({ videoId: match[1], time: this.handleYoutubeTime(link) @@ -138,22 +146,12 @@ export default class YoutubeVideo extends React.Component { play() { this.setState({playing: true}); - - if (ChannelStore.getCurrentId() === this.props.channelId) { - ChannelStore.addChangeListener(this.stopOnChannelChange); - } } stop() { this.setState({playing: false}); } - stopOnChannelChange() { - if (ChannelStore.getCurrentId() !== this.props.channelId) { - this.stop(); - } - } - render() { if (!this.state.loaded) { return ( @@ -243,9 +241,3 @@ export default class YoutubeVideo extends React.Component { return link.trim().match(ytRegex); } } - -YoutubeVideo.propTypes = { - channelId: PropTypes.string.isRequired, - link: PropTypes.string.isRequired, - show: PropTypes.bool.isRequired -}; diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index ae5082548..bcf591f30 100755 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -1887,6 +1887,7 @@ "post_info.unpin": "Un-pin from channel", "post_message_view.edited": "(edited)", "posts_view.loadMore": "Load more messages", + "posts_view.loadingMore": "Loading more messages...", "posts_view.newMsg": "New Messages", "posts_view.newMsgBelow": "New {count, plural, one {message} other {messages}} below", "quick_switch_modal.channels": "Channels", diff --git a/webapp/reducers/index.js b/webapp/reducers/index.js new file mode 100644 index 000000000..ff2eb0d50 --- /dev/null +++ b/webapp/reducers/index.js @@ -0,0 +1,8 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import views from './views'; + +export default { + views +}; diff --git a/webapp/reducers/views/channel.js b/webapp/reducers/views/channel.js new file mode 100644 index 000000000..0deb2389e --- /dev/null +++ b/webapp/reducers/views/channel.js @@ -0,0 +1,69 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {ActionTypes, Constants} from 'utils/constants.jsx'; +import {ChannelTypes, PostTypes} from 'mattermost-redux/action_types'; + +function postVisibility(state = {}, action) { + switch (action.type) { + case ChannelTypes.SELECT_CHANNEL: { + const nextState = {...state}; + nextState[action.data] = Constants.POST_CHUNK_SIZE / 2; + return nextState; + } + case ActionTypes.INCREASE_POST_VISIBILITY: { + const nextState = {...state}; + nextState[action.data] += action.amount; + return nextState; + } + case ActionTypes.RECEIVED_FOCUSED_POST: { + const nextState = {...state}; + nextState[action.channelId] = Constants.POST_CHUNK_SIZE / 2; + return nextState; + } + case PostTypes.RECEIVED_POST: { + if (action.data && state[action.data.channel_id]) { + const nextState = {...state}; + nextState[action.data.channel_id] += 1; + return nextState; + } + return state; + } + default: + return state; + } +} + +function lastChannelViewTime(state = {}, action) { + switch (action.type) { + case ChannelTypes.SELECT_CHANNEL: { + if (action.member) { + const nextState = {...state}; + nextState[action.data] = action.member.last_viewed_at; + return nextState; + } + return state; + } + default: + return state; + } +} + +function loadingPosts(state = {}, action) { + switch (action.type) { + case ActionTypes.LOADING_POSTS: { + const nextState = {...state}; + nextState[action.channelId] = action.data; + return nextState; + } + default: + return state; + } +} + +export default combineReducers({ + postVisibility, + lastChannelViewTime, + loadingPosts +}); diff --git a/webapp/reducers/views/index.js b/webapp/reducers/views/index.js new file mode 100644 index 000000000..98eb7dac9 --- /dev/null +++ b/webapp/reducers/views/index.js @@ -0,0 +1,12 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; + +import rhs from './rhs'; +import channel from './channel'; + +export default combineReducers({ + rhs, + channel +}); diff --git a/webapp/reducers/views/rhs.js b/webapp/reducers/views/rhs.js new file mode 100644 index 000000000..1e6480743 --- /dev/null +++ b/webapp/reducers/views/rhs.js @@ -0,0 +1,63 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {ActionTypes} from 'utils/constants.jsx'; +import {PostTypes} from 'mattermost-redux/action_types'; + +function selectedPostId(state = '', action) { + switch (action.type) { + case ActionTypes.SELECT_POST: + return action.postId; + case PostTypes.REMOVE_POST: + if (action.data && action.data.id === state) { + return ''; + } + return state; + default: + return state; + } +} + +function fromSearch(state = '', action) { + switch (action.type) { + case ActionTypes.SELECT_POST: + if (action.from_search) { + return action.from_search; + } + return ''; + default: + return state; + } +} + +function fromFlaggedPosts(state = false, action) { + switch (action.type) { + case ActionTypes.SELECT_POST: + if (action.from_flagged_posts) { + return action.from_flagged_posts; + } + return false; + default: + return state; + } +} + +function fromPinnedPosts(state = false, action) { + switch (action.type) { + case ActionTypes.SELECT_POST: + if (action.from_pinned_posts) { + return action.from_pinned_posts; + } + return false; + default: + return state; + } +} + +export default combineReducers({ + selectedPostId, + fromSearch, + fromFlaggedPosts, + fromPinnedPosts +}); diff --git a/webapp/sass/layout/_post.scss b/webapp/sass/layout/_post.scss index 5dc524caa..f2cfe1243 100644 --- a/webapp/sass/layout/_post.scss +++ b/webapp/sass/layout/_post.scss @@ -236,6 +236,26 @@ display: none; } + .post-list__loading { + @include opacity(.5); + font-size: .9em; + font-style: italic; + padding: 1em 0; + text-align: center; + + i { + margin-right: 2px; + } + + &.post-list__loading-search { + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + } + .post-list-holder-by-time { -webkit-overflow-scrolling: touch; background: #ffffff; diff --git a/webapp/store/index.js b/webapp/store/index.js index 7ab22e292..6d2fc6d9c 100644 --- a/webapp/store/index.js +++ b/webapp/store/index.js @@ -7,6 +7,11 @@ import {General, RequestStatus} from 'mattermost-redux/constants'; import reduxInitialState from 'mattermost-redux/store/initial_state'; import {createTransform, persistStore} from 'redux-persist'; import localForage from 'localforage'; +import appReducer from 'reducers'; + +function getAppReducer() { + return require('../reducers'); // eslint-disable-line global-require +} import {transformSet} from './utils'; @@ -99,7 +104,7 @@ export default function configureStore(initialState) { autoRehydrate: { log: false }, - blacklist: ['errors', 'offline', 'requests', 'entities'], + blacklist: ['errors', 'offline', 'requests', 'entities', 'views'], debounce: 500, transforms: [ setTransformer @@ -107,6 +112,6 @@ export default function configureStore(initialState) { } }; - return configureServiceStore({}, {}, offlineOptions, null, false); + return configureServiceStore({}, appReducer, offlineOptions, getAppReducer, false); } diff --git a/webapp/stores/channel_store.jsx b/webapp/stores/channel_store.jsx index 07513aade..58ea19419 100644 --- a/webapp/stores/channel_store.jsx +++ b/webapp/stores/channel_store.jsx @@ -159,7 +159,8 @@ class ChannelStoreClass extends EventEmitter { setCurrentId(id) { store.dispatch({ type: ChannelTypes.SELECT_CHANNEL, - data: id + data: id, + member: this.getMyMember(id) }); } diff --git a/webapp/stores/emoji_store.jsx b/webapp/stores/emoji_store.jsx index 8a4165dd4..812688995 100644 --- a/webapp/stores/emoji_store.jsx +++ b/webapp/stores/emoji_store.jsx @@ -15,7 +15,7 @@ const MAXIMUM_RECENT_EMOJI = 27; // Wrap the contents of the store so that we don't need to construct an ES6 map where most of the content // (the system emojis) will never change. It provides the get/has functions of a map and an iterator so // that it can be used in for..of loops -class EmojiMap { +export class EmojiMap { constructor(customEmojis) { this.customEmojis = customEmojis; diff --git a/webapp/stores/file_store.jsx b/webapp/stores/file_store.jsx deleted file mode 100644 index 34378c062..000000000 --- a/webapp/stores/file_store.jsx +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; -import Constants from 'utils/constants.jsx'; -import EventEmitter from 'events'; - -const ActionTypes = Constants.ActionTypes; - -const CHANGE_EVENT = 'changed'; - -class FileStore extends EventEmitter { - constructor() { - super(); - - this.handleEventPayload = this.handleEventPayload.bind(this); - this.dispatchToken = AppDispatcher.register(this.handleEventPayload); - - this.setMaxListeners(600); - - this.fileInfosByPost = new Map(); - } - - addChangeListener(callback) { - this.on(CHANGE_EVENT, callback); - } - - removeChangeListener(callback) { - this.removeListener(CHANGE_EVENT, callback); - } - - emitChange() { - this.emit(CHANGE_EVENT); - } - - hasInfosForPost(postId) { - return this.fileInfosByPost.has(postId); - } - - getInfosForPost(postId) { - return this.fileInfosByPost.get(postId); - } - - saveInfos(postId, infos) { - this.fileInfosByPost.set(postId, infos); - } - - getFileUrl(fileId) { - return `/api/v3/files/${fileId}/get`; - } - - getFileThumbnailUrl(fileId) { - return `/api/v3/files/${fileId}/get_thumbnail`; - } - - getFilePreviewUrl(fileId) { - return `/api/v3/files/${fileId}/get_preview`; - } - - handleEventPayload(payload) { - const action = payload.action; - - switch (action.type) { - case ActionTypes.RECEIVED_FILE_INFOS: - // This assumes that all received file infos are for a single post - this.saveInfos(action.postId, action.infos); - this.emitChange(action.postId); - break; - } - } -} - -export default new FileStore(); diff --git a/webapp/stores/post_store.jsx b/webapp/stores/post_store.jsx index a402490af..66e7108ca 100644 --- a/webapp/stores/post_store.jsx +++ b/webapp/stores/post_store.jsx @@ -4,40 +4,29 @@ import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import EventEmitter from 'events'; +import ChannelStore from 'stores/channel_store.jsx'; import BrowserStore from 'stores/browser_store.jsx'; import UserStore from 'stores/user_store.jsx'; -import {Constants, PostTypes} from 'utils/constants.jsx'; +import {Constants} from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; -const CHANGE_EVENT = 'change'; const FOCUSED_POST_CHANGE = 'focused_post_change'; const EDIT_POST_EVENT = 'edit_post'; -const POSTS_VIEW_JUMP_EVENT = 'post_list_jump'; -const SELECTED_POST_CHANGE_EVENT = 'selected_post_change'; const POST_PINNED_CHANGE_EVENT = 'post_pinned_change'; -const POST_DRAFT_CHANGE_EVENT = 'post_draft_change'; + +import store from 'stores/redux_store.jsx'; +const dispatch = store.dispatch; +const getState = store.getState; + +import * as Selectors from 'mattermost-redux/selectors/entities/posts'; class PostStoreClass extends EventEmitter { constructor() { super(); this.selectedPostId = null; - this.postsInfo = {}; - this.latestPageTime = {}; - this.earliestPostFromPage = {}; this.currentFocusedPostId = null; } - emitChange() { - this.emit(CHANGE_EVENT); - } - - addChangeListener(callback) { - this.on(CHANGE_EVENT, callback); - } - - removeChangeListener(callback) { - this.removeListener(CHANGE_EVENT, callback); - } emitPostFocused() { this.emit(FOCUSED_POST_CHANGE); @@ -63,512 +52,64 @@ class PostStoreClass extends EventEmitter { this.removeListener(EDIT_POST_EVENT, callback); } - emitPostsViewJump(type, post) { - this.emit(POSTS_VIEW_JUMP_EVENT, type, post); - } - - addPostsViewJumpListener(callback) { - this.on(POSTS_VIEW_JUMP_EVENT, callback); - } - - removePostsViewJumpListener(callback) { - this.removeListener(POSTS_VIEW_JUMP_EVENT, callback); - } - - emitPostDraftChange(channelId) { - this.emit(POST_DRAFT_CHANGE_EVENT + channelId, this.getPostDraft(channelId)); - } - - addPostDraftChangeListener(channelId, callback) { - this.on(POST_DRAFT_CHANGE_EVENT + channelId, callback); - } - - removePostDraftChangeListener(channelId, callback) { - this.removeListener(POST_DRAFT_CHANGE_EVENT + channelId, callback); - } - - jumpPostsViewToBottom() { - this.emitPostsViewJump(Constants.PostsViewJumpTypes.BOTTOM, null); - } - - jumpPostsViewToPost(post) { - this.emitPostsViewJump(Constants.PostsViewJumpTypes.POST, post); - } - - jumpPostsViewSidebarOpen() { - this.emitPostsViewJump(Constants.PostsViewJumpTypes.SIDEBAR_OPEN, null); - } - - // All this does is makes sure the postsInfo is not null for the specified channel - makePostsInfo(id) { - if (!this.postsInfo.hasOwnProperty(id)) { - this.postsInfo[id] = {}; - } - } - - getPost(channelId, postId) { - const postInfo = this.postsInfo[channelId]; - if (postInfo == null) { - return null; - } - - const postList = postInfo.postList; - let post = null; - - if (postList && postList.posts && postList.posts.hasOwnProperty(postId)) { - post = postList.posts[postId]; - } - - return post; - } - - getAllPosts(id) { - if (this.postsInfo.hasOwnProperty(id)) { - return this.postsInfo[id].postList; - } - - return null; - } - - getEarliestPostFromPage(id) { - return this.earliestPostFromPage[id]; + emitPostPinnedChange() { + this.emit(POST_PINNED_CHANGE_EVENT); } - getLatestPost(id) { - if (this.postsInfo.hasOwnProperty(id)) { - const postList = this.postsInfo[id].postList; - - for (const postId of postList.order) { - if (postList.posts[postId].state !== Constants.POST_DELETED) { - return postList.posts[postId]; - } - } - } - - return null; + addPostPinnedChangeListener(callback) { + this.on(POST_PINNED_CHANGE_EVENT, callback); } - getLatestNonEphemeralPost(id) { - if (this.postsInfo.hasOwnProperty(id)) { - const postList = this.postsInfo[id].postList; - - for (const postId of postList.order) { - if (postList.posts[postId].state !== Constants.POST_DELETED && postList.posts[postId].type !== Constants.PostTypes.EPHEMERAL) { - return postList.posts[postId]; - } - } - } - - return null; + removePostPinnedChangeListener(callback) { + this.removeListener(POST_PINNED_CHANGE_EVENT, callback); } - getLatestPostFromPageTime(id) { - if (this.latestPageTime.hasOwnProperty(id)) { - return this.latestPageTime[id]; - } - - return 0; + getLatestPostId(channelId) { + const postsInChannel = getState().entities.posts.postsInChannel[channelId] || []; + return postsInChannel[0]; } - getVisiblePosts(id) { - if (this.postsInfo.hasOwnProperty(id) && this.postsInfo[id].hasOwnProperty('postList')) { - const postList = JSON.parse(JSON.stringify(this.postsInfo[id].postList)); + getLatestNonEphemeralPost(channelId) { + const postIds = getState().entities.posts.postsInChannel[channelId]; + const posts = getState().entities.posts.posts; - // Only limit visibility if we are not focused on a post - if (this.currentFocusedPostId === null) { - postList.order = postList.order.slice(0, this.postsInfo[id].endVisible); + for (const postId of postIds) { + const post = posts[postId] || {}; + if (post.state !== Constants.POST_DELETED && post.type !== Constants.PostTypes.EPHEMERAL) { + return post; } - - // Add pending posts - if (this.postsInfo[id].hasOwnProperty('pendingPosts')) { - Object.assign(postList.posts, this.postsInfo[id].pendingPosts.posts); - postList.order = this.postsInfo[id].pendingPosts.order.concat(postList.order); - } - - return postList; } return null; } - getVisibilityAtTop(id) { - if (this.postsInfo.hasOwnProperty(id)) { - return this.postsInfo[id].atTop && this.postsInfo[id].endVisible >= this.postsInfo[id].postList.order.length; - } - - return false; - } - - getVisibilityAtBottom(id) { - if (this.postsInfo.hasOwnProperty(id)) { - return this.postsInfo[id].atBottom; - } - - return false; - } - - // Returns true if posts need to be fetched - requestVisibilityIncrease(id, amount) { - const endVisible = this.postsInfo[id].endVisible; - const postList = this.postsInfo[id].postList; - if (this.getVisibilityAtTop(id)) { - return false; - } - this.postsInfo[id].endVisible += amount; - this.emitChange(); - return endVisible + amount > postList.order.length; + getVisiblePosts() { + const posts = Selectors.getPostsInCurrentChannel(getState()); + const currentChannelId = getState().entities.channels.currentChannelId; + return posts.slice(0, getState().views.channel.postVisibility[currentChannelId]); } getFocusedPostId() { return this.currentFocusedPostId; } - storePosts(id, newPosts, checkLatest, checkEarliest) { - if (isPostListNull(newPosts)) { - return; - } - - if (checkLatest) { - const currentLatest = this.latestPageTime[id] || 0; - if (newPosts.order.length >= 1) { - const newLatest = newPosts.posts[newPosts.order[0]].create_at || 0; - if (newLatest > currentLatest) { - this.latestPageTime[id] = newLatest; - } - } else if (currentLatest === 0) { - // Mark that an empty page was received - this.latestPageTime[id] = 1; - } - } - - if (checkEarliest) { - const currentEarliest = this.earliestPostFromPage[id] || {create_at: Number.MAX_SAFE_INTEGER}; - const orderLength = newPosts.order.length; - if (orderLength >= 1) { - const newEarliestPost = newPosts.posts[newPosts.order[orderLength - 1]]; - if (newEarliestPost.create_at < currentEarliest.create_at) { - this.earliestPostFromPage[id] = newEarliestPost; - } - } - } - - const combinedPosts = makePostListNonNull(this.getAllPosts(id)); - - for (const pid in newPosts.posts) { - if (newPosts.posts.hasOwnProperty(pid)) { - const np = newPosts.posts[pid]; - if (np.delete_at === 0) { - combinedPosts.posts[pid] = np; - if (combinedPosts.order.indexOf(pid) === -1 && newPosts.order.indexOf(pid) !== -1) { - combinedPosts.order.push(pid); - } - } else if (combinedPosts.posts.hasOwnProperty(pid)) { - combinedPosts.posts[pid] = Object.assign({}, np, { - state: Constants.POST_DELETED, - fileIds: [] - }); - } - } - } - - combinedPosts.order.sort((a, b) => { - if (combinedPosts.posts[a].create_at > combinedPosts.posts[b].create_at) { - return -1; - } - if (combinedPosts.posts[a].create_at < combinedPosts.posts[b].create_at) { - return 1; - } - - return 0; - }); - - this.makePostsInfo(id); - this.postsInfo[id].postList = combinedPosts; - } - - focusedPostListHasPost(id) { - const focusedPostId = this.getFocusedPostId(); - if (focusedPostId == null) { - return false; - } - - const focusedPostList = makePostListNonNull(this.getAllPosts(focusedPostId)); - return focusedPostList.posts.hasOwnProperty(id); - } - - storePost(post, isNewPost = false) { - const ids = [ - post.channel_id - ]; - - // update the post in the permalink view if it's there - if (!isNewPost && this.focusedPostListHasPost(post.id)) { - ids.push(this.getFocusedPostId()); - } - - ids.forEach((id) => { - const postList = makePostListNonNull(this.getAllPosts(id)); - if (post.pending_post_id !== '') { - this.removePendingPost(post.channel_id, post.pending_post_id); - } - - post.pending_post_id = ''; - - postList.posts[post.id] = post; - if (isNewPost && postList.order.indexOf(post.id) === -1) { - postList.order.unshift(post.id); - } - - this.makePostsInfo(post.channel_id); - this.postsInfo[id].postList = postList; - }); - } - - storeFocusedPost(postId, channelId, postList) { - const focusedPost = postList.posts[postId]; - if (!focusedPost) { - return; - } + storeFocusedPostId(postId) { this.currentFocusedPostId = postId; - this.storePosts(postId, postList); - this.storePosts(channelId, postList); - } - - checkBounds(id, numRequested, postList, before) { - if (numRequested > postList.order.length) { - if (before) { - this.postsInfo[id].atTop = true; - } else { - this.postsInfo[id].atBottom = true; - } - } } clearFocusedPost() { - if (this.currentFocusedPostId != null) { - Reflect.deleteProperty(this.postsInfo, this.currentFocusedPostId); - this.currentFocusedPostId = null; - } - } - - clearChannelVisibility(id, atBottom) { - this.makePostsInfo(id); - this.postsInfo[id].endVisible = Constants.POST_CHUNK_SIZE; - if (this.postsInfo[id].postList) { - this.postsInfo[id].atTop = this.postsInfo[id].atTop && Constants.POST_CHUNK_SIZE >= this.postsInfo[id].postList.order.length; - } else { - this.postsInfo[id].atTop = false; - } - this.postsInfo[id].atBottom = atBottom; - } - - deletePost(post) { - let postInfo = null; - if (this.currentFocusedPostId == null) { - postInfo = this.postsInfo[post.channel_id]; - } else { - postInfo = this.postsInfo[this.currentFocusedPostId]; - } - if (!postInfo) { - // the post that has been deleted is in a channel that we haven't seen so just ignore it - return; - } - - const postList = postInfo.postList; - - if (isPostListNull(postList)) { - return; - } - - if (post.id in postList.posts) { - // make sure to copy the post so that component state changes work properly - postList.posts[post.id] = Object.assign({}, post, { - state: Constants.POST_DELETED, - file_ids: [], - has_reactions: false - }); - } - } - - removePost(post) { - const channelId = post.channel_id; - this.makePostsInfo(channelId); - const postList = this.postsInfo[channelId].postList; - if (isPostListNull(postList)) { - return; - } - - if (post.id in postList.posts) { - Reflect.deleteProperty(postList.posts, post.id); - } - - const index = postList.order.indexOf(post.id); - if (index !== -1) { - postList.order.splice(index, 1); - } - - for (const pid in postList.posts) { - if (!postList.posts.hasOwnProperty(pid)) { - continue; - } - - if (postList.posts[pid].root_id === post.id) { - Reflect.deleteProperty(postList.posts, pid); - const commentIndex = postList.order.indexOf(pid); - if (commentIndex !== -1) { - postList.order.splice(commentIndex, 1); - } - } - } - - this.postsInfo[channelId].postList = postList; - } - - getPendingPosts(channelId) { - if (this.postsInfo.hasOwnProperty(channelId)) { - return this.postsInfo[channelId].pendingPosts; - } - - return null; - } - - storePendingPost(post) { - const copyPost = JSON.parse(JSON.stringify(post)); - copyPost.state = Constants.POST_LOADING; - - const postList = makePostListNonNull(this.getPendingPosts(copyPost.channel_id)); - - postList.posts[copyPost.pending_post_id] = copyPost; - postList.order.unshift(copyPost.pending_post_id); - - this.makePostsInfo(copyPost.channel_id); - this.postsInfo[copyPost.channel_id].pendingPosts = postList; - this.emitChange(); - } - - removePendingPost(channelId, pendingPostId) { - const postList = makePostListNonNull(this.getPendingPosts(channelId)); - - Reflect.deleteProperty(postList.posts, pendingPostId); - const index = postList.order.indexOf(pendingPostId); - if (index === -1) { - return; - } - - postList.order.splice(index, 1); - - this.postsInfo[channelId].pendingPosts = postList; - this.emitChange(); - } - - clearPendingPosts(channelId) { - if (this.postsInfo.hasOwnProperty(channelId)) { - Reflect.deleteProperty(this.postsInfo[channelId], 'pendingPosts'); - } - } - - updatePendingPost(post) { - const copyPost = JSON.parse(JSON.stringify(post)); - const postList = makePostListNonNull(this.getPendingPosts(copyPost.channel_id)); - - if (postList.order.indexOf(copyPost.pending_post_id) === -1) { - return; - } - - postList.posts[copyPost.pending_post_id] = copyPost; - this.postsInfo[copyPost.channel_id].pendingPosts = postList; - this.emitChange(); - } - - storeSelectedPostId(postId) { - this.selectedPostId = postId; - } - - getSelectedPostId() { - return this.selectedPostId; - } - - getSelectedPost() { - if (this.selectedPostId == null) { - return null; - } - - for (const k in this.postsInfo) { - if (this.postsInfo[k].postList.posts.hasOwnProperty(this.selectedPostId)) { - return this.postsInfo[k].postList.posts[this.selectedPostId]; - } - } - - return null; - } - - getSelectedPostThread() { - if (this.selectedPostId == null) { - return null; - } - - const posts = {}; - let pendingPosts; - for (const k in this.postsInfo) { - if (this.postsInfo[k].postList && this.postsInfo[k].postList.posts.hasOwnProperty(this.selectedPostId)) { - Object.assign(posts, this.postsInfo[k].postList.posts); - if (this.postsInfo[k].pendingPosts != null) { - pendingPosts = this.postsInfo[k].pendingPosts.posts; - } - } - } - - const threadPosts = {}; - const rootId = this.selectedPostId; - for (const k in posts) { - if (posts[k].root_id === rootId) { - threadPosts[k] = JSON.parse(JSON.stringify(posts[k])); - } - } - - for (const k in pendingPosts) { - if (pendingPosts[k].root_id === rootId) { - threadPosts[k] = JSON.parse(JSON.stringify(pendingPosts[k])); - } - } - - return threadPosts; - } - - emitSelectedPostChange(fromSearch, fromFlaggedPosts, fromPinnedPosts) { - this.emit(SELECTED_POST_CHANGE_EVENT, fromSearch, fromFlaggedPosts, fromPinnedPosts); - } - - addSelectedPostChangeListener(callback) { - this.on(SELECTED_POST_CHANGE_EVENT, callback); - } - - removeSelectedPostChangeListener(callback) { - this.removeListener(SELECTED_POST_CHANGE_EVENT, callback); - } - - emitPostPinnedChange() { - this.emit(POST_PINNED_CHANGE_EVENT); - } - - addPostPinnedChangeListener(callback) { - this.on(POST_PINNED_CHANGE_EVENT, callback); - } - - removePostPinnedChangeListener(callback) { - this.removeListener(POST_PINNED_CHANGE_EVENT, callback); + this.currentFocusedPostId = null; } getCurrentUsersLatestPost(channelId, rootId) { const userId = UserStore.getCurrentId(); - const postList = makePostListNonNull(this.getAllPosts(channelId)); - const len = postList.order.length; + const postIds = getState().entities.posts.postsInChannel[channelId] || []; let lastPost = null; - for (let i = 0; i < len; i++) { - const post = postList.posts[postList.order[i]]; + for (const id of postIds) { + const post = Selectors.getPost(getState(), id) || {}; // don't edit webhook posts, deleted posts, or system messages if (post.user_id !== userId || @@ -611,11 +152,21 @@ class PostStoreClass extends EventEmitter { return draft; } - storePostDraft(channelId, draft) { + storeCurrentDraft(draft) { + var channelId = ChannelStore.getCurrentId(); + BrowserStore.setGlobalItem('draft_' + channelId, draft); + } + + getCurrentDraft() { + var channelId = ChannelStore.getCurrentId(); + return this.getDraft(channelId); + } + + storeDraft(channelId, draft) { BrowserStore.setGlobalItem('draft_' + channelId, draft); } - getPostDraft(channelId) { + getDraft(channelId) { return this.normalizeDraft(BrowserStore.getGlobalItem('draft_' + channelId)); } @@ -645,40 +196,19 @@ class PostStoreClass extends EventEmitter { }); } - getCommentCount(post) { - const posts = this.getAllPosts(post.channel_id).posts; + getCommentCount(rootPost) { + const postIds = getState().entities.posts.postsInChannel[rootPost.channel_id] || []; let commentCount = 0; - for (const id in posts) { - if (posts.hasOwnProperty(id)) { - if (posts[id].root_id === post.id) { - commentCount += 1; - } + for (const postId of postIds) { + const post = Selectors.getPost(getState(), postId) || {}; + if (post.root_id === rootPost.id) { + commentCount += 1; } } return commentCount; } - - filterPosts(channelId, joinLeave) { - const postsList = JSON.parse(JSON.stringify(this.getVisiblePosts(channelId))); - - if (!joinLeave && postsList) { - postsList.order = postsList.order.filter((id) => { - const post = postsList.posts[id]; - - if (post.type === PostTypes.JOIN_LEAVE || post.type === PostTypes.JOIN_CHANNEL || post.type === PostTypes.LEAVE_CHANNEL) { - Reflect.deleteProperty(postsList.posts, id); - - return false; - } - - return true; - }); - } - - return postsList; - } } var PostStore = new PostStoreClass(); @@ -687,97 +217,25 @@ PostStore.dispatchToken = AppDispatcher.register((payload) => { var action = payload.action; switch (action.type) { - case ActionTypes.RECEIVED_POSTS: { - if (PostStore.currentFocusedPostId !== null && action.isPost) { - PostStore.storePosts(PostStore.currentFocusedPostId, makePostListNonNull(action.post_list), action.checkLatest, action.checkEarliest); - PostStore.checkBounds(PostStore.currentFocusedPostId, action.numRequested, makePostListNonNull(action.post_list), action.before); - } - PostStore.storePosts(action.id, makePostListNonNull(action.post_list), action.checkLatest, action.checkEarliest); - PostStore.checkBounds(action.id, action.numRequested, makePostListNonNull(action.post_list), action.before); - PostStore.emitChange(); - break; - } case ActionTypes.RECEIVED_FOCUSED_POST: - PostStore.clearChannelVisibility(action.postId, false); - PostStore.storeFocusedPost(action.postId, action.channelId, makePostListNonNull(action.post_list)); - PostStore.emitChange(); - break; - case ActionTypes.RECEIVED_POST: - PostStore.storePost(action.post, true); - PostStore.emitChange(); - break; - case ActionTypes.RECEIVED_EDIT_POST: - PostStore.emitEditPost(action); - PostStore.emitChange(); + PostStore.storeFocusedPostId(action.postId); + PostStore.emitPostFocused(); break; case ActionTypes.CLICK_CHANNEL: PostStore.clearFocusedPost(); - PostStore.clearChannelVisibility(action.id, true); - break; - case ActionTypes.CREATE_POST: - PostStore.storePendingPost(action.post); - PostStore.storePostDraft(action.post.channel_id, null); - PostStore.jumpPostsViewToBottom(); - break; - case ActionTypes.CREATE_COMMENT: - PostStore.storePendingPost(action.post); - PostStore.storeCommentDraft(action.post.root_id, null); - break; - case ActionTypes.POST_DELETED: - PostStore.deletePost(action.post); - PostStore.emitChange(); break; - case ActionTypes.REMOVE_POST: - PostStore.removePost(action.post); - PostStore.emitChange(); + case ActionTypes.RECEIVED_EDIT_POST: + PostStore.emitEditPost(action); break; case ActionTypes.RECEIVED_POST_SELECTED: - PostStore.storeSelectedPostId(action.postId); - PostStore.emitSelectedPostChange(action.from_search, action.from_flagged_posts, action.from_pinned_posts); + dispatch({...action, type: ActionTypes.SELECT_POST}); break; case ActionTypes.RECEIVED_POST_PINNED: case ActionTypes.RECEIVED_POST_UNPINNED: PostStore.emitPostPinnedChange(); break; - case ActionTypes.POST_DRAFT_CHANGED: - PostStore.storePostDraft(action.channelId, action.draft); - PostStore.emitPostDraftChange(action.channelId); - break; default: } }); export default PostStore; - -function makePostListNonNull(pl) { - var postList = pl; - if (postList == null) { - postList = {order: [], posts: {}}; - } - - if (postList.order == null) { - postList.order = []; - } - - if (postList.posts == null) { - postList.posts = {}; - } - - return postList; -} - -function isPostListNull(pl) { - if (pl == null) { - return true; - } - - if (pl.posts == null) { - return true; - } - - if (pl.order == null) { - return true; - } - - return false; -} diff --git a/webapp/stores/reaction_store.jsx b/webapp/stores/reaction_store.jsx deleted file mode 100644 index ebebd4374..000000000 --- a/webapp/stores/reaction_store.jsx +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; -import Constants from 'utils/constants.jsx'; -import EventEmitter from 'events'; - -const ActionTypes = Constants.ActionTypes; - -const CHANGE_EVENT = 'changed'; - -class ReactionStore extends EventEmitter { - constructor() { - super(); - - this.dispatchToken = AppDispatcher.register(this.handleEventPayload.bind(this)); - - this.reactions = new Map(); - - this.setMaxListeners(600); - } - - addChangeListener(postId, callback) { - this.on(CHANGE_EVENT + postId, callback); - } - - removeChangeListener(postId, callback) { - this.removeListener(CHANGE_EVENT + postId, callback); - } - - emitChange(postId) { - this.emit(CHANGE_EVENT + postId, postId); - } - - setReactions(postId, reactions) { - this.reactions.set(postId, reactions); - } - - addReaction(postId, reaction) { - let reactions = this.getReactions(postId) || []; - - // Make sure not to add duplicates - const existingIndex = reactions.findIndex((existing) => { - return existing.user_id === reaction.user_id && existing.post_id === reaction.post_id && existing.emoji_name === reaction.emoji_name; - }); - - if (existingIndex === -1) { - reactions = [...reactions, reaction]; - } - - this.setReactions(postId, reactions); - } - - removeReaction(postId, reaction) { - let reactions = this.getReactions(postId) || []; - - const existingIndex = reactions.findIndex((existing) => { - return existing.user_id === reaction.user_id && existing.post_id === reaction.post_id && existing.emoji_name === reaction.emoji_name; - }); - - if (existingIndex !== -1) { - reactions = reactions.slice(0, existingIndex).concat(reactions.slice(existingIndex + 1)); - } - - this.setReactions(postId, reactions); - } - - getReactions(postId) { - return this.reactions.get(postId); - } - - handleEventPayload(payload) { - const action = payload.action; - - switch (action.type) { - case ActionTypes.RECEIVED_REACTIONS: - this.setReactions(action.postId, action.reactions); - this.emitChange(action.postId); - break; - case ActionTypes.ADDED_REACTION: - this.addReaction(action.postId, action.reaction); - this.emitChange(action.postId); - break; - case ActionTypes.REMOVED_REACTION: - this.removeReaction(action.postId, action.reaction); - this.emitChange(action.postId); - break; - } - } -} - -export default new ReactionStore(); diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index 516c4910c..e8dc583ef 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -87,12 +87,14 @@ export const ActionTypes = keyMirror({ RECEIVED_EDIT_POST: null, RECEIVED_SEARCH: null, RECEIVED_SEARCH_TERM: null, + SELECT_POST: null, RECEIVED_POST_SELECTED: null, RECEIVED_MENTION_DATA: null, RECEIVED_ADD_MENTION: null, RECEIVED_POST_PINNED: null, RECEIVED_POST_UNPINNED: null, - POST_DRAFT_CHANGED: null, + INCREASE_POST_VISIBILITY: null, + LOADING_POSTS: null, RECEIVED_PROFILES: null, RECEIVED_PROFILES_IN_TEAM: null, @@ -309,6 +311,8 @@ export const Constants = { ErrorPageTypes, ErrorBarTypes, + MAX_POST_VISIBILITY: 1000000, + IGNORE_POST_TYPES: [PostTypes.JOIN_LEAVE, PostTypes.JOIN_CHANNEL, PostTypes.LEAVE_CHANNEL, PostTypes.REMOVE_FROM_CHANNEL, PostTypes.ADD_TO_CHANNEL, PostTypes.ADD_REMOVE], PayloadSources: keyMirror({ diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx index e7f13c4a0..9827dfb34 100644 --- a/webapp/utils/utils.jsx +++ b/webapp/utils/utils.jsx @@ -12,6 +12,7 @@ import Constants from 'utils/constants.jsx'; var ActionTypes = Constants.ActionTypes; import Client from 'client/web_client.jsx'; import * as UserAgent from 'utils/user_agent.jsx'; +import {Posts} from 'mattermost-redux/constants'; import {browserHistory} from 'react-router/es6'; import {FormattedMessage} from 'react-intl'; @@ -1194,7 +1195,7 @@ export function clearFileInput(elm) { } export function isPostEphemeral(post) { - return post.type === Constants.PostTypes.EPHEMERAL || post.state === Constants.POST_DELETED; + return post.type === Constants.PostTypes.EPHEMERAL || post.state === Posts.POST_DELETED; } export function getRootId(post) { diff --git a/webapp/yarn.lock b/webapp/yarn.lock index fea8eb6a2..044fa1852 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -4884,7 +4884,7 @@ math-expression-evaluator@^1.2.14: mattermost-redux@mattermost/mattermost-redux#webapp-master: version "0.0.1" - resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/1ba6245017789943d286443b95fd75d651437df7" + resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/f19e3ea5487dcd172d177d271f1b596b23ef6ea5" dependencies: deep-equal "1.0.1" harmony-reflect "1.5.1" @@ -6019,6 +6019,13 @@ rc@^1.1.2, rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-addons-perf@^15.4.2: + version "15.4.2" + resolved "https://registry.yarnpkg.com/react-addons-perf/-/react-addons-perf-15.4.2.tgz#110bdcf5c459c4f77cb85ed634bcd3397536383b" + dependencies: + fbjs "^0.8.4" + object-assign "^4.1.0" + react-addons-pure-render-mixin@15.5.2: version "15.5.2" resolved "https://registry.yarnpkg.com/react-addons-pure-render-mixin/-/react-addons-pure-render-mixin-15.5.2.tgz#ebb846aeb2fd771336c232822923108f87d5bff2" |