diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2017-02-22 15:43:07 +0100 |
---|---|---|
committer | Eugen Rochko <eugen@zeonfederated.com> | 2017-02-22 15:43:07 +0100 |
commit | 974d712fbe5775903a4fec5ddc44b4069e68c925 (patch) | |
tree | 00883ef448ffd9b7136be8dd7205c79ba42bd32f | |
parent | 5997bb47a8a97de6cf3b69f81ef86376019f8f31 (diff) |
Improve performance of compose form
10 files changed, 169 insertions, 99 deletions
diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx index b901f73c3..45812def6 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -2,7 +2,7 @@ import CharacterCounter from './character_counter'; import Button from '../../../components/button'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import ReplyIndicator from './reply_indicator'; +import ReplyIndicatorContainer from '../containers/reply_indicator_container'; import UploadButton from './upload_button'; import AutosuggestTextarea from '../../../components/autosuggest_textarea'; import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container'; @@ -11,6 +11,7 @@ import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Toggle from 'react-toggle'; import Collapsable from '../../../components/collapsable'; +import UnlistedToggleContainer from '../containers/unlisted_toggle_container'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, @@ -31,24 +32,23 @@ const ComposeForm = React.createClass({ unlisted: React.PropTypes.bool, private: React.PropTypes.bool, fileDropDate: React.PropTypes.instanceOf(Date), + focusDate: React.PropTypes.instanceOf(Date), + preselectDate: React.PropTypes.instanceOf(Date), is_submitting: React.PropTypes.bool, is_uploading: React.PropTypes.bool, - in_reply_to: ImmutablePropTypes.map, media_count: React.PropTypes.number, me: React.PropTypes.number, needsPrivacyWarning: React.PropTypes.bool, mentionedDomains: React.PropTypes.array.isRequired, onChange: React.PropTypes.func.isRequired, onSubmit: React.PropTypes.func.isRequired, - onCancelReply: React.PropTypes.func.isRequired, onClearSuggestions: React.PropTypes.func.isRequired, onFetchSuggestions: React.PropTypes.func.isRequired, onSuggestionSelected: React.PropTypes.func.isRequired, onChangeSensitivity: React.PropTypes.func.isRequired, onChangeSpoilerness: React.PropTypes.func.isRequired, onChangeSpoilerText: React.PropTypes.func.isRequired, - onChangeVisibility: React.PropTypes.func.isRequired, - onChangeListability: React.PropTypes.func.isRequired, + onChangeVisibility: React.PropTypes.func.isRequired }, mixins: [PureRenderMixin], @@ -97,17 +97,13 @@ const ComposeForm = React.createClass({ this.props.onChangeVisibility(e.target.checked); }, - handleChangeListability (e) { - this.props.onChangeListability(e.target.checked); - }, - componentDidUpdate (prevProps) { - if ((prevProps.in_reply_to === null && this.props.in_reply_to !== null) || (prevProps.in_reply_to !== null && this.props.in_reply_to !== null && prevProps.in_reply_to.get('id') !== this.props.in_reply_to.get('id'))) { + if (this.props.focusDate !== prevProps.focusDate) { // If replying to zero or one users, places the cursor at the end of the textbox. // If replying to more than one user, selects any usernames past the first; // this provides a convenient shortcut to drop everyone else from the conversation. - const selectionStart = this.props.text.search(/\s/) + 1; const selectionEnd = this.props.text.length; + const selectionStart = (this.props.preselectDate !== prevProps.preselectDate) ? (this.props.text.search(/\s/) + 1) : selectionEnd; this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); this.autosuggestTextarea.textarea.focus(); @@ -122,14 +118,9 @@ const ComposeForm = React.createClass({ const { intl, needsPrivacyWarning, mentionedDomains } = this.props; const disabled = this.props.is_submitting || this.props.is_uploading; - let replyArea = ''; let publishText = ''; let privacyWarning = ''; - let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me); - - if (this.props.in_reply_to) { - replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />; - } + let reply_to_other = false; if (needsPrivacyWarning) { privacyWarning = ( @@ -158,7 +149,8 @@ const ComposeForm = React.createClass({ </Collapsable> {privacyWarning} - {replyArea} + + <ReplyIndicatorContainer /> <AutosuggestTextarea ref={this.setAutosuggestTextarea} @@ -190,12 +182,7 @@ const ComposeForm = React.createClass({ <span className='compose-form__label__text'><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span> </label> - <Collapsable isVisible={!(this.props.private || reply_to_other)} fullHeight={39.5}> - <label className='compose-form__label'> - <Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} /> - <span className='compose-form__label__text'><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display on public timelines' /></span> - </label> - </Collapsable> + <UnlistedToggleContainer /> <Collapsable isVisible={this.props.media_count > 0} fullHeight={39.5}> <label className='compose-form__label'> diff --git a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx index 73e5ee99e..a72bd32c2 100644 --- a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx +++ b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx @@ -17,7 +17,7 @@ const ReplyIndicator = React.createClass({ }, propTypes: { - status: ImmutablePropTypes.map.isRequired, + status: ImmutablePropTypes.map, onCancel: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired }, @@ -36,17 +36,22 @@ const ReplyIndicator = React.createClass({ }, render () { - const { intl } = this.props; - const content = { __html: emojify(this.props.status.get('content')) }; + const { status, intl } = this.props; + + if (!status) { + return null; + } + + const content = { __html: emojify(status.get('content')) }; return ( <div className='reply-indicator'> <div style={{ overflow: 'hidden', marginBottom: '5px' }}> <div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> - <a href={this.props.status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}> - <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={this.props.status.getIn(['account', 'avatar'])} /></div> - <DisplayName account={this.props.status.get('account')} /> + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}> + <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={status.getIn(['account', 'avatar'])} /></div> + <DisplayName account={status.get('account')} /> </a> </div> diff --git a/app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx b/app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx new file mode 100644 index 000000000..0745051eb --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx @@ -0,0 +1,32 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import Collapsable from '../../../components/collapsable'; + +const UnlistedToggle = React.createClass({ + + propTypes: { + isPrivate: React.PropTypes.bool, + isUnlisted: React.PropTypes.bool, + isReplyToOther: React.PropTypes.bool, + onChangeListability: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + render () { + const { isPrivate, isUnlisted, isReplyToOther, onChangeListability } = this.props; + + return ( + <Collapsable isVisible={!(isPrivate || isReplyToOther)} fullHeight={39.5}> + <label className='compose-form__label'> + <Toggle checked={isUnlisted} onChange={onChangeListability} /> + <span className='compose-form__label__text'><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display on public timelines' /></span> + </label> + </Collapsable> + ); + } + +}); + +export default UnlistedToggle; diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx index 2671ea618..bff273c15 100644 --- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx @@ -3,7 +3,6 @@ import ComposeForm from '../components/compose_form'; import { changeCompose, submitCompose, - cancelReplyCompose, clearComposeSuggestions, fetchComposeSuggestions, selectComposeSuggestion, @@ -13,83 +12,69 @@ import { changeComposeVisibility, changeComposeListability } from '../../../actions/compose'; -import { makeGetStatus } from '../../../selectors'; -const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); +const mapStateToProps = (state, props) => { + const mentionedUsernamesWithDomains = state.getIn(['compose', 'text']).match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig); - const mapStateToProps = function (state, props) { - const mentionedUsernamesWithDomains = state.getIn(['compose', 'text']).match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig); - - return { - text: state.getIn(['compose', 'text']), - suggestion_token: state.getIn(['compose', 'suggestion_token']), - suggestions: state.getIn(['compose', 'suggestions']), - sensitive: state.getIn(['compose', 'sensitive']), - spoiler: state.getIn(['compose', 'spoiler']), - spoiler_text: state.getIn(['compose', 'spoiler_text']), - unlisted: state.getIn(['compose', 'unlisted'], ), - private: state.getIn(['compose', 'private']), - fileDropDate: state.getIn(['compose', 'fileDropDate']), - is_submitting: state.getIn(['compose', 'is_submitting']), - is_uploading: state.getIn(['compose', 'is_uploading']), - in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])), - media_count: state.getIn(['compose', 'media_attachments']).size, - me: state.getIn(['compose', 'me']), - needsPrivacyWarning: state.getIn(['compose', 'private']) && mentionedUsernamesWithDomains !== null, - mentionedDomains: mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : [] - }; + return { + text: state.getIn(['compose', 'text']), + suggestion_token: state.getIn(['compose', 'suggestion_token']), + suggestions: state.getIn(['compose', 'suggestions']), + sensitive: state.getIn(['compose', 'sensitive']), + spoiler: state.getIn(['compose', 'spoiler']), + spoiler_text: state.getIn(['compose', 'spoiler_text']), + unlisted: state.getIn(['compose', 'unlisted'], ), + private: state.getIn(['compose', 'private']), + fileDropDate: state.getIn(['compose', 'fileDropDate']), + focusDate: state.getIn(['compose', 'focusDate']), + preselectDate: state.getIn(['compose', 'preselectDate']), + is_submitting: state.getIn(['compose', 'is_submitting']), + is_uploading: state.getIn(['compose', 'is_uploading']), + media_count: state.getIn(['compose', 'media_attachments']).size, + me: state.getIn(['compose', 'me']), + needsPrivacyWarning: state.getIn(['compose', 'private']) && mentionedUsernamesWithDomains !== null, + mentionedDomains: mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : [] }; - - return mapStateToProps; }; -const mapDispatchToProps = function (dispatch) { - return { - onChange (text) { - dispatch(changeCompose(text)); - }, +const mapDispatchToProps = (dispatch) => ({ - onSubmit () { - dispatch(submitCompose()); - }, + onChange (text) { + dispatch(changeCompose(text)); + }, - onCancelReply () { - dispatch(cancelReplyCompose()); - }, + onSubmit () { + dispatch(submitCompose()); + }, - onClearSuggestions () { - dispatch(clearComposeSuggestions()); - }, + onClearSuggestions () { + dispatch(clearComposeSuggestions()); + }, - onFetchSuggestions (token) { - dispatch(fetchComposeSuggestions(token)); - }, + onFetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, - onSuggestionSelected (position, token, accountId) { - dispatch(selectComposeSuggestion(position, token, accountId)); - }, + onSuggestionSelected (position, token, accountId) { + dispatch(selectComposeSuggestion(position, token, accountId)); + }, - onChangeSensitivity (checked) { - dispatch(changeComposeSensitivity(checked)); - }, + onChangeSensitivity (checked) { + dispatch(changeComposeSensitivity(checked)); + }, - onChangeSpoilerness (checked) { - dispatch(changeComposeSpoilerness(checked)); - }, + onChangeSpoilerness (checked) { + dispatch(changeComposeSpoilerness(checked)); + }, - onChangeSpoilerText (checked) { - dispatch(changeComposeSpoilerText(checked)); - }, + onChangeSpoilerText (checked) { + dispatch(changeComposeSpoilerText(checked)); + }, - onChangeVisibility (checked) { - dispatch(changeComposeVisibility(checked)); - }, + onChangeVisibility (checked) { + dispatch(changeComposeVisibility(checked)); + }, - onChangeListability (checked) { - dispatch(changeComposeListability(checked)); - } - } -}; +}); -export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm); +export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); diff --git a/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx b/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx new file mode 100644 index 000000000..39b48f3b6 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { cancelReplyCompose } from '../../../actions/compose'; +import { makeGetStatus } from '../../../selectors'; +import ReplyIndicator from '../components/reply_indicator'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + + onCancel () { + dispatch(cancelReplyCompose()); + } + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator); diff --git a/app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx new file mode 100644 index 000000000..ceac903d9 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import UnlistedToggle from '../components/unlisted_toggle'; +import { makeGetStatus } from '../../../selectors'; +import { changeComposeListability } from '../../../actions/compose'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = state => { + const status = getStatus(state, state.getIn(['compose', 'in_reply_to'])); + const me = state.getIn(['compose', 'me']); + + return { + isPrivate: state.getIn(['compose', 'private']), + isUnlisted: state.getIn(['compose', 'unlisted']), + isReplyToOther: status ? status.getIn(['account', 'id']) !== me : false + }; + }; + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + + onChangeListability (e) { + dispatch(changeComposeListability(e.target.checked)); + } + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(UnlistedToggle); diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx index c185d7eb0..bb6df1133 100644 --- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx @@ -54,12 +54,12 @@ const mapDispatchToProps = (dispatch, { type, id }) => ({ dispatch(expandTimeline(type, id)); }, - @debounce(300) + @debounce(100) onScrollToTop () { dispatch(scrollTopTimeline(type, true)); }, - @debounce(500) + @debounce(100) onScroll () { dispatch(scrollTopTimeline(type, false)); } diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index 8d281048e..e401a2d98 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -35,6 +35,8 @@ const initialState = Immutable.Map({ private: false, text: '', fileDropDate: null, + focusDate: null, + preselectDate: null, in_reply_to: null, is_submitting: false, is_uploading: false, @@ -99,6 +101,7 @@ const insertSuggestion = (state, position, token, completion) => { map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`); map.set('suggestion_token', null); map.update('suggestions', Immutable.List(), list => list.clear()); + map.set('focusDate', new Date()); }); }; @@ -128,6 +131,8 @@ export default function compose(state = initialState, action) { map.set('text', statusToTextMentions(state, action.status)); map.set('unlisted', action.status.get('visibility') === 'unlisted' || state.get('default_privacy') === 'unlisted'); map.set('private', action.status.get('visibility') === 'private' || state.get('default_privacy') === 'private'); + map.set('focusDate', new Date()); + map.set('preselectDate', new Date()); }); case COMPOSE_REPLY_CANCEL: return state.withMutations(map => { @@ -156,7 +161,7 @@ export default function compose(state = initialState, action) { case COMPOSE_UPLOAD_PROGRESS: return state.set('progress', Math.round((action.loaded / action.total) * 100)); case COMPOSE_MENTION: - return state.update('text', text => `${text}@${action.account.get('acct')} `); + return state.update('text', text => `${text}@${action.account.get('acct')} `).set('focusDate', new Date()); case COMPOSE_SUGGESTIONS_CLEAR: return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null); case COMPOSE_SUGGESTIONS_READY: diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index ffb49be78..6472ac6a0 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -249,6 +249,7 @@ const resetTimeline = (state, timeline, id) => { .set('isLoading', true) .set('loaded', false) .set('next', null) + .set('top', true) .update('items', list => list.clear())); } else { state = state.setIn([timeline, 'isLoading'], true); diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index 4b6bfca48..1d2b789bb 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -1080,7 +1080,7 @@ button.active i.fa-retweet { flex: 0 0 auto; cursor: pointer; - &.active { + &.active .fa { color: $color4; text-shadow: 0 0 10px rgba($color4, 0.4); } |