diff options
Diffstat (limited to 'web')
49 files changed, 1731 insertions, 1083 deletions
diff --git a/web/react/.eslintrc b/web/react/.eslintrc index 29ca97faf..935bb638a 100644 --- a/web/react/.eslintrc +++ b/web/react/.eslintrc @@ -47,7 +47,7 @@ "no-irregular-whitespace": 2, "no-unexpected-multiline": 2, "no-unreachable": 2, - "no-magic-numbers": [0, { "enforceConst": true, "detectObjects": true } ], + "no-magic-numbers": [1, { "enforceConst": true, "detectObjects": true } ], "valid-typeof": 2, "block-scoped-var": 2, @@ -190,7 +190,7 @@ "react/no-did-mount-set-state": 2, "react/no-did-update-set-state": 2, "react/no-direct-mutation-state": 2, - "react/no-multi-comp": 2, + "react/no-multi-comp": [2, { "ignoreStateless": true }], "react/no-set-state": 0, "react/no-unknown-property": 2, "react/prefer-es6-class": 2, diff --git a/web/react/components/center_panel.jsx b/web/react/components/center_panel.jsx index c2ecf4fa2..3c6a36ad4 100644 --- a/web/react/components/center_panel.jsx +++ b/web/react/components/center_panel.jsx @@ -4,11 +4,13 @@ import TutorialIntroScreens from './tutorial/tutorial_intro_screens.jsx'; import CreatePost from './create_post.jsx'; import PostsViewContainer from './posts_view_container.jsx'; +import PostFocusView from './post_focus_view.jsx'; import ChannelHeader from './channel_header.jsx'; import Navbar from './navbar.jsx'; import FileUploadOverlay from './file_upload_overlay.jsx'; import PreferenceStore from '../stores/preference_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; import UserStore from '../stores/user_store.jsx'; import Constants from '../utils/constants.jsx'; @@ -20,26 +22,48 @@ export default class CenterPanel extends React.Component { super(props); this.onPreferenceChange = this.onPreferenceChange.bind(this); + this.onChannelChange = this.onChannelChange.bind(this); const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '999'}); - this.state = {showTutorialScreens: parseInt(tutorialPref.value, 10) === TutorialSteps.INTRO_SCREENS}; + this.state = { + showTutorialScreens: parseInt(tutorialPref.value, 10) === TutorialSteps.INTRO_SCREENS, + showPostFocus: ChannelStore.getPostMode() === ChannelStore.POST_MODE_FOCUS + }; } componentDidMount() { PreferenceStore.addChangeListener(this.onPreferenceChange); + ChannelStore.addChangeListener(this.onChannelChange); } componentWillUnmount() { PreferenceStore.removeChangeListener(this.onPreferenceChange); + ChannelStore.removeChangeListener(this.onChannelChange); } onPreferenceChange() { const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '999'}); this.setState({showTutorialScreens: parseInt(tutorialPref.value, 10) <= TutorialSteps.INTRO_SCREENS}); } + onChannelChange() { + this.setState({showPostFocus: ChannelStore.getPostMode() === ChannelStore.POST_MODE_FOCUS}); + } render() { let postsContainer; + let createPost; if (this.state.showTutorialScreens) { postsContainer = <TutorialIntroScreens />; + createPost = null; + } else if (this.state.showPostFocus) { + postsContainer = <PostFocusView />; + createPost = null; } else { postsContainer = <PostsViewContainer />; + createPost = ( + <div + className='post-create__container' + id='post-create' + > + <CreatePost /> + </div> + ); } return ( @@ -62,12 +86,7 @@ export default class CenterPanel extends React.Component { <ChannelHeader /> </div> {postsContainer} - <div - className='post-create__container' - id='post-create' - > - <CreatePost /> - </div> + {createPost} </div> </div> </div> diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx index 8c721348f..6e12c7c14 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -4,6 +4,7 @@ import NavbarSearchBox from './search_bar.jsx'; import MessageWrapper from './message_wrapper.jsx'; import PopoverListMembers from './popover_list_members.jsx'; +import EditChannelHeaderModal from './edit_channel_header_modal.jsx'; import EditChannelPurposeModal from './edit_channel_purpose_modal.jsx'; import ChannelInfoModal from './channel_info_modal.jsx'; import ChannelInviteModal from './channel_invite_modal.jsx'; @@ -167,17 +168,13 @@ export default class ChannelHeader extends React.Component { key='edit_header_direct' role='presentation' > - <a + <ToggleModalButton role='menuitem' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} + dialogType={EditChannelHeaderModal} + dialogProps={{channel}} > {'Set Channel Header...'} - </a> + </ToggleModalButton> </li> ); } else { @@ -235,17 +232,13 @@ export default class ChannelHeader extends React.Component { key='set_channel_header' role='presentation' > - <a + <ToggleModalButton role='menuitem' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} + dialogType={EditChannelHeaderModal} + dialogProps={{channel}} > - {'Set '}{channelTerm}{' Header...'} - </a> + {`Set ${channelTerm} Header...`} + </ToggleModalButton> </li> ); dropdownContents.push( diff --git a/web/react/components/channel_invite_modal.jsx b/web/react/components/channel_invite_modal.jsx index 6d3203ae5..0518ccb86 100644 --- a/web/react/components/channel_invite_modal.jsx +++ b/web/react/components/channel_invite_modal.jsx @@ -22,6 +22,17 @@ export default class ChannelInviteModal extends React.Component { this.state = this.getStateFromStores(); } + shouldComponentUpdate(nextProps, nextState) { + if (!Utils.areObjectsEqual(this.props, nextProps)) { + return true; + } + + if (!Utils.areObjectsEqual(this.state, nextState)) { + return true; + } + + return false; + } getStateFromStores() { function getId(user) { return user.id; @@ -105,7 +116,7 @@ export default class ChannelInviteModal extends React.Component { } this.setState({inviteError: null, memberIds, nonmembers}); - AsyncClient.getChannelExtraInfo(true); + AsyncClient.getChannelExtraInfo(); }, (err) => { this.setState({inviteError: err.message}); diff --git a/web/react/components/channel_loader.jsx b/web/react/components/channel_loader.jsx index e29c659c7..c8f1196a8 100644 --- a/web/react/components/channel_loader.jsx +++ b/web/react/components/channel_loader.jsx @@ -27,8 +27,8 @@ export default class ChannelLoader extends React.Component { componentDidMount() { /* Initial aysnc loads */ AsyncClient.getPosts(ChannelStore.getCurrentId()); - AsyncClient.getChannels(true, true); - AsyncClient.getChannelExtraInfo(true); + AsyncClient.getChannels(); + AsyncClient.getChannelExtraInfo(); AsyncClient.findTeams(); AsyncClient.getMyTeam(); setTimeout(() => AsyncClient.getStatuses(), 3000); // temporary until statuses are reworked a bit diff --git a/web/react/components/channel_members_modal.jsx b/web/react/components/channel_members_modal.jsx index 08ad95091..f07fc166a 100644 --- a/web/react/components/channel_members_modal.jsx +++ b/web/react/components/channel_members_modal.jsx @@ -25,6 +25,17 @@ export default class ChannelMembersModal extends React.Component { state.showInviteModal = false; this.state = state; } + shouldComponentUpdate(nextProps, nextState) { + if (!Utils.areObjectsEqual(this.props, nextProps)) { + return true; + } + + if (!Utils.areObjectsEqual(this.state, nextState)) { + return true; + } + + return false; + } getStateFromStores() { const users = UserStore.getActiveOnlyProfiles(); const memberList = ChannelStore.getCurrentExtraInfo().members; @@ -74,6 +85,7 @@ export default class ChannelMembersModal extends React.Component { if ($(window).width() > 768) { $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar(); } + this.onChange(); } componentDidUpdate(prevProps) { if (this.props.show && !prevProps.show) { @@ -130,7 +142,7 @@ export default class ChannelMembersModal extends React.Component { } this.setState({memberList, nonmemberList}); - AsyncClient.getChannelExtraInfo(true); + AsyncClient.getChannelExtraInfo(); }, (err) => { this.setState({inviteError: err.message}); diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 0a2979e21..f7f63fb92 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -8,6 +8,7 @@ import FilePreview from './file_preview.jsx'; import TutorialTip from './tutorial/tutorial_tip.jsx'; import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; import * as Client from '../utils/client.jsx'; import * as AsyncClient from '../utils/async_client.jsx'; import * as Utils from '../utils/utils.jsx'; @@ -19,6 +20,7 @@ import PreferenceStore from '../stores/preference_store.jsx'; import SocketStore from '../stores/socket_store.jsx'; import Constants from '../utils/constants.jsx'; + const Preferences = Constants.Preferences; const TutorialSteps = Constants.TutorialSteps; const ActionTypes = Constants.ActionTypes; @@ -176,9 +178,7 @@ export default class CreatePost extends React.Component { const channel = ChannelStore.get(this.state.channelId); - PostStore.storePendingPost(post); - PostStore.storeDraft(channel.id, null); - PostStore.jumpPostsViewToBottom(); + EventHelpers.emitUserPostedEvent(post); this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); Client.createPost(post, channel, @@ -190,10 +190,7 @@ export default class CreatePost extends React.Component { member.last_viewed_at = Date.now(); ChannelStore.setChannelMember(member); - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_POST, - post: data - }); + EventHelpers.emitPostRecievedEvent(data); }, (err) => { const state = {}; diff --git a/web/react/components/delete_post_modal.jsx b/web/react/components/delete_post_modal.jsx index fab5b60ea..3c4b17905 100644 --- a/web/react/components/delete_post_modal.jsx +++ b/web/react/components/delete_post_modal.jsx @@ -159,13 +159,4 @@ export default class DeletePostModal extends React.Component { </Modal> ); } - - static show(post, commentCount) { - AppDispatcher.handleViewAction({ - type: ActionTypes.TOGGLE_DELETE_POST_MODAL, - value: true, - post, - commentCount: commentCount || 0 - }); - } } diff --git a/web/react/components/edit_channel_header_modal.jsx b/web/react/components/edit_channel_header_modal.jsx new file mode 100644 index 000000000..5529a419d --- /dev/null +++ b/web/react/components/edit_channel_header_modal.jsx @@ -0,0 +1,126 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as Client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import * as Utils from '../utils/utils.jsx'; + +const Modal = ReactBootstrap.Modal; + +export default class EditChannelHeaderModal extends React.Component { + constructor(props) { + super(props); + + this.handleEdit = this.handleEdit.bind(this); + + this.onShow = this.onShow.bind(this); + this.onHide = this.onHide.bind(this); + + this.state = { + serverError: '' + }; + } + + componentDidMount() { + if (this.props.show) { + this.onShow(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.show && !prevProps.show) { + this.onShow(); + } + } + + handleEdit() { + var data = {}; + data.channel_id = this.props.channel.id; + + if (data.channel_id.length !== 26) { + return; + } + + data.channel_header = ReactDOM.findDOMNode(this.refs.textarea).value; + + Client.updateChannelHeader(data, + () => { + this.setState({serverError: ''}); + AsyncClient.getChannel(this.props.channel.id); + this.onHide(); + }, + (err) => { + if (err.message === 'Invalid channel_header parameter') { + this.setState({serverError: 'This channel header is too long, please enter a shorter one'}); + } else { + this.setState({serverError: err.message}); + } + } + ); + } + + onShow() { + const textarea = ReactDOM.findDOMNode(this.refs.textarea); + Utils.placeCaretAtEnd(textarea); + } + + onHide() { + this.setState({ + serverError: '' + }); + + this.props.onHide(); + } + + render() { + var serverError = null; + if (this.state.serverError) { + serverError = <div className='form-group has-error'><br/><label className='control-label'>{this.state.serverError}</label></div>; + } + + return ( + <Modal + show={this.props.show} + onHide={this.onHide} + > + <Modal.Header closeButton={true}> + {'Edit Header for ' + this.props.channel.display_name} + </Modal.Header> + <Modal.Body> + <p>{'Edit the text appearing next to the channel name in the channel header.'}</p> + <textarea + ref='textarea' + className='form-control no-resize' + rows='6' + id='edit_header' + maxLength='1024' + defaultValue={this.props.channel.header} + /> + {serverError} + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-default' + onClick={this.props.onHide} + > + {'Cancel'} + </button> + <button + type='button' + className='btn btn-primary' + onClick={this.handleEdit} + > + {'Save'} + </button> + </Modal.Footer> + </Modal> + ); + } +} + +EditChannelHeaderModal.propTypes = { + show: React.PropTypes.bool.isRequired, + onHide: React.PropTypes.func.isRequired, + channel: React.PropTypes.object.isRequired +}; diff --git a/web/react/components/edit_channel_modal.jsx b/web/react/components/edit_channel_modal.jsx deleted file mode 100644 index 80dab4a57..000000000 --- a/web/react/components/edit_channel_modal.jsx +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import * as Client from '../utils/client.jsx'; -import * as AsyncClient from '../utils/async_client.jsx'; - -export default class EditChannelModal extends React.Component { - constructor(props) { - super(props); - - this.handleEdit = this.handleEdit.bind(this); - this.handleUserInput = this.handleUserInput.bind(this); - this.handleClose = this.handleClose.bind(this); - this.onShow = this.onShow.bind(this); - this.handleShown = this.handleShown.bind(this); - - this.state = { - header: '', - title: '', - channelId: '', - serverError: '' - }; - } - handleEdit() { - var data = {}; - data.channel_id = this.state.channelId; - - if (data.channel_id.length !== 26) { - return; - } - - data.channel_header = this.state.header.trim(); - - Client.updateChannelHeader(data, - () => { - this.setState({serverError: ''}); - AsyncClient.getChannel(this.state.channelId); - $(ReactDOM.findDOMNode(this.refs.modal)).modal('hide'); - }, - (err) => { - if (err.message === 'Invalid channel_header parameter') { - this.setState({serverError: 'This channel header is too long, please enter a shorter one'}); - } else { - this.setState({serverError: err.message}); - } - } - ); - } - handleUserInput(e) { - this.setState({header: e.target.value}); - } - handleClose() { - this.setState({header: '', serverError: ''}); - } - onShow(e) { - const button = e.relatedTarget; - this.setState({header: $(button).attr('data-header'), title: $(button).attr('data-title'), channelId: $(button).attr('data-channelid'), serverError: ''}); - } - handleShown() { - $('#edit_channel #edit_header').focus(); - } - componentDidMount() { - $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', this.onShow); - $(ReactDOM.findDOMNode(this.refs.modal)).on('hidden.bs.modal', this.handleClose); - $(ReactDOM.findDOMNode(this.refs.modal)).on('shown.bs.modal', this.handleShown); - } - componentWillUnmount() { - $(ReactDOM.findDOMNode(this.refs.modal)).off('hidden.bs.modal', this.handleClose); - } - render() { - var serverError = null; - if (this.state.serverError) { - serverError = <div className='form-group has-error'><br/><label className='control-label'>{this.state.serverError}</label></div>; - } - - var editTitle = ( - <h4 - className='modal-title' - ref='title' - > - {'Edit Header'} - </h4> - ); - if (this.state.title) { - editTitle = ( - <h4 - className='modal-title' - ref='title' - > - {'Edit Header for '}<span className='name'>{this.state.title}</span> - </h4> - ); - } - - return ( - <div - className='modal fade' - ref='modal' - id='edit_channel' - role='dialog' - tabIndex='-1' - aria-hidden='true' - > - <div className='modal-dialog'> - <div className='modal-content'> - <div className='modal-header'> - <button - type='button' - className='close' - data-dismiss='modal' - aria-label='Close' - > - <span aria-hidden='true'>{'×'}</span> - </button> - {editTitle} - </div> - <div className='modal-body'> - <p>{'Edit the text appearing next to the channel name in the channel header.'}</p> - <textarea - className='form-control no-resize' - rows='6' - id='edit_header' - maxLength='1024' - value={this.state.header} - onChange={this.handleUserInput} - /> - {serverError} - </div> - <div className='modal-footer'> - <button - type='button' - className='btn btn-default' - data-dismiss='modal' - > - {'Cancel'} - </button> - <button - type='button' - className='btn btn-primary' - onClick={this.handleEdit} - > - {'Save'} - </button> - </div> - </div> - </div> - </div> - ); - } -} diff --git a/web/react/components/edit_post_modal.jsx b/web/react/components/edit_post_modal.jsx index ddbdee8a4..eb58fe721 100644 --- a/web/react/components/edit_post_modal.jsx +++ b/web/react/components/edit_post_modal.jsx @@ -3,7 +3,7 @@ import * as Client from '../utils/client.jsx'; import * as AsyncClient from '../utils/async_client.jsx'; -import DeletePostModal from './delete_post_modal.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; import Textbox from './textbox.jsx'; import BrowserStore from '../stores/browser_store.jsx'; import PostStore from '../stores/post_store.jsx'; @@ -35,7 +35,7 @@ export default class EditPostModal extends React.Component { delete tempState.editText; BrowserStore.setItem('edit_state_transfer', tempState); $('#edit_post').modal('hide'); - DeletePostModal.show(PostStore.getPost(this.state.channel_id, this.state.post_id), this.state.comments); + EventHelpers.showDeletePostModal(PostStore.getPost(this.state.channel_id, this.state.post_id), this.state.comments); return; } diff --git a/web/react/components/get_link_modal.jsx b/web/react/components/get_link_modal.jsx index 2bd2c42d6..df5d6b8e1 100644 --- a/web/react/components/get_link_modal.jsx +++ b/web/react/components/get_link_modal.jsx @@ -1,32 +1,28 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import UserStore from '../stores/user_store.jsx'; +const Modal = ReactBootstrap.Modal; export default class GetLinkModal extends React.Component { constructor(props) { super(props); - this.handleClick = this.handleClick.bind(this); - this.onShow = this.onShow.bind(this); this.onHide = this.onHide.bind(this); - this.state = {copiedLink: false}; - } - onShow(e) { - var button = e.relatedTarget; - this.setState({title: $(button).attr('data-title'), value: $(button).attr('data-value')}); + this.copyLink = this.copyLink.bind(this); + + this.state = { + copiedLink: false + }; } + onHide() { this.setState({copiedLink: false}); + + this.props.onHide(); } - componentDidMount() { - if (this.refs.modal) { - $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', this.onShow); - $(ReactDOM.findDOMNode(this.refs.modal)).on('hide.bs.modal', this.onHide); - } - } - handleClick() { + + copyLink() { var copyTextarea = $(ReactDOM.findDOMNode(this.refs.textarea)); copyTextarea.select(); @@ -41,8 +37,18 @@ export default class GetLinkModal extends React.Component { this.setState({copiedLink: false}); } } + render() { - var currentUser = UserStore.getCurrentUser(); + let helpText = null; + if (this.props.helpText) { + helpText = ( + <p> + {this.props.helpText} + <br /> + <br /> + </p> + ); + } let copyLink = null; if (document.queryCommandSupported('copy')) { @@ -51,75 +57,59 @@ export default class GetLinkModal extends React.Component { data-copy-btn='true' type='button' className='btn btn-primary pull-left' - onClick={this.handleClick} - data-clipboard-text={this.state.value} + onClick={this.copyLink} > - Copy Link + {'Copy Link'} </button> ); } var copyLinkConfirm = null; if (this.state.copiedLink) { - copyLinkConfirm = <p className='alert alert-success copy-link-confirm'><i className='fa fa-check'></i> Link copied to clipboard.</p>; + copyLinkConfirm = <p className='alert alert-success copy-link-confirm'><i className='fa fa-check'></i>{' Link copied to clipboard.'}</p>; } - if (currentUser != null) { - return ( - <div - className='modal fade' - ref='modal' - id='get_link' - tabIndex='-1' - role='dialog' - aria-hidden='true' - > - <div className='modal-dialog'> - <div className='modal-content'> - <div className='modal-header'> - <button - type='button' - className='close' - data-dismiss='modal' - aria-label='Close' - > - <span aria-hidden='true'>×</span> - </button> - <h4 - className='modal-title' - id='myModalLabel' - > - {this.state.title} Link - </h4> - </div> - <div className='modal-body'> - <p> - Send teammates the link below for them to sign-up to this team site. - <br /><br /> - </p> - <textarea - className='form-control no-resize min-height' - readOnly='true' - ref='textarea' - value={this.state.value} - /> - </div> - <div className='modal-footer'> - <button - type='button' - className='btn btn-default' - data-dismiss='modal' - > - Close - </button> - {copyLink} - {copyLinkConfirm} - </div> - </div> - </div> - </div> - ); - } - return <div/>; + return ( + <Modal + show={this.props.show} + onHide={this.onHide} + > + <Modal.Header closeButton={true}> + {this.props.title} + </Modal.Header> + <Modal.Body> + {helpText} + <textarea + className='form-control no-resize min-height' + readOnly='true' + ref='textarea' + value={this.props.link} + /> + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-default' + onClick={this.onHide} + > + {'Close'} + </button> + {copyLink} + {copyLinkConfirm} + </Modal.Footer> + </Modal> + ); } } + +GetLinkModal.propTypes = { + show: React.PropTypes.bool.isRequired, + onHide: React.PropTypes.func.isRequired, + title: React.PropTypes.string.isRequired, + helpText: React.PropTypes.string, + link: React.PropTypes.string.isRequired +}; + +GetLinkModal.defaultProps = { + helpText: null +}; diff --git a/web/react/components/get_team_invite_link_modal.jsx b/web/react/components/get_team_invite_link_modal.jsx new file mode 100644 index 000000000..a926c4451 --- /dev/null +++ b/web/react/components/get_team_invite_link_modal.jsx @@ -0,0 +1,45 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Constants from '../utils/constants.jsx'; +import GetLinkModal from './get_link_modal.jsx'; +import ModalStore from '../stores/modal_store.jsx'; +import TeamStore from '../stores/team_store.jsx'; + +export default class GetTeamInviteLinkModal extends React.Component { + constructor(props) { + super(props); + + this.handleToggle = this.handleToggle.bind(this); + + this.state = { + show: false + }; + } + + componentDidMount() { + ModalStore.addModalListener(Constants.ActionTypes.TOGGLE_GET_TEAM_INVITE_LINK_MODAL, this.handleToggle); + } + + componentWillUnmount() { + ModalStore.removeModalListener(Constants.ActionTypes.TOGGLE_GET_TEAM_INVITE_LINK_MODAL, this.handleToggle); + } + + handleToggle(value) { + this.setState({ + show: value + }); + } + + render() { + return ( + <GetLinkModal + show={this.state.show} + onHide={() => this.setState({show: false})} + title='Team Invite Link' + helpText='Send teammates the link below for them to sign-up to this team site.' + link={TeamStore.getCurrentInviteLink()} + /> + ); + } +} diff --git a/web/react/components/invite_member_modal.jsx b/web/react/components/invite_member_modal.jsx index 7df75252e..76f52faa9 100644 --- a/web/react/components/invite_member_modal.jsx +++ b/web/react/components/invite_member_modal.jsx @@ -4,8 +4,8 @@ import * as utils from '../utils/utils.jsx'; import Constants from '../utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import * as Client from '../utils/client.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; import ModalStore from '../stores/modal_store.jsx'; import UserStore from '../stores/user_store.jsx'; import TeamStore from '../stores/team_store.jsx'; @@ -23,6 +23,7 @@ export default class InviteMemberModal extends React.Component { this.addInviteFields = this.addInviteFields.bind(this); this.clearFields = this.clearFields.bind(this); this.removeInviteFields = this.removeInviteFields.bind(this); + this.showGetTeamInviteLinkModal = this.showGetTeamInviteLinkModal.bind(this); this.state = { show: false, @@ -188,6 +189,12 @@ export default class InviteMemberModal extends React.Component { this.setState({inviteIds: inviteIds, idCount: count}); } + showGetTeamInviteLinkModal() { + this.handleHide(false); + + EventHelpers.showGetTeamInviteLinkModal(); + } + render() { var currentUser = UserStore.getCurrentUser(); @@ -333,22 +340,18 @@ export default class InviteMemberModal extends React.Component { } else { var teamInviteLink = null; if (currentUser && TeamStore.getCurrent().type === 'O') { - var linkUrl = utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + TeamStore.getCurrent().invite_id; - var link = - ( - <a - href='#' - data-toggle='modal' - data-target='#get_link' - data-title='Team Invite' - data-value={linkUrl} - onClick={() => this.handleHide(this, false)} - >Team Invite Link</a> + var link = ( + <a + href='#' + onClick={this.showGetTeamInviteLinkModal} + > + {'Team Invite Link'} + </a> ); teamInviteLink = ( <p> - You can also invite people using the {link}. + {'You can also invite people using the '}{link}{'.'} </p> ); } @@ -405,13 +408,6 @@ export default class InviteMemberModal extends React.Component { return null; } - - static show() { - AppDispatcher.handleViewAction({ - type: ActionTypes.TOGGLE_INVITE_MEMBER_MODAL, - value: true - }); - } } InviteMemberModal.propTypes = { diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx index 6848ee5da..03cc75a08 100644 --- a/web/react/components/navbar.jsx +++ b/web/react/components/navbar.jsx @@ -1,6 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import EditChannelHeaderModal from './edit_channel_header_modal.jsx'; import EditChannelPurposeModal from './edit_channel_purpose_modal.jsx'; import MessageWrapper from './message_wrapper.jsx'; import NotifyCounts from './notify_counts.jsx'; @@ -33,11 +34,15 @@ export default class Navbar extends React.Component { this.onChange = this.onChange.bind(this); this.handleLeave = this.handleLeave.bind(this); this.showSearch = this.showSearch.bind(this); + + this.showEditChannelHeaderModal = this.showEditChannelHeaderModal.bind(this); + this.createCollapseButtons = this.createCollapseButtons.bind(this); this.createDropdown = this.createDropdown.bind(this); const state = this.getStateFromStores(); state.showEditChannelPurposeModal = false; + state.showEditChannelHeaderModal = false; state.showMembersModal = false; state.showInviteModal = false; this.state = state; @@ -110,6 +115,16 @@ export default class Navbar extends React.Component { this.setState(this.getStateFromStores()); $('#navbar .navbar-brand .description').popover({placement: 'bottom', trigger: 'click', html: true}); } + showEditChannelHeaderModal() { + // this can't be done using a ToggleModalButton because we can't use one inside an OverlayTrigger + if (this.refs.headerOverlay) { + this.refs.headerOverlay.hide(); + } + + this.setState({ + showEditChannelHeaderModal: true + }); + } createDropdown(channel, channelTitle, isAdmin, isDirect, popoverContent) { if (channel) { var viewInfoOption = ( @@ -129,11 +144,7 @@ export default class Navbar extends React.Component { <a role='menuitem' href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} + onClick={this.showEditChannelHeaderModal} > {'Set Channel Header...'} </a> @@ -239,7 +250,7 @@ export default class Navbar extends React.Component { dialogType={ChannelNotificationsModal} dialogProps={{channel}} > - {'Notification Preferences'} + {'Notification Preferences'} </ToggleModalButton> </li> ); @@ -249,6 +260,7 @@ export default class Navbar extends React.Component { <div className='navbar-brand'> <div className='dropdown'> <OverlayTrigger + ref='headerOverlay' trigger='click' placement='bottom' overlay={popoverContent} @@ -358,6 +370,9 @@ export default class Navbar extends React.Component { var isAdmin = false; var isDirect = false; + var editChannelHeaderModal = null; + var editChannelPurposeModal = null; + if (channel) { popoverContent = ( <Popover @@ -400,11 +415,7 @@ export default class Navbar extends React.Component { <br/> <a href='#' - data-toggle='modal' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - data-target='#edit_channel' + onClick={this.showEditChannelHeaderModal} > {'Click here'} </a> @@ -413,6 +424,22 @@ export default class Navbar extends React.Component { </Popover> ); } + + editChannelHeaderModal = ( + <EditChannelHeaderModal + show={this.state.showEditChannelHeaderModal} + onHide={() => this.setState({showEditChannelHeaderModal: false})} + channel={channel} + /> + ); + + editChannelPurposeModal = ( + <EditChannelPurposeModal + show={this.state.showEditChannelPurposeModal} + onModalDismissed={() => this.setState({showEditChannelPurposeModal: false})} + channel={channel} + /> + ); } var collapseButtons = this.createCollapseButtons(currentId); @@ -443,11 +470,8 @@ export default class Navbar extends React.Component { </div> </div> </nav> - <EditChannelPurposeModal - show={this.state.showEditChannelPurposeModal} - onModalDismissed={() => this.setState({showEditChannelPurposeModal: false})} - channel={channel} - /> + {editChannelHeaderModal} + {editChannelPurposeModal} <ChannelMembersModal show={this.state.showMembersModal} onModalDismissed={() => this.setState({showMembersModal: false})} diff --git a/web/react/components/navbar_dropdown.jsx b/web/react/components/navbar_dropdown.jsx index c0230fe5f..a14434bfc 100644 --- a/web/react/components/navbar_dropdown.jsx +++ b/web/react/components/navbar_dropdown.jsx @@ -5,9 +5,9 @@ import * as Utils from '../utils/utils.jsx'; import * as client from '../utils/client.jsx'; import UserStore from '../stores/user_store.jsx'; import TeamStore from '../stores/team_store.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; import AboutBuildModal from './about_build_modal.jsx'; -import InviteMemberModal from './invite_member_modal.jsx'; import UserSettingsModal from './user_settings/user_settings_modal.jsx'; import Constants from '../utils/constants.jsx'; @@ -93,7 +93,7 @@ export default class NavbarDropdown extends React.Component { <li> <a href='#' - onClick={InviteMemberModal.show} + onClick={EventHelpers.showInviteMemberModal} > {'Invite New Member'} </a> @@ -105,10 +105,7 @@ export default class NavbarDropdown extends React.Component { <li> <a href='#' - data-toggle='modal' - data-target='#get_link' - data-title='Team Invite' - data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + TeamStore.getCurrent().invite_id} + onClick={EventHelpers.showGetTeamInviteLinkModal} > {'Get Team Invite Link'} </a> diff --git a/web/react/components/post.jsx b/web/react/components/post.jsx index 5b61c711c..278261e22 100644 --- a/web/react/components/post.jsx +++ b/web/react/components/post.jsx @@ -105,7 +105,7 @@ export default class Post extends React.Component { } else { commentRootId = post.id; } - for (let postId in posts) { + for (const postId in posts) { if (posts[postId].root_id === commentRootId) { commentCount += 1; } @@ -114,53 +114,58 @@ export default class Post extends React.Component { return commentCount; } render() { - var post = this.props.post; - var parentPost = this.props.parentPost; - var posts = this.props.posts; + const post = this.props.post; + const parentPost = this.props.parentPost; + const posts = this.props.posts; if (!post.props) { post.props = {}; } - var type = 'Post'; + let type = 'Post'; if (post.root_id && post.root_id.length > 0) { type = 'Comment'; } const commentCount = this.getCommentCount(this.props); - var rootUser; + let rootUser; if (this.props.sameRoot) { rootUser = 'same--root'; } else { rootUser = 'other--root'; } - var postType = ''; + let postType = ''; if (type !== 'Post') { postType = 'post--comment'; } else if (commentCount > 0) { postType = 'post--root'; } - var currentUserCss = ''; + let currentUserCss = ''; if (UserStore.getCurrentId() === post.user_id && !post.props.from_webhook) { currentUserCss = 'current--user'; } - var userProfile = UserStore.getProfile(post.user_id); + const userProfile = UserStore.getProfile(post.user_id); - var timestamp = UserStore.getCurrentUser().update_at; + let timestamp = UserStore.getCurrentUser().update_at; if (userProfile) { timestamp = userProfile.update_at; } - var sameUserClass = ''; + let sameUserClass = ''; if (this.props.sameUser) { sameUserClass = 'same--user'; } - var profilePic = null; + let shouldHighlightClass = ''; + if (this.props.shouldHighlight) { + shouldHighlightClass = 'post--highlight'; + } + + let profilePic = null; if (!this.props.hideProfilePic) { let src = '/api/v1/users/' + post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex(); if (post.props && post.props.from_webhook && global.window.mm_config.EnablePostIconOverride === 'true') { @@ -182,7 +187,7 @@ export default class Post extends React.Component { <div> <div id={'post_' + post.id} - className={'post ' + sameUserClass + ' ' + rootUser + ' ' + postType + ' ' + currentUserCss} + className={'post ' + sameUserClass + ' ' + rootUser + ' ' + postType + ' ' + currentUserCss + ' ' + shouldHighlightClass} > <div className='post__content'> <div className='post__img'>{profilePic}</div> @@ -218,5 +223,6 @@ Post.propTypes = { sameUser: React.PropTypes.bool, sameRoot: React.PropTypes.bool, hideProfilePic: React.PropTypes.bool, - isLastComment: React.PropTypes.bool + isLastComment: React.PropTypes.bool, + shouldHighlight: React.PropTypes.bool }; diff --git a/web/react/components/post_focus_view.jsx b/web/react/components/post_focus_view.jsx new file mode 100644 index 000000000..5c6ad6c28 --- /dev/null +++ b/web/react/components/post_focus_view.jsx @@ -0,0 +1,110 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PostsView from './posts_view.jsx'; + +import PostStore from '../stores/post_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; + +export default class PostFocusView extends React.Component { + constructor(props) { + super(props); + + this.onChannelChange = this.onChannelChange.bind(this); + this.onPostsChange = this.onPostsChange.bind(this); + this.handlePostsViewScroll = this.handlePostsViewScroll.bind(this); + this.loadMorePostsTop = this.loadMorePostsTop.bind(this); + this.loadMorePostsBottom = this.loadMorePostsBottom.bind(this); + + const focusedPostId = PostStore.getFocusedPostId(); + + this.state = { + scrollType: PostsView.SCROLL_TYPE_POST, + scrollPostId: focusedPostId, + postList: PostStore.getVisiblePosts(focusedPostId), + atTop: PostStore.getVisibilityAtTop(focusedPostId), + atBottom: PostStore.getVisibilityAtBottom(focusedPostId) + }; + } + + componentDidMount() { + ChannelStore.addChangeListener(this.onChannelChange); + PostStore.addChangeListener(this.onPostsChange); + } + + componentWillUnmount() { + ChannelStore.removeChangeListener(this.onChannelChange); + PostStore.removeChangeListener(this.onPostsChange); + } + + onChannelChange() { + this.setState({ + scrollType: PostsView.SCROLL_TYPE_POST + }); + } + + onPostsChange() { + const focusedPostId = PostStore.getFocusedPostId(); + if (focusedPostId == null) { + return; + } + + this.setState({ + scrollPostId: focusedPostId, + postList: PostStore.getVisiblePosts(focusedPostId), + atTop: PostStore.getVisibilityAtTop(focusedPostId), + atBottom: PostStore.getVisibilityAtBottom(focusedPostId) + }); + } + + handlePostsViewScroll() { + this.setState({scrollType: PostsView.SCROLL_TYPE_FREE}); + } + + loadMorePostsTop() { + EventHelpers.emitLoadMorePostsFocusedTopEvent(); + } + + loadMorePostsBottom() { + EventHelpers.emitLoadMorePostsFocusedBottomEvent(); + } + + getIntroMessage() { + return ( + <div className='channel-intro'> + <h4 className='channel-intro__title'>{'Beginning of Channel'}</h4> + </div> + ); + } + + render() { + const postsToHighlight = {}; + postsToHighlight[this.state.scrollPostId] = true; + + return ( + <div id='post-list'> + <PostsView + key={'postfocusview'} + isActive={true} + postList={this.state.postList} + scrollType={this.state.scrollType} + scrollPostId={this.state.scrollPostId} + postViewScrolled={this.handlePostsViewScroll} + loadMorePostsTopClicked={this.loadMorePostsTop} + loadMorePostsBottomClicked={this.loadMorePostsBottom} + showMoreMessagesTop={!this.state.atTop} + showMoreMessagesBottom={!this.state.atBottom} + introText={this.getIntroMessage()} + messageSeparatorTime={0} + postsToHighlight={postsToHighlight} + /> + </div> + ); + } +} +PostFocusView.defaultProps = { +}; + +PostFocusView.propTypes = { +}; diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx index 5306edd3d..cedb2b59b 100644 --- a/web/react/components/post_info.jsx +++ b/web/react/components/post_info.jsx @@ -1,22 +1,30 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import DeletePostModal from './delete_post_modal.jsx'; import UserStore from '../stores/user_store.jsx'; -import * as utils from '../utils/utils.jsx'; +import TeamStore from '../stores/team_store.jsx'; +import * as Utils from '../utils/utils.jsx'; import TimeSince from './time_since.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; import Constants from '../utils/constants.jsx'; +const OverlayTrigger = ReactBootstrap.OverlayTrigger; +const Popover = ReactBootstrap.Popover; + export default class PostInfo extends React.Component { constructor(props) { super(props); - this.state = {}; + this.state = { + copiedLink: false + }; + + this.handlePermalinkCopy = this.handlePermalinkCopy.bind(this); } createDropdown() { var post = this.props.post; var isOwner = UserStore.getCurrentId() === post.user_id; - var isAdmin = utils.isAdmin(UserStore.getCurrentUser().roles); + var isAdmin = Utils.isAdmin(UserStore.getCurrentUser().roles); if (post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING || post.state === Constants.POST_DELETED) { return ''; @@ -66,7 +74,7 @@ export default class PostInfo extends React.Component { <a href='#' role='menuitem' - onClick={() => DeletePostModal.show(post, dataComments)} + onClick={() => EventHelpers.showDeletePostModal(post, dataComments)} > {'Delete'} </a> @@ -81,7 +89,7 @@ export default class PostInfo extends React.Component { role='presentation' > <a - className='reply-link visible-xs theme' + className='link__reply theme' href='#' onClick={this.props.handleCommentClick} > @@ -113,6 +121,21 @@ export default class PostInfo extends React.Component { </div> ); } + handlePermalinkCopy() { + const textBox = $(ReactDOM.findDOMNode(this.refs.permalinkbox)); + textBox.select(); + + try { + const successful = document.execCommand('copy'); + if (successful) { + this.setState({copiedLink: true}); + } else { + this.setState({copiedLink: false}); + } + } catch (err) { + this.setState({copiedLink: false}); + } + } render() { var post = this.props.post; var comments = ''; @@ -143,6 +166,37 @@ export default class PostInfo extends React.Component { var dropdown = this.createDropdown(); + const permalink = TeamStore.getCurrentTeamUrl() + '/pl/' + post.id; + const copyButtonText = this.state.copiedLink ? (<div>{'Copy '}<i className='fa fa-check'/></div>) : 'Copy'; + const permalinkOverlay = ( + <Popover + id='permalink-overlay' + className='permalink-popover' + placement='left' + title='' + > + <div className='form-inline'> + <input + type='text' + readOnly='true' + ref='permalinkbox' + className='permalink-text form-control no-resize min-height input-large' + rows='1' + value={permalink} + /> + <button + data-copy-btn='true' + type='button' + className='btn btn-primary' + onClick={this.handlePermalinkCopy} + data-clipboard-text={permalink} + > + {copyButtonText} + </button> + </div> + </Popover> + ); + return ( <ul className='post__header post__header--info'> <li className='col'> @@ -152,6 +206,15 @@ export default class PostInfo extends React.Component { </li> <li className='col col__reply'> {comments} + <OverlayTrigger + trigger='click' + placement='left' + rootClose={true} + overlay={permalinkOverlay} + > + <i className={'permalink-icon fa fa-link ' + showCommentClass}/> + </OverlayTrigger> + <div className='dropdown'> {dropdown} </div> diff --git a/web/react/components/posts_view.jsx b/web/react/components/posts_view.jsx index 5b36ecbc5..5e374b877 100644 --- a/web/react/components/posts_view.jsx +++ b/web/react/components/posts_view.jsx @@ -2,6 +2,7 @@ // See License.txt for license information. import UserStore from '../stores/user_store.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; import * as Utils from '../utils/utils.jsx'; import Post from './post.jsx'; import Constants from '../utils/constants.jsx'; @@ -13,6 +14,7 @@ export default class PostsView extends React.Component { this.handleScroll = this.handleScroll.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); @@ -27,12 +29,15 @@ export default class PostsView extends React.Component { static get SCROLL_TYPE_BOTTOM() { return 2; } - static get SIDEBAR_OPEN() { + static get SCROLL_TYPE_SIDEBAR_OPEN() { return 3; } static get SCROLL_TYPE_NEW_MESSAGE() { return 4; } + static get SCROLL_TYPE_POST() { + return 5; + } isAtBottom() { return ((this.refs.postlist.scrollHeight - this.refs.postlist.scrollTop) === this.refs.postlist.clientHeight); } @@ -47,15 +52,22 @@ export default class PostsView extends React.Component { } } this.wasAtBottom = this.isAtBottom(); + if (!this.jumpToPostNode && childNodes.length > 0) { + this.jumpToPostNode = childNodes[childNodes.length - 1]; + } // --- -------- this.props.postViewScrolled(this.isAtBottom()); this.prevScrollHeight = this.refs.postlist.scrollHeight; + this.prevOffsetTop = this.jumpToPostNode.offsetTop; } loadMorePostsTop() { this.props.loadMorePostsTopClicked(); } + loadMorePostsBottom() { + this.props.loadMorePostsBottomClicked(); + } createPosts(posts, order) { const postCtls = []; let previousPostDay = new Date(0); @@ -63,12 +75,7 @@ export default class PostsView extends React.Component { let renderedLastViewed = false; - let numToDisplay = this.props.numPostsToDisplay; - if (order.length - 1 < numToDisplay) { - numToDisplay = order.length - 1; - } - - for (let i = numToDisplay; i >= 0; i--) { + 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]]; @@ -113,6 +120,8 @@ export default class PostsView extends React.Component { const keyPrefix = post.id ? post.id : i; + const shouldHighlight = this.props.postsToHighlight && this.props.postsToHighlight.hasOwnProperty(post.id); + const postCtl = ( <Post key={keyPrefix + 'postKey'} @@ -124,6 +133,8 @@ export default class PostsView extends React.Component { posts={posts} hideProfilePic={hideProfilePic} isLastComment={isLastComment} + shouldHighlight={shouldHighlight} + onClick={() => EventHelpers.emitPostFocusEvent(post.id)} //eslint-disable-line no-loop-func /> ); @@ -185,9 +196,12 @@ export default class PostsView extends React.Component { this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; } }); - } else if (this.props.scrollType === PostsView.SCROLL_TYPE_POST && this.props.scrollPost) { + } else if (this.props.scrollType === PostsView.SCROLL_TYPE_POST && this.props.scrollPostId) { window.requestAnimationFrame(() => { - const postNode = ReactDOM.findDOMNode(this.refs[this.props.scrollPost]); + 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 / 3); @@ -195,7 +209,7 @@ export default class PostsView extends React.Component { this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / 3) + (this.refs.postlist.scrollTop - postNode.offsetTop); } }); - } else if (this.props.scrollType === PostsView.SIDEBAR_OPEN) { + } else if (this.props.scrollType === PostsView.SCROLL_TYPE_SIDEBAR_OPEN) { // If we are at the bottom then stay there if (this.wasAtBottom) { this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; @@ -211,7 +225,10 @@ export default class PostsView extends React.Component { } } else if (this.refs.postlist.scrollHeight !== this.prevScrollHeight) { window.requestAnimationFrame(() => { - this.refs.postlist.scrollTop += (this.refs.postlist.scrollHeight - this.prevScrollHeight); + // Only need to jump if we added posts to the top. + if (this.jumpToPostNode && (this.jumpToPostNode.offsetTop !== this.prevOffsetTop)) { + this.refs.postlist.scrollTop += (this.refs.postlist.scrollHeight - this.prevScrollHeight); + } }); } } @@ -219,14 +236,18 @@ export default class PostsView extends React.Component { this.updateScrolling(); } componentDidMount() { - this.updateScrolling(); + if (this.props.postList != null) { + this.updateScrolling(); + } window.addEventListener('resize', this.handleResize); } componentWillUnmount() { window.removeEventListener('resize', this.handleResize); } componentDidUpdate() { - this.updateScrolling(); + if (this.props.postList != null) { + this.updateScrolling(); + } } shouldComponentUpdate(nextProps) { if (this.props.isActive !== nextProps.isActive) { @@ -235,15 +256,12 @@ export default class PostsView extends React.Component { if (this.props.postList !== nextProps.postList) { return true; } - if (this.props.scrollPost !== nextProps.scrollPost) { + if (this.props.scrollPostId !== nextProps.scrollPostId) { return true; } if (this.props.scrollType !== nextProps.scrollType && nextProps.scrollType !== PostsView.SCROLL_TYPE_FREE) { return true; } - if (this.props.numPostsToDisplay !== nextProps.numPostsToDisplay) { - return true; - } if (this.props.messageSeparatorTime !== nextProps.messageSeparatorTime) { return true; } @@ -256,7 +274,8 @@ export default class PostsView extends React.Component { render() { let posts = []; let order = []; - let moreMessages; + let moreMessagesTop; + let moreMessagesBottom; let postElements; let activeClass = 'inactive'; if (this.props.postList != null) { @@ -264,10 +283,10 @@ export default class PostsView extends React.Component { order = this.props.postList.order; // Create intro message or top loadmore link - if (order.length >= this.props.numPostsToDisplay) { - moreMessages = ( + if (this.props.showMoreMessagesTop) { + moreMessagesTop = ( <a - ref='loadmore' + ref='loadmoretop' className='more-messages-text theme' href='#' onClick={this.loadMorePostsTop} @@ -276,7 +295,23 @@ export default class PostsView extends React.Component { </a> ); } else { - moreMessages = this.props.introText; + moreMessagesTop = this.props.introText; + } + + // Give option to load more posts at bottom if nessisary + if (this.props.showMoreMessagesBottom) { + moreMessagesBottom = ( + <a + ref='loadmorebottom' + className='more-messages-text theme' + href='#' + onClick={this.loadMorePostsBottom} + > + {'Load more messages'} + </a> + ); + } else { + moreMessagesBottom = null; } // Create post elements @@ -299,8 +334,9 @@ export default class PostsView extends React.Component { ref='postlistcontent' className='post-list__content' > - {moreMessages} + {moreMessagesTop} {postElements} + {moreMessagesBottom} </div> </div> </div> @@ -313,11 +349,14 @@ PostsView.defaultProps = { PostsView.propTypes = { isActive: React.PropTypes.bool, postList: React.PropTypes.object, - scrollPost: React.PropTypes.string, + scrollPostId: React.PropTypes.string, scrollType: React.PropTypes.number, postViewScrolled: React.PropTypes.func.isRequired, loadMorePostsTopClicked: React.PropTypes.func.isRequired, - numPostsToDisplay: React.PropTypes.number, + loadMorePostsBottomClicked: React.PropTypes.func.isRequired, + showMoreMessagesTop: React.PropTypes.bool, + showMoreMessagesBottom: React.PropTypes.bool, introText: React.PropTypes.element, - messageSeparatorTime: React.PropTypes.number + messageSeparatorTime: React.PropTypes.number, + postsToHighlight: React.PropTypes.object }; diff --git a/web/react/components/posts_view_container.jsx b/web/react/components/posts_view_container.jsx index c71ef401e..367d3687e 100644 --- a/web/react/components/posts_view_container.jsx +++ b/web/react/components/posts_view_container.jsx @@ -9,12 +9,9 @@ import ChannelStore from '../stores/channel_store.jsx'; import PostStore from '../stores/post_store.jsx'; import * as Utils from '../utils/utils.jsx'; -import * as Client from '../utils/client.jsx'; -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; -import * as AsyncClient from '../utils/async_client.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; import Constants from '../utils/constants.jsx'; -const ActionTypes = Constants.ActionTypes; import {createChannelIntroMessage} from '../utils/channel_intro_mssages.jsx'; @@ -27,27 +24,26 @@ export default class PostsViewContainer extends React.Component { this.onPostsChange = this.onPostsChange.bind(this); this.handlePostsViewScroll = this.handlePostsViewScroll.bind(this); this.loadMorePostsTop = this.loadMorePostsTop.bind(this); - this.postsLoaded = this.postsLoaded.bind(this); - this.postsLoadedFailure = this.postsLoadedFailure.bind(this); this.handlePostsViewJumpRequest = this.handlePostsViewJumpRequest.bind(this); const currentChannelId = ChannelStore.getCurrentId(); const state = { scrollType: PostsView.SCROLL_TYPE_BOTTOM, - scrollPost: null, - numPostsToDisplay: Constants.POST_CHUNK_SIZE + scrollPost: null }; if (currentChannelId) { Object.assign(state, { currentChannelIndex: 0, channels: [currentChannelId], - postLists: [this.getChannelPosts(currentChannelId)] + postLists: [this.getChannelPosts(currentChannelId)], + atTop: [PostStore.getVisibilityAtTop(currentChannelId)] }); } else { Object.assign(state, { currentChannelIndex: null, channels: [], - postLists: [] + postLists: [], + atTop: [] }); } @@ -78,24 +74,21 @@ export default class PostsViewContainer extends React.Component { }); break; case Constants.PostsViewJumpTypes.SIDEBAR_OPEN: - this.setState({scrollType: PostsView.SIDEBAR_OPEN}); + this.setState({scrollType: PostsView.SCROLL_TYPE_SIDEBAR_OPEN}); break; } } onChannelChange() { const postLists = this.state.postLists.slice(); + const atTop = this.state.atTop.slice(); const channels = this.state.channels.slice(); const channelId = ChannelStore.getCurrentId(); // Has the channel really changed? if (channelId === channels[this.state.currentChannelIndex]) { - // Dirty hack - this.forceUpdate(); return; } - PostStore.clearUnseenDeletedPosts(channelId); - let lastViewed = Number.MAX_VALUE; const member = ChannelStore.getMember(channelId); if (member != null) { @@ -107,115 +100,45 @@ export default class PostsViewContainer extends React.Component { newIndex = channels.length; channels.push(channelId); postLists[newIndex] = this.getChannelPosts(channelId); + atTop[newIndex] = PostStore.getVisibilityAtTop(channelId); } + this.setState({ currentChannelIndex: newIndex, currentLastViewed: lastViewed, scrollType: PostsView.SCROLL_TYPE_NEW_MESSAGE, channels, - postLists}); + postLists, + atTop}); } onChannelLeave(id) { const postLists = this.state.postLists.slice(); const channels = this.state.channels.slice(); + const atTop = this.state.atTop.slice(); const index = channels.indexOf(id); if (index !== -1) { postLists.splice(index, 1); channels.splice(index, 1); + atTop.splice(index, 1); } - this.setState({channels, postLists}); + this.setState({channels, postLists, atTop}); } onPostsChange() { const channels = this.state.channels; const postLists = this.state.postLists.slice(); - const newPostsView = this.getChannelPosts(channels[this.state.currentChannelIndex]); + const atTop = this.state.atTop.slice(); + const currentChannelId = channels[this.state.currentChannelIndex]; + const newPostsView = this.getChannelPosts(currentChannelId); postLists[this.state.currentChannelIndex] = newPostsView; - this.setState({postLists}); + atTop[this.state.currentChannelIndex] = PostStore.getVisibilityAtTop(currentChannelId); + this.setState({postLists, atTop}); } getChannelPosts(id) { - const postList = PostStore.getPosts(id); - - if (postList != null) { - const deletedPosts = PostStore.getUnseenDeletedPosts(id); - - if (deletedPosts && Object.keys(deletedPosts).length > 0) { - for (const pid in deletedPosts) { - if (deletedPosts.hasOwnProperty(pid)) { - postList.posts[pid] = deletedPosts[pid]; - postList.order.unshift(pid); - } - } - - postList.order.sort((a, b) => { - if (postList.posts[a].create_at > postList.posts[b].create_at) { - return -1; - } - if (postList.posts[a].create_at < postList.posts[b].create_at) { - return 1; - } - return 0; - }); - } - - const pendingPostList = PostStore.getPendingPosts(id); - - if (pendingPostList) { - postList.order = pendingPostList.order.concat(postList.order); - for (const ppid in pendingPostList.posts) { - if (pendingPostList.posts.hasOwnProperty(ppid)) { - postList.posts[ppid] = pendingPostList.posts[ppid]; - } - } - } - } - - return postList; + return PostStore.getVisiblePosts(id); } loadMorePostsTop() { - const postLists = this.state.postLists; - const channels = this.state.channels; - const currentChannelId = channels[this.state.currentChannelIndex]; - const currentPostList = postLists[this.state.currentChannelIndex]; - - this.setState({numPostsToDisplay: this.state.numPostsToDisplay + Constants.POST_CHUNK_SIZE}); - - Client.getPostsPage( - currentChannelId, - currentPostList.order.length, - Constants.POST_CHUNK_SIZE, - this.postsLoaded, - this.postsLoadedFailure - ); - } - postsLoaded(data) { - if (!data) { - return; - } - - if (data.order.length === 0) { - return; - } - - const postLists = this.state.postLists; - const currentPostList = postLists[this.state.currentChannelIndex]; - const channels = this.state.channels; - const currentChannelId = channels[this.state.currentChannelIndex]; - - var newPostList = {}; - newPostList.posts = Object.assign(currentPostList.posts, data.posts); - newPostList.order = currentPostList.order.concat(data.order); - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_POSTS, - id: currentChannelId, - post_list: newPostList - }); - - Client.getProfiles(); - } - postsLoadedFailure(err) { - AsyncClient.dispatchError(err, 'getPosts'); + EventHelpers.emitLoadMorePostsEvent(); } handlePostsViewScroll(atBottom) { if (atBottom) { @@ -246,15 +169,17 @@ export default class PostsViewContainer extends React.Component { isActive={isActive} postList={postLists[i]} scrollType={this.state.scrollType} - scrollPost={this.state.scrollPost} + scrollPostId={this.state.scrollPost} postViewScrolled={this.handlePostsViewScroll} loadMorePostsTopClicked={this.loadMorePostsTop} - numPostsToDisplay={this.state.numPostsToDisplay} + loadMorePostsBottomClicked={() => {}} + showMoreMessagesTop={!this.state.atTop[this.state.currentChannelIndex]} + showMoreMessagesBottom={false} introText={channel ? createChannelIntroMessage(channel, () => this.setState({showInviteModal: true})) : null} messageSeparatorTime={this.state.currentLastViewed} /> ); - if ((!postLists[i] || !channel) && isActive) { + if (!postLists[i] && isActive) { postListCtls.push( <LoadingScreen position='absolute' diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx index 3e555c85a..7aae5177e 100644 --- a/web/react/components/rhs_comment.jsx +++ b/web/react/components/rhs_comment.jsx @@ -8,13 +8,13 @@ import UserStore from '../stores/user_store.jsx'; import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import * as Utils from '../utils/utils.jsx'; import Constants from '../utils/constants.jsx'; -import DeletePostModal from './delete_post_modal.jsx'; import FileAttachmentList from './file_attachment_list.jsx'; import * as Client from '../utils/client.jsx'; import * as AsyncClient from '../utils/async_client.jsx'; var ActionTypes = Constants.ActionTypes; import * as TextFormatting from '../utils/text_formatting.jsx'; import twemoji from 'twemoji'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; export default class RhsComment extends React.Component { constructor(props) { @@ -115,7 +115,7 @@ export default class RhsComment extends React.Component { <a href='#' role='menuitem' - onClick={() => DeletePostModal.show(post, 0)} + onClick={() => EventHelpers.showDeletePostModal(post, 0)} > {'Delete'} </a> diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx index 96f43bdb5..8142888ba 100644 --- a/web/react/components/rhs_root_post.jsx +++ b/web/react/components/rhs_root_post.jsx @@ -6,11 +6,11 @@ import UserProfile from './user_profile.jsx'; import UserStore from '../stores/user_store.jsx'; import * as TextFormatting from '../utils/text_formatting.jsx'; import * as utils from '../utils/utils.jsx'; -import DeletePostModal from './delete_post_modal.jsx'; import FileAttachmentList from './file_attachment_list.jsx'; import twemoji from 'twemoji'; import Constants from '../utils/constants.jsx'; import PostBodyAdditionalContent from './post_body_additional_content.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; export default class RhsRootPost extends React.Component { constructor(props) { @@ -94,7 +94,7 @@ export default class RhsRootPost extends React.Component { <a href='#' role='menuitem' - onClick={() => DeletePostModal.show(post, this.props.commentCount)} + onClick={() => EventHelpers.showDeletePostModal(post, this.props.commentCount)} > {'Delete'} </a> diff --git a/web/react/components/rhs_thread.jsx b/web/react/components/rhs_thread.jsx index 4f453f76b..61f138539 100644 --- a/web/react/components/rhs_thread.jsx +++ b/web/react/components/rhs_thread.jsx @@ -94,7 +94,7 @@ export default class RhsThread extends React.Component { return; } - var currentPosts = PostStore.getPosts(currentSelected.posts[currentSelected.order[0]].channel_id); + var currentPosts = PostStore.getVisiblePosts(currentSelected.posts[currentSelected.order[0]].channel_id); if (!currentPosts || currentPosts.order.length === 0) { return; diff --git a/web/react/components/search_results_item.jsx b/web/react/components/search_results_item.jsx index 2202831a3..da422fe1b 100644 --- a/web/react/components/search_results_item.jsx +++ b/web/react/components/search_results_item.jsx @@ -1,17 +1,12 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import SearchStore from '../stores/search_store.jsx'; import ChannelStore from '../stores/channel_store.jsx'; import UserStore from '../stores/user_store.jsx'; import UserProfile from './user_profile.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; import * as utils from '../utils/utils.jsx'; -import * as client from '../utils/client.jsx'; -import * as AsyncClient from '../utils/async_client.jsx'; -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; -import Constants from '../utils/constants.jsx'; import * as TextFormatting from '../utils/text_formatting.jsx'; -var ActionTypes = Constants.ActionTypes; export default class SearchResultsItem extends React.Component { constructor(props) { @@ -23,32 +18,7 @@ export default class SearchResultsItem extends React.Component { handleClick(e) { e.preventDefault(); - var self = this; - - client.getPost( - this.props.post.channel_id, - this.props.post.id, - function success(data) { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_POST_SELECTED, - post_list: data, - from_search: SearchStore.getSearchTerm() - }); - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_SEARCH, - results: null, - is_mention_search: self.props.isMentionSearch - }); - }, - function success(err) { - AsyncClient.dispatchError(err, 'getPost'); - } - ); - - var postChannel = ChannelStore.get(this.props.post.channel_id); - - utils.switchChannel(postChannel); + EventHelpers.emitPostFocusEvent(this.props.post.id); } render() { diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx index 77d222436..30422ff7d 100644 --- a/web/react/components/sidebar.jsx +++ b/web/react/components/sidebar.jsx @@ -201,10 +201,6 @@ export default class Sidebar extends React.Component { }); } updateScrollbar() { - if (this.state.windowWidth > 768) { - $('.nav-pills__container').perfectScrollbar(); - $('.nav-pills__container').perfectScrollbar('update'); - } } onChange() { this.setState(this.getStateFromStores()); diff --git a/web/react/components/sidebar_right_menu.jsx b/web/react/components/sidebar_right_menu.jsx index f6c0c8adb..0525eca4b 100644 --- a/web/react/components/sidebar_right_menu.jsx +++ b/web/react/components/sidebar_right_menu.jsx @@ -1,11 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import InviteMemberModal from './invite_member_modal.jsx'; import UserSettingsModal from './user_settings/user_settings_modal.jsx'; import UserStore from '../stores/user_store.jsx'; -import TeamStore from '../stores/team_store.jsx'; import * as client from '../utils/client.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; import * as utils from '../utils/utils.jsx'; export default class SidebarRightMenu extends React.Component { @@ -46,7 +45,7 @@ export default class SidebarRightMenu extends React.Component { <li> <a href='#' - onClick={InviteMemberModal.show} + onClick={EventHelpers.showInviteMemberModal} > <i className='fa fa-user'></i>Invite New Member </a> @@ -56,12 +55,12 @@ export default class SidebarRightMenu extends React.Component { if (this.props.teamType === 'O') { teamLink = ( <li> - <a href='#' - data-toggle='modal' - data-target='#get_link' - data-title='Team Invite' - data-value={utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + TeamStore.getCurrent().invite_id} - ><i className='fa fa-link'></i>Get Team Invite Link</a> + <a + href='#' + onClick={EventHelpers.showGetTeamInviteLinkModal} + > + <i className='glyphicon glyphicon-link'></i>{'Get Team Invite Link'} + </a> </li> ); } diff --git a/web/react/components/toggle_modal_button.jsx b/web/react/components/toggle_modal_button.jsx index eae4a024d..ce8ff3f60 100644 --- a/web/react/components/toggle_modal_button.jsx +++ b/web/react/components/toggle_modal_button.jsx @@ -22,7 +22,17 @@ export default class ModalToggleButton extends React.Component { } render() { - const {children, dialogType, dialogProps, ...props} = this.props; //eslint-disable-line no-redeclare + const {children, dialogType, dialogProps, onClick, ...props} = this.props; // eslint-disable-line no-redeclare + + // allow callers to provide an onClick which will be called before the modal is shown + let clickHandler = this.show; + if (onClick) { + clickHandler = () => { + onClick(); + + this.show(); + }; + } // this assumes that all modals will have a show property and an onHide event const dialog = React.createElement(this.props.dialogType, Object.assign({}, dialogProps, { @@ -42,7 +52,7 @@ export default class ModalToggleButton extends React.Component { <a {...props} href='#' - onClick={this.show} + onClick={clickHandler} > {children} {dialog} @@ -54,7 +64,8 @@ export default class ModalToggleButton extends React.Component { ModalToggleButton.propTypes = { children: React.PropTypes.node.isRequired, dialogType: React.PropTypes.func.isRequired, - dialogProps: React.PropTypes.object + dialogProps: React.PropTypes.object, + onClick: React.PropTypes.func }; ModalToggleButton.defaultProps = { diff --git a/web/react/dispatcher/event_helpers.jsx b/web/react/dispatcher/event_helpers.jsx new file mode 100644 index 000000000..d7f255aaa --- /dev/null +++ b/web/react/dispatcher/event_helpers.jsx @@ -0,0 +1,106 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import PostStore from '../stores/post_store.jsx'; +import Constants from '../utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; +import * as AsyncClient from '../utils/async_client.jsx'; +import * as Client from '../utils/client.jsx'; + +export function emitChannelClickEvent(channel) { + AsyncClient.getChannels(); + AsyncClient.getChannelExtraInfo(); + AsyncClient.updateLastViewedAt(); + AsyncClient.getPosts(channel.id); + + AppDispatcher.handleViewAction({ + type: ActionTypes.CLICK_CHANNEL, + name: channel.name, + id: channel.id + }); +} + +export function emitPostFocusEvent(postId) { + Client.getPostById( + postId, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_FOCUSED_POST, + postId, + post_list: data + }); + + AsyncClient.getPostsBefore(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS); + AsyncClient.getPostsAfter(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS); + } + ); +} + +export function emitLoadMorePostsEvent() { + const id = ChannelStore.getCurrentId(); + loadMorePostsTop(id); +} + +export function emitLoadMorePostsFocusedTopEvent() { + const id = PostStore.getFocusedPostId(); + loadMorePostsTop(id); +} + +export function loadMorePostsTop(id) { + const earliestPostId = PostStore.getEarliestPost(id).id; + if (PostStore.requestVisibilityIncrease(id, Constants.POST_CHUNK_SIZE)) { + AsyncClient.getPostsBefore(earliestPostId, 0, Constants.POST_CHUNK_SIZE); + } +} + +export function emitLoadMorePostsFocusedBottomEvent() { + const id = PostStore.getFocusedPostId(); + const latestPostId = PostStore.getLatestPost(id).id; + AsyncClient.getPostsAfter(latestPostId, 0, Constants.POST_CHUNK_SIZE); +} + +export function emitPostRecievedEvent(post) { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POST, + post + }); +} + +export function emitUserPostedEvent(post) { + AppDispatcher.handleServerAction({ + type: ActionTypes.CREATE_POST, + 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, + value: true, + post, + commentCount + }); +} + +export function showGetTeamInviteLinkModal() { + AppDispatcher.handleViewAction({ + type: Constants.ActionTypes.TOGGLE_GET_TEAM_INVITE_LINK_MODAL, + value: true + }); +} + +export function showInviteMemberModal() { + AppDispatcher.handleViewAction({ + type: ActionTypes.TOGGLE_INVITE_MEMBER_MODAL, + value: true + }); +} diff --git a/web/react/package.json b/web/react/package.json index b63fe35fb..41b2468af 100644 --- a/web/react/package.json +++ b/web/react/package.json @@ -22,13 +22,14 @@ "watchify": "3.6.1", "eslint": "1.9.0", "eslint-plugin-react": "3.9.0", + "exorcist": "0.4.0", "babel-eslint": "4.1.5" }, "scripts": { "check": "", "build-libs": "browserify -r crypto -r autolinker -r flux -r keymirror -r marked -r object-assign -r twemoji | uglifyjs -c -m --screw-ie8 > ../static/js/libs.min.js", "start": "watchify --fast -x crypto -x node -x autolinker -x flux -x keymirror -x marked -x object-assign -x twemoji -o ../static/js/bundle.js -v -d ./**/*.jsx", - "build": "browserify -x crypto -x autolinker -x flux -x keymirror -x marked -x object-assign -x twemoji ./**/*.jsx | uglifyjs -c -m --screw-ie8 > ../static/js/bundle.min.js" + "build": "browserify -x crypto -x autolinker -x flux -x keymirror -x marked -x object-assign -x twemoji -d ./**/*.jsx | exorcist ../static/js/inter.js.map > ../static/js/tmp.js && uglifyjs ../static/js/tmp.js --in-source-map \"../static/js/inter.js.map\" --source-map \"../static/js/bundle.min.js.map\" --source-map-url \"/static/js/bundle.min.js.map\" -c -m --screw-ie8 > ../static/js/bundle.min.js && rm ../static/js/tmp.js && rm ../static/js/inter.js.map" }, "browserify": { "transform": [ diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx index 126942e65..161e6ab22 100644 --- a/web/react/pages/channel.jsx +++ b/web/react/pages/channel.jsx @@ -1,15 +1,13 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import ChannelView from '../components/channel_view.jsx'; import ChannelLoader from '../components/channel_loader.jsx'; import ErrorBar from '../components/error_bar.jsx'; import ErrorStore from '../stores/error_store.jsx'; import MentionList from '../components/mention_list.jsx'; -import GetLinkModal from '../components/get_link_modal.jsx'; -import EditChannelModal from '../components/edit_channel_modal.jsx'; +import GetTeamInviteLinkModal from '../components/get_team_invite_link_modal.jsx'; import RenameChannelModal from '../components/rename_channel_modal.jsx'; import EditPostModal from '../components/edit_post_modal.jsx'; import DeletePostModal from '../components/delete_post_modal.jsx'; @@ -23,15 +21,14 @@ import ImportThemeModal from '../components/user_settings/import_theme_modal.jsx import InviteMemberModal from '../components/invite_member_modal.jsx'; import * as AsyncClient from '../utils/async_client.jsx'; -import Constants from '../utils/constants.jsx'; -var ActionTypes = Constants.ActionTypes; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; -function setupChannelPage(props) { - AppDispatcher.handleViewAction({ - type: ActionTypes.CLICK_CHANNEL, - name: props.ChannelName, - id: props.ChannelId - }); +function setupChannelPage(props, team, channel) { + if (props.PostId === '') { + EventHelpers.emitChannelClickEvent(channel); + } else { + EventHelpers.emitPostFocusEvent(props.PostId); + } AsyncClient.getAllPreferences(); @@ -70,8 +67,8 @@ function setupChannelPage(props) { // Modals // ReactDOM.render( - <GetLinkModal />, - document.getElementById('get_link_modal') + <GetTeamInviteLinkModal />, + document.getElementById('get_team_invite_link_modal') ); ReactDOM.render( @@ -95,11 +92,6 @@ function setupChannelPage(props) { ); ReactDOM.render( - <EditChannelModal />, - document.getElementById('edit_channel_modal') - ); - - ReactDOM.render( <RenameChannelModal />, document.getElementById('rename_channel_modal') ); diff --git a/web/react/pages/home.jsx b/web/react/pages/home.jsx index 2c1edaa3a..ff81c4994 100644 --- a/web/react/pages/home.jsx +++ b/web/react/pages/home.jsx @@ -1,12 +1,11 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import ChannelStore from '../stores/channel_store.jsx'; import TeamStore from '../stores/team_store.jsx'; import Constants from '../utils/constants.jsx'; function setupHomePage() { - var last = ChannelStore.getLastVisitedName(); + var last = null; if (last == null || last.length === 0) { window.location = TeamStore.getCurrentTeamUrl() + '/channels/' + Constants.DEFAULT_CHANNEL; } else { diff --git a/web/react/stores/channel_store.jsx b/web/react/stores/channel_store.jsx index 1d481ada4..dec4926f5 100644 --- a/web/react/stores/channel_store.jsx +++ b/web/react/stores/channel_store.jsx @@ -8,8 +8,6 @@ var Utils; import Constants from '../utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; -import BrowserStore from '../stores/browser_store.jsx'; - const CHANGE_EVENT = 'change'; const LEAVE_EVENT = 'leave'; const MORE_CHANGE_EVENT = 'change'; @@ -21,7 +19,38 @@ class ChannelStoreClass extends EventEmitter { this.setMaxListeners(11); + this.emitChange = this.emitChange.bind(this); + this.addChangeListener = this.addChangeListener.bind(this); + this.removeChangeListener = this.removeChangeListener.bind(this); + this.emitMoreChange = this.emitMoreChange.bind(this); + this.addMoreChangeListener = this.addMoreChangeListener.bind(this); + this.removeMoreChangeListener = this.removeMoreChangeListener.bind(this); + this.emitExtraInfoChange = this.emitExtraInfoChange.bind(this); + this.addExtraInfoChangeListener = this.addExtraInfoChangeListener.bind(this); + this.removeExtraInfoChangeListener = this.removeExtraInfoChangeListener.bind(this); + this.emitLeave = this.emitLeave.bind(this); + this.addLeaveListener = this.addLeaveListener.bind(this); + this.removeLeaveListener = this.removeLeaveListener.bind(this); + this.findFirstBy = this.findFirstBy.bind(this); + this.get = this.get.bind(this); + this.getMember = this.getMember.bind(this); + this.getByName = this.getByName.bind(this); + this.pSetPostMode = this.pSetPostMode.bind(this); + this.getPostMode = this.getPostMode.bind(this); + this.currentId = null; + this.postMode = this.POST_MODE_CHANNEL; + this.channels = []; + this.channelMembers = {}; + this.moreChannels = {}; + this.moreChannels.loading = true; + this.extraInfos = {}; + } + get POST_MODE_CHANNEL() { + return 1; + } + get POST_MODE_FOCUS() { + return 2; } emitChange() { this.emit(CHANGE_EVENT); @@ -90,16 +119,6 @@ class ChannelStoreClass extends EventEmitter { setCurrentId(id) { this.currentId = id; } - setLastVisitedName(name) { - if (name == null) { - BrowserStore.removeItem('last_visited_name'); - } else { - BrowserStore.setItem('last_visited_name', name); - } - } - getLastVisitedName() { - return BrowserStore.getItem('last_visited_name'); - } resetCounts(id) { var cm = this.pGetChannelMembers(); for (var cmid in cm) { @@ -192,10 +211,10 @@ class ChannelStoreClass extends EventEmitter { this.pStoreChannels(channels); } pStoreChannels(channels) { - BrowserStore.setItem('channels', channels); + this.channels = channels; } pGetChannels() { - return BrowserStore.getItem('channels', []); + return this.channels; } pStoreChannelMember(channelMember) { var members = this.pGetChannelMembers(); @@ -203,49 +222,58 @@ class ChannelStoreClass extends EventEmitter { this.pStoreChannelMembers(members); } pStoreChannelMembers(channelMembers) { - BrowserStore.setItem('channel_members', channelMembers); + this.channelMembers = channelMembers; } pGetChannelMembers() { - return BrowserStore.getItem('channel_members', {}); + return this.channelMembers; } pStoreMoreChannels(channels) { - BrowserStore.setItem('more_channels', channels); + this.moreChannels = channels; } pGetMoreChannels() { - var channels = BrowserStore.getItem('more_channels'); - - if (channels == null) { - channels = {}; - channels.loading = true; - } - - return channels; + return this.moreChannels; } pStoreExtraInfos(extraInfos) { - BrowserStore.setItem('extra_infos', extraInfos); + this.extraInfos = extraInfos; } pGetExtraInfos() { - return BrowserStore.getItem('extra_infos', {}); + return this.extraInfos; } isDefault(channel) { return channel.name === Constants.DEFAULT_CHANNEL; } + + pSetPostMode(mode) { + this.postMode = mode; + } + + getPostMode() { + return this.postMode; + } } var ChannelStore = new ChannelStoreClass(); -ChannelStore.dispatchToken = AppDispatcher.register(function handleAction(payload) { +ChannelStore.dispatchToken = AppDispatcher.register((payload) => { var action = payload.action; var currentId; switch (action.type) { case ActionTypes.CLICK_CHANNEL: ChannelStore.setCurrentId(action.id); - ChannelStore.setLastVisitedName(action.name); ChannelStore.resetCounts(action.id); + ChannelStore.pSetPostMode(ChannelStore.POST_MODE_CHANNEL); ChannelStore.emitChange(); break; + case ActionTypes.RECIEVED_FOCUSED_POST: { + const post = action.post_list.posts[action.postId]; + ChannelStore.setCurrentId(post.channel_id); + ChannelStore.pSetPostMode(ChannelStore.POST_MODE_FOCUS); + ChannelStore.emitChange(); + break; + } + case ActionTypes.RECIEVED_CHANNELS: ChannelStore.pStoreChannels(action.channels); ChannelStore.pStoreChannelMembers(action.members); diff --git a/web/react/stores/modal_store.jsx b/web/react/stores/modal_store.jsx index 69f43a5cf..a26a97f53 100644 --- a/web/react/stores/modal_store.jsx +++ b/web/react/stores/modal_store.jsx @@ -34,6 +34,7 @@ class ModalStoreClass extends EventEmitter { case ActionTypes.TOGGLE_IMPORT_THEME_MODAL: case ActionTypes.TOGGLE_INVITE_MEMBER_MODAL: case ActionTypes.TOGGLE_DELETE_POST_MODAL: + case ActionTypes.TOGGLE_GET_TEAM_INVITE_LINK_MODAL: this.emit(type, value, args); break; } diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx index ec01eef18..c76560c25 100644 --- a/web/react/stores/post_store.jsx +++ b/web/react/stores/post_store.jsx @@ -12,9 +12,10 @@ import Constants from '../utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; const CHANGE_EVENT = 'change'; -const SELECTED_POST_CHANGE_EVENT = 'selected_post_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'; class PostStoreClass extends EventEmitter { constructor() { @@ -24,10 +25,6 @@ class PostStoreClass extends EventEmitter { this.addChangeListener = this.addChangeListener.bind(this); this.removeChangeListener = this.removeChangeListener.bind(this); - this.emitSelectedPostChange = this.emitSelectedPostChange.bind(this); - this.addSelectedPostChangeListener = this.addSelectedPostChangeListener.bind(this); - this.removeSelectedPostChangeListener = this.removeSelectedPostChangeListener.bind(this); - this.emitEditPost = this.emitEditPost.bind(this); this.addEditPostListener = this.addEditPostListener.bind(this); this.removeEditPostListener = this.removeEditPostListner.bind(this); @@ -36,27 +33,49 @@ class PostStoreClass extends EventEmitter { this.addPostsViewJumpListener = this.addPostsViewJumpListener.bind(this); this.removePostsViewJumpListener = this.removePostsViewJumpListener.bind(this); - this.getCurrentPosts = this.getCurrentPosts.bind(this); + this.emitPostFocused = this.emitPostFocused.bind(this); + this.addPostFocusedListener = this.addPostFocusedListener.bind(this); + this.removePostFocusedListener = this.removePostFocusedListener.bind(this); + + this.makePostsInfo = this.makePostsInfo.bind(this); + + this.getAllPosts = this.getAllPosts.bind(this); + this.getEarliestPost = this.getEarliestPost.bind(this); + this.getLatestPost = this.getLatestPost.bind(this); + this.getVisiblePosts = this.getVisiblePosts.bind(this); + this.getVisibilityAtTop = this.getVisibilityAtTop.bind(this); + this.getVisibilityAtBottom = this.getVisibilityAtBottom.bind(this); + this.requestVisibilityIncrease = this.requestVisibilityIncrease.bind(this); + this.getFocusedPostId = this.getFocusedPostId.bind(this); + this.storePosts = this.storePosts.bind(this); - this.pStorePosts = this.pStorePosts.bind(this); - this.getPosts = this.getPosts.bind(this); - this.getPost = this.getPost.bind(this); this.storePost = this.storePost.bind(this); - this.pStorePost = this.pStorePost.bind(this); + this.storeFocusedPost = this.storeFocusedPost.bind(this); + this.checkBounds = this.checkBounds.bind(this); + + this.clearFocusedPost = this.clearFocusedPost.bind(this); + this.clearChannelVisibility = this.clearChannelVisibility.bind(this); + this.removePost = this.removePost.bind(this); - this.storePendingPost = this.storePendingPost.bind(this); - this.pStorePendingPosts = this.pStorePendingPosts.bind(this); + this.getPendingPosts = this.getPendingPosts.bind(this); - this.storeUnseenDeletedPost = this.storeUnseenDeletedPost.bind(this); - this.storeUnseenDeletedPosts = this.storeUnseenDeletedPosts.bind(this); - this.getUnseenDeletedPosts = this.getUnseenDeletedPosts.bind(this); - this.clearUnseenDeletedPosts = this.clearUnseenDeletedPosts.bind(this); + this.storePendingPost = this.storePendingPost.bind(this); this.removePendingPost = this.removePendingPost.bind(this); - this.pRemovePendingPost = this.pRemovePendingPost.bind(this); this.clearPendingPosts = this.clearPendingPosts.bind(this); this.updatePendingPost = this.updatePendingPost.bind(this); + + this.storeUnseenDeletedPost = this.storeUnseenDeletedPost.bind(this); + this.getUnseenDeletedPosts = this.getUnseenDeletedPosts.bind(this); + this.clearUnseenDeletedPosts = this.clearUnseenDeletedPosts.bind(this); + + // These functions are bad and work should be done to remove this system when the RHS dies this.storeSelectedPost = this.storeSelectedPost.bind(this); this.getSelectedPost = this.getSelectedPost.bind(this); + this.emitSelectedPostChange = this.emitSelectedPostChange.bind(this); + this.addSelectedPostChangeListener = this.addSelectedPostChangeListener.bind(this); + this.removeSelectedPostChangeListener = this.removeSelectedPostChangeListener.bind(this); + this.selectedPost = null; + this.getEmptyDraft = this.getEmptyDraft.bind(this); this.storeCurrentDraft = this.storeCurrentDraft.bind(this); this.getCurrentDraft = this.getCurrentDraft.bind(this); @@ -70,6 +89,9 @@ class PostStoreClass extends EventEmitter { this.getLatestUpdate = this.getLatestUpdate.bind(this); this.getCurrentUsersLatestPost = this.getCurrentUsersLatestPost.bind(this); this.getCommentCount = this.getCommentCount.bind(this); + + this.postsInfo = {}; + this.currentFocusedPostId = null; } emitChange() { this.emit(CHANGE_EVENT); @@ -83,16 +105,16 @@ class PostStoreClass extends EventEmitter { this.removeListener(CHANGE_EVENT, callback); } - emitSelectedPostChange(fromSearch) { - this.emit(SELECTED_POST_CHANGE_EVENT, fromSearch); + emitPostFocused() { + this.emit(FOCUSED_POST_CHANGE); } - addSelectedPostChangeListener(callback) { - this.on(SELECTED_POST_CHANGE_EVENT, callback); + addPostFocusedListener(callback) { + this.on(FOCUSED_POST_CHANGE, callback); } - removeSelectedPostChangeListener(callback) { - this.removeListener(SELECTED_POST_CHANGE_EVENT, callback); + removePostFocusedListener(callback) { + this.removeListener(FOCUSED_POST_CHANGE, callback); } emitEditPost(post) { @@ -131,104 +153,157 @@ class PostStoreClass extends EventEmitter { this.emitPostsViewJump(Constants.PostsViewJumpTypes.SIDEBAR_OPEN, null); } - getCurrentPosts() { - var currentId = ChannelStore.getCurrentId(); + // 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] = {}; + } + } - if (currentId != null) { - return this.getPosts(currentId); + getAllPosts(id) { + if (this.postsInfo.hasOwnProperty(id)) { + return Object.assign({}, this.postsInfo[id].postList); } + return null; } - storePosts(channelId, newPostsView) { - if (isPostListNull(newPostsView)) { + + getEarliestPost(id) { + if (this.postsInfo.hasOwnProperty(id)) { + return this.postsInfo[id].postList.posts[this.postsInfo[id].postList.order[this.postsInfo[id].postList.order.length - 1]]; + } + + return null; + } + + getLatestPost(id) { + if (this.postsInfo.hasOwnProperty(id)) { + return this.postsInfo[id].postList.posts[this.postsInfo[id].postList.order[0]]; + } + + return null; + } + + getVisiblePosts(id) { + if (this.postsInfo.hasOwnProperty(id) && this.postsInfo[id].hasOwnProperty('postList')) { + const postList = JSON.parse(JSON.stringify(this.postsInfo[id].postList)); + + // 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); + } + + // 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); + } + + // Add delteted posts + if (this.postsInfo[id].hasOwnProperty('deletedPosts')) { + Object.assign(postList.posts, this.postsInfo[id].deletedPosts); + + for (const postID in this.postsInfo[id].deletedPosts) { + if (this.postsInfo[id].deletedPosts.hasOwnProperty(postID)) { + postList.order.push(postID); + } + } + + // Merge would be faster + postList.order.sort((a, b) => { + if (postList.posts[a].create_at > postList.posts[b].create_at) { + return -1; + } + if (postList.posts[a].create_at < postList.posts[b].create_at) { + return 1; + } + return 0; + }); + } + + 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, ammount) { + const endVisible = this.postsInfo[id].endVisible; + const postList = this.postsInfo[id].postList; + if (this.getVisibilityAtTop(id)) { + return false; + } + this.postsInfo[id].endVisible += ammount; + this.emitChange(); + return endVisible + ammount > postList.order.length; + } + + getFocusedPostId() { + return this.currentFocusedPostId; + } + + storePosts(id, newPosts) { + if (isPostListNull(newPosts)) { return; } - var postList = makePostListNonNull(this.getPosts(channelId)); + const combinedPosts = makePostListNonNull(this.getAllPosts(id)); - for (const pid in newPostsView.posts) { - if (newPostsView.posts.hasOwnProperty(pid)) { - const np = newPostsView.posts[pid]; + for (const pid in newPosts.posts) { + if (newPosts.posts.hasOwnProperty(pid)) { + const np = newPosts.posts[pid]; if (np.delete_at === 0) { - postList.posts[pid] = np; - if (postList.order.indexOf(pid) === -1) { - postList.order.push(pid); + combinedPosts.posts[pid] = np; + if (combinedPosts.order.indexOf(pid) === -1) { + combinedPosts.order.push(pid); } } else { - if (pid in postList.posts) { - delete postList.posts[pid]; + if (pid in combinedPosts.posts) { + Reflect.deleteProperty(combinedPosts.posts, pid); } - const index = postList.order.indexOf(pid); + const index = combinedPosts.order.indexOf(pid); if (index !== -1) { - postList.order.splice(index, 1); + combinedPosts.order.splice(index, 1); } } } } - postList.order.sort((a, b) => { - if (postList.posts[a].create_at > postList.posts[b].create_at) { + combinedPosts.order.sort((a, b) => { + if (combinedPosts.posts[a].create_at > combinedPosts.posts[b].create_at) { return -1; } - if (postList.posts[a].create_at < postList.posts[b].create_at) { + if (combinedPosts.posts[a].create_at < combinedPosts.posts[b].create_at) { return 1; } return 0; }); - var latestUpdate = 0; - for (var pid in postList.posts) { - if (postList.posts[pid].update_at > latestUpdate) { - latestUpdate = postList.posts[pid].update_at; - } - } - - this.storeLatestUpdate(channelId, latestUpdate); - this.pStorePosts(channelId, postList); - this.emitChange(); - } - pStorePosts(channelId, posts) { - BrowserStore.setItem('posts_' + channelId, posts); - } - getPosts(channelId) { - return BrowserStore.getItem('posts_' + channelId); + this.makePostsInfo(id); + this.postsInfo[id].postList = combinedPosts; } - getPost(channelId, postId) { - return this.getPosts(channelId).posts[postId]; - } - getCurrentUsersLatestPost(channelId, rootId) { - const userId = UserStore.getCurrentId(); - var postList = makePostListNonNull(this.getPosts(channelId)); - var i = 0; - var len = postList.order.length; - var lastPost = null; - for (i; i < len; i++) { - let post = postList.posts[postList.order[i]]; - if (post.user_id === userId && (post.props && !post.props.from_webhook || !post.props)) { - if (rootId) { - if (post.root_id === rootId || post.id === rootId) { - lastPost = post; - break; - } - } else { - lastPost = post; - break; - } - } - } - - return lastPost; - } storePost(post) { - this.pStorePost(post); - this.emitChange(); - } - pStorePost(post) { - var postList = this.getPosts(post.channel_id); - postList = makePostListNonNull(postList); + const postList = makePostListNonNull(this.getAllPosts(post.channel_id)); if (post.pending_post_id !== '') { this.removePendingPost(post.channel_id, post.pending_post_id); @@ -241,65 +316,117 @@ class PostStoreClass extends EventEmitter { postList.order.unshift(post.id); } - this.pStorePosts(post.channel_id, postList); + this.makePostsInfo(post.channel_id); + this.postsInfo[post.channel_id].postList = postList; + } + + storeFocusedPost(postId, postList) { + const focusedPost = postList.posts[postId]; + if (!focusedPost) { + return; + } + this.currentFocusedPostId = postId; + this.storePosts(postId, postList); + } + + checkBounds(id, numRequested, postList, before) { + if (numRequested > postList.order.length) { + if (before) { + this.postsInfo[id].atTop = true; + } else { + this.postsInfo[id].atBottom = true; + } + } } - removePost(postId, channelId) { - var postList = this.getPosts(channelId); + + 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; + this.postsInfo[id].atTop = false; + this.postsInfo[id].atBottom = atBottom; + } + + removePost(post) { + const channelId = post.channel_id; + this.makePostsInfo(channelId); + const postList = this.postsInfo[channelId].postList; if (isPostListNull(postList)) { return; } - if (postId in postList.posts) { - delete postList.posts[postId]; + if (post.id in postList.posts) { + Reflect.deleteProperty(postList.posts, post.id); } - var index = postList.order.indexOf(postId); + const index = postList.order.indexOf(post.id); if (index !== -1) { postList.order.splice(index, 1); } - this.pStorePosts(channelId, postList); + this.postsInfo[channelId].postList = postList; } + + getPendingPosts(channelId) { + if (this.postsInfo.hasOwnProperty(channelId)) { + return this.postsInfo[channelId].pendingPosts; + } + + return null; + } + storePendingPost(post) { post.state = Constants.POST_LOADING; - var postList = this.getPendingPosts(post.channel_id); - postList = makePostListNonNull(postList); + const postList = makePostListNonNull(this.getPendingPosts(post.channel_id)); postList.posts[post.pending_post_id] = post; postList.order.unshift(post.pending_post_id); - this.pStorePendingPosts(post.channel_id, postList); + + this.makePostsInfo(post.channel_id); + this.postsInfo[post.channel_id].pendingPosts = postList; this.emitChange(); } - pStorePendingPosts(channelId, postList) { - var posts = postList.posts; - // sort failed posts to the bottom - postList.order.sort((a, b) => { - if (posts[a].state === Constants.POST_LOADING && posts[b].state === Constants.POST_FAILED) { - return 1; - } - if (posts[a].state === Constants.POST_FAILED && posts[b].state === Constants.POST_LOADING) { - return -1; - } + removePendingPost(channelId, pendingPostId) { + const postList = makePostListNonNull(this.getPendingPosts(channelId)); - if (posts[a].create_at > posts[b].create_at) { - return -1; - } - if (posts[a].create_at < posts[b].create_at) { - return 1; - } + Reflect.deleteProperty(postList.posts, pendingPostId); + const index = postList.order.indexOf(pendingPostId); + if (index !== -1) { + postList.order.splice(index, 1); + } - return 0; - }); + this.postsInfo[channelId].pendingPosts = postList; + this.emitChange(); + } - BrowserStore.setGlobalItem('pending_posts_' + channelId, postList); + clearPendingPosts(channelId) { + if (this.postsInfo.hasOwnProperty(channelId)) { + Reflect.deleteProperty(this.postsInfo[channelId], 'pendingPosts'); + } } - getPendingPosts(channelId) { - return BrowserStore.getGlobalItem('pending_posts_' + channelId); + + updatePendingPost(post) { + const postList = makePostListNonNull(this.getPendingPosts(post.channel_id)); + + if (postList.order.indexOf(post.pending_post_id) === -1) { + return; + } + + postList.posts[post.pending_post_id] = post; + this.postsInfo[post.channel_id].pendingPosts = postList; + this.emitChange(); } + storeUnseenDeletedPost(post) { - var posts = this.getUnseenDeletedPosts(post.channel_id); + let posts = this.getUnseenDeletedPosts(post.channel_id); if (!posts) { posts = {}; @@ -310,58 +437,68 @@ class PostStoreClass extends EventEmitter { post.filenames = []; posts[post.id] = post; - this.storeUnseenDeletedPosts(post.channel_id, posts); - } - storeUnseenDeletedPosts(channelId, posts) { - BrowserStore.setItem('deleted_posts_' + channelId, posts); + this.postsInfo[post.channel_id].deletedPosts = posts; } + getUnseenDeletedPosts(channelId) { - return BrowserStore.getItem('deleted_posts_' + channelId); + if (this.postsInfo.hasOwnProperty(channelId)) { + return this.postsInfo[channelId].deletedPosts; + } + + return null; } + clearUnseenDeletedPosts(channelId) { - BrowserStore.setItem('deleted_posts_' + channelId, {}); + if (this.postsInfo.hasOwnProperty(channelId)) { + Reflect.deleteProperty(this.postsInfo[channelId], 'deletedPosts'); + } } - removePendingPost(channelId, pendingPostId) { - this.pRemovePendingPost(channelId, pendingPostId); - this.emitChange(); + + storeSelectedPost(postList) { + this.selectedPost = postList; } - pRemovePendingPost(channelId, pendingPostId) { - var postList = this.getPendingPosts(channelId); - postList = makePostListNonNull(postList); - if (pendingPostId in postList.posts) { - delete postList.posts[pendingPostId]; - } - var index = postList.order.indexOf(pendingPostId); - if (index !== -1) { - postList.order.splice(index, 1); - } + getSelectedPost() { + return this.selectedPost; + } - this.pStorePendingPosts(channelId, postList); + emitSelectedPostChange(fromSearch) { + this.emit(SELECTED_POST_CHANGE_EVENT, fromSearch); } - clearPendingPosts() { - BrowserStore.actionOnGlobalItemsWithPrefix('pending_posts_', (key) => { - BrowserStore.removeItem(key); - }); + + addSelectedPostChangeListener(callback) { + this.on(SELECTED_POST_CHANGE_EVENT, callback); } - updatePendingPost(post) { - var postList = this.getPendingPosts(post.channel_id); - postList = makePostListNonNull(postList); - if (postList.order.indexOf(post.pending_post_id) === -1) { - return; + removeSelectedPostChangeListener(callback) { + this.removeListener(SELECTED_POST_CHANGE_EVENT, callback); + } + + getCurrentUsersLatestPost(channelId, rootId) { + const userId = UserStore.getCurrentId(); + var postList = makePostListNonNull(this.getAllPosts(channelId)); + var i = 0; + var len = postList.order.length; + var lastPost = null; + + for (i; i < len; i++) { + const post = postList.posts[postList.order[i]]; + if (post.user_id === userId && (post.props && !post.props.from_webhook || !post.props)) { + if (rootId) { + if (post.root_id === rootId || post.id === rootId) { + lastPost = post; + break; + } + } else { + lastPost = post; + break; + } + } } - postList.posts[post.pending_post_id] = post; - this.pStorePendingPosts(post.channel_id, postList); - this.emitChange(); - } - storeSelectedPost(postList) { - BrowserStore.setItem('select_post', postList); - } - getSelectedPost() { - return BrowserStore.getItem('select_post'); + return lastPost; } + getEmptyDraft() { return {message: '', uploadsInProgress: [], previews: []}; } @@ -402,16 +539,23 @@ class PostStoreClass extends EventEmitter { }); } storeLatestUpdate(channelId, time) { - BrowserStore.setItem('latest_post_' + channelId, time); + if (!this.postsInfo.hasOwnProperty(channelId)) { + this.postsInfo[channelId] = {}; + } + this.postsInfo[channelId].latestPost = time; } getLatestUpdate(channelId) { - return BrowserStore.getItem('latest_post_' + channelId, 0); + if (this.postsInfo.hasOwnProperty(channelId) && this.postsInfo[channelId].hasOwnProperty('latestPost')) { + return this.postsInfo[channelId].latestPost; + } + + return 0; } getCommentCount(post) { const posts = this.getPosts(post.channel_id).posts; let commentCount = 0; - for (let id in posts) { + for (const id in posts) { if (posts.hasOwnProperty(id)) { if (posts[id].root_id === post.id) { commentCount += 1; @@ -429,20 +573,45 @@ PostStore.dispatchToken = AppDispatcher.register((payload) => { var action = payload.action; switch (action.type) { - case ActionTypes.RECIEVED_POSTS: - PostStore.storePosts(action.id, makePostListNonNull(action.post_list)); + case ActionTypes.RECIEVED_POSTS: { + const id = PostStore.currentFocusedPostId == null ? action.id : PostStore.currentFocusedPostId; + PostStore.checkBounds(id, action.numRequested, makePostListNonNull(action.post_list), action.before); + PostStore.storePosts(id, makePostListNonNull(action.post_list)); + PostStore.emitChange(); + break; + } + case ActionTypes.RECIEVED_FOCUSED_POST: + PostStore.clearChannelVisibility(action.postId, false); + PostStore.storeFocusedPost(action.postId, makePostListNonNull(action.post_list)); + PostStore.emitChange(); break; case ActionTypes.RECIEVED_POST: - PostStore.pStorePost(action.post); + PostStore.storePost(action.post); + PostStore.emitChange(); + break; + case ActionTypes.RECIEVED_EDIT_POST: + PostStore.emitEditPost(action); + PostStore.emitChange(); + break; + case ActionTypes.CLICK_CHANNEL: + PostStore.clearFocusedPost(); + PostStore.clearChannelVisibility(action.id, true); + PostStore.clearUnseenDeletedPosts(action.id); + break; + case ActionTypes.CREATE_POST: + PostStore.storePendingPost(action.post); + PostStore.storeDraft(action.post.channel_id, null); + PostStore.jumpPostsViewToBottom(); + break; + case ActionTypes.POST_DELETED: + PostStore.storeUnseenDeletedPost(action.post); + PostStore.removePost(action.post); PostStore.emitChange(); break; case ActionTypes.RECIEVED_POST_SELECTED: PostStore.storeSelectedPost(action.post_list); PostStore.emitSelectedPostChange(action.from_search); break; - case ActionTypes.RECIEVED_EDIT_POST: - PostStore.emitEditPost(action); - break; default: } }); diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx index f2936c50a..2e0769cc4 100644 --- a/web/react/stores/socket_store.jsx +++ b/web/react/stores/socket_store.jsx @@ -1,7 +1,6 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import UserStore from './user_store.jsx'; import PostStore from './post_store.jsx'; import ChannelStore from './channel_store.jsx'; @@ -11,9 +10,9 @@ import EventEmitter from 'events'; import * as Utils from '../utils/utils.jsx'; import * as AsyncClient from '../utils/async_client.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; import Constants from '../utils/constants.jsx'; -const ActionTypes = Constants.ActionTypes; const SocketEvents = Constants.SocketEvents; const CHANGE_EVENT = 'change'; @@ -91,10 +90,9 @@ class SocketStoreClass extends EventEmitter { }; conn.onmessage = (evt) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_MSG, - msg: JSON.parse(evt.data) - }); + const msg = JSON.parse(evt.data); + this.handleMessage(msg); + this.emitChange(msg); }; } } @@ -153,12 +151,12 @@ class SocketStoreClass extends EventEmitter { function handleNewPostEvent(msg) { // Store post const post = JSON.parse(msg.props.post); - PostStore.storePost(post); + EventHelpers.emitPostRecievedEvent(post); // Update channel state if (ChannelStore.getCurrentId() === msg.channel_id) { if (window.isActive) { - AsyncClient.updateLastViewedAt(true); + AsyncClient.updateLastViewedAt(); } } else if (UserStore.getCurrentId() !== msg.user_id || post.type !== Constants.POST_TYPE_JOIN_LEAVE) { AsyncClient.getChannel(msg.channel_id); @@ -237,20 +235,17 @@ function handlePostEditEvent(msg) { function handlePostDeleteEvent(msg) { const post = JSON.parse(msg.props.post); - - PostStore.storeUnseenDeletedPost(post); - PostStore.removePost(post, true); - PostStore.emitChange(); + EventHelpers.emitPostDeletedEvent(post); } function handleNewUserEvent() { AsyncClient.getProfiles(); - AsyncClient.getChannelExtraInfo(true); + AsyncClient.getChannelExtraInfo(); } function handleUserAddedEvent(msg) { if (ChannelStore.getCurrentId() === msg.channel_id) { - AsyncClient.getChannelExtraInfo(true); + AsyncClient.getChannelExtraInfo(); } if (UserStore.getCurrentId() === msg.user_id) { @@ -273,7 +268,7 @@ function handleUserRemovedEvent(msg) { $('#removed_from_channel').modal('show'); } } else if (ChannelStore.getCurrentId() === msg.channel_id) { - AsyncClient.getChannelExtraInfo(true); + AsyncClient.getChannelExtraInfo(); } } @@ -286,17 +281,12 @@ function handleChannelViewedEvent(msg) { var SocketStore = new SocketStoreClass(); -SocketStore.dispatchToken = AppDispatcher.register((payload) => { +/*SocketStore.dispatchToken = AppDispatcher.register((payload) => { var action = payload.action; switch (action.type) { - case ActionTypes.RECIEVED_MSG: - SocketStore.handleMessage(action.msg); - SocketStore.emitChange(action.msg); - break; - default: } -}); + });*/ export default SocketStore; diff --git a/web/react/stores/team_store.jsx b/web/react/stores/team_store.jsx index 26c83cc8c..2d518d9e7 100644 --- a/web/react/stores/team_store.jsx +++ b/web/react/stores/team_store.jsx @@ -31,6 +31,7 @@ class TeamStoreClass extends EventEmitter { this.getCurrentId = this.getCurrentId.bind(this); this.getCurrent = this.getCurrent.bind(this); this.getCurrentTeamUrl = this.getCurrentTeamUrl.bind(this); + this.getCurrentInviteLink = this.getCurrentInviteLink.bind(this); this.saveTeam = this.saveTeam.bind(this); } @@ -92,6 +93,16 @@ class TeamStoreClass extends EventEmitter { return null; } + getCurrentInviteLink() { + const current = this.getCurrent(); + + if (current) { + return getWindowLocationOrigin() + '/signup_user_complete/?id=' + current.invite_id; + } + + return ''; + } + saveTeam(team) { var teams = this.getAll(); teams[team.id] = team; diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index fac4cd009..8cf111d55 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -40,88 +40,42 @@ function isCallInProgress(callName) { return true; } -export function getChannels(force, updateLastViewed, checkVersion) { - var channels = ChannelStore.getAll(); - - if (channels.length === 0 || force) { - if (isCallInProgress('getChannels')) { - return; - } - - callTracker.getChannels = utils.getTimestamp(); +export function getChannels(checkVersion) { + if (isCallInProgress('getChannels')) { + return; + } - client.getChannels( - (data, textStatus, xhr) => { - callTracker.getChannels = 0; + callTracker.getChannels = utils.getTimestamp(); - if (checkVersion) { - var serverVersion = xhr.getResponseHeader('X-Version-ID'); + client.getChannels( + (data, textStatus, xhr) => { + callTracker.getChannels = 0; - if (!BrowserStore.getLastServerVersion()) { - BrowserStore.setLastServerVersion(serverVersion); - } + if (checkVersion) { + var serverVersion = xhr.getResponseHeader('X-Version-ID'); - if (serverVersion !== BrowserStore.getLastServerVersion()) { - BrowserStore.setLastServerVersion(serverVersion); - window.location.reload(true); - console.log('Detected version update refreshing the page'); //eslint-disable-line no-console - } + if (serverVersion !== BrowserStore.getLastServerVersion()) { + BrowserStore.setLastServerVersion(serverVersion); + window.location.reload(true); + console.log('Detected version update refreshing the page'); //eslint-disable-line no-console } - - if (xhr.status === 304 || !data) { - return; - } - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_CHANNELS, - channels: data.channels, - members: data.members - }); - }, - (err) => { - callTracker.getChannels = 0; - dispatchError(err, 'getChannels'); } - ); - } else { - if (isCallInProgress('getChannelCounts')) { - return; - } - - callTracker.getChannelCounts = utils.getTimestamp(); - - client.getChannelCounts( - function getChannelCountsSuccess(data, textStatus, xhr) { - callTracker.getChannelCounts = 0; - - if (xhr.status === 304 || !data) { - return; - } - var countMap = data.counts; - var updateAtMap = data.update_times; - - for (var id in countMap) { - if ({}.hasOwnProperty.call(countMap, id)) { - var c = ChannelStore.get(id); - var count = countMap[id]; - var updateAt = updateAtMap[id]; - if (!c || c.total_msg_count !== count || updateAt > c.update_at) { - getChannel(id); - } - } - } - }, - function getChannelCountsFailure(err) { - callTracker.getChannelCounts = 0; - dispatchError(err, 'getChannelCounts'); + if (xhr.status === 304 || !data) { + return; } - ); - } - if (updateLastViewed && ChannelStore.getCurrentId() != null) { - updateLastViewedAt(); - } + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_CHANNELS, + channels: data.channels, + members: data.members + }); + }, + (err) => { + callTracker.getChannels = 0; + dispatchError(err, 'getChannels'); + } + ); } export function getChannel(id) { @@ -152,14 +106,14 @@ export function getChannel(id) { ); } -export function updateLastViewedAt(force) { +export function updateLastViewedAt() { const channelId = ChannelStore.getCurrentId(); if (channelId === null) { return; } - if (isCallInProgress(`updateLastViewed${channelId}`) && !force) { + if (isCallInProgress(`updateLastViewed${channelId}`)) { return; } @@ -205,40 +159,35 @@ export function getMoreChannels(force) { } } -export function getChannelExtraInfo(force) { - var channelId = ChannelStore.getCurrentId(); +export function getChannelExtraInfo() { + const channelId = ChannelStore.getCurrentId(); if (channelId != null) { if (isCallInProgress('getChannelExtraInfo_' + channelId)) { return; } - var minMembers = 0; - if (ChannelStore.getCurrent() && ChannelStore.getCurrent().type === 'D') { - minMembers = 1; - } - if (ChannelStore.getCurrentExtraInfo().members.length <= minMembers || force) { - callTracker['getChannelExtraInfo_' + channelId] = utils.getTimestamp(); - client.getChannelExtraInfo( - channelId, - function getChannelExtraInfoSuccess(data, textStatus, xhr) { - callTracker['getChannelExtraInfo_' + channelId] = 0; - - if (xhr.status === 304 || !data) { - return; - } - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_CHANNEL_EXTRA_INFO, - extra_info: data - }); - }, - function getChannelExtraInfoFailure(err) { - callTracker['getChannelExtraInfo_' + channelId] = 0; - dispatchError(err, 'getChannelExtraInfo'); + callTracker['getChannelExtraInfo_' + channelId] = utils.getTimestamp(); + + client.getChannelExtraInfo( + channelId, + (data, textStatus, xhr) => { + callTracker['getChannelExtraInfo_' + channelId] = 0; + + if (xhr.status === 304 || !data) { + return; } - ); - } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_CHANNEL_EXTRA_INFO, + extra_info: data + }); + }, + (err) => { + callTracker['getChannelExtraInfo_' + channelId] = 0; + dispatchError(err, 'getChannelExtraInfo'); + } + ); } } @@ -457,89 +406,92 @@ export function search(terms) { ); } -export function getPostsPage(force, id, maxPosts) { - if (PostStore.getCurrentPosts() == null || force) { - var channelId = id; +export function getPostsPage(id, maxPosts) { + let channelId = id; + if (channelId == null) { + channelId = ChannelStore.getCurrentId(); if (channelId == null) { - channelId = ChannelStore.getCurrentId(); - } - - if (isCallInProgress('getPostsPage_' + channelId)) { return; } + } - var postList = PostStore.getCurrentPosts(); + if (isCallInProgress('getPostsPage_' + channelId)) { + return; + } - var max = maxPosts; - if (max == null) { - max = Constants.POST_CHUNK_SIZE * Constants.MAX_POST_CHUNKS; - } + var postList = PostStore.getAllPosts(id); - // 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 at maxPosts - var 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)); - } + var max = maxPosts; + if (max == null) { + max = Constants.POST_CHUNK_SIZE * Constants.MAX_POST_CHUNKS; + } + + // 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 at maxPosts + var 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)); + } + + if (channelId != null) { + callTracker['getPostsPage_' + channelId] = utils.getTimestamp(); - if (channelId != null) { - callTracker['getPostsPage_' + channelId] = utils.getTimestamp(); - - client.getPostsPage( - channelId, - 0, - numPosts, - function getPostsPageSuccess(data, textStatus, xhr) { - if (xhr.status === 304 || !data) { - return; - } - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_POSTS, - id: channelId, - post_list: data - }); - - getProfiles(); - }, - function getPostsPageFailure(err) { - dispatchError(err, 'getPostsPage'); - }, - function getPostsPageComplete() { - callTracker['getPostsPage_' + channelId] = 0; + client.getPostsPage( + channelId, + 0, + numPosts, + (data, textStatus, xhr) => { + if (xhr.status === 304 || !data) { + return; } - ); - } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POSTS, + id: channelId, + before: true, + numRequested: numPosts, + post_list: data + }); + + getProfiles(); + }, + (err) => { + dispatchError(err, 'getPostsPage'); + }, + () => { + callTracker['getPostsPage_' + channelId] = 0; + } + ); } } export function getPosts(id) { - var channelId = id; + let channelId = id; if (channelId == null) { - if (ChannelStore.getCurrentId() == null) { + channelId = ChannelStore.getCurrentId(); + if (channelId == null) { return; } - channelId = ChannelStore.getCurrentId(); } if (isCallInProgress('getPosts_' + channelId)) { return; } - if (PostStore.getCurrentPosts() == null) { - getPostsPage(true, id, Constants.POST_CHUNK_SIZE); + if (PostStore.getAllPosts(channelId) == null) { + getPostsPage(channelId, Constants.POST_CHUNK_SIZE); return; } - var latestUpdate = PostStore.getLatestUpdate(channelId); + const latestUpdate = PostStore.getLatestUpdate(channelId); callTracker['getPosts_' + channelId] = utils.getTimestamp(); client.getPosts( channelId, latestUpdate, - function success(data, textStatus, xhr) { + (data, textStatus, xhr) => { if (xhr.status === 304 || !data) { return; } @@ -547,20 +499,100 @@ export function getPosts(id) { AppDispatcher.handleServerAction({ type: ActionTypes.RECIEVED_POSTS, id: channelId, + before: true, + numRequested: Constants.POST_CHUNK_SIZE, post_list: data }); getProfiles(); }, - function fail(err) { + (err) => { dispatchError(err, 'getPosts'); }, - function complete() { + () => { callTracker['getPosts_' + channelId] = 0; } ); } +export function getPostsBefore(postId, offset, numPost) { + const channelId = ChannelStore.getCurrentId(); + if (channelId == null) { + return; + } + + if (isCallInProgress('getPostsBefore_' + channelId)) { + return; + } + + client.getPostsBefore( + channelId, + postId, + offset, + numPost, + (data, textStatus, xhr) => { + if (xhr.status === 304 || !data) { + return; + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POSTS, + id: channelId, + before: true, + numRequested: numPost, + post_list: data + }); + + getProfiles(); + }, + (err) => { + dispatchError(err, 'getPostsBefore'); + }, + () => { + callTracker['getPostsBefore_' + channelId] = 0; + } + ); +} + +export function getPostsAfter(postId, offset, numPost) { + const channelId = ChannelStore.getCurrentId(); + if (channelId == null) { + return; + } + + if (isCallInProgress('getPostsAfter_' + channelId)) { + return; + } + + client.getPostsAfter( + channelId, + postId, + offset, + numPost, + (data, textStatus, xhr) => { + if (xhr.status === 304 || !data) { + return; + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POSTS, + id: channelId, + before: false, + numRequested: numPost, + post_list: data + }); + + getProfiles(); + }, + (err) => { + dispatchError(err, 'getPostsAfter'); + }, + () => { + callTracker['getPostsAfter_' + channelId] = 0; + } + ); +} + export function getMe() { if (isCallInProgress('getMe')) { return; diff --git a/web/react/utils/channel_intro_mssages.jsx b/web/react/utils/channel_intro_mssages.jsx index aef1593dc..6f83778c9 100644 --- a/web/react/utils/channel_intro_mssages.jsx +++ b/web/react/utils/channel_intro_mssages.jsx @@ -1,13 +1,14 @@ - // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. import * as Utils from './utils.jsx'; -import InviteMemberModal from '../components/invite_member_modal.jsx'; +import EditChannelHeaderModal from '../components/edit_channel_header_modal.jsx'; +import ToggleModalButton from '../components/toggle_modal_button.jsx'; import UserProfile from '../components/user_profile.jsx'; import ChannelStore from '../stores/channel_store.jsx'; import Constants from '../utils/constants.jsx'; import TeamStore from '../stores/team_store.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; export function createChannelIntroMessage(channel, showInviteModal) { if (channel.type === 'D') { @@ -49,17 +50,7 @@ export function createDMIntroMessage(channel) { {'This is the start of your direct message history with ' + teammateName + '.'}<br/> {'Direct messages and files shared here are not shown to people outside this area.'} </p> - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a header'} - </a> + {createSetHeaderButton(channel)} </div> ); } @@ -71,7 +62,7 @@ export function createDMIntroMessage(channel) { ); } -export function createOffTopicIntroMessage(channel, showInviteModal) { //eslint-disable-line react/no-multi-comp +export function createOffTopicIntroMessage(channel, showInviteModal) { return ( <div className='channel-intro'> <h4 className='channel-intro__title'>{'Beginning of ' + channel.display_name}</h4> @@ -79,17 +70,7 @@ export function createOffTopicIntroMessage(channel, showInviteModal) { //eslint- {'This is the start of ' + channel.display_name + ', a channel for non-work-related conversations.'} <br/> </p> - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a header'} - </a> + {createSetHeaderButton(channel)} <a href='#' className='intro-links' @@ -101,7 +82,7 @@ export function createOffTopicIntroMessage(channel, showInviteModal) { //eslint- ); } -export function createDefaultIntroMessage(channel) { //eslint-disable-line react/no-multi-comp +export function createDefaultIntroMessage(channel) { const team = TeamStore.getCurrent(); let inviteModalLink; if (team.type === Constants.INVITE_TEAM) { @@ -109,7 +90,7 @@ export function createDefaultIntroMessage(channel) { //eslint-disable-line react <a className='intro-links' href='#' - onClick={InviteMemberModal.show} + onClick={EventHelpers.showInviteMemberModal} > <i className='fa fa-user-plus'></i>{'Invite others to this team'} </a> @@ -119,10 +100,7 @@ export function createDefaultIntroMessage(channel) { //eslint-disable-line react <a className='intro-links' href='#' - data-toggle='modal' - data-target='#get_link' - data-title='Team Invite' - data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + team.id} + onClick={EventHelpers.showGetTeamInviteLinkModal} > <i className='fa fa-user-plus'></i>{'Invite others to this team'} </a> @@ -138,23 +116,13 @@ export function createDefaultIntroMessage(channel) { //eslint-disable-line react {'This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.'} </p> {inviteModalLink} - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a header'} - </a> + {createSetHeaderButton(channel)} <br/> </div> ); } -export function createStandardIntroMessage(channel, showInviteModal) { //eslint-disable-line react/no-multi-comp +export function createStandardIntroMessage(channel, showInviteModal) { var uiName = channel.display_name; var creatorName = ''; @@ -193,17 +161,7 @@ export function createStandardIntroMessage(channel, showInviteModal) { //eslint- {memberMessage} <br/> </p> - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a header'} - </a> + {createSetHeaderButton(channel)} <a className='intro-links' href='#' @@ -214,3 +172,15 @@ export function createStandardIntroMessage(channel, showInviteModal) { //eslint- </div> ); } + +function createSetHeaderButton(channel) { + return ( + <ToggleModalButton + className='intro-links' + dialogType={EditChannelHeaderModal} + dialogProps={{channel}} + > + <i className='fa fa-pencil'></i>{'Set a header'} + </ToggleModalButton> + ); +} diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index e6c24aa9c..09e962161 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -820,7 +820,37 @@ export function getPosts(channelId, since, success, error, complete) { }); } -export function getPost(channelId, postId, success, error) { +export function getPostsBefore(channelId, post, offset, numPost, success, error, complete) { + $.ajax({ + url: '/api/v1/channels/' + channelId + '/post/' + post + '/before/' + offset + '/' + numPost, + dataType: 'json', + type: 'GET', + ifModified: false, + success, + error: function onError(xhr, status, err) { + var e = handleError('getPostsBefore', xhr, status, err); + error(e); + }, + complete: complete + }); +} + +export function getPostsAfter(channelId, post, offset, numPost, success, error, complete) { + $.ajax({ + url: '/api/v1/channels/' + channelId + '/post/' + post + '/after/' + offset + '/' + numPost, + dataType: 'json', + type: 'GET', + ifModified: false, + success, + error: function onError(xhr, status, err) { + var e = handleError('getPostsAfter', xhr, status, err); + error(e); + }, + complete: complete + }); +} + +export function getPost(channelId, postId, success, error, complete) { $.ajax({ cache: false, url: '/api/v1/channels/' + channelId + '/post/' + postId, @@ -831,7 +861,24 @@ export function getPost(channelId, postId, success, error) { error: function onError(xhr, status, err) { var e = handleError('getPost', xhr, status, err); error(e); - } + }, + complete + }); +} + +export function getPostById(postId, success, error, complete) { + $.ajax({ + cache: false, + url: '/api/v1/posts/' + postId, + dataType: 'json', + type: 'GET', + ifModified: false, + success, + error: function onError(xhr, status, err) { + var e = handleError('getPostById', xhr, status, err); + error(e); + }, + complete }); } diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 958bfa8d2..6281813e9 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -10,12 +10,17 @@ export default { CLICK_CHANNEL: null, CREATE_CHANNEL: null, LEAVE_CHANNEL: null, + CREATE_POST: null, + POST_DELETED: null, + RECIEVED_CHANNELS: null, RECIEVED_CHANNEL: null, RECIEVED_MORE_CHANNELS: null, RECIEVED_CHANNEL_EXTRA_INFO: null, + FOCUS_POST: null, RECIEVED_POSTS: null, + RECIEVED_FOCUSED_POST: null, RECIEVED_POST: null, RECIEVED_EDIT_POST: null, RECIEVED_SEARCH: null, @@ -43,7 +48,8 @@ export default { TOGGLE_IMPORT_THEME_MODAL: null, TOGGLE_INVITE_MEMBER_MODAL: null, - TOGGLE_DELETE_POST_MODAL: null + TOGGLE_DELETE_POST_MODAL: null, + TOGGLE_GET_TEAM_INVITE_LINK_MODAL: null }), PayloadSources: keyMirror({ @@ -99,6 +105,7 @@ export default { EMAIL_SERVICE: 'email', POST_CHUNK_SIZE: 60, MAX_POST_CHUNKS: 3, + POST_FOCUS_CONTEXT_RADIUS: 10, POST_LOADING: 'loading', POST_FAILED: 'failed', POST_DELETED: 'deleted', diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index 7957ea31b..b0ec64bfd 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -110,32 +110,47 @@ class MattermostMarkdownRenderer extends marked.Renderer { this.formattingOptions = formattingOptions; } - code(code, language) { - let usedLanguage = language; + code(code, language, escaped) { + let usedLanguage = language || ''; + usedLanguage = usedLanguage.toLowerCase(); - if (String(usedLanguage).toLocaleLowerCase() === 'html') { + // treat html as xml to prevent injection attacks + if (usedLanguage === 'html') { usedLanguage = 'xml'; } - if (usedLanguage && (usedLanguage === 'tex' || usedLanguage === 'latex')) { + if (HighlightedLanguages[usedLanguage]) { + const parsed = highlightJs.highlight(usedLanguage, code); + + return ( + '<div class="post-body--code">' + + '<span class="post-body--code__language">' + + HighlightedLanguages[usedLanguage] + + '</span>' + + '<pre>' + + '<code class="hljs">' + + parsed.value + + '</code>' + + '</pre>' + + '</div>' + ); + } else if (usedLanguage === 'tex' || usedLanguage === 'latex') { try { - var html = katex.renderToString(TextFormatting.sanitizeHtml(code), {throwOnError: false, displayMode: true}); + const html = katex.renderToString(TextFormatting.sanitizeHtml(code), {throwOnError: false, displayMode: true}); + return '<div class="post-body--code tex">' + html + '</div>'; } catch (e) { - return '<div class="post-body--code">' + TextFormatting.sanitizeHtml(code) + '</div>'; + // fall through if latex parsing fails and handle below } } - if (!usedLanguage || highlightJs.listLanguages().indexOf(usedLanguage) < 0) { - let parsed = super.code(code, usedLanguage); - return '<div class="post-body--code"><code class="hljs">' + TextFormatting.sanitizeHtml($(parsed).text()) + '</code></div>'; - } - - let parsed = highlightJs.highlight(usedLanguage, code); - return '<div class="post-body--code">' + - '<span class="post-body--code__language">' + HighlightedLanguages[usedLanguage] + '</span>' + - '<code class="hljs">' + parsed.value + '</code>' + - '</div>'; + return ( + '<pre>' + + '<code class="hljs">' + + (escaped ? code : TextFormatting.sanitizeHtml(code)) + '\n' + + '</code>' + + '</pre>' + ); } br() { diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 668d8100f..9b2f7e057 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -2,6 +2,7 @@ // See License.txt for license information. import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; import ChannelStore from '../stores/channel_store.jsx'; import UserStore from '../stores/user_store.jsx'; import PreferenceStore from '../stores/preference_store.jsx'; @@ -748,19 +749,10 @@ export function updateCodeTheme(theme) { export function placeCaretAtEnd(el) { el.focus(); - if (typeof window.getSelection != 'undefined' && typeof document.createRange != 'undefined') { - var range = document.createRange(); - range.selectNodeContents(el); - range.collapse(false); - var sel = window.getSelection(); - sel.removeAllRanges(); - sel.addRange(range); - } else if (typeof document.body.createTextRange != 'undefined') { - var textRange = document.body.createTextRange(); - textRange.moveToElementText(el); - textRange.collapse(false); - textRange.select(); - } + el.selectionStart = el.value.length; + el.selectionEnd = el.value.length; + + return; } export function getCaretPosition(el) { @@ -839,23 +831,15 @@ export function isValidUsername(name) { } export function updateAddressBar(channelName) { - var teamURL = window.location.href.split('/channels')[0]; + const teamURL = TeamStore.getCurrentTeamUrl(); history.replaceState('data', '', teamURL + '/channels/' + channelName); } export function switchChannel(channel) { - AppDispatcher.handleViewAction({ - type: ActionTypes.CLICK_CHANNEL, - name: channel.name, - id: channel.id - }); + EventHelpers.emitChannelClickEvent(channel); updateAddressBar(channel.name); - AsyncClient.getChannels(true, true, true); - AsyncClient.getChannelExtraInfo(true); - AsyncClient.getPosts(channel.id); - $('.inner__wrap').removeClass('move--right'); $('.sidebar--left').removeClass('move--right'); diff --git a/web/sass-files/sass/partials/_markdown.scss b/web/sass-files/sass/partials/_markdown.scss index ea5ccd2d2..6d1ecbfa0 100644 --- a/web/sass-files/sass/partials/_markdown.scss +++ b/web/sass-files/sass/partials/_markdown.scss @@ -9,11 +9,26 @@ } } #post-list { - .markdown-inline-img { - -moz-force-broken-image-icon: 1; - max-height: 500px; - height: 500px; - } + .markdown-inline-img { + -moz-force-broken-image-icon: 1; + max-height: 500px; + height: 500px; + } +} + +.post-body--code { + position: relative; +} + +.post-body--code__language { + position: absolute; + top: 0; + right: 0; + color: #fff; + background: #21586D; + padding: 4px 10px 5px 10px; + font-size: 13px; + opacity: 0.7; } .post__body { @@ -25,6 +40,9 @@ border: 0 none; @include opacity(0.2); } + code { + white-space: pre; + } } .markdown__table { background: #fff; diff --git a/web/sass-files/sass/partials/_navbar.scss b/web/sass-files/sass/partials/_navbar.scss index c570c4663..aad46d3d9 100644 --- a/web/sass-files/sass/partials/_navbar.scss +++ b/web/sass-files/sass/partials/_navbar.scss @@ -33,6 +33,9 @@ background: #fff; width: 21px; } + .glyphicon-search { + top: -1px; + } .icon--white { color: #fff; } diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss index 99e985b0c..b7609bb7d 100644 --- a/web/sass-files/sass/partials/_post.scss +++ b/web/sass-files/sass/partials/_post.scss @@ -361,8 +361,15 @@ body.ios { .dropdown, .comment-icon__container { visibility: visible; } + .permalink-icon { + visibility: visible; + } } + &.post--highlight { + background-color: beige; + } + ul { margin: 0; padding: 0; @@ -520,6 +527,8 @@ body.ios { } .post__header { + padding: 0; + list-style: none; margin-bottom: 2px; li { @@ -588,6 +597,11 @@ body.ios { padding: 0.2em 0.5em 0em; @include legacy-pie-clearfix; width: calc(100% - 70px); + + ul { + padding: 5px 0 0 20px; + } + } .post__link { @@ -617,6 +631,12 @@ body.ios { color: #999; } + .permalink-icon { + display: inline-block; + color: $primary-color; + visibility: hidden; + } + .comment-icon__container { fill: $primary-color; display: inline-block; @@ -755,4 +775,12 @@ body.ios { margin: 0; } } -}
\ No newline at end of file +} + +.permalink-text { + overflow: hidden; +} + +.permalink-popover { + min-width: 320px; +} diff --git a/web/templates/channel.html b/web/templates/channel.html index c15cea178..7b8f6a243 100644 --- a/web/templates/channel.html +++ b/web/templates/channel.html @@ -10,30 +10,21 @@ <div id="post_mention_tab"></div> <div id="reply_mention_tab"></div> <div id="edit_mention_tab"></div> - <div id="get_link_modal"></div> - <div id="user_settings_modal"></div> + <div id="get_team_invite_link_modal"></div> <div id="import_theme_modal"></div> <div id="team_settings_modal"></div> <div id="invite_member_modal"></div> - <div id="edit_channel_modal"></div> - <div id="delete_channel_modal"></div> <div id="rename_channel_modal"></div> - <div id="rename_team_modal"></div> <div id="edit_post_modal"></div> <div id="delete_post_modal"></div> <div id="more_channels_modal"></div> - <div id="new_channel_modal"></div> <div id="post_deleted_modal"></div> <div id="channel_notifications_modal"></div> <div id="team_members_modal"></div> - <div id="direct_channel_modal"></div> - <div id="channel_info_modal"></div> - <div id="access_history_modal"></div> - <div id="activity_log_modal"></div> <div id="removed_from_channel_modal"></div> <div id="register_app_modal"></div> <script> - window.setup_channel_page({{ .Props }}); +window.setup_channel_page({{ .Props }}, {{ .Team }}, {{ .Channel }}, {{ .User }}); $('body').tooltip( {selector: '[data-toggle=tooltip]'} ); if($(window).height() > 1200){ $('.modal-body').css('max-height', 1000); diff --git a/web/templates/head.html b/web/templates/head.html index 30a83c4f2..ec06ba9bc 100644 --- a/web/templates/head.html +++ b/web/templates/head.html @@ -47,6 +47,7 @@ window.mm_config = {{ .ClientCfg }}; window.mm_team = {{ .Team }}; window.mm_user = {{ .User }}; + window.mm_channel = {{ .Channel }}; if ({{.SessionTokenIndex}} >= 0) { window.mm_session_token_index = {{.SessionTokenIndex}}; diff --git a/web/web.go b/web/web.go index 477bd8b27..a72bff2bf 100644 --- a/web/web.go +++ b/web/web.go @@ -91,6 +91,7 @@ func InitWeb() { mainrouter.Handle("/{team:[A-Za-z0-9-]+(__)?[A-Za-z0-9-]+}/login", api.AppHandler(login)).Methods("GET") mainrouter.Handle("/{team:[A-Za-z0-9-]+(__)?[A-Za-z0-9-]+}/logout", api.AppHandler(logout)).Methods("GET") mainrouter.Handle("/{team:[A-Za-z0-9-]+(__)?[A-Za-z0-9-]+}/reset_password", api.AppHandler(resetPassword)).Methods("GET") + mainrouter.Handle("/{team}/pl/{postid}", api.AppHandler(postPermalink)).Methods("GET") // Bug in gorilla.mux prevents us from using regex here. mainrouter.Handle("/{team}/login/{service}", api.AppHandler(loginWithOAuth)).Methods("GET") // Bug in gorilla.mux prevents us from using regex here. mainrouter.Handle("/{team}/channels/{channelname}", api.AppHandler(getChannel)).Methods("GET") // Bug in gorilla.mux prevents us from using regex here. mainrouter.Handle("/{team}/signup/{service}", api.AppHandler(signupWithOAuth)).Methods("GET") // Bug in gorilla.mux prevents us from using regex here. @@ -342,15 +343,142 @@ func logout(c *api.Context, w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, c.GetTeamURL(), http.StatusTemporaryRedirect) } +func postPermalink(c *api.Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + teamName := params["team"] + postId := params["postid"] + + if len(postId) != 26 { + c.Err = model.NewAppError("postPermalink", "Invalid Post ID", "id="+postId) + return + } + + team := checkSessionSwitch(c, w, r, teamName) + if team == nil { + // Error already set by getTeam + return + } + + var post *model.Post + if result := <-api.Srv.Store.Post().Get(postId); result.Err != nil { + c.Err = result.Err + return + } else { + postlist := result.Data.(*model.PostList) + post = postlist.Posts[postlist.Order[0]] + } + + var channel *model.Channel + if result := <-api.Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, post.ChannelId, c.Session.UserId); result.Err != nil { + c.Err = result.Err + return + } else { + if result.Data.(int64) == 0 { + if channel = autoJoinChannelId(c, w, r, post.ChannelId); channel == nil { + http.Redirect(w, r, c.GetTeamURL()+"/channels/town-square", http.StatusFound) + return + } + } else { + if result := <-api.Srv.Store.Channel().Get(post.ChannelId); result.Err != nil { + c.Err = result.Err + return + } else { + channel = result.Data.(*model.Channel) + } + } + } + + doLoadChannel(c, w, r, team, channel, post.Id) +} + func getChannel(c *api.Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) name := params["channelname"] teamName := params["team"] + team := checkSessionSwitch(c, w, r, teamName) + if team == nil { + // Error already set by getTeam + return + } + + var channel *model.Channel + if result := <-api.Srv.Store.Channel().CheckPermissionsToByName(c.Session.TeamId, name, c.Session.UserId); result.Err != nil { + c.Err = result.Err + return + } else { + channelId := result.Data.(string) + if len(channelId) == 0 { + if channel = autoJoinChannelName(c, w, r, name); channel == nil { + http.Redirect(w, r, c.GetTeamURL()+"/channels/town-square", http.StatusFound) + return + } + } else { + if result := <-api.Srv.Store.Channel().Get(channelId); result.Err != nil { + c.Err = result.Err + return + } else { + channel = result.Data.(*model.Channel) + } + } + } + + doLoadChannel(c, w, r, team, channel, "") +} + +func autoJoinChannelName(c *api.Context, w http.ResponseWriter, r *http.Request, channelName string) *model.Channel { + if strings.Index(channelName, "__") > 0 { + // It's a direct message channel that doesn't exist yet so let's create it + ids := strings.Split(channelName, "__") + otherUserId := "" + if ids[0] == c.Session.UserId { + otherUserId = ids[1] + } else { + otherUserId = ids[0] + } + + if sc, err := api.CreateDirectChannel(c, otherUserId); err != nil { + api.Handle404(w, r) + return nil + } else { + return sc + } + } else { + // We will attempt to auto-join open channels + return joinOpenChannel(c, w, r, api.Srv.Store.Channel().GetByName(c.Session.TeamId, channelName)) + } + + return nil +} + +func autoJoinChannelId(c *api.Context, w http.ResponseWriter, r *http.Request, channelId string) *model.Channel { + return joinOpenChannel(c, w, r, api.Srv.Store.Channel().Get(channelId)) +} + +func joinOpenChannel(c *api.Context, w http.ResponseWriter, r *http.Request, channel store.StoreChannel) *model.Channel { + if cr := <-channel; cr.Err != nil { + http.Redirect(w, r, c.GetTeamURL()+"/channels/town-square", http.StatusFound) + return nil + } else { + channel := cr.Data.(*model.Channel) + if channel.Type == model.CHANNEL_OPEN { + api.JoinChannel(c, channel.Id, "") + if c.Err != nil { + return nil + } + } else { + http.Redirect(w, r, c.GetTeamURL()+"/channels/town-square", http.StatusFound) + return nil + } + return channel + } +} + +func checkSessionSwitch(c *api.Context, w http.ResponseWriter, r *http.Request, teamName string) *model.Team { var team *model.Team if result := <-api.Srv.Store.Team().GetByName(teamName); result.Err != nil { c.Err = result.Err - return + return nil } else { team = result.Data.(*model.Team) } @@ -368,15 +496,11 @@ func getChannel(c *api.Context, w http.ResponseWriter, r *http.Request) { } } - userChan := api.Srv.Store.User().Get(c.Session.UserId) + return team +} - var channelId string - if result := <-api.Srv.Store.Channel().CheckPermissionsToByName(c.Session.TeamId, name, c.Session.UserId); result.Err != nil { - c.Err = result.Err - return - } else { - channelId = result.Data.(string) - } +func doLoadChannel(c *api.Context, w http.ResponseWriter, r *http.Request, team *model.Team, channel *model.Channel, postid string) { + userChan := api.Srv.Store.User().Get(c.Session.UserId) var user *model.User if ur := <-userChan; ur.Err != nil { @@ -388,54 +512,15 @@ func getChannel(c *api.Context, w http.ResponseWriter, r *http.Request) { user = ur.Data.(*model.User) } - if len(channelId) == 0 { - if strings.Index(name, "__") > 0 { - // It's a direct message channel that doesn't exist yet so let's create it - ids := strings.Split(name, "__") - otherUserId := "" - if ids[0] == c.Session.UserId { - otherUserId = ids[1] - } else { - otherUserId = ids[0] - } - - if sc, err := api.CreateDirectChannel(c, otherUserId); err != nil { - api.Handle404(w, r) - return - } else { - channelId = sc.Id - } - } else { - // We will attempt to auto-join open channels - if cr := <-api.Srv.Store.Channel().GetByName(c.Session.TeamId, name); cr.Err != nil { - http.Redirect(w, r, c.GetTeamURL()+"/channels/town-square", http.StatusFound) - } else { - channel := cr.Data.(*model.Channel) - if channel.Type == model.CHANNEL_OPEN { - api.JoinChannel(c, channel.Id, "") - if c.Err != nil { - return - } - - channelId = channel.Id - } else { - http.Redirect(w, r, c.GetTeamURL()+"/channels/town-square", http.StatusFound) - } - } - } - } - page := NewHtmlTemplatePage("channel", "") - page.Props["Title"] = name + " - " + team.DisplayName + " " + page.ClientCfg["SiteName"] + page.Props["Title"] = channel.DisplayName + " - " + team.DisplayName + " " + page.ClientCfg["SiteName"] page.Props["TeamDisplayName"] = team.DisplayName - page.Props["TeamName"] = team.Name - page.Props["TeamType"] = team.Type - page.Props["TeamId"] = team.Id - page.Props["ChannelName"] = name - page.Props["ChannelId"] = channelId - page.Props["UserId"] = c.Session.UserId + page.Props["ChannelName"] = channel.Name + page.Props["ChannelId"] = channel.Id + page.Props["PostId"] = postid page.Team = team page.User = user + page.Channel = channel page.Render(c, w) } |