diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index f87128b65..50ad384d4 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -25,6 +25,7 @@ import type { ApiTopic, ApiUser, ApiVideo, + ApiWebPage, } from '../../api/types'; import type { ApiDraft, GlobalState, MessageList, @@ -75,6 +76,7 @@ import { selectIsReactionPickerOpen, selectIsRightColumnShown, selectNewestMessageWithBotKeyboardButtons, + selectNoWebPage, selectPeerStory, selectRequestedDraft, selectRequestedDraftFiles, @@ -249,6 +251,8 @@ type StateProps = quickReplyMessages?: Record; quickReplies?: Record; canSendQuickReplies?: boolean; + webPagePreview?: ApiWebPage; + noWebPage?: boolean; }; enum MainButtonState { @@ -355,6 +359,8 @@ const Composer: FC = ({ quickReplies, canSendQuickReplies, onForward, + webPagePreview, + noWebPage, }) => { const { sendMessage, @@ -377,6 +383,7 @@ const Composer: FC = ({ closeReactionPicker, sendStoryReaction, editMessage, + updateAttachmentSettings, } = getActions(); const lang = useOldLang(); @@ -466,8 +473,15 @@ const Composer: FC = ({ [chat, chatFullInfo, isChatWithBot, isInStoryViewer], ); + const hasWebPagePreview = !hasAttachments && canAttachEmbedLinks && !noWebPage && Boolean(webPagePreview); const isComposerBlocked = !canSendPlainText && !editingMessage; + useEffect(() => { + if (!hasWebPagePreview) { + updateAttachmentSettings({ isInvertedMedia: undefined }); + } + }, [hasWebPagePreview]); + const insertHtmlAndUpdateCursor = useLastCallback((newHtml: string, inInputId: string = editableInputId) => { if (inInputId === editableInputId && isComposerBlocked) return; const selection = window.getSelection()!; @@ -1012,6 +1026,8 @@ const Composer: FC = ({ if (text) { if (!checkSlowMode()) return; + const isInvertedMedia = hasWebPagePreview ? attachmentSettings.isInvertedMedia : undefined; + sendMessage({ messageList: currentMessageList, text, @@ -1019,6 +1035,7 @@ const Composer: FC = ({ scheduledAt, isSilent, shouldUpdateStickerSetOrder, + isInvertedMedia, }); } @@ -1684,6 +1701,7 @@ const Composer: FC = ({ threadId={threadId} getHtml={getHtml} isDisabled={!canAttachEmbedLinks || hasAttachments} + isEditing={Boolean(editingMessage)} /> )} @@ -2048,6 +2066,8 @@ export default memo(withGlobal( const canSendQuickReplies = isChatWithUser && !isChatWithBot && !isInScheduledList && !isChatWithSelf; + const noWebPage = selectNoWebPage(global, chatId, threadId); + return { availableReactions: type === 'story' ? global.reactions.availableReactions : undefined, topReactions: type === 'story' ? global.reactions.topReactions : undefined, @@ -2115,6 +2135,8 @@ export default memo(withGlobal( quickReplyMessages: global.quickReplies.messagesById, quickReplies: global.quickReplies.byId, canSendQuickReplies, + noWebPage, + webPagePreview: selectTabState(global).webPagePreview, }; }, )(Composer)); diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index c2b683db4..d84fb094b 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -167,7 +167,7 @@ const AttachmentModal: FC = ({ (shouldSendCompressed || shouldForceCompression || isInAlbum) && !shouldForceAsFile, ); const [shouldSendGrouped, setShouldSendGrouped] = useState(attachmentSettings.shouldSendGrouped); - const [isInvertedMedia, setIsInvertedMedia] = useState(attachmentSettings.isInvertedMedia); + const isInvertedMedia = attachmentSettings.isInvertedMedia; const { handleScroll: handleAttachmentsScroll, @@ -184,6 +184,7 @@ const AttachmentModal: FC = ({ useEffect(() => { if (!isOpen) { closeSymbolMenu(); + updateAttachmentSettings({ isInvertedMedia: undefined }); } }, [closeSymbolMenu, isOpen]); @@ -254,10 +255,19 @@ const AttachmentModal: FC = ({ if (isOpen) { setShouldSendCompressed(shouldSuggestCompression ?? attachmentSettings.shouldCompress); setShouldSendGrouped(attachmentSettings.shouldSendGrouped); - setIsInvertedMedia(attachmentSettings.isInvertedMedia); } }, [attachmentSettings, isOpen, shouldSuggestCompression]); + useEffect(() => { + if (!isOpen) { + updateAttachmentSettings({ isInvertedMedia: undefined }); + } + }, [updateAttachmentSettings, isOpen, shouldSuggestCompression]); + + function setIsInvertedMedia(value?: true) { + updateAttachmentSettings({ isInvertedMedia: value }); + } + useEffect(() => { if (isOpen && isMobile) { removeAllSelections(); diff --git a/src/components/middle/composer/WebPagePreview.scss b/src/components/middle/composer/WebPagePreview.scss index b61ffff60..f55849844 100644 --- a/src/components/middle/composer/WebPagePreview.scss +++ b/src/components/middle/composer/WebPagePreview.scss @@ -1,4 +1,5 @@ .WebPagePreview { + position: relative; height: 3.125rem; /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ transition: height 150ms ease-out, opacity 150ms ease-out; @@ -30,6 +31,18 @@ } } + .web-page-preview-context-menu { + position: absolute; + + .bubble { + width: auto; + } + + .icon-placeholder { + width: 1.5rem; + } + } + & &-left-icon { flex-shrink: 0; background: none !important; diff --git a/src/components/middle/composer/WebPagePreview.tsx b/src/components/middle/composer/WebPagePreview.tsx index 2992ae71f..5e71ab93c 100644 --- a/src/components/middle/composer/WebPagePreview.tsx +++ b/src/components/middle/composer/WebPagePreview.tsx @@ -1,10 +1,13 @@ import type { FC } from '../../../lib/teact/teact'; -import React, { memo, useEffect, useRef } from '../../../lib/teact/teact'; +import React, { + memo, useEffect, useRef, +} from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { ApiFormattedText, ApiMessage, ApiMessageEntityTextUrl, ApiWebPage, } from '../../../api/types'; +import type { GlobalState } from '../../../global/types'; import type { ISettings, ThreadId } from '../../../types'; import type { Signal } from '../../../util/signals'; import { ApiMessageEntityTypes } from '../../../api/types'; @@ -15,14 +18,19 @@ import buildClassName from '../../../util/buildClassName'; import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText'; import { useDebouncedResolver } from '../../../hooks/useAsyncResolvers'; +import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; import useDerivedSignal from '../../../hooks/useDerivedSignal'; import useDerivedState from '../../../hooks/useDerivedState'; import useLastCallback from '../../../hooks/useLastCallback'; +import useMenuPosition from '../../../hooks/useMenuPosition'; +import useOldLang from '../../../hooks/useOldLang'; import useShowTransition from '../../../hooks/useShowTransition'; import useSyncEffect from '../../../hooks/useSyncEffect'; import Button from '../../ui/Button'; +import Menu from '../../ui/Menu'; +import MenuItem from '../../ui/MenuItem'; import WebPage from '../message/WebPage'; import './WebPagePreview.scss'; @@ -31,6 +39,7 @@ type OwnProps = { chatId: string; threadId: ThreadId; getHtml: Signal; + isEditing: boolean; isDisabled?: boolean; }; @@ -38,6 +47,7 @@ type StateProps = { webPagePreview?: ApiWebPage; noWebPage?: boolean; theme: ISettings['theme']; + attachmentSettings: GlobalState['attachmentSettings']; }; const DEBOUNCE_MS = 300; @@ -51,15 +61,25 @@ const WebPagePreview: FC = ({ webPagePreview, noWebPage, theme, + attachmentSettings, + isEditing, }) => { const { loadWebPagePreview, clearWebPagePreview, toggleMessageWebPage, + updateAttachmentSettings, } = getActions(); + const lang = useOldLang(); + const formattedTextWithLinkRef = useRef(); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + + const isInvertedMedia = attachmentSettings.isInvertedMedia; + const detectLinkDebounced = useDebouncedResolver(() => { const formattedText = parseHtmlAsFormattedText(getHtml()); const linkEntity = formattedText.entities?.find((entity): entity is ApiMessageEntityTextUrl => ( @@ -101,6 +121,41 @@ const WebPagePreview: FC = ({ toggleMessageWebPage({ chatId, threadId, noWebPage: true }); }); + const { + isContextMenuOpen, contextMenuPosition, handleContextMenu, + handleContextMenuClose, handleContextMenuHide, + } = useContextMenuHandlers(ref, isEditing, true); + + const getTriggerElement = useLastCallback(() => ref.current); + const getRootElement = useLastCallback(() => ref.current!); + const getMenuElement = useLastCallback( + () => ref.current!.querySelector('.web-page-preview-context-menu .bubble'), + ); + + const { + positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, + } = useMenuPosition( + contextMenuPosition, + getTriggerElement, + getRootElement, + getMenuElement, + ); + + const handlePreviewClick = useLastCallback((e: React.MouseEvent): void => { + handleContextMenu(e); + }); + + useEffect(() => { + if (!shouldRender || !renderingWebPage) { + handleContextMenuClose(); + handleContextMenuHide(); + } + }, [handleContextMenuClose, handleContextMenuHide, shouldRender, renderingWebPage]); + + function updateIsInvertedMedia(value?: true) { + updateAttachmentSettings({ isInvertedMedia: value }); + } + if (!shouldRender || !renderingWebPage) { return undefined; } @@ -113,13 +168,59 @@ const WebPagePreview: FC = ({ }, } as ApiMessage; + function renderContextMenu() { + return ( + + <> + { + isInvertedMedia ? ( + // eslint-disable-next-line react/jsx-no-bind + updateIsInvertedMedia(undefined)}> + {lang('PreviewSender.MoveTextUp')} + + ) : ( + // eslint-disable-next-line react/jsx-no-bind + updateIsInvertedMedia(true)}> + {lang(('PreviewSender.MoveTextDown'))} + + ) + } + + {lang('ChatInput.EditLink.RemovePreview')} + + + + ); + } + return ( -
+
-
+
- + + {!isEditing && renderContextMenu()}
); @@ -138,10 +240,14 @@ const WebPagePreview: FC = ({ export default memo(withGlobal( (global, { chatId, threadId }): StateProps => { const noWebPage = selectNoWebPage(global, chatId, threadId); + const { + attachmentSettings, + } = global; return { theme: selectTheme(global), webPagePreview: selectTabState(global).webPagePreview, noWebPage, + attachmentSettings, }; }, )(WebPagePreview)); diff --git a/src/components/middle/message/WebPage.scss b/src/components/middle/message/WebPage.scss index 262b7538d..2db5c5316 100644 --- a/src/components/middle/message/WebPage.scss +++ b/src/components/middle/message/WebPage.scss @@ -49,6 +49,13 @@ margin: 0; padding: 0.125rem 0.25rem 0.125rem 0.625rem; transition: background-color 0.2s ease-in; + + &.interactive { + cursor: var(--custom-cursor, pointer); + &:active { + background-color: var(--background-active-color); + } + } } &::before { diff --git a/src/components/middle/message/WebPage.tsx b/src/components/middle/message/WebPage.tsx index 2fb6235ca..a3bdd7aa6 100644 --- a/src/components/middle/message/WebPage.tsx +++ b/src/components/middle/message/WebPage.tsx @@ -56,6 +56,8 @@ type OwnProps = { onAudioPlay?: NoneToVoidFunction; onMediaClick?: NoneToVoidFunction; onCancelMediaTransfer?: NoneToVoidFunction; + onContainerClick?: ((e: React.MouseEvent) => void); + isEditing?: boolean; }; const WebPage: FC = ({ @@ -76,8 +78,10 @@ const WebPage: FC = ({ shouldWarnAboutSvg, autoLoadFileMaxSizeMb, onMediaClick, + onContainerClick, onAudioPlay, onCancelMediaTransfer, + isEditing, }) => { const { openTelegramLink } = getActions(); const webPage = getMessageWebPage(message); @@ -92,6 +96,9 @@ const WebPage: FC = ({ const handleMediaClick = useLastCallback(() => { onMediaClick!(); }); + const handleContainerClick = useLastCallback((e: React.MouseEvent) => { + onContainerClick?.(e); + }); const handleQuickButtonClick = useLastCallback(() => { if (!webPage) return; @@ -138,6 +145,7 @@ const WebPage: FC = ({ const className = buildClassName( 'WebPage', inPreview && 'in-preview', + !isEditing && inPreview && 'interactive', isSquarePhoto && 'with-square-photo', !photo && !video && !inPreview && 'without-media', video && 'with-video', @@ -166,6 +174,7 @@ const WebPage: FC = ({ className={className} data-initial={(siteName || displayUrl)[0]} dir={lang.isRtl ? 'rtl' : 'auto'} + onClick={handleContainerClick} >
{backgroundEmojiId && (