Allow viewing unlisted collections on your own Profile's Featured tab (#38690)

This commit is contained in:
diondiondion 2026-04-15 14:25:43 +02:00 committed by GitHub
parent 298fc7ce4c
commit 4835c3b7b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 207 additions and 98 deletions

View File

@ -0,0 +1,107 @@
import { useCallback, useState } from 'react';
import { Article } from '@/mastodon/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<TListItem> {
item: TListItem;
index: number;
totalListLength: number;
isLastElement: boolean;
}
interface ToggleButtonOptions {
title: NonNullable<React.ReactNode>;
subtitle?: React.ReactNode;
icon?: IconProp;
}
interface TruncatedListProps<TListItem> {
visibleItems: TListItem[];
truncatedItems: TListItem[];
renderListItem: (
itemInfo: TruncatedListItemInfo<TListItem>,
) => 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 = <TListItem,>({
visibleItems,
truncatedItems,
toggleButton,
renderListItem,
}: TruncatedListProps<TListItem>) => {
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 && (
<Article aria-posinset={initialListSize} aria-setsize={totalListLength}>
<ListItemWrapper
icon={
toggleButton.icon && (
<Icon id='toggle-icon' icon={toggleButton.icon} />
)
}
iconEnd={
<Icon
id='open-status'
icon={
showTruncatedItems
? KeyboardArrowUpIcon
: KeyboardArrowDownIcon
}
/>
}
>
<ListItemButton
aria-expanded={showTruncatedItems}
onClick={toggleTruncatedItems}
subtitle={toggleButton.subtitle}
>
{toggleButton.title}
</ListItemButton>
</ListItemWrapper>
</Article>
)}
{showTruncatedItems &&
truncatedItems.map((item, index) => {
return renderListItem({
item,
index: initialListSize + index + 1,
totalListLength,
isLastElement: index === truncatedItems.length - 1,
});
})}
</>
);
};

View File

@ -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 '@/mastodon/components/account_list_item';
import { useAccount } from '@/mastodon/hooks/useAccount';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import { fetchEndorsedAccounts } from 'mastodon/actions/accounts';
import { AccountListItem } from 'mastodon/components/account_list_item';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { RemoteHint } from 'mastodon/components/remote_hint';
@ -18,9 +17,12 @@ import {
ItemList,
Scrollable,
} from 'mastodon/components/scrollable_list/components';
import type { TruncatedListItemInfo } from 'mastodon/components/truncated_list';
import { TruncatedListItems } from 'mastodon/components/truncated_list';
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import Column from 'mastodon/features/ui/components/column';
import { useAccount } from 'mastodon/hooks/useAccount';
import { useAccountId } from 'mastodon/hooks/useAccountId';
import { useAccountVisibility } from 'mastodon/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]>) => (
<CollectionListItem
key={item.id}
collection={item}
withoutBorder={isLastElement}
withAuthorHandle={false}
positionInList={index}
listSize={totalListLength}
/>
),
[],
);
const hasCollections =
@ -167,16 +190,26 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
</Subheading>
{hasCollections ? (
<ItemList>
{listedCollections.map((item, index) => (
<CollectionListItem
key={item.id}
collection={item}
withoutBorder={index === listedCollections.length - 1}
withAuthorHandle={false}
positionInList={index + 1}
listSize={listedCollections.length}
/>
))}
<TruncatedListItems
visibleItems={listedCollections}
truncatedItems={unlistedCollections}
toggleButton={{
title: (
<FormattedMessage
id='collections.unlisted_collections_with_count'
defaultMessage='Unlisted collections ({count})'
values={{ count: unlistedCollections.length }}
/>
),
subtitle: (
<FormattedMessage
id='collections.unlisted_collections_description'
defaultMessage='These dont appear on your profile to others. Anyone with the link can discover them.'
/>
),
}}
renderListItem={renderListItem}
/>
</ItemList>
) : (
<EmptyMessage

View File

@ -2,13 +2,6 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import {
ListItemButton,
ListItemWrapper,
} from '@/mastodon/components/list_item';
import { createAppSelector, useAppSelector } from '@/mastodon/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 'mastodon/api_types/collections';
import type { RenderButtonOptions } from 'mastodon/components/account_list_item';
@ -18,13 +11,15 @@ import {
} from 'mastodon/components/account_list_item';
import { Button } from 'mastodon/components/button';
import { Callout } from 'mastodon/components/callout';
import { Icon } from 'mastodon/components/icon';
import {
Article,
ItemList,
} from 'mastodon/components/scrollable_list/components';
import type { TruncatedListItemInfo } from 'mastodon/components/truncated_list';
import { TruncatedListItems } from 'mastodon/components/truncated_list';
import { me } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account';
import { createAppSelector, useAppSelector } from 'mastodon/store';
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
import classes from './styles.module.scss';
@ -99,13 +94,9 @@ export const CollectionAccountsList: React.FC<{
const intl = useIntl();
const confirmRevoke = useConfirmRevoke(collection);
const listHeadingRef = useRef<HTMLHeadingElement>(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<Account>) => (
<Article
key={item.id}
aria-posinset={index + 1}
aria-setsize={totalListLength}
>
<AccountListItem
accountId={item.id}
withBorder={!isLastElement}
renderButton={renderAccountItemButton}
/>
</Article>
),
[renderAccountItemButton],
);
return (
<>
<h3
@ -194,71 +204,28 @@ export const CollectionAccountsList: React.FC<{
isLoading={isLoading}
emptyMessage={intl.formatMessage(messages.empty)}
>
{visibleAccounts.map(({ id }, index) => (
<Article
key={id}
aria-posinset={index + 1}
aria-setsize={initialListSize}
>
<AccountListItem
accountId={id}
withBorder={index !== items.length - 1 || hasHiddenAccounts}
renderButton={renderAccountItemButton}
/>
</Article>
))}
{hasHiddenAccounts && (
<Article
aria-posinset={initialListSize}
aria-setsize={initialListSize}
>
<ListItemWrapper
icon={<Icon id='visibility-off' icon={VisibilityOffIcon} />}
iconEnd={
<Icon
id='open-status'
icon={
canShowHiddenAccounts
? KeyboardArrowUpIcon
: KeyboardArrowDownIcon
}
/>
}
>
<ListItemButton
aria-expanded={canShowHiddenAccounts}
onClick={toggleHiddenAccounts}
subtitle={
<FormattedMessage
id='collections.hidden_accounts_description'
defaultMessage='Youve blocked or muted {count, plural, one {this user} other {these users}}'
values={{ count: hiddenAccounts.length }}
/>
}
>
<FormattedMessage
id='collections.hidden_accounts_link'
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
values={{ count: hiddenAccounts.length }}
/>
</ListItemButton>
</ListItemWrapper>
</Article>
)}
{canShowHiddenAccounts &&
hiddenAccounts.map(({ id }, index) => (
<Article
key={id}
aria-posinset={initialListSize + index + 1}
aria-setsize={initialListSize + hiddenAccounts.length}
>
<AccountListItem
accountId={id}
withBorder={index !== hiddenAccounts.length - 1}
renderButton={renderAccountItemButton}
<TruncatedListItems
visibleItems={visibleAccounts}
truncatedItems={hiddenAccounts}
toggleButton={{
icon: VisibilityOffIcon,
title: (
<FormattedMessage
id='collections.hidden_accounts_link'
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
values={{ count: hiddenAccounts.length }}
/>
</Article>
))}
),
subtitle: (
<FormattedMessage
id='collections.hidden_accounts_description'
defaultMessage='Youve blocked or muted {count, plural, one {this user} other {these users}}'
values={{ count: hiddenAccounts.length }}
/>
),
}}
renderListItem={renderListItem}
/>
</ItemList>
</SensitiveScreen>
)}

View File

@ -409,6 +409,8 @@
"collections.sensitive": "Sensitive",
"collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
"collections.topic_special_chars_hint": "Special characters will be removed when saving",
"collections.unlisted_collections_description": "These dont appear on your profile to others. Anyone with the link can discover them.",
"collections.unlisted_collections_with_count": "Unlisted collections ({count})",
"collections.view_collection": "View collection",
"collections.view_other_collections_by_user": "View other collections by this user",
"collections.visibility_public": "Public",