Attachment Modal: Add Symbol menu (#2525)

This commit is contained in:
Alexander Zinchuk 2023-02-13 03:32:27 +01:00
parent ecd1870fff
commit 30a36c7908
16 changed files with 524 additions and 200 deletions

View File

@ -175,7 +175,7 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
{renderingStickerSet ? renderText(renderingStickerSet.title, ['emoji', 'links']) : lang('AccDescrStickerSet')}
</div>
<DropdownMenu
className="stickers-more-menu"
className="stickers-more-menu with-menu-transitions"
trigger={MoreMenuButton}
positionX="right"
>

View File

@ -226,7 +226,8 @@ const Main: FC<OwnProps & StateProps> = ({
// switch back to the mobile version, you get a blank screen
const { isDesktop } = useAppLayout();
useEffect(() => {
if (!isMiddleColumnOpen && !isLeftColumnOpen && !isDesktop) {
const areColumnsConflicting = isLeftColumnOpen === isMiddleColumnOpen;
if (areColumnsConflicting && !isDesktop) {
toggleLeftColumn();
}
}, [isDesktop, isLeftColumnOpen, isMiddleColumnOpen, toggleLeftColumn]);

View File

@ -355,7 +355,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
</Button>
<div className="modal-title">{bot?.firstName}</div>
<DropdownMenu
className="web-app-more-menu"
className="web-app-more-menu with-menu-transitions"
trigger={MoreMenuButton}
positionX="right"
>

View File

@ -25,6 +25,64 @@
max-height: calc(100vh - 3.25rem - 5rem);
overflow-x: auto;
padding-bottom: env(safe-area-inset-bottom);
@supports not (padding-bottom: env(safe-area-inset-bottom)) {
padding-bottom: 0;
}
}
.symbol-menu-button {
flex-shrink: 0;
background: none !important;
width: 3.5rem !important;
height: 3.5rem !important;
padding: 0 !important;
align-self: flex-end;
}
.symbol-menu-button, .mobile-symbol-menu-button {
margin-right: -1.75rem;
margin-left: -0.5rem !important;
color: var(--color-composer-button);
}
.mobile-symbol-menu-button {
margin-left: 0 !important;
margin-right: -1.25rem !important;
width: 2.875rem;
height: 2.875rem;
}
}
:global(body.keyboard-visible) & :global(.modal-content) {
padding-bottom: 0;
}
&.mobile :global {
.modal-dialog {
margin: 0;
max-width: 100% !important;
align-self: end;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
&.mobile:global(:not(.open)) :global(.modal-dialog) {
transform: translate3d(0, 8rem, 0);
}
&.mobile.symbolMenuOpen :global(.modal-dialog) {
transition: var(--layer-transition);
transform: translate3d(0, calc((var(--symbol-menu-footer-height) + var(--symbol-menu-height) - env(safe-area-inset-bottom)) * -1), 0);
@supports not (bottom: env(safe-area-inset-bottom)) {
transform: translate3d(0, calc((var(--symbol-menu-footer-height) + var(--symbol-menu-height)) * -1), 0);
}
}

View File

@ -4,7 +4,9 @@ import React, {
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiAttachment, ApiChatMember, ApiSticker } from '../../../api/types';
import type {
ApiAttachment, ApiChatMember, ApiSticker,
} from '../../../api/types';
import type { GlobalState } from '../../../global/types';
import type { Signal } from '../../../util/signals';
@ -46,6 +48,7 @@ import CustomEmojiTooltip from './CustomEmojiTooltip.async';
import AttachmentModalItem from './AttachmentModalItem';
import DropdownMenu from '../../ui/DropdownMenu';
import MenuItem from '../../ui/MenuItem';
import SymbolMenuButton from './SymbolMenuButton';
import styles from './AttachmentModal.module.scss';
@ -66,6 +69,9 @@ export type OwnProps = {
onClear: NoneToVoidFunction;
onSendSilent: (sendCompressed: boolean, sendGrouped: boolean) => void;
onSendScheduled: (sendCompressed: boolean, sendGrouped: boolean) => void;
onCustomEmojiSelect: (emoji: ApiSticker) => void;
onRemoveSymbol: VoidFunction;
onEmojiSelect: (emoji: string) => void;
};
type StateProps = {
@ -111,6 +117,9 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
onClear,
onSendSilent,
onSendScheduled,
onCustomEmojiSelect,
onRemoveSymbol,
onEmojiSelect,
}) => {
const { addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings } = getActions();
@ -126,6 +135,8 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
const renderingAttachments = attachments.length ? attachments : prevAttachments;
const { isMobile } = useAppLayout();
const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag();
const [shouldSendCompressed, setShouldSendCompressed] = useState(
shouldSuggestCompression ?? attachmentSettings.shouldCompress,
);
@ -143,6 +154,12 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
const renderingIsOpen = Boolean(renderingAttachments?.length);
const [isHovered, markHovered, unmarkHovered] = useFlag();
useEffect(() => {
if (!isOpen) {
closeSymbolMenu();
}
}, [closeSymbolMenu, isOpen]);
const [hasMedia, hasOnlyMedia] = useMemo(() => {
const onlyMedia = Boolean(renderingAttachments?.every((a) => a.quick || a.audio));
if (onlyMedia) return [true, true];
@ -388,7 +405,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
</Button>
<div className="modal-title">{title}</div>
<DropdownMenu
className="attachment-modal-more-menu"
className="attachment-modal-more-menu with-menu-transitions"
trigger={MoreMenuButton}
positionX="right"
>
@ -453,6 +470,8 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
styles.root,
isHovered && styles.hovered,
!areAttachmentsNotScrolled && styles.headerBorder,
isMobile && styles.mobile,
isSymbolMenuOpen && styles.symbolMenuOpen,
)}
noBackdropClose
>
@ -517,6 +536,20 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
onClose={closeCustomEmojiTooltip}
/>
<div className={styles.caption}>
<SymbolMenuButton
chatId={chatId}
threadId={threadId}
isMobile={isMobile}
isReady={isReady}
isSymbolMenuOpen={isSymbolMenuOpen}
openSymbolMenu={openSymbolMenu}
closeSymbolMenu={closeSymbolMenu}
onCustomEmojiSelect={onCustomEmojiSelect}
onRemoveSymbol={onRemoveSymbol}
onEmojiSelect={onEmojiSelect}
isAttachmentModal
className="attachment-modal-symbol-menu with-menu-transitions"
/>
<MessageInput
ref={inputRef}
id="caption-input-text"
@ -532,6 +565,8 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
onScroll={handleCaptionScroll}
canAutoFocus={Boolean(isReady && isForCurrentMessageList && attachments.length)}
captionLimit={leftChars}
shouldSuppressFocus={isMobile && isSymbolMenuOpen}
onSuppressedFocus={closeSymbolMenu}
/>
<div className={styles.sendWrapper}>
<Button

View File

@ -163,59 +163,60 @@
animation: 0.25s ease-in-out forwards show-send-as-button;
transform-origin: right;
}
}
.mobile-symbol-menu-button {
width: 2.875rem;
height: 2.875rem;
position: relative;
.mobile-symbol-menu-button {
width: 2.875rem;
height: 2.875rem;
position: relative;
.icon-smile,
.icon-keyboard,
.icon-smile,
.icon-keyboard,
.Spinner {
position: absolute;
}
.Spinner {
--spinner-size: 1.5rem;
}
.icon-smile {
animation: grow-icon 0.4s ease-out;
}
.icon-keyboard,
.Spinner {
animation: hide-icon 0.4s forwards ease-out;
}
&.not-ready > i {
animation-duration: 0ms !important;
}
&.is-loading {
.Spinner {
position: absolute;
}
.Spinner {
--spinner-size: 1.5rem;
}
.icon-smile {
animation: grow-icon 0.4s ease-out;
}
.icon-keyboard,
.icon-smile {
animation: hide-icon 0.4s forwards ease-out;
}
}
&.menu-opened {
.icon-keyboard {
animation: grow-icon 0.4s ease-out;
}
.icon-smile,
.Spinner {
animation: hide-icon 0.4s forwards ease-out;
}
&.not-ready > i {
animation-duration: 0ms !important;
}
&.is-loading {
.Spinner {
animation: grow-icon 0.4s ease-out;
}
.icon-keyboard,
.icon-smile {
animation: hide-icon 0.4s forwards ease-out;
}
}
&.menu-opened {
.icon-keyboard {
animation: grow-icon 0.4s ease-out;
}
.icon-smile,
.Spinner {
animation: hide-icon 0.4s forwards ease-out;
}
}
}
}
#message-compose {
flex-grow: 1;
max-width: calc(100% - 4rem);
@ -396,6 +397,12 @@
}
}
.symbol-menu-trigger {
left: -1rem;
bottom: 0;
position: absolute;
}
@media (min-width: 600px) {
.symbol-menu-button {
width: 2rem !important;

View File

@ -29,7 +29,7 @@ import {
REPLIES_USER_ID,
SEND_MESSAGE_ACTION_INTERVAL,
EDITABLE_INPUT_CSS_SELECTOR,
MAX_UPLOAD_FILEPART_SIZE,
MAX_UPLOAD_FILEPART_SIZE, EDITABLE_INPUT_MODAL_ID,
} from '../../../config';
import { IS_VOICE_RECORDING_SUPPORTED, IS_IOS } from '../../../util/environment';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
@ -93,7 +93,6 @@ import useInterval from '../../../hooks/useInterval';
import useSyncEffect from '../../../hooks/useSyncEffect';
import useVoiceRecording from './hooks/useVoiceRecording';
import useClipboardPaste from './hooks/useClipboardPaste';
import useDraft from './hooks/useDraft';
import useEditing from './hooks/useEditing';
import useEmojiTooltip from './hooks/useEmojiTooltip';
import useMentionTooltip from './hooks/useMentionTooltip';
@ -105,6 +104,7 @@ import useAttachmentModal from './hooks/useAttachmentModal';
import useGetSelectionRange from '../../../hooks/useGetSelectionRange';
import useDerivedState from '../../../hooks/useDerivedState';
import { useStateRef } from '../../../hooks/useStateRef';
import useDraft from './hooks/useDraft';
import DeleteMessageModal from '../../common/DeleteMessageModal.async';
import Button from '../../ui/Button';
@ -112,7 +112,6 @@ import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton';
import Spinner from '../../ui/Spinner';
import AttachMenu from './AttachMenu';
import Avatar from '../../common/Avatar';
import SymbolMenu from './SymbolMenu.async';
import InlineBotTooltip from './InlineBotTooltip.async';
import MentionTooltip from './MentionTooltip.async';
import CustomSendMenu from './CustomSendMenu.async';
@ -130,6 +129,7 @@ import DropArea, { DropAreaState } from './DropArea.async';
import WebPagePreview from './WebPagePreview';
import SendAsMenu from './SendAsMenu.async';
import BotMenuButton from './BotMenuButton';
import SymbolMenuButton from './SymbolMenuButton';
import './Composer.scss';
@ -280,8 +280,6 @@ const Composer: FC<OwnProps & StateProps> = ({
sendMessage,
clearDraft,
showDialog,
setStickerSearchQuery,
setGifSearchQuery,
forwardMessages,
openPollModal,
closePollModal,
@ -374,7 +372,6 @@ const Composer: FC<OwnProps & StateProps> = ({
const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag();
const [isSendAsMenuOpen, openSendAsMenu, closeSendAsMenu] = useFlag();
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag();
const [isSymbolMenuLoaded, onSymbolMenuLoadingComplete] = useFlag();
const [isHoverDisabled, disableHover, enableHover] = useFlag();
const {
@ -525,31 +522,10 @@ const Composer: FC<OwnProps & StateProps> = ({
insertHtmlAndUpdateCursor(newHtml, inputId);
}, [insertHtmlAndUpdateCursor]);
const insertTextAndUpdateCursor = useCallback((text: string, inputId: string = EDITABLE_INPUT_ID) => {
const newHtml = renderText(text, ['escape_html', 'emoji_html', 'br_html'])
.join('')
.replace(/\u200b+/g, '\u200b');
insertHtmlAndUpdateCursor(newHtml, inputId);
}, [insertHtmlAndUpdateCursor]);
const insertCustomEmojiAndUpdateCursor = useCallback((emoji: ApiSticker, inputId: string = EDITABLE_INPUT_ID) => {
insertHtmlAndUpdateCursor(buildCustomEmojiHtml(emoji), inputId);
}, [insertHtmlAndUpdateCursor]);
const removeSymbol = useCallback(() => {
const selection = window.getSelection()!;
if (selection.rangeCount) {
const selectionRange = selection.getRangeAt(0);
if (isSelectionInsideInput(selectionRange, EDITABLE_INPUT_ID)) {
document.execCommand('delete', false);
return;
}
}
setHtml(deleteLastCharacterOutsideSelection(getHtml()));
}, [getHtml, setHtml]);
useDraft(draft, chatId, threadId, getHtml, setHtml, editingMessage, lastSyncTime);
const resetComposer = useCallback((shouldPreserveInput = false) => {
@ -867,12 +843,6 @@ const Composer: FC<OwnProps & StateProps> = ({
openBotCommandMenu();
}, [closeSymbolMenu, openBotCommandMenu]);
const handleActivateSymbolMenu = useCallback(() => {
closeBotCommandMenu();
closeSendAsMenu();
openSymbolMenu();
}, [closeBotCommandMenu, closeSendAsMenu, openSymbolMenu]);
const handleMessageSchedule = useCallback((
args: ScheduledMessageArgs, scheduledAt: number,
) => {
@ -928,15 +898,40 @@ const Composer: FC<OwnProps & StateProps> = ({
}
}, [handleFileSelect, requestedDraftFiles, resetOpenChatWithDraft]);
const handleCustomEmojiSelect = useCallback((emoji: ApiSticker) => {
const handleCustomEmojiSelect = useCallback((emoji: ApiSticker, inputId?: string) => {
if (!emoji.isFree && !isCurrentUserPremium && !isChatWithSelf) {
showCustomEmojiPremiumNotification();
return;
}
insertCustomEmojiAndUpdateCursor(emoji);
insertCustomEmojiAndUpdateCursor(emoji, inputId);
}, [insertCustomEmojiAndUpdateCursor, isChatWithSelf, isCurrentUserPremium, showCustomEmojiPremiumNotification]);
const handleCustomEmojiSelectAttachmentModal = useCallback((emoji: ApiSticker) => {
handleCustomEmojiSelect(emoji, EDITABLE_INPUT_MODAL_ID);
}, [handleCustomEmojiSelect]);
const handleGifSelect = useCallback((gif: ApiVideo, isSilent?: boolean, isScheduleRequested?: boolean) => {
if (shouldSchedule || isScheduleRequested) {
forceShowSymbolMenu();
requestCalendar((scheduledAt) => {
cancelForceShowSymbolMenu();
handleMessageSchedule({ gif, isSilent }, scheduledAt);
requestAnimationFrame(() => {
resetComposer(true);
});
});
} else {
sendMessage({ gif, isSilent });
requestAnimationFrame(() => {
resetComposer(true);
});
}
}, [
shouldSchedule, forceShowSymbolMenu, requestCalendar, cancelForceShowSymbolMenu, handleMessageSchedule,
resetComposer, sendMessage,
]);
const handleStickerSelect = useCallback((
sticker: ApiSticker,
isSilent?: boolean,
@ -969,27 +964,6 @@ const Composer: FC<OwnProps & StateProps> = ({
resetComposer, sendMessage,
]);
const handleGifSelect = useCallback((gif: ApiVideo, isSilent?: boolean, isScheduleRequested?: boolean) => {
if (shouldSchedule || isScheduleRequested) {
forceShowSymbolMenu();
requestCalendar((scheduledAt) => {
cancelForceShowSymbolMenu();
handleMessageSchedule({ gif, isSilent }, scheduledAt);
requestAnimationFrame(() => {
resetComposer(true);
});
});
} else {
sendMessage({ gif, isSilent });
requestAnimationFrame(() => {
resetComposer(true);
});
}
}, [
shouldSchedule, forceShowSymbolMenu, requestCalendar, cancelForceShowSymbolMenu, handleMessageSchedule,
resetComposer, sendMessage,
]);
const handleInlineBotSelect = useCallback((
inlineResult: ApiBotInlineResult | ApiBotInlineMediaResult, isSilent?: boolean, isScheduleRequested?: boolean,
) => {
@ -1059,31 +1033,6 @@ const Composer: FC<OwnProps & StateProps> = ({
}
}, [handleMessageSchedule, handleSend, handleSendAttachments, requestCalendar, shouldSchedule]);
const handleSearchOpen = useCallback((type: 'stickers' | 'gifs') => {
if (type === 'stickers') {
setStickerSearchQuery({ query: '' });
setGifSearchQuery({ query: undefined });
} else {
setGifSearchQuery({ query: '' });
setStickerSearchQuery({ query: undefined });
}
}, [setStickerSearchQuery, setGifSearchQuery]);
const handleSymbolMenuOpen = useCallback(() => {
const messageInput = document.querySelector<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR);
if (!isMobile || messageInput !== document.activeElement) {
openSymbolMenu();
return;
}
messageInput?.blur();
setTimeout(() => {
closeBotCommandMenu();
openSymbolMenu();
}, MOBILE_KEYBOARD_HIDE_DELAY_MS);
}, [openSymbolMenu, closeBotCommandMenu, isMobile]);
const handleSendAsMenuOpen = useCallback(() => {
const messageInput = document.querySelector<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR);
@ -1102,6 +1051,35 @@ const Composer: FC<OwnProps & StateProps> = ({
}, MOBILE_KEYBOARD_HIDE_DELAY_MS);
}, [closeBotCommandMenu, closeSymbolMenu, openSendAsMenu, isMobile]);
const insertTextAndUpdateCursor = useCallback((text: string, inputId: string = EDITABLE_INPUT_ID) => {
const newHtml = renderText(text, ['escape_html', 'emoji_html', 'br_html'])
.join('')
.replace(/\u200b+/g, '\u200b');
insertHtmlAndUpdateCursor(newHtml, inputId);
}, [insertHtmlAndUpdateCursor]);
const insertTextAndUpdateCursorAttachmentModal = useCallback((text: string) => {
insertTextAndUpdateCursor(text, EDITABLE_INPUT_MODAL_ID);
}, [insertTextAndUpdateCursor]);
const removeSymbol = useCallback((inputId = EDITABLE_INPUT_ID) => {
const selection = window.getSelection()!;
if (selection.rangeCount) {
const selectionRange = selection.getRangeAt(0);
if (isSelectionInsideInput(selectionRange, inputId)) {
document.execCommand('delete', false);
return;
}
}
setHtml(deleteLastCharacterOutsideSelection(getHtml()));
}, [getHtml, setHtml]);
const removeSymbolAttachmentModal = useCallback(() => {
removeSymbol(EDITABLE_INPUT_MODAL_ID);
}, [removeSymbol]);
const handleAllScheduledClick = useCallback(() => {
openChat({
id: chatId, threadId, type: 'scheduled', noForumTopicPanel: true,
@ -1193,14 +1171,6 @@ const Composer: FC<OwnProps & StateProps> = ({
isHoverDisabled && 'hover-disabled',
);
const symbolMenuButtonClassName = buildClassName(
'mobile-symbol-menu-button',
!isReady && 'not-ready',
isSymbolMenuLoaded
? (isSymbolMenuOpen && 'menu-opened')
: (isSymbolMenuOpen && 'is-loading'),
);
const handleSendScheduled = useCallback(() => {
requestCalendar((scheduledAt) => {
handleMessageSchedule({}, scheduledAt);
@ -1260,6 +1230,9 @@ const Composer: FC<OwnProps & StateProps> = ({
onFileAppend={handleAppendFiles}
onClear={handleClearAttachments}
onAttachmentsUpdate={handleSetAttachments}
onCustomEmojiSelect={handleCustomEmojiSelectAttachmentModal}
onRemoveSymbol={removeSymbolAttachmentModal}
onEmojiSelect={insertTextAndUpdateCursorAttachmentModal}
/>
<PollModal
isOpen={pollModal.isOpen}
@ -1359,29 +1332,25 @@ const Composer: FC<OwnProps & StateProps> = ({
/>
</Button>
)}
{isMobile ? (
<Button
className={symbolMenuButtonClassName}
round
color="translucent"
onClick={isSymbolMenuOpen ? closeSymbolMenu : handleSymbolMenuOpen}
ariaLabel="Choose emoji, sticker or GIF"
>
<i className="icon-smile" />
<i className="icon-keyboard" />
{isSymbolMenuOpen && !isSymbolMenuLoaded && <Spinner color="gray" />}
</Button>
) : (
<ResponsiveHoverButton
className={buildClassName('symbol-menu-button', isSymbolMenuOpen && 'activated')}
round
color="translucent"
onActivate={handleActivateSymbolMenu}
ariaLabel="Choose emoji, sticker or GIF"
>
<i className="icon-smile" />
</ResponsiveHoverButton>
)}
<SymbolMenuButton
chatId={chatId}
threadId={threadId}
isMobile={isMobile}
isReady={isReady}
isSymbolMenuOpen={isSymbolMenuOpen}
openSymbolMenu={openSymbolMenu}
closeSymbolMenu={closeSymbolMenu}
canSendStickers={canSendStickers}
canSendGifs={canSendGifs}
onGifSelect={handleGifSelect}
onStickerSelect={handleStickerSelect}
onCustomEmojiSelect={handleCustomEmojiSelect}
onRemoveSymbol={removeSymbol}
onEmojiSelect={insertTextAndUpdateCursor}
closeBotCommandMenu={closeBotCommandMenu}
closeSendAsMenu={closeSendAsMenu}
isSymbolMenuForced={isSymbolMenuForced}
/>
<MessageInput
ref={inputRef}
id="message-input-text"
@ -1486,23 +1455,6 @@ const Composer: FC<OwnProps & StateProps> = ({
onCustomEmojiSelect={insertEmoji}
onClose={closeEmojiTooltip}
/>
<SymbolMenu
chatId={chatId}
threadId={threadId}
isOpen={isSymbolMenuOpen || isSymbolMenuForced}
canSendGifs={canSendGifs}
canSendStickers={canSendStickers}
onLoad={onSymbolMenuLoadingComplete}
onClose={closeSymbolMenu}
onEmojiSelect={insertTextAndUpdateCursor}
onStickerSelect={handleStickerSelect}
onCustomEmojiSelect={handleCustomEmojiSelect}
onGifSelect={handleGifSelect}
onRemoveSymbol={removeSymbol}
onSearchOpen={handleSearchOpen}
addRecentEmoji={addRecentEmoji}
addRecentCustomEmoji={addRecentCustomEmoji}
/>
</div>
</div>
{activeVoiceRecording && (

View File

@ -39,7 +39,7 @@ const CustomSendMenu: FC<OwnProps> = ({
autoClose
positionX="right"
positionY={isOpenToBottom ? 'top' : 'bottom'}
className="CustomSendMenu"
className="CustomSendMenu with-menu-transitions"
onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd}
onMouseEnter={!IS_TOUCH_ENV ? handleMouseEnter : undefined}

View File

@ -164,7 +164,8 @@ const EmojiPicker: FC<OwnProps & StateProps> = ({
const selectCategory = useCallback((index: number) => {
setActiveCategoryIndex(index);
const categoryEl = document.getElementById(`emoji-category-${index}`)!;
const categoryEl = containerRef.current!.closest<HTMLElement>('.SymbolMenu-main')!
.querySelector(`#emoji-category-${index}`)! as HTMLElement;
fastSmoothScroll(containerRef.current!, categoryEl, 'start', FOCUS_MARGIN, SMOOTH_SCROLL_DISTANCE);
}, []);

View File

@ -1,6 +1,17 @@
@import "../../../styles/mixins";
.SymbolMenu {
&.attachment-modal-symbol-menu {
position: absolute;
z-index: 10000;
}
&:not(.mobile-menu) {
@media (max-height: 800px) {
--symbol-menu-height: 40vh;
}
}
&.mobile-menu {
position: fixed;
left: 0;
@ -19,7 +30,7 @@
0
);
&.open {
&.open:not(.in-attachment-modal) {
transform: translate3d(0, 0, 0);
body.is-media-viewer-open & {
@ -27,6 +38,15 @@
}
}
&.open.in-attachment-modal {
z-index: calc(var(--z-modal) + 1);
transform: translate3d(
0,
calc(var(--symbol-menu-height) + var(--symbol-menu-footer-height)),
0
);
}
// Target: Old Firefox (Waterfox Classic)
@supports not (padding-right: env(safe-area-inset-right)) {
padding-right: 0;
@ -107,6 +127,11 @@
width: 3.5rem;
height: 4.5rem;
}
&.attachment-modal-symbol-menu > .backdrop {
bottom: 0;
top: auto;
}
}
// TODO Remove this monster with context menu refactor

View File

@ -35,24 +35,31 @@ export type OwnProps = {
chatId: string;
threadId?: number;
isOpen: boolean;
canSendStickers: boolean;
canSendGifs: boolean;
canSendStickers?: boolean;
canSendGifs?: boolean;
onLoad: () => void;
onClose: () => void;
onEmojiSelect: (emoji: string) => void;
onCustomEmojiSelect: (emoji: ApiSticker) => void;
onStickerSelect: (
onStickerSelect?: (
sticker: ApiSticker,
isSilent?: boolean,
shouldSchedule?: boolean,
shouldPreserveInput?: boolean,
shouldUpdateStickerSetsOrder?: boolean
) => void;
onGifSelect: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onGifSelect?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onRemoveSymbol: () => void;
onSearchOpen: (type: 'stickers' | 'gifs') => void;
addRecentEmoji: GlobalActions['addRecentEmoji'];
addRecentCustomEmoji: GlobalActions['addRecentCustomEmoji'];
className?: string;
isAttachmentModal?: boolean;
positionX?: 'left' | 'right';
positionY?: 'top' | 'bottom';
transformOriginX?: number;
transformOriginY?: number;
style?: string;
};
type StateProps = {
@ -75,13 +82,20 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
onLoad,
onClose,
onEmojiSelect,
isAttachmentModal,
onCustomEmojiSelect,
onStickerSelect,
className,
onGifSelect,
onRemoveSymbol,
onSearchOpen,
addRecentEmoji,
addRecentCustomEmoji,
positionX,
positionY,
transformOriginX,
transformOriginY,
style,
}) => {
const { loadPremiumSetStickers, loadFeaturedEmojiStickers } = getActions();
const [activeTab, setActiveTab] = useState<number>(0);
@ -109,7 +123,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
}, [isCurrentUserPremium, lastSyncTime, loadFeaturedEmojiStickers, loadPremiumSetStickers]);
useLayoutEffect(() => {
if (!isMobile) {
if (!isMobile || isAttachmentModal) {
return undefined;
}
@ -128,7 +142,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
});
}
};
}, [isMobile, isOpen]);
}, [isAttachmentModal, isMobile, isOpen]);
const recentEmojisRef = useRef(recentEmojis);
recentEmojisRef.current = recentEmojis;
@ -180,7 +194,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
const handleStickerSelect = useCallback((
sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean, shouldUpdateStickerSetsOrder?: boolean,
) => {
onStickerSelect(sticker, isSilent, shouldSchedule, true, shouldUpdateStickerSetsOrder);
onStickerSelect?.(sticker, isSilent, shouldSchedule, true, shouldUpdateStickerSetsOrder);
}, [onStickerSelect]);
const lang = useLang();
@ -204,6 +218,8 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
/>
);
case SymbolMenuTabs.Stickers:
if (!canSendStickers) return undefined;
return (
<StickerPicker
className="picker-tab"
@ -215,6 +231,8 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
/>
);
case SymbolMenuTabs.GIFs:
if (!canSendGifs || !onGifSelect) return undefined;
return (
<GifPicker
className="picker-tab"
@ -259,6 +277,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
onSwitchTab={setActiveTab}
onRemoveSymbol={onRemoveSymbol}
onSearchOpen={handleSearch}
isAttachmentModal={isAttachmentModal}
/>
</>
);
@ -268,15 +287,24 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
return undefined;
}
const className = buildClassName(
const mobileClassName = buildClassName(
'SymbolMenu mobile-menu',
transitionClassNames,
isLeftColumnShown && 'left-column-open',
isAttachmentModal && 'in-attachment-modal',
);
if (isAttachmentModal) {
return (
<div className={mobileClassName}>
{content}
</div>
);
}
return (
<Portal>
<div className={className}>
<div className={mobileClassName}>
{content}
</div>
</Portal>
@ -286,15 +314,19 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
return (
<Menu
isOpen={isOpen}
positionX="left"
positionY="bottom"
positionX={isAttachmentModal ? positionX : 'left'}
positionY={isAttachmentModal ? positionY : 'bottom'}
onClose={onClose}
className="SymbolMenu"
withPortal={isAttachmentModal}
className={buildClassName('SymbolMenu', className)}
onCloseAnimationEnd={onClose}
onMouseEnter={!IS_TOUCH_ENV ? handleMouseEnter : undefined}
onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined}
noCloseOnBackdrop={!IS_TOUCH_ENV}
noCompact
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
style={style}
>
{content}
</Menu>

View File

@ -0,0 +1,210 @@
import React, {
memo, useCallback, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { IAnchorPosition } from '../../../types';
import type { ApiVideo, ApiSticker } from '../../../api/types';
import { EDITABLE_INPUT_CSS_SELECTOR, EDITABLE_INPUT_MODAL_CSS_SELECTOR } from '../../../config';
import buildClassName from '../../../util/buildClassName';
import useFlag from '../../../hooks/useFlag';
import useContextMenuPosition from '../../../hooks/useContextMenuPosition';
import Button from '../../ui/Button';
import Spinner from '../../ui/Spinner';
import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton';
import SymbolMenu from './SymbolMenu.async';
const MOBILE_KEYBOARD_HIDE_DELAY_MS = 100;
type OwnProps = {
chatId: string;
threadId?: number;
isMobile?: boolean;
isReady?: boolean;
isSymbolMenuOpen?: boolean;
canSendGifs?: boolean;
canSendStickers?: boolean;
openSymbolMenu: VoidFunction;
closeSymbolMenu: VoidFunction;
onCustomEmojiSelect: (emoji: ApiSticker) => void;
onStickerSelect?: (
sticker: ApiSticker,
isSilent?: boolean,
shouldSchedule?: boolean,
shouldPreserveInput?: boolean,
shouldUpdateStickerSetsOrder?: boolean
) => void;
onGifSelect?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onRemoveSymbol: VoidFunction;
onEmojiSelect: (emoji: string) => void;
closeBotCommandMenu?: VoidFunction;
closeSendAsMenu?: VoidFunction;
isSymbolMenuForced?: boolean;
isAttachmentModal?: boolean;
className?: string;
};
const SymbolMenuButton: FC<OwnProps> = ({
chatId,
threadId,
isMobile,
canSendGifs,
canSendStickers,
isReady,
isSymbolMenuOpen,
openSymbolMenu,
closeSymbolMenu,
onCustomEmojiSelect,
onStickerSelect,
onGifSelect,
isAttachmentModal,
onRemoveSymbol,
onEmojiSelect,
closeBotCommandMenu,
closeSendAsMenu,
isSymbolMenuForced,
className,
}) => {
const {
setStickerSearchQuery,
setGifSearchQuery,
addRecentEmoji,
addRecentCustomEmoji,
} = getActions();
// eslint-disable-next-line no-null/no-null
const triggerRef = useRef<HTMLDivElement>(null);
const [isSymbolMenuLoaded, onSymbolMenuLoadingComplete] = useFlag();
const [contextMenuPosition, setContextMenuPosition] = useState<IAnchorPosition | undefined>(undefined);
const symbolMenuButtonClassName = buildClassName(
'mobile-symbol-menu-button',
!isReady && 'not-ready',
isSymbolMenuLoaded
? (isSymbolMenuOpen && 'menu-opened')
: (isSymbolMenuOpen && 'is-loading'),
);
const handleActivateSymbolMenu = useCallback(() => {
closeBotCommandMenu?.();
closeSendAsMenu?.();
openSymbolMenu();
const triggerEl = triggerRef.current;
if (!triggerEl) return;
const { x, y } = triggerEl.getBoundingClientRect();
setContextMenuPosition({ x, y });
}, [closeBotCommandMenu, closeSendAsMenu, openSymbolMenu]);
const handleSearchOpen = useCallback((type: 'stickers' | 'gifs') => {
if (type === 'stickers') {
setStickerSearchQuery({ query: '' });
setGifSearchQuery({ query: undefined });
} else {
setGifSearchQuery({ query: '' });
setStickerSearchQuery({ query: undefined });
}
}, [setStickerSearchQuery, setGifSearchQuery]);
const handleSymbolMenuOpen = useCallback(() => {
const messageInput = document.querySelector<HTMLDivElement>(
isAttachmentModal ? EDITABLE_INPUT_MODAL_CSS_SELECTOR : EDITABLE_INPUT_CSS_SELECTOR,
);
if (!isMobile || messageInput !== document.activeElement) {
openSymbolMenu();
return;
}
messageInput?.blur();
setTimeout(() => {
closeBotCommandMenu?.();
openSymbolMenu();
}, MOBILE_KEYBOARD_HIDE_DELAY_MS);
}, [isAttachmentModal, isMobile, openSymbolMenu, closeBotCommandMenu]);
const getTriggerElement = useCallback(() => triggerRef.current, []);
const getRootElement = useCallback(
() => triggerRef.current?.closest('.custom-scroll, .no-scrollbar'),
[],
);
const getMenuElement = useCallback(
() => document.querySelector('#portals .SymbolMenu .bubble'),
[],
);
const getLayout = useCallback(() => ({
withPortal: true,
}), []);
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useContextMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,
getMenuElement,
getLayout,
);
return (
<>
{isMobile ? (
<Button
className={symbolMenuButtonClassName}
round
color="translucent"
onClick={isSymbolMenuOpen ? closeSymbolMenu : handleSymbolMenuOpen}
ariaLabel="Choose emoji, sticker or GIF"
>
<i className="icon-smile" />
<i className="icon-keyboard" />
{isSymbolMenuOpen && !isSymbolMenuLoaded && <Spinner color="gray" />}
</Button>
) : (
<ResponsiveHoverButton
className={buildClassName('symbol-menu-button', isSymbolMenuOpen && 'activated')}
round
color="translucent"
onActivate={handleActivateSymbolMenu}
ariaLabel="Choose emoji, sticker or GIF"
>
<div ref={triggerRef} className="symbol-menu-trigger" />
<i className="icon-smile" />
</ResponsiveHoverButton>
)}
<SymbolMenu
chatId={chatId}
threadId={threadId}
isOpen={isSymbolMenuOpen || Boolean(isSymbolMenuForced)}
canSendGifs={canSendGifs}
canSendStickers={canSendStickers}
onLoad={onSymbolMenuLoadingComplete}
onClose={closeSymbolMenu}
onEmojiSelect={onEmojiSelect}
onStickerSelect={onStickerSelect}
onCustomEmojiSelect={onCustomEmojiSelect}
onGifSelect={onGifSelect}
onRemoveSymbol={onRemoveSymbol}
onSearchOpen={handleSearchOpen}
addRecentEmoji={addRecentEmoji}
addRecentCustomEmoji={addRecentCustomEmoji}
isAttachmentModal={isAttachmentModal}
className={className}
positionX={isAttachmentModal ? positionX : undefined}
positionY={isAttachmentModal ? positionY : undefined}
transformOriginX={isAttachmentModal ? transformOriginX : undefined}
transformOriginY={isAttachmentModal ? transformOriginY : undefined}
style={isAttachmentModal ? menuStyle : undefined}
/>
</>
);
};
export default memo(SymbolMenuButton);

View File

@ -10,6 +10,7 @@ type OwnProps = {
onSwitchTab: (tab: SymbolMenuTabs) => void;
onRemoveSymbol: () => void;
onSearchOpen: (type: 'stickers' | 'gifs') => void;
isAttachmentModal?: boolean;
};
export enum SymbolMenuTabs {
@ -34,7 +35,7 @@ const SYMBOL_MENU_TAB_ICONS = {
};
const SymbolMenuFooter: FC<OwnProps> = ({
activeTab, onSwitchTab, onRemoveSymbol, onSearchOpen,
activeTab, onSwitchTab, onRemoveSymbol, onSearchOpen, isAttachmentModal,
}) => {
const lang = useLang();
@ -79,8 +80,8 @@ const SymbolMenuFooter: FC<OwnProps> = ({
{renderTabButton(SymbolMenuTabs.Emoji)}
{renderTabButton(SymbolMenuTabs.CustomEmoji)}
{renderTabButton(SymbolMenuTabs.Stickers)}
{renderTabButton(SymbolMenuTabs.GIFs)}
{!isAttachmentModal && renderTabButton(SymbolMenuTabs.Stickers)}
{!isAttachmentModal && renderTabButton(SymbolMenuTabs.GIFs)}
{(activeTab === SymbolMenuTabs.Emoji || activeTab === SymbolMenuTabs.CustomEmoji) && (
<Button

View File

@ -67,7 +67,7 @@
}
}
body.has-open-dialog &:not(.CustomSendMenu):not(.web-app-more-menu):not(.attachment-modal-more-menu):not(.stickers-more-menu) .bubble {
body.has-open-dialog &:not(.with-menu-transitions) .bubble {
transition: none !important;
}

View File

@ -1,13 +1,14 @@
import type { FC } from '../../lib/teact/teact';
import React, { useRef, useCallback, memo } from '../../lib/teact/teact';
import type { OwnProps as ButtonProps } from './Button';
import { IS_TOUCH_ENV } from '../../util/environment';
import type { OwnProps as ButtonProps } from './Button';
import Button from './Button';
type OwnProps = {
onActivate: NoneToVoidFunction;
onActivate: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
} & Omit<ButtonProps, (
'onClick' | 'onMouseDown' |
'onMouseEnter' | 'onMouseLeave' |
@ -21,13 +22,13 @@ let isFirstTimeActivation = true;
const ResponsiveHoverButton: FC<OwnProps> = ({ onActivate, ...buttonProps }) => {
const isMouseInside = useRef(false);
const handleMouseEnter = useCallback(() => {
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
isMouseInside.current = true;
// This is used to counter additional delay caused by asynchronous module loading
if (isFirstTimeActivation) {
isFirstTimeActivation = false;
onActivate();
onActivate(e);
return;
}
@ -37,7 +38,7 @@ const ResponsiveHoverButton: FC<OwnProps> = ({ onActivate, ...buttonProps }) =>
}
openTimeout = window.setTimeout(() => {
if (isMouseInside.current) {
onActivate();
onActivate(e);
}
}, BUTTON_ACTIVATE_DELAY);
}, [onActivate]);
@ -46,9 +47,9 @@ const ResponsiveHoverButton: FC<OwnProps> = ({ onActivate, ...buttonProps }) =>
isMouseInside.current = false;
}, []);
const handleClick = useCallback(() => {
const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
isMouseInside.current = true;
onActivate();
onActivate(e);
}, [onActivate]);
return (

View File

@ -111,6 +111,7 @@ export const EDITABLE_INPUT_ID = 'editable-message-text';
export const EDITABLE_INPUT_MODAL_ID = 'editable-message-text-modal';
// eslint-disable-next-line max-len
export const EDITABLE_INPUT_CSS_SELECTOR = `.messages-layout .Transition__slide--active #${EDITABLE_INPUT_ID}, .messages-layout .Transition > .to #${EDITABLE_INPUT_ID}`;
export const EDITABLE_INPUT_MODAL_CSS_SELECTOR = `#${EDITABLE_INPUT_MODAL_ID}`;
export const CUSTOM_APPENDIX_ATTRIBUTE = 'data-has-custom-appendix';
export const MESSAGE_CONTENT_CLASS_NAME = 'message-content';