From e14230678def3853a435776747661c4c0d17814a Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 22 Nov 2022 09:55:31 -0500 Subject: [PATCH] Add emoji autocomplete to Chats --- app/soapbox/components/autosuggest-input.tsx | 37 +----- .../components/autosuggest-textarea.tsx | 33 ++--- .../components/ui/combobox/combobox.css | 31 +++++ .../components/ui/combobox/combobox.tsx | 10 ++ app/soapbox/components/ui/index.ts | 8 ++ .../chats/components/chat-composer.tsx | 115 +++++++++++++++--- app/soapbox/utils/suggestions.ts | 35 ++++++ package.json | 3 +- yarn.lock | 12 ++ 9 files changed, 212 insertions(+), 72 deletions(-) create mode 100644 app/soapbox/components/ui/combobox/combobox.css create mode 100644 app/soapbox/components/ui/combobox/combobox.tsx create mode 100644 app/soapbox/utils/suggestions.ts diff --git a/app/soapbox/components/autosuggest-input.tsx b/app/soapbox/components/autosuggest-input.tsx index dc5912a01..d8b457a71 100644 --- a/app/soapbox/components/autosuggest-input.tsx +++ b/app/soapbox/components/autosuggest-input.tsx @@ -9,42 +9,13 @@ import Icon from 'soapbox/components/icon'; import { Input } from 'soapbox/components/ui'; import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account'; import { isRtl } from 'soapbox/rtl'; +import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu'; import type { InputThemes } from 'soapbox/components/ui/input/input'; -type CursorMatch = [ - tokenStart: number | null, - token: string | null, -]; - export type AutoSuggestion = string | Emoji; -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); - } else { - word = str.slice(left, right + caretPosition); - } - - if (!word || word.trim().length < 3 || !searchTokens.includes(word[0])) { - return [null, null]; - } - - word = word.trim().toLowerCase(); - - if (word.length > 0) { - return [left + 1, word]; - } else { - return [null, null]; - } -}; - export interface IAutosuggestInput extends Pick, 'onChange' | 'onKeyUp' | 'onKeyDown'> { value: string, suggestions: ImmutableList, @@ -89,7 +60,11 @@ export default class AutosuggestInput extends ImmutablePureComponent = (e) => { - const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart || 0, this.props.searchTokens); + 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 }); diff --git a/app/soapbox/components/autosuggest-textarea.tsx b/app/soapbox/components/autosuggest-textarea.tsx index 8e877021f..809d69153 100644 --- a/app/soapbox/components/autosuggest-textarea.tsx +++ b/app/soapbox/components/autosuggest-textarea.tsx @@ -4,6 +4,8 @@ import React from 'react'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Textarea from 'react-textarea-autosize'; +import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; + import AutosuggestAccount from '../features/compose/components/autosuggest-account'; import { isRtl } from '../rtl'; @@ -11,31 +13,6 @@ import AutosuggestEmoji, { Emoji } from './autosuggest-emoji'; import type { List as ImmutableList } from 'immutable'; -const textAtCursorMatchesToken = (str: string, caretPosition: number) => { - let word; - - const left = str.slice(0, caretPosition).search(/\S+$/); - const right = str.slice(caretPosition).search(/\s/); - - if (right < 0) { - word = str.slice(left); - } else { - word = str.slice(left, right + caretPosition); - } - - if (!word || word.trim().length < 3 || !['@', ':', '#'].includes(word[0])) { - return [null, null]; - } - - word = word.trim().toLowerCase(); - - if (word.length > 0) { - return [left + 1, word]; - } else { - return [null, null]; - } -}; - interface IAutosuggesteTextarea { id?: string, value: string, @@ -72,7 +49,11 @@ class AutosuggestTextarea extends ImmutablePureComponent }; onChange: React.ChangeEventHandler = (e) => { - const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); + const [tokenStart, token] = textAtCursorMatchesToken( + e.target.value, + e.target.selectionStart, + ['@', ':', '#'], + ); if (token !== null && this.state.lastToken !== token) { this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); diff --git a/app/soapbox/components/ui/combobox/combobox.css b/app/soapbox/components/ui/combobox/combobox.css new file mode 100644 index 000000000..bbce16ac4 --- /dev/null +++ b/app/soapbox/components/ui/combobox/combobox.css @@ -0,0 +1,31 @@ +:root { + --reach-combobox: 1; +} + +[data-reach-combobox-popover] { + @apply rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700 z-[100]; +} + +[data-reach-combobox-list] { + @apply list-none m-0 py-1 px-0 select-none; +} + +[data-reach-combobox-option] { + @apply block px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 cursor-pointer; +} + +[data-reach-combobox-option][aria-selected="true"] { + @apply bg-gray-100 dark:bg-gray-800; +} + +[data-reach-combobox-option]:hover { + @apply bg-gray-100 dark:bg-gray-800; +} + +[data-reach-combobox-option][aria-selected="true"]:hover { + @apply bg-gray-100 dark:bg-gray-800; +} + +[data-suggested-value] { + @apply font-bold; +} \ No newline at end of file diff --git a/app/soapbox/components/ui/combobox/combobox.tsx b/app/soapbox/components/ui/combobox/combobox.tsx new file mode 100644 index 000000000..156a1b94f --- /dev/null +++ b/app/soapbox/components/ui/combobox/combobox.tsx @@ -0,0 +1,10 @@ +import './combobox.css'; + +export { + Combobox, + ComboboxInput, + ComboboxPopover, + ComboboxList, + ComboboxOption, + ComboboxOptionText, +} from '@reach/combobox'; diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index 90f5933ed..3b174e47a 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -5,6 +5,14 @@ export { default as Button } from './button/button'; export { Card, CardBody, CardHeader, CardTitle } from './card/card'; export { default as Checkbox } from './checkbox/checkbox'; export { default as Column } from './column/column'; +export { + Combobox, + ComboboxInput, + ComboboxPopover, + ComboboxList, + ComboboxOption, + ComboboxOptionText, +} from './combobox/combobox'; export { default as Counter } from './counter/counter'; export { default as Datepicker } from './datepicker/datepicker'; export { default as Divider } from './divider/divider'; diff --git a/app/soapbox/features/chats/components/chat-composer.tsx b/app/soapbox/features/chats/components/chat-composer.tsx index e39d48e2c..369d2b7e8 100644 --- a/app/soapbox/features/chats/components/chat-composer.tsx +++ b/app/soapbox/features/chats/components/chat-composer.tsx @@ -1,11 +1,13 @@ -import React from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { unblockAccount } from 'soapbox/actions/accounts'; import { openModal } from 'soapbox/actions/modals'; -import { Button, HStack, IconButton, Stack, Text, Textarea } from 'soapbox/components/ui'; +import { Button, Combobox, ComboboxInput, ComboboxList, ComboboxOption, ComboboxPopover, HStack, IconButton, Stack, Text, Textarea } from 'soapbox/components/ui'; import { useChatContext } from 'soapbox/contexts/chat-context'; +import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; const messages = defineMessages({ placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' }, @@ -18,6 +20,18 @@ const messages = defineMessages({ unblockConfirm: { id: 'chat_settings.unblock.confirm', defaultMessage: 'Unblock' }, }); +const initialSuggestionState = { + list: [], + tokenStart: 0, + token: '', +}; + +interface Suggestion { + list: { native: string, colons: string }[], + tokenStart: number, + token: string, +} + interface IChatComposer extends Pick, 'onKeyDown' | 'onChange' | 'disabled'> { value: string onSubmit: () => void @@ -42,11 +56,60 @@ const ChatComposer = React.forwardRef const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking'])); const maxCharacterCount = useAppSelector((state) => state.instance.getIn(['configuration', 'chats', 'max_characters']) as number); + const [suggestions, setSuggestions] = useState(initialSuggestionState); + const isSuggestionsAvailable = suggestions.list.length > 0; + const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount; const isSubmitDisabled = disabled || isOverCharacterLimit || value.length === 0; const overLimitText = maxCharacterCount ? maxCharacterCount - value?.length : ''; + const renderSuggestionValue = (emoji: any) => { + return `${(value).slice(0, suggestions.tokenStart)}${emoji.native} ${(value as string).slice(suggestions.tokenStart + suggestions.token.length)}`; + }; + + const onSelectComboboxOption = (selection: string) => { + const event = { target: { value: selection } } as React.ChangeEvent; + + if (onChange) { + onChange(event); + setSuggestions(initialSuggestionState); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + const [tokenStart, token] = textAtCursorMatchesToken( + event.target.value, + event.target.selectionStart, + [':'], + ); + + if (token && tokenStart) { + const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any); + setSuggestions({ + list: results, + token, + tokenStart: tokenStart - 1, + }); + } else { + setSuggestions(initialSuggestionState); + } + + if (onChange) { + onChange(event); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = (event) => { + if (event.key === 'Enter' && !event.shiftKey && isSuggestionsAvailable) { + return; + } + + if (onKeyDown) { + onKeyDown(event); + } + }; + const handleUnblockUser = () => { dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.unblockHeading, { acct: chat?.account.acct }), @@ -81,18 +144,42 @@ const ChatComposer = React.forwardRef
-