diff --git a/app/javascript/flavours/glitch/components/truncated_list/index.tsx b/app/javascript/flavours/glitch/components/truncated_list/index.tsx new file mode 100644 index 0000000000..e9cc202037 --- /dev/null +++ b/app/javascript/flavours/glitch/components/truncated_list/index.tsx @@ -0,0 +1,107 @@ +import { useCallback, useState } from 'react'; + +import { Article } from '@/flavours/glitch/components/scrollable_list/components'; +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 { Icon } from '../icon'; +import type { IconProp } from '../icon'; +import { ListItemButton, ListItemWrapper } from '../list_item'; + +export interface TruncatedListItemInfo { + item: TListItem; + index: number; + totalListLength: number; + isLastElement: boolean; +} + +interface ToggleButtonOptions { + title: NonNullable; + subtitle?: React.ReactNode; + icon?: IconProp; +} + +interface TruncatedListProps { + visibleItems: TListItem[]; + truncatedItems: TListItem[]; + renderListItem: ( + itemInfo: TruncatedListItemInfo, + ) => React.ReactElement; + toggleButton: ToggleButtonOptions; +} + +/** + * Truncate the children of an `ItemList` component with this helper + * component. + * It handles rendering the children with correct indexes for accessibility, + * and has a configurable toggle button. + */ +export const TruncatedListItems = ({ + visibleItems, + truncatedItems, + toggleButton, + renderListItem, +}: TruncatedListProps) => { + const [showTruncatedItems, setShowTruncatedItems] = useState(false); + const toggleTruncatedItems = useCallback(() => { + setShowTruncatedItems((prev) => !prev); + }, []); + + const hasHiddenAccounts = truncatedItems.length > 0; + // Add the toggle button's item to the list size when needed + const initialListSize = visibleItems.length + (hasHiddenAccounts ? 1 : 0); + const totalListLength = + initialListSize + (showTruncatedItems ? truncatedItems.length : 0); + + return ( + <> + {visibleItems.map((item, index) => { + return renderListItem({ + item, + index, + totalListLength, + isLastElement: + index === visibleItems.length - 1 && !hasHiddenAccounts, + }); + })} + {hasHiddenAccounts && ( +
+ + ) + } + iconEnd={ + + } + > + + {toggleButton.title} + + +
+ )} + {showTruncatedItems && + truncatedItems.map((item, index) => { + return renderListItem({ + item, + index: initialListSize + index + 1, + totalListLength, + isLastElement: index === truncatedItems.length - 1, + }); + })} + + ); +}; diff --git a/app/javascript/flavours/glitch/features/account_featured/index.tsx b/app/javascript/flavours/glitch/features/account_featured/index.tsx index 463c229168..06e1bf8b8f 100644 --- a/app/javascript/flavours/glitch/features/account_featured/index.tsx +++ b/app/javascript/flavours/glitch/features/account_featured/index.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -6,10 +6,9 @@ import { useHistory } from 'react-router'; import { List as ImmutableList } from 'immutable'; -import { AccountListItem } from '@/flavours/glitch/components/account_list_item'; -import { useAccount } from '@/flavours/glitch/hooks/useAccount'; import AddIcon from '@/material-icons/400-24px/add.svg?react'; import { fetchEndorsedAccounts } from 'flavours/glitch/actions/accounts'; +import { AccountListItem } from 'flavours/glitch/components/account_list_item'; import { ColumnBackButton } from 'flavours/glitch/components/column_back_button'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { RemoteHint } from 'flavours/glitch/components/remote_hint'; @@ -18,9 +17,12 @@ import { ItemList, Scrollable, } from 'flavours/glitch/components/scrollable_list/components'; +import type { TruncatedListItemInfo } from 'flavours/glitch/components/truncated_list'; +import { TruncatedListItems } from 'flavours/glitch/components/truncated_list'; import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header'; import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; import Column from 'flavours/glitch/features/ui/components/column'; +import { useAccount } from 'flavours/glitch/hooks/useAccount'; import { useAccountId } from 'flavours/glitch/hooks/useAccountId'; import { useAccountVisibility } from 'flavours/glitch/hooks/useAccountVisibility'; import { @@ -74,11 +76,32 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ const { collections, status: collectionsLoadStatus } = useAppSelector( (state) => selectAccountCollections(state, accountId ?? null), ); - const listedCollections = collections.filter( - // Hide unlisted and empty collections to avoid confusion - // (Unlisted collections will only be part of the payload - // when viewing your own profile.) - (item) => item.discoverable && !!item.item_count, + + const { listedCollections = [], unlistedCollections = [] } = Object.groupBy( + collections, + (item) => + item.discoverable && !!item.item_count + ? 'listedCollections' + : 'unlistedCollections', + ); + + const renderListItem = useCallback( + ({ + item, + index, + totalListLength, + isLastElement, + }: TruncatedListItemInfo<(typeof listedCollections)[number]>) => ( + + ), + [], ); const hasCollections = @@ -167,16 +190,26 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ {hasCollections ? ( - {listedCollections.map((item, index) => ( - - ))} + + ), + subtitle: ( + + ), + }} + renderListItem={renderListItem} + /> ) : ( (null); - const [canShowHiddenAccounts, setCanShowHiddenAccounts] = useState(false); - const toggleHiddenAccounts = useCallback(() => { - setCanShowHiddenAccounts((prev) => !prev); - }, []); const isOwnCollection = collection?.account_id === me; - const { items = [], account_id: collectionOwnerId, id } = collection ?? {}; + const { account_id: collectionOwnerId, id } = collection ?? {}; const relationships = useAppSelector((state) => state.relationships); const collectionAccounts = useAppSelector((state) => @@ -135,9 +126,6 @@ export const CollectionAccountsList: React.FC<{ return { visibleAccounts, hiddenAccounts }; }, [collectionAccounts, relationships]); - const hasHiddenAccounts = hiddenAccounts.length > 0; - const initialListSize = visibleAccounts.length + (hasHiddenAccounts ? 1 : 0); - const renderAccountItemButton = useCallback( ({ relationship, accountId }: RenderButtonOptions) => { // When viewing your own collection, only show the Follow button @@ -165,6 +153,28 @@ export const CollectionAccountsList: React.FC<{ [collectionOwnerId, confirmRevoke], ); + const renderListItem = useCallback( + ({ + item, + index, + totalListLength, + isLastElement, + }: TruncatedListItemInfo) => ( +
+ +
+ ), + [renderAccountItemButton], + ); + return ( <>

- {visibleAccounts.map(({ id }, index) => ( -
- -
- ))} - {hasHiddenAccounts && ( -
- } - iconEnd={ - - } - > - - } - > - - - -
- )} - {canShowHiddenAccounts && - hiddenAccounts.map(({ id }, index) => ( -
- -
- ))} + ), + subtitle: ( + + ), + }} + renderListItem={renderListItem} + /> )}