[Glitch] Implements tag suggestions for collections topic field
Port 8bce0b99d4c426840de7e48165e1b37bbdefa5de to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
parent
781491e643
commit
784f0169e2
@ -134,8 +134,12 @@ export async function apiRequest<
|
|||||||
export async function apiRequestGet<ApiResponse = unknown, ApiParams = unknown>(
|
export async function apiRequestGet<ApiResponse = unknown, ApiParams = unknown>(
|
||||||
url: ApiUrl,
|
url: ApiUrl,
|
||||||
params?: RequestParamsOrData<ApiParams>,
|
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>(
|
export async function apiRequestPost<ApiResponse = unknown, ApiData = unknown>(
|
||||||
|
|||||||
@ -4,13 +4,22 @@ import type {
|
|||||||
ApiSearchResultsJSON,
|
ApiSearchResultsJSON,
|
||||||
} from 'flavours/glitch/api_types/search';
|
} from 'flavours/glitch/api_types/search';
|
||||||
|
|
||||||
export const apiGetSearch = (params: {
|
export const apiGetSearch = (
|
||||||
q: string;
|
params: {
|
||||||
resolve?: boolean;
|
q: string;
|
||||||
type?: ApiSearchType;
|
resolve?: boolean;
|
||||||
limit?: number;
|
type?: ApiSearchType;
|
||||||
offset?: number;
|
limit?: number;
|
||||||
}) =>
|
offset?: number;
|
||||||
apiRequestGet<ApiSearchResultsJSON>('v2/search', {
|
},
|
||||||
...params,
|
options: {
|
||||||
});
|
signal?: AbortSignal;
|
||||||
|
} = {},
|
||||||
|
) =>
|
||||||
|
apiRequestGet<ApiSearchResultsJSON>(
|
||||||
|
'v2/search',
|
||||||
|
{
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|||||||
@ -28,7 +28,10 @@ export interface ComboboxItemState {
|
|||||||
isDisabled: boolean;
|
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
|
* 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.
|
* The main selection handler, called when an option is selected or deselected.
|
||||||
*/
|
*/
|
||||||
onSelectItem: (item: T) => void;
|
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>
|
interface Props<T extends ComboboxItem>
|
||||||
@ -124,6 +139,8 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
onSelectItem,
|
onSelectItem,
|
||||||
onChange,
|
onChange,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
|
closeOnSelect = true,
|
||||||
|
suppressMenu = false,
|
||||||
icon = SearchIcon,
|
icon = SearchIcon,
|
||||||
className,
|
className,
|
||||||
...otherProps
|
...otherProps
|
||||||
@ -148,7 +165,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
const showStatusMessageInMenu =
|
const showStatusMessageInMenu =
|
||||||
!!statusMessage && value.length > 0 && items.length === 0;
|
!!statusMessage && value.length > 0 && items.length === 0;
|
||||||
const hasMenuContent =
|
const hasMenuContent =
|
||||||
!disabled && (items.length > 0 || showStatusMessageInMenu);
|
!disabled && !suppressMenu && (items.length > 0 || showStatusMessageInMenu);
|
||||||
const isMenuOpen = shouldMenuOpen && hasMenuContent;
|
const isMenuOpen = shouldMenuOpen && hasMenuContent;
|
||||||
|
|
||||||
const openMenu = useCallback(() => {
|
const openMenu = useCallback(() => {
|
||||||
@ -204,11 +221,15 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
const isDisabled = getIsItemDisabled?.(item) ?? false;
|
const isDisabled = getIsItemDisabled?.(item) ?? false;
|
||||||
if (!isDisabled) {
|
if (!isDisabled) {
|
||||||
onSelectItem(item);
|
onSelectItem(item);
|
||||||
|
|
||||||
|
if (closeOnSelect) {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
},
|
},
|
||||||
[getIsItemDisabled, items, onSelectItem],
|
[closeMenu, closeOnSelect, getIsItemDisabled, items, onSelectItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectItem = useCallback(
|
const handleSelectItem = useCallback(
|
||||||
@ -343,7 +364,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
icon={icon}
|
icon={icon ?? undefined}
|
||||||
className={classNames(classes.input, className)}
|
className={classNames(classes.input, className)}
|
||||||
ref={mergeRefs}
|
ref={mergeRefs}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,95 +1,80 @@
|
|||||||
import type { ChangeEventHandler, FC } from 'react';
|
import type { ChangeEventHandler, FC } from 'react';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useId, useState } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import type { ApiHashtagJSON } from '@/flavours/glitch/api_types/tags';
|
|
||||||
import { Combobox } from '@/flavours/glitch/components/form_fields';
|
import { Combobox } from '@/flavours/glitch/components/form_fields';
|
||||||
import {
|
import { useSearchTags } from '@/flavours/glitch/hooks/useSearchTags';
|
||||||
addFeaturedTag,
|
import type { TagSearchResult } from '@/flavours/glitch/hooks/useSearchTags';
|
||||||
clearSearch,
|
import { addFeaturedTag } from '@/flavours/glitch/reducers/slices/profile_edit';
|
||||||
updateSearchQuery,
|
import { useAppDispatch } from '@/flavours/glitch/store';
|
||||||
} from '@/flavours/glitch/reducers/slices/profile_edit';
|
|
||||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
|
||||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||||
|
|
||||||
import classes from '../styles.module.scss';
|
import classes from '../styles.module.scss';
|
||||||
|
|
||||||
type SearchResult = Omit<ApiHashtagJSON, 'url' | 'history'> & {
|
|
||||||
label?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
placeholder: {
|
placeholder: {
|
||||||
id: 'account_edit_tags.search_placeholder',
|
id: 'account_edit_tags.search_placeholder',
|
||||||
defaultMessage: 'Enter a hashtag…',
|
defaultMessage: 'Enter a hashtag…',
|
||||||
},
|
},
|
||||||
addTag: {
|
|
||||||
id: 'account_edit_tags.add_tag',
|
|
||||||
defaultMessage: 'Add #{tagName}',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AccountEditTagSearch: FC = () => {
|
export const AccountEditTagSearch: FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
const {
|
const {
|
||||||
query,
|
tags: suggestedTags,
|
||||||
|
searchTags,
|
||||||
|
resetSearch,
|
||||||
isLoading,
|
isLoading,
|
||||||
results: rawResults,
|
} = useSearchTags({
|
||||||
} = useAppSelector((state) => state.profileEdit.search);
|
query,
|
||||||
const results = useMemo(() => {
|
// Remove existing featured tags from suggestions
|
||||||
if (!rawResults) {
|
filterResults: (tag) => !tag.featuring,
|
||||||
return [];
|
});
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
void dispatch(updateSearchQuery(e.target.value));
|
setQuery(e.target.value);
|
||||||
|
searchTags(e.target.value);
|
||||||
},
|
},
|
||||||
[dispatch],
|
[searchTags],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(item: SearchResult) => {
|
(item: TagSearchResult) => {
|
||||||
void dispatch(clearSearch());
|
resetSearch();
|
||||||
|
setQuery('');
|
||||||
void dispatch(addFeaturedTag({ name: item.name }));
|
void dispatch(addFeaturedTag({ name: item.name }));
|
||||||
},
|
},
|
||||||
[dispatch],
|
[dispatch, resetSearch],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const inputId = useId();
|
||||||
|
const inputLabel = intl.formatMessage(messages.placeholder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<>
|
||||||
value={query}
|
<label htmlFor={inputId} className='sr-only'>
|
||||||
onChange={handleSearchChange}
|
{inputLabel}
|
||||||
placeholder={intl.formatMessage(messages.placeholder)}
|
</label>
|
||||||
items={results}
|
<Combobox
|
||||||
isLoading={isLoading}
|
id={inputId}
|
||||||
renderItem={renderItem}
|
value={query}
|
||||||
onSelectItem={handleSelect}
|
onChange={handleSearchChange}
|
||||||
className={classes.autoComplete}
|
placeholder={inputLabel}
|
||||||
icon={SearchIcon}
|
items={suggestedTags as TagSearchResult[]}
|
||||||
type='search'
|
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}`;
|
||||||
|
|||||||
@ -25,8 +25,8 @@ import {
|
|||||||
ItemList,
|
ItemList,
|
||||||
Scrollable,
|
Scrollable,
|
||||||
} from 'flavours/glitch/components/scrollable_list/components';
|
} from 'flavours/glitch/components/scrollable_list/components';
|
||||||
import { useSearchAccounts } from 'flavours/glitch/features/lists/use_search_accounts';
|
|
||||||
import { useAccount } from 'flavours/glitch/hooks/useAccount';
|
import { useAccount } from 'flavours/glitch/hooks/useAccount';
|
||||||
|
import { useSearchAccounts } from 'flavours/glitch/hooks/useSearchAccounts';
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
import {
|
import {
|
||||||
addCollectionItem,
|
addCollectionItem,
|
||||||
@ -374,6 +374,7 @@ export const CollectionAccounts: React.FC<{
|
|||||||
onSelectItem={
|
onSelectItem={
|
||||||
isEditMode ? instantToggleAccountItem : toggleAccountItem
|
isEditMode ? instantToggleAccountItem : toggleAccountItem
|
||||||
}
|
}
|
||||||
|
closeOnSelect={false}
|
||||||
/>
|
/>
|
||||||
{hasMaxAccounts && (
|
{hasMaxAccounts && (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ import { isFulfilled } from '@reduxjs/toolkit';
|
|||||||
import {
|
import {
|
||||||
hasSpecialCharacters,
|
hasSpecialCharacters,
|
||||||
inputToHashtag,
|
inputToHashtag,
|
||||||
|
trimHashFromStart,
|
||||||
} from '@/flavours/glitch/utils/hashtags';
|
} from '@/flavours/glitch/utils/hashtags';
|
||||||
import type {
|
import type {
|
||||||
ApiCreateCollectionPayload,
|
ApiCreateCollectionPayload,
|
||||||
@ -17,12 +18,15 @@ import type {
|
|||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import {
|
import {
|
||||||
CheckboxField,
|
CheckboxField,
|
||||||
|
ComboboxField,
|
||||||
Fieldset,
|
Fieldset,
|
||||||
FormStack,
|
FormStack,
|
||||||
RadioButtonField,
|
RadioButtonField,
|
||||||
TextAreaField,
|
TextAreaField,
|
||||||
} from 'flavours/glitch/components/form_fields';
|
} from 'flavours/glitch/components/form_fields';
|
||||||
import { TextInputField } from 'flavours/glitch/components/form_fields/text_input_field';
|
import { TextInputField } from 'flavours/glitch/components/form_fields/text_input_field';
|
||||||
|
import { useSearchTags } from 'flavours/glitch/hooks/useSearchTags';
|
||||||
|
import type { TagSearchResult } from 'flavours/glitch/hooks/useSearchTags';
|
||||||
import {
|
import {
|
||||||
createCollection,
|
createCollection,
|
||||||
updateCollection,
|
updateCollection,
|
||||||
@ -34,7 +38,6 @@ import classes from './styles.module.scss';
|
|||||||
import { WizardStepHeader } from './wizard_step_header';
|
import { WizardStepHeader } from './wizard_step_header';
|
||||||
|
|
||||||
export const CollectionDetails: React.FC = () => {
|
export const CollectionDetails: React.FC = () => {
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { id, name, description, topic, discoverable, sensitive, accountIds } =
|
const { id, name, description, topic, discoverable, sensitive, accountIds } =
|
||||||
@ -64,18 +67,6 @@ export const CollectionDetails: React.FC = () => {
|
|||||||
[dispatch],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTopicChange = useCallback(
|
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
dispatch(
|
|
||||||
updateCollectionEditorField({
|
|
||||||
field: 'topic',
|
|
||||||
value: inputToHashtag(event.target.value),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[dispatch],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDiscoverableChange = useCallback(
|
const handleDiscoverableChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
@ -156,11 +147,6 @@ export const CollectionDetails: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const topicHasSpecialCharacters = useMemo(
|
|
||||||
() => hasSpecialCharacters(topic),
|
|
||||||
[topic],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className={classes.form}>
|
<form onSubmit={handleSubmit} className={classes.form}>
|
||||||
<FormStack className={classes.formFieldStack}>
|
<FormStack className={classes.formFieldStack}>
|
||||||
@ -213,39 +199,7 @@ export const CollectionDetails: React.FC = () => {
|
|||||||
maxLength={100}
|
maxLength={100}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInputField
|
<TopicField />
|
||||||
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
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Fieldset
|
<Fieldset
|
||||||
legend={
|
legend={
|
||||||
@ -335,3 +289,95 @@ export const CollectionDetails: React.FC = () => {
|
|||||||
</form>
|
</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}`;
|
||||||
|
|||||||
@ -28,11 +28,10 @@ import { DisplayName } from 'flavours/glitch/components/display_name';
|
|||||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
||||||
|
import { useSearchAccounts } from 'flavours/glitch/hooks/useSearchAccounts';
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
import { useSearchAccounts } from './use_search_accounts';
|
|
||||||
|
|
||||||
export const messages = defineMessages({
|
export const messages = defineMessages({
|
||||||
manageMembers: {
|
manageMembers: {
|
||||||
id: 'column.list_members',
|
id: 'column.list_members',
|
||||||
|
|||||||
121
app/javascript/flavours/glitch/hooks/useSearchTags.ts
Normal file
121
app/javascript/flavours/glitch/hooks/useSearchTags.ts
Normal 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 'flavours/glitch/api/search';
|
||||||
|
import type { ApiHashtagJSON } from 'flavours/glitch/api_types/tags';
|
||||||
|
import { trimHashFromStart } from 'flavours/glitch/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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,8 +1,5 @@
|
|||||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { debounce } from 'lodash';
|
|
||||||
|
|
||||||
import { fetchAccount } from '@/flavours/glitch/actions/accounts';
|
import { fetchAccount } from '@/flavours/glitch/actions/accounts';
|
||||||
import {
|
import {
|
||||||
apiDeleteFeaturedTag,
|
apiDeleteFeaturedTag,
|
||||||
@ -14,7 +11,6 @@ import {
|
|||||||
apiPatchProfile,
|
apiPatchProfile,
|
||||||
apiPostFeaturedTag,
|
apiPostFeaturedTag,
|
||||||
} from '@/flavours/glitch/api/accounts';
|
} from '@/flavours/glitch/api/accounts';
|
||||||
import { apiGetSearch } from '@/flavours/glitch/api/search';
|
|
||||||
import type { ApiAccountFieldJSON } from '@/flavours/glitch/api_types/accounts';
|
import type { ApiAccountFieldJSON } from '@/flavours/glitch/api_types/accounts';
|
||||||
import type {
|
import type {
|
||||||
ApiProfileJSON,
|
ApiProfileJSON,
|
||||||
@ -24,7 +20,6 @@ import type {
|
|||||||
ApiFeaturedTagJSON,
|
ApiFeaturedTagJSON,
|
||||||
ApiHashtagJSON,
|
ApiHashtagJSON,
|
||||||
} from '@/flavours/glitch/api_types/tags';
|
} from '@/flavours/glitch/api_types/tags';
|
||||||
import type { AppDispatch } from '@/flavours/glitch/store';
|
|
||||||
import {
|
import {
|
||||||
createAppAsyncThunk,
|
createAppAsyncThunk,
|
||||||
createAppSelector,
|
createAppSelector,
|
||||||
@ -59,40 +54,16 @@ export interface ProfileEditState {
|
|||||||
profile?: ProfileData;
|
profile?: ProfileData;
|
||||||
tagSuggestions?: ApiHashtagJSON[];
|
tagSuggestions?: ApiHashtagJSON[];
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
search: {
|
|
||||||
query: string;
|
|
||||||
isLoading: boolean;
|
|
||||||
results?: ApiHashtagJSON[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ProfileEditState = {
|
const initialState: ProfileEditState = {
|
||||||
isPending: false,
|
isPending: false,
|
||||||
search: {
|
|
||||||
query: '',
|
|
||||||
isLoading: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileEditSlice = createSlice({
|
const profileEditSlice = createSlice({
|
||||||
name: 'profileEdit',
|
name: 'profileEdit',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extraReducers(builder) {
|
extraReducers(builder) {
|
||||||
builder.addCase(fetchProfile.fulfilled, (state, action) => {
|
builder.addCase(fetchProfile.fulfilled, (state, action) => {
|
||||||
state.profile = action.payload;
|
state.profile = action.payload;
|
||||||
@ -172,37 +143,10 @@ const profileEditSlice = createSlice({
|
|||||||
);
|
);
|
||||||
state.isPending = false;
|
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 profileEdit = profileEditSlice.reducer;
|
||||||
export const { clearSearch } = profileEditSlice.actions;
|
|
||||||
|
|
||||||
const transformTag = (result: ApiFeaturedTagJSON): TagData => ({
|
const transformTag = (result: ApiFeaturedTagJSON): TagData => ({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
@ -426,27 +370,3 @@ export const deleteFeaturedTag = createDataLoadingThunk(
|
|||||||
`${profileEditSlice.name}/deleteFeaturedTag`,
|
`${profileEditSlice.name}/deleteFeaturedTag`,
|
||||||
({ tagId }: { tagId: string }) => apiDeleteFeaturedTag(tagId),
|
({ 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,
|
|
||||||
);
|
|
||||||
|
|||||||
@ -28,6 +28,12 @@ export const HASHTAG_PATTERN_REGEX = buildHashtagPatternRegex();
|
|||||||
|
|
||||||
export const HASHTAG_REGEX = buildHashtagRegex();
|
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:
|
* Formats an input string as a hashtag:
|
||||||
* - Prepends `#` unless present
|
* - Prepends `#` unless present
|
||||||
@ -41,11 +47,7 @@ export const inputToHashtag = (input: string): string => {
|
|||||||
|
|
||||||
const trailingSpace = /\s+$/.exec(input)?.[0] ?? '';
|
const trailingSpace = /\s+$/.exec(input)?.[0] ?? '';
|
||||||
const trimmedInput = input.trimEnd();
|
const trimmedInput = input.trimEnd();
|
||||||
|
const withoutHash = trimHashFromStart(trimmedInput);
|
||||||
const withoutHash =
|
|
||||||
trimmedInput.startsWith('#') || trimmedInput.startsWith('#')
|
|
||||||
? trimmedInput.slice(1)
|
|
||||||
: trimmedInput;
|
|
||||||
|
|
||||||
// Split by space, filter empty strings, and capitalise the start of each word but the first
|
// Split by space, filter empty strings, and capitalise the start of each word but the first
|
||||||
const words = withoutHash
|
const words = withoutHash
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user