diff --git a/src/components/common/AnimatedSticker.tsx b/src/components/common/AnimatedSticker.tsx index d3b63eb7c..9911875d5 100644 --- a/src/components/common/AnimatedSticker.tsx +++ b/src/components/common/AnimatedSticker.tsx @@ -15,6 +15,8 @@ import usePriorityPlaybackCheck, { isPriorityPlaybackActive } from '../../hooks/ import useBackgroundMode, { isBackgroundModeActive } from '../../hooks/useBackgroundMode'; import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; import { useStateRef } from '../../hooks/useStateRef'; +import useSharedIntersectionObserver from '../../hooks/useSharedIntersectionObserver'; +import useThrottledCallback from '../../hooks/useThrottledCallback'; export type OwnProps = { ref?: RefObject; @@ -46,8 +48,8 @@ let RLottie: RLottieClass; // Time for the main interface to completely load const LOTTIE_LOAD_DELAY = 3000; +const THROTTLE_MS = 150; const ID_STORE = {}; -const ANIMATION_END_TIMEOUT = 500; async function ensureLottie() { if (!lottiePromise) { @@ -98,76 +100,73 @@ const AnimatedSticker: FC = ({ const playRef = useStateRef(play); const playSegmentRef = useStateRef(playSegment); - const isUnmountedRef = useRef(); + const isUnmountedRef = useRef(false); useEffect(() => { return () => { isUnmountedRef.current = true; }; }, []); + const init = useCallback(() => { + if ( + animationRef.current + || isUnmountedRef.current + || !tgsUrl + || (sharedCanvas && (!sharedCanvasCoords || !sharedCanvas.offsetWidth || !sharedCanvas.offsetHeight)) + ) { + return; + } + + const container = containerRef.current || sharedCanvas; + if (!container) { + return; + } + + const newAnimation = RLottie.init( + tgsUrl, + container, + renderId || generateIdFor(ID_STORE, true), + viewId, + { + noLoop, + size, + quality, + isLowPriority, + coords: sharedCanvasCoords, + }, + color, + onLoad, + onEnded, + onLoop, + ); + + if (speed) { + newAnimation.setSpeed(speed); + } + + setAnimation(newAnimation); + animationRef.current = newAnimation; + }, [ + color, isLowPriority, noLoop, onEnded, onLoad, onLoop, quality, + renderId, sharedCanvas, sharedCanvasCoords, size, speed, tgsUrl, viewId, + ]); + useEffect(() => { if (animation || !tgsUrl || (sharedCanvas && !sharedCanvasCoords)) { return; } - const exec = () => { - if (isUnmountedRef.current) { - return; - } - - const container = containerRef.current || sharedCanvas; - if (!container) { - return; - } - - // Wait until element is properly mounted - if (sharedCanvas && !sharedCanvas.offsetParent) { - // `requestMeasure` is useful as timeouts are run in parallel with image loadings and thus causing reflow - setTimeout(() => requestMeasure(exec), ANIMATION_END_TIMEOUT); - return; - } - - const newAnimation = RLottie.init( - tgsUrl, - container, - renderId || generateIdFor(ID_STORE, true), - viewId, - { - noLoop, - size, - quality, - isLowPriority, - coords: sharedCanvasCoords, - }, - color, - onLoad, - onEnded, - onLoop, - ); - - if (speed) { - newAnimation.setSpeed(speed); - } - - setAnimation(newAnimation); - animationRef.current = newAnimation; - }; - if (RLottie) { - exec(); + init(); } else { ensureLottie().then(() => { - requestMeasure(() => { - if (containerRef.current) { - exec(); - } - }); + requestMeasure(init); }); } - }, [ - animation, renderId, tgsUrl, color, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded, onLoop, viewId, - sharedCanvas, sharedCanvasCoords, - ]); + }, [animation, init, sharedCanvas, sharedCanvasCoords, tgsUrl]); + + const throttledInit = useThrottledCallback(init, [init], THROTTLE_MS); + useSharedIntersectionObserver(sharedCanvas, throttledInit); useEffect(() => { if (!animation) return; diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index a4a140fd0..d62a7c1ea 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -691,7 +691,7 @@ const Message: FC = ({ const throttledResize = useThrottledCallback(handleResize, [handleResize], THROTTLE_MS, false); - useResizeObserver(shouldFocusOnResize ? ref : undefined, throttledResize); + useResizeObserver(ref, throttledResize, !shouldFocusOnResize); useEffect(() => { const bottomMarker = bottomMarkerRef.current; diff --git a/src/hooks/useBoundsInSharedCanvas.ts b/src/hooks/useBoundsInSharedCanvas.ts index 4ce1bd5b3..c2f784954 100644 --- a/src/hooks/useBoundsInSharedCanvas.ts +++ b/src/hooks/useBoundsInSharedCanvas.ts @@ -1,15 +1,14 @@ import { useCallback, useEffect, useMemo, useState, } from '../lib/teact/teact'; -import { requestMeasure } from '../lib/fasterdom/fasterdom'; import { round } from '../util/math'; import useResizeObserver from './useResizeObserver'; import useThrottledCallback from './useThrottledCallback'; +import useSharedIntersectionObserver from './useSharedIntersectionObserver'; -const ANIMATION_END_TIMEOUT = 500; -const THROTTLE_MS = 300; +const THROTTLE_MS = 150; export default function useBoundsInSharedCanvas( containerRef: React.RefObject, @@ -27,9 +26,7 @@ export default function useBoundsInSharedCanvas( } // Wait until elements are properly mounted - if (!container.offsetParent || !canvas.offsetParent) { - // `requestMeasure` is useful as timeouts are run in parallel with image loadings and thus causing reflow - setTimeout(() => requestMeasure(recalculate), ANIMATION_END_TIMEOUT); + if (!canvas.offsetWidth || !canvas.offsetHeight) { return; } @@ -49,6 +46,7 @@ export default function useBoundsInSharedCanvas( const throttledRecalculate = useThrottledCallback(recalculate, [recalculate], THROTTLE_MS); useResizeObserver(sharedCanvasRef, throttledRecalculate); + useSharedIntersectionObserver(sharedCanvasRef, throttledRecalculate); const coords = useMemo(() => (x !== undefined && y !== undefined ? { x, y } : undefined), [x, y]); diff --git a/src/hooks/useDynamicColorListener.ts b/src/hooks/useDynamicColorListener.ts index 5160581fa..beeb2d4bb 100644 --- a/src/hooks/useDynamicColorListener.ts +++ b/src/hooks/useDynamicColorListener.ts @@ -12,12 +12,12 @@ import useSyncEffect from './useSyncEffect'; const TRANSITION_PROPERTY = 'color'; const TRANSITION_STYLE = `60ms ${TRANSITION_PROPERTY} linear`; -export default function useDynamicColorListener(ref?: React.RefObject, isDisabled?: boolean) { +export default function useDynamicColorListener(ref: React.RefObject, isDisabled?: boolean) { const [hexColor, setHexColor] = useState(); const rgbColorRef = useRef<[number, number, number] | undefined>(); const updateColor = useCallback(() => { - if (!ref?.current || isDisabled) { + if (!ref.current || isDisabled) { setHexColor(undefined); return; } @@ -28,7 +28,7 @@ export default function useDynamicColorListener(ref?: React.RefObject { @@ -42,7 +42,7 @@ export default function useDynamicColorListener(ref?: React.RefObject { - const el = ref?.current; + const el = ref.current; if (!el || isDisabled) { return undefined; } @@ -55,7 +55,7 @@ export default function useDynamicColorListener(ref?: React.RefObject { - const el = ref?.current; + const el = ref.current; if (!el) { return undefined; } diff --git a/src/hooks/useResizeObserver.ts b/src/hooks/useResizeObserver.ts index d5e58dc26..989233513 100644 --- a/src/hooks/useResizeObserver.ts +++ b/src/hooks/useResizeObserver.ts @@ -3,25 +3,30 @@ import { useEffect } from '../lib/teact/teact'; import type { CallbackManager } from '../util/callbacks'; import { createCallbackManager } from '../util/callbacks'; +import { useStateRef } from './useStateRef'; const elementObserverMap = new Map(); export default function useResizeObserver( ref: React.RefObject | undefined, onResize: (entry: ResizeObserverEntry) => void, + isDisabled = false, ) { + const onResizeRef = useStateRef(onResize); + useEffect(() => { - if (!('ResizeObserver' in window) || !ref?.current) { + const el = ref?.current; + if (!el || isDisabled) { return undefined; } - const el = ref.current; + const callback: ResizeObserverCallback = ([entry]) => { // Ignore updates when element is not properly mounted (`display: none`) if (entry.contentRect.width === 0 && entry.contentRect.height === 0) { return; } - onResize(entry); + onResizeRef.current(entry); }; let [observer, callbackManager] = elementObserverMap.get(el) || [undefined, undefined]; @@ -41,5 +46,5 @@ export default function useResizeObserver( elementObserverMap.delete(el); } }; - }, [onResize, ref]); + }, [isDisabled, onResizeRef, ref]); } diff --git a/src/hooks/useSharedIntersectionObserver.ts b/src/hooks/useSharedIntersectionObserver.ts new file mode 100644 index 000000000..5d942a2a4 --- /dev/null +++ b/src/hooks/useSharedIntersectionObserver.ts @@ -0,0 +1,50 @@ +import { useEffect } from '../lib/teact/teact'; + +import type { CallbackManager } from '../util/callbacks'; + +import { createCallbackManager } from '../util/callbacks'; +import { useStateRef } from './useStateRef'; + +const elementObserverMap = new Map(); + +export default function useSharedIntersectionObserver( + refOrElement: React.RefObject | HTMLElement | undefined, + onIntersectionChange: (entry: IntersectionObserverEntry) => void, + isDisabled = false, +) { + const onIntersectionChangeRef = useStateRef(onIntersectionChange); + + useEffect(() => { + const el = refOrElement && 'current' in refOrElement ? refOrElement.current : refOrElement; + if (!el || isDisabled) { + return undefined; + } + + const callback: IntersectionObserverCallback = ([entry]) => { + // Ignore updates when element is not properly mounted (`display: none`) + if (!(entry.target as HTMLElement).offsetWidth || !(entry.target as HTMLElement).offsetHeight) { + return; + } + + onIntersectionChangeRef.current(entry); + }; + + let [observer, callbackManager] = elementObserverMap.get(el) || [undefined, undefined]; + if (!observer) { + callbackManager = createCallbackManager(); + observer = new IntersectionObserver(callbackManager.runCallbacks); + elementObserverMap.set(el, [observer, callbackManager]); + observer.observe(el); + } + callbackManager!.addCallback(callback); + + return () => { + callbackManager!.removeCallback(callback); + if (!callbackManager!.hasCallbacks()) { + observer!.unobserve(el); + observer!.disconnect(); + elementObserverMap.delete(el); + } + }; + }, [isDisabled, onIntersectionChangeRef, refOrElement]); +}