diff --git a/app/javascript/flavours/glitch/components/list_item/index.tsx b/app/javascript/flavours/glitch/components/list_item/index.tsx new file mode 100644 index 0000000000..66758bd4de --- /dev/null +++ b/app/javascript/flavours/glitch/components/list_item/index.tsx @@ -0,0 +1,82 @@ +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; + +import classes from './styles.module.scss'; + +interface WrapperProps extends Omit< + React.ComponentPropsWithoutRef<'div'>, + 'title' +> { + icon?: React.ReactNode; + iconEnd?: React.ReactNode; +} + +/** + * A basic list item component that can be used as a base for more bespoke list items. + * + * Depending on functionality, use `ListItemButton` or `ListItemLink` as a child of the + * wrapper component. + */ +export const ListItemWrapper: React.FC = ({ + icon, + iconEnd, + children, + className, + ...otherProps +}) => { + return ( +
+ {icon} +
{children}
+ {iconEnd && {iconEnd}} +
+ ); +}; + +interface LinkProps extends React.ComponentPropsWithoutRef { + subtitle?: React.ReactNode; +} + +export const ListItemLink: React.FC = ({ + subtitle, + children, + className, + ...otherProps +}) => { + return ( + <> +

+ + {children} + +

+ {subtitle &&
{subtitle}
} + + ); +}; + +interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> { + subtitle?: React.ReactNode; +} + +export const ListItemButton: React.FC = ({ + subtitle, + children, + className, + ...otherProps +}) => { + return ( + <> +

+ +

+ {subtitle &&
{subtitle}
} + + ); +}; diff --git a/app/javascript/flavours/glitch/components/list_item/list_item.stories.tsx b/app/javascript/flavours/glitch/components/list_item/list_item.stories.tsx new file mode 100644 index 0000000000..d46102c1c3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/list_item/list_item.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; +import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; +import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react'; +import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react'; + +import { Icon } from '../icon'; + +import { ListItemWrapper, ListItemButton, ListItemLink } from './index'; + +const meta = { + title: 'Components/ListItem', + component: ListItemWrapper, + subcomponents: { ListItemButton, ListItemLink }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const WithButton: Story = { + render: () => ( + } + iconEnd={} + > + + 3 hidden accounts + + + ), +}; + +export const WithLink: Story = { + render: () => ( + } + iconEnd={} + > + View more + + ), +}; diff --git a/app/javascript/flavours/glitch/components/list_item/styles.module.scss b/app/javascript/flavours/glitch/components/list_item/styles.module.scss new file mode 100644 index 0000000000..42542d5a22 --- /dev/null +++ b/app/javascript/flavours/glitch/components/list_item/styles.module.scss @@ -0,0 +1,44 @@ +.wrapper { + box-sizing: border-box; + position: relative; + display: flex; + align-items: center; + width: 100%; + gap: 12px; + padding: 16px; + font-size: 15px; + color: var(--color-text-primary); +} + +.title { + font-weight: 500; + + :any-link, + button { + padding: 0; + border: none; + color: inherit; + background: none; + font: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &::before { + content: ''; + position: absolute; + inset: 0; + } + } +} + +.subtitle { + font-size: 13px; + color: var(--color-text-secondary); +} + +.iconEnd { + margin-inline-start: auto; +} diff --git a/app/javascript/flavours/glitch/features/collections/detail/accounts_list.tsx b/app/javascript/flavours/glitch/features/collections/detail/accounts_list.tsx index 1c76bfb5f6..aa3f618e4d 100644 --- a/app/javascript/flavours/glitch/features/collections/detail/accounts_list.tsx +++ b/app/javascript/flavours/glitch/features/collections/detail/accounts_list.tsx @@ -1,7 +1,15 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { + ListItemButton, + ListItemWrapper, +} from '@/flavours/glitch/components/list_item'; +import { createAppSelector, useAppSelector } from '@/flavours/glitch/store'; +import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; +import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react'; +import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react'; import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections'; import type { RenderButtonOptions } from 'flavours/glitch/components/account_list_item'; import { @@ -10,11 +18,13 @@ import { } from 'flavours/glitch/components/account_list_item'; import { Button } from 'flavours/glitch/components/button'; import { Callout } from 'flavours/glitch/components/callout'; +import { Icon } from 'flavours/glitch/components/icon'; import { Article, ItemList, } from 'flavours/glitch/components/scrollable_list/components'; import { me } from 'flavours/glitch/initial_state'; +import type { Account } from 'flavours/glitch/models/account'; import { useConfirmRevoke } from './revoke_collection_inclusion_modal'; import classes from './styles.module.scss'; @@ -70,6 +80,18 @@ const SensitiveScreen: React.FC<{ ); }; +const getCollectionAccounts = createAppSelector( + [ + (state) => state.accounts, + (state, collectionId?: string) => + state.collections.collections[collectionId ?? '']?.items, + ], + (accounts, collectionAccountItems) => + (collectionAccountItems ?? []).map(({ account_id }) => + account_id ? accounts.get(account_id) : null, + ), +); + export const CollectionAccountsList: React.FC<{ collection?: ApiCollectionJSON; isLoading: boolean; @@ -77,9 +99,44 @@ export const CollectionAccountsList: React.FC<{ const intl = useIntl(); const confirmRevoke = useConfirmRevoke(collection); const listHeadingRef = useRef(null); + const [canShowHiddenAccounts, setCanShowHiddenAccounts] = useState(false); + const toggleHiddenAccounts = useCallback(() => { + setCanShowHiddenAccounts((prev) => !prev); + }, []); const isOwnCollection = collection?.account_id === me; - const { items = [], account_id: collectionOwnerId } = collection ?? {}; + const { items = [], account_id: collectionOwnerId, id } = collection ?? {}; + + const relationships = useAppSelector((state) => state.relationships); + const collectionAccounts = useAppSelector((state) => + getCollectionAccounts(state, id), + ); + + const { visibleAccounts, hiddenAccounts } = useMemo(() => { + const visibleAccounts: Account[] = []; + const hiddenAccounts: Account[] = []; + + collectionAccounts.forEach((item) => { + if (!item) { + // We currently simply hide unavailable accounts, this includes + // accounts that are pending inclusion; at least for the collection + // owner we should display an indication of pending users + return; + } + + const relationship = relationships.get(item.id); + if (relationship?.blocking || relationship?.muting) { + hiddenAccounts.push(item); + } else { + visibleAccounts.push(item); + } + }); + + return { visibleAccounts, hiddenAccounts }; + }, [collectionAccounts, relationships]); + + const hasHiddenAccounts = hiddenAccounts.length > 0; + const initialListSize = visibleAccounts.length + (hasHiddenAccounts ? 1 : 0); const renderAccountItemButton = useCallback( ({ relationship, accountId }: RenderButtonOptions) => { @@ -137,19 +194,71 @@ export const CollectionAccountsList: React.FC<{ isLoading={isLoading} emptyMessage={intl.formatMessage(messages.empty)} > - {items.map(({ account_id }, index) => ( + {visibleAccounts.map(({ id }, index) => (
))} + {hasHiddenAccounts && ( +
+ } + iconEnd={ + + } + > + + } + > + + + +
+ )} + {canShowHiddenAccounts && + hiddenAccounts.map(({ id }, index) => ( +
+ +
+ ))} )}