[Glitch] Allow revealing blocked/muted accounts in a collection

Port 02deb0b2381e116b512a826064f6d4008c260cd5 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion 2026-04-13 17:41:47 +02:00 committed by Claire
parent 9177a3e807
commit 2e70474cde
4 changed files with 286 additions and 7 deletions

View File

@ -0,0 +1,82 @@
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import classes from './styles.module.scss';
interface WrapperProps extends Omit<
React.ComponentPropsWithoutRef<'div'>,
'title'
> {
icon?: React.ReactNode;
iconEnd?: React.ReactNode;
}
/**
* A basic list item component that can be used as a base for more bespoke list items.
*
* Depending on functionality, use `ListItemButton` or `ListItemLink` as a child of the
* wrapper component.
*/
export const ListItemWrapper: React.FC<WrapperProps> = ({
icon,
iconEnd,
children,
className,
...otherProps
}) => {
return (
<div {...otherProps} className={classNames(classes.wrapper, className)}>
{icon}
<div>{children}</div>
{iconEnd && <span className={classes.iconEnd}>{iconEnd}</span>}
</div>
);
};
interface LinkProps extends React.ComponentPropsWithoutRef<typeof Link> {
subtitle?: React.ReactNode;
}
export const ListItemLink: React.FC<LinkProps> = ({
subtitle,
children,
className,
...otherProps
}) => {
return (
<>
<h3 className={classes.title}>
<Link className={classNames(className, 'focusable')} {...otherProps}>
{children}
</Link>
</h3>
{subtitle && <div className={classes.subtitle}>{subtitle}</div>}
</>
);
};
interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
subtitle?: React.ReactNode;
}
export const ListItemButton: React.FC<ButtonProps> = ({
subtitle,
children,
className,
...otherProps
}) => {
return (
<>
<h3 className={classes.title}>
<button
type='button'
className={classNames(className, 'focusable')}
{...otherProps}
>
{children}
</button>
</h3>
{subtitle && <div className={classes.subtitle}>{subtitle}</div>}
</>
);
};

View File

@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import { Icon } from '../icon';
import { ListItemWrapper, ListItemButton, ListItemLink } from './index';
const meta = {
title: 'Components/ListItem',
component: ListItemWrapper,
subcomponents: { ListItemButton, ListItemLink },
} satisfies Meta<typeof ListItemWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const WithButton: Story = {
render: () => (
<ListItemWrapper
icon={<Icon icon={VisibilityOffIcon} id='visibility' />}
iconEnd={<Icon icon={KeyboardArrowDownIcon} id='down' />}
>
<ListItemButton subtitle='Youve blocked or muted these users'>
3 hidden accounts
</ListItemButton>
</ListItemWrapper>
),
};
export const WithLink: Story = {
render: () => (
<ListItemWrapper
icon={<Icon icon={VisibilityIcon} id='visibility' />}
iconEnd={<Icon icon={ChevronRightIcon} id='right' />}
>
<ListItemLink to='/'>View more</ListItemLink>
</ListItemWrapper>
),
};

View File

@ -0,0 +1,44 @@
.wrapper {
box-sizing: border-box;
position: relative;
display: flex;
align-items: center;
width: 100%;
gap: 12px;
padding: 16px;
font-size: 15px;
color: var(--color-text-primary);
}
.title {
font-weight: 500;
:any-link,
button {
padding: 0;
border: none;
color: inherit;
background: none;
font: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
&::before {
content: '';
position: absolute;
inset: 0;
}
}
}
.subtitle {
font-size: 13px;
color: var(--color-text-secondary);
}
.iconEnd {
margin-inline-start: auto;
}

View File

@ -1,7 +1,15 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import {
ListItemButton,
ListItemWrapper,
} from '@/flavours/glitch/components/list_item';
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import type { RenderButtonOptions } from 'flavours/glitch/components/account_list_item';
import {
@ -10,11 +18,13 @@ import {
} from 'flavours/glitch/components/account_list_item';
import { Button } from 'flavours/glitch/components/button';
import { Callout } from 'flavours/glitch/components/callout';
import { Icon } from 'flavours/glitch/components/icon';
import {
Article,
ItemList,
} from 'flavours/glitch/components/scrollable_list/components';
import { me } from 'flavours/glitch/initial_state';
import type { Account } from 'flavours/glitch/models/account';
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
import classes from './styles.module.scss';
@ -70,6 +80,18 @@ const SensitiveScreen: React.FC<{
);
};
const getCollectionAccounts = createAppSelector(
[
(state) => state.accounts,
(state, collectionId?: string) =>
state.collections.collections[collectionId ?? '']?.items,
],
(accounts, collectionAccountItems) =>
(collectionAccountItems ?? []).map(({ account_id }) =>
account_id ? accounts.get(account_id) : null,
),
);
export const CollectionAccountsList: React.FC<{
collection?: ApiCollectionJSON;
isLoading: boolean;
@ -77,9 +99,44 @@ export const CollectionAccountsList: React.FC<{
const intl = useIntl();
const confirmRevoke = useConfirmRevoke(collection);
const listHeadingRef = useRef<HTMLHeadingElement>(null);
const [canShowHiddenAccounts, setCanShowHiddenAccounts] = useState(false);
const toggleHiddenAccounts = useCallback(() => {
setCanShowHiddenAccounts((prev) => !prev);
}, []);
const isOwnCollection = collection?.account_id === me;
const { items = [], account_id: collectionOwnerId } = collection ?? {};
const { items = [], account_id: collectionOwnerId, id } = collection ?? {};
const relationships = useAppSelector((state) => state.relationships);
const collectionAccounts = useAppSelector((state) =>
getCollectionAccounts(state, id),
);
const { visibleAccounts, hiddenAccounts } = useMemo(() => {
const visibleAccounts: Account[] = [];
const hiddenAccounts: Account[] = [];
collectionAccounts.forEach((item) => {
if (!item) {
// We currently simply hide unavailable accounts, this includes
// accounts that are pending inclusion; at least for the collection
// owner we should display an indication of pending users
return;
}
const relationship = relationships.get(item.id);
if (relationship?.blocking || relationship?.muting) {
hiddenAccounts.push(item);
} else {
visibleAccounts.push(item);
}
});
return { visibleAccounts, hiddenAccounts };
}, [collectionAccounts, relationships]);
const hasHiddenAccounts = hiddenAccounts.length > 0;
const initialListSize = visibleAccounts.length + (hasHiddenAccounts ? 1 : 0);
const renderAccountItemButton = useCallback(
({ relationship, accountId }: RenderButtonOptions) => {
@ -137,19 +194,71 @@ export const CollectionAccountsList: React.FC<{
isLoading={isLoading}
emptyMessage={intl.formatMessage(messages.empty)}
>
{items.map(({ account_id }, index) => (
{visibleAccounts.map(({ id }, index) => (
<Article
key={account_id}
key={id}
aria-posinset={index + 1}
aria-setsize={items.length}
aria-setsize={initialListSize}
>
<AccountListItem
accountId={account_id}
withBorder={index !== items.length - 1}
accountId={id}
withBorder={index !== items.length - 1 || hasHiddenAccounts}
renderButton={renderAccountItemButton}
/>
</Article>
))}
{hasHiddenAccounts && (
<Article
aria-posinset={initialListSize}
aria-setsize={initialListSize}
>
<ListItemWrapper
icon={<Icon id='visibility-off' icon={VisibilityOffIcon} />}
iconEnd={
<Icon
id='open-status'
icon={
canShowHiddenAccounts
? KeyboardArrowUpIcon
: KeyboardArrowDownIcon
}
/>
}
>
<ListItemButton
aria-expanded={canShowHiddenAccounts}
onClick={toggleHiddenAccounts}
subtitle={
<FormattedMessage
id='collections.hidden_accounts_description'
defaultMessage='Youve blocked or muted {count, plural, one {this user} other {these users}}'
values={{ count: hiddenAccounts.length }}
/>
}
>
<FormattedMessage
id='collections.hidden_accounts_link'
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
values={{ count: hiddenAccounts.length }}
/>
</ListItemButton>
</ListItemWrapper>
</Article>
)}
{canShowHiddenAccounts &&
hiddenAccounts.map(({ id }, index) => (
<Article
key={id}
aria-posinset={initialListSize + index + 1}
aria-setsize={initialListSize + hiddenAccounts.length}
>
<AccountListItem
accountId={id}
withBorder={index !== hiddenAccounts.length - 1}
renderButton={renderAccountItemButton}
/>
</Article>
))}
</ItemList>
</SensitiveScreen>
)}