Display public collections on profile "Featured tab" (#37967)

This commit is contained in:
diondiondion 2026-02-25 15:14:55 +01:00 committed by GitHub
parent 32873e63cf
commit 440466c246
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 115 additions and 24 deletions

View File

@ -17,8 +17,15 @@ import BundleColumnError from 'mastodon/features/ui/components/bundle_column_err
import Column from 'mastodon/features/ui/components/column'; import Column from 'mastodon/features/ui/components/column';
import { useAccountId } from 'mastodon/hooks/useAccountId'; import { useAccountId } from 'mastodon/hooks/useAccountId';
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility'; import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
import {
fetchAccountCollections,
selectAccountCollections,
} from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { CollectionListItem } from '../collections/detail/collection_list_item';
import { areCollectionsEnabled } from '../collections/utils';
import { EmptyMessage } from './components/empty_message'; import { EmptyMessage } from './components/empty_message';
import { FeaturedTag } from './components/featured_tag'; import { FeaturedTag } from './components/featured_tag';
import type { TagMap } from './components/featured_tag'; import type { TagMap } from './components/featured_tag';
@ -42,6 +49,9 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
if (accountId) { if (accountId) {
void dispatch(fetchFeaturedTags({ accountId })); void dispatch(fetchFeaturedTags({ accountId }));
void dispatch(fetchEndorsedAccounts({ accountId })); void dispatch(fetchEndorsedAccounts({ accountId }));
if (areCollectionsEnabled()) {
void dispatch(fetchAccountCollections({ accountId }));
}
} }
}, [accountId, dispatch]); }, [accountId, dispatch]);
@ -64,6 +74,14 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
ImmutableList(), ImmutableList(),
) as ImmutableList<string>, ) as ImmutableList<string>,
); );
const { collections, status } = useAppSelector((state) =>
selectAccountCollections(state, accountId ?? null),
);
const publicCollections = collections.filter(
// This filter only applies when viewing your own profile, where the endpoint
// returns all collections, but we hide unlisted ones here to avoid confusion
(item) => item.discoverable,
);
if (accountId === null) { if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />; return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
@ -101,6 +119,25 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
{accountId && ( {accountId && (
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} /> <AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
)} )}
{publicCollections.length > 0 && status === 'idle' && (
<>
<h4 className='column-subheading'>
<FormattedMessage
id='account.featured.collections'
defaultMessage='Collections'
/>
</h4>
<section>
{publicCollections.map((item, index) => (
<CollectionListItem
key={item.id}
collection={item}
withoutBorder={index === publicCollections.length - 1}
/>
))}
</section>
</>
)}
{!featuredTags.isEmpty() && ( {!featuredTags.isEmpty() && (
<> <>
<h4 className='column-subheading'> <h4 className='column-subheading'>

View File

@ -2,15 +2,17 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
margin-inline: 10px; padding-inline: 16px;
padding-inline-end: 5px;
border-bottom: 1px solid var(--color-border-primary); &:not(.wrapperWithoutBorder) {
border-bottom: 1px solid var(--color-border-primary);
}
} }
.content { .content {
position: relative; position: relative;
flex-grow: 1; flex-grow: 1;
padding: 15px 5px; padding-block: 15px;
} }
.link { .link {

View File

@ -67,13 +67,18 @@ export const CollectionMetaData: React.FC<{
export const CollectionListItem: React.FC<{ export const CollectionListItem: React.FC<{
collection: ApiCollectionJSON; collection: ApiCollectionJSON;
}> = ({ collection }) => { withoutBorder?: boolean;
}> = ({ collection, withoutBorder }) => {
const { id, name } = collection; const { id, name } = collection;
const linkId = useId(); const linkId = useId();
return ( return (
<article <article
className={classNames(classes.wrapper, 'focusable')} className={classNames(
classes.wrapper,
'focusable',
withoutBorder && classes.wrapperWithoutBorder,
)}
tabIndex={-1} tabIndex={-1}
aria-labelledby={linkId} aria-labelledby={linkId}
> >

View File

@ -2,6 +2,8 @@ import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { matchPath } from 'react-router';
import { useAccount } from '@/mastodon/hooks/useAccount'; import { useAccount } from '@/mastodon/hooks/useAccount';
import MoreVertIcon from '@/material-icons/400-24px/more_vert.svg?react'; import MoreVertIcon from '@/material-icons/400-24px/more_vert.svg?react';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
@ -9,6 +11,7 @@ import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Dropdown } from 'mastodon/components/dropdown_menu'; import { Dropdown } from 'mastodon/components/dropdown_menu';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
import { messages as editorMessages } from '../editor'; import { messages as editorMessages } from '../editor';
@ -70,7 +73,7 @@ export const CollectionMenu: React.FC<{
const menu = useMemo(() => { const menu = useMemo(() => {
if (isOwnCollection) { if (isOwnCollection) {
const commonItems = [ const commonItems: MenuItem[] = [
{ {
text: intl.formatMessage(editorMessages.manageAccounts), text: intl.formatMessage(editorMessages.manageAccounts),
to: `/collections/${id}/edit`, to: `/collections/${id}/edit`,
@ -97,17 +100,31 @@ export const CollectionMenu: React.FC<{
return commonItems; return commonItems;
} }
} else if (ownerAccount) { } else if (ownerAccount) {
return [ const items: MenuItem[] = [
{
text: intl.formatMessage(messages.viewOtherCollections),
to: `/@${ownerAccount.acct}/featured`,
},
null,
{ {
text: intl.formatMessage(messages.report), text: intl.formatMessage(messages.report),
action: openReportModal, action: openReportModal,
}, },
]; ];
const featuredCollectionsPath = `/@${ownerAccount.acct}/featured`;
// Don't show menu link to featured collections while on that very page
if (
!matchPath(location.pathname, {
path: featuredCollectionsPath,
exact: true,
})
) {
items.unshift(
...[
{
text: intl.formatMessage(messages.viewOtherCollections),
to: featuredCollectionsPath,
},
null,
],
);
}
return items;
} else { } else {
return []; return [];
} }

View File

@ -5,6 +5,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { useRelationship } from '@/mastodon/hooks/useRelationship';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react'; import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { showAlert } from 'mastodon/actions/alerts'; import { showAlert } from 'mastodon/actions/alerts';
@ -123,6 +124,28 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
); );
}; };
const CollectionAccountItem: React.FC<{
accountId: string | undefined;
collectionOwnerId: string;
}> = ({ accountId, collectionOwnerId }) => {
const relationship = useRelationship(accountId);
if (!accountId) {
return null;
}
// When viewing your own collection, only show the Follow button
// for accounts you're not following (anymore).
// Otherwise, always show the follow button in its various states.
const withoutButton =
accountId === me ||
!relationship ||
(collectionOwnerId === me &&
(relationship.following || relationship.requested));
return <Account minimal={withoutButton} withMenu={false} id={accountId} />;
};
export const CollectionDetailPage: React.FC<{ export const CollectionDetailPage: React.FC<{
multiColumn?: boolean; multiColumn?: boolean;
}> = ({ multiColumn }) => { }> = ({ multiColumn }) => {
@ -163,11 +186,13 @@ export const CollectionDetailPage: React.FC<{
collection ? <CollectionHeader collection={collection} /> : null collection ? <CollectionHeader collection={collection} /> : null
} }
> >
{collection?.items.map(({ account_id }) => {collection?.items.map(({ account_id }) => (
account_id ? ( <CollectionAccountItem
<Account key={account_id} minimal id={account_id} /> key={account_id}
) : null, accountId={account_id}
)} collectionOwnerId={collection.account_id}
/>
))}
</ScrollableList> </ScrollableList>
<Helmet> <Helmet>

View File

@ -14,7 +14,7 @@ import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import { import {
fetchAccountCollections, fetchAccountCollections,
selectMyCollections, selectAccountCollections,
} from 'mastodon/reducers/slices/collections'; } from 'mastodon/reducers/slices/collections';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -31,7 +31,9 @@ export const Collections: React.FC<{
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const me = useAppSelector((state) => state.meta.get('me') as string); const me = useAppSelector((state) => state.meta.get('me') as string);
const { collections, status } = useAppSelector(selectMyCollections); const { collections, status } = useAppSelector((state) =>
selectAccountCollections(state, me),
);
useEffect(() => { useEffect(() => {
void dispatch(fetchAccountCollections({ accountId: me })); void dispatch(fetchAccountCollections({ accountId: me }));

View File

@ -45,6 +45,7 @@
"account.familiar_followers_two": "Followed by {name1} and {name2}", "account.familiar_followers_two": "Followed by {name1} and {name2}",
"account.featured": "Featured", "account.featured": "Featured",
"account.featured.accounts": "Profiles", "account.featured.accounts": "Profiles",
"account.featured.collections": "Collections",
"account.featured.hashtags": "Hashtags", "account.featured.hashtags": "Hashtags",
"account.featured_tags.last_status_at": "Last post on {date}", "account.featured_tags.last_status_at": "Last post on {date}",
"account.featured_tags.last_status_never": "No posts", "account.featured_tags.last_status_never": "No posts",

View File

@ -229,14 +229,16 @@ interface AccountCollectionQuery {
collections: ApiCollectionJSON[]; collections: ApiCollectionJSON[];
} }
export const selectMyCollections = createAppSelector( export const selectAccountCollections = createAppSelector(
[ [
(state) => state.meta.get('me') as string, (_, accountId: string | null) => accountId,
(state) => state.collections.accountCollections, (state) => state.collections.accountCollections,
(state) => state.collections.collections, (state) => state.collections.collections,
], ],
(me, collectionsByAccountId, collectionsMap) => { (accountId, collectionsByAccountId, collectionsMap) => {
const myCollectionsQuery = collectionsByAccountId[me]; const myCollectionsQuery = accountId
? collectionsByAccountId[accountId]
: null;
if (!myCollectionsQuery) { if (!myCollectionsQuery) {
return { return {