mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
995 lines
31 KiB
TypeScript
995 lines
31 KiB
TypeScript
// 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<MediaViewerItem>(
|
|
() => ({
|
|
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<ImagePreviewHandlerProps> = 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 (
|
|
<button
|
|
type="button"
|
|
className={styles.imagePreviewHandler}
|
|
aria-label={ariaLabel}
|
|
onClick={openImagePreview}
|
|
onMouseDown={openInBrowser.onMouseDown}
|
|
onAuxClick={openInBrowser.onAuxClick}
|
|
onKeyDown={openImagePreview}
|
|
onMouseEnter={onViewerWarmEnter}
|
|
onMouseLeave={onViewerWarmLeave}
|
|
data-flx="channel.embeds.media.embed-gifv.image-preview-handler.image-preview-handler.open-image-preview.button"
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
},
|
|
);
|
|
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<HTMLVideoElement>(null);
|
|
const [containerElement, setContainerElement] = useState<HTMLDivElement | null>(null);
|
|
const {isPointerInside, hasFocusInside} = useMediaSurfaceInteraction(containerElement);
|
|
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
|
|
const {
|
|
ref: visibilityRef,
|
|
loadMedia: shouldLoadMedia,
|
|
animate: shouldAnimate,
|
|
} = useGifViewportGate<HTMLDivElement>({
|
|
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}) => (
|
|
<MediaContextMenu
|
|
message={message}
|
|
sourceChannel={messageViewContext?.channel}
|
|
originalSrc={embedURL}
|
|
proxyURL={videoProxyURL}
|
|
type="gifv"
|
|
contentHash={contentHash}
|
|
attachmentId={attachmentId}
|
|
defaultName={effectiveDefaultName}
|
|
defaultAltText={alt ?? undefined}
|
|
naturalWidth={naturalWidth}
|
|
naturalHeight={naturalHeight}
|
|
snapshotIndex={snapshotIndex}
|
|
onClose={onClose}
|
|
onDelete={onDelete || (() => {})}
|
|
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 (
|
|
<div
|
|
ref={visibilityRef}
|
|
className={styles.blurContainer}
|
|
data-flx="channel.embeds.media.embed-gifv.blur-container"
|
|
>
|
|
<div
|
|
className={styles.blurContent}
|
|
style={blurContainerStyle}
|
|
data-flx="channel.embeds.media.embed-gifv.blur-content"
|
|
>
|
|
<div className={styles.blurInnerContainer} data-flx="channel.embeds.media.embed-gifv.blur-inner-container">
|
|
{thumbHashURL && (
|
|
<img
|
|
src={thumbHashURL}
|
|
className={styles.thumbHashPlaceholder}
|
|
alt=""
|
|
aria-hidden={true}
|
|
style={{filter: 'blur(40px)'}}
|
|
data-flx="channel.embeds.media.embed-gifv.thumb-hash-placeholder"
|
|
/>
|
|
)}
|
|
</div>
|
|
<MatureMediaBlurOverlay
|
|
reason={gateReason}
|
|
canReveal={canReveal}
|
|
onReveal={revealSensitiveMedia}
|
|
data-flx="channel.embeds.media.embed-gifv.mature-media-blur-overlay"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
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 (
|
|
<MediaContainer
|
|
ref={setContainerRef}
|
|
className={clsx(embedStyles.embedGifvContainer, styles.mediaContainer)}
|
|
style={containerStyle}
|
|
showFavoriteButton={showFavoriteButton}
|
|
isFavorited={isFavorited}
|
|
onFavoriteClick={toggleFavorite}
|
|
showDownloadButton={false}
|
|
onDownloadClick={handleDownloadClick}
|
|
showDeleteButton={showDeleteButton}
|
|
onDeleteClick={handleDeleteClick}
|
|
onContextMenu={handleContextMenu}
|
|
renderedWidth={dimensions.width}
|
|
renderedHeight={dimensions.height}
|
|
forceShowFavoriteButton={true}
|
|
data-flx="channel.embeds.media.embed-gifv.media-container.context-menu"
|
|
>
|
|
{showGifIndicator && <GifIndicator data-flx="channel.embeds.media.embed-gifv.gif-indicator" />}
|
|
<ImagePreviewHandler
|
|
src={videoProxyURL}
|
|
originalSrc={embedURL}
|
|
naturalWidth={naturalWidth}
|
|
naturalHeight={naturalHeight}
|
|
type="gifv"
|
|
handlePress={openImagePreview}
|
|
data-flx="channel.embeds.media.embed-gifv.image-preview-handler.gifv"
|
|
>
|
|
<div
|
|
className={styles.videoWrapper}
|
|
style={aspectRatioStyle}
|
|
data-flx="channel.embeds.media.embed-gifv.video-wrapper"
|
|
>
|
|
{canPlayInline ? (
|
|
<video
|
|
ref={videoRef}
|
|
className={styles.videoElement}
|
|
src={shouldLoadMedia ? videoProxyURL : undefined}
|
|
poster={shouldLoadMedia ? posterURL : thumbHashURL}
|
|
preload={videoConfig?.preload ?? 'none'}
|
|
loop={videoConfig?.loop ?? true}
|
|
muted={videoConfig?.muted ?? true}
|
|
playsInline={videoConfig?.playsInline ?? true}
|
|
controls={videoConfig?.controls ?? false}
|
|
width={dimensions.width}
|
|
height={dimensions.height}
|
|
tabIndex={-1}
|
|
aria-label={i18n._(ANIMATED_GIF_VIDEO_DESCRIPTOR)}
|
|
data-embed-media="gifv"
|
|
data-flx="channel.embeds.media.embed-gifv.video-element"
|
|
/>
|
|
) : (
|
|
<img
|
|
className={styles.videoElement}
|
|
src={shouldLoadMedia ? posterURL : thumbHashURL}
|
|
alt=""
|
|
width={dimensions.width}
|
|
height={dimensions.height}
|
|
loading="eager"
|
|
tabIndex={-1}
|
|
data-embed-media="gifv-still"
|
|
data-flx="channel.embeds.media.embed-gifv.still-element"
|
|
/>
|
|
)}
|
|
{thumbHashCurtain.shouldMount && thumbHashURL && (
|
|
<img
|
|
src={thumbHashURL}
|
|
className={clsx(
|
|
styles.placeholder,
|
|
thumbHashCurtain.curtainLifted ? styles.placeholderHidden : styles.placeholderVisible,
|
|
thumbHashCurtain.fastFade && styles.placeholderFastFade,
|
|
)}
|
|
alt=""
|
|
aria-hidden="true"
|
|
data-flx="channel.embeds.media.embed-gifv.image-placeholder"
|
|
/>
|
|
)}
|
|
</div>
|
|
</ImagePreviewHandler>
|
|
<AltTextBadge
|
|
altText={alt}
|
|
onPopoutToggle={messageViewContext?.onPopoutToggle}
|
|
data-flx="channel.embeds.media.embed-gifv.alt-text-badge"
|
|
/>
|
|
</MediaContainer>
|
|
);
|
|
},
|
|
);
|
|
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<HTMLDivElement | null>(null);
|
|
const {isPointerInside, hasFocusInside} = useMediaSurfaceInteraction(containerElement);
|
|
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
|
|
const {
|
|
ref: visibilityRef,
|
|
loadMedia: shouldLoadMedia,
|
|
animate: shouldAnimate,
|
|
} = useGifViewportGate<HTMLDivElement>({
|
|
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}) => (
|
|
<MediaContextMenu
|
|
message={message}
|
|
sourceChannel={messageViewContext?.channel}
|
|
originalSrc={embedURL}
|
|
proxyURL={proxyURL}
|
|
type="gif"
|
|
contentHash={contentHash}
|
|
attachmentId={attachmentId}
|
|
defaultName={effectiveDefaultName}
|
|
defaultAltText={alt ?? undefined}
|
|
naturalWidth={naturalWidth}
|
|
naturalHeight={naturalHeight}
|
|
snapshotIndex={snapshotIndex}
|
|
onClose={onClose}
|
|
onDelete={onDelete || (() => {})}
|
|
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 (
|
|
<div
|
|
ref={visibilityRef}
|
|
className={styles.blurContainer}
|
|
data-flx="channel.embeds.media.embed-gifv.embed-gif.blur-container"
|
|
>
|
|
<div
|
|
className={styles.blurContent}
|
|
style={blurContainerStyle}
|
|
data-flx="channel.embeds.media.embed-gifv.embed-gif.blur-content"
|
|
>
|
|
<div
|
|
className={styles.blurInnerContainer}
|
|
data-flx="channel.embeds.media.embed-gifv.embed-gif.blur-inner-container"
|
|
>
|
|
{thumbHashURL && (
|
|
<img
|
|
src={thumbHashURL}
|
|
className={styles.thumbHashPlaceholder}
|
|
alt=""
|
|
aria-hidden={true}
|
|
style={{filter: 'blur(40px)'}}
|
|
data-flx="channel.embeds.media.embed-gifv.embed-gif.thumb-hash-placeholder"
|
|
/>
|
|
)}
|
|
</div>
|
|
<MatureMediaBlurOverlay
|
|
reason={gateReason}
|
|
canReveal={canReveal}
|
|
onReveal={revealSensitiveMedia}
|
|
data-flx="channel.embeds.media.embed-gifv.embed-gif.mature-media-blur-overlay"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
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 (
|
|
<MediaContainer
|
|
ref={setContainerRef}
|
|
className={clsx(embedStyles.embedGifvContainer, styles.mediaContainer)}
|
|
style={containerStyle}
|
|
showFavoriteButton={showFavoriteButton}
|
|
isFavorited={isFavorited}
|
|
onFavoriteClick={toggleFavorite}
|
|
showDownloadButton={showDownloadButton}
|
|
onDownloadClick={handleDownloadClickGif}
|
|
showDeleteButton={showDeleteButton}
|
|
onDeleteClick={handleDeleteClick}
|
|
onContextMenu={handleContextMenu}
|
|
renderedWidth={dimensions.width}
|
|
renderedHeight={dimensions.height}
|
|
forceShowFavoriteButton={true}
|
|
data-flx="channel.embeds.media.embed-gifv.embed-gif.media-container.context-menu"
|
|
>
|
|
{showGifIndicator && <GifIndicator data-flx="channel.embeds.media.embed-gifv.embed-gif.gif-indicator" />}
|
|
<ImagePreviewHandler
|
|
src={optimizedAnimatedURL}
|
|
originalSrc={embedURL}
|
|
naturalWidth={naturalWidth}
|
|
naturalHeight={naturalHeight}
|
|
type="gif"
|
|
handlePress={openImagePreview}
|
|
channelId={channelId}
|
|
messageId={messageId}
|
|
attachmentId={attachmentId}
|
|
embedIndex={embedIndex}
|
|
contentHash={contentHash}
|
|
message={message}
|
|
sourceChannel={messageViewContext?.channel}
|
|
onViewerWarmEnter={scheduleViewerWarm}
|
|
onViewerWarmLeave={cancelViewerWarm}
|
|
data-flx="channel.embeds.media.embed-gifv.embed-gif.image-preview-handler.gif"
|
|
>
|
|
<div
|
|
className={styles.videoWrapper}
|
|
style={aspectRatioStyle}
|
|
data-flx="channel.embeds.media.embed-gifv.embed-gif.video-wrapper"
|
|
>
|
|
<img
|
|
ref={mediaRef}
|
|
alt={i18n._(ANIMATED_GIF_DESCRIPTOR)}
|
|
src={shouldLoadMedia ? activeSource : undefined}
|
|
className={styles.videoElement}
|
|
data-embed-media="gif"
|
|
loading="eager"
|
|
tabIndex={-1}
|
|
width={dimensions.width}
|
|
height={dimensions.height}
|
|
onLoad={handleImageLoad}
|
|
onError={handleImageError}
|
|
data-flx="channel.embeds.media.embed-gifv.embed-gif.video-element"
|
|
/>
|
|
{placeholderMounted && thumbHashURL && (
|
|
<img
|
|
src={thumbHashURL}
|
|
className={clsx(
|
|
styles.placeholder,
|
|
placeholderHidden ? styles.placeholderHidden : styles.placeholderVisible,
|
|
placeholderFastFade && styles.placeholderFastFade,
|
|
)}
|
|
alt=""
|
|
aria-hidden="true"
|
|
data-flx="channel.embeds.media.embed-gifv.embed-gif.image-placeholder"
|
|
/>
|
|
)}
|
|
</div>
|
|
</ImagePreviewHandler>
|
|
<AltTextBadge
|
|
altText={alt}
|
|
onPopoutToggle={messageViewContext?.onPopoutToggle}
|
|
data-flx="channel.embeds.media.embed-gifv.embed-gif.alt-text-badge"
|
|
/>
|
|
</MediaContainer>
|
|
);
|
|
},
|
|
);
|