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;
style?: React.CSSProperties;
items: SelectItem[];
header?: React.ReactNode;
onChange: (value: string) => void;
onClose: () => void;
}
@ -35,6 +36,7 @@ export const DropdownSelector: React.FC<Props> = ({
style,
items,
value,
header,
classNamePrefix = 'privacy-dropdown',
onClose,
onChange,
@ -142,42 +144,45 @@ export const DropdownSelector: React.FC<Props> = ({
}, [onClose]);
return (
<ul style={style} role='listbox' ref={listRef}>
{items.map((item) => (
<li
role='option'
tabIndex={0}
key={item.value}
data-index={item.value}
onKeyDown={handleKeyDown}
onClick={handleClick}
className={classNames(`${classNamePrefix}__option`, {
active: item.value === currentValue,
})}
aria-selected={item.value === currentValue}
ref={item.value === currentValue ? focusedItemRef : null}
>
{item.icon && item.iconComponent && (
<div className={`${classNamePrefix}__option__icon`}>
<Icon id={item.icon} icon={item.iconComponent} />
</div>
)}
<>
{header}
<ul style={style} role='listbox' ref={listRef}>
{items.map((item) => (
<li
role='option'
tabIndex={0}
key={item.value}
data-index={item.value}
onKeyDown={handleKeyDown}
onClick={handleClick}
className={classNames(`${classNamePrefix}__option`, {
active: item.value === currentValue,
})}
aria-selected={item.value === currentValue}
ref={item.value === currentValue ? focusedItemRef : null}
>
{item.icon && item.iconComponent && (
<div className={`${classNamePrefix}__option__icon`}>
<Icon id={item.icon} icon={item.iconComponent} />
</div>
)}
<div className={`${classNamePrefix}__option__content`}>
<strong>{item.text}</strong>
{item.meta}
</div>
{item.extra && (
<div
className={`${classNamePrefix}__option__additional`}
title={item.extra}
>
<Icon id='info-circle' icon={InfoIcon} />
<div className={`${classNamePrefix}__option__content`}>
<strong>{item.text}</strong>
{item.meta}
</div>
)}
</li>
))}
</ul>
{item.extra && (
<div
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 { defineMessages, useIntl } from 'react-intl';
import type { IntlShape } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/esm/Overlay';
import type { Placement } from 'react-overlays/esm/usePopper';
import {
changeComposeVisibility,
setComposeQuotePolicy,
@ -12,9 +16,11 @@ import {
import { openModal } from '@/mastodon/actions/modal';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { DropdownSelector } from '@/mastodon/components/dropdown_selector';
import { Icon } from '@/mastodon/components/icon';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
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 PublicIcon from '@/material-icons/400-24px/public.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 intl = useIntl();
@ -88,25 +263,7 @@ const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
return { icon: option.icon, iconComponent: option.iconComponent };
}, [visibility]);
const text = useMemo(() => {
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,
});
return shortMessageFromSettings(intl, visibility, quotePolicy);
}, [quotePolicy, visibility, intl]);
const dispatch = useAppDispatch();
@ -133,15 +290,18 @@ const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
}, [dispatch, handleChange]);
return (
<button
type='button'
title={intl.formatMessage(privacyMessages.change_privacy)}
onClick={handleOpen}
disabled={disabled}
className={classNames('dropdown-button')}
>
<Icon id={icon} icon={iconComponent} />
<span className='dropdown-button__label'>{text}</span>
</button>
<div className='split-button'>
<button
type='button'
title={intl.formatMessage(privacyMessages.change_privacy)}
onClick={handleOpen}
disabled={disabled}
className={classNames('dropdown-button')}
>
<Icon id={icon} icon={iconComponent} />
<span className='dropdown-button__label'>{text}</span>
</button>
<PrivacyPresetsDropdown disabled={disabled} />
</div>
);
};

View File

@ -755,6 +755,7 @@
"privacy.private.short": "Followers",
"privacy.public.long": "Anyone on and off Mastodon",
"privacy.public.short": "Public",
"privacy.quick_settings_presets": "Quick settings presets",
"privacy.quote.anyone": "{visibility}, anyone can quote",
"privacy.quote.disabled": "{visibility}, quotes disabled",
"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 {
cursor: default;
font-family: $font-sans-serif, sans-serif;
@ -5603,6 +5622,17 @@ a.status-card {
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,
.visibility-dropdown__option {
--dropdown-text-color: $primary-text-color;