[Glitch] Refactor PrivacyDropdown to TypeScript

Port 8a235dd187 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion
2025-11-21 14:33:27 +01:00
committed by Claire
parent 652ed7ab50
commit 975c7097b8
4 changed files with 203 additions and 178 deletions

View File

@@ -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 (
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
<button
type='button'
title={intl.formatMessage(messages.change_privacy)}
aria-expanded={open}
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
disabled={disabled}
className={classNames('dropdown-button', { active: open })}
>
<Icon id={valueOption.icon} icon={valueOption.iconComponent} />
<span className='dropdown-button__label'>{valueOption.text}</span>
</button>
<Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
{({ props, placement }) => (
<div {...props}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
<DropdownSelector
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
/>
</div>
</div>
)}
</Overlay>
</div>
);
}
}
export default injectIntl(PrivacyDropdown);

View File

@@ -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<PrivacyDropdownProps> = ({
value,
onChange,
noDirect,
container,
disabled,
}) => {
const intl = useIntl();
const overlayTargetRef = useRef<HTMLDivElement | null>(null);
const previousFocusTargetRef = useRef<HTMLElement | null>(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 (
<div ref={overlayTargetRef}>
<button
type='button'
title={intl.formatMessage(messages.change_privacy)}
aria-expanded={isOpen}
onClick={handleToggle}
onMouseDown={registerPreviousFocusTarget}
onKeyDown={handleButtonKeyDown}
disabled={disabled}
className={classNames('dropdown-button', { active: isOpen })}
>
{selectedOption && (
<>
<Icon
id={selectedOption.icon}
icon={selectedOption.iconComponent}
/>
<span className='dropdown-button__label'>
{selectedOption.text}
</span>
</>
)}
</button>
<Overlay
show={isOpen}
offset={[5, 5]}
placement='bottom'
flip
target={overlayTargetRef}
container={container}
popperConfig={{ strategy: 'fixed' }}
>
{({ props, placement }) => (
<div {...props}>
<div
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
>
<DropdownSelector
items={options}
value={value}
onClose={handleClose}
// @ts-expect-error DropdownSelector doesn't yet return the correct type for onChange
onChange={onChange}
/>
</div>
</div>
)}
</Overlay>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default PrivacyDropdown;

View File

@@ -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);

View File

@@ -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,
[],
);