From 70e4e032d359c2e912f8afdca12878c0a87c9014 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 10 Nov 2025 15:58:40 +0100 Subject: [PATCH] Add privacy presets in composer --- .../mastodon/components/dropdown_selector.tsx | 75 +++--- .../compose/components/visibility_button.tsx | 222 +++++++++++++++--- app/javascript/mastodon/locales/en.json | 1 + .../styles/mastodon/components.scss | 30 +++ 4 files changed, 262 insertions(+), 66 deletions(-) diff --git a/app/javascript/mastodon/components/dropdown_selector.tsx b/app/javascript/mastodon/components/dropdown_selector.tsx index 8b8fea42b14..48a7fc22a7f 100644 --- a/app/javascript/mastodon/components/dropdown_selector.tsx +++ b/app/javascript/mastodon/components/dropdown_selector.tsx @@ -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 = ({ style, items, value, + header, classNamePrefix = 'privacy-dropdown', onClose, onChange, @@ -142,42 +144,45 @@ export const DropdownSelector: React.FC = ({ }, [onClose]); return ( -
    - {items.map((item) => ( -
  • - {item.icon && item.iconComponent && ( -
    - -
    - )} + <> + {header} +
      + {items.map((item) => ( +
    • + {item.icon && item.iconComponent && ( +
      + +
      + )} -
      - {item.text} - {item.meta} -
      - - {item.extra && ( -
      - +
      + {item.text} + {item.meta}
      - )} -
    • - ))} -
    + + {item.extra && ( +
    + +
    + )} +
  • + ))} +
+ ); }; diff --git a/app/javascript/mastodon/features/compose/components/visibility_button.tsx b/app/javascript/mastodon/features/compose/components/visibility_button.tsx index d9394050207..468f59fe2d8 100644 --- a/app/javascript/mastodon/features/compose/components/visibility_button.tsx +++ b/app/javascript/mastodon/features/compose/components/visibility_button.tsx @@ -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 = ({ + disabled = false, +}) => { + const intl = useIntl(); + + const dispatch = useAppDispatch(); + + const targetRef = useRef(null); + const [open, setOpen] = useState(false); + const [placement, setPlacement] = useState('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 ( + <> + + + {({ props, placement }) => ( +
+
+ + + + } + items={options} + value={visibility} + onClose={handleClose} + onChange={handlePresetChange} + /> +
+
+ )} +
+ + ); +}; + const PrivacyModalButton: FC = ({ disabled = false }) => { const intl = useIntl(); @@ -88,25 +263,7 @@ const PrivacyModalButton: FC = ({ 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 = ({ disabled = false }) => { }, [dispatch, handleChange]); return ( - +
+ + +
); }; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 07f80c3a51a..241b85d99bf 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 5748e3c6051..838f9a98b81 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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;