diff --git a/ts/components/conversation/SessionStagedLinkPreview.tsx b/ts/components/conversation/SessionStagedLinkPreview.tsx index f303152a6..9ce9a69dd 100644 --- a/ts/components/conversation/SessionStagedLinkPreview.tsx +++ b/ts/components/conversation/SessionStagedLinkPreview.tsx @@ -1,12 +1,11 @@ -import { AbortSignal } from 'abort-controller'; -import { useEffect, useMemo } from 'react'; +import AbortController, { AbortSignal } from 'abort-controller'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; - +import { isUndefined } from 'lodash'; import type { RequestInit, Response } from 'node-fetch'; -import { StagedLinkPreviewData } from './composition/CompositionBox'; - +import useUpdate from 'react-use/lib/useUpdate'; +import useUnmount from 'react-use/lib/useUnmount'; import { Image } from './Image'; - import { isImage } from '../../types/MIME'; import { Flex } from '../basic/Flex'; import { SessionSpinner } from '../loading'; @@ -14,12 +13,15 @@ import { AriaLabels } from '../../util/hardcodedAriaLabels'; import { SessionLucideIconButton } from '../icon/SessionIconButton'; import { LUCIDE_ICONS_UNICODE } from '../icon/lucide'; import { tr } from '../../localization/localeTools'; - import { LinkPreviewUtil } from '../../util'; import { fetchLinkPreviewImage } from '../../util/linkPreviewFetch'; import { LinkPreviews } from '../../util/linkPreviews'; import { maxThumbnailDetails } from '../../util/attachment/attachmentSizes'; import { ImageProcessor } from '../../webworker/workers/browser/image_processor_interface'; +import { DURATION } from '../../session/constants'; +import { isDevProd } from '../../shared/env_vars'; +import { useHasLinkPreviewEnabled } from '../../state/selectors/settings'; +import { StagedLinkPreviewData } from './composition/CompositionBox'; import { FetchDestination, insecureNodeFetch } from '../../session/utils/InsecureNodeFetch'; function insecureDirectNodeFetch(href: string, init: RequestInit): Promise { @@ -31,15 +33,12 @@ function insecureDirectNodeFetch(href: string, init: RequestInit): Promise void; -} -export const LINK_PREVIEW_TIMEOUT = 20 * 1000; +export const LINK_PREVIEW_TIMEOUT = 20 * DURATION.SECONDS; export const getPreview = async (url: string, abortSignal: AbortSignal) => { // This is already checked elsewhere, but we want to be extra-careful. if (!LinkPreviews.isLinkSafeToPreview(url)) { - throw new Error('Link not safe for preview'); + throw new Error(`Link not safe for preview ${isDevProd() ? url : ''}`); } window?.log?.info('insecureNodeFetch => plaintext for getPreview()'); @@ -50,7 +49,7 @@ export const getPreview = async (url: string, abortSignal: AbortSignal) => { abortSignal ); if (!linkPreviewMetadata) { - throw new Error('Could not fetch link preview metadata'); + throw new Error(`Could not fetch link preview metadata ${isDevProd() ? url : ''}`); } const { title, imageHref } = linkPreviewMetadata; @@ -92,21 +91,181 @@ export const getPreview = async (url: string, abortSignal: AbortSignal) => { }; }; -export const SessionStagedLinkPreview = (props: StagedLinkPreviewProps) => { - if (!props.url) { - return null; +type SessionStagedLinkPreviewProps = { + draft: string; + setStagedLinkPreview: (linkPreview: StagedLinkPreviewData) => void; +}; + +export const SessionStagedLinkPreview = ({ + draft, + setStagedLinkPreview, +}: SessionStagedLinkPreviewProps) => { + const enabled = useHasLinkPreviewEnabled(); + return enabled ? ( + + ) : null; +}; + +type PreviewFetchResult = { + data: StagedLinkPreviewData | null; + cacheData: boolean; +}; + +class PreviewFetch { + readonly link: string; + readonly abortController: AbortController; + readonly timeoutId: ReturnType; + + constructor(link: string) { + this.link = link; + this.abortController = new AbortController(); + this.timeoutId = setTimeout(() => { + this.cleanup(); + }, LINK_PREVIEW_TIMEOUT); } - return ( - + cleanup() { + if (!this.abortController.signal.aborted) { + this.abortController.abort(); + } + clearTimeout(this.timeoutId); + } + + async fetch(): Promise { + try { + const ret = await getPreview(this.link, this.abortController.signal); + if (this.abortController.signal.aborted) { + return { data: null, cacheData: false }; + } + // we finished loading the preview, and checking the abortController, we are still not aborted. + // => update the staged preview + if (ret) { + return { + data: { + title: ret.title || null, + url: ret.url || null, + domain: (ret.url && LinkPreviews.getDomain(ret.url)) || '', + scaledDown: ret.scaledDown, + }, + cacheData: true, + }; + } + } catch (e) { + window?.log?.error(e); + if (this.abortController.signal.aborted) { + return { data: null, cacheData: false }; + } + } + return { data: null, cacheData: true }; + } +} + +function useDebouncedIsLoading(isLoading: boolean, delay: number): boolean { + const [debouncedIsLoading, setDebouncedIsLoading] = useState(isLoading); + + useEffect(() => { + if (!isLoading) { + setDebouncedIsLoading(false); + return () => undefined; + } + + const timer = setTimeout(() => setDebouncedIsLoading(isLoading), delay); + return () => clearTimeout(timer); + }, [isLoading, delay]); + + return debouncedIsLoading; +} + +const previews = new Map(); + +const SessionStagedLinkPreviewComp = ({ + draft, + setStagedLinkPreview, +}: SessionStagedLinkPreviewProps) => { + const [hiddenLink, setHiddenLink] = useState(null); + const forceUpdate = useUpdate(); + + const firstLink = useMemo(() => { + // we try to match the first link found in the current message + const links = LinkPreviews.findLinks(draft, undefined); + return links[0]; + }, [draft]); + + const onClose = useCallback(() => { + setHiddenLink(firstLink); + forceUpdate(); + }, [forceUpdate, firstLink]); + + const previewFetch = useRef(null); + + const handleFetchResult = useCallback( + async (previewFetchInstance: PreviewFetch) => { + const result = await previewFetchInstance.fetch(); + if (result.cacheData) { + previews.set(previewFetchInstance.link, result.data); + } + // Forces the UI to refresh in case the result changes the data state + forceUpdate(); + }, + [forceUpdate] + ); + + const handleFetchLinkPreview = useCallback( + (link: string) => { + if (previews.has(link)) { + return; + } + + if (!LinkPreviews.isLinkSafeToPreview(link)) { + return; + } + + if (previewFetch.current) { + previewFetch.current.cleanup(); + } + + previewFetch.current = new PreviewFetch(link); + // Forces the UI to enter the loading state in case it doesnt do that by itself + forceUpdate(); + void handleFetchResult(previewFetch.current); + }, + [forceUpdate, handleFetchResult] + ); + + useEffect(() => { + if (firstLink) { + handleFetchLinkPreview(firstLink); + } + }, [firstLink, handleFetchLinkPreview]); + + const data = previews.get(firstLink); + + useEffect(() => { + if (data) { + setStagedLinkPreview(data); + } + }, [data, setStagedLinkPreview]); + + const isLoading = !!( + isUndefined(data) && + previewFetch.current && + previewFetch.current.link === firstLink && + !previewFetch.current.abortController.signal.aborted ); + + const debouncedIsLoading = useDebouncedIsLoading(isLoading, DURATION.SECONDS); + + useUnmount(() => { + if (previewFetch.current) { + previewFetch.current.cleanup(); + } + }); + + if (firstLink === hiddenLink) { + return null; + } + + return ; }; // Note Similar to QuotedMessageComposition @@ -135,23 +294,20 @@ const StyledText = styled(Flex)` margin: 0 0 0 var(--margins-sm); `; -const StagedLinkPreview = ({ - isLoaded, - onClose, - title, - domain, - url, - scaledDown, -}: StagedLinkPreviewProps) => { - const isContentTypeImage = scaledDown && isImage(scaledDown.contentType); +type StagedLinkPreviewProps = { + isLoading: boolean; + data: StagedLinkPreviewData | null | undefined; + onClose: () => void; +}; +const StagedLinkPreview = ({ isLoading, data, onClose }: StagedLinkPreviewProps) => { const blobUrl = useMemo(() => { - if (!scaledDown) { - return undefined; + if (!data?.scaledDown) { + return null; } - const blob = new Blob([scaledDown.outputBuffer], { type: scaledDown.contentType }); + const blob = new Blob([data.scaledDown.outputBuffer], { type: data.scaledDown.contentType }); return URL.createObjectURL(blob); - }, [scaledDown]); + }, [data?.scaledDown]); useEffect(() => { return () => { @@ -161,12 +317,11 @@ const StagedLinkPreview = ({ }; }, [blobUrl]); - if (isLoaded && !(title && domain)) { + if (!data && !isLoading) { return null; } - const isLoading = !isLoaded; - + const isContentTypeImage = data?.scaledDown && isImage(data.scaledDown.contentType); return ( ) : null} - {isLoaded && isContentTypeImage ? ( + {!isLoading && isContentTypeImage && blobUrl ? ( {AriaLabels.imageStagedLinkPreview} ) : null} - {isLoaded ? {title} : null} + {!isLoading && data?.title ? ( + {data.title} + ) : null} { - onClose(url || ''); - }} + onClick={onClose} margin={'0 var(--margin-close-button-composition-box) 0 0'} // we want this aligned with the send button aria-label={tr('close')} dataTestId="link-preview-close" diff --git a/ts/components/conversation/composition/CompositionBox.tsx b/ts/components/conversation/composition/CompositionBox.tsx index 6d335bfa7..89889485a 100644 --- a/ts/components/conversation/composition/CompositionBox.tsx +++ b/ts/components/conversation/composition/CompositionBox.tsx @@ -33,17 +33,12 @@ import { StagedAttachmentImportedType, StagedPreviewImportedType, } from '../../../util/attachment/attachmentsUtil'; -import { LinkPreviews } from '../../../util/linkPreviews'; import { CaptionEditor } from '../../CaptionEditor'; import { Flex } from '../../basic/Flex'; import { getMediaPermissionsSettings } from '../../settings/SessionSettings'; import { getDraftForConversation, updateDraftForConversation } from '../SessionConversationDrafts'; import { SessionQuotedMessageComposition } from '../SessionQuotedMessageComposition'; -import { - getPreview, - LINK_PREVIEW_TIMEOUT, - SessionStagedLinkPreview, -} from '../SessionStagedLinkPreview'; +import { SessionStagedLinkPreview } from '../SessionStagedLinkPreview'; import { StagedAttachmentList } from '../StagedAttachmentList'; import { AddStagedAttachmentButton, @@ -76,13 +71,12 @@ export interface ReplyingToMessageProps { attachments?: Array; } -export interface StagedLinkPreviewData { - isLoaded: boolean; +export type StagedLinkPreviewData = { title: string | null; url: string | null; domain: string | null; scaledDown: ProcessedLinkPreviewThumbnailType | null; -} +}; export type StagedAttachmentType = AttachmentType & { file: File; @@ -270,8 +264,8 @@ class CompositionBoxInner extends Component { } public render() { - const { showRecordingView } = this.state; - const { typingEnabled, isBlocked } = this.props; + const { showRecordingView, draft } = this.state; + const { typingEnabled, isBlocked, stagedAttachments, quotedMessageProps } = this.props; // we completely hide the composition box when typing is not enabled now. // Actually not anymore. We want the above, except when we can't write because that user is blocked. @@ -283,7 +277,15 @@ class CompositionBoxInner extends Component { return ( - {this.renderStagedLinkPreview()} + { + // Don't render link previews if quoted message or attachments are already added + stagedAttachments.length !== 0 || quotedMessageProps?.id ? null : ( + + ) + } {this.renderAttachmentsStaged()}
{showRecordingView ? this.renderRecordingView() : this.renderCompositionView()} @@ -322,7 +324,7 @@ class CompositionBoxInner extends Component { imgBlob = item.getAsFile(); break; case 'text': - void showLinkSharingConfirmationModalDialog(e); + void showLinkSharingConfirmationModalDialog(e.clipboardData.getData('text')); break; default: } @@ -361,6 +363,10 @@ class CompositionBoxInner extends Component { this.setState({ draft, characterCount: this.getSendableText().length }); } + private setStagedLinkPreview(linkPreview: StagedLinkPreviewData) { + this.setState({ stagedLinkPreview: linkPreview }); + } + private toggleEmojiPanel() { if (this.state.showEmojiPanel) { this.hideEmojiPanel(); @@ -455,133 +461,6 @@ class CompositionBoxInner extends Component { ); } - private renderStagedLinkPreview(): JSX.Element | null { - // Don't generate link previews if user has turned them off - if (!(window.getSettingValue(SettingsKey.settingsLinkPreview) || false)) { - return null; - } - - const { stagedAttachments, quotedMessageProps } = this.props; - const { ignoredLink } = this.state; - - // Don't render link previews if quoted message or attachments are already added - if (stagedAttachments.length !== 0 || quotedMessageProps?.id) { - return null; - } - // we try to match the first link found in the current message - const links = LinkPreviews.findLinks(this.state.draft, undefined); - if (!links || links.length === 0 || ignoredLink === links[0]) { - if (this.state.stagedLinkPreview) { - this.setState({ - stagedLinkPreview: undefined, - }); - } - return null; - } - const firstLink = links[0]; - // if the first link changed, reset the ignored link so that the preview is generated - if (ignoredLink && ignoredLink !== firstLink) { - this.setState({ ignoredLink: undefined }); - } - if (firstLink !== this.state.stagedLinkPreview?.url) { - // trigger fetching of link preview data and image - this.fetchLinkPreview(firstLink); - } - - // if the fetch did not start yet, just don't show anything - if (!this.state.stagedLinkPreview) { - return null; - } - - const { isLoaded, title, domain, scaledDown } = this.state.stagedLinkPreview; - - return ( - { - this.setState({ ignoredLink: url }); - }} - /> - ); - } - - private fetchLinkPreview(firstLink: string) { - // mark the link preview as loading, no data are set yet - this.setState({ - stagedLinkPreview: { - isLoaded: false, - url: firstLink, - domain: null, - title: null, - scaledDown: null, - }, - }); - const abortController = new AbortController(); - this.linkPreviewAbortController?.abort(); - this.linkPreviewAbortController = abortController; - setTimeout(() => { - abortController.abort(); - }, LINK_PREVIEW_TIMEOUT); - - // eslint-disable-next-line more/no-then - getPreview(firstLink, abortController.signal) - .then(ret => { - // we finished loading the preview, and checking the abortController, we are still not aborted. - // => update the staged preview - if (this.linkPreviewAbortController && !this.linkPreviewAbortController.signal.aborted) { - this.setState({ - stagedLinkPreview: { - isLoaded: true, - title: ret?.title || null, - url: ret?.url || null, - domain: (ret?.url && LinkPreviews.getDomain(ret.url)) || '', - scaledDown: ret?.scaledDown, - }, - }); - } else if (this.linkPreviewAbortController) { - this.setState({ - stagedLinkPreview: { - isLoaded: false, - title: null, - url: null, - domain: null, - scaledDown: null, - }, - }); - this.linkPreviewAbortController = undefined; - } - }) - .catch(err => { - window?.log?.warn('fetch link preview: ', err); - const aborted = this.linkPreviewAbortController?.signal.aborted; - this.linkPreviewAbortController = undefined; - // if we were aborted, it either means the UI was unmount, or more probably, - // than the message was sent without the link preview. - // So be sure to reset the staged link preview so it is not sent with the next message. - - // if we were not aborted, it's probably just an error on the fetch. Nothing to do except mark the fetch as done (with errors) - - if (aborted) { - this.setState({ - stagedLinkPreview: undefined, - }); - } else { - this.setState({ - stagedLinkPreview: { - isLoaded: true, - title: null, - url: firstLink, - domain: null, - scaledDown: null, - }, - }); - } - }); - } private onClickAttachment(attachment: AttachmentType) { this.setState({ showCaptionEditor: attachment }); @@ -796,7 +675,7 @@ class CompositionBoxInner extends Component { // we consider that a link preview without a title at least is not a preview const linkPreview = - stagedLinkPreview?.isLoaded && stagedLinkPreview.title?.length + stagedLinkPreview && stagedLinkPreview.title?.length ? _.pick(stagedLinkPreview, 'url', 'scaledDown', 'title') : undefined; diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index abeca279b..7fb27e993 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -539,11 +539,9 @@ export async function resendMessage(messageId: string) { /** * Check if what is pasted is a URL and prompt confirmation for a setting change - * @param e paste event */ -export async function showLinkSharingConfirmationModalDialog(e: any) { - const pastedText = e.clipboardData.getData('text'); - if (isURL(pastedText) && !window.getSettingValue(SettingsKey.settingsLinkPreview, false)) { +export async function showLinkSharingConfirmationModalDialog(link: string) { + if (isURL(link) && !window.getSettingValue(SettingsKey.settingsLinkPreview, false)) { const alreadyDisplayedPopup = (await Data.getItemById(SettingsKey.hasLinkPreviewPopupBeenDisplayed))?.value || false; if (!alreadyDisplayedPopup) {