2024-09-06 15:43:12 +02:00

254 lines
7.8 KiB
TypeScript

import type { FC } 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';
import { RE_LINK_TEMPLATE } from '../../../config';
import { selectNoWebPage, selectTabState, selectTheme } from '../../../global/selectors';
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 useShowTransitionDeprecated from '../../../hooks/useShowTransitionDeprecated';
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';
type OwnProps = {
chatId: string;
threadId: ThreadId;
getHtml: Signal<string>;
isEditing: boolean;
isDisabled?: boolean;
};
type StateProps = {
webPagePreview?: ApiWebPage;
noWebPage?: boolean;
theme: ISettings['theme'];
attachmentSettings: GlobalState['attachmentSettings'];
};
const DEBOUNCE_MS = 300;
const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i');
const WebPagePreview: FC<OwnProps & StateProps> = ({
chatId,
threadId,
getHtml,
isDisabled,
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 => (
entity.type === ApiMessageEntityTypes.TextUrl
));
formattedTextWithLinkRef.current = formattedText;
return linkEntity?.url || formattedText.text.match(RE_LINK)?.[0];
}, [getHtml], DEBOUNCE_MS, true);
const getLink = useDerivedSignal(detectLinkDebounced, [detectLinkDebounced, getHtml], true);
useEffect(() => {
const link = getLink();
const formattedText = formattedTextWithLinkRef.current;
if (link) {
loadWebPagePreview({ text: formattedText! });
} else {
clearWebPagePreview();
toggleMessageWebPage({ chatId, threadId });
}
}, [getLink, chatId, threadId]);
useSyncEffect(() => {
clearWebPagePreview();
toggleMessageWebPage({ chatId, threadId });
}, [chatId, clearWebPagePreview, threadId, toggleMessageWebPage]);
const isShown = useDerivedState(() => {
return Boolean(webPagePreview && getHtml() && !noWebPage && !isDisabled);
}, [isDisabled, getHtml, noWebPage, webPagePreview]);
const { shouldRender, transitionClassNames } = useShowTransitionDeprecated(isShown);
const renderingWebPage = useCurrentOrPrev(webPagePreview, true);
const handleClearWebpagePreview = useLastCallback(() => {
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;
}
// TODO Refactor so `WebPage` can be used without message
const { photo, ...webPageWithoutPhoto } = renderingWebPage;
const messageStub = {
content: {
webPage: webPageWithoutPhoto,
},
} 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)} ref={ref}>
<div className="WebPagePreview_inner">
<div className="WebPagePreview-left-icon" onClick={handlePreviewClick}>
<i className="icon icon-link" />
</div>
<WebPage
message={messageStub}
inPreview
theme={theme}
onContainerClick={handlePreviewClick}
isEditing={isEditing}
/>
<Button
className="WebPagePreview-clear"
round
faded
color="translucent"
ariaLabel="Clear Webpage Preview"
onClick={handleClearWebpagePreview}
>
<i className="icon icon-close" />
</Button>
{!isEditing && renderContextMenu()}
</div>
</div>
);
};
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));