diff --git a/app/soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown.tsx b/app/soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown.tsx new file mode 100644 index 000000000..282438c9f --- /dev/null +++ b/app/soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown.tsx @@ -0,0 +1,210 @@ +import classNames from 'clsx'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import React, { useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +// @ts-ignore +import Overlay from 'react-overlays/lib/Overlay'; +import { createSelector } from 'reselect'; + +import { useEmoji } from 'soapbox/actions/emojis'; +import { getSettings, changeSetting } from 'soapbox/actions/settings'; +import { IconButton } from 'soapbox/components/ui'; +import { EmojiPicker as EmojiPickerAsync } from 'soapbox/features/ui/util/async-components'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import EmojiPickerMenu from './emoji-picker-menu'; + +import type { Emoji as EmojiType } from 'soapbox/components/autosuggest_emoji'; +import type { RootState } from 'soapbox/store'; + +let EmojiPicker: any, Emoji: any; // load asynchronously + +const perLine = 8; +const lines = 2; + +const DEFAULTS = [ + '+1', + 'grinning', + 'kissing_heart', + 'heart_eyes', + 'laughing', + 'stuck_out_tongue_winking_eye', + 'sweat_smile', + 'joy', + 'yum', + 'disappointed', + 'thinking_face', + 'weary', + 'sob', + 'sunglasses', + 'heart', + 'ok_hand', +]; + +const getFrequentlyUsedEmojis = createSelector([ + (state: RootState) => state.settings.get('frequentlyUsedEmojis', ImmutableMap()), +], emojiCounters => { + let emojis = emojiCounters + .keySeq() + .sort((a: number, b: number) => emojiCounters.get(a) - emojiCounters.get(b)) + .reverse() + .slice(0, perLine * lines) + .toArray(); + + if (emojis.length < DEFAULTS.length) { + const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji)); + emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length)); + } + + return emojis; +}); + +const getCustomEmojis = createSelector([ + (state: RootState) => state.custom_emojis as ImmutableList>, +], emojis => emojis.filter((e) => e.get('visible_in_picker')).sort((a, b) => { + const aShort = a.get('shortcode')!.toLowerCase(); + const bShort = b.get('shortcode')!.toLowerCase(); + + if (aShort < bShort) { + return -1; + } else if (aShort > bShort) { + return 1; + } else { + return 0; + } +}) as ImmutableList>); + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, + emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' }, + emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' }, + custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, + recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, + search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' }, + people: { id: 'emoji_button.people', defaultMessage: 'People' }, + nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, + food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, + activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, + travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, + objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, + symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, + flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, +}); + +interface IEmojiPickerDropdown { + onPickEmoji: (data: EmojiType) => void, + button?: JSX.Element, +} + +const EmojiPickerDropdown: React.FC = ({ onPickEmoji, button }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const customEmojis = useAppSelector((state) => getCustomEmojis(state)); + const skinTone = useAppSelector((state) => getSettings(state).get('skinTone') as number); + const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state)); + + const [active, setActive] = useState(false); + const [loading, setLoading] = useState(false); + const [placement, setPlacement] = useState<'bottom' | 'top'>(); + + const target = useRef(null); + + const onSkinTone = (skinTone: number) => { + dispatch(changeSetting(['skinTone'], skinTone)); + }; + + const handlePickEmoji = (emoji: EmojiType) => { + console.log(emoji); + // eslint-disable-next-line react-hooks/rules-of-hooks + dispatch(useEmoji(emoji)); + + if (onPickEmoji) { + onPickEmoji(emoji); + } + }; + + const onShowDropdown: React.EventHandler = (e) => { + e.stopPropagation(); + + setActive(true); + + if (!EmojiPicker) { + setLoading(true); + + EmojiPickerAsync().then(EmojiMart => { + EmojiPicker = EmojiMart.Picker; + Emoji = EmojiMart.Emoji; + + setLoading(false); + }).catch(() => { + setLoading(false); + }); + } + + const { top } = (e.target as any).getBoundingClientRect(); + setPlacement(top * 2 < innerHeight ? 'bottom' : 'top'); + }; + + const onHideDropdown = () => { + setActive(false); + }; + + const onToggle: React.EventHandler = (e) => { + if (!loading && (!(e as React.KeyboardEvent).key || (e as React.KeyboardEvent).key === 'Enter')) { + if (active) { + onHideDropdown(); + } else { + onShowDropdown(e); + } + } + }; + + const handleKeyDown: React.KeyboardEventHandler = e => { + if (e.key === 'Escape') { + onHideDropdown(); + } + }; + + const title = intl.formatMessage(messages.emoji); + + return ( +
+
+ {button || } +
+ + + + +
+ ); +}; + +export { EmojiPicker, Emoji }; + +export default EmojiPickerDropdown; diff --git a/app/soapbox/features/compose/components/emoji-picker/emoji-picker-menu.tsx b/app/soapbox/features/compose/components/emoji-picker/emoji-picker-menu.tsx new file mode 100644 index 000000000..7cb12e8f5 --- /dev/null +++ b/app/soapbox/features/compose/components/emoji-picker/emoji-picker-menu.tsx @@ -0,0 +1,170 @@ +import classNames from 'clsx'; +import { supportsPassiveEvents } from 'detect-passive-events'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { buildCustomEmojis } from '../../../emoji/emoji'; + +import { EmojiPicker } from './emoji-picker-dropdown'; +import ModifierPicker from './modifier-picker'; + +import type { Emoji } from 'soapbox/components/autosuggest_emoji'; + +const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png'); +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +const categoriesSort = [ + 'recent', + 'custom', + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags', +]; + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, + emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' }, + emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' }, + custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, + recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, + search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' }, + people: { id: 'emoji_button.people', defaultMessage: 'People' }, + nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, + food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, + activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, + travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, + objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, + symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, + flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, +}); + +interface IEmojiPickerMenu { + customEmojis: ImmutableList>, + loading?: boolean, + onClose: () => void, + onPick: (emoji: Emoji) => void, + onSkinTone: (skinTone: number) => void, + skinTone?: number, + frequentlyUsedEmojis?: Array, + style?: React.CSSProperties, +} + +const EmojiPickerMenu: React.FC = ({ + customEmojis, + loading = true, + onClose, + onPick, + onSkinTone, + skinTone, + frequentlyUsedEmojis = [], + style = {}, +}) => { + const intl = useIntl(); + + const node = useRef(null); + + const [modifierOpen, setModifierOpen] = useState(false); + + const handleDocumentClick = useCallback(e => { + if (node.current && !node.current.contains(e.target)) { + onClose(); + } + }, []); + + const getI18n = () => { + return { + search: intl.formatMessage(messages.emoji_search), + notfound: intl.formatMessage(messages.emoji_not_found), + categories: { + search: intl.formatMessage(messages.search_results), + recent: intl.formatMessage(messages.recent), + people: intl.formatMessage(messages.people), + nature: intl.formatMessage(messages.nature), + foods: intl.formatMessage(messages.food), + activity: intl.formatMessage(messages.activity), + places: intl.formatMessage(messages.travel), + objects: intl.formatMessage(messages.objects), + symbols: intl.formatMessage(messages.symbols), + flags: intl.formatMessage(messages.flags), + custom: intl.formatMessage(messages.custom), + }, + }; + }; + + const handleClick = (emoji: any) => { + if (!emoji.native) { + emoji.native = emoji.colons; + } + + onClose(); + onPick(emoji); + }; + + const handleModifierOpen = () => { + setModifierOpen(true); + }; + + const handleModifierClose = () => { + setModifierOpen(false); + }; + + const handleModifierChange = (modifier: number) => { + onSkinTone(modifier); + }; + + useEffect(() => { + document.addEventListener('click', handleDocumentClick, false); + document.addEventListener('touchend', handleDocumentClick, listenerOptions); + + return () => { + document.removeEventListener('click', handleDocumentClick, false); + document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any); + }; + }, []); + + if (loading) { + return
; + } + + const title = intl.formatMessage(messages.emoji); + + return ( +
+ + + +
+ ); +}; + +export default EmojiPickerMenu; diff --git a/app/soapbox/features/compose/components/emoji-picker/modifier-picker-menu.tsx b/app/soapbox/features/compose/components/emoji-picker/modifier-picker-menu.tsx new file mode 100644 index 000000000..b62053ca5 --- /dev/null +++ b/app/soapbox/features/compose/components/emoji-picker/modifier-picker-menu.tsx @@ -0,0 +1,73 @@ +import { supportsPassiveEvents } from 'detect-passive-events'; +import React, { useCallback, useEffect, useRef } from 'react'; + +import { Emoji } from './emoji-picker-dropdown'; + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; +const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png'); + +interface IModifierPickerMenu { + active: boolean, + onSelect: (modifier: number) => void, + onClose: () => void, +} + +const ModifierPickerMenu: React.FC = ({ active, onSelect, onClose }) => { + const node = useRef(null); + + const handleClick: React.MouseEventHandler = e => { + onSelect(+e.currentTarget.getAttribute('data-index')! * 1); + }; + + const handleDocumentClick = useCallback((e => { + if (node.current && !node.current.contains(e.target)) { + onClose(); + } + }), []); + + const attachListeners = () => { + document.addEventListener('click', handleDocumentClick, false); + document.addEventListener('touchend', handleDocumentClick, listenerOptions); + }; + + const removeListeners = () => { + document.removeEventListener('click', handleDocumentClick, false); + document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any); + }; + + useEffect(() => { + return () => { + removeListeners(); + }; + }, []); + + useEffect(() => { + if (active) attachListeners(); + else removeListeners(); + }, [active]); + + return ( +
+ + + + + + +
+ ); +}; + +export default ModifierPickerMenu; diff --git a/app/soapbox/features/compose/components/emoji-picker/modifier-picker.tsx b/app/soapbox/features/compose/components/emoji-picker/modifier-picker.tsx new file mode 100644 index 000000000..a84b71122 --- /dev/null +++ b/app/soapbox/features/compose/components/emoji-picker/modifier-picker.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { Emoji } from './emoji-picker-dropdown'; +import ModifierPickerMenu from './modifier-picker-menu'; + +const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png'); + +interface IModifierPicker { + active: boolean, + modifier?: number, + onOpen: () => void, + onClose: () => void, + onChange: (skinTone: number) => void, +} + +const ModifierPicker: React.FC = ({ active, modifier, onOpen, onClose, onChange }) => { + const handleClick = () => { + if (active) { + onClose(); + } else { + onOpen(); + } + }; + + const handleSelect = (modifier: number) => { + onChange(modifier); + onClose(); + }; + + return ( +
+ + +
+ ); +}; + +export default ModifierPicker;