/*
* 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 * as ContextMenuActionCreators from '@app/actions/ContextMenuActionCreators';
import * as FavoriteMemeActionCreators from '@app/actions/FavoriteMemeActionCreators';
import * as MediaViewerActionCreators from '@app/actions/MediaViewerActionCreators';
import * as MessageActionCreators from '@app/actions/MessageActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import {deriveDefaultNameFromMessage} from '@app/components/channel/embeds/EmbedUtils';
import {AudioPlayer} from '@app/components/media_player/components/AudioPlayer';
import {VideoPlayer} from '@app/components/media_player/components/VideoPlayer';
import {AddFavoriteMemeModal} from '@app/components/modals/AddFavoriteMemeModal';
import {MediaModal} from '@app/components/modals/MediaModal';
import styles from '@app/components/modals/MediaViewerModal.module.css';
import {useMediaMenuData} from '@app/components/uikit/context_menu/items/MediaMenuData';
import {MediaContextMenu} from '@app/components/uikit/context_menu/MediaContextMenu';
import {MenuBottomSheet} from '@app/components/uikit/menu_bottom_sheet/MenuBottomSheet';
import {Platform} from '@app/lib/Platform';
import type {MessageRecord} from '@app/records/MessageRecord';
import FavoriteMemeStore from '@app/stores/FavoriteMemeStore';
import MediaViewerStore, {type MediaViewerItem} from '@app/stores/MediaViewerStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import {formatAttachmentDate} from '@app/utils/AttachmentExpiryUtils';
import {createSaveHandler} from '@app/utils/FileDownloadUtils';
import * as ImageCacheUtils from '@app/utils/ImageCacheUtils';
import {buildMediaProxyURL, stripMediaProxyParams} from '@app/utils/MediaProxyUtils';
import {openExternalUrl} from '@app/utils/NativeUtils';
import {useLingui} from '@lingui/react/macro';
import {TrashIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {type CSSProperties, type FC, type MouseEvent, useCallback, useEffect, useMemo, useRef, useState} from 'react';
interface MobileMediaOptionsSheetProps {
currentItem: MediaViewerItem;
defaultName: string;
isOpen: boolean;
message: MessageRecord;
onClose: () => void;
onDelete: (bypassConfirm?: boolean) => void;
}
function getBaseProxyURL(src: string): string {
if (src.startsWith('blob:')) {
return src;
}
return stripMediaProxyParams(src);
}
const MobileMediaOptionsSheet: FC = observer(function MobileMediaOptionsSheet({
currentItem,
defaultName,
isOpen,
message,
onClose,
onDelete,
}: MobileMediaOptionsSheetProps) {
const {t} = useLingui();
const mediaMenuData = useMediaMenuData(
{
message,
originalSrc: currentItem.originalSrc,
proxyURL: currentItem.src,
type: currentItem.type,
contentHash: currentItem.contentHash,
attachmentId: currentItem.attachmentId,
embedIndex: currentItem.embedIndex,
defaultName,
defaultAltText: undefined,
},
{
onClose,
},
);
const mediaMenuGroupsWithDelete = useMemo(
() => [
...mediaMenuData.groups,
{
items: [
{
label: t`Delete Message`,
icon: ,
onClick: () => {
onDelete();
onClose();
},
danger: true,
},
],
},
],
[mediaMenuData.groups, onClose, onDelete, t],
);
return (
);
});
const MediaViewerModalComponent: FC = observer(() => {
const {t, i18n} = useLingui();
const {isOpen, items, currentIndex, channelId, messageId, message} = MediaViewerStore;
const {enabled: isMobile} = MobileLayoutStore;
const videoRef = useRef(null);
const [isMediaMenuOpen, setIsMediaMenuOpen] = useState(false);
const currentItem = items[currentIndex];
useEffect(() => {
if (currentItem?.type !== 'gifv') return;
const video = videoRef.current;
if (!video) return;
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
void video.play().catch(() => {});
}, [currentItem?.src, currentItem?.type, currentIndex]);
useEffect(() => {
if (!isOpen || items.length <= 1) return;
const preloadIndices = [currentIndex - 1, currentIndex + 1].filter(
(i) => i >= 0 && i < items.length && i !== currentIndex,
);
for (const idx of preloadIndices) {
const item = items[idx];
if (!item) continue;
if (item.type === 'image' || item.type === 'gif') {
const isItemBlob = item.src.startsWith('blob:');
if (isItemBlob) continue;
const baseProxyURL = getBaseProxyURL(item.src);
const shouldRequestAnimated = item.animated || item.type === 'gif';
let preloadSrc: string;
if (shouldRequestAnimated) {
preloadSrc = buildMediaProxyURL(baseProxyURL, {
format: 'webp',
animated: true,
});
} else {
const maxPreviewSize = 1920;
const aspectRatio = item.naturalWidth / item.naturalHeight;
let targetWidth = item.naturalWidth;
let targetHeight = item.naturalHeight;
if (item.naturalWidth > maxPreviewSize || item.naturalHeight > maxPreviewSize) {
if (aspectRatio > 1) {
targetWidth = Math.min(item.naturalWidth, maxPreviewSize);
targetHeight = Math.round(targetWidth / aspectRatio);
} else {
targetHeight = Math.min(item.naturalHeight, maxPreviewSize);
targetWidth = Math.round(targetHeight * aspectRatio);
}
}
preloadSrc = buildMediaProxyURL(baseProxyURL, {
format: 'webp',
width: targetWidth,
height: targetHeight,
animated: item.animated,
});
}
if (!ImageCacheUtils.hasImage(preloadSrc)) {
ImageCacheUtils.loadImage(preloadSrc, () => {});
}
} else if (item.type === 'gifv' || item.type === 'video') {
const video = document.createElement('video');
video.preload = 'metadata';
video.src = item.src;
video.load();
}
}
}, [isOpen, currentIndex, items]);
const memes = FavoriteMemeStore.memes;
const isFavorited = currentItem?.contentHash
? memes.some((meme) => meme.contentHash === currentItem.contentHash)
: false;
const defaultName = deriveDefaultNameFromMessage({
message,
attachmentId: currentItem?.attachmentId,
url: currentItem?.originalSrc,
proxyUrl: currentItem?.src,
i18nInstance: i18n,
});
const handleFavoriteClick = useCallback(async () => {
if (!channelId || !messageId || !currentItem) return;
if (isFavorited && currentItem.contentHash) {
const meme = memes.find((m) => m.contentHash === currentItem.contentHash);
if (!meme) return;
await FavoriteMemeActionCreators.deleteFavoriteMeme(i18n, meme.id);
} else {
ModalActionCreators.push(
modal(() => (
)),
);
}
}, [channelId, messageId, currentItem, defaultName, i18n, isFavorited, memes]);
const handleSave = useCallback(() => {
if (!currentItem) return;
const mediaType = (() => {
if (currentItem.type === 'audio') return 'audio';
if (currentItem.type === 'video') return 'video';
return 'image';
})();
createSaveHandler(currentItem.originalSrc, mediaType)();
}, [currentItem]);
const handleOpenInBrowser = useCallback(() => {
if (!currentItem) return;
void openExternalUrl(currentItem.originalSrc);
}, [currentItem]);
const handleDelete = useCallback(
(bypassConfirm?: boolean) => {
if (!message) return;
if (bypassConfirm) {
MessageActionCreators.remove(message.channelId, message.id);
return;
}
MessageActionCreators.showDeleteConfirmation(i18n, {message});
},
[i18n, message],
);
const handlePrevious = useCallback(() => {
MediaViewerActionCreators.navigateMediaViewer((currentIndex - 1 + items.length) % items.length);
}, [currentIndex, items.length]);
const handleNext = useCallback(() => {
MediaViewerActionCreators.navigateMediaViewer((currentIndex + 1) % items.length);
}, [currentIndex, items.length]);
const handleThumbnailSelect = useCallback(
(index: number) => {
if (index === currentIndex) return;
MediaViewerActionCreators.navigateMediaViewer(index);
},
[currentIndex],
);
const handleContextMenu = useCallback(
(event: MouseEvent) => {
if (!currentItem || !message) return;
const renderMenu = ({onClose}: {onClose: () => void}) => (
);
event.stopPropagation?.();
if (Platform.isElectron) {
event.preventDefault();
ContextMenuActionCreators.openFromEvent(event, renderMenu);
return;
}
ContextMenuActionCreators.openAtPoint(
{
x: event.pageX + 2,
y: event.pageY + 2,
},
renderMenu,
);
},
[currentItem, defaultName, handleDelete, message],
);
const handleMenuOpen = useCallback(() => {
if (!currentItem || !message) return;
if (isMobile) {
setIsMediaMenuOpen(true);
} else {
ContextMenuActionCreators.openAtPoint({x: window.innerWidth / 2, y: window.innerHeight / 2}, ({onClose}) => (
));
}
}, [currentItem, defaultName, handleDelete, message, isMobile]);
const isBlob = currentItem?.src.startsWith('blob:');
const imageSrc = useMemo(() => {
if (!currentItem) return '';
if (isBlob) {
return currentItem.src;
}
const baseProxyURL = getBaseProxyURL(currentItem.src);
const shouldRequestAnimated = currentItem.animated || currentItem.type === 'gif';
if (shouldRequestAnimated) {
return buildMediaProxyURL(baseProxyURL, {
format: 'webp',
animated: true,
});
}
if (currentItem.type === 'gifv' || currentItem.type === 'video' || currentItem.type === 'audio') {
return baseProxyURL;
}
const maxPreviewSize = 1920;
const aspectRatio = currentItem.naturalWidth / currentItem.naturalHeight;
let targetWidth = currentItem.naturalWidth;
let targetHeight = currentItem.naturalHeight;
if (currentItem.naturalWidth > maxPreviewSize || currentItem.naturalHeight > maxPreviewSize) {
if (aspectRatio > 1) {
targetWidth = Math.min(currentItem.naturalWidth, maxPreviewSize);
targetHeight = Math.round(targetWidth / aspectRatio);
} else {
targetHeight = Math.min(currentItem.naturalHeight, maxPreviewSize);
targetWidth = Math.round(targetHeight * aspectRatio);
}
}
return buildMediaProxyURL(baseProxyURL, {
format: 'webp',
width: targetWidth,
height: targetHeight,
animated: currentItem.animated,
});
}, [currentItem, isBlob]);
const thumbnails = useMemo(
() =>
items.map((item, index) => {
const name = item.filename || item.originalSrc.split('/').pop()?.split('?')[0] || t`Attachment ${index + 1}`;
if ((item.type === 'image' || item.type === 'gif' || item.animated) && !item.src.startsWith('blob:')) {
const baseProxyURL = getBaseProxyURL(item.src);
return {
src: buildMediaProxyURL(baseProxyURL, {
format: 'webp',
width: 320,
height: 320,
animated: Boolean(item.animated),
}),
alt: name,
type: item.type,
};
}
return {
src: item.src,
alt: name,
type: item.type,
};
}),
[items, t],
);
if (!isOpen || !currentItem) {
return null;
}
const dimensions =
currentItem.naturalWidth && currentItem.naturalHeight
? `${currentItem.naturalWidth}×${currentItem.naturalHeight}`
: undefined;
const fileName = currentItem.filename || currentItem.originalSrc.split('/').pop()?.split('?')[0] || 'media';
const expiryInfo =
currentItem.expiresAt && currentItem.expiresAt.length > 0
? {
expiresAt: new Date(currentItem.expiresAt),
isExpired: currentItem.expired ?? false,
label: formatAttachmentDate(new Date(currentItem.expiresAt)),
}
: undefined;
const getTitle = () => {
if (currentItem.type === 'image') {
return currentItem.animated ? t`Animated image preview` : t`Image preview`;
}
if (currentItem.type === 'gif' || currentItem.type === 'gifv') {
return t`GIF preview`;
}
if (currentItem.type === 'video') {
return t`Video preview`;
}
if (currentItem.type === 'audio') {
return t`Audio preview`;
}
return t`Media preview`;
};
const modalTitle = getTitle();
const renderMedia = () => {
if (currentItem.type === 'gifv') {
const isActualGif = currentItem.src.endsWith('.gif') || currentItem.originalSrc.endsWith('.gif');
if (isActualGif) {
return (
);
}
return (
);
}
if (currentItem.type === 'video') {
const hasNaturalVideoDimensions = currentItem.naturalWidth > 0 && currentItem.naturalHeight > 0;
const videoAspectRatio = hasNaturalVideoDimensions
? `${currentItem.naturalWidth} / ${currentItem.naturalHeight}`
: '16 / 9';
return (
);
}
if (currentItem.type === 'audio') {
return (
);
}
const imageAlt = (() => {
if (currentItem.type === 'gif') return t`Animated GIF`;
if (currentItem.animated) return t`Animated image`;
return t`Image`;
})();
return (
);
};
const canFavoriteCurrentItem =
Boolean(channelId) &&
Boolean(messageId) &&
(currentItem.type === 'image' ||
currentItem.type === 'gif' ||
currentItem.type === 'gifv' ||
currentItem.type === 'video');
return (
<>
1 ? handlePrevious : undefined}
onNext={items.length > 1 ? handleNext : undefined}
thumbnails={thumbnails}
onSelectThumbnail={handleThumbnailSelect}
providerName={currentItem.providerName}
videoSrc={currentItem.type === 'video' ? currentItem.src : undefined}
initialTime={currentItem.initialTime}
mediaType={currentItem.type === 'audio' ? 'audio' : currentItem.type === 'video' ? 'video' : 'image'}
onMenuOpen={handleMenuOpen}
>
{renderMedia()}
{isMobile && message && (
setIsMediaMenuOpen(false)}
onDelete={handleDelete}
/>
)}
>
);
});
export const MediaViewerModal: FC = MediaViewerModalComponent;