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 to be displayed in the text input
*/ */
icon?: TextInputProps['icon'] | null; 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 * 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, renderGroupTitle,
renderItem, renderItem,
onSelectItem, onSelectItem,
onFocus,
onChange, onChange,
onKeyDown, onKeyDown,
openOnFocus = false,
closeOnSelect = true, closeOnSelect = true,
suppressMenu = false, suppressMenu = false,
icon = SearchIcon, 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( const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e); onChange(e);
@ -487,6 +503,7 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
autoComplete='off' autoComplete='off'
spellCheck='false' spellCheck='false'
value={value} value={value}
onFocus={handleFocus}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
icon={icon ?? undefined} icon={icon ?? undefined}

View File

@ -215,6 +215,7 @@ export const CollectionAccounts: React.FC<{
resetAccounts, resetAccounts,
} = useSearchAccounts({ } = useSearchAccounts({
withRelationships: true, withRelationships: true,
withDefaultFollows: searchValue === '',
// Don't suggest accounts that were already added // Don't suggest accounts that were already added
filterResults: (account) => filterResults: (account) =>
!editorItems.find((item) => item.account_id === account.id), !editorItems.find((item) => item.account_id === account.id),
@ -363,6 +364,7 @@ export const CollectionAccounts: React.FC<{
)} )}
{hasPendingItems && <PendingNote />} {hasPendingItems && <PendingNote />}
<ComboboxField <ComboboxField
openOnFocus
id={inputId} id={inputId}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'collections.search_accounts_label', 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 type { Account } from '@/mastodon/models/account';
import { isServerFeatureEnabled } from '@/mastodon/utils/environment'; import { isServerFeatureEnabled } from '@/mastodon/utils/environment';
@ -8,11 +8,11 @@ export function areCollectionsEnabled() {
export const getCollectionPath = (id: string) => `/collections/${id}`; 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); ['automatic', 'manual'].includes(account.feature_approval.current_user);
export const canAccountBeAddedByFollowers = ( export const canAccountBeAddedByFollowers = (
account: ApiMutedAccountJSON | Account, account: ApiAccountJSON | Account,
) => ) =>
account.feature_approval.automatic.includes('followers') || account.feature_approval.automatic.includes('followers') ||
account.feature_approval.manual.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'; import { useDebouncedCallback } from 'use-debounce';
@ -8,16 +8,20 @@ import { apiRequest } from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
import { useCurrentAccountId } from './useAccountId';
export function useSearchAccounts({ export function useSearchAccounts({
onSettled, onSettled,
filterResults, filterResults,
resetOnInputClear = true, resetOnInputClear = true,
withRelationships = false, withRelationships = false,
withDefaultFollows = false,
}: { }: {
onSettled?: (value: string) => void; onSettled?: (value: string) => void;
filterResults?: (account: ApiAccountJSON) => boolean; filterResults?: (account: ApiAccountJSON) => boolean;
resetOnInputClear?: boolean; resetOnInputClear?: boolean;
withRelationships?: boolean; withRelationships?: boolean;
withDefaultFollows?: boolean;
} = {}) { } = {}) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -29,7 +33,7 @@ export function useSearchAccounts({
const searchRequestRef = useRef<AbortController | null>(null); const searchRequestRef = useRef<AbortController | null>(null);
const searchAccounts = useDebouncedCallback( const searchAccounts = useDebouncedCallback(
(value: string) => { async (value: string) => {
if (searchRequestRef.current) { if (searchRequestRef.current) {
searchRequestRef.current.abort(); searchRequestRef.current.abort();
} }
@ -46,41 +50,100 @@ export function useSearchAccounts({
searchRequestRef.current = new AbortController(); searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', { try {
signal: searchRequestRef.current.signal, const data = await apiRequest<ApiAccountJSON[]>(
params: { 'GET',
q: value, 'v1/accounts/search',
resolve: true, {
}, signal: searchRequestRef.current.signal,
}) params: {
.then((data) => { q: value,
const accounts = filterResults ? data.filter(filterResults) : data; resolve: true,
const accountIds = accounts.map((a) => a.id); },
dispatch(importFetchedAccounts(accounts)); },
if (withRelationships) { );
dispatch(fetchRelationships(accountIds)); const accounts = filterResults ? data.filter(filterResults) : data;
} const accountIds = accounts.map((a) => a.id);
setAccounts(accounts); dispatch(importFetchedAccounts(accounts));
setLoadingState('idle'); if (withRelationships) {
onSettled?.(value); dispatch(fetchRelationships(accountIds));
}) }
.catch(() => { setAccounts(accounts);
setLoadingState('error'); setLoadingState('idle');
onSettled?.(value); onSettled?.(value);
}); } catch {
setLoadingState('error');
onSettled?.(value);
}
}, },
500, 500,
{ leading: true, trailing: true }, { leading: true, trailing: true },
); );
const startSearch = useCallback(
(value: string) => {
void searchAccounts(value);
},
[searchAccounts],
);
const resetAccounts = useCallback(() => { const resetAccounts = useCallback(() => {
setAccounts([]); setAccounts([]);
}, []); }, []);
return { const currentUserId = useCurrentAccountId();
searchAccounts, const [defaultAccounts, setDefaultAccounts] = useState<
resetAccounts, 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, accounts,
dispatch,
filterResults,
loadingState,
withRelationships,
defaultAccounts,
withDefaultFollows,
]);
return {
searchAccounts: startSearch,
resetAccounts,
accounts:
accounts.length === 0 && withDefaultFollows
? (defaultAccounts ?? [])
: accounts,
isLoading: loadingState === 'loading', isLoading: loadingState === 'loading',
isError: loadingState === 'error', isError: loadingState === 'error',
}; };