diff --git a/src/actions/compose.ts b/src/actions/compose.ts index 85b2d4c5d..fef615386 100644 --- a/src/actions/compose.ts +++ b/src/actions/compose.ts @@ -13,7 +13,7 @@ import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures, parseVersion } from 'soapbox/utils/features'; -import { useEmoji } from './emojis'; +import { chooseEmoji } from './emojis'; import { importFetchedAccounts } from './importer'; import { uploadFile, updateMedia } from './media'; import { openModal, closeModal } from './modals'; @@ -631,7 +631,7 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons; startPosition = position - 1; - dispatch(useEmoji(suggestion)); + dispatch(chooseEmoji(suggestion)); } else if (typeof suggestion === 'string' && suggestion[0] === '#') { completion = suggestion; startPosition = position - 1; diff --git a/src/actions/emojis.ts b/src/actions/emojis.ts index f3d33ac61..2a959fcc3 100644 --- a/src/actions/emojis.ts +++ b/src/actions/emojis.ts @@ -3,12 +3,12 @@ import { saveSettings } from './settings'; import type { Emoji } from 'soapbox/features/emoji'; import type { AppDispatch } from 'soapbox/store'; -const EMOJI_USE = 'EMOJI_USE'; +const EMOJI_CHOOSE = 'EMOJI_CHOOSE'; -const useEmoji = (emoji: Emoji) => +const chooseEmoji = (emoji: Emoji) => (dispatch: AppDispatch) => { dispatch({ - type: EMOJI_USE, + type: EMOJI_CHOOSE, emoji, }); @@ -16,6 +16,6 @@ const useEmoji = (emoji: Emoji) => }; export { - EMOJI_USE, - useEmoji, + EMOJI_CHOOSE, + chooseEmoji, }; diff --git a/src/features/compose/components/compose-form.tsx b/src/features/compose/components/compose-form.tsx index 0994ca00b..6e0bda14f 100644 --- a/src/features/compose/components/compose-form.tsx +++ b/src/features/compose/components/compose-form.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { CLEAR_EDITOR_COMMAND, type LexicalEditor } from 'lexical'; +import { CLEAR_EDITOR_COMMAND, TextNode, type LexicalEditor } from 'lexical'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; @@ -11,7 +11,6 @@ import { clearComposeSuggestions, fetchComposeSuggestions, selectComposeSuggestion, - insertEmojiCompose, uploadCompose, } from 'soapbox/actions/compose'; import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input'; @@ -28,6 +27,7 @@ import ReplyIndicatorContainer from '../containers/reply-indicator-container'; import ScheduleFormContainer from '../containers/schedule-form-container'; import UploadButtonContainer from '../containers/upload-button-container'; import WarningContainer from '../containers/warning-container'; +import { $createEmojiNode } from '../editor/nodes/emoji-node'; import { countableText } from '../util/counter'; import MarkdownButton from './markdown-button'; @@ -46,8 +46,6 @@ import Warning from './warning'; import type { Emoji } from 'soapbox/features/emoji'; -const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; - const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' }, pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' }, @@ -181,10 +179,12 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab }; const handleEmojiPick = (data: Emoji) => { - const position = autosuggestTextareaRef.current!.textarea!.selectionStart; - const needsSpace = !!data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]); + const editor = editorRef.current; + if (!editor) return; - dispatch(insertEmojiCompose(id, position, data, needsSpace)); + editor.update(() => { + editor.getEditorState()._selection?.insertNodes([$createEmojiNode(data), new TextNode(' ')]); + }); }; const onPaste = (files: FileList) => { diff --git a/src/features/compose/editor/index.tsx b/src/features/compose/editor/index.tsx index c5bcfb48e..d35762e6d 100644 --- a/src/features/compose/editor/index.tsx +++ b/src/features/compose/editor/index.tsx @@ -49,6 +49,7 @@ interface IComposeEditor { } const theme: InitialConfigType['theme'] = { + emoji: 'select-none', hashtag: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue', mention: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue', link: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue', diff --git a/src/features/compose/editor/nodes/emoji-node.tsx b/src/features/compose/editor/nodes/emoji-node.tsx index 7f7d71767..4b1f0ddf6 100644 --- a/src/features/compose/editor/nodes/emoji-node.tsx +++ b/src/features/compose/editor/nodes/emoji-node.tsx @@ -1,10 +1,10 @@ import { $applyNodeReplacement, DecoratorNode } from 'lexical'; import React from 'react'; -import { Emoji } from 'soapbox/components/ui'; +import { Emoji as Component } from 'soapbox/components/ui'; +import { isNativeEmoji, type Emoji } from 'soapbox/features/emoji'; import type { - DOMExportOutput, EditorConfig, LexicalNode, NodeKey, @@ -13,29 +13,26 @@ import type { } from 'lexical'; type SerializedEmojiNode = Spread<{ - name: string - src: string + data: Emoji type: 'emoji' version: 1 }, SerializedLexicalNode>; class EmojiNode extends DecoratorNode { - __name: string; - __src: string; + __emoji: Emoji; static getType(): 'emoji' { return 'emoji'; } static clone(node: EmojiNode): EmojiNode { - return new EmojiNode(node.__name, node.__src, node.__key); + return new EmojiNode(node.__emoji, node.__key); } - constructor(name: string, src: string, key?: NodeKey) { + constructor(emoji: Emoji, key?: NodeKey) { super(key); - this.__name = name; - this.__src = src; + this.__emoji = emoji; } createDOM(config: EditorConfig): HTMLElement { @@ -52,24 +49,13 @@ class EmojiNode extends DecoratorNode { return false; } - exportDOM(): DOMExportOutput { - const element = document.createElement('img'); - element.setAttribute('src', this.__src); - element.setAttribute('alt', this.__name); - element.classList.add('h-4', 'w-4'); - return { element }; - } - - static importJSON(serializedNode: SerializedEmojiNode): EmojiNode { - const { name, src } = serializedNode; - const node = $createEmojiNode(name, src); - return node; + static importJSON({ data }: SerializedEmojiNode): EmojiNode { + return $createEmojiNode(data); } exportJSON(): SerializedEmojiNode { return { - name: this.__name, - src: this.__src, + data: this.__emoji, type: 'emoji', version: 1, }; @@ -84,18 +70,29 @@ class EmojiNode extends DecoratorNode { } getTextContent(): string { - return this.__name; + const emoji = this.__emoji; + if (isNativeEmoji(emoji)) { + return emoji.native; + } else { + return emoji.colons; + } } decorate(): JSX.Element { - return ( - - ); + const emoji = this.__emoji; + if (isNativeEmoji(emoji)) { + return ; + } else { + return ; + } } } -const $createEmojiNode = (name = '', src: string): EmojiNode => $applyNodeReplacement(new EmojiNode(name, src)); +function $createEmojiNode(emoji: Emoji): EmojiNode { + const node = new EmojiNode(emoji); + return $applyNodeReplacement(node); +} const $isEmojiNode = ( node: LexicalNode | null | undefined, diff --git a/src/features/compose/editor/plugins/autosuggest-plugin.tsx b/src/features/compose/editor/plugins/autosuggest-plugin.tsx index f13fcaf55..c4419eff0 100644 --- a/src/features/compose/editor/plugins/autosuggest-plugin.tsx +++ b/src/features/compose/editor/plugins/autosuggest-plugin.tsx @@ -18,6 +18,7 @@ import { KEY_ESCAPE_COMMAND, KEY_TAB_COMMAND, LexicalEditor, + LexicalNode, RangeSelection, TextNode, } from 'lexical'; @@ -32,14 +33,14 @@ import React, { import ReactDOM from 'react-dom'; import { clearComposeSuggestions, fetchComposeSuggestions } from 'soapbox/actions/compose'; -import { useEmoji } from 'soapbox/actions/emojis'; +import { chooseEmoji } from 'soapbox/actions/emojis'; import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji'; -import { isNativeEmoji } from 'soapbox/features/emoji'; import { useAppDispatch, useCompose } from 'soapbox/hooks'; import { selectAccount } from 'soapbox/selectors'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import AutosuggestAccount from '../../components/autosuggest-account'; +import { $createEmojiNode } from '../nodes/emoji-node'; import { $createMentionNode } from '../nodes/mention-node'; import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; @@ -309,25 +310,25 @@ const AutosuggestPlugin = ({ /** Offset for the beginning of the matched text, including the token. */ const offset = leadOffset - 1; + /** Replace the matched text with the given node. */ + function replaceMatch(replaceWith: LexicalNode) { + const result = (node as TextNode).splitText(offset, offset + matchingString.length); + const textNode = result[1] ?? result[0]; + const replacedNode = textNode.replace(replaceWith); + replacedNode.insertAfter(new TextNode(' ')); + replacedNode.selectNext(); + } + if (typeof suggestion === 'object') { if (!suggestion.id) return; - dispatch(useEmoji(suggestion)); // eslint-disable-line react-hooks/rules-of-hooks - - if (isNativeEmoji(suggestion)) { - node.spliceText(offset, matchingString.length, `${suggestion.native} `, true); - } else { - node.spliceText(offset, matchingString.length, `${suggestion.colons} `, true); - } + dispatch(chooseEmoji(suggestion)); + replaceMatch($createEmojiNode(suggestion)); } else if (suggestion[0] === '#') { node.setTextContent(`${suggestion} `); node.select(); } else { const acct = selectAccount(getState(), suggestion)!.acct; - const result = (node as TextNode).splitText(offset, offset + matchingString.length); - const textNode = result[1] ?? result[0]; - const mentionNode = textNode.replace($createMentionNode(`@${acct}`)); - mentionNode.insertAfter(new TextNode(' ')); - mentionNode.selectNext(); + replaceMatch($createMentionNode(`@${acct}`)); } dispatch(clearComposeSuggestions(composeId)); @@ -341,11 +342,6 @@ const AutosuggestPlugin = ({ if (!node) return null; - if (['hashtag'].includes(node.getType())) { - const matchingString = node.getTextContent(); - return { leadOffset: 0, matchingString }; - } - if (node.getType() === 'text') { const [leadOffset, matchingString] = textAtCursorMatchesToken( node.getTextContent(), diff --git a/src/features/emoji/components/emoji-picker-dropdown.tsx b/src/features/emoji/components/emoji-picker-dropdown.tsx index 3f486502e..f384c4e19 100644 --- a/src/features/emoji/components/emoji-picker-dropdown.tsx +++ b/src/features/emoji/components/emoji-picker-dropdown.tsx @@ -3,9 +3,9 @@ import React, { useEffect, useState, useLayoutEffect } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { createSelector } from 'reselect'; -import { useEmoji } from 'soapbox/actions/emojis'; +import { chooseEmoji } from 'soapbox/actions/emojis'; import { changeSetting } from 'soapbox/actions/settings'; -import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useTheme } from 'soapbox/hooks'; import { RootState } from 'soapbox/store'; import { buildCustomEmojis } from '../../emoji'; @@ -130,10 +130,8 @@ const EmojiPickerDropdown: React.FC = ({ }) => { const intl = useIntl(); const dispatch = useAppDispatch(); - const settings = useSettings(); const title = intl.formatMessage(messages.emoji); - const userTheme = settings.get('themeMode'); - const theme = (userTheme === 'dark' || userTheme === 'light') ? userTheme : 'auto'; + const theme = useTheme(); const customEmojis = useAppSelector((state) => getCustomEmojis(state)); const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state)); @@ -162,7 +160,7 @@ const EmojiPickerDropdown: React.FC = ({ } as CustomEmoji; } - dispatch(useEmoji(pickedEmoji)); // eslint-disable-line react-hooks/rules-of-hooks + dispatch(chooseEmoji(pickedEmoji)); if (onPickEmoji) { onPickEmoji(pickedEmoji); diff --git a/src/features/emoji/containers/emoji-picker-dropdown-container.tsx b/src/features/emoji/containers/emoji-picker-dropdown-container.tsx index 7bc3cd055..af662f7ad 100644 --- a/src/features/emoji/containers/emoji-picker-dropdown-container.tsx +++ b/src/features/emoji/containers/emoji-picker-dropdown-container.tsx @@ -18,7 +18,6 @@ const EmojiPickerDropdownContainer = ( ) => { const intl = useIntl(); const title = intl.formatMessage(messages.emoji); - const [visible, setVisible] = useState(false); const { x, y, strategy, refs, update } = useFloating({ diff --git a/src/reducers/settings.ts b/src/reducers/settings.ts index f124b38a5..e7946ca37 100644 --- a/src/reducers/settings.ts +++ b/src/reducers/settings.ts @@ -3,7 +3,7 @@ import { AnyAction } from 'redux'; import { ME_FETCH_SUCCESS } from 'soapbox/actions/me'; -import { EMOJI_USE } from '../actions/emojis'; +import { EMOJI_CHOOSE } from '../actions/emojis'; import { NOTIFICATIONS_FILTER_SET } from '../actions/notifications'; import { SEARCH_FILTER_SET } from '../actions/search'; import { @@ -40,7 +40,7 @@ export default function settings(state: State = ImmutableMap({ save return state .setIn(action.path, action.value) .set('saved', false); - case EMOJI_USE: + case EMOJI_CHOOSE: return updateFrequentEmojis(state, action.emoji); case SETTING_SAVE: return state.set('saved', true);