Port dcb6dbbc86cb293b7661495837ba4cfdce3882b7 to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com>
338 lines
9.7 KiB
TypeScript
338 lines
9.7 KiB
TypeScript
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 we’re 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>
|
||
);
|
||
};
|