diff options
author | Thibaut Girka <thib@sitedethib.com> | 2019-04-21 17:17:10 +0200 |
---|---|---|
committer | ThibG <thib@sitedethib.com> | 2019-04-22 20:15:47 +0200 |
commit | 14028655df1b94a1722d6e329174947db45db477 (patch) | |
tree | 0b73cb978a81104677ba903b492a478a3d18d801 /app/javascript/flavours/glitch/features/compose/components | |
parent | 4c6221929f608764c616c2b909eccdc8a17e8909 (diff) |
Move composer Dropdown from features/composer to features/compose
Diffstat (limited to 'app/javascript/flavours/glitch/features/compose/components')
4 files changed, 802 insertions, 3 deletions
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js index ae22f4d6d..a9be9c751 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -5,7 +5,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; // Components. -import ComposerOptions from '../../composer/options'; +import Options from './options'; import ComposerPublisher from '../../composer/publisher'; import TextareaIcons from './textarea_icons'; import UploadFormContainer from '../containers/upload_form_container'; @@ -372,7 +372,7 @@ class ComposeForm extends ImmutablePureComponent { <PollFormContainer /> </div> - <ComposerOptions + <Options acceptContentTypes={acceptContentTypes} advancedOptions={advancedOptions} disabled={isSubmitting} @@ -382,7 +382,6 @@ class ComposeForm extends ImmutablePureComponent { hasMedia={media && !!media.size} allowPoll={!(media && !!media.size)} hasPoll={!!poll} - intl={intl} onChangeAdvancedOption={onChangeAdvancedOption} onChangeSensitivity={onChangeSensitivity} onChangeVisibility={onChangeVisibility} diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js new file mode 100644 index 000000000..8d982208f --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -0,0 +1,229 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import Overlay from 'react-overlays/lib/Overlay'; + +// Components. +import IconButton from 'flavours/glitch/components/icon_button'; +import DropdownMenu from './dropdown_menu'; + +// Utils. +import { isUserTouching } from 'flavours/glitch/util/is_mobile'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// Handlers. +const handlers = { + + // Closes the dropdown. + handleClose () { + this.setState({ open: false }); + }, + + // The enter key toggles the dropdown's open state, and the escape + // key closes it. + handleKeyDown ({ key }) { + const { + handleClose, + handleToggle, + } = this.handlers; + switch (key) { + case 'Enter': + handleToggle(key); + break; + case 'Escape': + handleClose(); + break; + } + }, + + // Creates an action modal object. + handleMakeModal () { + const component = this; + const { + items, + onChange, + onModalOpen, + onModalClose, + value, + } = this.props; + + // Required props. + if (!(onChange && onModalOpen && onModalClose && items)) { + return null; + } + + // The object. + return { + actions: items.map( + ({ + name, + ...rest + }) => ({ + ...rest, + active: value && name === value, + name, + onClick (e) { + e.preventDefault(); // Prevents focus from changing + onModalClose(); + onChange(name); + }, + onPassiveClick (e) { + e.preventDefault(); // Prevents focus from changing + onChange(name); + component.setState({ needsModalUpdate: true }); + }, + }) + ), + }; + }, + + // Toggles opening and closing the dropdown. + handleToggle ({ target }) { + const { handleMakeModal } = this.handlers; + const { onModalOpen } = this.props; + const { open } = this.state; + + // If this is a touch device, we open a modal instead of the + // dropdown. + if (isUserTouching()) { + + // This gets the modal to open. + const modal = handleMakeModal(); + + // If we can, we then open the modal. + if (modal && onModalOpen) { + onModalOpen(modal); + return; + } + } + + const { top } = target.getBoundingClientRect(); + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + // Otherwise, we just set our state to open. + this.setState({ open: !open }); + }, + + // If our modal is open and our props update, we need to also update + // the modal. + handleUpdate () { + const { handleMakeModal } = this.handlers; + const { onModalOpen } = this.props; + const { needsModalUpdate } = this.state; + + // Gets our modal object. + const modal = handleMakeModal(); + + // Reopens the modal with the new object. + if (needsModalUpdate && modal && onModalOpen) { + onModalOpen(modal); + } + }, +}; + +// The component. +export default class ComposerOptionsDropdown extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + this.state = { + needsModalUpdate: false, + open: false, + placement: 'bottom', + }; + } + + // Updates our modal as necessary. + componentDidUpdate (prevProps) { + const { handleUpdate } = this.handlers; + const { items } = this.props; + const { needsModalUpdate } = this.state; + if (needsModalUpdate && items.find( + (item, i) => item.on !== prevProps.items[i].on + )) { + handleUpdate(); + this.setState({ needsModalUpdate: false }); + } + } + + // Rendering. + render () { + const { + handleClose, + handleKeyDown, + handleToggle, + } = this.handlers; + const { + active, + disabled, + title, + icon, + items, + onChange, + value, + } = this.props; + const { open, placement } = this.state; + const computedClass = classNames('composer--options--dropdown', { + active, + open, + top: placement === 'top', + }); + + // The result. + return ( + <div + className={computedClass} + onKeyDown={handleKeyDown} + > + <IconButton + active={open || active} + className='value' + disabled={disabled} + icon={icon} + onClick={handleToggle} + size={18} + style={{ + height: null, + lineHeight: '27px', + }} + title={title} + /> + <Overlay + containerPadding={20} + placement={placement} + show={open} + target={this} + > + <DropdownMenu + items={items} + onChange={onChange} + onClose={handleClose} + value={value} + /> + </Overlay> + </div> + ); + } + +} + +// Props. +ComposerOptionsDropdown.propTypes = { + active: PropTypes.bool, + disabled: PropTypes.bool, + icon: PropTypes.string, + items: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + name: PropTypes.string.isRequired, + on: PropTypes.bool, + text: PropTypes.node, + })).isRequired, + onChange: PropTypes.func, + onModalClose: PropTypes.func, + onModalOpen: PropTypes.func, + title: PropTypes.string, + value: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js new file mode 100644 index 000000000..b4e2ec07b --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js @@ -0,0 +1,219 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import spring from 'react-motion/lib/spring'; +import Toggle from 'react-toggle'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import classNames from 'classnames'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import { withPassive } from 'flavours/glitch/util/dom_helpers'; +import Motion from 'flavours/glitch/util/optional_motion'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +class ComposerOptionsDropdownContentItem extends ImmutablePureComponent { + + static propTypes = { + active: PropTypes.bool, + name: PropTypes.string, + onChange: PropTypes.func, + onClose: PropTypes.func, + options: PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + on: PropTypes.bool, + text: PropTypes.node, + }), + }; + + handleActivate = (e) => { + const { + name, + onChange, + onClose, + options: { on }, + } = this.props; + + // If the escape key was pressed, we close the dropdown. + if (e.key === 'Escape' && onClose) { + onClose(); + + // Otherwise, we both close the dropdown and change the value. + } else if (onChange && (!e.key || e.key === 'Enter')) { + e.preventDefault(); // Prevents change in focus on click + if ((on === null || typeof on === 'undefined') && onClose) { + onClose(); + } + onChange(name); + } + } + + // Rendering. + render () { + const { + active, + options: { + icon, + meta, + on, + text, + }, + } = this.props; + const computedClass = classNames('composer--options--dropdown--content--item', { + active, + lengthy: meta, + 'toggled-off': !on && on !== null && typeof on !== 'undefined', + 'toggled-on': on, + 'with-icon': icon, + }); + + let prefix = null; + + if (on !== null && typeof on !== 'undefined') { + prefix = <Toggle checked={on} onChange={this.handleActivate} />; + } else if (icon) { + prefix = <Icon className='icon' fullwidth icon={icon} /> + } + + // The result. + return ( + <div + className={computedClass} + onClick={this.handleActivate} + onKeyDown={this.handleActivate} + role='button' + tabIndex='0' + > + {prefix} + + <div className='content'> + <strong>{text}</strong> + {meta ? meta : nil} + </div> + </div> + ); + } + +}; + +// The spring to use with our motion. +const springMotion = spring(1, { + damping: 35, + stiffness: 400, +}); + +// The component. +export default class ComposerOptionsDropdownContent extends React.PureComponent { + + static propTypes = { + items: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + name: PropTypes.string.isRequired, + on: PropTypes.bool, + text: PropTypes.node, + })), + onChange: PropTypes.func, + onClose: PropTypes.func, + style: PropTypes.object, + value: PropTypes.string, + }; + + static defaultProps = { + style: {}, + }; + + state = { + mounted: false, + }; + + // When the document is clicked elsewhere, we close the dropdown. + handleDocumentClick = ({ target }) => { + const { node } = this; + const { onClose } = this.props; + if (onClose && node && !node.contains(target)) { + onClose(); + } + } + + // Stores our node in `this.node`. + handleRef = (node) => { + this.node = node; + } + + // On mounting, we add our listeners. + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, withPassive); + this.setState({ mounted: true }); + } + + // On unmounting, we remove our listeners. + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, withPassive); + } + + // Rendering. + render () { + const { mounted } = this.state; + const { + items, + onChange, + onClose, + style, + value, + } = this.props; + + // The result. + return ( + <Motion + defaultStyle={{ + opacity: 0, + scaleX: 0.85, + scaleY: 0.75, + }} + style={{ + opacity: springMotion, + scaleX: springMotion, + scaleY: springMotion, + }} + > + {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays + <div + className='composer--options--dropdown--content' + ref={this.handleRef} + style={{ + ...style, + opacity: opacity, + transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, + }} + > + {items ? items.map( + ({ + name, + ...rest + }) => ( + <ComposerOptionsDropdownContentItem + active={name === value} + key={name} + name={name} + onChange={onChange} + onClose={onClose} + options={rest} + /> + ) + ) : null} + </div> + )} + </Motion> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js new file mode 100644 index 000000000..8a760bd15 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/options.js @@ -0,0 +1,352 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import spring from 'react-motion/lib/spring'; + +// Components. +import IconButton from 'flavours/glitch/components/icon_button'; +import TextIconButton from 'flavours/glitch/components/text_icon_button'; +import Dropdown from './dropdown'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Utils. +import Motion from 'flavours/glitch/util/optional_motion'; +import { pollLimits } from 'flavours/glitch/util/initial_state'; + +// Messages. +const messages = defineMessages({ + advanced_options_icon_title: { + defaultMessage: 'Advanced options', + id: 'advanced_options.icon_title', + }, + attach: { + defaultMessage: 'Attach...', + id: 'compose.attach', + }, + change_privacy: { + defaultMessage: 'Adjust status privacy', + id: 'privacy.change', + }, + direct_long: { + defaultMessage: 'Post to mentioned users only', + id: 'privacy.direct.long', + }, + direct_short: { + defaultMessage: 'Direct', + id: 'privacy.direct.short', + }, + doodle: { + defaultMessage: 'Draw something', + id: 'compose.attach.doodle', + }, + local_only_long: { + defaultMessage: 'Do not post to other instances', + id: 'advanced_options.local-only.long', + }, + local_only_short: { + defaultMessage: 'Local-only', + id: 'advanced_options.local-only.short', + }, + private_long: { + defaultMessage: 'Post to followers only', + id: 'privacy.private.long', + }, + private_short: { + defaultMessage: 'Followers-only', + id: 'privacy.private.short', + }, + public_long: { + defaultMessage: 'Post to public timelines', + id: 'privacy.public.long', + }, + public_short: { + defaultMessage: 'Public', + id: 'privacy.public.short', + }, + sensitive: { + defaultMessage: 'Mark media as sensitive', + id: 'compose_form.sensitive', + }, + spoiler: { + 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', + }, + unlisted_short: { + defaultMessage: 'Unlisted', + id: 'privacy.unlisted.short', + }, + upload: { + defaultMessage: 'Upload a file', + id: 'compose.attach.upload', + }, + add_poll: { + defaultMessage: 'Add a poll', + id: 'poll_button.add_poll', + }, + remove_poll: { + defaultMessage: 'Remove poll', + id: 'poll_button.remove_poll', + }, +}); + +export default @injectIntl +class ComposerOptions extends ImmutablePureComponent { + + static propTypes = { + acceptContentTypes: PropTypes.string, + advancedOptions: ImmutablePropTypes.map, + disabled: PropTypes.bool, + allowMedia: PropTypes.bool, + hasMedia: PropTypes.bool, + allowPoll: PropTypes.bool, + hasPoll: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChangeAdvancedOption: PropTypes.func, + onChangeSensitivity: PropTypes.func, + onChangeVisibility: PropTypes.func, + onTogglePoll: PropTypes.func, + onDoodleOpen: PropTypes.func, + onModalClose: PropTypes.func, + onModalOpen: PropTypes.func, + onToggleSpoiler: PropTypes.func, + onUpload: PropTypes.func, + privacy: PropTypes.string, + resetFileKey: PropTypes.number, + sensitive: PropTypes.bool, + spoiler: PropTypes.bool, + }; + + // Handles file selection. + handleChangeFiles = ({ target: { files } }) => { + const { onUpload } = this.props; + if (files.length && onUpload) { + onUpload(files); + } + } + + // Handles attachment clicks. + handleClickAttach = (name) => { + const { fileElement } = this; + const { onDoodleOpen } = this.props; + + // We switch over the name of the option. + switch (name) { + case 'upload': + if (fileElement) { + fileElement.click(); + } + return; + case 'doodle': + if (onDoodleOpen) { + onDoodleOpen(); + } + return; + } + } + + // Handles a ref to the file input. + handleRefFileElement = (fileElement) => { + this.fileElement = fileElement; + } + + // Rendering. + render () { + const { + acceptContentTypes, + advancedOptions, + disabled, + allowMedia, + hasMedia, + allowPoll, + hasPoll, + intl, + onChangeAdvancedOption, + onChangeSensitivity, + onChangeVisibility, + onTogglePoll, + onModalClose, + onModalOpen, + onToggleSpoiler, + privacy, + resetFileKey, + sensitive, + spoiler, + } = this.props; + + // We predefine our privacy items so that we can easily pick the + // dropdown icon later. + const privacyItems = { + direct: { + icon: 'envelope', + meta: <FormattedMessage {...messages.direct_long} />, + name: 'direct', + text: <FormattedMessage {...messages.direct_short} />, + }, + private: { + icon: 'lock', + meta: <FormattedMessage {...messages.private_long} />, + name: 'private', + text: <FormattedMessage {...messages.private_short} />, + }, + public: { + icon: 'globe', + meta: <FormattedMessage {...messages.public_long} />, + name: 'public', + text: <FormattedMessage {...messages.public_short} />, + }, + unlisted: { + icon: 'unlock', + meta: <FormattedMessage {...messages.unlisted_long} />, + name: 'unlisted', + text: <FormattedMessage {...messages.unlisted_short} />, + }, + }; + + // The result. + return ( + <div className='composer--options'> + <input + accept={acceptContentTypes} + disabled={disabled || !allowMedia} + key={resetFileKey} + onChange={this.handleChangeFiles} + ref={this.handleRefFileElement} + type='file' + multiple + style={{ display: 'none' }} + /> + <Dropdown + disabled={disabled || !allowMedia} + icon='paperclip' + items={[ + { + icon: 'cloud-upload', + name: 'upload', + text: <FormattedMessage {...messages.upload} />, + }, + { + icon: 'paint-brush', + name: 'doodle', + text: <FormattedMessage {...messages.doodle} />, + }, + ]} + onChange={this.handleClickAttach} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.attach)} + /> + {!!pollLimits && ( + <IconButton + active={hasPoll} + disabled={disabled || !allowPoll} + icon='tasks' + inverted + onClick={onTogglePoll} + size={18} + style={{ + height: null, + lineHeight: null, + }} + title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)} + /> + )} + <Motion + defaultStyle={{ scale: 0.87 }} + style={{ + scale: spring(hasMedia ? 1 : 0.87, { + stiffness: 200, + damping: 3, + }), + }} + > + {({ scale }) => ( + <div + style={{ + display: hasMedia ? null : 'none', + transform: `scale(${scale})`, + }} + > + <IconButton + active={sensitive} + className='sensitive' + disabled={spoiler} + icon={sensitive ? 'eye-slash' : 'eye'} + inverted + onClick={onChangeSensitivity} + size={18} + style={{ + height: null, + lineHeight: null, + }} + title={intl.formatMessage(messages.sensitive)} + /> + </div> + )} + </Motion> + <hr /> + <Dropdown + disabled={disabled} + icon={(privacyItems[privacy] || {}).icon} + items={[ + privacyItems.public, + privacyItems.unlisted, + privacyItems.private, + privacyItems.direct, + ]} + onChange={onChangeVisibility} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.change_privacy)} + value={privacy} + /> + {onToggleSpoiler && ( + <TextIconButton + active={spoiler} + ariaControls='glitch.composer.spoiler.input' + label='CW' + onClick={onToggleSpoiler} + title={intl.formatMessage(messages.spoiler)} + /> + )} + <Dropdown + active={advancedOptions && advancedOptions.some(value => !!value)} + disabled={disabled} + icon='ellipsis-h' + items={advancedOptions ? [ + { + 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} />, + }, + ] : null} + onChange={onChangeAdvancedOption} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.advanced_options_icon_title)} + /> + </div> + ); + } + +} |