Collections: Add default recommendations (#39202)

This commit is contained in:
Echo 2026-05-29 13:53:03 +02:00 committed by GitHub
parent 796f771362
commit fa1e16ed9f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 112 additions and 30 deletions

View File

@ -100,6 +100,10 @@ interface ComboboxProps<
* Icon to be displayed in the text input
*/
icon?: TextInputProps['icon'] | null;
/**
* Set to true to open as soon as there is focus
*/
openOnFocus?: boolean;
/**
* Set to false to keep the menu open when an item is selected
*/
@ -217,8 +221,10 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
renderGroupTitle,
renderItem,
onSelectItem,
onFocus,
onChange,
onKeyDown,
openOnFocus = false,
closeOnSelect = true,
suppressMenu = false,
icon = SearchIcon,
@ -288,6 +294,16 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
}
}, []);
const handleFocus: React.FocusEventHandler<HTMLInputElement> = useCallback(
(e) => {
if (openOnFocus) {
setShouldMenuOpen(true);
}
onFocus?.(e);
},
[onFocus, openOnFocus],
);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e);
@ -487,6 +503,7 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
autoComplete='off'
spellCheck='false'
value={value}
onFocus={handleFocus}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
icon={icon ?? undefined}

View File

@ -215,6 +215,7 @@ export const CollectionAccounts: React.FC<{
resetAccounts,
} = useSearchAccounts({
withRelationships: true,
withDefaultFollows: searchValue === '',
// Don't suggest accounts that were already added
filterResults: (account) =>
!editorItems.find((item) => item.account_id === account.id),
@ -363,6 +364,7 @@ export const CollectionAccounts: React.FC<{
)}
{hasPendingItems && <PendingNote />}
<ComboboxField
openOnFocus
id={inputId}
label={intl.formatMessage({
id: 'collections.search_accounts_label',

View File

@ -1,4 +1,4 @@
import type { ApiMutedAccountJSON } from '@/mastodon/api_types/accounts';
import type { ApiAccountJSON } from '@/mastodon/api_types/accounts';
import type { Account } from '@/mastodon/models/account';
import { isServerFeatureEnabled } from '@/mastodon/utils/environment';
@ -8,11 +8,11 @@ export function areCollectionsEnabled() {
export const getCollectionPath = (id: string) => `/collections/${id}`;
export const canAccountBeAdded = (account: ApiMutedAccountJSON | Account) =>
export const canAccountBeAdded = (account: ApiAccountJSON | Account) =>
['automatic', 'manual'].includes(account.feature_approval.current_user);
export const canAccountBeAddedByFollowers = (
account: ApiMutedAccountJSON | Account,
account: ApiAccountJSON | Account,
) =>
account.feature_approval.automatic.includes('followers') ||
account.feature_approval.manual.includes('followers');

View File

@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
@ -8,16 +8,20 @@ import { apiRequest } from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { useAppDispatch } from 'mastodon/store';
import { useCurrentAccountId } from './useAccountId';
export function useSearchAccounts({
onSettled,
filterResults,
resetOnInputClear = true,
withRelationships = false,
withDefaultFollows = false,
}: {
onSettled?: (value: string) => void;
filterResults?: (account: ApiAccountJSON) => boolean;
resetOnInputClear?: boolean;
withRelationships?: boolean;
withDefaultFollows?: boolean;
} = {}) {
const dispatch = useAppDispatch();
@ -29,7 +33,7 @@ export function useSearchAccounts({
const searchRequestRef = useRef<AbortController | null>(null);
const searchAccounts = useDebouncedCallback(
(value: string) => {
async (value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
@ -46,41 +50,100 @@ export function useSearchAccounts({
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: true,
},
})
.then((data) => {
const accounts = filterResults ? data.filter(filterResults) : data;
const accountIds = accounts.map((a) => a.id);
dispatch(importFetchedAccounts(accounts));
if (withRelationships) {
dispatch(fetchRelationships(accountIds));
}
setAccounts(accounts);
setLoadingState('idle');
onSettled?.(value);
})
.catch(() => {
setLoadingState('error');
onSettled?.(value);
});
try {
const data = await apiRequest<ApiAccountJSON[]>(
'GET',
'v1/accounts/search',
{
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: true,
},
},
);
const accounts = filterResults ? data.filter(filterResults) : data;
const accountIds = accounts.map((a) => a.id);
dispatch(importFetchedAccounts(accounts));
if (withRelationships) {
dispatch(fetchRelationships(accountIds));
}
setAccounts(accounts);
setLoadingState('idle');
onSettled?.(value);
} catch {
setLoadingState('error');
onSettled?.(value);
}
},
500,
{ leading: true, trailing: true },
);
const startSearch = useCallback(
(value: string) => {
void searchAccounts(value);
},
[searchAccounts],
);
const resetAccounts = useCallback(() => {
setAccounts([]);
}, []);
return {
searchAccounts,
resetAccounts,
const currentUserId = useCurrentAccountId();
const [defaultAccounts, setDefaultAccounts] = useState<
ApiAccountJSON[] | null
>(null);
useEffect(() => {
if (
!currentUserId ||
loadingState !== 'idle' ||
defaultAccounts !== null ||
!withDefaultFollows
) {
return;
}
async function doRequest() {
setLoadingState('loading');
try {
const data = await apiRequest<ApiAccountJSON[]>(
'GET',
`v1/accounts/${currentUserId}/following`,
{ params: { limit: 40 } },
);
const accounts = filterResults ? data.filter(filterResults) : data;
const accountIds = accounts.map((a) => a.id);
dispatch(importFetchedAccounts(accounts));
if (withRelationships) {
dispatch(fetchRelationships(accountIds));
}
setDefaultAccounts(accounts);
setLoadingState('idle');
} catch {
setLoadingState('error');
}
}
void doRequest();
}, [
currentUserId,
accounts,
dispatch,
filterResults,
loadingState,
withRelationships,
defaultAccounts,
withDefaultFollows,
]);
return {
searchAccounts: startSearch,
resetAccounts,
accounts:
accounts.length === 0 && withDefaultFollows
? (defaultAccounts ?? [])
: accounts,
isLoading: loadingState === 'loading',
isError: loadingState === 'error',
};