Implements tag suggestions for collections topic field (#38307)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
Chromatic / Check for relevant changes (push) Waiting to run
Chromatic / Run Chromatic (push) Blocked by required conditions
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.29) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.19.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.29) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.29) (push) Blocked by required conditions

This commit is contained in:
diondiondion 2026-03-20 14:48:06 +01:00 committed by GitHub
parent 6507a61d30
commit 8bce0b99d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 323 additions and 215 deletions

View File

@ -155,8 +155,12 @@ export async function apiRequest<
export async function apiRequestGet<ApiResponse = unknown, ApiParams = unknown>(
url: ApiUrl,
params?: RequestParamsOrData<ApiParams>,
args: {
signal?: AbortSignal;
timeout?: number;
} = {},
) {
return apiRequest<ApiResponse>('GET', url, { params });
return apiRequest<ApiResponse>('GET', url, { params, ...args });
}
export async function apiRequestPost<ApiResponse = unknown, ApiData = unknown>(

View File

@ -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<ApiSearchResultsJSON>('v2/search', {
...params,
});
export const apiGetSearch = (
params: {
q: string;
resolve?: boolean;
type?: ApiSearchType;
limit?: number;
offset?: number;
},
options: {
signal?: AbortSignal;
} = {},
) =>
apiRequestGet<ApiSearchResultsJSON>(
'v2/search',
{
...params,
},
options,
);

View File

@ -28,7 +28,10 @@ export interface ComboboxItemState {
isDisabled: boolean;
}
interface ComboboxProps<T extends ComboboxItem> extends TextInputProps {
interface ComboboxProps<T extends ComboboxItem> extends Omit<
TextInputProps,
'icon'
> {
/**
* The value of the combobox's text input
*/
@ -71,6 +74,18 @@ interface ComboboxProps<T extends ComboboxItem> 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<T extends ComboboxItem>
@ -124,6 +139,8 @@ const ComboboxWithRef = <T extends ComboboxItem>(
onSelectItem,
onChange,
onKeyDown,
closeOnSelect = true,
suppressMenu = false,
icon = SearchIcon,
className,
...otherProps
@ -148,7 +165,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
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 = <T extends ComboboxItem>(
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 = <T extends ComboboxItem>(
value={value}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
icon={icon}
icon={icon ?? undefined}
className={classNames(classes.input, className)}
ref={mergeRefs}
/>

View File

@ -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<ApiHashtagJSON, 'url' | 'history'> & {
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<HTMLInputElement> = 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 (
<Combobox
value={query}
onChange={handleSearchChange}
placeholder={intl.formatMessage(messages.placeholder)}
items={results}
isLoading={isLoading}
renderItem={renderItem}
onSelectItem={handleSelect}
className={classes.autoComplete}
icon={SearchIcon}
type='search'
/>
<>
<label htmlFor={inputId} className='sr-only'>
{inputLabel}
</label>
<Combobox
id={inputId}
value={query}
onChange={handleSearchChange}
placeholder={inputLabel}
items={suggestedTags as TagSearchResult[]}
isLoading={isLoading}
renderItem={renderItem}
onSelectItem={handleSelect}
className={classes.autoComplete}
icon={SearchIcon}
type='search'
/>
</>
);
};
const renderItem = (item: SearchResult) => item.label ?? `#${item.name}`;
const renderItem = (item: TagSearchResult) => item.label ?? `#${item.name}`;

View File

@ -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 && (
<FormattedMessage

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
@ -9,6 +9,7 @@ import { isFulfilled } from '@reduxjs/toolkit';
import {
hasSpecialCharacters,
inputToHashtag,
trimHashFromStart,
} from '@/mastodon/utils/hashtags';
import type {
ApiCreateCollectionPayload,
@ -17,12 +18,15 @@ import type {
import { Button } from 'mastodon/components/button';
import {
CheckboxField,
ComboboxField,
Fieldset,
FormStack,
RadioButtonField,
TextAreaField,
} from 'mastodon/components/form_fields';
import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
import { useSearchTags } from 'mastodon/hooks/useSearchTags';
import type { TagSearchResult } from 'mastodon/hooks/useSearchTags';
import {
createCollection,
updateCollection,
@ -34,7 +38,6 @@ import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header';
export const CollectionDetails: React.FC = () => {
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<HTMLInputElement>) => {
dispatch(
updateCollectionEditorField({
field: 'topic',
value: inputToHashtag(event.target.value),
}),
);
},
[dispatch],
);
const handleDiscoverableChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
dispatch(
@ -156,11 +147,6 @@ export const CollectionDetails: React.FC = () => {
],
);
const topicHasSpecialCharacters = useMemo(
() => hasSpecialCharacters(topic),
[topic],
);
return (
<form onSubmit={handleSubmit} className={classes.form}>
<FormStack className={classes.formFieldStack}>
@ -213,39 +199,7 @@ export const CollectionDetails: React.FC = () => {
maxLength={100}
/>
<TextInputField
required={false}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
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
}
/>
<TopicField />
<Fieldset
legend={
@ -335,3 +289,95 @@ export const CollectionDetails: React.FC = () => {
</form>
);
};
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<HTMLInputElement>) => {
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 (
<ComboboxField
required={false}
icon={null}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
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}`;

View File

@ -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',

View File

@ -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',
};
}

View File

@ -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<string>) {
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,
);

View File

@ -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