Update content & placement of "sensitive content" warning on collection page (#39069)

This commit is contained in:
diondiondion 2026-05-18 15:08:41 +02:00 committed by GitHub
parent 99b72f60ad
commit dcb6dbbc86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 128 additions and 90 deletions

View File

@ -67,9 +67,14 @@ interface LinkProps
extends React.ComponentPropsWithoutRef<typeof Link>, ContentProps {} extends React.ComponentPropsWithoutRef<typeof Link>, ContentProps {}
export const ListItemLink = polymorphicForwardRef<'h3', LinkProps>( export const ListItemLink = polymorphicForwardRef<'h3', LinkProps>(
({ as, subtitle, children, className, ...otherProps }, ref) => { ({ as, subtitle, subtitleId, children, className, ...otherProps }, ref) => {
return ( return (
<ListItemContent ref={ref} as={as} subtitle={subtitle}> <ListItemContent
ref={ref}
as={as}
subtitle={subtitle}
subtitleId={subtitleId}
>
<Link className={classNames(className, 'focusable')} {...otherProps}> <Link className={classNames(className, 'focusable')} {...otherProps}>
{children} {children}
</Link> </Link>
@ -82,9 +87,14 @@ interface ButtonProps
extends React.ComponentPropsWithoutRef<'button'>, ContentProps {} extends React.ComponentPropsWithoutRef<'button'>, ContentProps {}
export const ListItemButton = polymorphicForwardRef<'h3', ButtonProps>( export const ListItemButton = polymorphicForwardRef<'h3', ButtonProps>(
({ as, subtitle, children, className, ...otherProps }, ref) => { ({ as, subtitle, subtitleId, children, className, ...otherProps }, ref) => {
return ( return (
<ListItemContent as={as} ref={ref} subtitle={subtitle}> <ListItemContent
as={as}
ref={ref}
subtitle={subtitle}
subtitleId={subtitleId}
>
<button <button
type='button' type='button'
className={classNames(className, 'focusable')} className={classNames(className, 'focusable')}

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -14,7 +14,6 @@ import {
AccountListItemFollowButton, AccountListItemFollowButton,
} from 'mastodon/components/account_list_item'; } from 'mastodon/components/account_list_item';
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { Callout } from 'mastodon/components/callout';
import { import {
Article, Article,
ItemList, ItemList,
@ -35,50 +34,6 @@ const messages = defineMessages({
}, },
}); });
const SensitiveScreen: React.FC<{
sensitive: boolean | undefined;
focusTargetRef: React.RefObject<HTMLHeadingElement>;
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 (
<Callout
variant='warning'
title={
<FormattedMessage
id='collections.detail.sensitive_content'
defaultMessage='Sensitive content'
/>
}
primaryLabel={
<FormattedMessage
id='content_warning.show_short'
defaultMessage='Show'
/>
}
onPrimary={showAnyway}
className={classes.sensitiveScreen}
>
<FormattedMessage
id='collections.detail.sensitive_note'
defaultMessage='The description and accounts may not be suitable for all viewers.'
/>
</Callout>
);
};
type CollectionItemWithAccount = CollectionAccountItem & { type CollectionItemWithAccount = CollectionAccountItem & {
account?: Account | null; account?: Account | null;
}; };
@ -203,35 +158,30 @@ export const CollectionAccountsList: React.FC<{
values={{ count: collection.item_count }} values={{ count: collection.item_count }}
/> />
</h3> </h3>
<SensitiveScreen <ItemList emptyMessage={intl.formatMessage(messages.empty)}>
sensitive={!isOwnCollection && collection.sensitive} <TruncatedListItems
focusTargetRef={listHeadingRef} visibleItems={visibleAccounts}
> truncatedItems={hiddenAccounts}
<ItemList emptyMessage={intl.formatMessage(messages.empty)}> toggleButton={{
<TruncatedListItems icon: VisibilityOffIcon,
visibleItems={visibleAccounts} title: (
truncatedItems={hiddenAccounts} <FormattedMessage
toggleButton={{ id='collections.hidden_accounts_link'
icon: VisibilityOffIcon, defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
title: ( values={{ count: hiddenAccounts.length }}
<FormattedMessage />
id='collections.hidden_accounts_link' ),
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}' subtitle: (
values={{ count: hiddenAccounts.length }} <FormattedMessage
/> id='collections.hidden_accounts_description'
), defaultMessage='Youve blocked or muted {count, plural, one {this user} other {these users}}'
subtitle: ( values={{ count: hiddenAccounts.length }}
<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>
}}
renderListItem={renderListItem}
/>
</ItemList>
</SensitiveScreen>
</> </>
); );
}; };

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -138,9 +138,40 @@ export const PendingNote: React.FC = () => {
); );
}; };
const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ const SensitiveContentNote: React.FC<{ onReveal: () => void }> = ({
collection, onReveal,
}) => { }) => {
return (
<Callout
variant='warning'
title={
<FormattedMessage
id='collections.detail.sensitive_content'
defaultMessage='Sensitive content'
/>
}
primaryLabel={
<FormattedMessage
id='content_warning.show_short'
defaultMessage='Show'
/>
}
onPrimary={onReveal}
className={classes.sensitiveScreen}
>
<FormattedMessage
id='collections.detail.sensitive_note'
defaultMessage='The description and accounts may not be suitable for all viewers.'
/>
</Callout>
);
};
const CollectionHeader: React.FC<{
collection: ApiCollectionJSON;
withDescription: boolean;
headingRef: React.RefObject<HTMLHeadingElement>;
}> = ({ collection, withDescription, headingRef }) => {
const intl = useIntl(); const intl = useIntl();
const { name, description, tag, account_id, items } = collection; const { name, description, tag, account_id, items } = collection;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -181,7 +212,9 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
<div className={classes.titleWithMenu}> <div className={classes.titleWithMenu}>
<div className={classes.titleWrapper}> <div className={classes.titleWrapper}>
{tag && <Badge label={`#${tag.name}`} icon={null} />} {tag && <Badge label={`#${tag.name}`} icon={null} />}
<h2 className={classes.name}>{name}</h2> <h2 className={classes.name} ref={headingRef} tabIndex={-1}>
{name}
</h2>
<AuthorNote id={account_id} /> <AuthorNote id={account_id} />
</div> </div>
<div className={classes.headerButtonWrapper}> <div className={classes.headerButtonWrapper}>
@ -199,7 +232,9 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
/> />
</div> </div>
</div> </div>
{description && <p className={classes.description}>{description}</p>} {withDescription && description && (
<p className={classes.description}>{description}</p>
)}
{hasPendingAccounts && <PendingNote />} {hasPendingAccounts && <PendingNote />}
{isCurrentUserInCollection && ( {isCurrentUserInCollection && (
<RevokeControls <RevokeControls
@ -211,6 +246,52 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
); );
}; };
function useRevealSensitiveContent({
sensitive,
}: {
sensitive: boolean | undefined;
}) {
const postRevealFocusTargetRef = useRef<HTMLHeadingElement>(null);
const [isContentVisible, setIsContentVisible] = useState(!sensitive);
const revealContent = useCallback(() => {
setIsContentVisible(true);
setTimeout(() => {
postRevealFocusTargetRef.current?.focus();
}, 0);
}, [postRevealFocusTargetRef]);
return {
isContentVisible,
revealContent,
postRevealFocusTargetRef,
};
}
const ColumnContent: React.FC<{
collection: ApiCollectionJSON;
}> = ({ collection }) => {
const { isContentVisible, revealContent, postRevealFocusTargetRef } =
useRevealSensitiveContent({
sensitive: collection.sensitive && collection.account_id !== me,
});
return (
<>
<CollectionHeader
collection={collection}
headingRef={postRevealFocusTargetRef}
withDescription={isContentVisible}
/>
{isContentVisible ? (
<CollectionAccountsList collection={collection} />
) : (
<SensitiveContentNote onReveal={revealContent} />
)}
</>
);
};
export const CollectionDetailPage: React.FC<{ export const CollectionDetailPage: React.FC<{
multiColumn?: boolean; multiColumn?: boolean;
}> = ({ multiColumn }) => { }> = ({ multiColumn }) => {
@ -241,10 +322,7 @@ export const CollectionDetailPage: React.FC<{
<Scrollable> <Scrollable>
{collection ? ( {collection ? (
<> <ColumnContent collection={collection} />
<CollectionHeader collection={collection} />
<CollectionAccountsList collection={collection} />
</>
) : ( ) : (
<LoadingIndicator /> <LoadingIndicator />
)} )}

View File

@ -64,7 +64,7 @@
} }
.sensitiveScreen { .sensitiveScreen {
margin: 16px; margin-inline: 16px;
} }
.columnSubheading { .columnSubheading {

View File

@ -394,7 +394,7 @@
"collections.detail.loading": "Loading collection…", "collections.detail.loading": "Loading collection…",
"collections.detail.revoke_inclusion": "Remove me", "collections.detail.revoke_inclusion": "Remove me",
"collections.detail.sensitive_content": "Sensitive content", "collections.detail.sensitive_content": "Sensitive content",
"collections.detail.sensitive_note": "This collection contains accounts and content that may be sensitive to some users.", "collections.detail.sensitive_note": "The description and accounts may not be suitable for all viewers.",
"collections.detail.share": "Share this collection", "collections.detail.share": "Share this collection",
"collections.detail.you_are_in_this_collection": "You're featured in this collection", "collections.detail.you_are_in_this_collection": "You're featured in this collection",
"collections.edit_details": "Edit details", "collections.edit_details": "Edit details",