diondiondion 95328ba754 [Glitch] Update content & placement of "sensitive content" warning on collection page
Port dcb6dbbc86cb293b7661495837ba4cfdce3882b7 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2026-05-18 19:38:57 +02:00

338 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory, useLocation, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { Helmet } from '@unhead/react/helmet';
import HelpIcon from '@/material-icons/400-24px/help.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import StarIcon from '@/material-icons/400-24px/star.svg?react';
import { openModal } from 'flavours/glitch/actions/modal';
import type {
ApiCollectionJSON,
CollectionAccountItem,
} from 'flavours/glitch/api_types/collections';
import { Badge } from 'flavours/glitch/components/badge';
import { Callout } from 'flavours/glitch/components/callout';
import { Column } from 'flavours/glitch/components/column';
import { ColumnHeader } from 'flavours/glitch/components/column_header';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { useAccountHandle } from 'flavours/glitch/components/display_name/default';
import { FormattedDateWrapper } from 'flavours/glitch/components/formatted_date';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { Scrollable } from 'flavours/glitch/components/scrollable_list/components';
import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { domain, me } from 'flavours/glitch/initial_state';
import { fetchCollection } from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { CollectionMenu } from '../components/collection_menu';
import { CollectionAccountsList } from './accounts_list';
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
import classes from './styles.module.scss';
const messages = defineMessages({
loading: {
id: 'collections.detail.loading',
defaultMessage: 'Loading collection…',
},
share: {
id: 'collections.detail.share',
defaultMessage: 'Share this collection',
},
});
export const AuthorNote: React.FC<{ id: string }> = ({ id }) => {
const account = useAccount(id);
const authorHandle = useAccountHandle(account, domain);
if (!account) {
return null;
}
const author = (
<Link to={`/@${account.acct}`} data-hover-card-account={account.id}>
{authorHandle}
</Link>
);
return (
<p className={classes.authorNote}>
<FormattedMessage
id='collections.by_account'
defaultMessage='by {account_handle}'
values={{
account_handle: author,
}}
/>
</p>
);
};
const RevokeControls: React.FC<{
currentUserCollectionItem: CollectionAccountItem;
collection: ApiCollectionJSON;
}> = ({ currentUserCollectionItem, collection }) => {
const authorAccount = useAccount(collection.account_id);
const confirmRevoke = useConfirmRevoke(collection);
return (
<Callout
icon={StarIcon}
title={
<FormattedMessage
id='collections.detail.you_are_in_this_collection'
defaultMessage="You're featured in this collection"
/>
}
primaryLabel={
<FormattedMessage
id='collections.detail.revoke_inclusion'
defaultMessage='Remove me'
/>
}
onPrimary={confirmRevoke}
>
<FormattedMessage
id='collections.detail.author_added_you_on_date'
defaultMessage='{author} added you on {date}'
values={{
author: <DisplayName account={authorAccount} variant='simple' />,
date: (
<FormattedDateWrapper
value={currentUserCollectionItem.created_at}
day='2-digit'
month='short'
year='numeric'
/>
),
}}
/>
</Callout>
);
};
export const PendingNote: React.FC = () => {
return (
<Callout
variant='subtle'
icon={HelpIcon}
title={
<FormattedMessage
id='collections.pending_accounts.title'
defaultMessage='Why am I seeing pending accounts?'
/>
}
>
<FormattedMessage
id='collections.pending_accounts.message'
defaultMessage='Accounts may appear as pending when were awaiting a response from the user or their server. Only you can see pending accounts.'
/>
</Callout>
);
};
const SensitiveContentNote: React.FC<{ onReveal: () => void }> = ({
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 { name, description, tag, account_id, items } = collection;
const dispatch = useAppDispatch();
const history = useHistory();
const isOwnCollection = account_id === me;
const currentUserCollectionItem = items.find(
(account) => account.account_id === me,
);
const isCurrentUserInCollection =
!isOwnCollection && !!currentUserCollectionItem;
const openShareModal = useCallback(() => {
dispatch(
openModal({
modalType: 'SHARE_COLLECTION',
modalProps: {
collection,
},
}),
);
}, [collection, dispatch]);
const location = useLocation<{ newCollection?: boolean } | undefined>();
const isNewCollection = location.state?.newCollection;
useEffect(() => {
if (isNewCollection) {
// Replace with current pathname to clear `newCollection` state
history.replace(location.pathname);
openShareModal();
}
}, [history, openShareModal, isNewCollection, location.pathname]);
const hasPendingAccounts = items.some((item) => item.state === 'pending');
return (
<header className={classes.header}>
<div className={classes.titleWithMenu}>
<div className={classes.titleWrapper}>
{tag && <Badge label={`#${tag.name}`} icon={null} />}
<h2 className={classes.name} ref={headingRef} tabIndex={-1}>
{name}
</h2>
<AuthorNote id={account_id} />
</div>
<div className={classes.headerButtonWrapper}>
<IconButton
iconComponent={ShareIcon}
icon='share-icon'
title={intl.formatMessage(messages.share)}
className={classes.iconButton}
onClick={openShareModal}
/>
<CollectionMenu
context='collection'
collection={collection}
className={classes.iconButton}
/>
</div>
</div>
{withDescription && description && (
<p className={classes.description}>{description}</p>
)}
{hasPendingAccounts && <PendingNote />}
{isCurrentUserInCollection && (
<RevokeControls
currentUserCollectionItem={currentUserCollectionItem}
collection={collection}
/>
)}
</header>
);
};
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<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const collection = useAppSelector((state) =>
id ? state.collections.collections[id] : undefined,
);
useEffect(() => {
if (id) {
void dispatch(fetchCollection({ collectionId: id }));
}
}, [dispatch, id]);
const pageTitle = collection?.name ?? intl.formatMessage(messages.loading);
return (
<Column bindToDocument={!multiColumn} label={pageTitle}>
<ColumnHeader
showBackButton
title={pageTitle}
icon='collection-icon'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
/>
<Scrollable>
{collection ? (
<ColumnContent collection={collection} />
) : (
<LoadingIndicator />
)}
</Scrollable>
<Helmet>
<title>{pageTitle}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};