diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 0a43e9844e..bb307b833c 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -6,6 +6,7 @@ import { defineMessages, IntlShape } from 'react-intl'; import api from 'soapbox/api'; import { isNativeEmoji } from 'soapbox/features/emoji'; import emojiSearch from 'soapbox/features/emoji/search'; +import { normalizeTag } from 'soapbox/normalizers'; import { tagHistory } from 'soapbox/settings'; import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; @@ -29,7 +30,7 @@ import type { History } from 'soapbox/types/history'; const { CancelToken, isCancel } = axios; -let cancelFetchComposeSuggestionsAccounts: Canceler; +let cancelFetchComposeSuggestions: Canceler; const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; @@ -500,8 +501,8 @@ const setGroupTimelineVisible = (composeId: string, groupTimelineVisible: boolea }); const clearComposeSuggestions = (composeId: string) => { - if (cancelFetchComposeSuggestionsAccounts) { - cancelFetchComposeSuggestionsAccounts(); + if (cancelFetchComposeSuggestions) { + cancelFetchComposeSuggestions(); } return { type: COMPOSE_SUGGESTIONS_CLEAR, @@ -510,12 +511,12 @@ const clearComposeSuggestions = (composeId: string) => { }; const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => { - if (cancelFetchComposeSuggestionsAccounts) { - cancelFetchComposeSuggestionsAccounts(composeId); + if (cancelFetchComposeSuggestions) { + cancelFetchComposeSuggestions(composeId); } api(getState).get('/api/v1/accounts/search', { cancelToken: new CancelToken(cancel => { - cancelFetchComposeSuggestionsAccounts = cancel; + cancelFetchComposeSuggestions = cancel; }), params: { q: token.slice(1), @@ -540,10 +541,37 @@ const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => Ro }; const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => { - const state = getState(); - const currentTrends = state.trends.items; + if (cancelFetchComposeSuggestions) { + cancelFetchComposeSuggestions(composeId); + } - dispatch(updateSuggestionTags(composeId, token, currentTrends)); + const state = getState(); + + const instance = state.instance; + const { trends } = getFeatures(instance); + + if (trends) { + const currentTrends = state.trends.items; + + return dispatch(updateSuggestionTags(composeId, token, currentTrends)); + } + + api(getState).get('/api/v2/search', { + cancelToken: new CancelToken(cancel => { + cancelFetchComposeSuggestions = cancel; + }), + params: { + q: token.slice(1), + limit: 4, + type: 'hashtags', + }, + }).then(response => { + dispatch(updateSuggestionTags(composeId, token, response.data?.hashtags.map(normalizeTag))); + }).catch(error => { + if (!isCancel(error)) { + toast.showAlertForError(error); + } + }); }; const fetchComposeSuggestions = (composeId: string, token: string) => @@ -602,11 +630,11 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str }); }; -const updateSuggestionTags = (composeId: string, token: string, currentTrends: ImmutableList) => ({ +const updateSuggestionTags = (composeId: string, token: string, tags: ImmutableList) => ({ type: COMPOSE_SUGGESTION_TAGS_UPDATE, id: composeId, token, - currentTrends, + tags, }); const updateTagHistory = (composeId: string, tags: string[]) => ({ diff --git a/app/soapbox/components/ui/emoji/emoji.tsx b/app/soapbox/components/ui/emoji/emoji.tsx index eb8f02509d..e4d926459e 100644 --- a/app/soapbox/components/ui/emoji/emoji.tsx +++ b/app/soapbox/components/ui/emoji/emoji.tsx @@ -5,16 +5,21 @@ import { joinPublicPath } from 'soapbox/utils/static'; interface IEmoji extends React.ImgHTMLAttributes { /** Unicode emoji character. */ - emoji: string + emoji?: string } /** A single emoji image. */ const Emoji: React.FC = (props): JSX.Element | null => { const { emoji, alt, src, ...rest } = props; - const codepoints = toCodePoints(removeVS16s(emoji)); - const filename = codepoints.join('-'); - if (!filename) return null; + let filename; + + if (emoji) { + const codepoints = toCodePoints(removeVS16s(emoji)); + filename = codepoints.join('-'); + } + + if (!filename && !src) return null; return ( (({ composeId, condensed, onF /> - {features.richText && } diff --git a/app/soapbox/features/compose/editor/nodes/emoji-node.tsx b/app/soapbox/features/compose/editor/nodes/emoji-node.tsx index 42889d74a1..74ae000545 100644 --- a/app/soapbox/features/compose/editor/nodes/emoji-node.tsx +++ b/app/soapbox/features/compose/editor/nodes/emoji-node.tsx @@ -1,54 +1,74 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ +import { $applyNodeReplacement, DecoratorNode } from 'lexical'; +import React from 'react'; -import { addClassNamesToElement } from '@lexical/utils'; -import { $applyNodeReplacement, TextNode } from 'lexical'; +import { Emoji } from 'soapbox/components/ui'; import type { + DOMExportOutput, EditorConfig, LexicalNode, NodeKey, - SerializedTextNode, + SerializedLexicalNode, + Spread, } from 'lexical'; -class EmojiNode extends TextNode { +type SerializedEmojiNode = Spread<{ + name: string + src: string + type: 'emoji' + version: 1 +}, SerializedLexicalNode>; - static getType(): string { +class EmojiNode extends DecoratorNode { + + __name: string; + __src: string; + + static getType(): 'emoji' { return 'emoji'; } static clone(node: EmojiNode): EmojiNode { - return new EmojiNode(node.__text, node.__key); + return new EmojiNode(node.__name, node.__key); } - constructor(text: string, key?: NodeKey) { - super(text, key); + constructor(name: string, src: string, key?: NodeKey) { + super(key); + this.__name = name; + this.__src = src; } createDOM(config: EditorConfig): HTMLElement { - const element = super.createDOM(config); - addClassNamesToElement(element, config.theme.emoji); - return element; + const span = document.createElement('span'); + const theme = config.theme; + const className = theme.emoji; + if (className !== undefined) { + span.className = className; + } + return span; } - static importJSON(serializedNode: SerializedTextNode): EmojiNode { - const node = $createEmojiNode(serializedNode.text); - node.setFormat(serializedNode.format); - node.setDetail(serializedNode.detail); - node.setMode(serializedNode.mode); - node.setStyle(serializedNode.style); + 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; } - exportJSON(): SerializedTextNode { + exportJSON(): SerializedEmojiNode { return { - ...super.exportJSON(), + name: this.__name, + src: this.__src, type: 'emoji', + version: 1, }; } @@ -60,9 +80,15 @@ class EmojiNode extends TextNode { return true; } + decorate(): JSX.Element { + return ( + + ); + } + } -const $createEmojiNode = (text = ''): EmojiNode => $applyNodeReplacement(new EmojiNode(text).setMode('token')); +const $createEmojiNode = (name = '', src: string): EmojiNode => $applyNodeReplacement(new EmojiNode(name, src)); const $isEmojiNode = ( node: LexicalNode | null | undefined, diff --git a/app/soapbox/features/compose/editor/plugins/autosuggest-plugin.tsx b/app/soapbox/features/compose/editor/plugins/autosuggest-plugin.tsx index 65bb1dbc0d..b039d941a2 100644 --- a/app/soapbox/features/compose/editor/plugins/autosuggest-plugin.tsx +++ b/app/soapbox/features/compose/editor/plugins/autosuggest-plugin.tsx @@ -26,18 +26,16 @@ import React, { import ReactDOM from 'react-dom'; import { fetchComposeSuggestions } from 'soapbox/actions/compose'; +import { useEmoji } from 'soapbox/actions/emojis'; import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji'; +import { isNativeEmoji } from 'soapbox/features/emoji'; import { useAppDispatch, useCompose } from 'soapbox/hooks'; +import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import AutosuggestAccount from '../../components/autosuggest-account'; -import { MENTION_REGEX } from './mention-plugin'; - import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; - -const EMOJI_REGEX = new RegExp('(^|$|(?:^|\\s))([:])([a-z\\d_-]+([:]?))', 'i'); - export type QueryMatch = { leadOffset: number matchingString: string @@ -75,15 +73,6 @@ function tryToPositionRange(leadOffset: number, range: Range): boolean { return true; } -function getQueryTextForSearch(editor: LexicalEditor): string | null { - const state = editor.getEditorState(); - const node = (state._selection as RangeSelection)?.anchor?.getNode(); - - if (node && (node.getType() === 'mention' || node.getType() === 'text')) return node.getTextContent(); - - return null; -} - function isSelectionOnEntityBoundary( editor: LexicalEditor, offset: number, @@ -309,34 +298,60 @@ export function AutosuggestPlugin({ const onSelectSuggestion: React.MouseEventHandler = (e) => { e.preventDefault(); - const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index') as any); + const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index') as any) as AutoSuggestion; editor.update(() => { - dispatch((_, getState) => { + dispatch((dispatch, getState) => { const state = editor.getEditorState(); const node = (state._selection as RangeSelection)?.anchor?.getNode(); - const content = getState().accounts.get(suggestion)!.acct; + if (typeof suggestion === 'object' && suggestion.id) { + dispatch(useEmoji(suggestion)); // eslint-disable-line react-hooks/rules-of-hooks - node.setTextContent(`@${content} `); - node.select(); + const { leadOffset, matchingString } = resolution!.match; + + if (isNativeEmoji(suggestion)) { + node.spliceText(leadOffset - 1, matchingString.length, `${suggestion.native} `, true); + } else { + // const completion = suggestion.colons; + // node.replace($createEmojiNode(completion, suggestion.imageUrl)); + } + } else if ((suggestion as string)[0] === '#') { + node.setTextContent(`${suggestion} `); + node.select(); + } else { + const content = getState().accounts.get(suggestion)!.acct; + + node.setTextContent(`@${content} `); + node.select(); + } }); }); }; - const checkForMatch = useCallback((text: string) => { - const matchArr = MENTION_REGEX.exec(text) || EMOJI_REGEX.exec(text); + const getQueryTextForSearch = (editor: LexicalEditor) => { + const state = editor.getEditorState(); + const node = (state._selection as RangeSelection)?.anchor?.getNode(); - if (!matchArr) return null; + if (!node) return null; - dispatch(fetchComposeSuggestions(composeId, matchArr[0]?.trim())); + if (['mention', 'hashtag'].includes(node.getType())) { + const matchingString = node.getTextContent(); - return { - leadOffset: matchArr.index, - matchingString: matchArr[0], - }; - }, []); + return { leadOffset: 0, matchingString }; + } + + if (node.getType() === 'text') { + const [leadOffset, matchingString] = textAtCursorMatchesToken(node.getTextContent(), (state._selection as RangeSelection)?.anchor?.offset, [':']); + + if (!leadOffset || !matchingString) return null; + + return { leadOffset, matchingString }; + } + + return null; + }; const renderSuggestion = (suggestion: AutoSuggestion, i: number) => { let inner; @@ -345,6 +360,9 @@ export function AutosuggestPlugin({ if (typeof suggestion === 'object') { inner = ; key = suggestion.id; + } else if (suggestion[0] === '#') { + inner = suggestion; + key = suggestion; } else { inner = ; key = suggestion; @@ -382,17 +400,16 @@ export function AutosuggestPlugin({ const updateListener = () => { editor.getEditorState().read(() => { const range = document.createRange(); - const text = getQueryTextForSearch(editor); + const match = getQueryTextForSearch(editor); - if (!text) { + if (!match) { closeTypeahead(); return; } - const match = checkForMatch(text); + dispatch(fetchComposeSuggestions(composeId, match.matchingString.trim())); if ( - match !== null && !isSelectionOnEntityBoundary(editor, match.leadOffset) ) { const isRangePositioned = tryToPositionRange(match.leadOffset, range); diff --git a/app/soapbox/features/compose/editor/plugins/emoji-plugin.tsx b/app/soapbox/features/compose/editor/plugins/emoji-plugin.tsx deleted file mode 100644 index 30252d70e4..0000000000 --- a/app/soapbox/features/compose/editor/plugins/emoji-plugin.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity'; -import { useCallback, useEffect } from 'react'; - -import { $createEmojiNode, EmojiNode } from '../nodes/emoji-node'; - - -import type { TextNode } from 'lexical'; - -const REGEX = new RegExp('ggfafsdasdf(^|$|(?:^|\\s))([:])([a-z\\d_-]+([:]))', 'i'); - -export const getEmojiMatch = (text: string) => { - const matchArr = REGEX.exec(text); - - if (!matchArr) return null; - return matchArr; -}; - -export const EmojiPlugin = (): JSX.Element | null => { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - if (!editor.hasNodes([EmojiNode])) { - throw new Error('EmojiPlugin: EmojiNode not registered on editor'); - } - }, [editor]); - - const createEmojiNode = useCallback((textNode: TextNode): EmojiNode => { - return $createEmojiNode(textNode.getTextContent()); - }, []); - - const getEntityMatch = useCallback((text: string) => { - const matchArr = getEmojiMatch(text); - - if (!matchArr) return null; - - const emojiLength = matchArr[3].length + 1; - const startOffset = matchArr.index + matchArr[1].length; - const endOffset = startOffset + emojiLength; - return { - end: endOffset, - start: startOffset, - }; - }, []); - - useLexicalTextEntity( - getEntityMatch, - EmojiNode, - createEmojiNode, - ); - - return null; -}; diff --git a/app/soapbox/reducers/compose.ts b/app/soapbox/reducers/compose.ts index a6325b882a..fbf16e84c7 100644 --- a/app/soapbox/reducers/compose.ts +++ b/app/soapbox/reducers/compose.ts @@ -183,11 +183,11 @@ const insertSuggestion = (compose: Compose, position: number, token: string, com }); }; -const updateSuggestionTags = (compose: Compose, token: string, currentTrends: ImmutableList) => { +const updateSuggestionTags = (compose: Compose, token: string, tags: ImmutableList) => { const prefix = token.slice(1); return compose.merge({ - suggestions: ImmutableList(currentTrends + suggestions: ImmutableList(tags .filter((tag) => tag.get('name').toLowerCase().startsWith(prefix.toLowerCase())) .slice(0, 4) .map((tag) => '#' + tag.name)), @@ -412,7 +412,7 @@ export default function compose(state = initialState, action: AnyAction) { case COMPOSE_SUGGESTION_SELECT: return updateCompose(state, action.id, compose => insertSuggestion(compose, action.position, action.token, action.completion, action.path)); case COMPOSE_SUGGESTION_TAGS_UPDATE: - return updateCompose(state, action.id, compose => updateSuggestionTags(compose, action.token, action.currentTrends)); + return updateCompose(state, action.id, compose => updateSuggestionTags(compose, action.token, action.tags)); case COMPOSE_TAG_HISTORY_UPDATE: return updateCompose(state, action.id, compose => compose.set('tagHistory', ImmutableList(fromJS(action.tags)) as ImmutableList)); case TIMELINE_DELETE: diff --git a/app/soapbox/utils/suggestions.ts b/app/soapbox/utils/suggestions.ts index 4fe13fbb87..b09f53ef3a 100644 --- a/app/soapbox/utils/suggestions.ts +++ b/app/soapbox/utils/suggestions.ts @@ -32,4 +32,4 @@ const textAtCursorMatchesToken = ( } }; -export { textAtCursorMatchesToken }; \ No newline at end of file +export { textAtCursorMatchesToken }; diff --git a/package.json b/package.json index ed9d6d35f5..26389afd56 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@lexical/react": "^0.9.0", "@lexical/rich-text": "^0.9.0", "@lexical/selection": "^0.9.0", - "@lexical/utils": "^0.9.0", + "@lexical/utils": "^0.9.1", "@metamask/providers": "^10.0.0", "@popperjs/core": "^2.11.5", "@reach/combobox": "^0.18.0", diff --git a/yarn.lock b/yarn.lock index 26c985f02f..40b16dae7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2440,6 +2440,13 @@ dependencies: "@lexical/utils" "0.9.0" +"@lexical/list@0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@lexical/list/-/list-0.9.1.tgz#18eba1e28d818a53661b7381f32b4461f024981e" + integrity sha512-z3wJfDjStesqjvamUgMez+CVvfGrmVqyGgMnCkYBrlfSZ8zlHvz8sJ/6iZkTh7TXIEkCTQpusCrkjUeYccmTxQ== + dependencies: + "@lexical/utils" "0.9.1" + "@lexical/mark@0.9.0": version "0.9.0" resolved "https://registry.yarnpkg.com/@lexical/mark/-/mark-0.9.0.tgz#040d3e8d3e2f46160bd4e5b1e6a5489df0c6aa46" @@ -2508,6 +2515,11 @@ resolved "https://registry.yarnpkg.com/@lexical/selection/-/selection-0.9.0.tgz#2c11085f94435c1c71344654a1f2b9a0c3549a22" integrity sha512-jPLeaqNujER1t61OWmSuUMS010A6Nz49efO8rEJFjMbUK7GB9IDsSDgvE4WnT/yBrXLAickjSiXgUq51LQTYYw== +"@lexical/selection@0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@lexical/selection/-/selection-0.9.1.tgz#6d809d9b8c3673992ba6fc6ccd6af5f402f57ba0" + integrity sha512-4eZcH8d4Kq/GswobVNIkM5fKk8YXPNFLCOHIyi0FmHymW/Ku7HLz2+mcOR6EQmk1Ce/4VJ4Oq+d1XlZw4PPTNA== + "@lexical/table@0.9.0": version "0.9.0" resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.9.0.tgz#8f4e0d797c15141e26667cc54d7b640c90f5a9b8" @@ -2515,12 +2527,19 @@ dependencies: "@lexical/utils" "0.9.0" +"@lexical/table@0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.9.1.tgz#06978da97425e46399d48b5d75d19b389c5b6a38" + integrity sha512-BPKmpToQRxv87OtA6W22XwfOBtZf+vG95jp/bj2ecJAW5BVMjHf5ViI3vjXf3Z701giVh1OVXNct8EPkcfvg3w== + dependencies: + "@lexical/utils" "0.9.1" + "@lexical/text@0.9.0": version "0.9.0" resolved "https://registry.yarnpkg.com/@lexical/text/-/text-0.9.0.tgz#ee90b42d8558123c917f561cfbfb804dac25e0a4" integrity sha512-+OHoB1Qb2SajGTEm9K4I3hDtCmeLr0Bs62psra6qYE/lil0Y8brrEXB5egppDpm6mKtiMzGrkBDMpcsKw/kiWA== -"@lexical/utils@0.9.0", "@lexical/utils@^0.9.0": +"@lexical/utils@0.9.0": version "0.9.0" resolved "https://registry.yarnpkg.com/@lexical/utils/-/utils-0.9.0.tgz#923e79af94566844442bc8699aba89f7d1cf5779" integrity sha512-s4BrBKrd7VHexLSYdSKrRAVmuxmfVV49MYx/khILGnNQKYS2O8PuERCXi+IRi8Ac5ASELO6d28Y2my8NMM1CYA== @@ -2529,6 +2548,15 @@ "@lexical/selection" "0.9.0" "@lexical/table" "0.9.0" +"@lexical/utils@0.9.1", "@lexical/utils@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@lexical/utils/-/utils-0.9.1.tgz#5fd58468da890e7ed5260c502f66fd19ec64e9b3" + integrity sha512-cszNQufH6UZxOVew7DTELM63wjj8qOiGiOFklMmM6QmkirHb2fK4LxeLjaKiwP5DkA0A1NDNz8yuuMXqA/mWPQ== + dependencies: + "@lexical/list" "0.9.1" + "@lexical/selection" "0.9.1" + "@lexical/table" "0.9.1" + "@lexical/yjs@0.9.0": version "0.9.0" resolved "https://registry.yarnpkg.com/@lexical/yjs/-/yjs-0.9.0.tgz#88d90c346102bf46f5ee2ef6a6c763bef1f12bf6"