From 4841c5b0a2f71a32a3b712f96bd62904cf6d3a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 3 Sep 2024 15:18:58 +0200 Subject: [PATCH] Allow addressing posts to lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../dropdown-menu/dropdown-menu-item.tsx | 16 +++- .../dropdown-menu/dropdown-menu.tsx | 90 +++++++++++++----- packages/pl-fe/src/components/icon-button.tsx | 2 - .../pl-fe/src/components/ui/icon/icon.tsx | 7 +- .../compose/components/privacy-dropdown.tsx | 94 ++++++++++++++++--- packages/pl-fe/src/features/lists/index.tsx | 2 +- .../modals/list-adder-modal/index.tsx | 14 +-- packages/pl-fe/src/locales/en.json | 2 + 8 files changed, 168 insertions(+), 59 deletions(-) diff --git a/packages/pl-fe/src/components/dropdown-menu/dropdown-menu-item.tsx b/packages/pl-fe/src/components/dropdown-menu/dropdown-menu-item.tsx index 3df69a4d6..8503b5c01 100644 --- a/packages/pl-fe/src/components/dropdown-menu/dropdown-menu-item.tsx +++ b/packages/pl-fe/src/components/dropdown-menu/dropdown-menu-item.tsx @@ -21,6 +21,7 @@ interface MenuItem { text: string; to?: string; type?: 'toggle'; + items?: Array>; } interface IDropdownMenuItem { @@ -28,9 +29,10 @@ interface IDropdownMenuItem { item: MenuItem | null; onClick?(goBack?: boolean): void; autoFocus?: boolean; + onSetTab: (tab?: number) => void; } -const DropdownMenuItem = ({ index, item, onClick, autoFocus }: IDropdownMenuItem) => { +const DropdownMenuItem = ({ index, item, onClick, autoFocus, onSetTab }: IDropdownMenuItem) => { const history = useHistory(); const itemRef = useRef(null); @@ -40,6 +42,12 @@ const DropdownMenuItem = ({ index, item, onClick, autoFocus }: IDropdownMenuItem if (!item) return; + if (item.items?.length) { + event.preventDefault(); + onSetTab(index); + return; + } + if (onClick) onClick(!(item.to && userTouching.matches)); if (item.to) { @@ -112,7 +120,7 @@ const DropdownMenuItem = ({ index, item, onClick, autoFocus }: IDropdownMenuItem > {item.icon && } -
+
{item.text}
{item.meta}
@@ -128,6 +136,10 @@ const DropdownMenuItem = ({ index, item, onClick, autoFocus }: IDropdownMenuItem
)} + + {!!item.items?.length && ( + + )} ); diff --git a/packages/pl-fe/src/components/dropdown-menu/dropdown-menu.tsx b/packages/pl-fe/src/components/dropdown-menu/dropdown-menu.tsx index 229f62fcc..d9e805ed4 100644 --- a/packages/pl-fe/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/pl-fe/src/components/dropdown-menu/dropdown-menu.tsx @@ -4,12 +4,13 @@ import { supportsPassiveEvents } from 'detect-passive-events'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { useHistory } from 'react-router-dom'; +import ReactSwipeableViews from 'react-swipeable-views'; import { closeDropdownMenu as closeDropdownMenuRedux, openDropdownMenu } from 'pl-fe/actions/dropdown-menu'; import { useAppDispatch } from 'pl-fe/hooks'; import { userTouching } from 'pl-fe/is-mobile'; -import { IconButton, Portal } from '../ui'; +import { HStack, IconButton, Portal } from '../ui'; import DropdownMenuItem, { MenuItem } from './dropdown-menu-item'; @@ -49,6 +50,7 @@ const DropdownMenu = (props: IDropdownMenu) => { const [isOpen, setIsOpen] = useState(false); const [isDisplayed, setIsDisplayed] = useState(false); + const [tab, setTab] = useState(); const touching = userTouching.matches; @@ -92,6 +94,7 @@ const DropdownMenu = (props: IDropdownMenu) => { const handleOpen = () => { dispatch(openDropdownMenu()); setIsOpen(true); + setTab(undefined); if (onOpen) { onOpen(); @@ -147,6 +150,11 @@ const DropdownMenu = (props: IDropdownMenu) => { } }, [refs.floating.current]); + const handleExitSubmenu: React.EventHandler = (event) => { + event.stopPropagation(); + setTab(undefined); + }; + const handleKeyDown = useMemo(() => (e: KeyboardEvent) => { if (!refs.floating.current) return; @@ -156,6 +164,9 @@ const DropdownMenu = (props: IDropdownMenu) => { let element = null; switch (e.key) { + case 'ArrowLeft': + if (tab !== undefined) setTab(undefined); + break; case 'ArrowDown': element = items[index + 1] || items[0]; break; @@ -280,6 +291,21 @@ const DropdownMenu = (props: IDropdownMenu) => { return className; }; + const renderItems = (items: Menu | undefined) => ( +
    + {items?.map((item, idx) => ( + + ))} +
+ ); + return ( <> {children ? ( @@ -325,29 +351,45 @@ const DropdownMenu = (props: IDropdownMenu) => { left: x ?? 0, }} > - {Component && } - {(items?.length || touching) && ( -
    - {items?.map((item, idx) => ( - - ))} - {touching && ( -
  • - -
  • - )} -
+ {items?.some(item => item?.items?.length) ? ( + +
+ {Component && } + {(items?.length || touching) && renderItems(items)} +
+
+ {tab !== undefined && ( + <> + + + {items![tab]?.text} + + {renderItems(items![tab]?.items)} + + )} +
+
+ ) : ( + <> + {Component && } + {(items?.length || touching) && renderItems(items)} + + )} + + {touching && ( +
+ +
)} {/* Arrow */} diff --git a/packages/pl-fe/src/components/icon-button.tsx b/packages/pl-fe/src/components/icon-button.tsx index d249c8a22..87e624dc7 100644 --- a/packages/pl-fe/src/components/icon-button.tsx +++ b/packages/pl-fe/src/components/icon-button.tsx @@ -8,7 +8,6 @@ interface IIconButton extends Pick expanded?: boolean; iconClassName?: string; pressed?: boolean; - size?: number; src: string; text?: React.ReactNode; } @@ -27,7 +26,6 @@ const IconButton: React.FC = ({ onMouseEnter, onMouseLeave, pressed, - size = 18, src, tabIndex = 0, text, diff --git a/packages/pl-fe/src/components/ui/icon/icon.tsx b/packages/pl-fe/src/components/ui/icon/icon.tsx index 8cb45d0d1..e52c5a459 100644 --- a/packages/pl-fe/src/components/ui/icon/icon.tsx +++ b/packages/pl-fe/src/components/ui/icon/icon.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import React from 'react'; import Counter from '../counter/counter'; @@ -7,6 +8,8 @@ import SvgIcon from './svg-icon'; interface IIcon extends Pick, 'strokeWidth'> { /** Class name for the element. */ className?: string; + /** Class name for the
element. */ + containerClassName?: string; /** Number to display a counter over the icon. */ count?: number; /** Optional max to cap count (ie: N+) */ @@ -22,9 +25,9 @@ interface IIcon extends Pick, 'strokeWidth'> { } /** Renders and SVG icon with optional counter. */ -const Icon: React.FC = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => ( +const Icon: React.FC = ({ src, alt, count, size, countMax, containerClassName, ...filteredProps }): JSX.Element => (
{count ? ( diff --git a/packages/pl-fe/src/features/compose/components/privacy-dropdown.tsx b/packages/pl-fe/src/features/compose/components/privacy-dropdown.tsx index 5d910be43..1c0302a8f 100644 --- a/packages/pl-fe/src/features/compose/components/privacy-dropdown.tsx +++ b/packages/pl-fe/src/features/compose/components/privacy-dropdown.tsx @@ -1,10 +1,12 @@ -import React from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useIntl, defineMessages, IntlShape } from 'react-intl'; import { changeComposeFederated, changeComposeVisibility } from 'pl-fe/actions/compose'; +import { fetchLists } from 'pl-fe/actions/lists'; import DropdownMenu, { MenuItem } from 'pl-fe/components/dropdown-menu'; import { Button } from 'pl-fe/components/ui'; -import { useAppDispatch, useCompose, useFeatures } from 'pl-fe/hooks'; +import { getOrderedLists } from 'pl-fe/features/lists'; +import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'pl-fe/hooks'; import type { Features } from 'pl-api'; @@ -21,6 +23,8 @@ const messages = defineMessages({ direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, local_short: { id: 'privacy.local.short', defaultMessage: 'Local-only' }, local_long: { id: 'privacy.local.long', defaultMessage: 'Only visible on your instance' }, + list_short: { id: 'privacy.list.short', defaultMessage: 'List only' }, + list_long: { id: 'privacy.list.long', defaultMessage: 'Visible to members of a list' }, change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust post privacy' }, local: { id: 'privacy.local', defaultMessage: '{privacy} (local-only)' }, @@ -30,16 +34,58 @@ interface Option { icon: string; value: string; text: string; - meta: string; + meta?: string; + items?: Array>; } -const getItems = (features: Features, intl: IntlShape) => [ - { icon: require('@tabler/icons/outline/world.svg'), value: 'public', text: intl.formatMessage(messages.public_short), meta: intl.formatMessage(messages.public_long) }, - { icon: require('@tabler/icons/outline/lock-open.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) }, - { icon: require('@tabler/icons/outline/lock.svg'), value: 'private', text: intl.formatMessage(messages.private_short), meta: intl.formatMessage(messages.private_long) }, - features.visibilityMutualsOnly ? { icon: require('@tabler/icons/outline/users-group.svg'), value: 'mutuals_only', text: intl.formatMessage(messages.mutuals_only_short), meta: intl.formatMessage(messages.mutuals_only_long) } : undefined, - { icon: require('@tabler/icons/outline/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) }, - features.visibilityLocalOnly ? { icon: require('@tabler/icons/outline/affiliate.svg'), value: 'local', text: intl.formatMessage(messages.local_short), meta: intl.formatMessage(messages.local_long) } : undefined, +const getItems = (features: Features, lists: ReturnType, intl: IntlShape) => [ + { + icon: require('@tabler/icons/outline/world.svg'), + value: 'public', + text: intl.formatMessage(messages.public_short), + meta: intl.formatMessage(messages.public_long), + }, + { + icon: require('@tabler/icons/outline/lock-open.svg'), + value: 'unlisted', + text: intl.formatMessage(messages.unlisted_short), + meta: intl.formatMessage(messages.unlisted_long), + }, + { + icon: require('@tabler/icons/outline/lock.svg'), + value: 'private', + text: intl.formatMessage(messages.private_short), + meta: intl.formatMessage(messages.private_long), + }, + features.visibilityMutualsOnly ? { + icon: require('@tabler/icons/outline/users-group.svg'), + value: 'mutuals_only', + text: intl.formatMessage(messages.mutuals_only_short), + meta: intl.formatMessage(messages.mutuals_only_long), + } : undefined, + { + icon: require('@tabler/icons/outline/mail.svg'), + value: 'direct', + text: intl.formatMessage(messages.direct_short), + meta: intl.formatMessage(messages.direct_long), + }, + features.visibilityLocalOnly ? { + icon: require('@tabler/icons/outline/affiliate.svg'), + value: 'local', + text: intl.formatMessage(messages.local_short), + meta: intl.formatMessage(messages.local_long), + } : undefined, + features.addressableLists && !lists.isEmpty() ? { + icon: require('@tabler/icons/outline/list.svg'), + value: '', + items: lists.toArray().map((list) => ({ + icon: require('@tabler/icons/outline/list.svg'), + value: `list:${list.id}`, + text: list.title, + })), + text: intl.formatMessage(messages.list_short), + meta: intl.formatMessage(messages.list_long), + } as Option : undefined, ].filter((option): option is Option => !!option); interface IPrivacyDropdown { @@ -54,14 +100,29 @@ const PrivacyDropdown: React.FC = ({ const dispatch = useAppDispatch(); const compose = useCompose(composeId); + const lists = useAppSelector((state) => getOrderedLists(state)); const value = compose.privacy; const unavailable = compose.id; - const onChange = (value: string) => value && dispatch(changeComposeVisibility(composeId, value)); + const onChange = (value: string) => value && dispatch(changeComposeVisibility(composeId, + value)); - const options = getItems(features, intl); - const items: Array = options.map(item => ({ ...item, action: () => onChange(item.value), active: item.value === value })); + const options = useMemo(() => getItems(features, lists, intl), [features, lists]); + const items: Array = options.map(item => ({ + ...item, + action: item.value ? () => onChange(item.value) : undefined, + active: item.value === value || item.items?.some((item) => item.value === value), + items: item.items?.map(item => ({ + ...item, + action: item.value ? () => onChange(item.value) : undefined, + active: item.value === value, + })), + })); + + useEffect(() => { + if (features.addressableLists) dispatch(fetchLists()); + }, []); if (features.localOnlyStatuses) items.push({ icon: require('@tabler/icons/outline/affiliate.svg'), @@ -72,12 +133,15 @@ const PrivacyDropdown: React.FC = ({ onChange: () => dispatch(changeComposeFederated(composeId)), }); + const valueOption = useMemo(() => [ + options, + options.filter(option => option.items).map(option => option.items).flat(), + ].flat().find(item => item!.value === value), [value]); + if (unavailable) { return null; } - const valueOption = options.find(item => item.value === value); - return (