diff --git a/package-lock.json b/package-lock.json index 00ad7a96e..f98f0c27c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7660,8 +7660,8 @@ "dev": true }, "emoji-data-ios": { - "version": "github:korenskoy/emoji-data-ios#10073b1244de618a3e587ae1d91b5e46ec01fd06", - "from": "github:korenskoy/emoji-data-ios#10073b1" + "version": "github:korenskoy/emoji-data-ios#e2c6557d2d36612a882d9b81b2467f441f1f4179", + "from": "github:korenskoy/emoji-data-ios#e2c6557" }, "emojis-list": { "version": "2.1.0", diff --git a/package.json b/package.json index 42a5a785b..d44a80737 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "async-mutex": "^0.1.4", "big-integer": "painor/BigInteger.js", "croppie": "^2.6.4", - "emoji-data-ios": "github:korenskoy/emoji-data-ios#10073b1", + "emoji-data-ios": "github:korenskoy/emoji-data-ios#e2c6557", "events": "^3.0.0", "idb-keyval": "^5.0.5", "opus-recorder": "^6.2.0", diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 3257ce5b2..e5267aeaf 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -38,7 +38,7 @@ type EmojiCategory = { type Emoji = { id: string; - colons: string; + names: string[]; native: string; image: string; skin?: number; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index cd775f2c7..021195619 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -27,12 +27,13 @@ export { default as AttachmentModal } from '../components/middle/composer/Attach export { default as PollModal } from '../components/middle/composer/PollModal'; export { default as SymbolMenu } from '../components/middle/composer/SymbolMenu'; export { default as AttachMenu } from '../components/middle/composer/AttachMenu'; -export { default as MentionMenu } from '../components/middle/composer/MentionMenu'; -export { default as EmojiTooltip } from '../components/middle/composer/EmojiTooltip'; +export { default as MentionTooltip } from '../components/middle/composer/MentionTooltip'; +export { default as StickerTooltip } from '../components/middle/composer/StickerTooltip'; export { default as BotKeyboardMenu } from '../components/middle/composer/BotKeyboardMenu'; export { default as CustomSendMenu } from '../components/middle/composer/CustomSendMenu'; export { default as DropArea } from '../components/middle/composer/DropArea'; export { default as TextFormatter } from '../components/middle/composer/TextFormatter'; +export { default as EmojiTooltip } from '../components/middle/composer/EmojiTooltip'; export { default as RightSearch } from '../components/right/RightSearch'; export { default as StickerSearch } from '../components/right/StickerSearch'; diff --git a/src/components/middle/composer/AttachmentModal.scss b/src/components/middle/composer/AttachmentModal.scss index b952891ca..52185daa5 100644 --- a/src/components/middle/composer/AttachmentModal.scss +++ b/src/components/middle/composer/AttachmentModal.scss @@ -69,7 +69,7 @@ background: var(--color-background); } - .MentionMenu { + .MentionTooltip { right: 0 !important; z-index: 0; } diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 0568786ea..96975070a 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -8,14 +8,14 @@ import { EDITABLE_INPUT_MODAL_ID } from '../../../config'; import { getFileExtension } from '../../common/helpers/documentInfo'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import usePrevious from '../../../hooks/usePrevious'; -import useMentionMenu from './hooks/useMentionMenu'; +import useMentionTooltip from './hooks/useMentionTooltip'; import useLang from '../../../hooks/useLang'; import Button from '../../ui/Button'; import Modal from '../../ui/Modal'; import File from '../../common/File'; import MessageInput from './MessageInput'; -import MentionMenu from './MentionMenu'; +import MentionTooltip from './MentionTooltip'; import './AttachmentModal.scss'; @@ -47,10 +47,10 @@ const AttachmentModal: FC = ({ const isOpen = Boolean(attachments.length); const { - isMentionMenuOpen, mentionFilter, - closeMentionMenu, insertMention, + isMentionTooltipOpen, mentionFilter, + closeMentionTooltip, insertMention, mentionFilteredMembers, - } = useMentionMenu( + } = useMentionTooltip( canSuggestMembers && isOpen, caption, onCaptionUpdate, @@ -136,9 +136,9 @@ const AttachmentModal: FC = ({ )}
- ; + recentEmojis: string[]; lastSyncTime?: number; contentToBeScheduled?: GlobalState['messages']['contentToBeScheduled']; shouldSuggestStickers?: boolean; @@ -171,6 +174,7 @@ const Composer: FC = ({ lastSyncTime, contentToBeScheduled, shouldSuggestStickers, + recentEmojis, sendMessage, editMessage, saveDraft, @@ -254,10 +258,10 @@ const Composer: FC = ({ const canShowCustomSendMenu = !shouldSchedule; const { - isMentionMenuOpen, mentionFilter, - closeMentionMenu, insertMention, + isMentionTooltipOpen, mentionFilter, + closeMentionTooltip, insertMention, mentionFilteredMembers, - } = useMentionMenu( + } = useMentionTooltip( canSuggestMembers && !attachments.length, html, setHtml, @@ -281,11 +285,20 @@ const Composer: FC = ({ const isAdmin = chat && isChatAdmin(chat); const slowMode = getChatSlowModeOptions(chat); - const { isEmojiTooltipOpen, closeEmojiTooltip } = useEmojiTooltip( + const { isStickerTooltipOpen, closeStickerTooltip } = useStickerTooltip( Boolean(shouldSuggestStickers && allowedAttachmentOptions.canSendStickers && !attachments.length), html, stickersForEmoji, ); + const { + isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji, + } = useEmojiTooltip( + Boolean(shouldSuggestStickers && allowedAttachmentOptions.canSendStickers && !attachments.length), + html, + recentEmojis, + undefined, + setHtml, + ); const insertTextAndUpdateCursor = useCallback((text: string) => { const selection = window.getSelection()!; @@ -337,10 +350,11 @@ const Composer: FC = ({ const resetComposer = useCallback(() => { setHtml(''); setAttachments([]); - closeEmojiTooltip(); + closeStickerTooltip(); closeCalendar(); setScheduledMessageArgs(undefined); - closeMentionMenu(); + closeMentionTooltip(); + closeEmojiTooltip(); if (IS_MOBILE_SCREEN) { // @perf @@ -348,7 +362,7 @@ const Composer: FC = ({ } else { closeSymbolMenu(); } - }, [closeEmojiTooltip, closeCalendar, closeMentionMenu, closeSymbolMenu]); + }, [closeStickerTooltip, closeCalendar, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu]); // Handle chat change const prevChatId = usePrevious(chatId); @@ -689,10 +703,10 @@ const Composer: FC = ({ message={renderedEditedMessage} /> )} - = ({ } shouldSetFocus={isSymbolMenuOpen} shouldSupressFocus={IS_MOBILE_SCREEN && isSymbolMenuOpen} + shouldSupressTextFormatter={isEmojiTooltipOpen || isMentionTooltipOpen} onUpdate={setHtml} onSend={mainButtonState === MainButtonState.Edit ? handleEditComplete @@ -786,9 +801,15 @@ const Composer: FC = ({ {formatVoiceRecordDuration(currentRecordTime - startRecordTimeRef.current!)} )} + ( isPaymentModalOpen: global.payment.isPaymentModalOpen, isReceiptModalOpen: Boolean(global.payment.receipt), shouldSuggestStickers: global.settings.byKey.shouldSuggestStickers, + recentEmojis: global.recentEmojis, }; }, (setGlobal, actions): DispatchProps => pick(actions, [ diff --git a/src/components/middle/composer/EmojiButton.scss b/src/components/middle/composer/EmojiButton.scss index b3f4e6d10..6e20ae65b 100644 --- a/src/components/middle/composer/EmojiButton.scss +++ b/src/components/middle/composer/EmojiButton.scss @@ -16,6 +16,7 @@ line-height: inherit; } + &.focus, &:hover { background-color: rgba(var(--color-text-secondary-rgb), 0.08); } diff --git a/src/components/middle/composer/EmojiButton.tsx b/src/components/middle/composer/EmojiButton.tsx index 687fb296d..d2c0e0b5e 100644 --- a/src/components/middle/composer/EmojiButton.tsx +++ b/src/components/middle/composer/EmojiButton.tsx @@ -6,19 +6,20 @@ import './EmojiButton.scss'; type OwnProps = { emoji: Emoji; + focus?: boolean; onClick: (emoji: string, name: string) => void; }; -const EmojiButton: FC = ({ emoji, onClick }) => { +const EmojiButton: FC = ({ emoji, focus, onClick }) => { const handleClick = useCallback(() => { onClick(emoji.native, emoji.id); }, [emoji, onClick]); return (
{IS_EMOJI_SUPPORTED ? emoji.native : }
diff --git a/src/components/middle/composer/EmojiTooltip.scss b/src/components/middle/composer/EmojiTooltip.scss index 15314fe9b..817f5e818 100644 --- a/src/components/middle/composer/EmojiTooltip.scss +++ b/src/components/middle/composer/EmojiTooltip.scss @@ -1,37 +1,11 @@ .EmojiTooltip { - position: absolute; - bottom: calc(100% + .5rem); - left: 0; - width: 100%; - background: var(--color-background); - border-radius: var(--border-radius-messages); - padding: 0.5rem 0; - max-height: 15rem; - overflow-x: hidden; - overflow-y: auto; + display: flex; + padding-left: .25rem; + overflow-x: auto; + overflow-x: overlay; + overflow-y: hidden; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(5rem, 1fr)); - grid-auto-rows: auto; - place-items: center; - - box-shadow: 0 1px 2px var(--color-default-shadow); - - opacity: 0; - transform: translateY(1.5rem); - transform-origin: bottom; - transition: opacity var(--layer-transition), transform var(--layer-transition); - - &:not(.shown) { - display: none; - } - - &.open { - opacity: 1; - transform: translateY(0); - } - - .Loading { - margin: 1rem 0; + .EmojiButton { + flex: 0 0 2.5rem } } diff --git a/src/components/middle/composer/EmojiTooltip.tsx b/src/components/middle/composer/EmojiTooltip.tsx index ce37716ec..47fb1fe2d 100644 --- a/src/components/middle/composer/EmojiTooltip.tsx +++ b/src/components/middle/composer/EmojiTooltip.tsx @@ -1,55 +1,110 @@ import React, { - FC, memo, useEffect, useRef, + FC, memo, useCallback, useEffect, useRef, useState, } from '../../../lib/teact/teact'; -import { withGlobal } from '../../../lib/teact/teactn'; -import { ApiSticker } from '../../../api/types'; -import { GlobalActions } from '../../../global/types'; - -import { STICKER_SIZE_PICKER } from '../../../config'; import { IS_TOUCH_ENV } from '../../../util/environment'; import buildClassName from '../../../util/buildClassName'; -import captureEscKeyListener from '../../../util/captureEscKeyListener'; -import { pick } from '../../../util/iteratees'; -import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; +import cycleRestrict from '../../../util/cycleRestrict'; +import captureKeyboardListeners from '../../../util/captureKeyboardListeners'; +import findInViewport from '../../../util/findInViewport'; +import isFullyVisible from '../../../util/isFullyVisible'; +import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal'; import useShowTransition from '../../../hooks/useShowTransition'; -import usePrevious from '../../../hooks/usePrevious'; import Loading from '../../ui/Loading'; -import StickerButton from '../../common/StickerButton'; +import EmojiButton from './EmojiButton'; import './EmojiTooltip.scss'; +const VIEWPORT_MARGIN = 8; +const EMOJI_BUTTON_WIDTH = 44; + +function setItemVisible(index: number, containerRef: Record) { + const container = containerRef.current!; + if (!container) { + return; + } + + const { visibleIndexes, allElements } = findInViewport( + container, + '.EmojiButton', + VIEWPORT_MARGIN, + true, + true, + true, + ); + + if (!allElements.length || !allElements[index]) { + return; + } + const first = visibleIndexes[0]; + if (!visibleIndexes.includes(index) + || (index === first && !isFullyVisible(container, allElements[first], true))) { + const position = index > visibleIndexes[visibleIndexes.length - 1] ? 'start' : 'end'; + const newLeft = position === 'start' ? index * EMOJI_BUTTON_WIDTH : 0; + + fastSmoothScrollHorizontal(container, newLeft); + } +} + export type OwnProps = { isOpen: boolean; - onStickerSelect: (sticker: ApiSticker) => void; + onEmojiSelect: (text: string) => void; + onClose: NoneToVoidFunction; + emojis: Emoji[]; }; -type StateProps = { - stickers?: ApiSticker[]; -}; - -type DispatchProps = Pick; - -const INTERSECTION_THROTTLE = 200; - -const EmojiTooltip: FC = ({ +const EmojiTooltip: FC = ({ isOpen, - onStickerSelect, - stickers, - clearStickersForEmoji, + emojis, + onClose, + onEmojiSelect, }) => { // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false); - const prevStickers = usePrevious(stickers, true); - const displayedStickers = stickers || prevStickers; - const { - observe: observeIntersection, - } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE }); + const [selectedIndex, setSelectedIndex] = useState(-1); - useEffect(() => (isOpen ? captureEscKeyListener(clearStickersForEmoji) : undefined), [isOpen, clearStickersForEmoji]); + useEffect(() => { + setSelectedIndex(0); + }, [emojis]); + + useEffect(() => { + setItemVisible(selectedIndex, containerRef); + }, [selectedIndex]); + + const getSelectedIndex = useCallback((newIndex: number) => { + if (!emojis.length) { + return -1; + } + + const emojisCount = emojis.length; + return cycleRestrict(emojisCount, newIndex); + }, [emojis]); + + + const handleArrowKey = useCallback((value: number, e: KeyboardEvent) => { + e.preventDefault(); + setSelectedIndex((index) => (getSelectedIndex(index + value))); + }, [setSelectedIndex, getSelectedIndex]); + + const handleSelectEmoji = useCallback((e: KeyboardEvent) => { + if (emojis.length && selectedIndex > -1) { + const emoji = emojis[selectedIndex]; + if (emoji) { + e.preventDefault(); + onEmojiSelect(emoji.native); + } + } + }, [emojis, onEmojiSelect, selectedIndex]); + + useEffect(() => (isOpen ? captureKeyboardListeners({ + onEsc: onClose, + onLeft: (e: KeyboardEvent) => handleArrowKey(-1, e), + onRight: (e: KeyboardEvent) => handleArrowKey(1, e), + onEnter: handleSelectEmoji, + }) : undefined), [handleArrowKey, handleSelectEmoji, isOpen, onClose]); const handleMouseEnter = () => { document.body.classList.add('no-select'); @@ -60,7 +115,7 @@ const EmojiTooltip: FC = ({ }; const className = buildClassName( - 'EmojiTooltip custom-scroll', + 'EmojiTooltip composer-tooltip custom-scroll-x', transitionClassNames, ); @@ -71,15 +126,13 @@ const EmojiTooltip: FC = ({ onMouseEnter={!IS_TOUCH_ENV ? handleMouseEnter : undefined} onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined} > - {shouldRender && displayedStickers ? ( - displayedStickers.map((sticker) => ( - ( + )) ) : shouldRender ? ( @@ -89,11 +142,4 @@ const EmojiTooltip: FC = ({ ); }; -export default memo(withGlobal( - (global): StateProps => { - const { stickers } = global.stickers.forEmoji; - - return { stickers }; - }, - (setGlobal, actions): DispatchProps => pick(actions, ['clearStickersForEmoji']), -)(EmojiTooltip)); +export default memo(EmojiTooltip); diff --git a/src/components/middle/composer/MentionMenu.async.tsx b/src/components/middle/composer/MentionMenu.async.tsx deleted file mode 100644 index 01e99dab8..000000000 --- a/src/components/middle/composer/MentionMenu.async.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React, { FC, memo } from '../../../lib/teact/teact'; -import { OwnProps } from './MentionMenu'; -import { Bundles } from '../../../util/moduleLoader'; - -import useModuleLoader from '../../../hooks/useModuleLoader'; - -const MentionMenuAsync: FC = (props) => { - const { isOpen } = props; - const MentionMenu = useModuleLoader(Bundles.Extra, 'MentionMenu', !isOpen); - - // eslint-disable-next-line react/jsx-props-no-spreading - return MentionMenu ? : undefined; -}; - -export default memo(MentionMenuAsync); diff --git a/src/components/middle/composer/MentionTooltip.async.tsx b/src/components/middle/composer/MentionTooltip.async.tsx new file mode 100644 index 000000000..228d9a30b --- /dev/null +++ b/src/components/middle/composer/MentionTooltip.async.tsx @@ -0,0 +1,15 @@ +import React, { FC, memo } from '../../../lib/teact/teact'; +import { OwnProps } from './MentionTooltip'; +import { Bundles } from '../../../util/moduleLoader'; + +import useModuleLoader from '../../../hooks/useModuleLoader'; + +const MentionTooltipAsync: FC = (props) => { + const { isOpen } = props; + const MentionTooltip = useModuleLoader(Bundles.Extra, 'MentionTooltip', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading + return MentionTooltip ? : undefined; +}; + +export default memo(MentionTooltipAsync); diff --git a/src/components/middle/composer/MentionMenu.scss b/src/components/middle/composer/MentionTooltip.scss similarity index 50% rename from src/components/middle/composer/MentionMenu.scss rename to src/components/middle/composer/MentionTooltip.scss index a96fa9c2b..bec4e108f 100644 --- a/src/components/middle/composer/MentionMenu.scss +++ b/src/components/middle/composer/MentionTooltip.scss @@ -1,41 +1,14 @@ -.MentionMenu { - position: absolute; - bottom: calc(100% + .75rem); - left: 0; +.MentionTooltip { width: calc(100% - 4rem); max-width: 20rem; - background: var(--color-background); - border-radius: var(--border-radius-messages); - padding: 0.5rem 0; - max-height: 15rem; - overflow-x: hidden; - overflow-y: auto; + flex-direction: column; - box-shadow: 3px 3px 5px var(--color-default-shadow); z-index: -1; - opacity: 0; - transform: translateY(1.5rem); - transform-origin: bottom; - transition: opacity var(--layer-transition), transform var(--layer-transition); - @media (max-width: 600px) { width: calc(100% - 3rem); } - &:not(.shown) { - display: none; - } - - &.open { - opacity: 1; - transform: translateY(0); - } - - .Loading { - margin: 1rem 0; - } - .ListItem.chat-item-clickable { margin: 0; diff --git a/src/components/middle/composer/MentionMenu.tsx b/src/components/middle/composer/MentionTooltip.tsx similarity index 96% rename from src/components/middle/composer/MentionMenu.tsx rename to src/components/middle/composer/MentionTooltip.tsx index 73e3d58d7..face38747 100644 --- a/src/components/middle/composer/MentionMenu.tsx +++ b/src/components/middle/composer/MentionTooltip.tsx @@ -16,7 +16,7 @@ import cycleRestrict from '../../../util/cycleRestrict'; import ListItem from '../../ui/ListItem'; import PrivateChatInfo from '../../common/PrivateChatInfo'; -import './MentionMenu.scss'; +import './MentionTooltip.scss'; const VIEWPORT_MARGIN = 8; const SCROLL_MARGIN = 10; @@ -53,7 +53,7 @@ export type OwnProps = { usersById?: Record; }; -const MentionMenu: FC = ({ +const MentionTooltip: FC = ({ isOpen, filter, onClose, @@ -136,7 +136,7 @@ const MentionMenu: FC = ({ } const className = buildClassName( - 'MentionMenu custom-scroll', + 'MentionTooltip composer-tooltip custom-scroll', transitionClassNames, ); @@ -160,4 +160,4 @@ const MentionMenu: FC = ({ ); }; -export default memo(MentionMenu); +export default memo(MentionTooltip); diff --git a/src/components/middle/composer/MessageInput.tsx b/src/components/middle/composer/MessageInput.tsx index 7caf41861..adcabd8c3 100644 --- a/src/components/middle/composer/MessageInput.tsx +++ b/src/components/middle/composer/MessageInput.tsx @@ -36,6 +36,7 @@ type OwnProps = { placeholder: string; shouldSetFocus: boolean; shouldSupressFocus?: boolean; + shouldSupressTextFormatter?: boolean; onUpdate: (html: string) => void; onSupressedFocus?: () => void; onSend: () => void; @@ -76,6 +77,7 @@ const MessageInput: FC = ({ placeholder, shouldSetFocus, shouldSupressFocus, + shouldSupressTextFormatter, onUpdate, onSupressedFocus, onSend, @@ -139,7 +141,8 @@ const MessageInput: FC = ({ const selectionRange = selection.getRangeAt(0); const selectedText = selectionRange.toString().trim(); if ( - !isSelectionInsideInput(selectionRange) + shouldSupressTextFormatter + || !isSelectionInsideInput(selectionRange) || !selectedText || parseEmojiOnlyString(selectedText) || !selectionRange.START_TO_END diff --git a/src/components/middle/composer/StickerTooltip.async.tsx b/src/components/middle/composer/StickerTooltip.async.tsx new file mode 100644 index 000000000..3a100816f --- /dev/null +++ b/src/components/middle/composer/StickerTooltip.async.tsx @@ -0,0 +1,15 @@ +import React, { FC } from '../../../lib/teact/teact'; +import { OwnProps } from './StickerTooltip'; +import { Bundles } from '../../../util/moduleLoader'; + +import useModuleLoader from '../../../hooks/useModuleLoader'; + +const StickerTooltipAsync: FC = (props) => { + const { isOpen } = props; + const StickerTooltip = useModuleLoader(Bundles.Extra, 'StickerTooltip', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading + return StickerTooltip ? : undefined; +}; + +export default StickerTooltipAsync; diff --git a/src/components/middle/composer/StickerTooltip.scss b/src/components/middle/composer/StickerTooltip.scss new file mode 100644 index 000000000..b9c940d9a --- /dev/null +++ b/src/components/middle/composer/StickerTooltip.scss @@ -0,0 +1,10 @@ +.StickerTooltip { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr)); + grid-auto-rows: auto; + place-items: center; + + &.hidden { + display: none; + } +} diff --git a/src/components/middle/composer/StickerTooltip.tsx b/src/components/middle/composer/StickerTooltip.tsx new file mode 100644 index 000000000..d07946d6b --- /dev/null +++ b/src/components/middle/composer/StickerTooltip.tsx @@ -0,0 +1,100 @@ +import React, { + FC, memo, useEffect, useRef, +} from '../../../lib/teact/teact'; +import { withGlobal } from '../../../lib/teact/teactn'; + +import { ApiSticker } from '../../../api/types'; +import { GlobalActions } from '../../../global/types'; + +import { STICKER_SIZE_PICKER } from '../../../config'; +import { IS_TOUCH_ENV } from '../../../util/environment'; +import buildClassName from '../../../util/buildClassName'; +import captureEscKeyListener from '../../../util/captureEscKeyListener'; +import { pick } from '../../../util/iteratees'; +import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; +import useShowTransition from '../../../hooks/useShowTransition'; +import usePrevious from '../../../hooks/usePrevious'; + +import Loading from '../../ui/Loading'; +import StickerButton from '../../common/StickerButton'; + +import './StickerTooltip.scss'; + +export type OwnProps = { + isOpen: boolean; + onStickerSelect: (sticker: ApiSticker) => void; +}; + +type StateProps = { + stickers?: ApiSticker[]; +}; + +type DispatchProps = Pick; + +const INTERSECTION_THROTTLE = 200; + +const StickerTooltip: FC = ({ + isOpen, + onStickerSelect, + stickers, + clearStickersForEmoji, +}) => { + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false); + const prevStickers = usePrevious(stickers, true); + const displayedStickers = stickers || prevStickers; + + const { + observe: observeIntersection, + } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE }); + + useEffect(() => (isOpen ? captureEscKeyListener(clearStickersForEmoji) : undefined), [isOpen, clearStickersForEmoji]); + + const handleMouseEnter = () => { + document.body.classList.add('no-select'); + }; + + const handleMouseLeave = () => { + document.body.classList.remove('no-select'); + }; + + const className = buildClassName( + 'StickerTooltip composer-tooltip custom-scroll', + transitionClassNames, + !(displayedStickers && displayedStickers.length) && 'hidden', + ); + + return ( +
+ {shouldRender && displayedStickers ? ( + displayedStickers.map((sticker) => ( + + )) + ) : shouldRender ? ( + + ) : undefined} +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const { stickers } = global.stickers.forEmoji; + + return { stickers }; + }, + (setGlobal, actions): DispatchProps => pick(actions, ['clearStickersForEmoji']), +)(StickerTooltip)); diff --git a/src/components/middle/composer/hooks/useEmojiTooltip.ts b/src/components/middle/composer/hooks/useEmojiTooltip.ts index b55e29bd6..a89e802fa 100644 --- a/src/components/middle/composer/hooks/useEmojiTooltip.ts +++ b/src/components/middle/composer/hooks/useEmojiTooltip.ts @@ -1,36 +1,132 @@ -import { useEffect } from '../../../../lib/teact/teact'; -import { getDispatch } from '../../../../lib/teact/teactn'; +import { + useCallback, useEffect, useMemo, useState, +} from '../../../../lib/teact/teact'; -import { ApiSticker } from '../../../../api/types'; +import { EDITABLE_INPUT_ID } from '../../../../config'; +import { IS_MOBILE_SCREEN } from '../../../../util/environment'; +import { + EmojiData, EmojiModule, EmojiRawData, uncompressEmoji, +} from '../../../../util/emoji'; +import useFlag from '../../../../hooks/useFlag'; +import focusEditableElement from '../../../../util/focusEditableElement'; -import { IS_EMOJI_SUPPORTED } from '../../../../util/environment'; +let emojiDataPromise: Promise; +let emojiRawData: EmojiRawData; +let emojiData: EmojiData; -import parseEmojiOnlyString from '../../../common/helpers/parseEmojiOnlyString'; +const RE_NOT_EMOJI_SEARCH = /[^-:_a-z\d]+/i; +const EMOJIS_LIMIT = 50; export default function useEmojiTooltip( isAllowed: boolean, html: string, - stickers?: ApiSticker[], + recentEmojiIds: string[], + inputId = EDITABLE_INPUT_ID, + onUpdateHtml: (html: string) => void, ) { - const { loadStickersForEmoji, clearStickersForEmoji } = getDispatch(); - const isSingleEmoji = ( - (IS_EMOJI_SUPPORTED && parseEmojiOnlyString(html) === 1) - || (!IS_EMOJI_SUPPORTED && Boolean(html.match(/^]*?>$/g))) + const [isOpen, markIsOpen, unmarkIsOpen] = useFlag(); + const [emojis, setEmojis] = useState([]); + const [filteredEmojis, setFilteredEmojis] = useState([]); + + const recentEmojis = useMemo( + () => { + if (!emojis && !recentEmojiIds.length) { + return []; + } + + return emojis.filter((emoji) => recentEmojiIds.includes(emoji.id)) as Emoji[]; + }, + [emojis, recentEmojiIds], ); - const hasStickers = Boolean(stickers) && isSingleEmoji; + + // Initialize data on first render. + useEffect(() => { + const exec = () => { + setEmojis(Object.values(emojiData.emojis)); + }; + + if (emojiData) { + exec(); + } else { + ensureEmojiData() + .then(exec); + } + }, []); useEffect(() => { - if (isAllowed && isSingleEmoji) { - loadStickersForEmoji({ emoji: html }); - } else if (hasStickers || !isSingleEmoji) { - clearStickersForEmoji(); + if (!html || !emojis) { + unmarkIsOpen(); + return; } - // We omit `hasStickers` here to prevent re-fetching after manually closing tooltip (via ). - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [html, isSingleEmoji, clearStickersForEmoji, loadStickersForEmoji, isAllowed]); + + const code = getEmojiCode(html); + if (!code) { + setFilteredEmojis([]); + unmarkIsOpen(); + return; + } + + const filter = code.substr(1); + const matched = filter === '' ? recentEmojis : emojis.filter((emoji) => { + return 'names' in emoji && (!filter || emoji.names.find((name) => name.includes(filter))); + }) as Emoji[]; + + if (matched.length) { + markIsOpen(); + setFilteredEmojis(matched.slice(0, EMOJIS_LIMIT)); + } else { + unmarkIsOpen(); + } + }, [emojis, html, markIsOpen, recentEmojis, unmarkIsOpen]); + + const insertEmoji = useCallback((textEmoji: string) => { + const atIndex = html.lastIndexOf(':'); + if (atIndex !== -1) { + onUpdateHtml(`${html.substr(0, atIndex)}${textEmoji}`); + const messageInput = document.getElementById(inputId)!; + if (!IS_MOBILE_SCREEN) { + requestAnimationFrame(() => { + focusEditableElement(messageInput, true); + }); + } + } + + unmarkIsOpen(); + }, [html, inputId, onUpdateHtml, unmarkIsOpen]); return { - isEmojiTooltipOpen: hasStickers, - closeEmojiTooltip: clearStickersForEmoji, + isEmojiTooltipOpen: isOpen, + closeEmojiTooltip: unmarkIsOpen, + filteredEmojis, + insertEmoji, }; } + +function getEmojiCode(html: string) { + const tempEl = document.createElement('div'); + tempEl.innerHTML = html; + const text = tempEl.innerText; + + const lastSymbol = text[text.length - 1]; + const lastWord = text.split(RE_NOT_EMOJI_SEARCH).pop(); + + if ( + !text.length || RE_NOT_EMOJI_SEARCH.test(lastSymbol) + || !lastWord || !lastWord.startsWith(':') + ) { + return undefined; + } + + return lastWord.toLowerCase(); +} + +async function ensureEmojiData() { + if (!emojiDataPromise) { + emojiDataPromise = import('emoji-data-ios/emoji-data.json') as unknown as Promise; + emojiRawData = (await emojiDataPromise).default; + + emojiData = uncompressEmoji(emojiRawData); + } + + return emojiDataPromise; +} diff --git a/src/components/middle/composer/hooks/useMentionMenu.ts b/src/components/middle/composer/hooks/useMentionTooltip.ts similarity index 96% rename from src/components/middle/composer/hooks/useMentionMenu.ts rename to src/components/middle/composer/hooks/useMentionTooltip.ts index 67f43e60a..7da685e25 100644 --- a/src/components/middle/composer/hooks/useMentionMenu.ts +++ b/src/components/middle/composer/hooks/useMentionTooltip.ts @@ -10,7 +10,7 @@ import useFlag from '../../../../hooks/useFlag'; const RE_NOT_USERNAME_SEARCH = /[^@_\d\wа-яё]+/i; -export default function useMentionMenu( +export default function useMentionTooltip( canSuggestMembers: boolean | undefined, html: string, onUpdateHtml: (html: string) => void, @@ -90,9 +90,9 @@ export default function useMentionMenu( }, [html, inputId, onUpdateHtml, unmarkIsOpen]); return { - isMentionMenuOpen: isOpen, + isMentionTooltipOpen: isOpen, mentionFilter: currentFilter, - closeMentionMenu: unmarkIsOpen, + closeMentionTooltip: unmarkIsOpen, insertMention, mentionFilteredMembers: filteredMembers, }; diff --git a/src/components/middle/composer/hooks/useStickerTooltip.ts b/src/components/middle/composer/hooks/useStickerTooltip.ts new file mode 100644 index 000000000..1f85f4466 --- /dev/null +++ b/src/components/middle/composer/hooks/useStickerTooltip.ts @@ -0,0 +1,36 @@ +import { useEffect } from '../../../../lib/teact/teact'; +import { getDispatch } from '../../../../lib/teact/teactn'; + +import { ApiSticker } from '../../../../api/types'; + +import { IS_EMOJI_SUPPORTED } from '../../../../util/environment'; + +import parseEmojiOnlyString from '../../../common/helpers/parseEmojiOnlyString'; + +export default function useStickerTooltip( + isAllowed: boolean, + html: string, + stickers?: ApiSticker[], +) { + const { loadStickersForEmoji, clearStickersForEmoji } = getDispatch(); + const isSingleEmoji = ( + (IS_EMOJI_SUPPORTED && parseEmojiOnlyString(html) === 1) + || (!IS_EMOJI_SUPPORTED && Boolean(html.match(/^]*?>$/g))) + ); + const hasStickers = Boolean(stickers) && isSingleEmoji; + + useEffect(() => { + if (isAllowed && isSingleEmoji) { + loadStickersForEmoji({ emoji: html }); + } else if (hasStickers || !isSingleEmoji) { + clearStickersForEmoji(); + } + // We omit `hasStickers` here to prevent re-fetching after manually closing tooltip (via ). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [html, isSingleEmoji, clearStickersForEmoji, loadStickersForEmoji, isAllowed]); + + return { + isStickerTooltipOpen: hasStickers, + closeStickerTooltip: clearStickersForEmoji, + }; +} diff --git a/src/util/captureKeyboardListeners.ts b/src/util/captureKeyboardListeners.ts index 8a087c335..cef332fb3 100644 --- a/src/util/captureKeyboardListeners.ts +++ b/src/util/captureKeyboardListeners.ts @@ -1,4 +1,5 @@ -type HandlerName = 'onEnter' | 'onBackspace' | 'onDelete' | 'onEsc' | 'onUp' | 'onDown' | 'onTab'; +type HandlerName = 'onEnter' | 'onBackspace' | 'onDelete' | 'onEsc' | 'onUp' | 'onDown' | 'onLeft' | 'onRight' +| 'onTab'; type Handler = (e: KeyboardEvent) => void; type CaptureOptions = Partial>; @@ -10,6 +11,8 @@ const keyToHandlerName: Record = { Escape: 'onEsc', ArrowUp: 'onUp', ArrowDown: 'onDown', + ArrowLeft: 'onLeft', + ArrowRight: 'onRight', Tab: 'onTab', }; @@ -20,6 +23,8 @@ const handlers: Record = { onEsc: [], onUp: [], onDown: [], + onLeft: [], + onRight: [], onTab: [], }; diff --git a/src/util/emoji.ts b/src/util/emoji.ts index 8dc9e5547..dbfa5906b 100644 --- a/src/util/emoji.ts +++ b/src/util/emoji.ts @@ -55,13 +55,13 @@ export function uncompressEmoji(data: EmojiRawData): EmojiData { for (let j = 0; j < data[i + 1].length; j++) { const emojiRaw = data[i + 1][j]; - if (!EXCLUDE_EMOJIS.includes(emojiRaw[1])) { - category.emojis.push(emojiRaw[1]); - emojiData.emojis[emojiRaw[1]] = { - id: emojiRaw[1], - colons: `:${emojiRaw[1]}:`, - native: unifiedToNative(emojiRaw[0]), - image: emojiRaw[0].toLowerCase(), + if (!EXCLUDE_EMOJIS.includes(emojiRaw[1][0])) { + category.emojis.push(emojiRaw[1][0]); + emojiData.emojis[emojiRaw[1][0]] = { + id: emojiRaw[1][0], + names: emojiRaw[1] as string[], + native: unifiedToNative(emojiRaw[0] as string), + image: (emojiRaw[0] as string).toLowerCase(), }; } } diff --git a/src/util/fastSmoothScrollHorizontal.ts b/src/util/fastSmoothScrollHorizontal.ts index 5050b3a50..491fb23ee 100644 --- a/src/util/fastSmoothScrollHorizontal.ts +++ b/src/util/fastSmoothScrollHorizontal.ts @@ -4,7 +4,7 @@ import { IS_IOS } from './environment'; const DURATION = 450; -export default function fastSmoothScroll(container: HTMLElement, left: number) { +export default function fastSmoothScrollHorizontal(container: HTMLElement, left: number) { // Native way seems to be smoother in Chrome if (!IS_IOS) { container.scrollTo({ left, behavior: 'smooth' }); diff --git a/src/util/findInViewport.ts b/src/util/findInViewport.ts index b4ff2166a..9d81f132d 100644 --- a/src/util/findInViewport.ts +++ b/src/util/findInViewport.ts @@ -4,9 +4,10 @@ export default function findInViewport( margin = 0, isDense = false, shouldContainBottom = false, + isHorizontal = false, ) { - const viewportY1 = container.scrollTop; - const viewportY2 = viewportY1 + container.offsetHeight; + const viewportY1 = container[isHorizontal ? 'scrollLeft' : 'scrollTop']; + const viewportY2 = viewportY1 + container[isHorizontal ? 'offsetWidth' : 'offsetHeight']; const allElements = typeof selectorOrElements === 'string' ? container.querySelectorAll(selectorOrElements) : selectorOrElements; @@ -16,8 +17,8 @@ export default function findInViewport( for (let i = 0; i < length; i++) { const element = allElements[i]; - const y1 = element.offsetTop; - const y2 = y1 + element.offsetHeight; + const y1 = element[isHorizontal ? 'offsetLeft' : 'offsetTop']; + const y2 = y1 + element[isHorizontal ? 'offsetWidth' : 'offsetHeight']; const isVisible = shouldContainBottom ? y2 >= viewportY1 - margin && y2 <= viewportY2 + margin : y1 <= viewportY2 + margin && y2 >= viewportY1 - margin; diff --git a/src/util/isFullyVisible.ts b/src/util/isFullyVisible.ts index 7cdeed367..ee0a90acf 100644 --- a/src/util/isFullyVisible.ts +++ b/src/util/isFullyVisible.ts @@ -1,8 +1,8 @@ -function isFullyVisible(container: HTMLElement, element: HTMLElement) { - const viewportY1 = container.scrollTop; - const viewportY2 = viewportY1 + container.offsetHeight; - const y1 = element.offsetTop; - const y2 = y1 + element.offsetHeight; +function isFullyVisible(container: HTMLElement, element: HTMLElement, isHorizontal = false) { + const viewportY1 = container[isHorizontal ? 'scrollLeft' : 'scrollTop']; + const viewportY2 = viewportY1 + container[isHorizontal ? 'offsetWidth' : 'offsetHeight']; + const y1 = element[isHorizontal ? 'offsetLeft' : 'offsetTop']; + const y2 = y1 + element[isHorizontal ? 'offsetWidth' : 'offsetHeight']; return y1 > viewportY1 && y2 < viewportY2; }