Commit a6fbccba authored by Joram Wilander's avatar Joram Wilander Committed by GitHub

PLT-6215 Major post list refactor (#6501)

* Major post list refactor

* Fix post and thread deletion

* Fix preferences not selecting correctly

* Fix military time displaying

* Fix UP key for editing posts

* Fix ESLint error

* Various fixes and updates per feedback

* Fix for permalink view

* Revert to old scrolling method and various fixes

* Add floating timestamp, new message indicator, scroll arrows

* Update post loading for focus mode and add visibility limit

* Fix pinning posts and a react warning

* Add loading UI updates from Asaad

* Fix refreshing loop

* Temporarily bump post visibility limit

* Update infinite scrolling

* Remove infinite scrolling
parent c2419bef
......@@ -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"],
......
// 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});
}
}
);
......
......@@ -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();
......
This diff is collapsed.
......@@ -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
});
}
......@@ -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();
......
......@@ -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'
......
......@@ -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();
......
......@@ -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;
}
......
......@@ -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
}}
/>
);
}
......
......@@ -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 = {
......
......@@ -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 = {
......
// 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);
......@@ -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;
......
......@@ -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)})`
}}
/>
);
......
// 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';