Add emoji autocomplete to Chats

environments/review-chats-g56n7m/deployments/1581
Chewbacca 2022-11-22 09:55:31 -05:00
rodzic f2e7cf4e5c
commit e14230678d
9 zmienionych plików z 212 dodań i 72 usunięć

Wyświetl plik

@ -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<React.HTMLAttributes<HTMLInputElement>, 'onChange' | 'onKeyUp' | 'onKeyDown'> {
value: string,
suggestions: ImmutableList<any>,
@ -89,7 +60,11 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
input: HTMLInputElement | null = null;
onChange: React.ChangeEventHandler<HTMLInputElement> = (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 });

Wyświetl plik

@ -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<IAutosuggesteTextarea>
};
onChange: React.ChangeEventHandler<HTMLTextAreaElement> = (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 });

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -0,0 +1,10 @@
import './combobox.css';
export {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption,
ComboboxOptionText,
} from '@reach/combobox';

Wyświetl plik

@ -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';

Wyświetl plik

@ -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<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onKeyDown' | 'onChange' | 'disabled'> {
value: string
onSubmit: () => void
@ -42,11 +56,60 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
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<Suggestion>(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<HTMLTextAreaElement>;
if (onChange) {
onChange(event);
setSuggestions(initialSuggestionState);
}
};
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement> = (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<HTMLTextAreaElement | null, IChatComposer>
<div className='mt-auto pt-4 px-4 shadow-3xl'>
<HStack alignItems='stretch' justifyContent='between' space={4}>
<Stack grow>
<Textarea
autoFocus
ref={ref}
placeholder={intl.formatMessage(messages.placeholder)}
onKeyDown={onKeyDown}
value={value}
onChange={onChange}
isResizeable={false}
autoGrow
maxRows={5}
disabled={disabled}
/>
<Combobox
aria-labelledby='demo'
onSelect={onSelectComboboxOption}
>
<ComboboxInput
as={Textarea}
autoFocus
ref={ref}
placeholder={intl.formatMessage(messages.placeholder)}
onKeyDown={handleKeyDown}
value={value}
onChange={handleChange}
isResizeable={false}
autoGrow
maxRows={5}
disabled={disabled}
autoComplete='off'
/>
{isSuggestionsAvailable ? (
<ComboboxPopover>
<ComboboxList>
{suggestions.list.map((emojiSuggestion) => (
<ComboboxOption
key={emojiSuggestion.colons}
value={renderSuggestionValue(emojiSuggestion)}
>
<span>{emojiSuggestion.native}</span>
<span className='ml-1'>
{emojiSuggestion.colons}
</span>
</ComboboxOption>
))}
</ComboboxList>
</ComboboxPopover>
) : null}
</Combobox>
</Stack>
<Stack space={2} justifyContent='end' alignItems='center' className='w-10 mb-1.5'>

Wyświetl plik

@ -0,0 +1,35 @@
type CursorMatch = [
tokenStart: number | null,
token: string | null,
];
const textAtCursorMatchesToken = (
str: string,
caretPosition: number,
searchTokens: string[],
): CursorMatch => {
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 || !searchTokens.includes(word[0])) {
return [null, null];
}
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left + 1, word];
} else {
return [null, null];
}
};
export { textAtCursorMatchesToken };

Wyświetl plik

@ -56,6 +56,7 @@
"@lcdp/offline-plugin": "^5.1.0",
"@metamask/providers": "^9.0.0",
"@popperjs/core": "^2.11.5",
"@reach/combobox": "^0.18.0",
"@reach/menu-button": "^0.18.0",
"@reach/popover": "^0.18.0",
"@reach/portal": "^0.18.0",
@ -245,4 +246,4 @@
"webpack-dev-server": "^4.9.1",
"yargs": "^16.0.3"
}
}
}

Wyświetl plik

@ -2055,6 +2055,18 @@
dependencies:
"@reach/utils" "0.18.0"
"@reach/combobox@^0.18.0":
version "0.18.0"
resolved "https://registry.yarnpkg.com/@reach/combobox/-/combobox-0.18.0.tgz#8b3879b7c2dc426cddf0941b041d1ddc6d9adee6"
integrity sha512-x60PiPOIB4azeyh+FZ/svh0kXZRCneGCXVLL6htWs1VmaKq+TWR/48V03yQX5cSKjvRM8UFDVn47mpcg5ZSFtg==
dependencies:
"@reach/auto-id" "0.18.0"
"@reach/descendants" "0.18.0"
"@reach/polymorphic" "0.18.0"
"@reach/popover" "0.18.0"
"@reach/portal" "0.18.0"
"@reach/utils" "0.18.0"
"@reach/descendants@0.18.0":
version "0.18.0"
resolved "https://registry.yarnpkg.com/@reach/descendants/-/descendants-0.18.0.tgz#16fe52a5154da262994b0b8768baff4f670922d1"