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

Emoji floating ui

Closes #1390 and #1398

See merge request soapbox-pub/soapbox!2367
develop^2
Alex Gleason 2023-03-20 20:03:41 +00:00
commit 396f6ada1a
10 zmienionych plików z 90 dodań i 137 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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,

Wyświetl plik

@ -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>

Wyświetl plik

@ -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;

Wyświetl plik

@ -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;

Wyświetl plik

@ -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,
)}

Wyświetl plik

@ -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;

Wyświetl plik

@ -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.

Wyświetl plik

@ -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",

Wyświetl plik

@ -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"