// SPDX-License-Identifier: AGPL-3.0-or-later import Accessibility from '@app/features/accessibility/state/Accessibility'; import {useAnimatedMediaPlaybackAllowed} from '@app/features/app/hooks/useAnimatedMediaPlayback'; import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate'; import {AltTextBadge} from '@app/features/channel/components/embeds/AltTextBadge'; import embedStyles from '@app/features/channel/components/embeds/ChannelEmbed.module.css'; import {deriveDefaultNameFromMessage} from '@app/features/channel/components/embeds/EmbedUtils'; import {MatureMediaBlurOverlay} from '@app/features/channel/components/embeds/MatureMediaBlurOverlay'; import styles from '@app/features/channel/components/embeds/media/EmbedGifv.module.css'; import {GifIndicator} from '@app/features/channel/components/embeds/media/GifIndicator'; import {useGifViewportGate} from '@app/features/channel/components/embeds/media/GifViewportGate'; import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils'; import {MediaContainer} from '@app/features/channel/components/embeds/media/MediaContainer'; import {shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaOverlayFit'; import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes'; import {isInlinePlayableVideoSize} from '@app/features/channel/components/embeds/media/VideoDimensionUtils'; import {safePause, safePlay} from '@app/features/channel/components/GifVideoPool'; import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext'; import type {Channel} from '@app/features/channel/models/Channel'; import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment'; import {useMatureMedia} from '@app/features/messaging/hooks/useMatureMedia'; import {useMediaFavorite} from '@app/features/messaging/hooks/useMediaFavorite'; import {useMediaLoading} from '@app/features/messaging/hooks/useMediaLoading'; import {useMediaViewerHoverWarm} from '@app/features/messaging/hooks/useMediaViewerHoverWarm'; import {useOpenInBrowserOnMiddleClick} from '@app/features/messaging/hooks/useOpenInBrowserOnMiddleClick'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; import {createDownloadHandler} from '@app/features/messaging/utils/FileDownloadUtils'; import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils'; import {getEmbedMediaDimensions} from '@app/features/messaging/utils/MediaDimensionConfig'; import {resolveProxyRequestSize} from '@app/features/messaging/utils/MediaProxyRequestSize'; import { buildFittedAnimatedImageProxyURL, buildFittedStaticGifPreviewURL, stripMediaProxyParams, } from '@app/features/messaging/utils/MediaProxyUtils'; import {decodeThumbHashDataURL} from '@app/features/messaging/utils/ThumbHashUtils'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {MediaContextMenu} from '@app/features/ui/action_menu/MediaContextMenu'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import * as MediaViewerCommands from '@app/features/ui/commands/MediaViewerCommands'; import MediaViewer, {type MediaViewerItem} from '@app/features/ui/state/MediaViewer'; import {createCalculator} from '@app/features/ui/utils/DimensionUtils'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import {type FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; const OPEN_ANIMATED_GIF_VIDEO_IN_FULL_VIEW_DESCRIPTOR = msg({ message: 'Open animated GIF video in full view', comment: 'Button or menu action label in the channel and chat embed gifv. Keep it concise.', }); const OPEN_ANIMATED_GIF_IN_FULL_VIEW_DESCRIPTOR = msg({ message: 'Open animated GIF in full view', comment: 'Button or menu action label in the channel and chat embed gifv. Keep it concise.', }); const OPEN_IMAGE_IN_FULL_VIEW_DESCRIPTOR = msg({ message: 'Open image in full view', comment: 'Button or menu action label in the channel and chat embed gifv. Keep it concise.', }); const ANIMATED_GIF_VIDEO_DESCRIPTOR = msg({ message: 'Animated GIF video', comment: 'Short label in the channel and chat embed gifv. Keep it concise.', }); const ANIMATED_GIF_DESCRIPTOR = msg({ message: 'Animated GIF', comment: 'Short label in the channel and chat embed gifv. Keep it concise.', }); const PLACEHOLDER_FAST_FADE_WINDOW_MS = 200; type GifvEmbedProps = BaseMediaProps & { embedURL: string; naturalWidth: number; naturalHeight: number; placeholder?: string; thumbnailProxyURL?: string; alt?: string | null; }; interface VideoConfig { autoplay?: boolean; loop?: boolean; muted?: boolean; playsInline?: boolean; controls?: boolean; preload?: 'none' | 'metadata' | 'auto'; } function useEmbedMediaCalculator(constraints?: {maxWidth: number; maxHeight: number}) { const embedDimensions = getEmbedMediaDimensions(); const maxWidth = constraints?.maxWidth ?? embedDimensions.maxWidth; const maxHeight = constraints?.maxHeight ?? embedDimensions.maxHeight; return useMemo( () => createCalculator({ maxWidth, maxHeight, responsive: true, }), [maxWidth, maxHeight], ); } function useAspectRatioStyle(width: number, height: number): React.CSSProperties | undefined { return useMemo(() => { if (width <= 0 || height <= 0) return undefined; return {aspectRatio: `${width} / ${height}`}; }, [height, width]); } function useMediaSurfaceInteraction(container: HTMLDivElement | null): { isPointerInside: boolean; hasFocusInside: boolean; } { const [isPointerInside, setIsPointerInside] = useState(false); const [hasFocusInside, setHasFocusInside] = useState(false); useEffect(() => { if (container == null) return; const handleMouseEnter = () => setIsPointerInside(true); const handleMouseLeave = () => setIsPointerInside(false); const handleFocusIn = () => setHasFocusInside(true); const handleFocusOut = (event: FocusEvent) => { const nextTarget = event.relatedTarget; if (nextTarget != null && container.contains(nextTarget as Node)) return; setHasFocusInside(false); }; container.addEventListener('mouseenter', handleMouseEnter); container.addEventListener('mouseleave', handleMouseLeave); container.addEventListener('focusin', handleFocusIn); container.addEventListener('focusout', handleFocusOut); return () => { container.removeEventListener('mouseenter', handleMouseEnter); container.removeEventListener('mouseleave', handleMouseLeave); container.removeEventListener('focusin', handleFocusIn); container.removeEventListener('focusout', handleFocusOut); }; }, [container]); return {isPointerInside, hasFocusInside}; } interface ThumbHashCurtainState { shouldMount: boolean; curtainLifted: boolean; fastFade: boolean; posterRevealed: boolean; } function useThumbHashCurtain(source: string, enabled: boolean): ThumbHashCurtainState { const [mountedAt] = useState(() => Date.now()); const [cachedAtMount] = useState(() => ImageCacheUtils.hasImage(source)); const [curtainLifted, setCurtainLifted] = useState(false); const [fastFade, setFastFade] = useState(false); useLayoutEffect(() => { if (!enabled || source.length === 0) return; return ImageCacheUtils.loadImage(source, () => { setFastFade(Date.now() - mountedAt < PLACEHOLDER_FAST_FADE_WINDOW_MS); setCurtainLifted(true); }); }, [enabled, mountedAt, source]); return { shouldMount: !cachedAtMount, curtainLifted, fastFade, posterRevealed: source.length === 0 || cachedAtMount || curtainLifted, }; } const useImagePreview = ({ proxyUrl, embedUrl, naturalWidth, naturalHeight, type, channelId, messageId, attachmentId, embedIndex, contentHash, message, sourceChannel, providerName, allowAttachmentDelete = false, }: { proxyUrl: string; embedUrl: string; naturalWidth: number; naturalHeight: number; type: 'gifv' | 'gif' | 'image'; channelId?: string; messageId?: string; attachmentId?: string; embedIndex?: number; contentHash?: string | null; message?: Message; sourceChannel?: Channel | null; providerName?: string; allowAttachmentDelete?: boolean; }): {viewerItem: MediaViewerItem; openPreview: (event: React.MouseEvent | React.KeyboardEvent) => void} => { const viewerItem = useMemo( () => ({ src: proxyUrl, originalSrc: embedUrl, naturalWidth, naturalHeight, type, contentHash, attachmentId, embedIndex, animated: true, providerName, }), [proxyUrl, embedUrl, naturalWidth, naturalHeight, type, contentHash, attachmentId, embedIndex, providerName], ); const openPreview = useCallback( (event: React.MouseEvent | React.KeyboardEvent) => { if (event.type === 'click' && (event as React.MouseEvent).button !== 0) { return; } event.preventDefault(); event.stopPropagation(); MediaViewerCommands.openMediaViewer([viewerItem], 0, { channelId, messageId, message, sourceChannel, allowAttachmentDelete, }); }, [viewerItem, channelId, messageId, message, sourceChannel, allowAttachmentDelete], ); return {viewerItem, openPreview}; }; interface ImagePreviewHandlerProps { src: string; originalSrc: string; naturalWidth: number; naturalHeight: number; type: 'gifv' | 'gif' | 'image'; handlePress?: (event: React.MouseEvent | React.KeyboardEvent) => void; channelId?: string; messageId?: string; attachmentId?: string; embedIndex?: number; contentHash?: string | null; message?: Message; sourceChannel?: Channel | null; onViewerWarmEnter?: () => void; onViewerWarmLeave?: () => void; children: React.ReactNode; } const ImagePreviewHandler: FC = observer( ({ src, originalSrc, naturalWidth, naturalHeight, type, handlePress, channelId, messageId, attachmentId, embedIndex, contentHash, message, sourceChannel, onViewerWarmEnter, onViewerWarmLeave, children, }) => { const {i18n} = useLingui(); const openImagePreview = useCallback( (event: React.MouseEvent | React.KeyboardEvent) => { if (event.type === 'click' && (event as React.MouseEvent).button !== 0) { return; } if (event.type === 'keydown') { const keyEvent = event as React.KeyboardEvent; if (!isKeyboardActivationKey(keyEvent.key)) { return; } } if (handlePress) { event.preventDefault(); event.stopPropagation(); handlePress(event); return; } event.preventDefault(); event.stopPropagation(); MediaViewerCommands.openMediaViewer( [ { src, originalSrc, naturalWidth, naturalHeight, type, contentHash, attachmentId, embedIndex, animated: true, }, ], 0, { channelId, messageId, message, sourceChannel, }, ); }, [ src, originalSrc, naturalWidth, naturalHeight, handlePress, type, channelId, messageId, attachmentId, embedIndex, contentHash, message, sourceChannel, ], ); const openInBrowser = useOpenInBrowserOnMiddleClick(originalSrc || src); const ariaLabel = (() => { if (type === 'gifv') return i18n._(OPEN_ANIMATED_GIF_VIDEO_IN_FULL_VIEW_DESCRIPTOR); if (type === 'gif') return i18n._(OPEN_ANIMATED_GIF_IN_FULL_VIEW_DESCRIPTOR); return i18n._(OPEN_IMAGE_IN_FULL_VIEW_DESCRIPTOR); })(); return ( ); }, ); export const EmbedGifv: FC< GifvEmbedProps & { videoProxyURL: string; videoURL: string; videoConfig?: VideoConfig; isPreview?: boolean; snapshotIndex?: number; providerName?: string; } > = observer( ({ embedURL, videoProxyURL, thumbnailProxyURL, alt, naturalWidth, naturalHeight, placeholder, videoConfig, nsfw, channelId, messageId, attachmentId, embedIndex, message, contentHash, onDelete, isPreview, snapshotIndex, providerName, }) => { const {i18n} = useLingui(); const messageViewContext = useMaybeMessageViewContext(); const mediaCalculator = useEmbedMediaCalculator(); const videoRef = useRef(null); const [containerElement, setContainerElement] = useState(null); const {isPointerInside, hasFocusInside} = useMediaSurfaceInteraction(containerElement); const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId); const { ref: visibilityRef, loadMedia: shouldLoadMedia, animate: shouldAnimate, } = useGifViewportGate({ element: containerElement, rememberKey: videoProxyURL, shouldBlur, }); const setContainerRef = useCallback( (node: HTMLDivElement | null) => { setContainerElement(node); visibilityRef(node); }, [visibilityRef], ); const {dimensions} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}); const canPlayInline = isInlinePlayableVideoSize({width: naturalWidth, height: naturalHeight}); const posterSource = thumbnailProxyURL && thumbnailProxyURL.length > 0 ? thumbnailProxyURL : videoProxyURL; const posterURL = useMemo(() => { const requestedSize = resolveProxyRequestSize(dimensions.width, dimensions.height, naturalWidth, naturalHeight); return buildFittedStaticGifPreviewURL( stripMediaProxyParams(posterSource), requestedSize?.width, requestedSize?.height, ); }, [dimensions.width, naturalHeight, naturalWidth, posterSource]); const thumbHashURL = useMemo(() => decodeThumbHashDataURL(placeholder), [placeholder]); const thumbHashCurtain = useThumbHashCurtain(posterURL, shouldLoadMedia); const aspectRatioStyle = useAspectRatioStyle(dimensions.width, dimensions.height); const defaultName = deriveDefaultNameFromMessage({ message, attachmentId, embedIndex, url: embedURL, proxyUrl: videoProxyURL, }); const effectiveDefaultName = alt?.trim() ? alt.trim() : defaultName || 'GIF'; const {toggleFavorite, isFavorited, canFavorite} = useMediaFavorite({ channelId, messageId, attachmentId, embedIndex, defaultName: effectiveDefaultName, contentHash, isGifv: true, embedURL, proxyURL: videoProxyURL, naturalWidth, naturalHeight, }); const animationPolicyAllowed = useShouldAnimate({ kind: 'gif', isHovering: isPointerInside, isFocused: hasFocusInside, }); const animatedMediaPlaybackAllowed = useAnimatedMediaPlaybackAllowed(); const isMediaViewerOpen = MediaViewer.isOpen; const {openPreview: openImagePreview} = useImagePreview({ proxyUrl: videoProxyURL, embedUrl: embedURL, naturalWidth, naturalHeight, type: 'gifv', channelId, messageId, attachmentId, embedIndex, contentHash, message, sourceChannel: messageViewContext?.channel, providerName, allowAttachmentDelete: !isPreview && snapshotIndex === undefined, }); const handleDeleteClick = useDeleteAttachment(message, attachmentId); const handleDownloadClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); createDownloadHandler(videoProxyURL, 'video')(); }, [videoProxyURL], ); const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (!message) return; if (isPreview && snapshotIndex === undefined) return; e.preventDefault(); e.stopPropagation(); ContextMenuCommands.openFromEvent(e, ({onClose}) => ( {})} data-flx="channel.embeds.media.embed-gifv.handle-context-menu.media-context-menu.gifv" /> )); }, [ message, messageViewContext?.channel, embedURL, videoProxyURL, contentHash, attachmentId, effectiveDefaultName, alt, naturalWidth, naturalHeight, onDelete, isPreview, snapshotIndex, ], ); const shouldPlay = shouldAnimate && animationPolicyAllowed && animatedMediaPlaybackAllowed && !isMediaViewerOpen && thumbHashCurtain.posterRevealed; useEffect(() => { const video = videoRef.current; if (video == null) return; if (shouldPlay) { video.autoplay = true; void safePlay(video); return; } video.autoplay = false; safePause(video); }, [shouldPlay]); if (shouldBlur) { const blurContainerStyle: React.CSSProperties = { maxWidth: '100%', width: remFromPx(dimensions.width), ...aspectRatioStyle, }; return (
{thumbHashURL && ( )}
); } const {showFavoriteButton, showDeleteButton} = getMediaButtonVisibility( canFavorite, isPreview ? undefined : message, attachmentId, {disableDelete: !!isPreview || snapshotIndex !== undefined}, ); const showGifIndicator = Accessibility.showGifIndicator && shouldShowOverlays(dimensions.width, dimensions.height); const containerStyle: React.CSSProperties = { maxWidth: '100%', width: remFromPx(dimensions.width), ...aspectRatioStyle, }; return ( {showGifIndicator && }
{canPlayInline ? (
); }, ); export const EmbedGif: FC< GifvEmbedProps & { proxyURL: string; includeButton?: boolean; isPreview?: boolean; snapshotIndex?: number; layoutConstraints?: {maxWidth: number; maxHeight: number}; } > = observer( ({ embedURL, proxyURL, alt, naturalWidth, naturalHeight, placeholder, nsfw, channelId, messageId, attachmentId, embedIndex, message, contentHash, onDelete, isPreview, snapshotIndex, layoutConstraints, }) => { const {i18n} = useLingui(); const messageViewContext = useMaybeMessageViewContext(); const mediaCalculator = useEmbedMediaCalculator(layoutConstraints); const [containerElement, setContainerElement] = useState(null); const {isPointerInside, hasFocusInside} = useMediaSurfaceInteraction(containerElement); const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId); const { ref: visibilityRef, loadMedia: shouldLoadMedia, animate: shouldAnimate, } = useGifViewportGate({ element: containerElement, rememberKey: proxyURL, shouldBlur, }); const setContainerRef = useCallback( (node: HTMLDivElement | null) => { setContainerElement(node); visibilityRef(node); }, [visibilityRef], ); const {dimensions} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}); const {width: displayWidth, height: displayHeight} = dimensions; const gifAutoPlay = useShouldAnimate({kind: 'gif'}); const animationPolicyAllowed = useShouldAnimate({ kind: 'gif', isHovering: isPointerInside, isFocused: hasFocusInside, }); const baseProxyURL = stripMediaProxyParams(proxyURL); const requestedSize = resolveProxyRequestSize(displayWidth, displayHeight, naturalWidth, naturalHeight); const optimizedAnimatedURL = buildFittedAnimatedImageProxyURL( baseProxyURL, requestedSize?.width, requestedSize?.height, ); const optimizedStaticURL = buildFittedStaticGifPreviewURL( baseProxyURL, requestedSize?.width, requestedSize?.height, ); const shouldShowAnimated = shouldAnimate && animationPolicyAllowed; const activeSource = shouldShowAnimated ? optimizedAnimatedURL : optimizedStaticURL; const { loaded, cachedOnMount, thumbHashURL, ref: mediaRef, onLoad: handleImageLoad, onError: handleImageError, } = useMediaLoading(activeSource, placeholder, {enabled: shouldLoadMedia}); const [mountedAt] = useState(() => Date.now()); const [placeholderMounted] = useState(!cachedOnMount); const [placeholderHidden, setPlaceholderHidden] = useState(loaded); const [placeholderFastFade, setPlaceholderFastFade] = useState(false); const aspectRatioStyle = useAspectRatioStyle(dimensions.width, dimensions.height); useEffect(() => { if (!loaded) return; setPlaceholderFastFade(Date.now() - mountedAt < PLACEHOLDER_FAST_FADE_WINDOW_MS); setPlaceholderHidden(true); }, [loaded, mountedAt]); useEffect(() => { if (!shouldLoadMedia) return; if (!animationPolicyAllowed) return; if (activeSource === optimizedAnimatedURL) return; return ImageCacheUtils.pinImage(optimizedAnimatedURL); }, [activeSource, animationPolicyAllowed, optimizedAnimatedURL, shouldLoadMedia]); const defaultName = deriveDefaultNameFromMessage({ message, attachmentId, embedIndex, url: embedURL, proxyUrl: proxyURL, }); const effectiveDefaultName = alt?.trim() ? alt.trim() : defaultName || 'GIF'; const {toggleFavorite, isFavorited, canFavorite} = useMediaFavorite({ channelId, messageId, attachmentId, embedIndex, defaultName: effectiveDefaultName, contentHash, isGifv: true, embedURL, proxyURL, naturalWidth, naturalHeight, }); const {viewerItem, openPreview: openImagePreview} = useImagePreview({ proxyUrl: optimizedAnimatedURL, embedUrl: embedURL, naturalWidth, naturalHeight, type: 'gif', channelId, messageId, attachmentId, embedIndex, contentHash, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete: !isPreview && snapshotIndex === undefined, }); const {scheduleViewerWarm, cancelViewerWarm} = useMediaViewerHoverWarm(viewerItem, { allowAnimated: gifAutoPlay, enabled: !shouldBlur, }); const handleDeleteClick = useDeleteAttachment(message, attachmentId); const handleDownloadClickGif = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); createDownloadHandler(baseProxyURL, 'gif')(); }, [baseProxyURL], ); const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (!message) return; if (isPreview && snapshotIndex === undefined) return; e.preventDefault(); e.stopPropagation(); ContextMenuCommands.openFromEvent(e, ({onClose}) => ( {})} data-flx="channel.embeds.media.embed-gifv.handle-context-menu.media-context-menu.gif" /> )); }, [ message, messageViewContext?.channel, embedURL, proxyURL, contentHash, attachmentId, effectiveDefaultName, alt, naturalWidth, naturalHeight, onDelete, isPreview, snapshotIndex, ], ); if (shouldBlur) { const blurContainerStyle: React.CSSProperties = { maxWidth: '100%', width: remFromPx(dimensions.width), ...aspectRatioStyle, }; return (
{thumbHashURL && ( )}
); } const {showFavoriteButton, showDownloadButton, showDeleteButton} = getMediaButtonVisibility( canFavorite, isPreview ? undefined : message, attachmentId, {disableDelete: !!isPreview || snapshotIndex !== undefined}, ); const showGifIndicator = Accessibility.showGifIndicator && shouldShowOverlays(dimensions.width, dimensions.height); const containerStyle: React.CSSProperties = { maxWidth: '100%', width: remFromPx(dimensions.width), ...aspectRatioStyle, }; return ( {showGifIndicator && }
{i18n._(ANIMATED_GIF_DESCRIPTOR)} {placeholderMounted && thumbHashURL && ( )}
); }, );