import PropTypes from "prop-types"; import { PureComponent } from "react"; import { defineMessages, injectIntl, FormattedMessage, FormattedList } from "react-intl"; import classNames from "classnames"; import ImmutablePropTypes from "react-immutable-proptypes"; import { Icon } from "mastodon/components/icon"; import { domain, searchEnabled } from "mastodon/initial_state"; 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 => { switch(search.get("type")) { case "account": return `@${search.get("q")}`; case "hashtag": return `#${search.get("q")}`; default: return search.get("q"); } }; class Search extends PureComponent { static contextTypes = { router: PropTypes.object.isRequired, identity: PropTypes.object.isRequired, }; static propTypes = { value: PropTypes.string.isRequired, recent: ImmutablePropTypes.orderedSet, submitted: PropTypes.bool, onChange: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired, onOpenURL: PropTypes.func.isRequired, onClickSearchResult: PropTypes.func.isRequired, onForgetSearchResult: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, onShow: PropTypes.func.isRequired, openInRoute: PropTypes.bool, intl: PropTypes.object.isRequired, singleColumn: PropTypes.bool, }; state = { expanded: false, selectedOption: -1, options: [], }; defaultOptions = [ { label: <>has: , action: e => { e.preventDefault(); this._insertText("has:"); } }, { label: <>is: , action: e => { e.preventDefault(); this._insertText("is:"); } }, { label: <>language: , action: e => { e.preventDefault(); this._insertText("language:"); } }, { label: <>from: , action: e => { e.preventDefault(); this._insertText("from:"); } }, { label: <>before: , action: e => { e.preventDefault(); this._insertText("before:"); } }, { label: <>during: , action: e => { e.preventDefault(); this._insertText("during:"); } }, { label: <>after: , action: e => { e.preventDefault(); this._insertText("after:"); } }, { label: <>in: , action: e => { e.preventDefault(); this._insertText("in:"); } }, ]; setRef = c => { this.searchForm = c; }; handleChange = ({ target }) => { const { onChange } = this.props; onChange(target.value); this._calculateOptions(target.value); }; handleClear = e => { const { value, submitted, onClear } = this.props; e.preventDefault(); if (value.length > 0 || submitted) { onClear(); this.setState({ options: [], selectedOption: -1 }); } }; handleKeyDown = (e) => { const { selectedOption } = this.state; const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions(); switch(e.key) { case "Escape": e.preventDefault(); this._unfocus(); break; case "ArrowDown": e.preventDefault(); if (options.length > 0) { this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) }); } break; case "ArrowUp": e.preventDefault(); if (options.length > 0) { this.setState({ selectedOption: Math.max(selectedOption - 1, -1) }); } break; case "Enter": e.preventDefault(); if (selectedOption === -1) { this._submit(); } else if (options.length > 0) { options[selectedOption].action(e); } break; case "Delete": if (selectedOption > -1 && options.length > 0) { const search = options[selectedOption]; if (typeof search.forget === "function") { e.preventDefault(); search.forget(e); } } break; } }; handleFocus = () => { const { onShow, singleColumn } = this.props; this.setState({ expanded: true, selectedOption: -1 }); onShow(); if (this.searchForm && !singleColumn) { const { left, right } = this.searchForm.getBoundingClientRect(); if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) { this.searchForm.scrollIntoView(); } } }; handleBlur = () => { this.setState({ expanded: false, selectedOption: -1 }); }; handleHashtagClick = () => { const { router } = this.context; const { value, onClickSearchResult } = this.props; const query = value.trim().replace(/^#/, ""); router.history.push(`/tags/${query}`); onClickSearchResult(query, "hashtag"); this._unfocus(); }; handleAccountClick = () => { const { router } = this.context; const { value, onClickSearchResult } = this.props; const query = value.trim().replace(/^@/, ""); router.history.push(`/@${query}`); onClickSearchResult(query, "account"); this._unfocus(); }; handleURLClick = () => { const { router } = this.context; const { value, onOpenURL } = this.props; onOpenURL(value, router.history); this._unfocus(); }; handleStatusSearch = () => { this._submit("statuses"); }; handleAccountSearch = () => { this._submit("accounts"); }; handleRecentSearchClick = search => { const { onChange } = this.props; const { router } = this.context; if (search.get("type") === "account") { router.history.push(`/@${search.get("q")}`); } else if (search.get("type") === "hashtag") { router.history.push(`/tags/${search.get("q")}`); } else { onChange(search.get("q")); this._submit(search.get("type")); } this._unfocus(); }; handleForgetRecentSearchClick = search => { const { onForgetSearchResult } = this.props; onForgetSearchResult(search.get("q")); }; _unfocus () { document.querySelector(".ui").parentElement.focus(); } _insertText (text) { const { value, onChange } = this.props; if (value === "") { onChange(text); } else if (value[value.length - 1] === " ") { onChange(`${value}${text}`); } else { onChange(`${value} ${text}`); } } _submit (type) { const { onSubmit, openInRoute, value, onClickSearchResult } = this.props; const { router } = this.context; onSubmit(type); if (value) { onClickSearchResult(value, type); } if (openInRoute) { router.history.push("/search"); } this._unfocus(); } _getOptions () { const { options } = this.state; if (options.length > 0) { return options; } const { recent } = this.props; return recent.toArray().map(search => ({ label: labelForRecentSearch(search), action: () => this.handleRecentSearchClick(search), forget: e => { e.stopPropagation(); this.handleForgetRecentSearchClick(search); }, })); } _calculateOptions (value) { const trimmedValue = value.trim(); const options = []; if (trimmedValue.length > 0) { const couldBeURL = trimmedValue.startsWith("https://") && !trimmedValue.includes(" "); if (couldBeURL) { options.push({ key: "open-url", label: , action: this.handleURLClick }); } const couldBeHashtag = (trimmedValue.startsWith("#") && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX); if (couldBeHashtag) { options.push({ key: "go-to-hashtag", label: #{trimmedValue.replace(/^#/, "")} }} />, action: this.handleHashtagClick }); } const couldBeUsername = trimmedValue.match(/^@?[a-z0-9_-]+(@[^\s]+)?$/i); if (couldBeUsername) { options.push({ key: "go-to-account", label: @{trimmedValue.replace(/^@/, "")} }} />, action: this.handleAccountClick }); } const couldBeStatusSearch = searchEnabled; if (couldBeStatusSearch) { options.push({ key: "status-search", label: {trimmedValue} }} />, action: this.handleStatusSearch }); } const couldBeUserSearch = true; if (couldBeUserSearch) { options.push({ key: "account-search", label: {trimmedValue} }} />, action: this.handleAccountSearch }); } } this.setState({ options }); } render () { const { intl, value, submitted, recent } = this.props; const { expanded, options, selectedOption } = this.state; const { signedIn } = this.context.identity; const hasValue = value.length > 0 || submitted; return (
{options.length === 0 && ( <>

{recent.size > 0 ? this._getOptions().map(({ label, action, forget }, i) => ( )) : (
)}
)} {options.length > 0 && ( <>

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

{searchEnabled ? (
{this.defaultOptions.map(({ key, label, action }, i) => ( ))}
) : (
)}
); } } export default injectIntl(Search);