diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index 39617d82fe0..688c50d2185 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -155,8 +155,12 @@ export async function apiRequest< export async function apiRequestGet( url: ApiUrl, params?: RequestParamsOrData, + args: { + signal?: AbortSignal; + timeout?: number; + } = {}, ) { - return apiRequest('GET', url, { params }); + return apiRequest('GET', url, { params, ...args }); } export async function apiRequestPost( diff --git a/app/javascript/mastodon/api/search.ts b/app/javascript/mastodon/api/search.ts index 79b0385fe8e..497327004ae 100644 --- a/app/javascript/mastodon/api/search.ts +++ b/app/javascript/mastodon/api/search.ts @@ -4,13 +4,22 @@ import type { ApiSearchResultsJSON, } from 'mastodon/api_types/search'; -export const apiGetSearch = (params: { - q: string; - resolve?: boolean; - type?: ApiSearchType; - limit?: number; - offset?: number; -}) => - apiRequestGet('v2/search', { - ...params, - }); +export const apiGetSearch = ( + params: { + q: string; + resolve?: boolean; + type?: ApiSearchType; + limit?: number; + offset?: number; + }, + options: { + signal?: AbortSignal; + } = {}, +) => + apiRequestGet( + 'v2/search', + { + ...params, + }, + options, + ); diff --git a/app/javascript/mastodon/components/form_fields/combobox_field.tsx b/app/javascript/mastodon/components/form_fields/combobox_field.tsx index 8ce7161657b..057258847e7 100644 --- a/app/javascript/mastodon/components/form_fields/combobox_field.tsx +++ b/app/javascript/mastodon/components/form_fields/combobox_field.tsx @@ -28,7 +28,10 @@ export interface ComboboxItemState { isDisabled: boolean; } -interface ComboboxProps extends TextInputProps { +interface ComboboxProps extends Omit< + TextInputProps, + 'icon' +> { /** * The value of the combobox's text input */ @@ -71,6 +74,18 @@ interface ComboboxProps extends TextInputProps { * The main selection handler, called when an option is selected or deselected. */ onSelectItem: (item: T) => void; + /** + * Icon to be displayed in the text input + */ + icon?: TextInputProps['icon'] | null; + /** + * Set to false to keep the menu open when an item is selected + */ + closeOnSelect?: boolean; + /** + * Prevent the menu from opening, e.g. to prevent the empty state from showing + */ + suppressMenu?: boolean; } interface Props @@ -124,6 +139,8 @@ const ComboboxWithRef = ( onSelectItem, onChange, onKeyDown, + closeOnSelect = true, + suppressMenu = false, icon = SearchIcon, className, ...otherProps @@ -148,7 +165,7 @@ const ComboboxWithRef = ( const showStatusMessageInMenu = !!statusMessage && value.length > 0 && items.length === 0; const hasMenuContent = - !disabled && (items.length > 0 || showStatusMessageInMenu); + !disabled && !suppressMenu && (items.length > 0 || showStatusMessageInMenu); const isMenuOpen = shouldMenuOpen && hasMenuContent; const openMenu = useCallback(() => { @@ -204,11 +221,15 @@ const ComboboxWithRef = ( const isDisabled = getIsItemDisabled?.(item) ?? false; if (!isDisabled) { onSelectItem(item); + + if (closeOnSelect) { + closeMenu(); + } } } inputRef.current?.focus(); }, - [getIsItemDisabled, items, onSelectItem], + [closeMenu, closeOnSelect, getIsItemDisabled, items, onSelectItem], ); const handleSelectItem = useCallback( @@ -343,7 +364,7 @@ const ComboboxWithRef = ( value={value} onChange={handleInputChange} onKeyDown={handleInputKeyDown} - icon={icon} + icon={icon ?? undefined} className={classNames(classes.input, className)} ref={mergeRefs} /> diff --git a/app/javascript/mastodon/features/account_edit/components/tag_search.tsx b/app/javascript/mastodon/features/account_edit/components/tag_search.tsx index f0bba5a7459..9c67f4e4dd8 100644 --- a/app/javascript/mastodon/features/account_edit/components/tag_search.tsx +++ b/app/javascript/mastodon/features/account_edit/components/tag_search.tsx @@ -1,95 +1,80 @@ import type { ChangeEventHandler, FC } from 'react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useId, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import type { ApiHashtagJSON } from '@/mastodon/api_types/tags'; import { Combobox } from '@/mastodon/components/form_fields'; -import { - addFeaturedTag, - clearSearch, - updateSearchQuery, -} from '@/mastodon/reducers/slices/profile_edit'; -import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import { useSearchTags } from '@/mastodon/hooks/useSearchTags'; +import type { TagSearchResult } from '@/mastodon/hooks/useSearchTags'; +import { addFeaturedTag } from '@/mastodon/reducers/slices/profile_edit'; +import { useAppDispatch } from '@/mastodon/store'; import SearchIcon from '@/material-icons/400-24px/search.svg?react'; import classes from '../styles.module.scss'; -type SearchResult = Omit & { - label?: string; -}; - const messages = defineMessages({ placeholder: { id: 'account_edit_tags.search_placeholder', defaultMessage: 'Enter a hashtag…', }, - addTag: { - id: 'account_edit_tags.add_tag', - defaultMessage: 'Add #{tagName}', - }, }); export const AccountEditTagSearch: FC = () => { const intl = useIntl(); + const [query, setQuery] = useState(''); const { - query, + tags: suggestedTags, + searchTags, + resetSearch, isLoading, - results: rawResults, - } = useAppSelector((state) => state.profileEdit.search); - const results = useMemo(() => { - if (!rawResults) { - return []; - } + } = useSearchTags({ + query, + // Remove existing featured tags from suggestions + filterResults: (tag) => !tag.featuring, + }); - const results: SearchResult[] = [...rawResults]; // Make array mutable - const trimmedQuery = query.trim(); - 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; - }, [intl, query, rawResults]); - - const dispatch = useAppDispatch(); const handleSearchChange: ChangeEventHandler = useCallback( (e) => { - void dispatch(updateSearchQuery(e.target.value)); + setQuery(e.target.value); + searchTags(e.target.value); }, - [dispatch], + [searchTags], ); + const dispatch = useAppDispatch(); const handleSelect = useCallback( - (item: SearchResult) => { - void dispatch(clearSearch()); + (item: TagSearchResult) => { + resetSearch(); + setQuery(''); void dispatch(addFeaturedTag({ name: item.name })); }, - [dispatch], + [dispatch, resetSearch], ); + const inputId = useId(); + const inputLabel = intl.formatMessage(messages.placeholder); + return ( - + <> + + + ); }; -const renderItem = (item: SearchResult) => item.label ?? `#${item.name}`; +const renderItem = (item: TagSearchResult) => item.label ?? `#${item.name}`; diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx index 423b72e628a..a6942193d07 100644 --- a/app/javascript/mastodon/features/collections/editor/accounts.tsx +++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx @@ -25,8 +25,8 @@ import { ItemList, Scrollable, } from 'mastodon/components/scrollable_list/components'; -import { useSearchAccounts } from 'mastodon/features/lists/use_search_accounts'; import { useAccount } from 'mastodon/hooks/useAccount'; +import { useSearchAccounts } from 'mastodon/hooks/useSearchAccounts'; import { me } from 'mastodon/initial_state'; import { addCollectionItem, @@ -374,6 +374,7 @@ export const CollectionAccounts: React.FC<{ onSelectItem={ isEditMode ? instantToggleAccountItem : toggleAccountItem } + closeOnSelect={false} /> {hasMaxAccounts && ( { - const intl = useIntl(); const dispatch = useAppDispatch(); const history = useHistory(); const { id, name, description, topic, discoverable, sensitive, accountIds } = @@ -64,18 +67,6 @@ export const CollectionDetails: React.FC = () => { [dispatch], ); - const handleTopicChange = useCallback( - (event: React.ChangeEvent) => { - dispatch( - updateCollectionEditorField({ - field: 'topic', - value: inputToHashtag(event.target.value), - }), - ); - }, - [dispatch], - ); - const handleDiscoverableChange = useCallback( (event: React.ChangeEvent) => { dispatch( @@ -156,11 +147,6 @@ export const CollectionDetails: React.FC = () => { ], ); - const topicHasSpecialCharacters = useMemo( - () => hasSpecialCharacters(topic), - [topic], - ); - return (
@@ -213,39 +199,7 @@ export const CollectionDetails: React.FC = () => { maxLength={100} /> - - } - hint={ - - } - value={topic} - onChange={handleTopicChange} - autoCapitalize='off' - autoCorrect='off' - spellCheck='false' - maxLength={40} - status={ - topicHasSpecialCharacters - ? { - variant: 'warning', - message: intl.formatMessage({ - id: 'collections.topic_special_chars_hint', - defaultMessage: - 'Special characters will be removed when saving', - }), - } - : undefined - } - /> +
{ ); }; + +const TopicField: React.FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const { id, topic } = useAppSelector((state) => state.collections.editor); + + const collection = useAppSelector((state) => + id ? state.collections.collections[id] : undefined, + ); + const [isInitialValue, setIsInitialValue] = useState( + () => trimHashFromStart(topic) === (collection?.tag?.name ?? ''), + ); + + const { tags, isLoading, searchTags } = useSearchTags({ + query: topic, + }); + + const handleTopicChange = useCallback( + (event: React.ChangeEvent) => { + setIsInitialValue(false); + dispatch( + updateCollectionEditorField({ + field: 'topic', + value: inputToHashtag(event.target.value), + }), + ); + searchTags(event.target.value); + }, + [dispatch, searchTags], + ); + + const handleSelectTopicSuggestion = useCallback( + (item: TagSearchResult) => { + dispatch( + updateCollectionEditorField({ + field: 'topic', + value: inputToHashtag(item.name), + }), + ); + }, + [dispatch], + ); + + const topicHasSpecialCharacters = useMemo( + () => hasSpecialCharacters(topic), + [topic], + ); + + return ( + + } + hint={ + + } + value={topic} + items={tags} + isLoading={isLoading} + renderItem={renderItem} + onSelectItem={handleSelectTopicSuggestion} + onChange={handleTopicChange} + autoCapitalize='off' + autoCorrect='off' + spellCheck='false' + maxLength={40} + status={ + topicHasSpecialCharacters + ? { + variant: 'warning', + message: intl.formatMessage({ + id: 'collections.topic_special_chars_hint', + defaultMessage: + 'Special characters will be removed when saving', + }), + } + : undefined + } + suppressMenu={isInitialValue} + /> + ); +}; + +const renderItem = (item: TagSearchResult) => item.label ?? `#${item.name}`; diff --git a/app/javascript/mastodon/features/lists/members.tsx b/app/javascript/mastodon/features/lists/members.tsx index c0c6ea54f04..c8970b6d7a1 100644 --- a/app/javascript/mastodon/features/lists/members.tsx +++ b/app/javascript/mastodon/features/lists/members.tsx @@ -28,11 +28,10 @@ import { DisplayName } from 'mastodon/components/display_name'; import ScrollableList from 'mastodon/components/scrollable_list'; import { ShortNumber } from 'mastodon/components/short_number'; import { VerifiedBadge } from 'mastodon/components/verified_badge'; +import { useSearchAccounts } from 'mastodon/hooks/useSearchAccounts'; import { me } from 'mastodon/initial_state'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; -import { useSearchAccounts } from './use_search_accounts'; - export const messages = defineMessages({ manageMembers: { id: 'column.list_members', diff --git a/app/javascript/mastodon/features/lists/use_search_accounts.ts b/app/javascript/mastodon/hooks/useSearchAccounts.ts similarity index 100% rename from app/javascript/mastodon/features/lists/use_search_accounts.ts rename to app/javascript/mastodon/hooks/useSearchAccounts.ts diff --git a/app/javascript/mastodon/hooks/useSearchTags.ts b/app/javascript/mastodon/hooks/useSearchTags.ts new file mode 100644 index 00000000000..2f029b07e83 --- /dev/null +++ b/app/javascript/mastodon/hooks/useSearchTags.ts @@ -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 & { + 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([]); + const [loadingState, setLoadingState] = useState< + 'idle' | 'loading' | 'error' + >('idle'); + + const searchRequestRef = useRef(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', + }; +} diff --git a/app/javascript/mastodon/reducers/slices/profile_edit.ts b/app/javascript/mastodon/reducers/slices/profile_edit.ts index aa1ae4a73ae..9148a55f5fa 100644 --- a/app/javascript/mastodon/reducers/slices/profile_edit.ts +++ b/app/javascript/mastodon/reducers/slices/profile_edit.ts @@ -1,8 +1,5 @@ -import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; -import { debounce } from 'lodash'; - import { fetchAccount } from '@/mastodon/actions/accounts'; import { apiDeleteFeaturedTag, @@ -14,7 +11,6 @@ import { apiPatchProfile, apiPostFeaturedTag, } from '@/mastodon/api/accounts'; -import { apiGetSearch } from '@/mastodon/api/search'; import type { ApiAccountFieldJSON } from '@/mastodon/api_types/accounts'; import type { ApiProfileJSON, @@ -24,7 +20,6 @@ import type { ApiFeaturedTagJSON, ApiHashtagJSON, } from '@/mastodon/api_types/tags'; -import type { AppDispatch } from '@/mastodon/store'; import { createAppAsyncThunk, createAppSelector, @@ -59,40 +54,16 @@ export interface ProfileEditState { profile?: ProfileData; tagSuggestions?: ApiHashtagJSON[]; isPending: boolean; - search: { - query: string; - isLoading: boolean; - results?: ApiHashtagJSON[]; - }; } const initialState: ProfileEditState = { isPending: false, - search: { - query: '', - isLoading: false, - }, }; const profileEditSlice = createSlice({ name: 'profileEdit', initialState, - reducers: { - setSearchQuery(state, action: PayloadAction) { - if (state.search.query === action.payload) { - return; - } - - state.search.query = action.payload; - state.search.isLoading = true; - state.search.results = undefined; - }, - clearSearch(state) { - state.search.query = ''; - state.search.isLoading = false; - state.search.results = undefined; - }, - }, + reducers: {}, extraReducers(builder) { builder.addCase(fetchProfile.fulfilled, (state, action) => { state.profile = action.payload; @@ -172,37 +143,10 @@ const profileEditSlice = createSlice({ ); state.isPending = false; }); - - builder.addCase(fetchSearchResults.pending, (state) => { - state.search.isLoading = true; - }); - builder.addCase(fetchSearchResults.rejected, (state) => { - state.search.isLoading = false; - state.search.results = undefined; - }); - builder.addCase(fetchSearchResults.fulfilled, (state, action) => { - state.search.isLoading = false; - const searchResults: ApiHashtagJSON[] = []; - const currentTags = new Set( - (state.profile?.featuredTags ?? []).map((tag) => tag.name), - ); - - for (const tag of action.payload) { - if (currentTags.has(tag.name)) { - continue; - } - searchResults.push(tag); - if (searchResults.length >= 10) { - break; - } - } - state.search.results = searchResults; - }); }, }); export const profileEdit = profileEditSlice.reducer; -export const { clearSearch } = profileEditSlice.actions; const transformTag = (result: ApiFeaturedTagJSON): TagData => ({ id: result.id, @@ -426,27 +370,3 @@ export const deleteFeaturedTag = createDataLoadingThunk( `${profileEditSlice.name}/deleteFeaturedTag`, ({ tagId }: { tagId: string }) => apiDeleteFeaturedTag(tagId), ); - -const debouncedFetchSearchResults = debounce( - async (dispatch: AppDispatch, query: string) => { - await dispatch(fetchSearchResults({ q: query })); - }, - 300, -); - -export const updateSearchQuery = createAppAsyncThunk( - `${profileEditSlice.name}/updateSearchQuery`, - (query: string, { dispatch }) => { - dispatch(profileEditSlice.actions.setSearchQuery(query)); - - if (query.trim().length > 0) { - void debouncedFetchSearchResults(dispatch, query); - } - }, -); - -export const fetchSearchResults = createDataLoadingThunk( - `${profileEditSlice.name}/fetchSearchResults`, - ({ q }: { q: string }) => apiGetSearch({ q, type: 'hashtags', limit: 11 }), - (result) => result.hashtags, -); diff --git a/app/javascript/mastodon/utils/hashtags.ts b/app/javascript/mastodon/utils/hashtags.ts index ff90a884653..963ca483693 100644 --- a/app/javascript/mastodon/utils/hashtags.ts +++ b/app/javascript/mastodon/utils/hashtags.ts @@ -28,6 +28,12 @@ export const HASHTAG_PATTERN_REGEX = buildHashtagPatternRegex(); export const HASHTAG_REGEX = buildHashtagRegex(); +export const trimHashFromStart = (input: string) => { + return input.startsWith('#') || input.startsWith('#') + ? input.slice(1) + : input; +}; + /** * Formats an input string as a hashtag: * - Prepends `#` unless present @@ -41,11 +47,7 @@ export const inputToHashtag = (input: string): string => { const trailingSpace = /\s+$/.exec(input)?.[0] ?? ''; const trimmedInput = input.trimEnd(); - - const withoutHash = - trimmedInput.startsWith('#') || trimmedInput.startsWith('#') - ? trimmedInput.slice(1) - : trimmedInput; + const withoutHash = trimHashFromStart(trimmedInput); // Split by space, filter empty strings, and capitalise the start of each word but the first const words = withoutHash