Revert "Revert "Composer: Add Emoji Picker (#1061)""

This reverts commit b1a2e27576b3bbcc5d319a506540042462e8b105.
This commit is contained in:
Alexander Zinchuk 2021-05-09 04:03:12 +03:00
parent f8852fdf45
commit f638ae3717
28 changed files with 520 additions and 202 deletions

4
package-lock.json generated
View File

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

View File

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

View File

@ -38,7 +38,7 @@ type EmojiCategory = {
type Emoji = {
id: string;
colons: string;
names: string[];
native: string;
image: string;
skin?: number;

View File

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

View File

@ -69,7 +69,7 @@
background: var(--color-background);
}
.MentionMenu {
.MentionTooltip {
right: 0 !important;
z-index: 0;
}

View File

@ -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<OwnProps> = ({
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<OwnProps> = ({
)}
<div className="attachment-caption-wrapper">
<MentionMenu
isOpen={isMentionMenuOpen}
onClose={closeMentionMenu}
<MentionTooltip
isOpen={isMentionTooltipOpen}
onClose={closeMentionTooltip}
filter={mentionFilter}
onInsertUserName={insertMention}
filteredChatMembers={mentionFilteredMembers}

View File

@ -368,3 +368,37 @@
left: 1rem;
}
}
.composer-tooltip {
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;
overflow-y: overlay;
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;
}
}

View File

@ -55,8 +55,9 @@ import useClipboardPaste from './hooks/useClipboardPaste';
import useDraft from './hooks/useDraft';
import useEditing from './hooks/useEditing';
import usePrevious from '../../../hooks/usePrevious';
import useStickerTooltip from './hooks/useStickerTooltip';
import useEmojiTooltip from './hooks/useEmojiTooltip';
import useMentionMenu from './hooks/useMentionMenu';
import useMentionTooltip from './hooks/useMentionTooltip';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useLang from '../../../hooks/useLang';
@ -66,8 +67,9 @@ import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton';
import Spinner from '../../ui/Spinner';
import AttachMenu from './AttachMenu.async';
import SymbolMenu from './SymbolMenu.async';
import MentionMenu from './MentionMenu.async';
import MentionTooltip from './MentionTooltip.async';
import CustomSendMenu from './CustomSendMenu.async';
import StickerTooltip from './StickerTooltip.async';
import EmojiTooltip from './EmojiTooltip.async';
import BotKeyboardMenu from './BotKeyboardMenu.async';
import MessageInput from './MessageInput';
@ -112,6 +114,7 @@ type StateProps = {
groupChatMembers?: ApiChatMember[];
currentUserId?: number;
usersById?: Record<number, ApiUser>;
recentEmojis: string[];
lastSyncTime?: number;
contentToBeScheduled?: GlobalState['messages']['contentToBeScheduled'];
shouldSuggestStickers?: boolean;
@ -171,6 +174,7 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
lastSyncTime,
contentToBeScheduled,
shouldSuggestStickers,
recentEmojis,
sendMessage,
editMessage,
saveDraft,
@ -255,10 +259,10 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
const canShowCustomSendMenu = !shouldSchedule;
const {
isMentionMenuOpen, mentionFilter,
closeMentionMenu, insertMention,
isMentionTooltipOpen, mentionFilter,
closeMentionTooltip, insertMention,
mentionFilteredMembers,
} = useMentionMenu(
} = useMentionTooltip(
canSuggestMembers && !attachments.length,
html,
setHtml,
@ -282,11 +286,20 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
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()!;
@ -338,10 +351,11 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
const resetComposer = useCallback(() => {
setHtml('');
setAttachments([]);
closeEmojiTooltip();
closeStickerTooltip();
closeCalendar();
setScheduledMessageArgs(undefined);
closeMentionMenu();
closeMentionTooltip();
closeEmojiTooltip();
if (IS_MOBILE_SCREEN) {
// @perf
@ -349,7 +363,7 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
} else {
closeSymbolMenu();
}
}, [closeEmojiTooltip, closeCalendar, closeMentionMenu, closeSymbolMenu]);
}, [closeStickerTooltip, closeCalendar, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu]);
// Handle chat change
const prevChatId = usePrevious(chatId);
@ -690,10 +704,10 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
message={renderedEditedMessage}
/>
)}
<MentionMenu
isOpen={isMentionMenuOpen}
<MentionTooltip
isOpen={isMentionTooltipOpen}
filter={mentionFilter}
onClose={closeMentionMenu}
onClose={closeMentionTooltip}
onInsertUserName={insertMention}
filteredChatMembers={mentionFilteredMembers}
usersById={usersById}
@ -740,6 +754,7 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
}
shouldSetFocus={isSymbolMenuOpen}
shouldSupressFocus={IS_MOBILE_SCREEN && isSymbolMenuOpen}
shouldSupressTextFormatter={isEmojiTooltipOpen || isMentionTooltipOpen}
onUpdate={setHtml}
onSend={mainButtonState === MainButtonState.Edit
? handleEditComplete
@ -787,9 +802,15 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
{formatVoiceRecordDuration(currentRecordTime - startRecordTimeRef.current!)}
</span>
)}
<StickerTooltip
isOpen={isStickerTooltipOpen}
onStickerSelect={handleStickerSelect}
/>
<EmojiTooltip
isOpen={isEmojiTooltipOpen}
onStickerSelect={handleStickerSelect}
emojis={filteredEmojis}
onClose={closeEmojiTooltip}
onEmojiSelect={insertEmoji}
/>
<AttachMenu
isOpen={isAttachMenuOpen}
@ -911,6 +932,7 @@ export default memo(withGlobal<OwnProps>(
isPaymentModalOpen: global.payment.isPaymentModalOpen,
isReceiptModalOpen: Boolean(global.payment.receipt),
shouldSuggestStickers: global.settings.byKey.shouldSuggestStickers,
recentEmojis: global.recentEmojis,
};
},
(setGlobal, actions): DispatchProps => pick(actions, [

View File

@ -16,6 +16,7 @@
line-height: inherit;
}
&.focus,
&:hover {
background-color: rgba(var(--color-text-secondary-rgb), 0.08);
}

View File

@ -6,19 +6,20 @@ import './EmojiButton.scss';
type OwnProps = {
emoji: Emoji;
focus?: boolean;
onClick: (emoji: string, name: string) => void;
};
const EmojiButton: FC<OwnProps> = ({ emoji, onClick }) => {
const EmojiButton: FC<OwnProps> = ({ emoji, focus, onClick }) => {
const handleClick = useCallback(() => {
onClick(emoji.native, emoji.id);
}, [emoji, onClick]);
return (
<div
className="EmojiButton"
className={`EmojiButton ${focus ? 'focus' : ''}`}
onClick={handleClick}
title={emoji.colons}
title={`:${emoji.names[0]}:`}
>
{IS_EMOJI_SUPPORTED ? emoji.native : <img src={`/img-apple-64/${emoji.image}.png`} alt="" loading="lazy" />}
</div>

View File

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

View File

@ -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<string, any>) {
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<GlobalActions, 'clearStickersForEmoji'>;
const INTERSECTION_THROTTLE = 200;
const EmojiTooltip: FC<OwnProps & StateProps & DispatchProps> = ({
const EmojiTooltip: FC<OwnProps> = ({
isOpen,
onStickerSelect,
stickers,
clearStickersForEmoji,
emojis,
onClose,
onEmojiSelect,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(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<OwnProps & StateProps & DispatchProps> = ({
};
const className = buildClassName(
'EmojiTooltip custom-scroll',
'EmojiTooltip composer-tooltip custom-scroll-x',
transitionClassNames,
);
@ -71,15 +126,13 @@ const EmojiTooltip: FC<OwnProps & StateProps & DispatchProps> = ({
onMouseEnter={!IS_TOUCH_ENV ? handleMouseEnter : undefined}
onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined}
>
{shouldRender && displayedStickers ? (
displayedStickers.map((sticker) => (
<StickerButton
key={sticker.id}
sticker={sticker}
size={STICKER_SIZE_PICKER}
observeIntersection={observeIntersection}
onClick={onStickerSelect}
clickArg={sticker}
{shouldRender && emojis ? (
emojis.map((emoji, index) => (
<EmojiButton
key={emoji.id}
emoji={emoji}
focus={selectedIndex === index}
onClick={onEmojiSelect}
/>
))
) : shouldRender ? (
@ -89,11 +142,4 @@ const EmojiTooltip: FC<OwnProps & StateProps & DispatchProps> = ({
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { stickers } = global.stickers.forEmoji;
return { stickers };
},
(setGlobal, actions): DispatchProps => pick(actions, ['clearStickersForEmoji']),
)(EmojiTooltip));
export default memo(EmojiTooltip);

View File

@ -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<OwnProps> = (props) => {
const { isOpen } = props;
const MentionMenu = useModuleLoader(Bundles.Extra, 'MentionMenu', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return MentionMenu ? <MentionMenu {...props} /> : undefined;
};
export default memo(MentionMenuAsync);

View File

@ -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<OwnProps> = (props) => {
const { isOpen } = props;
const MentionTooltip = useModuleLoader(Bundles.Extra, 'MentionTooltip', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return MentionTooltip ? <MentionTooltip {...props} /> : undefined;
};
export default memo(MentionTooltipAsync);

View File

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

View File

@ -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<number, ApiUser>;
};
const MentionMenu: FC<OwnProps> = ({
const MentionTooltip: FC<OwnProps> = ({
isOpen,
filter,
onClose,
@ -136,7 +136,7 @@ const MentionMenu: FC<OwnProps> = ({
}
const className = buildClassName(
'MentionMenu custom-scroll',
'MentionTooltip composer-tooltip custom-scroll',
transitionClassNames,
);
@ -160,4 +160,4 @@ const MentionMenu: FC<OwnProps> = ({
);
};
export default memo(MentionMenu);
export default memo(MentionTooltip);

View File

@ -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<OwnProps & StateProps & DispatchProps> = ({
placeholder,
shouldSetFocus,
shouldSupressFocus,
shouldSupressTextFormatter,
onUpdate,
onSupressedFocus,
onSend,
@ -139,7 +141,8 @@ const MessageInput: FC<OwnProps & StateProps & DispatchProps> = ({
const selectionRange = selection.getRangeAt(0);
const selectedText = selectionRange.toString().trim();
if (
!isSelectionInsideInput(selectionRange)
shouldSupressTextFormatter
|| !isSelectionInsideInput(selectionRange)
|| !selectedText
|| parseEmojiOnlyString(selectedText)
|| !selectionRange.START_TO_END

View File

@ -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<OwnProps> = (props) => {
const { isOpen } = props;
const StickerTooltip = useModuleLoader(Bundles.Extra, 'StickerTooltip', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return StickerTooltip ? <StickerTooltip {...props} /> : undefined;
};
export default StickerTooltipAsync;

View File

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

View File

@ -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<GlobalActions, 'clearStickersForEmoji'>;
const INTERSECTION_THROTTLE = 200;
const StickerTooltip: FC<OwnProps & StateProps & DispatchProps> = ({
isOpen,
onStickerSelect,
stickers,
clearStickersForEmoji,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className={className}
onMouseEnter={!IS_TOUCH_ENV ? handleMouseEnter : undefined}
onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined}
>
{shouldRender && displayedStickers ? (
displayedStickers.map((sticker) => (
<StickerButton
key={sticker.id}
sticker={sticker}
size={STICKER_SIZE_PICKER}
observeIntersection={observeIntersection}
onClick={onStickerSelect}
clickArg={sticker}
/>
))
) : shouldRender ? (
<Loading />
) : undefined}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { stickers } = global.stickers.forEmoji;
return { stickers };
},
(setGlobal, actions): DispatchProps => pick(actions, ['clearStickersForEmoji']),
)(StickerTooltip));

View File

@ -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<EmojiModule>;
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(/^<img.[^>]*?>$/g)))
const [isOpen, markIsOpen, unmarkIsOpen] = useFlag();
const [emojis, setEmojis] = useState<Emoji[]>([]);
const [filteredEmojis, setFilteredEmojis] = useState<Emoji[]>([]);
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 <Esc>).
// 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<EmojiModule>;
emojiRawData = (await emojiDataPromise).default;
emojiData = uncompressEmoji(emojiRawData);
}
return emojiDataPromise;
}

View File

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

View File

@ -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(/^<img.[^>]*?>$/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 <Esc>).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [html, isSingleEmoji, clearStickersForEmoji, loadStickersForEmoji, isAllowed]);
return {
isStickerTooltipOpen: hasStickers,
closeStickerTooltip: clearStickersForEmoji,
};
}

View File

@ -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<Record<HandlerName, Handler>>;
@ -10,6 +11,8 @@ const keyToHandlerName: Record<string, HandlerName> = {
Escape: 'onEsc',
ArrowUp: 'onUp',
ArrowDown: 'onDown',
ArrowLeft: 'onLeft',
ArrowRight: 'onRight',
Tab: 'onTab',
};
@ -20,6 +23,8 @@ const handlers: Record<HandlerName, Handler[]> = {
onEsc: [],
onUp: [],
onDown: [],
onLeft: [],
onRight: [],
onTab: [],
};

View File

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

View File

@ -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' });

View File

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

View File

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