[Glitch] Hide account list in sensitive collections

Port 3557be5d4d to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion
2026-03-05 16:43:57 +01:00
committed by Claire
parent 7575226495
commit 141cd0a741
7 changed files with 253 additions and 62 deletions

View File

@@ -74,6 +74,7 @@ interface AccountProps {
defaultAction?: 'block' | 'mute'; defaultAction?: 'block' | 'mute';
withBio?: boolean; withBio?: boolean;
withMenu?: boolean; withMenu?: boolean;
withBorder?: boolean;
extraAccountInfo?: React.ReactNode; extraAccountInfo?: React.ReactNode;
children?: React.ReactNode; children?: React.ReactNode;
} }
@@ -86,6 +87,7 @@ export const Account: React.FC<AccountProps> = ({
defaultAction, defaultAction,
withBio, withBio,
withMenu = true, withMenu = true,
withBorder = true,
extraAccountInfo, extraAccountInfo,
children, children,
}) => { }) => {
@@ -291,6 +293,7 @@ export const Account: React.FC<AccountProps> = ({
<div <div
className={classNames('account', { className={classNames('account', {
'account--minimal': minimal, 'account--minimal': minimal,
'account--without-border': !withBorder,
})} })}
> >
<div <div

View File

@@ -36,7 +36,7 @@ export const ItemList = forwardRef<
emptyMessage?: React.ReactNode; emptyMessage?: React.ReactNode;
} }
>(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => { >(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => {
if (Children.count(children) === 0 && emptyMessage) { if (!isLoading && Children.count(children) === 0 && emptyMessage) {
return <div className='empty-column-indicator'>{emptyMessage}</div>; return <div className='empty-column-indicator'>{emptyMessage}</div>;
} }

View File

@@ -0,0 +1,212 @@
import { Fragment, useCallback, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Button } from '@/flavours/glitch/components/button';
import { useRelationship } from '@/flavours/glitch/hooks/useRelationship';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { Account } from 'flavours/glitch/components/account';
import { DisplayName } from 'flavours/glitch/components/display_name';
import {
Article,
ItemList,
} from 'flavours/glitch/components/scrollable_list/components';
import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { me } from 'flavours/glitch/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 <DisplayName account={account} variant='simple' />;
};
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 (
<Account
minimal={withoutButton}
withMenu={false}
withBorder={withBorder}
id={accountId}
/>
);
};
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 (
<div className={classes.sensitiveWarning}>
<FormattedMessage
id='collections.detail.sensitive_note'
defaultMessage='This collection contains accounts and content that may be sensitive to some users.'
tagName='p'
/>
<Button onClick={showAnyway}>
<FormattedMessage
id='content_warning.show'
defaultMessage='Show anyway'
tagName={Fragment}
/>
</Button>
</div>
);
};
/**
* 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<HTMLHeadingElement>(null);
const isOwnCollection = collection?.account_id === me;
const { items, currentUserInCollection } = getCollectionItems(collection);
return (
<ItemList
isLoading={isLoading}
emptyMessage={intl.formatMessage(messages.empty)}
>
{collection && currentUserInCollection ? (
<>
<h3 className={classes.columnSubheading}>
<FormattedMessage
id='collections.detail.author_added_you'
defaultMessage='{author} added you to this collection'
values={{
author: <SimpleAuthorName id={collection.account_id} />,
}}
tagName={Fragment}
/>
</h3>
<Article
key={currentUserInCollection.account_id}
aria-posinset={1}
aria-setsize={items.length}
>
<AccountItem
withBorder={false}
accountId={currentUserInCollection.account_id}
collectionOwnerId={collection.account_id}
/>
</Article>
<h3
className={classes.columnSubheading}
tabIndex={-1}
ref={listHeadingRef}
>
<FormattedMessage
id='collections.detail.other_accounts_in_collection'
defaultMessage='Others in this collection:'
tagName={Fragment}
/>
</h3>
</>
) : (
<h3
className='column-subheading sr-only'
tabIndex={-1}
ref={listHeadingRef}
>
{intl.formatMessage(messages.accounts)}
</h3>
)}
{collection && (
<SensitiveScreen
sensitive={!isOwnCollection && collection.sensitive}
focusTargetRef={listHeadingRef}
>
{items.map(({ account_id }, index, items) => (
<Article
key={account_id}
aria-posinset={index + (currentUserInCollection ? 2 : 1)}
aria-setsize={items.length}
>
<AccountItem
accountId={account_id}
collectionOwnerId={collection.account_id}
/>
</Article>
))}
</SensitiveScreen>
)}
</ItemList>
);
};

View File

@@ -6,11 +6,9 @@ import { Helmet } from 'react-helmet';
import { useLocation, useParams } from 'react-router'; import { useLocation, useParams } from 'react-router';
import { openModal } from '@/flavours/glitch/actions/modal'; import { openModal } from '@/flavours/glitch/actions/modal';
import { useRelationship } from '@/flavours/glitch/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 type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections'; import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { Account } from 'flavours/glitch/components/account';
import { Avatar } from 'flavours/glitch/components/avatar'; import { Avatar } from 'flavours/glitch/components/avatar';
import { Column } from 'flavours/glitch/components/column'; import { Column } from 'flavours/glitch/components/column';
import { ColumnHeader } from 'flavours/glitch/components/column_header'; import { ColumnHeader } from 'flavours/glitch/components/column_header';
@@ -19,26 +17,19 @@ import {
LinkedDisplayName, LinkedDisplayName,
} from 'flavours/glitch/components/display_name'; } from 'flavours/glitch/components/display_name';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import { import { Scrollable } from 'flavours/glitch/components/scrollable_list/components';
Article,
ItemList,
Scrollable,
} from 'flavours/glitch/components/scrollable_list/components';
import { Tag } from 'flavours/glitch/components/tags/tag'; import { Tag } from 'flavours/glitch/components/tags/tag';
import { useAccount } from 'flavours/glitch/hooks/useAccount'; import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { me } from 'flavours/glitch/initial_state'; import { me } from 'flavours/glitch/initial_state';
import { fetchCollection } from 'flavours/glitch/reducers/slices/collections'; import { fetchCollection } from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { CollectionAccountsList } from './collection_list';
import { CollectionMetaData } from './collection_list_item'; import { CollectionMetaData } from './collection_list_item';
import { CollectionMenu } from './collection_menu'; import { CollectionMenu } from './collection_menu';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
const messages = defineMessages({ const messages = defineMessages({
empty: {
id: 'collections.accounts.empty_title',
defaultMessage: 'This collection is empty',
},
loading: { loading: {
id: 'collections.detail.loading', id: 'collections.detail.loading',
defaultMessage: 'Loading collection…', defaultMessage: 'Loading collection…',
@@ -47,10 +38,6 @@ const messages = defineMessages({
id: 'collections.detail.share', id: 'collections.detail.share',
defaultMessage: 'Share this collection', defaultMessage: 'Share this collection',
}, },
accounts: {
id: 'collections.detail.accounts_heading',
defaultMessage: 'Accounts',
},
}); });
export const AuthorNote: React.FC<{ id: string; previewMode?: boolean }> = ({ export const AuthorNote: React.FC<{ id: string; previewMode?: boolean }> = ({
@@ -149,33 +136,10 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
collection={collection} collection={collection}
className={classes.metaData} className={classes.metaData}
/> />
<h2 className='sr-only'>{intl.formatMessage(messages.accounts)}</h2>
</div> </div>
); );
}; };
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 }) => {
@@ -185,7 +149,6 @@ export const CollectionDetailPage: React.FC<{
const collection = useAppSelector((state) => const collection = useAppSelector((state) =>
id ? state.collections.collections[id] : undefined, id ? state.collections.collections[id] : undefined,
); );
const isLoading = !!id && !collection; const isLoading = !!id && !collection;
useEffect(() => { useEffect(() => {
@@ -208,24 +171,7 @@ export const CollectionDetailPage: React.FC<{
<Scrollable> <Scrollable>
{collection && <CollectionHeader collection={collection} />} {collection && <CollectionHeader collection={collection} />}
<ItemList <CollectionAccountsList collection={collection} isLoading={isLoading} />
isLoading={isLoading}
emptyMessage={intl.formatMessage(messages.empty)}
>
{collection?.items.map(({ account_id }, index, items) => (
<Article
key={account_id}
data-id={account_id}
aria-posinset={index + 1}
aria-setsize={items.length}
>
<CollectionAccountItem
accountId={account_id}
collectionOwnerId={collection.account_id}
/>
</Article>
))}
</ItemList>
</Scrollable> </Scrollable>
<Helmet> <Helmet>

View File

@@ -57,6 +57,18 @@
font-size: 15px; 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 { .displayNameWithAvatar {
display: inline-flex; display: inline-flex;
gap: 4px; gap: 4px;
@@ -76,3 +88,18 @@
align-self: center; 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;
}

View File

@@ -6,7 +6,7 @@ import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react'; 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 SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { Column } from 'flavours/glitch/components/column'; import { Column } from 'flavours/glitch/components/column';
import { ColumnHeader } from 'flavours/glitch/components/column_header'; import { ColumnHeader } from 'flavours/glitch/components/column_header';
@@ -73,8 +73,8 @@ export const Collections: React.FC<{
> >
<ColumnHeader <ColumnHeader
title={intl.formatMessage(messages.heading)} title={intl.formatMessage(messages.heading)}
icon='list-ul' icon='collections'
iconComponent={ListAltIcon} iconComponent={CollectionsFilledIcon}
multiColumn={multiColumn} multiColumn={multiColumn}
extraButton={ extraButton={
<Link <Link

View File

@@ -2074,7 +2074,10 @@ body > [data-popper-placement] {
.account { .account {
padding: 10px; // glitch: reduced padding padding: 10px; // glitch: reduced padding
border-bottom: 1px solid var(--color-border-primary);
&:not(&--without-border) {
border-bottom: 1px solid var(--color-border-primary);
}
.account__display-name { .account__display-name {
flex: 1 1 auto; flex: 1 1 auto;