Implements tag suggestions for collections topic field (#38307)

This commit is contained in:
diondiondion authored and GitHub committed 2026-03-20 13:48:06 +00:00
1 parent 6507a61d30
commit 8bce0b99d4
11 files changed
+323 -215

No files matched your search

@@ -0,0 +1,82 @@
import { useRef, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { fetchRelationships } from 'mastodon/actions/accounts';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { apiRequest } from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { useAppDispatch } from 'mastodon/store';
export function useSearchAccounts({
onSettled,
filterResults,
resetOnInputClear = true,
withRelationships = false,
}: {
onSettled?: (value: string) => void;
filterResults?: (account: ApiAccountJSON) => boolean;
resetOnInputClear?: boolean;
withRelationships?: boolean;
} = {}) {
const dispatch = useAppDispatch();
const [accountIds, setAccountIds] = useState<string[]>([]);
const [loadingState, setLoadingState] = useState<
'idle' | 'loading' | 'error'
>('idle');
const searchRequestRef = useRef<AbortController | null>(null);
const searchAccounts = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
onSettled?.('');
if (resetOnInputClear) {
setAccountIds([]);
}
return;
}
setLoadingState('loading');
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));
}
setAccountIds(accountIds);
setLoadingState('idle');
onSettled?.(value);
})
.catch(() => {
setLoadingState('error');
onSettled?.(value);
});
},
500,
{ leading: true, trailing: true },
);
return {
searchAccounts,
accountIds,
isLoading: loadingState === 'loading',
isError: loadingState === 'error',
};
}
@@ -0,0 +1,121 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDebouncedCallback } from 'use-debounce';
import { apiGetSearch } from 'mastodon/api/search';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
import { trimHashFromStart } from 'mastodon/utils/hashtags';
export type TagSearchResult = Omit<ApiHashtagJSON, 'url' | 'history'> & {
label?: string;
};
const messages = defineMessages({
addTag: {
id: 'account_edit_tags.add_tag',
defaultMessage: 'Add #{tagName}',
},
});
const fetchSearchHashtags = ({
q,
limit,
signal,
}: {
q: string;
limit: number;
signal: AbortSignal;
}) => apiGetSearch({ q, type: 'hashtags', limit }, { signal });
export function useSearchTags({
query,
limit = 11,
filterResults,
}: {
query?: string;
limit?: number;
filterResults?: (account: ApiHashtagJSON) => boolean;
} = {}) {
const intl = useIntl();
const [fetchedTags, setFetchedTags] = useState<ApiHashtagJSON[]>([]);
const [loadingState, setLoadingState] = useState<
'idle' | 'loading' | 'error'
>('idle');
const searchRequestRef = useRef<AbortController | null>(null);
const searchTags = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
const trimmedQuery = trimHashFromStart(value.trim());
if (trimmedQuery.length === 0) {
setFetchedTags([]);
return;
}
setLoadingState('loading');
searchRequestRef.current = new AbortController();
void fetchSearchHashtags({
q: trimmedQuery,
limit,
signal: searchRequestRef.current.signal,
})
.then(({ hashtags }) => {
const tags = filterResults
? hashtags.filter(filterResults)
: hashtags;
setFetchedTags(tags);
setLoadingState('idle');
})
.catch(() => {
setLoadingState('error');
});
},
500,
{ leading: true, trailing: true },
);
const resetSearch = useCallback(() => {
setFetchedTags([]);
setLoadingState('idle');
}, []);
// Add dedicated item for adding the current query
const tags = useMemo(() => {
const trimmedQuery = query ? trimHashFromStart(query.trim()) : '';
if (!trimmedQuery || !fetchedTags.length) {
return fetchedTags;
}
const results: TagSearchResult[] = [...fetchedTags]; // Make array mutable
if (
trimmedQuery.length > 0 &&
results.every(
(result) => result.name.toLowerCase() !== trimmedQuery.toLowerCase(),
)
) {
results.push({
id: 'new',
name: trimmedQuery,
label: intl.formatMessage(messages.addTag, { tagName: trimmedQuery }),
});
}
return results;
}, [fetchedTags, query, intl]);
return {
tags,
searchTags,
resetSearch,
isLoading: loadingState === 'loading',
isError: loadingState === 'error',
};
}