// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ForwardedRef, RefObject } from 'react'; import React, { useRef, useEffect, useState, forwardRef } from 'react'; import classNames from 'classnames'; import { isFocusable } from '@react-aria/focus'; import { strictAssert } from '../../../util/assert'; import { useReducedMotion } from '../../../hooks/useReducedMotion'; import type { FunImageAriaProps } from '../types'; export type FunImageProps = FunImageAriaProps & Readonly<{ className?: string; src: string; width: number; height: number; ignoreReducedMotion?: boolean; }>; export function FunImage(props: FunImageProps): JSX.Element { if (props.ignoreReducedMotion) { return ; } return ; } /** @internal */ const FunImageBase = forwardRef(function FunImageBase( props: FunImageProps, ref: ForwardedRef ) { return ( ); }); /** @internal */ function FunImageReducedMotion(props: FunImageProps) { const imageRef = useRef(null); const intent = useIntent(imageRef); const [staticSource, setStaticSource] = useState(null); const reducedMotion = useReducedMotion(); useEffect(() => { // Don't bother creating the static source if we're not in reduced motion if (!reducedMotion) { return; } strictAssert(imageRef.current, 'Expected imageRef to be set'); const image = imageRef.current; const controller = new AbortController(); const { signal } = controller; async function onLoad() { const blob = await createStaticImageBlob(image, signal); const url = URL.createObjectURL(blob); setStaticSource(url); } image.addEventListener('load', onLoad, { once: true, signal }); return () => { controller.abort(); image.removeEventListener('load', onLoad); }; }, [props.src, reducedMotion]); // Ensure we always revoke the object URL. useEffect(() => { return () => { if (staticSource != null) { URL.revokeObjectURL(staticSource); } }; }, [staticSource]); return ( tag is // always responsible for the layout/styles, this makes it a very good // drop-in for a plain tag className={classNames('FunImage', { // Only hide the image if we're in reduced motion mode and // we haven't loaded the static source yet. 'FunImage--Hidden': reducedMotion && staticSource == null, })} > {staticSource != null && reducedMotion && !intent && ( )} ); } /** Similar to `element.closest()` but with a predicate instead of selectors */ function closestElement( element: HTMLElement, predicate: (element: HTMLElement) => boolean ): HTMLElement | null { let search: HTMLElement | null = element; while (search != null) { if (predicate(search)) { return search; } search = search.parentElement; } return null; } /** * Tracks the closest focusable ancestor for user "intent" (focus/hover within). * * - Uses the nearest "focusable" element, even if it is not "tabbable", so it * should not be affected by `tabIndex` or `disabled` attributes. * - React doesn't support "reparenting" so we don't need to worry about the * ancestors changing on us. * - However, this will break if elements become focusable/unfocusable during * their lifetime (this is generally a sign something is being done wrong). */ export function useIntent(ref: RefObject): boolean { const [intent, setIntent] = useState(false); useEffect(() => { strictAssert(ref.current, 'Expected ref to be set'); const target = ref.current; const focusable = closestElement(target, isFocusable); strictAssert(focusable, 'Expected focusable ancestor to be found'); function onIntent() { setIntent(true); } function onDetent() { setIntent(false); } focusable.addEventListener('focusin', onIntent); focusable.addEventListener('mouseenter', onIntent); focusable.addEventListener('focusout', onDetent); focusable.addEventListener('mouseleave', onDetent); return () => { focusable.removeEventListener('focusin', onIntent); focusable.removeEventListener('mouseenter', onIntent); focusable.removeEventListener('focusout', onDetent); focusable.removeEventListener('mouseleave', onDetent); }; }, [ref]); return intent; } /** * Given any , bitmap, blob, etc. create a static image blob even if it's * animated. */ async function createStaticImageBlob( image: ImageBitmapSource, signal: AbortSignal ): Promise { const bitmap = await createImageBitmap(image); signal.throwIfAborted(); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const context = canvas.getContext('bitmaprenderer'); strictAssert(context, 'Failed to load bitmaprenderer context'); context.transferFromImageBitmap(bitmap); const blob = await canvas.convertToBlob(); signal.throwIfAborted(); return blob; }