[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:
parent
9177a3e807
commit
2e70474cde
@ -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>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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='You’ve 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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 { 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 { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
|
||||||
import type { RenderButtonOptions } from 'flavours/glitch/components/account_list_item';
|
import type { RenderButtonOptions } from 'flavours/glitch/components/account_list_item';
|
||||||
import {
|
import {
|
||||||
@ -10,11 +18,13 @@ import {
|
|||||||
} from 'flavours/glitch/components/account_list_item';
|
} from 'flavours/glitch/components/account_list_item';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { Callout } from 'flavours/glitch/components/callout';
|
import { Callout } from 'flavours/glitch/components/callout';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import {
|
import {
|
||||||
Article,
|
Article,
|
||||||
ItemList,
|
ItemList,
|
||||||
} from 'flavours/glitch/components/scrollable_list/components';
|
} from 'flavours/glitch/components/scrollable_list/components';
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
|
import type { Account } from 'flavours/glitch/models/account';
|
||||||
|
|
||||||
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
|
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
|
||||||
import classes from './styles.module.scss';
|
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<{
|
export const CollectionAccountsList: React.FC<{
|
||||||
collection?: ApiCollectionJSON;
|
collection?: ApiCollectionJSON;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@ -77,9 +99,44 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const confirmRevoke = useConfirmRevoke(collection);
|
const confirmRevoke = useConfirmRevoke(collection);
|
||||||
const listHeadingRef = useRef<HTMLHeadingElement>(null);
|
const listHeadingRef = useRef<HTMLHeadingElement>(null);
|
||||||
|
const [canShowHiddenAccounts, setCanShowHiddenAccounts] = useState(false);
|
||||||
|
const toggleHiddenAccounts = useCallback(() => {
|
||||||
|
setCanShowHiddenAccounts((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isOwnCollection = collection?.account_id === me;
|
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(
|
const renderAccountItemButton = useCallback(
|
||||||
({ relationship, accountId }: RenderButtonOptions) => {
|
({ relationship, accountId }: RenderButtonOptions) => {
|
||||||
@ -137,19 +194,71 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage={intl.formatMessage(messages.empty)}
|
emptyMessage={intl.formatMessage(messages.empty)}
|
||||||
>
|
>
|
||||||
{items.map(({ account_id }, index) => (
|
{visibleAccounts.map(({ id }, index) => (
|
||||||
<Article
|
<Article
|
||||||
key={account_id}
|
key={id}
|
||||||
aria-posinset={index + 1}
|
aria-posinset={index + 1}
|
||||||
aria-setsize={items.length}
|
aria-setsize={initialListSize}
|
||||||
>
|
>
|
||||||
<AccountListItem
|
<AccountListItem
|
||||||
accountId={account_id}
|
accountId={id}
|
||||||
withBorder={index !== items.length - 1}
|
withBorder={index !== items.length - 1 || hasHiddenAccounts}
|
||||||
renderButton={renderAccountItemButton}
|
renderButton={renderAccountItemButton}
|
||||||
/>
|
/>
|
||||||
</Article>
|
</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='You’ve 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>
|
</ItemList>
|
||||||
</SensitiveScreen>
|
</SensitiveScreen>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user