diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx
deleted file mode 100644
index d481b030a8..0000000000
--- a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx
+++ /dev/null
@@ -1,158 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { injectIntl, defineMessages } from 'react-intl';
-
-import classNames from 'classnames';
-
-import Overlay from 'react-overlays/Overlay';
-
-import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
-import LockIcon from '@/material-icons/400-24px/lock.svg?react';
-import PublicIcon from '@/material-icons/400-24px/public.svg?react';
-import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
-import { DropdownSelector } from 'flavours/glitch/components/dropdown_selector';
-import { Icon } from 'flavours/glitch/components/icon';
-
-export const messages = defineMessages({
- public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
- public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
- unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
- unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Hidden from Mastodon search results, trending, and public timelines' },
- private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
- private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
- direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
- direct_long: { id: 'privacy.direct.long', defaultMessage: 'Everyone mentioned in the post' },
- change_privacy: { id: 'privacy.change', defaultMessage: 'Change post privacy' },
- unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' },
-});
-
-class PrivacyDropdown extends PureComponent {
-
- static propTypes = {
- value: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
- noDirect: PropTypes.bool,
- container: PropTypes.func,
- disabled: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- open: false,
- placement: 'bottom',
- };
-
- handleToggle = () => {
- if (this.state.open && this.activeElement) {
- this.activeElement.focus({ preventScroll: true });
- }
-
- this.setState({ open: !this.state.open });
- };
-
- 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({ preventScroll: true });
- }
- this.setState({ open: false });
- };
-
- handleChange = value => {
- this.props.onChange(value);
- };
-
- UNSAFE_componentWillMount () {
- const { intl: { formatMessage } } = this.props;
-
- this.options = [
- { icon: 'globe', iconComponent: PublicIcon, value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
- { icon: 'unlock', iconComponent: QuietTimeIcon, value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long), extra: formatMessage(messages.unlisted_extra) },
- { icon: 'lock', iconComponent: LockIcon, value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
- ];
-
- if (!this.props.noDirect) {
- this.options.push(
- { icon: 'at', iconComponent: AlternateEmailIcon, value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
- );
- }
- }
-
- setTargetRef = c => {
- this.target = c;
- };
-
- findTarget = () => {
- return this.target;
- };
-
- handleOverlayEnter = (state) => {
- this.setState({ placement: state.placement });
- };
-
- render () {
- const { value, container, disabled, intl } = this.props;
- const { open, placement } = this.state;
-
- const valueOption = this.options.find(item => item.value === value);
-
- return (
-
-
-
-
- {({ props, placement }) => (
-
- )}
-
-
- );
- }
-
-}
-
-export default injectIntl(PrivacyDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.tsx b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.tsx
new file mode 100644
index 0000000000..ae15d01dce
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.tsx
@@ -0,0 +1,199 @@
+import { useCallback, useRef, useState } from 'react';
+
+import { defineMessages, useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+
+import type { OverlayProps } from 'react-overlays/Overlay';
+import Overlay from 'react-overlays/Overlay';
+
+import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
+import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
+import LockIcon from '@/material-icons/400-24px/lock.svg?react';
+import PublicIcon from '@/material-icons/400-24px/public.svg?react';
+import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
+import { DropdownSelector } from 'flavours/glitch/components/dropdown_selector';
+import { Icon } from 'flavours/glitch/components/icon';
+
+export const messages = defineMessages({
+ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ public_long: {
+ id: 'privacy.public.long',
+ defaultMessage: 'Anyone on and off Mastodon',
+ },
+ unlisted_short: {
+ id: 'privacy.unlisted.short',
+ defaultMessage: 'Quiet public',
+ },
+ unlisted_long: {
+ id: 'privacy.unlisted.long',
+ defaultMessage:
+ 'Hidden from Mastodon search results, trending, and public timelines',
+ },
+ private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
+ private_long: {
+ id: 'privacy.private.long',
+ defaultMessage: 'Only your followers',
+ },
+ direct_short: {
+ id: 'privacy.direct.short',
+ defaultMessage: 'Specific people',
+ },
+ direct_long: {
+ id: 'privacy.direct.long',
+ defaultMessage: 'Everyone mentioned in the post',
+ },
+ change_privacy: {
+ id: 'privacy.change',
+ defaultMessage: 'Change post privacy',
+ },
+ unlisted_extra: {
+ id: 'privacy.unlisted.additional',
+ defaultMessage:
+ 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.',
+ },
+});
+
+interface PrivacyDropdownProps {
+ value: StatusVisibility;
+ onChange: (value: StatusVisibility) => void;
+ noDirect?: boolean;
+ container?: OverlayProps['container'];
+ disabled?: boolean;
+}
+
+const PrivacyDropdown: React.FC = ({
+ value,
+ onChange,
+ noDirect,
+ container,
+ disabled,
+}) => {
+ const intl = useIntl();
+ const overlayTargetRef = useRef(null);
+ const previousFocusTargetRef = useRef(null);
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleClose = useCallback(() => {
+ if (isOpen && previousFocusTargetRef.current) {
+ previousFocusTargetRef.current.focus({ preventScroll: true });
+ }
+ setIsOpen(false);
+ }, [isOpen]);
+
+ const handleToggle = useCallback(() => {
+ if (isOpen) {
+ handleClose();
+ }
+ setIsOpen((prev) => !prev);
+ }, [handleClose, isOpen]);
+
+ const registerPreviousFocusTarget = useCallback(() => {
+ if (!isOpen) {
+ previousFocusTargetRef.current = document.activeElement as HTMLElement;
+ }
+ }, [isOpen]);
+
+ const handleButtonKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if ([' ', 'Enter'].includes(e.key)) {
+ registerPreviousFocusTarget();
+ }
+ },
+ [registerPreviousFocusTarget],
+ );
+
+ const options = [
+ {
+ icon: 'globe',
+ iconComponent: PublicIcon,
+ value: 'public',
+ text: intl.formatMessage(messages.public_short),
+ meta: intl.formatMessage(messages.public_long),
+ },
+ {
+ icon: 'unlock',
+ iconComponent: QuietTimeIcon,
+ value: 'unlisted',
+ text: intl.formatMessage(messages.unlisted_short),
+ meta: intl.formatMessage(messages.unlisted_long),
+ extra: intl.formatMessage(messages.unlisted_extra),
+ },
+ {
+ icon: 'lock',
+ iconComponent: LockIcon,
+ value: 'private',
+ text: intl.formatMessage(messages.private_short),
+ meta: intl.formatMessage(messages.private_long),
+ },
+ ];
+
+ if (!noDirect) {
+ options.push({
+ icon: 'at',
+ iconComponent: AlternateEmailIcon,
+ value: 'direct',
+ text: intl.formatMessage(messages.direct_short),
+ meta: intl.formatMessage(messages.direct_long),
+ });
+ }
+
+ const selectedOption =
+ options.find((item) => item.value === value) ?? options.at(0);
+
+ return (
+
+
+
+
+ {({ props, placement }) => (
+
+ )}
+
+
+ );
+};
+
+// eslint-disable-next-line import/no-default-export
+export default PrivacyDropdown;
diff --git a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js
deleted file mode 100644
index a44b5c0d97..0000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { connect } from 'react-redux';
-
-import { changeComposeVisibility } from '@/flavours/glitch/actions/compose_typed';
-
-import PrivacyDropdown from '../components/privacy_dropdown';
-
-const mapStateToProps = state => ({
- value: state.getIn(['compose', 'privacy']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
- onChange (value) {
- dispatch(changeComposeVisibility(value));
- },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/boost_modal.tsx
index 3af3b28890..fb86a215f2 100644
--- a/app/javascript/flavours/glitch/features/ui/components/boost_modal.tsx
+++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.tsx
@@ -53,7 +53,10 @@ export const BoostModal: React.FC<{
}, [onClose]);
const findContainer = useCallback(
- () => document.getElementsByClassName('modal-root__container')[0],
+ () =>
+ document.getElementsByClassName(
+ 'modal-root__container',
+ )[0] as HTMLDivElement,
[],
);