Signal-Desktop/ts/components/fun/FunGif.tsx

186 lines
5.2 KiB
TypeScript

// 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 { createLogger } from '../../logging/log';
import * as Errors from '../../types/errors';
import { isAbortError } from '../../util/isAbortError';
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 <FunGifBase {...props} autoPlay />;
}
return <FunGifReducedMotion {...props} />;
}
/** @internal */
const FunGifBase = forwardRef(function FunGifBase(
props: FunGifProps & { autoPlay: boolean },
ref: ForwardedRef<HTMLVideoElement>
) {
return (
<video
ref={ref}
className="FunGif"
src={props.src}
width={props.width}
height={props.height}
loop
autoPlay={props.autoPlay}
playsInline
muted
disablePictureInPicture
disableRemotePlayback
aria-label={props['aria-label']}
aria-describedby={props['aria-describedby']}
/>
);
});
/** @internal */
function FunGifReducedMotion(props: FunGifProps) {
const videoRef = useRef<HTMLVideoElement>(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 <FunGifBase {...props} ref={videoRef} autoPlay={shouldPlay} />;
}
export type FunGifPreviewLoadable = Loadable<string>;
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<HTMLVideoElement>(null);
const [spinner, setSpinner] = useState(false);
const [playbackError, setPlaybackError] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
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 (
<div className="FunGifPreview">
<svg
aria-hidden
className="FunGifPreview__Sizer"
width={props.width}
height={props.height}
style={
{
'--fun-gif-preview-sizer-max-height': `${props.maxHeight}px`,
} as CSSProperties
}
/>
<div className="FunGifPreview__Backdrop" role="status">
{spinner && !hasError && (
<SpinnerV2
className="FunGifPreview__Spinner"
size={36}
strokeWidth={4}
/>
)}
{hasError && <div className="FunGifPreview__ErrorIcon" />}
</div>
{props.src != null && (
<video
ref={ref}
className="FunGifPreview__Video"
src={props.src}
width={props.width}
height={props.height}
loop
autoPlay
playsInline
muted
disablePictureInPicture
disableRemotePlayback
aria-label={props['aria-label']}
aria-describedby={props['aria-describedby']}
/>
)}
</div>
);
}