From 3557be5d4d8db0c6f5a42071f258d2bf4cdc0a3b Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 5 Mar 2026 16:43:57 +0100 Subject: [PATCH] Hide account list in sensitive collections (#38081) --- .../components/account/account.stories.tsx | 11 + .../mastodon/components/account/index.tsx | 3 + .../components/scrollable_list/components.tsx | 2 +- .../collections/detail/collection_list.tsx | 212 ++++++++++++++++++ .../features/collections/detail/index.tsx | 60 +---- .../collections/detail/styles.module.scss | 27 +++ .../mastodon/features/collections/index.tsx | 6 +- app/javascript/mastodon/locales/en.json | 3 + .../styles/mastodon/components.scss | 5 +- 9 files changed, 267 insertions(+), 62 deletions(-) create mode 100644 app/javascript/mastodon/features/collections/detail/collection_list.tsx diff --git a/app/javascript/mastodon/components/account/account.stories.tsx b/app/javascript/mastodon/components/account/account.stories.tsx index 050ed6e900..0b1e9e29b3 100644 --- a/app/javascript/mastodon/components/account/account.stories.tsx +++ b/app/javascript/mastodon/components/account/account.stories.tsx @@ -50,6 +50,10 @@ const meta = { type: 'boolean', description: 'Whether to display the account menu or not', }, + withBorder: { + type: 'boolean', + description: 'Whether to display the bottom border or not', + }, }, args: { name: 'Test User', @@ -60,6 +64,7 @@ const meta = { defaultAction: 'mute', withBio: false, withMenu: true, + withBorder: true, }, parameters: { state: { @@ -103,6 +108,12 @@ export const NoMenu: Story = { }, }; +export const NoBorder: Story = { + args: { + withBorder: false, + }, +}; + export const Blocked: Story = { args: { defaultAction: 'block', diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx index adef3909a8..7397dfd1d1 100644 --- a/app/javascript/mastodon/components/account/index.tsx +++ b/app/javascript/mastodon/components/account/index.tsx @@ -73,6 +73,7 @@ interface AccountProps { defaultAction?: 'block' | 'mute'; withBio?: boolean; withMenu?: boolean; + withBorder?: boolean; extraAccountInfo?: React.ReactNode; children?: React.ReactNode; } @@ -85,6 +86,7 @@ export const Account: React.FC = ({ defaultAction, withBio, withMenu = true, + withBorder = true, extraAccountInfo, children, }) => { @@ -290,6 +292,7 @@ export const Account: React.FC = ({
(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => { - if (Children.count(children) === 0 && emptyMessage) { + if (!isLoading && Children.count(children) === 0 && emptyMessage) { return
{emptyMessage}
; } diff --git a/app/javascript/mastodon/features/collections/detail/collection_list.tsx b/app/javascript/mastodon/features/collections/detail/collection_list.tsx new file mode 100644 index 0000000000..f66fd855cf --- /dev/null +++ b/app/javascript/mastodon/features/collections/detail/collection_list.tsx @@ -0,0 +1,212 @@ +import { Fragment, useCallback, useRef, useState } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { Button } from '@/mastodon/components/button'; +import { useRelationship } from '@/mastodon/hooks/useRelationship'; +import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; +import { Account } from 'mastodon/components/account'; +import { DisplayName } from 'mastodon/components/display_name'; +import { + Article, + ItemList, +} from 'mastodon/components/scrollable_list/components'; +import { useAccount } from 'mastodon/hooks/useAccount'; +import { me } from 'mastodon/initial_state'; + +import classes from './styles.module.scss'; + +const messages = defineMessages({ + empty: { + id: 'collections.accounts.empty_title', + defaultMessage: 'This collection is empty', + }, + accounts: { + id: 'collections.detail.accounts_heading', + defaultMessage: 'Accounts', + }, +}); + +const SimpleAuthorName: React.FC<{ id: string }> = ({ id }) => { + const account = useAccount(id); + return ; +}; + +const AccountItem: React.FC<{ + accountId: string | undefined; + collectionOwnerId: string; + withBorder?: boolean; +}> = ({ accountId, withBorder = true, 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 ( + + ); +}; + +const SensitiveScreen: React.FC<{ + sensitive: boolean | undefined; + focusTargetRef: React.RefObject; + children: React.ReactNode; +}> = ({ sensitive, focusTargetRef, children }) => { + const [isVisible, setIsVisible] = useState(!sensitive); + + const showAnyway = useCallback(() => { + setIsVisible(true); + setTimeout(() => { + focusTargetRef.current?.focus(); + }, 0); + }, [focusTargetRef]); + + if (isVisible) { + return children; + } + + return ( +
+ + +
+ ); +}; + +/** + * Returns the collection's account items. If the current user's account + * is part of the collection, it will be returned separately. + */ +function getCollectionItems(collection: ApiCollectionJSON | undefined) { + if (!collection) + return { + currentUserInCollection: null, + items: [], + }; + + const { account_id, items } = collection; + + const isOwnCollection = account_id === me; + const currentUserIndex = items.findIndex( + (account) => account.account_id === me, + ); + + if (isOwnCollection || currentUserIndex === -1) { + return { + currentUserInCollection: null, + items, + }; + } else { + return { + currentUserInCollection: items.at(currentUserIndex) ?? null, + items: items.toSpliced(currentUserIndex, 1), + }; + } +} + +export const CollectionAccountsList: React.FC<{ + collection?: ApiCollectionJSON; + isLoading: boolean; +}> = ({ collection, isLoading }) => { + const intl = useIntl(); + const listHeadingRef = useRef(null); + + const isOwnCollection = collection?.account_id === me; + const { items, currentUserInCollection } = getCollectionItems(collection); + + return ( + + {collection && currentUserInCollection ? ( + <> +

+ , + }} + tagName={Fragment} + /> +

+
+ +
+

+ +

+ + ) : ( +

+ {intl.formatMessage(messages.accounts)} +

+ )} + {collection && ( + + {items.map(({ account_id }, index, items) => ( +
+ +
+ ))} +
+ )} +
+ ); +}; diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index e0d2ff12d7..4f21e7d267 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -6,11 +6,9 @@ import { Helmet } from 'react-helmet'; import { useLocation, useParams } from 'react-router'; import { openModal } from '@/mastodon/actions/modal'; -import { useRelationship } from '@/mastodon/hooks/useRelationship'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ShareIcon from '@/material-icons/400-24px/share.svg?react'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; -import { Account } from 'mastodon/components/account'; import { Avatar } from 'mastodon/components/avatar'; import { Column } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; @@ -19,26 +17,19 @@ import { LinkedDisplayName, } from 'mastodon/components/display_name'; import { IconButton } from 'mastodon/components/icon_button'; -import { - Article, - ItemList, - Scrollable, -} from 'mastodon/components/scrollable_list/components'; +import { Scrollable } from 'mastodon/components/scrollable_list/components'; import { Tag } from 'mastodon/components/tags/tag'; import { useAccount } from 'mastodon/hooks/useAccount'; import { me } from 'mastodon/initial_state'; import { fetchCollection } from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; +import { CollectionAccountsList } from './collection_list'; import { CollectionMetaData } from './collection_list_item'; import { CollectionMenu } from './collection_menu'; import classes from './styles.module.scss'; const messages = defineMessages({ - empty: { - id: 'collections.accounts.empty_title', - defaultMessage: 'This collection is empty', - }, loading: { id: 'collections.detail.loading', defaultMessage: 'Loading collection…', @@ -47,10 +38,6 @@ const messages = defineMessages({ id: 'collections.detail.share', defaultMessage: 'Share this collection', }, - accounts: { - id: 'collections.detail.accounts_heading', - defaultMessage: 'Accounts', - }, }); export const AuthorNote: React.FC<{ id: string; previewMode?: boolean }> = ({ @@ -149,33 +136,10 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ collection={collection} className={classes.metaData} /> -

{intl.formatMessage(messages.accounts)}

); }; -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 ; -}; - export const CollectionDetailPage: React.FC<{ multiColumn?: boolean; }> = ({ multiColumn }) => { @@ -185,7 +149,6 @@ export const CollectionDetailPage: React.FC<{ const collection = useAppSelector((state) => id ? state.collections.collections[id] : undefined, ); - const isLoading = !!id && !collection; useEffect(() => { @@ -208,24 +171,7 @@ export const CollectionDetailPage: React.FC<{ {collection && } - - {collection?.items.map(({ account_id }, index, items) => ( -
- -
- ))} -
+
diff --git a/app/javascript/mastodon/features/collections/detail/styles.module.scss b/app/javascript/mastodon/features/collections/detail/styles.module.scss index 690ec29f71..ad084eaed6 100644 --- a/app/javascript/mastodon/features/collections/detail/styles.module.scss +++ b/app/javascript/mastodon/features/collections/detail/styles.module.scss @@ -57,6 +57,18 @@ font-size: 15px; } +.columnSubheading { + background: var(--color-bg-secondary); + padding: 15px 20px; + font-size: 15px; + font-weight: 500; + + &:focus-visible { + outline: var(--outline-focus-default); + outline-offset: -2px; + } +} + .displayNameWithAvatar { display: inline-flex; gap: 4px; @@ -76,3 +88,18 @@ align-self: center; } } + +.sensitiveWarning { + display: flex; + flex-direction: column; + align-items: center; + max-width: 460px; + margin: auto; + padding: 60px 30px; + gap: 20px; + text-align: center; + text-wrap: balance; + font-size: 15px; + line-height: 1.5; + cursor: default; +} diff --git a/app/javascript/mastodon/features/collections/index.tsx b/app/javascript/mastodon/features/collections/index.tsx index e560e01366..9d9b5d06d8 100644 --- a/app/javascript/mastodon/features/collections/index.tsx +++ b/app/javascript/mastodon/features/collections/index.tsx @@ -6,7 +6,7 @@ import { Helmet } from 'react-helmet'; import { Link } from 'react-router-dom'; import AddIcon from '@/material-icons/400-24px/add.svg?react'; -import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; +import CollectionsFilledIcon from '@/material-icons/400-24px/category-fill.svg?react'; import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react'; import { Column } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; @@ -73,8 +73,8 @@ export const Collections: React.FC<{ > [data-popper-placement] { .account { padding: 16px; - border-bottom: 1px solid var(--color-border-primary); + + &:not(&--without-border) { + border-bottom: 1px solid var(--color-border-primary); + } .account__display-name { flex: 1 1 auto;