diff options
10 files changed, 217 insertions, 48 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 31866d223..a88dba1b1 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -61,7 +61,7 @@ export function replyCompose(status, router) { status: status, }); - if (!getState().getIn(['compose', 'mounted'])) { + if (router && !getState().getIn(['compose', 'mounted'])) { router.push('/statuses/new'); } }; @@ -118,6 +118,11 @@ export function submitCompose() { }).then(function (response) { dispatch(submitComposeSuccess({ ...response.data })); + // If the response has no data then we can't do anything else. + if (!response.data) { + return; + } + // To make the app more responsive, immediately get the status into the columns const insertOrRefresh = (timelineId, refreshAction) => { @@ -341,10 +346,11 @@ export function unmountCompose() { }; }; -export function toggleComposeAdvancedOption(option) { +export function changeComposeAdvancedOption(option, value) { return { + option, type: COMPOSE_ADVANCED_OPTIONS_CHANGE, - option: option, + value, }; } diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js index 3582dedfe..cae9bf9f2 100644 --- a/app/javascript/flavours/glitch/features/composer/index.js +++ b/app/javascript/flavours/glitch/features/composer/index.js @@ -7,6 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { cancelReplyCompose, changeCompose, + changeComposeAdvancedOption, changeComposeSensitivity, changeComposeSpoilerText, changeComposeSpoilerness, @@ -18,7 +19,6 @@ import { mountCompose, selectComposeSuggestion, submitCompose, - toggleComposeAdvancedOption, undoUploadCompose, unmountCompose, uploadCompose, @@ -49,8 +49,8 @@ function mapStateToProps (state) { const inReplyTo = state.getIn(['compose', 'in_reply_to']); return { acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','), + advancedOptions: state.getIn(['compose', 'advanced_options']), amUnlocked: !state.getIn(['accounts', me, 'locked']), - doNotFederate: state.getIn(['compose', 'advanced_options', 'do_not_federate']), focusDate: state.getIn(['compose', 'focusDate']), isSubmitting: state.getIn(['compose', 'is_submitting']), isUploading: state.getIn(['compose', 'is_uploading']), @@ -76,6 +76,7 @@ function mapStateToProps (state) { // Dispatch mapping. const mapDispatchToProps = { onCancelReply: cancelReplyCompose, + onChangeAdvancedOption: changeComposeAdvancedOption, onChangeDescription: changeUploadCompose, onChangeSensitivity: changeComposeSensitivity, onChangeSpoilerText: changeComposeSpoilerText, @@ -91,7 +92,6 @@ const mapDispatchToProps = { onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }), onSelectSuggestion: selectComposeSuggestion, onSubmit: submitCompose, - onToggleAdvancedOption: toggleComposeAdvancedOption, onUndoUpload: undoUploadCompose, onUnmount: unmountCompose, onUpload: uploadCompose, @@ -267,14 +267,15 @@ class Composer extends React.Component { } = this.handlers; const { acceptContentTypes, + advancedOptions, amUnlocked, - doNotFederate, intl, isSubmitting, isUploading, layout, media, onCancelReply, + onChangeAdvancedOption, onChangeDescription, onChangeSensitivity, onChangeSpoilerness, @@ -285,7 +286,6 @@ class Composer extends React.Component { onFetchSuggestions, onOpenActionsModal, onOpenDoodleModal, - onToggleAdvancedOption, onUndoUpload, onUpload, privacy, @@ -321,6 +321,7 @@ class Composer extends React.Component { /> ) : null} <ComposerTextarea + advancedOptions={advancedOptions} autoFocus={!showSearch && !isMobile(window.innerWidth, layout)} disabled={isSubmitting} intl={intl} @@ -347,19 +348,19 @@ class Composer extends React.Component { ) : null} <ComposerOptions acceptContentTypes={acceptContentTypes} + advancedOptions={advancedOptions} disabled={isSubmitting} - doNotFederate={doNotFederate} full={media.size >= 4 || media.some( item => item.get('type') === 'video' )} hasMedia={!!media.size} intl={intl} + onChangeAdvancedOption={onChangeAdvancedOption} onChangeSensitivity={onChangeSensitivity} onChangeVisibility={onChangeVisibility} onDoodleOpen={onOpenDoodleModal} onModalClose={onCloseModal} onModalOpen={onOpenActionsModal} - onToggleAdvancedOption={onToggleAdvancedOption} onToggleSpoiler={onChangeSpoilerness} onUpload={onUpload} privacy={privacy} @@ -368,7 +369,7 @@ class Composer extends React.Component { spoiler={spoiler} /> <ComposerPublisher - countText={`${spoilerText}${countableText(text)}${doNotFederate ? ' 👁️' : ''}`} + countText={`${spoilerText}${countableText(text)}${advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`} disabled={isSubmitting || isUploading || !!text.length && !text.trim().length} intl={intl} onSecondarySubmit={handleSecondarySubmit} @@ -388,8 +389,8 @@ Composer.propTypes = { // State props. acceptContentTypes: PropTypes.string, + advancedOptions: ImmutablePropTypes.map, amUnlocked: PropTypes.bool, - doNotFederate: PropTypes.bool, focusDate: PropTypes.instanceOf(Date), isSubmitting: PropTypes.bool, isUploading: PropTypes.bool, @@ -412,6 +413,7 @@ Composer.propTypes = { // Dispatch props. onCancelReply: PropTypes.func, + onChangeAdvancedOption: PropTypes.func, onChangeDescription: PropTypes.func, onChangeSensitivity: PropTypes.func, onChangeSpoilerText: PropTypes.func, @@ -427,7 +429,6 @@ Composer.propTypes = { onOpenDoodleModal: PropTypes.func, onSelectSuggestion: PropTypes.func, onSubmit: PropTypes.func, - onToggleAdvancedOption: PropTypes.func, onUndoUpload: PropTypes.func, onUnmount: PropTypes.func, onUpload: PropTypes.func, diff --git a/app/javascript/flavours/glitch/features/composer/options/index.js b/app/javascript/flavours/glitch/features/composer/options/index.js index e805372ab..954508c11 100644 --- a/app/javascript/flavours/glitch/features/composer/options/index.js +++ b/app/javascript/flavours/glitch/features/composer/options/index.js @@ -1,6 +1,7 @@ // Package imports. import PropTypes from 'prop-types'; import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage, defineMessages, @@ -47,11 +48,11 @@ const messages = defineMessages({ }, local_only_long: { defaultMessage: 'Do not post to other instances', - id: 'advanced-options.local-only.long', + id: 'advanced_options.local-only.long', }, local_only_short: { defaultMessage: 'Local-only', - id: 'advanced-options.local-only.short', + id: 'advanced_options.local-only.short', }, private_long: { defaultMessage: 'Post to followers only', @@ -77,6 +78,14 @@ const messages = defineMessages({ defaultMessage: 'Hide text behind warning', id: 'compose_form.spoiler', }, + threaded_mode_long: { + defaultMessage: 'Automatically opens a reply on posting', + id: 'advanced_options.threaded_mode.long', + }, + threaded_mode_short: { + defaultMessage: 'Threaded mode', + id: 'advanced_options.threaded_mode.short', + }, unlisted_long: { defaultMessage: 'Do not show in public timelines', id: 'privacy.unlisted.long', @@ -149,16 +158,16 @@ export default class ComposerOptions extends React.PureComponent { } = this.handlers; const { acceptContentTypes, + advancedOptions, disabled, - doNotFederate, full, hasMedia, intl, + onChangeAdvancedOption, onChangeSensitivity, onChangeVisibility, onModalClose, onModalOpen, - onToggleAdvancedOption, onToggleSpoiler, privacy, resetFileKey, @@ -283,23 +292,31 @@ export default class ComposerOptions extends React.PureComponent { onClick={onToggleSpoiler} title={intl.formatMessage(messages.spoiler)} /> - <Dropdown - active={doNotFederate} - disabled={disabled} - icon='home' - items={[ - { - meta: <FormattedMessage {...messages.local_only_long} />, - name: 'do_not_federate', - on: doNotFederate, - text: <FormattedMessage {...messages.local_only_short} />, - }, - ]} - onChange={onToggleAdvancedOption} - onModalClose={onModalClose} - onModalOpen={onModalOpen} - title={intl.formatMessage(messages.advanced_options_icon_title)} - /> + {advancedOptions ? ( + <Dropdown + active={advancedOptions.some(value => !!value)} + disabled={disabled} + icon='ellipsis-h' + items={[ + { + meta: <FormattedMessage {...messages.local_only_long} />, + name: 'do_not_federate', + on: advancedOptions.get('do_not_federate'), + text: <FormattedMessage {...messages.local_only_short} />, + }, + { + meta: <FormattedMessage {...messages.threaded_mode_long} />, + name: 'threaded_mode', + on: advancedOptions.get('threaded_mode'), + text: <FormattedMessage {...messages.threaded_mode_short} />, + }, + ]} + onChange={onChangeAdvancedOption} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.advanced_options_icon_title)} + /> + ) : null} </div> ); } @@ -309,17 +326,17 @@ export default class ComposerOptions extends React.PureComponent { // Props. ComposerOptions.propTypes = { acceptContentTypes: PropTypes.string, + advancedOptions: ImmutablePropTypes.map, disabled: PropTypes.bool, - doNotFederate: PropTypes.bool, full: PropTypes.bool, hasMedia: PropTypes.bool, intl: PropTypes.object.isRequired, + onChangeAdvancedOption: PropTypes.func, onChangeSensitivity: PropTypes.func, onChangeVisibility: PropTypes.func, onDoodleOpen: PropTypes.func, onModalClose: PropTypes.func, onModalOpen: PropTypes.func, - onToggleAdvancedOption: PropTypes.func, onToggleSpoiler: PropTypes.func, onUpload: PropTypes.func, privacy: PropTypes.string, diff --git a/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js b/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js new file mode 100644 index 000000000..049cdd5cd --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js @@ -0,0 +1,60 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages } from 'react-intl'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Messages. +const messages = defineMessages({ + localOnly: { + defaultMessage: 'This post is local-only', + id: 'advanced_options.local-only.tooltip', + }, + threadedMode: { + defaultMessage: 'Threaded mode enabled', + id: 'advanced_options.threaded_mode.tooltip', + }, +}); + +// We use an array of tuples here instead of an object because it +// preserves order. +const iconMap = [ + ['do_not_federate', 'home', messages.localOnly], + ['threaded_mode', 'comments', messages.threadedMode], +]; + +// The component. +export default function ComposerTextareaIcons ({ + advancedOptions, + intl, +}) { + + // The result. We just map every active option to its icon. + return ( + <div className='composer--textarea--icons'> + {advancedOptions ? iconMap.map( + ([key, icon, message]) => advancedOptions.get(key) ? ( + <span + className='textarea_icon' + key={key} + title={intl.formatMessage(message)} + > + <Icon + fullwidth + icon={icon} + /> + </span> + ) : null + ) : null} + </div> + ); +} + +// Props. +ComposerTextareaIcons.propTypes = { + advancedOptions: ImmutablePropTypes.map, + intl: PropTypes.object.isRequired, +}; diff --git a/app/javascript/flavours/glitch/features/composer/textarea/index.js b/app/javascript/flavours/glitch/features/composer/textarea/index.js index 2e0b3e3d7..0f5fd4d4d 100644 --- a/app/javascript/flavours/glitch/features/composer/textarea/index.js +++ b/app/javascript/flavours/glitch/features/composer/textarea/index.js @@ -10,6 +10,7 @@ import Textarea from 'react-textarea-autosize'; // Components. import EmojiPicker from 'flavours/glitch/features/emoji_picker'; +import ComposerTextareaIcons from './icons'; import ComposerTextareaSuggestions from './suggestions'; // Utils. @@ -232,6 +233,7 @@ export default class ComposerTextarea extends React.Component { handleRefTextarea, } = this.handlers; const { + advancedOptions, autoFocus, disabled, intl, @@ -249,6 +251,10 @@ export default class ComposerTextarea extends React.Component { <div className='composer--textarea'> <label> <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span> + <ComposerTextareaIcons + advancedOptions={advancedOptions} + intl={intl} + /> <Textarea aria-autocomplete='list' autoFocus={autoFocus} @@ -280,6 +286,7 @@ export default class ComposerTextarea extends React.Component { // Props. ComposerTextarea.propTypes = { + advancedOptions: ImmutablePropTypes.map, autoFocus: PropTypes.bool, disabled: PropTypes.bool, intl: PropTypes.object.isRequired, diff --git a/app/javascript/flavours/glitch/locales/en.js b/app/javascript/flavours/glitch/locales/en.js index a2a2b4a9b..de6af0990 100644 --- a/app/javascript/flavours/glitch/locales/en.js +++ b/app/javascript/flavours/glitch/locales/en.js @@ -52,9 +52,13 @@ const messages = { 'compose.attach.doodle': 'Draw something', 'compose.attach': 'Attach...', - 'advanced-options.local-only.short': 'Local-only', - 'advanced-options.local-only.long': 'Do not post to other instances', + 'advanced_options.local-only.short': 'Local-only', + 'advanced_options.local-only.long': 'Do not post to other instances', + 'advanced_options.local-only.tooltip': 'This post is local-only', 'advanced_options.icon_title': 'Advanced options', + 'advanced_options.threaded_mode.short': 'Threaded mode', + 'advanced_options.threaded_mode.long': 'Automatically opens a reply on posting', + 'advanced_options.threaded_mode.tooltip': 'Threaded mode enabled', }; export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ja.js b/app/javascript/flavours/glitch/locales/ja.js index 96e6e0b2c..0c365319e 100644 --- a/app/javascript/flavours/glitch/locales/ja.js +++ b/app/javascript/flavours/glitch/locales/ja.js @@ -55,8 +55,8 @@ const messages = { 'compose.attach.doodle': '落書きをする', 'compose.attach': 'アタッチ...', - 'advanced-options.local-only.short': 'ローカル限定', - 'advanced-options.local-only.long': '他のインスタンスには投稿されません', + 'advanced_options.local-only.short': 'ローカル限定', + 'advanced_options.local-only.long': '他のインスタンスには投稿されません', 'advanced_options.icon_title': '高度な設定', }; diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index e1f811f6f..610cc9446 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -33,11 +33,13 @@ import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import uuid from 'flavours/glitch/util/uuid'; import { me } from 'flavours/glitch/util/initial_state'; +import { overwrite } from 'flavours/glitch/util/js_helpers'; const initialState = ImmutableMap({ mounted: false, advanced_options: ImmutableMap({ do_not_federate: false, + threaded_mode: false, }), sensitive: false, spoiler: false, @@ -55,6 +57,7 @@ const initialState = ImmutableMap({ suggestions: ImmutableList(), default_advanced_options: ImmutableMap({ do_not_federate: false, + threaded_mode: null, // Do not reset }), default_privacy: 'public', default_sensitive: false, @@ -83,6 +86,20 @@ function statusToTextMentions(state, status) { return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join(''); }; +function apiStatusToTextMentions (state, status) { + let set = ImmutableOrderedSet([]); + + if (status.account.id !== me) { + set = set.add(`@${status.account.acct} `); + } + + return set.union(status.mentions.filter( + mention => mention.id !== me + ).map( + mention => `@${mention.acct} ` + )).join(''); +} + function clearAll(state) { return state.withMutations(map => { map.set('text', ''); @@ -90,7 +107,10 @@ function clearAll(state) { map.set('spoiler_text', ''); map.set('is_submitting', false); map.set('in_reply_to', null); - map.set('advanced_options', state.get('default_advanced_options')); + map.update( + 'advanced_options', + map => map.mergeWith(overwrite, state.get('default_advanced_options')) + ); map.set('privacy', state.get('default_privacy')); map.set('sensitive', false); map.update('media_attachments', list => list.clear()); @@ -98,6 +118,31 @@ function clearAll(state) { }); }; +function continueThread (state, status) { + return state.withMutations(function (map) { + map.set('text', apiStatusToTextMentions(state, status)); + if (status.spoiler_text) { + map.set('spoiler', true); + map.set('spoiler_text', status.spoiler_text); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + map.set('is_submitting', false); + map.set('in_reply_to', status.id); + map.update( + 'advanced_options', + map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(status.content) })) + ); + map.set('privacy', privacyPreference(status.visibility, state.get('default_privacy'))); + map.set('sensitive', false); + map.update('media_attachments', list => list.clear()); + map.set('idempotencyKey', uuid()); + map.set('focusDate', new Date()); + map.set('preselectDate', new Date()); + }); +} + function appendMedia(state, media) { const prevSize = state.get('media_attachments').size; @@ -182,8 +227,7 @@ export default function compose(state = initialState, action) { return state.set('mounted', false); case COMPOSE_ADVANCED_OPTIONS_CHANGE: return state - .set('advanced_options', - state.get('advanced_options').set(action.option, !state.getIn(['advanced_options', action.option]))) + .set('advanced_options', state.get('advanced_options').set(action.option, !!overwrite(!state.getIn(['advanced_options', action.option]), action.value))) .set('idempotencyKey', uuid()); case COMPOSE_SENSITIVITY_CHANGE: return state.withMutations(map => { @@ -220,9 +264,10 @@ export default function compose(state = initialState, action) { map.set('in_reply_to', action.status.get('id')); map.set('text', statusToTextMentions(state, action.status)); map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); - map.set('advanced_options', new ImmutableMap({ - do_not_federate: /👁\ufe0f?<\/p>$/.test(action.status.get('content')), - })); + map.update( + 'advanced_options', + map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(action.status.get('content')) })) + ); map.set('focusDate', new Date()); map.set('preselectDate', new Date()); map.set('idempotencyKey', uuid()); @@ -243,14 +288,17 @@ export default function compose(state = initialState, action) { map.set('spoiler', false); map.set('spoiler_text', ''); map.set('privacy', state.get('default_privacy')); - map.set('advanced_options', state.get('default_advanced_options')); + map.update( + 'advanced_options', + map => map.mergeWith(overwrite, state.get('default_advanced_options')) + ); map.set('idempotencyKey', uuid()); }); case COMPOSE_SUBMIT_REQUEST: case COMPOSE_UPLOAD_CHANGE_REQUEST: return state.set('is_submitting', true); case COMPOSE_SUBMIT_SUCCESS: - return clearAll(state); + return action.status && state.get('advanced_options', 'threaded_mode') ? continueThread(state, action.status) : clearAll(state); case COMPOSE_SUBMIT_FAIL: case COMPOSE_UPLOAD_CHANGE_FAIL: return state.set('is_submitting', false); diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss index ab5fa4712..52d9ed105 100644 --- a/app/javascript/flavours/glitch/styles/components/composer.scss +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -134,6 +134,27 @@ } } +.composer--textarea--icons { + display: block; + position: absolute; + top: 29px; + right: 5px; + bottom: 5px; + overflow: hidden; + + & > .textarea_icon { + display: block; + margin: 2px 0 0 2px; + width: 24px; + height: 24px; + color: darken($ui-primary-color, 24%); + font-size: 18px; + line-height: 24px; + text-align: center; + opacity: .8; + } +} + .composer--textarea--suggestions { display: block; position: absolute; diff --git a/app/javascript/flavours/glitch/util/js_helpers.js b/app/javascript/flavours/glitch/util/js_helpers.js new file mode 100644 index 000000000..2ebd5b6c5 --- /dev/null +++ b/app/javascript/flavours/glitch/util/js_helpers.js @@ -0,0 +1,5 @@ +// This function returns the new value unless it is `null` or +// `undefined`, in which case it returns the old one. +export function overwrite (oldVal, newVal) { + return newVal === null || typeof newVal === 'undefined' ? oldVal : newVal; +} |