TelegramPWA/src/components/common/CompactMediaPreview.tsx
2026-04-14 14:47:32 +02:00

255 lines
7.6 KiB
TypeScript

import type React from '../../lib/teact/teact';
import { memo, useRef } from '../../lib/teact/teact';
import type {
ApiAttachment,
ApiDocument,
ApiPhoto,
ApiVideo,
MediaContent,
} from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { IconName } from '../../types/icons';
import {
getDocumentMediaHash,
getMediaThumbUri,
getPhotoMediaHash,
getVideoMediaHash,
} from '../../global/helpers';
import { IS_CANVAS_FILTER_SUPPORTED } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName';
import { REM } from './helpers/mediaDimensions';
import useAppLayout from '../../hooks/useAppLayout';
import useCanvasBlur from '../../hooks/useCanvasBlur';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useMedia from '../../hooks/useMedia';
import useMediaTransition from '../../hooks/useMediaTransition';
import OptimizedVideo from '../ui/OptimizedVideo';
import Icon from './icons/Icon';
import MediaSpoiler from './MediaSpoiler';
import styles from './CompactMediaPreview.module.scss';
const PICTOGRAM_SIZE = 2 * REM;
type OwnProps = {
id?: string;
className?: string;
media?: MediaContent;
attachment?: ApiAttachment;
size?: number;
isPictogram?: boolean;
isRound?: boolean;
isProtected?: boolean;
isSpoiler?: boolean;
actionIcon?: IconName;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
onClick?: React.MouseEventHandler<HTMLDivElement>;
};
export function canRenderCompactMediaPreview(
media?: MediaContent,
attachment?: ApiAttachment,
) {
const photo = media?.photo;
const document = media?.document;
const previewUrl = getPreviewUrl(photo, document, media?.video, attachment);
const video = media?.video || attachment?.gif;
const shouldRenderPreviewAsVideo = shouldUseVideoPreview(video, previewUrl);
const previewVideoUrl = shouldRenderPreviewAsVideo
? (media?.video?.blobUrl || attachment?.blobUrl)
: undefined;
const previewHash = getPreviewHash(photo, document, video, shouldRenderPreviewAsVideo);
return Boolean(
previewUrl
|| previewVideoUrl
|| previewHash
|| getThumbDataUri(photo, document, media?.video, attachment),
);
}
const CompactMediaPreview = ({
id,
className,
media,
attachment,
size,
isPictogram,
isRound,
isProtected,
isSpoiler,
actionIcon,
observeIntersectionForLoading,
observeIntersectionForPlaying,
onClick,
}: OwnProps) => {
const ref = useRef<HTMLDivElement>();
const { isMobile } = useAppLayout();
const previewSize = size || (isPictogram ? PICTOGRAM_SIZE : undefined);
const photo = media?.photo;
const document = media?.document;
const video = media?.video || attachment?.gif;
const previewUrl = getPreviewUrl(photo, document, media?.video, attachment);
const shouldRenderPreviewAsVideo = shouldUseVideoPreview(video, previewUrl);
const isIntersectingForLoading = useIsIntersecting(ref, observeIntersectionForLoading);
const isIntersectingForPlaying = (
useIsIntersecting(ref, observeIntersectionForPlaying)
&& isIntersectingForLoading
);
const previewHash = getPreviewHash(photo, document, video, shouldRenderPreviewAsVideo);
const previewVideoUrl = shouldRenderPreviewAsVideo
? (media?.video?.blobUrl || attachment?.blobUrl)
: undefined;
const thumbDataUri = getThumbDataUri(photo, document, media?.video, attachment);
const fetchedPreviewUrl = useMedia(
previewHash,
Boolean(!previewHash || previewUrl || previewVideoUrl || !isIntersectingForLoading),
);
const resolvedPreviewUrl = previewUrl || (!shouldRenderPreviewAsVideo ? fetchedPreviewUrl : undefined);
const resolvedPreviewVideoUrl = previewVideoUrl || (shouldRenderPreviewAsVideo ? fetchedPreviewUrl : undefined);
const shouldShowSpoiler = isSpoiler ?? photo?.isSpoiler ?? media?.video?.isSpoiler ?? attachment?.shouldSendAsSpoiler;
const hasResolvedMedia = Boolean(resolvedPreviewUrl || resolvedPreviewVideoUrl);
const shouldShowCanvasThumb = Boolean(thumbDataUri && (shouldShowSpoiler || !hasResolvedMedia));
const canvasRef = useCanvasBlur(
thumbDataUri,
!thumbDataUri || !shouldShowCanvasThumb,
isMobile && !IS_CANVAS_FILTER_SUPPORTED,
undefined,
previewSize,
previewSize,
);
useMediaTransition({
ref: canvasRef,
hasMediaData: shouldShowCanvasThumb,
});
const { ref: imageRef } = useMediaTransition<HTMLImageElement>({
hasMediaData: Boolean(resolvedPreviewUrl && !shouldShowSpoiler),
});
const { ref: videoRef } = useMediaTransition<HTMLVideoElement>({
hasMediaData: Boolean(resolvedPreviewVideoUrl && !shouldShowSpoiler),
});
const spoilerThumbDataUri = thumbDataUri || resolvedPreviewUrl;
const style = previewSize ? `width: ${previewSize}px; height: ${previewSize}px` : undefined;
return (
<div
ref={ref}
id={id}
className={buildClassName(
styles.root,
className,
isPictogram && styles.pictogram,
isRound && styles.round,
actionIcon && styles.withActionIcon,
onClick && styles.interactive,
)}
style={style}
onClick={onClick}
>
{thumbDataUri && (
<canvas
ref={canvasRef}
className={buildClassName('thumbnail', styles.thumb)}
/>
)}
{!shouldShowSpoiler && resolvedPreviewUrl && (
<img
ref={imageRef}
src={resolvedPreviewUrl}
alt=""
className={buildClassName('full-media', styles.media)}
draggable={false}
/>
)}
{!shouldShowSpoiler && resolvedPreviewVideoUrl && (
<OptimizedVideo
ref={videoRef}
className={buildClassName('full-media', styles.media)}
src={resolvedPreviewVideoUrl}
canPlay={isIntersectingForPlaying}
poster={thumbDataUri}
loop
playsInline
muted
disablePictureInPicture
/>
)}
<MediaSpoiler
thumbDataUri={spoilerThumbDataUri}
isVisible={Boolean(shouldShowSpoiler)}
width={previewSize}
height={previewSize}
/>
{isProtected && <span className={buildClassName('protector', styles.protector)} />}
{actionIcon && <Icon name={actionIcon} className={styles.actionIcon} />}
</div>
);
};
function getThumbDataUri(
photo?: ApiPhoto,
document?: ApiDocument,
video?: ApiVideo,
attachment?: ApiAttachment,
) {
return (photo && getMediaThumbUri(photo))
|| (document && getMediaThumbUri(document))
|| (video && getMediaThumbUri(video))
|| attachment?.previewBlobUrl
|| (attachment?.mimeType.startsWith('image/') ? attachment.blobUrl : undefined);
}
function getPreviewUrl(
photo?: ApiPhoto,
document?: ApiDocument,
video?: ApiVideo,
attachment?: ApiAttachment,
) {
return photo?.blobUrl
|| document?.previewBlobUrl
|| video?.previewBlobUrl
|| attachment?.previewBlobUrl
|| (attachment?.mimeType.startsWith('image/') ? attachment.blobUrl : undefined);
}
function shouldUseVideoPreview(video: ApiVideo | undefined, previewUrl?: string) {
return Boolean(video?.isGif && !video.previewPhotoSizes?.length && !previewUrl);
}
function getPreviewHash(
photo?: ApiPhoto,
document?: ApiDocument,
video?: ApiVideo,
shouldRenderPreviewAsVideo?: boolean,
) {
if (photo) {
return getPhotoMediaHash(photo, 'pictogram');
}
if (document) {
return getDocumentMediaHash(document, 'pictogram');
}
if (video) {
return getVideoMediaHash(video, shouldRenderPreviewAsVideo ? 'full' : 'pictogram');
}
return undefined;
}
export default memo(CompactMediaPreview);