Composer: Add Emoji Picker (#1061)

This commit is contained in:
Alexander Zinchuk 2021-05-09 03:42:54 +03:00
parent 59b848697c
commit 4559e0ff8f
28 changed files with 520 additions and 202 deletions

4
package-lock.json generated
View File

@ -7660,8 +7660,8 @@
"dev": true "dev": true
}, },
"emoji-data-ios": { "emoji-data-ios": {
"version": "github:korenskoy/emoji-data-ios#10073b1244de618a3e587ae1d91b5e46ec01fd06", "version": "github:korenskoy/emoji-data-ios#e2c6557d2d36612a882d9b81b2467f441f1f4179",
"from": "github:korenskoy/emoji-data-ios#10073b1" "from": "github:korenskoy/emoji-data-ios#e2c6557"
}, },
"emojis-list": { "emojis-list": {
"version": "2.1.0", "version": "2.1.0",

View File

@ -101,7 +101,7 @@
"async-mutex": "^0.1.4", "async-mutex": "^0.1.4",
"big-integer": "painor/BigInteger.js", "big-integer": "painor/BigInteger.js",
"croppie": "^2.6.4", "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", "events": "^3.0.0",
"idb-keyval": "^5.0.5", "idb-keyval": "^5.0.5",
"opus-recorder": "^6.2.0", "opus-recorder": "^6.2.0",

View File

@ -38,7 +38,7 @@ type EmojiCategory = {
type Emoji = { type Emoji = {
id: string; id: string;
colons: string; names: string[];
native: string; native: string;
image: string; image: string;
skin?: number; 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 PollModal } from '../components/middle/composer/PollModal';
export { default as SymbolMenu } from '../components/middle/composer/SymbolMenu'; export { default as SymbolMenu } from '../components/middle/composer/SymbolMenu';
export { default as AttachMenu } from '../components/middle/composer/AttachMenu'; export { default as AttachMenu } from '../components/middle/composer/AttachMenu';
export { default as MentionMenu } from '../components/middle/composer/MentionMenu'; export { default as MentionTooltip } from '../components/middle/composer/MentionTooltip';
export { default as EmojiTooltip } from '../components/middle/composer/EmojiTooltip'; export { default as StickerTooltip } from '../components/middle/composer/StickerTooltip';
export { default as BotKeyboardMenu } from '../components/middle/composer/BotKeyboardMenu'; export { default as BotKeyboardMenu } from '../components/middle/composer/BotKeyboardMenu';
export { default as CustomSendMenu } from '../components/middle/composer/CustomSendMenu'; export { default as CustomSendMenu } from '../components/middle/composer/CustomSendMenu';
export { default as DropArea } from '../components/middle/composer/DropArea'; export { default as DropArea } from '../components/middle/composer/DropArea';
export { default as TextFormatter } from '../components/middle/composer/TextFormatter'; 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 RightSearch } from '../components/right/RightSearch';
export { default as StickerSearch } from '../components/right/StickerSearch'; export { default as StickerSearch } from '../components/right/StickerSearch';

View File

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

View File

@ -8,14 +8,14 @@ import { EDITABLE_INPUT_MODAL_ID } from '../../../config';
import { getFileExtension } from '../../common/helpers/documentInfo'; import { getFileExtension } from '../../common/helpers/documentInfo';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../util/captureEscKeyListener';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
import useMentionMenu from './hooks/useMentionMenu'; import useMentionTooltip from './hooks/useMentionTooltip';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import Modal from '../../ui/Modal'; import Modal from '../../ui/Modal';
import File from '../../common/File'; import File from '../../common/File';
import MessageInput from './MessageInput'; import MessageInput from './MessageInput';
import MentionMenu from './MentionMenu'; import MentionTooltip from './MentionTooltip';
import './AttachmentModal.scss'; import './AttachmentModal.scss';
@ -47,10 +47,10 @@ const AttachmentModal: FC<OwnProps> = ({
const isOpen = Boolean(attachments.length); const isOpen = Boolean(attachments.length);
const { const {
isMentionMenuOpen, mentionFilter, isMentionTooltipOpen, mentionFilter,
closeMentionMenu, insertMention, closeMentionTooltip, insertMention,
mentionFilteredMembers, mentionFilteredMembers,
} = useMentionMenu( } = useMentionTooltip(
canSuggestMembers && isOpen, canSuggestMembers && isOpen,
caption, caption,
onCaptionUpdate, onCaptionUpdate,
@ -136,9 +136,9 @@ const AttachmentModal: FC<OwnProps> = ({
)} )}
<div className="attachment-caption-wrapper"> <div className="attachment-caption-wrapper">
<MentionMenu <MentionTooltip
isOpen={isMentionMenuOpen} isOpen={isMentionTooltipOpen}
onClose={closeMentionMenu} onClose={closeMentionTooltip}
filter={mentionFilter} filter={mentionFilter}
onInsertUserName={insertMention} onInsertUserName={insertMention}
filteredChatMembers={mentionFilteredMembers} filteredChatMembers={mentionFilteredMembers}

View File

@ -368,3 +368,37 @@
left: 1rem; 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 useDraft from './hooks/useDraft';
import useEditing from './hooks/useEditing'; import useEditing from './hooks/useEditing';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
import useStickerTooltip from './hooks/useStickerTooltip';
import useEmojiTooltip from './hooks/useEmojiTooltip'; import useEmojiTooltip from './hooks/useEmojiTooltip';
import useMentionMenu from './hooks/useMentionMenu'; import useMentionTooltip from './hooks/useMentionTooltip';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
@ -66,8 +67,9 @@ import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton';
import Spinner from '../../ui/Spinner'; import Spinner from '../../ui/Spinner';
import AttachMenu from './AttachMenu.async'; import AttachMenu from './AttachMenu.async';
import SymbolMenu from './SymbolMenu.async'; import SymbolMenu from './SymbolMenu.async';
import MentionMenu from './MentionMenu.async'; import MentionTooltip from './MentionTooltip.async';
import CustomSendMenu from './CustomSendMenu.async'; import CustomSendMenu from './CustomSendMenu.async';
import StickerTooltip from './StickerTooltip.async';
import EmojiTooltip from './EmojiTooltip.async'; import EmojiTooltip from './EmojiTooltip.async';
import BotKeyboardMenu from './BotKeyboardMenu.async'; import BotKeyboardMenu from './BotKeyboardMenu.async';
import MessageInput from './MessageInput'; import MessageInput from './MessageInput';
@ -112,6 +114,7 @@ type StateProps = {
groupChatMembers?: ApiChatMember[]; groupChatMembers?: ApiChatMember[];
currentUserId?: number; currentUserId?: number;
usersById?: Record<number, ApiUser>; usersById?: Record<number, ApiUser>;
recentEmojis: string[];
lastSyncTime?: number; lastSyncTime?: number;
contentToBeScheduled?: GlobalState['messages']['contentToBeScheduled']; contentToBeScheduled?: GlobalState['messages']['contentToBeScheduled'];
shouldSuggestStickers?: boolean; shouldSuggestStickers?: boolean;
@ -171,6 +174,7 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
lastSyncTime, lastSyncTime,
contentToBeScheduled, contentToBeScheduled,
shouldSuggestStickers, shouldSuggestStickers,
recentEmojis,
sendMessage, sendMessage,
editMessage, editMessage,
saveDraft, saveDraft,
@ -254,10 +258,10 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
const canShowCustomSendMenu = !shouldSchedule; const canShowCustomSendMenu = !shouldSchedule;
const { const {
isMentionMenuOpen, mentionFilter, isMentionTooltipOpen, mentionFilter,
closeMentionMenu, insertMention, closeMentionTooltip, insertMention,
mentionFilteredMembers, mentionFilteredMembers,
} = useMentionMenu( } = useMentionTooltip(
canSuggestMembers && !attachments.length, canSuggestMembers && !attachments.length,
html, html,
setHtml, setHtml,
@ -281,11 +285,20 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
const isAdmin = chat && isChatAdmin(chat); const isAdmin = chat && isChatAdmin(chat);
const slowMode = getChatSlowModeOptions(chat); const slowMode = getChatSlowModeOptions(chat);
const { isEmojiTooltipOpen, closeEmojiTooltip } = useEmojiTooltip( const { isStickerTooltipOpen, closeStickerTooltip } = useStickerTooltip(
Boolean(shouldSuggestStickers && allowedAttachmentOptions.canSendStickers && !attachments.length), Boolean(shouldSuggestStickers && allowedAttachmentOptions.canSendStickers && !attachments.length),
html, html,
stickersForEmoji, stickersForEmoji,
); );
const {
isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji,
} = useEmojiTooltip(
Boolean(shouldSuggestStickers && allowedAttachmentOptions.canSendStickers && !attachments.length),
html,
recentEmojis,
undefined,
setHtml,
);
const insertTextAndUpdateCursor = useCallback((text: string) => { const insertTextAndUpdateCursor = useCallback((text: string) => {
const selection = window.getSelection()!; const selection = window.getSelection()!;
@ -337,10 +350,11 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
const resetComposer = useCallback(() => { const resetComposer = useCallback(() => {
setHtml(''); setHtml('');
setAttachments([]); setAttachments([]);
closeEmojiTooltip(); closeStickerTooltip();
closeCalendar(); closeCalendar();
setScheduledMessageArgs(undefined); setScheduledMessageArgs(undefined);
closeMentionMenu(); closeMentionTooltip();
closeEmojiTooltip();
if (IS_MOBILE_SCREEN) { if (IS_MOBILE_SCREEN) {
// @perf // @perf
@ -348,7 +362,7 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
} else { } else {
closeSymbolMenu(); closeSymbolMenu();
} }
}, [closeEmojiTooltip, closeCalendar, closeMentionMenu, closeSymbolMenu]); }, [closeStickerTooltip, closeCalendar, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu]);
// Handle chat change // Handle chat change
const prevChatId = usePrevious(chatId); const prevChatId = usePrevious(chatId);
@ -689,10 +703,10 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
message={renderedEditedMessage} message={renderedEditedMessage}
/> />
)} )}
<MentionMenu <MentionTooltip
isOpen={isMentionMenuOpen} isOpen={isMentionTooltipOpen}
filter={mentionFilter} filter={mentionFilter}
onClose={closeMentionMenu} onClose={closeMentionTooltip}
onInsertUserName={insertMention} onInsertUserName={insertMention}
filteredChatMembers={mentionFilteredMembers} filteredChatMembers={mentionFilteredMembers}
usersById={usersById} usersById={usersById}
@ -739,6 +753,7 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
} }
shouldSetFocus={isSymbolMenuOpen} shouldSetFocus={isSymbolMenuOpen}
shouldSupressFocus={IS_MOBILE_SCREEN && isSymbolMenuOpen} shouldSupressFocus={IS_MOBILE_SCREEN && isSymbolMenuOpen}
shouldSupressTextFormatter={isEmojiTooltipOpen || isMentionTooltipOpen}
onUpdate={setHtml} onUpdate={setHtml}
onSend={mainButtonState === MainButtonState.Edit onSend={mainButtonState === MainButtonState.Edit
? handleEditComplete ? handleEditComplete
@ -786,9 +801,15 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
{formatVoiceRecordDuration(currentRecordTime - startRecordTimeRef.current!)} {formatVoiceRecordDuration(currentRecordTime - startRecordTimeRef.current!)}
</span> </span>
)} )}
<StickerTooltip
isOpen={isStickerTooltipOpen}
onStickerSelect={handleStickerSelect}
/>
<EmojiTooltip <EmojiTooltip
isOpen={isEmojiTooltipOpen} isOpen={isEmojiTooltipOpen}
onStickerSelect={handleStickerSelect} emojis={filteredEmojis}
onClose={closeEmojiTooltip}
onEmojiSelect={insertEmoji}
/> />
<AttachMenu <AttachMenu
isOpen={isAttachMenuOpen} isOpen={isAttachMenuOpen}
@ -909,6 +930,7 @@ export default memo(withGlobal<OwnProps>(
isPaymentModalOpen: global.payment.isPaymentModalOpen, isPaymentModalOpen: global.payment.isPaymentModalOpen,
isReceiptModalOpen: Boolean(global.payment.receipt), isReceiptModalOpen: Boolean(global.payment.receipt),
shouldSuggestStickers: global.settings.byKey.shouldSuggestStickers, shouldSuggestStickers: global.settings.byKey.shouldSuggestStickers,
recentEmojis: global.recentEmojis,
}; };
}, },
(setGlobal, actions): DispatchProps => pick(actions, [ (setGlobal, actions): DispatchProps => pick(actions, [

View File

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

View File

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

View File

@ -1,37 +1,11 @@
.EmojiTooltip { .EmojiTooltip {
position: absolute; display: flex;
bottom: calc(100% + .5rem); padding-left: .25rem;
left: 0; overflow-x: auto;
width: 100%; overflow-x: overlay;
background: var(--color-background); overflow-y: hidden;
border-radius: var(--border-radius-messages);
padding: 0.5rem 0;
max-height: 15rem;
overflow-x: hidden;
overflow-y: auto;
display: grid; .EmojiButton {
grid-template-columns: repeat(auto-fit, minmax(5rem, 1fr)); flex: 0 0 2.5rem
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;
} }
} }

View File

@ -1,55 +1,110 @@
import React, { import React, {
FC, memo, useEffect, useRef, FC, memo, useCallback, useEffect, useRef, useState,
} from '../../../lib/teact/teact'; } 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 { IS_TOUCH_ENV } from '../../../util/environment';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import cycleRestrict from '../../../util/cycleRestrict';
import { pick } from '../../../util/iteratees'; import captureKeyboardListeners from '../../../util/captureKeyboardListeners';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import findInViewport from '../../../util/findInViewport';
import isFullyVisible from '../../../util/isFullyVisible';
import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import usePrevious from '../../../hooks/usePrevious';
import Loading from '../../ui/Loading'; import Loading from '../../ui/Loading';
import StickerButton from '../../common/StickerButton'; import EmojiButton from './EmojiButton';
import './EmojiTooltip.scss'; 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 = { export type OwnProps = {
isOpen: boolean; isOpen: boolean;
onStickerSelect: (sticker: ApiSticker) => void; onEmojiSelect: (text: string) => void;
onClose: NoneToVoidFunction;
emojis: Emoji[];
}; };
type StateProps = { const EmojiTooltip: FC<OwnProps> = ({
stickers?: ApiSticker[];
};
type DispatchProps = Pick<GlobalActions, 'clearStickersForEmoji'>;
const INTERSECTION_THROTTLE = 200;
const EmojiTooltip: FC<OwnProps & StateProps & DispatchProps> = ({
isOpen, isOpen,
onStickerSelect, emojis,
stickers, onClose,
clearStickersForEmoji, onEmojiSelect,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false); const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false);
const prevStickers = usePrevious(stickers, true);
const displayedStickers = stickers || prevStickers;
const { const [selectedIndex, setSelectedIndex] = useState(-1);
observe: observeIntersection,
} = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE });
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 = () => { const handleMouseEnter = () => {
document.body.classList.add('no-select'); document.body.classList.add('no-select');
@ -60,7 +115,7 @@ const EmojiTooltip: FC<OwnProps & StateProps & DispatchProps> = ({
}; };
const className = buildClassName( const className = buildClassName(
'EmojiTooltip custom-scroll', 'EmojiTooltip composer-tooltip custom-scroll-x',
transitionClassNames, transitionClassNames,
); );
@ -71,15 +126,13 @@ const EmojiTooltip: FC<OwnProps & StateProps & DispatchProps> = ({
onMouseEnter={!IS_TOUCH_ENV ? handleMouseEnter : undefined} onMouseEnter={!IS_TOUCH_ENV ? handleMouseEnter : undefined}
onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined} onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined}
> >
{shouldRender && displayedStickers ? ( {shouldRender && emojis ? (
displayedStickers.map((sticker) => ( emojis.map((emoji, index) => (
<StickerButton <EmojiButton
key={sticker.id} key={emoji.id}
sticker={sticker} emoji={emoji}
size={STICKER_SIZE_PICKER} focus={selectedIndex === index}
observeIntersection={observeIntersection} onClick={onEmojiSelect}
onClick={onStickerSelect}
clickArg={sticker}
/> />
)) ))
) : shouldRender ? ( ) : shouldRender ? (
@ -89,11 +142,4 @@ const EmojiTooltip: FC<OwnProps & StateProps & DispatchProps> = ({
); );
}; };
export default memo(withGlobal<OwnProps>( export default memo(EmojiTooltip);
(global): StateProps => {
const { stickers } = global.stickers.forEmoji;
return { stickers };
},
(setGlobal, actions): DispatchProps => pick(actions, ['clearStickersForEmoji']),
)(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 { .MentionTooltip {
position: absolute;
bottom: calc(100% + .75rem);
left: 0;
width: calc(100% - 4rem); width: calc(100% - 4rem);
max-width: 20rem; max-width: 20rem;
background: var(--color-background); flex-direction: column;
border-radius: var(--border-radius-messages);
padding: 0.5rem 0;
max-height: 15rem;
overflow-x: hidden;
overflow-y: auto;
box-shadow: 3px 3px 5px var(--color-default-shadow);
z-index: -1; 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) { @media (max-width: 600px) {
width: calc(100% - 3rem); width: calc(100% - 3rem);
} }
&:not(.shown) {
display: none;
}
&.open {
opacity: 1;
transform: translateY(0);
}
.Loading {
margin: 1rem 0;
}
.ListItem.chat-item-clickable { .ListItem.chat-item-clickable {
margin: 0; margin: 0;

View File

@ -16,7 +16,7 @@ import cycleRestrict from '../../../util/cycleRestrict';
import ListItem from '../../ui/ListItem'; import ListItem from '../../ui/ListItem';
import PrivateChatInfo from '../../common/PrivateChatInfo'; import PrivateChatInfo from '../../common/PrivateChatInfo';
import './MentionMenu.scss'; import './MentionTooltip.scss';
const VIEWPORT_MARGIN = 8; const VIEWPORT_MARGIN = 8;
const SCROLL_MARGIN = 10; const SCROLL_MARGIN = 10;
@ -53,7 +53,7 @@ export type OwnProps = {
usersById?: Record<number, ApiUser>; usersById?: Record<number, ApiUser>;
}; };
const MentionMenu: FC<OwnProps> = ({ const MentionTooltip: FC<OwnProps> = ({
isOpen, isOpen,
filter, filter,
onClose, onClose,
@ -136,7 +136,7 @@ const MentionMenu: FC<OwnProps> = ({
} }
const className = buildClassName( const className = buildClassName(
'MentionMenu custom-scroll', 'MentionTooltip composer-tooltip custom-scroll',
transitionClassNames, 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; placeholder: string;
shouldSetFocus: boolean; shouldSetFocus: boolean;
shouldSupressFocus?: boolean; shouldSupressFocus?: boolean;
shouldSupressTextFormatter?: boolean;
onUpdate: (html: string) => void; onUpdate: (html: string) => void;
onSupressedFocus?: () => void; onSupressedFocus?: () => void;
onSend: () => void; onSend: () => void;
@ -76,6 +77,7 @@ const MessageInput: FC<OwnProps & StateProps & DispatchProps> = ({
placeholder, placeholder,
shouldSetFocus, shouldSetFocus,
shouldSupressFocus, shouldSupressFocus,
shouldSupressTextFormatter,
onUpdate, onUpdate,
onSupressedFocus, onSupressedFocus,
onSend, onSend,
@ -139,7 +141,8 @@ const MessageInput: FC<OwnProps & StateProps & DispatchProps> = ({
const selectionRange = selection.getRangeAt(0); const selectionRange = selection.getRangeAt(0);
const selectedText = selectionRange.toString().trim(); const selectedText = selectionRange.toString().trim();
if ( if (
!isSelectionInsideInput(selectionRange) shouldSupressTextFormatter
|| !isSelectionInsideInput(selectionRange)
|| !selectedText || !selectedText
|| parseEmojiOnlyString(selectedText) || parseEmojiOnlyString(selectedText)
|| !selectionRange.START_TO_END || !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 {
import { getDispatch } from '../../../../lib/teact/teactn'; 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( export default function useEmojiTooltip(
isAllowed: boolean, isAllowed: boolean,
html: string, html: string,
stickers?: ApiSticker[], recentEmojiIds: string[],
inputId = EDITABLE_INPUT_ID,
onUpdateHtml: (html: string) => void,
) { ) {
const { loadStickersForEmoji, clearStickersForEmoji } = getDispatch(); const [isOpen, markIsOpen, unmarkIsOpen] = useFlag();
const isSingleEmoji = ( const [emojis, setEmojis] = useState<Emoji[]>([]);
(IS_EMOJI_SUPPORTED && parseEmojiOnlyString(html) === 1) const [filteredEmojis, setFilteredEmojis] = useState<Emoji[]>([]);
|| (!IS_EMOJI_SUPPORTED && Boolean(html.match(/^<img.[^>]*?>$/g)))
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(() => { useEffect(() => {
if (isAllowed && isSingleEmoji) { if (!html || !emojis) {
loadStickersForEmoji({ emoji: html }); unmarkIsOpen();
} else if (hasStickers || !isSingleEmoji) { return;
clearStickersForEmoji();
} }
// We omit `hasStickers` here to prevent re-fetching after manually closing tooltip (via <Esc>).
// eslint-disable-next-line react-hooks/exhaustive-deps const code = getEmojiCode(html);
}, [html, isSingleEmoji, clearStickersForEmoji, loadStickersForEmoji, isAllowed]); 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 { return {
isEmojiTooltipOpen: hasStickers, isEmojiTooltipOpen: isOpen,
closeEmojiTooltip: clearStickersForEmoji, 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; const RE_NOT_USERNAME_SEARCH = /[^@_\d\wа-яё]+/i;
export default function useMentionMenu( export default function useMentionTooltip(
canSuggestMembers: boolean | undefined, canSuggestMembers: boolean | undefined,
html: string, html: string,
onUpdateHtml: (html: string) => void, onUpdateHtml: (html: string) => void,
@ -90,9 +90,9 @@ export default function useMentionMenu(
}, [html, inputId, onUpdateHtml, unmarkIsOpen]); }, [html, inputId, onUpdateHtml, unmarkIsOpen]);
return { return {
isMentionMenuOpen: isOpen, isMentionTooltipOpen: isOpen,
mentionFilter: currentFilter, mentionFilter: currentFilter,
closeMentionMenu: unmarkIsOpen, closeMentionTooltip: unmarkIsOpen,
insertMention, insertMention,
mentionFilteredMembers: filteredMembers, 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 Handler = (e: KeyboardEvent) => void;
type CaptureOptions = Partial<Record<HandlerName, Handler>>; type CaptureOptions = Partial<Record<HandlerName, Handler>>;
@ -10,6 +11,8 @@ const keyToHandlerName: Record<string, HandlerName> = {
Escape: 'onEsc', Escape: 'onEsc',
ArrowUp: 'onUp', ArrowUp: 'onUp',
ArrowDown: 'onDown', ArrowDown: 'onDown',
ArrowLeft: 'onLeft',
ArrowRight: 'onRight',
Tab: 'onTab', Tab: 'onTab',
}; };
@ -20,6 +23,8 @@ const handlers: Record<HandlerName, Handler[]> = {
onEsc: [], onEsc: [],
onUp: [], onUp: [],
onDown: [], onDown: [],
onLeft: [],
onRight: [],
onTab: [], onTab: [],
}; };

View File

@ -55,13 +55,13 @@ export function uncompressEmoji(data: EmojiRawData): EmojiData {
for (let j = 0; j < data[i + 1].length; j++) { for (let j = 0; j < data[i + 1].length; j++) {
const emojiRaw = data[i + 1][j]; const emojiRaw = data[i + 1][j];
if (!EXCLUDE_EMOJIS.includes(emojiRaw[1])) { if (!EXCLUDE_EMOJIS.includes(emojiRaw[1][0])) {
category.emojis.push(emojiRaw[1]); category.emojis.push(emojiRaw[1][0]);
emojiData.emojis[emojiRaw[1]] = { emojiData.emojis[emojiRaw[1][0]] = {
id: emojiRaw[1], id: emojiRaw[1][0],
colons: `:${emojiRaw[1]}:`, names: emojiRaw[1] as string[],
native: unifiedToNative(emojiRaw[0]), native: unifiedToNative(emojiRaw[0] as string),
image: emojiRaw[0].toLowerCase(), image: (emojiRaw[0] as string).toLowerCase(),
}; };
} }
} }

View File

@ -4,7 +4,7 @@ import { IS_IOS } from './environment';
const DURATION = 450; 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 // Native way seems to be smoother in Chrome
if (!IS_IOS) { if (!IS_IOS) {
container.scrollTo({ left, behavior: 'smooth' }); container.scrollTo({ left, behavior: 'smooth' });

View File

@ -4,9 +4,10 @@ export default function findInViewport(
margin = 0, margin = 0,
isDense = false, isDense = false,
shouldContainBottom = false, shouldContainBottom = false,
isHorizontal = false,
) { ) {
const viewportY1 = container.scrollTop; const viewportY1 = container[isHorizontal ? 'scrollLeft' : 'scrollTop'];
const viewportY2 = viewportY1 + container.offsetHeight; const viewportY2 = viewportY1 + container[isHorizontal ? 'offsetWidth' : 'offsetHeight'];
const allElements = typeof selectorOrElements === 'string' const allElements = typeof selectorOrElements === 'string'
? container.querySelectorAll<HTMLElement>(selectorOrElements) ? container.querySelectorAll<HTMLElement>(selectorOrElements)
: selectorOrElements; : selectorOrElements;
@ -16,8 +17,8 @@ export default function findInViewport(
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
const element = allElements[i]; const element = allElements[i];
const y1 = element.offsetTop; const y1 = element[isHorizontal ? 'offsetLeft' : 'offsetTop'];
const y2 = y1 + element.offsetHeight; const y2 = y1 + element[isHorizontal ? 'offsetWidth' : 'offsetHeight'];
const isVisible = shouldContainBottom const isVisible = shouldContainBottom
? y2 >= viewportY1 - margin && y2 <= viewportY2 + margin ? y2 >= viewportY1 - margin && y2 <= viewportY2 + margin
: y1 <= viewportY2 + margin && y2 >= viewportY1 - margin; : y1 <= viewportY2 + margin && y2 >= viewportY1 - margin;

View File

@ -1,8 +1,8 @@
function isFullyVisible(container: HTMLElement, element: HTMLElement) { function isFullyVisible(container: HTMLElement, element: HTMLElement, isHorizontal = false) {
const viewportY1 = container.scrollTop; const viewportY1 = container[isHorizontal ? 'scrollLeft' : 'scrollTop'];
const viewportY2 = viewportY1 + container.offsetHeight; const viewportY2 = viewportY1 + container[isHorizontal ? 'offsetWidth' : 'offsetHeight'];
const y1 = element.offsetTop; const y1 = element[isHorizontal ? 'offsetLeft' : 'offsetTop'];
const y2 = y1 + element.offsetHeight; const y2 = y1 + element[isHorizontal ? 'offsetWidth' : 'offsetHeight'];
return y1 > viewportY1 && y2 < viewportY2; return y1 > viewportY1 && y2 < viewportY2;
} }