Merge branch 'emoji-floating-ui' into 'develop'

Emoji floating ui

Closes #1390 and #1398

See merge request soapbox-pub/soapbox!2367
This commit is contained in:
Alex Gleason 2023-03-20 20:03:41 +00:00
commit 396f6ada1a
10 changed files with 90 additions and 137 deletions

View File

@ -1,11 +1,10 @@
import { Placement } from '@popperjs/core'; import { shift, useFloating, Placement, offset, OffsetOptions } from '@floating-ui/react';
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { usePopper } from 'react-popper';
import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui'; import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown'; import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown';
import { useFeatures, useSoapboxConfig } from 'soapbox/hooks'; import { useClickOutside, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
import type { Emoji } from 'soapbox/features/emoji'; import type { Emoji } from 'soapbox/features/emoji';
@ -45,8 +44,7 @@ interface IEmojiSelector {
placement?: Placement placement?: Placement
/** Whether the selector should be visible. */ /** Whether the selector should be visible. */
visible?: boolean visible?: boolean
/** X/Y offset of the floating picker. */ offsetOptions?: OffsetOptions
offset?: [number, number]
/** Whether to allow any emoji to be chosen. */ /** Whether to allow any emoji to be chosen. */
all?: boolean all?: boolean
} }
@ -58,7 +56,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
onReact, onReact,
placement = 'top', placement = 'top',
visible = false, visible = false,
offset = [-10, 0], offsetOptions,
all = true, all = true,
}): JSX.Element => { }): JSX.Element => {
const soapboxConfig = useSoapboxConfig(); const soapboxConfig = useSoapboxConfig();
@ -66,36 +64,9 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
// `useRef` won't trigger a re-render, while `useState` does. const { x, y, strategy, refs, update } = useFloating<HTMLElement>({
// https://popper.js.org/react-popper/v2/
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const handleClickOutside = (event: MouseEvent) => {
if ([referenceElement, popperElement, document.querySelector('em-emoji-picker')].some(el => el?.contains(event.target as Node))) {
return;
}
if (document.querySelector('em-emoji-picker')) {
event.preventDefault();
event.stopPropagation();
return setExpanded(false);
}
if (onClose) {
onClose();
}
};
const { styles, attributes, update } = usePopper(referenceElement, popperElement, {
placement, placement,
modifiers: [ middleware: [offset(offsetOptions), shift()],
{
name: 'offset',
options: {
offset,
},
},
],
}); });
const handleExpand: React.MouseEventHandler = () => { const handleExpand: React.MouseEventHandler = () => {
@ -106,6 +77,10 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
onReact(emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined); onReact(emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined);
}; };
useEffect(() => {
refs.setReference(referenceElement);
}, [referenceElement]);
useEffect(() => () => { useEffect(() => () => {
document.body.style.overflow = ''; document.body.style.overflow = '';
}, []); }, []);
@ -114,35 +89,24 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
setExpanded(false); setExpanded(false);
}, [visible]); }, [visible]);
useEffect(() => { useClickOutside(refs, () => {
document.addEventListener('mousedown', handleClickOutside); if (onClose) {
onClose();
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [referenceElement, popperElement]);
useEffect(() => {
if (visible && update) {
update();
} }
}, [visible, update]); });
useEffect(() => {
if (expanded && update) {
update();
}
}, [expanded, update]);
return ( return (
<div <div
className={clsx('z-[101] transition-opacity duration-100', { className={clsx('z-[101] transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible, 'opacity-0 pointer-events-none': !visible,
})} })}
ref={setPopperElement} ref={refs.setFloating}
style={styles.popper} style={{
{...attributes.popper} position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content',
}}
> >
{expanded ? ( {expanded ? (
<EmojiPickerDropdown <EmojiPickerDropdown

View File

@ -110,7 +110,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
); );
if (token && tokenStart) { if (token && tokenStart) {
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any); const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
setSuggestions({ setSuggestions({
list: results, list: results,
token, token,

View File

@ -44,7 +44,7 @@ function ChatMessageReactionWrapper(props: IChatMessageReactionWrapper) {
referenceElement={referenceElement} referenceElement={referenceElement}
onReact={handleSelect} onReact={handleSelect}
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
offset={[-10, 12]} offsetOptions={{ mainAxis: 12, crossAxis: -10 }}
all={false} all={false}
/> />
</Portal> </Portal>

View File

@ -14,13 +14,13 @@ interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
} }
/** Custom textarea for chats. */ /** Custom textarea for chats. */
const ChatTextarea: React.FC<IChatTextarea> = ({ const ChatTextarea: React.FC<IChatTextarea> = React.forwardRef(({
attachments, attachments,
onDeleteAttachment, onDeleteAttachment,
uploadCount = 0, uploadCount = 0,
uploadProgress = 0, uploadProgress = 0,
...rest ...rest
}) => { }, ref) => {
const isUploading = uploadCount > 0; const isUploading = uploadCount > 0;
const handleDeleteAttachment = (i: number) => { const handleDeleteAttachment = (i: number) => {
@ -64,9 +64,9 @@ const ChatTextarea: React.FC<IChatTextarea> = ({
</HStack> </HStack>
)} )}
<Textarea theme='transparent' {...rest} /> <Textarea ref={ref} theme='transparent' {...rest} />
</div> </div>
); );
}; });
export default ChatTextarea; export default ChatTextarea;

View File

@ -11,7 +11,6 @@ import { RootState } from 'soapbox/store';
import { buildCustomEmojis } from '../../emoji'; import { buildCustomEmojis } from '../../emoji';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
import type { State as PopperState } from '@popperjs/core';
import type { Emoji, CustomEmoji, NativeEmoji } from 'soapbox/features/emoji'; import type { Emoji, CustomEmoji, NativeEmoji } from 'soapbox/features/emoji';
let EmojiPicker: any; // load asynchronously let EmojiPicker: any; // load asynchronously
@ -49,7 +48,7 @@ export interface IEmojiPickerDropdown {
withCustom?: boolean withCustom?: boolean
visible: boolean visible: boolean
setVisible: (value: boolean) => void setVisible: (value: boolean) => void
update: (() => Promise<Partial<PopperState>>) | null update: (() => any) | null
} }
const perLine = 8; const perLine = 8;

View File

@ -1,17 +1,14 @@
import { useFloating, shift } from '@floating-ui/react';
import clsx from 'clsx'; import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events'; import React, { KeyboardEvent, useState } from 'react';
import React, { KeyboardEvent, useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { usePopper } from 'react-popper';
import { IconButton } from 'soapbox/components/ui'; import { IconButton } from 'soapbox/components/ui';
import { isMobile } from 'soapbox/is-mobile'; import { useClickOutside } from 'soapbox/hooks';
import EmojiPickerDropdown, { IEmojiPickerDropdown } from '../components/emoji-picker-dropdown'; import EmojiPickerDropdown, { IEmojiPickerDropdown } from '../components/emoji-picker-dropdown';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
export const messages = defineMessages({ export const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
}); });
@ -22,51 +19,28 @@ const EmojiPickerDropdownContainer = (
const intl = useIntl(); const intl = useIntl();
const title = intl.formatMessage(messages.emoji); const title = intl.formatMessage(messages.emoji);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [popperReference, setPopperReference] = useState<HTMLButtonElement | null>(null);
const [containerElement, setContainerElement] = useState<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const placement = props.condensed ? 'bottom-start' : 'top-start'; const { x, y, strategy, refs, update } = useFloating<HTMLButtonElement>({
const { styles, attributes, update } = usePopper(popperReference, popperElement, { middleware: [shift()],
placement: isMobile(window.innerWidth) ? 'auto' : placement,
}); });
const handleDocClick = (e: any) => { useClickOutside(refs, () => {
if (!containerElement?.contains(e.target) && !popperElement?.contains(e.target)) { setVisible(false);
setVisible(false); });
}
};
const handleToggle = (e: MouseEvent | KeyboardEvent) => { const handleToggle = (e: MouseEvent | KeyboardEvent) => {
e.stopPropagation(); e.stopPropagation();
setVisible(!visible); setVisible(!visible);
}; };
// TODO: move to class
const style: React.CSSProperties = !isMobile(window.innerWidth) ? styles.popper : {
...styles.popper, width: '100%',
};
useEffect(() => {
document.addEventListener('click', handleDocClick, false);
document.addEventListener('touchend', handleDocClick, listenerOptions);
return () => {
document.removeEventListener('click', handleDocClick, false);
// @ts-ignore
document.removeEventListener('touchend', handleDocClick, listenerOptions);
};
});
return ( return (
<div className='relative' ref={setContainerElement}> <div className='relative'>
<IconButton <IconButton
className={clsx({ className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true, 'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
})} })}
ref={setPopperReference} ref={refs.setReference}
src={require('@tabler/icons/mood-happy.svg')} src={require('@tabler/icons/mood-happy.svg')}
title={title} title={title}
aria-label={title} aria-label={title}
@ -80,11 +54,20 @@ const EmojiPickerDropdownContainer = (
{createPortal( {createPortal(
<div <div
className='z-[101]' className='z-[101]'
ref={setPopperElement} ref={refs.setFloating}
style={style} style={{
{...attributes.popper} position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content',
}}
> >
<EmojiPickerDropdown visible={visible} setVisible={setVisible} update={update} {...props} /> <EmojiPickerDropdown
visible={visible}
setVisible={setVisible}
update={update}
{...props}
/>
</div>, </div>,
document.body, document.body,
)} )}

View File

@ -1,11 +1,10 @@
import { Index } from 'flexsearch'; import { Index } from 'flexsearch-ts';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import data from './data'; import data from './data';
import type { Emoji } from './index'; import type { Emoji } from './index';
// import type { Emoji as EmojiMart, CustomEmoji } from 'emoji-mart';
// @ts-ignore
const index = new Index({ const index = new Index({
tokenize: 'full', tokenize: 'full',
optimize: true, optimize: true,
@ -37,29 +36,39 @@ export const addCustomToPool = (customEmojis: any[]) => {
}; };
// we can share an index by prefixing custom emojis with 'c' and native with 'n' // we can share an index by prefixing custom emojis with 'c' and native with 'n'
const search = (str: string, { maxResults = 5, custom }: searchOptions = {}, custom_emojis?: any): Emoji[] => { const search = (
str: string, { maxResults = 5 }: searchOptions = {},
custom_emojis?: ImmutableList<ImmutableMap<string, string>>,
): Emoji[] => {
return index.search(str, maxResults) return index.search(str, maxResults)
.flatMap((id: string) => { .flatMap((id) => {
if (id[0] === 'c') { if (typeof id !== 'string') return;
const { shortcode, static_url } = custom_emojis.get((id as string).slice(1)).toJS();
return { if (id[0] === 'c' && custom_emojis) {
id: shortcode, const index = Number(id.slice(1));
colons: ':' + shortcode + ':', const custom = custom_emojis.get(index);
custom: true,
imageUrl: static_url, if (custom) {
}; return {
id: custom.get('shortcode', ''),
colons: ':' + custom.get('shortcode', '') + ':',
custom: true,
imageUrl: custom.get('static_url', ''),
};
}
} }
const { skins } = data.emojis[(id as string).slice(1)]; const skins = data.emojis[id.slice(1)]?.skins;
return { if (skins) {
id: (id as string).slice(1), return {
colons: ':' + id.slice(1) + ':', id: id.slice(1),
unified: skins[0].unified, colons: ':' + id.slice(1) + ':',
native: skins[0].native, unified: skins[0].unified,
}; native: skins[0].native,
}); };
}
}).filter(Boolean) as Emoji[];
}; };
export default search; export default search;

View File

@ -327,7 +327,11 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* Ability to add non-standard reactions to a status. * Ability to add non-standard reactions to a status.
*/ */
customEmojiReacts: v.software === PLEROMA && gte(v.version, '2.5.50'), customEmojiReacts: any([
features.includes('pleroma_custom_emoji_reactions'),
features.includes('custom_emoji_reactions'),
v.software === PLEROMA && gte(v.version, '2.5.50'),
]),
/** /**
* Legacy DMs timeline where messages are displayed chronologically without groupings. * Legacy DMs timeline where messages are displayed chronologically without groupings.

View File

@ -73,7 +73,6 @@
"@tanstack/react-query": "^4.0.10", "@tanstack/react-query": "^4.0.10",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@types/escape-html": "^1.0.1", "@types/escape-html": "^1.0.1",
"@types/flexsearch": "^0.7.3",
"@types/http-link-header": "^1.0.3", "@types/http-link-header": "^1.0.3",
"@types/jest": "^29.0.0", "@types/jest": "^29.0.0",
"@types/leaflet": "^1.8.0", "@types/leaflet": "^1.8.0",
@ -118,7 +117,7 @@
"emoji-mart": "^5.5.2", "emoji-mart": "^5.5.2",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"exif-js": "^2.3.0", "exif-js": "^2.3.0",
"flexsearch": "^0.7.31", "flexsearch-ts": "^0.7.31",
"fork-ts-checker-webpack-plugin": "^8.0.0", "fork-ts-checker-webpack-plugin": "^8.0.0",
"graphemesplit": "^2.4.4", "graphemesplit": "^2.4.4",
"html-webpack-harddisk-plugin": "^2.0.0", "html-webpack-harddisk-plugin": "^2.0.0",

View File

@ -4143,11 +4143,6 @@
resolved "https://registry.yarnpkg.com/@types/filewriter/-/filewriter-0.0.29.tgz#a48795ecadf957f6c0d10e0c34af86c098fa5bee" resolved "https://registry.yarnpkg.com/@types/filewriter/-/filewriter-0.0.29.tgz#a48795ecadf957f6c0d10e0c34af86c098fa5bee"
integrity sha512-BsPXH/irW0ht0Ji6iw/jJaK8Lj3FJemon2gvEqHKpCdDCeemHa+rI3WBGq5z7cDMZgoLjY40oninGxqk+8NzNQ== integrity sha512-BsPXH/irW0ht0Ji6iw/jJaK8Lj3FJemon2gvEqHKpCdDCeemHa+rI3WBGq5z7cDMZgoLjY40oninGxqk+8NzNQ==
"@types/flexsearch@^0.7.3":
version "0.7.3"
resolved "https://registry.yarnpkg.com/@types/flexsearch/-/flexsearch-0.7.3.tgz#ee79b1618035c82284278e05652e91116765b634"
integrity sha512-HXwADeHEP4exXkCIwy2n1+i0f1ilP1ETQOH5KDOugjkTFZPntWo0Gr8stZOaebkxsdx+k0X/K6obU/+it07ocg==
"@types/fs-extra@^9.0.1": "@types/fs-extra@^9.0.1":
version "9.0.13" version "9.0.13"
resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45"
@ -9045,10 +9040,10 @@ flatted@^3.1.0:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561"
integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA== integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==
flexsearch@^0.7.31: flexsearch-ts@^0.7.31:
version "0.7.31" version "0.7.31"
resolved "https://registry.yarnpkg.com/flexsearch/-/flexsearch-0.7.31.tgz#065d4110b95083110b9b6c762a71a77cc52e4702" resolved "https://registry.yarnpkg.com/flexsearch-ts/-/flexsearch-ts-0.7.31.tgz#0353f51789ad8e7660c3df157534dcf2d346a20f"
integrity sha512-XGozTsMPYkm+6b5QL3Z9wQcJjNYxp0CYn3U1gO7dwD6PAqU1SVWZxI9CCg3z+ml3YfqdPnrBehaBrnH2AGKbNA== integrity sha512-Z3geBbHiPw/JALe/thvxTd1LAgDcUNvQuHWGjhO4lG7gOR5IVVPsyS8tRt/qmme9HgXj3zdtHC4yJ3anGW1Xmw==
flush-write-stream@^1.0.0: flush-write-stream@^1.0.0:
version "1.1.1" version "1.1.1"