From 3df95adc52706110fbb85cc024a0c001c76b8d60 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 18 Jun 2026 17:56:34 -0400 Subject: [PATCH] perf(media): defer image/video decryption until near-viewport (P5-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates useNearViewport hook (IntersectionObserver, 200px rootMargin, one-shot disconnect after first trigger). ImageContent and VideoContent now gate loadSrc() on nearViewport — when autoPlay is enabled, encrypted media is not decrypted until the element is within 200px of the visible area, reducing initial page load cost on long timelines. Co-Authored-By: Claude Sonnet 4.6 --- LOTUS_TODO.md | 2 +- .../message/content/ImageContent.tsx | 11 +++++-- .../message/content/VideoContent.tsx | 11 +++++-- src/app/hooks/useNearViewport.ts | 30 +++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 src/app/hooks/useNearViewport.ts diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 79294a854..020c82cc9 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -278,7 +278,7 @@ Themes: --- -### [ ] P5-5 · Intersection-Based Lazy Loading +### [x] P5-5 · Intersection-Based Lazy Loading ⚠️ UNTESTED — needs verification in timeline with many images **What:** Use `IntersectionObserver` to trigger media decryption and loading only when components approach the viewport. **Approach:** Reduce initial memory footprint and improve timeline load times by deferring decryption of images/videos until they are visible. diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 575682b36..203d06abc 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useCallback, useEffect, useState } from 'react'; +import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { Badge, Box, @@ -31,6 +31,7 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { ModalWide } from '../../../styles/Modal.css'; import { validBlurHash } from '../../../utils/blurHash'; +import { useNearViewport } from '../../../hooks/useNearViewport'; type RenderViewerProps = { src: string; @@ -85,6 +86,9 @@ export const ImageContent = as<'div', ImageContentProps>( const [viewer, setViewer] = useState(false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); + const sentinelRef = useRef(null); + const nearViewport = useNearViewport(sentinelRef); + const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); @@ -113,11 +117,12 @@ export const ImageContent = as<'div', ImageContentProps>( }; useEffect(() => { - if (autoPlay) loadSrc().catch(() => undefined); - }, [autoPlay, loadSrc]); + if (autoPlay && nearViewport) loadSrc().catch(() => undefined); + }, [autoPlay, nearViewport, loadSrc]); return ( +
{srcState.status === AsyncStatus.Success && ( }> diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index e63625087..bdd057366 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useCallback, useEffect, useState } from 'react'; +import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { Badge, Box, @@ -32,6 +32,7 @@ import { } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { validBlurHash } from '../../../utils/blurHash'; +import { useNearViewport } from '../../../hooks/useNearViewport'; type RenderVideoProps = { title: string; @@ -79,6 +80,9 @@ export const VideoContent = as<'div', VideoContentProps>( const [error, setError] = useState(false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); + const sentinelRef = useRef(null); + const nearViewport = useNearViewport(sentinelRef); + const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); @@ -106,11 +110,12 @@ export const VideoContent = as<'div', VideoContentProps>( }; useEffect(() => { - if (autoPlay) loadSrc().catch(() => undefined); - }, [autoPlay, loadSrc]); + if (autoPlay && nearViewport) loadSrc().catch(() => undefined); + }, [autoPlay, nearViewport, loadSrc]); return ( +
{typeof blurHash === 'string' && !load && ( , margin = 200): boolean { + const [triggered, setTriggered] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || triggered) return undefined; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setTriggered(true); + observer.disconnect(); + } + }, + { rootMargin: `${margin}px` }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [ref, margin, triggered]); + + return triggered; +}