diff --git a/app/javascript/mastodon/components/form_fields/combobox_field.tsx b/app/javascript/mastodon/components/form_fields/combobox_field.tsx index f3e7b45476..a0e0a36f79 100644 --- a/app/javascript/mastodon/components/form_fields/combobox_field.tsx +++ b/app/javascript/mastodon/components/form_fields/combobox_field.tsx @@ -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 = ( renderGroupTitle, renderItem, onSelectItem, + onFocus, onChange, onKeyDown, + openOnFocus = false, closeOnSelect = true, suppressMenu = false, icon = SearchIcon, @@ -288,6 +294,16 @@ const ComboboxWithRef = ( } }, []); + const handleFocus: React.FocusEventHandler = useCallback( + (e) => { + if (openOnFocus) { + setShouldMenuOpen(true); + } + onFocus?.(e); + }, + [onFocus, openOnFocus], + ); + const handleInputChange = useCallback( (e: React.ChangeEvent) => { onChange(e); @@ -487,6 +503,7 @@ const ComboboxWithRef = ( autoComplete='off' spellCheck='false' value={value} + onFocus={handleFocus} onChange={handleInputChange} onKeyDown={handleInputKeyDown} icon={icon ?? undefined} diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx index c9423217f5..c5d23bdf3e 100644 --- a/app/javascript/mastodon/features/collections/editor/accounts.tsx +++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx @@ -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 && } `/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'); diff --git a/app/javascript/mastodon/hooks/useSearchAccounts.ts b/app/javascript/mastodon/hooks/useSearchAccounts.ts index c19f08e734..b7e6ab9f52 100644 --- a/app/javascript/mastodon/hooks/useSearchAccounts.ts +++ b/app/javascript/mastodon/hooks/useSearchAccounts.ts @@ -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(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('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( + '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( + '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', };