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:
commit
396f6ada1a
|
@ -1,11 +1,10 @@
|
|||
import { Placement } from '@popperjs/core';
|
||||
import { shift, useFloating, Placement, offset, OffsetOptions } from '@floating-ui/react';
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
|
||||
import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui';
|
||||
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';
|
||||
|
||||
|
@ -45,8 +44,7 @@ interface IEmojiSelector {
|
|||
placement?: Placement
|
||||
/** Whether the selector should be visible. */
|
||||
visible?: boolean
|
||||
/** X/Y offset of the floating picker. */
|
||||
offset?: [number, number]
|
||||
offsetOptions?: OffsetOptions
|
||||
/** Whether to allow any emoji to be chosen. */
|
||||
all?: boolean
|
||||
}
|
||||
|
@ -58,7 +56,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
|
|||
onReact,
|
||||
placement = 'top',
|
||||
visible = false,
|
||||
offset = [-10, 0],
|
||||
offsetOptions,
|
||||
all = true,
|
||||
}): JSX.Element => {
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
@ -66,36 +64,9 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
|
|||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// `useRef` won't trigger a re-render, while `useState` does.
|
||||
// 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, {
|
||||
const { x, y, strategy, refs, update } = useFloating<HTMLElement>({
|
||||
placement,
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset,
|
||||
},
|
||||
},
|
||||
],
|
||||
middleware: [offset(offsetOptions), shift()],
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refs.setReference(referenceElement);
|
||||
}, [referenceElement]);
|
||||
|
||||
useEffect(() => () => {
|
||||
document.body.style.overflow = '';
|
||||
}, []);
|
||||
|
@ -114,35 +89,24 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
|
|||
setExpanded(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [referenceElement, popperElement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && update) {
|
||||
update();
|
||||
useClickOutside(refs, () => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, [visible, update]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && update) {
|
||||
update();
|
||||
}
|
||||
}, [expanded, update]);
|
||||
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('z-[101] transition-opacity duration-100', {
|
||||
'opacity-0 pointer-events-none': !visible,
|
||||
})}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
width: 'max-content',
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<EmojiPickerDropdown
|
||||
|
|
|
@ -110,7 +110,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
);
|
||||
|
||||
if (token && tokenStart) {
|
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any);
|
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
|
||||
setSuggestions({
|
||||
list: results,
|
||||
token,
|
||||
|
|
|
@ -44,7 +44,7 @@ function ChatMessageReactionWrapper(props: IChatMessageReactionWrapper) {
|
|||
referenceElement={referenceElement}
|
||||
onReact={handleSelect}
|
||||
onClose={() => setIsOpen(false)}
|
||||
offset={[-10, 12]}
|
||||
offsetOptions={{ mainAxis: 12, crossAxis: -10 }}
|
||||
all={false}
|
||||
/>
|
||||
</Portal>
|
||||
|
|
|
@ -14,13 +14,13 @@ interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
|
|||
}
|
||||
|
||||
/** Custom textarea for chats. */
|
||||
const ChatTextarea: React.FC<IChatTextarea> = ({
|
||||
const ChatTextarea: React.FC<IChatTextarea> = React.forwardRef(({
|
||||
attachments,
|
||||
onDeleteAttachment,
|
||||
uploadCount = 0,
|
||||
uploadProgress = 0,
|
||||
...rest
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const isUploading = uploadCount > 0;
|
||||
|
||||
const handleDeleteAttachment = (i: number) => {
|
||||
|
@ -64,9 +64,9 @@ const ChatTextarea: React.FC<IChatTextarea> = ({
|
|||
</HStack>
|
||||
)}
|
||||
|
||||
<Textarea theme='transparent' {...rest} />
|
||||
<Textarea ref={ref} theme='transparent' {...rest} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default ChatTextarea;
|
||||
|
|
|
@ -11,7 +11,6 @@ import { RootState } from 'soapbox/store';
|
|||
import { buildCustomEmojis } from '../../emoji';
|
||||
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';
|
||||
|
||||
let EmojiPicker: any; // load asynchronously
|
||||
|
@ -49,7 +48,7 @@ export interface IEmojiPickerDropdown {
|
|||
withCustom?: boolean
|
||||
visible: boolean
|
||||
setVisible: (value: boolean) => void
|
||||
update: (() => Promise<Partial<PopperState>>) | null
|
||||
update: (() => any) | null
|
||||
}
|
||||
|
||||
const perLine = 8;
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
import { useFloating, shift } from '@floating-ui/react';
|
||||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React, { KeyboardEvent, useEffect, useState } from 'react';
|
||||
import React, { KeyboardEvent, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { usePopper } from 'react-popper';
|
||||
|
||||
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';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
export const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
});
|
||||
|
@ -22,51 +19,28 @@ const EmojiPickerDropdownContainer = (
|
|||
const intl = useIntl();
|
||||
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 placement = props.condensed ? 'bottom-start' : 'top-start';
|
||||
const { styles, attributes, update } = usePopper(popperReference, popperElement, {
|
||||
placement: isMobile(window.innerWidth) ? 'auto' : placement,
|
||||
const { x, y, strategy, refs, update } = useFloating<HTMLButtonElement>({
|
||||
middleware: [shift()],
|
||||
});
|
||||
|
||||
const handleDocClick = (e: any) => {
|
||||
if (!containerElement?.contains(e.target) && !popperElement?.contains(e.target)) {
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
useClickOutside(refs, () => {
|
||||
setVisible(false);
|
||||
});
|
||||
|
||||
const handleToggle = (e: MouseEvent | KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
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 (
|
||||
<div className='relative' ref={setContainerElement}>
|
||||
<div className='relative'>
|
||||
<IconButton
|
||||
className={clsx({
|
||||
'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')}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
|
@ -80,11 +54,20 @@ const EmojiPickerDropdownContainer = (
|
|||
{createPortal(
|
||||
<div
|
||||
className='z-[101]'
|
||||
ref={setPopperElement}
|
||||
style={style}
|
||||
{...attributes.popper}
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
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>,
|
||||
document.body,
|
||||
)}
|
||||
|
|
|
@ -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 type { Emoji } from './index';
|
||||
// import type { Emoji as EmojiMart, CustomEmoji } from 'emoji-mart';
|
||||
|
||||
// @ts-ignore
|
||||
const index = new Index({
|
||||
tokenize: 'full',
|
||||
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'
|
||||
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)
|
||||
.flatMap((id: string) => {
|
||||
if (id[0] === 'c') {
|
||||
const { shortcode, static_url } = custom_emojis.get((id as string).slice(1)).toJS();
|
||||
.flatMap((id) => {
|
||||
if (typeof id !== 'string') return;
|
||||
|
||||
return {
|
||||
id: shortcode,
|
||||
colons: ':' + shortcode + ':',
|
||||
custom: true,
|
||||
imageUrl: static_url,
|
||||
};
|
||||
if (id[0] === 'c' && custom_emojis) {
|
||||
const index = Number(id.slice(1));
|
||||
const custom = custom_emojis.get(index);
|
||||
|
||||
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 {
|
||||
id: (id as string).slice(1),
|
||||
colons: ':' + id.slice(1) + ':',
|
||||
unified: skins[0].unified,
|
||||
native: skins[0].native,
|
||||
};
|
||||
});
|
||||
if (skins) {
|
||||
return {
|
||||
id: id.slice(1),
|
||||
colons: ':' + id.slice(1) + ':',
|
||||
unified: skins[0].unified,
|
||||
native: skins[0].native,
|
||||
};
|
||||
}
|
||||
}).filter(Boolean) as Emoji[];
|
||||
};
|
||||
|
||||
export default search;
|
||||
|
|
|
@ -327,7 +327,11 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
/**
|
||||
* 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.
|
||||
|
|
|
@ -73,7 +73,6 @@
|
|||
"@tanstack/react-query": "^4.0.10",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/escape-html": "^1.0.1",
|
||||
"@types/flexsearch": "^0.7.3",
|
||||
"@types/http-link-header": "^1.0.3",
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/leaflet": "^1.8.0",
|
||||
|
@ -118,7 +117,7 @@
|
|||
"emoji-mart": "^5.5.2",
|
||||
"escape-html": "^1.0.3",
|
||||
"exif-js": "^2.3.0",
|
||||
"flexsearch": "^0.7.31",
|
||||
"flexsearch-ts": "^0.7.31",
|
||||
"fork-ts-checker-webpack-plugin": "^8.0.0",
|
||||
"graphemesplit": "^2.4.4",
|
||||
"html-webpack-harddisk-plugin": "^2.0.0",
|
||||
|
|
11
yarn.lock
11
yarn.lock
|
@ -4143,11 +4143,6 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/filewriter/-/filewriter-0.0.29.tgz#a48795ecadf957f6c0d10e0c34af86c098fa5bee"
|
||||
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":
|
||||
version "9.0.13"
|
||||
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"
|
||||
integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==
|
||||
|
||||
flexsearch@^0.7.31:
|
||||
flexsearch-ts@^0.7.31:
|
||||
version "0.7.31"
|
||||
resolved "https://registry.yarnpkg.com/flexsearch/-/flexsearch-0.7.31.tgz#065d4110b95083110b9b6c762a71a77cc52e4702"
|
||||
integrity sha512-XGozTsMPYkm+6b5QL3Z9wQcJjNYxp0CYn3U1gO7dwD6PAqU1SVWZxI9CCg3z+ml3YfqdPnrBehaBrnH2AGKbNA==
|
||||
resolved "https://registry.yarnpkg.com/flexsearch-ts/-/flexsearch-ts-0.7.31.tgz#0353f51789ad8e7660c3df157534dcf2d346a20f"
|
||||
integrity sha512-Z3geBbHiPw/JALe/thvxTd1LAgDcUNvQuHWGjhO4lG7gOR5IVVPsyS8tRt/qmme9HgXj3zdtHC4yJ3anGW1Xmw==
|
||||
|
||||
flush-write-stream@^1.0.0:
|
||||
version "1.1.1"
|
||||
|
|
Loading…
Reference in New Issue