/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {useLingui} from '@lingui/react/macro'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import {type FC, useCallback} from 'react'; import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators'; import * as MediaViewerActionCreators from '~/actions/MediaViewerActionCreators'; import {deriveDefaultNameFromMessage} from '~/components/channel/embeds/EmbedUtils'; import {getMediaButtonVisibility} from '~/components/channel/embeds/media/MediaButtonUtils'; import {MediaContainer} from '~/components/channel/embeds/media/MediaContainer'; import type {BaseMediaProps} from '~/components/channel/embeds/media/MediaTypes'; import {NSFWBlurOverlay} from '~/components/channel/embeds/NSFWBlurOverlay'; import {MediaContextMenu} from '~/components/uikit/ContextMenu/MediaContextMenu'; import {useDeleteAttachment} from '~/hooks/useDeleteAttachment'; import {useMediaFavorite} from '~/hooks/useMediaFavorite'; import {useMediaLoading} from '~/hooks/useMediaLoading'; import {useNSFWMedia} from '~/hooks/useNSFWMedia'; import type {MessageAttachment, MessageRecord} from '~/records/MessageRecord'; import {createCalculator} from '~/utils/DimensionUtils'; import {createSaveHandler} from '~/utils/FileDownloadUtils'; import styles from './EmbedImage.module.css'; const IMAGE_CONFIG = { MAX_WIDTH: 400, } as const; const imageCalculator = createCalculator({ maxWidth: IMAGE_CONFIG.MAX_WIDTH, responsive: true, }); interface ImagePreviewHandlerProps { src: string; originalSrc: string; naturalWidth: number; naturalHeight: number; contentHash?: string | null; embedIndex?: number; handlePress?: (event: React.MouseEvent | React.KeyboardEvent) => void; channelId?: string; messageId?: string; attachmentId?: string; message?: MessageRecord; mediaAttachments?: ReadonlyArray; children: React.ReactNode; } type EmbedImageProps = React.ImgHTMLAttributes & BaseMediaProps & { src: string; originalSrc: string; naturalWidth: number; naturalHeight: number; width: number; height: number; placeholder?: string; constrain?: boolean; isInline?: boolean; handlePress?: (event: React.MouseEvent | React.KeyboardEvent) => void; alt?: string; mediaAttachments?: ReadonlyArray; isPreview?: boolean; }; const ImagePreviewHandler: FC = observer( ({ src, originalSrc, naturalWidth, naturalHeight, contentHash, embedIndex, handlePress, channelId, messageId, attachmentId, message, mediaAttachments = [], children, }) => { const {t} = 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 (keyEvent.key !== 'Enter' && keyEvent.key !== ' ') { return; } } if (handlePress) { event.preventDefault(); event.stopPropagation(); handlePress(event); return; } event.preventDefault(); event.stopPropagation(); if (mediaAttachments.length > 0) { const currentIndex = mediaAttachments.findIndex((a) => a.id === attachmentId); const items = mediaAttachments.map((att) => ({ src: att.proxy_url ?? att.url ?? '', originalSrc: att.url ?? '', naturalWidth: att.width!, naturalHeight: att.height!, type: 'image' as const, contentHash: att.content_hash, attachmentId: att.id, expiresAt: att.expires_at ?? null, expired: att.expired ?? false, })); MediaViewerActionCreators.openMediaViewer(items, currentIndex, { channelId, messageId, message, }); } else { MediaViewerActionCreators.openMediaViewer( [ { src, originalSrc: originalSrc ?? '', naturalWidth, naturalHeight, type: 'image' as const, contentHash, embedIndex, expiresAt: undefined, expired: undefined, }, ], 0, { channelId, messageId, message, }, ); } }, [ src, originalSrc, naturalWidth, naturalHeight, contentHash, embedIndex, handlePress, channelId, messageId, attachmentId, message, mediaAttachments, ], ); return ( ); }, ); export const EmbedImage: FC = observer( ({ src, originalSrc, naturalWidth, naturalHeight, width, height, placeholder, constrain, className, isInline, handlePress, alt = '', nsfw, channelId, messageId, attachmentId, embedIndex, message, contentHash, onDelete, mediaAttachments = [], isPreview, }) => { const {t} = useLingui(); const {loaded, error, thumbHashURL} = useMediaLoading(src, placeholder); const {shouldBlur, gateReason} = useNSFWMedia(nsfw, channelId); const defaultName = deriveDefaultNameFromMessage({ message, attachmentId, embedIndex, url: originalSrc, proxyUrl: src, }); const { isFavorited, toggleFavorite: handleFavoriteClick, canFavorite, } = useMediaFavorite({ channelId, messageId, attachmentId, embedIndex, defaultName: alt || defaultName, defaultAltText: alt, contentHash, }); const {style: containerStyle, dimensions} = imageCalculator.calculate( {width, height}, { preserve: constrain, responsive: !isInline, aspectRatio: true, }, ); const resolvedContainerStyle: React.CSSProperties = { ...containerStyle, width: dimensions.width, maxWidth: '100%', }; const shouldRenderPlaceholder = error || !loaded; const handleDownloadClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); createSaveHandler(originalSrc, 'image')(); }, [originalSrc], ); const handleDeleteClick = useDeleteAttachment(message, attachmentId); const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (!message || isPreview) return; e.preventDefault(); e.stopPropagation(); ContextMenuActionCreators.openFromEvent(e, ({onClose}) => ( {})} /> )); }, [message, src, originalSrc, contentHash, attachmentId, embedIndex, alt, defaultName, onDelete, isPreview], ); if (shouldBlur) { return (
{thumbHashURL && (
)}
); } const {showFavoriteButton, showDownloadButton, showDeleteButton} = getMediaButtonVisibility( canFavorite, isPreview ? undefined : message, attachmentId, {disableDelete: !!isPreview}, ); return (
{shouldRenderPlaceholder && thumbHashURL && (
{alt
)} {alt
); }, );