Add privacy presets in composer

This commit is contained in:
Claire 2025-11-10 15:58:40 +01:00
parent 441eb89537
commit 70e4e032d3
4 changed files with 262 additions and 66 deletions

View File

@ -27,6 +27,7 @@ interface Props {
classNamePrefix?: string; classNamePrefix?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
items: SelectItem[]; items: SelectItem[];
header?: React.ReactNode;
onChange: (value: string) => void; onChange: (value: string) => void;
onClose: () => void; onClose: () => void;
} }
@ -35,6 +36,7 @@ export const DropdownSelector: React.FC<Props> = ({
style, style,
items, items,
value, value,
header,
classNamePrefix = 'privacy-dropdown', classNamePrefix = 'privacy-dropdown',
onClose, onClose,
onChange, onChange,
@ -142,42 +144,45 @@ export const DropdownSelector: React.FC<Props> = ({
}, [onClose]); }, [onClose]);
return ( return (
<ul style={style} role='listbox' ref={listRef}> <>
{items.map((item) => ( {header}
<li <ul style={style} role='listbox' ref={listRef}>
role='option' {items.map((item) => (
tabIndex={0} <li
key={item.value} role='option'
data-index={item.value} tabIndex={0}
onKeyDown={handleKeyDown} key={item.value}
onClick={handleClick} data-index={item.value}
className={classNames(`${classNamePrefix}__option`, { onKeyDown={handleKeyDown}
active: item.value === currentValue, onClick={handleClick}
})} className={classNames(`${classNamePrefix}__option`, {
aria-selected={item.value === currentValue} active: item.value === currentValue,
ref={item.value === currentValue ? focusedItemRef : null} })}
> aria-selected={item.value === currentValue}
{item.icon && item.iconComponent && ( ref={item.value === currentValue ? focusedItemRef : null}
<div className={`${classNamePrefix}__option__icon`}> >
<Icon id={item.icon} icon={item.iconComponent} /> {item.icon && item.iconComponent && (
</div> <div className={`${classNamePrefix}__option__icon`}>
)} <Icon id={item.icon} icon={item.iconComponent} />
</div>
)}
<div className={`${classNamePrefix}__option__content`}> <div className={`${classNamePrefix}__option__content`}>
<strong>{item.text}</strong> <strong>{item.text}</strong>
{item.meta} {item.meta}
</div>
{item.extra && (
<div
className={`${classNamePrefix}__option__additional`}
title={item.extra}
>
<Icon id='info-circle' icon={InfoIcon} />
</div> </div>
)}
</li> {item.extra && (
))} <div
</ul> className={`${classNamePrefix}__option__additional`}
title={item.extra}
>
<Icon id='info-circle' icon={InfoIcon} />
</div>
)}
</li>
))}
</ul>
</>
); );
}; };

View File

@ -1,10 +1,14 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import type { IntlShape } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import Overlay from 'react-overlays/esm/Overlay';
import type { Placement } from 'react-overlays/esm/usePopper';
import { import {
changeComposeVisibility, changeComposeVisibility,
setComposeQuotePolicy, setComposeQuotePolicy,
@ -12,9 +16,11 @@ import {
import { openModal } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes'; import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import type { StatusVisibility } from '@/mastodon/api_types/statuses'; import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { DropdownSelector } from '@/mastodon/components/dropdown_selector';
import { Icon } from '@/mastodon/components/icon'; import { Icon } from '@/mastodon/components/icon';
import { useAppSelector, useAppDispatch } from '@/mastodon/store'; import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import ArrowDropDown from '@/material-icons/400-24px/arrow_drop_down.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react'; import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
@ -73,6 +79,175 @@ const visibilityOptions = {
}, },
}; };
const shortMessageFromSettings = (
intl: IntlShape,
visibility: StatusVisibility,
quotePolicy: ApiQuotePolicy,
) => {
const visibilityText = intl.formatMessage(visibilityOptions[visibility].text);
if (visibility === 'private' || visibility === 'direct') {
return visibilityText;
}
if (quotePolicy === 'nobody') {
return intl.formatMessage(messages.disabled_quote, {
visibility: visibilityText,
});
}
if (quotePolicy !== 'public') {
return intl.formatMessage(messages.limited_quote, {
visibility: visibilityText,
});
}
return intl.formatMessage(messages.anyone_quote, {
visibility: visibilityText,
});
};
const PrivacyPresetsDropdown: FC<PrivacyDropdownProps> = ({
disabled = false,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const targetRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState<boolean>(false);
const [placement, setPlacement] = useState<Placement>('bottom');
const visibility = useAppSelector(
(state) => state.compose.get('privacy') as StatusVisibility,
);
const defaultQuotePolicy = useAppSelector(
(state) => state.compose.get('default_quote_policy') as ApiQuotePolicy,
);
const handleOverlayEnter = useCallback(
({ placement }: { placement?: Placement }) => {
if (placement) setPlacement(placement);
},
[setPlacement],
);
const handleToggle = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
const handlePresetChange = useCallback(
(value: string) => {
switch (value) {
case 'public':
dispatch(changeComposeVisibility('public'));
dispatch(setComposeQuotePolicy(defaultQuotePolicy));
break;
case 'unlisted':
dispatch(changeComposeVisibility('unlisted'));
dispatch(
setComposeQuotePolicy(
defaultQuotePolicy === 'nobody' ? 'nobody' : 'followers',
),
);
break;
case 'private':
dispatch(changeComposeVisibility('private'));
dispatch(setComposeQuotePolicy('nobody'));
break;
case 'direct':
dispatch(changeComposeVisibility('direct'));
dispatch(setComposeQuotePolicy('nobody'));
break;
}
},
[dispatch, defaultQuotePolicy],
);
const options = useMemo(
() => [
{
icon: 'globe',
iconComponent: PublicIcon,
value: 'public',
text: shortMessageFromSettings(intl, 'public', defaultQuotePolicy),
meta: 'Anyone and on off Mastodon',
},
{
icon: 'unlock',
iconComponent: QuietTimeIcon,
value: 'unlisted',
text: shortMessageFromSettings(
intl,
'unlisted',
defaultQuotePolicy === 'nobody' ? 'nobody' : 'followers',
),
meta: 'Hidden from Mastodon search results, trending, and public timelines',
},
{
icon: 'lock',
iconComponent: LockIcon,
value: 'private',
text: shortMessageFromSettings(intl, 'private', 'nobody'),
meta: 'Only your followers',
},
{
icon: 'at',
iconComponent: AlternateEmailIcon,
value: 'direct',
text: shortMessageFromSettings(intl, 'direct', 'nobody'),
meta: 'Everyone mentioned in the post',
},
],
[intl, defaultQuotePolicy],
);
return (
<>
<button
type='button'
className={classNames('dropdown-button', { active: open })}
onClick={handleToggle}
disabled={disabled}
ref={targetRef}
>
<Icon id='down-arrow' icon={ArrowDropDown} />
</button>
<Overlay
show={open}
offset={[5, 5]}
placement={placement}
flip
target={targetRef}
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
>
{({ props, placement }) => (
<div {...props}>
<div
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
>
<DropdownSelector
header={
<h4>
<FormattedMessage
id='privacy.quick_settings_presets'
defaultMessage='Quick settings presets'
/>
</h4>
}
items={options}
value={visibility}
onClose={handleClose}
onChange={handlePresetChange}
/>
</div>
</div>
)}
</Overlay>
</>
);
};
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => { const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
const intl = useIntl(); const intl = useIntl();
@ -88,25 +263,7 @@ const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
return { icon: option.icon, iconComponent: option.iconComponent }; return { icon: option.icon, iconComponent: option.iconComponent };
}, [visibility]); }, [visibility]);
const text = useMemo(() => { const text = useMemo(() => {
const visibilityText = intl.formatMessage( return shortMessageFromSettings(intl, visibility, quotePolicy);
visibilityOptions[visibility].text,
);
if (visibility === 'private' || visibility === 'direct') {
return visibilityText;
}
if (quotePolicy === 'nobody') {
return intl.formatMessage(messages.disabled_quote, {
visibility: visibilityText,
});
}
if (quotePolicy !== 'public') {
return intl.formatMessage(messages.limited_quote, {
visibility: visibilityText,
});
}
return intl.formatMessage(messages.anyone_quote, {
visibility: visibilityText,
});
}, [quotePolicy, visibility, intl]); }, [quotePolicy, visibility, intl]);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -133,15 +290,18 @@ const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
}, [dispatch, handleChange]); }, [dispatch, handleChange]);
return ( return (
<button <div className='split-button'>
type='button' <button
title={intl.formatMessage(privacyMessages.change_privacy)} type='button'
onClick={handleOpen} title={intl.formatMessage(privacyMessages.change_privacy)}
disabled={disabled} onClick={handleOpen}
className={classNames('dropdown-button')} disabled={disabled}
> className={classNames('dropdown-button')}
<Icon id={icon} icon={iconComponent} /> >
<span className='dropdown-button__label'>{text}</span> <Icon id={icon} icon={iconComponent} />
</button> <span className='dropdown-button__label'>{text}</span>
</button>
<PrivacyPresetsDropdown disabled={disabled} />
</div>
); );
}; };

View File

@ -755,6 +755,7 @@
"privacy.private.short": "Followers", "privacy.private.short": "Followers",
"privacy.public.long": "Anyone on and off Mastodon", "privacy.public.long": "Anyone on and off Mastodon",
"privacy.public.short": "Public", "privacy.public.short": "Public",
"privacy.quick_settings_presets": "Quick settings presets",
"privacy.quote.anyone": "{visibility}, anyone can quote", "privacy.quote.anyone": "{visibility}, anyone can quote",
"privacy.quote.disabled": "{visibility}, quotes disabled", "privacy.quote.disabled": "{visibility}, quotes disabled",
"privacy.quote.limited": "{visibility}, quotes limited", "privacy.quote.limited": "{visibility}, quotes limited",

View File

@ -1074,6 +1074,25 @@
} }
} }
.split-button .dropdown-button {
&:first-child {
border-start-end-radius: 0;
border-end-end-radius: 0;
border-inline-end: none;
}
&:last-child {
border-start-start-radius: 0;
border-end-start-radius: 0;
padding: 0;
.icon {
width: unset;
height: unset;
}
}
}
.character-counter { .character-counter {
cursor: default; cursor: default;
font-family: $font-sans-serif, sans-serif; font-family: $font-sans-serif, sans-serif;
@ -5603,6 +5622,17 @@ a.status-card {
z-index: 9999; z-index: 9999;
} }
.privacy-dropdown__dropdown {
h4 {
--dropdown-text-color: $primary-text-color;
font-size: 14px;
font-weight: 500;
padding: 8px 12px;
color: var(--dropdown-text-color);
}
}
.privacy-dropdown__option, .privacy-dropdown__option,
.visibility-dropdown__option { .visibility-dropdown__option {
--dropdown-text-color: $primary-text-color; --dropdown-text-color: $primary-text-color;