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

This commit is contained in:
Claire committed 2026-06-07 14:29:57 +02:00
commit c2e6cbb12b
166 files changed
+1123 -500

No files matched your search

@@ -29,7 +29,7 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import { CollectionListItem } from '../collections/components/collection_list_item';
import { useCollectionsCreatedBy } from '../collections/overview/created_by_you';
import { useCollectionsCreatedBy } from '../collections/overview/created_by_account';
import { EmptyMessage } from './components/empty_message';
import { Subheading, SubheadingLink } from './components/subheading';
@@ -0,0 +1,91 @@
// app/javascript/mastodon/features/alt_text_modal/__tests__/index-test.tsx
import { IntlProvider } from 'react-intl';
import { List, Map } from 'immutable';
import { render } from '@testing-library/react';
import { vi } from 'vitest';
import type { RootState } from 'mastodon/store';
import { useAppSelector } from 'mastodon/store';
import { AltTextModal } from '../index';
vi.mock('mastodon/store', () => ({
useAppSelector: vi.fn(),
useAppDispatch: () => vi.fn(),
}));
describe('<AltTextModal />', () => {
const mediaId = '123';
const handleClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
const renderComponent = () => {
return render(
<IntlProvider locale='en' messages={{}}>
<AltTextModal mediaId={mediaId} onClose={handleClose} />
</IntlProvider>,
);
};
it('renders thumbnail upload button when video is unattached', () => {
vi.mocked(useAppSelector).mockImplementation(
(selector: (state: RootState) => unknown) => {
const mockState = {
compose: Map({
language: 'en',
media_attachments: List([
Map({
id: mediaId,
type: 'video',
unattached: true,
meta: Map({ focus: Map({ x: 0, y: 0 }) }),
}),
]),
}),
accounts: Map(),
} as unknown as RootState;
return selector(mockState);
},
);
const { container } = renderComponent();
const uploadInput = container.querySelector('#upload-modal__thumbnail');
expect(uploadInput).not.toBeNull();
});
it('hides thumbnail upload button when video is attached', () => {
vi.mocked(useAppSelector).mockImplementation(
(selector: (state: RootState) => unknown) => {
const mockState = {
compose: Map({
language: 'en',
media_attachments: List([
Map({
id: mediaId,
type: 'video',
unattached: false,
meta: Map({ focus: Map({ x: 0, y: 0 }) }),
}),
]),
}),
accounts: Map(),
} as unknown as RootState;
return selector(mockState);
},
);
const { container } = renderComponent();
const uploadInput = container.querySelector('#upload-modal__thumbnail');
expect(uploadInput).toBeNull();
});
});
@@ -283,6 +283,7 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
);
const type = media?.get('type') as string;
const valid = length(description) <= MAX_LENGTH;
const unattached = media?.get('unattached') as boolean | undefined;
const handleDescriptionChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -433,7 +434,8 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
onPositionChange={handlePositionChange}
/>
{(type === 'audio' || type === 'video') && (
{/* This button is hidden for attached audio/video files, as they are already posted */}
{(type === 'audio' || type === 'video') && unattached && (
<UploadButton
onSelectFile={handleThumbnailChange}
mimeTypes='image/jpeg,image/png,image/gif,image/heic,image/heif,image/webp,image/avif'
@@ -15,7 +15,7 @@ import { IconButton } from 'mastodon/components/icon_button';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { MAX_COLLECTION_ACCOUNT_COUNT } from '../collections/editor/accounts';
import { useCollectionsCreatedBy } from '../collections/overview/created_by_you';
import { useCollectionsCreatedBy } from '../collections/overview/created_by_account';
import { CollectionToggle } from './collection_toggle';
@@ -208,6 +208,12 @@ export const CollectionAccounts: React.FC<{
const hasItems = editorItems.length > 0;
const hasMaxItems = editorItems.length === MAX_COLLECTION_ACCOUNT_COUNT;
const wasAccountAdded = useCallback(
(account: ApiMutedAccountJSON) =>
!!editorItems.find((item) => item.account_id === account.id),
[editorItems],
);
const {
accounts: suggestedAccounts,
isLoading: isLoadingSuggestions,
@@ -217,8 +223,7 @@ export const CollectionAccounts: React.FC<{
withRelationships: true,
withDefaultFollows: searchValue === '',
// Don't suggest accounts that were already added
filterResults: (account) =>
!editorItems.find((item) => item.account_id === account.id),
filterResults: (account) => !wasAccountAdded(account),
});
const relationships = useAppSelector((state) => state.relationships);
@@ -256,23 +261,25 @@ export const CollectionAccounts: React.FC<{
const addAccountItem = useCallback(
(item: ApiMutedAccountJSON) => {
dispatch(
updateCollectionEditorField({
field: 'items',
value: [
...editorItems,
{
account_id: item.id,
state:
item.feature_approval.current_user === 'manual'
? 'pending'
: 'accepted',
},
],
}),
);
if (!wasAccountAdded(item)) {
dispatch(
updateCollectionEditorField({
field: 'items',
value: [
...editorItems,
{
account_id: item.id,
state:
item.feature_approval.current_user === 'manual'
? 'pending'
: 'accepted',
},
],
}),
);
}
},
[editorItems, dispatch],
[editorItems, wasAccountAdded, dispatch],
);
const instantRemoveAccountItem = useCallback(
@@ -299,13 +306,13 @@ export const CollectionAccounts: React.FC<{
const instantAddAccountItem = useCallback(
(item: ApiMutedAccountJSON) => {
if (id) {
if (id && !wasAccountAdded(item)) {
void dispatch(
addCollectionItem({ collectionId: id, accountId: item.id }),
);
}
},
[dispatch, id],
[dispatch, id, wasAccountAdded],
);
const handleRemoveAccountItem = useCallback(
@@ -13,20 +13,21 @@ import {
import { Helmet } from '@unhead/react/helmet';
import { Callout } from '@/mastodon/components/callout';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import { initialState } from '@/mastodon/initial_state';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { Callout } from 'mastodon/components/callout';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import { useIdentity } from 'mastodon/identity_context';
import { initialState } from 'mastodon/initial_state';
import {
collectionEditorActions,
fetchCollection,
} from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { useCollectionsCreatedBy } from '../overview/created_by_you';
import { useCollectionsCreatedBy } from '../overview/created_by_account';
import { CollectionAccounts } from './accounts';
import { CollectionDetails } from './details';
@@ -75,7 +76,7 @@ export const CollectionEditorPage: React.FC<{
}> = ({ multiColumn }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const accountId = useCurrentAccountId();
const { accountId, signedIn } = useIdentity();
const { id = null } = useParams<{ id?: string }>();
const { path } = useRouteMatch();
const collection = useAppSelector((state) =>
@@ -94,13 +95,13 @@ export const CollectionEditorPage: React.FC<{
(!isEditMode && collectionListStatus === 'loading');
const canCreateMoreCollections =
isEditMode || collectionList.length < userCollectionLimit;
signedIn && (isEditMode || collectionList.length < userCollectionLimit);
useEffect(() => {
if (id) {
if (id && signedIn) {
void dispatch(fetchCollection({ collectionId: id }));
}
}, [dispatch, id]);
}, [dispatch, id, signedIn]);
useEffect(() => {
if (id !== editorStateId) {
@@ -129,6 +130,8 @@ export const CollectionEditorPage: React.FC<{
<div className='scrollable'>
{isLoading ? (
<LoadingIndicator />
) : !signedIn ? (
<NotSignedInIndicator />
) : canCreateMoreCollections ? (
<Switch>
<Route
@@ -4,15 +4,15 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom';
import { Helmet } from '@unhead/react/helmet';
import { TabLink, TabList } from '@/mastodon/components/tab_list';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { DisplayNameSimple } from 'mastodon/components/display_name/simple';
import { Scrollable } from 'mastodon/components/scrollable_list/components';
import { TabLink, TabList } from 'mastodon/components/tab_list';
import { useAccount } from 'mastodon/hooks/useAccount';
import { useAccountId, useCurrentAccountId } from 'mastodon/hooks/useAccountId';
import { CollectionsCreatedByYou } from './overview/created_by_you';
import { CollectionsCreatedByAccount } from './overview/created_by_account';
import { CollectionsFeaturingYou } from './overview/featuring_you';
import classes from './styles.module.scss';
@@ -89,7 +89,7 @@ export const Collections: React.FC<{
</TabList>
</header>
<Switch>
<Route exact path={path} component={CollectionsCreatedByYou} />
<Route exact path={path} component={CollectionsCreatedByAccount} />
<Route
exact
path={`${path}/featuring-you`}
@@ -5,10 +5,12 @@ import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import { DisplayName } from 'mastodon/components/display_name';
import { EmptyState } from 'mastodon/components/empty_state';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { ItemList } from 'mastodon/components/scrollable_list/components';
import { useAccount } from 'mastodon/hooks/useAccount';
import { useAccountId, useCurrentAccountId } from 'mastodon/hooks/useAccountId';
import {
fetchCollectionsCreatedByAccount,
@@ -58,9 +60,10 @@ export function useCollectionsCreatedBy(accountId: string | null | undefined) {
);
}
export const CollectionsCreatedByYou: React.FC = () => {
export const CollectionsCreatedByAccount: React.FC = () => {
const me = useCurrentAccountId();
const accountId = useAccountId();
const account = useAccount(accountId);
const { collections, status } = useCollectionsCreatedBy(accountId);
@@ -78,24 +81,40 @@ export const CollectionsCreatedByYou: React.FC = () => {
}
if (collections.length === 0) {
return (
<EmptyState
title={
<FormattedMessage
id='empty_column.account_featured_self.showcase_accounts'
defaultMessage='Showcase your favorite accounts'
/>
}
message={
<FormattedMessage
id='empty_column.account_featured_self.showcase_accounts_desc'
defaultMessage='Collections are curated lists of accounts to help others discover more of the Fediverse.'
/>
}
>
<CreateButton />
</EmptyState>
);
if (isOwnCollectionPage) {
return (
<EmptyState
title={
<FormattedMessage
id='empty_column.account_featured_self.showcase_accounts'
defaultMessage='Showcase your favorite accounts'
/>
}
message={
<FormattedMessage
id='empty_column.account_featured_self.showcase_accounts_desc'
defaultMessage='Collections are curated lists of accounts to help others discover more of the Fediverse.'
/>
}
>
<CreateButton />
</EmptyState>
);
} else {
return (
<EmptyState
title={
<FormattedMessage
id='empty_column.collections'
defaultMessage='{acct} has not created any collections yet.'
values={{
acct: <DisplayName variant='simple' account={account} />,
}}
/>
}
/>
);
}
}
return (
@@ -113,7 +132,7 @@ export const CollectionsCreatedByYou: React.FC = () => {
{showCreateButton && <CreateButton />}
</div>
<ItemList>
{!canCreateMoreCollections && (
{isOwnCollectionPage && !canCreateMoreCollections && (
<MaxCollectionsCallout className={classes.maxCollectionsError} />
)}
{collections.map((item, index) => (
@@ -16,7 +16,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { CollectionListItem } from '../components/collection_list_item';
import classes from '../styles.module.scss';
import { CollectionListError } from './created_by_you';
import { CollectionListError } from './created_by_account';
function useCollectionsFeaturing(accountId: string | null | undefined) {
const dispatch = useAppDispatch();
@@ -6,6 +6,8 @@ import { Link } from 'react-router-dom';
import { Helmet } from '@unhead/react/helmet';
import { NotSignedInIndicator } from '@/mastodon/components/not_signed_in_indicator';
import { useIdentity } from '@/mastodon/identity_context';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@@ -78,10 +80,13 @@ const Lists: React.FC<{
const dispatch = useAppDispatch();
const intl = useIntl();
const lists = useAppSelector((state) => getOrderedLists(state));
const { signedIn } = useIdentity();
useEffect(() => {
void dispatch(fetchLists());
}, [dispatch]);
if (signedIn) {
void dispatch(fetchLists());
}
}, [signedIn, dispatch]);
const emptyMessage = (
<>
@@ -112,14 +117,16 @@ const Lists: React.FC<{
iconComponent={ListAltIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/lists/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
signedIn && (
<Link
to='/lists/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
)
}
/>
@@ -128,9 +135,13 @@ const Lists: React.FC<{
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{lists.map((list) => (
<ListItem key={list.id} id={list.id} title={list.title} />
))}
{signedIn ? (
lists.map((list) => (
<ListItem key={list.id} id={list.id} title={list.title} />
))
) : (
<NotSignedInIndicator />
)}
</ScrollableList>
<Helmet>
+12 -3
View File
@@ -8,6 +8,8 @@ import { isFulfilled } from '@reduxjs/toolkit';
import { Helmet } from '@unhead/react/helmet';
import { NotSignedInIndicator } from '@/mastodon/components/not_signed_in_indicator';
import { useIdentity } from '@/mastodon/identity_context';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchList } from 'mastodon/actions/lists';
@@ -250,16 +252,17 @@ const NewListWrapper: React.FC<{
}> = ({ multiColumn }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { signedIn } = useIdentity();
const { id } = useParams<{ id?: string }>();
const list = useAppSelector((state) =>
id ? state.lists.get(id) : undefined,
);
useEffect(() => {
if (id) {
if (signedIn && id) {
dispatch(fetchList(id));
}
}, [dispatch, id]);
}, [dispatch, signedIn, id]);
const isLoading = id && !list;
@@ -277,7 +280,13 @@ const NewListWrapper: React.FC<{
/>
<div className='scrollable'>
{isLoading ? <LoadingIndicator /> : <NewList list={list} />}
{!signedIn ? (
<NotSignedInIndicator />
) : isLoading ? (
<LoadingIndicator />
) : (
<NewList list={list} />
)}
</div>
<Helmet>
@@ -4,6 +4,7 @@ import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { Helmet } from '@unhead/react/helmet';
import CollectionsIcon from '@/material-icons/400-24px/category.svg?react';
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
@@ -23,6 +24,8 @@ import { useSearchParam } from 'mastodon/hooks/useSearchParam';
import type { Hashtag as HashtagType } from 'mastodon/models/tags';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { CollectionListItem } from '../collections/components/collection_list_item';
import { SearchSection } from './components/search_section';
const messages = defineMessages({
@@ -131,7 +134,8 @@ export const SearchResults: React.FC<{ multiColumn: boolean }> = ({
filteredResults =
results.accounts.length +
results.hashtags.length +
results.statuses.length >
results.statuses.length +
results.collections.length >
0 ? (
<>
{results.accounts.length > 0 && (
@@ -154,6 +158,32 @@ export const SearchResults: React.FC<{ multiColumn: boolean }> = ({
</SearchSection>
)}
{results.collections.length > 0 && (
<SearchSection
key='collections'
title={
<>
<Icon id='collections' icon={CollectionsIcon} />
<FormattedMessage
id='search_results.collections'
defaultMessage='Collections'
/>
</>
}
>
{results.collections
.slice(0, INITIAL_DISPLAY)
.map((collection, index, array) => (
<CollectionListItem
key={collection.id}
collection={collection}
listSize={array.length}
positionInList={index + 1}
/>
))}
</SearchSection>
)}
{results.hashtags.length > 0 && (
<SearchSection
key='hashtags'
@@ -86,7 +86,7 @@ import {
Quotes,
} from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context';
import { focusColumn, getFocusedItemIndex, focusItemSibling, focusFirstItem } from './util/focusUtils';
import { focusColumn, getFocusedItemIndex, focusItemSibling, focusFirstItem, getFocusedColumnIndex } from './util/focusUtils';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import { CustomHomepage } from 'mastodon/features/custom_homepage';
@@ -506,20 +506,18 @@ class UI extends PureComponent {
handleMoveUp = () => {
const currentItemIndex = getFocusedItemIndex();
if (currentItemIndex === -1) {
focusColumn(1);
return focusColumn(getFocusedColumnIndex());
} else {
const wasHandled = focusItemSibling(currentItemIndex, -1);
return wasHandled;
return focusItemSibling(currentItemIndex, -1);
}
};
handleMoveDown = () => {
const currentItemIndex = getFocusedItemIndex();
if (currentItemIndex === -1) {
focusColumn(1);
return focusColumn(getFocusedColumnIndex());
} else {
const wasHandled = focusItemSibling(currentItemIndex, 1);
return wasHandled;
return focusItemSibling(currentItemIndex, 1);
}
};
@@ -62,18 +62,17 @@ export function focusColumn(index = 1) {
function fallback() {
focusColumnTitle(index + indexOffset, isMultiColumnLayout);
return false;
}
if (!column) {
fallback();
return;
return fallback();
}
const container = column.querySelector('.scrollable');
if (!container) {
fallback();
return;
return fallback();
}
const focusableItems = Array.from(
@@ -86,8 +85,7 @@ export function focusColumn(index = 1) {
const itemToFocus = findFirstVisibleWithRect(focusableItems);
if (!itemToFocus) {
fallback();
return;
return fallback();
}
const viewportWidth =
@@ -110,6 +108,7 @@ export function focusColumn(index = 1) {
itemToFocus.item.scrollIntoView(true);
}
itemToFocus.item.focus();
return true;
}
/**
@@ -126,6 +125,18 @@ export function getFocusedItemIndex() {
return items.indexOf(focusedItem);
}
/**
* Get the index of the column that contains the user's focus
*/
export function getFocusedColumnIndex() {
const columnWithFocus = document.activeElement?.closest('.column');
if (!columnWithFocus) return 1;
const allColumns = Array.from(document.querySelectorAll('.column'));
return allColumns.indexOf(columnWithFocus) + 1;
}
/**
* Focus the topmost item of the column that currently has focus,
* or the first column if none
@@ -159,12 +170,7 @@ export function focusItemSibling(index: number, direction: 1 | -1) {
);
if (!siblingItem) {
return false;
}
// If sibling element is empty, we skip it
if (siblingItem.matches(':empty')) {
return focusItemSibling(index + direction, direction);
return focusListSibling(direction);
}
// Check if the sibling is a post or a 'follow suggestions' widget
@@ -177,6 +183,52 @@ export function focusItemSibling(index: number, direction: 1 | -1) {
targetElement = siblingItem;
}
// If sibling element is empty, we skip it
if (!targetElement || siblingItem.matches(':empty')) {
return focusItemSibling(index + direction, direction);
}
targetElement.scrollIntoView({
block: 'start',
});
targetElement.focus();
return true;
}
/**
* Finds the next or previous .item-list on the page or in the column,
* and focuses its first or last item.
*/
function focusListSibling(direction: 1 | -1) {
const container = document.activeElement?.closest('.item-list');
if (!container) {
return false;
}
// Get all item lists in the current column or page
const currentColumn = container.closest('.column') ?? document;
const columnItemLists = Array.from(
currentColumn.querySelectorAll('.item-list'),
);
const currentListIndex = columnItemLists.indexOf(container);
// Find the next or previous item-list
const listSibling = columnItemLists[currentListIndex + direction];
// Depending on the direction, find the first or last focusable in the list
let targetElement: HTMLElement | null | undefined;
if (direction > 0) {
targetElement = listSibling?.querySelector<HTMLElement>('.focusable');
} else {
const allFocusables =
listSibling?.querySelectorAll<HTMLElement>('.focusable');
targetElement = allFocusables?.[allFocusables.length - 1];
}
if (targetElement) {
targetElement.scrollIntoView({
block: 'start',