Web Page: Send inverted web page; Web Page menu (#4701)

This commit is contained in:
Alexander Zinchuk 2024-07-15 15:50:39 +02:00
parent cf4c562408
commit 5fb1283884
6 changed files with 173 additions and 6 deletions

View File

@ -25,6 +25,7 @@ import type {
ApiTopic, ApiTopic,
ApiUser, ApiUser,
ApiVideo, ApiVideo,
ApiWebPage,
} from '../../api/types'; } from '../../api/types';
import type { import type {
ApiDraft, GlobalState, MessageList, ApiDraft, GlobalState, MessageList,
@ -75,6 +76,7 @@ import {
selectIsReactionPickerOpen, selectIsReactionPickerOpen,
selectIsRightColumnShown, selectIsRightColumnShown,
selectNewestMessageWithBotKeyboardButtons, selectNewestMessageWithBotKeyboardButtons,
selectNoWebPage,
selectPeerStory, selectPeerStory,
selectRequestedDraft, selectRequestedDraft,
selectRequestedDraftFiles, selectRequestedDraftFiles,
@ -249,6 +251,8 @@ type StateProps =
quickReplyMessages?: Record<number, ApiMessage>; quickReplyMessages?: Record<number, ApiMessage>;
quickReplies?: Record<number, ApiQuickReply>; quickReplies?: Record<number, ApiQuickReply>;
canSendQuickReplies?: boolean; canSendQuickReplies?: boolean;
webPagePreview?: ApiWebPage;
noWebPage?: boolean;
}; };
enum MainButtonState { enum MainButtonState {
@ -355,6 +359,8 @@ const Composer: FC<OwnProps & StateProps> = ({
quickReplies, quickReplies,
canSendQuickReplies, canSendQuickReplies,
onForward, onForward,
webPagePreview,
noWebPage,
}) => { }) => {
const { const {
sendMessage, sendMessage,
@ -377,6 +383,7 @@ const Composer: FC<OwnProps & StateProps> = ({
closeReactionPicker, closeReactionPicker,
sendStoryReaction, sendStoryReaction,
editMessage, editMessage,
updateAttachmentSettings,
} = getActions(); } = getActions();
const lang = useOldLang(); const lang = useOldLang();
@ -466,8 +473,15 @@ const Composer: FC<OwnProps & StateProps> = ({
[chat, chatFullInfo, isChatWithBot, isInStoryViewer], [chat, chatFullInfo, isChatWithBot, isInStoryViewer],
); );
const hasWebPagePreview = !hasAttachments && canAttachEmbedLinks && !noWebPage && Boolean(webPagePreview);
const isComposerBlocked = !canSendPlainText && !editingMessage; const isComposerBlocked = !canSendPlainText && !editingMessage;
useEffect(() => {
if (!hasWebPagePreview) {
updateAttachmentSettings({ isInvertedMedia: undefined });
}
}, [hasWebPagePreview]);
const insertHtmlAndUpdateCursor = useLastCallback((newHtml: string, inInputId: string = editableInputId) => { const insertHtmlAndUpdateCursor = useLastCallback((newHtml: string, inInputId: string = editableInputId) => {
if (inInputId === editableInputId && isComposerBlocked) return; if (inInputId === editableInputId && isComposerBlocked) return;
const selection = window.getSelection()!; const selection = window.getSelection()!;
@ -1012,6 +1026,8 @@ const Composer: FC<OwnProps & StateProps> = ({
if (text) { if (text) {
if (!checkSlowMode()) return; if (!checkSlowMode()) return;
const isInvertedMedia = hasWebPagePreview ? attachmentSettings.isInvertedMedia : undefined;
sendMessage({ sendMessage({
messageList: currentMessageList, messageList: currentMessageList,
text, text,
@ -1019,6 +1035,7 @@ const Composer: FC<OwnProps & StateProps> = ({
scheduledAt, scheduledAt,
isSilent, isSilent,
shouldUpdateStickerSetOrder, shouldUpdateStickerSetOrder,
isInvertedMedia,
}); });
} }
@ -1684,6 +1701,7 @@ const Composer: FC<OwnProps & StateProps> = ({
threadId={threadId} threadId={threadId}
getHtml={getHtml} getHtml={getHtml}
isDisabled={!canAttachEmbedLinks || hasAttachments} isDisabled={!canAttachEmbedLinks || hasAttachments}
isEditing={Boolean(editingMessage)}
/> />
</> </>
)} )}
@ -2048,6 +2066,8 @@ export default memo(withGlobal<OwnProps>(
const canSendQuickReplies = isChatWithUser && !isChatWithBot && !isInScheduledList && !isChatWithSelf; const canSendQuickReplies = isChatWithUser && !isChatWithBot && !isInScheduledList && !isChatWithSelf;
const noWebPage = selectNoWebPage(global, chatId, threadId);
return { return {
availableReactions: type === 'story' ? global.reactions.availableReactions : undefined, availableReactions: type === 'story' ? global.reactions.availableReactions : undefined,
topReactions: type === 'story' ? global.reactions.topReactions : undefined, topReactions: type === 'story' ? global.reactions.topReactions : undefined,
@ -2115,6 +2135,8 @@ export default memo(withGlobal<OwnProps>(
quickReplyMessages: global.quickReplies.messagesById, quickReplyMessages: global.quickReplies.messagesById,
quickReplies: global.quickReplies.byId, quickReplies: global.quickReplies.byId,
canSendQuickReplies, canSendQuickReplies,
noWebPage,
webPagePreview: selectTabState(global).webPagePreview,
}; };
}, },
)(Composer)); )(Composer));

View File

@ -167,7 +167,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
(shouldSendCompressed || shouldForceCompression || isInAlbum) && !shouldForceAsFile, (shouldSendCompressed || shouldForceCompression || isInAlbum) && !shouldForceAsFile,
); );
const [shouldSendGrouped, setShouldSendGrouped] = useState(attachmentSettings.shouldSendGrouped); const [shouldSendGrouped, setShouldSendGrouped] = useState(attachmentSettings.shouldSendGrouped);
const [isInvertedMedia, setIsInvertedMedia] = useState(attachmentSettings.isInvertedMedia); const isInvertedMedia = attachmentSettings.isInvertedMedia;
const { const {
handleScroll: handleAttachmentsScroll, handleScroll: handleAttachmentsScroll,
@ -184,6 +184,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
closeSymbolMenu(); closeSymbolMenu();
updateAttachmentSettings({ isInvertedMedia: undefined });
} }
}, [closeSymbolMenu, isOpen]); }, [closeSymbolMenu, isOpen]);
@ -254,10 +255,19 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
if (isOpen) { if (isOpen) {
setShouldSendCompressed(shouldSuggestCompression ?? attachmentSettings.shouldCompress); setShouldSendCompressed(shouldSuggestCompression ?? attachmentSettings.shouldCompress);
setShouldSendGrouped(attachmentSettings.shouldSendGrouped); setShouldSendGrouped(attachmentSettings.shouldSendGrouped);
setIsInvertedMedia(attachmentSettings.isInvertedMedia);
} }
}, [attachmentSettings, isOpen, shouldSuggestCompression]); }, [attachmentSettings, isOpen, shouldSuggestCompression]);
useEffect(() => {
if (!isOpen) {
updateAttachmentSettings({ isInvertedMedia: undefined });
}
}, [updateAttachmentSettings, isOpen, shouldSuggestCompression]);
function setIsInvertedMedia(value?: true) {
updateAttachmentSettings({ isInvertedMedia: value });
}
useEffect(() => { useEffect(() => {
if (isOpen && isMobile) { if (isOpen && isMobile) {
removeAllSelections(); removeAllSelections();

View File

@ -1,4 +1,5 @@
.WebPagePreview { .WebPagePreview {
position: relative;
height: 3.125rem; height: 3.125rem;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: height 150ms ease-out, opacity 150ms ease-out; 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 { & &-left-icon {
flex-shrink: 0; flex-shrink: 0;
background: none !important; background: none !important;

View File

@ -1,10 +1,13 @@
import type { FC } from '../../../lib/teact/teact'; 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 { getActions, withGlobal } from '../../../global';
import type { import type {
ApiFormattedText, ApiMessage, ApiMessageEntityTextUrl, ApiWebPage, ApiFormattedText, ApiMessage, ApiMessageEntityTextUrl, ApiWebPage,
} from '../../../api/types'; } from '../../../api/types';
import type { GlobalState } from '../../../global/types';
import type { ISettings, ThreadId } from '../../../types'; import type { ISettings, ThreadId } from '../../../types';
import type { Signal } from '../../../util/signals'; import type { Signal } from '../../../util/signals';
import { ApiMessageEntityTypes } from '../../../api/types'; import { ApiMessageEntityTypes } from '../../../api/types';
@ -15,14 +18,19 @@ import buildClassName from '../../../util/buildClassName';
import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText'; import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText';
import { useDebouncedResolver } from '../../../hooks/useAsyncResolvers'; import { useDebouncedResolver } from '../../../hooks/useAsyncResolvers';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useDerivedSignal from '../../../hooks/useDerivedSignal'; import useDerivedSignal from '../../../hooks/useDerivedSignal';
import useDerivedState from '../../../hooks/useDerivedState'; import useDerivedState from '../../../hooks/useDerivedState';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useMenuPosition from '../../../hooks/useMenuPosition';
import useOldLang from '../../../hooks/useOldLang';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import useSyncEffect from '../../../hooks/useSyncEffect'; import useSyncEffect from '../../../hooks/useSyncEffect';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import WebPage from '../message/WebPage'; import WebPage from '../message/WebPage';
import './WebPagePreview.scss'; import './WebPagePreview.scss';
@ -31,6 +39,7 @@ type OwnProps = {
chatId: string; chatId: string;
threadId: ThreadId; threadId: ThreadId;
getHtml: Signal<string>; getHtml: Signal<string>;
isEditing: boolean;
isDisabled?: boolean; isDisabled?: boolean;
}; };
@ -38,6 +47,7 @@ type StateProps = {
webPagePreview?: ApiWebPage; webPagePreview?: ApiWebPage;
noWebPage?: boolean; noWebPage?: boolean;
theme: ISettings['theme']; theme: ISettings['theme'];
attachmentSettings: GlobalState['attachmentSettings'];
}; };
const DEBOUNCE_MS = 300; const DEBOUNCE_MS = 300;
@ -51,15 +61,25 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
webPagePreview, webPagePreview,
noWebPage, noWebPage,
theme, theme,
attachmentSettings,
isEditing,
}) => { }) => {
const { const {
loadWebPagePreview, loadWebPagePreview,
clearWebPagePreview, clearWebPagePreview,
toggleMessageWebPage, toggleMessageWebPage,
updateAttachmentSettings,
} = getActions(); } = getActions();
const lang = useOldLang();
const formattedTextWithLinkRef = useRef<ApiFormattedText>(); const formattedTextWithLinkRef = useRef<ApiFormattedText>();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const isInvertedMedia = attachmentSettings.isInvertedMedia;
const detectLinkDebounced = useDebouncedResolver(() => { const detectLinkDebounced = useDebouncedResolver(() => {
const formattedText = parseHtmlAsFormattedText(getHtml()); const formattedText = parseHtmlAsFormattedText(getHtml());
const linkEntity = formattedText.entities?.find((entity): entity is ApiMessageEntityTextUrl => ( const linkEntity = formattedText.entities?.find((entity): entity is ApiMessageEntityTextUrl => (
@ -101,6 +121,41 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
toggleMessageWebPage({ chatId, threadId, noWebPage: true }); 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) { if (!shouldRender || !renderingWebPage) {
return undefined; return undefined;
} }
@ -113,13 +168,59 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
}, },
} as ApiMessage; } as ApiMessage;
function renderContextMenu() {
return (
<Menu
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
className="web-page-preview-context-menu"
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
autoClose
>
<>
{
isInvertedMedia ? (
// eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="move-caption-up" onClick={() => updateIsInvertedMedia(undefined)}>
{lang('PreviewSender.MoveTextUp')}
</MenuItem>
) : (
// eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="move-caption-down" onClick={() => updateIsInvertedMedia(true)}>
{lang(('PreviewSender.MoveTextDown'))}
</MenuItem>
)
}
<MenuItem
icon="delete"
// eslint-disable-next-line react/jsx-no-bind
onClick={handleClearWebpagePreview}
>
{lang('ChatInput.EditLink.RemovePreview')}
</MenuItem>
</>
</Menu>
);
}
return ( return (
<div className={buildClassName('WebPagePreview', transitionClassNames)}> <div className={buildClassName('WebPagePreview', transitionClassNames)} ref={ref}>
<div className="WebPagePreview_inner"> <div className="WebPagePreview_inner">
<div className="WebPagePreview-left-icon"> <div className="WebPagePreview-left-icon" onClick={handlePreviewClick}>
<i className="icon icon-link" /> <i className="icon icon-link" />
</div> </div>
<WebPage message={messageStub} inPreview theme={theme} /> <WebPage
message={messageStub}
inPreview
theme={theme}
onContainerClick={handlePreviewClick}
isEditing={isEditing}
/>
<Button <Button
className="WebPagePreview-clear" className="WebPagePreview-clear"
round round
@ -130,6 +231,7 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
> >
<i className="icon icon-close" /> <i className="icon icon-close" />
</Button> </Button>
{!isEditing && renderContextMenu()}
</div> </div>
</div> </div>
); );
@ -138,10 +240,14 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, { chatId, threadId }): StateProps => { (global, { chatId, threadId }): StateProps => {
const noWebPage = selectNoWebPage(global, chatId, threadId); const noWebPage = selectNoWebPage(global, chatId, threadId);
const {
attachmentSettings,
} = global;
return { return {
theme: selectTheme(global), theme: selectTheme(global),
webPagePreview: selectTabState(global).webPagePreview, webPagePreview: selectTabState(global).webPagePreview,
noWebPage, noWebPage,
attachmentSettings,
}; };
}, },
)(WebPagePreview)); )(WebPagePreview));

View File

@ -49,6 +49,13 @@
margin: 0; margin: 0;
padding: 0.125rem 0.25rem 0.125rem 0.625rem; padding: 0.125rem 0.25rem 0.125rem 0.625rem;
transition: background-color 0.2s ease-in; transition: background-color 0.2s ease-in;
&.interactive {
cursor: var(--custom-cursor, pointer);
&:active {
background-color: var(--background-active-color);
}
}
} }
&::before { &::before {

View File

@ -56,6 +56,8 @@ type OwnProps = {
onAudioPlay?: NoneToVoidFunction; onAudioPlay?: NoneToVoidFunction;
onMediaClick?: NoneToVoidFunction; onMediaClick?: NoneToVoidFunction;
onCancelMediaTransfer?: NoneToVoidFunction; onCancelMediaTransfer?: NoneToVoidFunction;
onContainerClick?: ((e: React.MouseEvent) => void);
isEditing?: boolean;
}; };
const WebPage: FC<OwnProps> = ({ const WebPage: FC<OwnProps> = ({
@ -76,8 +78,10 @@ const WebPage: FC<OwnProps> = ({
shouldWarnAboutSvg, shouldWarnAboutSvg,
autoLoadFileMaxSizeMb, autoLoadFileMaxSizeMb,
onMediaClick, onMediaClick,
onContainerClick,
onAudioPlay, onAudioPlay,
onCancelMediaTransfer, onCancelMediaTransfer,
isEditing,
}) => { }) => {
const { openTelegramLink } = getActions(); const { openTelegramLink } = getActions();
const webPage = getMessageWebPage(message); const webPage = getMessageWebPage(message);
@ -92,6 +96,9 @@ const WebPage: FC<OwnProps> = ({
const handleMediaClick = useLastCallback(() => { const handleMediaClick = useLastCallback(() => {
onMediaClick!(); onMediaClick!();
}); });
const handleContainerClick = useLastCallback((e: React.MouseEvent) => {
onContainerClick?.(e);
});
const handleQuickButtonClick = useLastCallback(() => { const handleQuickButtonClick = useLastCallback(() => {
if (!webPage) return; if (!webPage) return;
@ -138,6 +145,7 @@ const WebPage: FC<OwnProps> = ({
const className = buildClassName( const className = buildClassName(
'WebPage', 'WebPage',
inPreview && 'in-preview', inPreview && 'in-preview',
!isEditing && inPreview && 'interactive',
isSquarePhoto && 'with-square-photo', isSquarePhoto && 'with-square-photo',
!photo && !video && !inPreview && 'without-media', !photo && !video && !inPreview && 'without-media',
video && 'with-video', video && 'with-video',
@ -166,6 +174,7 @@ const WebPage: FC<OwnProps> = ({
className={className} className={className}
data-initial={(siteName || displayUrl)[0]} data-initial={(siteName || displayUrl)[0]}
dir={lang.isRtl ? 'rtl' : 'auto'} dir={lang.isRtl ? 'rtl' : 'auto'}
onClick={handleContainerClick}
> >
<div className={buildClassName('WebPage--content', isStory && 'is-story')}> <div className={buildClassName('WebPage--content', isStory && 'is-story')}>
{backgroundEmojiId && ( {backgroundEmojiId && (