Composer redesign: Visibility menu (#40008)

This commit is contained in:
Echo
2026-08-10 18:22:00 +02:00
committed by GitHub
parent 0c593ca15f
commit a09a498dda
8 changed files with 235 additions and 86 deletions

View File

@@ -69,6 +69,11 @@
width: 100%;
}
.menuItemIcon {
width: var(--space-lg);
height: var(--space-lg);
}
.menu > hr {
height: 1px;
background-color: var(--color-border-primary);

View File

@@ -13,7 +13,6 @@ import { useToggle } from '@/mastodon/hooks/useToggle';
import { Button } from '../button/redesign';
import { ToggleField } from '../form_fields/redesign';
import { Icon } from '../icon';
import type { DropdownProps } from './redesign';
import {
@@ -47,13 +46,13 @@ export const Simple: Story = {
return (
<Dropdown {...args}>
<DropdownItemButton
icon={NumberCircleOneIcon}
leadingIcon={NumberCircleOneIcon}
onClick={handleMenuItemClick}
>
First item
</DropdownItemButton>
<DropdownItemButton
icon={NumberCircleTwoIcon}
leadingIcon={NumberCircleTwoIcon}
onClick={handleMenuItemClick}
>
Second item
@@ -81,13 +80,13 @@ export const Popover: Story = {
{...args}
>
<DropdownItemButton
icon={NumberCircleOneIcon}
leadingIcon={NumberCircleOneIcon}
onClick={handleMenuItemClick}
>
First item
</DropdownItemButton>
<DropdownItemButton
icon={NumberCircleTwoIcon}
leadingIcon={NumberCircleTwoIcon}
onClick={handleMenuItemClick}
>
Second item
@@ -109,12 +108,7 @@ export const Controls: Story = {
<hr />
<DropdownItem>
{sun ? (
<Icon id='sun' icon={SunIcon} />
) : (
<Icon id='moon' icon={MoonIcon} />
)}
<DropdownItem leadingIcon={sun ? SunIcon : MoonIcon}>
<ToggleField label='Daytime toggle' size='sm' onChange={onToggle} />
</DropdownItem>
</Dropdown>

View File

@@ -90,7 +90,15 @@ export const Dropdown = <As extends React.ElementType>({
};
type DropdownItemProps<As extends React.ElementType> = Merge<
{ as?: As; children?: React.ReactNode; className?: string; active?: boolean },
{
as?: As;
children?: React.ReactNode;
className?: string;
active?: boolean;
leadingIcon?: IconProp;
trailingIcon?: IconProp;
iconClassName?: string;
},
React.ComponentPropsWithoutRef<As>
>;
@@ -99,6 +107,9 @@ export const DropdownItem = <As extends React.ElementType>({
as: AsComp,
children,
className,
leadingIcon,
trailingIcon,
iconClassName,
...props
}: DropdownItemProps<As>) => {
const Component = AsComp ?? 'div';
@@ -111,14 +122,30 @@ export const DropdownItem = <As extends React.ElementType>({
active && classes.menuItemActive,
)}
>
{leadingIcon && (
<Icon
id='menu'
icon={leadingIcon}
className={classNames(iconClassName, classes.menuItemIcon)}
/>
)}
{children}
{trailingIcon && (
<Icon
id='menu'
icon={trailingIcon}
className={classNames(iconClassName, classes.menuItemIcon)}
/>
)}
</Component>
);
};
export const DropdownItemButton: React.FC<
{ icon?: IconProp } & React.ComponentProps<'button'>
> = ({ icon, children, className, ...props }) => {
Omit<DropdownItemProps<'button'>, 'as'>
> = ({ children, className, ...props }) => {
return (
<DropdownItem
type='button'
@@ -126,7 +153,6 @@ export const DropdownItemButton: React.FC<
as='button'
className={classNames(className, classes.menuItemButton)}
>
{icon && <Icon id='menu' icon={icon} />}
{children}
</DropdownItem>
);

View File

@@ -28,8 +28,8 @@
}
.icon {
width: var(--fs-lg);
height: var(--fs-lg);
width: var(--space-lg);
height: var(--space-lg);
}
// Text Input

View File

@@ -48,6 +48,12 @@
}
}
.dropdownItemControl {
color: inherit;
width: 100%;
user-select: none;
}
.textarea > textarea {
@include mixins.type-body-lg;

View File

@@ -111,7 +111,7 @@ export const ComposeUpload: React.FC<{ id?: string; className?: string }> = ({
offset={4}
maxWidth={170}
>
<DropdownItemButton onClick={handleEdit} icon={PlusIcon}>
<DropdownItemButton onClick={handleEdit} leadingIcon={PlusIcon}>
<FormattedMessage
id='compose.upload.menu.add_alt'
defaultMessage='Add alt text'
@@ -123,7 +123,7 @@ export const ComposeUpload: React.FC<{ id?: string; className?: string }> = ({
<DropdownItemButton
className={classes.mediaMenuDelete}
onClick={handleDelete}
icon={TrashIcon}
leadingIcon={TrashIcon}
>
<FormattedMessage
id='compose.upload.menu.delete'

View File

@@ -1,9 +1,14 @@
import type React from 'react';
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { ChatCircleIcon } from '@phosphor-icons/react';
import {
ChatCircleIcon,
CheckIcon,
MagnifyingGlassIcon,
QuotesIcon,
} from '@phosphor-icons/react';
import {
changeComposeVisibility,
@@ -17,8 +22,12 @@ import {
DropdownItem,
DropdownItemButton,
} from '@/mastodon/components/dropdown/redesign';
import { Fieldset, RadioButtonField } from '@/mastodon/components/form_fields';
import { ToggleField } from '@/mastodon/components/form_fields/redesign';
import { Fieldset } from '@/mastodon/components/form_fields';
import {
ToggleField,
RadioButtonField,
} from '@/mastodon/components/form_fields/redesign';
import type { IconProp } from '@/mastodon/components/icon';
import { Popover } from '@/mastodon/components/popover';
import { useToggle } from '@/mastodon/hooks/useToggle';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@@ -80,26 +89,28 @@ const ComposeVisibilityMenu: React.FC<Record<string, unknown>> = (
const defaultPrivacy = useAppSelector(
(state) => state.compose.get('default_privacy') as StatusVisibility,
);
const quotePolicy = useAppSelector(
(state) =>
(state.compose.get('quote_policy') as ApiQuotePolicy | undefined) ??
(state.compose.get('default_quote_policy') as ApiQuotePolicy),
const currentQuotePolicy = useAppSelector(
(state) => state.compose.get('quote_policy') as ApiQuotePolicy | undefined,
);
const defaultQuotePolicy = useAppSelector(
(state) => state.compose.get('default_quote_policy') as ApiQuotePolicy,
);
const quotePolicy = currentQuotePolicy ?? defaultQuotePolicy;
const dispatch = useAppDispatch();
const handlePrivacyChange: React.ChangeEventHandler<HTMLInputElement> =
useCallback(
(event) => {
const { name } = event.target;
if (name === 'private' && privacy !== 'private') {
dispatch(changeComposeVisibility(name));
} else if (name === 'public' && privacy === 'private') {
const { value } = event.target;
if (value === 'private' && privacy !== 'private') {
dispatch(changeComposeVisibility(value));
} else if (value === 'public' && privacy === 'private') {
dispatch(
changeComposeVisibility(
defaultPrivacy === 'unlisted' ? 'unlisted' : 'public',
),
);
} else if (name === 'unlisted' && privacy !== 'private') {
} else if (value === 'unlisted' && privacy !== 'private') {
dispatch(
changeComposeVisibility(
privacy === 'public' ? 'unlisted' : 'public',
@@ -112,10 +123,27 @@ const ComposeVisibilityMenu: React.FC<Record<string, unknown>> = (
const handleQuotePolicyChange: React.ChangeEventHandler<HTMLInputElement> =
useCallback(
(event) => {
const checked = event.target.checked;
dispatch(setComposeQuotePolicy(checked ? 'public' : 'nobody'));
const { value, checked } = event.target;
let newQuotePolicy: ApiQuotePolicy = 'nobody';
switch (value) {
case 'public':
newQuotePolicy = 'public';
break;
case 'followers':
newQuotePolicy = 'followers';
break;
case 'others':
// If it's not checked, then it's nobody.
if (checked) {
// Only use the default if it's not nobody, as then it'll never be enabled.
newQuotePolicy =
defaultQuotePolicy !== 'nobody' ? defaultQuotePolicy : 'public';
}
break;
}
dispatch(setComposeQuotePolicy(newQuotePolicy));
},
[dispatch],
[defaultQuotePolicy, dispatch],
);
const handleSwitchToMessage: React.MouseEventHandler<HTMLButtonElement> =
useCallback(() => {
@@ -134,71 +162,99 @@ const ComposeVisibilityMenu: React.FC<Record<string, unknown>> = (
}
className={classes.visibilityFieldset}
>
<DropdownItem>
<RadioButtonField
name='public'
label={
<FormattedMessage
id='privacy.public.short'
defaultMessage='Public'
/>
}
checked={privacy === 'public' || privacy === 'unlisted'}
onChange={handlePrivacyChange}
/>
</DropdownItem>
<DropdownRadioCheckField
name='visibility'
value='public'
checked={privacy === 'public' || privacy === 'unlisted'}
onChange={handlePrivacyChange}
>
<FormattedMessage id='privacy.public.short' defaultMessage='Public' />
</DropdownRadioCheckField>
<DropdownItem>
<RadioButtonField
name='private'
label={
<FormattedMessage
id='privacy.private.short'
defaultMessage='Followers'
/>
}
checked={privacy === 'private'}
onChange={handlePrivacyChange}
<DropdownRadioCheckField
name='visibility'
value='private'
checked={privacy === 'private'}
onChange={handlePrivacyChange}
>
<FormattedMessage
id='privacy.private.short'
defaultMessage='Followers'
/>
</DropdownItem>
</DropdownRadioCheckField>
</Fieldset>
<hr />
<DropdownItem>
<ToggleField
name='unlisted'
label={
<FormattedMessage
id='compose.discoverable'
defaultMessage='Discoverable in public feeds & search results'
/>
}
disabled={privacy === 'private'}
checked={privacy === 'public'}
onChange={handlePrivacyChange}
size='sm'
<DropdownToggleField
value='unlisted'
disabled={privacy === 'private'}
checked={privacy === 'public'}
onChange={handlePrivacyChange}
icon={MagnifyingGlassIcon}
>
<FormattedMessage
id='compose.discoverable'
defaultMessage='Discoverable in public feeds & search results'
/>
</DropdownItem>
</DropdownToggleField>
<DropdownItem>
<ToggleField
label={
<DropdownToggleField
value='others'
disabled={privacy === 'private'}
checked={quotePolicy !== 'nobody' && privacy !== 'private'}
onChange={handleQuotePolicyChange}
icon={QuotesIcon}
>
<FormattedMessage
id='compose.quotable'
defaultMessage='Allow others to quote'
/>
</DropdownToggleField>
{quotePolicy !== 'nobody' && privacy !== 'private' && (
<Fieldset
name='quote_policy'
legend={
<FormattedMessage
id='compose.quotable'
defaultMessage='Allow others to quote'
id='compose.visibility.quote_policy'
defaultMessage='Who can quote'
/>
}
disabled={privacy === 'private'}
checked={quotePolicy === 'public' && privacy !== 'private'}
onChange={handleQuotePolicyChange}
size='sm'
/>
</DropdownItem>
className={classes.visibilityFieldset}
>
<DropdownRadioCheckField
name='quote_policy'
value='public'
checked={quotePolicy === 'public'}
onChange={handleQuotePolicyChange}
>
<FormattedMessage
id='compose.visibility.quote_policy.anyone'
defaultMessage='Anyone'
/>
</DropdownRadioCheckField>
<DropdownRadioCheckField
name='quote_policy'
value='followers'
checked={quotePolicy === 'followers'}
onChange={handleQuotePolicyChange}
>
<FormattedMessage
id='compose.visibility.quote_policy.followers'
defaultMessage='Followers'
/>
</DropdownRadioCheckField>
</Fieldset>
)}
<hr />
<DropdownItemButton icon={ChatCircleIcon} onClick={handleSwitchToMessage}>
<DropdownItemButton
leadingIcon={ChatCircleIcon}
onClick={handleSwitchToMessage}
>
<FormattedMessage
id='compose.post.to_message'
defaultMessage='Compose a message instead'
@@ -207,3 +263,62 @@ const ComposeVisibilityMenu: React.FC<Record<string, unknown>> = (
</Dropdown>
);
};
const DropdownRadioCheckField: React.FC<
Omit<
React.ComponentProps<typeof RadioButtonField>,
'label' | 'icon' | 'iconClassName'
> & {
children: React.ReactNode;
}
> = ({ children, onClick, ...props }) => {
const { ref, onWrapperClick } = useDropdownControl();
return (
<DropdownItem onClick={onWrapperClick}>
<RadioButtonField
{...props}
ref={ref}
label={children}
icon={CheckIcon}
wrapperClassName={classes.dropdownItemControl}
/>
</DropdownItem>
);
};
const DropdownToggleField: React.FC<
Omit<React.ComponentProps<typeof ToggleField>, 'label'> & {
children: React.ReactNode;
icon?: IconProp;
}
> = ({ children, icon, ...props }) => {
const { ref, onWrapperClick } = useDropdownControl();
return (
<DropdownItem onClick={onWrapperClick} leadingIcon={icon}>
<ToggleField
size='sm'
{...props}
ref={ref}
label={children}
wrapperClassName={classes.dropdownItemControl}
/>
</DropdownItem>
);
};
function useDropdownControl() {
const ref = useRef<HTMLInputElement | null>(null);
const onWrapperClick: React.MouseEventHandler = useCallback((event) => {
const { target } = event;
if (
target instanceof HTMLLabelElement ||
target instanceof HTMLInputElement
) {
return;
}
ref.current?.click();
}, []);
return { ref, onWrapperClick };
}

View File

@@ -518,6 +518,9 @@
"compose.upload.menu": "Add alt text or remove the image",
"compose.upload.menu.add_alt": "Add alt text",
"compose.upload.menu.delete": "Remove image",
"compose.visibility.quote_policy": "Who can quote",
"compose.visibility.quote_policy.anyone": "Anyone",
"compose.visibility.quote_policy.followers": "Followers",
"compose.visibility.title": "Visibility",
"compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.",