// 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.js';
import { strictAssert } from '../../util/assert.js';
import type { Loadable } from '../../util/loadable.js';
import { LoadingState } from '../../util/loadable.js';
import { useIntent } from './base/FunImage.js';
import { createLogger } from '../../logging/log.js';
import * as Errors from '../../types/errors.js';
import { isAbortError } from '../../util/isAbortError.js';
const log = createLogger('FunGif');
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('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 && (
)}
);
}