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,
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<number, ApiMessage>;
quickReplies?: Record<number, ApiQuickReply>;
canSendQuickReplies?: boolean;
webPagePreview?: ApiWebPage;
noWebPage?: boolean;
};
enum MainButtonState {
@ -355,6 +359,8 @@ const Composer: FC<OwnProps & StateProps> = ({
quickReplies,
canSendQuickReplies,
onForward,
webPagePreview,
noWebPage,
}) => {
const {
sendMessage,
@ -377,6 +383,7 @@ const Composer: FC<OwnProps & StateProps> = ({
closeReactionPicker,
sendStoryReaction,
editMessage,
updateAttachmentSettings,
} = getActions();
const lang = useOldLang();
@ -466,8 +473,15 @@ const Composer: FC<OwnProps & StateProps> = ({
[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<OwnProps & StateProps> = ({
if (text) {
if (!checkSlowMode()) return;
const isInvertedMedia = hasWebPagePreview ? attachmentSettings.isInvertedMedia : undefined;
sendMessage({
messageList: currentMessageList,
text,
@ -1019,6 +1035,7 @@ const Composer: FC<OwnProps & StateProps> = ({
scheduledAt,
isSilent,
shouldUpdateStickerSetOrder,
isInvertedMedia,
});
}
@ -1684,6 +1701,7 @@ const Composer: FC<OwnProps & StateProps> = ({
threadId={threadId}
getHtml={getHtml}
isDisabled={!canAttachEmbedLinks || hasAttachments}
isEditing={Boolean(editingMessage)}
/>
</>
)}
@ -2048,6 +2066,8 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
quickReplyMessages: global.quickReplies.messagesById,
quickReplies: global.quickReplies.byId,
canSendQuickReplies,
noWebPage,
webPagePreview: selectTabState(global).webPagePreview,
};
},
)(Composer));

View File

@ -167,7 +167,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
(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<OwnProps & StateProps> = ({
useEffect(() => {
if (!isOpen) {
closeSymbolMenu();
updateAttachmentSettings({ isInvertedMedia: undefined });
}
}, [closeSymbolMenu, isOpen]);
@ -254,10 +255,19 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
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();

View File

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

View File

@ -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<string>;
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<OwnProps & StateProps> = ({
webPagePreview,
noWebPage,
theme,
attachmentSettings,
isEditing,
}) => {
const {
loadWebPagePreview,
clearWebPagePreview,
toggleMessageWebPage,
updateAttachmentSettings,
} = getActions();
const lang = useOldLang();
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 formattedText = parseHtmlAsFormattedText(getHtml());
const linkEntity = formattedText.entities?.find((entity): entity is ApiMessageEntityTextUrl => (
@ -101,6 +121,41 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
},
} 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 (
<div className={buildClassName('WebPagePreview', transitionClassNames)}>
<div className={buildClassName('WebPagePreview', transitionClassNames)} ref={ref}>
<div className="WebPagePreview_inner">
<div className="WebPagePreview-left-icon">
<div className="WebPagePreview-left-icon" onClick={handlePreviewClick}>
<i className="icon icon-link" />
</div>
<WebPage message={messageStub} inPreview theme={theme} />
<WebPage
message={messageStub}
inPreview
theme={theme}
onContainerClick={handlePreviewClick}
isEditing={isEditing}
/>
<Button
className="WebPagePreview-clear"
round
@ -130,6 +231,7 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
>
<i className="icon icon-close" />
</Button>
{!isEditing && renderContextMenu()}
</div>
</div>
);
@ -138,10 +240,14 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global, { chatId, threadId }): StateProps => {
const noWebPage = selectNoWebPage(global, chatId, threadId);
const {
attachmentSettings,
} = global;
return {
theme: selectTheme(global),
webPagePreview: selectTabState(global).webPagePreview,
noWebPage,
attachmentSettings,
};
},
)(WebPagePreview));

View File

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

View File

@ -56,6 +56,8 @@ type OwnProps = {
onAudioPlay?: NoneToVoidFunction;
onMediaClick?: NoneToVoidFunction;
onCancelMediaTransfer?: NoneToVoidFunction;
onContainerClick?: ((e: React.MouseEvent) => void);
isEditing?: boolean;
};
const WebPage: FC<OwnProps> = ({
@ -76,8 +78,10 @@ const WebPage: FC<OwnProps> = ({
shouldWarnAboutSvg,
autoLoadFileMaxSizeMb,
onMediaClick,
onContainerClick,
onAudioPlay,
onCancelMediaTransfer,
isEditing,
}) => {
const { openTelegramLink } = getActions();
const webPage = getMessageWebPage(message);
@ -92,6 +96,9 @@ const WebPage: FC<OwnProps> = ({
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<OwnProps> = ({
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<OwnProps> = ({
className={className}
data-initial={(siteName || displayUrl)[0]}
dir={lang.isRtl ? 'rtl' : 'auto'}
onClick={handleContainerClick}
>
<div className={buildClassName('WebPage--content', isStory && 'is-story')}>
{backgroundEmojiId && (