[Glitch] Update collection account list design

Port 225069d1f2161c94c696c74405ce2a058154bafc to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion 2026-04-02 15:32:05 +02:00 committed by Claire
parent 1505cdd3a2
commit b43005ac12
4 changed files with 183 additions and 183 deletions

View File

@ -2,26 +2,26 @@ import { useCallback, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Callout } from '@/flavours/glitch/components/callout'; import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { FollowButton } from '@/flavours/glitch/components/follow_button';
import { openModal } from 'flavours/glitch/actions/modal';
import type {
ApiCollectionJSON,
CollectionAccountItem,
} from 'flavours/glitch/api_types/collections';
import { Account } from 'flavours/glitch/components/account'; import { Account } from 'flavours/glitch/components/account';
import { Button } from 'flavours/glitch/components/button'; import { Button } from 'flavours/glitch/components/button';
import { DisplayName } from 'flavours/glitch/components/display_name'; import { Callout } from 'flavours/glitch/components/callout';
import { FollowButton } from 'flavours/glitch/components/follow_button';
import {
NumberFields,
NumberFieldsItem,
} from 'flavours/glitch/components/number_fields';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import { import {
Article, Article,
ItemList, ItemList,
} from 'flavours/glitch/components/scrollable_list/components'; } from 'flavours/glitch/components/scrollable_list/components';
import { ShortNumber } from 'flavours/glitch/components/short_number';
import { useAccount } from 'flavours/glitch/hooks/useAccount'; import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { useDismissible } from 'flavours/glitch/hooks/useDismissible';
import { useRelationship } from 'flavours/glitch/hooks/useRelationship'; import { useRelationship } from 'flavours/glitch/hooks/useRelationship';
import { me } from 'flavours/glitch/initial_state'; import { me } from 'flavours/glitch/initial_state';
import { useAppDispatch } from 'flavours/glitch/store';
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
const messages = defineMessages({ const messages = defineMessages({
@ -31,28 +31,33 @@ const messages = defineMessages({
}, },
}); });
const SimpleAuthorName: React.FC<{ id: string }> = ({ id }) => {
const account = useAccount(id);
return <DisplayName account={account} variant='simple' />;
};
const AccountItem: React.FC<{ const AccountItem: React.FC<{
accountId: string | undefined; accountId: string | undefined;
collectionOwnerId: string; collectionOwnerId: string;
onRevoke: () => void;
withBio?: boolean; withBio?: boolean;
withBorder?: boolean; withBorder?: boolean;
}> = ({ accountId, withBio = true, withBorder = true, collectionOwnerId }) => { }> = ({
accountId,
collectionOwnerId,
onRevoke,
withBio = true,
withBorder = true,
}) => {
const intl = useIntl();
const account = useAccount(accountId);
const relationship = useRelationship(accountId); const relationship = useRelationship(accountId);
if (!accountId) { if (!accountId || !account) {
return null; return null;
} }
// When viewing your own collection, only show the Follow button // When viewing your own collection, only show the Follow button
// for accounts you're not following (anymore). // for accounts you're not following (anymore).
// Otherwise, always show the follow button in its various states. // Otherwise, always show the follow button in its various states.
const isOwnAccount = accountId === me;
const withoutButton = const withoutButton =
accountId === me || isOwnAccount ||
!relationship || !relationship ||
(collectionOwnerId === me && (collectionOwnerId === me &&
(relationship.following || relationship.requested)); (relationship.following || relationship.requested));
@ -66,52 +71,55 @@ const AccountItem: React.FC<{
withBorder={false} withBorder={false}
withMenu={false} withMenu={false}
className={classes.accountItem} className={classes.accountItem}
extraAccountInfo={
<NumberFields>
<NumberFieldsItem
label={
<FormattedMessage
id='account.followers'
defaultMessage='Followers'
/>
}
hint={intl.formatNumber(account.followers_count)}
>
<ShortNumber value={account.followers_count} />
</NumberFieldsItem>
<NumberFieldsItem
label={
<FormattedMessage id='account.posts' defaultMessage='Posts' />
}
hint={intl.formatNumber(account.statuses_count)}
>
<ShortNumber value={account.statuses_count} />
</NumberFieldsItem>
<NumberFieldsItem
label={
<FormattedMessage
id='account.last_active'
defaultMessage='Last active'
/>
}
>
<RelativeTimestamp
long
timestamp={account.last_status_at}
noFuture
/>
</NumberFieldsItem>
</NumberFields>
}
/> />
{!withoutButton && <FollowButton accountId={accountId} />} {!withoutButton && <FollowButton accountId={accountId} />}
</div> {isOwnAccount && (
); <Button secondary onClick={onRevoke}>
}; <FormattedMessage
id='collections.detail.revoke_inclusion'
const RevokeControls: React.FC<{ defaultMessage='Remove me'
collectionId: string; />
collectionItem: CollectionAccountItem; </Button>
}> = ({ collectionId, collectionItem }) => { )}
const dispatch = useAppDispatch();
const confirmRevoke = useCallback(() => {
void dispatch(
openModal({
modalType: 'REVOKE_COLLECTION_INCLUSION',
modalProps: {
collectionId,
collectionItemId: collectionItem.id,
},
}),
);
}, [collectionId, collectionItem.id, dispatch]);
const { wasDismissed, dismiss } = useDismissible(
`collection-revoke-hint-${collectionItem.id}`,
);
if (wasDismissed) {
return null;
}
return (
<div className={classes.revokeControlWrapper}>
<Button secondary onClick={dismiss}>
<FormattedMessage
id='collections.detail.accept_inclusion'
defaultMessage='Okay'
/>
</Button>
<Button secondary onClick={confirmRevoke}>
<FormattedMessage
id='collections.detail.revoke_inclusion'
defaultMessage='Remove me'
/>
</Button>
</div> </div>
); );
}; };
@ -159,46 +167,16 @@ const SensitiveScreen: React.FC<{
); );
}; };
/**
* 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<{ export const CollectionAccountsList: React.FC<{
collection?: ApiCollectionJSON; collection?: ApiCollectionJSON;
isLoading: boolean; isLoading: boolean;
}> = ({ collection, isLoading }) => { }> = ({ collection, isLoading }) => {
const intl = useIntl(); const intl = useIntl();
const confirmRevoke = useConfirmRevoke(collection);
const listHeadingRef = useRef<HTMLHeadingElement>(null); const listHeadingRef = useRef<HTMLHeadingElement>(null);
const isOwnCollection = collection?.account_id === me; const isOwnCollection = collection?.account_id === me;
const { items, currentUserInCollection } = getCollectionItems(collection); const { items = [] } = collection ?? {};
return ( return (
<ItemList <ItemList
@ -206,80 +184,40 @@ export const CollectionAccountsList: React.FC<{
emptyMessage={intl.formatMessage(messages.empty)} emptyMessage={intl.formatMessage(messages.empty)}
className={classes.itemList} className={classes.itemList}
> >
{collection && currentUserInCollection ? ( <h3
<> className={classes.columnSubheading}
<h3 className={classes.columnSubheading}> tabIndex={-1}
<FormattedMessage ref={listHeadingRef}
id='collections.detail.you_were_added_to_this_collection' >
defaultMessage='You were added to this collection' {collection ? (
values={{ <FormattedMessage
author: <SimpleAuthorName id={collection.account_id} />, id='collections.account_count'
}} defaultMessage='{count, plural, one {# account} other {# accounts}}'
/> values={{ count: collection.item_count }}
</h3> />
<Article ) : (
key={currentUserInCollection.account_id} <FormattedMessage
aria-posinset={1} id='collections.detail.accounts_heading'
aria-setsize={items.length} defaultMessage='Accounts'
className={classes.youWereAddedWrapper} />
> )}
<AccountItem </h3>
withBorder={false}
withBio={false}
accountId={currentUserInCollection.account_id}
collectionOwnerId={collection.account_id}
/>
<RevokeControls
collectionId={collection.id}
collectionItem={currentUserInCollection}
/>
</Article>
<h3
className={classes.columnSubheading}
tabIndex={-1}
ref={listHeadingRef}
>
<FormattedMessage
id='collections.detail.other_accounts_count'
defaultMessage='{count, plural, one {# other account} other {# other accounts}}'
values={{ count: collection.item_count - 1 }}
/>
</h3>
</>
) : (
<h3
className={classes.columnSubheading}
tabIndex={-1}
ref={listHeadingRef}
>
{collection ? (
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
/>
) : (
<FormattedMessage
id='collections.detail.accounts_heading'
defaultMessage='Accounts'
/>
)}
</h3>
)}
{collection && ( {collection && (
<SensitiveScreen <SensitiveScreen
sensitive={!isOwnCollection && collection.sensitive} sensitive={!isOwnCollection && collection.sensitive}
focusTargetRef={listHeadingRef} focusTargetRef={listHeadingRef}
> >
{items.map(({ account_id }, index, items) => ( {items.map(({ account_id }, index) => (
<Article <Article
key={account_id} key={account_id}
aria-posinset={index + (currentUserInCollection ? 2 : 1)} aria-posinset={index + 1}
aria-setsize={items.length} aria-setsize={items.length}
> >
<AccountItem <AccountItem
withBorder={index !== items.length - 1}
accountId={account_id} accountId={account_id}
collectionOwnerId={collection.account_id} collectionOwnerId={collection.account_id}
onRevoke={confirmRevoke}
/> />
</Article> </Article>
))} ))}

View File

@ -11,17 +11,20 @@ import { useAccountHandle } from '@/flavours/glitch/components/display_name/defa
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 { Callout } from 'flavours/glitch/components/callout';
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';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import { Scrollable } from 'flavours/glitch/components/scrollable_list/components'; import { Scrollable } from 'flavours/glitch/components/scrollable_list/components';
import { useAccount } from 'flavours/glitch/hooks/useAccount'; import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { domain } from 'flavours/glitch/initial_state'; import { domain, 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 './accounts_list'; import { CollectionAccountsList } from './accounts_list';
import { CollectionMenu } from './collection_menu'; import { CollectionMenu } from './collection_menu';
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
const messages = defineMessages({ const messages = defineMessages({
@ -62,14 +65,54 @@ export const AuthorNote: React.FC<{ id: string }> = ({ id }) => {
); );
}; };
export const RevokeControls: React.FC<{
collection: ApiCollectionJSON;
}> = ({ collection }) => {
const authorAccount = useAccount(collection.account_id);
const confirmRevoke = useConfirmRevoke(collection);
return (
<Callout
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: '{date}', // TODO: Data not yet provided by API
}}
/>
</Callout>
);
};
const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
collection, collection,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { name, description, tag, account_id } = collection; const { name, description, tag, account_id, items } = collection;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const history = useHistory(); const history = useHistory();
const isOwnCollection = account_id === me;
const currentUserIndex = items.findIndex(
(account) => account.account_id === me,
);
const isCurrentUserInCollection = !isOwnCollection && currentUserIndex > -1;
const handleShare = useCallback(() => { const handleShare = useCallback(() => {
dispatch( dispatch(
openModal({ openModal({
@ -115,6 +158,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
</div> </div>
</div> </div>
{description && <p className={classes.description}>{description}</p>} {description && <p className={classes.description}>{description}</p>}
{isCurrentUserInCollection && <RevokeControls collection={collection} />}
</header> </header>
); );
}; };

View File

@ -3,8 +3,11 @@ import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { showAlert } from 'flavours/glitch/actions/alerts'; import { showAlert } from 'flavours/glitch/actions/alerts';
import { openModal } from 'flavours/glitch/actions/modal';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import type { BaseConfirmationModalProps } from 'flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal'; import type { BaseConfirmationModalProps } from 'flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal';
import { ConfirmationModal } from 'flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal'; import { ConfirmationModal } from 'flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal';
import { me } from 'flavours/glitch/initial_state';
import { revokeCollectionInclusion } from 'flavours/glitch/reducers/slices/collections'; import { revokeCollectionInclusion } from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
@ -24,6 +27,24 @@ const messages = defineMessages({
}, },
}); });
export function useConfirmRevoke(collection?: ApiCollectionJSON) {
const dispatch = useAppDispatch();
const { id, items = [] } = collection ?? {};
const ownCollectionItemId = items.find((item) => item.account_id === me)?.id;
return useCallback(() => {
void dispatch(
openModal({
modalType: 'REVOKE_COLLECTION_INCLUSION',
modalProps: {
collectionId: id,
collectionItemId: ownCollectionItemId,
},
}),
);
}, [dispatch, id, ownCollectionItemId]);
}
export const RevokeCollectionInclusionModal: React.FC< export const RevokeCollectionInclusionModal: React.FC<
{ {
collectionId: string; collectionId: string;

View File

@ -1,4 +1,7 @@
.header { .header {
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px; padding: 24px;
} }
@ -49,7 +52,6 @@
.description { .description {
font-size: 15px; font-size: 15px;
margin-top: 12px;
} }
.headerButtonWrapper { .headerButtonWrapper {
@ -59,8 +61,14 @@
.iconButton { .iconButton {
box-sizing: content-box; box-sizing: content-box;
padding: 5px; padding: 7px;
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--color-border-primary);
svg {
width: 20px;
height: 20px;
}
} }
.itemList { .itemList {
@ -81,6 +89,18 @@
&[data-with-border='true'] { &[data-with-border='true'] {
border-bottom: 1px solid var(--color-border-primary); border-bottom: 1px solid var(--color-border-primary);
} }
:global(.account__note) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
}
// Hide 'No description provided' message added by `Account` component
:global(.account__note--missing) {
display: none;
}
} }
.accountItem { .accountItem {
@ -93,26 +113,3 @@
flex-grow: 1; flex-grow: 1;
} }
.youWereAddedWrapper {
padding-bottom: 16px;
}
.revokeControlWrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 8px;
:global(.button) {
min-width: 30%;
white-space: normal;
}
--avatar-width: 46px;
@container (width < 360px) {
--avatar-width: 35px;
}
}