Merge commit 'c26003af215da7696e2b0dcece7fa695b1be8871' into glitch-soc/merge-upstream

This commit is contained in:
Claire committed 2026-05-18 19:35:08 +02:00
commit 42ec9a607b
157 files changed
+1064 -1429

No files matched your search

@@ -1,8 +1,10 @@
import { useCallback, useMemo, useRef, useState } from 'react';
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,
@@ -14,7 +16,6 @@ import {
AccountListItemFollowButton,
} from 'mastodon/components/account_list_item';
import { Button } from 'mastodon/components/button';
import { Callout } from 'mastodon/components/callout';
import {
Article,
ItemList,
@@ -35,50 +36,6 @@ const messages = defineMessages({
},
});
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 (
<Callout
variant='warning'
title={
<FormattedMessage
id='collections.detail.sensitive_content'
defaultMessage='Sensitive content'
/>
}
primaryLabel={
<FormattedMessage
id='content_warning.show_short'
defaultMessage='Show'
/>
}
onPrimary={showAnyway}
className={classes.sensitiveScreen}
>
<FormattedMessage
id='collections.detail.sensitive_note'
defaultMessage='The description and accounts may not be suitable for all viewers.'
/>
</Callout>
);
};
type CollectionItemWithAccount = CollectionAccountItem & {
account?: Account | null;
};
@@ -98,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 }) => {
@@ -113,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) {
@@ -134,7 +137,7 @@ export const CollectionAccountsList: React.FC<{
});
return { visibleAccounts, hiddenAccounts };
}, [collectionAccounts, isOwnCollection, relationships]);
}, [sortedAccounts, isOwnCollection, relationships]);
const renderAccountItemButton = useCallback(
({ relationship, accountId }: RenderButtonOptions) => {
@@ -192,46 +195,81 @@ 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>
<SensitiveScreen
sensitive={!isOwnCollection && collection.sensitive}
focusTargetRef={listHeadingRef}
>
<ItemList emptyMessage={intl.formatMessage(messages.empty)}>
<TruncatedListItems
visibleItems={visibleAccounts}
truncatedItems={hiddenAccounts}
toggleButton={{
icon: VisibilityOffIcon,
title: (
<FormattedMessage
id='collections.hidden_accounts_link'
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
values={{ count: hiddenAccounts.length }}
/>
),
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 }}
/>
),
}}
renderListItem={renderListItem}
<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 }}
/>
</ItemList>
</SensitiveScreen>
</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}
truncatedItems={hiddenAccounts}
toggleButton={{
icon: VisibilityOffIcon,
title: (
<FormattedMessage
id='collections.hidden_accounts_link'
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
values={{ count: hiddenAccounts.length }}
/>
),
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 }}
/>
),
}}
renderListItem={renderListItem}
/>
</ItemList>
</>
);
};
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@@ -138,9 +138,40 @@ export const PendingNote: React.FC = () => {
);
};
const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
collection,
const SensitiveContentNote: React.FC<{ onReveal: () => void }> = ({
onReveal,
}) => {
return (
<Callout
variant='warning'
title={
<FormattedMessage
id='collections.detail.sensitive_content'
defaultMessage='Sensitive content'
/>
}
primaryLabel={
<FormattedMessage
id='content_warning.show_short'
defaultMessage='Show'
/>
}
onPrimary={onReveal}
className={classes.sensitiveScreen}
>
<FormattedMessage
id='collections.detail.sensitive_note'
defaultMessage='The description and accounts may not be suitable for all viewers.'
/>
</Callout>
);
};
const CollectionHeader: React.FC<{
collection: ApiCollectionJSON;
withDescription: boolean;
headingRef: React.RefObject<HTMLHeadingElement>;
}> = ({ collection, withDescription, headingRef }) => {
const intl = useIntl();
const { name, description, tag, account_id, items } = collection;
const dispatch = useAppDispatch();
@@ -181,7 +212,9 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
<div className={classes.titleWithMenu}>
<div className={classes.titleWrapper}>
{tag && <Badge label={`#${tag.name}`} icon={null} />}
<h2 className={classes.name}>{name}</h2>
<h2 className={classes.name} ref={headingRef} tabIndex={-1}>
{name}
</h2>
<AuthorNote id={account_id} />
</div>
<div className={classes.headerButtonWrapper}>
@@ -199,7 +232,9 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
/>
</div>
</div>
{description && <p className={classes.description}>{description}</p>}
{withDescription && description && (
<p className={classes.description}>{description}</p>
)}
{hasPendingAccounts && <PendingNote />}
{isCurrentUserInCollection && (
<RevokeControls
@@ -211,6 +246,52 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
);
};
function useRevealSensitiveContent({
sensitive,
}: {
sensitive: boolean | undefined;
}) {
const postRevealFocusTargetRef = useRef<HTMLHeadingElement>(null);
const [isContentVisible, setIsContentVisible] = useState(!sensitive);
const revealContent = useCallback(() => {
setIsContentVisible(true);
setTimeout(() => {
postRevealFocusTargetRef.current?.focus();
}, 0);
}, [postRevealFocusTargetRef]);
return {
isContentVisible,
revealContent,
postRevealFocusTargetRef,
};
}
const ColumnContent: React.FC<{
collection: ApiCollectionJSON;
}> = ({ collection }) => {
const { isContentVisible, revealContent, postRevealFocusTargetRef } =
useRevealSensitiveContent({
sensitive: collection.sensitive && collection.account_id !== me,
});
return (
<>
<CollectionHeader
collection={collection}
headingRef={postRevealFocusTargetRef}
withDescription={isContentVisible}
/>
{isContentVisible ? (
<CollectionAccountsList collection={collection} />
) : (
<SensitiveContentNote onReveal={revealContent} />
)}
</>
);
};
export const CollectionDetailPage: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
@@ -241,10 +322,7 @@ export const CollectionDetailPage: React.FC<{
<Scrollable>
{collection ? (
<>
<CollectionHeader collection={collection} />
<CollectionAccountsList collection={collection} />
</>
<ColumnContent collection={collection} />
) : (
<LoadingIndicator />
)}
@@ -64,11 +64,30 @@
}
.sensitiveScreen {
margin: 16px;
margin-inline: 16px;
}
.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 {
padding-inline: 16px;
font-size: 15px;
font-weight: 500;
}
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { EmptyState } from 'mastodon/components/empty_state';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { ItemList } from 'mastodon/components/scrollable_list/components';
@@ -34,6 +35,7 @@ function useCollectionsFeaturing(accountId: string | null | undefined) {
export const CollectionsFeaturingYou: React.FC = () => {
const accountId = useAccountId();
const account = useAccount(accountId);
const { collections, status } = useCollectionsFeaturing(accountId);
@@ -46,16 +48,43 @@ export const CollectionsFeaturingYou: React.FC = () => {
}
if (collections.length === 0) {
return (
<EmptyState
message={
<FormattedMessage
id='empty_column.collections.featured_in'
defaultMessage='You have not been added to any collections yet.'
/>
}
/>
);
if (account?.discoverable) {
return (
<EmptyState
message={
<FormattedMessage
id='empty_column.collections.featured_in'
defaultMessage='You have not been added to any collections yet.'
/>
}
/>
);
} else {
return (
<EmptyState
message={
<>
<FormattedMessage
id='empty_column.collections.featured_in'
defaultMessage='You have not been added to any collections yet.'
/>
<br />
<FormattedMessage
id='empty_column.collections.featured_in_undiscoverable'
defaultMessage='In order for people to add you to collections, you need to allow featuring in discovery experiences from <link>Preferences > Privacy and reach</link>'
values={{
link: (chunks) => (
<a href='/settings/privacy#account_discoverable'>
{chunks}
</a>
),
}}
/>
</>
}
/>
);
}
}
return (