diff --git a/app/soapbox/features/compose/components/privacy_dropdown.js b/app/soapbox/features/compose/components/privacy_dropdown.js
deleted file mode 100644
index d6ddf771c..000000000
--- a/app/soapbox/features/compose/components/privacy_dropdown.js
+++ /dev/null
@@ -1,284 +0,0 @@
-import classNames from 'classnames';
-import { supportsPassiveEvents } from 'detect-passive-events';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { injectIntl, defineMessages } from 'react-intl';
-import spring from 'react-motion/lib/spring';
-import Overlay from 'react-overlays/lib/Overlay';
-
-import Icon from 'soapbox/components/icon';
-
-import { IconButton } from '../../../components/ui';
-import Motion from '../../ui/util/optional_motion';
-
-const messages = defineMessages({
- public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
- public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
- unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
- unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not post to public timelines' },
- private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
- private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
- direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
- direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
- change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust post privacy' },
-});
-
-const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
-
-class PrivacyDropdownMenu extends React.PureComponent {
-
- static propTypes = {
- style: PropTypes.object,
- items: PropTypes.array.isRequired,
- value: PropTypes.string.isRequired,
- placement: PropTypes.string.isRequired,
- onClose: PropTypes.func.isRequired,
- onChange: PropTypes.func.isRequired,
- unavailable: PropTypes.bool,
- };
-
- state = {
- mounted: false,
- };
-
- handleDocumentClick = e => {
- if (this.node && !this.node.contains(e.target)) {
- this.props.onClose();
- }
- }
-
- handleKeyDown = e => {
- const { items } = this.props;
- const value = e.currentTarget.getAttribute('data-index');
- const index = items.findIndex(item => {
- return (item.value === value);
- });
- let element = null;
-
- switch (e.key) {
- case 'Escape':
- this.props.onClose();
- break;
- case 'Enter':
- this.handleClick(e);
- break;
- case 'ArrowDown':
- element = this.node.childNodes[index + 1] || this.node.firstChild;
- break;
- case 'ArrowUp':
- element = this.node.childNodes[index - 1] || this.node.lastChild;
- break;
- case 'Tab':
- if (e.shiftKey) {
- element = this.node.childNodes[index - 1] || this.node.lastChild;
- } else {
- element = this.node.childNodes[index + 1] || this.node.firstChild;
- }
- break;
- case 'Home':
- element = this.node.firstChild;
- break;
- case 'End':
- element = this.node.lastChild;
- break;
- }
-
- if (element) {
- element.focus();
- this.props.onChange(element.getAttribute('data-index'));
- e.preventDefault();
- e.stopPropagation();
- }
- }
-
- handleClick = e => {
- const value = e.currentTarget.getAttribute('data-index');
-
- e.preventDefault();
-
- this.props.onClose();
- this.props.onChange(value);
- }
-
- componentDidMount() {
- document.addEventListener('click', this.handleDocumentClick, false);
- document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
- if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
- this.setState({ mounted: true });
- }
-
- componentWillUnmount() {
- document.removeEventListener('click', this.handleDocumentClick, false);
- document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
- }
-
- setRef = c => {
- this.node = c;
- }
-
- setFocusRef = c => {
- this.focusedItem = c;
- }
-
- render() {
- const { mounted } = this.state;
- const { style, items, placement, value } = this.props;
-
- return (
-
- {({ 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
-
- {items.map(item => (
-
-
-
-
-
-
- {item.text}
- {item.meta}
-
-
- ))}
-
- )}
-
- );
- }
-
-}
-
-export default @injectIntl
-class PrivacyDropdown extends React.PureComponent {
-
- static propTypes = {
- isUserTouching: PropTypes.func,
- isModalOpen: PropTypes.bool.isRequired,
- onModalOpen: PropTypes.func,
- onModalClose: PropTypes.func,
- value: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- open: false,
- placement: 'bottom',
- };
-
- constructor(props) {
- super(props);
- const { intl: { formatMessage } } = props;
-
- this.options = [
- { icon: require('@tabler/icons/icons/world.svg'), value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
- { icon: require('@tabler/icons/icons/lock-open.svg'), value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
- { icon: require('@tabler/icons/icons/lock.svg'), value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
- { icon: require('@tabler/icons/icons/mail.svg'), value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
- ];
- }
-
- handleToggle = (e) => {
- if (this.props.isUserTouching()) {
- if (this.state.open) {
- this.props.onModalClose();
- } else {
- this.props.onModalOpen({
- actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
- onClick: this.handleModalActionClick,
- });
- }
- } else {
- const { top } = e.target.getBoundingClientRect();
- if (this.state.open && this.activeElement) {
- this.activeElement.focus();
- }
- this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
- this.setState({ open: !this.state.open });
- }
- e.stopPropagation();
- }
-
- handleModalActionClick = (e) => {
- e.preventDefault();
-
- const { value } = this.options[e.currentTarget.getAttribute('data-index')];
-
- this.props.onModalClose();
- this.props.onChange(value);
- }
-
- handleKeyDown = e => {
- switch (e.key) {
- case 'Escape':
- this.handleClose();
- break;
- }
- }
-
- handleMouseDown = () => {
- if (!this.state.open) {
- this.activeElement = document.activeElement;
- }
- }
-
- handleButtonKeyDown = (e) => {
- switch (e.key) {
- case ' ':
- case 'Enter':
- this.handleMouseDown();
- break;
- }
- }
-
- handleClose = () => {
- if (this.state.open && this.activeElement) {
- this.activeElement.focus();
- }
- this.setState({ open: false });
- }
-
- handleChange = value => {
- this.props.onChange(value);
- }
-
- render() {
- const { value, intl, unavailable } = this.props;
- const { open, placement } = this.state;
-
- if (unavailable) {
- return null;
- }
-
- const valueOption = this.options.find(item => item.value === value);
-
- return (
-
- );
- }
-
-}
diff --git a/app/soapbox/features/compose/components/privacy_dropdown.tsx b/app/soapbox/features/compose/components/privacy_dropdown.tsx
new file mode 100644
index 000000000..3e3440ae4
--- /dev/null
+++ b/app/soapbox/features/compose/components/privacy_dropdown.tsx
@@ -0,0 +1,263 @@
+import classNames from 'classnames';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import React, { useState, useRef, useEffect } from 'react';
+import { useIntl, defineMessages } from 'react-intl';
+import { spring } from 'react-motion';
+// @ts-ignore
+import Overlay from 'react-overlays/lib/Overlay';
+
+import Icon from 'soapbox/components/icon';
+
+import { IconButton } from '../../../components/ui';
+import Motion from '../../ui/util/optional_motion';
+
+const messages = defineMessages({
+ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
+ unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not post to public timelines' },
+ private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+ private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
+ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+ direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
+ change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust post privacy' },
+});
+
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+
+interface IPrivacyDropdownMenu {
+ style?: React.CSSProperties,
+ items: any[],
+ value: string,
+ placement: string,
+ onClose: () => void,
+ onChange: (value: string | null) => void,
+ unavailable?: boolean,
+}
+
+const PrivacyDropdownMenu: React.FC = ({ style, items, placement, value, onClose, onChange }) => {
+ const node = useRef(null);
+ const focusedItem = useRef(null);
+
+ const [mounted, setMounted] = useState(false);
+
+ const handleDocumentClick = (e: MouseEvent | TouchEvent) => {
+ if (node.current && !node.current.contains(e.target as HTMLElement)) {
+ onClose();
+ }
+ };
+
+ const handleKeyDown: React.KeyboardEventHandler = e => {
+ const value = e.currentTarget.getAttribute('data-index');
+ const index = items.findIndex(item => {
+ return (item.value === value);
+ });
+ let element = null;
+
+ switch (e.key) {
+ case 'Escape':
+ onClose();
+ break;
+ case 'Enter':
+ handleClick(e);
+ break;
+ case 'ArrowDown':
+ element = node.current?.childNodes[index + 1] || node.current?.firstChild;
+ break;
+ case 'ArrowUp':
+ element = node.current?.childNodes[index - 1] || node.current?.lastChild;
+ break;
+ case 'Tab':
+ if (e.shiftKey) {
+ element = node.current?.childNodes[index - 1] || node.current?.lastChild;
+ } else {
+ element = node.current?.childNodes[index + 1] || node.current?.firstChild;
+ }
+ break;
+ case 'Home':
+ element = node.current?.firstChild;
+ break;
+ case 'End':
+ element = node.current?.lastChild;
+ break;
+ }
+
+ if (element) {
+ (element as HTMLElement).focus();
+ onChange((element as HTMLElement).getAttribute('data-index'));
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ };
+
+ const handleClick: React.EventHandler = (e: MouseEvent | KeyboardEvent) => {
+ const value = (e.currentTarget as HTMLElement)?.getAttribute('data-index');
+
+ e.preventDefault();
+
+ onClose();
+ onChange(value);
+ };
+
+ useEffect(() => {
+ document.addEventListener('click', handleDocumentClick, false);
+ document.addEventListener('touchend', handleDocumentClick, listenerOptions);
+
+ focusedItem.current?.focus({ preventScroll: true });
+ setMounted(true);
+
+ return () => {
+ document.removeEventListener('click', handleDocumentClick, false);
+ document.removeEventListener('touchend', handleDocumentClick);
+ };
+ });
+
+ return (
+
+ {({ 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
+
+ {items.map(item => (
+
+
+
+
+
+
+ {item.text}
+ {item.meta}
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+interface IPrivacyDropdown {
+ isUserTouching: () => boolean,
+ isModalOpen: boolean,
+ onModalOpen: (opts: any) => void,
+ onModalClose: () => void,
+ value: string,
+ onChange: (value: string | null) => void,
+ unavailable: boolean,
+}
+
+const PrivacyDropdown: React.FC = ({
+ isUserTouching,
+ onChange,
+ onModalClose,
+ onModalOpen,
+ value,
+ unavailable,
+}) => {
+ const intl = useIntl();
+ const activeElement = useRef(null);
+
+ const [open, setOpen] = useState(false);
+ const [placement, setPlacement] = useState('bottom');
+
+ const options = [
+ { icon: require('@tabler/icons/icons/world.svg'), value: 'public', text: intl.formatMessage(messages.public_short), meta: intl.formatMessage(messages.public_long) },
+ { icon: require('@tabler/icons/icons/lock-open.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) },
+ { icon: require('@tabler/icons/icons/lock.svg'), value: 'private', text: intl.formatMessage(messages.private_short), meta: intl.formatMessage(messages.private_long) },
+ { icon: require('@tabler/icons/icons/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) },
+ ];
+
+ const handleToggle: React.MouseEventHandler = (e) => {
+ if (isUserTouching()) {
+ if (open) {
+ onModalClose();
+ } else {
+ onModalOpen({
+ actions: options.map(option => ({ ...option, active: option.value === value })),
+ onClick: handleModalActionClick,
+ });
+ }
+ } else {
+ const { top } = e.currentTarget.getBoundingClientRect();
+ if (open) {
+ activeElement.current?.focus();
+ }
+ setPlacement(top * 2 < innerHeight ? 'bottom' : 'top');
+ setOpen(!open);
+ }
+ e.stopPropagation();
+ };
+
+ const handleModalActionClick: React.MouseEventHandler = (e) => {
+ e.preventDefault();
+
+ const { value } = options[e.currentTarget.getAttribute('data-index') as any];
+
+ onModalClose();
+ onChange(value);
+ };
+
+ const handleKeyDown: React.KeyboardEventHandler = e => {
+ switch (e.key) {
+ case 'Escape':
+ handleClose();
+ break;
+ }
+ };
+
+ const handleMouseDown = () => {
+ if (!open) {
+ activeElement.current = document.activeElement as HTMLElement | null;
+ }
+ };
+
+ const handleButtonKeyDown: React.KeyboardEventHandler = (e) => {
+ switch (e.key) {
+ case ' ':
+ case 'Enter':
+ handleMouseDown();
+ break;
+ }
+ };
+
+ const handleClose = () => {
+ if (open) {
+ activeElement.current?.focus();
+ }
+ setOpen(false);
+ };
+
+ if (unavailable) {
+ return null;
+ }
+
+ const valueOption = options.find(item => item.value === value);
+
+ return (
+
+ );
+};
+
+export default PrivacyDropdown;