Add emoji autocomplete to Chats
This commit is contained in:
parent
f2e7cf4e5c
commit
e14230678d
9 changed files with 212 additions and 72 deletions
|
@ -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 });
|
||||
|
|
|
@ -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 });
|
||||
|
|
31
app/soapbox/components/ui/combobox/combobox.css
Normal file
31
app/soapbox/components/ui/combobox/combobox.css
Normal file
|
@ -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;
|
||||
}
|
10
app/soapbox/components/ui/combobox/combobox.tsx
Normal file
10
app/soapbox/components/ui/combobox/combobox.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import './combobox.css';
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxPopover,
|
||||
ComboboxList,
|
||||
ComboboxOption,
|
||||
ComboboxOptionText,
|
||||
} from '@reach/combobox';
|
|
@ -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';
|
||||
|
|
|
@ -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'>
|
||||
|
|
35
app/soapbox/utils/suggestions.ts
Normal file
35
app/soapbox/utils/suggestions.ts
Normal file
|
@ -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 };
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue