// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { CSSProperties, ForwardedRef } from 'react'; import React, { forwardRef, useEffect, useRef, useState } from 'react'; import { useReducedMotion } from '@react-spring/web'; import { SpinnerV2 } from '../SpinnerV2'; import { strictAssert } from '../../util/assert'; import type { Loadable } from '../../util/loadable'; import { LoadingState } from '../../util/loadable'; import { useIntent } from './base/FunImage'; import * as log from '../../logging/log'; import * as Errors from '../../types/errors'; import { isAbortError } from '../../util/isAbortError'; export type FunGifProps = Readonly<{ src: string; width: number; height: number; 'aria-label'?: string; 'aria-describedby': string; ignoreReducedMotion?: boolean; }>; export function FunGif(props: FunGifProps): JSX.Element { if (props.ignoreReducedMotion) { return ; } return ; } /** @internal */ const FunGifBase = forwardRef(function FunGifBase( props: FunGifProps & { autoPlay: boolean }, ref: ForwardedRef ) { return ( ); }); /** @internal */ function FunGifReducedMotion(props: FunGifProps) { const videoRef = useRef(null); const intent = useIntent(videoRef); const reducedMotion = useReducedMotion(); const shouldPlay = !reducedMotion || intent; useEffect(() => { strictAssert(videoRef.current, 'Expected video element'); const video = videoRef.current; if (shouldPlay) { video.play().catch(error => { // ignore errors where `play()` was interrupted by `pause()` if (!isAbortError(error)) { log.error('FunGif: Playback error', Errors.toLogFormat(error)); } }); } else { video.pause(); } }, [shouldPlay]); return ; } export type FunGifPreviewLoadable = Loadable; export type FunGifPreviewProps = Readonly<{ src: string | null; state: LoadingState; width: number; height: number; // It would be nice if this were determined by the container, but that's a // difficult problem because it creates a cycle where the parent's height // depends on its children, and its children's height depends on its parent. // As far as I was able to figure out, this could only be done in one dimension // at a time. maxHeight: number; 'aria-label'?: string; 'aria-describedby': string; }>; export function FunGifPreview(props: FunGifPreviewProps): JSX.Element { const ref = useRef(null); const [spinner, setSpinner] = useState(false); const [playbackError, setPlaybackError] = useState(false); const timerRef = useRef>(); useEffect(() => { const timer = setTimeout(() => { setSpinner(true); }); timerRef.current = timer; return () => { clearTimeout(timer); }; }, []); useEffect(() => { if (props.src == null) { return; } strictAssert(ref.current != null, 'video ref should not be null'); const video = ref.current; function onCanPlay() { video.hidden = false; clearTimeout(timerRef.current); setSpinner(false); setPlaybackError(false); } function onError() { clearTimeout(timerRef.current); setSpinner(false); setPlaybackError(true); } video.addEventListener('canplay', onCanPlay, { once: true }); video.addEventListener('error', onError, { once: true }); return () => { video.removeEventListener('canplay', onCanPlay); video.removeEventListener('error', onError); }; }, [props.src]); const hasError = props.state === LoadingState.LoadFailed || playbackError; return ( {spinner && !hasError && ( )} {hasError && } {props.src != null && ( )} ); }