diff --git a/app/soapbox/actions/accounts.js b/app/soapbox/actions/accounts.js index 1fa430d71..5447afa9c 100644 --- a/app/soapbox/actions/accounts.js +++ b/app/soapbox/actions/accounts.js @@ -1044,10 +1044,10 @@ export function fetchPinnedAccountsFail(id, error) { }; } -export function accountSearch(params, cancelToken) { +export function accountSearch(params, signal) { return (dispatch, getState) => { dispatch({ type: ACCOUNT_SEARCH_REQUEST, params }); - return api(getState).get('/api/v1/accounts/search', { params, cancelToken }).then(({ data: accounts }) => { + return api(getState).get('/api/v1/accounts/search', { params, signal }).then(({ data: accounts }) => { dispatch(importFetchedAccounts(accounts)); dispatch({ type: ACCOUNT_SEARCH_SUCCESS, accounts }); return accounts; diff --git a/app/soapbox/components/account_search.tsx b/app/soapbox/components/account_search.tsx index 961a4b2ef..3acda1152 100644 --- a/app/soapbox/components/account_search.tsx +++ b/app/soapbox/components/account_search.tsx @@ -14,6 +14,8 @@ interface IAccountSearch { onSelected: (accountId: string) => void, /** Override the default placeholder of the input. */ placeholder?: string, + /** Position of results relative to the input. */ + resultsPosition?: 'above' | 'below', } /** Input to search for accounts. */ diff --git a/app/soapbox/components/autosuggest_account_input.js b/app/soapbox/components/autosuggest_account_input.js deleted file mode 100644 index fd93f52a9..000000000 --- a/app/soapbox/components/autosuggest_account_input.js +++ /dev/null @@ -1,95 +0,0 @@ -import { CancelToken } from 'axios'; -import { OrderedSet as ImmutableOrderedSet } from 'immutable'; -import { throttle } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { accountSearch } from 'soapbox/actions/accounts'; - -import AutosuggestInput from './autosuggest_input'; - -const noOp = () => {}; - -export default @connect() -class AutosuggestAccountInput extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onSelected: PropTypes.func.isRequired, - value: PropTypes.string.isRequired, - limit: PropTypes.number.isRequired, - } - - static defaultProps = { - value: '', - limit: 4, - } - - state = { - accountIds: ImmutableOrderedSet(), - } - - source = CancelToken.source(); - - refreshCancelToken = () => { - this.source.cancel(); - this.source = CancelToken.source(); - return this.source; - } - - clearResults = () => { - this.setState({ accountIds: ImmutableOrderedSet() }); - } - - handleAccountSearch = throttle(q => { - const { dispatch, limit } = this.props; - const source = this.refreshCancelToken(); - - const params = { q, limit, resolve: false }; - - dispatch(accountSearch(params, source.token)) - .then(accounts => { - const accountIds = accounts.map(account => account.id); - this.setState({ accountIds: ImmutableOrderedSet(accountIds) }); - }) - .catch(noOp); - - }, 900, { leading: true, trailing: true }) - - handleChange = e => { - this.handleAccountSearch(e.target.value); - this.props.onChange(e); - } - - handleSelected = (tokenStart, lastToken, accountId) => { - this.props.onSelected(accountId); - } - - componentDidUpdate(prevProps) { - if (this.props.value === '' && prevProps.value !== '') { - this.clearResults(); - } - } - - render() { - const { intl, value, onChange, ...rest } = this.props; - const { accountIds } = this.state; - - return ( - - ); - } - -} diff --git a/app/soapbox/components/autosuggest_account_input.tsx b/app/soapbox/components/autosuggest_account_input.tsx new file mode 100644 index 000000000..e103d7722 --- /dev/null +++ b/app/soapbox/components/autosuggest_account_input.tsx @@ -0,0 +1,88 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import { throttle } from 'lodash'; +import React, { useState, useRef, useCallback, useEffect } from 'react'; + +import { accountSearch } from 'soapbox/actions/accounts'; +import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest_input'; +import { useAppDispatch } from 'soapbox/hooks'; + +import type { Menu } from 'soapbox/components/dropdown_menu'; + +const noOp = () => {}; + +interface IAutosuggestAccountInput { + onChange: React.ChangeEventHandler, + onSelected: (accountId: string) => void, + value: string, + limit?: number, + className?: string, + autoSelect?: boolean, + menu?: Menu, + onKeyDown?: React.KeyboardEventHandler, +} + +const AutosuggestAccountInput: React.FC = ({ + onChange, + onSelected, + value = '', + limit = 4, + ...rest +}) => { + const dispatch = useAppDispatch(); + const [accountIds, setAccountIds] = useState(ImmutableOrderedSet()); + const controller = useRef(new AbortController()); + + const refreshCancelToken = () => { + controller.current.abort(); + controller.current = new AbortController(); + }; + + const clearResults = () => { + setAccountIds(ImmutableOrderedSet()); + }; + + const handleAccountSearch = useCallback(throttle(q => { + const params = { q, limit, resolve: false }; + + dispatch(accountSearch(params, controller.current.signal)) + .then((accounts: { id: string }[]) => { + const accountIds = accounts.map(account => account.id); + setAccountIds(ImmutableOrderedSet(accountIds)); + }) + .catch(noOp); + + }, 900, { leading: true, trailing: true }), [limit]); + + const handleChange: React.ChangeEventHandler = e => { + refreshCancelToken(); + handleAccountSearch(e.target.value); + onChange(e); + }; + + const handleSelected = (_tokenStart: number, _lastToken: string | null, suggestion: AutoSuggestion) => { + if (typeof suggestion === 'string' && suggestion[0] !== '#') { + onSelected(suggestion); + } + }; + + useEffect(() => { + if (value === '') { + clearResults(); + } + }, [value]); + + return ( + + ); +}; + +export default AutosuggestAccountInput; diff --git a/app/soapbox/components/autosuggest_emoji.js b/app/soapbox/components/autosuggest_emoji.js deleted file mode 100644 index e16d2a2c5..000000000 --- a/app/soapbox/components/autosuggest_emoji.js +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; - -import { joinPublicPath } from 'soapbox/utils/static'; - -import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; - -export default class AutosuggestEmoji extends React.PureComponent { - - static propTypes = { - emoji: PropTypes.object.isRequired, - }; - - render() { - const { emoji } = this.props; - let url; - - if (emoji.custom) { - url = emoji.imageUrl; - } else { - const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; - - if (!mapping) { - return null; - } - - url = joinPublicPath(`packs/emoji/${mapping.filename}.svg`); - } - - return ( -
- {emoji.native - - {emoji.colons} -
- ); - } - -} diff --git a/app/soapbox/components/autosuggest_emoji.tsx b/app/soapbox/components/autosuggest_emoji.tsx new file mode 100644 index 000000000..22979d454 --- /dev/null +++ b/app/soapbox/components/autosuggest_emoji.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import unicodeMapping from 'soapbox/features/emoji/emoji_unicode_mapping_light'; +import { joinPublicPath } from 'soapbox/utils/static'; + +export type Emoji = { + id: string, + custom: boolean, + imageUrl: string, + native: string, + colons: string, +} + +type UnicodeMapping = { + filename: string, +} + +interface IAutosuggestEmoji { + emoji: Emoji, +} + +const AutosuggestEmoji: React.FC = ({ emoji }) => { + let url; + + if (emoji.custom) { + url = emoji.imageUrl; + } else { + // @ts-ignore + const mapping: UnicodeMapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; + + if (!mapping) { + return null; + } + + url = joinPublicPath(`packs/emoji/${mapping.filename}.svg`); + } + + return ( +
+ {emoji.native + + {emoji.colons} +
+ ); +}; + +export default AutosuggestEmoji; diff --git a/app/soapbox/components/autosuggest_input.js b/app/soapbox/components/autosuggest_input.tsx similarity index 68% rename from app/soapbox/components/autosuggest_input.js rename to app/soapbox/components/autosuggest_input.tsx index 7fd4a23ed..cac979337 100644 --- a/app/soapbox/components/autosuggest_input.js +++ b/app/soapbox/components/autosuggest_input.tsx @@ -1,22 +1,27 @@ import classNames from 'classnames'; import { List as ImmutableList } from 'immutable'; -import PropTypes from 'prop-types'; import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import AutosuggestEmoji, { Emoji } from 'soapbox/components/autosuggest_emoji'; import Icon from 'soapbox/components/icon'; +import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest_account'; +import { isRtl } from 'soapbox/rtl'; -import AutosuggestAccount from '../features/compose/components/autosuggest_account'; -import { isRtl } from '../rtl'; +import type { Menu, MenuItem } from 'soapbox/components/dropdown_menu'; -import AutosuggestEmoji from './autosuggest_emoji'; +type CursorMatch = [ + tokenStart: number | null, + token: string | null, +]; -const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { - let word; +export type AutoSuggestion = string | Emoji; - const left = str.slice(0, caretPosition).search(/\S+$/); - const right = str.slice(caretPosition).search(/\s/); +const textAtCursorMatchesToken = (str: string, caretPosition: number, searchTokens: string[]): CursorMatch => { + let word: string; + + const left: number = str.slice(0, caretPosition).search(/\S+$/); + const right: number = str.slice(caretPosition).search(/\s/); if (right < 0) { word = str.slice(left); @@ -37,32 +42,31 @@ const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { } }; -export default class AutosuggestInput extends ImmutablePureComponent { +interface IAutosuggestInput extends Pick, 'onChange' | 'onKeyUp' | 'onKeyDown'> { + value: string, + suggestions: ImmutableList, + disabled?: boolean, + placeholder?: string, + onSuggestionSelected: (tokenStart: number, lastToken: string | null, suggestion: AutoSuggestion) => void, + onSuggestionsClearRequested: () => void, + onSuggestionsFetchRequested: (token: string) => void, + autoFocus: boolean, + autoSelect: boolean, + className?: string, + id?: string, + searchTokens: string[], + maxLength?: number, + menu?: Menu, + resultsPosition: string, +} - static propTypes = { - value: PropTypes.string, - suggestions: ImmutablePropTypes.list, - disabled: PropTypes.bool, - placeholder: PropTypes.string, - onSuggestionSelected: PropTypes.func.isRequired, - onSuggestionsClearRequested: PropTypes.func.isRequired, - onSuggestionsFetchRequested: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onKeyUp: PropTypes.func, - onKeyDown: PropTypes.func, - autoFocus: PropTypes.bool, - autoSelect: PropTypes.bool, - className: PropTypes.string, - id: PropTypes.string, - searchTokens: PropTypes.arrayOf(PropTypes.string), - maxLength: PropTypes.number, - menu: PropTypes.arrayOf(PropTypes.object), - }; +export default class AutosuggestInput extends ImmutablePureComponent { static defaultProps = { autoFocus: false, autoSelect: true, searchTokens: ImmutableList(['@', ':', '#']), + resultsPosition: 'below', }; getFirstIndex = () => { @@ -77,8 +81,10 @@ export default class AutosuggestInput extends ImmutablePureComponent { tokenStart: 0, }; - onChange = (e) => { - const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens); + input: HTMLInputElement | null = null; + + onChange: React.ChangeEventHandler = (e) => { + const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart || 0, this.props.searchTokens); if (token !== null && this.state.lastToken !== token) { this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); @@ -88,10 +94,12 @@ export default class AutosuggestInput extends ImmutablePureComponent { this.props.onSuggestionsClearRequested(); } - this.props.onChange(e); + if (this.props.onChange) { + this.props.onChange(e); + } } - onKeyDown = (e) => { + onKeyDown: React.KeyboardEventHandler = (e) => { const { suggestions, menu, disabled } = this.props; const { selectedSuggestion, suggestionsHidden } = this.state; const firstIndex = this.getFirstIndex(); @@ -102,7 +110,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { return; } - if (e.which === 229 || e.isComposing) { + if (e.which === 229) { // Ignore key events during text composition // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) return; @@ -111,7 +119,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { switch (e.key) { case 'Escape': if (suggestions.size === 0 || suggestionsHidden) { - document.querySelector('.ui').parentElement.focus(); + document.querySelector('.ui')?.parentElement?.focus(); } else { e.preventDefault(); this.setState({ suggestionsHidden: true }); @@ -134,7 +142,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { break; case 'Enter': case 'Tab': - // Select suggestion + // Select suggestion if (!suggestionsHidden && selectedSuggestion > -1 && (suggestions.size > 0 || menu)) { e.preventDefault(); e.stopPropagation(); @@ -142,9 +150,9 @@ export default class AutosuggestInput extends ImmutablePureComponent { if (selectedSuggestion < suggestions.size) { this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); - } else { + } else if (menu) { const item = menu[selectedSuggestion - suggestions.size]; - this.handleMenuItemAction(item); + this.handleMenuItemAction(item, e); } } @@ -155,7 +163,9 @@ export default class AutosuggestInput extends ImmutablePureComponent { return; } - this.props.onKeyDown(e); + if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } } onBlur = () => { @@ -166,25 +176,26 @@ export default class AutosuggestInput extends ImmutablePureComponent { this.setState({ focused: true }); } - onSuggestionClick = (e) => { - const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); - e.preventDefault(); + onSuggestionClick: React.MouseEventHandler = (e) => { + const index = Number(e.currentTarget?.getAttribute('data-index')); + const suggestion = this.props.suggestions.get(index); this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); - this.input.focus(); + this.input?.focus(); + e.preventDefault(); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: IAutosuggestInput, prevState: any) { const { suggestions } = this.props; if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) { this.setState({ suggestionsHidden: false }); } } - setInput = (c) => { + setInput = (c: HTMLInputElement) => { this.input = c; } - renderSuggestion = (suggestion, i) => { + renderSuggestion = (suggestion: AutoSuggestion, i: number) => { const { selectedSuggestion } = this.state; let inner, key; @@ -202,7 +213,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { return (
{ + handleMenuItemAction = (item: MenuItem | null, e: React.MouseEvent | React.KeyboardEvent) => { this.onBlur(); - item.action(); + if (item?.action) { + item.action(e); + } } - handleMenuItemClick = item => { + handleMenuItemClick = (item: MenuItem | null): React.MouseEventHandler => { return e => { e.preventDefault(); - this.handleMenuItemAction(item); + this.handleMenuItemAction(item, e); }; } @@ -241,23 +254,23 @@ export default class AutosuggestInput extends ImmutablePureComponent { className={classNames('flex items-center space-x-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-400 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700', { selected: suggestions.size - selectedSuggestion === i })} href='#' role='button' - tabIndex='0' + tabIndex={0} onMouseDown={this.handleMenuItemClick(item)} key={i} > - {item.icon && ( + {item?.icon && ( )} - {item.text} + {item?.text} )); }; render() { - const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu } = this.props; + const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu, resultsPosition } = this.props; const { suggestionsHidden } = this.state; - const style = { direction: 'ltr' }; + const style: React.CSSProperties = { direction: 'ltr' }; const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value)); @@ -273,8 +286,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { type='text' className={classNames({ 'block w-full sm:text-sm dark:bg-slate-800 dark:text-white dark:placeholder:text-gray-500 focus:ring-indigo-500 focus:border-indigo-500': true, - [className]: typeof className !== 'undefined', - })} + }, className)} ref={this.setInput} disabled={disabled} placeholder={placeholder} @@ -293,7 +305,9 @@ export default class AutosuggestInput extends ImmutablePureComponent { />
)} diff --git a/app/soapbox/features/compose/components/search.tsx b/app/soapbox/features/compose/components/search.tsx index 401001fc9..d68f82bc2 100644 --- a/app/soapbox/features/compose/components/search.tsx +++ b/app/soapbox/features/compose/components/search.tsx @@ -21,7 +21,7 @@ const messages = defineMessages({ action: { id: 'search.action', defaultMessage: 'Search for “{query}”' }, }); -function redirectToAccount(accountId: number, routerHistory: any) { +function redirectToAccount(accountId: string, routerHistory: any) { return (_dispatch: any, getState: () => ImmutableMap) => { const acct = getState().getIn(['accounts', accountId, 'acct']); @@ -97,7 +97,7 @@ const Search = (props: ISearch) => { dispatch(showSearch()); }; - const handleSelected = (accountId: number) => { + const handleSelected = (accountId: string) => { dispatch(clearSearch()); dispatch(redirectToAccount(accountId, history)); }; diff --git a/app/styles/basics.scss b/app/styles/basics.scss index 33257bf2a..987a6d58d 100644 --- a/app/styles/basics.scss +++ b/app/styles/basics.scss @@ -100,18 +100,7 @@ noscript { } .floating-link { - width: 100%; - height: 100%; - top: 0; - right: 0; - bottom: 0; - left: 0; - position: absolute; - z-index: 201; - background: transparent; - border: 0; - margin: 0; - padding: 0; + @apply w-full h-full inset-0 absolute z-10; } .greentext {