mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-13 02:36:36 -05:00
Composer redesign: Visibility menu (#40008)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: var(--fs-lg);
|
||||
height: var(--fs-lg);
|
||||
width: var(--space-lg);
|
||||
height: var(--space-lg);
|
||||
}
|
||||
|
||||
// Text Input
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownItemControl {
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.textarea > textarea {
|
||||
@include mixins.type-body-lg;
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user