From cdd11eee47bbbf0b12d893b40fb179a9eb127551 Mon Sep 17 00:00:00 2001 From: audric Date: Fri, 27 Aug 2021 16:57:29 +1000 Subject: [PATCH] speed up attachment loading by only loading those inview --- ts/components/conversation/Image.tsx | 22 ++++++-- ts/components/conversation/ImageGrid.tsx | 39 ++++++------- .../conversation/ReadableMessage.tsx | 27 ++++++--- .../message/MessageAttachment.tsx | 2 +- ts/hooks/useEncryptedFileFetch.ts | 4 +- .../crypto/DecryptedAttachmentsManager.ts | 56 +++++++++++++------ 6 files changed, 96 insertions(+), 54 deletions(-) diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index cdba596bf..bed54e179 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -8,7 +8,7 @@ import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch'; type Props = { alt: string; attachment: AttachmentTypeWithPath | AttachmentType; - url: string; + url: string | undefined; // url is undefined if the message is not visible yet height?: number; width?: number; @@ -51,12 +51,22 @@ export const Image = (props: Props) => { return false; }, []); - const { caption, pending } = attachment || { caption: null, pending: true }; + const onErrorUrlFilterering = useCallback(() => { + if (url && onError) { + onError(); + } + return; + }, [url, onError]); + + const { caption } = attachment || { caption: null }; + let { pending } = attachment || { pending: true }; + if (!url) { + // force pending to true if the url is undefined, so we show a loader while decrypting the attachemtn + pending = true; + } const canClick = onClick && !pending; const role = canClick ? 'button' : undefined; - - const { loading, urlToLoad } = useEncryptedFileFetch(url, attachment.contentType); - + const { loading, urlToLoad } = useEncryptedFileFetch(url || '', attachment.contentType); // data will be url if loading is finished and '' if not const srcData = !loading ? urlToLoad : ''; @@ -89,7 +99,7 @@ export const Image = (props: Props) => { ) : ( {alt}; bottomOverlay?: boolean; - onError: () => void; onClickAttachment?: (attachment: AttachmentTypeWithPath | AttachmentType) => void; }; - +// tslint:disable: cyclomatic-complexity max-func-body-length use-simple-attributes export const ImageGrid = (props: Props) => { - // tslint:disable-next-line max-func-body-length */ const { attachments, bottomOverlay, onError, onClickAttachment } = props; + const isMessageVisible = useContext(IsMessageVisibleContext); + const withBottomOverlay = Boolean(bottomOverlay); if (!attachments || !attachments.length) { @@ -43,7 +44,7 @@ export const ImageGrid = (props: Props) => { playIconOverlay={isVideoAttachment(attachments[0])} height={height} width={width} - url={getThumbnailUrl(attachments[0])} + url={isMessageVisible ? getThumbnailUrl(attachments[0]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -61,7 +62,7 @@ export const ImageGrid = (props: Props) => { playIconOverlay={isVideoAttachment(attachments[0])} height={149} width={149} - url={getThumbnailUrl(attachments[0])} + url={isMessageVisible ? getThumbnailUrl(attachments[0]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -72,7 +73,7 @@ export const ImageGrid = (props: Props) => { height={149} width={149} attachment={attachments[1]} - url={getThumbnailUrl(attachments[1])} + url={isMessageVisible ? getThumbnailUrl(attachments[1]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -90,7 +91,7 @@ export const ImageGrid = (props: Props) => { playIconOverlay={isVideoAttachment(attachments[0])} height={200} width={199} - url={getThumbnailUrl(attachments[0])} + url={isMessageVisible ? getThumbnailUrl(attachments[0]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -101,7 +102,7 @@ export const ImageGrid = (props: Props) => { width={99} attachment={attachments[1]} playIconOverlay={isVideoAttachment(attachments[1])} - url={getThumbnailUrl(attachments[1])} + url={isMessageVisible ? getThumbnailUrl(attachments[1]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -112,7 +113,7 @@ export const ImageGrid = (props: Props) => { width={99} attachment={attachments[2]} playIconOverlay={isVideoAttachment(attachments[2])} - url={getThumbnailUrl(attachments[2])} + url={isMessageVisible ? getThumbnailUrl(attachments[2]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -132,7 +133,7 @@ export const ImageGrid = (props: Props) => { playIconOverlay={isVideoAttachment(attachments[0])} height={149} width={149} - url={getThumbnailUrl(attachments[0])} + url={isMessageVisible ? getThumbnailUrl(attachments[0]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -142,7 +143,7 @@ export const ImageGrid = (props: Props) => { height={149} width={149} attachment={attachments[1]} - url={getThumbnailUrl(attachments[1])} + url={isMessageVisible ? getThumbnailUrl(attachments[1]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -155,7 +156,7 @@ export const ImageGrid = (props: Props) => { height={149} width={149} attachment={attachments[2]} - url={getThumbnailUrl(attachments[2])} + url={isMessageVisible ? getThumbnailUrl(attachments[2]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -166,7 +167,7 @@ export const ImageGrid = (props: Props) => { height={149} width={149} attachment={attachments[3]} - url={getThumbnailUrl(attachments[3])} + url={isMessageVisible ? getThumbnailUrl(attachments[3]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -189,7 +190,7 @@ export const ImageGrid = (props: Props) => { playIconOverlay={isVideoAttachment(attachments[0])} height={149} width={149} - url={getThumbnailUrl(attachments[0])} + url={isMessageVisible ? getThumbnailUrl(attachments[0]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -199,7 +200,7 @@ export const ImageGrid = (props: Props) => { height={149} width={149} attachment={attachments[1]} - url={getThumbnailUrl(attachments[1])} + url={isMessageVisible ? getThumbnailUrl(attachments[1]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -212,7 +213,7 @@ export const ImageGrid = (props: Props) => { height={99} width={99} attachment={attachments[2]} - url={getThumbnailUrl(attachments[2])} + url={isMessageVisible ? getThumbnailUrl(attachments[2]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -223,7 +224,7 @@ export const ImageGrid = (props: Props) => { height={99} width={98} attachment={attachments[3]} - url={getThumbnailUrl(attachments[3])} + url={isMessageVisible ? getThumbnailUrl(attachments[3]) : undefined} onClick={onClickAttachment} onError={onError} /> @@ -236,7 +237,7 @@ export const ImageGrid = (props: Props) => { darkOverlay={moreMessagesOverlay} overlayText={moreMessagesOverlayText} attachment={attachments[4]} - url={getThumbnailUrl(attachments[4])} + url={isMessageVisible ? getThumbnailUrl(attachments[4]) : undefined} onClick={onClickAttachment} onError={onError} /> diff --git a/ts/components/conversation/ReadableMessage.tsx b/ts/components/conversation/ReadableMessage.tsx index 923a62336..0b8ae66d4 100644 --- a/ts/components/conversation/ReadableMessage.tsx +++ b/ts/components/conversation/ReadableMessage.tsx @@ -1,5 +1,5 @@ import _, { noop } from 'lodash'; -import React, { useCallback, useState } from 'react'; +import React, { createContext, useCallback, useState } from 'react'; import { InView } from 'react-intersection-observer'; import { useDispatch, useSelector } from 'react-redux'; import { getMessageById } from '../../data/data'; @@ -42,6 +42,8 @@ const debouncedTriggerLoadMore = _.debounce( 100 ); +export const IsMessageVisibleContext = createContext(false); + export const ReadableMessage = (props: ReadableMessageProps) => { const { messageId, onContextMenu, className, receivedAt, isUnread } = props; @@ -57,7 +59,10 @@ export const ReadableMessage = (props: ReadableMessageProps) => { const fetchingMore = useSelector(areMoreMessagesBeingFetched); const shouldMarkReadWhenVisible = isUnread; + const [isMessageVisible, setMessageIsVisible] = useState(false); + const onVisible = useCallback( + // tslint:disable-next-line: cyclomatic-complexity async (inView: boolean | Object) => { // when the view first loads, it needs to scroll to the unread messages. // we need to disable the inview on the first loading @@ -91,15 +96,19 @@ export const ReadableMessage = (props: ReadableMessageProps) => { if ( (inView === true || ((inView as any).type === 'focus' && (inView as any).returnValue === true)) && - shouldMarkReadWhenVisible && isAppFocused ) { - const found = await getMessageById(messageId); + if (isMessageVisible !== true) { + setMessageIsVisible(true); + } + if (shouldMarkReadWhenVisible) { + const found = await getMessageById(messageId); - if (found && Boolean(found.get('unread'))) { - // mark the message as read. - // this will trigger the expire timer. - await found.markRead(Date.now()); + if (found && Boolean(found.get('unread'))) { + // mark the message as read. + // this will trigger the expire timer. + await found.markRead(Date.now()); + } } } }, @@ -131,7 +140,9 @@ export const ReadableMessage = (props: ReadableMessageProps) => { triggerOnce={false} trackVisibility={true} > - {props.children} + + {props.children} + ); }; diff --git a/ts/components/conversation/message/MessageAttachment.tsx b/ts/components/conversation/message/MessageAttachment.tsx index dddda729a..1291d8b4c 100644 --- a/ts/components/conversation/message/MessageAttachment.tsx +++ b/ts/components/conversation/message/MessageAttachment.tsx @@ -49,6 +49,7 @@ type Props = { imageBroken: boolean; handleImageError: () => void; }; +// tslint:disable: use-simple-attributes // tslint:disable-next-line max-func-body-length cyclomatic-complexity export const MessageAttachment = (props: Props) => { @@ -57,7 +58,6 @@ export const MessageAttachment = (props: Props) => { const dispatch = useDispatch(); const attachmentProps = useSelector(state => getMessageAttachmentProps(state as any, messageId)); const multiSelectMode = useSelector(isMessageSelectionMode); - const onClickOnImageGrid = useCallback( (attachment: AttachmentTypeWithPath | AttachmentType) => { if (multiSelectMode) { diff --git a/ts/hooks/useEncryptedFileFetch.ts b/ts/hooks/useEncryptedFileFetch.ts index e1fb5988b..0742525a4 100644 --- a/ts/hooks/useEncryptedFileFetch.ts +++ b/ts/hooks/useEncryptedFileFetch.ts @@ -11,10 +11,10 @@ export const useEncryptedFileFetch = (url: string, contentType: string) => { const mountedRef = useRef(true); async function fetchUrl() { - perfStart(`getDecryptedMediaUrl${url}`); + perfStart(`getDecryptedMediaUrl-${url}`); const decryptedUrl = await getDecryptedMediaUrl(url, contentType); - perfEnd(`getDecryptedMediaUrl${url}`, 'getDecryptedMediaUrl'); + perfEnd(`getDecryptedMediaUrl-${url}`, `getDecryptedMediaUrl-${url}`); if (mountedRef.current) { setUrlToLoad(decryptedUrl); diff --git a/ts/session/crypto/DecryptedAttachmentsManager.ts b/ts/session/crypto/DecryptedAttachmentsManager.ts index c0725f502..833a471c5 100644 --- a/ts/session/crypto/DecryptedAttachmentsManager.ts +++ b/ts/session/crypto/DecryptedAttachmentsManager.ts @@ -15,6 +15,7 @@ import { DURATION } from '../constants'; // add a way to remove the blob when the attachment file path is removed (message removed?) // do not hardcode the password const urlToDecryptedBlobMap = new Map(); +const urlToDecryptingPromise = new Map>(); export const cleanUpOldDecryptedMedias = () => { const currentTimestamp = Date.now(); @@ -31,6 +32,8 @@ export const cleanUpOldDecryptedMedias = () => { countKept++; } } + urlToDecryptedBlobMap.clear(); + urlToDecryptingPromise.clear(); window?.log?.info(`Clean medias blobs: cleaned/kept: ${countCleaned}:${countKept}`); }; @@ -59,25 +62,42 @@ export const getDecryptedMediaUrl = async (url: string, contentType: string): Pr return existingObjUrl; } else { - const encryptedFileContent = await fse.readFile(url); - const decryptedContent = await decryptAttachmentBuffer(toArrayBuffer(encryptedFileContent)); - if (decryptedContent?.length) { - const arrayBuffer = decryptedContent.buffer; - const { makeObjectUrl } = window.Signal.Types.VisualAttachment; - const obj = makeObjectUrl(arrayBuffer, contentType); - - if (!urlToDecryptedBlobMap.has(url)) { - urlToDecryptedBlobMap.set(url, { - decrypted: obj, - lastAccessTimestamp: Date.now(), - }); - } - return obj; - } else { - // failed to decrypt, fallback to url image loading - // it might be a media we received before the update encrypting attachments locally. - return url; + if (urlToDecryptingPromise.has(url)) { + return urlToDecryptingPromise.get(url) as Promise; } + + urlToDecryptingPromise.set( + url, + new Promise(async resolve => { + const encryptedFileContent = await fse.readFile(url); + const decryptedContent = await decryptAttachmentBuffer( + toArrayBuffer(encryptedFileContent) + ); + if (decryptedContent?.length) { + const arrayBuffer = decryptedContent.buffer; + const { makeObjectUrl } = window.Signal.Types.VisualAttachment; + const obj = makeObjectUrl(arrayBuffer, contentType); + + if (!urlToDecryptedBlobMap.has(url)) { + urlToDecryptedBlobMap.set(url, { + decrypted: obj, + lastAccessTimestamp: Date.now(), + }); + } + urlToDecryptingPromise.delete(url); + resolve(obj); + return; + } else { + // failed to decrypt, fallback to url image loading + // it might be a media we received before the update encrypting attachments locally. + urlToDecryptingPromise.delete(url); + resolve(url); + return; + } + }) + ); + + return urlToDecryptingPromise.get(url) as Promise; } } else { // Not sure what we got here. Just return the file.