about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features/composer/options
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/features/composer/options')
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js146
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js129
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/index.js228
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/index.js344
4 files changed, 847 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js
new file mode 100644
index 000000000..b76410561
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js
@@ -0,0 +1,146 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import ComposerOptionsDropdownContentItem from './item';
+
+//  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';
+
+//  Handlers.
+const handlers = {
+  //  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;
+  },
+};
+
+//  The spring to use with our motion.
+const springMotion = spring(1, {
+  damping: 35,
+  stiffness: 400,
+});
+
+//  The component.
+export default class ComposerOptionsDropdownContent extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+
+    //  Instance variables.
+    this.node = null;
+
+    this.state = {
+      mounted: false,
+    };
+  }
+
+  //  On mounting, we add our listeners.
+  componentDidMount () {
+    const { handleDocumentClick } = this.handlers;
+    document.addEventListener('click', handleDocumentClick, false);
+    document.addEventListener('touchend', handleDocumentClick, withPassive);
+    this.setState({ mounted: true });
+  }
+
+  //  On unmounting, we remove our listeners.
+  componentWillUnmount () {
+    const { handleDocumentClick } = this.handlers;
+    document.removeEventListener('click', handleDocumentClick, false);
+    document.removeEventListener('touchend', handleDocumentClick, withPassive);
+  }
+
+  //  Rendering.
+  render () {
+    const { mounted } = this.state;
+    const { handleRef } = this.handlers;
+    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={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>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptionsDropdownContent.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,
+};
+
+//  Default props.
+ComposerOptionsDropdownContent.defaultProps = { style: {} };
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js
new file mode 100644
index 000000000..68a52083f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js
@@ -0,0 +1,129 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Toggle from 'react-toggle';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+
+  //  This function activates the dropdown item.
+  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);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerOptionsDropdownContentItem extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { handleActivate } = this.handlers;
+    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,
+    });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onClick={handleActivate}
+        onKeyDown={handleActivate}
+        role='button'
+        tabIndex='0'
+      >
+        {function () {
+
+          //  We render a `<Toggle>` if we were provided an `on`
+          //  property, and otherwise show an `<Icon>` if available.
+          switch (true) {
+          case on !== null && typeof on !== 'undefined':
+            return (
+              <Toggle
+                checked={on}
+                onChange={handleActivate}
+              />
+            );
+          case !!icon:
+            return (
+              <Icon
+                className='icon'
+                fullwidth
+                icon={icon}
+              />
+            );
+          default:
+            return null;
+          }
+        }()}
+        {meta ? (
+          <div className='content'>
+            <strong>{text}</strong>
+            {meta}
+          </div>
+        ) :
+          <div className='content'>
+            <strong>{text}</strong>
+          </div>}
+      </div>
+    );
+  }
+
+};
+
+//  Props.
+ComposerOptionsDropdownContentItem.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,
+  }),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
new file mode 100644
index 000000000..b3462e25a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
@@ -0,0 +1,228 @@
+//  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 ComposerOptionsDropdownContent from './content';
+
+//  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: null,
+    };
+  }
+
+  //  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,
+    });
+
+    //  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}
+        >
+          <ComposerOptionsDropdownContent
+            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/composer/options/index.js b/app/javascript/flavours/glitch/features/composer/options/index.js
new file mode 100644
index 000000000..c129622bc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/index.js
@@ -0,0 +1,344 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import {
+  FormattedMessage,
+  defineMessages,
+} 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';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  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',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  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;
+  },
+};
+
+//  The component.
+export default class ComposerOptions extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+
+    //  Instance variables.
+    this.fileElement = null;
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleChangeFiles,
+      handleClickAttach,
+      handleRefFileElement,
+    } = this.handlers;
+    const {
+      acceptContentTypes,
+      advancedOptions,
+      disabled,
+      full,
+      hasMedia,
+      intl,
+      onChangeAdvancedOption,
+      onChangeSensitivity,
+      onChangeVisibility,
+      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-alt',
+        meta: <FormattedMessage {...messages.unlisted_long} />,
+        name: 'unlisted',
+        text: <FormattedMessage {...messages.unlisted_short} />,
+      },
+    };
+
+    //  The result.
+    return (
+      <div className='composer--options'>
+        <input
+          accept={acceptContentTypes}
+          disabled={disabled || full}
+          key={resetFileKey}
+          onChange={handleChangeFiles}
+          ref={handleRefFileElement}
+          type='file'
+          {...hiddenComponent}
+        />
+        <Dropdown
+          disabled={disabled || full}
+          icon='paperclip'
+          items={[
+            {
+              icon: 'cloud-upload',
+              name: 'upload',
+              text: <FormattedMessage {...messages.upload} />,
+            },
+            {
+              icon: 'paint-brush',
+              name: 'doodle',
+              text: <FormattedMessage {...messages.doodle} />,
+            },
+          ]}
+          onChange={handleClickAttach}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.attach)}
+        />
+        <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}
+        />
+        <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>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptions.propTypes = {
+  acceptContentTypes: PropTypes.string,
+  advancedOptions: ImmutablePropTypes.map,
+  disabled: 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,
+  onToggleSpoiler: PropTypes.func,
+  onUpload: PropTypes.func,
+  privacy: PropTypes.string,
+  resetFileKey: PropTypes.number,
+  sensitive: PropTypes.bool,
+  spoiler: PropTypes.bool,
+};