Allow users to change how accounts are sorted when viewing a collection (#39073)
Some checks failed
Check i18n / check-i18n (push) Waiting to run
Chromatic / Check for relevant changes (push) Waiting to run
Chromatic / Run Chromatic (push) Blocked by required conditions
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / test (3.4) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.4) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.29) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.19.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.29) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.4, docker.elastic.co/elasticsearch/elasticsearch:7.17.29) (push) Blocked by required conditions
Crowdin / Upload translations / upload-translations (push) Has been cancelled
CSS Linting / lint (push) Has been cancelled

This commit is contained in:
diondiondion 2026-05-18 18:48:40 +02:00 committed by GitHub
parent 07a05e1edf
commit c26003af21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 156 additions and 19 deletions

View File

@ -48,10 +48,14 @@
.status {
// If there's no content, we need to compensate for the parent's
// flex gap to avoid extra spacing below the field.
// flex gap to avoid extra spacing below or next to the field.
&:empty {
margin-top: calc(-1 * var(--form-field-label-gap));
}
[data-input-placement^='inline'] &:empty {
margin-inline-start: calc(-1 * var(--form-field-label-gap));
}
}
.inputWrapper {

View File

@ -24,7 +24,7 @@ export interface FieldStatus {
message?: string;
}
interface FieldWrapperProps {
export interface FieldWrapperProps {
label: ReactNode;
hint?: ReactNode;
required?: boolean;

View File

@ -4,11 +4,17 @@ import { forwardRef } from 'react';
import classNames from 'classnames';
import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import type {
CommonFieldWrapperProps,
FieldWrapperProps,
} from './form_field_wrapper';
import classes from './select.module.scss';
interface Props
extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {}
extends
ComponentPropsWithoutRef<'select'>,
CommonFieldWrapperProps,
Pick<FieldWrapperProps, 'inputPlacement'> {}
/**
* A simple form field for single-item selections.
@ -19,13 +25,28 @@ interface Props
*/
export const SelectField = forwardRef<HTMLSelectElement, Props>(
({ id, label, hint, required, status, children, ...otherProps }, ref) => (
(
{
id,
label,
hint,
required,
status,
inputPlacement,
children,
wrapperClassName,
...otherProps
},
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
status={status}
inputId={id}
inputPlacement={inputPlacement}
className={wrapperClassName}
>
{(inputProps) => (
<Select {...otherProps} {...inputProps} ref={ref}>

View File

@ -3,6 +3,8 @@ import { useCallback, useMemo, useRef } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { PendingBadge } from '@/mastodon/components/badge';
import { SelectField } from '@/mastodon/components/form_fields';
import { useSearchParam } from '@/mastodon/hooks/useSearchParam';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import type {
ApiCollectionJSON,
@ -53,6 +55,43 @@ const getCollectionItems = createAppSelector(
),
);
function sortAccounts(
accounts: CollectionItemWithAccount[],
sortBy?: string,
): CollectionItemWithAccount[] {
if (!sortBy || sortBy === 'date_added') {
return accounts;
}
const sorted = [...accounts];
switch (sortBy) {
case 'alphabetical':
return sorted.sort((a, b) => {
const nameA = a.account?.display_name ?? '';
const nameB = b.account?.display_name ?? '';
return nameA.localeCompare(nameB);
});
case 'last_active':
return sorted.sort((a, b) => {
const dateA = a.account?.last_status_at ?? '';
const dateB = b.account?.last_status_at ?? '';
return new Date(dateB).getTime() - new Date(dateA).getTime();
});
case 'most_followers':
return sorted.sort((a, b) => {
const followersA = a.account?.followers_count ?? 0;
const followersB = b.account?.followers_count ?? 0;
return followersB - followersA;
});
default:
return accounts;
}
}
export const CollectionAccountsList: React.FC<{
collection: ApiCollectionJSON;
}> = ({ collection }) => {
@ -68,11 +107,20 @@ export const CollectionAccountsList: React.FC<{
getCollectionItems(state, id),
);
const [sortBy, setSortBy] = useSearchParam('sort', 'date_added');
const changeSortBy = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
setSortBy(event.target.value);
},
[setSortBy],
);
const sortedAccounts = sortAccounts(collectionAccounts, sortBy);
const { visibleAccounts, hiddenAccounts } = useMemo(() => {
const visibleAccounts: CollectionItemWithAccount[] = [];
const hiddenAccounts: CollectionItemWithAccount[] = [];
collectionAccounts.forEach((item) => {
sortedAccounts.forEach((item) => {
const { account, account_id } = item;
if (!isOwnCollection && !account) {
@ -89,7 +137,7 @@ export const CollectionAccountsList: React.FC<{
});
return { visibleAccounts, hiddenAccounts };
}, [collectionAccounts, isOwnCollection, relationships]);
}, [sortedAccounts, isOwnCollection, relationships]);
const renderAccountItemButton = useCallback(
({ relationship, accountId }: RenderButtonOptions) => {
@ -147,17 +195,57 @@ export const CollectionAccountsList: React.FC<{
return (
<>
<h3
className={classes.columnSubheading}
tabIndex={-1}
ref={listHeadingRef}
>
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
/>
</h3>
<div className={classes.subheadingWithSelect}>
<h3
className={classes.columnSubheading}
tabIndex={-1}
ref={listHeadingRef}
>
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
/>
</h3>
<SelectField
label={
<FormattedMessage
id='collections.sort_by'
defaultMessage='Sort by:'
/>
}
value={sortBy}
onChange={changeSortBy}
inputPlacement='inline-end'
className={classes.select}
wrapperClassName={classes.selectWrapper}
>
<option value='alphabetical'>
<FormattedMessage
id='collections.sort_alphabetical'
defaultMessage='Alphabetical'
/>
</option>
<option value='last_active'>
<FormattedMessage
id='collections.sort_last_active'
defaultMessage='Last active'
/>
</option>
<option value='most_followers'>
<FormattedMessage
id='collections.sort_most_followers'
defaultMessage='Most followers'
/>
</option>
<option value='date_added'>
<FormattedMessage
id='collections.sort_date_added'
defaultMessage='Date added'
/>
</option>
</SelectField>
</div>
<ItemList emptyMessage={intl.formatMessage(messages.empty)}>
<TruncatedListItems
visibleItems={visibleAccounts}

View File

@ -67,8 +67,27 @@
margin-inline: 16px;
}
.columnSubheading {
.subheadingWithSelect {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
padding-inline: 16px;
}
.selectWrapper {
// Align to right edge even when wrapped to new line
margin-inline-start: auto;
}
.select {
height: 36px;
border-radius: 8px;
font-size: 15px;
background-color: var(--color-bg-primary);
}
.columnSubheading {
font-size: 15px;
font-weight: 500;
}

View File

@ -425,6 +425,11 @@
"collections.search_accounts_max_reached": "You have added the maximum number of accounts",
"collections.sensitive": "Sensitive",
"collections.share_short": "Share",
"collections.sort_alphabetical": "Alphabetical",
"collections.sort_by": "Sort by:",
"collections.sort_date_added": "Date added",
"collections.sort_last_active": "Last active",
"collections.sort_most_followers": "Most followers",
"collections.suggestions.can_not_add": "Cant be added",
"collections.suggestions.can_not_add_desc": "These accounts may have opted out of discovery, or they might be on a server that doesnt support collections.",
"collections.suggestions.must_follow": "Must follow first",