2023-03-12 15:01:38 -07:00
|
|
|
/**
|
|
|
|
* 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';
|
2023-03-27 11:54:28 -07:00
|
|
|
import clsx from 'clsx';
|
|
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
2023-03-12 15:01:38 -07:00
|
|
|
import ReactDOM from 'react-dom';
|
|
|
|
|
2023-03-27 11:54:28 -07:00
|
|
|
import { fetchComposeSuggestions } from 'soapbox/actions/compose';
|
|
|
|
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
|
|
|
|
|
|
|
import AutosuggestAccount from '../../components/autosuggest-account';
|
2023-03-12 15:01:38 -07:00
|
|
|
import { $createMentionNode, MentionNode } from '../nodes/mention-node';
|
|
|
|
|
2023-03-13 12:16:37 -07:00
|
|
|
import { TypeaheadMenuPlugin } from './typeahead-menu-plugin';
|
|
|
|
|
2023-03-27 11:54:28 -07:00
|
|
|
import type { RangeSelection, TextNode } from 'lexical';
|
2023-03-12 15:01:38 -07:00
|
|
|
|
|
|
|
const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i');
|
|
|
|
|
2023-03-27 11:54:28 -07:00
|
|
|
export const MentionPlugin: React.FC<{
|
|
|
|
composeId: string
|
|
|
|
suggestionsHidden: boolean
|
|
|
|
setSuggestionsHidden: (value: boolean) => void
|
|
|
|
}> = ({
|
|
|
|
composeId, suggestionsHidden, setSuggestionsHidden,
|
|
|
|
}): JSX.Element | null => {
|
|
|
|
const { suggestions } = useCompose(composeId);
|
|
|
|
const dispatch = useAppDispatch();
|
2023-03-12 15:01:38 -07:00
|
|
|
|
|
|
|
const [editor] = useLexicalComposerContext();
|
|
|
|
|
2023-03-27 11:54:28 -07:00
|
|
|
const [selectedSuggestion] = useState(0);
|
2023-03-12 15:01:38 -07:00
|
|
|
|
2023-03-27 11:54:28 -07:00
|
|
|
const onSelectSuggestion: React.MouseEventHandler<HTMLDivElement> = (e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index') as any);
|
|
|
|
|
|
|
|
editor.update(() => {
|
|
|
|
|
|
|
|
dispatch((_, getState) => {
|
|
|
|
const state = editor.getEditorState();
|
|
|
|
const node = (state._selection as RangeSelection)?.anchor?.getNode();
|
|
|
|
|
|
|
|
const content = getState().accounts.get(suggestion)!.acct;
|
2023-03-12 15:01:38 -07:00
|
|
|
|
2023-03-27 11:54:28 -07:00
|
|
|
node.setTextContent(`@${content} `);
|
|
|
|
node.select();
|
2023-03-12 15:01:38 -07:00
|
|
|
});
|
2023-03-27 11:54:28 -07:00
|
|
|
});
|
|
|
|
};
|
2023-03-12 15:01:38 -07:00
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (!editor.hasNodes([MentionNode])) {
|
|
|
|
throw new Error('MentionPlugin: MentionNode not registered on editor');
|
|
|
|
}
|
|
|
|
}, [editor]);
|
|
|
|
|
|
|
|
const createMentionNode = useCallback((textNode: TextNode): MentionNode => {
|
|
|
|
return $createMentionNode(textNode.getTextContent());
|
|
|
|
}, []);
|
|
|
|
|
2023-03-27 14:49:37 -07:00
|
|
|
const getMentionMatch = (text: string) => {
|
2023-03-12 15:01:38 -07:00
|
|
|
const matchArr = REGEX.exec(text);
|
|
|
|
|
2023-03-27 14:49:37 -07:00
|
|
|
if (!matchArr) return null;
|
|
|
|
return matchArr;
|
|
|
|
};
|
2023-03-12 15:01:38 -07:00
|
|
|
|
2023-03-27 14:49:37 -07:00
|
|
|
const getEntityMatch = useCallback((text: string) => {
|
|
|
|
const matchArr = getMentionMatch(text);
|
|
|
|
|
|
|
|
if (!matchArr) return null;
|
2023-03-27 11:54:28 -07:00
|
|
|
|
2023-03-12 15:01:38 -07:00
|
|
|
const mentionLength = matchArr[3].length + 1;
|
|
|
|
const startOffset = matchArr.index + matchArr[1].length;
|
|
|
|
const endOffset = startOffset + mentionLength;
|
|
|
|
return {
|
|
|
|
end: endOffset,
|
|
|
|
start: startOffset,
|
|
|
|
};
|
|
|
|
}, []);
|
|
|
|
|
2023-03-27 14:49:37 -07:00
|
|
|
const checkForMentionMatch = useCallback((text: string) => {
|
|
|
|
const matchArr = getMentionMatch(text);
|
|
|
|
|
|
|
|
if (!matchArr) return null;
|
|
|
|
|
|
|
|
dispatch(fetchComposeSuggestions(composeId, matchArr[0]));
|
|
|
|
|
|
|
|
return {
|
|
|
|
leadOffset: matchArr.index,
|
|
|
|
matchingString: matchArr[0],
|
|
|
|
};
|
|
|
|
}, []);
|
|
|
|
|
2023-03-12 15:01:38 -07:00
|
|
|
useLexicalTextEntity<MentionNode>(
|
2023-03-27 14:49:37 -07:00
|
|
|
getEntityMatch,
|
2023-03-12 15:01:38 -07:00
|
|
|
MentionNode,
|
|
|
|
createMentionNode,
|
|
|
|
);
|
|
|
|
|
2023-03-27 11:54:28 -07:00
|
|
|
const renderSuggestion = (suggestion: string, i: number) => {
|
|
|
|
const inner = <AutosuggestAccount id={suggestion} />;
|
|
|
|
const key = suggestion;
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
role='button'
|
|
|
|
tabIndex={0}
|
|
|
|
key={key}
|
|
|
|
data-index={i}
|
|
|
|
className={clsx({
|
|
|
|
'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true,
|
|
|
|
'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion,
|
|
|
|
})}
|
|
|
|
onMouseDown={onSelectSuggestion}
|
|
|
|
>
|
|
|
|
{inner}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (suggestions && suggestions.size > 0) setSuggestionsHidden(false);
|
|
|
|
}, [suggestions]);
|
|
|
|
|
2023-03-12 15:01:38 -07:00
|
|
|
return (
|
2023-03-27 14:49:37 -07:00
|
|
|
<TypeaheadMenuPlugin
|
2023-03-12 15:01:38 -07:00
|
|
|
triggerFn={checkForMentionMatch}
|
2023-03-27 14:49:37 -07:00
|
|
|
menuRenderFn={(anchorElementRef) =>
|
|
|
|
anchorElementRef.current
|
2023-03-12 15:01:38 -07:00
|
|
|
? ReactDOM.createPortal(
|
2023-03-27 11:54:28 -07:00
|
|
|
<div
|
|
|
|
className={clsx({
|
|
|
|
'mt-6 fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
|
|
|
|
hidden: suggestionsHidden || suggestions.isEmpty(),
|
|
|
|
block: !suggestionsHidden && !suggestions.isEmpty(),
|
|
|
|
})}
|
|
|
|
>
|
|
|
|
{suggestions.map(renderSuggestion)}
|
2023-03-12 15:01:38 -07:00
|
|
|
</div>,
|
|
|
|
anchorElementRef.current,
|
|
|
|
)
|
|
|
|
: null
|
|
|
|
}
|
|
|
|
/>
|
|
|
|
);
|
2023-03-17 14:05:59 -07:00
|
|
|
};
|