Merge commit 'ead13d4826a7ba1699d6bb310e58642b15687671' into glitch-soc/merge-upstream
This commit is contained in:
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`}
|
||||
|
||||
+39
-20
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user