import { useCallback, useState, useRef } from 'react'; import { defineMessages, useIntl, FormattedMessage, FormattedList, } from 'react-intl'; import classNames from 'classnames'; import { useHistory } from 'react-router-dom'; import { isFulfilled } from '@reduxjs/toolkit'; import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import SearchIcon from '@/material-icons/400-24px/search.svg?react'; import { clickSearchResult, forgetSearchResult, openURL, } from 'mastodon/actions/search'; import { Icon } from 'mastodon/components/icon'; import { useIdentity } from 'mastodon/identity_context'; import { domain, searchEnabled } from 'mastodon/initial_state'; import type { RecentSearch, SearchType } from 'mastodon/models/search'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { HASHTAG_REGEX } from 'mastodon/utils/hashtags'; const messages = defineMessages({ placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL', }, }); const labelForRecentSearch = (search: RecentSearch) => { switch (search.type) { case 'account': return `@${search.q}`; case 'hashtag': return `#${search.q}`; default: return search.q; } }; const unfocus = () => { document.querySelector('.ui')?.parentElement?.focus(); }; interface SearchOption { key: string; label: React.ReactNode; action: (e: React.MouseEvent | React.KeyboardEvent) => void; forget?: (e: React.MouseEvent | React.KeyboardEvent) => void; } export const Search: React.FC<{ singleColumn: boolean; initialValue?: string; }> = ({ singleColumn, initialValue }) => { const intl = useIntl(); const recent = useAppSelector((state) => state.search.recent); const { signedIn } = useIdentity(); const dispatch = useAppDispatch(); const history = useHistory(); const searchInputRef = useRef(null); const [value, setValue] = useState(initialValue ?? ''); const hasValue = value.length > 0; const [expanded, setExpanded] = useState(false); const [selectedOption, setSelectedOption] = useState(-1); const [quickActions, setQuickActions] = useState([]); const searchOptions: SearchOption[] = []; if (searchEnabled) { searchOptions.push( { key: 'prompt-has', label: ( <> has:{' '} ), action: (e) => { e.preventDefault(); insertText('has:'); }, }, { key: 'prompt-is', label: ( <> is:{' '} ), action: (e) => { e.preventDefault(); insertText('is:'); }, }, { key: 'prompt-language', label: ( <> language:{' '} ), action: (e) => { e.preventDefault(); insertText('language:'); }, }, { key: 'prompt-from', label: ( <> from:{' '} ), action: (e) => { e.preventDefault(); insertText('from:'); }, }, { key: 'prompt-before', label: ( <> before:{' '} ), action: (e) => { e.preventDefault(); insertText('before:'); }, }, { key: 'prompt-during', label: ( <> during:{' '} ), action: (e) => { e.preventDefault(); insertText('during:'); }, }, { key: 'prompt-after', label: ( <> after:{' '} ), action: (e) => { e.preventDefault(); insertText('after:'); }, }, { key: 'prompt-in', label: ( <> in:{' '} ), action: (e) => { e.preventDefault(); insertText('in:'); }, }, ); } const recentOptions: SearchOption[] = recent.map((search) => ({ key: `${search.type}/${search.q}`, label: labelForRecentSearch(search), action: () => { setValue(search.q); if (search.type === 'account') { history.push(`/@${search.q}`); } else if (search.type === 'hashtag') { history.push(`/tags/${search.q}`); } else { const queryParams = new URLSearchParams({ q: search.q }); if (search.type) queryParams.set('type', search.type); history.push({ pathname: '/search', search: queryParams.toString() }); } unfocus(); }, forget: (e) => { e.stopPropagation(); void dispatch(forgetSearchResult(search.q)); }, })); const navigableOptions = hasValue ? quickActions.concat(searchOptions) : recentOptions.concat(quickActions, searchOptions); const insertText = (text: string) => { setValue((currentValue) => { if (currentValue === '') { return text; } else if (currentValue.endsWith(' ')) { return `${currentValue}${text}`; } else { return `${currentValue} ${text}`; } }); }; const submit = useCallback( (q: string, type?: SearchType) => { void dispatch(clickSearchResult({ q, type })); const queryParams = new URLSearchParams({ q }); if (type) queryParams.set('type', type); history.push({ pathname: '/search', search: queryParams.toString() }); unfocus(); }, [dispatch, history], ); const handleChange = useCallback( ({ target: { value } }: React.ChangeEvent) => { setValue(value); const trimmedValue = value.trim(); const newQuickActions = []; if (trimmedValue.length > 0) { const couldBeURL = trimmedValue.startsWith('https://') && !trimmedValue.includes(' '); if (couldBeURL) { newQuickActions.push({ key: 'open-url', label: ( ), action: async () => { const result = await dispatch(openURL({ url: trimmedValue })); if (isFulfilled(result)) { if (result.payload.accounts[0]) { history.push(`/@${result.payload.accounts[0].acct}`); } else if (result.payload.statuses[0]) { history.push( `/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`, ); } } unfocus(); }, }); } const couldBeHashtag = (trimmedValue.startsWith('#') && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX); if (couldBeHashtag) { newQuickActions.push({ key: 'go-to-hashtag', label: ( #{trimmedValue.replace(/^#/, '')} }} /> ), action: () => { const query = trimmedValue.replace(/^#/, ''); history.push(`/tags/${query}`); void dispatch(clickSearchResult({ q: query, type: 'hashtag' })); unfocus(); }, }); } const couldBeUsername = /^@?[a-z0-9_-]+(@[^\s]+)?$/i.exec(trimmedValue); if (couldBeUsername) { newQuickActions.push({ key: 'go-to-account', label: ( @{trimmedValue.replace(/^@/, '')} }} /> ), action: () => { const query = trimmedValue.replace(/^@/, ''); history.push(`/@${query}`); void dispatch(clickSearchResult({ q: query, type: 'account' })); unfocus(); }, }); } const couldBeStatusSearch = searchEnabled; if (couldBeStatusSearch && signedIn) { newQuickActions.push({ key: 'status-search', label: ( {trimmedValue} }} /> ), action: () => { submit(trimmedValue, 'statuses'); }, }); } newQuickActions.push({ key: 'account-search', label: ( {trimmedValue} }} /> ), action: () => { submit(trimmedValue, 'accounts'); }, }); } setQuickActions(newQuickActions); }, [dispatch, history, signedIn, setValue, setQuickActions, submit], ); const handleClear = useCallback(() => { setValue(''); setQuickActions([]); setSelectedOption(-1); }, [setValue, setQuickActions, setSelectedOption]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case 'Escape': e.preventDefault(); unfocus(); break; case 'ArrowDown': e.preventDefault(); if (navigableOptions.length > 0) { setSelectedOption( Math.min(selectedOption + 1, navigableOptions.length - 1), ); } break; case 'ArrowUp': e.preventDefault(); if (navigableOptions.length > 0) { setSelectedOption(Math.max(selectedOption - 1, -1)); } break; case 'Enter': e.preventDefault(); if (selectedOption === -1) { submit(value); } else if (navigableOptions.length > 0) { navigableOptions[selectedOption]?.action(e); } break; case 'Delete': if (selectedOption > -1 && navigableOptions.length > 0) { const search = navigableOptions[selectedOption]; if (typeof search?.forget === 'function') { e.preventDefault(); search.forget(e); } } break; } }, [navigableOptions, value, selectedOption, setSelectedOption, submit], ); const handleFocus = useCallback(() => { setExpanded(true); setSelectedOption(-1); if (searchInputRef.current && !singleColumn) { const { left, right } = searchInputRef.current.getBoundingClientRect(); if ( left < 0 || right > (window.innerWidth || document.documentElement.clientWidth) ) { searchInputRef.current.scrollIntoView(); } } }, [setExpanded, setSelectedOption, singleColumn]); const handleBlur = useCallback(() => { setExpanded(false); setSelectedOption(-1); }, [setExpanded, setSelectedOption]); return (
{!hasValue && ( <>

{recentOptions.length > 0 ? ( recentOptions.map(({ label, key, action, forget }, i) => ( )) ) : (
)}
)} {quickActions.length > 0 && ( <>

{quickActions.map(({ key, label, action }, i) => ( ))}
)}

{searchEnabled && signedIn ? (
{searchOptions.map(({ key, label, action }, i) => ( ))}
) : (
{searchEnabled ? ( ) : ( )}
)}
); };